pi-web-ui 0.97.0 → 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 (80) hide show
  1. package/CHANGELOG.md +97 -1
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/bin/pi-web-ui.mjs +64 -23
  5. package/dist/server/agent-service.js +637 -145
  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 +220 -23
  11. package/dist/server/compact-context-tool.js +3 -1
  12. package/dist/server/compacted-history.js +7 -5
  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 +6 -12
  16. package/dist/server/dsh/dsh-agent-service.js +32 -30
  17. package/dist/server/dsh/dsh-client.js +45 -9
  18. package/dist/server/dsh/dsh-serialize.js +12 -10
  19. package/dist/server/dsh/dsh-sessions.js +20 -6
  20. package/dist/server/dsh/runtime/goal-rpc.mjs +14 -1
  21. package/dist/server/dsh/runtime/runtime-root.mjs +1 -1
  22. package/dist/server/ensure-bash.js +3 -5
  23. package/dist/server/eval-tool.js +8 -4
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +49 -5
  26. package/dist/server/index.js +83 -15
  27. package/dist/server/launch-origin.js +0 -4
  28. package/dist/server/locales.js +3 -5
  29. package/dist/server/lsp-tool.js +110 -14
  30. package/dist/server/markers/builtins/notify.js +4 -2
  31. package/dist/server/markers/builtins/rename.js +0 -1
  32. package/dist/server/markers/builtins/todo.js +6 -26
  33. package/dist/server/markers/marker.js +0 -15
  34. package/dist/server/markers/registry.js +0 -3
  35. package/dist/server/markers/store.js +0 -3
  36. package/dist/server/mcp-hot-reload.js +12 -5
  37. package/dist/server/model-admin.js +19 -18
  38. package/dist/server/model-enrich.js +0 -3
  39. package/dist/server/patch-tool.js +21 -6
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +150 -5
  42. package/dist/server/plan-mode.js +58 -20
  43. package/dist/server/plugin-api-catalog.js +2 -0
  44. package/dist/server/plugin-catalog.js +6 -6
  45. package/dist/server/plugin-dom.js +3 -4
  46. package/dist/server/plugin-facilities.js +3 -5
  47. package/dist/server/plugin-grants.js +5 -8
  48. package/dist/server/plugin-permissions.js +4 -6
  49. package/dist/server/plugin-schedule.js +4 -7
  50. package/dist/server/plugin-updater.js +151 -38
  51. package/dist/server/plugins.js +16 -2
  52. package/dist/server/prompt-composer.js +12 -3
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/resolve-global-sdk.js +8 -0
  55. package/dist/server/schedule-agent-tool.js +5 -1
  56. package/dist/server/scheduler-tasks.js +46 -27
  57. package/dist/server/sdk-origin.js +27 -0
  58. package/dist/server/serialize.js +22 -9
  59. package/dist/server/slash-commands.js +16 -0
  60. package/dist/server/subagent-templates.js +4 -10
  61. package/dist/server/subagents.js +22 -11
  62. package/dist/server/terminals.js +11 -6
  63. package/dist/server/text-quote.js +59 -0
  64. package/dist/server/tool-manager.js +12 -3
  65. package/dist/server/tool-overrides.js +24 -0
  66. package/dist/server/update-check.js +69 -25
  67. package/extensions/webui.ts +15 -1
  68. package/package.json +31 -19
  69. package/plugin-sdk/client-utils.mjs +92 -0
  70. package/plugin-sdk/index.d.ts +21 -0
  71. package/plugin-sdk/index.mjs +2 -0
  72. package/plugins/catalog.json +7 -0
  73. package/web/dist/assets/{TerminalPanel-CITBqQrb.js → TerminalPanel-C88r3r3Q.js} +1 -1
  74. package/web/dist/assets/index-2jDtjHvW.css +1 -0
  75. package/web/dist/assets/index-BjdK7s1M.js +368 -0
  76. package/web/dist/index.html +2 -2
  77. package/web/dist/sw.js +2 -1
  78. package/web/public/sw.js +2 -1
  79. package/web/dist/assets/index-DwToli0J.css +0 -1
  80. package/web/dist/assets/index-oI7UKOqJ.js +0 -368
@@ -31,15 +31,22 @@ export function createMcpHotReload(deps) {
31
31
  let debounce = null;
32
32
  let poller = null;
33
33
  let watcher = null;
34
- /** 读一次磁盘:指纹 + 清单(`servers` 为 null = 坏配置)。文件不在按「没有服务器」算。 */
34
+ /** 读一次磁盘:指纹 + 清单(`servers` 为 null = 坏配置/读失败)。文件不在按「没有服务器」算。 */
35
35
  function readOnce() {
36
36
  let raw;
37
37
  try {
38
38
  raw = readFileSync(file, "utf8");
39
39
  }
40
- catch {
41
- // 读不到(文件/目录不在)→ 空配置:删掉 mcp.json 就是「关掉全部 MCP 服务器」。
42
- return { fp: "empty", servers: {} };
40
+ catch (err) {
41
+ // 不变量 3 只覆盖「文件不在」:删掉 mcp.json = 关掉全部 MCP 服务器。
42
+ // #497:EBUSY/EACCES 等临时读失败(OneDrive/杀毒独占、权限误改)既不是
43
+ // 坏配置也不是删除意图——按坏配置口径走(servers: null → 保留在跑的
44
+ // 服务器 + 一次性提示),否则会把在跑的 MCP 服务器全杀还报「成功 0 个」。
45
+ const code = err?.code;
46
+ if (code === "ENOENT")
47
+ return { fp: "empty", servers: {} };
48
+ log(`[mcp] mcp.json 读取失败(${code ?? String(err)}),按坏配置处理:保留在跑的 MCP 服务器`);
49
+ return { fp: `unreadable:${code ?? "unknown"}`, servers: null };
43
50
  }
44
51
  const parsed = parseMcpConfig(raw);
45
52
  if (!parsed)
@@ -54,7 +61,7 @@ export function createMcpHotReload(deps) {
54
61
  applied = fp;
55
62
  if (!servers) {
56
63
  log("[mcp] mcp.json 解析失败,保留在跑的 MCP 服务器");
57
- deps.onNotice?.("warning", "mcp.json 解析失败,已保留当前 MCP 服务器(改好保存后会自动重载)", "Failed to parse mcp.json — keeping the running MCP servers (saving a valid file reloads automatically)");
64
+ deps.onNotice?.("warning", "mcp.json 解析/读取失败,已保留当前 MCP 服务器(恢复可读后会自动重载)", "Failed to parse/read mcp.json — keeping the running MCP servers (it reloads automatically once readable)");
58
65
  return "invalid";
59
66
  }
60
67
  const summary = await deps.reload();
@@ -11,8 +11,9 @@
11
11
  * 经 ModelAdminHost 与 ClientSession 解耦(同 settings/goal/slash 服务模式)。
12
12
  * UI 文案直接中文(服务端 notice 约定)。apiKey/headers 绝不下发浏览器。
13
13
  */
14
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
+ import { writeJsonAtomicSync } from "./atomic-file.js";
16
17
  import { enrichBatch } from "./model-enrich.js";
17
18
  import { pick } from "./i18n.js";
18
19
  import { ProviderOAuthFlowManager } from "./provider-oauth-flow.js";
@@ -65,12 +66,9 @@ export function graduateOverlayModels(providers, official) {
65
66
  }
66
67
  return graduated;
67
68
  }
68
- /** 原子写 JSON(tmp + rename):进程崩溃不留半截文件——裸 writeFileSync 中途被
69
- * 杀会把整份配置截断,下次读盘即空表。tmp 名带 pid,避免同机多进程互踩。 */
69
+ /** 原子写 JSON(tmp + rename):进程崩溃不留半截文件。 */
70
70
  function atomicWriteJson(path, data) {
71
- const tmp = `${path}.${process.pid}.tmp`;
72
- writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
73
- renameSync(tmp, path);
71
+ writeJsonAtomicSync(path, data);
74
72
  }
75
73
  /** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
76
74
  function stripJsonComments(src) {
@@ -378,13 +376,8 @@ export class ModelAdminService {
378
376
  return out;
379
377
  }
380
378
  writeProviderKeys(data) {
381
- mkdirSync(this.host.agentDir, { recursive: true });
382
- // 原子写(tmp+rename):崩溃不留半截 JSON;0o600 限权防本机其他用户读密钥
383
- //(Windows 忽略 mode 无害,POSIX 生效)。
384
- const path = this.providerKeysPath();
385
- const tmp = `${path}.${process.pid}.tmp`;
386
- writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
387
- renameSync(tmp, path);
379
+ // 原子写(tmp+rename):崩溃不留半截 JSON。
380
+ writeJsonAtomicSync(this.providerKeysPath(), data);
388
381
  }
389
382
  /** 解析失败的配置文件改名留存为 <name>.corrupt-<timestamp>。改名失败(占用/
390
383
  * 跨盘等)也不抛——留存是尽力而为,绝不能阻塞主流程。 */
@@ -1461,7 +1454,15 @@ export class ModelAdminService {
1461
1454
  }
1462
1455
  const fetched = await ModelAdminService.probeModelsEndpoint(baseUrl, saved.apiKey, saved.authHeader === true ? true : undefined, saved.api, saved.headers, lang);
1463
1456
  // Merge: manual values win; fetched fills blanks and appends new ids.
1464
- const prev = new Map((saved.models ?? []).map((m) => [m.id, m]));
1457
+ // #486:probe 是数秒级网络 await,期间用户可能已在设置面板编辑同一服务商——
1458
+ // 以盘上最新条目为合并基准(旧快照 saved 只提供探测参数),否则刷新会用
1459
+ // 旧快照整表覆盖,静默回滚并发编辑(删除的模型复活、手动新增丢失)。
1460
+ const fresh = this.readModelsConfig().providers[pid];
1461
+ if (!fresh) {
1462
+ // probe 期间服务商被删除:不再写回,避免用旧快照复活已删除的条目。
1463
+ return done(false, { error: "provider deleted during refresh" });
1464
+ }
1465
+ const prev = new Map((fresh.models ?? []).map((m) => [m.id, m]));
1465
1466
  let added = 0;
1466
1467
  for (const f of fetched) {
1467
1468
  const cur = prev.get(f.id);
@@ -1478,11 +1479,11 @@ export class ModelAdminService {
1478
1479
  const merged = [...prev.values()].sort((a, b) => a.id.localeCompare(b.id));
1479
1480
  await this.saveModelConfig(pid, {
1480
1481
  providerId: pid,
1481
- name: saved.name,
1482
- api: saved.api,
1483
- baseUrl: saved.baseUrl,
1482
+ name: fresh.name,
1483
+ api: fresh.api,
1484
+ baseUrl: fresh.baseUrl,
1484
1485
  // apiKey/headers 不回传浏览器——saveModelConfig 会保留旧值
1485
- authHeader: saved.authHeader === true ? true : undefined,
1486
+ authHeader: fresh.authHeader === true ? true : undefined,
1486
1487
  models: merged,
1487
1488
  });
1488
1489
  this.host.emit({
@@ -159,9 +159,6 @@ async function fetchJson(fetchFn, url, maxBytes, timeoutMs, signal) {
159
159
  /** 目录抓取缓存(24h TTL,进程内;首跑后无感)。 */
160
160
  const catalogCache = new Map();
161
161
  const CATALOG_TTL_MS = 24 * 3600 * 1000;
162
- export function clearEnrichCache() {
163
- catalogCache.clear();
164
- }
165
162
  export async function getOpenRouterCatalog(fetchFn = fetch, signal) {
166
163
  const hit = catalogCache.get("openrouter");
167
164
  if (hit && Date.now() - hit.at < CATALOG_TTL_MS)
@@ -11,11 +11,13 @@
11
11
  import { resolve } from "node:path";
12
12
  import { defineTool } from "@earendil-works/pi-coding-agent";
13
13
  import { Type } from "typebox";
14
- import { applyHashlinePatch, computeFileHash, formatHashlineHeader, formatNumberedLines, globalSnapshotStore, } from "./hashline-engine.js";
14
+ import { pick } from "./i18n.js";
15
+ import { applyHashlinePatch, formatHashlineHeader, globalSnapshotStore, } from "./hashline-engine.js";
15
16
  import { getLiveLspDiagnostics } from "./lsp-tool.js";
16
17
  export const PATCH_TOOL_NAME = "patch";
17
18
  export function makePatchTool(options) {
18
19
  const cwd = options.cwd;
20
+ const getLang = options.lang ?? (() => "en");
19
21
  return defineTool({
20
22
  name: PATCH_TOOL_NAME,
21
23
  promptSnippet: "apply content-hashed, line-anchored patches to workspace files (prevents stale edits, line drift, and indentation hallucination)",
@@ -34,10 +36,13 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
34
36
  description: "The complete Hashline patch text containing one or more [path#TAG] sections with PUT/CUT/REM/MV operations.",
35
37
  }),
36
38
  timeout: Type.Optional(Type.Number({
37
- description: "Optional execution timeout in seconds.",
39
+ minimum: 1,
40
+ maximum: 300,
41
+ description: "Optional execution timeout in seconds (1-300).",
38
42
  })),
39
43
  }),
40
44
  async execute(_callId, params, _signal, _onUpdate, _ctx) {
45
+ const L = getLang();
41
46
  const patchText = typeof params?.patch === "string" ? params.patch : "";
42
47
  if (!patchText.trim()) {
43
48
  const emptyReport = {
@@ -47,7 +52,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
47
52
  error: "Empty patch",
48
53
  };
49
54
  return {
50
- content: [{ type: "text", text: "Error: No patch content provided." }],
55
+ content: [
56
+ {
57
+ type: "text",
58
+ text: pick(L, "错误:未提供任何 patch 补丁内容。", "Error: No patch content provided."),
59
+ },
60
+ ],
51
61
  details: emptyReport,
52
62
  };
53
63
  }
@@ -57,7 +67,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
57
67
  });
58
68
  if (!report.ok) {
59
69
  return {
60
- content: [{ type: "text", text: `Patch Failed:\n${report.summary}` }],
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: pick(L, `补丁应用失败:\n${report.summary}`, `Patch Failed:\n${report.summary}`),
74
+ },
75
+ ],
61
76
  details: report,
62
77
  };
63
78
  }
@@ -66,12 +81,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
66
81
  for (const r of report.results) {
67
82
  if (r.op !== "deleted" && r.newHash) {
68
83
  const targetPath = r.newPath || r.filePath;
69
- textOutput.push(`\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``);
84
+ textOutput.push(pick(L, `\n${targetPath} 的下一处编辑锚点:\`${formatHashlineHeader(targetPath, r.newHash)}\``, `\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``));
70
85
  try {
71
86
  const fullPath = resolve(cwd, targetPath);
72
87
  const diags = await getLiveLspDiagnostics(fullPath, cwd);
73
88
  if (diags) {
74
- textOutput.push(`\nLive LSP diagnostics for ${targetPath}:\n${diags}`);
89
+ textOutput.push(pick(L, `\n${targetPath} 的实时 LSP 诊断:\n${diags}`, `\nLive LSP diagnostics for ${targetPath}:\n${diags}`));
75
90
  }
76
91
  }
77
92
  catch { }
@@ -67,3 +67,28 @@ export function readPermissionFromSession(sm) {
67
67
  }
68
68
  return undefined;
69
69
  }
70
+ /**
71
+ * 从会话 sessionManager entries 回放恢复最后保存的结构化计划状态。
72
+ * customType 为 "plan/update" 或 "pi-web-ui/plan",载荷结构为 { plan: PlanState }。
73
+ */
74
+ export function readPlanFromSession(sm) {
75
+ try {
76
+ const mgr = sm;
77
+ if (typeof mgr?.getEntries !== "function")
78
+ return null;
79
+ const entries = mgr.getEntries();
80
+ for (let i = entries.length - 1; i >= 0; i--) {
81
+ const e = entries[i];
82
+ if (e?.type === "custom" &&
83
+ (e.customType === "plan/update" || e.customType === "pi-web-ui/plan") &&
84
+ e.data?.plan &&
85
+ Array.isArray(e.data.plan.steps)) {
86
+ return e.data.plan;
87
+ }
88
+ }
89
+ }
90
+ catch {
91
+ // ignore
92
+ }
93
+ return null;
94
+ }
@@ -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) {
@@ -46,6 +46,9 @@ const BLOCKED_TOOLS = new Set([
46
46
  "rename",
47
47
  // 版本控制写(git add/commit/checkout/reset…;git 的只读子命令走 bash 白名单)
48
48
  "git",
49
+ // 代码求值(eval 沙箱的 execute 直接 kernel.execute,可写真实文件系统;
50
+ // 默认关但用户可开,开了就是一条写路径 —— #436)
51
+ "eval",
49
52
  // 形态变换(会重写整个文件树)
50
53
  "format",
51
54
  "prettier",
@@ -53,25 +56,43 @@ const BLOCKED_TOOLS = new Set([
53
56
  "autofix",
54
57
  ]);
55
58
  /**
56
- * 计划模式下也禁掉的「旁路工具」:它们能在**别的会话**里干活,而闸门是
57
- * 会话级的 —— 子代理/排程对话的 planMode 是 false,等于绕过只读约束。
58
- * 调研靠本对话的 read/grep/只读 bash 足够,需要并行调研时先退出计划模式。
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(只读)。
59
66
  */
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
- ]);
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;
73
89
  /** 计划模式下从模型视野中彻底隐藏的写类工具与旁路工具全集。 */
74
- export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([...BLOCKED_TOOLS, ...BYPASS_TOOLS, "subagent"]);
90
+ export const PLAN_MODE_BLOCKED_TOOL_NAMES = new Set([
91
+ ...BLOCKED_TOOLS,
92
+ ...BYPASS_TOOLS,
93
+ ...TERMINAL_WRITE_TOOLS,
94
+ "subagent",
95
+ ]);
75
96
  /** bash 类工具名(terminalBash 开关分流后可能是这几个)。 */
76
97
  const BASH_TOOLS = new Set(["bash", "bash_execute", "run_command", "shell", "terminal", "terminal_exec"]);
77
98
  /** 计划模式放行的只读命令(basename,无参数)。刻意保守:宁可拒一次让模型换命令。 */
@@ -238,6 +259,10 @@ function segmentIsReadOnly(segment) {
238
259
  return false;
239
260
  if (name === "awk" && /system\s*\(/.test(rest))
240
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;
241
266
  // 解释器:只放行 --version/--help 这类无副作用调用。
242
267
  if (name === "node" || name === "python" || name === "python3" || name === "npm") {
243
268
  return args.length > 0 && args.every((a) => INTERPRETER_SAFE_ARGS.has(a));
@@ -274,6 +299,7 @@ function commandOf(params) {
274
299
  * 计划模式闸门(纯函数):命中即拒绝,并把可操作的替代路径告诉模型。
275
300
  * - 写类工具 → 拒(提示:把要做的事写进计划,用户确认后另起一轮实施)
276
301
  * - bash 非常规命令 → 拒(提示:用 read/grep/只读 git,或给出计划)
302
+ * - 终端写向工具(terminal_input/terminal_key)→ 拒(会绕过只读命令白名单)
277
303
  * - 旁路工具(spawn/delegate/目标模式/排程)→ 拒(会绕过本会话只读约束)
278
304
  */
279
305
  export function planModeDenial(toolName, params) {
@@ -283,14 +309,26 @@ export function planModeDenial(toolName, params) {
283
309
  if (name === "subagent") {
284
310
  const action = typeof params === "object" && params !== null ? params.action : undefined;
285
311
  const act = typeof action === "string" ? action.trim().toLowerCase() : "";
286
- if (!act || act === "spawn") {
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") {
287
318
  return {
288
319
  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.`,
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.`,
291
322
  };
292
323
  }
293
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
+ }
294
332
  if (BYPASS_TOOLS.has(name)) {
295
333
  return {
296
334
  kind: "bypass-tool",
@@ -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 } : {}),
@@ -13,8 +13,9 @@
13
13
  * - 只有真正发生变更的写才落盘(临时文件 + rename 原子写);写失败 best-effort
14
14
  * (内存态仍生效,本次会话可用)。
15
15
  */
16
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { readFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
+ import { writeJsonAtomicSync } from "./atomic-file.js";
18
19
  const FILE = "plugin-dom.json";
19
20
  /** manifest.permissions 条目 → 是否请求特权 DOM(只看 `:` 前的族名,与宿主 can() 同口径)。
20
21
  * 例外:`dom:anchor` 只是客户端锚点挂载点的范围 DOM(免用户授权),不触发 bundle 403;
@@ -71,9 +72,7 @@ export class PluginDomConsent {
71
72
  else
72
73
  this.granted.delete(id);
73
74
  try {
74
- const tmp = join(this.dataDir, `${FILE}.tmp.${process.pid}`);
75
- writeFileSync(tmp, JSON.stringify({ v: 1, granted: this.list() }), "utf8");
76
- renameSync(tmp, join(this.dataDir, FILE));
75
+ writeJsonAtomicSync(join(this.dataDir, FILE), { v: 1, granted: this.list() });
77
76
  }
78
77
  catch {
79
78
  /* 写失败 best-effort:内存态仍生效,本次会话可用 */
@@ -16,9 +16,10 @@
16
16
  * 不能防同一用户账号下的完整进程妥协——本地个人工具的合理折衷。
17
17
  */
18
18
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
19
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
19
+ import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
20
20
  import { readFile as fspReadFile, readdir as fspReaddir, rm as fspRm, mkdir as fspMkdir, writeFile as fspWriteFile, appendFile as fspAppendFile, stat as fspStat, } from "node:fs/promises";
21
21
  import { dirname, join, relative, resolve } from "node:path";
22
+ import { writeAtomicSync } from "./atomic-file.js";
22
23
  import { createRequire } from "node:module";
23
24
  import { spawnSync } from "node:child_process";
24
25
  // 与 plugin-project 共用同一套 realpath 越界复核(isInsideRoot / realPathOfNearest),
@@ -26,10 +27,7 @@ import { spawnSync } from "node:child_process";
26
27
  import { isInsideRoot, realPathOfNearest } from "./plugin-project.js";
27
28
  /** tmp+rename 原子写(错误由调用方隔离——插件设施的 IO 一律尽力而为)。 */
28
29
  function atomicWrite(file, data) {
29
- mkdirSync(dirname(file), { recursive: true });
30
- const tmp = `${file}.tmp-${process.pid}`;
31
- writeFileSync(tmp, data);
32
- renameSync(tmp, file);
30
+ writeAtomicSync(file, data);
33
31
  }
34
32
  // ---------------------------------------------------------------------------
35
33
  // storage.json 的进程内「读-改-写」互斥