pi-web-ui 0.95.0 → 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/bin/pi-web-ui.mjs +159 -31
  3. package/dist/server/agent-service.js +880 -240
  4. package/dist/server/approval-rules.js +56 -10
  5. package/dist/server/attachment-store.js +21 -4
  6. package/dist/server/attachments.js +30 -3
  7. package/dist/server/bg-servers.js +45 -7
  8. package/dist/server/claim-files-tool.js +4 -8
  9. package/dist/server/claim-store.js +4 -2
  10. package/dist/server/client-state.js +63 -14
  11. package/dist/server/compact-context-tool.js +6 -5
  12. package/dist/server/composer-drafts.js +9 -0
  13. package/dist/server/control-socket.js +57 -28
  14. package/dist/server/conversation-read-tool.js +7 -17
  15. package/dist/server/dangling-tools.js +72 -5
  16. package/dist/server/delegate-task.js +25 -28
  17. package/dist/server/dsh/dsh-agent-service.js +19 -6
  18. package/dist/server/edit-soft-tool.js +14 -11
  19. package/dist/server/eval-tool.js +67 -14
  20. package/dist/server/file-archives.js +16 -5
  21. package/dist/server/files-service.js +30 -11
  22. package/dist/server/goal-service.js +141 -19
  23. package/dist/server/hashline-engine.js +59 -21
  24. package/dist/server/host-guard.js +94 -0
  25. package/dist/server/i18n.js +3 -3
  26. package/dist/server/index.js +306 -78
  27. package/dist/server/lsp-tool.js +752 -60
  28. package/dist/server/mcp-bridge.js +47 -3
  29. package/dist/server/model-admin.js +94 -34
  30. package/dist/server/office-parse.js +96 -30
  31. package/dist/server/patch-tool.js +9 -15
  32. package/dist/server/permission-preset.js +25 -0
  33. package/dist/server/plan-manager.js +11 -1
  34. package/dist/server/plugin-catalog-sync.js +36 -19
  35. package/dist/server/plugin-catalog.js +11 -4
  36. package/dist/server/plugin-facilities.js +92 -21
  37. package/dist/server/plugin-llm.js +71 -65
  38. package/dist/server/plugin-project.js +70 -2
  39. package/dist/server/plugin-updater.js +115 -12
  40. package/dist/server/plugins.js +205 -44
  41. package/dist/server/present-files-tool.js +9 -12
  42. package/dist/server/process-utils.js +4 -0
  43. package/dist/server/prompt-composer.js +9 -0
  44. package/dist/server/protocol-version.js +1 -1
  45. package/dist/server/read-tool.js +69 -30
  46. package/dist/server/resolve-global-sdk.js +30 -16
  47. package/dist/server/schedule-agent-tool.js +12 -15
  48. package/dist/server/sdk-origin.js +18 -2
  49. package/dist/server/serialize.js +108 -21
  50. package/dist/server/settings-service.js +4 -2
  51. package/dist/server/skill-tool.js +5 -7
  52. package/dist/server/subagent-templates.js +51 -0
  53. package/dist/server/subagents.js +48 -72
  54. package/dist/server/terminals.js +181 -69
  55. package/dist/server/tool-manager.js +24 -7
  56. package/dist/server/tool-overrides.js +59 -0
  57. package/dist/server/update-check.js +28 -1
  58. package/dist/server/uploads.js +17 -2
  59. package/dist/server/wait-subscription-scan.js +18 -21
  60. package/dist/server/ws-client-id.js +29 -0
  61. package/dist/server/ws-pending-queue.js +60 -0
  62. package/extensions/webui.ts +60 -2
  63. package/package.json +4 -2
  64. package/plugins/catalog.json +20 -0
  65. package/themes/aetheris.css +457 -0
  66. package/themes/zhupi-dark.css +658 -0
  67. package/themes/zhupi.css +711 -0
  68. package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-DDChcYOh.js} +1 -1
  69. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  70. package/web/dist/assets/index-DryOsILO.css +1 -0
  71. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  72. package/web/dist/index.html +3 -3
  73. package/web/dist/assets/index-BXnCQL9p.js +0 -364
  74. package/web/dist/assets/index-RpIGFyi_.css +0 -1
@@ -24,7 +24,7 @@ import "./patch-node-pty.js";
24
24
  import { spawn } from "node-pty";
25
25
  import { defineTool } from "@earendil-works/pi-coding-agent";
26
26
  import { Type } from "typebox";
27
- import { bilingual, pick } from "./i18n.js";
27
+ import { pick } from "./i18n.js";
28
28
  /** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
29
29
  export function commandsFilePath(workspaceRoot) {
30
30
  return join(workspaceRoot, ".pi", "commands.json");
@@ -147,8 +147,10 @@ export function terminalIdleNotifyLines() {
147
147
  /** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
148
148
  * 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
149
149
  const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
150
+ /** bash 阻塞循环 collected 的累积上限:超限丢最旧一半(见 bash 工具执行循环)。 */
151
+ const BASH_COLLECT_MAX = 2 * 1024 * 1024;
150
152
  /**
151
- * 把任意命令(含多行脚本)构造成「一行」交互 shell 命令:执行 + 捕获退出码。
153
+ * 把任意命令(含多行脚本)构造成一行交互 shell 命令:执行 + 捕获退出码。
152
154
  *
153
155
  * 单行很关键:整行先被 shell 完整解析再执行,命令中途读 stdin 也不会吃掉
154
156
  * 后续哨兵;也避开交互 shell 的 bracketed-paste 对多行输入的特殊处理。
@@ -156,15 +158,6 @@ const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
156
158
  */
157
159
  export function buildTerminalBashLine(command, tailFile) {
158
160
  const trimmed = command.replace(/\s+$/, "");
159
- let body = trimmed;
160
- if (trimmed.includes("\n")) {
161
- body = `eval $'${trimmed
162
- .replace(/\\/g, "\\\\")
163
- .replace(/'/g, "\\'")
164
- .replace(/\r/g, "\\r")
165
- .replace(/\n/g, "\\n")
166
- .replace(/\t/g, "\\t")}'`;
167
- }
168
161
  // 退出码取【第一个】命令(真正干活的那个)而非管道末尾命令:`head`/`grep`/`tail`
169
162
  // 在管道末尾会把退出码吞成自己的(head 恒 0、grep 无命恒 1)。`${PIPESTATUS:-$?}`
170
163
  // 在 bash 里取 PIPESTATUS[0](首命令),busybox ash/dash 无 PIPESTATUS 时退化为 `$?`。
@@ -172,12 +165,84 @@ export function buildTerminalBashLine(command, tailFile) {
172
165
  // 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败照报。
173
166
  // tailFile:`cmd > log 2>&1 | tail -N`——拆掉 tail 后 stdout 进文件、终端为空;
174
167
  // 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
175
- // eslint-disable-next-line no-useless-escape -- \$? must reach the shell literally
176
- const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
177
168
  const tailPart = tailFile
178
169
  ? `; tail -n ${Math.max(1, Math.floor(tailFile.lines))} -- '${tailFile.file.replace(/'/g, `'\\''`)}'`
179
170
  : "";
180
- return `${body}; ${rcGuard}${tailPart}; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
171
+ // eslint-disable-next-line no-useless-escape -- \$? must reach the shell literally
172
+ const sentinel = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0${tailPart}; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
173
+ if (trimmed.includes("\n")) {
174
+ // 多行路径(行为保持):$'...' 转义后是一行物理输入,引号/换行全部字面化,
175
+ // 以 `'` 收尾——`; ` 同行拼接哨兵不会被尾注释/尾管道/续行吞掉。
176
+ const body = `eval $'${trimmed
177
+ .replace(/\\/g, "\\\\")
178
+ .replace(/'/g, "\\'")
179
+ .replace(/\r/g, "\\r")
180
+ .replace(/\n/g, "\\n")
181
+ .replace(/\t/g, "\\t")}'`;
182
+ return `${body}; ${sentinel}`;
183
+ }
184
+ // 单行路径:哨兵独占一行追加。行内 `; ` 拼接会被常见形态吞掉——
185
+ // 尾注释(`cmd # note; printf…` 整段进注释)、尾管道(`cmd |; …` 语法错误)。
186
+ // 独占一行后这两类安全;尾随续行符/未闭合引号仍会把哨兵行并入命令或吞进
187
+ // 字符串——这类形态无法安全续行,直接拒注入(调用方按无退出码模式执行并注明)。
188
+ if (sentinelUnsafeReason(trimmed))
189
+ return trimmed;
190
+ return `${trimmed}\n${sentinel}`;
191
+ }
192
+ /**
193
+ * 判定命令能否安全追加「独占一行的退出码哨兵」;返回拒注入原因或 null(可注入)。
194
+ *
195
+ * - 尾随续行符 `\`:下一行被并入本命令,哨兵行成为命令的一部分。
196
+ * - 未闭合引号:哨兵行被吞进字符串字面量,永远不会作为代码执行。
197
+ * - 多行命令:经 `$'...'` 转义单行化后引号/换行全部字面化,总是安全 → null。
198
+ *
199
+ * 扫描按 POSIX 引号语义跟踪 `'`/`"`/`` ` `` 三种引号与转义;`$'...'`(ANSI-C
200
+ * 引号)内 `\'` 不闭合引号,也一并识别。
201
+ */
202
+ export function sentinelUnsafeReason(command) {
203
+ const trimmed = command.replace(/\s+$/, "");
204
+ if (trimmed.includes("\n"))
205
+ return null;
206
+ let quote = null;
207
+ // 当前单引号是否 $' 开头(ANSI-C 引号,内含转义语义)
208
+ let ansiC = false;
209
+ let escaped = false;
210
+ for (let i = 0; i < trimmed.length; i++) {
211
+ const ch = trimmed[i];
212
+ if (quote) {
213
+ if (escaped) {
214
+ escaped = false;
215
+ continue;
216
+ }
217
+ // POSIX 单引号内 `\` 是普通字符;ANSI-C/双引号/反引号内 `\` 转义下一字符
218
+ if (ch === "\\" && (quote !== "'" || ansiC)) {
219
+ escaped = true;
220
+ continue;
221
+ }
222
+ if (ch === quote)
223
+ quote = null;
224
+ continue;
225
+ }
226
+ if (escaped) {
227
+ // 引号外的转义:被转义字符原样跳过(`\"` 不开引号等)
228
+ escaped = false;
229
+ continue;
230
+ }
231
+ if (ch === "\\") {
232
+ escaped = true;
233
+ continue;
234
+ }
235
+ if (ch === "'" || ch === '"' || ch === "`") {
236
+ quote = ch;
237
+ ansiC = ch === "'" && i > 0 && trimmed[i - 1] === "$";
238
+ }
239
+ }
240
+ if (quote !== null)
241
+ return "unclosed_quote";
242
+ // 扫描结束仍在转义态 = 最后一个字符是引号外的 `\`(尾随续行符)
243
+ if (escaped)
244
+ return "trailing_backslash";
245
+ return null;
181
246
  }
182
247
  /** 顶层(引号/反引号/转义外)按 `|` 拆分的管道元素。 */
183
248
  export function splitTopLevelPipes(cmd) {
@@ -230,6 +295,11 @@ function parseTailLines(rest) {
230
295
  * - `tail -f` / `tail --follow`(长驻观察)也不拆。
231
296
  */
232
297
  export function detectTrailingLimiter(command) {
298
+ // 含命令替换($() / 反引号)的命令保守跳过拆管:splitTopLevelPipes 只跟踪引号
299
+ // 与顶层 `|`,不解析括号嵌套——`cmd $(x | y)` 里替换内的管道会被误判为顶层
300
+ // 管道段,拆掉后直接破坏命令语义。保守正确优先,宁可少优化。
301
+ if (/\$\(|`/.test(command))
302
+ return null;
233
303
  const parts = splitTopLevelPipes(command);
234
304
  if (parts.length < 2)
235
305
  return null;
@@ -1368,34 +1438,50 @@ export class TerminalManager {
1368
1438
  * Kill the native PTY (issue #215:Windows ConPTY 关机死锁;issue #269:MSYS2 控制台耗尽死锁)。
1369
1439
  *
1370
1440
  * Windows 下:
1371
- * 1. 若进程已经退出(entry.exited):绝不可再调用 Node 的 process.kill(pid),
1372
- * 直接调 pty.kill() 释放 HPCON 句柄(子进程已死、管道见 EOF,绝不会死锁)。
1373
- * 2. 若进程尚未退出(!entry.exited):
1374
- * 先尝试优雅关闭(向 PTY 写入 \x03exit\r),让 bash 正常触发清理钩子;
1375
- * 若仍未退出才以 process.kill(pid)(TerminateProcess)强制兜底。
1376
- * 3. 关机(shutdown=true):跳过 pty.kill(),由 OS 回收。
1377
- * 非 Windows 原样直调 pty.kill()。
1441
+ * 1. 若进程已经退出(entry.exited):直接调 pty.kill() 释放 HPCON 句柄
1442
+ * (子进程已死、管道见 EOF,绝不会死锁);关机路径跳过,由 OS 回收。
1443
+ * 2. 若进程尚未退出(!entry.exited):先向 PTY 写入 \x03exit\r 优雅关闭(让
1444
+ * bash 正常触发清理钩子),300ms 后仍活着再调 pty.kill() 兜底——node-pty 在
1445
+ * Windows 上走 console-process-list 做**全树**清理。不再使用裸
1446
+ * process.kill(pid)(TerminateProcess):它只杀 ConPTY 附着的单一进程,
1447
+ * 且从拿到 pid 到调用之间隔着一个 PID 复用误杀窗口。
1448
+ * 3. 关机(shutdown=true):只做优雅写,跳过 pty.kill() 与 300ms 兜底,由 OS
1449
+ * 回收(保持 50ms 内退出的关机预算)。
1450
+ * 非 Windows 原样直调 pty.kill()(POSIX 下 shell 是直接子进程,pty.kill 足够,
1451
+ * 保持既有行为不做额外延迟)。
1378
1452
  */
1379
1453
  killNative(entry, shutdown = false) {
1380
1454
  if (process.platform === "win32") {
1381
- if (!entry.exited) {
1382
- const pid = entry.pty.pid;
1383
- if (typeof pid === "number") {
1384
- try {
1385
- // 优雅关闭:先发 Ctrl+C,再发 exit\r
1386
- entry.pty.write("\x03exit\r");
1387
- }
1388
- catch { }
1455
+ if (entry.exited) {
1456
+ if (!shutdown) {
1389
1457
  try {
1390
- process.kill(pid);
1458
+ entry.pty.kill();
1391
1459
  }
1392
1460
  catch {
1393
1461
  // already dead
1394
1462
  }
1395
1463
  }
1464
+ return;
1465
+ }
1466
+ // 优雅关闭:先发 Ctrl+C,再发 exit\r
1467
+ try {
1468
+ entry.pty.write("\x03exit\r");
1396
1469
  }
1470
+ catch { }
1397
1471
  if (shutdown)
1398
1472
  return;
1473
+ const t = setTimeout(() => {
1474
+ if (entry.exited)
1475
+ return;
1476
+ try {
1477
+ entry.pty.kill();
1478
+ }
1479
+ catch {
1480
+ // already dead
1481
+ }
1482
+ }, 300);
1483
+ t.unref?.();
1484
+ return;
1399
1485
  }
1400
1486
  try {
1401
1487
  entry.pty.kill();
@@ -1530,29 +1616,29 @@ export function makeTerminalBashTool(terminals, opts) {
1530
1616
  return defineTool({
1531
1617
  name: "bash",
1532
1618
  label: "Run bash command",
1533
- description: bilingual("Run a shell command and return its full output plus exit code. Commands run in a visible terminal.\n" +
1534
- "persist=false (default, one-shot): a fresh terminal is created per call, run to completion, then the shell exits (the process ends) while its output stays in the terminal list for later review — like a normal bash call, but each command also leaves a viewable terminal record.\n" +
1535
- "persist=true: commands run in the PERSISTENT visible terminal 'ai-bash' — shell state such as cd, venv activation or ssh sessions is retained across calls; you can use terminal_wait to re-block on a backgrounded command, or terminal_read / terminal_input / terminal_key on 'ai-bash' to observe or interact anytime.\n" +
1536
- "Run the bare command — do NOT pipe through head/tail/more/less (output is returned complete anyway, and pipes hide live progress in the terminal). Use the head/tail parameters instead to trim the returned output. For interactive commands (REPLs, prompts, installers asking y/n) set persist=true and drive them with terminal_input / terminal_key.", "运行 shell 命令并返回完整输出与退出码。命令在可见终端中运行。\n" +
1537
- "persist=false(默认,一次性):每次调用新建一个终端,运行至结束,然后 shell 退出(进程结束),输出保留在终端列表中供稍后查看——如同普通 bash 调用,但每条命令都会留下一条可查看的终端记录。\n" +
1538
- "persist=true:命令在常驻可见终端 'ai-bash' 中运行——cd、venv 激活、ssh 会话等 shell 状态跨调用保留;可用 terminal_wait 重新阻塞等待后台命令,或随时用 terminal_read / terminal_input / terminal_key 观察或交互。\n" +
1539
- "直接运行裸命令——不要经 head/tail/more/less 管道(反正会返回完整输出,管道还会挡住终端里的实时进度)。用 head/tail 参数截断返回的输出。交互式命令(REPL、提示符、问 y/n 的安装程序)请设 persist=true 并用 terminal_input / terminal_key 驱动。"),
1540
- promptSnippet: bilingual("run shell commands (persist=true keeps the terminal alive across calls)", "运行 shell 命令(persist=true 让终端跨调用保持存活)"),
1619
+ description: "Run a shell command in a visible terminal; returns full output and exit code. " +
1620
+ "persist=false (default): a fresh one-shot terminal per call; the shell exits after the command but its output stays viewable. " +
1621
+ "persist=true: runs in the persistent 'ai-bash' terminal — shell state (cd/venv/ssh) is retained across calls; " +
1622
+ "terminal_read/terminal_input/terminal_key observe or interact; terminal_wait blocks on backgrounded commands. " +
1623
+ "Run the bare command — never pipe through head/tail/more/less (output is returned complete; pipes hide live progress); use the head/tail params instead. " +
1624
+ "For interactive commands (REPLs, y/n prompts) set persist=true and drive them with terminal_input / terminal_key.",
1625
+ promptSnippet: "run shell commands (persist=true keeps the terminal alive across calls)",
1541
1626
  parameters: Type.Object({
1542
- command: Type.String({ description: bilingual("The shell command to run", "要运行的 shell 命令") }),
1543
- timeout: Type.Optional(Type.Number({ description: bilingual("Optional timeout in seconds", "可选的超时秒数") })),
1627
+ command: Type.String({ description: "The shell command to run" }),
1628
+ timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
1544
1629
  persist: Type.Optional(Type.Boolean({
1545
- description: bilingual("Keep the terminal alive after the command (default: false → a one-shot terminal that exits when the command finishes while its output is retained for review). true runs in the persistent 'ai-bash' terminal so shell state (cd/venv/ssh) is retained across calls and the terminal stays interactive.", "运行后保持终端存活(默认 false → 命令结束时退出的、输出保留供查看的一次性终端)。true 则在常驻 'ai-bash' 终端中运行,shell 状态(cd/venv/ssh)跨调用保留、终端保持可交互。"),
1630
+ description: "true → run in the persistent 'ai-bash' terminal: shell state (cd/venv/ssh) is retained across calls. " +
1631
+ "false (default) → one-shot terminal that exits when the command finishes; output stays viewable.",
1546
1632
  })),
1547
1633
  head: Type.Optional(Type.Integer({
1548
1634
  minimum: 1,
1549
1635
  maximum: 5000,
1550
- description: bilingual("Only return the FIRST N lines of output (like `| head -N`). Use this for verbose commands instead of piping through head.", "只返回输出的前 N 行(如 `| head -N`)。输出冗长的命令请用它,而不要经 head 管道。"),
1636
+ description: "Only return the FIRST N lines of output (like `| head -N`); prefer this over piping through head.",
1551
1637
  })),
1552
1638
  tail: Type.Optional(Type.Integer({
1553
1639
  minimum: 1,
1554
1640
  maximum: 5000,
1555
- description: bilingual("Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail.", "只返回输出的后 N 行(如 `| tail -N`)。输出冗长的命令请用它,而不要经 tail 管道。"),
1641
+ description: "Only return the LAST N lines of output (like `| tail -N`); prefer this over piping through tail.",
1556
1642
  })),
1557
1643
  }),
1558
1644
  execute: async (_id, p, signal) => {
@@ -1614,8 +1700,16 @@ export function makeTerminalBashTool(terminals, opts) {
1614
1700
  let collected = "";
1615
1701
  let cursor = start;
1616
1702
  let lastDataAt = Date.now();
1703
+ // 尾随续行符/未闭合引号的命令无法安全注入哨兵(哨兵行会被并入命令或
1704
+ // 吞进字符串):按无退出码模式执行,靠静默/超时/总时长解阻收尾并注明。
1705
+ const sentinelUnsafe = sentinelUnsafeReason(runCommand);
1706
+ const sentinelNote = sentinelUnsafe
1707
+ ? pick(lang, `\n[注:命令以${sentinelUnsafe === "trailing_backslash" ? "续行符 \\" : "未闭合引号"}结尾,无法注入退出码哨兵——已按无退出码模式执行,拿不到真实 exit code。建议拆分成更简单的单行命令重试。]`, `\n[Note: the command ends with ${sentinelUnsafe === "trailing_backslash" ? "a line-continuation backslash" : "an unclosed quote"}; the exit-code sentinel could not be injected — it ran without exit-code detection. Prefer splitting it into simpler single-line commands.]`, "terminals.bash.nosentinel.note")
1708
+ : "";
1617
1709
  // 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
1618
- terminals.setSentinelPending(termId, true);
1710
+ // 无哨兵命令没有可等待的哨兵,标记反而误导 terminal_wait。
1711
+ if (!sentinelUnsafe)
1712
+ terminals.setSentinelPending(termId, true);
1619
1713
  const inputErr = terminals.inputChecked(termId, buildTerminalBashLine(runCommand, tailFile) + "\r");
1620
1714
  if (inputErr)
1621
1715
  throw new Error(inputErr);
@@ -1633,6 +1727,12 @@ export function makeTerminalBashTool(terminals, opts) {
1633
1727
  collected += read.data;
1634
1728
  cursor = read.cursor;
1635
1729
  lastDataAt = Date.now();
1730
+ // 长跑/刷屏命令的累积无上限会拖爆服务进程内存。哨兵判定只看尾部
1731
+ // 8KB(lastSentinel),超限丢最旧一半、保留近期输出即可;
1732
+ // 命令结束后的 cleanBashOutput 语义不受影响。
1733
+ if (collected.length > BASH_COLLECT_MAX) {
1734
+ collected = collected.slice(collected.length - BASH_COLLECT_MAX / 2);
1735
+ }
1636
1736
  }
1637
1737
  const m = lastSentinel(collected);
1638
1738
  if (m) {
@@ -1654,15 +1754,15 @@ export function makeTerminalBashTool(terminals, opts) {
1654
1754
  terminals.inputChecked(termId, "\x03");
1655
1755
  closeOneShot();
1656
1756
  const timeoutPartial = truncateMiddle(stripAnsi(collected), 4000);
1657
- throw new Error(pick(lang, `Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${timeoutPartial})`, `Command timed out after ${p.timeout}s (sent Ctrl+C; partial output: ${timeoutPartial})`, "terminals.bash.timeout", { "p.timeout": p.timeout, timeoutPartial }));
1757
+ throw new Error(pick(lang, `Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${timeoutPartial})${sentinelNote}`, `Command timed out after ${p.timeout}s (sent Ctrl+C; partial output: ${timeoutPartial})${sentinelNote}`, "terminals.bash.timeout", { "p.timeout": p.timeout, timeoutPartial }));
1658
1758
  }
1659
1759
  // 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
1660
1760
  if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1661
- return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - lastDataAt) / 1000), lang, termId, persist, "idle");
1761
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang) + sentinelNote, Math.round((Date.now() - lastDataAt) / 1000), lang, termId, persist, "idle");
1662
1762
  }
1663
1763
  // 总时长解阻(持久与一次性终端):前台运行达到上限,转入后台继续执行,避免卡死。
1664
1764
  if (maxForegroundMs > 0 && Date.now() - startTime >= maxForegroundMs) {
1665
- return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - startTime) / 1000), lang, termId, persist, "elapsed");
1765
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang) + sentinelNote, Math.round((Date.now() - startTime) / 1000), lang, termId, persist, "elapsed");
1666
1766
  }
1667
1767
  }
1668
1768
  }
@@ -1758,15 +1858,15 @@ export { TERMINAL_TOOL_NAMES } from "./tool-manager.js";
1758
1858
  /** System-prompt guidance teaching the model WHEN to prefer the terminal tools
1759
1859
  * over one-shot bash. Without it models almost never pick them — bash returns
1760
1860
  * complete output in a single call, so it always wins on convenience. */
1761
- export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands - it runs in a visible terminal and returns the full output (persist=false, one-shot terminal that exits when the command finishes). Switch to the bash tool's persist=true, or to the terminal tools, when:
1762
- - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin). For these, prefer bash({ persist: true }) which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key (and terminal_read) on terminalId='ai-bash'.
1763
- - You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
1764
- - The user explicitly asks you to work in the visible terminal panel.
1765
- Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.`;
1861
+ export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands. Switch to bash({persist:true}) or the terminal tools when:
1862
+ - The program is interactive or TUI-based (REPLs, vim/htop, y/n installers, anything waiting on stdin): prefer bash({persist:true}), which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key / terminal_read on terminalId='ai-bash'.
1863
+ - You start a long-running server or watcher and want to watch its output (terminal_read with waitMs), send keys later (e.g. Ctrl+c via terminal_key), or block until a backgrounded command finishes without polling (terminal_wait).
1864
+ - The user asks you to work in the visible terminal panel.
1865
+ Liveness watchdog: terminals you touched are monitored — if one goes silent while you are working (default 15s), a system reminder prompts you to check it (terminal_read), answer its input prompt, or close it (terminal_close) if no longer needed.`;
1766
1866
  /** Build the agent-facing persistent terminal tools for one conversation. */
1767
1867
  export function makePersistentTerminalTools(terminals, cwd,
1768
- /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
1769
- lang) {
1868
+ /** per-call 返回文本的服务端语言(默认英文);工具 definition 为纯英文。 */
1869
+ lang, options) {
1770
1870
  const getLang = lang ?? (() => "en");
1771
1871
  const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
1772
1872
  const failIf = (error) => {
@@ -1777,11 +1877,13 @@ lang) {
1777
1877
  defineTool({
1778
1878
  name: "terminal_create",
1779
1879
  label: "Create terminal",
1780
- description: bilingual("Create a named persistent interactive PTY in the current workspace. Use terminal_input or terminal_key to interact with it and terminal_read to inspect incremental output. Prefer this over bash when the program is interactive/TUI-based (REPLs, vim/htop, y/n prompts), when starting a long-running server you want to keep observing or interrupt, or when the user asks to work in the visible terminal. For simple one-shot commands use bash instead.", "在当前工作区创建具名常驻交互式 PTY。用 terminal_input 或 terminal_key 与之交互,用 terminal_read 查看增量输出。程序是交互式/TUI(REPL、vim/htop、y/n 提示)、要启动长驻服务并持续观察或中断、或用户明确要求在可见终端里操作时,优先用它而非 bash。简单的一次性命令请用 bash。"),
1781
- promptSnippet: bilingual("run interactive programs or long-running servers in a persistent visible PTY (multi-step: create → input/key → read)", "在常驻可见 PTY 中运行交互式程序或长驻服务(多步:create → input/key → read)"),
1880
+ description: "Create a named persistent interactive PTY. Interact via terminal_input / terminal_key, inspect output via terminal_read. " +
1881
+ "Prefer bash for one-shot commands; use this (or bash persist=true) for interactive/TUI programs (REPLs, vim/htop, y/n prompts) or long-running servers to observe or interrupt.",
1882
+ promptSnippet: "run interactive programs or long-running servers in a persistent visible PTY (multi-step: " +
1883
+ "create → input/key → read)",
1782
1884
  parameters: Type.Object({
1783
- terminalId: Type.String({ description: bilingual("Stable terminal name", "稳定的终端名称") }),
1784
- cwd: Type.Optional(Type.String({ description: bilingual("Workspace-relative directory", "工作区相对目录") })),
1885
+ terminalId: Type.String({ description: "Stable terminal name" }),
1886
+ cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
1785
1887
  cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
1786
1888
  rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
1787
1889
  }),
@@ -1799,15 +1901,15 @@ lang) {
1799
1901
  defineTool({
1800
1902
  name: "terminal_list",
1801
1903
  label: "List terminals",
1802
- description: bilingual("List all persistent PTY terminals owned by this conversation.", "列出本对话拥有的全部常驻 PTY 终端。"),
1803
- promptSnippet: bilingual("list persistent terminals", "列出常驻终端"),
1904
+ description: "List all persistent PTY terminals owned by this conversation.",
1905
+ promptSnippet: "list persistent terminals",
1804
1906
  parameters: Type.Object({}),
1805
1907
  execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
1806
1908
  }),
1807
1909
  defineTool({
1808
1910
  name: "terminal_close",
1809
1911
  label: "Close terminal",
1810
- description: bilingual("Close a persistent PTY and terminate its process tree.", "关闭常驻 PTY 并终止其进程树。"),
1912
+ description: "Close a persistent PTY and terminate its process tree.",
1811
1913
  parameters: Type.Object({ terminalId: Type.String() }),
1812
1914
  execute: async (_id, p) => {
1813
1915
  const lang = getLang();
@@ -1822,10 +1924,16 @@ lang) {
1822
1924
  defineTool({
1823
1925
  name: "terminal_input",
1824
1926
  label: "Send terminal input",
1825
- description: bilingual("Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.", "向常驻 PTY 发送任意文本。需要提交命令时带上换行。"),
1927
+ description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
1826
1928
  parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
1827
1929
  execute: async (_id, p) => {
1828
1930
  const lang = getLang();
1931
+ if (options?.checkSafety && (p.data.includes("\n") || p.data.includes("\r"))) {
1932
+ const safety = options.checkSafety(p.data.trim());
1933
+ if (safety.blocked) {
1934
+ throw new Error(pick(lang, `【终端输入命令被安全规则阻断】${safety.reason ? ` 原因:${safety.reason}` : ""}`, `[Terminal command blocked by safety rule]${safety.reason ? ` Reason: ${safety.reason}` : ""}`, "terminals.command.blocked", { reason: safety.reason ?? "" }));
1935
+ }
1936
+ }
1829
1937
  failIf(terminals.inputChecked(p.terminalId, p.data));
1830
1938
  // AI 发了输入 = 在等结果,重开一个静默纪元。
1831
1939
  terminals.noteAgentActivity(p.terminalId);
@@ -1835,11 +1943,11 @@ lang) {
1835
1943
  defineTool({
1836
1944
  name: "terminal_key",
1837
1945
  label: "Send terminal key",
1838
- description: bilingual("Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.", "向常驻 PTY 发送 Enter、Tab、方向键、功能键或 Ctrl/Alt 组合键。"),
1946
+ description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
1839
1947
  parameters: Type.Object({
1840
1948
  terminalId: Type.String(),
1841
1949
  key: Type.String({
1842
- description: bilingual("Enter, Tab, ArrowUp, c, etc.", "按键名,如 Enter、Tab、ArrowUp、c 等"),
1950
+ description: "Enter, Tab, ArrowUp, c, etc.",
1843
1951
  }),
1844
1952
  modifiers: Type.Optional(Type.Object({
1845
1953
  ctrl: Type.Optional(Type.Boolean()),
@@ -1858,7 +1966,9 @@ lang) {
1858
1966
  defineTool({
1859
1967
  name: "terminal_read",
1860
1968
  label: "Read terminal output",
1861
- description: bilingual("Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.", "从常驻 PTY 读取增量输出。保留返回的 cursor,下次读取时传回;可选择等待新输出或进程退出。"),
1969
+ description: "Read incremental output from a persistent PTY. " +
1970
+ "Keep the returned cursor and pass it on the next read; " +
1971
+ "optionally wait for new output or process exit.",
1862
1972
  parameters: Type.Object({
1863
1973
  terminalId: Type.String(),
1864
1974
  cursor: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -1879,18 +1989,20 @@ lang) {
1879
1989
  defineTool({
1880
1990
  name: "terminal_wait",
1881
1991
  label: "Wait for terminal command",
1882
- description: bilingual("Block until a command started THROUGH THE BASH TOOL finishes (its exit marker appears) or the timeout expires — no polling needed. Only applies to terminals with a pending bash-tool command; terminals driven manually via terminal_input (e.g. interactive programs) have no completion marker — use terminal_read(waitMs=…) to observe those instead. Returns {finished, exitCode} plus the output produced while waiting; finished=false means it is STILL running (call again to keep waiting).", "阻塞等待经 BASH 工具启动的命令结束(出现退出标记)或超时——无需轮询。仅适用于有待决 bash 工具命令的终端;经 terminal_input 手动驱动的终端(如交互式程序)没有完成标记——观察它们请用 terminal_read(waitMs=…)。返回 {finished, exitCode} 及等待期间产生的输出;finished=false 表示仍在运行(可再次调用继续等)。"),
1883
- promptSnippet: bilingual("block until a terminal's current command finishes (no polling)", "阻塞等待终端当前命令结束(无需轮询)"),
1992
+ description: "Block until a command started through the BASH TOOL finishes (exit marker appears) or timeout — no polling. " +
1993
+ "Only applies to bash-tool commands; commands sent via terminal_input have no completion marker — use terminal_read(waitMs=…) for those. " +
1994
+ "Returns {finished, exitCode} plus output produced while waiting; finished=false means still running (call again).",
1995
+ promptSnippet: "block until a terminal's current command finishes (no polling)",
1884
1996
  parameters: Type.Object({
1885
1997
  terminalId: Type.String(),
1886
1998
  cursor: Type.Optional(Type.Integer({
1887
1999
  minimum: 0,
1888
- description: bilingual("Ignore exit markers before this absolute offset (default: now)", "忽略该绝对偏移之前的退出标记(默认:现在)"),
2000
+ description: "Ignore exit markers before this absolute offset (default: now)",
1889
2001
  })),
1890
2002
  maxWaitMs: Type.Optional(Type.Integer({
1891
2003
  minimum: 100,
1892
2004
  maximum: 600000,
1893
- description: bilingual("Max wait in ms (default 300000)", "最长等待毫秒数(默认 300000)"),
2005
+ description: "Max wait in ms (default 300000)",
1894
2006
  })),
1895
2007
  }),
1896
2008
  execute: async (_id, p, signal) => {
@@ -77,8 +77,10 @@ export const PATCH_TOOL_NAME = "patch";
77
77
  export const LSP_TOOL_NAME = "lsp";
78
78
  /** 旧工具名(持久化迁移用;新代码一律用 MARKERS_LIST_TOOL_NAME)。 */
79
79
  export const LEGACY_MARKERS_LIST_TOOL_NAME = "markers_list";
80
- /** 可开关的 Agent 工具总目录(共 27 个;bash 本体与 SDK 内置 edit/read
81
- * 不进目录——关了 agent 就残了,不给关)。 */
80
+ /** 可开关的 Agent 工具总目录(共 27 个)。核心内置工具 bash/read/edit/write 不进
81
+ * 目录——目录条目 = OTHER_AGENT_TOOLS 自动渲染的设置行,而这四个在设置页
82
+ * 「核心工具」区单独开关(见 SettingsModal 的 CORE_BUILTIN_TOOL_NAMES 区块),
83
+ * 禁用名单同样接受它们(normalizeDisabledAgentTools)。 */
82
84
  export const AGENT_TOOL_CATALOG = [
83
85
  ...TERMINAL_TOOL_NAMES.map((name) => ({
84
86
  name,
@@ -248,13 +250,18 @@ export const AGENT_TOOL_CATALOG = [
248
250
  offHintKey: "todoListOffHint",
249
251
  },
250
252
  ];
253
+ /** SDK 核心内置工具名(可被门控显式禁用或被预设白名单过滤)。 */
254
+ export const CORE_BUILTIN_TOOL_NAMES = ["bash", "read", "edit", "write"];
255
+ export function isCoreBuiltinTool(name) {
256
+ return CORE_BUILTIN_TOOL_NAMES.includes(name);
257
+ }
251
258
  const KNOWN_NAMES = new Set(AGENT_TOOL_CATALOG.map((t) => t.name));
252
259
  /** 是否为本表登记的可开关工具(未知名一律 false,不抛错)。 */
253
260
  export function isKnownAgentTool(name) {
254
261
  return KNOWN_NAMES.has(name);
255
262
  }
256
263
  /** 归一化禁用名单:非数组回落默认(= 默认关的那些);数组则只保留已知工具名
257
- * (去重;未知名丢弃,防旧文件/手写脏数据污染)。 */
264
+ * (去重;未知名丢弃,防旧文件/手写脏数据污染)。支持登记核心内置工具。 */
258
265
  export function normalizeDisabledAgentTools(v) {
259
266
  if (!Array.isArray(v))
260
267
  return defaultDisabledAgentTools();
@@ -262,8 +269,9 @@ export function normalizeDisabledAgentTools(v) {
262
269
  for (const x of v) {
263
270
  // 旧名迁移:markers_list → todo_list(改名前已关闭的用户保持关闭)。
264
271
  const name = x === LEGACY_MARKERS_LIST_TOOL_NAME ? MARKERS_LIST_TOOL_NAME : x;
265
- if (typeof name === "string" && KNOWN_NAMES.has(name) && !out.includes(name))
272
+ if (typeof name === "string" && (KNOWN_NAMES.has(name) || isCoreBuiltinTool(name)) && !out.includes(name)) {
266
273
  out.push(name);
274
+ }
267
275
  }
268
276
  return out;
269
277
  }
@@ -317,21 +325,30 @@ export function setAgentToolsEnabled(session, names, on) {
317
325
  }
318
326
  }
319
327
  /**
320
- * 全量重放(创建会话 / reload 后 / 设置变更后调):按禁用名单把目录内工具
321
- * 逐个加回或剔除;目录外的工具(bash/SDK 内置/插件工具)原样不动。
328
+ * 全量重放(创建会话 / reload 后 / 设置变更后调):按禁用名单把目录内工具与
329
+ * 核心内置工具(bash/read/edit/write,禁用名单接受它们)逐个加回或剔除;
330
+ * 基线之外的工具(插件工具等)原样不动。
322
331
  * 支持传入 preset(预设 id),按预设白名单做二次过滤。
332
+ * 复原基线取 session.getAllTools()(全集,含被禁用的)——基线里没有的工具不会凭空发明。
323
333
  * Session 未就绪时静默跳过(下次创建/reload 会再应用)。
324
334
  */
325
335
  export function applyAgentToolsGating(session, disabled, preset) {
326
336
  try {
327
337
  const off = new Set(disabled);
328
- const names = new Set(session.getActiveToolNames());
338
+ const allNames = session.getAllTools().map((t) => t.name);
339
+ const names = new Set(allNames);
329
340
  for (const t of AGENT_TOOL_CATALOG) {
330
341
  if (off.has(t.name))
331
342
  names.delete(t.name);
332
343
  else
333
344
  names.add(t.name);
334
345
  }
346
+ for (const core of CORE_BUILTIN_TOOL_NAMES) {
347
+ if (off.has(core))
348
+ names.delete(core);
349
+ else if (allNames.includes(core))
350
+ names.add(core);
351
+ }
335
352
  const filtered = filterToolsByPreset(names, preset);
336
353
  session.setActiveToolsByName(filtered);
337
354
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * tool-overrides.ts —— pi-web-ui 对 SDK 内置工具的覆盖(read / write / edit)如何与
3
+ * **第三方 pi 扩展提供的同名工具共存**。
4
+ *
5
+ * 为什么需要这个模块(问题):SDK 的注册表合并链是
6
+ * `allCustomTools = [...扩展注册的工具, ...customTools]` 再逐个 `definitionRegistry.set(name, …)`
7
+ * —— 后写赢 ⇒ **customTools 恒顶掉扩展的同名工具,与加载顺序无关、也没有开关**(见
8
+ * `dist/core/agent-session.js` 的 `_refreshToolRegistry()`)。而官方
9
+ * `docs/extensions.md` §Overriding Built-in Tools 明写扩展**可以**覆盖 `read` / `bash` /
10
+ * `powershell` / `edit` / `write` / `grep` / `find` / `ls` 并逐个列出了 `read`。
11
+ * pi-web-ui 过去把这三个覆盖直接塞进创建时的 `customTools` ⇒ 那句话在 `read`/`write`/`edit`
12
+ * 上失效:第三方扩展的同名工具**永远轮不到**,而且症状会伪装(覆盖层转发内置实现 ⇒
13
+ * 「读文件/图片/目录都正常,只有扩展特有的能力没有」,例如 `pi-better-edit` 的 read 不出锚、
14
+ * 它的 edit 随后一律 `E_UNKNOWN_ANCHOR`)。
15
+ *
16
+ * 做法:这三个覆盖**不再于创建时注册**。会话建好后按名字从**扩展注册表**
17
+ * (`session.extensionRunner.getAllRegisteredTools()`,不受 customTools 顶替影响)取扩展的同名
18
+ * 实现当基底,没有则用 pi-web-ui 自己的完整实现,再交给装饰器(`composeWith`)在其上叠加
19
+ * pi-web-ui 的能力(目录列条目 / 权限沙箱)。装饰器必须把**行为**委托给基底,只加自己那一层;
20
+ * 基底的 schema、描述、prompt 指引、渲染全部原样保留,扩展独有的参数(如 better-edit 的
21
+ * `windows`)照旧可用。
22
+ *
23
+ * 注入手法与 `plugins.ts` 的 `syncPluginToolsIntoSession` 相同:改 `session._customTools`
24
+ * 后调 `_refreshToolRegistry()`(SDK 改私有字段名即返回 null,调用方按「覆盖没装上」降级;
25
+ * `extensionRunner` 缺失时退化成「有扩展也当内置」=改动前的行为,不会更差)。
26
+ *
27
+ * 顺序:注入项排在既有 `_customTools` **之前** ⇒ pi-web-ui 插件注册的同名工具(也是
28
+ * customTools,比覆盖层后写)仍然是最后赢家,与改动前的相对顺序一致。
29
+ */
30
+ /** 解析某个工具名在**扩展注册表**里的实现;没有扩展提供时返回 undefined。 */
31
+ export function extensionToolDefinition(session, name) {
32
+ const tools = session.extensionRunner?.getAllRegisteredTools?.();
33
+ return tools?.find((tool) => tool.definition?.name === name)?.definition;
34
+ }
35
+ /**
36
+ * 把覆盖装进已建好的会话:逐个解析基底(扩展同名工具优先)→ 装饰 → 注入 → 刷新注册表。
37
+ * 返回注入的工具名;会话对象形状不符(SDK 改私有字段名)返回 null,调用方按「覆盖未生效」降级。
38
+ */
39
+ export function installToolOverrides(session, specs) {
40
+ if (!Array.isArray(session._customTools) || typeof session._refreshToolRegistry !== "function")
41
+ return null;
42
+ const names = new Set(specs.map((s) => s.name));
43
+ const injected = [];
44
+ for (const spec of specs) {
45
+ const extensionTool = extensionToolDefinition(session, spec.name);
46
+ if (extensionTool) {
47
+ // 有扩展同名工具:只能叠(composeWith);没给装饰器就整个让给它 ——
48
+ // **绝不**用 fallback 顶掉它(那就又是本模块存在要防的那个问题)。
49
+ if (spec.composeWith)
50
+ injected.push(spec.composeWith(extensionTool));
51
+ continue;
52
+ }
53
+ injected.push(spec.fallback());
54
+ }
55
+ // 同名项先剔除再前置:重复调用幂等(不会层层叠加),且插件工具仍然后写赢。
56
+ session._customTools = [...injected, ...session._customTools.filter((t) => !names.has(t.name))];
57
+ session._refreshToolRegistry();
58
+ return [...names];
59
+ }
@@ -13,6 +13,7 @@ import { readdirSync, readFileSync, realpathSync, existsSync } from "node:fs";
13
13
  import { delimiter, dirname, join } from "node:path";
14
14
  import { promisify } from "node:util";
15
15
  import { pick } from "./i18n.js";
16
+ import { sdkCopies } from "./sdk-origin.js";
16
17
  const PI_CORE_PACKAGE = "@earendil-works/pi-coding-agent";
17
18
  /** npm 官方源;用户在 <agentDir>/npm/.npmrc 里配了镜像/私有源时会被覆盖(issue #151)。 */
18
19
  export const NPM_DEFAULT_REGISTRY = "https://registry.npmjs.org";
@@ -424,6 +425,32 @@ export function defaultProbePiCore() {
424
425
  piCoreProbe = { at: now, version };
425
426
  return version;
426
427
  }
428
+ /**
429
+ * issue #321 — detect "a newer pi is installed on this machine than the one
430
+ * this process actually loaded". The panel's pi-core row probes the GLOBAL pi
431
+ * CLI (what `npm i -g` updates), but the server loads its own bundled copy
432
+ * (nested beats ancestor in Node resolution, issue #260) — so after upgrading
433
+ * the global pi the panel reads "up to date" while conversations still run the
434
+ * old SDK. That invisible split is exactly what #321 reports; this turns it
435
+ * into data the UI can explain.
436
+ *
437
+ * Two detectors, unioned (newest wins): the CLI probe (standard global
438
+ * install) and the ancestor-chain copies (fallback when the probe finds
439
+ * nothing — different npm prefix, bun, …). Pure — tests inject both.
440
+ * Returns null when nothing newer than `runningVersion` is found.
441
+ */
442
+ export function detectPiSdkSplit(runningVersion, probePiCore = defaultProbePiCore, copies = sdkCopies()) {
443
+ let installed = probePiCore();
444
+ for (const copy of copies) {
445
+ if (compareVersions(copy.version, runningVersion) > 0 &&
446
+ (!installed || compareVersions(copy.version, installed) > 0)) {
447
+ installed = copy.version;
448
+ }
449
+ }
450
+ if (!installed || compareVersions(installed, runningVersion) <= 0)
451
+ return null;
452
+ return { running: runningVersion, installed };
453
+ }
427
454
  /**
428
455
  * Fallback when the CLI probe yields nothing: the version of the vendored pi
429
456
  * core copy in <agentDir>/npm/node_modules, or null if that is absent too.
@@ -646,7 +673,7 @@ gitCheck = defaultCheckGitExtension) {
646
673
  * up-to-date ones, errors last. Stable within each bucket (Array.sort is
647
674
  * stable) so registry order survives ties. */
648
675
  export function sortUpdateItems(items) {
649
- const kindRank = (k) => (k === "webui" ? 0 : k === "pi-core" ? 1 : 2);
676
+ const kindRank = (k) => (k === "webui" ? 0 : k === "pi-core" ? 1 : k === "plugin" ? 2 : 3);
650
677
  return [...items].sort((a, b) => {
651
678
  const ka = kindRank(a.kind);
652
679
  const kb = kindRank(b.kind);