zen-gitsync 2.17.8 → 2.17.10

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 (112) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +210 -58
  4. package/src/cli/ai/context.js +58 -0
  5. package/src/cli/ai/context.test.js +117 -0
  6. package/src/cli/ai/extensions.js +131 -0
  7. package/src/cli/ai/extensions.test.js +158 -0
  8. package/src/cli/ai/mcp.js +498 -0
  9. package/src/cli/ai/mcp.test.js +275 -0
  10. package/src/cli/ai/runtime.test.js +32 -0
  11. package/src/cli/ai/skills.js +207 -0
  12. package/src/cli/ai/skills.test.js +126 -0
  13. package/src/cli/ai/termui.js +1 -0
  14. package/src/cli/ai/tools.js +94 -3
  15. package/src/cli/ai/tools.test.js +82 -0
  16. package/src/cli/ai/transport.js +18 -4
  17. package/src/cli/ai/turn.js +21 -5
  18. package/src/config.js +27 -3
  19. package/src/paths.js +11 -0
  20. package/src/ui/public/assets/AgentView-BV15UtEF.css +1 -0
  21. package/src/ui/public/assets/AgentView-C-AJo7KS.js +5 -0
  22. package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +2 -0
  23. package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +1 -0
  24. package/src/ui/public/assets/CommitForm-DpXuFG-F.js +6 -0
  25. package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +1 -0
  26. package/src/ui/public/assets/EditorView-B1OnoXXf.js +1 -0
  27. package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +14 -0
  28. package/src/ui/public/assets/{FlowExecutionViewer-uXrzZjsW.js → FlowExecutionViewer-BgEt5_Hz.js} +1 -1
  29. package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +21 -0
  30. package/src/ui/public/assets/IconButton-aM3vaSIN.js +1 -0
  31. package/src/ui/public/assets/LogList-UjoVFZ-H.js +7 -0
  32. package/src/ui/public/assets/MindmapView-D_04v0n4.js +1 -0
  33. package/src/ui/public/assets/MonitorView-BD1_uzq7.js +1 -0
  34. package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +1 -0
  35. package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +1 -0
  36. package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +1 -0
  37. package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +3 -0
  38. package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +1 -0
  39. package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +1 -0
  41. package/src/ui/public/assets/{WorkbenchView-v3QFn6Ln.css → WorkbenchView-C_Gmzvj1.css} +1 -1
  42. package/src/ui/public/assets/WorkbenchView-UGC334JL.js +13 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +19 -0
  44. package/src/ui/public/assets/configStore-BLRcYJdf.js +1 -0
  45. package/src/ui/public/assets/{element-plus-KTmRV-so.js → element-plus-DcZ-TVp6.js} +3 -3
  46. package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +139 -0
  47. package/src/ui/public/assets/{gitStore-CpcS-MzM.js → gitStore-DMEDMZMk.js} +4 -4
  48. package/src/ui/public/assets/index-CW3xs-af.css +1 -0
  49. package/src/ui/public/assets/index-S0_gRyiO.js +26 -0
  50. package/src/ui/public/assets/{office-docx-BfSBpdBv.js → office-docx-C0g5Oepv.js} +5 -5
  51. package/src/ui/public/assets/{office-excel-S95W2mv4.js → office-excel-D-uYLKaA.js} +1 -1
  52. package/src/ui/public/assets/{office-pptx-DFFarGrk.js → office-pptx-De3fnc6M.js} +1 -1
  53. package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
  54. package/src/ui/public/assets/toolsStore-CpImRUhI.js +1 -0
  55. package/src/ui/public/assets/{useThemeObserver-B01D-Vl6.js → useThemeObserver-CG8k8U18.js} +1 -1
  56. package/src/ui/public/assets/{vendor-dO4B7wQf.js → vendor-BIgZEtkx.js} +27 -27
  57. package/src/ui/public/assets/vue-flow-CPCFgenl.js +39 -0
  58. package/src/ui/public/index.html +17 -17
  59. package/src/ui/server/index.js +4 -0
  60. package/src/ui/server/routes/config.js +16 -3
  61. package/src/ui/server/routes/exec.js +19 -11
  62. package/src/ui/server/routes/fileOpen.js +142 -2
  63. package/src/ui/server/routes/remoteRepos.js +604 -0
  64. package/src/ui/server/routes/remoteRepos.test.js +386 -0
  65. package/src/ui/server/routes/workbench/agentChat.js +86 -180
  66. package/src/ui/server/routes/workbench/agentChatShared.test.js +151 -0
  67. package/src/ui/server/routes/workbench/agentMarketplace.js +986 -0
  68. package/src/ui/server/routes/workbench/agentMarketplace.test.js +306 -0
  69. package/src/ui/server/routes/workbench/agentParity.test.js +167 -0
  70. package/src/ui/server/routes/workbench/agentRoutes.js +86 -1
  71. package/src/ui/server/routes/workbench/agentRoutes.test.js +50 -1
  72. package/src/ui/server/routes/workbench/jobStore.js +17 -3
  73. package/src/ui/server/routes/workbench/orchestratorStore.js +42 -2
  74. package/src/ui/server/routes/workbench/projectRegistry.js +6 -0
  75. package/src/ui/server/routes/workbench/projectRegistry.test.js +70 -0
  76. package/src/ui/server/routes/workbench/projectTool.js +198 -0
  77. package/src/ui/server/routes/workbench/projectTool.test.js +152 -0
  78. package/src/ui/server/routes/workbench/remoteCliPromptParity.test.js +177 -0
  79. package/src/ui/server/routes/workbench/taskRunner.js +269 -12
  80. package/src/ui/server/routes/workbench/taskRunner.toolcalls.test.js +307 -0
  81. package/src/ui/server/socket/registerUiSocketHandlers.js +18 -11
  82. package/src/utils/shellPath.js +253 -0
  83. package/src/utils/shellPath.test.js +221 -0
  84. package/src/ui/public/assets/AgentView-CdJaeRh0.js +0 -5
  85. package/src/ui/public/assets/AgentView-DAY4S5WN.css +0 -1
  86. package/src/ui/public/assets/AppVersionBadge-CiijSYfM.js +0 -2
  87. package/src/ui/public/assets/BranchSelector-BS7ajwvv.js +0 -1
  88. package/src/ui/public/assets/CommitForm-CEOSqXCA.js +0 -6
  89. package/src/ui/public/assets/CommonDialog-CAaEi-4T.js +0 -1
  90. package/src/ui/public/assets/EditorView-DyaFyRDD.js +0 -1
  91. package/src/ui/public/assets/FileDiffViewer-BvX9cQjR.js +0 -14
  92. package/src/ui/public/assets/FlowOrchestrationWorkspace-DIBpUSt_.js +0 -21
  93. package/src/ui/public/assets/IconButton-CnoW2on6.js +0 -1
  94. package/src/ui/public/assets/LogList-D2espSVo.js +0 -7
  95. package/src/ui/public/assets/MindmapView-CZF-rZVs.js +0 -1
  96. package/src/ui/public/assets/MonitorView-DjLr7qOG.js +0 -1
  97. package/src/ui/public/assets/ProjectStartupButton-DzsU-QIO.js +0 -1
  98. package/src/ui/public/assets/RemoteManagerDialog-CuuG2ucT.js +0 -1
  99. package/src/ui/public/assets/RemoteRepoCard-BGf8L0of.js +0 -1
  100. package/src/ui/public/assets/SourceMapView-CFb02m6z.js +0 -3
  101. package/src/ui/public/assets/SvgIcon-SRAeYjnN.js +0 -1
  102. package/src/ui/public/assets/TemplateManager-gedqAXl8.js +0 -1
  103. package/src/ui/public/assets/UserInputNode-BOCuMQW6.js +0 -1
  104. package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +0 -13
  105. package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +0 -19
  106. package/src/ui/public/assets/configStore-CuMElTgV.js +0 -1
  107. package/src/ui/public/assets/flow-mindmap-CurCIiCp.js +0 -139
  108. package/src/ui/public/assets/index-cApp6w0b.css +0 -1
  109. package/src/ui/public/assets/index-yXm9JLGv.js +0 -24
  110. package/src/ui/public/assets/previewSandbox-DkXYEpLP.js +0 -0
  111. package/src/ui/public/assets/toolsStore-DGxlRe3Y.js +0 -1
  112. package/src/ui/public/assets/vue-flow-CsvDD38k.js +0 -39
@@ -0,0 +1,177 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 系统提示词里「远程仓库 CLI」那一段的**口径一致性**守卫。
16
+ //
17
+ // 为什么需要:同一段能力说明在四个地方各写一遍 ——
18
+ // CLI 端 agent.js 的 zh / en,Web 端 agentChat.js 的 zh / en。
19
+ // 这个仓库为"改了一侧忘了另一侧"专门吃过亏(见同目录 pathParity.test.js 的注释:
20
+ // 路径归一规则分叉,同一个目录裂成两个项目)。提示词的失效方式更隐蔽:
21
+ // 少写一条**不会报错**,只会让模型在某些入口下做出错误行为 ——
22
+ // 比如漏掉「Gitee 未登录时退出码仍是 0」,Web 端就会一口咬定用户已登录。
23
+ //
24
+ // 所以这里不验证模型行为(没法验),只钉住"四条口径必须覆盖同样的事实"。
25
+ // 断言故意把关键事实钉死:谁要删一条,就得同时删四处、并来这里改期望值。
26
+
27
+ import { test } from 'node:test';
28
+ import assert from 'node:assert/strict';
29
+ import { readFileSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ const here = path.dirname(fileURLToPath(import.meta.url));
34
+ // 两个提示词文件分处两棵树:CLI 端在 src/cli/ai/,Web 端在 src/ui/server/。
35
+ // 测试落在 Web 端这一侧,只是因为同目录已有同族的 pathParity.test.js。
36
+ const PROMPT_FILES = [
37
+ { label: 'CLI 端 agent.js', file: path.join(here, '..', '..', '..', '..', 'cli', 'ai', 'agent.js') },
38
+ { label: 'Web 端 agentChat.js', file: path.join(here, 'agentChat.js') },
39
+ ];
40
+
41
+ const HEADER_ZH = '# 远程仓库(GitHub / Gitee)';
42
+ const HEADER_EN = '# Remote repositories (GitHub / Gitee)';
43
+
44
+ /**
45
+ * 抠出某个 header 到下一个 `# ` 标题之间的正文。
46
+ *
47
+ * 用"下一个一级标题"当右边界,而不是匹配到文件尾:提示词后面还有
48
+ * 权限 / 工作方式 / 交互 / 输出若干段,一起卷进来会把"这一段覆盖了什么"
49
+ * 的断言变得没有意义(别处的字样会让断言假通过)。
50
+ */
51
+ function extractSection(src, header, label) {
52
+ const start = src.indexOf(header);
53
+ assert.ok(start >= 0, `${label} 里找不到提示词段落 ` + '`' + header + '`');
54
+ const rest = src.slice(start + header.length);
55
+ const end = rest.search(/\n# /);
56
+ return end >= 0 ? rest.slice(0, end) : rest;
57
+ }
58
+
59
+ /** 每个文件必须中文、英文各一段 —— 少一段说明有人删了或换了标题写法。 */
60
+ test('四个入口的提示词里都带着「远程仓库」段落(CLI/Web × 中/英)', () => {
61
+ for (const { label, file } of PROMPT_FILES) {
62
+ const src = readFileSync(file, 'utf8');
63
+ for (const header of [HEADER_ZH, HEADER_EN]) {
64
+ assert.ok(
65
+ src.includes(header),
66
+ `${label} 缺少 ${header} 段落;新增 CLI 能力说明时必须四个入口一起加`,
67
+ );
68
+ }
69
+ }
70
+ });
71
+
72
+ /**
73
+ * 四条口径必须覆盖同样的事实。
74
+ *
75
+ * 用 `commands` 做的是**跨语言**的对齐检查:命令串和 JSON 字段名是中英共用的,
76
+ * 所以"中文写了 gh auth status、英文漏了"这种最容易犯的错会在这里暴露。
77
+ */
78
+ const FACTS = [
79
+ {
80
+ what: '本项目远端走 git remote -v(不依赖 CLI 的兜底路径)',
81
+ zh: ['git remote -v'],
82
+ en: ['git remote -v'],
83
+ commands: ['git remote -v'],
84
+ },
85
+ {
86
+ what: 'GitHub 列仓库用 gh repo list',
87
+ zh: [/gh repo list/],
88
+ en: [/gh repo list/],
89
+ commands: ['gh repo list --limit 50'],
90
+ },
91
+ {
92
+ what: 'Gitee 列仓库用 gitee repo list',
93
+ zh: [/gitee repo list/],
94
+ en: [/gitee repo list/],
95
+ commands: ['gitee repo list'],
96
+ },
97
+ {
98
+ what: '两个平台的登录态检查命令',
99
+ zh: [/gh auth status/, /gitee auth status/],
100
+ en: [/gh auth status/, /gitee auth status/],
101
+ commands: ['gh auth status', 'gitee auth status'],
102
+ },
103
+ {
104
+ // 这条是踩过的坑:gitee CLI 未登录时退出码是 0(实测 v0.3.1),
105
+ // 只看退出码会永远认为"已登录"。提示词里必须留一句,否则模型会误报。
106
+ what: 'Gitee 未登录时退出码仍是 0 这个反直觉行为',
107
+ zh: [/退出码/],
108
+ en: [/exit code is still 0/],
109
+ commands: [],
110
+ },
111
+ {
112
+ // 装完没重启服务时 CLI 不在 PATH 里,模型此时最危险的动作是
113
+ // "换个写法再试"和"凭印象编仓库名" —— 两句都要明确禁掉。
114
+ what: 'CLI 找不到时的处置:不重试、不编造',
115
+ zh: [/不要换写法反复重试/, /不要凭印象编/],
116
+ en: [/do NOT retry with a different spelling/, /do NOT invent/],
117
+ commands: [],
118
+ },
119
+ {
120
+ what: '不许索要 / 粘贴 token(凭据由 CLI 自己保管)',
121
+ zh: [/绝不向用户索要 token/],
122
+ en: [/Never ask the user for a token/],
123
+ commands: [],
124
+ },
125
+ ];
126
+
127
+ test('四条提示词覆盖同样的事实,命令串逐字一致(跨语言对齐)', () => {
128
+ const sections = [];
129
+ for (const { label, file } of PROMPT_FILES) {
130
+ const src = readFileSync(file, 'utf8');
131
+ sections.push({ label: `${label} · zh`, text: extractSection(src, HEADER_ZH, label) });
132
+ sections.push({ label: `${label} · en`, text: extractSection(src, HEADER_EN, label) });
133
+ }
134
+ assert.equal(sections.length, 4, '恰好四个入口:CLI/Web × 中/英');
135
+
136
+ for (const fact of FACTS) {
137
+ for (const section of sections) {
138
+ const isEn = section.label.endsWith('· en');
139
+ for (const pattern of (isEn ? fact.en : fact.zh)) {
140
+ const hit = typeof pattern === 'string' ? section.text.includes(pattern) : pattern.test(section.text);
141
+ assert.ok(
142
+ hit,
143
+ `${section.label} 里缺少「${fact.what}」;该事实在四个入口必须一致,` +
144
+ `缺一条不会报错,只会让某个入口下的模型行为变错`,
145
+ );
146
+ }
147
+ }
148
+ // 命令串与字段名跨语言共用 —— 这一层能抓住"中文写了、英文漏了"
149
+ for (const cmd of fact.commands) {
150
+ for (const section of sections) {
151
+ assert.ok(
152
+ section.text.includes(cmd),
153
+ `${section.label} 里缺少命令 \`${cmd}\`(「${fact.what}」);中英两版必须用同一条命令`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+ });
159
+
160
+ test('段落里不得暗示可以绕过凭据(不出现 token 输入类指引)', () => {
161
+ for (const { label, file } of PROMPT_FILES) {
162
+ const src = readFileSync(file, 'utf8');
163
+ for (const header of [HEADER_ZH, HEADER_EN]) {
164
+ const section = extractSection(src, header, label);
165
+ assert.doesNotMatch(
166
+ section,
167
+ /(粘|粘贴|输入|提供)\s*(一个)?\s*token/i,
168
+ `${label} 的段落里出现"让用户提供 token"类表述:凭据必须由 CLI 自己保管`,
169
+ );
170
+ assert.doesNotMatch(
171
+ section,
172
+ /paste\s+(a\s+)?token/i,
173
+ `${label} 的段落里出现 "paste a token" 类表述:凭据必须由 CLI 自己保管`,
174
+ );
175
+ }
176
+ }
177
+ });
@@ -273,13 +273,203 @@ export function launchOpencodeRun(cwd, promptText, resumeSessionId) {
273
273
  });
274
274
  }
275
275
 
276
+ // ── 工具调用(tool_use)收集 ──────────────────────────────────────────────
277
+ // 为什么要有这一层:job.output 只承载「模型说的话」。工具调用(读了哪个文件、跑了什么命令、
278
+ // 拿到了什么结果)原本在两个事件处理器里被整段丢掉,于是前端只剩「思考 + 正文」两块,
279
+ // 模型闷头干活的几分钟就完全看不出它在干什么(2026-09-22 用户反馈)。
280
+ //
281
+ // 这里把两种执行器的 tool_use / tool_result 归一成 zen-ai-chat-ui 的 ToolCall 形态:
282
+ // { id, name, argsPreview, arguments, result, status, error }
283
+ // 挂在 job.toolCalls 上随 job 一起落盘(jobs.json),并通过 job:toolcalls 事件流式推给前端。
284
+ //
285
+ // 截断统一在服务端做:前端只是展示层,两边看到的字段含义一致。
286
+ export const MAX_TOOL_CALLS = 150;
287
+ const MAX_TOOL_ARGS = 2000;
288
+ const MAX_TOOL_RESULT = 4000;
289
+ const MAX_TOOL_ERROR = 1000;
290
+ const TOOL_ARGS_PREVIEW = 200;
291
+
292
+ /** 截断(保留开头:工具名 / 命令的关键信息都在前面) */
293
+ function clipText(text, max) {
294
+ const s = String(text ?? '');
295
+ return s.length > max ? `${s.slice(0, max)}\n…(已截断 ${s.length - max} 字)` : s;
296
+ }
297
+
298
+ /**
299
+ * 参数摘要的截断:严格不超过 max(含那个省略号)。
300
+ * 摘要是一行展示用,尾部再挂「已截断 N 字」反而把这一行撑爆 ——
301
+ * 完整参数在 arguments 里,那里才有必要标出截断。
302
+ */
303
+ function clipPreview(text, max = TOOL_ARGS_PREVIEW) {
304
+ const s = String(text ?? '');
305
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
306
+ }
307
+
308
+ function safeJson(value) {
309
+ try { return JSON.stringify(value); } catch { return String(value); }
310
+ }
311
+
312
+ /**
313
+ * 工具参数一行摘要(折叠态展示用)。
314
+ *
315
+ * 不做「按工具名分派」——claude(Bash / Read / Edit)与 opencode(bash / read / edit)
316
+ * 的工具名并不统一,按名分派等于给自己挖一个要跟着 CLI 版本改的坑。
317
+ * 改成「先挑常见的主语字段,挑不到就退化成紧凑 JSON」。
318
+ */
319
+ function summarizeToolArgs(rawArgsText) {
320
+ const raw = String(rawArgsText || '').trim();
321
+ if (!raw) return '';
322
+ const flat = raw.replace(/\s+/g, ' ');
323
+ if (!raw.startsWith('{')) return clipPreview(flat);
324
+ let obj;
325
+ try { obj = JSON.parse(raw); } catch { return clipPreview(flat); }
326
+ if (!obj || typeof obj !== 'object') return clipPreview(flat);
327
+ const preferred = ['command', 'cmd', 'file_path', 'filePath', 'path', 'pattern', 'query', 'url', 'description', 'prompt', 'title'];
328
+ for (const key of preferred) {
329
+ const v = obj[key];
330
+ if (typeof v === 'string' && v.trim()) {
331
+ return clipPreview(v.trim().replace(/\s+/g, ' '));
332
+ }
333
+ }
334
+ return clipPreview(safeJson(obj).replace(/\s+/g, ' '));
335
+ }
336
+
337
+ /**
338
+ * 工具结果文本抽取。
339
+ * claude 的 tool_result.content 可能是 string,也可能是 [{type:'text',text}] 块数组
340
+ * (还可能夹图片块);opencode 的 state.output 是 string 或对象;错误对象常见形态是 {message}。
341
+ * 统一压成一段文本。
342
+ */
343
+ function extractToolText(content) {
344
+ if (content == null) return '';
345
+ if (typeof content === 'string') return content;
346
+ if (Array.isArray(content)) {
347
+ const parts = [];
348
+ for (const b of content) {
349
+ if (b == null) continue;
350
+ if (typeof b !== 'object') { parts.push(String(b)); continue; }
351
+ if (typeof b.text === 'string') parts.push(b.text);
352
+ else if (b.type === 'image') parts.push('[图片]');
353
+ else if (b.type) parts.push(`[${b.type}]`);
354
+ }
355
+ return parts.join('\n');
356
+ }
357
+ if (typeof content === 'object') {
358
+ if (typeof content.message === 'string') return content.message;
359
+ return safeJson(content);
360
+ }
361
+ return String(content);
362
+ }
363
+
364
+ /**
365
+ * 建一个工具调用收集器。
366
+ *
367
+ * 为什么批量 flush 而不是每条立即 publish:一次 NDJSON 批次里可能含多个 tool_use
368
+ * (模型并行调工具),攒成一批发一次,跟 thinking 的处理口径一致,避免高频小块 socket。
369
+ *
370
+ * @param {object} job 执行记录(toolCalls 挂到它身上,随 job 落盘)
371
+ * @param {(updates: Array) => void} [onUpdates] 增量回调;不传则只写 job 不推送(单测用)
372
+ */
373
+ export function createToolCallTracker(job, onUpdates) {
374
+ if (!Array.isArray(job.toolCalls)) job.toolCalls = [];
375
+ const byId = new Map();
376
+ let pending = [];
377
+ let anonSeq = 0;
378
+
379
+ // 只推可序列化字段(跟 snapshotJobs 的投影口径一致),存的是快照不是引用,
380
+ // 免得后续状态变更悄悄改掉已经 emit 出去、还堆在 pending 里的那一份。
381
+ const snapshot = (call) => ({
382
+ id: call.id,
383
+ name: call.name,
384
+ argsPreview: call.argsPreview,
385
+ arguments: call.arguments,
386
+ result: call.result,
387
+ status: call.status,
388
+ error: call.error
389
+ });
390
+
391
+ /**
392
+ * 记录一次工具调用的新增 / 状态变更。同一个 id 反复出现只更新同一条
393
+ * (opencode 会按 running → completed 推同一个 part,claude 的 tool_result
394
+ * 也回指 tool_use 的 id)。
395
+ *
396
+ * @param {{ id?: string, name?: string, input?: any, status?: string, output?: any, error?: any }} info
397
+ */
398
+ function record(info) {
399
+ const src = info || {};
400
+ const name = src.name ? String(src.name).slice(0, 80) : '';
401
+ const id = src.id ? String(src.id) : `anon-${++anonSeq}`;
402
+ let call = byId.get(id);
403
+ if (!call) {
404
+ // 超上限后直接丢弃新调用:保持数组有界,免得一次长跑生成上千条把 jobs.json 撑爆。
405
+ // 已经记下的那些照常更新,不会因为超限变成"半截"。
406
+ if (job.toolCalls.length >= MAX_TOOL_CALLS) return null;
407
+ call = { id, name, argsPreview: '', arguments: '', result: '', status: 'running' };
408
+ byId.set(id, call);
409
+ job.toolCalls.push(call);
410
+ }
411
+ if (name) call.name = name;
412
+ // 参数只在第一次拿到时写:opencode 的后续事件可能只剩 state.output
413
+ if (src.input !== undefined && src.input !== null && !call.arguments) {
414
+ const raw = typeof src.input === 'string' ? src.input : safeJson(src.input);
415
+ call.arguments = clipText(raw, MAX_TOOL_ARGS);
416
+ call.argsPreview = summarizeToolArgs(raw);
417
+ }
418
+ if (src.status && src.status !== call.status) call.status = src.status;
419
+ if (src.output !== undefined && src.output !== null) {
420
+ const text = extractToolText(src.output);
421
+ if (text) call.result = clipText(text, MAX_TOOL_RESULT);
422
+ }
423
+ if (src.error) {
424
+ call.error = clipText(extractToolText(src.error) || '工具执行失败', MAX_TOOL_ERROR);
425
+ call.status = 'error';
426
+ }
427
+ if (onUpdates) pending.push(snapshot(call));
428
+ return call;
429
+ }
430
+
431
+ /** 把攒下的一批增量推给前端(一批 NDJSON 处理完 flush 一次) */
432
+ function flush() {
433
+ if (!onUpdates || pending.length === 0) return;
434
+ const updates = pending;
435
+ pending = [];
436
+ onUpdates(updates);
437
+ }
438
+
439
+ /**
440
+ * 进程已退出,还挂在 pending / running 的调用不会再有下文 —— 收口成终态。
441
+ * 不做这一步,前端那个工具块会一直转圈(跟 job 终态不写回时 sub 一直 running 同一类 bug)。
442
+ */
443
+ function seal(jobStatus) {
444
+ for (const call of job.toolCalls) {
445
+ if (call.status !== 'running' && call.status !== 'pending') continue;
446
+ if (jobStatus === 'cancelled') {
447
+ call.status = 'error';
448
+ call.error = call.error || '未返回结果(执行已停止)';
449
+ } else if (jobStatus === 'error') {
450
+ call.status = 'error';
451
+ call.error = call.error || '未返回结果(执行出错)';
452
+ } else {
453
+ call.status = 'done';
454
+ }
455
+ if (onUpdates) pending.push(snapshot(call));
456
+ }
457
+ flush();
458
+ }
459
+
460
+ return { record, flush, seal };
461
+ }
462
+
276
463
  /**
277
464
  * claude stream-json 事件处理器。
278
- * system/init → session_id(--resume 续接用)
279
- * assistant → message.content 里的 text / thinking 块
280
- * 其余(tool_use / result / stream_event 等)忽略。
465
+ * system/init → session_id(--resume 续接用)
466
+ * assistant → message.content 里的 text / thinking / tool_use 块
467
+ * user → message.content 里的 tool_result 块(工具执行结果,回指 tool_use 的 id)
468
+ * 其余(result / stream_event 等)忽略。
469
+ * (导出仅为回归测试可见:taskRunner.toolcalls.test.js)
281
470
  */
282
- function createClaudeEventHandler(job, appendOutput, appendThinking) {
471
+ export function createClaudeEventHandler(job, appendOutput, appendThinking, toolCalls) {
472
+ const tracker = toolCalls || createToolCallTracker(job);
283
473
  return (evt) => {
284
474
  if (evt.type === 'system' && evt.subtype === 'init' && typeof evt.session_id === 'string') {
285
475
  if (!job.claudeSessionId || job.claudeSessionId !== evt.session_id) {
@@ -288,6 +478,26 @@ function createClaudeEventHandler(job, appendOutput, appendThinking) {
288
478
  }
289
479
  return;
290
480
  }
481
+ // 工具结果:claude 把 tool_result 包在一条 user 事件里回传
482
+ // {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":…}]}}
483
+ // 用块类型区分它和"用户自己那条 prompt"(那条 message.content 是字符串)。
484
+ if (evt.type === 'user') {
485
+ const ublocks = evt.message?.content;
486
+ if (Array.isArray(ublocks)) {
487
+ for (const b of ublocks) {
488
+ if (b && b.type === 'tool_result' && b.tool_use_id) {
489
+ const text = extractToolText(b.content);
490
+ tracker.record({
491
+ id: b.tool_use_id,
492
+ status: b.is_error ? 'error' : 'done',
493
+ output: text,
494
+ error: b.is_error ? (text || '工具执行失败') : ''
495
+ });
496
+ }
497
+ }
498
+ }
499
+ return;
500
+ }
291
501
  if (evt.type !== 'assistant') return;
292
502
  const blocks = evt.message?.content;
293
503
  if (!Array.isArray(blocks)) return;
@@ -296,6 +506,8 @@ function createClaudeEventHandler(job, appendOutput, appendThinking) {
296
506
  appendOutput(b.text);
297
507
  } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
298
508
  appendThinking(b.thinking);
509
+ } else if (b.type === 'tool_use') {
510
+ tracker.record({ id: b.id, name: b.name, input: b.input, status: 'running' });
299
511
  }
300
512
  }
301
513
  };
@@ -307,13 +519,16 @@ function createClaudeEventHandler(job, appendOutput, appendThinking) {
307
519
  * { type:'step_start' | 'step_finish', sessionID, part:{ type:'step-start'|'step-finish', tokens?, cost? } }
308
520
  * { type:'text', sessionID, part:{ id, type:'text', text } }
309
521
  * { type:'reasoning', sessionID, part:{ id, type:'reasoning', text } }
310
- * { type:'tool_use', sessionID, part:{ type:'tool', tool, state:{ status, input, output } } }
522
+ * { type:'tool_use', sessionID, part:{ id, callID, type:'tool', tool, state:{ status, input, output } } }
311
523
  * { type:'error', sessionID, error:{ name, data:{ message } } }
312
524
  * 正文/思考按 part.id 记录已落地的累计文本:同一 part 再次出现时若文本是前次的
313
525
  * 前缀延长(累积语义)只追加增量,否则按纯增量整段追加 —— 两种推送语义都兼容。
526
+ * tool_use 按 part.id / callID 归并:opencode 会按 state.status(running → completed)
527
+ * 反复推同一个 part,落成一条工具调用(status 映射 completed → done)。
314
528
  * (导出仅为回归测试可见:taskRunner.opencode.test.js)
315
529
  */
316
- export function createOpencodeEventHandler(job, appendOutput, appendThinking) {
530
+ export function createOpencodeEventHandler(job, appendOutput, appendThinking, toolCalls) {
531
+ const tracker = toolCalls || createToolCallTracker(job);
317
532
  const seenParts = new Map(); // part.id -> 已落地的累计文本
318
533
  const appendPartText = (part, isThinking) => {
319
534
  const text = typeof part?.text === 'string' ? part.text : '';
@@ -329,6 +544,26 @@ export function createOpencodeEventHandler(job, appendOutput, appendThinking) {
329
544
  if (isThinking) appendThinking(delta);
330
545
  else appendOutput(delta);
331
546
  };
547
+ // opencode 的 state.status: pending | running | completed | error
548
+ const mapToolStatus = (raw) => {
549
+ const s = typeof raw === 'string' ? raw.toLowerCase() : '';
550
+ if (s === 'completed' || s === 'done' || s === 'success') return 'done';
551
+ if (s === 'error' || s === 'failed') return 'error';
552
+ return 'running';
553
+ };
554
+ const recordToolPart = (part) => {
555
+ if (!part || part.type !== 'tool') return;
556
+ const st = part.state || {};
557
+ const status = mapToolStatus(st.status);
558
+ tracker.record({
559
+ id: part.callID || part.id,
560
+ name: part.tool || part.name,
561
+ input: st.input,
562
+ status,
563
+ output: status === 'done' ? st.output : undefined,
564
+ error: status === 'error' ? (st.error || st.output || 'opencode 工具执行出错') : ''
565
+ });
566
+ };
332
567
  return (evt) => {
333
568
  // 顶层 sessionID 是 opencode 的会话标识(--session <id> 续接)。
334
569
  // 复用 claudeSessionId 字段:前端与续接路由都认它,换执行器只是取值来源不同。
@@ -340,6 +575,10 @@ export function createOpencodeEventHandler(job, appendOutput, appendThinking) {
340
575
  appendPartText(evt.part, evt.type === 'reasoning');
341
576
  return;
342
577
  }
578
+ if (evt.type === 'tool_use') {
579
+ recordToolPart(evt.part);
580
+ return;
581
+ }
343
582
  if (evt.type === 'error') {
344
583
  const msg = evt.error?.data?.message
345
584
  || (typeof evt.error?.message === 'string' ? evt.error.message : '')
@@ -486,13 +725,19 @@ ${prompt}`;
486
725
  // 续接历史会话时先填上,首条协议事件回来后再用真值覆盖(通常等同)。
487
726
  // 字段名保留 claudeSessionId:claude 的 session_id 和 opencode 的 sessionID
488
727
  // 都存这里,语义是"该执行器的会话续接标识"。
489
- claudeSessionId: resumeSessionId
728
+ claudeSessionId: resumeSessionId,
729
+ // 工具调用流水({id,name,argsPreview,arguments,result,status,error}[])。
730
+ // 先给个空数组,前端拿到的 job:update 就有了稳定形态,不用到处判 undefined。
731
+ toolCalls: []
490
732
  };
491
733
  jobs.set(jobId, job);
492
734
  sub.status = 'running';
493
735
  publish('sub:update', { taskId: task.id, sub });
494
736
  publish('job:update', job);
495
737
 
738
+ // 声明在 try 之外:启动器就抛错时 finally 也要能安全收口(见 toolTracker?.seal)
739
+ let toolTracker = null;
740
+
496
741
  try {
497
742
  const launcher = executor === 'opencode' ? launchOpencodeRun : launchClaudeInNewWindow;
498
743
  const { pid, child } = await launcher(repoPath || process.cwd(), prompt, resumeSessionId);
@@ -504,9 +749,10 @@ ${prompt}`;
504
749
  publish('job:update', job);
505
750
 
506
751
  // 流式 NDJSON 解析:把 stdout 当作所选执行器的 JSON 事件流处理
507
- // 正文/思考块 → job.output / job.thinking(前端主视图 + 折叠思考)
508
- // 会话标识 → job.claudeSessionId(续接对话用)
509
- // 其他事件(tool_use 等)忽略,避免噪声
752
+ // 正文/思考块 → job.output / job.thinking(前端主视图 + 折叠思考)
753
+ // 会话标识 → job.claudeSessionId(续接对话用)
754
+ // 工具调用 → job.toolCalls(前端工具块 + 落盘归档)
755
+ // 其他事件 → 忽略,避免噪声
510
756
  const MAX_OUTPUT = 100 * 1024 * 1024;
511
757
  const MAX_THINKING = 100 * 1024 * 1024;
512
758
  job.output = '';
@@ -537,9 +783,15 @@ ${prompt}`;
537
783
  thinkingBatch += job.thinking.slice(prevLen);
538
784
  };
539
785
 
786
+ // 工具调用:写进 job.toolCalls(随 job 落盘),并按批推 job:toolcalls 给前端。
787
+ // 收口(把没收尾的调用标终态)在 finally 里做,见下方 toolTracker.seal()。
788
+ toolTracker = createToolCallTracker(job, (updates) => {
789
+ publish('job:toolcalls', { id: job.id, updates });
790
+ });
791
+
540
792
  const handleEvent = executor === 'opencode'
541
- ? createOpencodeEventHandler(job, appendOutput, appendThinking)
542
- : createClaudeEventHandler(job, appendOutput, appendThinking);
793
+ ? createOpencodeEventHandler(job, appendOutput, appendThinking, toolTracker)
794
+ : createClaudeEventHandler(job, appendOutput, appendThinking, toolTracker);
543
795
 
544
796
  const handleLine = (line, channel) => {
545
797
  const trimmed = line.trim();
@@ -561,6 +813,7 @@ ${prompt}`;
561
813
  lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
562
814
  for (const line of lines) handleLine(line, channel);
563
815
  flushThinkingBatch();
816
+ toolTracker?.flush();
564
817
  };
565
818
  if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
566
819
  if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
@@ -577,6 +830,7 @@ ${prompt}`;
577
830
  lineBuf.stderr = '';
578
831
  }
579
832
  flushThinkingBatch();
833
+ toolTracker?.flush();
580
834
  job.endedAt = nowIso();
581
835
  if (wasCancelled) {
582
836
  job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
@@ -612,6 +866,9 @@ ${prompt}`;
612
866
  sub.error = errMsg;
613
867
  sub.errorAt = nowIso();
614
868
  } finally {
869
+ // 工具调用收口:进程都退了,还挂在 running 的调用不会再有下文。先标终态再推 job:update,
870
+ // 否则前端那些工具块会一直转圈(跟 sub 终态没写回时 UI 一直 running 同一类问题)。
871
+ toolTracker?.seal(job.status);
615
872
  // 移除 child 引用——避免后续被 SSE 序列化到前端
616
873
  delete job.child;
617
874
  publish('job:update', job);