zen-gitsync 2.18.20 → 2.18.22

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 (88) hide show
  1. package/package.json +4 -1
  2. package/src/cli/ai/agent.js +9 -2
  3. package/src/cli/ai/extensions.js +1 -1
  4. package/src/cli/ai/runtime.test.js +57 -1
  5. package/src/cli/ai/termui.js +11 -2
  6. package/src/cli/ai/tools.js +266 -5
  7. package/src/cli/ai/tools.test.js +81 -0
  8. package/src/cli/ai/transport.js +1 -1
  9. package/src/cli/ai/turn.js +59 -3
  10. package/src/config.js +60 -4
  11. package/src/mcp/install.js +548 -529
  12. package/src/ui/public/assets/AgentView-DdiyBMDD.js +26 -0
  13. package/src/ui/public/assets/{AgentView-BE7GD8rc.css → AgentView-_DUYxFmp.css} +1 -1
  14. package/src/ui/public/assets/{AppVersionBadge-86tbKU20.js → AppVersionBadge-CSEM45L4.js} +2 -2
  15. package/src/ui/public/assets/{BranchSelector-CsrkmTCA.js → BranchSelector-cSx2ZxYM.js} +1 -1
  16. package/src/ui/public/assets/ChatSilenceBadge-Dtv23ZmV.js +16 -0
  17. package/src/ui/public/assets/{CommitForm-DBK0GXEh.js → CommitForm-CrAsbDkq.js} +2 -2
  18. package/src/ui/public/assets/{CommonDialog-BkuDIWro.js → CommonDialog-BtO3uKdw.js} +1 -1
  19. package/src/ui/public/assets/{EditorView-CnWsMdHg.css → EditorView-B-t-nuf0.css} +1 -1
  20. package/src/ui/public/assets/EditorView-BesiWTV2.js +10 -0
  21. package/src/ui/public/assets/{FlowExecutionViewer-ClUkAmA2.js → FlowExecutionViewer-CfU9DLFG.js} +1 -1
  22. package/src/ui/public/assets/{FlowOrchestrationWorkspace-B3sOK3fz.js → FlowOrchestrationWorkspace-D3CSRMvG.js} +1 -1
  23. package/src/ui/public/assets/{LogList-6aO0-2xh.js → LogList-CT-SRIS3.js} +4 -4
  24. package/src/ui/public/assets/MindmapView-BCGq2zdh.js +35 -0
  25. package/src/ui/public/assets/MindmapView-CoUbyadF.css +1 -0
  26. package/src/ui/public/assets/{MonitorView-fcNrgVaU.js → MonitorView-BuBJJVhR.js} +1 -1
  27. package/src/ui/public/assets/{ProjectManagerView-CZfrML6A.js → ProjectManagerView-DlSfRn9N.js} +2 -2
  28. package/src/ui/public/assets/{ProjectStartupButton-D5MREz6F.js → ProjectStartupButton-DKZttDb3.js} +1 -1
  29. package/src/ui/public/assets/RemoteManagerDialog-CVrhhUPg.js +4 -0
  30. package/src/ui/public/assets/{RemoteRepoCard-CTnmfv2y.js → RemoteRepoCard-CmdYDF24.js} +1 -1
  31. package/src/ui/public/assets/{SourceMapView-B4q5qiE8.js → SourceMapView-DClgTmda.js} +1 -1
  32. package/src/ui/public/assets/{SvgIcon-WpEiC1rI.js → SvgIcon-D5fhBiJr.js} +1 -1
  33. package/src/ui/public/assets/TaskExecutorPicker-CzvWdS_K.js +1 -0
  34. package/src/ui/public/assets/TaskExecutorPicker-Dn8SgrTZ.css +1 -0
  35. package/src/ui/public/assets/{UserInputNode-Deoc3UvQ.js → UserInputNode-CNuW_Dtu.js} +1 -1
  36. package/src/ui/public/assets/WorkbenchView-D61DxNnn.css +1 -0
  37. package/src/ui/public/assets/WorkbenchView-WKNPn-oz.js +341 -0
  38. package/src/ui/public/assets/_plugin-vue_export-helper-E2AaPXn7.js +19 -0
  39. package/src/ui/public/assets/configStore-JIX2MOKo.js +1 -0
  40. package/src/ui/public/assets/{dagre-CpKxpMNH.js → dagre-yiQDclZv.js} +3 -3
  41. package/src/ui/public/assets/{element-plus-Bz6lYur-.js → element-plus-BeZBQw4X.js} +1 -1
  42. package/src/ui/public/assets/{flow-mindmap-cTg78Krq.js → flow-mindmap-CGjPqCv0.js} +1 -1
  43. package/src/ui/public/assets/{index-cDiVh_ZL.js → index-Cjv2Z6nF.js} +134 -125
  44. package/src/ui/public/assets/index-IjYv2goK.css +1 -0
  45. package/src/ui/public/assets/{monaco-CvMddat7.js → monaco-CMQow_j-.js} +1 -1
  46. package/src/ui/public/assets/{office-docx-VOB-i4K2.js → office-docx-nuGPI15f.js} +1 -1
  47. package/src/ui/public/assets/{office-excel-CJ2JvfA8.js → office-excel-CK-HOYi8.js} +1 -1
  48. package/src/ui/public/assets/{office-pptx-Z88k9qnx.js → office-pptx-9onMeLux.js} +1 -1
  49. package/src/ui/public/assets/{relativeTime-CoXAs57j.js → relativeTime-DHhE9Ihn.js} +1 -1
  50. package/src/ui/public/assets/taskExecutionExport-DlBMELzX.js +15 -0
  51. package/src/ui/public/assets/toolsStore-h5iqPyST.js +1 -0
  52. package/src/ui/public/assets/{useWorkbenchProjects-BS59r5Rj.js → useWorkbenchProjects-DW3vBHpK.js} +1 -1
  53. package/src/ui/public/assets/{vendor-CSFGMcMB.css → vendor-CAhoE06w.css} +1 -1
  54. package/src/ui/public/assets/{vendor-C2k0GBzj.js → vendor-KxSgeiUX.js} +303 -303
  55. package/src/ui/public/assets/{vue-flow-Cnxq2qrs.js → vue-flow-BMMiguH1.js} +1 -1
  56. package/src/ui/public/index.html +14 -14
  57. package/src/ui/server/routes/config.js +13 -0
  58. package/src/ui/server/routes/fileOpen.js +1851 -1736
  59. package/src/ui/server/routes/fileOpen.test.js +39 -1
  60. package/src/ui/server/routes/workbench/agentChat.js +48 -8
  61. package/src/ui/server/routes/workbench/agentChatShared.test.js +584 -496
  62. package/src/ui/server/routes/workbench/agentEngines.js +7 -4
  63. package/src/ui/server/routes/workbench/executorModels.js +25 -4
  64. package/src/ui/server/routes/workbench/executorModels.test.js +557 -530
  65. package/src/ui/server/routes/workbench/jobActivity.js +2 -2
  66. package/src/ui/server/routes/workbench/taskRunner.codearts.test.js +252 -0
  67. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +317 -317
  68. package/src/ui/server/routes/workbench/taskRunner.js +216 -7
  69. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +130 -130
  70. package/src/ui/server/routes/workbench/taskRunner.pi.test.js +298 -298
  71. package/src/ui/public/assets/AgentView-Du9akUeZ.js +0 -12
  72. package/src/ui/public/assets/ChatSilenceBadge-BBoFeSqe.js +0 -21
  73. package/src/ui/public/assets/EditorAgentPanel-BMUphuG0.css +0 -1
  74. package/src/ui/public/assets/EditorAgentPanel-KiHbQPJP.js +0 -34
  75. package/src/ui/public/assets/EditorView-B18B4yNJ.js +0 -13
  76. package/src/ui/public/assets/MindmapView-BDI3D_1h.css +0 -1
  77. package/src/ui/public/assets/MindmapView-aJ9TTijE.js +0 -4
  78. package/src/ui/public/assets/RecentDirectoriesChat-DBGoUlkn.css +0 -1
  79. package/src/ui/public/assets/RecentDirectoriesChat-wfkSF6NU.js +0 -3
  80. package/src/ui/public/assets/RemoteManagerDialog-BBwNlK9J.js +0 -4
  81. package/src/ui/public/assets/WorkbenchView-CVqXb__l.css +0 -1
  82. package/src/ui/public/assets/WorkbenchView-CijzpDWN.js +0 -360
  83. package/src/ui/public/assets/_plugin-vue_export-helper-BhdsvhKo.js +0 -19
  84. package/src/ui/public/assets/agentConversations-DaAtF1v4.js +0 -10
  85. package/src/ui/public/assets/configStore-Drlw8KtR.js +0 -1
  86. package/src/ui/public/assets/index-DSQ6mp_N.css +0 -1
  87. package/src/ui/public/assets/toolsStore-CnX34C96.js +0 -1
  88. package/src/ui/public/assets/useOrchestrator-BXmxJsRl.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-gitsync",
3
- "version": "2.18.20",
3
+ "version": "2.18.22",
4
4
  "description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -48,6 +48,9 @@
48
48
  "verify:agent-prompt-settings:reverse": "node scripts/verify-agent-prompt-settings.cjs --reverse",
49
49
  "verify:agent-input-facts": "node scripts/verify-agent-input-facts.cjs",
50
50
  "verify:agent-input-facts:reverse": "node scripts/verify-agent-input-facts.cjs --reverse",
51
+ "verify:agent-presets": "node scripts/verify-agent-presets.cjs",
52
+ "verify:agent-presets:reverse": "node scripts/verify-agent-presets.cjs --reverse",
53
+ "verify:preset-scopes": "node scripts/verify-preset-scopes.cjs",
51
54
  "verify:wb-dialogs": "node scripts/verify-workbench-dialogs.cjs",
52
55
  "verify:wb-dispatch": "node scripts/verify-workbench-dispatch-attach.cjs",
53
56
  "verify:wb-env-context": "node scripts/verify-workbench-env-context.mjs",
@@ -19,8 +19,9 @@
19
19
  // 能力:
20
20
  // - 默认使用 g ui 里配置的模型(~/.zen-gitsync/config.json 顶层 models,
21
21
  // isDefault 优先,否则取第一个);--model=<序号|名称> 或会话内 /model 切换
22
- // - OpenAI 兼容流式 function calling:模型可以调用 run_command /
23
- // read_file / write_file / edit_file / list_files / search_text / ask_user
22
+ // - OpenAI 兼容流式 function calling:模型可以调用内置工具(文件读写、
23
+ // 命令执行、搜索、后台任务、计划、问用户…完整清单见 tools.js 的 TOOL_DEFINITIONS)
24
+ // - 同一轮的只读工具并发执行(连续的 read/search 合成一批),写类与命令串行
24
25
  // - 图片:Alt+V 粘贴剪贴板图片、/image <路径> 附加本地图片,
25
26
  // 以多模态(image_url)消息发给模型(需模型支持视觉)
26
27
  // - 权限:启动目录内全开放,其他目录可读写;唯一红线由 safety.js 拦截
@@ -76,6 +77,7 @@ const STRINGS = {
76
77
  llmRetrying: (attempt, max, reason) => `LLM 请求中断,正在重试(第 ${attempt}/${max} 次): ${reason}`,
77
78
  toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
78
79
  toolRunning: (name) => `执行 ${name}…`,
80
+ toolRunningBatch: (n) => `并行执行 ${n} 个工具…`,
79
81
  busy: '任务执行中,请稍候,或按 Ctrl+C 停止当前任务。',
80
82
  bye: '已退出 g ai',
81
83
  cleared: '对话历史已清空',
@@ -136,6 +138,7 @@ const STRINGS = {
136
138
  llmRetrying: (attempt, max, reason) => `LLM request interrupted, retrying (${attempt}/${max}): ${reason}`,
137
139
  toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
138
140
  toolRunning: (name) => `Running ${name}…`,
141
+ toolRunningBatch: (n) => `Running ${n} tools in parallel…`,
139
142
  busy: 'Agent is working. Wait or press Ctrl+C to stop the task.',
140
143
  bye: 'Bye',
141
144
  cleared: 'Conversation cleared',
@@ -293,6 +296,8 @@ ${isWin ? `- 当前是 Windows cmd.exe,以下 Unix 命令**不存在**,用了必
293
296
  - 大文件用 offset/limit 分段读取,不要一次读爆上下文
294
297
  - git 操作用 run_command 执行;提交代码可以用 git 命令,也可以用本 CLI 的 g -y(默认信息提交并推送)或 g --ai(AI 生成提交信息)
295
298
  - run_command 默认就在工作目录执行,不要再加 cd / cd /d 前缀;默认超时 120 秒,长任务加大 timeout_seconds(最大 600)
299
+ - 起 dev server / watch / 监听端口这类不会自己结束的命令,用 run_in_background=true(立刻拿到 bg_N),再用 background_task 看日志或 kill;直接前台跑只会一直占着,到超时被杀
300
+ - 一轮里要读多个文件 / 搜多处时,把它们放进同一次响应里一起发 —— 只读工具会并发执行,比一个个来回快得多
296
301
  - 命令在 ${shellDesc} 下执行,注意语法兼容
297
302
  - 先读取当前项目的规范、依赖与测试配置,再选择对应的验证命令,不要假定所有项目都使用 npm
298
303
  - 用户要求概述或简短回答时,只读取必要的结构与入口文件,控制探索范围
@@ -400,6 +405,8 @@ ${isWin ? `- This is Windows cmd.exe. The following Unix commands do NOT exist h
400
405
  - Read large files in segments (offset/limit)
401
406
  - Git operations go through run_command; to commit, use git commands or this CLI's g -y / g --ai
402
407
  - run_command already executes in the working directory — do NOT prefix with cd; default timeout 120s, raise timeout_seconds (max 600) for long tasks
408
+ - For processes that never exit on their own (dev server, watch, port listeners) pass run_in_background=true (you get a bg_N id immediately), then use background_task to read logs or kill it; running them in the foreground just blocks until the timeout kills them
409
+ - When a turn needs several files or several searches, issue them in the same response — read-only tools run concurrently, which is far faster than one round-trip each
403
410
  - Commands run under ${shellDesc}; keep syntax compatible
404
411
  - Read this project's instructions, dependencies and test configuration before choosing verification commands; do not assume npm
405
412
  - For a brief overview, inspect only the necessary structure and entry points; keep exploration proportional to the request
@@ -18,7 +18,7 @@
18
18
  // - 失败一律降级,绝不让扩展问题变成 "g ai 起不来"
19
19
  // - /cwd 切换工作目录时要能重新解析项目级扩展,同时关掉旧目录起的子进程
20
20
  //
21
- // 与 tools.js 的关系:内置 7 个工具是**底座**,永远存在。
21
+ // 与 tools.js 的关系:内置工具(见 TOOL_DEFINITIONS)是**底座**,永远存在。
22
22
  // 这里的 MCP 工具是**叠加**,名字以 mcp__ 开头,由 agent 循环按名字分流;
23
23
  // 名字不认识时返回 null,让调用方回落内置工具 —— 所以扩展全挂也不影响原有能力。
24
24
 
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
3
3
  import { streamChatOnce } from './transport.js'
4
4
  import { normalizeUsage, addUsage } from './telemetry.js'
5
5
  import { runAgentTurn } from './turn.js'
6
+ import { splitToolBatches } from './tools.js'
6
7
  import { buildRequestMessages, repairToolHistory, resolveRequestBudget } from './context.js'
7
8
  import { createAssistantWriter, stripAnsi } from './termui.js'
8
9
 
@@ -188,6 +189,8 @@ test('用户点了停止就不再重试', async () => {
188
189
  })
189
190
 
190
191
  const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
192
+ const readCall = (n, file) => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: file }) } })
193
+ const writeCall = n => ({ id: `call${n}`, type: 'function', function: { name: 'write_file', arguments: '{}' } })
191
194
  const askCall = (id = 'ask1') => ({ id, type: 'function', function: {
192
195
  name: 'ask_user',
193
196
  arguments: JSON.stringify({ question: 'Which option?', options: ['A', 'B'], allow_free_text: false }),
@@ -277,8 +280,10 @@ test('ask_user pauses a turn until the answer arrives, then continues the model
277
280
 
278
281
  test('cancellation during a tool skips the remaining batch and keeps valid tool history', async () => {
279
282
  const s = state(), executed = []
283
+ // 用写类工具验取消语义:只读工具现在会并发,取消时它们早已一起发出去了;
284
+ // 真正需要"取消后别再动"的是写文件这种有副作用的操作。
280
285
  const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet,
281
- chat: async () => ({ content: '', toolCalls: [call(1), call(2)] }),
286
+ chat: async () => ({ content: '', toolCalls: [writeCall(1), writeCall(2)] }),
282
287
  execute: async name => { executed.push(name); s.abortController.abort(); return 'first result' },
283
288
  })
284
289
  assert.equal(stats.status, 'cancelled')
@@ -324,3 +329,54 @@ test('resume marks missing tool results without replaying actions', () => {
324
329
  assert.equal(repaired[3].tool_call_id, 'call2')
325
330
  assert.match(repaired[3].content, /status is unknown/)
326
331
  })
332
+
333
+ test('consecutive read-only calls in one round run concurrently and keep call order', async () => {
334
+ const s = state()
335
+ let active = 0, maxActive = 0, round = 0
336
+ const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet,
337
+ chat: async () => (++round === 1
338
+ ? { content: '', toolCalls: [readCall(1, 'a.js'), readCall(2, 'b.js'), readCall(3, 'c.js')] }
339
+ : { content: 'done', toolCalls: [] }),
340
+ execute: async (name, args) => {
341
+ active++
342
+ maxActive = Math.max(maxActive, active)
343
+ await new Promise(resolve => setTimeout(resolve, 15))
344
+ active--
345
+ return `contents of ${args.path}`
346
+ },
347
+ })
348
+
349
+ assert.equal(stats.status, 'completed')
350
+ // 真并发:三个工具的执行区间必须重叠(串行时 maxActive 恒为 1)
351
+ assert.equal(maxActive, 3, '只读工具应该同时执行')
352
+ assert.equal(stats.toolCalls, 3)
353
+ const tools = s.messages.filter(m => m.role === 'tool')
354
+ assert.deepEqual(tools.map(m => m.tool_call_id), ['call1', 'call2', 'call3'], 'tool 消息必须按模型给的顺序入栈')
355
+ assert.deepEqual(tools.map(m => m.content), ['contents of a.js', 'contents of b.js', 'contents of c.js'])
356
+ })
357
+
358
+ test('write tools break the parallel batch instead of joining it', () => {
359
+ const batches = splitToolBatches([call(1), call(2), writeCall(3), call(4), call(5)])
360
+ assert.deepEqual(batches.map(b => [b.parallel, b.calls.length]), [[true, 2], [false, 1], [true, 2]])
361
+ // 单个只读工具没必要走并发路径(多一层 Promise 包装和聚合 spinner)
362
+ assert.deepEqual(splitToolBatches([call(1)]).map(b => b.parallel), [false])
363
+ })
364
+
365
+ test('one throwing tool in a parallel batch does not drop its siblings', async () => {
366
+ const s = state()
367
+ let round = 0
368
+ const stats = await runAgentTurn(s, 'task', strings, [], { ui: quiet,
369
+ chat: async () => (++round === 1
370
+ ? { content: '', toolCalls: [readCall(1, 'a.js'), readCall(2, 'boom.js')] }
371
+ : { content: 'done', toolCalls: [] }),
372
+ execute: async (_name, args) => {
373
+ if (args.path === 'boom.js') throw new Error('disk on fire')
374
+ return 'ok'
375
+ },
376
+ })
377
+ assert.equal(stats.status, 'completed')
378
+ const tools = s.messages.filter(m => m.role === 'tool')
379
+ assert.equal(tools.length, 2)
380
+ assert.equal(tools[0].content, 'ok')
381
+ assert.match(tools[1].content, /disk on fire/)
382
+ })
@@ -721,7 +721,16 @@ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
721
721
  }
722
722
  switch (name) {
723
723
  case 'run_command':
724
- return '$ ' + clamp(args?.command)
724
+ // 后台命令必须一眼看出来:否则卡片显示的命令和普通命令一样,用户不知道它还在跑
725
+ return (args?.run_in_background ? '[后台] $ ' : '$ ') + clamp(args?.command)
726
+ case 'background_task': {
727
+ const action = String(args?.action || 'read')
728
+ if (action === 'list') return '列出后台任务'
729
+ const id = String(args?.id || '')
730
+ if (action === 'kill') return clamp(`停止 ${id}`)
731
+ const wait = Number(args?.wait_seconds) || 0
732
+ return clamp(`${id || '?'}${wait ? ` · 等 ${wait}s` : ''}`)
733
+ }
725
734
  case 'read_file': {
726
735
  const range = (args?.offset || args?.limit)
727
736
  ? ` L${args?.offset || 1}-${(args?.offset || 1) + (args?.limit || 2000) - 1}`
@@ -752,7 +761,7 @@ export function summarizeToolArgs(name, args, { chars = '字符' } = {}) {
752
761
  export function printToolHeader(name, summary, write = (s) => process.stdout.write(s), { locale = 'zh-CN' } = {}) {
753
762
  // ▶ 保留青色作为工具执行的标识色;name 用白色加粗与下方结果区分;
754
763
  // summary 用浅灰(#a0aec0)— dim 在黑底下太暗看不清,浅灰可读且仍比 name 低调
755
- const labels = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件', list_files: '浏览目录', search_text: '搜索内容', update_plan: '更新计划' }
764
+ const labels = { run_command: '执行命令', background_task: '后台任务', read_file: '读取文件', write_file: '写入文件', edit_file: '修改文件', list_files: '浏览目录', search_text: '搜索内容', update_plan: '更新计划' }
756
765
  const rows = wrapTerminalText(summary || '', Math.max(16, Math.min(100, termWidth() - 6)))
757
766
  const label = String(locale).startsWith('en') ? name : labels[name] || name
758
767
  write('\n' + chalk.cyan('▸ ') + chalk.bold.white(label) + (label !== name ? chalk.gray(` ${name}`) : '') + '\n')
@@ -60,6 +60,12 @@ const MAX_IMAGE_BYTES = 4 * 1024 * 1024 // read_image 单张上限(原始字
60
60
  // 超了让模型自己先压缩(它手上有 run_command),比我们默默截断成半张图好。
61
61
  const CMD_TIMEOUT_DEFAULT = 120 // run_command 默认超时(秒)
62
62
  const CMD_TIMEOUT_MAX = 600 // run_command 超时上限(秒)
63
+ // 后台命令的输出缓冲上限(字符,stdout/stderr 各一份)。dev server 一晚上能吐几十兆,
64
+ // 全留着会把 agent 进程撑爆;超了丢最老的,记住丢了多少在结果里说明。
65
+ const MAX_BG_CHARS = 100000
66
+ // 同时保留的后台任务数。g ai 是长会话工具,模型忘了 kill 的命令会堆积,
67
+ // 超过就淘汰最老的**已结束**任务(还在跑的绝不淘汰,那是用户的服务)。
68
+ const MAX_BG_TASKS = 20
63
69
 
64
70
  // 已知的长耗时命令(装依赖 / 构建 / 测试 / clone …)。模型不给 timeout_seconds 时
65
71
  // 默认只有 120s,这类命令经常跑不完就被终止,模型再换个写法重试 —— 白等好几轮。
@@ -90,6 +96,36 @@ const BINARY_EXTS = new Set([
90
96
  '.map', '.jar', '.class', '.pyc',
91
97
  ])
92
98
 
99
+ // ──────────────────────────────────────────────
100
+ // 并发执行分组(CLI 的 turn.js 与 Web 智能体的 agentChat.js 共用一份口径)
101
+ // ──────────────────────────────────────────────
102
+ //
103
+ // 可以并发的工具:只读、无终端交互、彼此没有先后依赖。
104
+ // 写类(edit_file / write_file)和 run_command 绝不能进这个名单 —— 模型经常在同一轮里
105
+ // 发「write_file + run_command」表示"写完就跑",并发会把顺序打乱,命令可能在文件落盘前执行。
106
+ // 用白名单而不是黑名单:漏加一个只读工具只是慢,错加一个写工具会改错文件。
107
+ export const PARALLEL_SAFE_TOOLS = new Set([
108
+ 'read_file', 'read_image', 'list_files', 'search_text', 'list_projects',
109
+ ])
110
+
111
+ /**
112
+ * 把同一轮的 tool_calls 切成执行批次:连续的只读工具合成一个并发批,其余各自成批。
113
+ * 长度为 1 的批强制走串行路径 —— 单元素并发只会多一层 Promise 包装。
114
+ *
115
+ * @param {Array<object>} toolCalls - assistant 消息里的 tool_calls
116
+ * @returns {Array<{ parallel: boolean, calls: Array<object> }>}
117
+ */
118
+ export function splitToolBatches(toolCalls) {
119
+ const batches = []
120
+ for (const call of toolCalls || []) {
121
+ const safe = PARALLEL_SAFE_TOOLS.has(call?.function?.name)
122
+ const last = batches[batches.length - 1]
123
+ if (safe && last?.parallel) last.calls.push(call)
124
+ else batches.push({ parallel: safe, calls: [call] })
125
+ }
126
+ return batches.map(b => (b.calls.length > 1 ? b : { parallel: false, calls: b.calls }))
127
+ }
128
+
93
129
  // ──────────────────────────────────────────────
94
130
  // 工具 schema(OpenAI function calling 格式)
95
131
  // ──────────────────────────────────────────────
@@ -98,18 +134,39 @@ export const TOOL_DEFINITIONS = [
98
134
  type: 'function',
99
135
  function: {
100
136
  name: 'run_command',
101
- description: '在系统默认 shell 执行一条命令(Windows 为 cmd.exe,POSIX 为 /bin/sh)。可用于构建、测试、安装依赖、git 操作(add/commit/push)、查看系统信息等。命令以当前用户权限运行;会把系统搞崩的命令(格式化、删根目录、关机等)会被安全守卫拒绝。',
137
+ description: '在系统默认 shell 执行一条命令(Windows 为 cmd.exe,POSIX 为 /bin/sh)。可用于构建、测试、安装依赖、git 操作(add/commit/push)、查看系统信息等。命令以当前用户权限运行;会把系统搞崩的命令(格式化、删根目录、关机等)会被安全守卫拒绝。'
138
+ + '要起**长时间运行**的东西(dev server、watch、监听端口、持续日志)请传 run_in_background=true —— 它会立刻返回一个后台任务 id,你用 background_task 去看输出或停掉它;直接在前台跑这类命令会一直占着直到超时被终止。',
102
139
  parameters: {
103
140
  type: 'object',
104
141
  properties: {
105
142
  command: { type: 'string', description: '要执行的完整命令行' },
106
143
  cwd: { type: 'string', description: '执行目录,默认是智能体启动目录;可传任意绝对/相对路径' },
107
- timeout_seconds: { type: 'number', description: `超时秒数,默认 ${CMD_TIMEOUT_DEFAULT},最大 ${CMD_TIMEOUT_MAX}` },
144
+ timeout_seconds: { type: 'number', description: `超时秒数,默认 ${CMD_TIMEOUT_DEFAULT},最大 ${CMD_TIMEOUT_MAX};后台执行时忽略` },
145
+ run_in_background: { type: 'boolean', description: 'true = 后台执行,立刻拿到 bg_N 形式的任务 id,不阻塞本轮对话' },
108
146
  },
109
147
  required: ['command'],
110
148
  },
111
149
  },
112
150
  },
151
+ {
152
+ type: 'function',
153
+ function: {
154
+ name: 'background_task',
155
+ description: '查看或停止 run_command 起的后台命令。'
156
+ + 'action=read(默认):拿到**自上次读取以来**的新增输出 + 当前状态(还在跑 / 已退出 + 退出码);'
157
+ + 'action=kill:终止该后台进程;action=list:列出全部后台任务。'
158
+ + '刚起服务时输出往往还没出来,直接 read 会拿到空内容 —— 用 wait_seconds 等几秒再返回,别连续空转轮询。',
159
+ parameters: {
160
+ type: 'object',
161
+ properties: {
162
+ action: { type: 'string', enum: ['read', 'kill', 'list'], description: '要做的动作,默认 read' },
163
+ id: { type: 'string', description: 'run_command 返回的后台任务 id(形如 bg_1)。read / kill 必填' },
164
+ wait_seconds: { type: 'number', description: 'read 时最多等待多少秒(有新输出或进程退出就提前返回),默认 0,上限 60' },
165
+ },
166
+ required: [],
167
+ },
168
+ },
169
+ },
113
170
  {
114
171
  type: 'function',
115
172
  function: {
@@ -193,7 +250,9 @@ export const TOOL_DEFINITIONS = [
193
250
  type: 'function',
194
251
  function: {
195
252
  name: 'search_text',
196
- description: '搜索正则匹配的行,返回 文件:行号: 内容。path 可以是目录(递归搜全部文本文件)或单个文件。用于定位符号、关键字、调用点。',
253
+ description: '搜索正则匹配的行,返回 文件:行号: 内容。path 可以是目录(递归搜全部文本文件)或单个文件。用于定位符号、关键字、调用点。'
254
+ + '只看"这个符号出现在哪些文件"用 files_only=true(比读完内容省很多 token);'
255
+ + '要看匹配处的上下午文(判断是不是真的那个调用点)用 context_lines。',
197
256
  parameters: {
198
257
  type: 'object',
199
258
  properties: {
@@ -201,6 +260,8 @@ export const TOOL_DEFINITIONS = [
201
260
  path: { type: 'string', description: '搜索目录或单个文件路径,默认智能体启动目录' },
202
261
  ext: { type: 'string', description: '限定扩展名过滤,如 "js" 或 "js,ts,vue";不传则搜全部文本文件' },
203
262
  ignore_case: { type: 'boolean', description: '忽略大小写,默认 false' },
263
+ files_only: { type: 'boolean', description: 'true = 只返回命中的文件路径(每个文件一次),不返回行内容' },
264
+ context_lines: { type: 'number', description: '每条命中前后各显示几行上下文,默认 0,上限 5。上下文行用 "文件-行号- 内容" 标记' },
204
265
  },
205
266
  required: ['pattern'],
206
267
  },
@@ -512,6 +573,178 @@ function execCommand(command, options) {
512
573
  })
513
574
  }
514
575
 
576
+ // ──────────────────────────────────────────────
577
+ // 后台命令(run_in_background)
578
+ // ──────────────────────────────────────────────
579
+ //
580
+ // 为什么需要:起 dev server / watch / 监听端口这类命令在前台跑只会占着到超时被
581
+ // terminateCommand 杀掉 —— 模型看不到启动成功的日志,用户还得自己再起一遍。
582
+ // 后台执行让它立刻拿到输出句柄,继续干活。
583
+ //
584
+ // 与 execCommand 的区别:那边是"跑完给我结果"的 Promise,这边是"活着,随时能读"。
585
+ // 输出按 stdout/stderr 分别缓冲,读走的部分记游标 —— 每次 read 只回**新增**内容,
586
+ // 否则一个 dev server 的启动日志会被反复回喂进上下文。
587
+ //
588
+ // 生命周期:登记进 cleanup(trackChild),g ai 退出时统一终止。**不**接 ctx.onChild,
589
+ // 所以 Ctrl+C 停当前任务不会顺手杀掉用户刚起的服务。
590
+ const backgroundTasks = new Map()
591
+ let bgSeq = 0
592
+
593
+ function trimBackgroundTasks() {
594
+ if (backgroundTasks.size <= MAX_BG_TASKS) return
595
+ // Map 保持插入序:从最老的开始淘汰,但只淘汰已经结束的(还在跑的是用户的服务)
596
+ for (const [id, task] of backgroundTasks) {
597
+ if (backgroundTasks.size <= MAX_BG_TASKS) break
598
+ if (!task.running) backgroundTasks.delete(id)
599
+ }
600
+ }
601
+
602
+ function wakeBackgroundWaiters(task) {
603
+ const waiters = task.waiters
604
+ task.waiters = []
605
+ for (const resolve of waiters) resolve()
606
+ }
607
+
608
+ function spawnBackground(command, { cwd, env, onOutput }) {
609
+ const id = `bg_${++bgSeq}`
610
+ const child = spawn(command, {
611
+ cwd,
612
+ env,
613
+ shell: true,
614
+ detached: process.platform !== 'win32',
615
+ windowsHide: true,
616
+ stdio: ['ignore', 'pipe', 'pipe'],
617
+ })
618
+ const task = {
619
+ id, command, cwd, child,
620
+ stdout: '', stderr: '',
621
+ stdoutRead: 0, stderrRead: 0, // 已经回给模型的位置
622
+ dropped: 0, // 被缓冲上限挤掉的字符数
623
+ code: null, signal: null,
624
+ running: true,
625
+ startedAt: Date.now(), endedAt: null,
626
+ waiters: [],
627
+ }
628
+ // 增量解码:一次 data 事件可能切在多字节字符中间,留着尾巴等下一块
629
+ const decoders = { stdout: new TextDecoder('utf-8'), stderr: new TextDecoder('utf-8') }
630
+ const append = (stream, text) => {
631
+ if (!text) return
632
+ task[stream] += text
633
+ if (task[stream].length > MAX_BG_CHARS) {
634
+ const drop = task[stream].length - MAX_BG_CHARS
635
+ task[stream] = task[stream].slice(drop)
636
+ // 游标跟着一起往前挪,否则下次 read 会从被截掉的区域开始、吐出重复内容
637
+ task[`${stream}Read`] = Math.max(0, task[`${stream}Read`] - drop)
638
+ task.dropped += drop
639
+ }
640
+ // 实时推给界面(Web 端把它塞进那张卡的 result;CLI 不传,等于没有副作用)
641
+ if (typeof onOutput === 'function') {
642
+ try { onOutput(text) } catch { /* 推流失败不能影响进程本身 */ }
643
+ }
644
+ wakeBackgroundWaiters(task)
645
+ }
646
+ child.stdout?.on('data', buf => append('stdout', decoders.stdout.decode(buf, { stream: true })))
647
+ child.stderr?.on('data', buf => append('stderr', decoders.stderr.decode(buf, { stream: true })))
648
+ child.on('error', () => {})
649
+ child.on('close', (code, signal) => {
650
+ task.running = false
651
+ task.code = typeof code === 'number' ? code : 1
652
+ task.signal = signal || null
653
+ task.endedAt = Date.now()
654
+ // decoder 里可能还压着半个字符,进程都退了就不用等下一块了
655
+ append('stdout', decoders.stdout.decode())
656
+ append('stderr', decoders.stderr.decode())
657
+ wakeBackgroundWaiters(task)
658
+ })
659
+ trackChild(child)
660
+ backgroundTasks.set(id, task)
661
+ trimBackgroundTasks()
662
+ return task
663
+ }
664
+
665
+ /** 取出自上次读取以来的新增输出,并推进游标 */
666
+ function takeBackgroundOutput(task) {
667
+ const out = { stdout: task.stdout.slice(task.stdoutRead), stderr: task.stderr.slice(task.stderrRead) }
668
+ task.stdoutRead = task.stdout.length
669
+ task.stderrRead = task.stderr.length
670
+ return out
671
+ }
672
+
673
+ /** 等新输出或进程退出,最多 seconds 秒(到点返回,不报错) */
674
+ function waitForBackground(task, seconds) {
675
+ if (!(seconds > 0) || !task.running) return Promise.resolve()
676
+ return new Promise(resolve => {
677
+ const timer = setTimeout(done, seconds * 1000)
678
+ timer.unref?.()
679
+ function done() {
680
+ clearTimeout(timer)
681
+ task.waiters = task.waiters.filter(fn => fn !== done)
682
+ resolve()
683
+ }
684
+ task.waiters.push(done)
685
+ })
686
+ }
687
+
688
+ function describeBackground(task) {
689
+ const secs = Math.max(0, Math.round(((task.endedAt || Date.now()) - task.startedAt) / 1000))
690
+ return task.running
691
+ ? `[${task.id}] 运行中 · ${secs}s · ${task.command}`
692
+ : `[${task.id}] 已退出(exit ${task.code}${task.signal ? ` · ${task.signal}` : ''}) · ${secs}s · ${task.command}`
693
+ }
694
+
695
+ /**
696
+ * 后台任务是否还在跑。
697
+ * 给界面用:agentChat 在 background_task 返回后靠它判断"那个 bg 是不是已经结束了",
698
+ * 结束了就把原来那张"后台运行中"的卡改成完成。
699
+ * @returns {boolean|null} null = 没有这个 id 的任务
700
+ */
701
+ export function backgroundTaskRunning(id) {
702
+ const task = backgroundTasks.get(String(id || ''))
703
+ return task ? task.running : null
704
+ }
705
+
706
+ async function toolBackgroundTask(args) {
707
+ const action = String(args.action || 'read').toLowerCase()
708
+
709
+ if (action === 'list') {
710
+ if (!backgroundTasks.size) return '当前没有后台任务。用 run_command({ run_in_background: true, ... }) 起一个。'
711
+ return [...backgroundTasks.values()].map(describeBackground).join('\n')
712
+ }
713
+
714
+ const id = String(args.id || '').trim()
715
+ if (!id) return `错误: ${action} 需要 id(就是 run_command 返回的那个 bg_N)`
716
+ const task = backgroundTasks.get(id)
717
+ if (!task) return `错误: 没有找到后台任务 ${id}。用 background_task({ action: "list" }) 看现有任务。`
718
+
719
+ if (action === 'kill') {
720
+ if (!task.running) return `${describeBackground(task)}\n它已经退出了,不需要停止。`
721
+ await terminateCommand(task.child)
722
+ // taskkill 是异步的:close 事件可能在上面 await 期间就已经落定了,
723
+ // 这时候再等 waiter 会白等满 5s(实测)
724
+ if (task.running) {
725
+ await new Promise(resolve => {
726
+ const timer = setTimeout(resolve, 5000)
727
+ timer.unref?.()
728
+ task.waiters.push(() => { clearTimeout(timer); resolve() })
729
+ })
730
+ }
731
+ return `已终止 ${describeBackground(task)}`
732
+ }
733
+
734
+ if (action !== 'read') return `错误: 未知 action "${action}"(支持 read / kill / list)`
735
+
736
+ const waitSec = Math.max(0, Math.min(60, Number(args.wait_seconds) || 0))
737
+ await waitForBackground(task, waitSec)
738
+ const { stdout, stderr } = takeBackgroundOutput(task)
739
+
740
+ const parts = [describeBackground(task)]
741
+ if (task.dropped) parts.push(`[缓冲区已丢弃最老的 ${task.dropped} 字符]`)
742
+ parts.push(stdout.trim() ? `--- 新增输出 ---\n${truncateMiddle(stdout.trimEnd(), MAX_CMD_OUTPUT)}` : '(stdout 无新增输出)')
743
+ if (stderr.trim()) parts.push(`--- 新增 stderr ---\n${truncateMiddle(stderr.trimEnd(), MAX_CMD_OUTPUT)}`)
744
+ if (!task.running) parts.push(`进程已结束,退出码 ${task.code}。`)
745
+ return parts.join('\n')
746
+ }
747
+
515
748
  // ──────────────────────────────────────────────
516
749
  // 「建了仓库」的识别:run_command 之后把新项目顺手登记进常用目录
517
750
  // ──────────────────────────────────────────────
@@ -636,6 +869,19 @@ async function toolRunCommand(args, ctx) {
636
869
  return `错误: 执行目录不存在: ${cwd}`
637
870
  }
638
871
 
872
+ if (args.run_in_background) {
873
+ // PATH 补丁与前台一致:后台命令照样要能找到刚装的 CLI
874
+ const env = await augmentEnvPath({ ...process.env, FORCE_COLOR: '0' })
875
+ const task = spawnBackground(command, { cwd, env, onOutput: ctx.onOutput })
876
+ return [
877
+ `$ ${command}`,
878
+ `(后台运行 · id ${task.id} · pid ${task.child.pid || '?'})`,
879
+ `查看输出: background_task({ action: "read", id: "${task.id}", wait_seconds: 3 })`
880
+ + `(刚启动时日志还没出来,带上 wait_seconds 等一会儿)\n`
881
+ + `停止: background_task({ action: "kill", id: "${task.id}" })`,
882
+ ].join('\n')
883
+ }
884
+
639
885
  // 子进程的 PATH 每次补一次注册表最新值。
640
886
  // 为什么不能直接 {...process.env}:服务端/GUI 进程的环境块是**启动快照**,
641
887
  // 用户在这个进程活着期间装的 CLI(gh 就是这么被坑的)不在里面 —— 探测侧能找到,
@@ -857,6 +1103,8 @@ async function toolSearchText(args, ctx) {
857
1103
  const extFilter = args.ext
858
1104
  ? new Set(String(args.ext).split(',').map(e => e.trim().replace(/^\./, '').toLowerCase()).filter(Boolean))
859
1105
  : null
1106
+ const filesOnly = args.files_only === true
1107
+ const contextLines = Math.max(0, Math.min(5, Number(args.context_lines) || 0))
860
1108
 
861
1109
  const hits = []
862
1110
  let scanned = 0
@@ -868,11 +1116,23 @@ async function toolSearchText(args, ctx) {
868
1116
  let text
869
1117
  try { text = await fs.readFile(full, 'utf-8') } catch { return }
870
1118
  scanned++
1119
+ const rel = path.relative(ctx.cwd, full) || full
871
1120
  const lines = text.split('\n')
872
1121
  for (let i = 0; i < lines.length && hits.length < MAX_SEARCH_HITS; i++) {
873
1122
  regex.lastIndex = 0
874
- if (regex.test(lines[i])) {
875
- hits.push(`${path.relative(ctx.cwd, full) || full}:${i + 1}: ${lines[i].trim().slice(0, 300)}`)
1123
+ if (!regex.test(lines[i])) continue
1124
+ // 只要文件名:同一文件命中多次也只记一条,否则 100 条上限全被一个文件吃掉
1125
+ if (filesOnly) { hits.push(rel); break }
1126
+ if (contextLines === 0) {
1127
+ hits.push(`${rel}:${i + 1}: ${lines[i].trim().slice(0, 300)}`)
1128
+ continue
1129
+ }
1130
+ const from = Math.max(0, i - contextLines)
1131
+ const to = Math.min(lines.length - 1, i + contextLines)
1132
+ for (let j = from; j <= to && hits.length < MAX_SEARCH_HITS; j++) {
1133
+ // 命中行用 `:N:`、上下文行用 `-N-`,与本仓库其他探针脚本的 grep 习惯一致
1134
+ const sep = j === i ? ':' : '-'
1135
+ hits.push(`${rel}:${j + 1}${sep} ${lines[j].trim().slice(0, 300)}`)
876
1136
  }
877
1137
  }
878
1138
  }
@@ -1142,6 +1402,7 @@ async function toolScheduleTask(args, ctx) {
1142
1402
  // ──────────────────────────────────────────────
1143
1403
  const TOOL_HANDLERS = {
1144
1404
  run_command: toolRunCommand,
1405
+ background_task: toolBackgroundTask,
1145
1406
  read_file: toolReadFile,
1146
1407
  read_image: toolReadImage,
1147
1408
  write_file: toolWriteFile,
@@ -193,6 +193,30 @@ test('search_text 非法正则不抛异常', async () => {
193
193
  assert.match(r, /正则无效/)
194
194
  })
195
195
 
196
+ test('search_text 的 files_only 只回文件名,context_lines 带上下文并区分命中行', async () => {
197
+ await fs.mkdir(path.join(tmpDir, 'ctxcase'), { recursive: true })
198
+ await fs.writeFile(path.join(tmpDir, 'ctxcase/one.js'), 'alpha\nbeta\nTARGET\ndelta\nepsilon')
199
+ await fs.writeFile(path.join(tmpDir, 'ctxcase/two.js'), 'TARGET\ngamma\nTARGET')
200
+
201
+ // files_only:一个文件只记一条(否则同文件多次命中会把 100 条上限吃光),不吐行内容
202
+ const files = await executeTool('search_text', { pattern: 'TARGET', path: 'ctxcase', files_only: true }, ctx)
203
+ assert.equal(files.trim().split('\n').length, 2)
204
+ assert.match(files, /one\.js/)
205
+ assert.match(files, /two\.js/)
206
+ assert.doesNotMatch(files, /beta/)
207
+
208
+ // 命中行 `:N:`、上下文行 `:N-`(行号前固定冒号,与 ripgrep 一致);窗口外的行不该混进来
209
+ const withCtx = await executeTool('search_text', { pattern: 'TARGET', path: 'ctxcase/one.js', context_lines: 1 }, ctx)
210
+ assert.match(withCtx, /one\.js:2- beta/)
211
+ assert.match(withCtx, /one\.js:3: TARGET/)
212
+ assert.match(withCtx, /one\.js:4- delta/)
213
+ assert.doesNotMatch(withCtx, /epsilon/)
214
+
215
+ // 不传 context_lines 时输出格式不变(默认 0 不能退化成带上下文)
216
+ const plain = await executeTool('search_text', { pattern: 'TARGET', path: 'ctxcase/one.js' }, ctx)
217
+ assert.match(plain.trim(), /^ctxcase[\\/]one\.js:3: TARGET$/)
218
+ })
219
+
196
220
  // ========== run_command ==========
197
221
 
198
222
  test('run_command 执行成功并返回输出与退出码', async () => {
@@ -574,3 +598,60 @@ test('register_project:目录不存在 / 未注入实现 / 空路径都给可
574
598
  // CLI 下没有「常用目录」这个概念 —— 要回一句说明,而不是崩掉
575
599
  assert.match(await executeTool('register_project', { path: tmpDir }, ctx), /只在 g ui/)
576
600
  })
601
+
602
+ // ──────────────────────────────────────────────
603
+ // 后台命令:起服务不该占着前台等到超时
604
+ // ──────────────────────────────────────────────
605
+ test('run_command(run_in_background) 立刻返回 id,background_task 能读到输出且只回新增部分', async () => {
606
+ // 注意引号层次:外层双引号是给 cmd 的,JS 代码里只能用单引号,
607
+ // 否则 cmd 会在内层双引号处截断(实测 exit 1 + 无输出)
608
+ const script = "console.log('boot ok'); setTimeout(() => console.log('second line'), 120)"
609
+ const started = await executeTool('run_command',
610
+ { command: `"${process.execPath}" -e "${script}"`, run_in_background: true }, ctx)
611
+ const id = (started.match(/\bbg_\d+\b/) || [])[0]
612
+ assert.ok(id, `后台结果里应该带任务 id,实际: ${started}`)
613
+ assert.match(started, /background_task/)
614
+
615
+ const first = await executeTool('background_task', { action: 'read', id, wait_seconds: 10 }, ctx)
616
+ assert.match(first, /boot ok/)
617
+
618
+ // 增量语义:读走的内容不再重复回吐,否则 dev server 日志会被反复喂进上下文。
619
+ // 判据只能看「新增输出」段落 —— 整个返回值里还带着命令行原文(含 boot ok 字样),直接对全文断言会自命中。
620
+ await new Promise(r => setTimeout(r, 200))
621
+ const second = await executeTool('background_task', { action: 'read', id, wait_seconds: 10 }, ctx)
622
+ const secondBody = second.split('--- 新增输出 ---')[1] || ''
623
+ assert.doesNotMatch(secondBody, /boot ok/)
624
+ assert.match(secondBody, /second line/)
625
+
626
+ // 进程跑完了要如实报退出码,模型才知道"服务挂了"还是"还活着"
627
+ assert.match(second, /exit 0/)
628
+
629
+ // 读干净之后再读:如实说没有新输出,而不是把旧内容再吐一遍
630
+ const third = await executeTool('background_task', { action: 'read', id }, ctx)
631
+ assert.match(third, /stdout 无新增输出/)
632
+
633
+ const list = await executeTool('background_task', { action: 'list' }, ctx)
634
+ assert.match(list, new RegExp(id))
635
+ })
636
+
637
+ test('background_task 的 kill 会真的结束进程,未知 id / 空 id 给可照做的提示', async () => {
638
+ // 起一个不会自己退出的进程
639
+ const started = await executeTool('run_command',
640
+ { command: `"${process.execPath}" -e "setInterval(() => {}, 1000)"`, run_in_background: true }, ctx)
641
+ const id = (started.match(/\bbg_\d+\b/) || [])[0]
642
+ assert.ok(id)
643
+
644
+ // 起来之后立刻读,应该是 running
645
+ const running = await executeTool('background_task', { action: 'read', id }, ctx)
646
+ assert.match(running, /运行中/)
647
+
648
+ const killed = await executeTool('background_task', { action: 'kill', id }, ctx)
649
+ assert.match(killed, /已终止/)
650
+
651
+ const after = await executeTool('background_task', { action: 'read', id }, ctx)
652
+ assert.match(after, /已退出/)
653
+
654
+ assert.match(await executeTool('background_task', { action: 'read' }, ctx), /需要 id/)
655
+ assert.match(await executeTool('background_task', { action: 'read', id: 'bg_99999' }, ctx), /没有找到/)
656
+ assert.match(await executeTool('background_task', { action: 'nope', id }, ctx), /未知 action/)
657
+ })
@@ -116,7 +116,7 @@ export async function streamChatOnce({ model, messages, signal, onToken = () =>
116
116
  maxRetries = MAX_LLM_RETRIES, sleepFn = defaultSleep }) {
117
117
  const { url, headers } = buildAiChatRequest({ ...model, sessionId })
118
118
  const providerKey = `${url}\n${model.model}`
119
- // 内置 7 个工具是底座;extraTools 是 MCP 等外部能力的叠加(见 extensions.js)。
119
+ // 内置工具(TOOL_DEFINITIONS)是底座;extraTools 是 MCP 等外部能力的叠加(见 extensions.js)。
120
120
  // 扩展工具为空时**必须回落到原始数组**,避免把 tools: [] 发给模型。
121
121
  const tools = Array.isArray(extraTools) && extraTools.length ? [...TOOL_DEFINITIONS, ...extraTools] : TOOL_DEFINITIONS
122
122
  const body = { model: model.model, messages, tools, temperature: 0.3, stream: true }