pi-web-ui 0.97.0 → 0.99.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 +126 -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 +645 -147
  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 +30 -5
  24. package/dist/server/goal-review-gate.js +7 -20
  25. package/dist/server/goal-service.js +77 -10
  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 +2 -3
  39. package/dist/server/patch-tool.js +78 -28
  40. package/dist/server/permission-preset.js +25 -0
  41. package/dist/server/plan-manager.js +196 -10
  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 +24 -12
  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 -40
  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-NerrJlTS.js} +1 -1
  74. package/web/dist/assets/index-BdMaGCcs.css +1 -0
  75. package/web/dist/assets/index-ByCBN4Kt.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
@@ -3,10 +3,12 @@
3
3
  */
4
4
  import { getServerBlock, pick } from "../../i18n.js";
5
5
  const NOTIFY_GUIDANCE_ZH = [
6
- "- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|success|error。",
6
+ // #499:guidance 只能承诺协议真实支持的级别——success 从未存在于 notice 协议
7
+ // (protocol.ts 仅 info|warning|error),按指引用 success 会被静默降级成 info。
8
+ "- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|error。",
7
9
  ];
8
10
  const NOTIFY_GUIDANCE_EN = [
9
- "- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|success|error.",
11
+ "- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|error.",
10
12
  ];
11
13
  /** 语言感知的 notify guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
12
14
  export function getNotifyGuidance(lang = "en") {
@@ -10,7 +10,6 @@
10
10
  * 不需要额外状态。
11
11
  */
12
12
  import { getServerBlock, pick } from "../../i18n.js";
13
- export const RENAME_NAMESPACE = "conv";
14
13
  function extractTitle(token) {
15
14
  // args[0] 是主标题;kwargs 兼容 text/name/title
16
15
  const fromArgs = token.args.join(" ").trim();
@@ -15,30 +15,6 @@ function parseId(raw) {
15
15
  const n = Number(raw);
16
16
  return Number.isInteger(n) && n > 0 ? n : null;
17
17
  }
18
- function formatStatus(s) {
19
- switch (s) {
20
- case "pending":
21
- return "pending";
22
- case "in_progress":
23
- return "in_progress";
24
- case "completed":
25
- return "completed";
26
- default:
27
- return s;
28
- }
29
- }
30
- export function describeTodos(state, includeDeleted = false, lang = "en") {
31
- const visible = state.tasks.filter((t) => includeDeleted || t.status !== "deleted");
32
- if (visible.length === 0)
33
- return pick(lang, "[todo] (空)", "[todo] (empty)", "markers.todo.list.empty");
34
- return visible
35
- .map((t) => {
36
- const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
37
- const deps = t.blockedBy.length ? ` ⛓ ${t.blockedBy.join(",")}` : "";
38
- return `[${formatStatus(t.status)}] #${t.id} ${t.subject}${form}${deps}`;
39
- })
40
- .join("\n");
41
- }
42
18
  const TODO_GUIDANCE_ZH = [
43
19
  "- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
44
20
  "- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
@@ -64,7 +40,9 @@ export const todoMarker = {
64
40
  const op = token.op;
65
41
  switch (op) {
66
42
  case "new": {
67
- const subject = token.args[0]?.trim();
43
+ // #499:subject 是唯一「整段自由文本」参数——args.join 还原含逗号的主题,
44
+ // 否则 `[[todo:new:修复登录页,同步改注册页]]` 的后半段被静默丢弃。
45
+ const subject = token.args.join(",").trim();
68
46
  if (!subject)
69
47
  return {
70
48
  applied: false,
@@ -179,7 +157,9 @@ export const todoMarker = {
179
157
  const depsText = deps.length ? deps.join(",") : lang === "zh" ? "(无)" : "(none)";
180
158
  return {
181
159
  applied: true,
182
- feedback: pick(lang, `#${id} 依赖:${depsText}`, `#${id} blocks: ${depsText}`, "markers.todo.dep.blocks.updated", { id: id, depsText: depsText }),
160
+ feedback: pick(lang, `#${id} 依赖:${depsText}`,
161
+ // #499:实现是 blockedBy(deps 先完成)——原英文 "blocks" 把依赖方向说反了。
162
+ `#${id} is blocked by: ${depsText}`, "markers.todo.dep.blocks.updated", { id: id, depsText: depsText }),
183
163
  };
184
164
  }
185
165
  default:
@@ -2,8 +2,6 @@
2
2
  * marker.ts — 通用内联标记核心抽象(内置版)。
3
3
  * 复刻自 pi-marker-tools,保持相同解析语义,便于 AI 无缝迁移。
4
4
  */
5
- export const MARKER_OPEN = "[[";
6
- export const MARKER_CLOSE = "]]";
7
5
  // ---------------------------------------------------------------------------
8
6
  // 解析器
9
7
  // ---------------------------------------------------------------------------
@@ -38,16 +36,3 @@ export function parseMarkers(text) {
38
36
  }
39
37
  return tokens;
40
38
  }
41
- export function stripMarkers(text) {
42
- return text.replace(TOKEN_RE, () => "");
43
- }
44
- export function replaceToken(text, raw, replacement) {
45
- return text.split(raw).join(replacement);
46
- }
47
- export function serializeToken(token) {
48
- const parts = [token.tool, token.op, ...token.args];
49
- const kwargs = Object.entries(token.kwargs)
50
- .sort(([a], [b]) => (a < b ? -1 : 1))
51
- .map(([k, v]) => `${k}=${v}`);
52
- return `${MARKER_OPEN}${[...parts, ...kwargs].join(":")}${MARKER_CLOSE}`;
53
- }
@@ -11,9 +11,6 @@ export function getMarker(name) {
11
11
  export function allMarkers() {
12
12
  return [...registry.values()];
13
13
  }
14
- export function lookupToken(name) {
15
- return registry.get(name);
16
- }
17
14
  export function collectGuidance(disabled = new Set(), lang = "en") {
18
15
  const out = [];
19
16
  for (const m of allMarkers()) {
@@ -23,9 +23,6 @@ export function loadStateFromBranch(branch, namespace) {
23
23
  }
24
24
  return latest?.state;
25
25
  }
26
- export function hasStateInBranch(branch, namespace) {
27
- return loadStateFromBranch(branch, namespace) !== undefined;
28
- }
29
26
  /** 追加快照:优先走 sessionManager.appendCustomEntry,否则回退到回调。 */
30
27
  export function appendSnapshot(mgr, namespace, state, fallbackSave) {
31
28
  const snapshot = {
@@ -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({
@@ -156,12 +156,11 @@ async function fetchJson(fetchFn, url, maxBytes, timeoutMs, signal) {
156
156
  signal.removeEventListener("abort", onAbort);
157
157
  }
158
158
  }
159
+ // 手写 TTL 缓存而非通用 memoize:双键(openrouter/modelsdev)共享一个 Map,
160
+ // 且 compute 是 async fetch——单值同步的 memoizeWithTtl 覆盖不了(issue #470 处置说明)。
159
161
  /** 目录抓取缓存(24h TTL,进程内;首跑后无感)。 */
160
162
  const catalogCache = new Map();
161
163
  const CATALOG_TTL_MS = 24 * 3600 * 1000;
162
- export function clearEnrichCache() {
163
- catalogCache.clear();
164
- }
165
164
  export async function getOpenRouterCatalog(fetchFn = fetch, signal) {
166
165
  const hit = catalogCache.get("openrouter");
167
166
  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,40 +52,85 @@ 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
  }
54
- const report = applyHashlinePatch(patchText, {
55
- cwd,
56
- snapshotStore: globalSnapshotStore,
57
- });
58
- if (!report.ok) {
64
+ // timeout 此前是死参数:schema 声明了 1-300 秒,execute 从未读取,大补丁
65
+ // 卡住只能等 20 分钟看门狗(issue #462)。这里用 Promise.race 实现真实
66
+ // 超时,覆盖 await 长尾(实时 LSP 诊断等);applyHashlinePatch 是同步
67
+ // 本地文件操作,同步段无法被中断,race 只能兜异步部分。
68
+ const timeoutSec = params.timeout ? Math.min(Math.max(1, params.timeout), 300) : null;
69
+ const finish = async () => {
70
+ const report = applyHashlinePatch(patchText, {
71
+ cwd,
72
+ snapshotStore: globalSnapshotStore,
73
+ });
74
+ if (!report.ok) {
75
+ return {
76
+ content: [
77
+ {
78
+ type: "text",
79
+ text: pick(L, `补丁应用失败:\n${report.summary}`, `Patch Failed:\n${report.summary}`),
80
+ },
81
+ ],
82
+ details: report,
83
+ };
84
+ }
85
+ const textOutput = [report.summary];
86
+ // 为成功修改的文件输出最新的头部标签,方便 Agent 连续进行下一步修改
87
+ for (const r of report.results) {
88
+ if (r.op !== "deleted" && r.newHash) {
89
+ const targetPath = r.newPath || r.filePath;
90
+ textOutput.push(pick(L, `\n${targetPath} 的下一处编辑锚点:\`${formatHashlineHeader(targetPath, r.newHash)}\``, `\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``));
91
+ try {
92
+ const fullPath = resolve(cwd, targetPath);
93
+ const diags = await getLiveLspDiagnostics(fullPath, cwd);
94
+ if (diags) {
95
+ textOutput.push(pick(L, `\n${targetPath} 的实时 LSP 诊断:\n${diags}`, `\nLive LSP diagnostics for ${targetPath}:\n${diags}`));
96
+ }
97
+ }
98
+ catch { }
99
+ }
100
+ }
59
101
  return {
60
- content: [{ type: "text", text: `Patch Failed:\n${report.summary}` }],
102
+ content: [{ type: "text", text: textOutput.join("\n") }],
61
103
  details: report,
62
104
  };
105
+ };
106
+ if (!timeoutSec)
107
+ return finish();
108
+ const timeoutReply = {
109
+ content: [
110
+ {
111
+ type: "text",
112
+ text: pick(L, `补丁执行超时(${timeoutSec}s)。补丁可能已部分应用,请检查目标文件后重试。`, `Patch execution timed out (${timeoutSec}s). The patch may have been partially applied; check the target files and retry.`),
113
+ },
114
+ ],
115
+ details: {
116
+ ok: false,
117
+ summary: `Execution timed out after ${timeoutSec}s`,
118
+ results: [],
119
+ error: "timeout",
120
+ },
121
+ };
122
+ let timer;
123
+ try {
124
+ return await Promise.race([
125
+ finish(),
126
+ new Promise((resolveTimeout) => {
127
+ timer = setTimeout(() => resolveTimeout(timeoutReply), timeoutSec * 1000);
128
+ }),
129
+ ]);
63
130
  }
64
- const textOutput = [report.summary];
65
- // 为成功修改的文件输出最新的头部标签,方便 Agent 连续进行下一步修改
66
- for (const r of report.results) {
67
- if (r.op !== "deleted" && r.newHash) {
68
- const targetPath = r.newPath || r.filePath;
69
- textOutput.push(`\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``);
70
- try {
71
- const fullPath = resolve(cwd, targetPath);
72
- const diags = await getLiveLspDiagnostics(fullPath, cwd);
73
- if (diags) {
74
- textOutput.push(`\nLive LSP diagnostics for ${targetPath}:\n${diags}`);
75
- }
76
- }
77
- catch { }
78
- }
131
+ finally {
132
+ clearTimeout(timer);
79
133
  }
80
- return {
81
- content: [{ type: "text", text: textOutput.join("\n") }],
82
- details: report,
83
- };
84
134
  },
85
135
  });
86
136
  }
@@ -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
+ }
@@ -11,16 +11,111 @@
11
11
  * - 模型可通过 customTool `plan_update` 更新
12
12
  * - 客户端也可通过协议消息 `plan_update` 调整
13
13
  * - 状态自动同步到快照 `UiState.plan`
14
+ *
15
+ * 存储模型与隔离设计(防新会话串台):
16
+ * - 持久化源(plansBySession):以真实唯一的 SDK sessionId 为主键,落盘至 plans.json。
17
+ * - 运行时缓存(plansByConv):以会话槽位 conversationId 为主键,仅存活于进程内存,绝不落盘。
18
+ * - 历史文件清洗:严格过滤形如 /^c\d+$/ 的易变短 ID,防止服务重启后新会话被上一个会话的任务附体。
14
19
  */
20
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
+ import { dirname } from "node:path";
15
22
  const VALID_STATUSES = new Set(["pending", "in_progress", "done", "failed"]);
23
+ /** 判断是否为易变的临时 conversationId(如 c1, c2, c99 等)。此类短 ID 绝不入持久化文件。 */
24
+ function isEphemeralConvId(id) {
25
+ return /^c\d+$/.test(id);
26
+ }
16
27
  export class PlanManager {
17
- plans = new Map();
18
- /** 获取指定会话的计划状态。 */
19
- getPlan(conversationId) {
20
- return this.plans.get(conversationId) ?? null;
28
+ filePath;
29
+ /** 按持久化 sessionId 存储的真实计划状态(落盘至 plans.json)。 */
30
+ plansBySession = new Map();
31
+ /** 按运行时 conversationId 存储的临时计划缓存(进程内存态,不落盘)。 */
32
+ plansByConv = new Map();
33
+ /** conversationId 到 sessionId 的映射。 */
34
+ convToSession = new Map();
35
+ constructor(filePath) {
36
+ this.filePath = filePath;
37
+ if (filePath) {
38
+ this.load();
39
+ }
40
+ }
41
+ load() {
42
+ if (!this.filePath)
43
+ return;
44
+ try {
45
+ const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
46
+ for (const [key, val] of Object.entries(raw)) {
47
+ // 严格过滤:形如 c1, c2 的临时 conversationId 绝不作为持久化会话键载入,
48
+ // 防止服务重启或历史残留时污染新分配的相同 ID 对话。
49
+ if (isEphemeralConvId(key))
50
+ continue;
51
+ if (val && typeof val === "object" && Array.isArray(val.steps)) {
52
+ this.plansBySession.set(key, val);
53
+ }
54
+ }
55
+ }
56
+ catch {
57
+ // 文件不存在或格式异常,以空状态起步
58
+ }
59
+ }
60
+ save() {
61
+ if (!this.filePath)
62
+ return;
63
+ try {
64
+ mkdirSync(dirname(this.filePath), { recursive: true });
65
+ const obj = {};
66
+ for (const [k, v] of this.plansBySession.entries()) {
67
+ // 双重防护:临时 conversationId 绝不落盘
68
+ if (isEphemeralConvId(k))
69
+ continue;
70
+ obj[k] = v;
71
+ }
72
+ const tmp = `${this.filePath}.tmp.${Date.now()}`;
73
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
74
+ renameSync(tmp, this.filePath);
75
+ }
76
+ catch {
77
+ // 持久化失败绝不阻断主流程
78
+ }
79
+ }
80
+ /** 绑定 conversationId 与 sessionId。若 sessionId 已有落盘计划,立即恢复给 conversationId。 */
81
+ bindSession(conversationId, sessionId) {
82
+ this.convToSession.set(conversationId, sessionId);
83
+ // 绑定(或重新绑定)session 时,先清除当前 conversationId 上次残留的内存状态
84
+ this.plansByConv.delete(conversationId);
85
+ const existing = this.plansBySession.get(sessionId);
86
+ if (existing) {
87
+ this.plansByConv.set(conversationId, existing);
88
+ return existing;
89
+ }
90
+ // 绝不从 conversationId 逆向污染全新 sessionId!全新会话以空状态起步。
91
+ return null;
92
+ }
93
+ /** 解绑并清理指定 conversationId 的运行时内存计划与映射(在会话移除/销毁时调用)。 */
94
+ unbindConversation(conversationId) {
95
+ this.plansByConv.delete(conversationId);
96
+ this.convToSession.delete(conversationId);
21
97
  }
22
- /** 设置/全量更新指定会话的计划。 */
23
- setPlan(conversationId, steps, activeStepId) {
98
+ /** 获取指定会话的计划状态。优先使用 conversationId,若无则回退查找绑定的 sessionId。 */
99
+ getPlan(id) {
100
+ const directConv = this.plansByConv.get(id);
101
+ if (directConv)
102
+ return directConv;
103
+ const sessionId = this.convToSession.get(id);
104
+ if (sessionId) {
105
+ const bySession = this.plansBySession.get(sessionId);
106
+ if (bySession) {
107
+ this.plansByConv.set(id, bySession);
108
+ return bySession;
109
+ }
110
+ }
111
+ // 兼容以 sessionId 直接查询
112
+ const directSession = this.plansBySession.get(id);
113
+ if (directSession)
114
+ return directSession;
115
+ return null;
116
+ }
117
+ /** 设置/全量更新指定会话的计划(可选绑定 sessionId 进行稳定落盘)。 */
118
+ setPlan(conversationId, steps, activeStepId, sessionId) {
24
119
  const normalizedSteps = (Array.isArray(steps) ? steps : []).map((s, idx) => {
25
120
  const status = (VALID_STATUSES.has(s.status) ? s.status : "pending");
26
121
  return {
@@ -41,12 +136,23 @@ export class PlanManager {
41
136
  activeStepId: effectiveActiveId,
42
137
  updatedAt: Date.now(),
43
138
  };
44
- this.plans.set(conversationId, state);
139
+ this.plansByConv.set(conversationId, state);
140
+ const effectiveSessionId = sessionId ?? this.convToSession.get(conversationId);
141
+ if (effectiveSessionId) {
142
+ this.convToSession.set(conversationId, effectiveSessionId);
143
+ if (normalizedSteps.length === 0) {
144
+ this.plansBySession.delete(effectiveSessionId);
145
+ }
146
+ else {
147
+ this.plansBySession.set(effectiveSessionId, state);
148
+ }
149
+ this.save();
150
+ }
45
151
  return state;
46
152
  }
47
153
  /** 增量更新单个步骤的状态或内容。 */
48
154
  updateStep(conversationId, stepId, patch) {
49
- const current = this.plans.get(conversationId);
155
+ const current = this.getPlan(conversationId);
50
156
  if (!current)
51
157
  return null;
52
158
  const idx = current.steps.findIndex((s) => s.id === stepId);
@@ -85,12 +191,92 @@ export class PlanManager {
85
191
  activeStepId: nextActive,
86
192
  updatedAt: Date.now(),
87
193
  };
88
- this.plans.set(conversationId, nextState);
194
+ this.plansByConv.set(conversationId, nextState);
195
+ const sessId = this.convToSession.get(conversationId);
196
+ if (sessId) {
197
+ this.plansBySession.set(sessId, nextState);
198
+ this.save();
199
+ }
200
+ return nextState;
201
+ }
202
+ /** 增量删除单个步骤。 */
203
+ deleteStep(conversationId, stepId) {
204
+ const current = this.getPlan(conversationId);
205
+ if (!current)
206
+ return null;
207
+ const idx = current.steps.findIndex((s) => s.id === stepId);
208
+ if (idx === -1)
209
+ return current;
210
+ const nextSteps = current.steps.filter((s) => s.id !== stepId);
211
+ let nextActive = current.activeStepId;
212
+ if (current.activeStepId === stepId) {
213
+ const nextPending = nextSteps.find((s) => s.status === "pending" || s.status === "in_progress");
214
+ nextActive = nextPending ? nextPending.id : null;
215
+ }
216
+ const nextState = {
217
+ steps: nextSteps,
218
+ activeStepId: nextActive,
219
+ updatedAt: Date.now(),
220
+ };
221
+ this.plansByConv.set(conversationId, nextState);
222
+ const sessId = this.convToSession.get(conversationId);
223
+ if (sessId) {
224
+ this.plansBySession.set(sessId, nextState);
225
+ this.save();
226
+ }
227
+ return nextState;
228
+ }
229
+ /** 增量新增步骤。 */
230
+ addStep(conversationId, step, afterStepId) {
231
+ const current = this.getPlan(conversationId);
232
+ const existingSteps = current?.steps ?? [];
233
+ const normalized = {
234
+ id: String(step.id ?? `step-${Date.now()}`),
235
+ title: String(step.title ?? "").slice(0, 200),
236
+ status: step.status && VALID_STATUSES.has(step.status) ? step.status : "pending",
237
+ };
238
+ const description = String(step.description ?? "").slice(0, 1000);
239
+ if (description)
240
+ normalized.description = description;
241
+ let nextSteps;
242
+ if (afterStepId) {
243
+ const idx = existingSteps.findIndex((s) => s.id === afterStepId);
244
+ if (idx !== -1) {
245
+ nextSteps = [...existingSteps.slice(0, idx + 1), normalized, ...existingSteps.slice(idx + 1)];
246
+ }
247
+ else {
248
+ nextSteps = [...existingSteps, normalized];
249
+ }
250
+ }
251
+ else {
252
+ nextSteps = [...existingSteps, normalized];
253
+ }
254
+ let nextActive = current?.activeStepId ?? null;
255
+ if (!nextActive && normalized.status === "in_progress") {
256
+ nextActive = normalized.id;
257
+ }
258
+ const nextState = {
259
+ steps: nextSteps,
260
+ activeStepId: nextActive,
261
+ updatedAt: Date.now(),
262
+ };
263
+ this.plansByConv.set(conversationId, nextState);
264
+ const sessId = this.convToSession.get(conversationId);
265
+ if (sessId) {
266
+ this.plansBySession.set(sessId, nextState);
267
+ this.save();
268
+ }
89
269
  return nextState;
90
270
  }
91
271
  /** 清除指定会话的计划。 */
92
272
  clearPlan(conversationId) {
93
- this.plans.delete(conversationId);
273
+ this.plansByConv.delete(conversationId);
274
+ const sessId = this.convToSession.get(conversationId);
275
+ if (sessId) {
276
+ this.plansBySession.delete(sessId);
277
+ this.convToSession.delete(conversationId);
278
+ }
279
+ this.save();
94
280
  }
95
281
  /** 格式化计划为简洁文本,供模型上下文或诊断使用。 */
96
282
  describePlan(conversationId) {