pi-web-ui 0.19.2 → 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.
- package/dist/server/agent-service.js +366 -12
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/index.js +12 -1
- package/dist/server/terminals.js +44 -9
- package/package.json +1 -1
- package/web/dist/assets/{index-HKKXtrGa.js → index-7EA8G3pP.js} +53 -49
- package/web/dist/assets/index-CosCVJwx.css +41 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CdMheYs9.css +0 -41
|
@@ -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
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
|
|
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
|
|
908
|
-
//
|
|
909
|
-
//
|
|
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${
|
|
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({
|
|
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
|
|
@@ -2509,9 +2708,123 @@ export class ClientSession {
|
|
|
2509
2708
|
}
|
|
2510
2709
|
return out;
|
|
2511
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
|
+
*/
|
|
2512
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
|
+
// 为什么命令失败了)。
|
|
2790
|
+
try {
|
|
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).
|
|
2513
2826
|
try {
|
|
2514
|
-
await
|
|
2827
|
+
await conv.runtime.session.abort();
|
|
2515
2828
|
}
|
|
2516
2829
|
catch (err) {
|
|
2517
2830
|
this.emit({
|
|
@@ -2520,7 +2833,46 @@ export class ClientSession {
|
|
|
2520
2833
|
text: `中止失败:${err.message}`,
|
|
2521
2834
|
});
|
|
2522
2835
|
}
|
|
2523
|
-
|
|
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
|
+
}
|
|
2524
2876
|
}
|
|
2525
2877
|
async newChat() {
|
|
2526
2878
|
// Reuse an already-open blank conversation instead of piling up new ones
|
|
@@ -2620,6 +2972,7 @@ export class ClientSession {
|
|
|
2620
2972
|
if (!conv || id === this.activeId)
|
|
2621
2973
|
return;
|
|
2622
2974
|
this.convs.delete(id);
|
|
2975
|
+
this.clearAllToolWatchdogs(conv);
|
|
2623
2976
|
conv.unsubscribe?.();
|
|
2624
2977
|
conv.unsubscribe = undefined;
|
|
2625
2978
|
void conv.runtime.dispose().catch(() => { });
|
|
@@ -3975,6 +4328,7 @@ export class ClientSession {
|
|
|
3975
4328
|
this.unwatchDir();
|
|
3976
4329
|
this.webUi.dispose();
|
|
3977
4330
|
for (const conv of this.convs.values()) {
|
|
4331
|
+
this.clearAllToolWatchdogs(conv);
|
|
3978
4332
|
conv.unsubscribe?.();
|
|
3979
4333
|
try {
|
|
3980
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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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;
|
package/dist/server/terminals.js
CHANGED
|
@@ -103,16 +103,50 @@ export async function saveCommandsFile(workspaceRoot, commands) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
const isWindows = process.platform === "win32";
|
|
106
|
+
/** `-i` makes bash interactive; cmd.exe / powershell.exe are interactive on their own. */
|
|
107
|
+
function bashArgs(shell) {
|
|
108
|
+
return /[\\/]bash(\.exe)?$/i.test(shell) ? ["-i"] : [];
|
|
109
|
+
}
|
|
106
110
|
/**
|
|
107
|
-
* Interactive shell for PTYs.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
111
|
+
* Interactive shell for PTYs.
|
|
112
|
+
* - Windows: prefer bash — it matches the SDK's bash tool, so the agent and
|
|
113
|
+
* the terminal speak the same shell language (no more PowerShell/bash
|
|
114
|
+
* 混用 that leaves heredocs / `&&` / `<<` hanging or erroring). Order:
|
|
115
|
+
* 1. PI_WEB_SHELL (explicit override)
|
|
116
|
+
* 2. $SHELL when it exists on disk (user launched from a Git Bash session)
|
|
117
|
+
* 3. Git Bash install paths (ProgramFiles / ProgramFiles(x86))
|
|
118
|
+
* 4. busybox-w32 fallback in <home>/.pi-web/bin/bash.exe (ensure-bash.ts
|
|
119
|
+
* downloads it automatically when 2–3 are absent)
|
|
120
|
+
* 5. $COMSPEC (cmd.exe — always set)
|
|
121
|
+
* 6. powershell.exe (last resort)
|
|
122
|
+
* - POSIX: the user's login shell, falling back to bash.
|
|
123
|
+
* Resolved per terminal spawn (not at module load) so a busybox download that
|
|
124
|
+
* finishes after startup is picked up by the next terminal.
|
|
110
125
|
*/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
function resolveShell() {
|
|
127
|
+
if (isWindows) {
|
|
128
|
+
const explicit = process.env.PI_WEB_SHELL;
|
|
129
|
+
if (explicit)
|
|
130
|
+
return { shell: explicit, args: bashArgs(explicit) };
|
|
131
|
+
const she = process.env.SHELL;
|
|
132
|
+
if (she && existsSync(she))
|
|
133
|
+
return { shell: she, args: bashArgs(she) };
|
|
134
|
+
const pf = process.env.ProgramFiles;
|
|
135
|
+
const pf86 = process.env["ProgramFiles(x86)"];
|
|
136
|
+
for (const cand of [
|
|
137
|
+
pf ? join(pf, "Git", "bin", "bash.exe") : "",
|
|
138
|
+
pf86 ? join(pf86, "Git", "bin", "bash.exe") : "",
|
|
139
|
+
]) {
|
|
140
|
+
if (cand && existsSync(cand))
|
|
141
|
+
return { shell: cand, args: ["-i"] };
|
|
142
|
+
}
|
|
143
|
+
const busybox = join(homedir(), ".pi-web", "bin", "bash.exe");
|
|
144
|
+
if (existsSync(busybox))
|
|
145
|
+
return { shell: busybox, args: ["-i"] };
|
|
146
|
+
return { shell: process.env.COMSPEC || "powershell.exe", args: [] };
|
|
147
|
+
}
|
|
148
|
+
return { shell: process.env.SHELL || "bash", args: ["-i"] };
|
|
149
|
+
}
|
|
116
150
|
/**
|
|
117
151
|
* Environment for spawned shells. System services (launchd/systemd) run with
|
|
118
152
|
* no locale variables, which puts the shell in the C locale: its line editor
|
|
@@ -302,7 +336,8 @@ export class TerminalManager {
|
|
|
302
336
|
repairSpawnHelperPermissions();
|
|
303
337
|
let pty;
|
|
304
338
|
try {
|
|
305
|
-
|
|
339
|
+
const { shell, args } = resolveShell();
|
|
340
|
+
pty = spawn(shell, args, {
|
|
306
341
|
name: "xterm-256color",
|
|
307
342
|
cols: Math.max(2, Math.floor(cols) || 80),
|
|
308
343
|
rows: Math.max(2, Math.floor(rows) || 24),
|