pi-web-ui 0.96.1 → 0.98.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 (88) hide show
  1. package/CHANGELOG.md +144 -2
  2. package/README.md +29 -3
  3. package/README.zh-CN.md +28 -3
  4. package/bin/pi-web-ui.mjs +75 -25
  5. package/dist/server/agent-service.js +1829 -262
  6. package/dist/server/approval-rules.js +5 -10
  7. package/dist/server/atomic-file.js +40 -0
  8. package/dist/server/attachments.js +22 -0
  9. package/dist/server/claim-store.js +5 -11
  10. package/dist/server/client-state.js +296 -11
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +148 -0
  13. package/dist/server/composer-drafts.js +4 -6
  14. package/dist/server/control-socket.js +0 -40
  15. package/dist/server/delegate-mode.js +143 -0
  16. package/dist/server/delegate-task.js +10 -10
  17. package/dist/server/dsh/dsh-agent-service.js +66 -31
  18. package/dist/server/dsh/dsh-client.js +48 -10
  19. package/dist/server/dsh/dsh-serialize.js +12 -10
  20. package/dist/server/dsh/dsh-sessions.js +20 -6
  21. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  22. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  23. package/dist/server/ensure-bash.js +3 -5
  24. package/dist/server/eval-tool.js +11 -5
  25. package/dist/server/goal-review-gate.js +250 -0
  26. package/dist/server/goal-service.js +1272 -448
  27. package/dist/server/index.js +118 -16
  28. package/dist/server/launch-origin.js +0 -4
  29. package/dist/server/locales.js +3 -5
  30. package/dist/server/lsp-tool.js +123 -24
  31. package/dist/server/markers/builtins/notify.js +4 -2
  32. package/dist/server/markers/builtins/rename.js +0 -1
  33. package/dist/server/markers/builtins/todo.js +6 -28
  34. package/dist/server/markers/marker.js +0 -15
  35. package/dist/server/markers/registry.js +0 -3
  36. package/dist/server/markers/store.js +0 -3
  37. package/dist/server/mcp-bridge.js +13 -0
  38. package/dist/server/mcp-hot-reload.js +12 -5
  39. package/dist/server/model-admin.js +19 -18
  40. package/dist/server/model-enrich.js +0 -3
  41. package/dist/server/patch-tool.js +22 -6
  42. package/dist/server/patch-turn-end-boundary.js +77 -0
  43. package/dist/server/permission-preset.js +69 -0
  44. package/dist/server/plan-manager.js +150 -5
  45. package/dist/server/plan-mode.js +414 -0
  46. package/dist/server/plugin-api-catalog.js +2 -0
  47. package/dist/server/plugin-catalog.js +6 -6
  48. package/dist/server/plugin-dom.js +3 -4
  49. package/dist/server/plugin-facilities.js +39 -8
  50. package/dist/server/plugin-grants.js +5 -8
  51. package/dist/server/plugin-permissions.js +4 -6
  52. package/dist/server/plugin-schedule.js +4 -7
  53. package/dist/server/plugin-updater.js +151 -38
  54. package/dist/server/plugins.js +22 -5
  55. package/dist/server/prompt-composer.js +12 -3
  56. package/dist/server/protocol-version.js +1 -1
  57. package/dist/server/resolve-global-sdk.js +8 -0
  58. package/dist/server/schedule-agent-tool.js +5 -1
  59. package/dist/server/scheduler-tasks.js +46 -27
  60. package/dist/server/sdk-origin.js +27 -0
  61. package/dist/server/serialize.js +33 -9
  62. package/dist/server/settings-service.js +18 -6
  63. package/dist/server/slash-commands.js +16 -0
  64. package/dist/server/subagent-templates.js +4 -10
  65. package/dist/server/subagents.js +379 -416
  66. package/dist/server/terminals.js +11 -6
  67. package/dist/server/text-quote.js +59 -0
  68. package/dist/server/text-sniff.js +15 -9
  69. package/dist/server/tool-manager.js +60 -7
  70. package/dist/server/tool-overrides.js +24 -0
  71. package/dist/server/update-check.js +80 -28
  72. package/dist/server/wait-subscription-scan.js +5 -1
  73. package/extensions/webui.ts +15 -1
  74. package/package.json +33 -21
  75. package/plugin-sdk/client-utils.mjs +92 -0
  76. package/plugin-sdk/index.d.ts +21 -0
  77. package/plugin-sdk/index.mjs +2 -0
  78. package/plugins/catalog.json +7 -0
  79. package/themes/zhupi-dark.css +5 -30
  80. package/themes/zhupi.css +5 -30
  81. package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-C88r3r3Q.js} +1 -1
  82. package/web/dist/assets/index-2jDtjHvW.css +1 -0
  83. package/web/dist/assets/index-BjdK7s1M.js +368 -0
  84. package/web/dist/index.html +2 -2
  85. package/web/dist/sw.js +2 -1
  86. package/web/public/sw.js +2 -1
  87. package/web/dist/assets/index-B7Zp23Vc.css +0 -1
  88. package/web/dist/assets/index-ByfoaDwp.js +0 -366
@@ -78,6 +78,7 @@ export class DshRuntime {
78
78
  notificationHandler = null;
79
79
  stderrTail = "";
80
80
  closed = false;
81
+ disposed = false;
81
82
  initialized = false;
82
83
  /** PI_WEB_DSH_DEBUG=1 时把 RPC 帧/生命周期事件打到 stderr(诊断用,默认关)。 */
83
84
  debugEnabled = process.env.PI_WEB_DSH_DEBUG === "1";
@@ -122,6 +123,9 @@ export class DshRuntime {
122
123
  }
123
124
  /** 启动子进程 + initialize 握手(幂等;并发调用共享同一个启动任务)。 */
124
125
  start() {
126
+ if (this.disposed) {
127
+ return Promise.reject(new DshTransportError(bilingual("client has been disposed", "客户端已销毁")));
128
+ }
125
129
  if (this.alive && this.initialized)
126
130
  return Promise.resolve();
127
131
  if (!this.startPromise) {
@@ -136,7 +140,9 @@ export class DshRuntime {
136
140
  throw new DshTransportError(bilingual(`launcher missing: ${this.launcher}`, `launcher 不存在: ${this.launcher}`));
137
141
  }
138
142
  if (!existsSync(this.jsonrpcEntry)) {
139
- throw new DshTransportError(bilingual(`dsh-sdk-jsonrpc-server is not installed (missing ${this.jsonrpcEntry}). Run npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2 first`, `dsh-sdk-jsonrpc-server 未安装(缺 ${this.jsonrpcEntry})。请先 npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2`));
143
+ // 不写死版本号:依赖版本会随 package.json 升(见 docs/dsh-engine.md §1
144
+ // 「版本族」),写死的提示迟早骗人。报当前实际解析到的路径即可。
145
+ throw new DshTransportError(bilingual(`dsh-sdk-jsonrpc-server is not installed (missing ${this.jsonrpcEntry}). Run npm i (see docs/dsh-engine.md for the pinned version family) first`, `dsh-sdk-jsonrpc-server 未安装(缺 ${this.jsonrpcEntry})。请先 npm i(版本族见 docs/dsh-engine.md)`));
140
146
  }
141
147
  const key = loadDeepSeekKey(this.agentDir);
142
148
  const env = {
@@ -268,7 +274,10 @@ export class DshRuntime {
268
274
  if (!proc || !proc.stdin || proc.stdin.destroyed) {
269
275
  throw new DshTransportError(bilingual("runtime not started", "runtime 未启动"));
270
276
  }
271
- proc.stdin.write(JSON.stringify(msg) + "\n");
277
+ const ok = proc.stdin.write(JSON.stringify(msg) + "\n");
278
+ if (!ok) {
279
+ this.debug("stdin buffer full, backpressure triggered");
280
+ }
272
281
  }
273
282
  /**
274
283
  * 向一个会话排队 prompt。返回持久化收据 messageId(运行时接受即 resolve)。
@@ -409,8 +418,9 @@ export class DshRuntime {
409
418
  }
410
419
  /** 优雅关闭:shutdown 握手 → stdin EOF → SIGTERM → SIGKILL 阶梯。 */
411
420
  async close() {
412
- if (this.closed)
421
+ if (this.disposed)
413
422
  return;
423
+ this.disposed = true;
414
424
  this.closed = true;
415
425
  const proc = this.proc;
416
426
  if (!proc || proc.exitCode !== null) {
@@ -485,16 +495,44 @@ export class DshRuntime {
485
495
  this.failPending(new DshTransportError(bilingual("runtime killed (interrupt)", "运行时已被终止(中断)")));
486
496
  try {
487
497
  if (process.platform === "win32") {
488
- const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
489
- stdio: "ignore",
490
- windowsHide: true,
491
- });
492
- killer.on("error", () => {
498
+ await new Promise((resolve) => {
499
+ let settled = false;
500
+ const done = () => {
501
+ if (!settled) {
502
+ settled = true;
503
+ clearTimeout(timer);
504
+ resolve();
505
+ }
506
+ };
507
+ const timer = setTimeout(done, 1500);
493
508
  try {
494
- proc.kill("SIGKILL");
509
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
510
+ stdio: "ignore",
511
+ windowsHide: true,
512
+ });
513
+ killer.on("close", (code) => {
514
+ if (code !== 0) {
515
+ try {
516
+ proc.kill("SIGKILL");
517
+ }
518
+ catch { }
519
+ }
520
+ done();
521
+ });
522
+ killer.on("error", () => {
523
+ try {
524
+ proc.kill("SIGKILL");
525
+ }
526
+ catch { }
527
+ done();
528
+ });
495
529
  }
496
530
  catch {
497
- /* already dead */
531
+ try {
532
+ proc.kill("SIGKILL");
533
+ }
534
+ catch { }
535
+ done();
498
536
  }
499
537
  });
500
538
  }
@@ -23,15 +23,8 @@
23
23
  * - streamingMessage:由 assistant/chunk 增量累积(reasoning→thinking、
24
24
  * text→text、tool-call→toolCall),id 用 stream-<会话seq> 稳定跨快照。
25
25
  */
26
- const TEXT_CAP = 200_000;
27
- const TOOL_OUTPUT_CAP = 100_000;
28
- const ARGS_CAP = 20_000;
29
- function truncate(s, cap) {
30
- const str = typeof s === "string" ? s : String(s ?? "");
31
- if (str.length <= cap)
32
- return { text: str, truncated: false };
33
- return { text: `${str.slice(0, cap)}\n\n… [truncated]`, truncated: true };
34
- }
26
+ import { parseTextQuote } from "../text-quote.js";
27
+ import { ARGS_CAP, TEXT_CAP, TOOL_OUTPUT_CAP, truncate } from "../serialize.js";
35
28
  /** DSH user-role content blocks → UiContentBlock[]。 */
36
29
  export function serializeUserBlocks(blocks) {
37
30
  if (!Array.isArray(blocks) || blocks.length === 0) {
@@ -102,10 +95,19 @@ export function toolResultEventToUiMessage(data) {
102
95
  }
103
96
  /** user/message 事件 data → UiMessage。 */
104
97
  export function userMessageEventToUiMessage(data) {
98
+ const quotes = [];
99
+ const content = (data.content ?? []).filter((block) => {
100
+ const quote = block.type === "text" ? parseTextQuote(block.text ?? "") : null;
101
+ if (!quote)
102
+ return true;
103
+ quotes.push(quote);
104
+ return false;
105
+ });
105
106
  return {
106
107
  id: `u-${data.id ?? "?"}`,
107
108
  role: "user",
108
- content: serializeUserBlocks(data.content ?? []),
109
+ content: serializeUserBlocks(content),
110
+ ...(quotes.length ? { details: { quotes } } : {}),
109
111
  timestamp: data.time,
110
112
  };
111
113
  }
@@ -84,14 +84,28 @@ function zstdDecompressAll(buf) {
84
84
  }
85
85
  }
86
86
  let out = "";
87
- for (let i = 0; i < starts.length; i++) {
87
+ let i = 0;
88
+ while (i < starts.length) {
88
89
  const from = starts[i];
89
- const to = i + 1 < starts.length ? starts[i + 1] : buf.length;
90
- try {
91
- out += zstdDecompressSync(buf.subarray(from, to)).toString("utf8");
90
+ let nextIdx = i + 1;
91
+ let decompressed = null;
92
+ // 贪心尝试:若数据体内部误命中魔数导致切帧错误,向后试探合并到下一个魔数直至解压成功(issue #467)
93
+ while (nextIdx <= starts.length) {
94
+ const to = nextIdx < starts.length ? starts[nextIdx] : buf.length;
95
+ try {
96
+ decompressed = zstdDecompressSync(buf.subarray(from, to));
97
+ break;
98
+ }
99
+ catch {
100
+ nextIdx++;
101
+ }
92
102
  }
93
- catch {
94
- /* skip unreadable frame */
103
+ if (decompressed) {
104
+ out += decompressed.toString("utf8");
105
+ i = nextIdx;
106
+ }
107
+ else {
108
+ i++;
95
109
  }
96
110
  }
97
111
  return out || buf.toString("utf8");
@@ -870,10 +870,19 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
870
870
  return new Promise((resolve, reject) => {
871
871
  const timer = setTimeout(() => {
872
872
  const queued = this.questionBridge.queue.findIndex((q) => q.qid === qid);
873
- if (queued >= 0) this.questionBridge.queue.splice(queued, 1);
873
+ if (queued >= 0) {
874
+ this.questionBridge.queue.splice(queued, 1);
875
+ // #459:排队条目超时也必须 settle——此前只 splice 不 reject,
876
+ // 该 ask 的 Promise 永不落定,agent-loop 卡死在工具调用上。
877
+ reject(new Error("提问超时(等待回答过久)"));
878
+ return;
879
+ }
874
880
  if (this.questionBridge.pending?.qid === qid) {
875
881
  this.questionBridge.pending = null;
876
882
  reject(new Error("提问超时(等待回答过久)"));
883
+ // #459:pending 腾出后必须派发下一个排队提问——否则队列无人派发,
884
+ // 后续条目只能等各自超时且永不 settle,整条提问桥永久停摆。
885
+ this.dispatchNextQuestion();
877
886
  }
878
887
  }, QUESTION_TIMEOUT_MS);
879
888
  timer.unref?.();
@@ -939,6 +948,10 @@ class DshGoalJsonRpcServer extends HarnessSdkJsonRpcServer {
939
948
  }
940
949
  const bytes = new Uint8Array(Buffer.from(params.data, "base64"));
941
950
  if (bytes.length === 0) throw new TypeError("attachment/save: empty image data");
951
+ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20MB 上限
952
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
953
+ throw new RangeError(`attachment/save: image data exceeds 20MB limit (${bytes.length} bytes)`);
954
+ }
942
955
  const ref = await this.ctx.attachments.saveImage({
943
956
  data: bytes,
944
957
  mediaType,
@@ -1,5 +1,5 @@
1
1
  // Shared runtime-tree resolution for the pi-web-ui DSH runtime.
2
- // Used by launcher.mjs (boot-time) and probe-mixed.mjs (self-check).
2
+ // Used by launcher.mjs (boot-time) and dev/dsh-probes/probe-mixed.mjs (self-check).
3
3
  //
4
4
  // A "runtime tree" is a node_modules root that directly contains the dsh
5
5
  // runtime packages: `@deepseek-ai/dsh-base` (with its cordis.patch.yml
@@ -11,9 +11,10 @@
11
11
  * Download is fire-and-forget at server start and never throws: on failure
12
12
  * the terminal simply falls back to $COMSPEC (cmd.exe) as before.
13
13
  */
14
- import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { join } from "node:path";
17
+ import { writeAtomicSync } from "./atomic-file.js";
17
18
  /** Official busybox-w32 64-bit Unicode build (Win10 1903+ / Win11). */
18
19
  const BUSYBOX_URL = "https://frippery.org/files/busybox/busybox64u.exe";
19
20
  /** busybox.exe is ~660KB; anything far smaller is an error page, not a binary. */
@@ -55,7 +56,6 @@ export async function ensureWindowsBash() {
55
56
  return null; // Git Bash preferred — nothing to install.
56
57
  const dir = windowsBashDir();
57
58
  mkdirSync(dir, { recursive: true });
58
- const tmp = join(dir, `busybox-${process.pid}.tmp`);
59
59
  try {
60
60
  const res = await fetch(BUSYBOX_URL, {
61
61
  signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
@@ -65,13 +65,11 @@ export async function ensureWindowsBash() {
65
65
  const buf = Buffer.from(await res.arrayBuffer());
66
66
  if (buf.length < MIN_SIZE)
67
67
  return null; // error/HTML page, not the exe.
68
- writeFileSync(tmp, buf);
69
- renameSync(tmp, join(dir, "busybox.exe"));
68
+ writeAtomicSync(join(dir, "busybox.exe"), buf);
70
69
  copyFileSync(join(dir, "busybox.exe"), target);
71
70
  return target;
72
71
  }
73
72
  catch {
74
- rmSync(tmp, { force: true });
75
73
  return null;
76
74
  }
77
75
  }
@@ -287,8 +287,9 @@ class EvalKernel {
287
287
  this.proc = spawn(pyCmd, ["-u", "-c", PYTHON_DRIVER_SCRIPT], spawnOpts);
288
288
  }
289
289
  else {
290
- // js / ts:独立 node -e 驱动,避免 eval 里的死循环卡住服务端事件循环。
291
- this.proc = spawn(process.execPath, ["-e", NODE_DRIVER_SCRIPT], spawnOpts);
290
+ // js / ts:独立 node -e 驱动,ts 时带 --experimental-strip-types 支持 TypeScript 原生剥离执行。
291
+ const nodeArgs = this.language === "ts" ? ["--experimental-strip-types", "-e", NODE_DRIVER_SCRIPT] : ["-e", NODE_DRIVER_SCRIPT];
292
+ this.proc = spawn(process.execPath, nodeArgs, spawnOpts);
292
293
  }
293
294
  // stderr 必须持续排空:驱动只把用户的 print/console 劫进 StringIO,但
294
295
  // os.write(2, …) / 原生扩展 / eval 里再 spawn 的子进程仍写真实 fd 2。
@@ -503,7 +504,9 @@ export function makeEvalTool(opts) {
503
504
  code: Type.String({
504
505
  description: "The code snippet to evaluate. Top-level variables and functions are preserved across calls.",
505
506
  }),
506
- language: Type.Optional(Type.Union([Type.Literal("py"), Type.Literal("js"), Type.Literal("ts")], {
507
+ language: Type.Optional(Type.Unsafe({
508
+ type: "string",
509
+ enum: ["py", "js", "ts"],
507
510
  description: 'Target language: "py" for Python (default), "js" or "ts" for Node.js sandbox.',
508
511
  })),
509
512
  title: Type.Optional(Type.String({
@@ -567,8 +570,11 @@ export function makeEvalTool(opts) {
567
570
  }
568
571
  catch (err) {
569
572
  const durationMs = Date.now() - startTime;
570
- const errMsg = err.message;
571
- const text = `[eval:${language} error (${durationMs}ms)]\n${errMsg}`;
573
+ let errMsg = err.message;
574
+ if (errMsg.includes("Python executable not found in PATH")) {
575
+ errMsg = pick(lang, "未在系统 PATH 中找到 Python 可执行程序,请安装 Python 或使用 language: 'js'。", "Python executable not found in PATH. Please install Python or use language: 'js'.");
576
+ }
577
+ const text = `${pick(lang, `[eval:${language} 出错 (${durationMs}ms)]`, `[eval:${language} error (${durationMs}ms)]`)}\n${errMsg}`;
572
578
  return {
573
579
  content: [{ type: "text", text }],
574
580
  details: {
@@ -0,0 +1,250 @@
1
+ /**
2
+ * server/goal-review-gate.ts
3
+ *
4
+ * 目标审查回合的硬闸门(纯函数):主对话在 `awaitingVerdict` 置位期间是审查者,
5
+ * 只许只读核实 —— 写类工具 / 非常规 bash / 派发类工具 / 向用户提问一律拒。
6
+ *
7
+ * 为什么要硬闸门而不只靠契约:审查回合的提示词只写了「只读核实」,但主对话是
8
+ * 用户自己的全功能会话,模型在长任务里「顺手改一下」是常态;且 D6 要求轮次由
9
+ * 服务端控制,派生/等待/指挥别的对话必须拦。闸门是**无状态**的(每次执行只读
10
+ * `awaitingVerdict` 是否置位),verdict 落定即自动恢复,不存在「钉死只读」。
11
+ *
12
+ * 判定全部是纯函数(无 IO),单测见 tests/unit/goal-review-gate.test.ts。
13
+ * 包裹形态与 withPlanModeGate / withDelegationGate 同构,见 agent-service.ts。
14
+ *
15
+ * 已知局限(与计划/审查者闸门同口径):创建时注册进 customTools 的插件工具
16
+ * 同样被包裹;但之后经 refreshPluginTools 动态补入已有会话的不在覆盖面。
17
+ * 自研插件若绕过 wrapper 直接写文件同样拦不住 —— 只读是硬闸门,不是安全边界。
18
+ */
19
+ import { bashCommandIsReadOnly, SESSION_DISPATCH_TOOLS } from "./plan-mode.js";
20
+ /** 审查回合禁写的工具:与计划/审查者模式同名单(写 / 改 / 增删 / git 写 / 形态变换)。 */
21
+ const WRITE_TOOLS = new Set([
22
+ // 文件写入与编辑(第一方 + SDK 内置 + 编辑器类扩展的常见名)
23
+ "write",
24
+ "create",
25
+ "create_file",
26
+ "write_file",
27
+ "edit",
28
+ "edit_file",
29
+ "edit_soft",
30
+ "apply_patch",
31
+ "patch",
32
+ "patch_tool",
33
+ "str_replace",
34
+ "str_replace_editor",
35
+ "multi_edit",
36
+ "notebook_edit",
37
+ // 文件系统增删改
38
+ "mv",
39
+ "move",
40
+ "cp",
41
+ "copy",
42
+ "mkdir",
43
+ "rm",
44
+ "rmdir",
45
+ "delete",
46
+ "delete_file",
47
+ "touch",
48
+ "chmod",
49
+ "chown",
50
+ "truncate",
51
+ "rename",
52
+ // 版本控制写(git add/commit/checkout/reset…;只读子命令走 bash 白名单)
53
+ "git",
54
+ // 代码求值(eval 沙箱可写真实文件系统 —— 审查回合只读核实,#436)
55
+ "eval",
56
+ // 形态变换(会重写整个文件树)
57
+ "format",
58
+ "prettier",
59
+ "lint_fix",
60
+ "autofix",
61
+ ]);
62
+ /**
63
+ * 审查回合禁掉的「派发类」工具:轮次由服务端控制(D6)—— 派生 / 等待 / 指挥
64
+ * 别的对话、排程、改模式 / 开新目标都会绕开「一轮 = 派活 + 判定」的口径。
65
+ * 名单 = 三闸门共用的真实注册派发工具(SESSION_DISPATCH_TOOLS,#436 前是一堆
66
+ * 幽灵名)。subagent 不在此列:上面的按 action 判定已拦掉全部写向 action,
67
+ * 只读的 `conversation_read` / subagent get_result/list/templates 放行 ——
68
+ * 审查者可以按需看执行者的转录(契约要求看实际状态,而不是信描述)。
69
+ */
70
+ const DISPATCH_TOOLS = SESSION_DISPATCH_TOOLS;
71
+ /** bash 家族(要逐条判命令是否只读;与审查者模式同名单)。 */
72
+ const BASH_TOOLS = new Set(["bash", "shell", "sh", "zsh", "terminal", "terminal_bash", "run_command", "exec"]);
73
+ /** 引号感知的命令切分(按 | ; && || 换行拆段;含 $( / 反引号时整条视为一段交白名单判
74
+ * —— 白名单必拒,方向是 fail-closed,这里的拆分只会让判定更严不会放宽)。 */
75
+ function splitReviewSegments(cmd) {
76
+ if (/[`$]\(/.test(cmd))
77
+ return [cmd];
78
+ const out = [];
79
+ let cur = "";
80
+ let quote = null;
81
+ for (let i = 0; i < cmd.length; i++) {
82
+ const ch = cmd[i];
83
+ if (quote) {
84
+ cur += ch;
85
+ if (ch === quote && cmd[i - 1] !== "\\")
86
+ quote = null;
87
+ continue;
88
+ }
89
+ if (ch === '"' || ch === "'") {
90
+ quote = ch;
91
+ cur += ch;
92
+ continue;
93
+ }
94
+ if (ch === ";" || ch === "\n") {
95
+ out.push(cur);
96
+ cur = "";
97
+ continue;
98
+ }
99
+ if ((ch === "&" || ch === "|") && cmd[i + 1] === (ch === "&" ? "&" : "|")) {
100
+ out.push(cur);
101
+ cur = "";
102
+ i++;
103
+ continue;
104
+ }
105
+ if (ch === "|") {
106
+ out.push(cur);
107
+ cur = "";
108
+ continue;
109
+ }
110
+ cur += ch;
111
+ }
112
+ out.push(cur);
113
+ return out.map((s) => s.trim()).filter((s) => s !== "");
114
+ }
115
+ /**
116
+ * 审查回合额外放行的测试命令:契约承诺审查者可以「跑测试」验结论,而计划模式的
117
+ * 保守白名单连 `npm test` 都没有(解释器只放 --version/--help)。只认「跑测试」
118
+ * 的形状(test 脚本 / 各语言测试运行器),`npm run deploy` 这类一律不放;
119
+ * 重定向 / 反引号沿白名单方向必拒。
120
+ */
121
+ function isTestRunnerChunk(chunk) {
122
+ if (/[><`]/.test(chunk))
123
+ return false;
124
+ const tokens = chunk.split(/\s+/).filter(Boolean);
125
+ const first = tokens[0] ?? "";
126
+ const name = first.slice(first.lastIndexOf("/") + 1);
127
+ const args = tokens.slice(1);
128
+ switch (name) {
129
+ case "npm":
130
+ case "yarn":
131
+ case "pnpm":
132
+ case "bun":
133
+ return args[0] === "test" || (args[0] === "run" && (args[1] ?? "").startsWith("test"));
134
+ case "npx":
135
+ return args[0] === "vitest" || args[0] === "jest" || args[0] === "mocha";
136
+ case "pytest":
137
+ return true;
138
+ case "python":
139
+ case "python3":
140
+ return args[0] === "-m" && (args[1] === "pytest" || args[1] === "unittest");
141
+ case "node":
142
+ return args[0] === "--test";
143
+ case "go":
144
+ case "cargo":
145
+ return args[0] === "test";
146
+ case "make":
147
+ return (args[0] ?? "").startsWith("test") || args[0] === "check";
148
+ default:
149
+ return false;
150
+ }
151
+ }
152
+ /**
153
+ * 审查回合的 bash 是否可放行:每段要么过计划模式只读白名单,要么是跑测试的形状。
154
+ * 空命令返回 false(调用方另行处理无命令调用)。
155
+ */
156
+ export function goalReviewBashAllowed(cmd) {
157
+ const text = (cmd ?? "").trim();
158
+ if (!text)
159
+ return false;
160
+ const chunks = splitReviewSegments(text);
161
+ if (chunks.length === 0)
162
+ return false;
163
+ return chunks.every((c) => isTestRunnerChunk(c) || bashCommandIsReadOnly(c));
164
+ }
165
+ /** 从工具参数里取命令文本(各工具字段名不同)。 */
166
+ function commandOf(params) {
167
+ if (!params || typeof params !== "object")
168
+ return "";
169
+ const p = params;
170
+ for (const key of ["command", "cmd", "script", "input"]) {
171
+ const v = p[key];
172
+ if (typeof v === "string" && v.trim() !== "")
173
+ return v;
174
+ }
175
+ return "";
176
+ }
177
+ /**
178
+ * 审查回合中的用户插话是否顺延:纯文本 steer 会污染 verdict,必须排队到 verdict
179
+ * 落定后;followUp(补充按钮)由 SDK 排在整轮结束后才送达,本来就安全;斜杠命令
180
+ * 与带附件的不进顺延(前者直通,后者引用只在发送瞬间有效、调用方改响亮拒绝)。
181
+ */
182
+ export function shouldDeferPromptForReview(opts) {
183
+ if (opts.queue || !opts.awaitingVerdict || opts.hasAttachments)
184
+ return false;
185
+ if (opts.text.trim().startsWith("/"))
186
+ return false;
187
+ return true;
188
+ }
189
+ /**
190
+ * 目标审查闸门(纯函数):命中即拒绝,并把「该干什么」告诉模型 ——
191
+ * 拒绝理由是模型在审查回合里唯一的纠偏机会(长任务里最容易「顺手改一下」)。
192
+ */
193
+ export function goalReviewDenial(toolName, params) {
194
+ const name = (toolName ?? "").trim();
195
+ if (!name)
196
+ return undefined;
197
+ // 向用户提问:审查回合禁弹窗(问了就没人应答,verdict 会超时拖成 blocked)。
198
+ if (name === "ask_user_question") {
199
+ return {
200
+ kind: "ask-user",
201
+ reason: "目标审查回合:不能向用户提问(本回合由服务端自动驱动,弹窗无人应答)。请只做本轮审查、只回 verdict JSON。",
202
+ reasonEn: "Goal review round: do not ask the user anything (this round is server-driven; dialogs go unanswered). Just review this round and reply with only the verdict JSON.",
203
+ };
204
+ }
205
+ if (name === "subagent") {
206
+ const action = typeof params === "object" && params !== null ? params.action : undefined;
207
+ const act = typeof action === "string" ? action.trim().toLowerCase() : "";
208
+ // 只读 action(get_result, list, templates)放行;其余(spawn, steer, stop, wait_all, handoff)或未传 action 均拒绝
209
+ if (act === "get_result" || act === "list" || act === "templates") {
210
+ return undefined;
211
+ }
212
+ return {
213
+ kind: "dispatch-tool",
214
+ reason: `目标审查回合:不能调用 subagent(action="${act || "spawn"}") —— 轮次由服务端控制。请只做本轮审查、只回 verdict JSON。`,
215
+ reasonEn: `Goal review round: subagent(action="${act || "spawn"}") is unavailable — the server owns the rounds. Just review this round and reply with only the verdict JSON.`,
216
+ };
217
+ }
218
+ if (DISPATCH_TOOLS.has(name)) {
219
+ return {
220
+ kind: "dispatch-tool",
221
+ reason: `目标审查回合:不能调用 ${name} —— 轮次由服务端控制。请只做本轮审查、只回 verdict JSON。`,
222
+ reasonEn: `Goal review round: ${name} is unavailable — the server owns the rounds. Just review this round and reply with only the verdict JSON.`,
223
+ };
224
+ }
225
+ if (BASH_TOOLS.has(name)) {
226
+ const cmd = commandOf(params);
227
+ if (cmd.trim() === "") {
228
+ return {
229
+ kind: "bash",
230
+ reason: "目标审查回合:终端只用于只读核实(跑测试 / 看 diff),不接受无命令调用。请用 read / grep / 只读 git。",
231
+ reasonEn: "Goal review round: the terminal is for read-only verification only (run tests / inspect the diff); calls without a command are rejected. Use read / grep / read-only git.",
232
+ };
233
+ }
234
+ if (goalReviewBashAllowed(cmd))
235
+ return undefined;
236
+ return {
237
+ kind: "bash",
238
+ reason: `目标审查回合:只允许只读命令,命令被拒:\`${cmd.slice(0, 200)}\`。要改工作区请在 verdict 里判 fail 并写具体待改项,让执行对话下一轮做。`,
239
+ reasonEn: `Goal review round: only read-only commands are allowed; rejected: \`${cmd.slice(0, 200)}\`. To change the workspace, verdict fail with concrete items and let the executor do it next round.`,
240
+ };
241
+ }
242
+ if (WRITE_TOOLS.has(name)) {
243
+ return {
244
+ kind: "write-tool",
245
+ reason: `目标审查回合:只读核实,${name} 被拒。工作区的改动由执行对话做;你的职责是看实际状态、只回 verdict JSON。`,
246
+ reasonEn: `Goal review round: read-only verification — ${name} was rejected. The executor conversation makes the changes; your job is to inspect the actual state and reply with only the verdict JSON.`,
247
+ };
248
+ }
249
+ return undefined;
250
+ }