pi-web-ui 0.94.1 → 0.96.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 (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -19,6 +19,28 @@
19
19
  import { createServer, createConnection } from "node:net";
20
20
  import { chmodSync, existsSync, rmSync } from "node:fs";
21
21
  import { join } from "node:path";
22
+ /** 探测既有 socket 的超时:活实例对 connect 的响应是内核级的,300ms 足够。 */
23
+ const CONTROL_PROBE_TIMEOUT_MS = 300;
24
+ /** 控制路径探测:连得上(有活实例在 listen)→ null;失败 → errno
25
+ * (ECONNREFUSED/ENOENT = 崩溃残留,可安全删除)。超时按「可能是活实例」
26
+ * 保守处理,返回超时码 —— 宁可不删残留也别摘掉正在服务的 socket。 */
27
+ function probeControlPath(path) {
28
+ return new Promise((resolve) => {
29
+ const sock = createConnection(path);
30
+ let settled = false;
31
+ const finish = (result) => {
32
+ if (settled)
33
+ return;
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ sock.destroy();
37
+ resolve(result);
38
+ };
39
+ const timer = setTimeout(() => finish("ETIMEDOUT"), CONTROL_PROBE_TIMEOUT_MS);
40
+ sock.once("connect", () => finish(null));
41
+ sock.once("error", (err) => finish(err.code ?? "EUNKNOWN"));
42
+ });
43
+ }
22
44
  /** How long a control connection may sit idle before the server closes it. */
23
45
  const CONTROL_IDLE_TIMEOUT_MS = 5_000;
24
46
  /** How long the CLI waits for a reply before giving up. */
@@ -32,21 +54,45 @@ export function startControlServer(opts) {
32
54
  const { service, dataDir, port } = opts;
33
55
  const path = controlPath(dataDir, port);
34
56
  let server;
57
+ // 只有本实例真正 bind 成功才允许 stop 时删 socket 文件:探测发现活实例而
58
+ // listen 失败时,path 上是对方的 socket,删了会让后续客户端全部失联。
59
+ let bound = false;
35
60
  if (process.platform === "win32") {
61
+ // Windows named pipe 不落盘(最后一个句柄关闭即消失),不存在崩溃残留
62
+ // 文件可清理;撞上活实例时 listen 直接 EADDRINUSE,走下方统一 error
63
+ // 处理。无需探测。
36
64
  server = createServer(handleConnection);
65
+ server.listen(path, () => {
66
+ bound = true;
67
+ console.log(` control : ${path}`);
68
+ });
37
69
  }
38
70
  else {
39
- // Remove a stale socket left by a previous crash (only if it's ours —
40
- // an existing socket file that refuses connections is stale).
41
- if (existsSync(path)) {
42
- try {
43
- rmSync(path);
44
- }
45
- catch {
46
- /* best-effort */
47
- }
48
- }
49
71
  server = createServer(handleConnection);
72
+ // POSIX socket 文件崩溃后会残留,但先探测再删:无条件 rmSync 会把正在
73
+ // 服务的实例(比如被新的 pi-web 抢占前仍在跑的旧实例)的 socket 文件摘
74
+ // 走,新旧实例互相踩。只有连不上的残留(ECONNREFUSED/ENOENT)才删;
75
+ // 探测超时保守当作活实例,让 listen 自己以 EADDRINUSE 失败告警。
76
+ void probeControlPath(path).then((probeErr) => {
77
+ if (probeErr && existsSync(path)) {
78
+ try {
79
+ rmSync(path);
80
+ }
81
+ catch {
82
+ /* best-effort */
83
+ }
84
+ }
85
+ server.listen(path, () => {
86
+ bound = true;
87
+ try {
88
+ chmodSync(path, 0o600);
89
+ }
90
+ catch {
91
+ /* best-effort */
92
+ }
93
+ console.log(` control : ${path}`);
94
+ });
95
+ });
50
96
  }
51
97
  // A second instance on the same data dir / port would fail to bind — don't
52
98
  // crash the server over it, just log and run without a control socket.
@@ -105,27 +151,10 @@ export function startControlServer(opts) {
105
151
  });
106
152
  sock.on("close", () => clearTimeout(timer));
107
153
  }
108
- if (process.platform === "win32") {
109
- // net.Server on a named pipe: listen on the pipe name directly.
110
- server.listen(path, () => {
111
- console.log(` control : ${path}`);
112
- });
113
- }
114
- else {
115
- server.listen(path, () => {
116
- try {
117
- chmodSync(path, 0o600);
118
- }
119
- catch {
120
- /* best-effort */
121
- }
122
- console.log(` control : ${path}`);
123
- });
124
- }
125
154
  return () => {
126
155
  server.close();
127
156
  try {
128
- if (process.platform !== "win32" && existsSync(path))
157
+ if (process.platform !== "win32" && bound && existsSync(path))
129
158
  rmSync(path);
130
159
  }
131
160
  catch {
@@ -16,12 +16,12 @@
16
16
  // 长对话分多次 offset 翻页。跨标签页的实时运行读不到——以落盘历史为准,
17
17
  // description 里会告诉模型这一点。
18
18
  //
19
- // 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
19
+ // 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
20
20
  // 返回文本按 lang 取 pick(lang, zh, en, key, vars),缺表回落英文内联。
21
21
  // ---------------------------------------------------------------------------
22
22
  import { defineTool } from "@earendil-works/pi-coding-agent";
23
23
  import { Type } from "typebox";
24
- import { bilingual, pick } from "./i18n.js";
24
+ import { pick } from "./i18n.js";
25
25
  import { CONVERSATION_READ_TOOL_NAME } from "./tool-manager.js";
26
26
  import { extractTouches, formatTouchEntry, formatTouchesCompact, toolCallRefsOfContent, unionTouchLists, } from "./conversation-touches.js";
27
27
  /** 由 content 块提 toolCalls 字段(additive;格式化输出不受影响,见接口注释)。 */
@@ -369,21 +369,11 @@ export function makeConversationReadTool(host, lang, extras) {
369
369
  return defineTool({
370
370
  name: CONVERSATION_READ_TOOL_NAME,
371
371
  label: "Read another conversation",
372
- description: bilingual('Read ANOTHER conversation: a running conversation of this client (including subagents — use its conversation id like "c3", see action=list) or a persisted history session transcript (use its file path). ' +
373
- 'Use it when the user references another chat (a quoted conversation, a pasted conversation id, or "see the other chat about X"). ' +
374
- "action=list shows running conversations plus history sessions (kind=running|history|all, query filter, limit; history defaults to 15). " +
375
- 'action=read returns one transcript: view=chat (default: only user/assistant text) or full (incl. tool calls/results); last=N takes the latest N messages instead of guessing offset; query searches within the conversation (hits ±1 context); each message is capped (~600 chars, marked "… +N chars"). ' +
376
- "action=files lists files the conversation created/modified (with counts). " +
377
- "action=status gives a short summary: last tool call, last assistant text, recently touched files, whether it is waiting for a question answer. " +
378
- "Only sessions from the session list can be read (arbitrary file paths are refused). " +
379
- "Live runs in OTHER browser tabs are not readable here — they appear in history once persisted.", "读取**另一个对话**:本客户端的运行中对话(含子代理——用 action=list 查 conversation id,如 c3)或已落盘的历史会话转录(用它的文件 path)。" +
380
- "用户引用了别的对话(引用 chip、粘过来的对话 id、“看看之前那个关于 X 的对话”)时用它。" +
381
- "action=list 列运行中对话与历史会话(kind=running|history|all,query 过滤,limit;历史默认 15 条)。" +
382
- "action=read 取某一份转录:view=chat(默认:只给 user/assistant 文本)或 full(含工具调用与结果);last=N 取最新 N 条(不用猜 offset);query 在对话内搜索(命中 ±1 条上下文);每条消息独立截断(约 600 字符,超出标 “… +N chars”)。" +
383
- "action=files 列该对话创建/修改过的文件(含次数)。" +
384
- "action=status 给一两行摘要:最后一次工具调用、最后一句 assistant、最近动过的文件、是否在等用户回答问卷。" +
385
- "只能读会话列表里的转录(任意文件路径会被拒绝)。" +
386
- "其他浏览器标签页里的实时运行在这里读不到——它们落盘后会出现在历史里。"),
372
+ description: "Read ANOTHER conversation: a running conversation of this client (incl. subagents — id from action=list) or a persisted history session (file path). " +
373
+ "Use when the user references another chat. list: running + history sessions (query filter, limit). " +
374
+ "read: one transcript — view=chat (default, user/assistant text) or full (incl. tool calls); last=N latest; query searches within (±1 context); messages capped ~600 chars. " +
375
+ "files: files it created/modified. status: last tool call, last assistant text, pending question. " +
376
+ "Only listed sessions are readable; other tabs' live runs appear in history once persisted.",
387
377
  promptSnippet: "read another conversation: list/find chats, read transcript (chat view / tail / search), touched files, status summary",
388
378
  parameters: Type.Object({
389
379
  action: Type.Optional(Type.String({
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 悬空 toolCall 检测与修复(issue #280、issue #332)。
3
+ *
4
+ * 背景:流式中模型卡死 → 工具看门狗 abort 无效 → forceResetConversation
5
+ * dispose 在飞运行时并从磁盘重建。内存里未落盘的工具结果静默蒸发,
6
+ * 会话文件尾留下一个「有调用、无结果」的悬空 toolCall;重建后继续
7
+ * prompt 会把非法转录链喂给 provider——请求有发起迹象但零落盘、零报错,
8
+ * 用户在往黑洞里打字。
9
+ *
10
+ * 缺陷与收紧(issue #332):
11
+ * 不能将全文件所有找不到 toolResult 的调用一律视为悬空并追加合成结果:
12
+ * 1. 上线检查:若 assistant 的 stopReason 是 "error" 或 "aborted",
13
+ * pi 在发送给 provider 前会丢弃该 assistant(transformMessages)。
14
+ * 该调用属于永不上线的幽灵调用,补合成结果反而会构造出没有前置 tool_calls
15
+ * 的孤儿 role: "tool",导致 provider 400(DeepSeek/OpenAI Responses 等);
16
+ * 2. 分支检查:会话文件由树状结构追加存储,老分支被遗弃的 toolCall 不在当前分支上;
17
+ * 若在文件尾(当前活跃分支末尾)追加老分支调用的合成结果,也会制造孤儿 toolResult。
18
+ * 因此落盘修复必须沿当前活跃分支(lastId 开始回溯)定位尾部生效 assistant。
19
+ *
20
+ * 本模块:
21
+ * - findDanglingToolCalls:纯函数,消息/条目数组里找「有调用、无后继结果」的 toolCall;
22
+ * - tailAssistantToolCallIds:沿当前分支回溯,定位尾部会上线的 assistant 的 toolCallId 集合;
23
+ * - healDanglingToolCallFile:落盘版,仅针对当前分支尾部生效的悬空调用追加合成 toolResult;
24
+ * - 合成结果文案:DANGLING_TOOL_RESULT_TEXT(中英各一,toolResult content 只带一条文本)。
25
+ */
26
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
27
+ import { randomUUID } from "node:crypto";
28
+ export const DANGLING_TOOL_RESULT_TEXT = "(系统:上一次运行被强制终止(工具执行超时/模型流卡死),该工具调用没有返回结果。为避免对话记录损坏,已自动填入一条合成结果。请根据需要重新执行该工具或继续对话。)";
29
+ export const DANGLING_TOOL_RESULT_TEXT_EN = "(System: the previous run was force-terminated (tool timeout / hung model stream) and this tool call never returned. A synthetic result was inserted automatically to keep the transcript valid. Re-run the tool or continue as needed.)";
30
+ function toolCallsOfMessage(msg) {
31
+ if (typeof msg !== "object" || msg === null)
32
+ return [];
33
+ const m = msg;
34
+ if (m.role !== "assistant" || !Array.isArray(m.content))
35
+ return [];
36
+ if (m.stopReason === "error" || m.stopReason === "aborted")
37
+ return [];
38
+ const out = [];
39
+ for (const b of m.content) {
40
+ if (typeof b !== "object" || b === null || b.type !== "toolCall")
41
+ continue;
42
+ const id = typeof b.id === "string" ? b.id : "";
43
+ if (!id)
44
+ continue;
45
+ const name = typeof b.name === "string" && b.name ? b.name : "unknown";
46
+ out.push({ toolCallId: id, toolName: name });
47
+ }
48
+ return out;
49
+ }
50
+ function toolResultIdsOfMessage(msg) {
51
+ const ids = new Set();
52
+ if (typeof msg !== "object" || msg === null)
53
+ return ids;
54
+ const m = msg;
55
+ if (m.role === "toolResult" && typeof m.toolCallId === "string")
56
+ ids.add(m.toolCallId);
57
+ // 兼容:个别版本把结果放在 content 块里。
58
+ if (Array.isArray(m.content)) {
59
+ for (const b of m.content) {
60
+ if (typeof b !== "object" || b === null)
61
+ continue;
62
+ if ((b.type === "toolResult" || b.type === "tool_result") && typeof b.toolCallId === "string") {
63
+ ids.add(b.toolCallId);
64
+ }
65
+ }
66
+ }
67
+ return ids;
68
+ }
69
+ function messagesOfEntries(entries) {
70
+ return entries.map((e) => {
71
+ if (typeof e === "object" && e !== null && "message" in e) {
72
+ const msg = e.message;
73
+ if (typeof msg === "object" && msg !== null && "role" in msg)
74
+ return msg;
75
+ }
76
+ return e;
77
+ });
78
+ }
79
+ /**
80
+ * 找悬空 toolCall:出现过调用、但之后没有任何 toolResult 与之配对的。
81
+ * 输入既可以是 Message[](内存 agent.state.messages),也可以是
82
+ * SessionManager entry[](带 { message } 包装的落盘条目)——统一按顺序扫。
83
+ */
84
+ export function findDanglingToolCalls(messagesOrEntries) {
85
+ const messages = messagesOfEntries(messagesOrEntries);
86
+ const calls = [];
87
+ const results = new Map();
88
+ messages.forEach((msg, index) => {
89
+ for (const c of toolCallsOfMessage(msg))
90
+ calls.push({ call: c, index });
91
+ for (const id of toolResultIdsOfMessage(msg)) {
92
+ let arr = results.get(id);
93
+ if (!arr) {
94
+ arr = [];
95
+ results.set(id, arr);
96
+ }
97
+ arr.push(index);
98
+ }
99
+ });
100
+ const out = [];
101
+ const seen = new Set();
102
+ for (const { call, index } of calls) {
103
+ const later = (results.get(call.toolCallId) ?? []).some((ri) => ri > index);
104
+ if (!later && !seen.has(call.toolCallId)) {
105
+ seen.add(call.toolCallId);
106
+ out.push(call);
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+ /**
112
+ * 沿当前分支从 lastId 往前回溯,定位当前分支尾部生效 assistant 的 toolCallId 集合(issue #332)。
113
+ *
114
+ * 两个守卫条件:
115
+ * 1. 分支检查:严格沿 parentId 链回溯,老分支(不在当前分支路径上)的条目绝不纳入,
116
+ * 避免在当前分支尾部追加针对老分支调用的合成结果而制造孤儿 toolResult;
117
+ * 2. 上线检查:若遇到的 assistant 是 stopReason: "error" | "aborted",
118
+ * 说明该 assistant 已被中断/报错,pi 在发送给模型前会直接跳过丢弃它(transformMessages),
119
+ * 其 toolCall 属于绝不上线的幽灵调用,必须跳过且绝不能为其补结果。
120
+ *
121
+ * 此外,若在回溯中遇到了 user 消息,说明当前尾部已经退出该回合(例如最新是 user 消息),
122
+ * 绝不能跨过 user 消息去为更早的 assistant 补 toolResult,应立即停止。
123
+ */
124
+ export function tailAssistantToolCallIds(entries, lastId) {
125
+ if (!lastId)
126
+ return new Set();
127
+ const byId = new Map();
128
+ for (const e of entries) {
129
+ if (typeof e === "object" && e !== null && typeof e.id === "string") {
130
+ byId.set(e.id, e);
131
+ }
132
+ }
133
+ const seen = new Set();
134
+ let cursor = lastId;
135
+ while (cursor && !seen.has(cursor)) {
136
+ seen.add(cursor);
137
+ const entry = byId.get(cursor);
138
+ if (!entry || typeof entry !== "object")
139
+ break;
140
+ const msg = "message" in entry && typeof entry.message === "object"
141
+ ? entry.message
142
+ : null;
143
+ if (msg && typeof msg === "object" && "role" in msg) {
144
+ const m = msg;
145
+ if (m.role === "user") {
146
+ // 跨入更早的用户回合,尾部无正在等待结果的 assistant
147
+ break;
148
+ }
149
+ if (m.role === "assistant") {
150
+ if (m.stopReason !== "error" && m.stopReason !== "aborted") {
151
+ return new Set(toolCallsOfMessage(m).map((c) => c.toolCallId));
152
+ }
153
+ // 尾部最新 assistant 处于 aborted/error 状态,幽灵调用不上线;且不可越过它去修更早回合
154
+ break;
155
+ }
156
+ }
157
+ cursor =
158
+ typeof entry.parentId === "string" ? entry.parentId : null;
159
+ }
160
+ return new Set();
161
+ }
162
+ /**
163
+ * 落盘修复:向会话文件尾追加合成 toolResult(仅针对当前分支尾部生效的悬空调用),
164
+ * parentId 链式接在当前尾行之后。append-only——历史字节不动,无需备份。
165
+ * 返回追加条数(0 = 健康,无需处理;-1 = 文件不可读/不可写)。
166
+ */
167
+ export function healDanglingToolCallFile(filePath) {
168
+ let raw;
169
+ try {
170
+ raw = readFileSync(filePath, "utf8");
171
+ }
172
+ catch {
173
+ return -1;
174
+ }
175
+ const lines = raw.split("\n");
176
+ const entries = [];
177
+ let lastId = null;
178
+ for (const line of lines) {
179
+ if (!line.trim())
180
+ continue;
181
+ try {
182
+ const parsed = JSON.parse(line);
183
+ entries.push(parsed);
184
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.id === "string") {
185
+ lastId = parsed.id;
186
+ }
187
+ }
188
+ catch {
189
+ // 脏行:SDK 加载时同样跳过,这里忽略。
190
+ }
191
+ }
192
+ if (entries.length === 0 || !lastId)
193
+ return 0;
194
+ const tailIds = tailAssistantToolCallIds(entries, lastId);
195
+ if (tailIds.size === 0)
196
+ return 0;
197
+ const dangling = findDanglingToolCalls(entries).filter((d) => tailIds.has(d.toolCallId));
198
+ if (dangling.length === 0)
199
+ return 0;
200
+ try {
201
+ let parentId = lastId;
202
+ for (const d of dangling) {
203
+ const id = `synthetic-tool-result-${randomUUID().slice(0, 8)}`;
204
+ const entry = {
205
+ type: "message",
206
+ id,
207
+ parentId,
208
+ timestamp: new Date().toISOString(),
209
+ message: {
210
+ role: "toolResult",
211
+ toolCallId: d.toolCallId,
212
+ toolName: d.toolName,
213
+ content: [{ type: "text", text: `${DANGLING_TOOL_RESULT_TEXT}\n${DANGLING_TOOL_RESULT_TEXT_EN}` }],
214
+ isError: true,
215
+ timestamp: Date.now(),
216
+ },
217
+ };
218
+ const line = JSON.stringify(entry);
219
+ if (!existsSync(filePath))
220
+ return -1;
221
+ appendFileSync(filePath, `${line}\n`, "utf8");
222
+ parentId = id;
223
+ }
224
+ return dangling.length;
225
+ }
226
+ catch {
227
+ return -1;
228
+ }
229
+ }
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { Type } from "typebox";
18
18
  import { defineTool } from "@earendil-works/pi-coding-agent";
19
- import { bilingual, pick } from "./i18n.js";
19
+ import { pick } from "./i18n.js";
20
20
  import { DELEGATE_TASK_TOOL_NAME } from "./tool-manager.js";
21
21
  import { subagentTitle } from "./subagents.js";
22
22
  /** 工具名(唯一值在 tool-manager.ts;本别名保留:前端派单卡片与旧导入沿用它)。 */
@@ -101,33 +101,35 @@ export function buildDelegationPrompt(input, lang = "en") {
101
101
  }
102
102
  const delegateSchema = Type.Object({
103
103
  agent: Type.String({
104
- description: bilingual("Specialist template to delegate to (e.g. oracle, metis, momus, explore, librarian, sisyphus-junior, multimodal-looker, review). Must be an enabled template — use subagent_templates to see descriptions and pick the domain match.", "派单的目标 specialist 模板(如 oracle、metis、momus、explore、librarian、sisyphus-junior、multimodal-looker、review)。必须是启用的模板——先用 subagent_templates 看简介、按任务领域匹配。"),
104
+ description: "Specialist template to delegate to (e.g. oracle, metis, momus, explore, librarian, sisyphus-junior, multimodal-looker, review). " +
105
+ "Must be an enabled template — use subagent_templates to see descriptions and pick the domain match.",
105
106
  }),
106
107
  task: Type.String({
107
- description: bilingual("Atomic, specific goal: ONE action per delegation (minimum 20 chars). Vague tasks are rejected.", "原子化具体目标:一次派单只做一件事(至少 20 字)。含糊的任务会被驳回。"),
108
+ description: "Atomic, specific goal: ONE action per delegation (minimum 20 chars). Vague tasks are rejected.",
108
109
  }),
109
110
  expected_outcome: Type.String({
110
- description: bilingual("Concrete deliverables with done criteria: what does success look like (minimum 10 chars).", "具体交付物 + 完成标准:什么样算做完(至少 10 字)。"),
111
+ description: "Concrete deliverables with done criteria: what does success look like (minimum 10 chars).",
111
112
  }),
112
113
  required_tools: Type.String({
113
- description: bilingual("Explicit tool whitelist for the subagent (prevents tool sprawl).", "子代理可用工具白名单(防工具乱用)。"),
114
+ description: "Explicit tool whitelist for the subagent (prevents tool sprawl).",
114
115
  }),
115
116
  must_do: Type.String({
116
- description: bilingual("Exhaustive requirements — leave NOTHING implicit.", "必须做的要求——写尽,不要留隐含项。"),
117
+ description: "Exhaustive requirements — leave NOTHING implicit.",
117
118
  }),
118
119
  must_not_do: Type.String({
119
- description: bilingual("Forbidden actions — anticipate and block rogue behavior.", "禁止事项——预判并堵住乱发挥。"),
120
+ description: "Forbidden actions — anticipate and block rogue behavior.",
120
121
  }),
121
122
  context: Type.String({
122
- description: bilingual("File paths, existing patterns, constraints the subagent must know.", "子代理必须知道的文件路径、既有模式、约束。"),
123
+ description: "File paths, existing patterns, constraints the subagent must know.",
123
124
  }),
124
125
  model: Type.Optional(Type.String({
125
- description: bilingual('Optional model "provider/id" for this delegation; omit = template model → panel default → follow the main conversation model.', "可选:本次派单的子代理模型(provider/id);不传 = 模板模型 → 面板默认 → 跟随主对话模型。"),
126
+ description: 'Optional model "provider/id" for this delegation; ' +
127
+ "omit = template model → panel default → follow the main conversation model.",
126
128
  })),
127
129
  persist: Type.Optional(Type.Boolean({
128
- description: bilingual("Optional: persist this conversation to disk as a regular session (saved in history, resumable). " +
129
- "Default false (lightweight in-memory subagent). Use true for tasks that need long-term retention.", "可选:是否将该对话持久化落盘为普通对话(保存在历史会话中,可随时回顾与继续)。" +
130
- "默认 false(轻量内存子代理)。需要长期留存的任务建议设为 true。"),
130
+ description: "Optional: persist this conversation to disk as a regular session (saved in history, resumable). " +
131
+ "Default false (lightweight in-memory subagent). " +
132
+ "Use true for tasks that need long-term retention.",
131
133
  })),
132
134
  });
133
135
  /** 结构化派单工具:校验六段 → 拼装标准 prompt → 走 host.spawnSubagent 真子代理。 */
@@ -140,23 +142,18 @@ export function makeDelegateTaskTool(host, lang) {
140
142
  return defineTool({
141
143
  name: DELEGATE_TOOL_NAME,
142
144
  label: "Delegate task",
143
- description: bilingual("Delegate ONE well-defined task to a specialist subagent template with a structured six-section brief " +
144
- "(TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT). The brief is validated " +
145
- "server-side: missing or vague sections are rejected with an error, so fill every section concretely. " +
146
- "Prefer this over subagent_spawn when the work fits a specialist template. The delegation spawns a real " +
147
- "subagent conversation (visible in the left running list); use subagent_wait_all / subagent_get_result to " +
148
- "collect results, subagent_steer to redirect, subagent_stop to stop. For follow-ups continue the SAME " +
149
- "subagent session instead of delegating again.", "把一个定义清楚的任务派给 specialist 子代理模板,派单文本是结构化六段 " +
150
- "(TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT)。六段在服务端校验:" +
151
- "缺段或含糊直接报错打回,所以每段都要写实在。任务适合 specialist 模板时优先用它而不是 subagent_spawn。" +
152
- "派单会启动真实子代理会话(左栏运行列表可见);用 subagent_wait_all / subagent_get_result 收结果、" +
153
- "subagent_steer 改向、subagent_stop 停止。追问要在同一子代理会话里继续,不要重复派单。"),
154
- promptSnippet: bilingual("Delegate a well-defined task to a specialist template with a validated six-section brief", "把定义清楚的任务派给 specialist 模板,六段派单文本带服务端校验"),
145
+ description: "Delegate ONE well-defined task to a specialist subagent template via a structured six-section brief (TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT). " +
146
+ "The brief is validated server-side — missing or vague sections are rejected, so fill every section concretely. " +
147
+ "Prefer over subagent_spawn when the work fits a specialist template. Spawns a real subagent conversation; " +
148
+ "collect with subagent_wait_all / subagent_get_result, redirect with subagent_steer, stop with subagent_stop. " +
149
+ "For follow-ups continue the SAME subagent session instead of delegating again.",
150
+ promptSnippet: "Delegate a well-defined task to a specialist template with a validated six-section brief",
155
151
  promptGuidelines: [
156
- bilingual("Prefer delegate_task over subagent_spawn when the work matches a specialist template's domain", "任务落在 specialist 模板领域内时,优先用 delegate_task 而不是 subagent_spawn"),
157
- bilingual("Before delegating, declare which template you chose and WHY its description matches the task", "派单前先声明选了哪个模板、它的简介与任务哪里匹配"),
158
- bilingual("After delegation ALWAYS verify the result: does it work, does it follow codebase patterns, did it respect MUST DO / MUST NOT DO", "拿到派单结果必须验证:能跑吗、符合代码库模式吗、遵守 MUST DO / MUST NOT DO 了吗"),
159
- bilingual("Never start implementing work that a pending delegated result was asked to decide", "已派出去待定的结论回来之前,不准先把相关的实现写了"),
152
+ "Prefer delegate_task over subagent_spawn when the work matches a specialist template's domain",
153
+ "Before delegating, declare which template you chose and WHY its description matches the task",
154
+ "After delegation ALWAYS verify the result: " +
155
+ "does it work, does it follow codebase patterns, did it respect MUST DO / MUST NOT DO",
156
+ "Never start implementing work that a pending delegated result was asked to decide",
160
157
  ],
161
158
  parameters: delegateSchema,
162
159
  execute: async (_id, params, _signal, _onUpdate, ctx) => {