zen-gitsync 2.17.5 → 2.17.7

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/README.md +35 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +139 -48
  4. package/src/aiCommit.js +6 -3
  5. package/src/cli/ai/agent.js +106 -311
  6. package/src/cli/ai/cli.integration.test.js +99 -0
  7. package/src/cli/ai/context.js +90 -0
  8. package/src/cli/ai/modelSetup.js +44 -8
  9. package/src/cli/ai/runtime.test.js +152 -0
  10. package/src/cli/ai/safety.js +27 -4
  11. package/src/cli/ai/safety.test.js +13 -0
  12. package/src/cli/ai/telemetry.js +43 -0
  13. package/src/cli/ai/termui.js +154 -41
  14. package/src/cli/ai/termui.test.js +63 -14
  15. package/src/cli/ai/tools.js +27 -5
  16. package/src/cli/ai/tools.test.js +23 -0
  17. package/src/cli/ai/transport.js +89 -0
  18. package/src/cli/ai/turn.js +126 -0
  19. package/src/config.js +88 -5
  20. package/src/ui/public/assets/{AgentView-DJOdvJEV.js → AgentView-CZc191Xm.js} +3 -3
  21. package/src/ui/public/assets/AgentView-CzUeyQfR.css +1 -0
  22. package/src/ui/public/assets/{AppVersionBadge-D2Li5_R-.js → AppVersionBadge-Gs5lUl7P.js} +2 -2
  23. package/src/ui/public/assets/{BranchSelector-CSd5uaVm.js → BranchSelector-IjaFk0S-.js} +1 -1
  24. package/src/ui/public/assets/{CommitForm-Ckg9-zay.js → CommitForm-C-sowOXH.js} +3 -3
  25. package/src/ui/public/assets/{CommonDialog-B8XOXtz_.js → CommonDialog-QyptFD1R.js} +1 -1
  26. package/src/ui/public/assets/{EditorView-Crj8S2TI.js → EditorView-CvvluFCY.js} +1 -1
  27. package/src/ui/public/assets/{FileDiffViewer-DL07IMz5.js → FileDiffViewer-BXVsdCxE.js} +1 -1
  28. package/src/ui/public/assets/{FlowExecutionViewer-DvVT19Qv.js → FlowExecutionViewer-CRxipXru.js} +1 -1
  29. package/src/ui/public/assets/{FlowOrchestrationWorkspace-kRhK9IyL.js → FlowOrchestrationWorkspace-AE1ZHTKF.js} +1 -1
  30. package/src/ui/public/assets/{IconButton-pqSYKRT8.js → IconButton-BmSueF_H.js} +1 -1
  31. package/src/ui/public/assets/{LogList-BrN6qtFb.js → LogList-iaVA-_aC.js} +1 -1
  32. package/src/ui/public/assets/{MindmapView-CSsT0NDZ.js → MindmapView-DcG4jpCh.js} +1 -1
  33. package/src/ui/public/assets/{MonitorView-edRyBybQ.js → MonitorView-DO59syvl.js} +1 -1
  34. package/src/ui/public/assets/{ProjectStartupButton-DZ8VgU79.js → ProjectStartupButton-DYppceyw.js} +1 -1
  35. package/src/ui/public/assets/{RemoteManagerDialog-pu9szuYB.js → RemoteManagerDialog-CDbxG6pZ.js} +1 -1
  36. package/src/ui/public/assets/{RemoteRepoCard-BSFehgY5.js → RemoteRepoCard-Cko2KB8w.js} +1 -1
  37. package/src/ui/public/assets/{SourceMapView-DbEydLSU.js → SourceMapView-CwWPVdz-.js} +1 -1
  38. package/src/ui/public/assets/{SvgIcon-BVCbrPws.js → SvgIcon-Cf7nOKc8.js} +1 -1
  39. package/src/ui/public/assets/{TemplateManager-DPyYaGCE.js → TemplateManager-DWPDjWxM.js} +1 -1
  40. package/src/ui/public/assets/{UserInputNode-DXHeCjzG.js → UserInputNode-YOziN-5A.js} +1 -1
  41. package/src/ui/public/assets/WorkbenchView-DNnEpIYy.js +13 -0
  42. package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
  43. package/src/ui/public/assets/_plugin-vue_export-helper-CQjg8MXm.js +19 -0
  44. package/src/ui/public/assets/configStore-C7HOx5S1.js +1 -0
  45. package/src/ui/public/assets/{gitStore-BJbNtjxA.js → gitStore-DIsBd6n4.js} +1 -1
  46. package/src/ui/public/assets/index-FOe1cHkh.css +1 -0
  47. package/src/ui/public/assets/index-MmcnT2Dn.js +24 -0
  48. package/src/ui/public/assets/{previewSandbox-BZV6Eqhv.js → previewSandbox-TKucqLxt.js} +0 -0
  49. package/src/ui/public/assets/{toolsStore-Bu8Ujw_A.js → toolsStore-BiC2mmC3.js} +1 -1
  50. package/src/ui/public/assets/{vendor-CwZs6S_i.js → vendor-92fKgtnz.js} +7 -7
  51. package/src/ui/public/assets/{vendor-_6pgW9Z2.css → vendor-B1UrVTTI.css} +1 -1
  52. package/src/ui/public/index.html +14 -14
  53. package/src/ui/server/routes/codeAnalysis.js +6 -3
  54. package/src/ui/server/routes/config.js +40 -9
  55. package/src/ui/server/routes/fileOpen.js +48 -2
  56. package/src/ui/server/routes/gAiLauncher.test.js +70 -0
  57. package/src/ui/server/routes/workbench/agentChat.js +34 -8
  58. package/src/ui/server/routes/workbench/index.js +37 -6
  59. package/src/ui/server/routes/workbench/jobStore.js +4 -0
  60. package/src/ui/server/routes/workbench/llmClient.js +15 -8
  61. package/src/ui/server/routes/workbench/taskRunner.js +276 -96
  62. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
  63. package/src/utils/aiEndpoint.js +231 -0
  64. package/src/utils/aiEndpoint.test.js +175 -0
  65. package/src/ui/public/assets/AgentView-BsN9y4Kg.css +0 -1
  66. package/src/ui/public/assets/WorkbenchView-CKXtZAJn.css +0 -1
  67. package/src/ui/public/assets/WorkbenchView-DGcirla3.js +0 -13
  68. package/src/ui/public/assets/_plugin-vue_export-helper-C-YBnnP5.js +0 -19
  69. package/src/ui/public/assets/configStore-DoobmT7N.js +0 -1
  70. package/src/ui/public/assets/index-ClqvC4kJ.css +0 -1
  71. package/src/ui/public/assets/index-ndDaGoRO.js +0 -24
@@ -12,12 +12,18 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  //
15
- // 任务执行引擎:spawn claude CLI 跑子任务,流式收集输出,更新 job 状态。
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
+ //
18
22
  // 核心 API:
19
23
  // - launchClaudeInNewWindow(cwd, prompt, resumeSessionId) spawn claude,返回 {pid, child}
20
- // - runTaskQueue(task, repoPath, branch, opts) 顺序执行 task.subtasks
24
+ // - launchOpencodeRun(cwd, prompt, resumeSessionId) spawn opencode,返回 {pid, child}
25
+ // - normalizeTaskExecutor(value) 非法值回落 'claude'
26
+ // - runTaskQueue(task, repoPath, branch, opts) 顺序执行 task.subtasks(opts.executor 透传)
21
27
  // - runSingleSubtask(task, sub, repoPath, branch, priorOutputs, opts) 跑单个 sub
22
28
  // - syncSubToCancelled(job) cancel 路径专用
23
29
  // - persistTaskAfterRun(task) 把 sub.status 落回 tasks.json
@@ -85,8 +91,7 @@ async function resolveEnvContext(repoPath) {
85
91
  // 返回 { pid, child }:调用方可以监听 child.stdout/stderr 实时收集输出。
86
92
  // 不再走 cmd /k 弹窗——claude -p 是非交互模式,输出通过 stdout pipe 实时回传
87
93
  // 到前端面板展示。
88
- export function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) {
89
- return new Promise((resolve, reject) => {
94
+ export function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) { return new Promise((resolve, reject) => {
90
95
  const args = [];
91
96
  // 续接历史会话:--resume 必须放在 -p 之前,确保 claude CLI 先识别 resume 上下文
92
97
  if (resumeSessionId) {
@@ -154,28 +159,197 @@ export function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) {
154
159
  env: { ...process.env, LANG: 'zh_CN.UTF-8' }
155
160
  });
156
161
  }
157
- child.on('error', reject);
158
- child.on('spawn', () => {
159
- // unref 让 claude 独立于父进程事件循环;返回 child 引用让调用方继续读 stdout。
160
- child.unref();
161
- // 长 prompt 通过 stdin 喂入(避开 Windows CreateProcess 32K 命令行上限)。
162
- // 必须在 spawn 事件回调里 write,而不是 resolve 前同步 write——因为 spawn
163
- // 返回时 child.stdin 句柄可能尚未绑定到真正的 pipe fd。
164
- // write 完成后必须 end(),否则 claude CLI 会一直阻塞在读 stdin 上 → hang。
165
- try {
166
- child.stdin.write(promptText, () => {
167
- try { child.stdin.end(); } catch { /* 子进程已关闭,忽略 */ }
168
- });
169
- } catch (err) {
170
- // stdin 写入失败不要让 spawn 整体 reject —— 让 child 自然以错误状态收尾,
171
- // 后面的 stdout/stderr 监听会捕获到 LLM 端反馈。
172
- logger.warn('[workbench] stdin write failed:', err && err.message || err);
173
- }
174
- resolve({ pid: child.pid, child });
175
- });
162
+ attachPromptStdin(child, promptText, resolve, reject);
163
+ });
164
+ }
165
+
166
+ /**
167
+ * spawn 后的公共收尾:挂 error/spawn 监听,spawn 成功后把 prompt 从 stdin 喂进去。
168
+ *
169
+ * 长 prompt 必须走 stdin(避开 Windows CreateProcess 32K 命令行上限)。必须在
170
+ * spawn 事件回调里 write——spawn 返回时 child.stdin 句柄可能尚未绑定到真正的
171
+ * pipe fd;write 完成后必须 end(),否则 CLI 会一直阻塞在读 stdin 上 → hang。
172
+ */
173
+ function attachPromptStdin(child, promptText, resolve, reject) {
174
+ child.on('error', reject);
175
+ child.on('spawn', () => {
176
+ // unref 让 CLI 独立于父进程事件循环;返回 child 引用让调用方继续读 stdout。
177
+ child.unref();
178
+ try {
179
+ child.stdin.write(promptText, () => {
180
+ try { child.stdin.end(); } catch { /* 子进程已关闭,忽略 */ }
181
+ });
182
+ } catch (err) {
183
+ // stdin 写入失败不要让 spawn 整体 reject —— 让 child 自然以错误状态收尾,
184
+ // 后面的 stdout/stderr 监听会捕获到 LLM 端反馈。
185
+ logger.warn('[workbench] stdin write failed:', err && err.message || err);
186
+ }
187
+ resolve({ pid: child.pid, child });
188
+ });
189
+ }
190
+
191
+ // ── 执行器(executor) ──────────────────────────────────────────────────
192
+ // 工作台任务默认由 claude CLI 执行;opencode 作为可选执行器(2026-09-20)。
193
+ // 两者都用「stdin 喂 prompt + stdout 结构化 JSON 事件流」的同构形态,
194
+ // 差异只在协议:claude 是 stream-json,opencode 是 --format json 的事件 NDJSON。
195
+ export const TASK_EXECUTORS = ['claude', 'opencode'];
196
+
197
+ /** 非法/缺省的 executor 一律回落 'claude'(历史行为的兼容兜底)。
198
+ * body.executor 来自网络,做一次 trim + 小写归一,与 config.js 同名函数同语义。 */
199
+ export function normalizeTaskExecutor(value) {
200
+ if (typeof value !== 'string') return 'claude';
201
+ const v = value.trim().toLowerCase();
202
+ return TASK_EXECUTORS.includes(v) ? v : 'claude';
203
+ }
204
+
205
+ /**
206
+ * Windows 下把 opencode 解析成可直接 spawn 的 exe。
207
+ * npm 全局安装的 opencode 是 .cmd shim(Node 23+ 拒绝 spawn .cmd,EINVAL),
208
+ * shim 内容实际指向 <prefix>\node_modules\opencode-ai\bin\opencode.exe,
209
+ * 与 claude 的 cli.js 解析同一条套路,只是目标文件不同。
210
+ */
211
+ function resolveOpencodeExe() {
212
+ try {
213
+ const cmdShim = execFileSync('where', ['opencode'], { encoding: 'utf8', windowsHide: true })
214
+ .split(/\r?\n/).map(s => s.trim()).find(s => /\.cmd$/i.test(s));
215
+ if (cmdShim) {
216
+ const exe = path.join(path.dirname(cmdShim), 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
217
+ if (fs.existsSync(exe)) return exe;
218
+ }
219
+ } catch { /* fallback */ }
220
+ // 兜底交给 PATH:装在非 npm 全局目录(scoop/手动下载)时通常有 opencode.exe
221
+ return 'opencode.exe';
222
+ }
223
+
224
+ /**
225
+ * opencode 子进程环境。
226
+ * 关键点:删掉 PWD/OLDPWD。opencode(Bun 构建)在某些路径下会拿环境里的 PWD
227
+ * 当 cwd 用,而从 Git Bash 继承的 POSIX 风格 PWD(/tmp/xxx)会被它解析成
228
+ * 不存在的 C:\tmp\xxx,SystemPrompt 阶段直接抛
229
+ * "FileSystem.realPath ... ENOENT"。删掉让它老老实实走 process.cwd()。
230
+ */
231
+ function buildOpencodeEnv() {
232
+ const env = { ...process.env, LANG: 'zh_CN.UTF-8' };
233
+ delete env.PWD;
234
+ delete env.OLDPWD;
235
+ return env;
236
+ }
237
+
238
+ /**
239
+ * 用 opencode run 跑一个 prompt(非交互模式),返回 { pid, child }。
240
+ *
241
+ * opencode run --format json --auto [--thinking] [--session <id>]
242
+ *
243
+ * - --format json 结构化事件流(step_start/text/reasoning/tool_use/step_finish/error)
244
+ * - --auto 自动批准未被显式拒绝的权限(对应 claude 的 bypassPermissions 档位)
245
+ * - --thinking 输出 reasoning 事件(对应 claude 的 thinking 块)
246
+ * - --session <id> 续接历史会话(对应 claude 的 --resume)
247
+ * - 不传 -m:模型跟随 opencode 自身配置的默认值(用户在 opencode 里配了什么就用什么)
248
+ * - prompt 从 stdin 喂入:opencode run 无位置参数时读 stdin,顺带避开命令行长度上限
249
+ */
250
+ export function launchOpencodeRun(cwd, promptText, resumeSessionId) {
251
+ return new Promise((resolve, reject) => {
252
+ const args = ['run', '--format', 'json', '--auto', '--thinking'];
253
+ if (resumeSessionId) {
254
+ args.push('--session', String(resumeSessionId));
255
+ }
256
+ let child;
257
+ if (process.platform === 'win32') {
258
+ child = spawn(resolveOpencodeExe(), args, {
259
+ cwd,
260
+ stdio: ['pipe', 'pipe', 'pipe'],
261
+ windowsHide: false,
262
+ env: buildOpencodeEnv()
263
+ });
264
+ } else {
265
+ child = spawn('opencode', args, {
266
+ cwd,
267
+ detached: true,
268
+ stdio: ['pipe', 'pipe', 'pipe'],
269
+ env: buildOpencodeEnv()
270
+ });
271
+ }
272
+ attachPromptStdin(child, promptText, resolve, reject);
176
273
  });
177
274
  }
178
275
 
276
+ /**
277
+ * claude stream-json 事件处理器。
278
+ * system/init → session_id(--resume 续接用)
279
+ * assistant → message.content 里的 text / thinking 块
280
+ * 其余(tool_use / result / stream_event 等)忽略。
281
+ */
282
+ function createClaudeEventHandler(job, appendOutput, appendThinking) {
283
+ return (evt) => {
284
+ if (evt.type === 'system' && evt.subtype === 'init' && typeof evt.session_id === 'string') {
285
+ if (!job.claudeSessionId || job.claudeSessionId !== evt.session_id) {
286
+ job.claudeSessionId = evt.session_id;
287
+ publish('job:update', job);
288
+ }
289
+ return;
290
+ }
291
+ if (evt.type !== 'assistant') return;
292
+ const blocks = evt.message?.content;
293
+ if (!Array.isArray(blocks)) return;
294
+ for (const b of blocks) {
295
+ if (b.type === 'text' && typeof b.text === 'string') {
296
+ appendOutput(b.text);
297
+ } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
298
+ appendThinking(b.thinking);
299
+ }
300
+ }
301
+ };
302
+ }
303
+
304
+ /**
305
+ * opencode --format json 事件处理器。
306
+ * 实测事件形态(opencode 1.18.x):
307
+ * { type:'step_start' | 'step_finish', sessionID, part:{ type:'step-start'|'step-finish', tokens?, cost? } }
308
+ * { type:'text', sessionID, part:{ id, type:'text', text } }
309
+ * { type:'reasoning', sessionID, part:{ id, type:'reasoning', text } }
310
+ * { type:'tool_use', sessionID, part:{ type:'tool', tool, state:{ status, input, output } } }
311
+ * { type:'error', sessionID, error:{ name, data:{ message } } }
312
+ * 正文/思考按 part.id 记录已落地的累计文本:同一 part 再次出现时若文本是前次的
313
+ * 前缀延长(累积语义)只追加增量,否则按纯增量整段追加 —— 两种推送语义都兼容。
314
+ * (导出仅为回归测试可见:taskRunner.opencode.test.js)
315
+ */
316
+ export function createOpencodeEventHandler(job, appendOutput, appendThinking) {
317
+ const seenParts = new Map(); // part.id -> 已落地的累计文本
318
+ const appendPartText = (part, isThinking) => {
319
+ const text = typeof part?.text === 'string' ? part.text : '';
320
+ if (!text) return;
321
+ const id = typeof part?.id === 'string' ? part.id : '';
322
+ const prev = id ? seenParts.get(id) : undefined;
323
+ let delta = text;
324
+ if (prev !== undefined) {
325
+ delta = text.startsWith(prev) ? text.slice(prev.length) : text;
326
+ }
327
+ if (!delta) return;
328
+ if (id) seenParts.set(id, prev !== undefined ? prev + delta : text);
329
+ if (isThinking) appendThinking(delta);
330
+ else appendOutput(delta);
331
+ };
332
+ return (evt) => {
333
+ // 顶层 sessionID 是 opencode 的会话标识(--session <id> 续接)。
334
+ // 复用 claudeSessionId 字段:前端与续接路由都认它,换执行器只是取值来源不同。
335
+ if (typeof evt.sessionID === 'string' && evt.sessionID && job.claudeSessionId !== evt.sessionID) {
336
+ job.claudeSessionId = evt.sessionID;
337
+ publish('job:update', job);
338
+ }
339
+ if (evt.type === 'text' || evt.type === 'reasoning') {
340
+ appendPartText(evt.part, evt.type === 'reasoning');
341
+ return;
342
+ }
343
+ if (evt.type === 'error') {
344
+ const msg = evt.error?.data?.message
345
+ || (typeof evt.error?.message === 'string' ? evt.error.message : '')
346
+ || evt.error?.name
347
+ || 'opencode 执行出错';
348
+ if (!job.agentError) job.agentError = msg;
349
+ }
350
+ };
351
+ }
352
+
179
353
  /**
180
354
  * 顺序执行一个任务下所有子任务;上一个结束再启动下一个。
181
355
  * 入参 fromIndex 指定从哪个 sub 开始(0-based,默认 0),用于"从此处开始"入口;
@@ -204,7 +378,7 @@ export async function runTaskQueue(task, repoPath, branch, opts) {
204
378
  for (let i = fromIndex; i < task.subtasks.length; i++) {
205
379
  const sub = task.subtasks[i];
206
380
  if (sub.status === 'done') continue;
207
- const outcome = await runSingleSubtask(task, sub, repoPath, branch, priorOutputs);
381
+ const outcome = await runSingleSubtask(task, sub, repoPath, branch, priorOutputs, { executor: opts.executor });
208
382
  // 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
209
383
  // sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
210
384
  await persistTaskAfterRun(task);
@@ -228,14 +402,17 @@ export async function runTaskQueue(task, repoPath, branch, opts) {
228
402
  * 知道上下文。单独跑一个 sub 时,这个数组里只会有"前面 done 的 sub"。
229
403
  * @param {object} [options]
230
404
  * @param {string|null} [options.resumeSessionId]
231
- * 续接历史 claude 会话:传入上一轮通过 stream-json 的 system.init
232
- * 事件捕获到的 session_id,本轮以 --resume <id> 启动,claude 会带着
233
- * 上下文继续对话。前端"简单任务执行完成后继续聊"走的就是这条路径。
405
+ * 续接历史会话:claude 走 --resume <id>,opencode 走 --session <id>。
406
+ * id 来自上一轮 stream 事件捕获的会话标识(两者都记在 job.claudeSessionId)。
407
+ * @param {string} [options.executor]
408
+ * 执行器 'claude' | 'opencode'。缺省回落 'claude';路由层负责把
409
+ * "body 指定 > 配置默认"先解析好再传进来。
234
410
  * @returns {Promise<'done'|'cancelled'|'error'>} sub 的终态
235
411
  */
236
412
  export async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs, options) {
237
413
  const opts = options || {};
238
414
  const resumeSessionId = opts.resumeSessionId || null;
415
+ const executor = normalizeTaskExecutor(opts.executor);
239
416
  const promptTemplate = sub.promptOverride || (task.promptId
240
417
  ? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
241
418
  : null) || '';
@@ -303,7 +480,12 @@ ${prompt}`;
303
480
  title: `${task.title} / ${sub.title}`,
304
481
  status: 'pending',
305
482
  prompt,
306
- // 续接历史会话时先填上,首条 system.init 事件回来后再用真值覆盖(通常等同)
483
+ // 本轮用的执行器。前端日志详情按它显示助手名;简单任务续聊时
484
+ // 路由层用它保证"用同一个 CLI 续同一个会话"。
485
+ agent: executor,
486
+ // 续接历史会话时先填上,首条协议事件回来后再用真值覆盖(通常等同)。
487
+ // 字段名保留 claudeSessionId:claude 的 session_id 和 opencode 的 sessionID
488
+ // 都存这里,语义是"该执行器的会话续接标识"。
307
489
  claudeSessionId: resumeSessionId
308
490
  };
309
491
  jobs.set(jobId, job);
@@ -312,7 +494,8 @@ ${prompt}`;
312
494
  publish('job:update', job);
313
495
 
314
496
  try {
315
- const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt, resumeSessionId);
497
+ const launcher = executor === 'opencode' ? launchOpencodeRun : launchClaudeInNewWindow;
498
+ const { pid, child } = await launcher(repoPath || process.cwd(), prompt, resumeSessionId);
316
499
  job.pid = pid;
317
500
  // 保存 child 引用,供 cancel 接口调用 kill
318
501
  job.child = child;
@@ -320,64 +503,64 @@ ${prompt}`;
320
503
  job.status = 'running';
321
504
  publish('job:update', job);
322
505
 
323
- // 流式 NDJSON 解析:把 stdout 当作 stream-json 协议处理
324
- // assistant.text → job.output (用户主要关心的内容)
325
- // assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
326
- // 其他事件(init / tool_use / result 等)忽略,避免噪声
506
+ // 流式 NDJSON 解析:把 stdout 当作所选执行器的 JSON 事件流处理
507
+ // 正文/思考块 → job.output / job.thinking(前端主视图 + 折叠思考)
508
+ // 会话标识 → job.claudeSessionId(续接对话用)
509
+ // 其他事件(tool_use 等)忽略,避免噪声
327
510
  const MAX_OUTPUT = 100 * 1024 * 1024;
328
511
  const MAX_THINKING = 100 * 1024 * 1024;
329
512
  job.output = '';
330
513
  job.thinking = '';
331
514
  const lineBuf = { stdout: '', stderr: '' };
332
515
 
516
+ // 追加正文(尾部截断保底 + 增量推送给前端)
517
+ const appendOutput = (text) => {
518
+ if (!text) return;
519
+ const prevLen = job.output.length;
520
+ job.output = (job.output + text).slice(-MAX_OUTPUT);
521
+ const delta = job.output.slice(prevLen);
522
+ if (delta) publish('job:output-delta', { id: job.id, delta });
523
+ };
524
+
525
+ // 追加思考:先攒在本批缓冲里,一批 NDJSON 处理完统一发一次,避免高频小块 socket 占用
526
+ let thinkingBatch = '';
527
+ const flushThinkingBatch = () => {
528
+ if (thinkingBatch) {
529
+ publish('job:thinking-delta', { id: job.id, delta: thinkingBatch });
530
+ thinkingBatch = '';
531
+ }
532
+ };
533
+ const appendThinking = (text) => {
534
+ if (!text) return;
535
+ const prevLen = job.thinking.length;
536
+ job.thinking = (job.thinking + text).slice(-MAX_THINKING);
537
+ thinkingBatch += job.thinking.slice(prevLen);
538
+ };
539
+
540
+ const handleEvent = executor === 'opencode'
541
+ ? createOpencodeEventHandler(job, appendOutput, appendThinking)
542
+ : createClaudeEventHandler(job, appendOutput, appendThinking);
543
+
544
+ const handleLine = (line, channel) => {
545
+ const trimmed = line.trim();
546
+ if (!trimmed) return;
547
+ if (channel === 'stderr' || !trimmed.startsWith('{')) {
548
+ // 非 JSON 行:原样塞进 output(兼容老版本 claude / CLI 的错误信息)
549
+ appendOutput(trimmed + '\n');
550
+ return;
551
+ }
552
+ let evt;
553
+ try { evt = JSON.parse(trimmed); } catch { return; }
554
+ handleEvent(evt);
555
+ };
556
+
333
557
  const parseLines = (channel, buf) => {
334
558
  const chunk = buf.toString('utf8');
335
559
  lineBuf[channel] += chunk;
336
560
  const lines = lineBuf[channel].split('\n');
337
561
  lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
338
- let pendingThinkingDelta = '';
339
- for (const line of lines) {
340
- const trimmed = line.trim();
341
- if (!trimmed) continue;
342
- if (channel === 'stderr' || !trimmed.startsWith('{')) {
343
- // 非 stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
344
- const prevLen = job.output.length;
345
- job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
346
- const delta = job.output.slice(prevLen);
347
- if (delta) publish('job:output-delta', { id: job.id, delta });
348
- continue;
349
- }
350
- let evt;
351
- try { evt = JSON.parse(trimmed); } catch { continue; }
352
- // 捕获 system.init 里的 session_id,供后续"续接对话"用(--resume <id>)
353
- if (evt.type === 'system' && evt.subtype === 'init' && typeof evt.session_id === 'string') {
354
- if (!job.claudeSessionId || job.claudeSessionId !== evt.session_id) {
355
- job.claudeSessionId = evt.session_id;
356
- publish('job:update', job);
357
- }
358
- continue;
359
- }
360
- if (evt.type !== 'assistant') continue;
361
- const blocks = evt.message?.content;
362
- if (!Array.isArray(blocks)) continue;
363
- for (const b of blocks) {
364
- if (b.type === 'text' && typeof b.text === 'string') {
365
- const prevLen = job.output.length;
366
- job.output = (job.output + b.text).slice(-MAX_OUTPUT);
367
- const delta = job.output.slice(prevLen);
368
- if (delta) publish('job:output-delta', { id: job.id, delta });
369
- } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
370
- const prevLen = job.thinking.length;
371
- job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
372
- const delta = job.thinking.slice(prevLen);
373
- if (delta) pendingThinkingDelta += delta;
374
- }
375
- }
376
- }
377
- // 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
378
- if (pendingThinkingDelta) {
379
- publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
380
- }
562
+ for (const line of lines) handleLine(line, channel);
563
+ flushThinkingBatch();
381
564
  };
382
565
  if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
383
566
  if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
@@ -386,27 +569,14 @@ ${prompt}`;
386
569
  await waitProcessExit(pid);
387
570
  const wasCancelled = cancelledJobs.has(jobId);
388
571
  if (wasCancelled) cancelledJobs.delete(jobId);
389
- // 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
390
- if (lineBuf.stdout.trim()) {
391
- const outPrev = job.output.length;
392
- const thinkPrev = job.thinking.length;
393
- try {
394
- const evt = JSON.parse(lineBuf.stdout.trim());
395
- if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
396
- for (const b of evt.message.content) {
397
- if (b.type === 'text' && typeof b.text === 'string') {
398
- job.output = (job.output + b.text).slice(-MAX_OUTPUT);
399
- } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
400
- job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
401
- }
402
- }
403
- }
404
- } catch { /* 不是 JSON,忽略 */ }
405
- const outDelta = job.output.slice(outPrev);
406
- if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
407
- const thinkDelta = job.thinking.slice(thinkPrev);
408
- if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
572
+ // 进程退出时 stdout/stderr 可能残留最后一段未换行的 NDJSON,flush 一次
573
+ if (lineBuf.stdout.trim() || lineBuf.stderr.trim()) {
574
+ for (const line of lineBuf.stdout.split('\n')) handleLine(line, 'stdout');
575
+ for (const line of lineBuf.stderr.split('\n')) handleLine(line, 'stderr');
576
+ lineBuf.stdout = '';
577
+ lineBuf.stderr = '';
409
578
  }
579
+ flushThinkingBatch();
410
580
  job.endedAt = nowIso();
411
581
  if (wasCancelled) {
412
582
  job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
@@ -417,6 +587,16 @@ ${prompt}`;
417
587
  // 会用 status='running' 覆盖掉前端已渲染的 cancelled 状态,导致 UI 反复跳回 running。
418
588
  sub.status = 'cancelled';
419
589
  if (!sub.error) sub.error = '用户已停止执行';
590
+ } else if (job.agentError) {
591
+ // 执行器在协议层报了 error(如模型 5xx、配置缺失)。CLI 退出码可能是 0,
592
+ // 但不能标 done —— 那会把失败伪装成完成,后续 sub 还会拿空输出当下文继续跑。
593
+ job.exitCode = 1;
594
+ job.status = 'error';
595
+ job.error = job.agentError;
596
+ sub.status = 'error';
597
+ sub.error = job.agentError;
598
+ sub.errorAt = nowIso();
599
+ appendOutput(`\n> [${executor}] ${job.agentError}\n`);
420
600
  } else {
421
601
  job.exitCode = 0;
422
602
  job.status = 'done';
@@ -0,0 +1,126 @@
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
+ // opencode 执行器事件处理器的回归测试(2026-09-20)。
17
+ //
18
+ // 钉住 opencode `run --format json` 适配层的四条口径:
19
+ // 1. text / reasoning 事件的 part.text 分别落到 output / thinking
20
+ // 2. 顶层 sessionID 覆盖 job.claudeSessionId(续接 --session 的取值来源)
21
+ // 3. 同一 part.id 重复出现时按"前缀延长"只追加增量,不把同一段文字拼两遍
22
+ // 4. error 事件写入 job.agentError(进程退出码可能是 0,终态判定靠它)
23
+ //
24
+ // 隔离策略同 jobStore.test.js:import taskRunner.js 之前把 USERPROFILE/HOME
25
+ // 指向 mkdtemp 沙箱(taskRunner → jobStore import 时就会 hydrate)。
26
+
27
+ import { test } from 'node:test';
28
+ import assert from 'node:assert/strict';
29
+ import path from 'node:path';
30
+ import os from 'node:os';
31
+ import { mkdtempSync } from 'node:fs';
32
+ import { fileURLToPath, pathToFileURL } from 'node:url';
33
+
34
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
+ const projectRoot = path.resolve(__dirname, '../../../../..');
36
+
37
+ // ---- 环境隔离:必须在 import taskRunner.js 之前完成 ----
38
+ const sandbox = mkdtempSync(path.join(os.tmpdir(), 'zen-taskrunner-test-'));
39
+ process.env.USERPROFILE = sandbox;
40
+ process.env.HOME = sandbox;
41
+ delete process.env.HOMEDRIVE;
42
+ delete process.env.HOMEPATH;
43
+
44
+ const { createOpencodeEventHandler, normalizeTaskExecutor, TASK_EXECUTORS } = await import(
45
+ pathToFileURL(path.join(projectRoot, 'src/ui/server/routes/workbench/taskRunner.js')).href
46
+ );
47
+
48
+ /** 可断言的 appendOutput/appendThinking 收集器 */
49
+ function makeSink() {
50
+ const sink = { output: [], thinking: [] };
51
+ sink.appendOutput = (t) => { if (t) sink.output.push(t); };
52
+ sink.appendThinking = (t) => { if (t) sink.thinking.push(t); };
53
+ return sink;
54
+ }
55
+
56
+ test('normalizeTaskExecutor:白名单 + 大小写 + 非法回落', () => {
57
+ assert.deepEqual(TASK_EXECUTORS, ['claude', 'opencode']);
58
+ assert.equal(normalizeTaskExecutor('opencode'), 'opencode');
59
+ // 大小写不敏感(存储前统一小写)
60
+ assert.equal(normalizeTaskExecutor('OpenCode'), 'opencode');
61
+ assert.equal(normalizeTaskExecutor('opencode '), 'opencode');
62
+ assert.equal(normalizeTaskExecutor('codex'), 'claude');
63
+ assert.equal(normalizeTaskExecutor(undefined), 'claude');
64
+ assert.equal(normalizeTaskExecutor(null), 'claude');
65
+ });
66
+
67
+ test('opencode 事件:text/reasoning 分别落 output/thinking,sessionID 被捕获', () => {
68
+ const job = {};
69
+ const sink = makeSink();
70
+ const handle = createOpencodeEventHandler(job, sink.appendOutput, sink.appendThinking);
71
+
72
+ handle({ type: 'step_start', sessionID: 'ses_a', part: { type: 'step-start' } });
73
+ handle({ type: 'reasoning', sessionID: 'ses_a', part: { id: 'p1', type: 'reasoning', text: '想一下' } });
74
+ handle({ type: 'text', sessionID: 'ses_a', part: { id: 'p2', type: 'text', text: '好的' } });
75
+ handle({ type: 'step_finish', sessionID: 'ses_a', part: { type: 'step-finish', tokens: {} } });
76
+
77
+ assert.deepEqual(sink.output, ['好的']);
78
+ assert.deepEqual(sink.thinking, ['想一下']);
79
+ assert.equal(job.claudeSessionId, 'ses_a');
80
+ assert.equal(job.agentError, undefined);
81
+ });
82
+
83
+ test('opencode 事件:同一 part 累积推送只追加增量', () => {
84
+ const job = {};
85
+ const sink = makeSink();
86
+ const handle = createOpencodeEventHandler(job, sink.appendOutput, sink.appendThinking);
87
+
88
+ // 累积语义:第二次事件的 text 是第一次的前缀延长
89
+ handle({ type: 'text', sessionID: 'ses_b', part: { id: 'p1', text: 'Hello' } });
90
+ handle({ type: 'text', sessionID: 'ses_b', part: { id: 'p1', text: 'Hello, world' } });
91
+ // 重复事件(完全相同):不产生任何输出
92
+ handle({ type: 'text', sessionID: 'ses_b', part: { id: 'p1', text: 'Hello, world' } });
93
+
94
+ assert.deepEqual(sink.output, ['Hello', ', world']);
95
+
96
+ // 纯增量语义(text 不是前次的前缀延长):整段追加
97
+ handle({ type: 'text', sessionID: 'ses_b', part: { id: 'p1', text: '!' } });
98
+ assert.deepEqual(sink.output, ['Hello', ', world', '!']);
99
+ });
100
+
101
+ test('opencode 事件:error 事件写入 agentError,只记第一条', () => {
102
+ const job = {};
103
+ const sink = makeSink();
104
+ const handle = createOpencodeEventHandler(job, sink.appendOutput, sink.appendThinking);
105
+
106
+ handle({ type: 'error', sessionID: 'ses_c', error: { name: 'UnknownError', data: { message: 'Unexpected server error' } } });
107
+ handle({ type: 'error', sessionID: 'ses_c', error: { name: 'UnknownError', data: { message: 'second one' } } });
108
+
109
+ assert.equal(job.agentError, 'Unexpected server error');
110
+ // error 前若有正文,正文保留(前端能看到已产出的部分)
111
+ assert.deepEqual(sink.output, []);
112
+ });
113
+
114
+ test('opencode 事件:error 结构缺 data.message 时逐级回落', () => {
115
+ const job = {};
116
+ const sink = makeSink();
117
+ const handle = createOpencodeEventHandler(job, sink.appendOutput, sink.appendThinking);
118
+
119
+ handle({ type: 'error', sessionID: 'ses_d', error: { name: 'ProviderError' } });
120
+ assert.equal(job.agentError, 'ProviderError');
121
+
122
+ const job2 = {};
123
+ const handle2 = createOpencodeEventHandler(job2, sink.appendOutput, sink.appendThinking);
124
+ handle2({ type: 'error', sessionID: 'ses_e', error: {} });
125
+ assert.equal(job2.agentError, 'opencode 执行出错');
126
+ });