zen-gitsync 2.17.6 → 2.17.8

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 (70) hide show
  1. package/README.md +39 -2
  2. package/package.json +1 -1
  3. package/scripts/release.js +139 -48
  4. package/src/cli/ai/agent.js +108 -324
  5. package/src/cli/ai/cli.integration.test.js +124 -0
  6. package/src/cli/ai/context.js +90 -0
  7. package/src/cli/ai/modelSetup.js +38 -0
  8. package/src/cli/ai/runtime.test.js +181 -0
  9. package/src/cli/ai/safety.js +27 -4
  10. package/src/cli/ai/safety.test.js +13 -0
  11. package/src/cli/ai/telemetry.js +43 -0
  12. package/src/cli/ai/termui.js +181 -51
  13. package/src/cli/ai/termui.test.js +165 -16
  14. package/src/cli/ai/tools.js +27 -5
  15. package/src/cli/ai/tools.test.js +23 -0
  16. package/src/cli/ai/transport.js +89 -0
  17. package/src/cli/ai/turn.js +126 -0
  18. package/src/config.js +34 -4
  19. package/src/ui/public/assets/AgentView-CdJaeRh0.js +5 -0
  20. package/src/ui/public/assets/AgentView-DAY4S5WN.css +1 -0
  21. package/src/ui/public/assets/{AppVersionBadge-DV6KuLqb.js → AppVersionBadge-CiijSYfM.js} +2 -2
  22. package/src/ui/public/assets/{BranchSelector-DGXDAOkR.js → BranchSelector-BS7ajwvv.js} +1 -1
  23. package/src/ui/public/assets/{CommitForm-BYBj7UUa.js → CommitForm-CEOSqXCA.js} +3 -3
  24. package/src/ui/public/assets/{CommonDialog-DWMCeujE.js → CommonDialog-CAaEi-4T.js} +1 -1
  25. package/src/ui/public/assets/{EditorView-D-TEQZso.js → EditorView-DyaFyRDD.js} +1 -1
  26. package/src/ui/public/assets/{FileDiffViewer-C4ZANajI.js → FileDiffViewer-BvX9cQjR.js} +1 -1
  27. package/src/ui/public/assets/{FlowExecutionViewer-DUv7mBuG.js → FlowExecutionViewer-uXrzZjsW.js} +1 -1
  28. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BiSuvzL7.js → FlowOrchestrationWorkspace-DIBpUSt_.js} +1 -1
  29. package/src/ui/public/assets/{IconButton-BltPKVbV.js → IconButton-CnoW2on6.js} +1 -1
  30. package/src/ui/public/assets/{LogList-BY1Haq9r.js → LogList-D2espSVo.js} +1 -1
  31. package/src/ui/public/assets/{MindmapView-BKsvCnYv.js → MindmapView-CZF-rZVs.js} +1 -1
  32. package/src/ui/public/assets/{MonitorView-B3_NaJRy.js → MonitorView-DjLr7qOG.js} +1 -1
  33. package/src/ui/public/assets/{ProjectStartupButton-DP3eDVRp.js → ProjectStartupButton-DzsU-QIO.js} +1 -1
  34. package/src/ui/public/assets/{RemoteManagerDialog-Bl9b_iRv.js → RemoteManagerDialog-CuuG2ucT.js} +1 -1
  35. package/src/ui/public/assets/{RemoteRepoCard-TQ3DxDnq.js → RemoteRepoCard-BGf8L0of.js} +1 -1
  36. package/src/ui/public/assets/{SourceMapView-BF-Mzv3V.js → SourceMapView-CFb02m6z.js} +1 -1
  37. package/src/ui/public/assets/{SvgIcon-DxazW7dI.js → SvgIcon-SRAeYjnN.js} +1 -1
  38. package/src/ui/public/assets/{TemplateManager-BN6gWFWX.js → TemplateManager-gedqAXl8.js} +1 -1
  39. package/src/ui/public/assets/{UserInputNode-CPwvIfP6.js → UserInputNode-BOCuMQW6.js} +1 -1
  40. package/src/ui/public/assets/WorkbenchView-CRoR21LQ.js +13 -0
  41. package/src/ui/public/assets/WorkbenchView-v3QFn6Ln.css +1 -0
  42. package/src/ui/public/assets/_plugin-vue_export-helper-C8OE7U9H.js +19 -0
  43. package/src/ui/public/assets/configStore-CuMElTgV.js +1 -0
  44. package/src/ui/public/assets/{gitStore-CYrdjXPU.js → gitStore-CpcS-MzM.js} +1 -1
  45. package/src/ui/public/assets/index-cApp6w0b.css +1 -0
  46. package/src/ui/public/assets/index-yXm9JLGv.js +24 -0
  47. package/src/ui/public/assets/{previewSandbox-D_j5PWX1.js → previewSandbox-DkXYEpLP.js} +0 -0
  48. package/src/ui/public/assets/{toolsStore-BiC2mmC3.js → toolsStore-DGxlRe3Y.js} +1 -1
  49. package/src/ui/public/assets/vendor-4ojsjDYc.css +1 -0
  50. package/src/ui/public/assets/{vendor-92fKgtnz.js → vendor-dO4B7wQf.js} +28 -28
  51. package/src/ui/public/index.html +14 -14
  52. package/src/ui/server/routes/config.js +8 -3
  53. package/src/ui/server/routes/fileOpen.js +48 -2
  54. package/src/ui/server/routes/fs.js +37 -0
  55. package/src/ui/server/routes/gAiLauncher.test.js +70 -0
  56. package/src/ui/server/routes/workbench/index.js +37 -6
  57. package/src/ui/server/routes/workbench/jobStore.js +4 -0
  58. package/src/ui/server/routes/workbench/taskRunner.js +276 -96
  59. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +126 -0
  60. package/src/ui/server/utils/directoryFetch.js +209 -0
  61. package/src/ui/server/utils/directoryFetch.test.js +232 -0
  62. package/src/ui/public/assets/AgentView-BC3F0F6m.js +0 -4
  63. package/src/ui/public/assets/AgentView-CzUeyQfR.css +0 -1
  64. package/src/ui/public/assets/WorkbenchView-CDu61WWs.js +0 -13
  65. package/src/ui/public/assets/WorkbenchView-DEejub9Z.css +0 -1
  66. package/src/ui/public/assets/_plugin-vue_export-helper-B7A-NgBp.js +0 -19
  67. package/src/ui/public/assets/configStore-Dx84lvTX.js +0 -1
  68. package/src/ui/public/assets/index-DNHOYMCg.js +0 -24
  69. package/src/ui/public/assets/index-Dsfmp6w8.css +0 -1
  70. package/src/ui/public/assets/vendor-B1UrVTTI.css +0 -1
@@ -10,28 +10,28 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-DNHOYMCg.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-yXm9JLGv.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
15
15
  <link rel="modulepreload" crossorigin href="/assets/element-plus-KTmRV-so.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/monaco-DkJuNcCr.js">
17
- <link rel="modulepreload" crossorigin href="/assets/vendor-92fKgtnz.js">
18
- <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-B7A-NgBp.js">
19
- <link rel="modulepreload" crossorigin href="/assets/SvgIcon-DxazW7dI.js">
20
- <link rel="modulepreload" crossorigin href="/assets/IconButton-BltPKVbV.js">
21
- <link rel="modulepreload" crossorigin href="/assets/configStore-Dx84lvTX.js">
17
+ <link rel="modulepreload" crossorigin href="/assets/vendor-dO4B7wQf.js">
18
+ <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-C8OE7U9H.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/SvgIcon-SRAeYjnN.js">
20
+ <link rel="modulepreload" crossorigin href="/assets/IconButton-CnoW2on6.js">
21
+ <link rel="modulepreload" crossorigin href="/assets/configStore-CuMElTgV.js">
22
22
  <link rel="modulepreload" crossorigin href="/assets/socket-io-TBtw0msS.js">
23
- <link rel="modulepreload" crossorigin href="/assets/gitStore-CYrdjXPU.js">
23
+ <link rel="modulepreload" crossorigin href="/assets/gitStore-CpcS-MzM.js">
24
24
  <link rel="modulepreload" crossorigin href="/assets/useThemeObserver-B01D-Vl6.js">
25
25
  <link rel="modulepreload" crossorigin href="/assets/flow-mindmap-CurCIiCp.js">
26
- <link rel="modulepreload" crossorigin href="/assets/previewSandbox-D_j5PWX1.js">
27
- <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-C4ZANajI.js">
28
- <link rel="modulepreload" crossorigin href="/assets/toolsStore-BiC2mmC3.js">
29
- <link rel="modulepreload" crossorigin href="/assets/CommonDialog-DWMCeujE.js">
30
- <link rel="modulepreload" crossorigin href="/assets/TemplateManager-BN6gWFWX.js">
26
+ <link rel="modulepreload" crossorigin href="/assets/previewSandbox-DkXYEpLP.js">
27
+ <link rel="modulepreload" crossorigin href="/assets/FileDiffViewer-BvX9cQjR.js">
28
+ <link rel="modulepreload" crossorigin href="/assets/toolsStore-DGxlRe3Y.js">
29
+ <link rel="modulepreload" crossorigin href="/assets/CommonDialog-CAaEi-4T.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/TemplateManager-gedqAXl8.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vue-flow-CsvDD38k.js">
32
32
  <link rel="stylesheet" crossorigin href="/assets/element-plus-BWbXh9mk.css">
33
33
  <link rel="stylesheet" crossorigin href="/assets/monaco-B2lPvkrc.css">
34
- <link rel="stylesheet" crossorigin href="/assets/vendor-B1UrVTTI.css">
34
+ <link rel="stylesheet" crossorigin href="/assets/vendor-4ojsjDYc.css">
35
35
  <link rel="stylesheet" crossorigin href="/assets/SvgIcon-BQ6bR7l0.css">
36
36
  <link rel="stylesheet" crossorigin href="/assets/IconButton-CTwyZrv2.css">
37
37
  <link rel="stylesheet" crossorigin href="/assets/flow-mindmap-DnzM9SlK.css">
@@ -40,7 +40,7 @@
40
40
  <link rel="stylesheet" crossorigin href="/assets/CommonDialog-C_RjF9Mp.css">
41
41
  <link rel="stylesheet" crossorigin href="/assets/TemplateManager-C0rm8xfC.css">
42
42
  <link rel="stylesheet" crossorigin href="/assets/vue-flow-CavUTEzp.css">
43
- <link rel="stylesheet" crossorigin href="/assets/index-Dsfmp6w8.css">
43
+ <link rel="stylesheet" crossorigin href="/assets/index-cApp6w0b.css">
44
44
  </head>
45
45
  <body>
46
46
  <div id="app"></div>
@@ -986,10 +986,10 @@ export function registerConfigRoutes({
986
986
  }
987
987
  })
988
988
 
989
- // 保存通用设置(主题和语言)
989
+ // 保存通用设置(主题、语言、工作台任务执行器)
990
990
  app.post('/api/config/save-general-settings', express.json(), async (req, res) => {
991
991
  try {
992
- const { theme, locale } = req.body
992
+ const { theme, locale, taskExecutor } = req.body
993
993
 
994
994
  // 读取原始配置以保留项目设置
995
995
  const rawConfig = await configManager.readRawConfigFile()
@@ -1001,10 +1001,15 @@ export function registerConfigRoutes({
1001
1001
  if (locale && ['zh-CN', 'en-US'].includes(locale)) {
1002
1002
  rawConfig.locale = locale
1003
1003
  }
1004
+ // 任务执行器是全局配置(跨项目共享);白名单外的值静默忽略,不落盘
1005
+ const normalizedExecutor = configManager.normalizeTaskExecutor(taskExecutor)
1006
+ if (normalizedExecutor) {
1007
+ rawConfig.taskExecutor = normalizedExecutor
1008
+ }
1004
1009
 
1005
1010
  // 直接写入原始配置,避免覆盖项目设置
1006
1011
  await configManager.writeRawConfigFile(rawConfig)
1007
- res.json({ success: true })
1012
+ res.json({ success: true, taskExecutor: normalizedExecutor || undefined })
1008
1013
  } catch (error) {
1009
1014
  res.status(500).json({ success: false, error: error.message })
1010
1015
  }
@@ -17,7 +17,11 @@ import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
17
17
  import path from 'path';
18
18
  import os from 'os';
19
19
  import open from 'open';
20
- import { spawn, spawnSync } from 'child_process';
20
+ import { spawn, spawnSync, execFile } from 'child_process';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { promisify } from 'node:util';
23
+
24
+ const execFileAsync = promisify(execFile);
21
25
 
22
26
  const TOOL_INSTALL_PACKAGES = Object.freeze({
23
27
  claude: '@anthropic-ai/claude-code',
@@ -367,6 +371,32 @@ async function launchOpenCode(dirPath) {
367
371
  return launchInTerminal(dirPath, 'opencode');
368
372
  }
369
373
 
374
+ // Run the CLI shipped with this server, even when `g` is not in PATH or points
375
+ // to another install. Directory paths remain spawn options, never CMD source.
376
+ export async function launchGai(dirPath, {
377
+ platform = process.platform,
378
+ spawnFn = execFileAsync,
379
+ terminalFn = launchInTerminal,
380
+ } = {}) {
381
+ const entry = fileURLToPath(new URL('../../../gitCommit.js', import.meta.url));
382
+ if (platform !== 'win32') return terminalFn(dirPath, process.execPath, [entry, 'ai']);
383
+
384
+ const psLiteral = value => `'${String(value).replace(/'/g, "''")}'`;
385
+ const encode = value => Buffer.from(value, 'utf16le').toString('base64');
386
+ const command = `& ${psLiteral(process.execPath)} ${psLiteral(entry)} 'ai'`;
387
+ // The hidden launcher opens a visible interactive console. -NoExit leaves
388
+ // startup errors readable; no model request is sent until the user types.
389
+ const launcher = [
390
+ "$ErrorActionPreference = 'Stop';",
391
+ "Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe')",
392
+ `-ArgumentList '-NoLogo','-NoProfile','-NoExit','-EncodedCommand','${encode(command)}'`,
393
+ '-WorkingDirectory (Get-Location).ProviderPath -WindowStyle Normal',
394
+ ].join(' ');
395
+ return spawnFn('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encode(launcher)], {
396
+ cwd: dirPath, windowsHide: true, timeout: 10000,
397
+ });
398
+ }
399
+
370
400
  async function launchKimiCode(dirPath) {
371
401
  if (process.platform === 'win32') {
372
402
  const executable = await findKimiExecutable();
@@ -548,8 +578,24 @@ async function launchToolInstaller(installer, dirPath = process.cwd(), { update
548
578
  }
549
579
 
550
580
  export function registerFileOpenRoutes({
551
- app
581
+ app,
582
+ launchAi = launchGai,
552
583
  }) {
584
+ app.post('/api/open-directory-with-g-ai', asyncRoute(async (req, res) => {
585
+ const dirPath = req.body?.path;
586
+ if (typeof dirPath !== 'string' || !dirPath.trim() || /[\0\r\n]/.test(dirPath)) {
587
+ throw new HttpError(400, '目录路径不能为空或包含控制字符');
588
+ }
589
+ const target = path.resolve(dirPath);
590
+ let stat;
591
+ try { stat = await fs.stat(target); }
592
+ catch { throw new HttpError(400, '目录不存在或不可访问'); }
593
+ if (!stat.isDirectory()) throw new HttpError(400, '指定路径不是目录');
594
+ try { await launchAi(target); }
595
+ catch (error) { throw new HttpError(400, `无法启动 g ai: ${error.message}`); }
596
+ res.json({ success: true, message: '已在新终端中启动 g ai' });
597
+ }));
598
+
553
599
  // 打开文件
554
600
  app.post('/api/open-file', asyncRoute(async (req, res) => {
555
601
  try {
@@ -23,6 +23,7 @@ import { spawn, exec, execSync } from 'child_process';
23
23
  import { ensureWithinCwd, normalizeProjectPath } from '../utils/pathGuard.js';
24
24
  import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
25
25
  import { probeDirectoryGitStates, normalizeDirKey } from '../utils/directoryGitState.js';
26
+ import { fetchDirectoryRemotes } from '../utils/directoryFetch.js';
26
27
  import { invalidateCurrentProjectKey, invalidateRawConfigCache } from '../../../config.js';
27
28
  import { invalidateCwdCache } from '../../../utils/index.js';
28
29
 
@@ -404,6 +405,42 @@ export function registerFsRoutes({
404
405
  res.json({ success: true, results });
405
406
  }));
406
407
 
408
+ // 对**单个**「最近目录」执行 git fetch,让卡片上的「领先/落后」从
409
+ // "上次 fetch 时的快照"回到真实状态。
410
+ //
411
+ // 为什么是单目录接口,而不是一个"一次刷新全部"的批量接口:
412
+ // 十几个仓库并发联网既慢又容易撞凭据提示;拆成一个个请求后,每个请求的
413
+ // 耗时被单目录超时箍住(不会出现一挂两分钟、还看不到进度的大请求),
414
+ // 刷新顺序与并发由调用方(列表组件)决定,还能每完成一个就地更新那张卡片。
415
+ // 服务端因此不需要任何调度逻辑,也不会被一个慢仓库拖住其他仓库。
416
+ //
417
+ // 安全口径与上面的 git-state 完全一致:只接受**配置里的最近目录**。
418
+ // 否则这个接口就是一个"对磁盘上任意仓库发起网络请求"的口子。
419
+ app.post('/api/recent_directories/fetch', asyncRoute(async (req, res) => {
420
+ const recentDirs = (await configManager.getRecentDirectories()) || [];
421
+ const allowed = new Map(recentDirs.map(d => [normalizeDirKey(d), d]));
422
+
423
+ const requested = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
424
+ if (!requested) throw new HttpError(400, '缺少 path 参数');
425
+ const target = allowed.get(normalizeDirKey(requested));
426
+ if (!target) throw new HttpError(403, '该目录不在最近项目列表中');
427
+
428
+ const result = await fetchDirectoryRemotes(target);
429
+ // fetch 动了本地 remote-tracking 引用 → 顺手重探一次状态回给前端:
430
+ // 省掉一次往返,且 useCache:false 保证读到的是 fetch 之后的值
431
+ // (否则会拿到 15s TTL 里那份 fetch 之前的快照,等于白刷)。
432
+ let state = null;
433
+ if (result.status === 'ok') {
434
+ const probed = await probeDirectoryGitStates([target], { useCache: false });
435
+ state = probed[target] || null;
436
+ }
437
+ logger.info(`[recent_directories/fetch] ${result.status}${result.reason ? `(${result.reason})` : ''} ${target}`);
438
+
439
+ // success 表示"这次请求处理完了",fetch 本身的结果在 status 里 ——
440
+ // 跳过(非仓库/无 remote)不是接口错误,前端要按三态分别计数。
441
+ res.json({ success: true, path: target, ...result, state });
442
+ }));
443
+
407
444
  // 在资源管理器/访达中打开当前目录
408
445
  app.post('/api/open_directory', asyncRoute(async (req, res) => {
409
446
  try {
@@ -0,0 +1,70 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { promises as fs } from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { launchGai, registerFileOpenRoutes } from './fileOpen.js'
7
+
8
+ test('Windows g ai launcher keeps directory characters out of shell source', async () => {
9
+ const directory = "C:\\项目 & test's %name%\\current"
10
+ let actual
11
+ await launchGai(directory, { platform: 'win32', spawnFn: async (...args) => { actual = args } })
12
+ assert.equal(actual[0], 'powershell.exe')
13
+ assert.equal(actual[2].cwd, directory)
14
+ assert.equal(actual[2].windowsHide, true)
15
+ const script = Buffer.from(actual[1].at(-1), 'base64').toString('utf16le')
16
+ assert.doesNotMatch(script, /%name%|test's/)
17
+ assert.match(script, /-WorkingDirectory \(Get-Location\).ProviderPath/)
18
+ assert.match(script, /-WindowStyle Normal/)
19
+ const inner = script.match(/'-EncodedCommand','([^']+)'/)[1]
20
+ const command = Buffer.from(inner, 'base64').toString('utf16le')
21
+ assert.ok(command.includes(process.execPath.replace(/'/g, "''")))
22
+ assert.match(command, /gitCommit\.js' 'ai'$/)
23
+ })
24
+
25
+ test('POSIX launches the bundled CLI with structured arguments in the selected directory', async () => {
26
+ let actual
27
+ await launchGai('/tmp/project with spaces', { platform: 'linux', terminalFn: async (...args) => { actual = args } })
28
+ assert.equal(actual[0], '/tmp/project with spaces')
29
+ assert.equal(actual[1], process.execPath)
30
+ assert.equal(actual[2][1], 'ai')
31
+ assert.ok((await fs.stat(actual[2][0])).isFile())
32
+ })
33
+
34
+ test('g ai endpoint validates directories and reports launch failures', async t => {
35
+ const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-g-ai-launch-'))
36
+ const project = path.join(fixture, "项目 & test's %name%")
37
+ await fs.mkdir(project)
38
+ const file = path.join(fixture, 'file.txt')
39
+ await fs.writeFile(file, 'not a directory')
40
+ t.after(async () => {
41
+ assert.equal(path.dirname(fixture), path.resolve(os.tmpdir()))
42
+ assert.ok(path.basename(fixture).startsWith('zen-g-ai-launch-'))
43
+ await fs.rm(fixture, { recursive: true, force: true })
44
+ })
45
+ const routes = new Map(), launches = []
46
+ let fail = false
47
+ registerFileOpenRoutes({
48
+ app: { get: () => {}, post: (url, handler) => routes.set(url, handler) },
49
+ launchAi: async directory => { if (fail) throw new Error('terminal unavailable'); launches.push(directory) },
50
+ })
51
+ const handler = routes.get('/api/open-directory-with-g-ai')
52
+ async function request(body) {
53
+ const response = { code: 200, body: null, status(code) { this.code = code; return this }, json(value) { this.body = value; return this } }
54
+ await handler({ method: 'POST', path: '/api/open-directory-with-g-ai', body }, response)
55
+ return response
56
+ }
57
+ for (const value of [undefined, '', ' ', {}, 42, 'bad\npath', file, path.join(fixture, 'missing')]) {
58
+ const response = await request({ path: value })
59
+ assert.equal(response.code, 400)
60
+ assert.equal(response.body.success, false)
61
+ }
62
+ assert.equal(launches.length, 0)
63
+ const good = await request({ path: project })
64
+ assert.equal(good.body.success, true)
65
+ assert.deepEqual(launches, [project])
66
+ fail = true
67
+ const error = await request({ path: project })
68
+ assert.equal(error.code, 400)
69
+ assert.match(error.body.error, /terminal unavailable/)
70
+ })
@@ -105,6 +105,7 @@ import {
105
105
  } from './jobStore.js';
106
106
  import {
107
107
  launchClaudeInNewWindow,
108
+ normalizeTaskExecutor,
108
109
  runTaskQueue,
109
110
  runSingleSubtask,
110
111
  syncSubToCancelled,
@@ -138,6 +139,25 @@ import {
138
139
 
139
140
  const { genSessionId, read: readSessionFile, write: writeSessionFile, delete: deleteSessionFile, listMeta: listSessionsMeta, enforceRetention: enforceSessionsRetention } = sessionStore;
140
141
 
142
+ /**
143
+ * 解析本次执行用哪个执行器:body.executor 显式指定 > 配置里的全局默认 > 'claude'。
144
+ * 配置读失败一律回落 'claude' —— 执行器选不出来不该挡住任务本身能跑。
145
+ */
146
+ async function resolveExecutor(requested) {
147
+ const explicit = normalizeTaskExecutor(requested);
148
+ if (explicit) return explicit;
149
+ try {
150
+ if (configManager) {
151
+ const cfg = await configManager.loadConfig();
152
+ const fallback = normalizeTaskExecutor(cfg?.taskExecutor);
153
+ if (fallback) return fallback;
154
+ }
155
+ } catch (err) {
156
+ logger.warn('[workbench] 读取 taskExecutor 配置失败,回落 claude:', err && err.message || err);
157
+ }
158
+ return 'claude';
159
+ }
160
+
141
161
  /**
142
162
  * 注册所有 workbench 路由(共 45 个端点)。
143
163
  * @param {Object} deps
@@ -1059,9 +1079,10 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1059
1079
  throw new HttpError(400, '任务没有子任务');
1060
1080
  }
1061
1081
  const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1082
+ const executor = await resolveExecutor(req.body?.executor);
1062
1083
  // 异步执行,立即返回
1063
1084
  res.json({ success: true, message: '已开始执行' });
1064
- runTaskQueue(task, repoPath, '').catch(err => {
1085
+ runTaskQueue(task, repoPath, '', { executor }).catch(err => {
1065
1086
  publish('task:error', { taskId: task.id, error: err.message });
1066
1087
  });
1067
1088
  }));
@@ -1078,8 +1099,9 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1078
1099
  throw new HttpError(400, 'startSubIndex 越界');
1079
1100
  }
1080
1101
  const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1102
+ const executor = await resolveExecutor(req.body?.executor);
1081
1103
  res.json({ success: true, message: `已从第 ${startSubIndex + 1} 个子任务开始执行` });
1082
- runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex }).catch(err => {
1104
+ runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex, executor }).catch(err => {
1083
1105
  publish('task:error', { taskId: task.id, error: err.message });
1084
1106
  });
1085
1107
  }));
@@ -1092,6 +1114,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1092
1114
  throw new HttpError(400, '该任务不是简单任务,请使用普通执行接口');
1093
1115
  }
1094
1116
  const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1117
+ const executor = await resolveExecutor(req.body?.executor);
1095
1118
  const virtualSub = {
1096
1119
  id: `${task.id}__simple`,
1097
1120
  title: task.title,
@@ -1101,7 +1124,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1101
1124
  attachments: Array.isArray(task.attachments) ? task.attachments : []
1102
1125
  };
1103
1126
  res.json({ success: true, message: '已开始执行简单任务' });
1104
- runSingleSubtask(task, virtualSub, repoPath, '', []).catch(err => {
1127
+ runSingleSubtask(task, virtualSub, repoPath, '', [], { executor }).catch(err => {
1105
1128
  publish('task:error', { taskId: task.id, error: err.message });
1106
1129
  });
1107
1130
  }));
@@ -1124,11 +1147,12 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1124
1147
  const liveJob = snapshotJobs().find(j => j.subId === subId && (j.status === 'running' || j.status === 'pending'));
1125
1148
  if (liveJob) throw new HttpError(400, '该子任务已有正在执行的 job');
1126
1149
  const repoPath = resolveTaskRepoPath(foundTask, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1150
+ const executor = await resolveExecutor(req.body?.executor);
1127
1151
  res.json({ success: true, message: `已开始执行子任务:${foundSub.title || subId}` });
1128
1152
  (async () => {
1129
1153
  try {
1130
1154
  const priorOutputs = await collectPriorOutputs(foundTask, foundSub);
1131
- await runSingleSubtask(foundTask, foundSub, repoPath, '', priorOutputs);
1155
+ await runSingleSubtask(foundTask, foundSub, repoPath, '', priorOutputs, { executor });
1132
1156
  await persistTaskAfterRun(foundTask);
1133
1157
  } catch (err) {
1134
1158
  publish('task:error', { taskId: foundTask.id, error: err.message });
@@ -1240,7 +1264,12 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1240
1264
  };
1241
1265
  const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1242
1266
  res.json({ success: true, message: '已加入续接队列' });
1243
- runSingleSubtask(task, virtualSub, repoPath, '', [], { resumeSessionId: prevJob.claudeSessionId })
1267
+ // 执行器必须沿用上一轮 job 的 agent:claude 的 --resume 和 opencode 的 --session
1268
+ // 互不认对方的会话 id,串了执行器续接必然失败。
1269
+ runSingleSubtask(task, virtualSub, repoPath, '', [], {
1270
+ resumeSessionId: prevJob.claudeSessionId,
1271
+ executor: normalizeTaskExecutor(prevJob.agent)
1272
+ })
1244
1273
  .catch(err => {
1245
1274
  publish('task:error', { taskId: task.id, error: err.message });
1246
1275
  });
@@ -2113,7 +2142,9 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2113
2142
  promptOverride: task.simpleOverride || '',
2114
2143
  attachments: [],
2115
2144
  };
2116
- runSingleSubtask(task, virtualSub, targetPath, '', []).catch(err => {
2145
+ // 派发控制台可以显式指定执行器;不传则用配置里的全局默认(resolveExecutor 走回落链)
2146
+ const executor = await resolveExecutor(req.body?.executor);
2147
+ runSingleSubtask(task, virtualSub, targetPath, '', [], { executor }).catch(err => {
2117
2148
  publish('task:error', { taskId: task.id, error: err.message });
2118
2149
  });
2119
2150
  }
@@ -309,11 +309,15 @@ export function snapshotJobs() {
309
309
  status: j.status,
310
310
  prompt: j.prompt || '',
311
311
  output: j.output || '',
312
+ thinking: j.thinking || '',
312
313
  pid: j.pid || null,
313
314
  startedAt: j.startedAt || null,
314
315
  endedAt: j.endedAt || null,
315
316
  exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
316
317
  error: j.error || null,
318
+ // 本轮用的执行器(claude | opencode)。前端对话区助手名 / 日志详情按它显示;
319
+ // 加字段时记得同步这里 —— 白名单投影会把没列出的字段静默剥掉。
320
+ agent: j.agent || null,
317
321
  // 续接对话用:claude --output-format stream-json 的 system.init 事件捕获到的 session_id
318
322
  claudeSessionId: j.claudeSessionId || null
319
323
  }));