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.
Files changed (71) hide show
  1. package/package.json +2 -1
  2. package/src/cli/ai/agent.js +7 -0
  3. package/src/cli/ai/runtime.test.js +22 -1
  4. package/src/cli/ai/tools.js +1 -1
  5. package/src/cli/ai/tools.test.js +1 -1
  6. package/src/cli/ai/transport.js +8 -3
  7. package/src/config.js +7 -5
  8. package/src/mcp/tools/workbench.js +1 -1
  9. package/src/ui/public/assets/{AgentView-DZx3n-So.css → AgentView-CV_8sYX6.css} +1 -1
  10. package/src/ui/public/assets/AgentView-DxmnqJxE.js +1 -0
  11. package/src/ui/public/assets/{AppVersionBadge-Bg5lrtKb.js → AppVersionBadge-C4OhcUi8.js} +2 -2
  12. package/src/ui/public/assets/{BranchSelector-CxjTqAkl.js → BranchSelector-CrLieQyJ.js} +1 -1
  13. package/src/ui/public/assets/{CommitForm-BWWrOvj5.js → CommitForm-KGQisnPQ.js} +2 -2
  14. package/src/ui/public/assets/{CommonDialog-x5jfL4Lq.js → CommonDialog-j_53ORvj.js} +1 -1
  15. package/src/ui/public/assets/CopySessionButton-y5Rzmact.js +20 -0
  16. package/src/ui/public/assets/EditorView-CkZb2kw5.js +1 -0
  17. package/src/ui/public/assets/{EditorView-BUiVsBv7.css → EditorView-Ims2BUK_.css} +1 -1
  18. package/src/ui/public/assets/{FlowExecutionViewer-BkZ20jxN.js → FlowExecutionViewer-CQ-TPhwF.js} +1 -1
  19. package/src/ui/public/assets/{FlowOrchestrationWorkspace-aZBYu29g.js → FlowOrchestrationWorkspace-BMQSVm3V.js} +1 -1
  20. package/src/ui/public/assets/{LogList-CSIbmUMK.js → LogList-BxH6QNlK.js} +4 -4
  21. package/src/ui/public/assets/{MindmapView-JYLp185X.js → MindmapView-D9_qjJsR.js} +1 -1
  22. package/src/ui/public/assets/{MonitorView-BS2Mh-I7.js → MonitorView-z_fsnE8Y.js} +1 -1
  23. package/src/ui/public/assets/{ProjectStartupButton-DAEP6ZU5.js → ProjectStartupButton-CFB_JpYG.js} +1 -1
  24. package/src/ui/public/assets/RecentDirectoriesChat-7fzSkSmc.css +1 -0
  25. package/src/ui/public/assets/RecentDirectoriesChat-BYI8lkjQ.js +1 -0
  26. package/src/ui/public/assets/{RemoteManagerDialog-aaNmsKpy.js → RemoteManagerDialog-DXDcN8e7.js} +1 -1
  27. package/src/ui/public/assets/{RemoteRepoCard-DndI8rNZ.js → RemoteRepoCard-C_TYGsXi.js} +1 -1
  28. package/src/ui/public/assets/{SourceMapView-BTD9-R8t.js → SourceMapView-D6cSweDt.js} +1 -1
  29. package/src/ui/public/assets/{SvgIcon-0n6uuKFm.js → SvgIcon-5AlG3IfR.js} +1 -1
  30. package/src/ui/public/assets/{UserInputNode-B-mDg9Wy.js → UserInputNode-BKP869D6.js} +1 -1
  31. package/src/ui/public/assets/WorkbenchView-D4UDurhF.js +6 -0
  32. package/src/ui/public/assets/WorkbenchView-DogERhHQ.css +1 -0
  33. package/src/ui/public/assets/{_plugin-vue_export-helper-C3o7kvRL.js → _plugin-vue_export-helper-Csn085FF.js} +4 -4
  34. package/src/ui/public/assets/agentConversations-DyzaxUXm.js +10 -0
  35. package/src/ui/public/assets/configStore-DOaPZ6Mq.js +1 -0
  36. package/src/ui/public/assets/index-C7Y9odvZ.js +57 -0
  37. package/src/ui/public/assets/index-NYzaTfbS.css +1 -0
  38. package/src/ui/public/assets/{useOrchestrator-CS9sn7nV.js → useOrchestrator-C236G5P5.js} +1 -1
  39. package/src/ui/public/index.html +6 -6
  40. package/src/ui/server/index.js +8 -0
  41. package/src/ui/server/routes/fileOpen.js +57 -1
  42. package/src/ui/server/routes/fileOpen.test.js +1 -1
  43. package/src/ui/server/routes/workbench/agentChat.js +70 -8
  44. package/src/ui/server/routes/workbench/agentChatExtensions.test.js +290 -0
  45. package/src/ui/server/routes/workbench/agentEngines.js +8 -4
  46. package/src/ui/server/routes/workbench/agentExtensions.js +157 -0
  47. package/src/ui/server/routes/workbench/agentMarketplace.js +12 -0
  48. package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
  49. package/src/ui/server/routes/workbench/agentRoutes.js +82 -7
  50. package/src/ui/server/routes/workbench/agentRoutes.test.js +141 -1
  51. package/src/ui/server/routes/workbench/executorModels.js +30 -5
  52. package/src/ui/server/routes/workbench/executorModels.test.js +16 -4
  53. package/src/ui/server/routes/workbench/index.js +1 -1
  54. package/src/ui/server/routes/workbench/jobActivity.js +3 -3
  55. package/src/ui/server/routes/workbench/jobActivity.test.js +1 -0
  56. package/src/ui/server/routes/workbench/runExternalTurn.js +22 -25
  57. package/src/ui/server/routes/workbench/runExternalTurn.test.js +34 -0
  58. package/src/ui/server/routes/workbench/taskRunner.codebuddy.test.js +275 -0
  59. package/src/ui/server/routes/workbench/taskRunner.js +311 -24
  60. package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +2 -1
  61. package/src/ui/public/assets/AgentView-BRAUZCtA.js +0 -1
  62. package/src/ui/public/assets/CopySessionButton-D0wFpk8a.js +0 -20
  63. package/src/ui/public/assets/EditorView-DmaGiI-z.js +0 -1
  64. package/src/ui/public/assets/RecentDirectoriesChat-B19uAf8k.css +0 -1
  65. package/src/ui/public/assets/RecentDirectoriesChat-CVFTShrU.js +0 -1
  66. package/src/ui/public/assets/WorkbenchView-CfSrCIbp.js +0 -8
  67. package/src/ui/public/assets/WorkbenchView-D6NRqZ5W.css +0 -1
  68. package/src/ui/public/assets/agentConversations-DpiGplZB.js +0 -8
  69. package/src/ui/public/assets/configStore-BdL3uW2E.js +0 -1
  70. package/src/ui/public/assets/index-BS7BDFG4.js +0 -57
  71. package/src/ui/public/assets/index-DwJWiCiN.css +0 -1
@@ -0,0 +1,290 @@
1
+ // Web 端扩展层(Skill + MCP)接入 runAgentTurn 的回归测试。
2
+ //
3
+ // 钉住的行为(2026-10-12 修的问题):MCP 广场显示"已安装",但 Web 智能体
4
+ // 对话/定时任务的工具列表里就是没有那些工具 —— AgentExtensions.load 原先
5
+ // 只有 CLI 的 g ai 调。现在 Web 侧也加载,这里跑真实的 runAgentTurn,
6
+ // 把请求体与事件抓下来逐条钉住:
7
+ // 1) MCP 工具定义随请求下发(mcp__ 前缀),系统提示词出现 MCP 一节;
8
+ // 2) mcp__ 调用分流到 MCP server,内置工具照常走 executeTool;
9
+ // 3) 连接失败只降级(该 server 没工具),轮次照常完成;
10
+ // 4) 同一 cwd 只握手一次(进程内缓存),invalidate 后按新配置重载;
11
+ // 5) 广场安装路由走完,下一轮对话就吃得到(不等 g gui 重启);
12
+ // 6) 扩展摘要只进请求副本,不腌进 session.messages(老会话也吃得到)。
13
+ //
14
+ // 注意:必须在 import agentChat.js **之前**把 USERPROFILE/HOME 指到沙箱,否则
15
+ // paths.js 在模块加载时用 os.homedir() 求值,会去读真实的 ~/.zen-gitsync。
16
+ import { test } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
19
+ import http from 'node:http'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ const SANDBOX = mkdtempSync(path.join(os.tmpdir(), 'zen-agent-ext-'))
24
+ process.env.USERPROFILE = SANDBOX
25
+ process.env.HOME = SANDBOX
26
+ delete process.env.HOMEDRIVE
27
+ delete process.HOMEPATH
28
+
29
+ const DATA_DIR = path.join(SANDBOX, '.zen-gitsync')
30
+ const MCP_FILE = path.join(DATA_DIR, 'ai', 'mcp.json')
31
+
32
+ // ── 脚手架 ────────────────────────────────────────────────────
33
+
34
+ const event = data => `data: ${JSON.stringify(data)}\n\n`
35
+ const sse = chunks => new Response(new ReadableStream({
36
+ start(controller) {
37
+ const bytes = new TextEncoder()
38
+ chunks.forEach(chunk => controller.enqueue(bytes.encode(chunk)))
39
+ controller.close()
40
+ },
41
+ }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
42
+
43
+ const model = { model: 'test', name: 'test', baseURL: 'https://example.invalid/v1', apiKey: 'test' }
44
+ const okStream = () => sse([event({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }), 'data: [DONE]\n\n'])
45
+ const withSystem = () => [{ role: 'system', content: 'rules' }, { role: 'user', content: 'hi' }]
46
+ const newSession = (sessionId, messages) => ({ sessionId, messages, cwd: process.cwd() })
47
+
48
+ // 跑一轮真实的 runAgentTurn,桩掉 globalThis.fetch —— 抓住发出去的请求体与事件。
49
+ // ⚠️ MCP 的连接也走 globalThis.fetch(HttpConnection),所以这个桩按 URL 分流:
50
+ // 打 127.0.0.1 的(桩 server)放真 fetch 过去,其余(LLM)抓请求体 + 回灌 SSE。
51
+ async function runWeb({ session, userMessage, respond }) {
52
+ const { runAgentTurn } = await import('./agentChat.js')
53
+ const { streamChatOnce } = await import('../../../../cli/ai/transport.js')
54
+ const events = []
55
+ let sent
56
+ const realFetch = globalThis.fetch
57
+ globalThis.fetch = async (url, init) => {
58
+ if (String(url).startsWith('http://127.0.0.1')) return realFetch(url, init)
59
+ sent = JSON.parse(init.body)
60
+ return respond(init)
61
+ }
62
+ try {
63
+ await runAgentTurn({
64
+ session, model, userMessage, locale: 'zh-CN', cwd: process.cwd(),
65
+ signal: new AbortController().signal,
66
+ send: e => events.push(e),
67
+ streamFn: opts => streamChatOnce({ ...opts, maxRetries: 0 }),
68
+ })
69
+ return { sent, events }
70
+ } finally {
71
+ globalThis.fetch = realFetch
72
+ }
73
+ }
74
+
75
+ // 最小 Streamable HTTP MCP server 桩:initialize / tools/list / tools/call。
76
+ // 每个响应都带 Mcp-Session-Id,顺带覆盖 HttpConnection 的会话关闭(DELETE)路径。
77
+ function startStubMcp({ onHandshake, onCall, onClose } = {}) {
78
+ const server = http.createServer((req, res) => {
79
+ const chunks = []
80
+ req.on('data', c => chunks.push(c))
81
+ req.on('end', () => {
82
+ if (req.method === 'DELETE') {
83
+ onClose?.()
84
+ res.writeHead(204)
85
+ res.end()
86
+ return
87
+ }
88
+ let msg = {}
89
+ try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch { /* 通知类可能没体 */ }
90
+ const reply = result => {
91
+ res.writeHead(200, {
92
+ 'content-type': 'application/json',
93
+ 'mcp-session-id': `sess-${Math.random().toString(36).slice(2, 8)}`,
94
+ })
95
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: msg.id ?? null, result }))
96
+ }
97
+ if (msg.method === 'initialize') {
98
+ onHandshake?.()
99
+ reply({ protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'stub', version: '0' } })
100
+ } else if (msg.method === 'tools/list') {
101
+ reply({ tools: [{ name: 'echo', description: '回声工具', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }] })
102
+ } else if (msg.method === 'tools/call') {
103
+ onCall?.(msg.params)
104
+ reply({ content: [{ type: 'text', text: `stub 收到: ${msg.params?.arguments?.text ?? ''}` }] })
105
+ } else {
106
+ reply({}) // notifications/initialized 等
107
+ }
108
+ })
109
+ })
110
+ return new Promise((resolve) => {
111
+ server.listen(0, '127.0.0.1', () => resolve({
112
+ url: `http://127.0.0.1:${server.address().port}/api/mcp`,
113
+ close: () => new Promise(r => server.close(r)),
114
+ }))
115
+ })
116
+ }
117
+
118
+ function writeMcpFile(url) {
119
+ mkdirSync(path.dirname(MCP_FILE), { recursive: true })
120
+ writeFileSync(MCP_FILE, JSON.stringify({ mcpServers: { stub: { name: 'Stub', type: 'http', url } } }, null, 2))
121
+ }
122
+
123
+ const hasMcpTools = sent => sent.tools.some(t => String(t.function?.name || '').startsWith('mcp__'))
124
+
125
+ // 每个用例开工前清一遍:上一条用例的 finally 也该清,双保险 ——
126
+ // 缓存是模块级的,残留一份配置会让下一条用例莫名其妙吃到 MCP。
127
+ async function resetExtensions() {
128
+ rmSync(MCP_FILE, { force: true })
129
+ const { invalidateWebExtensions } = await import('./agentExtensions.js')
130
+ await invalidateWebExtensions()
131
+ }
132
+
133
+ // ── 挂载与分发 ────────────────────────────────────────────────
134
+
135
+ test('MCP 工具挂进请求、按 mcp__ 名字分发到 server,内置工具不受影响', async () => {
136
+ let handshakes = 0
137
+ const stub = await startStubMcp({ onHandshake: () => handshakes++ })
138
+ try {
139
+ await resetExtensions()
140
+ writeMcpFile(stub.url)
141
+
142
+ // 第一轮:模型同时调一个 MCP 工具和一个内置工具 —— 分流是否正确一轮就看出来
143
+ const round1 = sse([event({
144
+ choices: [{
145
+ delta: { tool_calls: [
146
+ { index: 0, id: 'm1', function: { name: 'mcp__stub__echo', arguments: JSON.stringify({ text: '你好' }) } },
147
+ { index: 1, id: 'p1', function: { name: 'update_plan', arguments: JSON.stringify({ steps: [{ content: '一步', status: 'completed' }] }) } },
148
+ ] },
149
+ finish_reason: 'tool_calls',
150
+ }],
151
+ }), 'data: [DONE]\n\n'])
152
+ let turn = 0
153
+ const session = newSession('ag-ext-dispatch', withSystem())
154
+
155
+ const { sent, events } = await runWeb({
156
+ session, userMessage: '调用 mcp', respond: () => (turn++ === 0 ? round1 : okStream()),
157
+ })
158
+
159
+ // ① 请求里带着 MCP 工具定义(transport 把 extraTools 并进 tools)
160
+ const mcpTool = sent.tools.find(t => t.function?.name === 'mcp__stub__echo')
161
+ assert.ok(mcpTool, 'MCP 工具必须随请求下发')
162
+ assert.match(mcpTool.function.description, /回声/)
163
+ assert.equal(mcpTool.type, 'function')
164
+ // 内置工具一个没少
165
+ assert.ok(sent.tools.some(t => t.function?.name === 'update_plan'), '内置工具表必须还在')
166
+ // ② 系统提示词里出现 MCP 一节,且点名 mcp__ 前缀
167
+ assert.match(sent.messages[0].content, /已接入的 MCP 服务/)
168
+ assert.match(sent.messages[0].content, /mcp__/)
169
+ // ③ MCP 调用真的走到了 stub server
170
+ assert.equal(handshakes, 1)
171
+ const mcpResult = events.find(e => e.type === 'tool_result' && e.name === 'mcp__stub__echo')
172
+ assert.ok(mcpResult, 'MCP 调用必须产出 tool_result')
173
+ assert.match(mcpResult.result, /stub 收到: 你好/)
174
+ // ④ 同一轮里的内置工具照常执行(分流没有把内置的也吞掉)
175
+ const planResult = events.find(e => e.type === 'tool_result' && e.name === 'update_plan')
176
+ assert.match(planResult.result, /计划已更新/)
177
+ // ⑤ 两个结果都进了会话历史(重开会话能回放)
178
+ const toolNames = session.messages.filter(m => m.role === 'tool').map(m => m.name)
179
+ assert.deepEqual(toolNames, ['mcp__stub__echo', 'update_plan'])
180
+ // ⑥ 摘要只进请求副本,不写回 session.messages
181
+ assert.equal(session.messages[0].content, 'rules', '扩展摘要不该腌进磁盘历史')
182
+ } finally {
183
+ await resetExtensions()
184
+ await stub.close()
185
+ }
186
+ })
187
+
188
+ // ── 降级 ──────────────────────────────────────────────────────
189
+
190
+ test('MCP 连接失败只降级:轮次照常完成,请求里不带 MCP 工具', async () => {
191
+ // 占一个端口再松开,拿一个一定没人听的地址
192
+ const stub = await startStubMcp()
193
+ const deadUrl = stub.url
194
+ await stub.close()
195
+ try {
196
+ await resetExtensions()
197
+ writeMcpFile(deadUrl)
198
+ const session = newSession('ag-ext-dead', withSystem())
199
+
200
+ const { sent, events } = await runWeb({ session, userMessage: 'hello', respond: okStream })
201
+
202
+ assert.ok(!events.some(e => e.type === 'error'), '连接失败不该把轮次打挂')
203
+ assert.equal(hasMcpTools(sent), false, '连不上的 server 不该贡献工具定义')
204
+ assert.doesNotMatch(sent.messages[0].content, /已接入的 MCP 服务/)
205
+ assert.equal(session.messages[session.messages.length - 1].content, 'ok', '轮次要正常收尾')
206
+ } finally {
207
+ await resetExtensions()
208
+ }
209
+ })
210
+
211
+ // ── 缓存与失效 ────────────────────────────────────────────────
212
+
213
+ test('同 cwd 只握手一次(进程内缓存),invalidate 后按新配置重新加载', async () => {
214
+ let handshakes = 0
215
+ let deletes = 0
216
+ const stub = await startStubMcp({ onHandshake: () => handshakes++, onClose: () => deletes++ })
217
+ try {
218
+ await resetExtensions()
219
+ // 没配 MCP:第一轮不该有 mcp 工具,也不该发生握手
220
+ const s1 = newSession('ag-ext-cache-1', withSystem())
221
+ const r1 = await runWeb({ session: s1, userMessage: 'hello', respond: okStream })
222
+ assert.equal(handshakes, 0)
223
+ assert.equal(hasMcpTools(r1.sent), false)
224
+
225
+ // 模拟"广场装完":写配置 + 失效(这一路正是 agentMarketplace.js 调的)
226
+ writeMcpFile(stub.url)
227
+ const { invalidateWebExtensions } = await import('./agentExtensions.js')
228
+ await invalidateWebExtensions()
229
+
230
+ // 第二轮:立刻吃得到,不等重启
231
+ const s2 = newSession('ag-ext-cache-2', withSystem())
232
+ const r2 = await runWeb({ session: s2, userMessage: 'hello', respond: okStream })
233
+ assert.equal(handshakes, 1, '失效后必须重新加载')
234
+ assert.equal(hasMcpTools(r2.sent), true)
235
+ assert.match(r2.sent.messages[0].content, /已接入的 MCP 服务/)
236
+
237
+ // 第三轮:同一 cwd 走缓存,不再握手
238
+ const s3 = newSession('ag-ext-cache-3', withSystem())
239
+ const r3 = await runWeb({ session: s3, userMessage: 'hello', respond: okStream })
240
+ assert.equal(handshakes, 1, '缓存生效时不能反复握手')
241
+ assert.equal(hasMcpTools(r3.sent), true)
242
+
243
+ // 失效时旧连接要真的关掉(HTTP 侧表现为 DELETE 到过 server)
244
+ await invalidateWebExtensions()
245
+ assert.ok(deletes >= 1, 'invalidate 必须关闭旧连接(DELETE 结束会话)')
246
+ } finally {
247
+ await resetExtensions()
248
+ await stub.close()
249
+ }
250
+ })
251
+
252
+ // ── 广场安装路由 → 对话生效(端到端) ───────────────────────────
253
+
254
+ test('广场安装路由走完后,下一轮对话就挂上该 MCP(不等 g gui 重启)', async () => {
255
+ let handshakes = 0
256
+ const stub = await startStubMcp({ onHandshake: () => handshakes++ })
257
+ try {
258
+ await resetExtensions()
259
+ const s1 = newSession('ag-ext-mp-1', withSystem())
260
+ const r1 = await runWeb({ session: s1, userMessage: 'hello', respond: okStream })
261
+ assert.equal(hasMcpTools(r1.sent), false)
262
+
263
+ // 走真实的注册路由(harness 抄自 agentMarketplace.test.js 的打法):
264
+ // 远程端点型 MCP 只写配置文件,不联网
265
+ const { registerAgentMarketplaceRoutes } = await import('./agentMarketplace.js')
266
+ const handlers = new Map()
267
+ const app = {
268
+ get: (r, h) => handlers.set(`GET ${r}`, h),
269
+ post: (r, h) => handlers.set(`POST ${r}`, h),
270
+ delete: (r, h) => handlers.set(`DELETE ${r}`, h),
271
+ }
272
+ registerAgentMarketplaceRoutes({ app, getCurrentProjectPath: () => process.cwd() })
273
+ let payload = null
274
+ const res = { status() { return this }, json(data) { payload = data; return this } }
275
+ await handlers.get('POST /api/agent/marketplace/install')({
276
+ body: { type: 'mcp', target: 'global', cwd: process.cwd(), item: { id: 'stub', name: 'Stub', remoteUrl: stub.url } },
277
+ }, res)
278
+ assert.equal(payload?.success, true, '安装路由要报成功')
279
+ assert.ok(payload?.item?.file, '安装结果要带回落盘路径')
280
+
281
+ // 下一轮对话:工具表里必须有它,且握手真的发生过
282
+ const s2 = newSession('ag-ext-mp-2', withSystem())
283
+ const r2 = await runWeb({ session: s2, userMessage: 'hello', respond: okStream })
284
+ assert.equal(handshakes, 1)
285
+ assert.equal(hasMcpTools(r2.sent), true, '广场装完必须立刻在对话里生效')
286
+ } finally {
287
+ await resetExtensions()
288
+ await stub.close()
289
+ }
290
+ })
@@ -19,15 +19,18 @@
19
19
  // gai ── 内置。**服务端**跑循环:LLM 流式 + 服务端执行工具(src/cli/ai/tools.js),
20
20
  // 前端只渲染事件。默认引擎,历史行为。
21
21
  //
22
- // claude / opencode / codex ── 外部 CLI。**CLI 自己**跑循环:它有自己的工具集、
22
+ // claude / opencode / codex / codebuddy ── 外部 CLI。**CLI 自己**跑循环:它有自己的工具集、
23
23
  // 自己的系统提示词、自己的权限模型。服务端退化成一根管子 —— spawn 出来、
24
24
  // 把它的 NDJSON 翻译成同一组 SSE 事件。见 runExternalTurn.js。
25
+ // (codebuddy = 腾讯 CodeBuddy Code CLI,即 WorkBuddy 桌面版的引擎,
26
+ // 2026-10-12 加入;协议与 claude 的 stream-json 对齐。)
25
27
  //
26
- // ⚠️ 这儿**不新写一份 id 清单**:外部三家直接复用 config.js 的 TASK_EXECUTORS。
28
+ // ⚠️ 这儿**不新写一份 id 清单**:外部四家直接复用 config.js 的 TASK_EXECUTORS。
27
29
  // 本仓库为"同一份口径在多个地方各写一遍"吃过亏(见 agentParity.test.js 的注释:
28
30
  // 提示词四处、路径归一三处,分叉的失效方式是「不报错,两个入口表现不一样」)。
29
- // 工作台那份是任务执行器,这里是对话引擎,但**底层是同一批 CLI**,加第四家时
30
- // 应当只改 config.js 一处。有单测钉住这条。
31
+ // 工作台那份是任务执行器,这里是对话引擎,但**底层是同一批 CLI**,加第五家时
32
+ // 应当只改 config.js 一处(taskRunner.js 的运行时分发表是第二处,见那里的注释)。
33
+ // 有单测钉住这条。
31
34
 
32
35
  import { TASK_EXECUTORS } from '../../../../config.js';
33
36
 
@@ -49,6 +52,7 @@ export const ENGINE_LABELS = {
49
52
  claude: 'Claude Code',
50
53
  opencode: 'OpenCode',
51
54
  codex: 'Codex',
55
+ codebuddy: 'CodeBuddy Code',
52
56
  };
53
57
 
54
58
  export function isAgentEngine(value) {
@@ -0,0 +1,157 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // Web 端(g ui 服务端)的扩展层(Skill 清单 + MCP 工具)加载缓存。
16
+ //
17
+ // 背景:AgentExtensions.load 原先只有 CLI 的 `g ai` 调(agent.js 启动时一次),
18
+ // 而 Web 智能体面板的 runAgentTurn 与定时任务的执行体共用同一个函数却不加载
19
+ // 扩展 —— 表现就是「MCP 广场显示已安装,对话框里就是没有那些工具」
20
+ // (2026-10-12 用户实测:agent 只能自己读 mcp.json 按 HTTP 协议直调)。
21
+ // 这个模块把同一份加载逻辑接到 Web 侧,并按 cwd 做进程内缓存。
22
+ //
23
+ // 为什么按 cwd 缓存而不是每轮加载:
24
+ // - MCP 握手不便宜:stdio 要起子进程(npx 首次还要下载包,实测 20s+),
25
+ // HTTP 也要 initialize + tools/list 两个往返。每轮都付一次的话,
26
+ // 装了两三个 MCP 的用户每轮对话都要先等好几秒。
27
+ // - CLI 的口径是「进程启动时加载一次,/cwd 切换时重载」。Web 没有进程重启
28
+ // 这个自然边界,用缓存等价实现:同一 cwd 只加载一次,配置变化靠失效。
29
+ //
30
+ // 缓存什么时候失效:
31
+ // - 广场安装/卸载(agentMarketplace.js 调 invalidateWebExtensions)——
32
+ // 不接这一路的话,刚装的 MCP 要等 g gui 重启才在对话里生效;
33
+ // - 手动编辑 ~/.zen-gitsync/ai/mcp.json(不走广场的那种)—— 下次 g gui
34
+ // 重启生效,与 CLI「下次启动时生效」一致。
35
+ //
36
+ // 生命周期:stdio 子进程走 cli/cleanup.js 的 trackChild,不在 g ui 自己的
37
+ // runningProcesses 表里,shutdown 的 drainRunningProcesses 兜不到 —— 所以
38
+ // index.js 的优雅退出里显式调 closeWebExtensions(),否则 Ctrl+C 之后会留下
39
+ // 一堆 npx 孤儿子进程。
40
+
41
+ import path from 'node:path';
42
+ import { AgentExtensions } from '../../../../cli/ai/extensions.js';
43
+ import { logger } from './shared.js';
44
+
45
+ /** cwd(绝对路径) → { extensions: AgentExtensions } */
46
+ const cache = new Map();
47
+ /** cwd → 进行中的加载。同 cwd 的并发首轮只允许一次真实加载(去重)。 */
48
+ const inflight = new Map();
49
+ /**
50
+ * 加载期间被 invalidate 标记过的 cwd。任务收尾时看到自己在这张表里 →
51
+ * 不写回缓存、直接关掉刚建好的连接,只服务当前这一个调用方。
52
+ *
53
+ * 为什么需要它:不能改用"cache 里没有这个 key"来判断 —— 首次加载时缓存
54
+ * 本来就是空的,两种状态长得一模一样。2026-10-12 第一版就是这么写的,
55
+ * 后果是每次首轮加载完都把刚握好的 MCP 连接关掉(还不进缓存),
56
+ * 症状是"工具定义下发成功了、一调用就报连接已关闭"。
57
+ */
58
+ const poisoned = new Set();
59
+
60
+ const normalizeCwd = (cwd) => {
61
+ try {
62
+ return path.resolve(cwd || process.cwd());
63
+ } catch {
64
+ return String(cwd || process.cwd());
65
+ }
66
+ };
67
+
68
+ /**
69
+ * 取某个 cwd 的扩展层;没有则加载(同 cwd 并发只加载一次)。
70
+ *
71
+ * 失败不抛:AgentExtensions.load 内部已对 skill / MCP 各自降级,这里再包一层
72
+ * 是防读取路径之外的意外把整轮对话打挂 —— 扩展是增量能力,不该让用户消息发不出去。
73
+ *
74
+ * @param {{ cwd?: string, locale?: string }} [options]
75
+ * @returns {Promise<AgentExtensions>}
76
+ */
77
+ export async function getWebExtensions({ cwd, locale } = {}) {
78
+ const key = normalizeCwd(cwd);
79
+ const hit = cache.get(key);
80
+ if (hit) return hit.extensions;
81
+ const pending = inflight.get(key);
82
+ if (pending) return pending;
83
+
84
+ const task = (async () => {
85
+ const extensions = await AgentExtensions.load({
86
+ cwd: key,
87
+ locale,
88
+ onWarn: (message) => logger.warn(`[agentExtensions] ${message}`),
89
+ }).catch((err) => {
90
+ logger.warn(`[agentExtensions] 加载失败(${key}),本轮按无扩展继续: ${err?.message || err}`);
91
+ return new AgentExtensions();
92
+ });
93
+ // 加载期间被 invalidate 过 → 不把这份刚建好的连接写回缓存(下一个调用方
94
+ // 会按新配置重新加载),直接关掉它,只服务当前这一个调用方。
95
+ // has → delete → set 之间没有 await,整段是原子的,不会被 invalidate 插进来。
96
+ if (poisoned.has(key)) {
97
+ poisoned.delete(key);
98
+ await extensions.mcp?.close().catch(() => {});
99
+ } else {
100
+ cache.set(key, { extensions });
101
+ }
102
+ return extensions;
103
+ })();
104
+
105
+ inflight.set(key, task);
106
+ try {
107
+ return await task;
108
+ } finally {
109
+ inflight.delete(key);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 让扩展缓存失效(广场安装 / 卸载后调)。
115
+ *
116
+ * 先摘缓存再关旧连接 —— 反过来会让下一轮拿到正在关闭的连接。
117
+ * 传 cwd 只失效那一个目录(项目级安装),不传失效全部(全局安装影响所有目录)。
118
+ *
119
+ * @param {string} [cwd]
120
+ */
121
+ export async function invalidateWebExtensions(cwd) {
122
+ // 受影响对象 = 缓存键 + 在途加载键。只遍历缓存的话,"加载前刚被失效过
123
+ // (缓存已空)、又在加载中被失效"这一下会漏掉在途那个,让它把按旧配置
124
+ // 建好的连接写回缓存。
125
+ const keys = cwd ? [normalizeCwd(cwd)] : [...new Set([...cache.keys(), ...inflight.keys()])];
126
+ const victims = [];
127
+ for (const key of keys) {
128
+ const entry = cache.get(key);
129
+ if (entry) {
130
+ cache.delete(key);
131
+ victims.push(entry.extensions);
132
+ }
133
+ // 有加载在进行中 → 标记它收尾时别写回缓存(见 getWebExtensions 里的 poisonous 分支)
134
+ if (inflight.has(key)) poisoned.add(key);
135
+ }
136
+ await Promise.all(victims.map(async (extensions) => {
137
+ // 正在跑的某一轮可能还持有这份对象:它的 in-flight tools/call 会收到
138
+ // "连接已关闭"(以错误字符串回喂给模型,不会打挂对话)。装/卸配置与对话
139
+ // 并发是罕见边角,为它做代际管理不值这个复杂度。
140
+ try {
141
+ await extensions.mcp?.close();
142
+ } catch (err) {
143
+ logger.warn(`[agentExtensions] 关闭旧连接失败: ${err?.message || err}`);
144
+ }
145
+ }));
146
+ }
147
+
148
+ /**
149
+ * 关掉全部缓存的 MCP 连接。g gui 优雅退出时调(见 index.js 的 shutdown)。
150
+ */
151
+ export async function closeWebExtensions() {
152
+ const all = [...cache.values()];
153
+ cache.clear();
154
+ await Promise.all(all.map(async ({ extensions }) => {
155
+ try { await extensions.mcp?.close(); } catch (_) {}
156
+ }));
157
+ }
@@ -44,6 +44,10 @@ import { execFile, spawn } from 'node:child_process';
44
44
  import { promisify } from 'node:util';
45
45
  import open from 'open';
46
46
  import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
47
+ import { logger } from './shared.js';
48
+ // 装/卸之后让 Web 端扩展缓存失效:否则刚装的 MCP/Skill 要等 g gui 重启
49
+ // 才在智能体对话里生效("广场显示已安装、对话框里没有工具"就是这么来的)。
50
+ import { invalidateWebExtensions } from './agentExtensions.js';
47
51
  import { AI_SKILLS_DIR, AI_MCP_FILE, projectSkillsDir, projectMcpFile } from '../../../../paths.js';
48
52
 
49
53
  const execFileAsync = promisify(execFile);
@@ -1097,6 +1101,11 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
1097
1101
 
1098
1102
  // 装完立刻让缓存失效,避免刚装的条目在列表里还是"未安装"
1099
1103
  cache.clear();
1104
+ // 同一用意再往下一层:Web 智能体对话的扩展缓存也要失效,否则新装的
1105
+ // MCP/Skill 要等 g gui 重启才在对话里出现。全局安装影响所有目录 → 全量失效;
1106
+ // 项目安装只影响该目录。关旧连接是异步的,不拖慢这个响应,失败只记账。
1107
+ invalidateWebExtensions(target === 'project' ? cwd : undefined)
1108
+ .catch((err) => logger.warn(`[agentMarketplace] 扩展缓存失效失败: ${err?.message || err}`));
1100
1109
  res.json({ success: true, target, cwd, item: installed });
1101
1110
  }));
1102
1111
 
@@ -1135,6 +1144,9 @@ export function registerAgentMarketplaceRoutes({ app, getCurrentProjectPath }) {
1135
1144
  const id = decodeURIComponent(String(req.params.id || ''));
1136
1145
  const result = await uninstall({ type, id, target, cwd });
1137
1146
  cache.clear();
1147
+ // 与安装同一用意:卸完也让对话侧的扩展缓存失效(口径见 install 路由里的注释)
1148
+ invalidateWebExtensions(target === 'project' ? cwd : undefined)
1149
+ .catch((err) => logger.warn(`[agentMarketplace] 扩展缓存失效失败: ${err?.message || err}`));
1138
1150
  res.json({ success: true, ...result });
1139
1151
  }));
1140
1152
  }
@@ -181,3 +181,19 @@ test('两个入口都把推理内容写进 assistant 历史消息', () => {
181
181
  assert.match(code, /reasoning_content/, `${label} 没有把推理内容写进 assistant 历史消息`);
182
182
  }
183
183
  });
184
+
185
+ test('两个入口都把扩展工具叠加进请求,并按 owns() 分流', () => {
186
+ // 2026-10-12:Web 侧曾经**完全不加载**扩展层 —— MCP 广场显示"已安装",
187
+ // 对话框里却没有那些工具(agent 只能自己读 mcp.json 按 HTTP 直调)。
188
+ // 失效方式和这个文件的其它分叉一样:不报错,只是两个入口表现不一样。
189
+ // 所以钉住两个入口都要有:extraTools(叠加进请求)+ owns(按名字分流)。
190
+ // 只认代码行,注释里的提及不算。
191
+ for (const { label, file } of ENTRY_POINTS) {
192
+ const code = readFileSync(file, 'utf8')
193
+ .split(/\r?\n/)
194
+ .filter(line => !/^\s*(\/\/|\*|\/\*)/.test(line))
195
+ .join('\n');
196
+ assert.match(code, /extraTools/, `${label} 没有把扩展工具叠加进请求(extraTools)`);
197
+ assert.match(code, /owns/, `${label} 没有按 owns() 分流扩展工具`);
198
+ }
199
+ });
@@ -17,6 +17,7 @@
17
17
  // 路由清单:
18
18
  // GET /api/agent/sessions — 列出会话(仅 metadata;?cwd= 按项目过滤,空 cwd 视为当前项目)
19
19
  // GET /api/agent/sessions/:id — 获取会话详情(含完整消息)
20
+ // GET /api/agent/sessions/:id/image — 正文内嵌图片(按会话 cwd 解析,判定见 jobImage.js)
20
21
  // DELETE /api/agent/sessions/:id — 删除会话
21
22
  // PUT /api/agent/sessions/:id — 重命名会话
22
23
  // POST /api/agent/chat — SSE 流式聊天(含工具调用;body.cwd 必须与服务端当前项目一致)
@@ -40,7 +41,11 @@ import { isSessionBusy, acquireSession, releaseSession } from './sessionGate.js'
40
41
  import { createScheduler } from './scheduler.js';
41
42
  import { registerScheduleRoutes } from './scheduleRoutes.js';
42
43
  import { normalizeItems, filterToRecentDirs, buildDirStatusBlock } from '../recentDirectoriesAiSummary.js';
43
- import { nowIso, logger } from './shared.js';
44
+ import { nowIso, logger, fs, fsp, MAX_IMAGE_BYTES } from './shared.js';
45
+ // 正文内嵌图片的路径判定纯函数 —— 与任务对话的图片端点(workbench/index.js 的
46
+ // /api/workbench/jobs/:id/image)同一份:后缀白名单、root 内、防穿越。两份白名单
47
+ // 分叉时不会报错,只会让某一类图在任务对话里能看、在 Agent 对话里裂。
48
+ import { resolveJobImagePath, IMAGE_PATH_STATUS, IMAGE_PATH_ERRORS, isInsideRoot } from './jobImage.js';
44
49
  // 请求预算的解析公式与 CLI `g ai` 共用同一份(cli/ai/context.js),Web 侧只管把
45
50
  // 配置值喂进去、把结果传给 agentChat —— 2026-10-07 起每轮请求的字符预算可配。
46
51
  import { resolveRequestBudget } from '../../../../cli/ai/context.js';
@@ -294,6 +299,75 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
294
299
  res.json({ success: true, session });
295
300
  }));
296
301
 
302
+ // GET /api/agent/sessions/:id/image?path=… —— 正文内嵌图片(模型写 ![说明](本机路径))
303
+ //
304
+ // 与任务对话那一个端点(workbench/index.js 的 /api/workbench/jobs/:id/image)同一目的:
305
+ // 智能体在本机干活,"展示一张图"最自然的写法是贴一条本机路径,而对话流是 markdown
306
+ // 渲染 —— `<img src="c:\ws\repo\docs\a.png">` 在浏览器里永远是裂图。前端把这类路径
307
+ // 重写成这个 URL(utils/localImageSrc.ts 的 resolveAgentImages,四个 g ai 对话入口共用)。
308
+ //
309
+ // 权限边界 = 「这个会话的 cwd 内的图片文件」:
310
+ // · 判定复用 jobImage.js 的纯函数(后缀白名单 + root 内 + 防穿越);
311
+ // · 这里**再按 realpath 校验一次** —— lexical 判定挡不住会话目录里一个指向外部的
312
+ // 符号链接,而会话内容很可能是从网上 clone 来的;
313
+ // · 响应带 nosniff + CSP(sandbox):一份恶意 SVG 被直接在浏览器里打开时,它的脚本
314
+ // 不能跑在本应用的源上(<img> 里本来就不会执行,这里防的是直接访问)。
315
+ app.get('/api/agent/sessions/:sessionId/image', asyncRoute(async (req, res) => {
316
+ const session = await readSession(req.params.sessionId);
317
+ // 根 = 会话自己的 cwd(模型眼里"相对路径"的基准,就是它启动 g ai 的那个目录)。
318
+ // 历史存量会话没有 cwd 时退回当前项目路径 —— 与列表可见性的老会话包容口径一致
319
+ // (上面 sessionBelongsTo:没有 cwd 的老会话暂按当前项目可见)。
320
+ const root = String(session.cwd || '').trim()
321
+ || (typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
322
+
323
+ const resolved = resolveJobImagePath({ root, requested: req.query.path });
324
+ if (!resolved.ok) {
325
+ throw new HttpError(
326
+ IMAGE_PATH_STATUS[resolved.reason] || 400,
327
+ IMAGE_PATH_ERRORS[resolved.reason] || '图片路径不合法',
328
+ );
329
+ }
330
+
331
+ let realFile;
332
+ try {
333
+ realFile = await fsp.realpath(resolved.absPath);
334
+ } catch {
335
+ throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
336
+ }
337
+ let realRoot;
338
+ try {
339
+ realRoot = await fsp.realpath(root);
340
+ } catch {
341
+ realRoot = root; // 根目录都没了的话,下面那次 stat 也会失败,不必在这里另报一种错
342
+ }
343
+ if (!isInsideRoot(realRoot, realFile)) {
344
+ throw new HttpError(IMAGE_PATH_STATUS['outside-root'], IMAGE_PATH_ERRORS['outside-root']);
345
+ }
346
+
347
+ let stat;
348
+ try {
349
+ stat = await fsp.stat(realFile);
350
+ } catch {
351
+ throw new HttpError(404, '图片不存在(模型给的路径在本机读不到)');
352
+ }
353
+ if (!stat.isFile()) throw new HttpError(404, '路径不是一个文件');
354
+ // 与附件同一个上限:正文里嵌一张 20MB 以上的图只会把页面拖死
355
+ if (stat.size > MAX_IMAGE_BYTES) {
356
+ throw new HttpError(413, `图片超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB,未加载`);
357
+ }
358
+
359
+ // mime 按**后缀**给(不读文件头):后缀已过白名单,且这条内容只喂给 <img>
360
+ res.set('Content-Type', resolved.mime);
361
+ res.set('Content-Length', String(stat.size));
362
+ res.set('Content-Disposition', 'inline');
363
+ res.set('Cache-Control', 'private, max-age=300');
364
+ res.set('X-Content-Type-Options', 'nosniff');
365
+ res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
366
+ const stream = fs.createReadStream(realFile);
367
+ stream.on('error', () => res.end());
368
+ stream.pipe(res);
369
+ }));
370
+
297
371
  // ════════════════════════════════════════════════════════════════════════
298
372
  // §3. 删除会话
299
373
  // ════════════════════════════════════════════════════════════════════════
@@ -441,10 +515,11 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
441
515
  return res.end();
442
516
  }
443
517
  // 引擎在会话**建立时就锁死**,中途不许换。
444
- // 为什么:三家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
445
- // codex exec resume),而且历史消息格式不同(我们存的是 OpenAI chat completions
446
- // 形状,喂给 claude 没有意义)。允许中途切换的表现是"看起来切了,上下文却串了"——
447
- // 与工作台 taskExecutor.ts 注释里那条"续哪个执行器由上一轮 job.agent 决定"同源。
518
+ // 为什么:各家 CLI 的续聊标识互不通用(claude --resume / opencode --session /
519
+ // codex exec resume / codebuddy --resume 的 id 也互不认),而且历史消息格式不同
520
+ // (我们存的是 OpenAI chat completions 形状,喂给 claude 没有意义)。允许中途
521
+ // 切换的表现是"看起来切了,上下文却串了"—— 与工作台 taskExecutor.ts 注释里那条
522
+ // "续哪个执行器由上一轮 job.agent 决定"同源。
448
523
  const requestedEngine = req.body?.engine === undefined
449
524
  ? null
450
525
  : normalizeAgentEngine(req.body?.engine);
@@ -467,10 +542,10 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
467
542
  source: 'web',
468
543
  cwd: currentProjectCwd,
469
544
  model: '',
470
- // 引擎:gai(内置,默认)| claude | opencode | codex
545
+ // 引擎:gai(内置,默认)| claude | opencode | codex | codebuddy
471
546
  engine: normalizeAgentEngine(req.body?.engine),
472
547
  // 外部 CLI 自己的续聊标识(claude session_id / opencode sessionID /
473
- // codex thread_id)。g ai 用不到,留着空串。
548
+ // codex thread_id / codebuddy session_id)。g ai 用不到,留着空串。
474
549
  engineSessionId: '',
475
550
  createdAt: nowIso(),
476
551
  updatedAt: nowIso(),