pi-web-ui 0.96.0 → 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 (42) hide show
  1. package/CHANGELOG.md +69 -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 +79 -20
  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/plugin-installer.js +30 -0
  25. package/dist/server/plugins.js +6 -3
  26. package/dist/server/protocol-version.js +1 -1
  27. package/dist/server/serialize.js +11 -0
  28. package/dist/server/settings-service.js +20 -8
  29. package/dist/server/subagents.js +360 -408
  30. package/dist/server/text-sniff.js +15 -9
  31. package/dist/server/tool-manager.js +48 -4
  32. package/dist/server/update-check.js +11 -3
  33. package/dist/server/wait-subscription-scan.js +5 -1
  34. package/package.json +3 -3
  35. package/themes/zhupi-dark.css +79 -44
  36. package/themes/zhupi.css +79 -44
  37. package/web/dist/assets/{TerminalPanel-DDChcYOh.js → TerminalPanel-CITBqQrb.js} +1 -1
  38. package/web/dist/assets/index-DwToli0J.css +1 -0
  39. package/web/dist/assets/index-oI7UKOqJ.js +368 -0
  40. package/web/dist/index.html +2 -2
  41. package/web/dist/assets/index-Do9RgJC3.js +0 -366
  42. package/web/dist/assets/index-DryOsILO.css +0 -1
@@ -0,0 +1,376 @@
1
+ /**
2
+ * server/plan-mode.ts
3
+ *
4
+ * 计划模式(Plan Mode):**只规划不实施** 的纯函数闸门与判定。
5
+ *
6
+ * 形态:会话级开关(`set_plan_mode`)→ 打开后本对话只允许「只读调研」,
7
+ * 产出走 `plan_update` 步骤看板 + 计划正文,实施由用户确认后另起一轮。
8
+ *
9
+ * 为什么要硬闸门而不只靠提示词:模型不遵守软约束是常态(尤其长任务里
10
+ * 「顺手改一下」)。所以计划模式在**工具执行前**再挡一道:写类工具直接拒、
11
+ * bash 只放行只读命令,拒了就把原因当工具结果回给模型,让它换只读路径。
12
+ *
13
+ * 判定全部是纯函数(无 IO),单测见 tests/unit/plan-mode.test.ts。
14
+ */
15
+ /** 计划模式下确定禁止的工具:写文件 / 改文件 / 建删 / 版本控制写。 */
16
+ const BLOCKED_TOOLS = new Set([
17
+ // 文件写入与编辑(第一方 + SDK 内置 + 编辑器类扩展的常见名)
18
+ "write",
19
+ "create",
20
+ "create_file",
21
+ "write_file",
22
+ "edit",
23
+ "edit_file",
24
+ "edit_soft",
25
+ "apply_patch",
26
+ "patch",
27
+ "patch_tool",
28
+ "str_replace",
29
+ "str_replace_editor",
30
+ "multi_edit",
31
+ "notebook_edit",
32
+ // 文件系统增删改
33
+ "mv",
34
+ "move",
35
+ "cp",
36
+ "copy",
37
+ "mkdir",
38
+ "rm",
39
+ "rmdir",
40
+ "delete",
41
+ "delete_file",
42
+ "touch",
43
+ "chmod",
44
+ "chown",
45
+ "truncate",
46
+ "rename",
47
+ // 版本控制写(git add/commit/checkout/reset…;git 的只读子命令走 bash 白名单)
48
+ "git",
49
+ // 形态变换(会重写整个文件树)
50
+ "format",
51
+ "prettier",
52
+ "lint_fix",
53
+ "autofix",
54
+ ]);
55
+ /**
56
+ * 计划模式下也禁掉的「旁路工具」:它们能在**别的会话**里干活,而闸门是
57
+ * 会话级的 —— 子代理/排程对话的 planMode 是 false,等于绕过只读约束。
58
+ * 调研靠本对话的 read/grep/只读 bash 足够,需要并行调研时先退出计划模式。
59
+ */
60
+ const BYPASS_TOOLS = new Set([
61
+ "spawn",
62
+ "spawn_agent",
63
+ "subagent_spawn",
64
+ "delegate_task",
65
+ "schedule_agent",
66
+ "host_schedule",
67
+ "create_goal",
68
+ "set_goal",
69
+ "start_goal_wizard",
70
+ "create_conversation",
71
+ "fork_conversation",
72
+ ]);
73
+ /** 计划模式下从模型视野中彻底隐藏的写类工具与旁路工具全集。 */
74
+ export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([...BLOCKED_TOOLS, ...BYPASS_TOOLS, "subagent"]);
75
+ /** bash 类工具名(terminalBash 开关分流后可能是这几个)。 */
76
+ const BASH_TOOLS = new Set(["bash", "bash_execute", "run_command", "shell", "terminal", "terminal_exec"]);
77
+ /** 计划模式放行的只读命令(basename,无参数)。刻意保守:宁可拒一次让模型换命令。 */
78
+ const READ_ONLY_COMMANDS = new Set([
79
+ // 目录 / 文件查看
80
+ "ls",
81
+ "ll",
82
+ "pwd",
83
+ "cat",
84
+ "bat",
85
+ "head",
86
+ "tail",
87
+ "wc",
88
+ "file",
89
+ "stat",
90
+ "du",
91
+ "df",
92
+ "tree",
93
+ "find",
94
+ "fd",
95
+ "realpath",
96
+ "readlink",
97
+ "basename",
98
+ "dirname",
99
+ "which",
100
+ "type",
101
+ // 检索与文本处理(只读变体;sed 的 -i 在段内另行拒绝)
102
+ "grep",
103
+ "egrep",
104
+ "fgrep",
105
+ "rg",
106
+ "ag",
107
+ "ack",
108
+ "sed",
109
+ "awk",
110
+ "cut",
111
+ "tr",
112
+ "sort",
113
+ "uniq",
114
+ "jq",
115
+ "yq",
116
+ "diff",
117
+ "cmp",
118
+ // 环境 / 版本
119
+ "env",
120
+ "printenv",
121
+ "date",
122
+ "uname",
123
+ "whoami",
124
+ "node",
125
+ "npm",
126
+ "python",
127
+ "python3",
128
+ // 校验和(只读)
129
+ "md5sum",
130
+ "sha256sum",
131
+ ]);
132
+ /** git 的只读子命令(写子命令一律禁)。 */
133
+ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
134
+ "status",
135
+ "diff",
136
+ "log",
137
+ "show",
138
+ "blame",
139
+ "rev-parse",
140
+ "rev-list",
141
+ "ls-files",
142
+ "ls-tree",
143
+ "describe",
144
+ "shortlog",
145
+ "grep",
146
+ "cat-file",
147
+ "whatchanged",
148
+ ]);
149
+ /** 解释器的「只读例外」:只有这些参数组合放行,其余(-e/--eval/run script/子命令)拒。 */
150
+ const INTERPRETER_SAFE_ARGS = new Set(["--version", "-v", "-V", "--help", "-h", "--revision"]);
151
+ /** 段内出现即判写:重定向、命令替换、后台、heredoc。 */
152
+ const WRITE_SHELL_TOKENS = /[><`]|&\s*$/;
153
+ /** 引号感知的命令切分:把一条命令按 ; && || | 换行 拆成独立段。 */
154
+ function splitShellSegments(cmd) {
155
+ const out = [];
156
+ let cur = "";
157
+ let quote = null;
158
+ for (let i = 0; i < cmd.length; i++) {
159
+ const ch = cmd[i];
160
+ if (quote) {
161
+ cur += ch;
162
+ if (ch === quote && cmd[i - 1] !== "\\")
163
+ quote = null;
164
+ continue;
165
+ }
166
+ if (ch === '"' || ch === "'") {
167
+ quote = ch;
168
+ cur += ch;
169
+ continue;
170
+ }
171
+ // 命令替换 $( … ) 一律视为写(能跑任意代码),整条拒。
172
+ if (ch === "$" && cmd[i + 1] === "(")
173
+ return [""];
174
+ if (ch === ";" || ch === "\n") {
175
+ out.push(cur);
176
+ cur = "";
177
+ continue;
178
+ }
179
+ if ((ch === "&" || ch === "|") && cmd[i + 1] === (ch === "&" ? "&" : "|")) {
180
+ out.push(cur);
181
+ cur = "";
182
+ i++;
183
+ continue;
184
+ }
185
+ if (ch === "|") {
186
+ out.push(cur);
187
+ cur = "";
188
+ continue;
189
+ }
190
+ cur += ch;
191
+ }
192
+ out.push(cur);
193
+ return out.map((s) => s.trim()).filter((s) => s !== "");
194
+ }
195
+ /** 取一段里的命令 token:跳过 `FOO=bar` 前缀、`/usr/bin/env`、路径前缀。 */
196
+ function commandToken(segment) {
197
+ const tokens = segment.split(/\s+/);
198
+ let i = 0;
199
+ while (i < tokens.length) {
200
+ const tok = tokens[i];
201
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) {
202
+ i++;
203
+ continue;
204
+ }
205
+ if (tok === "env" || tok === "command" || tok === "nohup" || tok === "time") {
206
+ i++;
207
+ continue;
208
+ }
209
+ return tok;
210
+ }
211
+ return "";
212
+ }
213
+ function basenameOf(token) {
214
+ const idx = token.lastIndexOf("/");
215
+ return idx >= 0 ? token.slice(idx + 1) : token;
216
+ }
217
+ /** 单段是否只读(不含管道/重定向/命令替换,命令在白名单内)。 */
218
+ function segmentIsReadOnly(segment) {
219
+ if (WRITE_SHELL_TOKENS.test(segment))
220
+ return false;
221
+ // `>` 重定向在别处已覆盖;这里补 `2>&1` 之外的 fd 复制与 `<<<`/`2>`。
222
+ if (/<<|&\s*\d|\d\s*>/.test(segment))
223
+ return false;
224
+ const cmd = commandToken(segment);
225
+ if (!cmd)
226
+ return false;
227
+ const name = basenameOf(cmd);
228
+ const rest = segment.slice(segment.indexOf(cmd) + cmd.length).trim();
229
+ const args = rest.split(/\s+/).filter(Boolean);
230
+ if (name === "git") {
231
+ const sub = args.find((a) => !a.startsWith("-"));
232
+ return sub !== undefined && READ_ONLY_GIT_SUBCOMMANDS.has(sub);
233
+ }
234
+ if (!READ_ONLY_COMMANDS.has(name))
235
+ return false;
236
+ // sed -i / awk 的 system() 之类写路径。
237
+ if (name === "sed" && args.some((a) => /^-[a-z]*i/.test(a) || a === "--in-place"))
238
+ return false;
239
+ if (name === "awk" && /system\s*\(/.test(rest))
240
+ return false;
241
+ // 解释器:只放行 --version/--help 这类无副作用调用。
242
+ if (name === "node" || name === "python" || name === "python3" || name === "npm") {
243
+ return args.length > 0 && args.every((a) => INTERPRETER_SAFE_ARGS.has(a));
244
+ }
245
+ return true;
246
+ }
247
+ /**
248
+ * bash 命令是否只读(纯函数)。规则:按 `;` `&&` `||` `|` 切段,**每段**都要
249
+ * 只读;段内禁重定向 / 命令替换 / 后台 / heredoc;命令在白名单内,
250
+ * git 只放只读子命令,解释器只放行 --version/--help。
251
+ */
252
+ export function bashCommandIsReadOnly(cmd) {
253
+ const text = (cmd ?? "").trim();
254
+ if (!text)
255
+ return false;
256
+ const segments = splitShellSegments(text);
257
+ if (segments.length === 0)
258
+ return false;
259
+ return segments.every(segmentIsReadOnly);
260
+ }
261
+ /** 从工具参数里取命令文本(各工具字段名不同)。 */
262
+ function commandOf(params) {
263
+ if (!params || typeof params !== "object")
264
+ return "";
265
+ const p = params;
266
+ for (const key of ["command", "cmd", "script", "input"]) {
267
+ const v = p[key];
268
+ if (typeof v === "string" && v.trim() !== "")
269
+ return v;
270
+ }
271
+ return "";
272
+ }
273
+ /**
274
+ * 计划模式闸门(纯函数):命中即拒绝,并把可操作的替代路径告诉模型。
275
+ * - 写类工具 → 拒(提示:把要做的事写进计划,用户确认后另起一轮实施)
276
+ * - bash 非常规命令 → 拒(提示:用 read/grep/只读 git,或给出计划)
277
+ * - 旁路工具(spawn/delegate/目标模式/排程)→ 拒(会绕过本会话只读约束)
278
+ */
279
+ export function planModeDenial(toolName, params) {
280
+ const name = (toolName ?? "").trim();
281
+ if (!name)
282
+ return undefined;
283
+ if (name === "subagent") {
284
+ const action = typeof params === "object" && params !== null ? params.action : undefined;
285
+ const act = typeof action === "string" ? action.trim().toLowerCase() : "";
286
+ if (!act || act === "spawn") {
287
+ return {
288
+ kind: "bypass-tool",
289
+ reason: `计划模式:不能调用 subagent(action="spawn")(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
290
+ reasonEn: `Plan mode: subagent(action="spawn") is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
291
+ };
292
+ }
293
+ }
294
+ if (BYPASS_TOOLS.has(name)) {
295
+ return {
296
+ kind: "bypass-tool",
297
+ reason: `计划模式:不能调用 ${name}(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
298
+ reasonEn: `Plan mode: ${name} is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
299
+ };
300
+ }
301
+ if (BASH_TOOLS.has(name)) {
302
+ const cmd = commandOf(params);
303
+ if (cmd.trim() === "") {
304
+ return {
305
+ kind: "bash",
306
+ reason: "计划模式:终端命令不可用。请用 read/grep/只读 git 完成调研。",
307
+ reasonEn: "Plan mode: shell commands are unavailable. Use read/grep and read-only git instead.",
308
+ };
309
+ }
310
+ if (bashCommandIsReadOnly(cmd))
311
+ return undefined;
312
+ return {
313
+ kind: "bash",
314
+ reason: `计划模式:只允许只读命令,命令被拒:\`${cmd.slice(0, 200)}\`。请改用 read/grep/git status|diff|log 等只读手段;需要做的改动写进计划,等用户确认后实施。`,
315
+ reasonEn: `Plan mode: only read-only commands are allowed; rejected: \`${cmd.slice(0, 200)}\`. Use read/grep or read-only git (status/diff/log). Put proposed changes in the plan and wait for the user's go-ahead.`,
316
+ };
317
+ }
318
+ if (BLOCKED_TOOLS.has(name)) {
319
+ return {
320
+ kind: "write-tool",
321
+ reason: `计划模式:只规划不实施,${name} 被拒。请把这一步写进计划(plan_update 步骤 + 计划正文),等用户确认后在新一轮里实施。`,
322
+ reasonEn: `Plan mode: planning only, no implementation — ${name} was rejected. Record it as a plan step (plan_update) and wait for the user to approve before implementing.`,
323
+ };
324
+ }
325
+ return undefined;
326
+ }
327
+ /**
328
+ * 计划模式系统提示词段(英文:进入模型上下文,按工具提示词卫生口径只写英文)。
329
+ *
330
+ * 头两条是这份提示词真正的载荷:**禁代码倾倒**(模型在本模式下写不出文件,
331
+ * 于是会把整份实现当正文吐出来 —— 那既烧 token 又没落地,是计划模式最大的
332
+ * 失败模式)与 **小需求走短计划**(一句话需求不必先跑一轮 plan_update 看板)。
333
+ */
334
+ export const PLAN_MODE_SYSTEM_PROMPT = [
335
+ "# Plan mode (planning only — do NOT implement)",
336
+ "The user turned on plan mode for this conversation. Your job in this mode is to produce a concrete, executable plan — never to make the changes.",
337
+ "## Hard rules",
338
+ "- Read-only research only: read, grep/rg, ls, and read-only bash (git status/diff/log/show, cat, wc, head, tail, jq). Anything that writes is blocked by the server and the rejection comes back as a tool error.",
339
+ "- Do not create, edit, move or delete files, do not run installs, builds, tests that write artifacts, and do not spawn subagents or start goal mode (those run outside this conversation).",
340
+ "## Output shape (this is the part that matters most)",
341
+ '- NEVER write the implementation. No full file bodies, no complete functions, no "here is the finished code", no working version of anything — it cannot be applied from this conversation and it wastes the user\'s tokens.',
342
+ "- Describe the work instead: which files to create or change, the structure (modules / functions / state), the key decisions and their reasons, the risks and unknowns, how it is verified, and the first concrete step.",
343
+ "- Code is allowed only as short illustrative snippets (about 20 lines each, at most two per plan) for genuinely tricky parts — a non-obvious algorithm or an API signature. No file dumps.",
344
+ "- Keep the whole plan short: a screen or two. Depth beats length; if a decision is still open, list it as a question instead of exploring it in prose.",
345
+ "## Plan board",
346
+ '- For work with 3+ steps or real unknowns, call plan_update early with decision-ready steps (all status "pending") and refine it as you learn more.',
347
+ '- For a small, unambiguous, greenfield request (e.g. "write a single-page snake game", "add a settings toggle", "rename this helper"), skip plan_update entirely and answer with the short plan: files to touch, structure, acceptance checks, first step.',
348
+ "## Finishing",
349
+ "- End with the plan and stop. Ask the user to confirm; implementation happens in the next turn after they turn plan mode off (or press the plan board's start-implementing action).",
350
+ "- If the request is too ambiguous to plan, ask 1-3 targeted questions instead of guessing.",
351
+ ].join("\n");
352
+ /**
353
+ * 按设置面板偏好拼出生效的计划模式提示词(契约同 buildCommitMsgPrompt):
354
+ * append 把自定义文字接在内置默认之后(空自定义 = 纯默认);replace 用自定义
355
+ * 文字整体替换,但空自定义仍回落内置默认(绝不发空提示词)。
356
+ */
357
+ export function buildPlanModePrompt(mode, custom) {
358
+ const text = custom?.trim() ?? "";
359
+ if (mode === "replace" && text)
360
+ return text;
361
+ if (text)
362
+ return `${PLAN_MODE_SYSTEM_PROMPT}\n\n${text}`;
363
+ return PLAN_MODE_SYSTEM_PROMPT;
364
+ }
365
+ /** 拒给用户的界面文案(notice,zh + en 成对)。 */
366
+ export function planModeNoticeText(enabled) {
367
+ return enabled
368
+ ? {
369
+ text: "📋 计划模式:只调研与出计划,不实施(写操作会被服务端拒绝)",
370
+ textEn: "📋 Plan mode on: research and plan only — writes are blocked by the server",
371
+ }
372
+ : {
373
+ text: "计划模式已关闭:恢复正常对话",
374
+ textEn: "Plan mode off: back to normal",
375
+ };
376
+ }
@@ -303,14 +303,47 @@ export function depName(spec) {
303
303
  function str(v) {
304
304
  return typeof v === "string" ? v.trim() : "";
305
305
  }
306
- /** 从插件目录出发能否解析到这个模块(模拟插件自身 import() 的查找链)。 */
306
+ /** 纯包名(`foo` / `@scope/foo`):能按 `node_modules/<name>/package.json` 判盘。
307
+ * `node:path`、URL、相对/绝对路径、git spec 一律 false(交给 resolve 判)。 */
308
+ function isPlainPackageName(name) {
309
+ return /^(?:@[a-z\d](?:[a-z\d._-]*[a-z\d])?\/)?[a-z\d](?:[a-z\d._-]*[a-z\d])?$/i.test(name);
310
+ }
311
+ /** 从 fromDir 沿目录树向上找 `node_modules/<name>/package.json` 是否已落盘。 */
312
+ function depOnDisk(fromDir, name) {
313
+ let dir = fromDir;
314
+ for (;;) {
315
+ try {
316
+ if (existsSync(join(dir, "node_modules", ...name.split("/"), "package.json")))
317
+ return true;
318
+ }
319
+ catch {
320
+ /* 权限/坏路径:当作这一层没有,继续往上找 */
321
+ }
322
+ const up = dirname(dir);
323
+ if (!up || up === dir)
324
+ return false;
325
+ dir = up;
326
+ }
327
+ }
328
+ /** 从插件目录出发能否解析到这个模块(模拟插件自身 import() 的查找链)。
329
+ *
330
+ * resolve 失败后再看一眼「文件是否已落盘」,这不是冗余判据而是**修 bug**(issue #383):
331
+ * Node 的 CJS 解析会把 `node_modules/<pkg>/package.json` 不存在的**负结果**缓存在
332
+ * 进程内存里(packageJsonReader 的路径缓存)。于是「探测缺失 → npm install 成功 →
333
+ * 复查」这组三连里,最后一次复查永远命中那条负缓存,恒抛
334
+ * `Cannot find module`,用户看到的是「装成功了却报 npm install 没跑通」,
335
+ * 且非重启整个进程不能恢复。落盘判据不碰 CJS 缓存,天然免疫。
336
+ *
337
+ * 顺序保持「先 resolve 后判盘」:解析成功仍然是最强的信号(含 exports 映射等
338
+ * 盘上判据覆盖不到的形状),判盘只是给解析的假阴性兜底,不会放宽真缺失。 */
307
339
  export function isDepAvailable(pluginDir, spec) {
340
+ const name = depName(spec);
308
341
  try {
309
- createRequire(join(pluginDir, "index.mjs")).resolve(depName(spec));
342
+ createRequire(join(pluginDir, "index.mjs")).resolve(name);
310
343
  return true;
311
344
  }
312
345
  catch {
313
- return false;
346
+ return isPlainPackageName(name) && depOnDisk(pluginDir, name);
314
347
  }
315
348
  }
316
349
  const depInstallLocks = new Map();
@@ -315,3 +315,33 @@ export class PluginInstaller {
315
315
  this.currentJobId = null;
316
316
  }
317
317
  }
318
+ /**
319
+ * 插件安装的用户确认门(P0):plugin_catalog_sync 的 install:true 与 plugin_job
320
+ * 的 install/update 在真正动安装器之前必须拿到用户确认。
321
+ *
322
+ * 用户若选择「记住并允许」,授权存入 plugin-permissions.json(后续同类操作自动放行,
323
+ * 设置面板「能力授权」页可审计与撤销)。拒绝 / 超时 / 未接弹窗设施(无头 DSH)一律 fail-closed。
324
+ */
325
+ export async function confirmPluginInstall(items, deps) {
326
+ if (deps.permGrants?.has("plugin-installer", "net", { host: "github.com" })) {
327
+ return true;
328
+ }
329
+ const ask = deps.permissionRequester;
330
+ if (!ask)
331
+ return false;
332
+ const list = items.map((x) => `${x.id} ← ${x.source}`).join("\n");
333
+ const ans = await ask("plugin-installer", {
334
+ family: "net",
335
+ hosts: ["github.com"],
336
+ reason: `安装确认:将安装/更新以下插件(id ← source):\n${list}\n拒绝或 120 秒未确认则不安装。`,
337
+ });
338
+ if (ans.ok && ans.remember && deps.permGrants) {
339
+ deps.permGrants.grant("plugin-installer", "net", {
340
+ hosts: ["github.com"],
341
+ reason: "插件安装/更新确认",
342
+ remember: true,
343
+ });
344
+ deps.onGrantsChanged?.();
345
+ }
346
+ return ans.ok === true;
347
+ }
@@ -1763,11 +1763,14 @@ export class PluginManager {
1763
1763
  }
1764
1764
  return out;
1765
1765
  }
1766
- /** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序)。 */
1766
+ /** 当前全部插件注册的 AI 工具(扁平化,按插件 id 稳定排序,附带所属 pluginId)。 */
1767
1767
  getAgentTools() {
1768
1768
  const out = [];
1769
- for (const table of [...this.agentTools.values()].sort())
1770
- out.push(...table.values());
1769
+ for (const [pid, table] of [...this.agentTools.entries()].sort(([a], [b]) => a.localeCompare(b))) {
1770
+ for (const tool of table.values()) {
1771
+ out.push({ ...tool, pluginId: pid });
1772
+ }
1773
+ }
1771
1774
  return out;
1772
1775
  }
1773
1776
  /** 注册一个供 AI 调用的工具;重名拒绝并返回空操作注销函数。
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 20;
11
+ export const PROTOCOL_VERSION = 21;
@@ -177,6 +177,17 @@ export function findEntryByUiId(entries, messageId, seqOf) {
177
177
  if (uiMessageId(m, seqOf(m)) === messageId)
178
178
  return entry;
179
179
  }
180
+ else if (entry.type === "compaction") {
181
+ const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
182
+ const m = {
183
+ role: "compactionSummary",
184
+ summary: entry.summary ?? "",
185
+ tokensBefore: entry.tokensBefore,
186
+ timestamp: ts,
187
+ };
188
+ if (uiMessageId(m, seqOf(m)) === messageId)
189
+ return entry;
190
+ }
180
191
  }
181
192
  return null;
182
193
  }
@@ -13,6 +13,7 @@ import { extensionKey, normalizeDisabledPluginTools, normalizeRetryMaxAttempts,
13
13
  import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
14
14
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
15
15
  import { COMMITMSG_SYSTEM_PROMPT } from "./scm-commitmsg.js";
16
+ import { PLAN_MODE_SYSTEM_PROMPT } from "./plan-mode.js";
16
17
  import { DEFAULT_TEMPLATES } from "./subagent-templates.js";
17
18
  import { deriveLegacy, foldLegacyIntoDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
18
19
  export class SettingsService {
@@ -54,12 +55,6 @@ export class SettingsService {
54
55
  }
55
56
  return false;
56
57
  }
57
- get reviewPrefs() {
58
- return {
59
- reviewPrompt: this.settings.reviewPrompt,
60
- reviewDisabledSkills: this.settings.reviewDisabledSkills,
61
- };
62
- }
63
58
  hasPendingReload() {
64
59
  return this.pendingReload;
65
60
  }
@@ -314,6 +309,8 @@ export class SettingsService {
314
309
  visionBridgePrompt: this.settings.visionBridgePrompt,
315
310
  scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
316
311
  scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
312
+ planModePromptMode: this.settings.planModePromptMode,
313
+ planModePrompt: this.settings.planModePrompt,
317
314
  reviewPrompt: this.settings.reviewPrompt,
318
315
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
319
316
  disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
@@ -327,6 +324,7 @@ export class SettingsService {
327
324
  toolsSchema: promptSnap.toolsSchema,
328
325
  visionBridgeDefaultPrompt: SYSTEM_PROMPT,
329
326
  scmCommitMsgDefaultPrompt: COMMITMSG_SYSTEM_PROMPT,
327
+ planModeDefaultPrompt: PLAN_MODE_SYSTEM_PROMPT,
330
328
  visionModels: this.collectVisionModels(),
331
329
  disabledSkills: [...this.settings.disabledSkills],
332
330
  disabledExtensions: [...this.settings.disabledExtensions],
@@ -404,6 +402,7 @@ export class SettingsService {
404
402
  // 统一工具开关 live 生效(ActiveSet 加减,无需 reload),见末尾 applyToolGating。
405
403
  const toolGatingChanged = partial.disabledAgentTools !== undefined ||
406
404
  partial.disabledPluginTools !== undefined ||
405
+ partial.disabledPlugins !== undefined ||
407
406
  partial.terminalToolsEnabled !== undefined ||
408
407
  partial.editSoftEnabled !== undefined ||
409
408
  partial.questionnaireEnabled !== undefined;
@@ -484,8 +483,12 @@ export class SettingsService {
484
483
  this.settings.toolApprovalEnabled = partial.toolApprovalEnabled;
485
484
  }
486
485
  // 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
486
+ // on→off 时立即停掉在飞的目标/调研 —— 不停的话循环照跑照派,开关名存实亡。
487
487
  if (partial.goalModeEnabled !== undefined) {
488
+ const wasOn = this.settings.goalModeEnabled !== false;
488
489
  this.settings.goalModeEnabled = partial.goalModeEnabled;
490
+ if (wasOn && partial.goalModeEnabled === false)
491
+ this.host.onGoalModeDisabled?.();
489
492
  }
490
493
  // 同项目并行提醒开关:同上,发送入口逐轮实时读取,无需 reload。
491
494
  if (partial.parallelReminderEnabled !== undefined) {
@@ -529,6 +532,12 @@ export class SettingsService {
529
532
  if (partial.scmCommitMsgPrompt !== undefined) {
530
533
  this.settings.scmCommitMsgPrompt = partial.scmCommitMsgPrompt;
531
534
  }
535
+ if (partial.planModePromptMode !== undefined) {
536
+ this.settings.planModePromptMode = partial.planModePromptMode;
537
+ }
538
+ if (partial.planModePrompt !== undefined) {
539
+ this.settings.planModePrompt = partial.planModePrompt;
540
+ }
532
541
  if (partial.reviewPrompt !== undefined) {
533
542
  this.settings.reviewPrompt = partial.reviewPrompt;
534
543
  }
@@ -573,11 +582,11 @@ export class SettingsService {
573
582
  // 必须在 this.push() 前生效,保证 promptSnapshot 收集到的是最新的活跃工具集与完整提示词!
574
583
  if (toolGatingChanged)
575
584
  this.host.applyToolGating();
576
- if (needsReload)
577
- await this.applyRuntime();
578
585
  this.host.stateStore.saveSettings(this.host.clientId, this.settings);
579
586
  this.push();
580
587
  this.host.flushSnapshot();
588
+ if (needsReload)
589
+ await this.applyRuntime();
581
590
  }
582
591
  /** Save the CURRENT settings as a named preset (overwrites if exists). */
583
592
  async savePreset(name) {
@@ -693,6 +702,9 @@ export class SettingsService {
693
702
  // 「AI 提交信息」提示词同样不进预设——保留当前值。
694
703
  scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
695
704
  scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
705
+ // 计划模式提示词同样不进预设——保留当前值。
706
+ planModePromptMode: this.settings.planModePromptMode,
707
+ planModePrompt: this.settings.planModePrompt,
696
708
  // 子代理默认模型也不进预设——保留当前值。
697
709
  subagentDefaultModel: this.settings.subagentDefaultModel,
698
710
  // 快捷短语是纯 UI 偏好,不进预设——保留当前值。