zen-gitsync 2.18.7 → 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 (76) hide show
  1. package/package.json +4 -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 +48 -6
  8. package/src/mcp/tools/workbench.js +1 -1
  9. package/src/ui/public/assets/{AgentView-am93H7C2.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-D7X53Bf1.js → AppVersionBadge-C4OhcUi8.js} +2 -2
  12. package/src/ui/public/assets/{BranchSelector-yXxfIs57.js → BranchSelector-CrLieQyJ.js} +1 -1
  13. package/src/ui/public/assets/{CommitForm-C8Ez91_c.js → CommitForm-KGQisnPQ.js} +2 -2
  14. package/src/ui/public/assets/{CommonDialog-BUuLsgNS.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-BuU5kQG8.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
  19. package/src/ui/public/assets/{FlowOrchestrationWorkspace-CTBEooj5.js → FlowOrchestrationWorkspace-BMQSVm3V.js} +1 -1
  20. package/src/ui/public/assets/{LogList-Bq5aLoA-.js → LogList-BxH6QNlK.js} +4 -4
  21. package/src/ui/public/assets/{MindmapView-CSysrruz.js → MindmapView-D9_qjJsR.js} +1 -1
  22. package/src/ui/public/assets/{MonitorView-Cgf5zRxT.js → MonitorView-z_fsnE8Y.js} +1 -1
  23. package/src/ui/public/assets/{ProjectStartupButton-CrLWTK20.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-CJZFzQPc.js → RemoteManagerDialog-DXDcN8e7.js} +1 -1
  27. package/src/ui/public/assets/{RemoteRepoCard-aSx1ikZ8.js → RemoteRepoCard-C_TYGsXi.js} +1 -1
  28. package/src/ui/public/assets/{SourceMapView-DEljf14g.js → SourceMapView-D6cSweDt.js} +1 -1
  29. package/src/ui/public/assets/{SvgIcon-Du5MOe8A.js → SvgIcon-5AlG3IfR.js} +1 -1
  30. package/src/ui/public/assets/{UserInputNode-oOQwaadU.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-Csn085FF.js +19 -0
  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-C236G5P5.js +1 -0
  39. package/src/ui/public/index.html +6 -6
  40. package/src/ui/server/index.js +8 -0
  41. package/src/ui/server/routes/config.js +13 -0
  42. package/src/ui/server/routes/fileOpen.js +57 -1
  43. package/src/ui/server/routes/fileOpen.test.js +1 -1
  44. package/src/ui/server/routes/workbench/agentChat.js +106 -12
  45. package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
  46. package/src/ui/server/routes/workbench/agentChatShared.test.js +148 -57
  47. package/src/ui/server/routes/workbench/agentEngines.js +8 -4
  48. package/src/ui/server/routes/workbench/agentExtensions.js +157 -0
  49. package/src/ui/server/routes/workbench/agentMarketplace.js +12 -0
  50. package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
  51. package/src/ui/server/routes/workbench/agentRoutes.js +105 -8
  52. package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
  53. package/src/ui/server/routes/workbench/chatPrompts.js +115 -0
  54. package/src/ui/server/routes/workbench/chatPrompts.test.js +114 -0
  55. package/src/ui/server/routes/workbench/executorModels.js +30 -5
  56. package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
  57. package/src/ui/server/routes/workbench/index.js +1 -1
  58. package/src/ui/server/routes/workbench/jobActivity.js +3 -3
  59. package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
  60. package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
  61. package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
  62. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
  63. package/src/ui/server/routes/workbench/taskRunner.js +311 -24
  64. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
  65. package/src/ui/public/assets/AgentView-BlG0PdkI.js +0 -1
  66. package/src/ui/public/assets/CopySessionButton-Do4vHJm6.js +0 -20
  67. package/src/ui/public/assets/EditorView-DK2dYRVb.js +0 -1
  68. package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
  69. package/src/ui/public/assets/RecentDirectoriesChat-BlIG_9G4.js +0 -1
  70. package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
  71. package/src/ui/public/assets/WorkbenchView-DXzxg5_z.js +0 -8
  72. package/src/ui/public/assets/_plugin-vue_export-helper-CDrhhAKC.js +0 -19
  73. package/src/ui/public/assets/agentConversations-BKjyYn-u.js +0 -8
  74. package/src/ui/public/assets/configStore-D0oycG71.js +0 -1
  75. package/src/ui/public/assets/index-DjaZpz97.js +0 -57
  76. package/src/ui/public/assets/index-DwJWiCiN.css +0 -1
@@ -0,0 +1,114 @@
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
+ // 「和 g ai 对话时固定注入的两块提示词」的拼装与取数口径。
16
+ //
17
+ // 这个能力是**纯 prompt 注入**的:没有按钮、没有状态、界面上看不出对错。
18
+ // 改 agentChat.js / runExternalTurn.js 的人(哪怕只是把一段挪个位置)会无声地把它弄掉,
19
+ // 而用户只会觉得"模型今天不太听话"。所以口径在这一层逐条钉住,注入点那条链路由
20
+ // agentChatShared.test.js 跑真实 runAgentTurn 验(见那里的"用户固定提示词"用例)。
21
+ //
22
+ // 注意:必须在 import 之前把 USERPROFILE/HOME 指到沙箱 —— chatPrompts.js 会连带
23
+ // 加载 configManager 与 orchestratorStore,它们按 home 定位 ~/.zen-gitsync。
24
+ // 本文件里的用例全部走注入口(不读真实配置),沙箱只是防"哪天有人加了真实读取"。
25
+ import { test } from 'node:test'
26
+ import assert from 'node:assert/strict'
27
+ import { mkdtempSync } from 'node:fs'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
30
+
31
+ const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-chat-prompts-'))
32
+ process.env.USERPROFILE = SANDBOX
33
+ process.env.HOME = SANDBOX
34
+ delete process.env.HOMEDRIVE
35
+ delete process.env.HOMEPATH
36
+
37
+ const { buildChatPromptSection, resolveChatPromptSection } = await import('./chatPrompts.js')
38
+
39
+ // ── 拼装(纯函数)─────────────────────────────────────────
40
+
41
+ test('两块都没配 → 空串(调用方据此完全跳过注入,连一节标题都不出现)', () => {
42
+ assert.equal(buildChatPromptSection({}), '')
43
+ assert.equal(buildChatPromptSection({ gaiPrompt: '', dispatchPrompt: '' }), '')
44
+ // 只有空白也算没配 —— 用户清空后留下一堆换行,不该在提示词里留一节空标题
45
+ assert.equal(buildChatPromptSection({ gaiPrompt: ' \n\n ', dispatchPrompt: '\t' }), '')
46
+ })
47
+
48
+ test('两块都在 → 各带自己的小标题,g ai 那段在前', () => {
49
+ const out = buildChatPromptSection({ gaiPrompt: '回答用中文', dispatchPrompt: '改完就提交' })
50
+ assert.match(out, /### g ai 默认提示词\n回答用中文/)
51
+ assert.match(out, /### 派发任务的提示词(与工作台派发任务时附加的是同一份)\n改完就提交/)
52
+ assert.ok(out.indexOf('回答用中文') < out.indexOf('改完就提交'), 'g ai 那段必须在前')
53
+ })
54
+
55
+ test('只配一块 → 只出那一块(另一块不留空标题)', () => {
56
+ const onlyGai = buildChatPromptSection({ gaiPrompt: '回答用中文' })
57
+ assert.match(onlyGai, /### g ai 默认提示词/)
58
+ assert.doesNotMatch(onlyGai, /### 派发任务的提示词/)
59
+
60
+ const onlyDispatch = buildChatPromptSection({ dispatchPrompt: '改完就提交' })
61
+ assert.match(onlyDispatch, /### 派发任务的提示词/)
62
+ assert.doesNotMatch(onlyDispatch, /### g ai 默认提示词/)
63
+ })
64
+
65
+ test('正文前后空白被裁掉,中间原样保留(用户排版不被破坏)', () => {
66
+ const out = buildChatPromptSection({ gaiPrompt: '\n\n第一段\n\n第二段\n\n' })
67
+ assert.match(out, /### g ai 默认提示词\n第一段\n\n第二段$/)
68
+ })
69
+
70
+ test('英文界面走英文标题(与系统提示词同一套 locale 判定)', () => {
71
+ const out = buildChatPromptSection({ gaiPrompt: 'answer in Chinese', locale: 'en-US' })
72
+ assert.match(out, /### g ai default prompt/)
73
+ assert.match(out, /Standing instructions|Settings/)
74
+ })
75
+
76
+ test('非字符串入参不炸(手改配置塞了数字 / 对象)', () => {
77
+ assert.equal(buildChatPromptSection({ gaiPrompt: 42, dispatchPrompt: {} }), '')
78
+ })
79
+
80
+ // ── 取数 ────────────────────────────────────────────────
81
+
82
+ test('两块分别来自两处:g ai 默认提示词取 config,派发提示词取 orchestrator', async () => {
83
+ const seenCwd = []
84
+ const out = await resolveChatPromptSection({
85
+ cwd: 'D:\\ws\\alpha',
86
+ loadAgentPrompt: async () => '回答用中文',
87
+ loadDispatchPrompt: async (cwd) => { seenCwd.push(cwd); return '改完就提交' },
88
+ })
89
+ assert.match(out, /回答用中文/)
90
+ assert.match(out, /改完就提交/)
91
+ assert.deepEqual(seenCwd, ['D:\\ws\\alpha'], '派发提示词必须按本轮 cwd 解析(项目级那条靠它生效)')
92
+ })
93
+
94
+ test('取数抛错一律当"没配",绝不把整轮对话打挂', async () => {
95
+ const both = await resolveChatPromptSection({
96
+ loadAgentPrompt: async () => { throw new Error('config.json 坏了') },
97
+ loadDispatchPrompt: async () => { throw new Error('orchestrator.json 坏了') },
98
+ })
99
+ assert.equal(both, '', '两边都挂 = 不注入')
100
+
101
+ // 一边挂另一边照常:坏的那份不该连累好的那份
102
+ const half = await resolveChatPromptSection({
103
+ loadAgentPrompt: async () => '回答用中文',
104
+ loadDispatchPrompt: async () => { throw new Error('orchestrator.json 坏了') },
105
+ })
106
+ assert.match(half, /回答用中文/)
107
+ assert.doesNotMatch(half, /### 派发任务的提示词/)
108
+ })
109
+
110
+ test('返回值是字符串而不是 undefined(调用方直接拼进 system,undefined 会印出字面量)', async () => {
111
+ const out = await resolveChatPromptSection({ loadAgentPrompt: async () => '', loadDispatchPrompt: async () => '' })
112
+ assert.equal(typeof out, 'string')
113
+ assert.equal(out, '')
114
+ })
@@ -25,7 +25,7 @@
25
25
  //
26
26
  // 这里把「读三个配置文件」这件事收口成一个模块,让前端能在选择器里直接显示。
27
27
  //
28
- // ── 三家配置文件的形状(这是本模块存在的全部理由)────────────────────────
28
+ // ── 四家配置文件的形状(这是本模块存在的全部理由)────────────────────────
29
29
  //
30
30
  // claude ~/.claude/settings.json JSON。模型在 env 里,且有**别名两层**:
31
31
  // ANTHROPIC_DEFAULT_SONNET_MODEL 是 CLI 认识的名字
@@ -48,6 +48,10 @@
48
48
  // variant(截图里那个蓝色的 max)在同文件的顶层 variant 表里。
49
49
  // 两处都没有才算"没指定",返回值里用 source 区分(config / state)。
50
50
  //
51
+ // codebuddy ~/.codebuddy/settings.json JSON。顶层 `model` 字符串
52
+ // (--settings '{"model":"..."}' 的落盘处;没写过就是没配,
53
+ // CLI 用自己的内置默认 —— 那种情况下这里如实报 null)。
54
+ //
51
55
  // ── 三条必须守住的底线 ──────────────────────────────────────────────────────
52
56
  //
53
57
  // 1. **纯只读**:只 fs.readFile,一个字节都不写。改用户 CLI 的配置是绝对禁区。
@@ -257,25 +261,42 @@ function detectOpencode(cfgJson, stateJson) {
257
261
  return detectOpencodeConfig(cfgJson) || detectOpencodeState(stateJson);
258
262
  }
259
263
 
264
+ /**
265
+ * codebuddy:~/.codebuddy/settings.json 的顶层 model。
266
+ *
267
+ * 与另三家一个重要差别:codebuddy **没有"第二处"**—— TUI 里选的模型会写回
268
+ * settings.json(`codebuddy config set -g model <id>`),不存在 opencode 那种
269
+ * "选择躺在 state 里、配置是空的"分裂。所以读一个文件就够。
270
+ * 没配 → null:CLI 有自己的内置默认(init 事件里能看到,如 fast-model),
271
+ * 但那是 CLI 的默认而不是用户的设置,按本模块的底线不猜、不报。
272
+ */
273
+ function detectCodebuddy(json) {
274
+ const raw = json && json.model;
275
+ if (typeof raw === 'string' && raw.trim()) {
276
+ return { model: raw.trim(), display: null, provider: null, source: 'config' };
277
+ }
278
+ return null;
279
+ }
280
+
260
281
  function escapeRe(s) {
261
282
  return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
262
283
  }
263
284
 
264
285
  /**
265
- * 探测三个执行器当前配置的模型。
286
+ * 探测四个执行器当前配置的模型。
266
287
  *
267
288
  * @param {object} [opts]
268
289
  * @param {string} [opts.homeDir] 测试沙箱用(默认 os.homedir())
269
- * @returns {Promise<{ claude: object|null, codex: object|null, opencode: object|null }>}
290
+ * @returns {Promise<{ claude: object|null, codex: object|null, opencode: object|null, codebuddy: object|null }>}
270
291
  * 每项 `{ model, display, provider, source }`;该执行器没配模型时为 null。
271
292
  * source = 'config'(配置文件里写死的)| 'state'(CLI 自己记的"上次用的")。
272
293
  *
273
- * 三路并发、互不阻塞:任何一个配置文件读失败都只让**它自己**变成 null。
294
+ * 四路并发、互不阻塞:任何一个配置文件读失败都只让**它自己**变成 null。
274
295
  */
275
296
  export async function detectExecutorModels({ homeDir } = {}) {
276
297
  const home = homeDir || os.homedir();
277
298
 
278
- const [claudeRaw, codexRaw, opencodeJsonRaw, opencodeJsoncRaw, opencodeStateRaw] = await Promise.all([
299
+ const [claudeRaw, codexRaw, opencodeJsonRaw, opencodeJsoncRaw, opencodeStateRaw, codebuddyRaw] = await Promise.all([
279
300
  readTextOrNull(path.join(home, '.claude', 'settings.json')),
280
301
  readTextOrNull(path.join(home, '.codex', 'config.toml')),
281
302
  // .json 优先(标准名),.jsonc 兜底。opencode 两个都认。
@@ -283,6 +304,8 @@ export async function detectExecutorModels({ homeDir } = {}) {
283
304
  readTextOrNull(path.join(home, '.config', 'opencode', 'opencode.jsonc')),
284
305
  // TUI 的"最近使用"(不写回配置文件,见 detectOpencodeState 的注释)
285
306
  readTextOrNull(path.join(home, '.local', 'state', 'opencode', 'model.json')),
307
+ // CodeBuddy Code CLI(与 WorkBuddy 桌面版共用的配置目录)
308
+ readTextOrNull(path.join(home, '.codebuddy', 'settings.json')),
286
309
  ]);
287
310
 
288
311
  const pickJson = (raw) => {
@@ -295,6 +318,7 @@ export async function detectExecutorModels({ homeDir } = {}) {
295
318
  // 于是注释里写的"jsonc 兜底"是死代码 —— 只写了 .jsonc 的机器永远显示未配置。
296
319
  const opencodeCfg = pickJson(opencodeJsonRaw) || pickJson(opencodeJsoncRaw);
297
320
  const opencodeState = pickJson(opencodeStateRaw);
321
+ const codebuddyCfg = pickJson(codebuddyRaw);
298
322
 
299
323
  return {
300
324
  claude: claudeCfg && typeof claudeCfg.env === 'object' && claudeCfg.env
@@ -302,6 +326,7 @@ export async function detectExecutorModels({ homeDir } = {}) {
302
326
  : null,
303
327
  codex: codexRaw ? detectCodex(codexRaw) : null,
304
328
  opencode: detectOpencode(opencodeCfg, opencodeState),
329
+ codebuddy: detectCodebuddy(codebuddyCfg),
305
330
  };
306
331
  }
307
332
 
@@ -121,12 +121,14 @@ const CODEX_TOML = [
121
121
  'model = "inner-model"',
122
122
  ].join('\n')
123
123
  const OPENCODE_JSONC = '{\n // 注释\n "model": "anthropic/claude-x",\n}'
124
+ const CODEBUDDY_JSON = JSON.stringify({ model: 'deepseek-v4-1-flash-260910' })
124
125
 
125
- test('detectExecutorModels: 三家配置齐全时各自读出模型', async () => {
126
+ test('detectExecutorModels: 四家配置齐全时各自读出模型', async () => {
126
127
  const home = await makeHome({
127
128
  '.claude/settings.json': CLAUDE_JSON,
128
129
  '.codex/config.toml': CODEX_TOML,
129
130
  '.config/opencode/opencode.jsonc': OPENCODE_JSONC,
131
+ '.codebuddy/settings.json': CODEBUDDY_JSON,
130
132
  })
131
133
  const out = await detectExecutorModels({ homeDir: home })
132
134
 
@@ -141,6 +143,15 @@ test('detectExecutorModels: 三家配置齐全时各自读出模型', async () =
141
143
  assert.deepEqual(out.opencode, {
142
144
  model: 'anthropic/claude-x', display: null, provider: null, source: 'config',
143
145
  })
146
+ // codebuddy:一个文件一处键,没有"第二来源"(TUI 选择会写回 settings.json)
147
+ assert.deepEqual(out.codebuddy, {
148
+ model: 'deepseek-v4-1-flash-260910', display: null, provider: null, source: 'config',
149
+ })
150
+ })
151
+
152
+ test('detectExecutorModels: codebuddy 没配 model 时为 null(CLI 内置默认不冒名)', async () => {
153
+ const home = await makeHome({ '.codebuddy/settings.json': '{"theme":"dark"}' })
154
+ assert.equal((await detectExecutorModels({ homeDir: home })).codebuddy, null)
144
155
  })
145
156
 
146
157
  test('detectExecutorModels: 只有 .jsonc 时 opencode 仍读得到 [回归]', async () => {
@@ -254,10 +265,10 @@ test('detectExecutorModels: claude 显式 ANTHROPIC_MODEL 优先于档位别名'
254
265
  })
255
266
  })
256
267
 
257
- test('detectExecutorModels: 一个配置文件都没有时三项全 null 且不抛', async () => {
268
+ test('detectExecutorModels: 一个配置文件都没有时四项全 null 且不抛', async () => {
258
269
  const home = await makeHome()
259
270
  const out = await detectExecutorModels({ homeDir: home })
260
- assert.deepEqual(out, { claude: null, codex: null, opencode: null })
271
+ assert.deepEqual(out, { claude: null, codex: null, opencode: null, codebuddy: null })
261
272
  })
262
273
 
263
274
  test('detectExecutorModels: 配置残缺/是目录/内容不是 JSON 时只让自己变 null', async () => {
@@ -265,9 +276,10 @@ test('detectExecutorModels: 配置残缺/是目录/内容不是 JSON 时只让
265
276
  '.claude/settings.json': '{ 这不是 JSON', // 解析失败 → null
266
277
  '.codex/config.toml': '# 只有注释,没有 model\n', // 无 model → null
267
278
  '.config/opencode/opencode.json': '{"model":""}', // 空串 → null
279
+ '.codebuddy/settings.json': '{"model":123}', // 非字符串 → null
268
280
  })
269
281
  const out = await detectExecutorModels({ homeDir: home })
270
- assert.deepEqual(out, { claude: null, codex: null, opencode: null })
282
+ assert.deepEqual(out, { claude: null, codex: null, opencode: null, codebuddy: null })
271
283
  })
272
284
 
273
285
  test('detectExecutorModels: 返回值里绝不出现任何密钥', async () => {
@@ -1660,7 +1660,7 @@ export function registerWorkbenchRoutes({
1660
1660
  * 无参数、服务端不算缓存:三个文件都很小,每次实读比让前端踩脏缓存划算;
1661
1661
  * 前端那边(stores/toolsStore)自己按分钟级 TTL + 并发去重压请求频率。
1662
1662
  * 返回值里某项为 null = 那个 CLI 没在配置里写模型,前端照实显示「未在配置中指定」。
1663
- * key 恒为 claude / codex / opencode 三个,与 TASK_EXECUTOR_OPTIONS 对齐。
1663
+ * key 恒为 claude / codex / opencode / codebuddy 四个,与 TASK_EXECUTOR_OPTIONS 对齐。
1664
1664
  */
1665
1665
  app.get('/api/workbench/executor-models', asyncRoute(async (_req, res) => {
1666
1666
  const detected = await detectExecutorModels();
@@ -216,7 +216,7 @@ export function jobDurationMs(job) {
216
216
  }
217
217
 
218
218
  /**
219
- * 本轮跑的是哪个执行器(`claude` | `opencode` | `codex`)。
219
+ * 本轮跑的是哪个执行器(`claude` | `opencode` | `codex` | `codebuddy`)。
220
220
  *
221
221
  * 只在**认出是已知执行器**时才回值:job.agent 来自 jobs.json(会被手工编辑、也读过
222
222
  * 别实例写的旧记录),回一个前端不认识的字符串只会让卡片上的图标位空着或显示错品牌。
@@ -224,10 +224,10 @@ export function jobDurationMs(job) {
224
224
  * 猜错的执行器比不显示更糟。
225
225
  *
226
226
  * 这里**刻意不 import taskRunner 的 TASK_EXECUTORS**:taskRunner 拉起进程、依赖一堆
227
- * 运行时模块,而本模块被 projectRegistry(5s 轮询的看板)直接引用,没必要为了三个
227
+ * 运行时模块,而本模块被 projectRegistry(5s 轮询的看板)直接引用,没必要为了四个
228
228
  * 字符串把整条执行链拖进来。新增执行器时这里和 taskRunner 一起改。
229
229
  */
230
- const KNOWN_EXECUTORS = ['claude', 'opencode', 'codex'];
230
+ const KNOWN_EXECUTORS = ['claude', 'opencode', 'codex', 'codebuddy'];
231
231
 
232
232
  export function jobAgent(job) {
233
233
  const v = job && typeof job.agent === 'string' ? job.agent.trim().toLowerCase() : '';
@@ -181,6 +181,7 @@ test('jobAgent:认识的执行器原样返回', () => {
181
181
  assert.equal(jobAgent({ agent: 'claude' }), 'claude');
182
182
  assert.equal(jobAgent({ agent: 'opencode' }), 'opencode');
183
183
  assert.equal(jobAgent({ agent: 'codex' }), 'codex');
184
+ assert.equal(jobAgent({ agent: 'codebuddy' }), 'codebuddy');
184
185
  });
185
186
 
186
187
  test('jobAgent:大小写与首尾空白归一后再认(jobs.json 会被手工编辑)', () => {
@@ -23,18 +23,22 @@
23
23
  //
24
24
  // ⚠️ 两条不重复实现的红线(本仓库 agentParity.test.js 就是为这类分叉立的规矩):
25
25
  // 1. **spawn 与协议解析一律复用 taskRunner.js**,绝不在这里再写一份。
26
- // `createClaudeEventHandler` / `createOpencodeEventHandler` / `createCodexEventHandler`
26
+ // `createClaudeEventHandler` / `createOpencodeEventHandler` /
27
+ // `createCodexEventHandler` / `createCodebuddyEventHandler`
27
28
  // 的签名是 `(job, appendOutput, appendThinking, toolCalls)` —— 回调早已抽象好,
28
29
  // 这里只是换一组回调(推 SSE 而不是推工作台面板),所以适配层能很薄。
29
30
  // 2. **权限档不在这里决定**。taskRunner 的 launcher 已经带死了各自的档位
30
31
  // (claude: --permission-mode bypassPermissions --dangerously-skip-permissions;
31
- // opencode: --auto;codex: --dangerously-bypass-approvals-and-sandbox),
32
- // 与工作台任务执行保持一致。要改档位请改 launcher,别在这儿加参数。
32
+ // opencode: --auto;codex: --dangerously-bypass-approvals-and-sandbox;
33
+ // codebuddy: -y),与工作台任务执行保持一致。要改档位请改 launcher,别在这儿加参数。
33
34
  //
34
- // 关于 headless 权限(2026-09-28 实测,三家在非交互下的真实行为):
35
+ // 关于 headless 权限(2026-09-28 实测,三家在非交互下的真实行为;codebuddy 2026-10-12 补测):
35
36
  // claude 默认档也能跑,但不放开会挡住写操作;工作台那套是 bypassPermissions。
36
37
  // codex 默认档是只读沙箱(写文件被拦),所以要 --dangerously-bypass-...
37
38
  // opencode **不给 --auto 会卡死**(在等一个永远不会有人回答的交互式批准)。
39
+ // codebuddy 不给 -y 时写操作被拦(init 事件回传 permissionMode 可验证);
40
+ // 另外它每次运行自带引擎服务器,默认端口 62443 会被 WorkBuddy
41
+ // 桌面版占着,所以 launcher 固定传 --port 0 让 OS 选空闲端口。
38
42
  // 这正是 AgentView 里没有终端、没人能点"允许"所决定的 —— 必须预设档位。
39
43
 
40
44
  import { spawn, execFile } from 'child_process';
@@ -42,27 +46,19 @@ import { logger } from './shared.js';
42
46
  import { engineLabel } from './agentEngines.js';
43
47
  import {
44
48
  createToolCallTracker,
45
- createClaudeEventHandler,
46
- createOpencodeEventHandler,
47
- createCodexEventHandler,
48
- launchClaudeInNewWindow,
49
- launchOpencodeRun,
50
- launchCodexExec,
49
+ EXECUTOR_LAUNCHERS,
50
+ EXECUTOR_HANDLERS,
51
51
  } from './taskRunner.js';
52
52
 
53
- /** 引擎 → spawn 实现。复用 taskRunner,不另写(见文件头红线 1)。 */
54
- export const ENGINE_LAUNCHERS = {
55
- claude: launchClaudeInNewWindow,
56
- opencode: launchOpencodeRun,
57
- codex: launchCodexExec,
58
- };
53
+ /**
54
+ * 引擎 → spawn 实现。直接复用 taskRunner 的运行时分发表(EXECUTOR_LAUNCHERS)——
55
+ * 不在这里另抄一张表:那张表已经是"执行器 → launcher"的唯一真相源,抄一份就等于
56
+ * 给未来某次"加了第五家只改了一处"埋雷。别名导出只为保持本模块对外的旧名字。
57
+ */
58
+ export const ENGINE_LAUNCHERS = EXECUTOR_LAUNCHERS;
59
59
 
60
- /** 引擎 → NDJSON 事件处理器工厂。同样复用 taskRunner。 */
61
- export const ENGINE_HANDLERS = {
62
- claude: createClaudeEventHandler,
63
- opencode: createOpencodeEventHandler,
64
- codex: createCodexEventHandler,
65
- };
60
+ /** 引擎 → NDJSON 事件处理器工厂。同上,复用 taskRunner 的 EXECUTOR_HANDLERS。 */
61
+ export const ENGINE_HANDLERS = EXECUTOR_HANDLERS;
66
62
 
67
63
  /** 输出片段最长保留多少字符(与前端 MAX_LOG_DISPLAY 的口径一致,防止会话文件膨胀) */
68
64
  const MAX_ACCUM = 400_000;
@@ -90,7 +86,7 @@ function killChildTree(child) {
90
86
  *
91
87
  * @param {object} args
92
88
  * @param {object} args.session 会话记录(会被就地更新:messages / engineSessionId)
93
- * @param {string} args.engine 'claude' | 'opencode' | 'codex'
89
+ * @param {string} args.engine 'claude' | 'opencode' | 'codex' | 'codebuddy'
94
90
  * @param {string} args.userMessage 用户这一轮说的话
95
91
  * @param {string} args.cwd 工作目录
96
92
  * @param {string} [args.promptPrefix] 拼在用户消息**前面**的工作区状态块(见 agentRoutes)
@@ -275,8 +271,9 @@ export async function runExternalTurn({
275
271
  });
276
272
  }
277
273
 
278
- // 续聊标识:三家字段名不同(claude session_id / opencode sessionID / codex thread_id),
279
- // 但 taskRunner 的 handler 已经统一落到 job.claudeSessionId 上,这里只做搬运。
274
+ // 续聊标识:各家字段名不同(claude session_id / opencode sessionID / codex thread_id /
275
+ // codebuddy session_id),但 taskRunner 的 handler 已经统一落到 job.claudeSessionId 上,
276
+ // 这里只做搬运。
280
277
  if (job.claudeSessionId) session.engineSessionId = job.claudeSessionId;
281
278
 
282
279
  send({ type: 'done', content: aborted ? `${text}\n\n[已停止]` : text });
@@ -127,6 +127,40 @@ test('codex:exec --json → 同一组事件(item.* 分派)', async () => {
127
127
  assert.equal(session.engineSessionId, 'th-1');
128
128
  });
129
129
 
130
+ test('codebuddy:stream-json → 同一组事件(协议与 claude 对齐,特有事件被忽略)', async () => {
131
+ // 样例取自 2026-10-12 实测(CLI 2.164.0):init 带 session_id / apiKeySource,
132
+ // 工具调用是"tool_use(running) → user(tool_result)"两条,另有 system/status、
133
+ // ai-title 两个 cbc 特有事件必须被无视。
134
+ const lines = [
135
+ JSON.stringify({ type: 'system', subtype: 'init', session_id: 'cbc-sess-1', apiKeySource: 'www.workbuddy.cn', model: 'fast-model', permissionMode: 'bypassPermissions' }),
136
+ JSON.stringify({ type: 'system', subtype: 'status', status: 'compacting', session_id: 'cbc-sess-1' }),
137
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: '读一下' }] } }),
138
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { file_path: 'a.txt' } }] } }),
139
+ JSON.stringify({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu-1', content: [{ type: 'text', text: 'hello' }] }] } }),
140
+ JSON.stringify({ type: 'ai-title', title: '读文件' }),
141
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: '完了' }] } }),
142
+ JSON.stringify({ type: 'result', subtype: 'success', is_error: false, session_id: 'cbc-sess-1' }),
143
+ ];
144
+ const session = baseSession();
145
+ const c = collector();
146
+ await runExternalTurn({
147
+ session, engine: 'codebuddy', userMessage: '读 a.txt', cwd: 'D:/ws',
148
+ send: c.send,
149
+ launchers: { codebuddy: async () => ({ pid: 4242, child: fakeChild(lines) }) },
150
+ });
151
+
152
+ assert.equal(c.of('content').map(e => e.delta).join(''), '读一下完了');
153
+ const starts = c.of('tool_call_start');
154
+ assert.equal(starts.length, 1);
155
+ assert.equal(starts[0].toolCallId, 'tu-1');
156
+ assert.equal(starts[0].name, 'Read');
157
+ assert.match(String(c.of('tool_result')[0].result), /hello/);
158
+ assert.equal(c.of('error').length, 0);
159
+ assert.equal(c.of('done').length, 1);
160
+ // 续聊标识搬运:cbc 的 session_id → session.engineSessionId(--resume 用)
161
+ assert.equal(session.engineSessionId, 'cbc-sess-1');
162
+ });
163
+
130
164
  test('非 JSON 行(CLI 的告警)原样贴出来,不当成事件丢掉', async () => {
131
165
  const session = baseSession();
132
166
  const c = collector();