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.
- package/package.json +2 -1
- package/src/cli/ai/agent.js +7 -0
- package/src/cli/ai/runtime.test.js +22 -1
- package/src/cli/ai/tools.js +1 -1
- package/src/cli/ai/tools.test.js +1 -1
- package/src/cli/ai/transport.js +8 -3
- package/src/config.js +7 -5
- package/src/mcp/tools/workbench.js +1 -1
- package/src/ui/public/assets/{AgentView-DZx3n-So.css → AgentView-CV_8sYX6.css} +1 -1
- package/src/ui/public/assets/AgentView-DxmnqJxE.js +1 -0
- package/src/ui/public/assets/{AppVersionBadge-Bg5lrtKb.js → AppVersionBadge-C4OhcUi8.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-CxjTqAkl.js → BranchSelector-CrLieQyJ.js} +1 -1
- package/src/ui/public/assets/{CommitForm-BWWrOvj5.js → CommitForm-KGQisnPQ.js} +2 -2
- package/src/ui/public/assets/{CommonDialog-x5jfL4Lq.js → CommonDialog-j_53ORvj.js} +1 -1
- package/src/ui/public/assets/CopySessionButton-y5Rzmact.js +20 -0
- package/src/ui/public/assets/EditorView-CkZb2kw5.js +1 -0
- package/src/ui/public/assets/{EditorView-BUiVsBv7.css → EditorView-Ims2BUK_.css} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-BkZ20jxN.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-aZBYu29g.js → FlowOrchestrationWorkspace-BMQSVm3V.js} +1 -1
- package/src/ui/public/assets/{LogList-CSIbmUMK.js → LogList-BxH6QNlK.js} +4 -4
- package/src/ui/public/assets/{MindmapView-JYLp185X.js → MindmapView-D9_qjJsR.js} +1 -1
- package/src/ui/public/assets/{MonitorView-BS2Mh-I7.js → MonitorView-z_fsnE8Y.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DAEP6ZU5.js → ProjectStartupButton-CFB_JpYG.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-7fzSkSmc.css +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-BYI8lkjQ.js +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-aaNmsKpy.js → RemoteManagerDialog-DXDcN8e7.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-DndI8rNZ.js → RemoteRepoCard-C_TYGsXi.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-BTD9-R8t.js → SourceMapView-D6cSweDt.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-0n6uuKFm.js → SvgIcon-5AlG3IfR.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-B-mDg9Wy.js → UserInputNode-BKP869D6.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-D4UDurhF.js +6 -0
- package/src/ui/public/assets/WorkbenchView-DogERhHQ.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-C3o7kvRL.js → _plugin-vue_export-helper-Csn085FF.js} +4 -4
- package/src/ui/public/assets/agentConversations-DyzaxUXm.js +10 -0
- package/src/ui/public/assets/configStore-DOaPZ6Mq.js +1 -0
- package/src/ui/public/assets/index-C7Y9odvZ.js +57 -0
- package/src/ui/public/assets/index-NYzaTfbS.css +1 -0
- package/src/ui/public/assets/{useOrchestrator-CS9sn7nV.js → useOrchestrator-C236G5P5.js} +1 -1
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/index.js +8 -0
- package/src/ui/server/routes/fileOpen.js +57 -1
- package/src/ui/server/routes/fileOpen.test.js +1 -1
- package/src/ui/server/routes/workbench/agentChat.js +70 -8
- package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
- package/src/ui/server/routes/workbench/agentEngines.js +8 -4
- package/src/ui/server/routes/workbench/agentExtensions.js +157 -0
- package/src/ui/server/routes/workbench/agentMarketplace.js +12 -0
- package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +82 -7
- package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
- package/src/ui/server/routes/workbench/executorModels.js +30 -5
- package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
- package/src/ui/server/routes/workbench/index.js +1 -1
- package/src/ui/server/routes/workbench/jobActivity.js +3 -3
- package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
- package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
- package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
- package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
- package/src/ui/server/routes/workbench/taskRunner.js +311 -24
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
- package/src/ui/public/assets/AgentView-BRAUZCtA.js +0 -1
- package/src/ui/public/assets/CopySessionButton-D0wFpk8a.js +0 -20
- package/src/ui/public/assets/EditorView-DmaGiI-z.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-CVFTShrU.js +0 -1
- package/src/ui/public/assets/WorkbenchView-CfSrCIbp.js +0 -8
- package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
- package/src/ui/public/assets/agentConversations-DpiGplZB.js +0 -8
- package/src/ui/public/assets/configStore-BdL3uW2E.js +0 -1
- package/src/ui/public/assets/index-BS7BDFG4.js +0 -57
- package/src/ui/public/assets/index-DwJWiCiN.css +0 -1
|
@@ -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
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
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` /
|
|
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
|
-
//
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
createCodexEventHandler,
|
|
48
|
-
launchClaudeInNewWindow,
|
|
49
|
-
launchOpencodeRun,
|
|
50
|
-
launchCodexExec,
|
|
49
|
+
EXECUTOR_LAUNCHERS,
|
|
50
|
+
EXECUTOR_HANDLERS,
|
|
51
51
|
} from './taskRunner.js';
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
/**
|
|
54
|
+
* 引擎 → spawn 实现。直接复用 taskRunner 的运行时分发表(EXECUTOR_LAUNCHERS)——
|
|
55
|
+
* 不在这里另抄一张表:那张表已经是"执行器 → launcher"的唯一真相源,抄一份就等于
|
|
56
|
+
* 给未来某次"加了第五家只改了一处"埋雷。别名导出只为保持本模块对外的旧名字。
|
|
57
|
+
*/
|
|
58
|
+
export const ENGINE_LAUNCHERS = EXECUTOR_LAUNCHERS;
|
|
59
59
|
|
|
60
|
-
/** 引擎 → NDJSON
|
|
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
|
-
//
|
|
279
|
-
//
|
|
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();
|