pi-web-ui 0.96.1 → 0.97.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 (41) hide show
  1. package/CHANGELOG.md +48 -2
  2. package/README.md +26 -0
  3. package/README.zh-CN.md +25 -0
  4. package/bin/pi-web-ui.mjs +11 -2
  5. package/dist/server/agent-service.js +1195 -120
  6. package/dist/server/client-state.js +89 -1
  7. package/dist/server/compacted-history.js +146 -0
  8. package/dist/server/delegate-mode.js +149 -0
  9. package/dist/server/delegate-task.js +10 -10
  10. package/dist/server/dsh/dsh-agent-service.js +35 -2
  11. package/dist/server/dsh/dsh-client.js +3 -1
  12. package/dist/server/eval-tool.js +3 -1
  13. package/dist/server/goal-review-gate.js +263 -0
  14. package/dist/server/goal-service.js +1225 -445
  15. package/dist/server/index.js +35 -1
  16. package/dist/server/lsp-tool.js +13 -10
  17. package/dist/server/markers/builtins/todo.js +0 -2
  18. package/dist/server/mcp-bridge.js +13 -0
  19. package/dist/server/patch-tool.js +1 -0
  20. package/dist/server/patch-turn-end-boundary.js +77 -0
  21. package/dist/server/permission-preset.js +44 -0
  22. package/dist/server/plan-mode.js +376 -0
  23. package/dist/server/plugin-facilities.js +36 -3
  24. package/dist/server/plugins.js +6 -3
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/serialize.js +11 -0
  27. package/dist/server/settings-service.js +18 -6
  28. package/dist/server/subagents.js +360 -408
  29. package/dist/server/text-sniff.js +15 -9
  30. package/dist/server/tool-manager.js +48 -4
  31. package/dist/server/update-check.js +11 -3
  32. package/dist/server/wait-subscription-scan.js +5 -1
  33. package/package.json +3 -3
  34. package/themes/zhupi-dark.css +5 -30
  35. package/themes/zhupi.css +5 -30
  36. package/web/dist/assets/{TerminalPanel-O3GyFIGG.js → TerminalPanel-CITBqQrb.js} +1 -1
  37. package/web/dist/assets/index-DwToli0J.css +1 -0
  38. package/web/dist/assets/index-oI7UKOqJ.js +368 -0
  39. package/web/dist/index.html +2 -2
  40. package/web/dist/assets/index-B7Zp23Vc.css +0 -1
  41. package/web/dist/assets/index-ByfoaDwp.js +0 -366
@@ -0,0 +1,263 @@
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 } 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
+ // 形态变换(会重写整个文件树)
55
+ "format",
56
+ "prettier",
57
+ "lint_fix",
58
+ "autofix",
59
+ ]);
60
+ /**
61
+ * 审查回合禁掉的「派发类」工具:轮次由服务端控制(D6)—— 派生 / 等待 / 指挥
62
+ * 别的对话、排程、改模式 / 开新目标都会绕开「一轮 = 派活 + 判定」的口径。
63
+ * 只读的 `conversation_read` / `subagent_get_result` / `subagent_list` 不在名单里:
64
+ * 审查者可以按需看执行者的转录(契约要求看实际状态,而不是信描述)。
65
+ */
66
+ const DISPATCH_TOOLS = new Set([
67
+ "spawn",
68
+ "spawn_agent",
69
+ "subagent_spawn",
70
+ "spawn_subagent",
71
+ "subagent_steer",
72
+ "subagent_stop",
73
+ "subagent_wait_all",
74
+ "subagent_handoff",
75
+ "delegate_task",
76
+ "schedule",
77
+ "schedule_agent",
78
+ "host_schedule",
79
+ "create_goal",
80
+ "set_goal",
81
+ "start_goal_wizard",
82
+ "set_plan_mode",
83
+ ]);
84
+ /** bash 家族(要逐条判命令是否只读;与审查者模式同名单)。 */
85
+ const BASH_TOOLS = new Set(["bash", "shell", "sh", "zsh", "terminal", "terminal_bash", "run_command", "exec"]);
86
+ /** 引号感知的命令切分(按 | ; && || 换行拆段;含 $( / 反引号时整条视为一段交白名单判
87
+ * —— 白名单必拒,方向是 fail-closed,这里的拆分只会让判定更严不会放宽)。 */
88
+ function splitReviewSegments(cmd) {
89
+ if (/[`$]\(/.test(cmd))
90
+ return [cmd];
91
+ const out = [];
92
+ let cur = "";
93
+ let quote = null;
94
+ for (let i = 0; i < cmd.length; i++) {
95
+ const ch = cmd[i];
96
+ if (quote) {
97
+ cur += ch;
98
+ if (ch === quote && cmd[i - 1] !== "\\")
99
+ quote = null;
100
+ continue;
101
+ }
102
+ if (ch === '"' || ch === "'") {
103
+ quote = ch;
104
+ cur += ch;
105
+ continue;
106
+ }
107
+ if (ch === ";" || ch === "\n") {
108
+ out.push(cur);
109
+ cur = "";
110
+ continue;
111
+ }
112
+ if ((ch === "&" || ch === "|") && cmd[i + 1] === (ch === "&" ? "&" : "|")) {
113
+ out.push(cur);
114
+ cur = "";
115
+ i++;
116
+ continue;
117
+ }
118
+ if (ch === "|") {
119
+ out.push(cur);
120
+ cur = "";
121
+ continue;
122
+ }
123
+ cur += ch;
124
+ }
125
+ out.push(cur);
126
+ return out.map((s) => s.trim()).filter((s) => s !== "");
127
+ }
128
+ /**
129
+ * 审查回合额外放行的测试命令:契约承诺审查者可以「跑测试」验结论,而计划模式的
130
+ * 保守白名单连 `npm test` 都没有(解释器只放 --version/--help)。只认「跑测试」
131
+ * 的形状(test 脚本 / 各语言测试运行器),`npm run deploy` 这类一律不放;
132
+ * 重定向 / 反引号沿白名单方向必拒。
133
+ */
134
+ function isTestRunnerChunk(chunk) {
135
+ if (/[><`]/.test(chunk))
136
+ return false;
137
+ const tokens = chunk.split(/\s+/).filter(Boolean);
138
+ const first = tokens[0] ?? "";
139
+ const name = first.slice(first.lastIndexOf("/") + 1);
140
+ const args = tokens.slice(1);
141
+ switch (name) {
142
+ case "npm":
143
+ case "yarn":
144
+ case "pnpm":
145
+ case "bun":
146
+ return args[0] === "test" || (args[0] === "run" && (args[1] ?? "").startsWith("test"));
147
+ case "npx":
148
+ return args[0] === "vitest" || args[0] === "jest" || args[0] === "mocha";
149
+ case "pytest":
150
+ return true;
151
+ case "python":
152
+ case "python3":
153
+ return args[0] === "-m" && (args[1] === "pytest" || args[1] === "unittest");
154
+ case "node":
155
+ return args[0] === "--test";
156
+ case "go":
157
+ case "cargo":
158
+ return args[0] === "test";
159
+ case "make":
160
+ return (args[0] ?? "").startsWith("test") || args[0] === "check";
161
+ default:
162
+ return false;
163
+ }
164
+ }
165
+ /**
166
+ * 审查回合的 bash 是否可放行:每段要么过计划模式只读白名单,要么是跑测试的形状。
167
+ * 空命令返回 false(调用方另行处理无命令调用)。
168
+ */
169
+ export function goalReviewBashAllowed(cmd) {
170
+ const text = (cmd ?? "").trim();
171
+ if (!text)
172
+ return false;
173
+ const chunks = splitReviewSegments(text);
174
+ if (chunks.length === 0)
175
+ return false;
176
+ return chunks.every((c) => isTestRunnerChunk(c) || bashCommandIsReadOnly(c));
177
+ }
178
+ /** 从工具参数里取命令文本(各工具字段名不同)。 */
179
+ function commandOf(params) {
180
+ if (!params || typeof params !== "object")
181
+ return "";
182
+ const p = params;
183
+ for (const key of ["command", "cmd", "script", "input"]) {
184
+ const v = p[key];
185
+ if (typeof v === "string" && v.trim() !== "")
186
+ return v;
187
+ }
188
+ return "";
189
+ }
190
+ /**
191
+ * 审查回合中的用户插话是否顺延:纯文本 steer 会污染 verdict,必须排队到 verdict
192
+ * 落定后;followUp(补充按钮)由 SDK 排在整轮结束后才送达,本来就安全;斜杠命令
193
+ * 与带附件的不进顺延(前者直通,后者引用只在发送瞬间有效、调用方改响亮拒绝)。
194
+ */
195
+ export function shouldDeferPromptForReview(opts) {
196
+ if (opts.queue || !opts.awaitingVerdict || opts.hasAttachments)
197
+ return false;
198
+ if (opts.text.trim().startsWith("/"))
199
+ return false;
200
+ return true;
201
+ }
202
+ /**
203
+ * 目标审查闸门(纯函数):命中即拒绝,并把「该干什么」告诉模型 ——
204
+ * 拒绝理由是模型在审查回合里唯一的纠偏机会(长任务里最容易「顺手改一下」)。
205
+ */
206
+ export function goalReviewDenial(toolName, params) {
207
+ const name = (toolName ?? "").trim();
208
+ if (!name)
209
+ return undefined;
210
+ // 向用户提问:审查回合禁弹窗(问了就没人应答,verdict 会超时拖成 blocked)。
211
+ if (name === "ask_user_question") {
212
+ return {
213
+ kind: "ask-user",
214
+ reason: "目标审查回合:不能向用户提问(本回合由服务端自动驱动,弹窗无人应答)。请只做本轮审查、只回 verdict JSON。",
215
+ 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.",
216
+ };
217
+ }
218
+ if (name === "subagent") {
219
+ const action = typeof params === "object" && params !== null ? params.action : undefined;
220
+ const act = typeof action === "string" ? action.trim().toLowerCase() : "";
221
+ // 只读 action(get_result, list, templates)放行;其余(spawn, steer, stop, wait_all, handoff)或未传 action 均拒绝
222
+ if (act === "get_result" || act === "list" || act === "templates") {
223
+ return undefined;
224
+ }
225
+ return {
226
+ kind: "dispatch-tool",
227
+ reason: `目标审查回合:不能调用 subagent(action="${act || "spawn"}") —— 轮次由服务端控制。请只做本轮审查、只回 verdict JSON。`,
228
+ 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.`,
229
+ };
230
+ }
231
+ if (DISPATCH_TOOLS.has(name)) {
232
+ return {
233
+ kind: "dispatch-tool",
234
+ reason: `目标审查回合:不能调用 ${name} —— 轮次由服务端控制。请只做本轮审查、只回 verdict JSON。`,
235
+ reasonEn: `Goal review round: ${name} is unavailable — the server owns the rounds. Just review this round and reply with only the verdict JSON.`,
236
+ };
237
+ }
238
+ if (BASH_TOOLS.has(name)) {
239
+ const cmd = commandOf(params);
240
+ if (cmd.trim() === "") {
241
+ return {
242
+ kind: "bash",
243
+ reason: "目标审查回合:终端只用于只读核实(跑测试 / 看 diff),不接受无命令调用。请用 read / grep / 只读 git。",
244
+ 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.",
245
+ };
246
+ }
247
+ if (goalReviewBashAllowed(cmd))
248
+ return undefined;
249
+ return {
250
+ kind: "bash",
251
+ reason: `目标审查回合:只允许只读命令,命令被拒:\`${cmd.slice(0, 200)}\`。要改工作区请在 verdict 里判 fail 并写具体待改项,让执行对话下一轮做。`,
252
+ 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.`,
253
+ };
254
+ }
255
+ if (WRITE_TOOLS.has(name)) {
256
+ return {
257
+ kind: "write-tool",
258
+ reason: `目标审查回合:只读核实,${name} 被拒。工作区的改动由执行对话做;你的职责是看实际状态、只回 verdict JSON。`,
259
+ 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.`,
260
+ };
261
+ }
262
+ return undefined;
263
+ }