@luchenglong/cli-bridge 0.0.0-stage → 0.2.3

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 (67) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/LICENSE +21 -0
  3. package/MCP-POLICY.md +53 -0
  4. package/PROVIDER-CONTRACT.md +235 -0
  5. package/README.en.md +98 -0
  6. package/README.md +151 -2
  7. package/assets/indicator-states.svg +62 -0
  8. package/bin/agy-quota.mjs +223 -0
  9. package/core/bridge-kit.mjs +549 -0
  10. package/core/scheduling.mjs +149 -0
  11. package/docs/ARCHITECTURE.md +117 -0
  12. package/docs/FALLBACK-AND-INDICATOR.md +93 -0
  13. package/docs/HANDOVER.md +226 -0
  14. package/docs/INSTALL.md +118 -0
  15. package/docs/MCP-POLICY.zh.md +49 -0
  16. package/docs/MIGRATION.md +43 -0
  17. package/docs/RELEASE-SOP.md +80 -0
  18. package/docs/history/agy/CHANGELOG.md +326 -0
  19. package/docs/history/codebuddy/CHANGELOG.md +668 -0
  20. package/docs/history/mimo/CHANGELOG.md +346 -0
  21. package/dynamic/agy.host.js +1237 -0
  22. package/dynamic/agy.host.template.mjs +140 -0
  23. package/dynamic/codebuddy.host.js +1787 -0
  24. package/dynamic/codebuddy.host.template.mjs +162 -0
  25. package/dynamic/mimo.host.js +1732 -0
  26. package/dynamic/mimo.host.template.mjs +169 -0
  27. package/dynamic/opencode.host.js +1443 -0
  28. package/dynamic/opencode.host.template.mjs +125 -0
  29. package/home-plugin/cli-bridge-indicator/cordis.patch.yml +31 -0
  30. package/home-plugin/cli-bridge-indicator/lib/client.js +606 -0
  31. package/home-plugin/cli-bridge-indicator/lib/index.mjs +420 -0
  32. package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +137 -0
  33. package/home-plugin/cli-bridge-indicator/lib/settings.mjs +300 -0
  34. package/mcp/agy-mcp-server.mjs +6 -0
  35. package/mcp/cli-bridge-mcp.mjs +185 -0
  36. package/mcp/codebuddy-mcp-server.mjs +6 -0
  37. package/mcp/mimo-mcp-server.mjs +6 -0
  38. package/mcp/providers/agy.mjs +254 -0
  39. package/mcp/providers/codebuddy.mjs +397 -0
  40. package/mcp/providers/mimo.mjs +290 -0
  41. package/mcp/providers/opencode.mjs +236 -0
  42. package/package.json +89 -4
  43. package/preset/agy-first/agent.cordis.yml +141 -0
  44. package/preset/agy-first/cli-bridge.mjs +169 -0
  45. package/preset/agy-first/plugin-list.mjs +26 -0
  46. package/preset/agy-first/preset.yml +3 -0
  47. package/preset/codebuddy-first/agent.cordis.yml +141 -0
  48. package/preset/codebuddy-first/cli-bridge.mjs +188 -0
  49. package/preset/codebuddy-first/plugin-list.mjs +20 -0
  50. package/preset/codebuddy-first/preset.yml +3 -0
  51. package/preset/mimo-first/agent.cordis.yml +143 -0
  52. package/preset/mimo-first/cli-bridge.mjs +236 -0
  53. package/preset/mimo-first/plugin-list.mjs +39 -0
  54. package/preset/mimo-first/preset.yml +3 -0
  55. package/preset/opencode-first/agent.cordis.yml +141 -0
  56. package/preset/opencode-first/cli-bridge.mjs +147 -0
  57. package/preset/opencode-first/plugin-list.mjs +26 -0
  58. package/preset/opencode-first/preset.yml +3 -0
  59. package/preset/standard-base.mjs +102 -0
  60. package/providers/agy.mjs +398 -0
  61. package/providers/codebuddy.mjs +926 -0
  62. package/providers/mimo.mjs +864 -0
  63. package/providers/opencode.mjs +619 -0
  64. package/providers/registry.mjs +39 -0
  65. package/scripts/build.mjs +235 -0
  66. package/scripts/deploy.mjs +61 -0
  67. package/scripts/verify.mjs +148 -0
@@ -0,0 +1,397 @@
1
+ // mcp/providers/codebuddy.mjs — codebuddy MCP 工具面(契约 §4;移植自旧
2
+ // mcp/codebuddy-mcp-server.mjs,framing/生命周期归 keystone 统一服务器
3
+ // mcp/cli-bridge-mcp.mjs)。
4
+ //
5
+ // 旧语义保持(verbatim 行锚 = 旧 codebuddy-mcp-server.mjs):
6
+ // - 三面独立安装包候选链 commandFor(L111-126):node + bin 脚本优先(.cmd shim
7
+ // spawn EINVAL,CVE-2024-27980),workbuddy/codebuddy-en 走 Program Files 候选,
8
+ // npm 面 CODEBUDDY_BIN > npm 全局 bin > 裸 'codebuddy'。
9
+ // - backendUnavailable 前置检查(L129-137)与 spawn error 文案(L293)。
10
+ // - codebuddy-en 无凭据 → AUTH_REQUIRED 早退,不白跑 spawn(L213-220)。
11
+ // - CODEBUDDY_MCP_ALLOWED_ROOTS cwd 白名单(L140-158):守卫在 call() 分发前
12
+ // 解析最终 cwd(参数 > 会话归属 > 默认)后执行;env 一律读 io.env(测试注入
13
+ // 面,Lead 定案),不直接摸 process.env。
14
+ // - 瞬时错误静默重试一次(L449-453,续接类不重试);旧 `if(!res.ok) out.isError
15
+ // =false` 怪癖在新形态天然成立(keystone 统一包装无 isError 位)。
16
+ // - 状态灯:本模块自持 engine,快照原子写 {homeDir}/cli-bridge-mcp-codebuddy.json
17
+ // (keystone readMcpFile 约定;写失败静默)。kit 的 makeSnapshotWriter 在本聚合
18
+ // 服务器内 —— 反向 import 会成环,按 agy 面同款复刻原子写。
19
+ // - 设置:统一文件 cli-bridge-settings.json 的 codebuddy 分区(5s 缓存),旧扁平
20
+ // 文件的字段级迁移在 DSH 面板侧由 keystone 完成;这里读不到即用默认值。
21
+ import { spawn as nodeSpawn } from 'node:child_process'
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { resolve as resolvePath, join as nodePathJoin } from 'node:path'
24
+
25
+ import defineProvider from '../../providers/codebuddy.mjs'
26
+ import { createStatusEngine, createLineStream, buildMcpBridgePayload, mcpBridgeFileName } from '../../core/bridge-kit.mjs'
27
+
28
+ export const meta = { id: 'codebuddy', label: 'CODEBUDDY', version: '0.2.2' }
29
+
30
+ const provider = defineProvider({
31
+ env: process.env,
32
+ process,
33
+ nodeFs: { readFileSync, existsSync },
34
+ nodePath: { join: nodePathJoin, sep: process.platform === 'win32' ? '\\' : '/' }
35
+ })
36
+
37
+ // publish 用 io(最近一次 call 注入)定位 homeDir;模块级 engine 只经此通道写盘。
38
+ let lastIo = null
39
+ function publishSnapshot(snapshot) {
40
+ const io = lastIo
41
+ if (!io) return
42
+ try {
43
+ const target = io.path.join(io.homeDir, mcpBridgeFileName('codebuddy'))
44
+ const payload = buildMcpBridgePayload(snapshot, process.pid, Date.now())
45
+ const tmp = target + '.' + process.pid + '.tmp'
46
+ try { io.fs.mkdirSync(io.path.dirname(target), { recursive: true }) } catch (e) {}
47
+ io.fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8')
48
+ io.fs.renameSync(tmp, target)
49
+ } catch (e) { /* 灯数据尽力而为,绝不影响工具调用 */ }
50
+ }
51
+ const engine = createStatusEngine({ provider, publish: publishSnapshot })
52
+ provider.engine = engine
53
+
54
+ // ── settings(统一文件 codebuddy 分区 + 5s 缓存;旧 readSettings L69-76 语义)──
55
+ const SETTINGS_FILE_NAME = 'cli-bridge-settings.json'
56
+ let settingsCache = { at: 0, value: null }
57
+ function readSettings(io) {
58
+ const now = Date.now()
59
+ if (settingsCache.value && now - settingsCache.at < 5000) return settingsCache.value
60
+ const o = io || lastIo || {}
61
+ let snap = provider.normalizeSettings(null)
62
+ try {
63
+ const env = o.env || {}
64
+ const p = env.CLI_BRIDGE_SETTINGS_FILE || (o.homeDir && o.path ? o.path.join(o.homeDir, SETTINGS_FILE_NAME) : null)
65
+ if (p && o.fs && typeof o.fs.readFileSync === 'function') {
66
+ const all = JSON.parse(o.fs.readFileSync(p, 'utf8'))
67
+ if (all && typeof all === 'object' && all.codebuddy && typeof all.codebuddy === 'object') snap = provider.normalizeSettings(all.codebuddy)
68
+ }
69
+ } catch (e) { /* 文件缺失/损坏 → 默认值 */ }
70
+ settingsCache = { at: now, value: snap }
71
+ return snap
72
+ }
73
+
74
+ // ── CLI command resolution(旧 commandFor L111-126 verbatim;env 读 io.env)─────
75
+ function commandFor(backend, io) {
76
+ const env = io.env || {}
77
+ const nodeExe = process.execPath || 'node'
78
+ if (backend === 'workbuddy') {
79
+ const wbBin = env.WORKBUDDY_BIN || 'C:\\Program Files\\WorkBuddy\\resources\\app.asar.unpacked\\cli\\bin\\codebuddy'
80
+ return [nodeExe, wbBin]
81
+ }
82
+ if (backend === 'codebuddy-en') {
83
+ const enBin = env.CODEBUDDY_EN_BIN || 'C:\\Program Files\\WorkBuddyAI\\resources\\app.asar.unpacked\\cli\\bin\\codebuddy'
84
+ return [nodeExe, enBin]
85
+ }
86
+ const envBin = env.CODEBUDDY_BIN
87
+ if (envBin && existsSync(envBin)) return [nodeExe, envBin]
88
+ const npmBin = (env.APPDATA || 'C:\\Users\\lcl\\AppData\\Roaming') + '\\npm\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy'
89
+ if (existsSync(npmBin)) return [nodeExe, npmBin]
90
+ return ['codebuddy']
91
+ }
92
+
93
+ // 后端不可用时的明确错误信息(旧 backendUnavailable L129-137 verbatim)。
94
+ function backendUnavailable(backend, prefix) {
95
+ if (backend === 'workbuddy' && !existsSync(prefix[1] || '')) {
96
+ return 'WorkBuddy CLI not found at "' + (prefix[1] || '') + '" — install the WorkBuddy desktop app (the CLI ships with it at <install>\\resources\\app.asar.unpacked\\cli\\bin\\codebuddy) or set WORKBUDDY_BIN.'
97
+ }
98
+ if (backend === 'codebuddy-en' && !existsSync(prefix[1] || '')) {
99
+ return 'WorkBuddy AI (international) CLI not found at "' + (prefix[1] || '') + '" — install the WorkBuddy AI desktop app (the CLI ships with it at <install>\\resources\\app.asar.unpacked\\cli\\bin\\codebuddy) or set CODEBUDDY_EN_BIN.'
100
+ }
101
+ return null
102
+ }
103
+
104
+ // ── cwd whitelist guard(旧 L140-158 verbatim;env 读 io.env —— Lead 定案)─────
105
+ // CODEBUDDY_MCP_ALLOWED_ROOTS 白名单(; 或 , 分隔的绝对目录)。设置后,每个
106
+ // codebuddy 调用的 cwd(含会话回落得到的 cwd)都必须位于白名单目录内,否则
107
+ // 拒绝。未设置 → 放行(默认兼容)。
108
+ function cwdBlockedReason(cwd, io) {
109
+ const raw = (io.env || {}).CODEBUDDY_MCP_ALLOWED_ROOTS
110
+ if (!raw || !String(raw).trim()) return null
111
+ const target = resolvePath(String(cwd))
112
+ const roots = String(raw).split(/[;,]+/).map((s) => s.trim()).filter(Boolean)
113
+ if (!roots.length) return null
114
+ const sep = io.path && io.path.sep ? io.path.sep : (process.platform === 'win32' ? '\\' : '/')
115
+ const ok = roots.some((r) => {
116
+ try {
117
+ const rp = resolvePath(r)
118
+ return target === rp || target.startsWith(rp.endsWith('/') || rp.endsWith('\\') ? rp : rp + sep)
119
+ } catch (e) { return false }
120
+ })
121
+ if (ok) return null
122
+ return 'cwd "' + target + '" is outside CODEBUDDY_MCP_ALLOWED_ROOTS (' + roots.join('; ') + '); this MCP server launches codebuddy with permissions bypassed, so the working directory is restricted to the whitelist. Pass an allowed cwd, or ask the server operator to extend CODEBUDDY_MCP_ALLOWED_ROOTS.'
123
+ }
124
+
125
+ // ── run orchestration(旧 runCodebuddy L198-313 verbatim 语义)────────────────
126
+ function runCodebuddy(args, io) {
127
+ return new Promise((resolve) => {
128
+ const settings = readSettings(io)
129
+ const cwdFallback = (io.env || {}).CODEBUDDY_MCP_CWD || process.cwd()
130
+ const target = engine.resolveTarget(args, cwdFallback)
131
+ const backend = provider.resolveBackend(args, target, settings.preferredBackend)
132
+ // 端点/凭据:端点按登录域自动对齐(修 npm 面 401);凭据仅 codebuddy-en 需要
133
+ // (其 token 被桌面 App 的 protector key 封装,headless 读不到)。
134
+ const authDomain = provider.readBackendAuthDomain(backend, { env: io.env || {} })
135
+ const enTok = backend === 'codebuddy-en' ? provider.resolveEnToken(settings.codebuddyEnToken) : { token: null }
136
+ const authToken = enTok.token
137
+ if (backend === 'codebuddy-en' && !authToken) {
138
+ resolve({
139
+ ok: false, status: 'AUTH_REQUIRED', response: '', sessionId: null, durationSeconds: null,
140
+ numTurns: null, totalTokens: null, exitCode: null, mode: 'accept-edits', backend: backend,
141
+ stderr: provider.endpointMismatchHint(backend, authDomain, false)
142
+ })
143
+ return
144
+ }
145
+ const prefix = commandFor(backend, io)
146
+ const built = provider.buildArgv(prefix, args, {
147
+ backend,
148
+ defaultMode: 'accept-edits',
149
+ defaultModel: (args && args.model) ? null : settings.defaultModel,
150
+ settings,
151
+ env: provider.endpointEnv(backend, settings.endpointOverride, authDomain, authToken)
152
+ })
153
+ const { argv, timeoutSec } = built
154
+ // codebuddy/workbuddy 会话按项目目录(cwd)归档:续接未显式给 cwd 时回落到
155
+ // 该 session 所在项目的 cwd,否则 "No conversation found with session ID"。
156
+ let cwd = resolvePath(target.cwd)
157
+ const blocked = cwdBlockedReason(cwd, io)
158
+ if (blocked) {
159
+ resolve({ ok: false, status: 'CWD_BLOCKED', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: built.mode, backend: backend, stderr: blocked })
160
+ return
161
+ }
162
+ const unavailable = backendUnavailable(backend, prefix)
163
+ if (unavailable) {
164
+ resolve({ ok: false, status: 'CODEBUDDY_UNAVAILABLE', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: built.mode, backend: backend, stderr: unavailable })
165
+ return
166
+ }
167
+ const spawnEnv = (built.env && typeof built.env === 'object') ? Object.assign({}, process.env, built.env) : undefined
168
+ let child
169
+ try {
170
+ // No shell: argv passed verbatim(prompt 含空格/引号安全)。spawnEnv 仅
171
+ // codebuddy-en(端点/凭据注入)时设置。
172
+ child = nodeSpawn(argv[0], argv.slice(1), {
173
+ cwd,
174
+ ...(spawnEnv ? { env: spawnEnv } : {}),
175
+ stdio: ['ignore', 'pipe', 'pipe'],
176
+ windowsHide: true
177
+ })
178
+ } catch (e) {
179
+ resolve({ ok: false, status: 'SPAWN_ERROR', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: built.mode, backend: backend, stderr: String(e && e.message || e) })
180
+ return
181
+ }
182
+ engine.begin(cwd)
183
+ let out = '', err = ''
184
+ let killed = false
185
+ const timer = setTimeout(() => { killed = true; try { child.kill() } catch (e) {} }, (timeoutSec + 60) * 1000)
186
+ // 半行安全的实时事件流(跨 chunk 的 NDJSON 行拼接)。
187
+ const stream = createLineStream((ln) => {
188
+ const t = ln.trim()
189
+ if (!t.startsWith('{')) return
190
+ try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
191
+ })
192
+ // stdout 必须声明 utf8:不声明时 'data' 派发 Buffer,createLineStream.pushChunk
193
+ // 只接受字符串 → foldEvent 从不触发、状态灯活动明细全空。用 setEncoding 而非
194
+ // String(d):后者会在多字节 UTF-8 跨 chunk 边界产生乱码。
195
+ child.stdout.setEncoding('utf8')
196
+ child.stderr.setEncoding('utf8')
197
+ child.stdout.on('data', (d) => {
198
+ out += d
199
+ if (out.length > 4000000) out = out.slice(-2000000)
200
+ stream.pushChunk(d)
201
+ })
202
+ child.stderr.on('data', (d) => { err += d; if (err.length > 1000000) err = err.slice(-500000) })
203
+ // ENOENT 等场景 Node 先派 'error' 再派 'close';settled 保证 p.running 只减
204
+ // 一次、Promise 只 resolve 一次(旧版双派发会双减计数)。
205
+ let settled = false
206
+ child.on('error', (e) => {
207
+ if (settled) return
208
+ settled = true
209
+ clearTimeout(timer)
210
+ stream.flush()
211
+ const msg = backend + ' spawn failed: ' + String(e && e.message || e) + (backend === 'workbuddy' ? ' — the WorkBuddy desktop app must be installed (or set WORKBUDDY_BIN)' : ' — install CodeBuddy Code (npm i -g @tencent-ai/codebuddy-code) or set CODEBUDDY_BIN')
212
+ engine.end({ ok: false, status: 'CODEBUDDY_UNAVAILABLE', backend: backend, stderr: msg }, cwd)
213
+ resolve({ ok: false, status: 'CODEBUDDY_UNAVAILABLE', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: built.mode, backend: backend, stderr: msg })
214
+ })
215
+ child.on('close', (code) => {
216
+ if (settled) return
217
+ settled = true
218
+ clearTimeout(timer)
219
+ stream.flush()
220
+ const outcome = { exitCode: killed ? 124 : code }
221
+ const parsed = provider.parseJson(out)
222
+ let res = provider.buildResult(parsed, outcome, built.mode, err, out, backend)
223
+ if (killed && !res.ok) {
224
+ res.status = 'HUNG_TIMEOUT'
225
+ res.stderr = (res.stderr ? res.stderr + ' ' : '') + '[killed by timeout guard after ' + timeoutSec + 's; if this was a long-running script (build/test) raise timeoutSec]'
226
+ }
227
+ engine.end(res, cwd)
228
+ resolve(res)
229
+ })
230
+ })
231
+ }
232
+
233
+ // ── text rendering(MCP has no fallback dialog: no human answerer;旧 L316-333
234
+ // verbatim;isLimited/failureHint 用描述符实现 —— 中文限流表红线)────────────────
235
+ function textResult(res) {
236
+ const bk = res.backend || 'codebuddy'
237
+ const limited = !res.ok && provider.isLimited(res)
238
+ const head = bk + ' ' + (res.ok ? 'OK' : 'FAILED') + ' [status=' + res.status + ' mode=' + res.mode +
239
+ (res.modelUsed ? ' model=' + res.modelUsed : '') +
240
+ (res.retried ? ' retried=1' : '') +
241
+ (res.sessionId ? ' session=' + res.sessionId : '') +
242
+ (res.totalTokens != null ? ' tokens=' + res.totalTokens : '') +
243
+ (res.durationSeconds != null ? ' ' + res.durationSeconds + 's' : '') + ']'
244
+ let note = ''
245
+ if (limited) note = '\n\n[Note: this looks like a rate-limit / network failure. Do NOT retry ' + bk + ' in a loop; finish the task with your own tools, or ask the user.]'
246
+ let body = res.response || (res.stderr ? '[stderr] ' + res.stderr : '')
247
+ // 观测性:无 result 事件时附带原始 stdout 尾部(诊断挂起/解析失败用)。
248
+ if (!res.ok && res.rawStdout) body = (body ? body + '\n\n' : '') + '[raw stdout tail] ' + res.rawStdout
249
+ const hint = provider.failureHint(res)
250
+ if (hint) body = (body ? body + '\n\n' : '') + hint
251
+ return head + (body ? '\n\n' + body : '') + note
252
+ }
253
+
254
+ // 旧 statusText L336-380 verbatim(runs/tokens 聚合、bkTag、trail 明细是
255
+ // codebuddy 面特有观测格式,不走 kit 通用 renderStatus)。
256
+ function statusText(filterCwd) {
257
+ const snap = engine.statusSnapshot()
258
+ let projects = snap.projects
259
+ let g = snap
260
+ if (filterCwd) {
261
+ const key = String(filterCwd)
262
+ projects = projects.filter((p) => p.cwd === key)
263
+ const first = projects[0]
264
+ if (first) g = first
265
+ }
266
+ const lines = []
267
+ lines.push('codebuddy status: ' + g.state + (g.running > 0 ? ' (' + g.running + ' running)' : '') + (projects.length > 1 ? ' across ' + projects.length + ' projects' : '') + (g.totalTokens ? ' | total ' + g.runs + ' runs, ' + g.totalTokens + ' tokens' : ''))
268
+ if (projects.length) {
269
+ for (const p of projects) {
270
+ const cur = p.current ? (' step ' + p.current.stepIndex + ' → ' + p.current.tool + (p.current.args ? ' ' + JSON.stringify(p.current.args) : '')) : (p.running > 0 ? ' (starting / thinking)' : '')
271
+ const usage = (p.runs ? ' | total ' + p.runs + ' runs, ' + (p.totalTokens || 0) + ' tokens' : '')
272
+ const bkTag = (p.lastBackend && p.lastBackend !== 'codebuddy') ? ' [' + p.lastBackend + ']' : ''
273
+ lines.push('· ' + p.name + ' [' + p.state + (p.running > 0 ? ' ×' + p.running : '') + ']' + bkTag + cur + (p.lastStatus ? ' | last=' + p.lastStatus + (p.lastSessionId ? ' ' + p.lastSessionId.slice(0, 8) : '') : '') + usage)
274
+ if (p.trail && p.trail.length) {
275
+ lines.push(' steps:')
276
+ for (const e of p.trail.slice(-3)) {
277
+ const a = e.args ? ' ' + JSON.stringify(e.args) : ''
278
+ lines.push(' [' + e.state + '] step ' + e.stepIndex + ' ' + e.tool + a)
279
+ }
280
+ }
281
+ }
282
+ } else {
283
+ if (g.current) {
284
+ const c = g.current
285
+ lines.push('current: step ' + c.stepIndex + ' → ' + c.tool + (c.args ? ' ' + JSON.stringify(c.args) : ''))
286
+ } else if (g.state === 'running') {
287
+ lines.push('current: (starting / thinking)')
288
+ }
289
+ if (g.trail && g.trail.length) {
290
+ lines.push('recent steps:')
291
+ for (const e of g.trail.slice(-6)) {
292
+ const a = e.args ? ' ' + JSON.stringify(e.args) : ''
293
+ lines.push(' [' + e.state + '] step ' + e.stepIndex + ' ' + e.tool + a)
294
+ }
295
+ }
296
+ if (g.lastStatus) lines.push('last: ' + g.lastStatus + (g.lastSessionId ? ' session=' + g.lastSessionId : '') + (g.lastAt ? ' @ ' + new Date(g.lastAt).toISOString() : ''))
297
+ }
298
+ if (g.updatedAt) lines.push('updatedAt: ' + new Date(g.updatedAt).toISOString())
299
+ return lines.join('\n')
300
+ }
301
+
302
+ // ── 工具面(旧 MCP TOOLS L383-428 verbatim:mode 无 auto、无 background,
303
+ // run/continue/status 描述与 DSH preset 面文本不同 —— 契约 §8 逐字红线)───────
304
+ const TOOLS = [
305
+ {
306
+ name: 'codebuddy_run',
307
+ description: 'Dispatch a coding/build/debug/investigation task to the local codebuddy agent CLI (Tencent CodeBuddy Code) and return its final answer. codebuddy runs fully non-interactively with permissions auto-approved (--permission-mode bypassPermissions, never prompts) and applies edits directly. Prefer it for implementation, multi-file edits, refactors and debugging; use your own tools for quick read-only lookups and final build/test verification. mode=plan runs codebuddy read-only. Optional model/effort/maxTurns select the codebuddy model and caps; timeoutSec (10-3600, default 300) is a server-side hang guard. While it runs, call codebuddy_status to watch what codebuddy is doing live. backend="codebuddy-intl" (the INTERNATIONAL face of the same npm CLI — product.ioa.json catalogue: claude-sonnet-5 / claude-opus-5 / gemini-3.1-pro / gpt-6-astra / hy3-ioa; aliases codebuddy-ioa / codebuddy-international accepted), backend="codebuddy-en" (WorkBuddy international edition — the WorkBuddyAI desktop bundled CLI; aliases workbuddy-en / workbuddy-ai accepted) and backend="workbuddy" (WorkBuddy domestic desktop bundled CLI; office-scenario product face: documents/slides/spreadsheets, knowledge-base lookups, image/video generation, WeChat/WeCom replies) route to the other product faces.',
308
+ inputSchema: {
309
+ type: 'object',
310
+ properties: {
311
+ prompt: { type: 'string', description: 'The full task/instruction for codebuddy. Be complete and self-contained.' },
312
+ backend: { type: 'string', enum: ['codebuddy', 'codebuddy-intl', 'codebuddy-ioa', 'codebuddy-international', 'codebuddy-en', 'workbuddy-en', 'workbuddy-ai', 'workbuddy'], description: 'codebuddy (default) for coding work (domestic CodeBuddy npm CLI); codebuddy-intl = the INTERNATIONAL face of that same npm CLI (product.ioa.json catalogue: claude-sonnet-5 / claude-opus-5 / gemini-3.1-pro / gpt-6-astra / hy3-ioa; aliases codebuddy-ioa / codebuddy-international) — use it for international CodeBuddy accounts; codebuddy-en = WorkBuddy international edition (the WorkBuddyAI desktop CLI at C:\\Program Files\\WorkBuddyAI; aliases workbuddy-en / workbuddy-ai accepted; needs codebuddyEnToken in the settings file unless DSH already holds a workbuddy key — its login token is sealed by a protector key the headless CLI cannot read); workbuddy for the WorkBuddy domestic desktop CLI, also the office-scenario face (docs/slides, knowledge base, media generation, WeChat/WeCom replies). New calls without a backend follow the user-preferred default backend (settings file). Continuing a session routes back to its owning backend automatically.' },
313
+ mode: { type: 'string', enum: ['plan', 'accept-edits'], description: 'plan = no writes; accept-edits = allow edits (default).' },
314
+ model: { type: 'string', description: 'Optional model id. When unspecified, the user-preferred default model (plugin settings) is used if set, else the CLI default. Two layers decide: the PRODUCT FACE decides whether the id exists in its catalogue, then the ACCOUNT decides whether it is licensed. The free tier (hy3, deepseek-v4.1-flash at x0.00 credits; hy4-preview at x0.29) belongs to the npm CLI faces codebuddy/codebuddy-intl: hy4-preview, hy3, hy3-x, deepseek-v4.1-flash, glm-5.3, glm-5.3-flash, glm-5.2, glm-5.1, glm-5v-turbo, minimax-m3, minimax-m2.7, kimi-k3-1, kimi-k2.8-preview, kimi-k2.7, kimi-k2.6, deepseek-v4-pro. codebuddy-en (WorkBuddyAI) carries NO hy4-preview and NO deepseek-v4.1-flash; its only free model is hy3. International ids (claude-sonnet-5, gemini-3.1-pro, ...) are account-gated. Availability is per ACCOUNT: the CLI rejects an id outside the account catalogue with "400 service info not found" and one inside it but unlicensed with "400 only available for authorized users" (many catalogue flagships such as claude-opus-4.8, gpt-5.4 and gpt-6-astra are in this second group for a domestic account). Run the CLI with a deliberately invalid model id to print "Currently supported models for your account:" and get the authoritative list; the plugin caches that result and the settings panel lists exactly those ids with their credit multiplier (x0.00 = free), read live from the CLI descriptor so it stays in sync with CLI upgrades. Prefer the cheapest model that can do the job, and prefer a free (x0.00) one for routine work. Pass a model only when the task clearly benefits from a specific one.' },
315
+ effort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], description: 'Optional reasoning effort.' },
316
+ maxTurns: { type: 'integer', description: 'Optional max agentic turns (1-500, default unlimited).' },
317
+ cwd: { type: 'string', description: 'Working directory for codebuddy (default: CODEBUDDY_MCP_CWD or the fallback workspace). If the server sets CODEBUDDY_MCP_ALLOWED_ROOTS, this must be inside the whitelist.' },
318
+ addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra directories to add to the codebuddy workspace.' },
319
+ timeoutSec: { type: 'integer', description: 'Run timeout seconds (10-3600, default 300); the server force-kills at timeout+60s.' }
320
+ },
321
+ required: ['prompt']
322
+ }
323
+ },
324
+ {
325
+ name: 'codebuddy_continue',
326
+ description: 'Continue an existing codebuddy conversation with a follow-up prompt, reusing codebuddy context. Pass sessionId from a prior codebuddy_run result, or latest=true for the most recent conversation.',
327
+ inputSchema: {
328
+ type: 'object',
329
+ properties: {
330
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing codebuddy conversation.' },
331
+ sessionId: { type: 'string', description: 'codebuddy session id to resume (from a prior codebuddy_run result).' },
332
+ latest: { type: 'boolean', description: 'Continue the most recent codebuddy conversation.' },
333
+ backend: { type: 'string', enum: ['codebuddy', 'codebuddy-intl', 'codebuddy-ioa', 'codebuddy-international', 'codebuddy-en', 'workbuddy-en', 'workbuddy-ai', 'workbuddy'], description: 'Which CLI face to resume on (codebuddy-en = WorkBuddy international, aliases workbuddy-en / workbuddy-ai accepted); defaults to the backend owning the sessionId, then the user-preferred default backend (settings file).' },
334
+ mode: { type: 'string', enum: ['plan', 'accept-edits'], description: 'plan = no writes; accept-edits = allow edits (default).' },
335
+ model: { type: 'string', description: 'Optional model id (two layers: product face carries it, account licenses it; the credit multiplier of each id comes from the CLI descriptor — see codebuddy_run).' },
336
+ effort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] },
337
+ maxTurns: { type: 'integer', description: 'Optional max agentic turns (1-500).' },
338
+ cwd: { type: 'string', description: "Working directory for codebuddy; when resuming, defaults to the resumed session's project directory." },
339
+ timeoutSec: { type: 'integer', description: 'Run timeout seconds (10-3600, default 300).' }
340
+ },
341
+ required: ['prompt']
342
+ }
343
+ },
344
+ {
345
+ name: 'codebuddy_status',
346
+ description: 'Read a live snapshot of what the local codebuddy agent is currently doing. Returns one section per project (working directory): running count, the current step (tool name + arguments being executed, or thinking/typing), the recent step trail (tools executed, done/error), the last completed run status + session id, and per-project cumulative usage (runs + total tokens, since codebuddy exposes no quota API). Optional cwd filters to a single project. Call this to check on an in-flight codebuddy_run/codebuddy_continue without waiting for it to finish.',
347
+ inputSchema: { type: 'object', properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
348
+ }
349
+ ]
350
+
351
+ export function tools(env) {
352
+ return TOOLS.map((t) => Object.assign({}, t))
353
+ }
354
+
355
+ export async function call(name, args, io) {
356
+ if (io) lastIo = io
357
+ if (!lastIo) return 'codebuddy error: MCP io not initialized'
358
+ const a = args || {}
359
+ if (name === 'codebuddy_status') return statusText(a.cwd)
360
+ if (!a.prompt || !String(a.prompt).trim()) return 'codebuddy error: prompt is required'
361
+ const mapped = Object.assign({}, a)
362
+ if (name === 'codebuddy_continue' && !mapped.sessionId && mapped.latest) mapped.continueLatest = true
363
+ // MCP has no human answerer → no fallback dialog:失败返回错误文本(限流特征
364
+ // 时附注),由调用方决定下一步。
365
+ let res = await runCodebuddy(mapped, lastIo)
366
+ // CLI 侧瞬时错误(error_during_execution,CLI 不给原因):静默自动重试一次。
367
+ // 续接类调用(--resume/--continue)不自动重试:会话状态可能已被前一次改动。
368
+ if (provider.isTransientCliError(res) && !mapped.sessionId && !mapped.continueLatest) {
369
+ const retry = await runCodebuddy(mapped, lastIo)
370
+ if (retry.ok) res = retry
371
+ else { res = retry; res.retried = true }
372
+ }
373
+ return textResult(res)
374
+ }
375
+
376
+ // --check 守卫(keystone gate 之外自带 argv-drift 探针,agy 面同款精神):
377
+ // 三工具名与描述符一致 + buildArgv 关键面不许漂移。
378
+ export function check(env) {
379
+ const ts = tools(env)
380
+ const names = ts.map((t) => t.name)
381
+ for (const req of ['codebuddy_run', 'codebuddy_continue', 'codebuddy_status']) {
382
+ if (!names.includes(req)) throw new Error('codebuddy mcp: missing tool ' + req)
383
+ }
384
+ for (const t of ts) {
385
+ if (!t.name || !t.description || !t.inputSchema || t.inputSchema.type !== 'object') throw new Error('codebuddy mcp: bad tool def ' + JSON.stringify(t && t.name))
386
+ }
387
+ for (const k of ['run', 'continue', 'status']) {
388
+ if (!names.includes(provider.tools[k].name)) throw new Error('codebuddy mcp: tools surface missing descriptor name ' + provider.tools[k].name)
389
+ }
390
+ const b = provider.buildArgv(['codebuddy'], { prompt: 'x' }, { planActive: false, defaultMode: 'accept-edits' })
391
+ for (const f of ['--output-format', 'stream-json', '--permission-mode', 'bypassPermissions']) {
392
+ if (!b.argv.includes(f)) throw new Error('codebuddy mcp: argv contract drift: ' + f)
393
+ }
394
+ const bp = provider.buildArgv(['codebuddy'], { prompt: 'x', mode: 'plan' }, {})
395
+ if (!bp.argv.includes('--allowedTools') || !bp.argv.includes('plan')) throw new Error('codebuddy mcp: argv contract drift: plan branch (--permission-mode plan / --allowedTools Bash)')
396
+ return true
397
+ }