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
@@ -15,39 +15,13 @@ 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
- "# 内联标记工具(状态类操作请写在回答正文,不要调用工具)",
44
19
  "- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
45
20
  "- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
46
21
  "- 想查看/list 当前任务列表时,才用 `todo_list` 工具(读操作走工具)。",
47
22
  "- 不要编造不存在的任务 id;id 由 [[todo:new:...]] 分配,首次分配是自增整数。",
48
23
  ];
49
24
  const TODO_GUIDANCE_EN = [
50
- "# Inline marker tools (express state changes inline in your reply text — never call a tool for them)",
51
25
  "- Marker syntax: [[todo:new:<subject>]] to create; [[todo:set:<id>,completed|in_progress|pending]] for status; [[todo:remove:<id>]] to delete; [[todo:dep:<id>,blocks=<dep ids, comma-separated>]] to set dependencies.",
52
26
  "- Express all status changes with the [[todo:...]] inline markers above; they never interrupt your reply and need no waiting for a result.",
53
27
  "- Only use the `todo_list` tool (the read path goes through the tool) when you want to list the current tasks.",
@@ -66,7 +40,9 @@ export const todoMarker = {
66
40
  const op = token.op;
67
41
  switch (op) {
68
42
  case "new": {
69
- const subject = token.args[0]?.trim();
43
+ // #499:subject 是唯一「整段自由文本」参数——args.join 还原含逗号的主题,
44
+ // 否则 `[[todo:new:修复登录页,同步改注册页]]` 的后半段被静默丢弃。
45
+ const subject = token.args.join(",").trim();
70
46
  if (!subject)
71
47
  return {
72
48
  applied: false,
@@ -181,7 +157,9 @@ export const todoMarker = {
181
157
  const depsText = deps.length ? deps.join(",") : lang === "zh" ? "(无)" : "(none)";
182
158
  return {
183
159
  applied: true,
184
- 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 }),
185
163
  };
186
164
  }
187
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 = {
@@ -393,12 +393,25 @@ function adaptMcpTool(serverName, mcpTool, client) {
393
393
  name,
394
394
  label: `${serverName} · ${mcpTool.name}`,
395
395
  description: mcpTool.description ?? `Tool ${mcpTool.name} provided by MCP server "${serverName}"`,
396
+ // 无 snippet 的工具不会进系统提示词的「Available tools」清单(SDK 只列有
397
+ // snippet 的工具),MCP 工具会变成 schema-only:模型读清单以为自己没有
398
+ // 这个能力(#403)。这里从 description 提取首句做极简 snippet。
399
+ promptSnippet: firstSentenceSnippet(mcpTool.description) ?? `MCP tool ${mcpTool.name} (server: ${serverName})`,
396
400
  parameters: mcpTool.inputSchema ?? {},
397
401
  execute: async (_toolCallId, params, _signal) => {
398
402
  return client.call(mcpTool.name, params ?? {});
399
403
  },
400
404
  };
401
405
  }
406
+ /** 从 MCP 工具 description 提取首句做 promptSnippet(≤100 字符);空描述返回 undefined。 */
407
+ function firstSentenceSnippet(description) {
408
+ const text = (description ?? "").replace(/\s+/g, " ").trim();
409
+ if (!text)
410
+ return undefined;
411
+ const cut = text.search(/[.。!?!?]/);
412
+ const head = cut > 0 ? text.slice(0, cut + 1) : text;
413
+ return head.length > 100 ? `${head.slice(0, 99)}…` : head;
414
+ }
402
415
  /** 工具名必须是 [A-Za-z0-9_-]+(与插件工具同规则),MCP 可能含冒号/斜杠 — 归一化。 */
403
416
  function sanitizeToolName(name) {
404
417
  const cleaned = (name || "").replace(/[^A-Za-z0-9_-]/g, "_");
@@ -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,13 +11,16 @@
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,
23
+ promptSnippet: "apply content-hashed, line-anchored patches to workspace files (prevents stale edits, line drift, and indentation hallucination)",
21
24
  label: "Apply Hashline patch",
22
25
  description: `Apply content-hashed, line-anchored patches to workspace files — prevents stale edits, line drift, and indentation hallucination.
23
26
  Each file section starts with \`[path#TAG]\` (or \`[path]\` if the hash is unknown yet). Operations:
@@ -33,10 +36,13 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
33
36
  description: "The complete Hashline patch text containing one or more [path#TAG] sections with PUT/CUT/REM/MV operations.",
34
37
  }),
35
38
  timeout: Type.Optional(Type.Number({
36
- description: "Optional execution timeout in seconds.",
39
+ minimum: 1,
40
+ maximum: 300,
41
+ description: "Optional execution timeout in seconds (1-300).",
37
42
  })),
38
43
  }),
39
44
  async execute(_callId, params, _signal, _onUpdate, _ctx) {
45
+ const L = getLang();
40
46
  const patchText = typeof params?.patch === "string" ? params.patch : "";
41
47
  if (!patchText.trim()) {
42
48
  const emptyReport = {
@@ -46,7 +52,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
46
52
  error: "Empty patch",
47
53
  };
48
54
  return {
49
- 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
+ ],
50
61
  details: emptyReport,
51
62
  };
52
63
  }
@@ -56,7 +67,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
56
67
  });
57
68
  if (!report.ok) {
58
69
  return {
59
- 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
+ ],
60
76
  details: report,
61
77
  };
62
78
  }
@@ -65,12 +81,12 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
65
81
  for (const r of report.results) {
66
82
  if (r.op !== "deleted" && r.newHash) {
67
83
  const targetPath = r.newPath || r.filePath;
68
- 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)}\``));
69
85
  try {
70
86
  const fullPath = resolve(cwd, targetPath);
71
87
  const diags = await getLiveLspDiagnostics(fullPath, cwd);
72
88
  if (diags) {
73
- 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}`));
74
90
  }
75
91
  }
76
92
  catch { }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * turn_end boundary error patch for @earendil-works/pi-coding-agent (issue #411).
3
+ *
4
+ * In SDK 0.87+, when an in-flight turn is aborted or torn down before its assistant
5
+ * message is persisted, `_dispatchTurnEndBoundary()` in `agent-session.js` calls
6
+ * `this._extensionRunner.emitError({ event: "turn_end", error: "turn_end could not resolve..." })`
7
+ * right before returning `false`, causing false-positive extension errors to flood
8
+ * both the main conversation and subagents.
9
+ *
10
+ * This module idempotently patches `agent-session.js` in the installed SDK to
11
+ * silently skip the boundary (`return false`) without emitting an error, matching
12
+ * the upstream issue expectation (earendil-works/pi#10149).
13
+ *
14
+ * Same pattern as patch-remote-catalog.ts and patch-node-pty.ts: best-effort, idempotent,
15
+ * and skips gracefully if the SDK code structure differs or is already fixed upstream.
16
+ */
17
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { sdkCopies } from "./sdk-origin.js";
21
+ export const TARGET_PATTERN = /if\s*\(!messageEntryId\)\s*\{\s*this\._extensionRunner\.emitError\(\{\s*extensionPath:\s*"<boundary>",\s*event:\s*"turn_end",\s*error:\s*"turn_end could not resolve the persisted assistant entry ID",\s*\}\);\s*return false;\s*\}/;
22
+ export const PATCHED_SENTINEL = "// pi-web-ui patch (issue #411: silent turn_end on aborted turns)";
23
+ export const REPLACEMENT = `if (!messageEntryId) {
24
+ ${PATCHED_SENTINEL}
25
+ return false;
26
+ }`;
27
+ /**
28
+ * 纯函数:给定 agent-session.js 内容,返回打补丁后的内容。
29
+ * - 命中目标代码段 → 替换并返回新内容;
30
+ * - 已打过补丁(含 PATCHED_SENTINEL)→ 原样返回;
31
+ * - 目标代码段未命中(上游已修复或版本不匹配)→ 原样返回,静默跳过。
32
+ */
33
+ export function patchAgentSessionSource(src) {
34
+ if (src.includes(PATCHED_SENTINEL)) {
35
+ return { patched: false, code: src };
36
+ }
37
+ if (!TARGET_PATTERN.test(src)) {
38
+ return { patched: false, code: src };
39
+ }
40
+ return { patched: true, code: src.replace(TARGET_PATTERN, REPLACEMENT) };
41
+ }
42
+ export function candidateAgentSessionFiles() {
43
+ const files = new Set();
44
+ try {
45
+ const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
46
+ files.add(join(dirname(entry), "core", "agent-session.js"));
47
+ }
48
+ catch {
49
+ // Ignore resolve failures
50
+ }
51
+ try {
52
+ for (const copy of sdkCopies()) {
53
+ files.add(join(dirname(copy.path), "dist", "core", "agent-session.js"));
54
+ }
55
+ }
56
+ catch {
57
+ // Ignore copy inspection failures
58
+ }
59
+ return [...files];
60
+ }
61
+ export function applyPatch() {
62
+ for (const file of candidateAgentSessionFiles()) {
63
+ try {
64
+ if (!existsSync(file))
65
+ continue;
66
+ const src = readFileSync(file, "utf8");
67
+ const { patched, code } = patchAgentSessionSource(src);
68
+ if (patched) {
69
+ writeFileSync(file, code, "utf8");
70
+ }
71
+ }
72
+ catch {
73
+ // best-effort
74
+ }
75
+ }
76
+ }
77
+ applyPatch();
@@ -1,6 +1,50 @@
1
1
  /**
2
2
  * 权限预设相关辅助逻辑与会话回放。
3
3
  */
4
+ /**
5
+ * 从会话 sessionManager entries 回放恢复最后设定的**计划模式**状态(同 permission/preset
6
+ * 的落盘口径:切会话/重载不丢)。从未设定或非布尔 → false。
7
+ */
8
+ export function readPlanModeFromSession(sm) {
9
+ try {
10
+ const mgr = sm;
11
+ if (typeof mgr?.getEntries !== "function")
12
+ return false;
13
+ const entries = mgr.getEntries();
14
+ for (let i = entries.length - 1; i >= 0; i--) {
15
+ const e = entries[i];
16
+ if (e?.customType === "plan/mode" && typeof e.data?.enabled === "boolean")
17
+ return e.data.enabled;
18
+ }
19
+ }
20
+ catch {
21
+ // ignore
22
+ }
23
+ return false;
24
+ }
25
+ /**
26
+ * 从会话 sessionManager entries 回放恢复最后设定的**审查者模式**状态(同 plan/mode
27
+ * 口径:customType "delegate/mode")。从未设定或非布尔 → false。
28
+ * ⚠️ 执行对话 id **不回放**:常驻执行对话是本进程内的活对象,重启后
29
+ * 首条请求会重新建一个(宁可多一个执行对话,也别指向一个不存在的 id)。
30
+ */
31
+ export function readDelegateModeFromSession(sm) {
32
+ try {
33
+ const mgr = sm;
34
+ if (typeof mgr?.getEntries !== "function")
35
+ return false;
36
+ const entries = mgr.getEntries();
37
+ for (let i = entries.length - 1; i >= 0; i--) {
38
+ const e = entries[i];
39
+ if (e?.customType === "delegate/mode" && typeof e.data?.enabled === "boolean")
40
+ return e.data.enabled;
41
+ }
42
+ }
43
+ catch {
44
+ // ignore
45
+ }
46
+ return false;
47
+ }
4
48
  /**
5
49
  * 从会话 sessionManager entries 回放恢复最后设定的权限预设(若从未设定则返回 undefined)。
6
50
  * customType 为 "permission/preset",载荷结构为 { preset: string }。
@@ -23,3 +67,28 @@ export function readPermissionFromSession(sm) {
23
67
  }
24
68
  return undefined;
25
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
+ }