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
@@ -12,15 +12,84 @@
12
12
  * - 客户端也可通过协议消息 `plan_update` 调整
13
13
  * - 状态自动同步到快照 `UiState.plan`
14
14
  */
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { dirname } from "node:path";
15
17
  const VALID_STATUSES = new Set(["pending", "in_progress", "done", "failed"]);
16
18
  export class PlanManager {
19
+ filePath;
17
20
  plans = new Map();
18
- /** 获取指定会话的计划状态。 */
21
+ convToSession = new Map();
22
+ constructor(filePath) {
23
+ this.filePath = filePath;
24
+ if (filePath) {
25
+ this.load();
26
+ }
27
+ }
28
+ load() {
29
+ if (!this.filePath)
30
+ return;
31
+ try {
32
+ const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
33
+ for (const [key, val] of Object.entries(raw)) {
34
+ if (val && typeof val === "object" && Array.isArray(val.steps)) {
35
+ this.plans.set(key, val);
36
+ }
37
+ }
38
+ }
39
+ catch {
40
+ // 文件不存在或格式异常,以空状态起步
41
+ }
42
+ }
43
+ save() {
44
+ if (!this.filePath)
45
+ return;
46
+ try {
47
+ mkdirSync(dirname(this.filePath), { recursive: true });
48
+ const obj = {};
49
+ for (const [k, v] of this.plans.entries()) {
50
+ obj[k] = v;
51
+ }
52
+ const tmp = `${this.filePath}.tmp.${Date.now()}`;
53
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
54
+ renameSync(tmp, this.filePath);
55
+ }
56
+ catch {
57
+ // 持久化失败绝不阻断主流程
58
+ }
59
+ }
60
+ /** 绑定 conversationId 与 sessionId。若 sessionId 已有落盘计划,立即恢复给 conversationId。 */
61
+ bindSession(conversationId, sessionId) {
62
+ this.convToSession.set(conversationId, sessionId);
63
+ const existing = this.plans.get(sessionId);
64
+ if (existing) {
65
+ this.plans.set(conversationId, existing);
66
+ return existing;
67
+ }
68
+ const convExisting = this.plans.get(conversationId);
69
+ if (convExisting) {
70
+ this.plans.set(sessionId, convExisting);
71
+ this.save();
72
+ return convExisting;
73
+ }
74
+ return null;
75
+ }
76
+ /** 获取指定会话的计划状态。优先使用 conversationId,若无则回退查找绑定的 sessionId。 */
19
77
  getPlan(conversationId) {
20
- return this.plans.get(conversationId) ?? null;
78
+ const direct = this.plans.get(conversationId);
79
+ if (direct)
80
+ return direct;
81
+ const sessionId = this.convToSession.get(conversationId);
82
+ if (sessionId) {
83
+ const bySession = this.plans.get(sessionId);
84
+ if (bySession) {
85
+ this.plans.set(conversationId, bySession);
86
+ return bySession;
87
+ }
88
+ }
89
+ return null;
21
90
  }
22
- /** 设置/全量更新指定会话的计划。 */
23
- setPlan(conversationId, steps, activeStepId) {
91
+ /** 设置/全量更新指定会话的计划(可选绑定 sessionId 进行稳定落盘)。 */
92
+ setPlan(conversationId, steps, activeStepId, sessionId) {
24
93
  const normalizedSteps = (Array.isArray(steps) ? steps : []).map((s, idx) => {
25
94
  const status = (VALID_STATUSES.has(s.status) ? s.status : "pending");
26
95
  return {
@@ -42,11 +111,17 @@ export class PlanManager {
42
111
  updatedAt: Date.now(),
43
112
  };
44
113
  this.plans.set(conversationId, state);
114
+ const effectiveSessionId = sessionId ?? this.convToSession.get(conversationId);
115
+ if (effectiveSessionId) {
116
+ this.convToSession.set(conversationId, effectiveSessionId);
117
+ this.plans.set(effectiveSessionId, state);
118
+ }
119
+ this.save();
45
120
  return state;
46
121
  }
47
122
  /** 增量更新单个步骤的状态或内容。 */
48
123
  updateStep(conversationId, stepId, patch) {
49
- const current = this.plans.get(conversationId);
124
+ const current = this.getPlan(conversationId);
50
125
  if (!current)
51
126
  return null;
52
127
  const idx = current.steps.findIndex((s) => s.id === stepId);
@@ -86,11 +161,81 @@ export class PlanManager {
86
161
  updatedAt: Date.now(),
87
162
  };
88
163
  this.plans.set(conversationId, nextState);
164
+ const sessId = this.convToSession.get(conversationId);
165
+ if (sessId) {
166
+ this.plans.set(sessId, nextState);
167
+ }
168
+ this.save();
169
+ return nextState;
170
+ }
171
+ /** 增量删除单个步骤。 */
172
+ deleteStep(conversationId, stepId) {
173
+ const current = this.plans.get(conversationId);
174
+ if (!current)
175
+ return null;
176
+ const idx = current.steps.findIndex((s) => s.id === stepId);
177
+ if (idx === -1)
178
+ return current;
179
+ const nextSteps = current.steps.filter((s) => s.id !== stepId);
180
+ let nextActive = current.activeStepId;
181
+ if (current.activeStepId === stepId) {
182
+ const nextPending = nextSteps.find((s) => s.status === "pending" || s.status === "in_progress");
183
+ nextActive = nextPending ? nextPending.id : null;
184
+ }
185
+ const nextState = {
186
+ steps: nextSteps,
187
+ activeStepId: nextActive,
188
+ updatedAt: Date.now(),
189
+ };
190
+ this.plans.set(conversationId, nextState);
191
+ return nextState;
192
+ }
193
+ /** 增量新增步骤。 */
194
+ addStep(conversationId, step, afterStepId) {
195
+ const current = this.plans.get(conversationId);
196
+ const existingSteps = current?.steps ?? [];
197
+ const normalized = {
198
+ id: String(step.id ?? `step-${Date.now()}`),
199
+ title: String(step.title ?? "").slice(0, 200),
200
+ status: step.status && VALID_STATUSES.has(step.status) ? step.status : "pending",
201
+ };
202
+ const description = String(step.description ?? "").slice(0, 1000);
203
+ if (description)
204
+ normalized.description = description;
205
+ let nextSteps;
206
+ if (afterStepId) {
207
+ const idx = existingSteps.findIndex((s) => s.id === afterStepId);
208
+ if (idx !== -1) {
209
+ nextSteps = [...existingSteps.slice(0, idx + 1), normalized, ...existingSteps.slice(idx + 1)];
210
+ }
211
+ else {
212
+ nextSteps = [...existingSteps, normalized];
213
+ }
214
+ }
215
+ else {
216
+ nextSteps = [...existingSteps, normalized];
217
+ }
218
+ let nextActive = current?.activeStepId ?? null;
219
+ if (!nextActive && normalized.status === "in_progress") {
220
+ nextActive = normalized.id;
221
+ }
222
+ const nextState = {
223
+ steps: nextSteps,
224
+ activeStepId: nextActive,
225
+ updatedAt: Date.now(),
226
+ };
227
+ this.plans.set(conversationId, nextState);
89
228
  return nextState;
90
229
  }
91
230
  /** 清除指定会话的计划。 */
92
231
  clearPlan(conversationId) {
93
232
  this.plans.delete(conversationId);
233
+ const sessId = this.convToSession.get(conversationId);
234
+ if (sessId) {
235
+ this.plans.delete(sessId);
236
+ this.convToSession.delete(conversationId);
237
+ }
238
+ this.save();
94
239
  }
95
240
  /** 格式化计划为简洁文本,供模型上下文或诊断使用。 */
96
241
  describePlan(conversationId) {
@@ -0,0 +1,414 @@
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
+ // 代码求值(eval 沙箱的 execute 直接 kernel.execute,可写真实文件系统;
50
+ // 默认关但用户可开,开了就是一条写路径 —— #436)
51
+ "eval",
52
+ // 形态变换(会重写整个文件树)
53
+ "format",
54
+ "prettier",
55
+ "lint_fix",
56
+ "autofix",
57
+ ]);
58
+ /**
59
+ * 常驻终端的「写向」工具(server/terminals.ts):直接往 PTY stdin 写字符/按键。
60
+ * 必须整体剥离而不只是补检查:terminal_input 只在自带换行时才过 checkSafety,
61
+ * terminal_key 的 Enter 连检查都没有 —— 「input(无换行) + key(Enter)」两步即可
62
+ * 绕过 bash 只读白名单执行任意命令(#436)。且 PTY 的行编辑缓冲在 shell 进程内,
63
+ * 服务端拿不到可靠的「待提交行」(退格/Ctrl+U/补全都无法镜像),按缓冲检查既会
64
+ * 误放也会误拦 —— 所以计划模式下干脆不给这两件工具:调研用 bash(走白名单),
65
+ * 观察已有终端用 terminal_read/terminal_list/terminal_wait(只读)。
66
+ */
67
+ const TERMINAL_WRITE_TOOLS = new Set(["terminal_input", "terminal_key"]);
68
+ /**
69
+ * 三个只读闸门(计划 / 审查者 / 目标审查)共用的「派发类」工具名:它们会把活
70
+ * 派到**别的会话**执行(delegate_task 走子代理 spawn 通道;schedule_task 到期
71
+ * 唤醒对话跑 prompt),而这三道闸门都是会话级的 —— 放行即绕过。
72
+ *
73
+ * 名单以 server/tool-manager.ts 实际注册的工具名为事实源(#436):历史名单里的
74
+ * spawn / spawn_agent / subagent_spawn / schedule_agent / host_schedule /
75
+ * create_goal / set_goal / start_goal_wizard / create_conversation /
76
+ * fork_conversation / schedule / set_plan_mode 等都是协议消息名或从未注册的
77
+ * 幽灵名,一个真实工具都没拦住,反而掩盖了 schedule_task 这类真实旁路工具
78
+ * 完全可用的事实。subagent 是单 action 工具,由各闸门按 action 细分(只读
79
+ * action 放行,见各闸门的判定函数)。
80
+ */
81
+ export const SESSION_DISPATCH_TOOLS = new Set(["delegate_task", "schedule_task"]);
82
+ /**
83
+ * 计划模式下也禁掉的「旁路工具」(真实派发类,见 SESSION_DISPATCH_TOOLS):
84
+ * 它们能在**别的会话**里干活,而闸门是会话级的 —— 子代理/排程对话的
85
+ * planMode 是 false,等于绕过只读约束。调研靠本对话的 read/grep/只读 bash
86
+ * 足够,需要并行调研时先退出计划模式。
87
+ */
88
+ const BYPASS_TOOLS = SESSION_DISPATCH_TOOLS;
89
+ /** 计划模式下从模型视野中彻底隐藏的写类工具与旁路工具全集。 */
90
+ export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([
91
+ ...BLOCKED_TOOLS,
92
+ ...BYPASS_TOOLS,
93
+ ...TERMINAL_WRITE_TOOLS,
94
+ "subagent",
95
+ ]);
96
+ /** bash 类工具名(terminalBash 开关分流后可能是这几个)。 */
97
+ const BASH_TOOLS = new Set(["bash", "bash_execute", "run_command", "shell", "terminal", "terminal_exec"]);
98
+ /** 计划模式放行的只读命令(basename,无参数)。刻意保守:宁可拒一次让模型换命令。 */
99
+ const READ_ONLY_COMMANDS = new Set([
100
+ // 目录 / 文件查看
101
+ "ls",
102
+ "ll",
103
+ "pwd",
104
+ "cat",
105
+ "bat",
106
+ "head",
107
+ "tail",
108
+ "wc",
109
+ "file",
110
+ "stat",
111
+ "du",
112
+ "df",
113
+ "tree",
114
+ "find",
115
+ "fd",
116
+ "realpath",
117
+ "readlink",
118
+ "basename",
119
+ "dirname",
120
+ "which",
121
+ "type",
122
+ // 检索与文本处理(只读变体;sed 的 -i 在段内另行拒绝)
123
+ "grep",
124
+ "egrep",
125
+ "fgrep",
126
+ "rg",
127
+ "ag",
128
+ "ack",
129
+ "sed",
130
+ "awk",
131
+ "cut",
132
+ "tr",
133
+ "sort",
134
+ "uniq",
135
+ "jq",
136
+ "yq",
137
+ "diff",
138
+ "cmp",
139
+ // 环境 / 版本
140
+ "env",
141
+ "printenv",
142
+ "date",
143
+ "uname",
144
+ "whoami",
145
+ "node",
146
+ "npm",
147
+ "python",
148
+ "python3",
149
+ // 校验和(只读)
150
+ "md5sum",
151
+ "sha256sum",
152
+ ]);
153
+ /** git 的只读子命令(写子命令一律禁)。 */
154
+ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
155
+ "status",
156
+ "diff",
157
+ "log",
158
+ "show",
159
+ "blame",
160
+ "rev-parse",
161
+ "rev-list",
162
+ "ls-files",
163
+ "ls-tree",
164
+ "describe",
165
+ "shortlog",
166
+ "grep",
167
+ "cat-file",
168
+ "whatchanged",
169
+ ]);
170
+ /** 解释器的「只读例外」:只有这些参数组合放行,其余(-e/--eval/run script/子命令)拒。 */
171
+ const INTERPRETER_SAFE_ARGS = new Set(["--version", "-v", "-V", "--help", "-h", "--revision"]);
172
+ /** 段内出现即判写:重定向、命令替换、后台、heredoc。 */
173
+ const WRITE_SHELL_TOKENS = /[><`]|&\s*$/;
174
+ /** 引号感知的命令切分:把一条命令按 ; && || | 换行 拆成独立段。 */
175
+ function splitShellSegments(cmd) {
176
+ const out = [];
177
+ let cur = "";
178
+ let quote = null;
179
+ for (let i = 0; i < cmd.length; i++) {
180
+ const ch = cmd[i];
181
+ if (quote) {
182
+ cur += ch;
183
+ if (ch === quote && cmd[i - 1] !== "\\")
184
+ quote = null;
185
+ continue;
186
+ }
187
+ if (ch === '"' || ch === "'") {
188
+ quote = ch;
189
+ cur += ch;
190
+ continue;
191
+ }
192
+ // 命令替换 $( … ) 一律视为写(能跑任意代码),整条拒。
193
+ if (ch === "$" && cmd[i + 1] === "(")
194
+ return [""];
195
+ if (ch === ";" || ch === "\n") {
196
+ out.push(cur);
197
+ cur = "";
198
+ continue;
199
+ }
200
+ if ((ch === "&" || ch === "|") && cmd[i + 1] === (ch === "&" ? "&" : "|")) {
201
+ out.push(cur);
202
+ cur = "";
203
+ i++;
204
+ continue;
205
+ }
206
+ if (ch === "|") {
207
+ out.push(cur);
208
+ cur = "";
209
+ continue;
210
+ }
211
+ cur += ch;
212
+ }
213
+ out.push(cur);
214
+ return out.map((s) => s.trim()).filter((s) => s !== "");
215
+ }
216
+ /** 取一段里的命令 token:跳过 `FOO=bar` 前缀、`/usr/bin/env`、路径前缀。 */
217
+ function commandToken(segment) {
218
+ const tokens = segment.split(/\s+/);
219
+ let i = 0;
220
+ while (i < tokens.length) {
221
+ const tok = tokens[i];
222
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok)) {
223
+ i++;
224
+ continue;
225
+ }
226
+ if (tok === "env" || tok === "command" || tok === "nohup" || tok === "time") {
227
+ i++;
228
+ continue;
229
+ }
230
+ return tok;
231
+ }
232
+ return "";
233
+ }
234
+ function basenameOf(token) {
235
+ const idx = token.lastIndexOf("/");
236
+ return idx >= 0 ? token.slice(idx + 1) : token;
237
+ }
238
+ /** 单段是否只读(不含管道/重定向/命令替换,命令在白名单内)。 */
239
+ function segmentIsReadOnly(segment) {
240
+ if (WRITE_SHELL_TOKENS.test(segment))
241
+ return false;
242
+ // `>` 重定向在别处已覆盖;这里补 `2>&1` 之外的 fd 复制与 `<<<`/`2>`。
243
+ if (/<<|&\s*\d|\d\s*>/.test(segment))
244
+ return false;
245
+ const cmd = commandToken(segment);
246
+ if (!cmd)
247
+ return false;
248
+ const name = basenameOf(cmd);
249
+ const rest = segment.slice(segment.indexOf(cmd) + cmd.length).trim();
250
+ const args = rest.split(/\s+/).filter(Boolean);
251
+ if (name === "git") {
252
+ const sub = args.find((a) => !a.startsWith("-"));
253
+ return sub !== undefined && READ_ONLY_GIT_SUBCOMMANDS.has(sub);
254
+ }
255
+ if (!READ_ONLY_COMMANDS.has(name))
256
+ return false;
257
+ // sed -i / awk 的 system() 之类写路径。
258
+ if (name === "sed" && args.some((a) => /^-[a-z]*i/.test(a) || a === "--in-place"))
259
+ return false;
260
+ if (name === "awk" && /system\s*\(/.test(rest))
261
+ return false;
262
+ // find 的 -exec/-delete/-fprintf 族有写副作用(issue #483):白名单命中不等于只读,
263
+ // `find . -name '*.log' -delete` / `-exec touch x {} +` 无任何重定向特征,必须显式拒。
264
+ if (name === "find" && args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint0|fprint|fprintf|fls)$/.test(a)))
265
+ return false;
266
+ // 解释器:只放行 --version/--help 这类无副作用调用。
267
+ if (name === "node" || name === "python" || name === "python3" || name === "npm") {
268
+ return args.length > 0 && args.every((a) => INTERPRETER_SAFE_ARGS.has(a));
269
+ }
270
+ return true;
271
+ }
272
+ /**
273
+ * bash 命令是否只读(纯函数)。规则:按 `;` `&&` `||` `|` 切段,**每段**都要
274
+ * 只读;段内禁重定向 / 命令替换 / 后台 / heredoc;命令在白名单内,
275
+ * git 只放只读子命令,解释器只放行 --version/--help。
276
+ */
277
+ export function bashCommandIsReadOnly(cmd) {
278
+ const text = (cmd ?? "").trim();
279
+ if (!text)
280
+ return false;
281
+ const segments = splitShellSegments(text);
282
+ if (segments.length === 0)
283
+ return false;
284
+ return segments.every(segmentIsReadOnly);
285
+ }
286
+ /** 从工具参数里取命令文本(各工具字段名不同)。 */
287
+ function commandOf(params) {
288
+ if (!params || typeof params !== "object")
289
+ return "";
290
+ const p = params;
291
+ for (const key of ["command", "cmd", "script", "input"]) {
292
+ const v = p[key];
293
+ if (typeof v === "string" && v.trim() !== "")
294
+ return v;
295
+ }
296
+ return "";
297
+ }
298
+ /**
299
+ * 计划模式闸门(纯函数):命中即拒绝,并把可操作的替代路径告诉模型。
300
+ * - 写类工具 → 拒(提示:把要做的事写进计划,用户确认后另起一轮实施)
301
+ * - bash 非常规命令 → 拒(提示:用 read/grep/只读 git,或给出计划)
302
+ * - 终端写向工具(terminal_input/terminal_key)→ 拒(会绕过只读命令白名单)
303
+ * - 旁路工具(spawn/delegate/目标模式/排程)→ 拒(会绕过本会话只读约束)
304
+ */
305
+ export function planModeDenial(toolName, params) {
306
+ const name = (toolName ?? "").trim();
307
+ if (!name)
308
+ return undefined;
309
+ if (name === "subagent") {
310
+ const action = typeof params === "object" && params !== null ? params.action : undefined;
311
+ const act = typeof action === "string" ? action.trim().toLowerCase() : "";
312
+ // 与 goal-review-gate 同口径:只读 action(get_result/list/templates,看状态
313
+ // 与名录)放行;其余(spawn/steer/stop/wait_all/handoff,见 subagents.ts 的
314
+ // SUBAGENT_ACTIONS)或未传 action 一律拒 —— steer/handoff 会向运行中的子代理
315
+ // 会话投指令驱动它继续写代码,子代理会话 planMode=false 工具齐全,等于绕过
316
+ // 本会话的只读约束(#436 此前只拒了 spawn)。
317
+ if (act !== "get_result" && act !== "list" && act !== "templates") {
318
+ return {
319
+ kind: "bypass-tool",
320
+ reason: `计划模式:不能调用 subagent(action="${act || "spawn"}")(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
321
+ reasonEn: `Plan mode: subagent(action="${act || "spawn"}") is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
322
+ };
323
+ }
324
+ }
325
+ if (TERMINAL_WRITE_TOOLS.has(name)) {
326
+ return {
327
+ kind: "write-tool",
328
+ reason: `计划模式:不能调用 ${name}(终端输入/按键会绕过只读命令白名单,见 #436)。调研请用 bash 跑只读命令;观察已有终端用 terminal_read/terminal_list/terminal_wait。`,
329
+ reasonEn: `Plan mode: ${name} is unavailable (terminal input/keys would bypass the read-only command allowlist). Run read-only commands via bash; observe existing terminals with terminal_read/terminal_list/terminal_wait.`,
330
+ };
331
+ }
332
+ if (BYPASS_TOOLS.has(name)) {
333
+ return {
334
+ kind: "bypass-tool",
335
+ reason: `计划模式:不能调用 ${name}(它会在别的会话里执行,绕过本会话的只读约束)。请在本对话内用只读工具完成调研。`,
336
+ reasonEn: `Plan mode: ${name} is unavailable (it runs outside this conversation and would bypass the read-only constraint). Research with read-only tools here.`,
337
+ };
338
+ }
339
+ if (BASH_TOOLS.has(name)) {
340
+ const cmd = commandOf(params);
341
+ if (cmd.trim() === "") {
342
+ return {
343
+ kind: "bash",
344
+ reason: "计划模式:终端命令不可用。请用 read/grep/只读 git 完成调研。",
345
+ reasonEn: "Plan mode: shell commands are unavailable. Use read/grep and read-only git instead.",
346
+ };
347
+ }
348
+ if (bashCommandIsReadOnly(cmd))
349
+ return undefined;
350
+ return {
351
+ kind: "bash",
352
+ reason: `计划模式:只允许只读命令,命令被拒:\`${cmd.slice(0, 200)}\`。请改用 read/grep/git status|diff|log 等只读手段;需要做的改动写进计划,等用户确认后实施。`,
353
+ 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.`,
354
+ };
355
+ }
356
+ if (BLOCKED_TOOLS.has(name)) {
357
+ return {
358
+ kind: "write-tool",
359
+ reason: `计划模式:只规划不实施,${name} 被拒。请把这一步写进计划(plan_update 步骤 + 计划正文),等用户确认后在新一轮里实施。`,
360
+ 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.`,
361
+ };
362
+ }
363
+ return undefined;
364
+ }
365
+ /**
366
+ * 计划模式系统提示词段(英文:进入模型上下文,按工具提示词卫生口径只写英文)。
367
+ *
368
+ * 头两条是这份提示词真正的载荷:**禁代码倾倒**(模型在本模式下写不出文件,
369
+ * 于是会把整份实现当正文吐出来 —— 那既烧 token 又没落地,是计划模式最大的
370
+ * 失败模式)与 **小需求走短计划**(一句话需求不必先跑一轮 plan_update 看板)。
371
+ */
372
+ export const PLAN_MODE_SYSTEM_PROMPT = [
373
+ "# Plan mode (planning only — do NOT implement)",
374
+ "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.",
375
+ "## Hard rules",
376
+ "- 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.",
377
+ "- 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).",
378
+ "## Output shape (this is the part that matters most)",
379
+ '- 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.',
380
+ "- 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.",
381
+ "- 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.",
382
+ "- 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.",
383
+ "## Plan board",
384
+ '- 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.',
385
+ '- 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.',
386
+ "## Finishing",
387
+ "- 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).",
388
+ "- If the request is too ambiguous to plan, ask 1-3 targeted questions instead of guessing.",
389
+ ].join("\n");
390
+ /**
391
+ * 按设置面板偏好拼出生效的计划模式提示词(契约同 buildCommitMsgPrompt):
392
+ * append 把自定义文字接在内置默认之后(空自定义 = 纯默认);replace 用自定义
393
+ * 文字整体替换,但空自定义仍回落内置默认(绝不发空提示词)。
394
+ */
395
+ export function buildPlanModePrompt(mode, custom) {
396
+ const text = custom?.trim() ?? "";
397
+ if (mode === "replace" && text)
398
+ return text;
399
+ if (text)
400
+ return `${PLAN_MODE_SYSTEM_PROMPT}\n\n${text}`;
401
+ return PLAN_MODE_SYSTEM_PROMPT;
402
+ }
403
+ /** 拒给用户的界面文案(notice,zh + en 成对)。 */
404
+ export function planModeNoticeText(enabled) {
405
+ return enabled
406
+ ? {
407
+ text: "📋 计划模式:只调研与出计划,不实施(写操作会被服务端拒绝)",
408
+ textEn: "📋 Plan mode on: research and plan only — writes are blocked by the server",
409
+ }
410
+ : {
411
+ text: "计划模式已关闭:恢复正常对话",
412
+ textEn: "Plan mode off: back to normal",
413
+ };
414
+ }
@@ -23,6 +23,8 @@ export const SLOT_EXAMPLES = {
23
23
  "goalbar.actions": `{ "ui": { "goalbar.actions": [{ "id": "m", "label": "目标条", "kind": "action", "action": "my:m" }] } }`,
24
24
  "notice.actions": `{ "ui": { "notice.actions": [{ "id": "m", "label": "通知动作", "kind": "action", "action": "my:m" }] } }`,
25
25
  "modal.dialog": `{ "modal": [{ "id": "m", "label": "弹窗", "kind": "action", "action": "my:m" }] }`,
26
+ "sidebar.left": `{ "sidebar.left": [{ "id": "m", "label": "左侧悬浮", "kind": "action", "action": "my:m" }] }`,
27
+ "sidebar.right": `{ "sidebar.right": [{ "id": "m", "label": "右侧悬浮", "kind": "action", "action": "my:m" }] }`,
26
28
  };
27
29
  /** 宿主方法表(与 server/plugins.ts#PluginHost 同语义的精简版:只收发现用的注册/调用面)。
28
30
  * needs="-" = 观察/基础设施类(无需能力声明);其余走 can() 门控。 */
@@ -14,8 +14,8 @@
14
14
  * 设计上服务端不做网络探测(不拉 manifest):名称/简介/图标由条目本身携带
15
15
  * (作者填),id 由来源推导或作者显式指定 —— 保持离线可用、可单测。
16
16
  */
17
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
- import { dirname } from "node:path";
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { writeJsonAtomicSync } from "./atomic-file.js";
19
19
  import { normalizeIconSvg } from "./icon-svg.js";
20
20
  import { pick } from "./i18n.js";
21
21
  /** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
@@ -61,10 +61,7 @@ function readJsonSafe(path, fallback) {
61
61
  }
62
62
  }
63
63
  function atomicWrite(path, data) {
64
- mkdirSync(dirname(path), { recursive: true });
65
- const tmp = `${path}.tmp-${process.pid}`;
66
- writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
67
- renameSync(tmp, path);
64
+ writeJsonAtomicSync(path, data);
68
65
  }
69
66
  /** 把任意对象规范化为合法条目;非法则返回 null。 */
70
67
  function toEntry(raw, builtin) {
@@ -75,6 +72,7 @@ function toEntry(raw, builtin) {
75
72
  if (!ID_RE.test(id))
76
73
  return null;
77
74
  const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : id;
75
+ const nameEn = typeof raw.nameEn === "string" && raw.nameEn.trim() ? raw.nameEn.trim() : undefined;
78
76
  const description = typeof raw.description === "string" && raw.description.trim() ? raw.description.trim() : undefined;
79
77
  const descriptionEn = typeof raw.descriptionEn === "string" && raw.descriptionEn.trim() ? raw.descriptionEn.trim() : undefined;
80
78
  const icon = typeof raw.icon === "string" && raw.icon.trim() ? raw.icon.trim() : undefined;
@@ -85,6 +83,7 @@ function toEntry(raw, builtin) {
85
83
  name,
86
84
  source,
87
85
  builtin,
86
+ ...(nameEn ? { nameEn } : {}),
88
87
  ...(description ? { description } : {}),
89
88
  ...(descriptionEn ? { descriptionEn } : {}),
90
89
  ...(icon ? { icon } : {}),
@@ -231,6 +230,7 @@ export function writeCustomCatalog(customPath, incoming, replace) {
231
230
  id: e.id,
232
231
  source: e.source,
233
232
  name: e.name,
233
+ ...(e.nameEn ? { nameEn: e.nameEn } : {}),
234
234
  ...(e.description ? { description: e.description } : {}),
235
235
  ...(e.descriptionEn ? { descriptionEn: e.descriptionEn } : {}),
236
236
  ...(e.icon ? { icon: e.icon } : {}),