@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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. 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
+ }