@youweichen/pi-web-ui 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,2975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentService — wraps the pi SDK (@earendil-works/pi-coding-agent) for the web
|
|
3
|
+
* frontend. Each browser client (identified by a persistent clientId) gets its
|
|
4
|
+
* own AgentSessionRuntime, but sessions live in the SDK default per-project
|
|
5
|
+
* directory (<agentDir>/sessions/--<cwd>--/) — the same transcript files the
|
|
6
|
+
* pi CLI/TUI use — so every conversation of a folder shows up everywhere.
|
|
7
|
+
*
|
|
8
|
+
* Streaming model: the SDK emits AgentSessionEvents; we forward lightweight
|
|
9
|
+
* `tool_delta` messages for live tool output and schedule throttled full-state
|
|
10
|
+
* snapshots. The frontend is snapshot-driven (server is the source of truth),
|
|
11
|
+
* so reconnects just re-request a snapshot.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
|
|
15
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
import { BgServerTracker } from "./bg-servers.js";
|
|
20
|
+
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
21
|
+
import { SettingsService } from "./settings-service.js";
|
|
22
|
+
import { GoalService } from "./goal-service.js";
|
|
23
|
+
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
24
|
+
import { ModelAdminService } from "./model-admin.js";
|
|
25
|
+
import { FilesService, workspacePath } from "./files-service.js";
|
|
26
|
+
import { isExtensionDisabled, ClientStateStore, } from "./client-state.js";
|
|
27
|
+
import { saveUpload } from "./uploads.js";
|
|
28
|
+
import { makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
|
|
29
|
+
import { WebUIContext } from "./webui-context.js";
|
|
30
|
+
import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
|
|
31
|
+
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
32
|
+
import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
|
|
33
|
+
import { buildVisionBridgePrompt, SYSTEM_PROMPT, transcribeImages, } from "./vision-bridge.js";
|
|
34
|
+
const SNAPSHOT_INTERVAL_MS = 60;
|
|
35
|
+
/** While assistant deltas are flowing, live rendering is carried by
|
|
36
|
+
* message_delta — full snapshots become pure reconciliation checkpoints, so
|
|
37
|
+
* send them on a slow event-driven cadence (see flushSnapshot call-sites:
|
|
38
|
+
* agent_end / tool_execution_end always checkpoint immediately). */
|
|
39
|
+
const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
|
|
40
|
+
/** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
|
|
41
|
+
const DELTA_ACTIVE_WINDOW_MS = 1500;
|
|
42
|
+
const WIDGET_REFRESH_MS = 2000;
|
|
43
|
+
/** Model-stall watchdog: warn (don't abort — deep thinking can be legitimately
|
|
44
|
+
* quiet for minutes) when a streaming run produced NO SDK events for this long.
|
|
45
|
+
* Covers the failure class the per-tool watchdog cannot see: half-open API
|
|
46
|
+
* connections / hung proxies where no tool is running and no error is thrown.
|
|
47
|
+
* Override: PI_WEB_STALL_NOTIFY_MS (milliseconds; 0 disables). */
|
|
48
|
+
const STALL_NOTIFY_MS = (() => {
|
|
49
|
+
const v = Number(process.env.PI_WEB_STALL_NOTIFY_MS);
|
|
50
|
+
return Number.isFinite(v) && v >= 0 ? v : 180_000;
|
|
51
|
+
})();
|
|
52
|
+
/** Serialization-cache cap per conversation (see serializeCached): cached
|
|
53
|
+
* UiMessage objects are pure-function results, so eviction only costs a
|
|
54
|
+
* recompute on next access. Bounds memory for marathon sessions. */
|
|
55
|
+
const UI_MESSAGE_CACHE_CAP = 4096;
|
|
56
|
+
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
57
|
+
/** Thrown when the service is quiesced (draining) and the request is NEW work
|
|
58
|
+
* the admission controller refuses: a brand-new client attach, a prompt,
|
|
59
|
+
* a fork, a session resume, or a goal wizard start. index.ts closes the
|
|
60
|
+
* WebSocket with 4403 so the browser reconnect loop can retry after the
|
|
61
|
+
* server reopens admission (see AgentService.quiesce). */
|
|
62
|
+
export class QuiesceRejectedError extends Error {
|
|
63
|
+
code = "QUIESCED";
|
|
64
|
+
constructor(detail) {
|
|
65
|
+
super(`服务器正在排空存量工作(quiesce)——${detail}`);
|
|
66
|
+
this.name = "QuiesceRejectedError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Preview kind classification. The preview panel only opens image / video /
|
|
71
|
+
// text-editable files; everything else (exe, jar, archives, …) is refused so
|
|
72
|
+
// it is never read or sent to the browser. Media files are served over the
|
|
73
|
+
// /api/file HTTP endpoint instead of the WebSocket, so they are classified
|
|
74
|
+
// here but never read into the snapshot path.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
/** Windows persona appendix — appended to the SDK system prompt on win32 only.
|
|
77
|
+
* Two failure modes it guards against: (1) the SDK bash tool has NO default
|
|
78
|
+
* timeout, so a long-running command hangs the whole conversation forever;
|
|
79
|
+
* (2) the in-app terminal is an interactive TTY where heredocs / interactive
|
|
80
|
+
* programs wait for input that never comes. Legacy Chinese files are often
|
|
81
|
+
* GBK/GB2312 — read them with the right encoding, never paste mojibake into
|
|
82
|
+
* reasoning/answers. */
|
|
83
|
+
const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
- ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
|
|
88
|
+
- NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
|
|
89
|
+
- In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
|
|
90
|
+
|
|
91
|
+
Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
|
|
92
|
+
/**
|
|
93
|
+
* Killable bash tool: wraps the SDK bash tool with operations that register
|
|
94
|
+
* their own AbortController into a client-level set. abortBash() aborts only
|
|
95
|
+
* those controllers → the command's process tree is killed while the agent
|
|
96
|
+
* run and the conversation continue (the tool returns an aborted error and
|
|
97
|
+
* the model moves on). Injected as a customTool overriding the builtin bash.
|
|
98
|
+
*/
|
|
99
|
+
function makeKillableBashTool(cwd, kills) {
|
|
100
|
+
const base = createLocalBashOperations();
|
|
101
|
+
const tool = createBashTool(cwd, {
|
|
102
|
+
operations: {
|
|
103
|
+
exec: async (command, c, opts) => {
|
|
104
|
+
const ac = new AbortController();
|
|
105
|
+
kills.add(ac);
|
|
106
|
+
try {
|
|
107
|
+
const signals = [opts.signal, ac.signal].filter((s) => s !== undefined);
|
|
108
|
+
return await base.exec(command, c, {
|
|
109
|
+
...opts,
|
|
110
|
+
signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
kills.delete(ac);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
// AgentTool → ToolDefinition (same fields; customTools expects definitions).
|
|
120
|
+
return {
|
|
121
|
+
name: tool.name,
|
|
122
|
+
label: tool.label,
|
|
123
|
+
description: tool.description,
|
|
124
|
+
parameters: tool.parameters,
|
|
125
|
+
prepareArguments: tool.prepareArguments,
|
|
126
|
+
executionMode: tool.executionMode,
|
|
127
|
+
execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 动态分流 bash:调用时按设置决定走哪套实现——「终端接管 bash」开关因此
|
|
132
|
+
* 即时生效(customTools 在 runtime 创建时固定,不能在创建时二选一)。
|
|
133
|
+
*/
|
|
134
|
+
function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
135
|
+
return {
|
|
136
|
+
...killable,
|
|
137
|
+
execute: (id, params, signal, onUpdate, ctx) => (useTerminal() ? terminalBacked : killable).execute(id, params, signal, onUpdate, ctx),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 插件结构化工具 → SDK ToolDefinition。
|
|
142
|
+
* execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
|
|
143
|
+
*/
|
|
144
|
+
function pluginToolToDefinition(tool) {
|
|
145
|
+
const normalize = (result) => {
|
|
146
|
+
if (result &&
|
|
147
|
+
typeof result === "object" &&
|
|
148
|
+
Array.isArray(result.content)) {
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
const text = typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2);
|
|
152
|
+
return { content: [{ type: "text", text }] };
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
name: tool.name,
|
|
156
|
+
label: tool.label ?? tool.name,
|
|
157
|
+
description: tool.description,
|
|
158
|
+
promptSnippet: tool.promptSnippet,
|
|
159
|
+
promptGuidelines: tool.promptGuidelines,
|
|
160
|
+
parameters: (tool.parameters ?? {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {},
|
|
163
|
+
}),
|
|
164
|
+
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
165
|
+
const raw = await tool.execute(toolCallId, params, signal, onUpdate
|
|
166
|
+
? (partial) => onUpdate(normalize(partial))
|
|
167
|
+
: undefined);
|
|
168
|
+
return normalize(raw);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Cheap per-message discriminator for the serialization cache key. Persisted
|
|
174
|
+
* message content never changes, so this is stable across snapshots, while
|
|
175
|
+
* several same-role messages created within one millisecond (attachment
|
|
176
|
+
* asides) get distinct keys. Text blocks are fingerprinted by a short hash of
|
|
177
|
+
* their head (paths embedded in <file> tags can share long prefixes — e.g.
|
|
178
|
+
* uploads created in the same millisecond differ only at the tail); image
|
|
179
|
+
* payloads by data length (identical lengths within the same ms are far too
|
|
180
|
+
* unlikely to matter).
|
|
181
|
+
*/
|
|
182
|
+
function contentFingerprint(m) {
|
|
183
|
+
const content = m.content;
|
|
184
|
+
if (!Array.isArray(content) || content.length === 0)
|
|
185
|
+
return "empty";
|
|
186
|
+
const first = content[0];
|
|
187
|
+
if (first?.type === "image") {
|
|
188
|
+
return `img:${(first.data ?? "").length}`;
|
|
189
|
+
}
|
|
190
|
+
const text = typeof first?.text === "string" ? first.text : "";
|
|
191
|
+
// djb2 — fast enough to run per snapshot, distinct enough for asides.
|
|
192
|
+
let h = 5381;
|
|
193
|
+
for (let i = 0; i < text.length && i < 512; i++) {
|
|
194
|
+
h = ((h << 5) + h + text.charCodeAt(i)) >>> 0;
|
|
195
|
+
}
|
|
196
|
+
return `txt:${h.toString(36)}:${text.length}`;
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
|
|
200
|
+
// browser. Extensions like rpiv-todo render a TUI widget via
|
|
201
|
+
// `ui.setWidget(key, (tui, theme) => comp)`; we capture the component, render it
|
|
202
|
+
// with a mock theme to plain text lines, and push them to the client.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
function extractPartialText(partial) {
|
|
205
|
+
const content = partial
|
|
206
|
+
?.content;
|
|
207
|
+
if (Array.isArray(content)) {
|
|
208
|
+
const text = content
|
|
209
|
+
.map((c) => c?.type === "text"
|
|
210
|
+
? c.text
|
|
211
|
+
: "")
|
|
212
|
+
.join("");
|
|
213
|
+
return text.length > 0 ? text : null;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
export { workspacePath };
|
|
218
|
+
/** Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
219
|
+
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
220
|
+
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
221
|
+
* conversation indefinitely. Override with the PI_WEB_TOOL_TIMEOUT_MS env var
|
|
222
|
+
* (milliseconds). */
|
|
223
|
+
const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
224
|
+
const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
|
|
225
|
+
return Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
|
|
226
|
+
})();
|
|
227
|
+
/** Cap on simultaneously open conversations of ONE project (each keeps a full
|
|
228
|
+
* runtime alive; conversations of other projects keep their own lists). */
|
|
229
|
+
const MAX_OPEN_CONVERSATIONS = 8;
|
|
230
|
+
const DEFAULT_CONV_TITLE = "新对话";
|
|
231
|
+
/** First user text in a session, truncated for the conversation list. */
|
|
232
|
+
function conversationTitle(session) {
|
|
233
|
+
try {
|
|
234
|
+
for (const m of session.agent.state.messages) {
|
|
235
|
+
if (m.role !== "user")
|
|
236
|
+
continue;
|
|
237
|
+
const content = m.content;
|
|
238
|
+
let text = "";
|
|
239
|
+
if (typeof content === "string") {
|
|
240
|
+
text = content;
|
|
241
|
+
}
|
|
242
|
+
else if (Array.isArray(content)) {
|
|
243
|
+
for (const p of content) {
|
|
244
|
+
if (p &&
|
|
245
|
+
typeof p === "object" &&
|
|
246
|
+
p.type === "text" &&
|
|
247
|
+
typeof p.text === "string") {
|
|
248
|
+
text = p.text;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
254
|
+
if (trimmed.length > 0) {
|
|
255
|
+
return trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// best-effort
|
|
261
|
+
}
|
|
262
|
+
return DEFAULT_CONV_TITLE;
|
|
263
|
+
}
|
|
264
|
+
export class ClientSession {
|
|
265
|
+
clientId;
|
|
266
|
+
/** Set by AgentService.attach: reflects the SERVICE-wide quiesce flag
|
|
267
|
+
* (server draining — new work rejected). Default false for direct use. */
|
|
268
|
+
isQuiesced = () => false;
|
|
269
|
+
cwd;
|
|
270
|
+
/** pi config dir (auth/models/skills). */
|
|
271
|
+
agentDir;
|
|
272
|
+
/** Persisted per-client UI state (last workspace + recent projects). */
|
|
273
|
+
stateStore;
|
|
274
|
+
/** Open conversations — each owns its OWN runtime, so starting a new chat
|
|
275
|
+
* or switching chats never interrupts an in-flight run. `runtime` and
|
|
276
|
+
* `session` accessors below target the ACTIVE conversation. */
|
|
277
|
+
convs = new Map();
|
|
278
|
+
activeId = "";
|
|
279
|
+
convSeq = 0;
|
|
280
|
+
/** One ModelRuntime shared by all conversations — the model chosen in the
|
|
281
|
+
* top bar applies to every chat, not just the one that set it. Seeded by
|
|
282
|
+
* the first conversation and reused by later ones. */
|
|
283
|
+
sharedModelRuntime;
|
|
284
|
+
// -----------------------------------------------------------------------
|
|
285
|
+
// Goal / review / wizard —— 自包含模块,见 goal-service.ts。每个对话有独立
|
|
286
|
+
// 的 GoalStatus,审查可并发;宿主回调在构造函数里接入。
|
|
287
|
+
// -----------------------------------------------------------------------
|
|
288
|
+
goalSvc;
|
|
289
|
+
/** Settings-panel state (system prompt + disabled skills/extensions) —
|
|
290
|
+
* 自包含模块,见 settings-service.ts。resource-loader overrides 在每次
|
|
291
|
+
* reload() 时读 current 的最新值,session.reload() 即可应用到运行中 runtime。 */
|
|
292
|
+
settingsSvc; // 构造函数里创建(需要 clientId/stateStore)
|
|
293
|
+
/** How long a hard abort waits for session.abort() to make the run idle
|
|
294
|
+
* before force-resetting the conversation (model streams that ignore the
|
|
295
|
+
* abort signal would otherwise leave the chat stuck forever). */
|
|
296
|
+
static HARD_ABORT_TIMEOUT_MS = 15_000;
|
|
297
|
+
/** Extra settle window after session.abort() returns: the run is only
|
|
298
|
+
* considered stopped once its agent_end event arrives. If it doesn't
|
|
299
|
+
* (model stream stuck before the run even started), force-reset. */
|
|
300
|
+
static HARD_ABORT_SETTLE_MS = 8_000;
|
|
301
|
+
/** Live AbortControllers of THIS client's running bash tool calls — aborting
|
|
302
|
+
* them kills only the command (agent run and conversation continue). */
|
|
303
|
+
bashKills = new Set();
|
|
304
|
+
/** Background-server tracking (port snapshots + 后台任务 panel state) —
|
|
305
|
+
* 自包含模块,见 bg-servers.ts。列表按 CLIENT 存活,不随对话切换/结束消失。 */
|
|
306
|
+
/** 文件树 / 预览读写 / SCM 查询 / watcher —— 自包含模块,见 files-service.ts。 */
|
|
307
|
+
files = new FilesService({
|
|
308
|
+
emit: (msg) => this.emit(msg),
|
|
309
|
+
isDisposed: () => this.disposed,
|
|
310
|
+
getCwd: () => this.cwd,
|
|
311
|
+
getActiveCwd: () => this.conv?.cwd ?? this.cwd,
|
|
312
|
+
});
|
|
313
|
+
bg = new BgServerTracker({
|
|
314
|
+
emit: (msg) => this.emit(msg),
|
|
315
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
316
|
+
isDisposed: () => this.disposed,
|
|
317
|
+
// 插件注册的常驻任务(host.registerBackgroundTask)并入同一「后台任务」面板。
|
|
318
|
+
pluginTasks: () => this.pluginBgTasksProvider?.() ?? [],
|
|
319
|
+
});
|
|
320
|
+
/** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
|
|
321
|
+
* 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
|
|
322
|
+
onToolEvent = undefined;
|
|
323
|
+
/** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
|
|
324
|
+
pluginToolsProvider = undefined;
|
|
325
|
+
/** index.ts 注入:读取插件当前注册的斜杠命令(目录展示 + prompt 拦截执行)。 */
|
|
326
|
+
pluginCommandsProvider = undefined;
|
|
327
|
+
/** index.ts 注入:读取插件注册的常驻后台任务(并入 bg_servers 面板)。 */
|
|
328
|
+
pluginBgTasksProvider = undefined;
|
|
329
|
+
/** index.ts 注入:停止插件任务(kill_background_server with taskId)。 */
|
|
330
|
+
pluginStopBgTask = undefined;
|
|
331
|
+
/** 上一轮注入会话的插件工具名集合(用于检测注销/移除)。 */
|
|
332
|
+
appliedPluginToolNames = new Set();
|
|
333
|
+
/** The active conversation (all session operations target it). */
|
|
334
|
+
get conv() {
|
|
335
|
+
const conv = this.convs.get(this.activeId);
|
|
336
|
+
if (!conv)
|
|
337
|
+
throw new Error("no active conversation");
|
|
338
|
+
return conv;
|
|
339
|
+
}
|
|
340
|
+
/** Runtime of the active conversation. */
|
|
341
|
+
get runtime() {
|
|
342
|
+
return this.conv.runtime;
|
|
343
|
+
}
|
|
344
|
+
/** Session of the active conversation. */
|
|
345
|
+
get session() {
|
|
346
|
+
return this.conv.session;
|
|
347
|
+
}
|
|
348
|
+
/** PTYs are owned by individual conversations; this getter targets the active one
|
|
349
|
+
* for compatibility with the existing terminal-panel dispatch path. */
|
|
350
|
+
get terminals() {
|
|
351
|
+
return this.conv.terminals;
|
|
352
|
+
}
|
|
353
|
+
getTerminalManager(conversationId) {
|
|
354
|
+
return (conversationId ? this.convs.get(conversationId) : this.conv)?.terminals;
|
|
355
|
+
}
|
|
356
|
+
getTerminalCwd(conversationId) {
|
|
357
|
+
return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
|
|
358
|
+
}
|
|
359
|
+
makeTerminalManager(conversationId, cwd) {
|
|
360
|
+
const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
|
|
361
|
+
// 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
|
|
362
|
+
// 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
|
|
363
|
+
mgr.onAgentIdle = (terminalId, idleMs, title) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title);
|
|
364
|
+
return mgr;
|
|
365
|
+
}
|
|
366
|
+
/** 终端活力提醒:仅在该对话正在流式运行时注入(sendUserMessage 在流式中
|
|
367
|
+
* 即 steer 语义——当前回合结算后送达,agent 立即响应);空闲时不打扰。
|
|
368
|
+
* 一次性语义由 TerminalManager 保证(触发后解除武装,agent 再次触碰才
|
|
369
|
+
* 重新计时),不会反复刷屏。 */
|
|
370
|
+
notifyTerminalIdle(conversationId, terminalId, idleMs, title) {
|
|
371
|
+
const conv = this.convs.get(conversationId);
|
|
372
|
+
if (!conv || this.disposed)
|
|
373
|
+
return;
|
|
374
|
+
if (!conv.runtime.session.isStreaming)
|
|
375
|
+
return;
|
|
376
|
+
const seconds = Math.max(1, Math.round(idleMs / 1000));
|
|
377
|
+
void conv.runtime.session
|
|
378
|
+
.sendUserMessage(`(系统自动提醒:你启动的终端「${title}」已连续 ${seconds} 秒没有任何新输出。` +
|
|
379
|
+
`进程可能在等待输入、卡住或已挂起。请用 terminal_read 查看它的当前状态;` +
|
|
380
|
+
`若在等交互就用 terminal_input / terminal_key 回应;确认不再需要就 terminal_close 关掉它。)`)
|
|
381
|
+
.catch(() => {
|
|
382
|
+
// best effort —— 注入失败不影响终端本身
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* 终端接管的 bash 静默转后台后的完成通知:命令真正结束时主动告诉 AI。
|
|
387
|
+
* 流式中 → sendUserMessage(steer,立即唤醒处理);空闲时 → sendCustomMessage
|
|
388
|
+
* nextTurn 排队(不唤醒 agent、不耗 token,下次对话自动带上)。
|
|
389
|
+
*/
|
|
390
|
+
notifyTerminalBashDone(terminals, info) {
|
|
391
|
+
const conv = [...this.convs.values()].find((c) => c.terminals === terminals);
|
|
392
|
+
if (!conv || this.disposed)
|
|
393
|
+
return;
|
|
394
|
+
let tail = "";
|
|
395
|
+
try {
|
|
396
|
+
const end = terminals.endCursor(info.terminalId);
|
|
397
|
+
if (end !== null) {
|
|
398
|
+
tail = terminals.read(info.terminalId, Math.max(0, end - 4000))?.data ?? "";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// 终端可能已被关闭
|
|
403
|
+
}
|
|
404
|
+
const exitText = info.exitCode === null ? "终端已关闭" : `退出码 ${info.exitCode}`;
|
|
405
|
+
const cmdShort = info.command.length > 120 ? `${info.command.slice(0, 120)}…` : info.command;
|
|
406
|
+
const text = `(系统:你之前在终端 ${info.terminalId} 后台运行的命令已结束(${exitText}):${cmdShort}\n` +
|
|
407
|
+
`最后输出:\n${stripAnsi(tail).trim() || "(无输出)"})`;
|
|
408
|
+
const session = conv.runtime.session;
|
|
409
|
+
if (session.isStreaming) {
|
|
410
|
+
void session.sendUserMessage(text).catch(() => { });
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
// 空闲时不唤醒 agent——排队为 nextTurn 上下文,下次对话自动可见。
|
|
414
|
+
void session
|
|
415
|
+
.sendCustomMessage({
|
|
416
|
+
customType: "terminal-bash-done",
|
|
417
|
+
content: [{ type: "text", text }],
|
|
418
|
+
display: true,
|
|
419
|
+
})
|
|
420
|
+
.catch(() => { });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
emitTerminal(conversationId, msg) {
|
|
424
|
+
// Background conversations keep collecting output in their own PTY buffer.
|
|
425
|
+
// Do not stream it into the active xterm; push the retained window on switch.
|
|
426
|
+
if (msg.type === "terminal_output" && conversationId !== this.activeId)
|
|
427
|
+
return;
|
|
428
|
+
if (msg.type === "terminal_output" || msg.type === "terminal_exit" || msg.type === "terminal_list") {
|
|
429
|
+
this.emit({ ...msg, conversationId });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
this.emit(msg);
|
|
433
|
+
}
|
|
434
|
+
pushTerminals(conversation = this.conv) {
|
|
435
|
+
this.emit({
|
|
436
|
+
type: "terminal_list",
|
|
437
|
+
conversationId: conversation.id,
|
|
438
|
+
terminals: conversation.terminals.list(),
|
|
439
|
+
});
|
|
440
|
+
for (const output of conversation.terminals.replay()) {
|
|
441
|
+
this.emit({
|
|
442
|
+
type: "terminal_output",
|
|
443
|
+
conversationId: conversation.id,
|
|
444
|
+
terminalId: output.terminalId,
|
|
445
|
+
data: output.data,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Vision-bridge transcript cache (batch hash → text). A re-sent / re-asked
|
|
451
|
+
* prompt with the same images skips the vision API call entirely — editing
|
|
452
|
+
* a question doesn't re-burn tokens on re-transcribing identical screenshots.
|
|
453
|
+
*/
|
|
454
|
+
/** Most recent built-in (default) system prompt observed by the
|
|
455
|
+
* resource-loader override — surfaced via settings_state so the
|
|
456
|
+
* replace-mode editor can show the prompt it would otherwise replace.
|
|
457
|
+
* Only non-empty when the user has a system-prompt file. */
|
|
458
|
+
lastBaseSystemPrompt = "";
|
|
459
|
+
/** The system prompt the replace-mode editor should show as its seed:
|
|
460
|
+
* the user's system-prompt file content if one exists, otherwise the
|
|
461
|
+
* SDK's built-in default actually in effect (agent.state.systemPrompt,
|
|
462
|
+
* which the loader rebuilds at session init). If the user HAS a custom
|
|
463
|
+
* prompt the seed is only cosmetic — an unmodified seed is saved as
|
|
464
|
+
* empty and the server falls back to the true base. */
|
|
465
|
+
effectiveDefaultSystemPrompt() {
|
|
466
|
+
if (this.lastBaseSystemPrompt)
|
|
467
|
+
return this.lastBaseSystemPrompt;
|
|
468
|
+
try {
|
|
469
|
+
const sp = this.session.agent.state.systemPrompt;
|
|
470
|
+
if (typeof sp === "string" && sp)
|
|
471
|
+
return sp;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// Session not ready yet.
|
|
475
|
+
}
|
|
476
|
+
return "";
|
|
477
|
+
}
|
|
478
|
+
/** The FULL system prompt actually in effect right now (AgentSession getter,
|
|
479
|
+
* includes the append/replace override + auto-appended sections like
|
|
480
|
+
* project context, skills and tool guidance). Read-only view source for
|
|
481
|
+
* the settings panel. */
|
|
482
|
+
effectiveSystemPrompt() {
|
|
483
|
+
try {
|
|
484
|
+
const sp = this.session.systemPrompt;
|
|
485
|
+
return typeof sp === "string" ? sp : "";
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// Session not ready yet.
|
|
489
|
+
return "";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Web-facing extension UI context (widgets, notifications). */
|
|
493
|
+
webUi = new WebUIContext((msg) => this.emit(msg));
|
|
494
|
+
widgetsTimer = null;
|
|
495
|
+
/** Model-stall watchdog interval (see startStallTimer). */
|
|
496
|
+
stallTimer = null;
|
|
497
|
+
/** Connected sockets for this client (multiple tabs share the session). */
|
|
498
|
+
sinks = new Set();
|
|
499
|
+
pendingNotices = [];
|
|
500
|
+
snapshotTimer = null;
|
|
501
|
+
/** Timestamp of the most recent message_delta push — while fresh, snapshots
|
|
502
|
+
* use the slower STREAMING_SNAPSHOT_INTERVAL_MS cadence. */
|
|
503
|
+
lastDeltaAt = 0;
|
|
504
|
+
sessionsTimer = null;
|
|
505
|
+
version = 0;
|
|
506
|
+
/** Snapshot revision counter (see emitSnapshotNow / protocol snapshot_delta). */
|
|
507
|
+
snapRev = 0;
|
|
508
|
+
/** Messages array as of the last emitted snapshot/delta — identity-walked
|
|
509
|
+
* against the current array to detect append-only growth. */
|
|
510
|
+
emittedMessages = null;
|
|
511
|
+
/** Conversation whose messages emittedMessages belongs to. A conversation
|
|
512
|
+
* switch (set_cwd / new_chat / switch_*) must fall back to a FULL snapshot:
|
|
513
|
+
* two empty conversations have identical (empty) arrays, so the identity
|
|
514
|
+
* walk alone would misread the switch as "nothing changed" → delta. */
|
|
515
|
+
emittedConvId = null;
|
|
516
|
+
/** snapRev value at which emittedMessages was captured. */
|
|
517
|
+
emittedRev = 0;
|
|
518
|
+
/**
|
|
519
|
+
* Per-conversation serialization caches (stable message ids, UiMessage
|
|
520
|
+
* object cache, message-array signature, queue counts) live inside each
|
|
521
|
+
* Conversation — see Conversation above.
|
|
522
|
+
*/
|
|
523
|
+
disposed = false;
|
|
524
|
+
/** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
|
|
525
|
+
piCheckCache = null;
|
|
526
|
+
/** fs.watch on the currently-listed directory — file changes push an instant
|
|
527
|
+
* refresh (`file_changed`) so the tree updates without waiting for the 10s
|
|
528
|
+
* poll. Only the listed directory is watched (one level); navigating
|
|
529
|
+
* re-watches the new target. fs.watch isn't available on every platform /
|
|
530
|
+
* filesystem — failures silently fall back to the poll. */
|
|
531
|
+
fsWatcher = null;
|
|
532
|
+
watchPath = null;
|
|
533
|
+
/** fs.watch on the active repo's git dir — external changes (CLI commit,
|
|
534
|
+
* IDE branch switch) push `scm_changed` so the panel refreshes itself.
|
|
535
|
+
* One watcher per client session, re-targeted when the queried cwd
|
|
536
|
+
* changes; failures (bare repo, unsupported fs) silently disable it. */
|
|
537
|
+
gitWatcher = null;
|
|
538
|
+
gitWatchCwd = null;
|
|
539
|
+
gitDirtyTimer = null;
|
|
540
|
+
watchTimer = null;
|
|
541
|
+
constructor(clientId, cwd, agentDir, stateStore) {
|
|
542
|
+
this.clientId = clientId;
|
|
543
|
+
this.cwd = cwd;
|
|
544
|
+
this.agentDir = agentDir;
|
|
545
|
+
this.stateStore = stateStore;
|
|
546
|
+
this.settingsSvc = new SettingsService({
|
|
547
|
+
clientId,
|
|
548
|
+
stateStore,
|
|
549
|
+
emit: (msg) => this.emit(msg),
|
|
550
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
551
|
+
isDisposed: () => this.disposed,
|
|
552
|
+
getSession: () => this.session,
|
|
553
|
+
cwd: () => this.cwd,
|
|
554
|
+
agentDir: () => this.agentDir,
|
|
555
|
+
isStreaming: () => this.session.isStreaming,
|
|
556
|
+
reloadSession: async () => {
|
|
557
|
+
await this.session.reload();
|
|
558
|
+
// reload() 会把 custom 工具重新加回活跃集——重放终端开关。
|
|
559
|
+
this.applyTerminalToolGating(this.session);
|
|
560
|
+
await this.pushSlashCommands();
|
|
561
|
+
},
|
|
562
|
+
effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
|
|
563
|
+
effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
|
|
564
|
+
});
|
|
565
|
+
this.goalSvc = new GoalService({
|
|
566
|
+
clientId,
|
|
567
|
+
agentDir,
|
|
568
|
+
stateStore,
|
|
569
|
+
webUi: this.webUi,
|
|
570
|
+
emit: (msg) => this.emit(msg),
|
|
571
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
572
|
+
isDisposed: () => this.disposed,
|
|
573
|
+
quiesceBlocked: () => this.quiesceBlocked(),
|
|
574
|
+
activeConvId: () => this.activeId,
|
|
575
|
+
activeConv: () => this.conv,
|
|
576
|
+
getConv: (id) => this.convs.get(id),
|
|
577
|
+
cwd: () => this.cwd,
|
|
578
|
+
reviewSettings: () => this.settingsSvc.reviewPrefs,
|
|
579
|
+
gitDiff: (dir) => this.gitDiff(dir),
|
|
580
|
+
});
|
|
581
|
+
this.modelAdmin = new ModelAdminService({
|
|
582
|
+
agentDir,
|
|
583
|
+
emit: (msg) => this.emit(msg),
|
|
584
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
585
|
+
isDisposed: () => this.disposed,
|
|
586
|
+
modelRuntime: () => this.runtime.services.modelRuntime,
|
|
587
|
+
invalidatePiConfig: () => {
|
|
588
|
+
this.piCheckCache = null;
|
|
589
|
+
},
|
|
590
|
+
pushModels: async () => this.listModels(),
|
|
591
|
+
});
|
|
592
|
+
// Prune dead background tasks every 30s (only spawns netstat/lsof while
|
|
593
|
+
// the list is non-empty). unref: must not keep the process alive.
|
|
594
|
+
this.bg.start();
|
|
595
|
+
}
|
|
596
|
+
static async create(clientId, cwd, stateStore) {
|
|
597
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
|
|
598
|
+
const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
|
|
599
|
+
const conversationId = cs.nextConversationId();
|
|
600
|
+
const terminals = cs.makeTerminalManager(conversationId, cwd);
|
|
601
|
+
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
|
|
602
|
+
cwd,
|
|
603
|
+
agentDir,
|
|
604
|
+
// Resume the most recent session for this project — the SDK default
|
|
605
|
+
// per-project dir (<agentDir>/sessions/--<cwd>--/, shared with the
|
|
606
|
+
// pi CLI/TUI) — or start a fresh one on first visit.
|
|
607
|
+
sessionManager: SessionManager.continueRecent(cwd),
|
|
608
|
+
});
|
|
609
|
+
// First conversation = the resumed session; it also seeds the shared
|
|
610
|
+
// ModelRuntime that every later conversation reuses.
|
|
611
|
+
cs.sharedModelRuntime = runtime.services.modelRuntime;
|
|
612
|
+
const conv = cs.makeConversation(runtime, conversationId, terminals);
|
|
613
|
+
cs.convs.set(conv.id, conv);
|
|
614
|
+
cs.activeId = conv.id;
|
|
615
|
+
for (const d of runtime.diagnostics) {
|
|
616
|
+
if (d.type !== "info") {
|
|
617
|
+
cs.pendingNotices.push({
|
|
618
|
+
type: "notice",
|
|
619
|
+
level: d.type,
|
|
620
|
+
text: d.message,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
await cs.bindSession();
|
|
625
|
+
return cs;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Factory for cwd-bound runtimes. All conversations share ONE ModelRuntime
|
|
629
|
+
* (the model choice is client-wide), so later conversations reuse the
|
|
630
|
+
* instance created with the first one.
|
|
631
|
+
*/
|
|
632
|
+
makeRuntimeFactory(terminals) {
|
|
633
|
+
return async ({ cwd: effectiveCwd, sessionManager }) => {
|
|
634
|
+
const services = await createAgentSessionServices({
|
|
635
|
+
cwd: effectiveCwd,
|
|
636
|
+
modelRuntime: this.sharedModelRuntime,
|
|
637
|
+
// 设置面板钩子(官方 SDK 的 resourceLoader overrides):三个 override
|
|
638
|
+
// 在每次 resourceLoader.reload() 时重放,且读取 this.settings 的当前
|
|
639
|
+
// 值——因此 session.reload() 即可让系统提示词 / 技能 / 插件开关生效,
|
|
640
|
+
// 新对话(新 runtime)也会自动带上当前设置。
|
|
641
|
+
resourceLoaderOptions: {
|
|
642
|
+
// 系统提示词:replace 模式整体替换;append 模式追加到提示词末尾。
|
|
643
|
+
systemPromptOverride: (base) => {
|
|
644
|
+
// Remember the built-in default so the settings panel can show
|
|
645
|
+
// it when the user edits in replace mode.
|
|
646
|
+
if (typeof base === "string" && base) {
|
|
647
|
+
this.lastBaseSystemPrompt = base;
|
|
648
|
+
}
|
|
649
|
+
return this.settingsSvc.current.promptMode === "replace" &&
|
|
650
|
+
this.settingsSvc.current.customSystemPrompt.trim()
|
|
651
|
+
? this.settingsSvc.current.customSystemPrompt
|
|
652
|
+
: base;
|
|
653
|
+
},
|
|
654
|
+
appendSystemPromptOverride: (base) => {
|
|
655
|
+
const out = [...base];
|
|
656
|
+
const custom = this.settingsSvc.current.customSystemPrompt.trim();
|
|
657
|
+
if (this.settingsSvc.current.promptMode === "append" && custom) {
|
|
658
|
+
out.push(custom);
|
|
659
|
+
}
|
|
660
|
+
if (process.platform === "win32") {
|
|
661
|
+
// Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
|
|
662
|
+
// 是交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
|
|
663
|
+
// GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
|
|
664
|
+
out.push(WINDOWS_PERSONA);
|
|
665
|
+
}
|
|
666
|
+
if (this.settingsSvc.current.terminalToolsEnabled !== false) {
|
|
667
|
+
// 终端工具使用引导(全平台):告诉模型什么场景该用持久终端
|
|
668
|
+
// 而不是一次性 bash——没有这段模型几乎从不主动选终端工具。
|
|
669
|
+
out.push(TERMINAL_TOOLS_GUIDANCE);
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
},
|
|
673
|
+
// 技能开关:禁用的技能从系统提示词和 /skill: 目录中剔除。
|
|
674
|
+
skillsOverride: (res) => ({
|
|
675
|
+
...res,
|
|
676
|
+
skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
|
|
677
|
+
}),
|
|
678
|
+
// 插件开关:禁用的扩展整个卸载(工具 / 命令随之消失)。
|
|
679
|
+
// 注意 SDK 在 extensionsOverride 之后才补 sourceInfo,包扩展此处只能靠路径
|
|
680
|
+
// 匹配 —— isExtensionDisabled 同时比对 npm:<pkg> 候选键。
|
|
681
|
+
extensionsOverride: (res) => ({
|
|
682
|
+
...res,
|
|
683
|
+
extensions: res.extensions.filter((e) => !isExtensionDisabled(e, this.settingsSvc.current.disabledExtensions)),
|
|
684
|
+
}),
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
const created = await createAgentSessionFromServices({
|
|
688
|
+
services,
|
|
689
|
+
sessionManager,
|
|
690
|
+
// 可手动停止的 bash 工具:覆盖 SDK 内置 bash(customTools 按 name
|
|
691
|
+
// 覆盖),执行时把自己的 AbortController 注册进客户端集合——
|
|
692
|
+
// abortBash() 只杀这些命令,agent run 与对话继续。
|
|
693
|
+
customTools: [
|
|
694
|
+
// bash 双实现动态分流:「终端接管」开启时命令跑进持久可见终端
|
|
695
|
+
// (保留 shell 状态、静默自动转后台),关闭时是原生 killable bash。
|
|
696
|
+
makeAdaptiveBashTool(makeKillableBashTool(effectiveCwd, this.bashKills), makeTerminalBashTool(terminals, {
|
|
697
|
+
cwd: effectiveCwd,
|
|
698
|
+
idleMs: () => this.settingsSvc.current.terminalBash
|
|
699
|
+
? Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) ||
|
|
700
|
+
0)
|
|
701
|
+
: 0,
|
|
702
|
+
kills: this.bashKills,
|
|
703
|
+
notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
|
|
704
|
+
}), () => this.settingsSvc.current.terminalBash),
|
|
705
|
+
...makePersistentTerminalTools(terminals, effectiveCwd),
|
|
706
|
+
// 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
|
|
707
|
+
// refreshPluginTools 动态补入已有会话)。
|
|
708
|
+
...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
|
|
709
|
+
],
|
|
710
|
+
});
|
|
711
|
+
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
712
|
+
this.applyTerminalToolGating(created.session);
|
|
713
|
+
return {
|
|
714
|
+
...created,
|
|
715
|
+
services,
|
|
716
|
+
diagnostics: services.diagnostics,
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/** Create independent goal state for one conversation. Preferences are
|
|
721
|
+
* client-wide defaults, while goal text/review progress is not shared. */
|
|
722
|
+
makeGoalStatus() {
|
|
723
|
+
return this.goalSvc.makeGoalStatus();
|
|
724
|
+
}
|
|
725
|
+
/** Allocate a stable conversation id before constructing its runtime/tools. */
|
|
726
|
+
nextConversationId() {
|
|
727
|
+
return `c${++this.convSeq}`;
|
|
728
|
+
}
|
|
729
|
+
/** Wrap a fresh runtime as a new conversation record. */
|
|
730
|
+
makeConversation(runtime, id, terminals) {
|
|
731
|
+
return {
|
|
732
|
+
id,
|
|
733
|
+
title: conversationTitle(runtime.session),
|
|
734
|
+
runtime,
|
|
735
|
+
session: runtime.session,
|
|
736
|
+
cwd: runtime.cwd,
|
|
737
|
+
createdAt: Date.now(),
|
|
738
|
+
// A brand-new conversation is not yet in the running list — it enters
|
|
739
|
+
// only when it is displaced to the background while still streaming.
|
|
740
|
+
listed: false,
|
|
741
|
+
promptedSinceActive: false,
|
|
742
|
+
lastActiveAt: Date.now(),
|
|
743
|
+
lastSdkEventAt: Date.now(),
|
|
744
|
+
stallNoticed: false,
|
|
745
|
+
goal: this.makeGoalStatus(),
|
|
746
|
+
goalGeneration: 0,
|
|
747
|
+
goalReviewGeneration: 0,
|
|
748
|
+
wizardRunning: false,
|
|
749
|
+
deltaSeq: 0,
|
|
750
|
+
terminals,
|
|
751
|
+
msgIds: new Map(),
|
|
752
|
+
nextMsgId: 1,
|
|
753
|
+
userSeqByTs: new Map(),
|
|
754
|
+
uiMessageCache: new Map(),
|
|
755
|
+
lastMessagesSig: "",
|
|
756
|
+
lastMessagesArray: [],
|
|
757
|
+
queueSteering: [],
|
|
758
|
+
queueFollowUp: [],
|
|
759
|
+
toolStartTimes: new Map(),
|
|
760
|
+
toolWatchdogs: new Map(),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
/** Summaries of conversations currently streaming — captured at shutdown
|
|
764
|
+
* so the next attach can tell the user their run was interrupted. */
|
|
765
|
+
streamingSummaries() {
|
|
766
|
+
const out = [];
|
|
767
|
+
for (const conv of this.convs.values()) {
|
|
768
|
+
if (conv.session.isStreaming)
|
|
769
|
+
out.push({ title: conv.title, cwd: conv.cwd });
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
/** Tell the user about runs lost to the last server restart (once). */
|
|
774
|
+
notifyInterrupted(list) {
|
|
775
|
+
if (!list || list.length === 0)
|
|
776
|
+
return;
|
|
777
|
+
const names = list
|
|
778
|
+
.map((r) => `「${r.title}」(${r.cwd})`)
|
|
779
|
+
.join("、");
|
|
780
|
+
this.pendingNotices.push({
|
|
781
|
+
type: "notice",
|
|
782
|
+
level: "warning",
|
|
783
|
+
text: `上次服务重启时有 ${list.length} 个进行中的对话被中断:${names}。可在历史对话中恢复继续。`,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
/** Add a socket to this client's broadcast set; flushes buffered startup notices. */
|
|
787
|
+
attachSink(send) {
|
|
788
|
+
this.sinks.add(send);
|
|
789
|
+
for (const msg of this.pendingNotices)
|
|
790
|
+
send(msg);
|
|
791
|
+
this.pendingNotices = [];
|
|
792
|
+
// Replay current extension widgets (setWidget may have fired during
|
|
793
|
+
// session creation, before any socket was attached).
|
|
794
|
+
const widgets = this.webUi.snapshot();
|
|
795
|
+
if (widgets.length > 0)
|
|
796
|
+
send({ type: "widgets", widgets });
|
|
797
|
+
const statuses = this.webUi.statusSnapshot();
|
|
798
|
+
if (statuses.length > 0)
|
|
799
|
+
send({ type: "statuses", statuses });
|
|
800
|
+
// Reconnect: push the current project's running-conversation list so the
|
|
801
|
+
// left panel shows every background chat (a fresh socket never got the
|
|
802
|
+
// newChat/switch pushes).
|
|
803
|
+
this.emitConversations();
|
|
804
|
+
// Reconnect: same for the slash-command catalog (the picker needs it even
|
|
805
|
+
// before the client asks).
|
|
806
|
+
void this.pushSlashCommands();
|
|
807
|
+
// Reconnect: push the remembered goal prefs (model choice, rounds cap,
|
|
808
|
+
// locked) so the goal bar restores them on reload — "全局记忆".
|
|
809
|
+
this.goalSvc.emitGoalStatus();
|
|
810
|
+
// Reconnect: push the settings panel state (prompt text/mode, skill &
|
|
811
|
+
// extension toggles, saved presets).
|
|
812
|
+
this.pushSettings();
|
|
813
|
+
// Reconnect: push the background-task list — it must survive reconnects
|
|
814
|
+
// and outlive the conversation that started the tasks.
|
|
815
|
+
this.bg.push();
|
|
816
|
+
// PTYs are conversation-owned and survive a socket reconnect.
|
|
817
|
+
this.pushTerminals();
|
|
818
|
+
}
|
|
819
|
+
detachSink(send) {
|
|
820
|
+
this.sinks.delete(send);
|
|
821
|
+
// PTYs intentionally survive a socket drop: they are owned by the
|
|
822
|
+
// conversation and can be inspected after reconnecting. Only conversation
|
|
823
|
+
// disposal or server shutdown kills them.
|
|
824
|
+
if (this.sinks.size === 0) {
|
|
825
|
+
this.files.unwatchDir();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/** Broadcast to every connected socket of this client. */
|
|
829
|
+
emit(msg) {
|
|
830
|
+
if (this.disposed)
|
|
831
|
+
return;
|
|
832
|
+
for (const sink of [...this.sinks])
|
|
833
|
+
sink(msg);
|
|
834
|
+
}
|
|
835
|
+
/** (Re)attach event plumbing to the ACTIVE conversation's session. */
|
|
836
|
+
async bindSession() {
|
|
837
|
+
const conv = this.conv;
|
|
838
|
+
conv.unsubscribe?.();
|
|
839
|
+
conv.session = conv.runtime.session;
|
|
840
|
+
await conv.session.bindExtensions({
|
|
841
|
+
mode: "rpc",
|
|
842
|
+
uiContext: this.webUi,
|
|
843
|
+
onError: (err) => {
|
|
844
|
+
this.emit({ type: "notice", level: "error", text: err.error });
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
848
|
+
this.scheduleSnapshot();
|
|
849
|
+
this.webUi.refresh();
|
|
850
|
+
this.startWidgetsTimer();
|
|
851
|
+
this.startStallTimer();
|
|
852
|
+
}
|
|
853
|
+
/** Poll extension widgets so TUI-only overlays (e.g. rpiv-todo) stay live. */
|
|
854
|
+
startWidgetsTimer() {
|
|
855
|
+
if (this.widgetsTimer)
|
|
856
|
+
return;
|
|
857
|
+
this.widgetsTimer = setInterval(() => {
|
|
858
|
+
if (!this.disposed)
|
|
859
|
+
this.webUi.refresh();
|
|
860
|
+
}, WIDGET_REFRESH_MS);
|
|
861
|
+
}
|
|
862
|
+
/** Model-stall watchdog: warn when a streaming run went completely silent
|
|
863
|
+
* (no SDK events at all) for STALL_NOTIFY_MS. Deliberately does NOT abort:
|
|
864
|
+
* deep-thinking models can legitimately be quiet for minutes — the notice
|
|
865
|
+
* just tells the user the run looks stuck so they can Stop it themselves. */
|
|
866
|
+
startStallTimer() {
|
|
867
|
+
if (this.stallTimer || STALL_NOTIFY_MS === 0)
|
|
868
|
+
return;
|
|
869
|
+
this.stallTimer = setInterval(() => {
|
|
870
|
+
if (this.disposed)
|
|
871
|
+
return;
|
|
872
|
+
const now = Date.now();
|
|
873
|
+
for (const conv of this.convs.values()) {
|
|
874
|
+
if (!conv.stallNoticed &&
|
|
875
|
+
conv.session.isStreaming &&
|
|
876
|
+
now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
|
|
877
|
+
conv.stallNoticed = true;
|
|
878
|
+
const mins = Math.round((now - conv.lastSdkEventAt) / 60_000);
|
|
879
|
+
this.emit({
|
|
880
|
+
type: "notice",
|
|
881
|
+
level: "warning",
|
|
882
|
+
text: `对话「${conv.title}」已 ${mins} 分钟无任何响应,可能已失联(网络中断或服务端挂起)。可点击停止后重试。`,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}, 30_000);
|
|
887
|
+
}
|
|
888
|
+
/** Arm the hang-guard for a tool call: if it is still running after
|
|
889
|
+
* TOOL_WATCHDOG_TIMEOUT_MS, abort the session instead of letting the
|
|
890
|
+
* conversation hang forever (the SDK bash tool has no default timeout). */
|
|
891
|
+
armToolWatchdog(conv, toolCallId) {
|
|
892
|
+
const t = setTimeout(() => {
|
|
893
|
+
conv.toolWatchdogs.delete(toolCallId);
|
|
894
|
+
// The tool finished before the deadline — nothing to do.
|
|
895
|
+
if (!conv.toolStartTimes.has(toolCallId))
|
|
896
|
+
return;
|
|
897
|
+
this.emit({
|
|
898
|
+
type: "notice",
|
|
899
|
+
level: "warning",
|
|
900
|
+
text: `工具执行超过 ${Math.round(TOOL_WATCHDOG_TIMEOUT_MS / 60_000)} 分钟,已自动终止(防止挂死)。可调整超时:环境变量 PI_WEB_TOOL_TIMEOUT_MS(毫秒)。`,
|
|
901
|
+
});
|
|
902
|
+
conv.toolStartTimes.delete(toolCallId);
|
|
903
|
+
// Abort the run (kills the process tree via the SDK's abort signal);
|
|
904
|
+
// agent_end will fire with stopReason "aborted" and existing logic
|
|
905
|
+
// clears any goal / review loop. interruptRun adds a force-reset
|
|
906
|
+
// fallback in case the model stream ignores the abort signal.
|
|
907
|
+
void this.interruptRun(conv, "工具执行超时");
|
|
908
|
+
}, TOOL_WATCHDOG_TIMEOUT_MS);
|
|
909
|
+
t.unref?.();
|
|
910
|
+
conv.toolWatchdogs.set(toolCallId, t);
|
|
911
|
+
}
|
|
912
|
+
/** Cancel a tool's watchdog — called when the tool finishes normally. */
|
|
913
|
+
clearToolWatchdog(conv, toolCallId) {
|
|
914
|
+
const t = conv.toolWatchdogs.get(toolCallId);
|
|
915
|
+
if (t) {
|
|
916
|
+
clearTimeout(t);
|
|
917
|
+
conv.toolWatchdogs.delete(toolCallId);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/** Cancel every watchdog of a conversation (removeConversation / dispose). */
|
|
921
|
+
clearAllToolWatchdogs(conv) {
|
|
922
|
+
for (const t of conv.toolWatchdogs.values())
|
|
923
|
+
clearTimeout(t);
|
|
924
|
+
conv.toolWatchdogs.clear();
|
|
925
|
+
}
|
|
926
|
+
onEvent(conv, event) {
|
|
927
|
+
// Any SDK event proves the run is alive — feeds the stall watchdog below.
|
|
928
|
+
conv.lastSdkEventAt = Date.now();
|
|
929
|
+
conv.stallNoticed = false;
|
|
930
|
+
switch (event.type) {
|
|
931
|
+
case "bash_execution_update": {
|
|
932
|
+
if (event.id) {
|
|
933
|
+
this.emit({
|
|
934
|
+
type: "tool_delta",
|
|
935
|
+
conversationId: conv.id,
|
|
936
|
+
seq: ++conv.deltaSeq,
|
|
937
|
+
toolCallId: event.id,
|
|
938
|
+
toolName: "bash",
|
|
939
|
+
delta: event.delta,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
case "tool_execution_start": {
|
|
945
|
+
// Record the moment the tool actually starts so tool_status can
|
|
946
|
+
// report real execution time (vs. time spent waiting on the model).
|
|
947
|
+
conv.toolStartTimes.set(event.toolCallId, Date.now());
|
|
948
|
+
// Snapshot listeners before a bash run — the post-run diff catches
|
|
949
|
+
// servers the agent started in the background.
|
|
950
|
+
if (event.toolName === "bash") {
|
|
951
|
+
this.bg.snapshotBefore();
|
|
952
|
+
}
|
|
953
|
+
this.armToolWatchdog(conv, event.toolCallId);
|
|
954
|
+
// 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
|
|
955
|
+
this.onToolEvent?.({ phase: "start", toolName: event.toolName, conversationId: conv.id });
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
case "tool_execution_end": {
|
|
959
|
+
const startedAt = conv.toolStartTimes.get(event.toolCallId);
|
|
960
|
+
conv.toolStartTimes.delete(event.toolCallId);
|
|
961
|
+
this.clearToolWatchdog(conv, event.toolCallId);
|
|
962
|
+
// Bash finished — wait briefly for background servers to bind their
|
|
963
|
+
// ports, then diff against the pre-run snapshot and record them.
|
|
964
|
+
if (event.toolName === "bash")
|
|
965
|
+
void this.bg.trackAfterBash();
|
|
966
|
+
const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
|
|
967
|
+
// 插件扩展点:工具结束执行(带耗时与错误标志)。
|
|
968
|
+
this.onToolEvent?.({
|
|
969
|
+
phase: "end",
|
|
970
|
+
toolName: event.toolName,
|
|
971
|
+
conversationId: conv.id,
|
|
972
|
+
...(durationMs !== undefined ? { durationMs } : {}),
|
|
973
|
+
isError: event.isError,
|
|
974
|
+
});
|
|
975
|
+
// The bash tool does not put its exit code in result.details — on
|
|
976
|
+
// failure it throws "Command exited with code N" and the agent
|
|
977
|
+
// wraps that into the error result text. Try details first (future
|
|
978
|
+
// tools / SDK changes), then parse the error text.
|
|
979
|
+
const details = event.result?.details;
|
|
980
|
+
let exitCode;
|
|
981
|
+
if (typeof details === "object" &&
|
|
982
|
+
details !== null &&
|
|
983
|
+
typeof details.exitCode === "number") {
|
|
984
|
+
exitCode = details.exitCode;
|
|
985
|
+
}
|
|
986
|
+
else if (event.isError) {
|
|
987
|
+
const content = event.result?.content;
|
|
988
|
+
const text = Array.isArray(content)
|
|
989
|
+
? content
|
|
990
|
+
.map((c) => (typeof c === "object" &&
|
|
991
|
+
c !== null &&
|
|
992
|
+
c.type === "text")
|
|
993
|
+
? (c.text ?? "")
|
|
994
|
+
: "")
|
|
995
|
+
.join("\n")
|
|
996
|
+
: "";
|
|
997
|
+
const m = text.match(/exited with code (\d+)/);
|
|
998
|
+
if (m)
|
|
999
|
+
exitCode = Number(m[1]);
|
|
1000
|
+
}
|
|
1001
|
+
this.emit({
|
|
1002
|
+
type: "tool_status",
|
|
1003
|
+
toolCallId: event.toolCallId,
|
|
1004
|
+
toolName: event.toolName,
|
|
1005
|
+
isError: event.isError,
|
|
1006
|
+
exitCode,
|
|
1007
|
+
durationMs,
|
|
1008
|
+
});
|
|
1009
|
+
break;
|
|
1010
|
+
}
|
|
1011
|
+
case "tool_execution_update": {
|
|
1012
|
+
const text = extractPartialText(event.partialResult);
|
|
1013
|
+
if (text) {
|
|
1014
|
+
this.emit({
|
|
1015
|
+
type: "tool_delta",
|
|
1016
|
+
conversationId: conv.id,
|
|
1017
|
+
seq: ++conv.deltaSeq,
|
|
1018
|
+
toolCallId: event.toolCallId,
|
|
1019
|
+
toolName: event.toolName,
|
|
1020
|
+
delta: text,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
case "queue_update":
|
|
1026
|
+
conv.queueSteering = [...event.steering];
|
|
1027
|
+
conv.queueFollowUp = [...event.followUp];
|
|
1028
|
+
break;
|
|
1029
|
+
// A run finished or a new entry was persisted — keep the session list fresh
|
|
1030
|
+
// (new chat + first message, completed turns, compaction, etc.).
|
|
1031
|
+
case "agent_end": {
|
|
1032
|
+
this.scheduleSessionsRefresh();
|
|
1033
|
+
// Manual interrupt (Stop button / abort): the last assistant message
|
|
1034
|
+
// carries stopReason "aborted". A half-finished run should NOT be
|
|
1035
|
+
// reviewed (it would fail and inject a revision, only to be stopped
|
|
1036
|
+
// again → an endless review loop). Clear the goal so the review loop
|
|
1037
|
+
// stops too, then let the user give a fresh instruction.
|
|
1038
|
+
const aborted = event.messages.some((m) => {
|
|
1039
|
+
const a = m;
|
|
1040
|
+
return a.role === "assistant" && a.stopReason === "aborted";
|
|
1041
|
+
});
|
|
1042
|
+
if (aborted) {
|
|
1043
|
+
const stopNotice = this.goalSvc.onAgentEnd(conv, true);
|
|
1044
|
+
if (stopNotice) {
|
|
1045
|
+
this.emit({ type: "notice", level: "warning", text: stopNotice });
|
|
1046
|
+
}
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
// Goal review hook lives in GoalService.onAgentEnd(conv, false).
|
|
1050
|
+
this.goalSvc.onAgentEnd(conv, false);
|
|
1051
|
+
// Deferred settings reload: settings (system prompt / skills /
|
|
1052
|
+
// extensions) changed while the run was streaming — applying now
|
|
1053
|
+
// would have torn down the in-flight run.
|
|
1054
|
+
if (this.settingsSvc.hasPendingReload() && !this.disposed) {
|
|
1055
|
+
this.settingsSvc.consumePendingReload();
|
|
1056
|
+
void this.applySettingsReload();
|
|
1057
|
+
}
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
case "entry_appended":
|
|
1061
|
+
this.scheduleSessionsRefresh();
|
|
1062
|
+
break;
|
|
1063
|
+
case "message_update": {
|
|
1064
|
+
// Live assistant-message increment, deliberately OUTSIDE the snapshot
|
|
1065
|
+
// channel: send() drops snapshots under backpressure (big sessions),
|
|
1066
|
+
// but this small message must always get through or the UI freezes on
|
|
1067
|
+
// stale state. Only the ACTIVE conversation streams to the browser —
|
|
1068
|
+
// background conversations would clobber the streaming view; their
|
|
1069
|
+
// state arrives via snapshot when switched to.
|
|
1070
|
+
if (conv.id !== this.conv.id)
|
|
1071
|
+
break;
|
|
1072
|
+
const ame = event.assistantMessageEvent;
|
|
1073
|
+
const m = event.message;
|
|
1074
|
+
this.lastDeltaAt = Date.now();
|
|
1075
|
+
this.emit({
|
|
1076
|
+
type: "message_delta",
|
|
1077
|
+
conversationId: conv.id,
|
|
1078
|
+
seq: ++conv.deltaSeq,
|
|
1079
|
+
// Must match serializeStreamingMessage()'s stable id so deltas
|
|
1080
|
+
// patch onto the snapshot's streamingMessage and reconcile.
|
|
1081
|
+
messageId: `stream-${m?.timestamp ?? 0}`,
|
|
1082
|
+
usage: (() => {
|
|
1083
|
+
try {
|
|
1084
|
+
const t = this.session.getSessionStats().tokens;
|
|
1085
|
+
return t ? { input: t.input, output: t.output, total: t.total } : null;
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
})(),
|
|
1091
|
+
// Strip `partial` (the cumulative message): re-serializing it per
|
|
1092
|
+
// token is exactly what we're trying to avoid. The next snapshot
|
|
1093
|
+
// carries the authoritative full message anyway.
|
|
1094
|
+
assistantMessageEvent: {
|
|
1095
|
+
type: ame.type,
|
|
1096
|
+
contentIndex: "contentIndex" in ame ? ame.contentIndex : undefined,
|
|
1097
|
+
delta: "delta" in ame ? ame.delta : undefined,
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
default:
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
// Snapshot checkpoint policy: deltas carry live rendering during streaming;
|
|
1106
|
+
// full snapshots are reconciliation checkpoints taken immediately at
|
|
1107
|
+
// run/tool boundaries and on a slow timer otherwise.
|
|
1108
|
+
if (event.type === "agent_end" || event.type === "tool_execution_end") {
|
|
1109
|
+
this.flushSnapshot();
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
this.scheduleSnapshot();
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
/** Debounced push of the persisted session list + open conversations. */
|
|
1116
|
+
scheduleSessionsRefresh() {
|
|
1117
|
+
if (this.sessionsTimer)
|
|
1118
|
+
return;
|
|
1119
|
+
this.sessionsTimer = setTimeout(() => {
|
|
1120
|
+
this.sessionsTimer = null;
|
|
1121
|
+
if (this.disposed)
|
|
1122
|
+
return;
|
|
1123
|
+
this.emitConversations();
|
|
1124
|
+
void this.pushSessions();
|
|
1125
|
+
}, 800);
|
|
1126
|
+
// pushSessions no-ops unless the client opted in via list_sessions.
|
|
1127
|
+
}
|
|
1128
|
+
/** Serialize a persisted message with a STABLE id + cached object reference. */
|
|
1129
|
+
serializeCached(m) {
|
|
1130
|
+
const conv = this.conv;
|
|
1131
|
+
// toolResult messages are keyed by toolCallId; everything else by
|
|
1132
|
+
// role+timestamp. A single prompt can emit several same-role messages
|
|
1133
|
+
// within the SAME millisecond (multiple attachment asides), so the
|
|
1134
|
+
// timestamp alone collides in the cache and only the first one renders
|
|
1135
|
+
// — append a cheap content fingerprint to keep them distinct while
|
|
1136
|
+
// staying stable across snapshots (content never changes once persisted).
|
|
1137
|
+
const key = m.role === "toolResult"
|
|
1138
|
+
? `t:${m.toolCallId}`
|
|
1139
|
+
: `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
|
|
1140
|
+
let n = conv.msgIds.get(key);
|
|
1141
|
+
if (n === undefined) {
|
|
1142
|
+
n = conv.nextMsgId++;
|
|
1143
|
+
conv.msgIds.set(key, n);
|
|
1144
|
+
}
|
|
1145
|
+
const cacheKey = `${key}#${n}`;
|
|
1146
|
+
const cached = conv.uiMessageCache.get(cacheKey);
|
|
1147
|
+
if (cached)
|
|
1148
|
+
return cached;
|
|
1149
|
+
// User-message id suffix is a 1-based count of user messages sharing
|
|
1150
|
+
// this timestamp (that's what resolveUserMessageEntryId() expects). n is
|
|
1151
|
+
// a global per-conversation counter across ALL roles, so it can't be
|
|
1152
|
+
// reused as the seq — otherwise editing anything but the first question
|
|
1153
|
+
// fails to resolve ("找不到要编辑的消息").
|
|
1154
|
+
let seq = n;
|
|
1155
|
+
if (m.role === "user") {
|
|
1156
|
+
const ts = m.timestamp ?? 0;
|
|
1157
|
+
seq = (conv.userSeqByTs.get(ts) ?? 0) + 1;
|
|
1158
|
+
conv.userSeqByTs.set(ts, seq);
|
|
1159
|
+
}
|
|
1160
|
+
const msg = serializeMessage(m, seq);
|
|
1161
|
+
if (msg) {
|
|
1162
|
+
conv.uiMessageCache.set(cacheKey, msg);
|
|
1163
|
+
// Bound the cache (marathon sessions otherwise grow without limit;
|
|
1164
|
+
// single messages can reach TEXT_CAP = 200K chars). Map iteration is
|
|
1165
|
+
// insertion order, so dropping from the front evicts the oldest —
|
|
1166
|
+
// recent messages (the ones every snapshot touches) always survive.
|
|
1167
|
+
// Safe: a miss just recomputes an identical object on next access.
|
|
1168
|
+
let excess = conv.uiMessageCache.size - UI_MESSAGE_CACHE_CAP;
|
|
1169
|
+
while (excess-- > 0) {
|
|
1170
|
+
const oldest = conv.uiMessageCache.keys().next().value;
|
|
1171
|
+
if (oldest === undefined)
|
|
1172
|
+
break;
|
|
1173
|
+
conv.uiMessageCache.delete(oldest);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return msg;
|
|
1177
|
+
}
|
|
1178
|
+
/** Current messages array (with the existing sig-reuse optimization).
|
|
1179
|
+
* Element objects are reference-stable (serializeCached cache), which is
|
|
1180
|
+
* what lets emitSnapshotNow detect append-only growth via identity walk. */
|
|
1181
|
+
currentMessages() {
|
|
1182
|
+
const conv = this.conv;
|
|
1183
|
+
const rawMessages = conv.session.agent.state.messages
|
|
1184
|
+
.map((m) => this.serializeCached(m))
|
|
1185
|
+
.filter((m) => m !== null);
|
|
1186
|
+
// Reuse the previous array when nothing changed: the element objects are
|
|
1187
|
+
// cached (reference-stable) anyway, and a stable array reference lets the
|
|
1188
|
+
// frontend memoize derived maps instead of rebuilding them every 60ms.
|
|
1189
|
+
const sig = rawMessages.map((m) => m.id).join("\u0001");
|
|
1190
|
+
const messages = conv.lastMessagesSig === sig ? conv.lastMessagesArray : rawMessages;
|
|
1191
|
+
conv.lastMessagesSig = sig;
|
|
1192
|
+
conv.lastMessagesArray = rawMessages;
|
|
1193
|
+
return messages;
|
|
1194
|
+
}
|
|
1195
|
+
/** Build every UiState field EXCEPT messages (the expensive part). */
|
|
1196
|
+
buildLightState(rev) {
|
|
1197
|
+
const conv = this.conv;
|
|
1198
|
+
const state = conv.session.agent.state;
|
|
1199
|
+
const model = state.model;
|
|
1200
|
+
let stats = {
|
|
1201
|
+
totalMessages: 0,
|
|
1202
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
1203
|
+
cost: 0,
|
|
1204
|
+
contextUsage: { tokens: null, contextWindow: 0, percent: null },
|
|
1205
|
+
};
|
|
1206
|
+
try {
|
|
1207
|
+
const s = this.session.getSessionStats();
|
|
1208
|
+
stats = {
|
|
1209
|
+
totalMessages: s.totalMessages,
|
|
1210
|
+
tokens: s.tokens,
|
|
1211
|
+
cost: s.cost,
|
|
1212
|
+
contextUsage: s.contextUsage
|
|
1213
|
+
? {
|
|
1214
|
+
tokens: s.contextUsage.tokens,
|
|
1215
|
+
contextWindow: s.contextUsage.contextWindow,
|
|
1216
|
+
percent: s.contextUsage.percent,
|
|
1217
|
+
}
|
|
1218
|
+
: stats.contextUsage,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
// stats are best-effort
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
clientId: this.clientId,
|
|
1226
|
+
cwd: this.cwd,
|
|
1227
|
+
sessionId: this.session.sessionId,
|
|
1228
|
+
sessionFile: this.session.sessionFile,
|
|
1229
|
+
conversationId: this.activeId,
|
|
1230
|
+
rev,
|
|
1231
|
+
// The in-progress assistant message lives in state.streamingMessage
|
|
1232
|
+
// (the SDK only pushes it into state.messages at message_end). Surfacing
|
|
1233
|
+
// it here is what makes thinking + text stream into the browser at
|
|
1234
|
+
// ~60ms granularity instead of appearing only when the turn finishes.
|
|
1235
|
+
streamingMessage: state.streamingMessage
|
|
1236
|
+
? serializeStreamingMessage(state.streamingMessage)
|
|
1237
|
+
: null,
|
|
1238
|
+
isStreaming: this.session.isStreaming,
|
|
1239
|
+
model: model
|
|
1240
|
+
? {
|
|
1241
|
+
id: model.id,
|
|
1242
|
+
name: model.name,
|
|
1243
|
+
provider: model.provider,
|
|
1244
|
+
vision: model.input?.includes("image") ?? false,
|
|
1245
|
+
}
|
|
1246
|
+
: null,
|
|
1247
|
+
thinkingLevel: state.thinkingLevel,
|
|
1248
|
+
// Only the levels the current model actually supports — the SDK clamps
|
|
1249
|
+
// anything else, so the UI must not offer (or must disable) the rest.
|
|
1250
|
+
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
|
|
1251
|
+
queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
|
|
1252
|
+
errorMessage: state.errorMessage,
|
|
1253
|
+
tools: state.tools.map((t) => t.name),
|
|
1254
|
+
version: ++this.version,
|
|
1255
|
+
piConfigured: this.isPiConfigured(),
|
|
1256
|
+
piAgentInstalled: this.isPiCliInstalled(),
|
|
1257
|
+
stats,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
/** Emit one snapshot update — incremental when possible, full otherwise.
|
|
1261
|
+
*
|
|
1262
|
+
* Persisted messages are content-immutable with reference-stable objects
|
|
1263
|
+
* (serializeCached), so an IDENTITY WALK over the previous array detects
|
|
1264
|
+
* append-only growth in O(n) pointer compares. Appends travel as
|
|
1265
|
+
* snapshot_delta carrying only the new tail + light fields; any mid-array
|
|
1266
|
+
* change/truncation (switch session, edit fork, compaction) or a forced
|
|
1267
|
+
* resync falls back to a full snapshot. The 10MB-stringify-per-checkpoint
|
|
1268
|
+
* cost of big sessions collapses to a few hundred bytes for the common
|
|
1269
|
+
* "nothing but stats/version changed" checkpoint. */
|
|
1270
|
+
emitSnapshotNow(forceFull = false) {
|
|
1271
|
+
if (this.disposed)
|
|
1272
|
+
return;
|
|
1273
|
+
const cur = this.currentMessages();
|
|
1274
|
+
const prev = this.emittedMessages;
|
|
1275
|
+
let incremental = !forceFull &&
|
|
1276
|
+
prev !== null &&
|
|
1277
|
+
this.emittedConvId === this.activeId &&
|
|
1278
|
+
prev.length <= cur.length;
|
|
1279
|
+
if (incremental && prev) {
|
|
1280
|
+
for (let i = 0; i < prev.length; i++) {
|
|
1281
|
+
if (prev[i] !== cur[i]) {
|
|
1282
|
+
incremental = false;
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
const rev = ++this.snapRev;
|
|
1288
|
+
if (incremental && prev) {
|
|
1289
|
+
const baseRev = this.emittedRev;
|
|
1290
|
+
this.emittedMessages = cur;
|
|
1291
|
+
this.emittedConvId = this.activeId;
|
|
1292
|
+
this.emittedRev = rev;
|
|
1293
|
+
this.emit({
|
|
1294
|
+
type: "snapshot_delta",
|
|
1295
|
+
conversationId: this.activeId,
|
|
1296
|
+
rev,
|
|
1297
|
+
baseRev,
|
|
1298
|
+
appended: cur.slice(prev.length),
|
|
1299
|
+
state: this.buildLightState(rev),
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
this.emittedMessages = cur;
|
|
1304
|
+
this.emittedConvId = this.activeId;
|
|
1305
|
+
this.emittedRev = rev;
|
|
1306
|
+
this.emit({
|
|
1307
|
+
type: "snapshot",
|
|
1308
|
+
state: { ...this.buildLightState(rev), messages: cur },
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
/** Resolve a browser-bridged dialog (select/confirm/input) for this session. */
|
|
1313
|
+
resolveDialog(id, value) {
|
|
1314
|
+
this.webUi.resolveDialog(id, value);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Whether the pi agent config looks ready: the agent dir exists and
|
|
1318
|
+
* auth.json has at least one provider credential. Cached for 2s.
|
|
1319
|
+
*/
|
|
1320
|
+
isPiConfigured() {
|
|
1321
|
+
const now = Date.now();
|
|
1322
|
+
const cached = this.piCheckCache;
|
|
1323
|
+
if (cached && now - cached.at < 2000)
|
|
1324
|
+
return cached.configured;
|
|
1325
|
+
let configured = false;
|
|
1326
|
+
try {
|
|
1327
|
+
const authPath = join(this.agentDir, "auth.json");
|
|
1328
|
+
if (existsSync(authPath)) {
|
|
1329
|
+
const data = JSON.parse(readFileSync(authPath, "utf8"));
|
|
1330
|
+
configured =
|
|
1331
|
+
typeof data === "object" &&
|
|
1332
|
+
data !== null &&
|
|
1333
|
+
Object.keys(data).length > 0;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
catch {
|
|
1337
|
+
configured = false;
|
|
1338
|
+
}
|
|
1339
|
+
this.piCheckCache = { at: now, configured };
|
|
1340
|
+
return configured;
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Whether the pi CLI binary is installed and runnable (`pi --version`
|
|
1344
|
+
* probe). Cached machine-wide (same binary for every client) for 10s —
|
|
1345
|
+
* the check is only rerun after install or when the cache expires.
|
|
1346
|
+
*/
|
|
1347
|
+
static piCliProbe = null;
|
|
1348
|
+
static PI_CLI_PROBE_TTL_MS = 10_000;
|
|
1349
|
+
isPiCliInstalled() {
|
|
1350
|
+
const now = Date.now();
|
|
1351
|
+
const cached = ClientSession.piCliProbe;
|
|
1352
|
+
if (cached && now - cached.at < ClientSession.PI_CLI_PROBE_TTL_MS)
|
|
1353
|
+
return cached.installed;
|
|
1354
|
+
let installed = false;
|
|
1355
|
+
try {
|
|
1356
|
+
const res = spawnSync("pi", ["--version"], {
|
|
1357
|
+
timeout: 5000,
|
|
1358
|
+
stdio: "ignore",
|
|
1359
|
+
// Windows: `pi` resolves to a pi.cmd shim — spawnSync can only
|
|
1360
|
+
// exec those through a shell (else ENOENT).
|
|
1361
|
+
shell: process.platform === "win32",
|
|
1362
|
+
});
|
|
1363
|
+
installed = !res.error && res.status === 0;
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
installed = false;
|
|
1367
|
+
}
|
|
1368
|
+
ClientSession.piCliProbe = { at: now, installed };
|
|
1369
|
+
return installed;
|
|
1370
|
+
}
|
|
1371
|
+
static invalidatePiCliProbe() {
|
|
1372
|
+
ClientSession.piCliProbe = null;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Run a command async, collecting stdout+stderr; kills on timeout.
|
|
1376
|
+
* Never throws / never crashes the server: spawn errors (ENOENT etc.)
|
|
1377
|
+
* resolve with code -1 so callers can report them as notices.
|
|
1378
|
+
*/
|
|
1379
|
+
runAsync(cmd, args, timeoutMs, cwd) {
|
|
1380
|
+
return new Promise((resolve) => {
|
|
1381
|
+
let p;
|
|
1382
|
+
try {
|
|
1383
|
+
p = spawn(cmd, args, {
|
|
1384
|
+
...(cwd ? { cwd } : {}),
|
|
1385
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1386
|
+
// Windows: npm and friends are .cmd shims — Node can only exec
|
|
1387
|
+
// them through the shell (otherwise spawn npm → ENOENT).
|
|
1388
|
+
shell: process.platform === "win32",
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
catch (err) {
|
|
1392
|
+
resolve({ code: -1, out: String(err) });
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
let out = "";
|
|
1396
|
+
let settled = false;
|
|
1397
|
+
const done = (code, text) => {
|
|
1398
|
+
if (settled)
|
|
1399
|
+
return;
|
|
1400
|
+
settled = true;
|
|
1401
|
+
clearTimeout(t);
|
|
1402
|
+
resolve({ code, out: text ?? out });
|
|
1403
|
+
};
|
|
1404
|
+
const t = setTimeout(() => p.kill(), timeoutMs);
|
|
1405
|
+
p.stdout?.on("data", (d) => (out += d.toString()));
|
|
1406
|
+
p.stderr?.on("data", (d) => (out += d.toString()));
|
|
1407
|
+
p.on("error", (err) => done(-1, String(err)));
|
|
1408
|
+
p.on("close", (code) => done(code));
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Auto-install the pi agent: ensure the config dir exists and install the
|
|
1413
|
+
* pi CLI globally (npm i -g). Auth is configured afterwards via the API key
|
|
1414
|
+
* form or by running `pi` in a terminal.
|
|
1415
|
+
*/
|
|
1416
|
+
/**
|
|
1417
|
+
* Version of the RUNNING pi-web-ui package (read from its own package.json,
|
|
1418
|
+
* resolved from this compiled module: <pkg>/dist/server → <pkg>).
|
|
1419
|
+
*/
|
|
1420
|
+
static currentAppVersion() {
|
|
1421
|
+
try {
|
|
1422
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1423
|
+
const pkgRoot = resolve(here, "..", "..");
|
|
1424
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
|
|
1425
|
+
return pkg.version ?? "0.0.0";
|
|
1426
|
+
}
|
|
1427
|
+
catch {
|
|
1428
|
+
return "0.0.0";
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
/** Simple numeric semver compare: >0 means a newer than b. */
|
|
1432
|
+
static compareVersions(a, b) {
|
|
1433
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
1434
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
1435
|
+
for (let i = 0; i < 3; i++) {
|
|
1436
|
+
const x = pa[i] ?? 0;
|
|
1437
|
+
const y = pb[i] ?? 0;
|
|
1438
|
+
if (x !== y)
|
|
1439
|
+
return x - y;
|
|
1440
|
+
}
|
|
1441
|
+
return 0;
|
|
1442
|
+
}
|
|
1443
|
+
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
1444
|
+
onQuit = undefined;
|
|
1445
|
+
/** 本客户端成功切换工作区(set_cwd)后触发,参数为新绝对路径。
|
|
1446
|
+
* attach 时由 AgentService 接到全局 onClientCwdChanged —— 编辑器等
|
|
1447
|
+
* 工作区跟随型插件借此把根目录切到用户当前项目。 */
|
|
1448
|
+
onCwdChanged = undefined;
|
|
1449
|
+
/** Ask the npm registry for the latest pi-web-ui version and report it. */
|
|
1450
|
+
async checkUpdate() {
|
|
1451
|
+
const current = ClientSession.currentAppVersion();
|
|
1452
|
+
try {
|
|
1453
|
+
// Fetch the full package doc (not /latest): it carries the per-version
|
|
1454
|
+
// publish timestamps so the UI can hint when a version was JUST
|
|
1455
|
+
// published and the registry/CDN caches may not have caught up yet.
|
|
1456
|
+
const res = await fetch("https://registry.npmjs.org/pi-web-ui", {
|
|
1457
|
+
signal: AbortSignal.timeout(8_000),
|
|
1458
|
+
});
|
|
1459
|
+
if (!res.ok)
|
|
1460
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1461
|
+
const data = (await res.json());
|
|
1462
|
+
const latest = data["dist-tags"]?.latest ?? null;
|
|
1463
|
+
const latestPublishedAt = latest && data.time ? (data.time[latest] ?? null) : null;
|
|
1464
|
+
const upToDate = latest === null || ClientSession.compareVersions(current, latest) >= 0;
|
|
1465
|
+
this.emit({
|
|
1466
|
+
type: "update_status",
|
|
1467
|
+
current,
|
|
1468
|
+
latest,
|
|
1469
|
+
latestPublishedAt,
|
|
1470
|
+
upToDate,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
catch (err) {
|
|
1474
|
+
this.emit({
|
|
1475
|
+
type: "update_status",
|
|
1476
|
+
current,
|
|
1477
|
+
latest: null,
|
|
1478
|
+
latestPublishedAt: null,
|
|
1479
|
+
upToDate: false,
|
|
1480
|
+
error: `检查更新失败:${err.message}`,
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
async installPiAgent() {
|
|
1485
|
+
try {
|
|
1486
|
+
mkdirSync(this.agentDir, { recursive: true });
|
|
1487
|
+
this.emit({
|
|
1488
|
+
type: "notice",
|
|
1489
|
+
level: "info",
|
|
1490
|
+
text: "正在安装 pi agent CLI(npm i -g @earendil-works/pi-coding-agent)…",
|
|
1491
|
+
});
|
|
1492
|
+
const { code, out } = await this.runAsync("npm", ["i", "-g", "@earendil-works/pi-coding-agent"], 180_000);
|
|
1493
|
+
if (code === 0) {
|
|
1494
|
+
this.emit({
|
|
1495
|
+
type: "notice",
|
|
1496
|
+
level: "info",
|
|
1497
|
+
text: "✅ pi agent CLI 安装完成。填入 API 密钥即可开始,或在终端运行 pi 完成登录。",
|
|
1498
|
+
});
|
|
1499
|
+
this.emit({ type: "install_result", ok: true, detail: "" });
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
this.emit({
|
|
1503
|
+
type: "notice",
|
|
1504
|
+
level: "error",
|
|
1505
|
+
text: `pi agent 安装失败(${code ?? "timeout"}):${out.slice(0, 400)}`,
|
|
1506
|
+
});
|
|
1507
|
+
this.emit({
|
|
1508
|
+
type: "install_result",
|
|
1509
|
+
ok: false,
|
|
1510
|
+
detail: out.slice(0, 600),
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
this.emit({
|
|
1516
|
+
type: "notice",
|
|
1517
|
+
level: "error",
|
|
1518
|
+
text: `pi agent 安装失败:${err.message}`,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
// The CLI may just have landed on PATH (or the install may have failed) —
|
|
1522
|
+
// drop the probe cache so the next snapshot re-checks.
|
|
1523
|
+
ClientSession.invalidatePiCliProbe();
|
|
1524
|
+
this.flushSnapshot();
|
|
1525
|
+
}
|
|
1526
|
+
/** Send a snapshot immediately (cancels any pending throttled one).
|
|
1527
|
+
* forceFull skips the incremental path — used by get_state so a (re)
|
|
1528
|
+
* connecting or desynced client always receives an authoritative full
|
|
1529
|
+
* state it can rebuild from. */
|
|
1530
|
+
flushSnapshot(forceFull = false) {
|
|
1531
|
+
if (this.snapshotTimer) {
|
|
1532
|
+
clearTimeout(this.snapshotTimer);
|
|
1533
|
+
this.snapshotTimer = null;
|
|
1534
|
+
}
|
|
1535
|
+
this.emitSnapshotNow(forceFull);
|
|
1536
|
+
}
|
|
1537
|
+
scheduleSnapshot() {
|
|
1538
|
+
if (this.snapshotTimer || this.disposed)
|
|
1539
|
+
return;
|
|
1540
|
+
// During active streaming the deltas carry live rendering — full snapshots
|
|
1541
|
+
// are just a periodic reconciliation checkpoint, so send them far less
|
|
1542
|
+
// often (they serialize the whole session; big sessions made this path OOM).
|
|
1543
|
+
const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS
|
|
1544
|
+
? STREAMING_SNAPSHOT_INTERVAL_MS
|
|
1545
|
+
: SNAPSHOT_INTERVAL_MS;
|
|
1546
|
+
this.snapshotTimer = setTimeout(() => {
|
|
1547
|
+
this.snapshotTimer = null;
|
|
1548
|
+
this.emitSnapshotNow();
|
|
1549
|
+
}, interval);
|
|
1550
|
+
}
|
|
1551
|
+
/** Slash-command catalog + native command execution — 自包含模块,见
|
|
1552
|
+
* slash-commands.ts(内置命令拦截 + 扩展/模板/技能目录推送)。 */
|
|
1553
|
+
slash = new SlashCommandsService({
|
|
1554
|
+
emit: (msg) => this.emit(msg),
|
|
1555
|
+
cwd: () => this.cwd,
|
|
1556
|
+
getSession: () => this.session,
|
|
1557
|
+
newChat: () => this.newChat(),
|
|
1558
|
+
setModel: (id) => this.setModel(id),
|
|
1559
|
+
setCwd: (path) => this.setCwd(path),
|
|
1560
|
+
setThinking: (level) => this.setThinking(level),
|
|
1561
|
+
refreshSessions: () => this.refreshSessions(),
|
|
1562
|
+
afterReload: () => this.applyTerminalToolGating(this.session),
|
|
1563
|
+
pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
|
|
1564
|
+
execPluginCommand: async (name, args) => {
|
|
1565
|
+
const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
|
|
1566
|
+
if (!def)
|
|
1567
|
+
return false;
|
|
1568
|
+
try {
|
|
1569
|
+
const result = await def.run(args, { clientId: this.clientId });
|
|
1570
|
+
// 字符串返回值 → 通知条回显给发起人;富展示用 broadcast/sendTo。
|
|
1571
|
+
if (typeof result === "string" && result.trim()) {
|
|
1572
|
+
this.emit({ type: "notice", level: "info", text: result });
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
catch (err) {
|
|
1576
|
+
this.emit({
|
|
1577
|
+
type: "notice",
|
|
1578
|
+
level: "error",
|
|
1579
|
+
text: `插件命令 /${name} 执行失败:${err.message}`,
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
return true;
|
|
1583
|
+
},
|
|
1584
|
+
onQuit: () => this.onQuit?.() ?? false,
|
|
1585
|
+
});
|
|
1586
|
+
/** Catalog push — index.ts get_commands / attach / cwd 切换等都会调用。 */
|
|
1587
|
+
pushSlashCommands() {
|
|
1588
|
+
return this.slash.push();
|
|
1589
|
+
}
|
|
1590
|
+
/** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
|
|
1591
|
+
modelAdmin;
|
|
1592
|
+
/** Persist an api-key credential for a provider (auth.json). */
|
|
1593
|
+
setProviderApiKey(provider, apiKey) {
|
|
1594
|
+
return this.modelAdmin.setProviderApiKey(provider, apiKey);
|
|
1595
|
+
}
|
|
1596
|
+
clearProviderApiKey(provider) {
|
|
1597
|
+
return this.modelAdmin.clearProviderApiKey(provider);
|
|
1598
|
+
}
|
|
1599
|
+
listProviders() {
|
|
1600
|
+
return this.modelAdmin.listProviders();
|
|
1601
|
+
}
|
|
1602
|
+
listModelsConfig() {
|
|
1603
|
+
return this.modelAdmin.listModelsConfig();
|
|
1604
|
+
}
|
|
1605
|
+
fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
|
|
1606
|
+
return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
|
|
1607
|
+
}
|
|
1608
|
+
refreshProviderModels(providerId, reqId) {
|
|
1609
|
+
return this.modelAdmin.refreshProviderModels(providerId, reqId);
|
|
1610
|
+
}
|
|
1611
|
+
/** Copy a built-in provider into an editable custom-provider draft
|
|
1612
|
+
* (clone_provider_result) — lets the user run a second API key without
|
|
1613
|
+
* overwriting the built-in one. */
|
|
1614
|
+
cloneProvider(providerId, reqId) {
|
|
1615
|
+
return this.modelAdmin.cloneProvider(providerId, reqId);
|
|
1616
|
+
}
|
|
1617
|
+
saveModelConfig(providerId, config) {
|
|
1618
|
+
return this.modelAdmin.saveModelConfig(providerId, config);
|
|
1619
|
+
}
|
|
1620
|
+
deleteModelConfig(providerId) {
|
|
1621
|
+
return this.modelAdmin.deleteModelConfig(providerId);
|
|
1622
|
+
}
|
|
1623
|
+
// ---------------------------------------------------------------------------
|
|
1624
|
+
// Settings (system prompt / skills / extensions / presets)
|
|
1625
|
+
// ---------------------------------------------------------------------------
|
|
1626
|
+
/** Push the full settings state (current settings + loaded skills/extensions
|
|
1627
|
+
* with enabled flags + saved presets). Pushed on attach and after every
|
|
1628
|
+
* settings change. */
|
|
1629
|
+
pushSettings() {
|
|
1630
|
+
this.settingsSvc.push();
|
|
1631
|
+
}
|
|
1632
|
+
/** Extensions/skills changed externally (e.g. `pi remove` finished in the
|
|
1633
|
+
* terminal): re-run session.reload() and re-push state. Streaming-safe —
|
|
1634
|
+
* deferred to agent_end, same as settings reloads. */
|
|
1635
|
+
async reloadExtensions() {
|
|
1636
|
+
return this.settingsSvc.applyRuntime();
|
|
1637
|
+
}
|
|
1638
|
+
/** Persist + apply a partial settings update (prompt text/mode, toggles). */
|
|
1639
|
+
async setSettings(partial) {
|
|
1640
|
+
await this.settingsSvc.set(partial);
|
|
1641
|
+
}
|
|
1642
|
+
/** Save the CURRENT settings as a named preset (overwrites if exists). */
|
|
1643
|
+
async savePreset(name) {
|
|
1644
|
+
return this.settingsSvc.savePreset(name);
|
|
1645
|
+
}
|
|
1646
|
+
/** Replace the current settings with the named preset and apply it. */
|
|
1647
|
+
async applyPreset(name) {
|
|
1648
|
+
return this.settingsSvc.applyPreset(name);
|
|
1649
|
+
}
|
|
1650
|
+
/** Remove a named preset. */
|
|
1651
|
+
async deletePreset(name) {
|
|
1652
|
+
return this.settingsSvc.deletePreset(name);
|
|
1653
|
+
}
|
|
1654
|
+
/** Make settings effective in the running runtime(流式中则延迟到 agent_end)。 */
|
|
1655
|
+
async applyRuntimeSettings() {
|
|
1656
|
+
return this.settingsSvc.applyRuntime();
|
|
1657
|
+
}
|
|
1658
|
+
/** 把终端工具开关应用到 session 的活跃工具集:关闭时从活跃集中剔除
|
|
1659
|
+
* terminal_*(工具仍留在注册表,重开时可直接加回)。session.reload() 与新
|
|
1660
|
+
* 会话创建都会把 custom 工具加回活跃集,所以这两条路径之后都要重放本方法。 */
|
|
1661
|
+
applyTerminalToolGating(session) {
|
|
1662
|
+
try {
|
|
1663
|
+
const enabled = this.settingsSvc.current.terminalToolsEnabled !== false;
|
|
1664
|
+
const names = new Set(session.getActiveToolNames());
|
|
1665
|
+
for (const n of TERMINAL_TOOL_NAMES) {
|
|
1666
|
+
if (enabled)
|
|
1667
|
+
names.add(n);
|
|
1668
|
+
else
|
|
1669
|
+
names.delete(n);
|
|
1670
|
+
}
|
|
1671
|
+
session.setActiveToolsByName([...names]);
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
// Session 未就绪——下次创建/reload 会再应用。
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除)。
|
|
1678
|
+
* 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
|
|
1679
|
+
syncPluginTools(session) {
|
|
1680
|
+
try {
|
|
1681
|
+
const defs = (this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition);
|
|
1682
|
+
const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
|
|
1683
|
+
if (next)
|
|
1684
|
+
this.appliedPluginToolNames = new Set(next);
|
|
1685
|
+
}
|
|
1686
|
+
catch (err) {
|
|
1687
|
+
console.error("[plugins] sync tools to session failed:", err);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
/** index.ts 经 pluginMgr.onAgentToolsChanged 触发:把插件 AI 工具推入全部会话。 */
|
|
1691
|
+
refreshPluginTools() {
|
|
1692
|
+
for (const conv of this.convs.values())
|
|
1693
|
+
this.syncPluginTools(conv.session);
|
|
1694
|
+
}
|
|
1695
|
+
async applySettingsReload() {
|
|
1696
|
+
// 兼容旧入口:reload + 刷目录在宿主回调里完成
|
|
1697
|
+
return this.settingsSvc.applyRuntime();
|
|
1698
|
+
}
|
|
1699
|
+
// ---------------------------------------------------------------------------
|
|
1700
|
+
// Commands
|
|
1701
|
+
// ---------------------------------------------------------------------------
|
|
1702
|
+
/** True when the service is draining (quiesced): emits a rejection notice
|
|
1703
|
+
* and returns true. Guards every NEW-work entry point (prompt / new chat /
|
|
1704
|
+
* edit-resend / session resume / goal wizard) — existing runs keep going.
|
|
1705
|
+
* Called BEFORE any LLM/token work starts so quiesce is a hard admission
|
|
1706
|
+
* gate, not a best-effort hint. */
|
|
1707
|
+
quiesceBlocked() {
|
|
1708
|
+
if (!this.isQuiesced())
|
|
1709
|
+
return false;
|
|
1710
|
+
this.emit({
|
|
1711
|
+
type: "notice",
|
|
1712
|
+
level: "error",
|
|
1713
|
+
text: "服务器正在排空存量工作(quiesce),已拒绝新的对话/消息/编辑。存量运行会继续跑完;用 pi-web-ui server unquiesce 可恢复。",
|
|
1714
|
+
});
|
|
1715
|
+
this.flushSnapshot();
|
|
1716
|
+
return true;
|
|
1717
|
+
}
|
|
1718
|
+
/** Conversations with an in-flight run — active work for quiesce status. */
|
|
1719
|
+
activeConversations() {
|
|
1720
|
+
let n = 0;
|
|
1721
|
+
for (const c of this.convs.values()) {
|
|
1722
|
+
try {
|
|
1723
|
+
if (c.session.isStreaming)
|
|
1724
|
+
n += 1;
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
// session being replaced — not running
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return n;
|
|
1731
|
+
}
|
|
1732
|
+
/** Messages queued in the SDK (steer + follow-up) — pending work for
|
|
1733
|
+
* quiesce status. Quiesce refuses to add more, so this only drains. */
|
|
1734
|
+
pendingMessages() {
|
|
1735
|
+
let n = 0;
|
|
1736
|
+
for (const c of this.convs.values())
|
|
1737
|
+
n += c.queueFollowUp.length + c.queueSteering.length;
|
|
1738
|
+
return n;
|
|
1739
|
+
}
|
|
1740
|
+
async prompt(text, attachments,
|
|
1741
|
+
/**
|
|
1742
|
+
* true = followUp: while streaming, queue the prompt and deliver it only
|
|
1743
|
+
* after the WHOLE run finishes (补充 button — "AI 生成结束才发送").
|
|
1744
|
+
* false/undefined = steer: the pi CLI Enter semantic — injected right
|
|
1745
|
+
* after the current turn settles, skipping remaining planned tool calls.
|
|
1746
|
+
*/
|
|
1747
|
+
queue = false) {
|
|
1748
|
+
try {
|
|
1749
|
+
const s = this.session;
|
|
1750
|
+
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
1751
|
+
// never reach the SDK. Extension / skill / template commands fall
|
|
1752
|
+
// through — AgentSession.prompt() handles those itself.
|
|
1753
|
+
const slash = parseSlash(text);
|
|
1754
|
+
if (slash && (await this.slash.exec(slash.name, slash.args))) {
|
|
1755
|
+
this.flushSnapshot();
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
// Native commands above are pure config tweaks (no tokens) — allow them
|
|
1759
|
+
// even while quiesced. Everything that reaches the SDK is NEW work and
|
|
1760
|
+
// is refused until admission reopens.
|
|
1761
|
+
if (this.quiesceBlocked())
|
|
1762
|
+
return;
|
|
1763
|
+
// Attach files as independent nextTurn context messages (asides) so the
|
|
1764
|
+
// user message stays clean; they render as separate attachment cards.
|
|
1765
|
+
const asides = await buildAttachmentMessages({
|
|
1766
|
+
cwd: this.cwd,
|
|
1767
|
+
clientId: this.clientId,
|
|
1768
|
+
emit: (msg) => this.emit(msg),
|
|
1769
|
+
settings: this.settingsSvc.current,
|
|
1770
|
+
session: this.session,
|
|
1771
|
+
}, attachments);
|
|
1772
|
+
for (const aside of asides) {
|
|
1773
|
+
await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
|
|
1774
|
+
}
|
|
1775
|
+
if (s.isStreaming) {
|
|
1776
|
+
// queue=true (补充 button) → followUp: the message is delivered only
|
|
1777
|
+
// after the whole run finishes — the agent finishes what it started,
|
|
1778
|
+
// then responds to the queued message. queue=false/undefined
|
|
1779
|
+
// (plain Enter) → steer: interrupts the current run — the message
|
|
1780
|
+
// is delivered right after the current assistant turn settles
|
|
1781
|
+
// (remaining planned tool calls are skipped) and the agent
|
|
1782
|
+
// immediately responds to it. This is the pi CLI
|
|
1783
|
+
// Enter-during-streaming semantic (docs/usage: Enter queues a
|
|
1784
|
+
// steering message); followUp would wait for the whole run
|
|
1785
|
+
// to finish, which users perceive as ordinary queueing.
|
|
1786
|
+
await s.prompt(text, {
|
|
1787
|
+
streamingBehavior: queue ? "followUp" : "steer",
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
else {
|
|
1791
|
+
await s.prompt(text);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
catch (err) {
|
|
1795
|
+
this.emit({
|
|
1796
|
+
type: "notice",
|
|
1797
|
+
level: "error",
|
|
1798
|
+
text: `提示发送失败:${err.message}`,
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
// Name the conversation after its first user prompt.
|
|
1802
|
+
const conv = this.conv;
|
|
1803
|
+
if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
|
|
1804
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
1805
|
+
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
1806
|
+
this.emitConversations();
|
|
1807
|
+
}
|
|
1808
|
+
// The active conversation has been continued since it was opened — it
|
|
1809
|
+
// must not be dismissed when the user switches away. (Also bumps the
|
|
1810
|
+
// per-project "most recently active" order used by set_cwd.)
|
|
1811
|
+
conv.promptedSinceActive = true;
|
|
1812
|
+
conv.lastActiveAt = Date.now();
|
|
1813
|
+
// Fresh run — restart the stall watchdog window.
|
|
1814
|
+
conv.lastSdkEventAt = Date.now();
|
|
1815
|
+
conv.stallNoticed = false;
|
|
1816
|
+
this.flushSnapshot();
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Turn attached files into custom-message payloads.
|
|
1820
|
+
*
|
|
1821
|
+
* Text files are size-aware: small files are inlined into the message so the
|
|
1822
|
+
* model sees them immediately; large files are passed as a <file path="...">
|
|
1823
|
+
* reference and the model reads them on demand with its read tool (which has
|
|
1824
|
+
* built-in truncation). Images are always passed as image content. Mode
|
|
1825
|
+
* "lines" inlines only a 1-based inclusive line range of the file. Raw
|
|
1826
|
+
* pasted/dropped/uploaded images (attachment.imageData) skip the workspace
|
|
1827
|
+
* path entirely and go straight to the model as image content. Raw uploaded
|
|
1828
|
+
* files (attachment.fileData) are persisted under <dataDir>/uploads/ and
|
|
1829
|
+
* attached as absolute-path references (small text ones are inlined).
|
|
1830
|
+
*/
|
|
1831
|
+
/**
|
|
1832
|
+
* Hard-abort the running agent (Stop button / global 中断). Tries
|
|
1833
|
+
* session.abort() first; if the run is not idle within
|
|
1834
|
+
* HARD_ABORT_TIMEOUT_MS (model stream ignoring the abort signal), the
|
|
1835
|
+
* conversation's runtime is force-disposed and recreated from the last
|
|
1836
|
+
* persisted session so the chat ALWAYS comes back usable — never stuck
|
|
1837
|
+
* overnight. The notice fires only on the forced-reset path.
|
|
1838
|
+
*/
|
|
1839
|
+
async abort() {
|
|
1840
|
+
// 只停止智能体运行本身;AI 在后台启动的服务由「后台任务」面板单独
|
|
1841
|
+
// 管理(可逐个停止或全部关闭),不会在停止对话时被连带杀掉。
|
|
1842
|
+
await this.interruptRun(this.conv, "已停止");
|
|
1843
|
+
this.flushSnapshot();
|
|
1844
|
+
}
|
|
1845
|
+
/** Re-push the current list on request (panel opened); prunes dead entries first. */
|
|
1846
|
+
async listBgServers() {
|
|
1847
|
+
await this.bg.listAndPush();
|
|
1848
|
+
}
|
|
1849
|
+
/** 插件任务集合变化时由宿主调用:重推一次 bg_servers(含插件任务)。 */
|
|
1850
|
+
refreshBgTasks() {
|
|
1851
|
+
this.bg.push();
|
|
1852
|
+
}
|
|
1853
|
+
/** 插件设置保存结果等需要从 index.ts 发 notice 时用(emit 是私有的)。 */
|
|
1854
|
+
emitNotice(level, text) {
|
|
1855
|
+
this.emit({ type: "notice", level, text });
|
|
1856
|
+
}
|
|
1857
|
+
/** Kill ONE background server (by port); returns whether anything was killed. */
|
|
1858
|
+
/** Kill ONE background server (by port) OR a plugin task (by taskId). */
|
|
1859
|
+
async killBackgroundServer(port, taskId) {
|
|
1860
|
+
if (taskId) {
|
|
1861
|
+
// 插件任务:交给插件管理器 stop 回调(不杀进程树——任务在宿主进程内)。
|
|
1862
|
+
const ok = this.pluginStopBgTask?.(taskId) ?? false;
|
|
1863
|
+
if (!ok) {
|
|
1864
|
+
this.emit({
|
|
1865
|
+
type: "notice",
|
|
1866
|
+
level: "info",
|
|
1867
|
+
text: `后台任务「${taskId}」不存在或已结束`,
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
this.bg.push();
|
|
1871
|
+
this.flushSnapshot();
|
|
1872
|
+
return ok;
|
|
1873
|
+
}
|
|
1874
|
+
if (typeof port !== "number")
|
|
1875
|
+
return false;
|
|
1876
|
+
return this.bg.killOne(port);
|
|
1877
|
+
}
|
|
1878
|
+
/** Kill every background server the agent started; returns the freed ports. */
|
|
1879
|
+
async killAllBackgroundServers() {
|
|
1880
|
+
return this.bg.killAll();
|
|
1881
|
+
}
|
|
1882
|
+
/** Kill only the running bash command(s) — the agent run itself continues
|
|
1883
|
+
* (the bash tool returns an aborted error and the model moves on). Uses
|
|
1884
|
+
* the per-client AbortController set registered by
|
|
1885
|
+
* makeKillableBashTool. */
|
|
1886
|
+
async abortBash() {
|
|
1887
|
+
if (this.bashKills.size === 0) {
|
|
1888
|
+
this.emit({
|
|
1889
|
+
type: "notice",
|
|
1890
|
+
level: "info",
|
|
1891
|
+
text: "当前没有正在运行的 bash 命令",
|
|
1892
|
+
});
|
|
1893
|
+
this.flushSnapshot();
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
for (const ac of [...this.bashKills])
|
|
1897
|
+
ac.abort();
|
|
1898
|
+
this.emit({
|
|
1899
|
+
type: "notice",
|
|
1900
|
+
level: "info",
|
|
1901
|
+
text: "已停止 bash 命令(对话继续)",
|
|
1902
|
+
});
|
|
1903
|
+
// 让 AI 明确知道是用户手动停止:sendUserMessage 触发下一轮,agent
|
|
1904
|
+
// 会看到「命令被用户中止」而不是普通失败,并据此继续(不会困惑于
|
|
1905
|
+
// 为什么命令失败了)。
|
|
1906
|
+
try {
|
|
1907
|
+
await this.conv.runtime.session.sendUserMessage("(系统:用户手动停止了刚才的 bash 命令——命令被中止,终止前已输出的内容在对应工具结果里。请据此继续,不要重跑被中止的命令,除非确实必要。)");
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
// best effort — 消息注入失败不影响命令已停止的事实
|
|
1911
|
+
}
|
|
1912
|
+
this.flushSnapshot();
|
|
1913
|
+
}
|
|
1914
|
+
/** Interrupt a run: abort, with a force-reset fallback on timeout. */
|
|
1915
|
+
async interruptRun(conv, reason) {
|
|
1916
|
+
// The run is only truly stopped when its agent_end event arrives:
|
|
1917
|
+
// session.abort() can return without stopping anything when the run is
|
|
1918
|
+
// stuck before the agent even started (e.g. a model stream that never
|
|
1919
|
+
// begins), so we watch for agent_end and force-reset when it never
|
|
1920
|
+
// comes — abort 卡住(超时)或空转(结算窗口)两条路都覆盖。
|
|
1921
|
+
let ended = false;
|
|
1922
|
+
let forced = false;
|
|
1923
|
+
const off = conv.session.subscribe((e) => {
|
|
1924
|
+
if (e.type === "agent_end") {
|
|
1925
|
+
ended = true;
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
const force = () => {
|
|
1929
|
+
if (forced)
|
|
1930
|
+
return;
|
|
1931
|
+
forced = true;
|
|
1932
|
+
void this.forceResetConversation(conv, `${reason}:运行未终止,已强制重置当前对话`);
|
|
1933
|
+
};
|
|
1934
|
+
// 1) abort itself hangs (model stream ignores the signal) → hard kill.
|
|
1935
|
+
const abortTimer = setTimeout(() => {
|
|
1936
|
+
if (!ended)
|
|
1937
|
+
force();
|
|
1938
|
+
}, ClientSession.HARD_ABORT_TIMEOUT_MS);
|
|
1939
|
+
abortTimer.unref?.();
|
|
1940
|
+
// 2) abort itself (Stop semantics: kills the process tree, emits
|
|
1941
|
+
// agent_end with stopReason "aborted" on the normal path).
|
|
1942
|
+
try {
|
|
1943
|
+
await conv.runtime.session.abort();
|
|
1944
|
+
}
|
|
1945
|
+
catch (err) {
|
|
1946
|
+
this.emit({
|
|
1947
|
+
type: "notice",
|
|
1948
|
+
level: "error",
|
|
1949
|
+
text: `中止失败:${err.message}`,
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
// 3) abort returned but no agent_end within the settle window → the
|
|
1953
|
+
// run was stuck before it started; force-reset to recover.
|
|
1954
|
+
if (!ended) {
|
|
1955
|
+
await new Promise((r) => setTimeout(r, ClientSession.HARD_ABORT_SETTLE_MS));
|
|
1956
|
+
}
|
|
1957
|
+
clearTimeout(abortTimer);
|
|
1958
|
+
off();
|
|
1959
|
+
if (!ended)
|
|
1960
|
+
force();
|
|
1961
|
+
}
|
|
1962
|
+
/** Force-reset a conversation: dispose the stuck runtime (kills the hung
|
|
1963
|
+
* model stream / child processes) and rebuild it from the most recent
|
|
1964
|
+
* persisted session. The conversation record itself is kept (same id,
|
|
1965
|
+
* same cwd, same serialization caches), so the UI stays attached. */
|
|
1966
|
+
async forceResetConversation(conv, reason) {
|
|
1967
|
+
try {
|
|
1968
|
+
conv.unsubscribe?.();
|
|
1969
|
+
conv.unsubscribe = undefined;
|
|
1970
|
+
this.clearAllToolWatchdogs(conv);
|
|
1971
|
+
conv.toolStartTimes.clear();
|
|
1972
|
+
await conv.runtime.dispose();
|
|
1973
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals), {
|
|
1974
|
+
cwd: conv.cwd,
|
|
1975
|
+
agentDir: this.agentDir,
|
|
1976
|
+
sessionManager: SessionManager.continueRecent(conv.cwd),
|
|
1977
|
+
});
|
|
1978
|
+
conv.runtime = runtime;
|
|
1979
|
+
conv.session = runtime.session;
|
|
1980
|
+
this.emit({ type: "notice", level: "warning", text: reason });
|
|
1981
|
+
await this.bindSession();
|
|
1982
|
+
this.emitConversations();
|
|
1983
|
+
void this.pushSlashCommands();
|
|
1984
|
+
}
|
|
1985
|
+
catch (err) {
|
|
1986
|
+
this.emit({
|
|
1987
|
+
type: "notice",
|
|
1988
|
+
level: "error",
|
|
1989
|
+
text: `强制中断失败:${err.message}`,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
async newChat() {
|
|
1994
|
+
if (this.quiesceBlocked())
|
|
1995
|
+
return;
|
|
1996
|
+
// Reuse an already-open blank conversation instead of piling up new ones
|
|
1997
|
+
// on every click: if the active chat has no messages it IS the new chat
|
|
1998
|
+
// (focus already on it); otherwise switch to the first blank one (under
|
|
1999
|
+
// the per-project running-list model displaced blanks are disposed, so
|
|
2000
|
+
// this branch normally can't exist — kept as a safety net).
|
|
2001
|
+
const isBlank = (c) => {
|
|
2002
|
+
try {
|
|
2003
|
+
return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0;
|
|
2004
|
+
}
|
|
2005
|
+
catch {
|
|
2006
|
+
// session being replaced — treat as used so we don't switch onto it
|
|
2007
|
+
return false;
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
const active = this.conv;
|
|
2011
|
+
if (active && isBlank(active)) {
|
|
2012
|
+
this.flushSnapshot();
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
for (const conv of this.convs.values()) {
|
|
2016
|
+
if (conv.id === this.activeId)
|
|
2017
|
+
continue;
|
|
2018
|
+
if (isBlank(conv)) {
|
|
2019
|
+
await this.switchConversation(conv.id);
|
|
2020
|
+
this.flushSnapshot();
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
// Cap is per project — conversations of other projects keep their own
|
|
2025
|
+
// lists and don't consume this project's slots.
|
|
2026
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd).length;
|
|
2027
|
+
if (openInProject >= MAX_OPEN_CONVERSATIONS) {
|
|
2028
|
+
this.emit({
|
|
2029
|
+
type: "notice",
|
|
2030
|
+
level: "warning",
|
|
2031
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
2032
|
+
});
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
// The outgoing conversation is left behind — apply the running-list
|
|
2036
|
+
// lifecycle. Removal is deferred until the new chat exists so the active
|
|
2037
|
+
// conversation stays valid during the (async) runtime creation.
|
|
2038
|
+
const displaced = this.displaceActive();
|
|
2039
|
+
// Carry the model chosen in the active chat over to the new chat so it
|
|
2040
|
+
// doesn't silently revert to the ModelRuntime default model.
|
|
2041
|
+
const prevModel = this.conv.session.agent.state.model ?? null;
|
|
2042
|
+
try {
|
|
2043
|
+
const conversationId = this.nextConversationId();
|
|
2044
|
+
const terminals = this.makeTerminalManager(conversationId, this.cwd);
|
|
2045
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
|
|
2046
|
+
cwd: this.cwd,
|
|
2047
|
+
agentDir: this.agentDir,
|
|
2048
|
+
sessionManager: SessionManager.create(this.cwd),
|
|
2049
|
+
});
|
|
2050
|
+
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
2051
|
+
this.convs.set(conv.id, conv);
|
|
2052
|
+
this.activeId = conv.id;
|
|
2053
|
+
if (displaced)
|
|
2054
|
+
this.removeConversation(displaced.id);
|
|
2055
|
+
await this.bindSession();
|
|
2056
|
+
// New session seeds with the ModelRuntime default model — restore the
|
|
2057
|
+
// model the user had selected in the previous chat.
|
|
2058
|
+
if (prevModel && this.sharedModelRuntime) {
|
|
2059
|
+
try {
|
|
2060
|
+
await this.session.setModel(prevModel);
|
|
2061
|
+
}
|
|
2062
|
+
catch {
|
|
2063
|
+
// model no longer resolvable — keep the default
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
this.emitConversations();
|
|
2067
|
+
this.goalSvc.emitGoalStatus();
|
|
2068
|
+
this.pushTerminals();
|
|
2069
|
+
// The new runtime re-discovered skills/templates — refresh the catalog
|
|
2070
|
+
// so the picker stops showing the previous runtime's list.
|
|
2071
|
+
void this.pushSlashCommands();
|
|
2072
|
+
}
|
|
2073
|
+
catch (err) {
|
|
2074
|
+
this.emit({
|
|
2075
|
+
type: "notice",
|
|
2076
|
+
level: "error",
|
|
2077
|
+
text: `新建对话失败:${err.message}`,
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
this.flushSnapshot();
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* The active conversation is being left (new_chat / switch_conversation /
|
|
2084
|
+
* set_cwd). Runs the running-list lifecycle:
|
|
2085
|
+
*
|
|
2086
|
+
* - still streaming → it becomes a background run: ensure it is listed;
|
|
2087
|
+
* - idle + listed + continued → keep it (the user did continue it);
|
|
2088
|
+
* - any retained terminal state → keep it listed until the terminals are closed;
|
|
2089
|
+
* - idle + listed + opened-but-not-continued, or never listed at all → the
|
|
2090
|
+
* caller must drop it (returns it so removal happens only after the
|
|
2091
|
+
* active conversation has been switched away).
|
|
2092
|
+
*/
|
|
2093
|
+
displaceActive() {
|
|
2094
|
+
const conv = this.conv;
|
|
2095
|
+
// An isolated reviewer can keep working while the main session is idle;
|
|
2096
|
+
// retain that conversation so its review is not disposed when the user
|
|
2097
|
+
// switches away without sending another prompt.
|
|
2098
|
+
if (conv.goal.reviewing || conv.wizardRunning) {
|
|
2099
|
+
conv.listed = true;
|
|
2100
|
+
return null;
|
|
2101
|
+
}
|
|
2102
|
+
if (conv.session.isStreaming) {
|
|
2103
|
+
conv.listed = true;
|
|
2104
|
+
return null;
|
|
2105
|
+
}
|
|
2106
|
+
// Terminal state is a reason to keep an otherwise idle conversation alive:
|
|
2107
|
+
// switching chats must not kill a PTY the user or agent may still need.
|
|
2108
|
+
if (conv.terminals.list().length > 0) {
|
|
2109
|
+
conv.listed = true;
|
|
2110
|
+
return null;
|
|
2111
|
+
}
|
|
2112
|
+
if (conv.listed && conv.promptedSinceActive)
|
|
2113
|
+
return null;
|
|
2114
|
+
return conv;
|
|
2115
|
+
}
|
|
2116
|
+
/** Remove a conversation from the running list and free its runtime. The
|
|
2117
|
+
* session stays persisted on disk, so it remains recoverable from the
|
|
2118
|
+
* history list. Never removes the active conversation. */
|
|
2119
|
+
removeConversation(id) {
|
|
2120
|
+
const conv = this.convs.get(id);
|
|
2121
|
+
if (!conv || id === this.activeId)
|
|
2122
|
+
return;
|
|
2123
|
+
this.convs.delete(id);
|
|
2124
|
+
this.clearAllToolWatchdogs(conv);
|
|
2125
|
+
conv.terminals.killAll();
|
|
2126
|
+
conv.unsubscribe?.();
|
|
2127
|
+
conv.unsubscribe = undefined;
|
|
2128
|
+
void conv.runtime.dispose().catch(() => { });
|
|
2129
|
+
}
|
|
2130
|
+
/** Switch the ACTIVE conversation without interrupting any other chat. */
|
|
2131
|
+
async switchConversation(id) {
|
|
2132
|
+
if (!this.convs.has(id) || id === this.activeId)
|
|
2133
|
+
return;
|
|
2134
|
+
const displaced = this.displaceActive();
|
|
2135
|
+
this.activeId = id;
|
|
2136
|
+
this.cwd = this.conv.cwd;
|
|
2137
|
+
// All listed conversations share the current project's cwd, so this is
|
|
2138
|
+
// normally a no-op — kept defensive for stale clients.
|
|
2139
|
+
if (displaced)
|
|
2140
|
+
this.removeConversation(displaced.id);
|
|
2141
|
+
this.conv.promptedSinceActive = false;
|
|
2142
|
+
this.conv.lastActiveAt = Date.now();
|
|
2143
|
+
this.webUi.refresh();
|
|
2144
|
+
this.emitConversations();
|
|
2145
|
+
this.goalSvc.emitGoalStatus();
|
|
2146
|
+
this.pushTerminals();
|
|
2147
|
+
// The switched-to conversation has its own runtime (own resource cache).
|
|
2148
|
+
void this.pushSlashCommands();
|
|
2149
|
+
this.flushSnapshot();
|
|
2150
|
+
}
|
|
2151
|
+
/** Push the current project's running-conversation list to the client. */
|
|
2152
|
+
emitConversations() {
|
|
2153
|
+
const conversations = [];
|
|
2154
|
+
for (const conv of this.convs.values()) {
|
|
2155
|
+
// The running-conversation list is per project and only contains
|
|
2156
|
+
// conversations that were displaced to the background while running.
|
|
2157
|
+
if (conv.cwd !== this.cwd || !conv.listed)
|
|
2158
|
+
continue;
|
|
2159
|
+
let messageCount = 0;
|
|
2160
|
+
let isStreaming = false;
|
|
2161
|
+
try {
|
|
2162
|
+
messageCount = conv.session.getSessionStats().totalMessages;
|
|
2163
|
+
isStreaming = conv.session.isStreaming;
|
|
2164
|
+
}
|
|
2165
|
+
catch {
|
|
2166
|
+
// session being replaced — report defaults
|
|
2167
|
+
}
|
|
2168
|
+
conversations.push({
|
|
2169
|
+
id: conv.id,
|
|
2170
|
+
title: conv.title,
|
|
2171
|
+
cwd: conv.cwd,
|
|
2172
|
+
messageCount,
|
|
2173
|
+
isStreaming,
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
this.emit({
|
|
2177
|
+
type: "conversations",
|
|
2178
|
+
conversations,
|
|
2179
|
+
activeId: this.activeId,
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
/** List persisted sessions for this client, newest first. */
|
|
2183
|
+
/** The client asked for the session list at least once (lazy loading) —
|
|
2184
|
+
* background refreshes only re-push when this is true, so a mobile
|
|
2185
|
+
* client that never opened the panel never pays the disk scan. */
|
|
2186
|
+
sessionsRequested = false;
|
|
2187
|
+
/** Push the persisted session list to the client (client-requested). */
|
|
2188
|
+
async refreshSessions() {
|
|
2189
|
+
this.sessionsRequested = true;
|
|
2190
|
+
await this.pushSessions();
|
|
2191
|
+
}
|
|
2192
|
+
async pushSessions() {
|
|
2193
|
+
if (!this.sessionsRequested)
|
|
2194
|
+
return;
|
|
2195
|
+
if (!this.sessionsRequested)
|
|
2196
|
+
return;
|
|
2197
|
+
try {
|
|
2198
|
+
// Sessions live in the SDK default per-project dir
|
|
2199
|
+
// (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
|
|
2200
|
+
// use — one listing covers every conversation of the current folder.
|
|
2201
|
+
const infos = await SessionManager.list(this.cwd);
|
|
2202
|
+
const sessions = new Map();
|
|
2203
|
+
for (const s of infos) {
|
|
2204
|
+
sessions.set(s.path, {
|
|
2205
|
+
path: s.path,
|
|
2206
|
+
name: s.name,
|
|
2207
|
+
firstMessage: s.firstMessage,
|
|
2208
|
+
messageCount: s.messageCount,
|
|
2209
|
+
modified: s.modified.getTime(),
|
|
2210
|
+
source: "web",
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
const sorted = [...sessions.values()]
|
|
2214
|
+
.sort((a, b) => b.modified - a.modified)
|
|
2215
|
+
.slice(0, 200); // newest first — the panel shows recent history
|
|
2216
|
+
this.emit({ type: "sessions", sessions: sorted });
|
|
2217
|
+
}
|
|
2218
|
+
catch {
|
|
2219
|
+
this.emit({ type: "sessions", sessions: [] });
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
/** Remove an entry from the client's recent-project list (UI state only). */
|
|
2223
|
+
async removeProject(path) {
|
|
2224
|
+
this.stateStore.removeProject(this.clientId, path);
|
|
2225
|
+
await this.pushProjects();
|
|
2226
|
+
}
|
|
2227
|
+
/** Permanently delete a persisted session transcript file (history list ✕). */
|
|
2228
|
+
async deleteSession(path) {
|
|
2229
|
+
try {
|
|
2230
|
+
const abs = resolve(path);
|
|
2231
|
+
// Guardrail: only transcripts under the shared sessions root
|
|
2232
|
+
// (<agentDir>/sessions/) may be deleted — never arbitrary files.
|
|
2233
|
+
const sessionsRoot = resolve(this.agentDir, "sessions");
|
|
2234
|
+
if (!abs.startsWith(sessionsRoot + sep)) {
|
|
2235
|
+
this.emit({
|
|
2236
|
+
type: "notice",
|
|
2237
|
+
level: "error",
|
|
2238
|
+
text: "只能删除会话目录中的对话记录",
|
|
2239
|
+
});
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
// Refuse to pull the file out from under a live conversation.
|
|
2243
|
+
for (const conv of this.convs.values()) {
|
|
2244
|
+
if (conv.session.sessionFile === abs) {
|
|
2245
|
+
this.emit({
|
|
2246
|
+
type: "notice",
|
|
2247
|
+
level: "warning",
|
|
2248
|
+
text: "该对话正在使用中,请先切换到其他对话再删除",
|
|
2249
|
+
});
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
rmSync(abs, { force: true });
|
|
2254
|
+
await this.refreshSessions();
|
|
2255
|
+
}
|
|
2256
|
+
catch (err) {
|
|
2257
|
+
this.emit({
|
|
2258
|
+
type: "notice",
|
|
2259
|
+
level: "error",
|
|
2260
|
+
text: `删除会话失败:${err.message}`,
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
/** Open a persisted session as the active conversation (from listSessions).
|
|
2265
|
+
*
|
|
2266
|
+
* A persisted-session click must follow the same ownership rule as
|
|
2267
|
+
* new_chat/switch_conversation: every open conversation keeps its own
|
|
2268
|
+
* runtime. AgentSessionRuntime.switchSession() tears down (and aborts) the
|
|
2269
|
+
* current runtime, which would otherwise stop a response merely because the
|
|
2270
|
+
* user opened history while it was streaming.
|
|
2271
|
+
*/
|
|
2272
|
+
async switchSession(path) {
|
|
2273
|
+
if (this.quiesceBlocked())
|
|
2274
|
+
return;
|
|
2275
|
+
let openedRuntime = null;
|
|
2276
|
+
let openedTerminals = null;
|
|
2277
|
+
try {
|
|
2278
|
+
const targetPath = resolve(path);
|
|
2279
|
+
// A session may already be open in the running-conversation map. Reuse it
|
|
2280
|
+
// instead of creating a second writer for the same JSONL transcript.
|
|
2281
|
+
for (const conv of this.convs.values()) {
|
|
2282
|
+
const sessionFile = conv.session.sessionFile;
|
|
2283
|
+
if (sessionFile && resolve(sessionFile) === targetPath) {
|
|
2284
|
+
await this.switchConversation(conv.id);
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
const sessionManager = SessionManager.open(targetPath);
|
|
2289
|
+
const targetCwd = sessionManager.getCwd();
|
|
2290
|
+
const conversationId = this.nextConversationId();
|
|
2291
|
+
openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
|
|
2292
|
+
openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals), {
|
|
2293
|
+
cwd: targetCwd,
|
|
2294
|
+
agentDir: this.agentDir,
|
|
2295
|
+
sessionManager,
|
|
2296
|
+
});
|
|
2297
|
+
// Only displace the old active conversation after the replacement runtime
|
|
2298
|
+
// is known-good. This keeps a failed history open entirely non-destructive.
|
|
2299
|
+
const oldListed = this.conv.listed;
|
|
2300
|
+
const displaced = this.displaceActive();
|
|
2301
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length +
|
|
2302
|
+
1 -
|
|
2303
|
+
(displaced?.cwd === targetCwd ? 1 : 0);
|
|
2304
|
+
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
2305
|
+
// displaceActive() may have promoted a streaming conversation into the
|
|
2306
|
+
// running list. Roll that presentation-only mutation back because no
|
|
2307
|
+
// switch will take place.
|
|
2308
|
+
this.conv.listed = oldListed;
|
|
2309
|
+
openedTerminals.killAll();
|
|
2310
|
+
await openedRuntime.dispose();
|
|
2311
|
+
openedRuntime = null;
|
|
2312
|
+
openedTerminals = null;
|
|
2313
|
+
this.emit({
|
|
2314
|
+
type: "notice",
|
|
2315
|
+
level: "warning",
|
|
2316
|
+
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
2317
|
+
});
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
const conv = this.makeConversation(openedRuntime, conversationId, openedTerminals);
|
|
2321
|
+
// Deliberately resumed — must not be dismissed when the user later
|
|
2322
|
+
// switches away without sending a new message.
|
|
2323
|
+
conv.promptedSinceActive = true;
|
|
2324
|
+
this.convs.set(conv.id, conv);
|
|
2325
|
+
this.activeId = conv.id;
|
|
2326
|
+
openedRuntime = null;
|
|
2327
|
+
openedTerminals = null;
|
|
2328
|
+
if (displaced)
|
|
2329
|
+
this.removeConversation(displaced.id);
|
|
2330
|
+
await this.bindSession();
|
|
2331
|
+
this.cwd = targetCwd;
|
|
2332
|
+
this.conv.lastActiveAt = Date.now();
|
|
2333
|
+
this.webUi.refresh();
|
|
2334
|
+
this.emitConversations();
|
|
2335
|
+
this.goalSvc.emitGoalStatus();
|
|
2336
|
+
this.pushTerminals();
|
|
2337
|
+
// The restored conversation has a fresh project-bound resource cache.
|
|
2338
|
+
void this.pushSlashCommands();
|
|
2339
|
+
}
|
|
2340
|
+
catch (err) {
|
|
2341
|
+
openedTerminals?.killAll();
|
|
2342
|
+
if (openedRuntime)
|
|
2343
|
+
await openedRuntime.dispose().catch(() => { });
|
|
2344
|
+
this.emit({
|
|
2345
|
+
type: "notice",
|
|
2346
|
+
level: "error",
|
|
2347
|
+
text: `切换会话失败:${err.message}`,
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
this.flushSnapshot();
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Map a rendered user-message id (`u-<timestamp>-<seq>`, assigned in
|
|
2354
|
+
* serialize.ts) back to its append-only session entry id. The seq handles
|
|
2355
|
+
* two user messages sharing the same millisecond timestamp.
|
|
2356
|
+
*/
|
|
2357
|
+
resolveUserMessageEntryId(messageId) {
|
|
2358
|
+
const m = /^u-(\d+)(?:-(\d+))?$/.exec(messageId);
|
|
2359
|
+
if (!m)
|
|
2360
|
+
return null;
|
|
2361
|
+
const ts = Number(m[1]);
|
|
2362
|
+
const seq = m[2] ? Number(m[2]) : 1;
|
|
2363
|
+
let count = 0;
|
|
2364
|
+
// Resolve against the compaction-aware current leaf path — the same list
|
|
2365
|
+
// the UI renders (state.messages). Scanning the whole file (getEntries)
|
|
2366
|
+
// could match a summarized entry or one on a different branch.
|
|
2367
|
+
for (const entry of this.session.sessionManager.buildContextEntries()) {
|
|
2368
|
+
if (entry.type !== "message")
|
|
2369
|
+
continue;
|
|
2370
|
+
const msg = entry.message;
|
|
2371
|
+
if (!msg || msg.role !== "user" || msg.timestamp !== ts)
|
|
2372
|
+
continue;
|
|
2373
|
+
count += 1;
|
|
2374
|
+
if (count === seq)
|
|
2375
|
+
return entry.id;
|
|
2376
|
+
}
|
|
2377
|
+
return null;
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Edit a past user question and re-ask it: forks a NEW session file that
|
|
2381
|
+
* keeps everything up to (but not including) that question, then sends the
|
|
2382
|
+
* edited text there. The original thread is untouched and stays in the
|
|
2383
|
+
* session list, so nothing is ever lost.
|
|
2384
|
+
*
|
|
2385
|
+
* Attachments (attachments) travel through the SAME pipeline as prompt()
|
|
2386
|
+
* — the fork intentionally drops the original attachment asides because
|
|
2387
|
+
* they live on the old branch past the fork point, so the browser re-sends
|
|
2388
|
+
* the images it kept in the edit composer (original image blocks + any
|
|
2389
|
+
* newly pasted/dropped ones). Text-only edits pass undefined.
|
|
2390
|
+
*/
|
|
2391
|
+
async editMessage(messageId, text, attachments) {
|
|
2392
|
+
if (this.quiesceBlocked())
|
|
2393
|
+
return;
|
|
2394
|
+
const trimmed = text.trim();
|
|
2395
|
+
if (!trimmed) {
|
|
2396
|
+
this.emit({
|
|
2397
|
+
type: "notice",
|
|
2398
|
+
level: "warning",
|
|
2399
|
+
text: "编辑内容为空,已取消",
|
|
2400
|
+
});
|
|
2401
|
+
this.flushSnapshot();
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
const entryId = this.resolveUserMessageEntryId(messageId);
|
|
2405
|
+
if (!entryId) {
|
|
2406
|
+
this.emit({
|
|
2407
|
+
type: "notice",
|
|
2408
|
+
level: "error",
|
|
2409
|
+
text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
|
|
2410
|
+
});
|
|
2411
|
+
this.flushSnapshot();
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
try {
|
|
2415
|
+
// Preserve the model the user had selected — fork() seeds a new
|
|
2416
|
+
// branch with the ModelRuntime default model otherwise.
|
|
2417
|
+
const prevModel = this.session.agent.state.model ?? null;
|
|
2418
|
+
const result = await this.runtime.fork(entryId);
|
|
2419
|
+
if (result.cancelled) {
|
|
2420
|
+
this.emit({
|
|
2421
|
+
type: "notice",
|
|
2422
|
+
level: "info",
|
|
2423
|
+
text: "已取消编辑重问",
|
|
2424
|
+
});
|
|
2425
|
+
this.flushSnapshot();
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
await this.bindSession();
|
|
2429
|
+
// Restore the previously-selected model on the forked branch.
|
|
2430
|
+
if (prevModel && this.sharedModelRuntime) {
|
|
2431
|
+
try {
|
|
2432
|
+
await this.session.setModel(prevModel);
|
|
2433
|
+
}
|
|
2434
|
+
catch {
|
|
2435
|
+
// model no longer resolvable — keep the default
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
await this.prompt(trimmed, attachments);
|
|
2439
|
+
this.emit({
|
|
2440
|
+
type: "notice",
|
|
2441
|
+
level: "info",
|
|
2442
|
+
text: "已从该问题重新提问(原对话保留在会话列表中)",
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
catch (err) {
|
|
2446
|
+
this.emit({
|
|
2447
|
+
type: "notice",
|
|
2448
|
+
level: "error",
|
|
2449
|
+
text: `编辑重问失败:${err.message}`,
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
this.flushSnapshot();
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* Push the recent-project list (persisted per client, merged with every cwd
|
|
2456
|
+
* that has persisted sessions in this client's session store — so workspaces
|
|
2457
|
+
* opened before the recent-list feature existed still show up).
|
|
2458
|
+
*/
|
|
2459
|
+
async pushProjects() {
|
|
2460
|
+
try {
|
|
2461
|
+
const saved = this.stateStore.get(this.clientId);
|
|
2462
|
+
const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId));
|
|
2463
|
+
const map = new Map();
|
|
2464
|
+
for (const p of saved.projects)
|
|
2465
|
+
map.set(p.path, p.lastUsed);
|
|
2466
|
+
const all = await SessionManager.listAll();
|
|
2467
|
+
for (const s of all) {
|
|
2468
|
+
if (s.cwd) {
|
|
2469
|
+
const t = s.modified.getTime();
|
|
2470
|
+
const prev = map.get(s.cwd);
|
|
2471
|
+
if (prev === undefined || t > prev)
|
|
2472
|
+
map.set(s.cwd, t);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
// Only keep directories that still exist — a deleted/unmounted workspace
|
|
2476
|
+
// is useless in the picker. Tombstoned entries (explicitly removed by
|
|
2477
|
+
// the user) stay hidden even though session files still mention them.
|
|
2478
|
+
const projects = [...map.entries()]
|
|
2479
|
+
.filter(([path]) => !removedProjects.has(path) && existsSync(path))
|
|
2480
|
+
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
2481
|
+
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
2482
|
+
.slice(0, 20);
|
|
2483
|
+
this.emit({ type: "projects", projects });
|
|
2484
|
+
}
|
|
2485
|
+
catch {
|
|
2486
|
+
this.emit({ type: "projects", projects: [] });
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
/** List a workspace directory (relative to the configured cwd). */
|
|
2490
|
+
async listFiles(relPath) {
|
|
2491
|
+
return this.files.listFiles(relPath);
|
|
2492
|
+
}
|
|
2493
|
+
/** 全局搜索:递归文件名匹配(结果经 search_files_result 回推,reqId 匹配)。 */
|
|
2494
|
+
async searchFiles(query, reqId) {
|
|
2495
|
+
return this.files.searchFiles(query, reqId);
|
|
2496
|
+
}
|
|
2497
|
+
/** SCM 只读查询(结构化 JSON,reqId 匹配)。 */
|
|
2498
|
+
async scmQuery(kind, reqId, arg) {
|
|
2499
|
+
return this.files.scmQuery(kind, reqId, arg);
|
|
2500
|
+
}
|
|
2501
|
+
/** Read a workspace file for the preview panel (size-capped, binary-safe). */
|
|
2502
|
+
async readFile(relPath) {
|
|
2503
|
+
return this.files.readFile(relPath);
|
|
2504
|
+
}
|
|
2505
|
+
/** Save text from the file preview panel within the active workspace. */
|
|
2506
|
+
async writeFile(relPath, text) {
|
|
2507
|
+
return this.files.writeFile(relPath, text);
|
|
2508
|
+
}
|
|
2509
|
+
async cycleModel() {
|
|
2510
|
+
try {
|
|
2511
|
+
await this.session.cycleModel();
|
|
2512
|
+
}
|
|
2513
|
+
catch (err) {
|
|
2514
|
+
this.emit({
|
|
2515
|
+
type: "notice",
|
|
2516
|
+
level: "error",
|
|
2517
|
+
text: `切换模型失败:${err.message}`,
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
this.flushSnapshot();
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* Path completion for the cwd input: expand ~/relative paths, list the parent
|
|
2524
|
+
* directory, and return prefix matches (dirs first, capped).
|
|
2525
|
+
*/
|
|
2526
|
+
async completePath(input) {
|
|
2527
|
+
return this.files.completePath(input);
|
|
2528
|
+
}
|
|
2529
|
+
async setCwd(newCwd) {
|
|
2530
|
+
try {
|
|
2531
|
+
const { resolve } = await import("node:path");
|
|
2532
|
+
this.files.unwatchGit(); // stale repo's watcher must not fire across projects
|
|
2533
|
+
const fs = await import("node:fs/promises");
|
|
2534
|
+
const abs = resolve(newCwd);
|
|
2535
|
+
const st = await fs.stat(abs);
|
|
2536
|
+
if (!st.isDirectory()) {
|
|
2537
|
+
throw new Error("路径不是目录");
|
|
2538
|
+
}
|
|
2539
|
+
if (abs === this.cwd) {
|
|
2540
|
+
this.emit({
|
|
2541
|
+
type: "notice",
|
|
2542
|
+
level: "info",
|
|
2543
|
+
text: `已在工作目录:${abs}`,
|
|
2544
|
+
});
|
|
2545
|
+
this.flushSnapshot();
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
// The outgoing conversation is left behind — apply the running-list
|
|
2549
|
+
// lifecycle (removal is deferred until the active conversation is
|
|
2550
|
+
// safely switched away).
|
|
2551
|
+
const displaced = this.displaceActive();
|
|
2552
|
+
// Prefer the target project's own most recently active conversation;
|
|
2553
|
+
// only create a fresh one (resuming its most recent session) when the
|
|
2554
|
+
// project has none open yet.
|
|
2555
|
+
let target;
|
|
2556
|
+
for (const c of this.convs.values()) {
|
|
2557
|
+
if (c.cwd === abs &&
|
|
2558
|
+
(!target || c.lastActiveAt > target.lastActiveAt)) {
|
|
2559
|
+
target = c;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
if (target) {
|
|
2563
|
+
this.activeId = target.id;
|
|
2564
|
+
if (displaced)
|
|
2565
|
+
this.removeConversation(displaced.id);
|
|
2566
|
+
}
|
|
2567
|
+
else {
|
|
2568
|
+
// First visit to this project: resume its most recent session.
|
|
2569
|
+
const conversationId = this.nextConversationId();
|
|
2570
|
+
const terminals = this.makeTerminalManager(conversationId, abs);
|
|
2571
|
+
const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
|
|
2572
|
+
cwd: abs,
|
|
2573
|
+
agentDir: this.agentDir,
|
|
2574
|
+
sessionManager: SessionManager.continueRecent(abs),
|
|
2575
|
+
});
|
|
2576
|
+
const conv = this.makeConversation(newRuntime, conversationId, terminals);
|
|
2577
|
+
this.convs.set(conv.id, conv);
|
|
2578
|
+
this.activeId = conv.id;
|
|
2579
|
+
if (displaced)
|
|
2580
|
+
this.removeConversation(displaced.id);
|
|
2581
|
+
for (const d of newRuntime.diagnostics) {
|
|
2582
|
+
if (d.type !== "info") {
|
|
2583
|
+
this.emit({ type: "notice", level: d.type, text: d.message });
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
await this.bindSession();
|
|
2587
|
+
}
|
|
2588
|
+
this.pushTerminals();
|
|
2589
|
+
this.conv.promptedSinceActive = false;
|
|
2590
|
+
this.conv.lastActiveAt = Date.now();
|
|
2591
|
+
this.cwd = abs;
|
|
2592
|
+
// 工作区跟随型插件(编辑器文件树等)同步切根。
|
|
2593
|
+
try {
|
|
2594
|
+
this.onCwdChanged?.(abs);
|
|
2595
|
+
}
|
|
2596
|
+
catch {
|
|
2597
|
+
/* 钩子异常不影响主流程 */
|
|
2598
|
+
}
|
|
2599
|
+
// Remember the new workspace (restore target + recent-project entry).
|
|
2600
|
+
this.stateStore.remember(this.clientId, abs);
|
|
2601
|
+
void this.pushProjects();
|
|
2602
|
+
this.webUi.refresh();
|
|
2603
|
+
this.emitConversations();
|
|
2604
|
+
this.goalSvc.emitGoalStatus();
|
|
2605
|
+
// Skills / prompt templates are project-bound — refresh the catalog.
|
|
2606
|
+
void this.pushSlashCommands();
|
|
2607
|
+
this.emit({
|
|
2608
|
+
type: "notice",
|
|
2609
|
+
level: "info",
|
|
2610
|
+
text: `已切换到工作目录:${abs}`,
|
|
2611
|
+
});
|
|
2612
|
+
void this.refreshSessions();
|
|
2613
|
+
void this.listFiles(undefined);
|
|
2614
|
+
// Commands are per-project (.pi/commands.json in the current cwd).
|
|
2615
|
+
void this.listCommands();
|
|
2616
|
+
}
|
|
2617
|
+
catch (err) {
|
|
2618
|
+
this.emit({
|
|
2619
|
+
type: "notice",
|
|
2620
|
+
level: "error",
|
|
2621
|
+
text: `切换工作目录失败:${err.message}`,
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
this.flushSnapshot();
|
|
2625
|
+
}
|
|
2626
|
+
/** List models that have valid authentication configured. */
|
|
2627
|
+
async listModels() {
|
|
2628
|
+
try {
|
|
2629
|
+
const mr = this.runtime.services.modelRuntime;
|
|
2630
|
+
const available = await mr.getAvailable();
|
|
2631
|
+
const models = available.map((m) => ({
|
|
2632
|
+
id: `${m.provider}/${m.id}`,
|
|
2633
|
+
name: m.name,
|
|
2634
|
+
provider: m.provider,
|
|
2635
|
+
reasoning: m.reasoning,
|
|
2636
|
+
vision: m.input?.includes("image") ?? false,
|
|
2637
|
+
}));
|
|
2638
|
+
this.emit({ type: "models", models });
|
|
2639
|
+
}
|
|
2640
|
+
catch (err) {
|
|
2641
|
+
this.emit({
|
|
2642
|
+
type: "notice",
|
|
2643
|
+
level: "error",
|
|
2644
|
+
text: `获取模型列表失败:${err.message}`,
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
// ---------------------------------------------------------------------------
|
|
2649
|
+
// Goal / review
|
|
2650
|
+
// ---------------------------------------------------------------------------
|
|
2651
|
+
/** Goal family delegates to GoalService (see goal-service.ts). */
|
|
2652
|
+
async setGoal(goalText, opts) {
|
|
2653
|
+
return this.goalSvc.setGoal(goalText, opts);
|
|
2654
|
+
}
|
|
2655
|
+
async startGoalWizard(text, opts) {
|
|
2656
|
+
return this.goalSvc.startGoalWizard(text, opts);
|
|
2657
|
+
}
|
|
2658
|
+
async setGoalPrefs(opts) {
|
|
2659
|
+
return this.goalSvc.setGoalPrefs(opts);
|
|
2660
|
+
}
|
|
2661
|
+
async clearGoal() {
|
|
2662
|
+
return this.goalSvc.clearGoal();
|
|
2663
|
+
}
|
|
2664
|
+
/** Run a git diff (unstaged + staged) in a conversation's workspace, or
|
|
2665
|
+
* "" when not a repo. */
|
|
2666
|
+
async gitDiff(cwd) {
|
|
2667
|
+
try {
|
|
2668
|
+
const { code, out } = await this.runAsync("git", ["diff", "HEAD"], 10_000, cwd);
|
|
2669
|
+
if (code !== 0)
|
|
2670
|
+
return "";
|
|
2671
|
+
return out.slice(0, 60_000);
|
|
2672
|
+
}
|
|
2673
|
+
catch {
|
|
2674
|
+
return "";
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
/** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
|
|
2678
|
+
async setModel(modelId) {
|
|
2679
|
+
try {
|
|
2680
|
+
const mr = this.runtime.services.modelRuntime;
|
|
2681
|
+
const slash = modelId.indexOf("/");
|
|
2682
|
+
if (slash <= 0 || slash === modelId.length - 1) {
|
|
2683
|
+
throw new Error(`无效的模型 ID:${modelId}`);
|
|
2684
|
+
}
|
|
2685
|
+
const provider = modelId.slice(0, slash);
|
|
2686
|
+
const id = modelId.slice(slash + 1);
|
|
2687
|
+
const model = mr.getModel(provider, id);
|
|
2688
|
+
if (!model)
|
|
2689
|
+
throw new Error(`模型不存在:${modelId}`);
|
|
2690
|
+
await this.session.setModel(model);
|
|
2691
|
+
}
|
|
2692
|
+
catch (err) {
|
|
2693
|
+
this.emit({
|
|
2694
|
+
type: "notice",
|
|
2695
|
+
level: "error",
|
|
2696
|
+
text: `切换模型失败:${err.message}`,
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
this.flushSnapshot();
|
|
2700
|
+
}
|
|
2701
|
+
/** Set the thinking level for future turns. */
|
|
2702
|
+
setThinking(level) {
|
|
2703
|
+
try {
|
|
2704
|
+
this.session.setThinkingLevel(level);
|
|
2705
|
+
}
|
|
2706
|
+
catch (err) {
|
|
2707
|
+
this.emit({
|
|
2708
|
+
type: "notice",
|
|
2709
|
+
level: "error",
|
|
2710
|
+
text: `切换思考强度失败:${err.message}`,
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
this.flushSnapshot();
|
|
2714
|
+
}
|
|
2715
|
+
cycleThinking() {
|
|
2716
|
+
try {
|
|
2717
|
+
this.session.cycleThinkingLevel();
|
|
2718
|
+
}
|
|
2719
|
+
catch (err) {
|
|
2720
|
+
this.emit({
|
|
2721
|
+
type: "notice",
|
|
2722
|
+
level: "error",
|
|
2723
|
+
text: `切换思考强度失败:${err.message}`,
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
this.flushSnapshot();
|
|
2727
|
+
}
|
|
2728
|
+
/** Push the user command list (.pi/commands.json) to the client. */
|
|
2729
|
+
async listCommands() {
|
|
2730
|
+
const { commands, path, warning } = await loadCommands(this.cwd);
|
|
2731
|
+
if (warning) {
|
|
2732
|
+
this.emit({ type: "notice", level: "warning", text: warning });
|
|
2733
|
+
}
|
|
2734
|
+
this.emit({ type: "commands", commands, path });
|
|
2735
|
+
}
|
|
2736
|
+
/** Persist the user command list (.pi/commands.json). */
|
|
2737
|
+
async saveCommands(commands) {
|
|
2738
|
+
const { path, error } = await saveCommandsFile(this.cwd, commands);
|
|
2739
|
+
if (error) {
|
|
2740
|
+
this.emit({ type: "notice", level: "error", text: error });
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
this.emit({ type: "commands", commands, path });
|
|
2744
|
+
this.emit({ type: "notice", level: "info", text: `命令已保存:${path}` });
|
|
2745
|
+
}
|
|
2746
|
+
async dispose() {
|
|
2747
|
+
this.disposed = true;
|
|
2748
|
+
for (const conv of this.convs.values())
|
|
2749
|
+
conv.terminals.killAll();
|
|
2750
|
+
if (this.snapshotTimer) {
|
|
2751
|
+
clearTimeout(this.snapshotTimer);
|
|
2752
|
+
this.snapshotTimer = null;
|
|
2753
|
+
}
|
|
2754
|
+
if (this.sessionsTimer) {
|
|
2755
|
+
clearTimeout(this.sessionsTimer);
|
|
2756
|
+
this.sessionsTimer = null;
|
|
2757
|
+
}
|
|
2758
|
+
if (this.widgetsTimer) {
|
|
2759
|
+
clearInterval(this.widgetsTimer);
|
|
2760
|
+
this.widgetsTimer = null;
|
|
2761
|
+
}
|
|
2762
|
+
if (this.stallTimer) {
|
|
2763
|
+
clearInterval(this.stallTimer);
|
|
2764
|
+
this.stallTimer = null;
|
|
2765
|
+
}
|
|
2766
|
+
this.files.unwatchDir();
|
|
2767
|
+
this.files.unwatchGit();
|
|
2768
|
+
this.webUi.dispose();
|
|
2769
|
+
this.bg.stop();
|
|
2770
|
+
for (const conv of this.convs.values()) {
|
|
2771
|
+
this.clearAllToolWatchdogs(conv);
|
|
2772
|
+
conv.unsubscribe?.();
|
|
2773
|
+
try {
|
|
2774
|
+
await conv.runtime.dispose();
|
|
2775
|
+
}
|
|
2776
|
+
catch {
|
|
2777
|
+
// best effort
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
export class AgentService {
|
|
2783
|
+
cwd;
|
|
2784
|
+
/** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
|
|
2785
|
+
onToolEvent = undefined;
|
|
2786
|
+
/** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
|
|
2787
|
+
pluginToolsProvider = undefined;
|
|
2788
|
+
/** index.ts 注入:读取插件当前注册的斜杠命令(attach 时拷贝到每个新会话)。 */
|
|
2789
|
+
pluginCommandsProvider = undefined;
|
|
2790
|
+
/** index.ts 注入:读取插件注册的常驻后台任务(并入 bg_servers 面板)。 */
|
|
2791
|
+
pluginBgTasksProvider = undefined;
|
|
2792
|
+
/** index.ts 注入:停止插件任务(kill_background_server with taskId)。 */
|
|
2793
|
+
pluginStopBgTask = undefined;
|
|
2794
|
+
clients = new Map();
|
|
2795
|
+
/** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
|
|
2796
|
+
* session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
|
|
2797
|
+
* once existing runs finish. Controlled via the local control socket:
|
|
2798
|
+
* `pi-web-ui server quiesce|unquiesce`. */
|
|
2799
|
+
quiesced = false;
|
|
2800
|
+
quiescedAt = 0;
|
|
2801
|
+
/** Attached browser sockets (reported by index.ts on open/close) — the
|
|
2802
|
+
* control socket reports real sockets, not cached client-session objects. */
|
|
2803
|
+
socketCount = 0;
|
|
2804
|
+
pending = new Map();
|
|
2805
|
+
stateStore;
|
|
2806
|
+
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
2807
|
+
onQuit = undefined;
|
|
2808
|
+
/** 任意客户端成功切换工作区后触发(新绝对路径)。index.ts 接到
|
|
2809
|
+
* PluginManager.notifyCwd,让插件宿主的 host.cwd 实时跟随当前项目。 */
|
|
2810
|
+
onClientCwdChanged = undefined;
|
|
2811
|
+
constructor(cwd, stateFile) {
|
|
2812
|
+
this.cwd = cwd;
|
|
2813
|
+
this.stateStore = new ClientStateStore(stateFile);
|
|
2814
|
+
}
|
|
2815
|
+
/** Get or create the session for a client, racing attach calls safely. */
|
|
2816
|
+
/** True while the service is draining — new work is refused. */
|
|
2817
|
+
isQuiesced() {
|
|
2818
|
+
return this.quiesced;
|
|
2819
|
+
}
|
|
2820
|
+
/** Enter quiesce: stop admitting new work. Existing runs keep going. */
|
|
2821
|
+
quiesce() {
|
|
2822
|
+
this.quiesced = true;
|
|
2823
|
+
this.quiescedAt = Date.now();
|
|
2824
|
+
}
|
|
2825
|
+
/** Leave quiesce: admit new work again. */
|
|
2826
|
+
unquiesce() {
|
|
2827
|
+
this.quiesced = false;
|
|
2828
|
+
this.quiescedAt = 0;
|
|
2829
|
+
}
|
|
2830
|
+
/** Snapshot for the control socket / status command. */
|
|
2831
|
+
quiesceInfo() {
|
|
2832
|
+
return this.quiesced
|
|
2833
|
+
? { quiesced: true, quiescedSince: this.quiescedAt }
|
|
2834
|
+
: { quiesced: false };
|
|
2835
|
+
}
|
|
2836
|
+
/** Aggregate across every client session: conversations with in-flight runs. */
|
|
2837
|
+
activeConversations() {
|
|
2838
|
+
let n = 0;
|
|
2839
|
+
for (const cs of this.clients.values())
|
|
2840
|
+
n += cs.activeConversations();
|
|
2841
|
+
return n;
|
|
2842
|
+
}
|
|
2843
|
+
/** Aggregate across every client session: messages queued in the SDK. */
|
|
2844
|
+
pendingMessages() {
|
|
2845
|
+
let n = 0;
|
|
2846
|
+
for (const cs of this.clients.values())
|
|
2847
|
+
n += cs.pendingMessages();
|
|
2848
|
+
return n;
|
|
2849
|
+
}
|
|
2850
|
+
/** index.ts calls this when a browser socket opens/closes. */
|
|
2851
|
+
noteSocketOpen() {
|
|
2852
|
+
this.socketCount += 1;
|
|
2853
|
+
}
|
|
2854
|
+
noteSocketClose() {
|
|
2855
|
+
this.socketCount = Math.max(0, this.socketCount - 1);
|
|
2856
|
+
}
|
|
2857
|
+
/** Full status for the control socket / `server status` command. */
|
|
2858
|
+
serviceStatus() {
|
|
2859
|
+
return {
|
|
2860
|
+
pid: process.pid,
|
|
2861
|
+
version: VERSION,
|
|
2862
|
+
cwd: this.cwd,
|
|
2863
|
+
...this.quiesceInfo(),
|
|
2864
|
+
connectedClients: this.socketCount,
|
|
2865
|
+
activeConversations: this.activeConversations(),
|
|
2866
|
+
pendingMessages: this.pendingMessages(),
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
/** Get or create the session for a client, racing attach calls safely. */
|
|
2870
|
+
async attach(clientId, send) {
|
|
2871
|
+
let cs = this.clients.get(clientId);
|
|
2872
|
+
if (!cs) {
|
|
2873
|
+
const inflight = this.pending.get(clientId);
|
|
2874
|
+
if (inflight) {
|
|
2875
|
+
cs = await inflight;
|
|
2876
|
+
}
|
|
2877
|
+
else {
|
|
2878
|
+
// Restore this client's last-used workspace when it still exists;
|
|
2879
|
+
// Admission gate: while quiesced, only clients with an EXISTING
|
|
2880
|
+
// session may attach (they can watch their runs drain); brand-new
|
|
2881
|
+
// clients are refused — index.ts closes their socket (4403) and the
|
|
2882
|
+
// browser reconnect loop retries after admission reopens.
|
|
2883
|
+
if (this.quiesced) {
|
|
2884
|
+
throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
|
|
2885
|
+
}
|
|
2886
|
+
// otherwise fall back to the server's configured default cwd.
|
|
2887
|
+
let cwd = this.cwd;
|
|
2888
|
+
const saved = this.stateStore.get(clientId);
|
|
2889
|
+
if (saved.lastCwd && saved.lastCwd !== this.cwd) {
|
|
2890
|
+
try {
|
|
2891
|
+
if (statSync(saved.lastCwd).isDirectory())
|
|
2892
|
+
cwd = saved.lastCwd;
|
|
2893
|
+
}
|
|
2894
|
+
catch {
|
|
2895
|
+
// gone (unmounted drive / deleted) — fall back to the default
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
// Sessions use the SDK default per-project dir — no per-client dir.
|
|
2899
|
+
const creating = ClientSession.create(clientId, cwd, this.stateStore).finally(() => {
|
|
2900
|
+
this.pending.delete(clientId);
|
|
2901
|
+
});
|
|
2902
|
+
this.pending.set(clientId, creating);
|
|
2903
|
+
cs = await creating;
|
|
2904
|
+
this.clients.set(clientId, cs);
|
|
2905
|
+
// Make sure the restored/default workspace appears in the project list.
|
|
2906
|
+
this.stateStore.remember(clientId, cwd);
|
|
2907
|
+
if (cwd !== this.cwd) {
|
|
2908
|
+
send({
|
|
2909
|
+
type: "notice",
|
|
2910
|
+
level: "info",
|
|
2911
|
+
text: `已恢复上次的工作目录:${cwd}`,
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
// First attach after a restart: report runs that were interrupted when
|
|
2917
|
+
// the previous process shut down (consumed once, then cleared). Queue
|
|
2918
|
+
// BEFORE attachSink so the notice rides the initial pending-notice flush.
|
|
2919
|
+
cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
|
|
2920
|
+
cs.attachSink(send);
|
|
2921
|
+
// Forward hooks (set once by index.ts) to every session.
|
|
2922
|
+
cs.onQuit = this.onQuit;
|
|
2923
|
+
cs.onToolEvent = this.onToolEvent;
|
|
2924
|
+
cs.pluginToolsProvider = this.pluginToolsProvider;
|
|
2925
|
+
cs.pluginCommandsProvider = this.pluginCommandsProvider;
|
|
2926
|
+
cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
|
|
2927
|
+
cs.pluginStopBgTask = this.pluginStopBgTask;
|
|
2928
|
+
cs.isQuiesced = () => this.quiesced;
|
|
2929
|
+
// 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
|
|
2930
|
+
// notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
|
|
2931
|
+
cs.onCwdChanged = (abs) => this.onClientCwdChanged?.(abs);
|
|
2932
|
+
this.onClientCwdChanged?.(cs.cwd);
|
|
2933
|
+
return cs;
|
|
2934
|
+
}
|
|
2935
|
+
/** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
|
|
2936
|
+
applyPluginAgentTools() {
|
|
2937
|
+
for (const cs of this.clients.values())
|
|
2938
|
+
cs.refreshPluginTools();
|
|
2939
|
+
}
|
|
2940
|
+
/** 插件斜杠命令集合变化时由 index.ts 触发:重推各客户端的命令目录。 */
|
|
2941
|
+
applyPluginCommandCatalog() {
|
|
2942
|
+
for (const cs of this.clients.values())
|
|
2943
|
+
void cs.pushSlashCommands();
|
|
2944
|
+
}
|
|
2945
|
+
/** 插件常驻后台任务变化时由 index.ts 触发:重推各客户端的 bg_servers。 */
|
|
2946
|
+
refreshBackgroundServers() {
|
|
2947
|
+
for (const cs of this.clients.values())
|
|
2948
|
+
cs.refreshBgTasks();
|
|
2949
|
+
}
|
|
2950
|
+
/** Remove a socket from a client's broadcast set (called on socket close). */
|
|
2951
|
+
detach(clientId, send) {
|
|
2952
|
+
this.clients.get(clientId)?.detachSink(send);
|
|
2953
|
+
}
|
|
2954
|
+
get(clientId) {
|
|
2955
|
+
return this.clients.get(clientId);
|
|
2956
|
+
}
|
|
2957
|
+
async disposeAll() {
|
|
2958
|
+
// Record still-streaming conversations BEFORE tearing anything down, so
|
|
2959
|
+
// the next attach can tell the user what was lost (SIGTERM / update).
|
|
2960
|
+
for (const [clientId, cs] of [...this.clients]) {
|
|
2961
|
+
try {
|
|
2962
|
+
const running = cs.streamingSummaries();
|
|
2963
|
+
if (running.length > 0) {
|
|
2964
|
+
this.stateStore.saveInterrupted(clientId, running.map((r) => ({ ...r, at: Date.now() })));
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
catch {
|
|
2968
|
+
// best effort — never block shutdown on bookkeeping
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
const all = [...this.clients.values()];
|
|
2972
|
+
this.clients.clear();
|
|
2973
|
+
await Promise.all(all.map((cs) => cs.dispose()));
|
|
2974
|
+
}
|
|
2975
|
+
}
|