pi-web-ui 0.94.1 → 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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -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) => {
@@ -1583,6 +1669,8 @@ export function makeTerminalBashTool(terminals, opts) {
1583
1669
  void terminals.inputChecked(termId, "exit\r");
1584
1670
  };
1585
1671
  const idleMs = Math.max(0, opts.idleMs());
1672
+ const maxForegroundMs = Math.max(0, opts.maxForegroundMs?.() ?? 0);
1673
+ const startTime = Date.now();
1586
1674
  const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
1587
1675
  // 拆掉模型常写的尾部输出限制/过滤管道(`| tail -N` / `| less` / `| more` / `| cat`):
1588
1676
  // 这类管道在终端里 ①缓冲输出——可见终端全程哑火、无法感知实时进度;②吞掉真实退出码——
@@ -1612,8 +1700,16 @@ export function makeTerminalBashTool(terminals, opts) {
1612
1700
  let collected = "";
1613
1701
  let cursor = start;
1614
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
+ : "";
1615
1709
  // 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
1616
- terminals.setSentinelPending(termId, true);
1710
+ // 无哨兵命令没有可等待的哨兵,标记反而误导 terminal_wait。
1711
+ if (!sentinelUnsafe)
1712
+ terminals.setSentinelPending(termId, true);
1617
1713
  const inputErr = terminals.inputChecked(termId, buildTerminalBashLine(runCommand, tailFile) + "\r");
1618
1714
  if (inputErr)
1619
1715
  throw new Error(inputErr);
@@ -1631,6 +1727,12 @@ export function makeTerminalBashTool(terminals, opts) {
1631
1727
  collected += read.data;
1632
1728
  cursor = read.cursor;
1633
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
+ }
1634
1736
  }
1635
1737
  const m = lastSentinel(collected);
1636
1738
  if (m) {
@@ -1652,11 +1754,15 @@ export function makeTerminalBashTool(terminals, opts) {
1652
1754
  terminals.inputChecked(termId, "\x03");
1653
1755
  closeOneShot();
1654
1756
  const timeoutPartial = truncateMiddle(stripAnsi(collected), 4000);
1655
- 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 }));
1656
1758
  }
1657
1759
  // 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
1658
1760
  if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1659
- return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - lastDataAt) / 1000), lang);
1761
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang) + sentinelNote, Math.round((Date.now() - lastDataAt) / 1000), lang, termId, persist, "idle");
1762
+ }
1763
+ // 总时长解阻(持久与一次性终端):前台运行达到上限,转入后台继续执行,避免卡死。
1764
+ if (maxForegroundMs > 0 && Date.now() - startTime >= maxForegroundMs) {
1765
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang) + sentinelNote, Math.round((Date.now() - startTime) / 1000), lang, termId, persist, "elapsed");
1660
1766
  }
1661
1767
  }
1662
1768
  }
@@ -1666,36 +1772,85 @@ export function makeTerminalBashTool(terminals, opts) {
1666
1772
  },
1667
1773
  });
1668
1774
  }
1669
- /** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1670
- function backgroundResult(terminals, opts, command, partialText, silentSeconds, lang) {
1671
- terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
1775
+ /** 静默 / 总时长解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1776
+ function backgroundResult(terminals, opts, command, partialText, durationSeconds, lang, termId = "ai-bash", persist = true, reason = "idle") {
1777
+ terminals.watchOutput(termId, BASH_SENTINEL_RE, (m) => {
1672
1778
  // 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
1673
- terminals.setSentinelPending("ai-bash", false);
1779
+ terminals.setSentinelPending(termId, false);
1674
1780
  opts.notifyBackgroundDone({
1675
- terminalId: "ai-bash",
1781
+ terminalId: termId,
1676
1782
  command,
1677
1783
  exitCode: m ? Number(m[1]) : null,
1678
1784
  });
1785
+ if (!persist) {
1786
+ void terminals.inputChecked(termId, "exit\r");
1787
+ }
1679
1788
  });
1680
1789
  // partialText 已在调用方做过 cleanBashOutput + applyTail。
1681
1790
  const partial = truncateMiddle(partialText, 6000);
1682
1791
  // 空输出占位按语言预渲染(issue #91 v2:vars 只收干净标识)。
1683
1792
  const partialZh = partial || "(暂无输出)";
1684
1793
  const partialEn = partial || "(no output yet)";
1794
+ let descZh = "";
1795
+ let descEn = "";
1796
+ if (reason === "elapsed") {
1797
+ descZh =
1798
+ `命令仍在终端 ${termId} 中运行(前台执行已达 ${durationSeconds} 秒上限,自动转入后台)。` +
1799
+ `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1800
+ `已有输出:\n${partialZh}\n`;
1801
+ descEn =
1802
+ `Command still running in terminal ${termId} (foreground execution reached ${durationSeconds}s limit, moved to background). ` +
1803
+ `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1804
+ `Partial output:\n${partialEn}\n`;
1805
+ }
1806
+ else {
1807
+ descZh =
1808
+ `命令仍在持久终端 ${termId} 中运行(已连续 ${durationSeconds} 秒无输出,未结束)。` +
1809
+ `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1810
+ `已有输出:\n${partialZh}\n`;
1811
+ descEn =
1812
+ `Command still running in the persistent terminal ${termId} (no output for ${durationSeconds}s, not finished). ` +
1813
+ `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1814
+ `Partial output:\n${partialEn}\n`;
1815
+ }
1816
+ if (persist) {
1817
+ descZh += `要重新阻塞等它结束就用 terminal_wait(terminalId="${termId}")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`;
1818
+ descEn += `To block until it finishes, use terminal_wait(terminalId="${termId}") (no polling needed); use terminal_input / terminal_key to interact (Ctrl+C aborts).`;
1819
+ }
1820
+ else {
1821
+ descZh += `命令完成后该终端将自动退出并发送通知;如需查看输出可用 terminal_read(terminalId="${termId}")。`;
1822
+ descEn += `When completed, this terminal will exit automatically and notify; to inspect output, use terminal_read(terminalId="${termId}").`;
1823
+ }
1824
+ if (reason === "elapsed") {
1825
+ return {
1826
+ content: [
1827
+ {
1828
+ type: "text",
1829
+ text: pick(lang, descZh, descEn, "terminals.bash.background.elapsed", {
1830
+ durationSeconds,
1831
+ partialZh,
1832
+ partialEn,
1833
+ termId,
1834
+ }),
1835
+ },
1836
+ ],
1837
+ details: { running: true, terminalId: termId, persist, durationSeconds, reason },
1838
+ };
1839
+ }
1685
1840
  return {
1686
1841
  content: [
1687
1842
  {
1688
1843
  type: "text",
1689
- text: pick(lang, `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1690
- `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1691
- `已有输出:\n${partialZh}\n` +
1692
- `要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`, `Command still running in the persistent terminal ai-bash (no output for ${silentSeconds}s, not finished). ` +
1693
- `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1694
- `Partial output:\n${partialEn}\n` +
1695
- `To block until it finishes, use terminal_wait(terminalId="ai-bash") (no polling needed); use terminal_input / terminal_key to interact (Ctrl+C aborts).`, "terminals.bash.background.running", { silentSeconds, partialZh, partialEn }),
1844
+ text: pick(lang, descZh, descEn, "terminals.bash.background.running", {
1845
+ silentSeconds: durationSeconds,
1846
+ durationSeconds,
1847
+ partialZh,
1848
+ partialEn,
1849
+ termId,
1850
+ }),
1696
1851
  },
1697
1852
  ],
1698
- details: { running: true, terminalId: "ai-bash", silentSeconds },
1853
+ details: { running: true, terminalId: termId, persist, durationSeconds, reason },
1699
1854
  };
1700
1855
  }
1701
1856
  /** Agent 持久终端工具名(唯一登记见 tool-manager.ts;此处 re-export 保兼容)。 */
@@ -1703,15 +1858,15 @@ export { TERMINAL_TOOL_NAMES } from "./tool-manager.js";
1703
1858
  /** System-prompt guidance teaching the model WHEN to prefer the terminal tools
1704
1859
  * over one-shot bash. Without it models almost never pick them — bash returns
1705
1860
  * complete output in a single call, so it always wins on convenience. */
1706
- 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:
1707
- - 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'.
1708
- - 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).
1709
- - The user explicitly asks you to work in the visible terminal panel.
1710
- 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.`;
1711
1866
  /** Build the agent-facing persistent terminal tools for one conversation. */
1712
1867
  export function makePersistentTerminalTools(terminals, cwd,
1713
- /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
1714
- lang) {
1868
+ /** per-call 返回文本的服务端语言(默认英文);工具 definition 为纯英文。 */
1869
+ lang, options) {
1715
1870
  const getLang = lang ?? (() => "en");
1716
1871
  const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
1717
1872
  const failIf = (error) => {
@@ -1722,11 +1877,13 @@ lang) {
1722
1877
  defineTool({
1723
1878
  name: "terminal_create",
1724
1879
  label: "Create terminal",
1725
- 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。"),
1726
- 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)",
1727
1884
  parameters: Type.Object({
1728
- terminalId: Type.String({ description: bilingual("Stable terminal name", "稳定的终端名称") }),
1729
- 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" })),
1730
1887
  cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
1731
1888
  rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
1732
1889
  }),
@@ -1744,15 +1901,15 @@ lang) {
1744
1901
  defineTool({
1745
1902
  name: "terminal_list",
1746
1903
  label: "List terminals",
1747
- description: bilingual("List all persistent PTY terminals owned by this conversation.", "列出本对话拥有的全部常驻 PTY 终端。"),
1748
- promptSnippet: bilingual("list persistent terminals", "列出常驻终端"),
1904
+ description: "List all persistent PTY terminals owned by this conversation.",
1905
+ promptSnippet: "list persistent terminals",
1749
1906
  parameters: Type.Object({}),
1750
1907
  execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
1751
1908
  }),
1752
1909
  defineTool({
1753
1910
  name: "terminal_close",
1754
1911
  label: "Close terminal",
1755
- description: bilingual("Close a persistent PTY and terminate its process tree.", "关闭常驻 PTY 并终止其进程树。"),
1912
+ description: "Close a persistent PTY and terminate its process tree.",
1756
1913
  parameters: Type.Object({ terminalId: Type.String() }),
1757
1914
  execute: async (_id, p) => {
1758
1915
  const lang = getLang();
@@ -1767,10 +1924,16 @@ lang) {
1767
1924
  defineTool({
1768
1925
  name: "terminal_input",
1769
1926
  label: "Send terminal input",
1770
- 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.",
1771
1928
  parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
1772
1929
  execute: async (_id, p) => {
1773
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
+ }
1774
1937
  failIf(terminals.inputChecked(p.terminalId, p.data));
1775
1938
  // AI 发了输入 = 在等结果,重开一个静默纪元。
1776
1939
  terminals.noteAgentActivity(p.terminalId);
@@ -1780,11 +1943,11 @@ lang) {
1780
1943
  defineTool({
1781
1944
  name: "terminal_key",
1782
1945
  label: "Send terminal key",
1783
- 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.",
1784
1947
  parameters: Type.Object({
1785
1948
  terminalId: Type.String(),
1786
1949
  key: Type.String({
1787
- description: bilingual("Enter, Tab, ArrowUp, c, etc.", "按键名,如 Enter、Tab、ArrowUp、c 等"),
1950
+ description: "Enter, Tab, ArrowUp, c, etc.",
1788
1951
  }),
1789
1952
  modifiers: Type.Optional(Type.Object({
1790
1953
  ctrl: Type.Optional(Type.Boolean()),
@@ -1803,7 +1966,9 @@ lang) {
1803
1966
  defineTool({
1804
1967
  name: "terminal_read",
1805
1968
  label: "Read terminal output",
1806
- 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.",
1807
1972
  parameters: Type.Object({
1808
1973
  terminalId: Type.String(),
1809
1974
  cursor: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -1824,18 +1989,20 @@ lang) {
1824
1989
  defineTool({
1825
1990
  name: "terminal_wait",
1826
1991
  label: "Wait for terminal command",
1827
- 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 表示仍在运行(可再次调用继续等)。"),
1828
- 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)",
1829
1996
  parameters: Type.Object({
1830
1997
  terminalId: Type.String(),
1831
1998
  cursor: Type.Optional(Type.Integer({
1832
1999
  minimum: 0,
1833
- description: bilingual("Ignore exit markers before this absolute offset (default: now)", "忽略该绝对偏移之前的退出标记(默认:现在)"),
2000
+ description: "Ignore exit markers before this absolute offset (default: now)",
1834
2001
  })),
1835
2002
  maxWaitMs: Type.Optional(Type.Integer({
1836
2003
  minimum: 100,
1837
2004
  maximum: 600000,
1838
- description: bilingual("Max wait in ms (default 300000)", "最长等待毫秒数(默认 300000)"),
2005
+ description: "Max wait in ms (default 300000)",
1839
2006
  })),
1840
2007
  }),
1841
2008
  execute: async (_id, p, signal) => {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * server/tool-approval.ts
3
+ *
4
+ * 人机协同拦截与「改写执行」(Human-in-the-Loop: Edit & Run)。
5
+ *
6
+ * 功能:
7
+ * 1. 高危操作识别与规则裁决(Dangerous Tool Call & Rule Evaluation):
8
+ * - 委托 server/approval-rules.ts 的规则引擎评估
9
+ * - 支持三种动作:ask(弹窗审批)、deny(直接拒绝报错)、allow(白名单放行)
10
+ * 2. 审批事件挂起与答复处理(askApproval / resolveApproval):
11
+ * - 用户可「批准」(Approve)
12
+ * - 用户可「拒绝」(Deny)
13
+ * - 用户可就地修改参数后「修改并放行」(Edit & Run)
14
+ * 3. 三档放行策略(省去反复弹窗):
15
+ * - 全局关(设置 →「工具」页的「工具执行审批」总开关)→ 一律不弹;
16
+ * - 本对话「全部允许」→ 该对话内一律不弹;
17
+ * - 本对话「允许同类」→ 该对话内同一规则档位(category)不再弹。
18
+ * 策略只活在内存里(挂在 Conversation 上,过户随对话搬走),重启/新对话即恢复询问。
19
+ */
20
+ import { DEFAULT_APPROVAL_RULES, evaluateApprovalRules } from "./approval-rules.js";
21
+ /** 策略为空(既没全部允许也没记同类):调用方据此不挂空策略、面板据此不显示撤销区。 */
22
+ export function isApprovalPolicyEmpty(policy) {
23
+ return !policy || (!policy.allowAll && policy.categories.size === 0);
24
+ }
25
+ /**
26
+ * 三档放行的纯判定:返回 null = 需要弹审批,否则返回放行的原因。
27
+ * - `disabled`:全局开关关(设置 →「工具」页)→ 一切审批都不弹(插件显式 ask 也放行);
28
+ * - `allow-all`:本对话已「全部允许」;
29
+ * - `category`:本对话已记住该同类档位。
30
+ */
31
+ export function approvalSuppressionReason(policy, enabled, categoryId) {
32
+ if (!enabled)
33
+ return "disabled";
34
+ if (!policy)
35
+ return null;
36
+ if (policy.allowAll)
37
+ return "allow-all";
38
+ if (categoryId && policy.categories.has(categoryId))
39
+ return "category";
40
+ return null;
41
+ }
42
+ /** 插件显式要求确认(pre guard 的 ask)对应的档位:按插件 id 分档,可「允许同类」。 */
43
+ export function pluginApprovalCategory(pluginId) {
44
+ return {
45
+ id: `plugin:${pluginId}`,
46
+ label: `插件 ${pluginId} 要求确认`,
47
+ labelEn: `Plugin ${pluginId} requested confirmation`,
48
+ };
49
+ }
50
+ /**
51
+ * 检测某个工具调用是否属于高危操作,或命中用户自定义规则。
52
+ * 可选接收 rules 规则集;缺省时以内置默认高危规则集判定。
53
+ */
54
+ export function checkDangerousToolCall(toolName, params, cwd, workspaceRoots = [], rules) {
55
+ if (!params || typeof params !== "object") {
56
+ return { dangerous: false };
57
+ }
58
+ const res = evaluateApprovalRules(rules ?? DEFAULT_APPROVAL_RULES, toolName, params, cwd, workspaceRoots);
59
+ if (res.action === "deny") {
60
+ return {
61
+ dangerous: true,
62
+ denied: true,
63
+ reason: res.reason,
64
+ reasonEn: res.reasonEn,
65
+ category: res.category,
66
+ };
67
+ }
68
+ if (res.action === "allow") {
69
+ return {
70
+ dangerous: false,
71
+ allowed: true,
72
+ };
73
+ }
74
+ if (res.action === "ask") {
75
+ return {
76
+ dangerous: true,
77
+ denied: false,
78
+ reason: res.reason,
79
+ reasonEn: res.reasonEn,
80
+ category: res.category,
81
+ };
82
+ }
83
+ return { dangerous: false };
84
+ }