zen-gitsync 2.18.8 → 2.18.9

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 (71) hide show
  1. package/package.json +2 -1
  2. package/src/cli/ai/agent.js +7 -0
  3. package/src/cli/ai/runtime.test.js +22 -1
  4. package/src/cli/ai/tools.js +1 -1
  5. package/src/cli/ai/tools.test.js +1 -1
  6. package/src/cli/ai/transport.js +8 -3
  7. package/src/config.js +7 -5
  8. package/src/mcp/tools/workbench.js +1 -1
  9. package/src/ui/public/assets/{AgentView-DZx3n-So.css → AgentView-CV_8sYX6.css} +1 -1
  10. package/src/ui/public/assets/AgentView-DxmnqJxE.js +1 -0
  11. package/src/ui/public/assets/{AppVersionBadge-Bg5lrtKb.js → AppVersionBadge-C4OhcUi8.js} +2 -2
  12. package/src/ui/public/assets/{BranchSelector-CxjTqAkl.js → BranchSelector-CrLieQyJ.js} +1 -1
  13. package/src/ui/public/assets/{CommitForm-BWWrOvj5.js → CommitForm-KGQisnPQ.js} +2 -2
  14. package/src/ui/public/assets/{CommonDialog-x5jfL4Lq.js → CommonDialog-j_53ORvj.js} +1 -1
  15. package/src/ui/public/assets/CopySessionButton-y5Rzmact.js +20 -0
  16. package/src/ui/public/assets/EditorView-CkZb2kw5.js +1 -0
  17. package/src/ui/public/assets/{EditorView-BUiVsBv7.css → EditorView-Ims2BUK_.css} +1 -1
  18. package/src/ui/public/assets/{FlowExecutionViewer-BkZ20jxN.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
  19. package/src/ui/public/assets/{FlowOrchestrationWorkspace-aZBYu29g.js → FlowOrchestrationWorkspace-BMQSVm3V.js} +1 -1
  20. package/src/ui/public/assets/{LogList-CSIbmUMK.js → LogList-BxH6QNlK.js} +4 -4
  21. package/src/ui/public/assets/{MindmapView-JYLp185X.js → MindmapView-D9_qjJsR.js} +1 -1
  22. package/src/ui/public/assets/{MonitorView-BS2Mh-I7.js → MonitorView-z_fsnE8Y.js} +1 -1
  23. package/src/ui/public/assets/{ProjectStartupButton-DAEP6ZU5.js → ProjectStartupButton-CFB_JpYG.js} +1 -1
  24. package/src/ui/public/assets/RecentDirectoriesChat-7fzSkSmc.css +1 -0
  25. package/src/ui/public/assets/RecentDirectoriesChat-BYI8lkjQ.js +1 -0
  26. package/src/ui/public/assets/{RemoteManagerDialog-aaNmsKpy.js → RemoteManagerDialog-DXDcN8e7.js} +1 -1
  27. package/src/ui/public/assets/{RemoteRepoCard-DndI8rNZ.js → RemoteRepoCard-C_TYGsXi.js} +1 -1
  28. package/src/ui/public/assets/{SourceMapView-BTD9-R8t.js → SourceMapView-D6cSweDt.js} +1 -1
  29. package/src/ui/public/assets/{SvgIcon-0n6uuKFm.js → SvgIcon-5AlG3IfR.js} +1 -1
  30. package/src/ui/public/assets/{UserInputNode-B-mDg9Wy.js → UserInputNode-BKP869D6.js} +1 -1
  31. package/src/ui/public/assets/WorkbenchView-D4UDurhF.js +6 -0
  32. package/src/ui/public/assets/WorkbenchView-DogERhHQ.css +1 -0
  33. package/src/ui/public/assets/{_plugin-vue_export-helper-C3o7kvRL.js → _plugin-vue_export-helper-Csn085FF.js} +4 -4
  34. package/src/ui/public/assets/agentConversations-DyzaxUXm.js +10 -0
  35. package/src/ui/public/assets/configStore-DOaPZ6Mq.js +1 -0
  36. package/src/ui/public/assets/index-C7Y9odvZ.js +57 -0
  37. package/src/ui/public/assets/index-NYzaTfbS.css +1 -0
  38. package/src/ui/public/assets/{useOrchestrator-CS9sn7nV.js → useOrchestrator-C236G5P5.js} +1 -1
  39. package/src/ui/public/index.html +6 -6
  40. package/src/ui/server/index.js +8 -0
  41. package/src/ui/server/routes/fileOpen.js +57 -1
  42. package/src/ui/server/routes/fileOpen.test.js +1 -1
  43. package/src/ui/server/routes/workbench/agentChat.js +70 -8
  44. package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
  45. package/src/ui/server/routes/workbench/agentEngines.js +8 -4
  46. package/src/ui/server/routes/workbench/agentExtensions.js +157 -0
  47. package/src/ui/server/routes/workbench/agentMarketplace.js +12 -0
  48. package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
  49. package/src/ui/server/routes/workbench/agentRoutes.js +82 -7
  50. package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
  51. package/src/ui/server/routes/workbench/executorModels.js +30 -5
  52. package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
  53. package/src/ui/server/routes/workbench/index.js +1 -1
  54. package/src/ui/server/routes/workbench/jobActivity.js +3 -3
  55. package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
  56. package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
  57. package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
  58. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
  59. package/src/ui/server/routes/workbench/taskRunner.js +311 -24
  60. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
  61. package/src/ui/public/assets/AgentView-BRAUZCtA.js +0 -1
  62. package/src/ui/public/assets/CopySessionButton-D0wFpk8a.js +0 -20
  63. package/src/ui/public/assets/EditorView-DmaGiI-z.js +0 -1
  64. package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
  65. package/src/ui/public/assets/RecentDirectoriesChat-CVFTShrU.js +0 -1
  66. package/src/ui/public/assets/WorkbenchView-CfSrCIbp.js +0 -8
  67. package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
  68. package/src/ui/public/assets/agentConversations-DpiGplZB.js +0 -8
  69. package/src/ui/public/assets/configStore-BdL3uW2E.js +0 -1
  70. package/src/ui/public/assets/index-BS7BDFG4.js +0 -57
  71. package/src/ui/public/assets/index-DwJWiCiN.css +0 -1
@@ -0,0 +1,275 @@
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
+
16
+ // codebuddy 执行器(CodeBuddy Code CLI)的回归测试(2026-10-12)。
17
+ //
18
+ // 钉住的口径(全部来自当天实测,CLI 2.164.0):
19
+ // 1. 白名单 / 归一化里有 codebuddy(config.js 与 taskRunner 两份清单一致)
20
+ // 2. spawn argv:-p + stream-json + -y + **--port 0**(避开桌面版占用的 62443)
21
+ // + 续聊时 --resume <id>;**不出现** --verbose(cbc 不需要,claude 才需要)
22
+ // 3. 子进程环境:DISABLE_AUTOUPDATER / CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS
23
+ // 必须带上(丢了不会报错,只会某天任务跑到一半被自动更新换掉二进制);
24
+ // 桌面版注入的五个前缀族 + 单键必须剥掉 —— CODEBUDDY_HOST 与
25
+ // CODEBUDDY_FORCE_LITE_WB_BUNDLE 是 2026-10-12 实测的两个认证杀手
26
+ // (后者命中桌面版专属 lite-wb bundle 分流);官方认证通道
27
+ // CODEBUDDY_API_KEY / CODEBUDDY_BASE_URL 必须放行
28
+ // 4. 事件映射:init → session_id;assistant 的 text/thinking/tool_use 分派;
29
+ // user 里的 tool_result 回指;cbc 特有的 system/status、ai-title、result 一律忽略
30
+ // 5. 工具调用是"先 tool_use(running)、后 user(tool_result)"两条,不是一次性终态
31
+ // (runExternalTurn 的 announced 补 start 机制就是为后者准备的,两条式直接命中)
32
+ //
33
+ // 隔离策略同 taskRunner.opencode.test.js:import taskRunner.js 之前把
34
+ // USERPROFILE/HOME 指向 mkdtemp 沙箱(taskRunner → jobStore import 时就会 hydrate)。
35
+
36
+ import { test } from 'node:test';
37
+ import assert from 'node:assert/strict';
38
+ import path from 'node:path';
39
+ import os from 'node:os';
40
+ import { mkdtempSync } from 'node:fs';
41
+ import { fileURLToPath, pathToFileURL } from 'node:url';
42
+
43
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
44
+ const projectRoot = path.resolve(__dirname, '../../../../..');
45
+
46
+ // ---- 环境隔离:必须在 import taskRunner.js 之前完成 ----
47
+ const sandbox = mkdtempSync(path.join(os.tmpdir(), 'zen-taskrunner-cb-test-'));
48
+ process.env.USERPROFILE = sandbox;
49
+ process.env.HOME = sandbox;
50
+ delete process.env.HOMEDRIVE;
51
+ delete process.env.HOMEPATH;
52
+
53
+ const {
54
+ createCodebuddyEventHandler,
55
+ buildCodebuddyArgs,
56
+ buildCodebuddyEnv,
57
+ normalizeTaskExecutor,
58
+ TASK_EXECUTORS,
59
+ } = await import(
60
+ pathToFileURL(path.join(projectRoot, 'src/ui/server/routes/workbench/taskRunner.js')).href
61
+ );
62
+
63
+ /** 可断言的 appendOutput/appendThinking 收集器 */
64
+ function makeSink() {
65
+ const sink = { output: [], thinking: [] };
66
+ sink.appendOutput = (t) => { if (t) sink.output.push(t); };
67
+ sink.appendThinking = (t) => { if (t) sink.thinking.push(t); };
68
+ return sink;
69
+ }
70
+
71
+ /** tracker 探针:只记 handler 喂了哪些 record(不碰真 tracker 的落盘/推送) */
72
+ function makeTrackerSpy() {
73
+ const records = [];
74
+ return {
75
+ records,
76
+ record: (r) => records.push(r),
77
+ flush: () => {},
78
+ };
79
+ }
80
+
81
+ test('normalizeTaskExecutor:codebuddy 在白名单里,非法值回落 claude', () => {
82
+ assert.deepEqual(TASK_EXECUTORS, ['claude', 'opencode', 'codex', 'codebuddy']);
83
+ assert.equal(normalizeTaskExecutor('codebuddy'), 'codebuddy');
84
+ assert.equal(normalizeTaskExecutor('CodeBuddy'), 'codebuddy');
85
+ assert.equal(normalizeTaskExecutor('codebuddy '), 'codebuddy');
86
+ // 历史默认不能变:老配置/老 job 缺 executor 时仍落在 claude
87
+ assert.equal(normalizeTaskExecutor(undefined), 'claude');
88
+ assert.equal(normalizeTaskExecutor('cursor'), 'claude');
89
+ });
90
+
91
+ test('buildCodebuddyArgs:基础 argv 带 -p/stream-json/-y/--port 0,不带 --verbose', () => {
92
+ const args = buildCodebuddyArgs('');
93
+ assert.deepEqual(args, ['-p', '--output-format', 'stream-json', '-y', '--port', '0']);
94
+ // claude 的 stream-json 必须配 --verbose,cbc 不需要 —— 多传一个少传一个都是坑,
95
+ // 这里双向钉住:不许出现 verbose,也不许漏掉 port 0
96
+ assert.ok(!args.includes('--verbose'));
97
+ assert.equal(args[args.indexOf('--port') + 1], '0');
98
+ // 不传模型:跟随 CLI 自己的配置(与另三家同一口径)
99
+ assert.ok(!args.includes('--model'));
100
+ });
101
+
102
+ test('buildCodebuddyArgs:有续聊 id 时 --resume 在前', () => {
103
+ const args = buildCodebuddyArgs('sess-abc');
104
+ assert.deepEqual(args.slice(0, 2), ['--resume', 'sess-abc']);
105
+ assert.ok(args.includes('-p'));
106
+ });
107
+
108
+ test('buildCodebuddyEnv:禁自动更新 + 禁后台任务 + LANG,前缀级剥掉桌面版注入变量', () => {
109
+ // 模拟"g gui 在 WorkBuddy 终端里被拉起":父进程带着桌面版整套注入变量。
110
+ // 覆盖五个前缀族 + 单键,每族挑代表;逐键断言剥离不留死角。
111
+ const had = {};
112
+ const pollution = {
113
+ // ★ 两个实测认证杀手
114
+ CODEBUDDY_HOST: 'workbuddy-desktop',
115
+ CODEBUDDY_FORCE_LITE_WB_BUNDLE: '1',
116
+ CODEBUDDY_FORCE_HEADLESS_BUNDLE: '1',
117
+ // 其余 CODEBUDDY_* 家族代表(认证辅助 / 网关 / 设备指纹 / 配置目录 / 会话追踪)
118
+ CODEBUDDY_API_KEY_HELPER_DISABLED: '1',
119
+ CODEBUDDY_GATEWAY_PASSWORD: 'secret',
120
+ CODEBUDDY_QIMEI36: 'device-fp',
121
+ CODEBUDDY_CONFIG_DIR: 'C:\\Users\\xuze\\.workbuddy',
122
+ CODEBUDDY_MCP_CONFIG: '{"mcpServers":{}}',
123
+ CODEBUDDY_SESSION_ID: 'some-session',
124
+ CODEBUDDY_CURRENT_MODEL_ID: 'some-model',
125
+ // WORKBUDDY_* / CLIENT_INFO_* / ACC_* / TENCENT_DOCS_LOCAL_* 族代表
126
+ WORKBUDDY_PAC_RPC_TOKEN: 'secret',
127
+ WORKBUDDY_PRODUCT_NAME: 'WorkBuddy',
128
+ CLIENT_INFO_PRODUCT_NAME: 'WorkBuddy',
129
+ CLIENT_INFO_MACHINE_ID: 'machine-id',
130
+ ACC_PRODUCT_CONFIG_PATH: 'C:\\Temp\\acc-product-config-v3.json',
131
+ TENCENT_DOCS_LOCAL_MCP: 'http://127.0.0.1:39099/mcp',
132
+ TENCENT_DOCS_LOCAL_SERVER: 'http://127.0.0.1:39099',
133
+ // 单键族:遥测追踪 / 沙箱 / 网关 / 运行时路径
134
+ BAGGAGE: 'codebuddy.session_id=xxx',
135
+ TRACEPARENT: '00-abc-def-01',
136
+ SANDBOX_CENTER_IPC_ADDRESS: '\\\\.\\pipe\\WorkBuddy_SandboxCenter_x',
137
+ SERVER__PORT: '52710',
138
+ EXEPATH: 'C:\\Users\\xuze\\AppData\\Local\\Programs\\WorkBuddy\\resources\\vendor\\PortableGit\\bin',
139
+ ELECTRON_RUN_AS_NODE: '1',
140
+ // 反例:用户合法认证通道(官方文档 headless 认证)必须保留
141
+ CODEBUDDY_API_KEY: 'user-key',
142
+ CODEBUDDY_BASE_URL: 'https://example.com/api',
143
+ // 反例:用户自己永久设置的变量不在剥离范围
144
+ DB_HOST: '192.168.3.44',
145
+ };
146
+ for (const [k, v] of Object.entries(pollution)) { had[k] = process.env[k]; process.env[k] = v; }
147
+ try {
148
+ const env = buildCodebuddyEnv();
149
+ // ★ 两个杀手必须剥掉:带上任一 CLI 会跑桌面版 bundle / 找桌面版凭据 → Authentication
150
+ // required(2026-10-12 逐变量二分 + 日志取证)。这两条测试就是那道防线。
151
+ assert.equal(env.CODEBUDDY_HOST, undefined);
152
+ assert.equal(env.CODEBUDDY_FORCE_LITE_WB_BUNDLE, undefined);
153
+ assert.equal(env.CODEBUDDY_FORCE_HEADLESS_BUNDLE, undefined);
154
+ for (const k of ['CODEBUDDY_API_KEY_HELPER_DISABLED', 'CODEBUDDY_GATEWAY_PASSWORD',
155
+ 'CODEBUDDY_QIMEI36', 'CODEBUDDY_CONFIG_DIR', 'CODEBUDDY_MCP_CONFIG',
156
+ 'CODEBUDDY_SESSION_ID', 'CODEBUDDY_CURRENT_MODEL_ID']) {
157
+ assert.equal(env[k], undefined, `${k} 应被 CODEBUDDY_ 前缀剥掉`);
158
+ }
159
+ for (const k of ['WORKBUDDY_PAC_RPC_TOKEN', 'WORKBUDDY_PRODUCT_NAME',
160
+ 'CLIENT_INFO_PRODUCT_NAME', 'CLIENT_INFO_MACHINE_ID', 'ACC_PRODUCT_CONFIG_PATH',
161
+ 'TENCENT_DOCS_LOCAL_MCP', 'TENCENT_DOCS_LOCAL_SERVER',
162
+ 'BAGGAGE', 'TRACEPARENT', 'SANDBOX_CENTER_IPC_ADDRESS', 'SERVER__PORT',
163
+ 'EXEPATH', 'ELECTRON_RUN_AS_NODE']) {
164
+ assert.equal(env[k], undefined, `${k} 应被剥掉`);
165
+ }
166
+ // 用户合法认证通道放行(白名单)
167
+ assert.equal(env.CODEBUDDY_API_KEY, 'user-key');
168
+ assert.equal(env.CODEBUDDY_BASE_URL, 'https://example.com/api');
169
+ // 用户自己的变量不受影响
170
+ assert.equal(env.DB_HOST, '192.168.3.44');
171
+ // 自己的三个设定仍在(且顺序上是在剥离之后设置的 —— 先设会被前缀规则误伤)
172
+ assert.equal(env.DISABLE_AUTOUPDATER, '1');
173
+ assert.equal(env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS, '1');
174
+ assert.equal(env.LANG, 'zh_CN.UTF-8');
175
+ } finally {
176
+ for (const [k, v] of Object.entries(had)) {
177
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
178
+ }
179
+ }
180
+ });
181
+
182
+ test('codebuddy 事件:init 落 session_id,text/thinking/tool_use 分派', () => {
183
+ const job = {};
184
+ const sink = makeSink();
185
+ const tracker = makeTrackerSpy();
186
+ const handle = createCodebuddyEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
187
+
188
+ handle({ type: 'system', subtype: 'init', session_id: 'cbc-sess-1', apiKeySource: 'www.workbuddy.cn', tools: [] });
189
+ handle({ type: 'assistant', message: { content: [{ type: 'text', text: '你好' }] } });
190
+ handle({ type: 'assistant', message: { content: [{ type: 'thinking', thinking: '先想想' }] } });
191
+ handle({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tu-1', name: 'Bash', input: { command: 'ls' } }] } });
192
+
193
+ assert.equal(job.claudeSessionId, 'cbc-sess-1');
194
+ assert.deepEqual(sink.output, ['你好']);
195
+ assert.deepEqual(sink.thinking, ['先想想']);
196
+ assert.equal(tracker.records.length, 1);
197
+ assert.deepEqual(tracker.records[0], { id: 'tu-1', name: 'Bash', input: { command: 'ls' }, status: 'running' });
198
+ });
199
+
200
+ test('codebuddy 事件:user 里的 tool_result 回指 tool_use_id,is_error 映射 error', () => {
201
+ const job = {};
202
+ const sink = makeSink();
203
+ const tracker = makeTrackerSpy();
204
+ const handle = createCodebuddyEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
205
+
206
+ handle({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu-1', content: 'a.txt\nb.txt' }] } });
207
+ handle({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu-2', is_error: true, content: 'boom' }] } });
208
+
209
+ assert.equal(tracker.records.length, 2);
210
+ assert.equal(tracker.records[0].id, 'tu-1');
211
+ assert.equal(tracker.records[0].status, 'done');
212
+ assert.match(String(tracker.records[0].output), /a\.txt/);
213
+ assert.equal(tracker.records[1].status, 'error');
214
+ assert.match(String(tracker.records[1].error), /boom/);
215
+ // tool_result 是回指,不当正文吐出来
216
+ assert.deepEqual(sink.output, []);
217
+ });
218
+
219
+ test('codebuddy 事件:cbc 特有事件(system/status、ai-title、result)一律忽略', () => {
220
+ const job = {};
221
+ const sink = makeSink();
222
+ const tracker = makeTrackerSpy();
223
+ const handle = createCodebuddyEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
224
+
225
+ handle({ type: 'system', subtype: 'status', status: 'compacting', session_id: 's' });
226
+ handle({ type: 'ai-title', title: '某个标题' });
227
+ handle({ type: 'result', subtype: 'success', is_error: false, session_id: 's', usage: {} });
228
+ // 未知/畸形事件也不能抛错(NDJSON 分行解析里抛错会打死整轮对话)
229
+ handle(null);
230
+ handle('not-an-object');
231
+ handle({ type: 'assistant' });
232
+ handle({ type: 'assistant', message: { content: 'string-not-array' } });
233
+ handle({ type: 'user', message: { content: 'plain user prompt' } });
234
+
235
+ assert.deepEqual(sink.output, []);
236
+ assert.deepEqual(sink.thinking, []);
237
+ assert.equal(tracker.records.length, 0);
238
+ assert.equal(job.claudeSessionId, undefined);
239
+ });
240
+
241
+ test('codebuddy 事件:工具调用是两条式(running → tool_result),不是一次性终态', () => {
242
+ const job = {};
243
+ const sink = makeSink();
244
+ const tracker = makeTrackerSpy();
245
+ const handle = createCodebuddyEventHandler(job, sink.appendOutput, sink.appendThinking, tracker);
246
+
247
+ // 实测序列(2026-10-12):Glob running → Glob 结果 → Read running → Read 结果 → 正文
248
+ handle({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'call_1', name: 'Glob', input: { pattern: '**/a.txt' } }] } });
249
+ handle({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'call_1', content: [{ type: 'text', text: '["a.txt"]' }] }] } });
250
+ handle({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'call_2', name: 'Read', input: { file_path: 'a.txt' } }] } });
251
+ handle({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'call_2', content: [{ type: 'text', text: 'hello' }] }] } });
252
+ handle({ type: 'assistant', message: { content: [{ type: 'text', text: '读完了' }] } });
253
+
254
+ assert.deepEqual(tracker.records.map(r => [r.id, r.status]), [
255
+ ['call_1', 'running'],
256
+ ['call_1', 'done'],
257
+ ['call_2', 'running'],
258
+ ['call_2', 'done'],
259
+ ]);
260
+ // tool_result.content 是块数组时抽文本(与 claude 的 extractToolText 同口径)
261
+ assert.match(String(tracker.records[1].output), /a\.txt/);
262
+ assert.deepEqual(sink.output, ['读完了']);
263
+ });
264
+
265
+ test('codebuddy 事件:init 重复推送(实测出现过两条)只认第一条', () => {
266
+ const job = {};
267
+ const sink = makeSink();
268
+ const handle = createCodebuddyEventHandler(job, sink.appendOutput, sink.appendThinking, makeTrackerSpy());
269
+
270
+ handle({ type: 'system', subtype: 'init', session_id: 'cbc-sess-x' });
271
+ handle({ type: 'system', subtype: 'init', session_id: 'cbc-sess-x' });
272
+
273
+ assert.equal(job.claudeSessionId, 'cbc-sess-x');
274
+ assert.deepEqual(sink.output, []);
275
+ });
@@ -15,10 +15,13 @@
15
15
  // 任务执行引擎:spawn 本地 agent CLI 跑子任务,流式收集输出,更新 job 状态。
16
16
  // 拆分自原 routes/workbench.js 1108-1564 行。
17
17
  //
18
- // 支持三种执行器(executor):
19
- // - claude —— claude -p --output-format stream-json(默认,历史行为)
20
- // - opencode —— opencode run --format json --auto(sst/opencode CLI,跟随其自身默认模型)
21
- // - codex —— codex exec --json(OpenAI Codex CLI,跟随其自身配置;2026-09-27 加入)
18
+ // 支持四种执行器(executor):
19
+ // - claude —— claude -p --output-format stream-json(默认,历史行为)
20
+ // - opencode —— opencode run --format json --auto(sst/opencode CLI,跟随其自身默认模型)
21
+ // - codex —— codex exec --json(OpenAI Codex CLI,跟随其自身配置;2026-09-27 加入)
22
+ // - codebuddy —— codebuddy -p --output-format stream-json -y --port 0
23
+ // (腾讯 CodeBuddy Code CLI,即 WorkBuddy 桌面版的智能体引擎;
24
+ // 2026-10-12 加入,协议与 claude 的 stream-json 逐字段对齐)
22
25
  //
23
26
  // 核心 API:
24
27
  // - launchClaudeInNewWindow(cwd, prompt, resumeSessionId) spawn claude,返回 {pid, child}
@@ -216,11 +219,32 @@ function attachPromptStdin(child, promptText, resolve, reject) {
216
219
  }
217
220
 
218
221
  // ── 执行器(executor) ──────────────────────────────────────────────────
219
- // 工作台任务默认由 claude CLI 执行;opencode(2026-09-20)、codex(2026-09-27)
220
- // 作为可选执行器。三者都用「stdin 喂 prompt + stdout 结构化 JSON 事件流」的同构形态,
221
- // 差异只在协议:claude 是 stream-json,opencode 是 --format json 的事件 NDJSON,
222
- // codex 是 exec --json 的 thread/item 事件 NDJSON。
223
- export const TASK_EXECUTORS = ['claude', 'opencode', 'codex'];
222
+ // 工作台任务默认由 claude CLI 执行;opencode(2026-09-20)、codex(2026-09-27)、
223
+ // codebuddy(2026-10-12)作为可选执行器。四者都用「stdin 喂 prompt + stdout 结构化
224
+ // JSON 事件流」的同构形态,差异只在协议:claude / codebuddy 是 stream-json,
225
+ // opencode 是 --format json 的事件 NDJSON,codex 是 exec --json 的 thread/item 事件 NDJSON。
226
+ //
227
+ // ⚠️ 这份清单与 src/config.js 的 TASK_EXECUTORS 是**同一份口径的两处书写**——
228
+ // 历史上为此吵过(见 agentEngines.js 文件头):config.js 那份是"配置/归一化"的
229
+ // 真相源,本文件这份服务"运行时分发"。两边不一致的失效方式是 normalize 放行、
230
+ // 这里查无此人 → 静默回落 claude。加第五家时两处都要改,taskRunner.*.test.js 有钉子。
231
+ export const TASK_EXECUTORS = ['claude', 'opencode', 'codex', 'codebuddy'];
232
+
233
+ /** 执行器 → launcher。runSingleSubtask 与 runExternalTurn 共用这一张表。 */
234
+ export const EXECUTOR_LAUNCHERS = {
235
+ claude: launchClaudeInNewWindow,
236
+ opencode: launchOpencodeRun,
237
+ codex: launchCodexExec,
238
+ codebuddy: launchCodebuddyPrint,
239
+ };
240
+
241
+ /** 执行器 → NDJSON 事件处理器工厂。 */
242
+ export const EXECUTOR_HANDLERS = {
243
+ claude: createClaudeEventHandler,
244
+ opencode: createOpencodeEventHandler,
245
+ codex: createCodexEventHandler,
246
+ codebuddy: createCodebuddyEventHandler,
247
+ };
224
248
 
225
249
  /** 非法/缺省的 executor 一律回落 'claude'(历史行为的兼容兜底)。
226
250
  * body.executor 来自网络,做一次 trim + 小写归一,与 config.js 同名函数同语义。 */
@@ -390,6 +414,274 @@ export function launchCodexExec(cwd, promptText, resumeSessionId) {
390
414
  });
391
415
  }
392
416
 
417
+ // ── codebuddy 执行器(2026-10-12 加入) ────────────────────────────────────
418
+ // CodeBuddy Code CLI(`codebuddy` / `cbc`,npm 包 @tencent-ai/codebuddy-code)就是
419
+ // WorkBuddy 桌面版内置的智能体引擎。把它接进来 = 工作台/对话面板可以直接调度
420
+ // "WorkBuddy 同款引擎"(同一套工具集、Skills、权限模型),账号体系也是同一个
421
+ // (都走腾讯账号;CLI 侧首次要在终端扫码登录一次,写 ~/.codebuddy)。
422
+ //
423
+ // ⚠️ 与桌面版**不共享任何运行时身份**:桌面版给它自己的子进程注入一整套变量
424
+ // (CODEBUDDY_HOST=workbuddy-desktop / CONFIG_DIR / MCP_CONFIG / 会话追踪 id…),
425
+ // 这套变量继承过来会让 CLI 以"桌面版子进程"身份运行 —— 实测直接认证失败。
426
+ // 全部在 buildCodebuddyEnv 里剥掉,CLI 固定以独立模式读自己的 ~/.codebuddy,
427
+ // 行为可复现,不随 g gui 被谁拉起而变。
428
+ //
429
+ // 实测口径(2026-10-12,CLI 2.164.0,本机与官方文档双向核对):
430
+ //
431
+ // codebuddy -p --output-format stream-json -y --port 0 [--resume <sessionId>]
432
+ // - prompt 从 stdin 喂(-p 无位置参数时读 stdin,与 claude 的 `-p -` 同效,
433
+ // 顺带避开 Windows 命令行 32K 上限)
434
+ // - --output-format stream-json NDJSON 事件流,**协议与 claude 逐字段对齐**
435
+ // (官方文档原话"对齐 Anthropic Claude Code v2.1.88"):
436
+ // system/init(带 session_id / tools / model / permissionMode)
437
+ // assistant(message.content 里的 text / thinking / tool_use 块)
438
+ // user(message.content 里的 tool_result 块,回指 tool_use_id)
439
+ // result(终态,带 is_error / errors / usage / total_cost_usd)
440
+ // 另有 system/status、ai-title 等 cbc 特有事件,handler 一律忽略。
441
+ // - -y 非交互免批准。init 事件回传 permissionMode: 'bypassPermissions'
442
+ // (不给 -y 时写操作会被拦,和另三家一个性质)
443
+ // - --port 0 **每次 spawn 必须显式传**。CLI 每次运行会自带一个引擎服务器,
444
+ // 默认端口写死 62443 —— 而 WorkBuddy 桌面版常驻就占着 62443,
445
+ // 不传端口(或传固定值)会 EADDRINUSE 直接退出。0 = 让 OS 选空闲
446
+ // 端口,实测并发两个任务各起各的互不冲突。
447
+ // - --resume 续接会话,id 取自上一轮 init 事件的 session_id(落 job.claudeSessionId)
448
+ // - 不传 --model:模型跟随 CLI 自己的配置(与另三家同一口径,探测见 executorModels.js)
449
+ // - 不需要 --verbose:claude 的 stream-json 必须配 --verbose,cbc 不用
450
+ // (实测不带 --verbose 事件流完整)。
451
+ //
452
+ // 登录态:CLI 读 ~/.codebuddy 下的凭据。**首次使用前要先在终端跑一次
453
+ // `codebuddy` 扫码登录**(微信),之后 headless 才能过认证;也可以用
454
+ // CODEBUDDY_API_KEY(+可选 CODEBUDDY_BASE_URL)环境变量认证。读不到凭据时
455
+ // 会话照常起,但模型调用返回 "Authentication required",表现是一轮秒退的
456
+ // error_during_execution —— 这种失败由 result 事件如实带出,不静默。
457
+ // (另见 buildCodebuddyEnv:桌面版注入的 CODEBUDDY_CONFIG_DIR 会被删掉。)
458
+
459
+ /**
460
+ * Windows 下把 codebuddy 解析成可直接 spawn 的目标。
461
+ *
462
+ * 三种安装形态都见过(2026-10-12):
463
+ * 1. npm 全局(`npm i -g @tencent-ai/codebuddy-code`)—— PATH 上是 .cmd shim,
464
+ * shim 实质是 `node <prefix>\node_modules\@tencent-ai\codebuddy-code\bin\codebuddy`。
465
+ * Node 23+ 拒绝直接 spawn .cmd(EINVAL),所以像 claude 一样解析到 node + bin 脚本。
466
+ * 2. 原生二进制安装(install.ps1 → %USERPROFILE%\AppData\Local\codebuddy\bin)——
467
+ * PATH 上直接是 codebuddy.exe,优先用(少一层 node 包装,杀进程树也更干净)。
468
+ * 3. 手动放 PATH 的其它形态 —— 兜底交给 PATH。
469
+ */
470
+ function resolveCodebuddyExe() {
471
+ try {
472
+ const lines = execFileSync('where', ['codebuddy'], { encoding: 'utf8', windowsHide: true })
473
+ .split(/\r?\n/).map(s => s.trim()).filter(Boolean);
474
+ // 独立安装优先:PATH 上直接就是原生 exe
475
+ const directExe = lines.find(s => /\.exe$/i.test(s));
476
+ if (directExe && fs.existsSync(directExe)) return { exe: directExe, prefixArgs: [] };
477
+ const cmdShim = lines.find(s => /\.cmd$/i.test(s));
478
+ if (cmdShim) {
479
+ const prefix = path.dirname(cmdShim);
480
+ const binJs = path.join(prefix, 'node_modules', '@tencent-ai', 'codebuddy-code', 'bin', 'codebuddy');
481
+ if (fs.existsSync(binJs)) return { exe: process.execPath, prefixArgs: [binJs] };
482
+ }
483
+ } catch { /* fallback */ }
484
+ return { exe: 'codebuddy', prefixArgs: [] };
485
+ }
486
+
487
+ /**
488
+ * WorkBuddy 桌面版注入给**它自己子进程**的环境变量,一个都不能让 codebuddy 子进程继承。
489
+ *
490
+ * ★ 两个实测确认的认证杀手(2026-10-12 逐变量二分 + CLI 日志取证):
491
+ * 1. `CODEBUDDY_HOST=workbuddy-desktop` —— 带上它,CLI 认为自己是"桌面版引擎
492
+ * 的子进程",转而去找桌面版凭据(`auth/workbuddy-desktop.info`,其解密密钥
493
+ * 只存在于桌面版运行时),拿不到就报 "Authentication required"(日志:
494
+ * AtRestEncryption missing-key → AuthRefresh failed)。而用户明明在
495
+ * ~/.codebuddy 登录过。
496
+ * 2. `CODEBUDDY_FORCE_LITE_WB_BUNDLE=1`(+配套 FORCE_HEADLESS_BUNDLE)——
497
+ * bin/codebuddy 里**专为桌面版宿主准备的 bundle 分流通道**(CLI 源码注释
498
+ * 原话:"宿主(workbuddy-server 起 sidecar / prewarm)在 spawn cbc 前设
499
+ * CODEBUDDY_FORCE_LITE_WB_BUNDLE=1,直接命中 codebuddy-lite-wb.js")。
500
+ * 命中后跑的是 lite-wb 裁剪包,强绑定桌面版身份,同样认证失败
501
+ * (日志 ProcessExit 调用栈落在 codebuddy-lite-wb.mjs)。
502
+ *
503
+ * 剥离策略:**前缀级 + 认证白名单**。桌面版实测注入 54 个 CODEBUDDY_* 变量
504
+ * (外加 WORKBUDDY_* / CLIENT_INFO_* / ACC_* / TENCENT_DOCS_LOCAL_* 各家族),
505
+ * 逐个人肉枚举是在打地鼠 —— FORCE_* 这对杀手就是枚举清单时代漏掉的。
506
+ *
507
+ * 判据(为什么这些前缀可以整族剥、为什么"全环境白名单"方向又是错的):
508
+ * 看"用户在自己的终端里跑 codebuddy 时环境里有没有它"。桌面版在拉起 g gui
509
+ * 或它的 sidecar 时注入的这几族变量,用户正常场景**全部不存在**;剥掉它们
510
+ * 只是把子进程环境对齐到"用户终端里的 codebuddy",不引入任何新偏差。
511
+ * 而 DB_* / pw / IMG_LIB 这类用户自己永久设置的变量不在这里剥。
512
+ * (反向的"只保留必需变量"白名单同样错:HTTP(S)_PROXY / NODE_EXTRA_CA_CERTS
513
+ * 这类网络变量是合法继承项,一刀切会把用户的代理环境也切没。)
514
+ */
515
+ const CODEBUDDY_DESKTOP_ENV_PREFIXES = Object.freeze([
516
+ 'CODEBUDDY_', // 运行时开关全家(认证白名单见 CODEBUDDY_ENV_KEEP)
517
+ 'WORKBUDDY_', // 桌面版自家命名空间(含 PAC_RPC_TOKEN 等内部令牌)
518
+ 'CLIENT_INFO_', // 桌面版客户端身份(PRODUCT_NAME=WorkBuddy / MACHINE_ID / UA 后缀)
519
+ 'ACC_', // 产品配置注入(ACC_PRODUCT_CONFIG_PATH → workbuddy 产品 json)
520
+ 'TENCENT_DOCS_LOCAL_', // 腾讯文档本地 MCP 地址注入
521
+ ]);
522
+
523
+ /**
524
+ * 前缀剥离里**必须放行**的例外:官方文档的 headless 认证通道。
525
+ * 用户在终端里显式设置它们属于正当配置,g gui 继承用户环境 = 继承用户意图。
526
+ * 只放行有文档背书的这两个(其余几百个 CODEBUDDY_* 开关一律剥,理由见上)。
527
+ */
528
+ const CODEBUDDY_ENV_KEEP = Object.freeze([
529
+ 'CODEBUDDY_API_KEY',
530
+ 'CODEBUDDY_BASE_URL',
531
+ ]);
532
+
533
+ /**
534
+ * 不属于任何前缀、但同样只在桌面版会话里出现的单键(逐个人工核对过)。
535
+ * 归入同一份剥离清单,测试里逐键钉住。
536
+ */
537
+ const CODEBUDDY_DESKTOP_ENV_KEYS = Object.freeze([
538
+ // 遥测 / 链路追踪:会把子会话的 trace 挂到桌面版那次会话下
539
+ 'BAGGAGE', 'TRACEPARENT',
540
+ // 桌面版遥测 SDK 标记
541
+ 'GALILEO_ENABLE', 'GALILEO_SDK_READY',
542
+ // 桌面版沙箱中心管道 / 网关服务地址(SERVER__PORT 与 SERVICE_PROXY_URL 同端口同源)
543
+ 'SANDBOX_CENTER_IPC_ADDRESS', 'SERVER__HOST', 'SERVER__PORT',
544
+ // 桌面版运行时路径与 Electron 标记(EXEPATH=桌面版 PortableGit;GENIE_TRASH_DIR=桌面版垃圾箱)
545
+ 'EXEPATH', 'GENIE_TRASH_DIR', 'ELECTRON_RUN_AS_NODE', 'ORIGINAL_XDG_CURRENT_DESKTOP',
546
+ ]);
547
+
548
+ /**
549
+ * codebuddy 子进程环境。四个刻意为之的取舍:
550
+ * - LANG=zh_CN.UTF-8:与另三家一致(CLI 的提示词/输出跟随 locale)。
551
+ * - DISABLE_AUTOUPDATER=1:执行器场景**不允许 CLI 自我更新** —— 任务跑到一半
552
+ * 二进制被换掉,轻则行为漂移重则进程被杀。用户想更新自己跑 `codebuddy update`。
553
+ * - CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS=1:-p 单发模式进程随首个 result
554
+ * 结束,收不了后台任务的跨轮回推事件(官方文档对 SDK query() 单发用法也是
555
+ * 这么处理的)。禁掉之后模型的 run_in_background 参数从工具 schema 里隐藏,
556
+ * 不会出现"派了后台任务然后永远等不到通知"。
557
+ * - **剥掉桌面版注入的前缀族**(见上方注释的两个杀手 —— 不剥 CODEBUDDY_HOST
558
+ * 或 CODEBUDDY_FORCE_LITE_WB_BUNDLE 都会直接认证失败,2026-10-12 实测)。
559
+ * (导出仅为回归测试可见:taskRunner.codebuddy.test.js 钉住剥/留两侧的边界)
560
+ */
561
+ export function buildCodebuddyEnv() {
562
+ const env = { ...process.env };
563
+ const keep = new Set(CODEBUDDY_ENV_KEEP);
564
+ for (const key of Object.keys(env)) {
565
+ if (keep.has(key)) continue;
566
+ if (CODEBUDDY_DESKTOP_ENV_KEYS.includes(key)) { delete env[key]; continue; }
567
+ if (CODEBUDDY_DESKTOP_ENV_PREFIXES.some((p) => key.startsWith(p))) delete env[key];
568
+ }
569
+ // 自己的三项在**剥离之后**设置:CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS 本身
570
+ // 命中 CODEBUDDY_ 前缀,若像从前那样先合并进来会被剥离循环顺手删掉。
571
+ env.LANG = 'zh_CN.UTF-8';
572
+ env.DISABLE_AUTOUPDATER = '1';
573
+ env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS = '1';
574
+ return env;
575
+ }
576
+
577
+ /**
578
+ * codebuddy 的 spawn argv(纯函数,抽出来为了能**真的跑断言**而不用 spawn 进程 ——
579
+ * 与 fileOpen.js 的 buildOpencodeArgs / buildCodexArgs 同一个理由。
580
+ *
581
+ * 口径见上方大注释。`--port 0` 是实测出来的必须项:CLI 自带引擎服务器占固定端口
582
+ * 62443,与 WorkBuddy 桌面版冲突,0 让 OS 选空闲端口(并发任务互不打架)。
583
+ */
584
+ export function buildCodebuddyArgs(resumeSessionId) {
585
+ const args = [];
586
+ if (resumeSessionId) {
587
+ args.push('--resume', String(resumeSessionId));
588
+ }
589
+ args.push(
590
+ '-p',
591
+ '--output-format', 'stream-json',
592
+ '-y',
593
+ '--port', '0'
594
+ );
595
+ return args;
596
+ }
597
+
598
+ /** spawn codebuddy 跑一个 prompt(非交互),返回 { pid, child }(参数口径见上方注释)。 */
599
+ export async function launchCodebuddyPrint(cwd, promptText, resumeSessionId) {
600
+ const args = buildCodebuddyArgs(resumeSessionId);
601
+ const { exe, prefixArgs } = resolveCodebuddyExe();
602
+ const spawnArgs = [...prefixArgs, ...args];
603
+ return new Promise((resolve, reject) => {
604
+ let child;
605
+ if (process.platform === 'win32') {
606
+ child = spawn(exe, spawnArgs, {
607
+ cwd,
608
+ stdio: ['pipe', 'pipe', 'pipe'],
609
+ windowsHide: false,
610
+ env: buildCodebuddyEnv()
611
+ });
612
+ } else {
613
+ child = spawn(exe, spawnArgs, {
614
+ cwd,
615
+ detached: true,
616
+ stdio: ['pipe', 'pipe', 'pipe'],
617
+ env: buildCodebuddyEnv()
618
+ });
619
+ }
620
+ attachPromptStdin(child, promptText, resolve, reject);
621
+ });
622
+ }
623
+
624
+ /**
625
+ * codebuddy stream-json 事件处理器。
626
+ *
627
+ * **协议与 claude 逐字段对齐**(2026-10-12 实测:init/assistant/user/result 四种
628
+ * 事件、content 块的 text/thinking/tool_use、user 事件里的 tool_result 回指,
629
+ * 全部同构;工具调用同样是"先 tool_use(running)、后 user(tool_result)"两条)。
630
+ * 所以这里刻意**另写一份**而不是直接复用 createClaudeEventHandler:
631
+ * 对齐是实测结论、不是合同 —— cbc 还多出 system/status、ai-title 等自有事件,
632
+ * 将来协议分叉时在这里修,不会顺带改坏 claude 那条链路(本仓库对"同一份口径
633
+ * 多处实现"的失效方式有惨痛记忆:不报错,只是悄悄少一半数据)。
634
+ *
635
+ * 已知被忽略的事件:system/status、system/task_*(后台任务,已被
636
+ * CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS 禁用)、ai-title、result。
637
+ * (导出仅为回归测试可见:taskRunner.codebuddy.test.js)
638
+ */
639
+ export function createCodebuddyEventHandler(job, appendOutput, appendThinking, toolCalls) {
640
+ const tracker = toolCalls || createToolCallTracker(job);
641
+ return (evt) => {
642
+ if (!evt || typeof evt !== 'object') return;
643
+ // init:session_id 落 job.claudeSessionId(--resume 续接用)
644
+ if (evt.type === 'system' && evt.subtype === 'init' && typeof evt.session_id === 'string') {
645
+ if (!job.claudeSessionId || job.claudeSessionId !== evt.session_id) {
646
+ job.claudeSessionId = evt.session_id;
647
+ publish('job:update', job);
648
+ }
649
+ return;
650
+ }
651
+ // 工具结果:与 claude 完全同构 —— user 事件的 content 是块数组,
652
+ // 里面才是 tool_result(用户自己那条 prompt 的 content 是字符串,不会走到这)
653
+ if (evt.type === 'user') {
654
+ const ublocks = evt.message?.content;
655
+ if (Array.isArray(ublocks)) {
656
+ for (const b of ublocks) {
657
+ if (b && b.type === 'tool_result' && b.tool_use_id) {
658
+ const text = extractToolText(b.content);
659
+ tracker.record({
660
+ id: b.tool_use_id,
661
+ status: b.is_error ? 'error' : 'done',
662
+ output: text,
663
+ error: b.is_error ? (text || '工具执行失败') : ''
664
+ });
665
+ }
666
+ }
667
+ }
668
+ return;
669
+ }
670
+ if (evt.type !== 'assistant') return;
671
+ const blocks = evt.message?.content;
672
+ if (!Array.isArray(blocks)) return;
673
+ for (const b of blocks) {
674
+ if (b.type === 'text' && typeof b.text === 'string') {
675
+ appendOutput(b.text);
676
+ } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
677
+ appendThinking(b.thinking);
678
+ } else if (b.type === 'tool_use') {
679
+ tracker.record({ id: b.id, name: b.name, input: b.input, status: 'running' });
680
+ }
681
+ }
682
+ };
683
+ }
684
+
393
685
  // ── 工具调用(tool_use)收集 ──────────────────────────────────────────────
394
686
  // 为什么要有这一层:job.output 只承载「模型说的话」。工具调用(读了哪个文件、跑了什么命令、
395
687
  // 拿到了什么结果)原本在两个事件处理器里被整段丢掉,于是前端只剩「思考 + 正文」两块,
@@ -901,10 +1193,10 @@ export function createCodexEventHandler(job, appendOutput, appendThinking, toolC
901
1193
  * @param {object} [options]
902
1194
  * @param {string|null} [options.resumeSessionId]
903
1195
  * 续接历史会话:claude 走 --resume <id>,opencode 走 --session <id>,
904
- * codex 走 exec resume <thread_id>。
905
- * id 来自上一轮 stream 事件捕获的会话标识(三者都记在 job.claudeSessionId)。
1196
+ * codex 走 exec resume <thread_id>,codebuddy 走 --resume <session_id>。
1197
+ * id 来自上一轮 stream 事件捕获的会话标识(四家都记在 job.claudeSessionId)。
906
1198
  * @param {string} [options.executor]
907
- * 执行器 'claude' | 'opencode' | 'codex'。缺省回落 'claude';路由层负责把
1199
+ * 执行器 'claude' | 'opencode' | 'codex' | 'codebuddy'。缺省回落 'claude';路由层负责把
908
1200
  * "body 指定 > 配置默认"先解析好再传进来。
909
1201
  * @returns {Promise<'done'|'cancelled'|'error'>} 本次运行的终态
910
1202
  */
@@ -990,8 +1282,8 @@ export async function runSingleSubtask(task, sub, repoPath, branch, options) {
990
1282
  // 路由层用它保证"用同一个 CLI 续同一个会话"。
991
1283
  agent: executor,
992
1284
  // 续接历史会话时先填上,首条协议事件回来后再用真值覆盖(通常等同)。
993
- // 字段名保留 claudeSessionId:claude 的 session_id 和 opencode 的 sessionID
994
- // 都存这里,语义是"该执行器的会话续接标识"。
1285
+ // 字段名保留 claudeSessionId:claude 的 session_id / opencode 的 sessionID /
1286
+ // codebuddy 的 session_id 都存这里,语义是"该执行器的会话续接标识"。
995
1287
  claudeSessionId: resumeSessionId,
996
1288
  // 工具调用流水({id,name,argsPreview,arguments,result,status,error}[])。
997
1289
  // 先给个空数组,前端拿到的 job:update 就有了稳定形态,不用到处判 undefined。
@@ -1013,11 +1305,9 @@ export async function runSingleSubtask(task, sub, repoPath, branch, options) {
1013
1305
  let toolTracker = null;
1014
1306
 
1015
1307
  try {
1016
- const launcher = executor === 'opencode'
1017
- ? launchOpencodeRun
1018
- : executor === 'codex'
1019
- ? launchCodexExec
1020
- : launchClaudeInNewWindow;
1308
+ // 查表而不是 if 链:加执行器只改 EXECUTOR_LAUNCHERS 一张表(四家以后 if 链
1309
+ // 已经长得该收了)。查无此人理论上不可能 —— normalizeTaskExecutor 刚夹过一遍。
1310
+ const launcher = EXECUTOR_LAUNCHERS[executor] || launchClaudeInNewWindow;
1021
1311
  const { pid, child } = await launcher(repoPath || process.cwd(), prompt, resumeSessionId);
1022
1312
  job.pid = pid;
1023
1313
  // 保存 child 引用,供 cancel 接口调用 kill
@@ -1074,11 +1364,8 @@ export async function runSingleSubtask(task, sub, repoPath, branch, options) {
1074
1364
  publish('job:toolcalls', { id: job.id, updates });
1075
1365
  });
1076
1366
 
1077
- const handleEvent = executor === 'opencode'
1078
- ? createOpencodeEventHandler(job, appendOutput, appendThinking, toolTracker)
1079
- : executor === 'codex'
1080
- ? createCodexEventHandler(job, appendOutput, appendThinking, toolTracker)
1081
- : createClaudeEventHandler(job, appendOutput, appendThinking, toolTracker);
1367
+ const makeHandler = EXECUTOR_HANDLERS[executor] || createClaudeEventHandler;
1368
+ const handleEvent = makeHandler(job, appendOutput, appendThinking, toolTracker);
1082
1369
 
1083
1370
  const handleLine = (line, channel) => {
1084
1371
  const trimmed = line.trim();