pi-web-ui 0.19.2 → 0.20.1

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.
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
14
14
  import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
15
15
  import { dirname, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
17
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
19
19
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
20
20
  import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
@@ -218,10 +218,122 @@ function decodeText(buf) {
218
218
  }
219
219
  }
220
220
  }
221
- /** Windows persona appendix: legacy Chinese files are often GBK/GB2312 read
222
- * them via the terminal with the right encoding, never paste mojibake into
223
- * reasoning/answers. Appended to the SDK system prompt on win32 only. */
224
- const WINDOWS_GBK_PERSONA = `You are a coding agent running on Windows. 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.`;
221
+ /** Windows persona appendix appended to the SDK system prompt on win32 only.
222
+ * Two failure modes it guards against: (1) the SDK bash tool has NO default
223
+ * timeout, so a long-running command hangs the whole conversation forever;
224
+ * (2) the in-app terminal is an interactive TTY where heredocs / interactive
225
+ * programs wait for input that never comes. Legacy Chinese files are often
226
+ * GBK/GB2312 — read them with the right encoding, never paste mojibake into
227
+ * reasoning/answers. */
228
+ 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:
229
+
230
+
231
+
232
+ - 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.
233
+ - 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.
234
+ - 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\`).
235
+
236
+ 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.`;
237
+ /**
238
+ * Killable bash tool: wraps the SDK bash tool with operations that register
239
+ * their own AbortController into a client-level set. abortBash() aborts only
240
+ * those controllers → the command's process tree is killed while the agent
241
+ * run and the conversation continue (the tool returns an aborted error and
242
+ * the model moves on). Injected as a customTool overriding the builtin bash.
243
+ */
244
+ function makeKillableBashTool(cwd, kills) {
245
+ const base = createLocalBashOperations();
246
+ const tool = createBashTool(cwd, {
247
+ operations: {
248
+ exec: async (command, c, opts) => {
249
+ const ac = new AbortController();
250
+ kills.add(ac);
251
+ try {
252
+ const signals = [opts.signal, ac.signal].filter((s) => s !== undefined);
253
+ return await base.exec(command, c, {
254
+ ...opts,
255
+ signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0],
256
+ });
257
+ }
258
+ finally {
259
+ kills.delete(ac);
260
+ }
261
+ },
262
+ },
263
+ });
264
+ // AgentTool → ToolDefinition (same fields; customTools expects definitions).
265
+ return {
266
+ name: tool.name,
267
+ label: tool.label,
268
+ description: tool.description,
269
+ parameters: tool.parameters,
270
+ prepareArguments: tool.prepareArguments,
271
+ executionMode: tool.executionMode,
272
+ execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
273
+ };
274
+ }
275
+ /**
276
+ * Snapshot currently LISTENING TCP ports → owning pid. Windows: netstat;
277
+ * POSIX: lsof. Used to detect servers the agent started in the background
278
+ * (the bash tool itself exits, leaving e.g. `npm run dev &` listening).
279
+ */
280
+ async function snapshotListeningPorts() {
281
+ const m = new Map();
282
+ try {
283
+ const { execFile } = await import("node:child_process");
284
+ if (process.platform === "win32") {
285
+ const out = await new Promise((resolve, reject) => execFile("netstat", ["-ano", "-p", "tcp"], { windowsHide: true, timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
286
+ for (const line of out.split(/\r?\n/)) {
287
+ const p = line.trim().split(/\s+/);
288
+ // TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 12345
289
+ if (p.length >= 5 && p[0] === "TCP" && p[3] === "LISTENING") {
290
+ const port = Number(p[1].split(":").pop());
291
+ const pid = Number(p[4]);
292
+ if (Number.isFinite(port) && Number.isFinite(pid))
293
+ m.set(port, pid);
294
+ }
295
+ }
296
+ }
297
+ else {
298
+ const out = await new Promise((resolve, reject) => execFile("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n"], { timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
299
+ for (const line of out.split(/\r?\n/).slice(1)) {
300
+ const p = line.trim().split(/\s+/);
301
+ if (p.length >= 9) {
302
+ // NAME column tail: "*:5173 (LISTEN)" or "[::1]:5173 (LISTEN)"
303
+ const mm = (p[p.length - 1] ?? "").match(/(\d+)\)?\s*$/);
304
+ const port = mm ? Number(mm[1]) : NaN;
305
+ const pid = Number(p[1]);
306
+ if (Number.isFinite(port) && Number.isFinite(pid))
307
+ m.set(port, pid);
308
+ }
309
+ }
310
+ }
311
+ }
312
+ catch {
313
+ // best effort — snapshot failure just means no tracking this round
314
+ }
315
+ return m;
316
+ }
317
+ /** Kill a pid and its whole process tree (cross-platform). */
318
+ function killPidTree(pid) {
319
+ try {
320
+ if (process.platform === "win32") {
321
+ void import("node:child_process").then(({ spawn }) => {
322
+ spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
323
+ stdio: "ignore",
324
+ detached: true,
325
+ windowsHide: true,
326
+ }).unref();
327
+ });
328
+ }
329
+ else {
330
+ process.kill(-pid, "SIGKILL");
331
+ }
332
+ }
333
+ catch {
334
+ // already dead
335
+ }
336
+ }
225
337
  /**
226
338
  * Cheap per-message discriminator for the serialization cache key. Persisted
227
339
  * message content never changes, so this is stable across snapshots, while
@@ -709,6 +821,15 @@ class ClientStateStore {
709
821
  this.save();
710
822
  }
711
823
  }
824
+ /** Hard cap on how long ONE tool call may run before the watchdog aborts the
825
+ * session. The SDK bash tool has NO default timeout, so a command that never
826
+ * finishes (servers, watchers, infinite loops) would otherwise hang the whole
827
+ * conversation indefinitely. Override with the PI_WEB_TOOL_TIMEOUT_MS env var
828
+ * (milliseconds). */
829
+ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
830
+ const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
831
+ return Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
832
+ })();
712
833
  /** Cap on simultaneously open conversations of ONE project (each keeps a full
713
834
  * runtime alive; conversations of other projects keep their own lists). */
714
835
  const MAX_OPEN_CONVERSATIONS = 8;
@@ -808,6 +929,23 @@ export class ClientSession {
808
929
  static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
809
930
  /** Absolute deadline for the whole wizard session (model latency guard). */
810
931
  static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
932
+ /** How long a hard abort waits for session.abort() to make the run idle
933
+ * before force-resetting the conversation (model streams that ignore the
934
+ * abort signal would otherwise leave the chat stuck forever). */
935
+ static HARD_ABORT_TIMEOUT_MS = 15_000;
936
+ /** Extra settle window after session.abort() returns: the run is only
937
+ * considered stopped once its agent_end event arrives. If it doesn't
938
+ * (model stream stuck before the run even started), force-reset. */
939
+ static HARD_ABORT_SETTLE_MS = 8_000;
940
+ /** Live AbortControllers of THIS client's running bash tool calls — aborting
941
+ * them kills only the command (agent run and conversation continue). */
942
+ bashKills = new Set();
943
+ /** LISTENING-port snapshot taken when the current bash tool started — the
944
+ * end-of-execution diff reveals servers the agent left running in the
945
+ * background (e.g. `npm run dev &`). Keyed by port → pid. */
946
+ bashListenBefore = null;
947
+ /** Background servers the agent started (port → pid). 「中断」kills them. */
948
+ bgServers = new Map();
811
949
  /** The active conversation (all session operations target it). */
812
950
  get conv() {
813
951
  const conv = this.convs.get(this.activeId);
@@ -904,17 +1042,26 @@ export class ClientSession {
904
1042
  modelRuntime: this.sharedModelRuntime,
905
1043
  ...(process.platform === "win32"
906
1044
  ? {
907
- // Windows 上很多老中文文件(.html/.txt/.md/.log、导出文档)是
908
- // GBK/GB2312 编码,read 工具按 UTF-8 读会乱码——注入 persona 让模型
909
- // 改用终端按 GBK 读,且绝不把乱码贴进推理/回答。
1045
+ // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端是
1046
+ // 交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
1047
+ // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
910
1048
  resourceLoaderOptions: {
911
- systemPromptOverride: (base) => base ? `${base}\n\n${WINDOWS_GBK_PERSONA}` : WINDOWS_GBK_PERSONA,
1049
+ systemPromptOverride: (base) => base ? `${base}\n\n${WINDOWS_PERSONA}` : WINDOWS_PERSONA,
912
1050
  },
913
1051
  }
914
1052
  : {}),
915
1053
  });
916
1054
  return {
917
- ...(await createAgentSessionFromServices({ services, sessionManager })),
1055
+ ...(await createAgentSessionFromServices({
1056
+ services,
1057
+ sessionManager,
1058
+ // 可手动停止的 bash 工具:覆盖 SDK 内置 bash(customTools 按 name
1059
+ // 覆盖),执行时把自己的 AbortController 注册进客户端集合——
1060
+ // abortBash() 只杀这些命令,agent run 与对话继续。
1061
+ customTools: [
1062
+ makeKillableBashTool(effectiveCwd, this.bashKills),
1063
+ ],
1064
+ })),
918
1065
  services,
919
1066
  diagnostics: services.diagnostics,
920
1067
  };
@@ -943,6 +1090,7 @@ export class ClientSession {
943
1090
  queueSteering: 0,
944
1091
  queueFollowUp: 0,
945
1092
  toolStartTimes: new Map(),
1093
+ toolWatchdogs: new Map(),
946
1094
  };
947
1095
  }
948
1096
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
@@ -1013,6 +1161,44 @@ export class ClientSession {
1013
1161
  this.webUi.refresh();
1014
1162
  }, WIDGET_REFRESH_MS);
1015
1163
  }
1164
+ /** Arm the hang-guard for a tool call: if it is still running after
1165
+ * TOOL_WATCHDOG_TIMEOUT_MS, abort the session instead of letting the
1166
+ * conversation hang forever (the SDK bash tool has no default timeout). */
1167
+ armToolWatchdog(conv, toolCallId) {
1168
+ const t = setTimeout(() => {
1169
+ conv.toolWatchdogs.delete(toolCallId);
1170
+ // The tool finished before the deadline — nothing to do.
1171
+ if (!conv.toolStartTimes.has(toolCallId))
1172
+ return;
1173
+ this.emit({
1174
+ type: "notice",
1175
+ level: "warning",
1176
+ text: `工具执行超过 ${Math.round(TOOL_WATCHDOG_TIMEOUT_MS / 60_000)} 分钟,已自动终止(防止挂死)。可调整超时:环境变量 PI_WEB_TOOL_TIMEOUT_MS(毫秒)。`,
1177
+ });
1178
+ conv.toolStartTimes.delete(toolCallId);
1179
+ // Abort the run (kills the process tree via the SDK's abort signal);
1180
+ // agent_end will fire with stopReason "aborted" and existing logic
1181
+ // clears any goal / review loop. interruptRun adds a force-reset
1182
+ // fallback in case the model stream ignores the abort signal.
1183
+ void this.interruptRun(conv, "工具执行超时");
1184
+ }, TOOL_WATCHDOG_TIMEOUT_MS);
1185
+ t.unref?.();
1186
+ conv.toolWatchdogs.set(toolCallId, t);
1187
+ }
1188
+ /** Cancel a tool's watchdog — called when the tool finishes normally. */
1189
+ clearToolWatchdog(conv, toolCallId) {
1190
+ const t = conv.toolWatchdogs.get(toolCallId);
1191
+ if (t) {
1192
+ clearTimeout(t);
1193
+ conv.toolWatchdogs.delete(toolCallId);
1194
+ }
1195
+ }
1196
+ /** Cancel every watchdog of a conversation (removeConversation / dispose). */
1197
+ clearAllToolWatchdogs(conv) {
1198
+ for (const t of conv.toolWatchdogs.values())
1199
+ clearTimeout(t);
1200
+ conv.toolWatchdogs.clear();
1201
+ }
1016
1202
  onEvent(conv, event) {
1017
1203
  switch (event.type) {
1018
1204
  case "bash_execution_update": {
@@ -1030,11 +1216,24 @@ export class ClientSession {
1030
1216
  // Record the moment the tool actually starts so tool_status can
1031
1217
  // report real execution time (vs. time spent waiting on the model).
1032
1218
  conv.toolStartTimes.set(event.toolCallId, Date.now());
1219
+ // Snapshot listeners before a bash run — the post-run diff catches
1220
+ // servers the agent started in the background.
1221
+ if (event.toolName === "bash") {
1222
+ void snapshotListeningPorts().then((m) => {
1223
+ this.bashListenBefore = m;
1224
+ });
1225
+ }
1226
+ this.armToolWatchdog(conv, event.toolCallId);
1033
1227
  break;
1034
1228
  }
1035
1229
  case "tool_execution_end": {
1036
1230
  const startedAt = conv.toolStartTimes.get(event.toolCallId);
1037
1231
  conv.toolStartTimes.delete(event.toolCallId);
1232
+ this.clearToolWatchdog(conv, event.toolCallId);
1233
+ // Bash finished — wait briefly for background servers to bind their
1234
+ // ports, then diff against the pre-run snapshot and record them.
1235
+ if (event.toolName === "bash")
1236
+ void this.trackBackgroundServers();
1038
1237
  const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
1039
1238
  // The bash tool does not put its exit code in result.details — on
1040
1239
  // failure it throws "Command exited with code N" and the agent
@@ -1366,6 +1565,8 @@ export class ClientSession {
1366
1565
  * the process is going to restart itself (so the notice can say so).
1367
1566
  */
1368
1567
  onUpdateReady = undefined;
1568
+ /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
1569
+ onQuit = undefined;
1369
1570
  /** Ask the npm registry for the latest pi-web-ui version and report it. */
1370
1571
  async checkUpdate() {
1371
1572
  const current = ClientSession.currentAppVersion();
@@ -1839,19 +2040,22 @@ export class ClientSession {
1839
2040
  * (they never reach the server) but stay listed so the picker shows them.
1840
2041
  */
1841
2042
  static NATIVE_COMMANDS = [
1842
- { name: "new", description: "新建对话" },
1843
- { name: "model", description: "切换模型", argumentHint: "[名称]" },
1844
- { name: "compact", description: "压缩上下文", argumentHint: "[说明]" },
1845
- { name: "cwd", description: "切换工作目录", argumentHint: "<路径>" },
2043
+ { name: "new", description: "新建对话", descriptionEn: "New chat" },
2044
+ { name: "model", description: "切换模型", descriptionEn: "Switch model", argumentHint: "[名称]", argumentHintEn: "[name]" },
2045
+ { name: "compact", description: "压缩上下文", descriptionEn: "Compact context", argumentHint: "[说明]", argumentHintEn: "[instructions]" },
2046
+ { name: "cwd", description: "切换工作目录", descriptionEn: "Switch workspace", argumentHint: "<路径>", argumentHintEn: "<path>" },
1846
2047
  {
1847
2048
  name: "thinking",
1848
2049
  description: "设置思考强度",
1849
- argumentHint: "<off|low|medium|high>",
2050
+ descriptionEn: "Set thinking level",
2051
+ argumentHint: "<off|low|medium|high|xhigh|max>",
2052
+ argumentHintEn: "<off|low|medium|high|xhigh|max>",
1850
2053
  },
1851
- { name: "resume", description: "刷新会话列表" },
1852
- { name: "reload", description: "重新加载扩展、技能与模板" },
1853
- { name: "help", description: "显示全部命令" },
1854
- { name: "copy", description: "复制上一条助手回复" },
2054
+ { name: "resume", description: "刷新会话列表", descriptionEn: "Refresh session list" },
2055
+ { name: "reload", description: "重新加载扩展、技能与模板", descriptionEn: "Reload extensions, skills & templates" },
2056
+ { name: "help", description: "显示全部命令", descriptionEn: "Show all commands" },
2057
+ { name: "copy", description: "复制上一条助手回复", descriptionEn: "Copy last assistant reply" },
2058
+ { name: "pi-web-ui:quit", description: "退出服务", descriptionEn: "Quit server (supervisor will restart)" },
1855
2059
  ];
1856
2060
  /** Parse a prompt into "/command args" — returns null when it isn't one. */
1857
2061
  parseSlash(text) {
@@ -1991,6 +2195,20 @@ export class ClientSession {
1991
2195
  });
1992
2196
  }
1993
2197
  return true;
2198
+ case "pi-web-ui:quit": {
2199
+ this.emit({
2200
+ type: "notice",
2201
+ level: "info",
2202
+ text: "正在退出 pi-web-ui… supervisor 将自动重启服务",
2203
+ });
2204
+ setTimeout(() => {
2205
+ const didSchedule = this.onQuit?.() ?? false;
2206
+ if (!didSchedule) {
2207
+ setTimeout(() => process.exit(0), 100);
2208
+ }
2209
+ }, 300);
2210
+ return true;
2211
+ }
1994
2212
  case "help":
1995
2213
  case "copy":
1996
2214
  // Client-side UI actions — the client handles them before sending;
@@ -2509,9 +2727,123 @@ export class ClientSession {
2509
2727
  }
2510
2728
  return out;
2511
2729
  }
2730
+ /**
2731
+ * Hard-abort the running agent (Stop button / global 中断). Tries
2732
+ * session.abort() first; if the run is not idle within
2733
+ * HARD_ABORT_TIMEOUT_MS (model stream ignoring the abort signal), the
2734
+ * conversation's runtime is force-disposed and recreated from the last
2735
+ * persisted session so the chat ALWAYS comes back usable — never stuck
2736
+ * overnight. The notice fires only on the forced-reset path.
2737
+ */
2512
2738
  async abort() {
2739
+ await this.interruptRun(this.conv, "已停止");
2740
+ // 中断同时清理 AI 在后台启动的服务(npm run dev & 等)——避免用户
2741
+ // 测试时发现端口被占用而不知道是什么进程。
2742
+ const killed = await this.killBackgroundServers();
2743
+ if (killed.length > 0) {
2744
+ this.emit({
2745
+ type: "notice",
2746
+ level: "info",
2747
+ text: `已停止 AI 后台服务:端口 ${killed.join("、")}(进程已结束)`,
2748
+ });
2749
+ }
2750
+ this.flushSnapshot();
2751
+ }
2752
+ /** After a bash tool run, wait briefly for background servers to bind,
2753
+ * then diff the listening-port snapshot against the pre-run one and
2754
+ * remember anything new — those are servers the agent left running. */
2755
+ async trackBackgroundServers() {
2756
+ const before = this.bashListenBefore;
2757
+ this.bashListenBefore = null;
2758
+ if (!before)
2759
+ return;
2760
+ await new Promise((r) => setTimeout(r, 1500));
2761
+ const after = await snapshotListeningPorts();
2762
+ for (const [port, pid] of after) {
2763
+ if (!before.has(port) && !this.bgServers.has(port)) {
2764
+ this.bgServers.set(port, { pid, since: Date.now() });
2765
+ this.emit({
2766
+ type: "notice",
2767
+ level: "info",
2768
+ text: `检测到 AI 启动的后台服务:端口 ${port}(pid ${pid})——点顶栏「中断」可停止`,
2769
+ });
2770
+ }
2771
+ }
2772
+ }
2773
+ /** Kill every background server the agent started; returns the freed ports. */
2774
+ async killBackgroundServers() {
2775
+ if (this.bgServers.size === 0)
2776
+ return [];
2777
+ const killed = [];
2778
+ for (const [port, { pid }] of [...this.bgServers]) {
2779
+ killPidTree(pid);
2780
+ killed.push(String(port));
2781
+ }
2782
+ this.bgServers.clear();
2783
+ return killed;
2784
+ }
2785
+ /** Kill only the running bash command(s) — the agent run itself continues
2786
+ * (the bash tool returns an aborted error and the model moves on). Uses
2787
+ * the per-client AbortController set registered by
2788
+ * makeKillableBashTool. */
2789
+ async abortBash() {
2790
+ if (this.bashKills.size === 0) {
2791
+ this.emit({
2792
+ type: "notice",
2793
+ level: "info",
2794
+ text: "当前没有正在运行的 bash 命令",
2795
+ });
2796
+ this.flushSnapshot();
2797
+ return;
2798
+ }
2799
+ for (const ac of [...this.bashKills])
2800
+ ac.abort();
2801
+ this.emit({
2802
+ type: "notice",
2803
+ level: "info",
2804
+ text: "已停止 bash 命令(对话继续)",
2805
+ });
2806
+ // 让 AI 明确知道是用户手动停止:sendUserMessage 触发下一轮,agent
2807
+ // 会看到「命令被用户中止」而不是普通失败,并据此继续(不会困惑于
2808
+ // 为什么命令失败了)。
2809
+ try {
2810
+ await this.conv.runtime.session.sendUserMessage("(系统:用户手动停止了刚才的 bash 命令——命令被中止,终止前已输出的内容在对应工具结果里。请据此继续,不要重跑被中止的命令,除非确实必要。)");
2811
+ }
2812
+ catch {
2813
+ // best effort — 消息注入失败不影响命令已停止的事实
2814
+ }
2815
+ this.flushSnapshot();
2816
+ }
2817
+ /** Interrupt a run: abort, with a force-reset fallback on timeout. */
2818
+ async interruptRun(conv, reason) {
2819
+ // The run is only truly stopped when its agent_end event arrives:
2820
+ // session.abort() can return without stopping anything when the run is
2821
+ // stuck before the agent even started (e.g. a model stream that never
2822
+ // begins), so we watch for agent_end and force-reset when it never
2823
+ // comes — abort 卡住(超时)或空转(结算窗口)两条路都覆盖。
2824
+ let ended = false;
2825
+ let forced = false;
2826
+ const off = conv.session.subscribe((e) => {
2827
+ if (e.type === "agent_end") {
2828
+ ended = true;
2829
+ }
2830
+ });
2831
+ const force = () => {
2832
+ if (forced)
2833
+ return;
2834
+ forced = true;
2835
+ void this.forceResetConversation(conv, `${reason}:运行未终止,已强制重置当前对话`);
2836
+ };
2837
+ // 1) abort itself hangs (model stream ignores the signal) → hard kill.
2838
+ const abortTimer = setTimeout(() => {
2839
+ if (!ended)
2840
+ force();
2841
+ }, ClientSession.HARD_ABORT_TIMEOUT_MS);
2842
+ abortTimer.unref?.();
2843
+ // 2) abort itself (Stop semantics: kills the process tree, emits
2844
+ // agent_end with stopReason "aborted" on the normal path).
2513
2845
  try {
2514
- await this.session.abort();
2846
+ await conv.runtime.session.abort();
2515
2847
  }
2516
2848
  catch (err) {
2517
2849
  this.emit({
@@ -2520,7 +2852,46 @@ export class ClientSession {
2520
2852
  text: `中止失败:${err.message}`,
2521
2853
  });
2522
2854
  }
2523
- this.flushSnapshot();
2855
+ // 3) abort returned but no agent_end within the settle window → the
2856
+ // run was stuck before it started; force-reset to recover.
2857
+ if (!ended) {
2858
+ await new Promise((r) => setTimeout(r, ClientSession.HARD_ABORT_SETTLE_MS));
2859
+ }
2860
+ clearTimeout(abortTimer);
2861
+ off();
2862
+ if (!ended)
2863
+ force();
2864
+ }
2865
+ /** Force-reset a conversation: dispose the stuck runtime (kills the hung
2866
+ * model stream / child processes) and rebuild it from the most recent
2867
+ * persisted session. The conversation record itself is kept (same id,
2868
+ * same cwd, same serialization caches), so the UI stays attached. */
2869
+ async forceResetConversation(conv, reason) {
2870
+ try {
2871
+ conv.unsubscribe?.();
2872
+ conv.unsubscribe = undefined;
2873
+ this.clearAllToolWatchdogs(conv);
2874
+ conv.toolStartTimes.clear();
2875
+ await conv.runtime.dispose();
2876
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
2877
+ cwd: conv.cwd,
2878
+ agentDir: this.agentDir,
2879
+ sessionManager: SessionManager.continueRecent(conv.cwd),
2880
+ });
2881
+ conv.runtime = runtime;
2882
+ conv.session = runtime.session;
2883
+ this.emit({ type: "notice", level: "warning", text: reason });
2884
+ await this.bindSession();
2885
+ this.emitConversations();
2886
+ void this.pushSlashCommands();
2887
+ }
2888
+ catch (err) {
2889
+ this.emit({
2890
+ type: "notice",
2891
+ level: "error",
2892
+ text: `强制中断失败:${err.message}`,
2893
+ });
2894
+ }
2524
2895
  }
2525
2896
  async newChat() {
2526
2897
  // Reuse an already-open blank conversation instead of piling up new ones
@@ -2620,6 +2991,7 @@ export class ClientSession {
2620
2991
  if (!conv || id === this.activeId)
2621
2992
  return;
2622
2993
  this.convs.delete(id);
2994
+ this.clearAllToolWatchdogs(conv);
2623
2995
  conv.unsubscribe?.();
2624
2996
  conv.unsubscribe = undefined;
2625
2997
  void conv.runtime.dispose().catch(() => { });
@@ -3975,6 +4347,7 @@ export class ClientSession {
3975
4347
  this.unwatchDir();
3976
4348
  this.webUi.dispose();
3977
4349
  for (const conv of this.convs.values()) {
4350
+ this.clearAllToolWatchdogs(conv);
3978
4351
  conv.unsubscribe?.();
3979
4352
  try {
3980
4353
  await conv.runtime.dispose();
@@ -3995,6 +4368,8 @@ export class AgentService {
3995
4368
  * self-update; returns whether the process will restart itself.
3996
4369
  */
3997
4370
  onUpdateReady = undefined;
4371
+ /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
4372
+ onQuit = undefined;
3998
4373
  constructor(cwd, stateFile) {
3999
4374
  this.cwd = cwd;
4000
4375
  this.stateStore = new ClientStateStore(stateFile);
@@ -4040,8 +4415,9 @@ export class AgentService {
4040
4415
  }
4041
4416
  }
4042
4417
  cs.attachSink(send);
4043
- // Forward the update hook (set once by index.ts) to every session.
4418
+ // Forward hooks (set once by index.ts) to every session.
4044
4419
  cs.onUpdateReady = this.onUpdateReady;
4420
+ cs.onQuit = this.onQuit;
4045
4421
  return cs;
4046
4422
  }
4047
4423
  /** Remove a socket from a client's broadcast set (called on socket close). */
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Lightweight bash fallback for Windows.
3
+ *
4
+ * When neither Git Bash nor a bash on PATH exists, download busybox-w32
5
+ * (single self-contained ~1.5MB exe, no installer) into <home>/.pi-web/bin/
6
+ * and expose it as bash.exe — busybox dispatches on argv[0], so `bash.exe`
7
+ * runs its bash (ash) applet. The terminal panel (terminals.ts) and the SDK
8
+ * bash tool (via PATH) then both resolve to it, so the agent never silently
9
+ * falls back to cmd/PowerShell syntax on a bare Windows box.
10
+ *
11
+ * Download is fire-and-forget at server start and never throws: on failure
12
+ * the terminal simply falls back to $COMSPEC (cmd.exe) as before.
13
+ */
14
+ import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ /** Official busybox-w32 64-bit Unicode build (Win10 1903+ / Win11). */
18
+ const BUSYBOX_URL = "https://frippery.org/files/busybox/busybox64u.exe";
19
+ /** busybox.exe is ~660KB; anything far smaller is an error page, not a binary. */
20
+ const MIN_SIZE = 500_000;
21
+ /** Download cap — a stalled connection must not block startup forever. */
22
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
23
+ /** Directory holding the busybox fallback (shared with terminals.ts / PATH). */
24
+ export function windowsBashDir() {
25
+ return join(homedir(), ".pi-web", "bin");
26
+ }
27
+ /** bash.exe (busybox bash applet) used by the terminal and the SDK bash tool. */
28
+ export function windowsBashPath() {
29
+ return join(windowsBashDir(), "bash.exe");
30
+ }
31
+ /** True when a standard Git Bash install exists (SDK's preferred shell). */
32
+ export function hasGitBash() {
33
+ const pf = process.env.ProgramFiles;
34
+ const pf86 = process.env["ProgramFiles(x86)"];
35
+ for (const cand of [
36
+ pf ? join(pf, "Git", "bin", "bash.exe") : "",
37
+ pf86 ? join(pf86, "Git", "bin", "bash.exe") : "",
38
+ ]) {
39
+ if (cand && existsSync(cand))
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ /**
45
+ * Ensure a bash.exe exists on Windows. No-op when Git Bash is already
46
+ * installed (it is strictly preferred) or the fallback is already present.
47
+ * Never throws — failures degrade silently to the previous behaviour.
48
+ *
49
+ * @returns the bash.exe path when ready, null otherwise.
50
+ */
51
+ export async function ensureWindowsBash() {
52
+ if (process.platform !== "win32")
53
+ return null;
54
+ const target = windowsBashPath();
55
+ if (existsSync(target))
56
+ return target;
57
+ if (hasGitBash())
58
+ return null; // Git Bash preferred — nothing to install.
59
+ const dir = windowsBashDir();
60
+ mkdirSync(dir, { recursive: true });
61
+ const tmp = join(dir, `busybox-${process.pid}.tmp`);
62
+ try {
63
+ const res = await fetch(BUSYBOX_URL, {
64
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
65
+ });
66
+ if (!res.ok)
67
+ return null;
68
+ const buf = Buffer.from(await res.arrayBuffer());
69
+ if (buf.length < MIN_SIZE)
70
+ return null; // error/HTML page, not the exe.
71
+ writeFileSync(tmp, buf);
72
+ renameSync(tmp, join(dir, "busybox.exe"));
73
+ copyFileSync(join(dir, "busybox.exe"), target);
74
+ return target;
75
+ }
76
+ catch {
77
+ rmSync(tmp, { force: true });
78
+ return null;
79
+ }
80
+ }