zen-gitsync 2.18.8 → 2.18.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +7 -0
  4. package/src/cli/ai/runtime.test.js +22 -1
  5. package/src/cli/ai/tools.js +153 -2
  6. package/src/cli/ai/tools.test.js +71 -2
  7. package/src/cli/ai/transport.js +8 -3
  8. package/src/config.js +7 -5
  9. package/src/mcp/tools/workbench.js +1 -1
  10. package/src/ui/public/assets/{AgentView-DZx3n-So.css → AgentView-BnEgu3NT.css} +1 -1
  11. package/src/ui/public/assets/AgentView-DQQe_ivW.js +1 -0
  12. package/src/ui/public/assets/{AppVersionBadge-Bg5lrtKb.js → AppVersionBadge-D7PiBP0p.js} +2 -2
  13. package/src/ui/public/assets/{BranchSelector-CxjTqAkl.js → BranchSelector-BzHoMBOc.js} +1 -1
  14. package/src/ui/public/assets/{CommitForm-BWWrOvj5.js → CommitForm-CNTjLYTR.js} +2 -2
  15. package/src/ui/public/assets/{CommonDialog-x5jfL4Lq.js → CommonDialog-j_53ORvj.js} +1 -1
  16. package/src/ui/public/assets/CopySessionButton-Dy5t8Mjy.js +20 -0
  17. package/src/ui/public/assets/EditorView-CwcdA0Ei.js +1 -0
  18. package/src/ui/public/assets/{EditorView-BUiVsBv7.css → EditorView-Ims2BUK_.css} +1 -1
  19. package/src/ui/public/assets/{FlowExecutionViewer-BkZ20jxN.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
  20. package/src/ui/public/assets/{FlowOrchestrationWorkspace-aZBYu29g.js → FlowOrchestrationWorkspace-CJHpEBCw.js} +1 -1
  21. package/src/ui/public/assets/{LogList-CSIbmUMK.js → LogList-KUePBjAt.js} +4 -4
  22. package/src/ui/public/assets/{MindmapView-JYLp185X.js → MindmapView-CwdvsuEK.js} +1 -1
  23. package/src/ui/public/assets/{MonitorView-BS2Mh-I7.js → MonitorView-CjEnnrTL.js} +1 -1
  24. package/src/ui/public/assets/{ProjectStartupButton-DAEP6ZU5.js → ProjectStartupButton-B36lRb6-.js} +1 -1
  25. package/src/ui/public/assets/RecentDirectoriesChat-7fzSkSmc.css +1 -0
  26. package/src/ui/public/assets/RecentDirectoriesChat-DNiji9Pr.js +1 -0
  27. package/src/ui/public/assets/{RemoteManagerDialog-aaNmsKpy.js → RemoteManagerDialog-Dj_GpA-c.js} +1 -1
  28. package/src/ui/public/assets/{RemoteRepoCard-DndI8rNZ.js → RemoteRepoCard-C8u9M6dn.js} +1 -1
  29. package/src/ui/public/assets/{SourceMapView-BTD9-R8t.js → SourceMapView-eng0B9U2.js} +1 -1
  30. package/src/ui/public/assets/{SvgIcon-0n6uuKFm.js → SvgIcon-5AlG3IfR.js} +1 -1
  31. package/src/ui/public/assets/{UserInputNode-B-mDg9Wy.js → UserInputNode-BKP869D6.js} +1 -1
  32. package/src/ui/public/assets/WorkbenchView-CZO18THH.js +6 -0
  33. package/src/ui/public/assets/WorkbenchView-DogERhHQ.css +1 -0
  34. package/src/ui/public/assets/{_plugin-vue_export-helper-C3o7kvRL.js → _plugin-vue_export-helper-Csn085FF.js} +4 -4
  35. package/src/ui/public/assets/agentConversations-De3wKTsN.js +10 -0
  36. package/src/ui/public/assets/configStore-DOaPZ6Mq.js +1 -0
  37. package/src/ui/public/assets/index-DQ82h1aq.js +57 -0
  38. package/src/ui/public/assets/index-NYzaTfbS.css +1 -0
  39. package/src/ui/public/assets/{useOrchestrator-CS9sn7nV.js → useOrchestrator-BjC7oHpt.js} +1 -1
  40. package/src/ui/public/index.html +6 -6
  41. package/src/ui/server/index.js +8 -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/git/clone.js +5 -13
  45. package/src/ui/server/routes/workbench/agentChat.js +75 -10
  46. package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
  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 +99 -7
  52. package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
  53. package/src/ui/server/routes/workbench/executorModels.js +30 -5
  54. package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
  55. package/src/ui/server/routes/workbench/index.js +17 -5
  56. package/src/ui/server/routes/workbench/jobActivity.js +3 -3
  57. package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
  58. package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
  59. package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
  60. package/src/ui/server/routes/workbench/scheduler.js +3 -0
  61. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
  62. package/src/ui/server/routes/workbench/taskRunner.js +311 -24
  63. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
  64. package/src/utils/gitRepoName.js +33 -0
  65. package/src/ui/public/assets/AgentView-BRAUZCtA.js +0 -1
  66. package/src/ui/public/assets/CopySessionButton-D0wFpk8a.js +0 -20
  67. package/src/ui/public/assets/EditorView-DmaGiI-z.js +0 -1
  68. package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
  69. package/src/ui/public/assets/RecentDirectoriesChat-CVFTShrU.js +0 -1
  70. package/src/ui/public/assets/WorkbenchView-CfSrCIbp.js +0 -8
  71. package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
  72. package/src/ui/public/assets/agentConversations-DpiGplZB.js +0 -8
  73. package/src/ui/public/assets/configStore-BdL3uW2E.js +0 -1
  74. package/src/ui/public/assets/index-BS7BDFG4.js +0 -57
  75. package/src/ui/public/assets/index-DwJWiCiN.css +0 -1
@@ -17,6 +17,7 @@
17
17
  // 路由清单:
18
18
  // GET /api/agent/sessions — 列出会话(仅 metadata;?cwd= 按项目过滤,空 cwd 视为当前项目)
19
19
  // GET /api/agent/sessions/:id — 获取会话详情(含完整消息)
20
+ // GET /api/agent/sessions/:id/image — 正文内嵌图片(按会话 cwd 解析,判定见 jobImage.js)
20
21
  // DELETE /api/agent/sessions/:id — 删除会话
21
22
  // PUT /api/agent/sessions/:id — 重命名会话
22
23
  // POST /api/agent/chat — SSE 流式聊天(含工具调用;body.cwd 必须与服务端当前项目一致)
@@ -40,7 +41,11 @@ import { isSessionBusy, acquireSession, releaseSession } from './sessionGate.js'
40
41
  import { createScheduler } from './scheduler.js';
41
42
  import { registerScheduleRoutes } from './scheduleRoutes.js';
42
43
  import { normalizeItems, filterToRecentDirs, buildDirStatusBlock } from '../recentDirectoriesAiSummary.js';
43
- import { nowIso, logger } from './shared.js';
44
+ import { nowIso, logger, fs, fsp, MAX_IMAGE_BYTES } from './shared.js';
45
+ // 正文内嵌图片的路径判定纯函数 —— 与任务对话的图片端点(workbench/index.js 的
46
+ // /api/workbench/jobs/:id/image)同一份:后缀白名单、root 内、防穿越。两份白名单
47
+ // 分叉时不会报错,只会让某一类图在任务对话里能看、在 Agent 对话里裂。
48
+ import { resolveJobImagePath, IMAGE_PATH_STATUS, IMAGE_PATH_ERRORS, isInsideRoot } from './jobImage.js';
44
49
  // 请求预算的解析公式与 CLI `g ai` 共用同一份(cli/ai/context.js),Web 侧只管把
45
50
  // 配置值喂进去、把结果传给 agentChat —— 2026-10-07 起每轮请求的字符预算可配。
46
51
  import { resolveRequestBudget } from '../../../../cli/ai/context.js';
@@ -196,6 +201,21 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
196
201
  // 不给它加缓存:agent 一轮里最多问一两次,而"项目状态"本来就要现读才准。
197
202
  const listProjects = createProjectListProvider({ configManager, getCurrentProjectPath });
198
203
 
204
+ // 把目录登记进「常用目录」(config.json 的 recentDirectories)。register_project 工具
205
+ // 与 run_command 里"建了仓库就顺手登记"那一步共用它 —— 智能体建/克隆完新项目,
206
+ // 用户下次在「切换工作目录」弹窗里就能直接看到,不必重启 GUI 或自己翻路径。
207
+ //
208
+ // 失败只 warn 不外抛:登记是锦上添花,不能让一条命令/一次工具调用因为写配置失败而报错。
209
+ const registerProject = (configManager && typeof configManager.saveRecentDirectory === 'function')
210
+ ? async (dir) => {
211
+ try {
212
+ await configManager.saveRecentDirectory(dir);
213
+ } catch (err) {
214
+ logger.warn(`[agent] 登记常用目录失败 ${dir}: ${err?.message || err}`);
215
+ }
216
+ }
217
+ : undefined;
218
+
199
219
  // dispatch_task 的实现 —— 与 POST /api/workbench/orchestrator/dispatch 共用**同一份**
200
220
  // 派发实现(见 dispatchInstruction.js 的文件头):落点怎么判、预设提示词附加哪一级、
201
221
  // 指令流水记什么、执行器怎么回落,只有一套。这里只是把同一套依赖再绑一次
@@ -226,6 +246,7 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
226
246
  configManager,
227
247
  getCurrentProjectPath,
228
248
  listProjects,
249
+ registerProject,
229
250
  dispatchTask: scheduleDispatchTask,
230
251
  getContextBlock,
231
252
  });
@@ -294,6 +315,75 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
294
315
  res.json({ success: true, session });
295
316
  }));
296
317
 
318
+ // GET /api/agent/sessions/:id/image?path=… —— 正文内嵌图片(模型写 ![说明](本机路径))
319
+ //
320
+ // 与任务对话那一个端点(workbench/index.js 的 /api/workbench/jobs/:id/image)同一目的:
321
+ // 智能体在本机干活,"展示一张图"最自然的写法是贴一条本机路径,而对话流是 markdown
322
+ // 渲染 —— `<img src="c:\ws\repo\docs\a.png">` 在浏览器里永远是裂图。前端把这类路径
323
+ // 重写成这个 URL(utils/localImageSrc.ts 的 resolveAgentImages,四个 g ai 对话入口共用)。
324
+ //
325
+ // 权限边界 = 「这个会话的 cwd 内的图片文件」:
326
+ // · 判定复用 jobImage.js 的纯函数(后缀白名单 + root 内 + 防穿越);
327
+ // · 这里**再按 realpath 校验一次** —— lexical 判定挡不住会话目录里一个指向外部的
328
+ // 符号链接,而会话内容很可能是从网上 clone 来的;
329
+ // · 响应带 nosniff + CSP(sandbox):一份恶意 SVG 被直接在浏览器里打开时,它的脚本
330
+ // 不能跑在本应用的源上(<img> 里本来就不会执行,这里防的是直接访问)。
331
+ app.get('/api/agent/sessions/:sessionId/image', asyncRoute(async (req, res) => {
332
+ const session = await readSession(req.params.sessionId);
333
+ // 根 = 会话自己的 cwd(模型眼里"相对路径"的基准,就是它启动 g ai 的那个目录)。
334
+ // 历史存量会话没有 cwd 时退回当前项目路径 —— 与列表可见性的老会话包容口径一致
335
+ // (上面 sessionBelongsTo:没有 cwd 的老会话暂按当前项目可见)。
336
+ const root = String(session.cwd || '').trim()
337
+ || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
338
+
339
+ const resolved = resolveJobImagePath({ root, requested: req.query.path });
340
+ if (!resolved.ok) {
341
+ throw new HttpError(
342
+ IMAGE_PATH_STATUS[resolved.reason] || 400,
343
+ IMAGE_PATH_ERRORS[resolved.reason] || '图片路径不合法',
344
+ );
345
+ }
346
+
347
+ let realFile;
348
+ try {
349
+ realFile = await fsp.realpath(resolved.absPath);
350
+ } catch {
351
+ throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
352
+ }
353
+ let realRoot;
354
+ try {
355
+ realRoot = await fsp.realpath(root);
356
+ } catch {
357
+ realRoot = root; // 根目录都没了的话,下面那次 stat 也会失败,不必在这里另报一种错
358
+ }
359
+ if (!isInsideRoot(realRoot, realFile)) {
360
+ throw new HttpError(IMAGE_PATH_STATUS['outside-root'], IMAGE_PATH_ERRORS['outside-root']);
361
+ }
362
+
363
+ let stat;
364
+ try {
365
+ stat = await fsp.stat(realFile);
366
+ } catch {
367
+ throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
368
+ }
369
+ if (!stat.isFile()) throw new HttpError(404, '路径不是一个文件');
370
+ // 与附件同一个上限:正文里嵌一张 20MB 以上的图只会把页面拖死
371
+ if (stat.size > MAX_IMAGE_BYTES) {
372
+ throw new HttpError(413, `图片超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB,未加载`);
373
+ }
374
+
375
+ // mime 按**后缀**给(不读文件头):后缀已过白名单,且这条内容只喂给 <img>
376
+ res.set('Content-Type', resolved.mime);
377
+ res.set('Content-Length', String(stat.size));
378
+ res.set('Content-Disposition', 'inline');
379
+ res.set('Cache-Control', 'private, max-age=300');
380
+ res.set('X-Content-Type-Options', 'nosniff');
381
+ res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
382
+ const stream = fs.createReadStream(realFile);
383
+ stream.on('error', () => res.end());
384
+ stream.pipe(res);
385
+ }));
386
+
297
387
  // ════════════════════════════════════════════════════════════════════════
298
388
  // §3. 删除会话
299
389
  // ════════════════════════════════════════════════════════════════════════
@@ -441,10 +531,11 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
441
531
  return res.end();
442
532
  }
443
533
  // 引擎在会话**建立时就锁死**,中途不许换。
444
- // 为什么:三家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
445
- // codex exec resume),而且历史消息格式不同(我们存的是 OpenAI chat completions
446
- // 形状,喂给 claude 没有意义)。允许中途切换的表现是"看起来切了,上下文却串了"——
447
- // 与工作台 taskExecutor.ts 注释里那条"续哪个执行器由上一轮 job.agent 决定"同源。
534
+ // 为什么:各家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
535
+ // codex exec resume / codebuddy --resume 的 id 也互不认),而且历史消息格式不同
536
+ // (我们存的是 OpenAI chat completions 形状,喂给 claude 没有意义)。允许中途
537
+ // 切换的表现是"看起来切了,上下文却串了"—— 与工作台 taskExecutor.ts 注释里那条
538
+ // "续哪个执行器由上一轮 job.agent 决定"同源。
448
539
  const requestedEngine = req.body?.engine === undefined
449
540
  ? null
450
541
  : normalizeAgentEngine(req.body?.engine);
@@ -467,10 +558,10 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
467
558
  source: 'web',
468
559
  cwd: currentProjectCwd,
469
560
  model: '',
470
- // 引擎:gai(内置,默认)| claude | opencode | codex
561
+ // 引擎:gai(内置,默认)| claude | opencode | codex | codebuddy
471
562
  engine: normalizeAgentEngine(req.body?.engine),
472
563
  // 外部 CLI 自己的续聊标识(claude session_id / opencode sessionID /
473
- // codex thread_id)。g ai 用不到,留着空串。
564
+ // codex thread_id / codebuddy session_id)。g ai 用不到,留着空串。
474
565
  engineSessionId: '',
475
566
  createdAt: nowIso(),
476
567
  updatedAt: nowIso(),
@@ -668,6 +759,7 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
668
759
  }),
669
760
  dispatchTask,
670
761
  listProjects,
762
+ registerProject,
671
763
  getContextBlock,
672
764
  // 每轮请求的上下文预算(解析结果)。agentChat 拿它调 prepareRequestMessages,
673
765
  // 与 CLI turn.js 同一条口径。
@@ -1,6 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
4
+ import { PassThrough } from 'node:stream';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
6
7
 
@@ -476,3 +477,142 @@ test('dispatch_task:开了 allowDispatch 才注入,且工具结果说清落
476
477
  globalThis.fetch = originalFetch;
477
478
  }
478
479
  });
480
+
481
+ // ── 正文内嵌图片端点(2026-10-12) ────────────────────────────────────────
482
+ //
483
+ // 模型"展示一张图"的写法是在正文里贴一条本机路径,而对话流是 markdown 渲染 ——
484
+ // 浏览器里那条地址永远是裂图。前端把路径重写成
485
+ // GET /api/agent/sessions/:id/image?path=...(utils/localImageSrc.ts 的
486
+ // resolveAgentImages),这里守服务端那一半:
487
+ // · 根 = 会话自己的 cwd;没有 cwd 的老会话退回当前项目路径
488
+ // · 后缀白名单 / root 内 / 防穿越(jobImage.js 的纯函数,与任务对话端点同一份)
489
+ // · realpath 双校验、单文件上限、nosniff + CSP 响应头
490
+ const { agentSessionStore } = await import('./agentSessionStore.js');
491
+
492
+ /**
493
+ * 最小 res:PassThrough 是真流(stream.pipe 直接可用),外加路由用到的
494
+ * set / status / json。字节经 'data' 收齐,收尾等 'finish'。
495
+ */
496
+ function makeImageRes() {
497
+ const chunks = [];
498
+ const headers = {};
499
+ const res = new PassThrough();
500
+ res.headers = headers;
501
+ res.statusCode = 200;
502
+ res.jsonBody = undefined;
503
+ res.set = (k, v) => { headers[String(k).toLowerCase()] = v; };
504
+ res.status = (code) => { res.statusCode = code; return res; };
505
+ res.json = (v) => { res.jsonBody = v; res.end(); };
506
+ res.on('data', (c) => chunks.push(Buffer.from(c)));
507
+ res.bodyText = () => Buffer.concat(chunks).toString('utf-8');
508
+ return res;
509
+ }
510
+
511
+ const IMAGE_FINISH = (res) => new Promise((resolve) => res.on('finish', resolve));
512
+
513
+ /** 注册路由并取出图片端点的 handler(fake app 只认这一条) */
514
+ function captureImageHandler(projectPath) {
515
+ let handler;
516
+ const app = {
517
+ get(route, h) { if (route === '/api/agent/sessions/:sessionId/image') handler = h; },
518
+ post() {}, put() {}, delete() {}
519
+ };
520
+ registerAgentRoutes({
521
+ app,
522
+ getCurrentProjectPath: () => projectPath,
523
+ configManager: null,
524
+ snapshotter: STUB_SNAPSHOTTER
525
+ });
526
+ return handler;
527
+ }
528
+
529
+ const PNG_BYTES = Buffer.from('not-a-real-png-but-ext-is-whitelisted');
530
+
531
+ test('会话图片端点:按会话 cwd 读盘,响应带 nosniff + CSP', async () => {
532
+ const repo = mkdtempSync(path.join(SANDBOX, 'img-repo-'));
533
+ mkdirSync(path.join(repo, 'docs'), { recursive: true });
534
+ writeFileSync(path.join(repo, 'docs', 'a.png'), PNG_BYTES);
535
+
536
+ const sessionId = 'ag-imgtest-0001';
537
+ await agentSessionStore.write(sessionId, { sessionId, cwd: repo, messages: [] });
538
+
539
+ const handler = captureImageHandler(path.resolve('active-project'));
540
+ const res = makeImageRes();
541
+ const finished = IMAGE_FINISH(res);
542
+ await handler({ params: { sessionId }, query: { path: 'docs/a.png' } }, res);
543
+ await finished;
544
+
545
+ assert.equal(res.statusCode, 200);
546
+ assert.equal(res.headers['content-type'], 'image/png');
547
+ assert.equal(res.headers['x-content-type-options'], 'nosniff');
548
+ assert.match(res.headers['content-security-policy'], /sandbox/);
549
+ assert.equal(res.bodyText(), PNG_BYTES.toString('utf-8'));
550
+ });
551
+
552
+ test('会话图片端点:绝对路径也认,但必须落在会话 cwd 内', async () => {
553
+ const repo = mkdtempSync(path.join(SANDBOX, 'img-repo2-'));
554
+ writeFileSync(path.join(repo, 'a.png'), PNG_BYTES);
555
+ const outside = path.join(SANDBOX, 'outside-a.png');
556
+ writeFileSync(outside, PNG_BYTES);
557
+
558
+ const sessionId = 'ag-imgtest-0002';
559
+ await agentSessionStore.write(sessionId, { sessionId, cwd: repo, messages: [] });
560
+ const handler = captureImageHandler(path.resolve('active-project'));
561
+
562
+ // cwd 内的绝对路径 → 200
563
+ const ok = makeImageRes();
564
+ const okFinished = IMAGE_FINISH(ok);
565
+ await handler({ params: { sessionId }, query: { path: path.join(repo, 'a.png') } }, ok);
566
+ await okFinished;
567
+ assert.equal(ok.statusCode, 200);
568
+ assert.equal(ok.bodyText(), PNG_BYTES.toString('utf-8'));
569
+
570
+ // cwd 外的绝对路径 → 403(`..` 穿越与盘符外路径都走这条)
571
+ const bad = makeImageRes();
572
+ const badFinished = IMAGE_FINISH(bad);
573
+ await handler({ params: { sessionId }, query: { path: outside } }, bad);
574
+ await badFinished;
575
+ assert.equal(bad.statusCode, 403);
576
+ assert.match(bad.jsonBody.error, /仓库内/);
577
+ });
578
+
579
+ test('会话图片端点:坏后缀 415、文件不存在 404、会话不存在 404', async () => {
580
+ const repo = mkdtempSync(path.join(SANDBOX, 'img-repo3-'));
581
+ writeFileSync(path.join(repo, 'notes.txt'), 'hello');
582
+ const sessionId = 'ag-imgtest-0003';
583
+ await agentSessionStore.write(sessionId, { sessionId, cwd: repo, messages: [] });
584
+ const handler = captureImageHandler(path.resolve('active-project'));
585
+
586
+ const call = async (params, query) => {
587
+ const res = makeImageRes();
588
+ const finished = IMAGE_FINISH(res);
589
+ await handler({ params, query }, res);
590
+ await finished;
591
+ return res;
592
+ };
593
+
594
+ const badExt = await call({ sessionId }, { path: 'notes.txt' });
595
+ assert.equal(badExt.statusCode, 415);
596
+
597
+ const missing = await call({ sessionId }, { path: 'docs/a.png' });
598
+ assert.equal(missing.statusCode, 404);
599
+
600
+ const noSession = await call({ sessionId: 'ag-imgtest-9999' }, { path: 'a.png' });
601
+ assert.equal(noSession.statusCode, 404);
602
+ });
603
+
604
+ test('没有 cwd 的老会话:根退回当前项目路径(与列表可见性的包容口径一致)', async () => {
605
+ const proj = mkdtempSync(path.join(SANDBOX, 'img-proj-'));
606
+ writeFileSync(path.join(proj, 'b.png'), PNG_BYTES);
607
+ const sessionId = 'ag-imgtest-0004';
608
+ await agentSessionStore.write(sessionId, { sessionId, cwd: '', messages: [] });
609
+
610
+ const handler = captureImageHandler(proj);
611
+ const res = makeImageRes();
612
+ const finished = IMAGE_FINISH(res);
613
+ await handler({ params: { sessionId }, query: { path: 'b.png' } }, res);
614
+ await finished;
615
+
616
+ assert.equal(res.statusCode, 200);
617
+ assert.equal(res.headers['content-type'], 'image/png');
618
+ });
@@ -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 () => {
@@ -548,10 +548,20 @@ export function registerWorkbenchRoutes({
548
548
  app.post('/api/workbench/jobs/:id/continue', asyncRoute(async (req, res) => {
549
549
  const userMessage = String(req.body?.userMessage || '').trim();
550
550
  if (!userMessage) throw new HttpError(400, 'userMessage 不能为空');
551
- const prevJob = jobs.get(req.params.id);
551
+ // 内存里没有就退回「磁盘历史 ∪ 别的实例正在跑」——续接要用到的三样东西
552
+ // (claudeSessionId / agent / taskId)全在持久化记录里,**不依赖本进程的 child 句柄**
553
+ // (那是 §14 取消路径非有不可的东西,所以那条 404 是对的,这条不是)。
554
+ // 只认内存的话:在 A 窗口跑完、到 B 窗口接着聊,B 的看板明明列着这条记录,
555
+ // 一发送却弹 "job 不存在" —— 2026-10-12 用户实测就是这个(本机同时开着 4 个实例)。
556
+ // 与 GET /jobs/:id、/jobs/:id/image 同一个口径。
557
+ await refreshJobsFromDisk();
558
+ const prevJob = jobs.get(req.params.id) || mergedJobs().get(req.params.id);
552
559
  if (!prevJob) throw new HttpError(404, 'job 不存在');
553
560
  if (prevJob.status === 'running' || prevJob.status === 'pending') {
554
- throw new HttpError(400, '上一轮还在执行,请等结束后再续接');
561
+ // 这一轮在**别的**实例里跑着:本进程停不了也接不上,别让用户以为是"上一轮"没结束
562
+ throw new HttpError(400, jobs.has(prevJob.id)
563
+ ? '上一轮还在执行,请等结束后再续接'
564
+ : '这个任务正在另一个 g ui 实例里执行,请等它结束后再续接');
555
565
  }
556
566
  if (!prevJob.claudeSessionId) {
557
567
  throw new HttpError(400, '无法续接:会话标识未捕获');
@@ -559,10 +569,12 @@ export function registerWorkbenchRoutes({
559
569
  const data = await readJson(TASKS_FILE, { tasks: [] });
560
570
  const task = (data.tasks || []).find(t => t.id === prevJob.taskId);
561
571
  if (!task) throw new HttpError(404, '所属任务不存在');
562
- // 计算续接轮次号
572
+ // 计算续接轮次号。**必须走合并表**:轮次是跨实例共享的编号,只看本进程内存的话,
573
+ // 另一个实例已经续过 #1 了,这边还会再发一个 #1 出去(两条 subId 撞车,前端按 subId
574
+ // 归组时那两轮会混在一起)。
563
575
  const subIdPrefix = `${task.id}__simple`;
564
576
  let maxRound = 0;
565
- for (const j of jobs.values()) {
577
+ for (const j of mergedJobs().values()) {
566
578
  if (j.taskId !== task.id) continue;
567
579
  if (j.subId === subIdPrefix) continue;
568
580
  const m = j.subId && j.subId.match(/__simple__r(\d+)$/);
@@ -1660,7 +1672,7 @@ export function registerWorkbenchRoutes({
1660
1672
  * 无参数、服务端不算缓存:三个文件都很小,每次实读比让前端踩脏缓存划算;
1661
1673
  * 前端那边(stores/toolsStore)自己按分钟级 TTL + 并发去重压请求频率。
1662
1674
  * 返回值里某项为 null = 那个 CLI 没在配置里写模型,前端照实显示「未在配置中指定」。
1663
- * key 恒为 claude / codex / opencode 三个,与 TASK_EXECUTOR_OPTIONS 对齐。
1675
+ * key 恒为 claude / codex / opencode / codebuddy 四个,与 TASK_EXECUTOR_OPTIONS 对齐。
1664
1676
  */
1665
1677
  app.get('/api/workbench/executor-models', asyncRoute(async (_req, res) => {
1666
1678
  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 });