pi-web-ui 0.19.1 → 0.20.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.
@@ -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";
@@ -202,6 +202,138 @@ function looksLikeText(buf) {
202
202
  }
203
203
  return control / Math.max(text.length, 1) < 0.02;
204
204
  }
205
+ /** Decode bytes: strict UTF-8 first, falling back to GBK (Windows legacy
206
+ * Chinese files), then latin1 as a last resort — so previews and inline
207
+ * attachments never show mojibake for GBK/GB2312 encoded files. */
208
+ function decodeText(buf) {
209
+ try {
210
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
211
+ }
212
+ catch {
213
+ try {
214
+ return new TextDecoder("gbk").decode(buf);
215
+ }
216
+ catch {
217
+ return buf.toString("latin1");
218
+ }
219
+ }
220
+ }
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
+ }
205
337
  /**
206
338
  * Cheap per-message discriminator for the serialization cache key. Persisted
207
339
  * message content never changes, so this is stable across snapshots, while
@@ -689,6 +821,15 @@ class ClientStateStore {
689
821
  this.save();
690
822
  }
691
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
+ })();
692
833
  /** Cap on simultaneously open conversations of ONE project (each keeps a full
693
834
  * runtime alive; conversations of other projects keep their own lists). */
694
835
  const MAX_OPEN_CONVERSATIONS = 8;
@@ -788,6 +929,23 @@ export class ClientSession {
788
929
  static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
789
930
  /** Absolute deadline for the whole wizard session (model latency guard). */
790
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();
791
949
  /** The active conversation (all session operations target it). */
792
950
  get conv() {
793
951
  const conv = this.convs.get(this.activeId);
@@ -882,9 +1040,28 @@ export class ClientSession {
882
1040
  const services = await createAgentSessionServices({
883
1041
  cwd: effectiveCwd,
884
1042
  modelRuntime: this.sharedModelRuntime,
1043
+ ...(process.platform === "win32"
1044
+ ? {
1045
+ // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端是
1046
+ // 交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
1047
+ // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
1048
+ resourceLoaderOptions: {
1049
+ systemPromptOverride: (base) => base ? `${base}\n\n${WINDOWS_PERSONA}` : WINDOWS_PERSONA,
1050
+ },
1051
+ }
1052
+ : {}),
885
1053
  });
886
1054
  return {
887
- ...(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
+ })),
888
1065
  services,
889
1066
  diagnostics: services.diagnostics,
890
1067
  };
@@ -913,6 +1090,7 @@ export class ClientSession {
913
1090
  queueSteering: 0,
914
1091
  queueFollowUp: 0,
915
1092
  toolStartTimes: new Map(),
1093
+ toolWatchdogs: new Map(),
916
1094
  };
917
1095
  }
918
1096
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
@@ -983,6 +1161,44 @@ export class ClientSession {
983
1161
  this.webUi.refresh();
984
1162
  }, WIDGET_REFRESH_MS);
985
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
+ }
986
1202
  onEvent(conv, event) {
987
1203
  switch (event.type) {
988
1204
  case "bash_execution_update": {
@@ -1000,11 +1216,24 @@ export class ClientSession {
1000
1216
  // Record the moment the tool actually starts so tool_status can
1001
1217
  // report real execution time (vs. time spent waiting on the model).
1002
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);
1003
1227
  break;
1004
1228
  }
1005
1229
  case "tool_execution_end": {
1006
1230
  const startedAt = conv.toolStartTimes.get(event.toolCallId);
1007
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();
1008
1237
  const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
1009
1238
  // The bash tool does not put its exit code in result.details — on
1010
1239
  // failure it throws "Command exited with code N" and the agent
@@ -2211,7 +2440,7 @@ export class ClientSession {
2211
2440
  content: [
2212
2441
  {
2213
2442
  type: "text",
2214
- text: `\n<file path="${wirePath}">\n\`\`\`\n${buf.toString("utf8")}\n\`\`\`\n</file>`,
2443
+ text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
2215
2444
  },
2216
2445
  ],
2217
2446
  display: true,
@@ -2344,7 +2573,7 @@ export class ClientSession {
2344
2573
  content: [
2345
2574
  {
2346
2575
  type: "text",
2347
- text: `\n<file path="${rel}">\n\`\`\`\n${buf.toString("utf8")}\n\`\`\`\n</file>`,
2576
+ text: `\n<file path="${rel}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
2348
2577
  },
2349
2578
  ],
2350
2579
  display: true,
@@ -2396,7 +2625,7 @@ export class ClientSession {
2396
2625
  out.push(makeReference());
2397
2626
  continue;
2398
2627
  }
2399
- const parts = buf.toString("utf8").split("\n");
2628
+ const parts = decodeText(buf).split("\n");
2400
2629
  // A trailing newline yields an empty phantom line — drop it so line
2401
2630
  // numbers match the preview panel.
2402
2631
  if (parts.length > 0 && parts[parts.length - 1] === "")
@@ -2479,9 +2708,123 @@ export class ClientSession {
2479
2708
  }
2480
2709
  return out;
2481
2710
  }
2711
+ /**
2712
+ * Hard-abort the running agent (Stop button / global 中断). Tries
2713
+ * session.abort() first; if the run is not idle within
2714
+ * HARD_ABORT_TIMEOUT_MS (model stream ignoring the abort signal), the
2715
+ * conversation's runtime is force-disposed and recreated from the last
2716
+ * persisted session so the chat ALWAYS comes back usable — never stuck
2717
+ * overnight. The notice fires only on the forced-reset path.
2718
+ */
2482
2719
  async abort() {
2720
+ await this.interruptRun(this.conv, "已停止");
2721
+ // 中断同时清理 AI 在后台启动的服务(npm run dev & 等)——避免用户
2722
+ // 测试时发现端口被占用而不知道是什么进程。
2723
+ const killed = await this.killBackgroundServers();
2724
+ if (killed.length > 0) {
2725
+ this.emit({
2726
+ type: "notice",
2727
+ level: "info",
2728
+ text: `已停止 AI 后台服务:端口 ${killed.join("、")}(进程已结束)`,
2729
+ });
2730
+ }
2731
+ this.flushSnapshot();
2732
+ }
2733
+ /** After a bash tool run, wait briefly for background servers to bind,
2734
+ * then diff the listening-port snapshot against the pre-run one and
2735
+ * remember anything new — those are servers the agent left running. */
2736
+ async trackBackgroundServers() {
2737
+ const before = this.bashListenBefore;
2738
+ this.bashListenBefore = null;
2739
+ if (!before)
2740
+ return;
2741
+ await new Promise((r) => setTimeout(r, 1500));
2742
+ const after = await snapshotListeningPorts();
2743
+ for (const [port, pid] of after) {
2744
+ if (!before.has(port) && !this.bgServers.has(port)) {
2745
+ this.bgServers.set(port, { pid, since: Date.now() });
2746
+ this.emit({
2747
+ type: "notice",
2748
+ level: "info",
2749
+ text: `检测到 AI 启动的后台服务:端口 ${port}(pid ${pid})——点顶栏「中断」可停止`,
2750
+ });
2751
+ }
2752
+ }
2753
+ }
2754
+ /** Kill every background server the agent started; returns the freed ports. */
2755
+ async killBackgroundServers() {
2756
+ if (this.bgServers.size === 0)
2757
+ return [];
2758
+ const killed = [];
2759
+ for (const [port, { pid }] of [...this.bgServers]) {
2760
+ killPidTree(pid);
2761
+ killed.push(String(port));
2762
+ }
2763
+ this.bgServers.clear();
2764
+ return killed;
2765
+ }
2766
+ /** Kill only the running bash command(s) — the agent run itself continues
2767
+ * (the bash tool returns an aborted error and the model moves on). Uses
2768
+ * the per-client AbortController set registered by
2769
+ * makeKillableBashTool. */
2770
+ async abortBash() {
2771
+ if (this.bashKills.size === 0) {
2772
+ this.emit({
2773
+ type: "notice",
2774
+ level: "info",
2775
+ text: "当前没有正在运行的 bash 命令",
2776
+ });
2777
+ this.flushSnapshot();
2778
+ return;
2779
+ }
2780
+ for (const ac of [...this.bashKills])
2781
+ ac.abort();
2782
+ this.emit({
2783
+ type: "notice",
2784
+ level: "info",
2785
+ text: "已停止 bash 命令(对话继续)",
2786
+ });
2787
+ // 让 AI 明确知道是用户手动停止:sendUserMessage 触发下一轮,agent
2788
+ // 会看到「命令被用户中止」而不是普通失败,并据此继续(不会困惑于
2789
+ // 为什么命令失败了)。
2483
2790
  try {
2484
- await this.session.abort();
2791
+ await this.conv.runtime.session.sendUserMessage("(系统:用户手动停止了刚才的 bash 命令——命令被中止,终止前已输出的内容在对应工具结果里。请据此继续,不要重跑被中止的命令,除非确实必要。)");
2792
+ }
2793
+ catch {
2794
+ // best effort — 消息注入失败不影响命令已停止的事实
2795
+ }
2796
+ this.flushSnapshot();
2797
+ }
2798
+ /** Interrupt a run: abort, with a force-reset fallback on timeout. */
2799
+ async interruptRun(conv, reason) {
2800
+ // The run is only truly stopped when its agent_end event arrives:
2801
+ // session.abort() can return without stopping anything when the run is
2802
+ // stuck before the agent even started (e.g. a model stream that never
2803
+ // begins), so we watch for agent_end and force-reset when it never
2804
+ // comes — abort 卡住(超时)或空转(结算窗口)两条路都覆盖。
2805
+ let ended = false;
2806
+ let forced = false;
2807
+ const off = conv.session.subscribe((e) => {
2808
+ if (e.type === "agent_end") {
2809
+ ended = true;
2810
+ }
2811
+ });
2812
+ const force = () => {
2813
+ if (forced)
2814
+ return;
2815
+ forced = true;
2816
+ void this.forceResetConversation(conv, `${reason}:运行未终止,已强制重置当前对话`);
2817
+ };
2818
+ // 1) abort itself hangs (model stream ignores the signal) → hard kill.
2819
+ const abortTimer = setTimeout(() => {
2820
+ if (!ended)
2821
+ force();
2822
+ }, ClientSession.HARD_ABORT_TIMEOUT_MS);
2823
+ abortTimer.unref?.();
2824
+ // 2) abort itself (Stop semantics: kills the process tree, emits
2825
+ // agent_end with stopReason "aborted" on the normal path).
2826
+ try {
2827
+ await conv.runtime.session.abort();
2485
2828
  }
2486
2829
  catch (err) {
2487
2830
  this.emit({
@@ -2490,7 +2833,46 @@ export class ClientSession {
2490
2833
  text: `中止失败:${err.message}`,
2491
2834
  });
2492
2835
  }
2493
- this.flushSnapshot();
2836
+ // 3) abort returned but no agent_end within the settle window → the
2837
+ // run was stuck before it started; force-reset to recover.
2838
+ if (!ended) {
2839
+ await new Promise((r) => setTimeout(r, ClientSession.HARD_ABORT_SETTLE_MS));
2840
+ }
2841
+ clearTimeout(abortTimer);
2842
+ off();
2843
+ if (!ended)
2844
+ force();
2845
+ }
2846
+ /** Force-reset a conversation: dispose the stuck runtime (kills the hung
2847
+ * model stream / child processes) and rebuild it from the most recent
2848
+ * persisted session. The conversation record itself is kept (same id,
2849
+ * same cwd, same serialization caches), so the UI stays attached. */
2850
+ async forceResetConversation(conv, reason) {
2851
+ try {
2852
+ conv.unsubscribe?.();
2853
+ conv.unsubscribe = undefined;
2854
+ this.clearAllToolWatchdogs(conv);
2855
+ conv.toolStartTimes.clear();
2856
+ await conv.runtime.dispose();
2857
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(), {
2858
+ cwd: conv.cwd,
2859
+ agentDir: this.agentDir,
2860
+ sessionManager: SessionManager.continueRecent(conv.cwd),
2861
+ });
2862
+ conv.runtime = runtime;
2863
+ conv.session = runtime.session;
2864
+ this.emit({ type: "notice", level: "warning", text: reason });
2865
+ await this.bindSession();
2866
+ this.emitConversations();
2867
+ void this.pushSlashCommands();
2868
+ }
2869
+ catch (err) {
2870
+ this.emit({
2871
+ type: "notice",
2872
+ level: "error",
2873
+ text: `强制中断失败:${err.message}`,
2874
+ });
2875
+ }
2494
2876
  }
2495
2877
  async newChat() {
2496
2878
  // Reuse an already-open blank conversation instead of piling up new ones
@@ -2590,6 +2972,7 @@ export class ClientSession {
2590
2972
  if (!conv || id === this.activeId)
2591
2973
  return;
2592
2974
  this.convs.delete(id);
2975
+ this.clearAllToolWatchdogs(conv);
2593
2976
  conv.unsubscribe?.();
2594
2977
  conv.unsubscribe = undefined;
2595
2978
  void conv.runtime.dispose().catch(() => { });
@@ -2971,7 +3354,7 @@ export class ClientSession {
2971
3354
  type: "file_content",
2972
3355
  path: rel,
2973
3356
  name,
2974
- text: data.toString("utf8"),
3357
+ text: decodeText(data),
2975
3358
  truncated: bytesRead < stat.size,
2976
3359
  binary: false,
2977
3360
  kind: "text",
@@ -3945,6 +4328,7 @@ export class ClientSession {
3945
4328
  this.unwatchDir();
3946
4329
  this.webUi.dispose();
3947
4330
  for (const conv of this.convs.values()) {
4331
+ this.clearAllToolWatchdogs(conv);
3948
4332
  conv.unsubscribe?.();
3949
4333
  try {
3950
4334
  await conv.runtime.dispose();
@@ -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
+ }
@@ -21,7 +21,7 @@ import { stat } from "node:fs/promises";
21
21
  import { createServer } from "node:http";
22
22
  import { createConnection } from "node:net";
23
23
  import { spawn } from "node:child_process";
24
- import { basename, dirname, join, resolve } from "node:path";
24
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
25
25
  import { homedir } from "node:os";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { randomUUID } from "node:crypto";
@@ -29,6 +29,7 @@ import express from "express";
29
29
  import { WebSocket, WebSocketServer } from "ws";
30
30
  import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
31
31
  import { AgentService, previewKind, workspacePath } from "./agent-service.js";
32
+ import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
32
33
  const PORT = Number(process.env.PORT ?? 8787);
33
34
  const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
34
35
  const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
@@ -36,6 +37,13 @@ const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web
36
37
  // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
37
38
  // honors PI_CODING_AGENT_DIR).
38
39
  const SESSION_DIR_ROOT = join(getAgentDir(), "sessions");
40
+ // Windows 轻量 bash 兜底:把 <home>/.pi-web/bin 前置到 PATH(SDK 的 bash 工具经
41
+ // findBashOnPath 会找到其中的 bash.exe),并在无 Git Bash 时后台下载 busybox-w32。
42
+ // 终端面板的 shell 探测链也已包含该目录(见 terminals.ts resolveShell)。
43
+ if (process.platform === "win32") {
44
+ process.env.PATH = `${windowsBashDir()}${delimiter}${process.env.PATH ?? ""}`;
45
+ void ensureWindowsBash();
46
+ }
39
47
  const app = express();
40
48
  app.use(express.json({ limit: "10mb" }));
41
49
  app.get("/api/health", (_req, res) => {
@@ -209,6 +217,9 @@ wss.on("connection", (ws) => {
209
217
  case "abort":
210
218
  void cs.abort();
211
219
  break;
220
+ case "abort_bash":
221
+ void cs.abortBash();
222
+ break;
212
223
  case "new_chat":
213
224
  void cs.newChat();
214
225
  break;