@luchenglong/cli-bridge 0.2.9 → 0.2.10

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.
@@ -1,578 +1,600 @@
1
- // providers/codebuddy.mjs — codebuddy provider descriptor。
2
- //
3
- // 移植基线:codebuddy-first-bridge v1.6.x 的纯逻辑与 preset/codebuddy-first 内联语义,
4
- // 逐字保持(契约 §8:工具入参语义与 schema 不许漂移)。provider 无关骨架在 bridge-kit。
5
- // 纯逻辑片:codebuddy-catalog.mjs(模型目录/凭据/探测)、codebuddy-schemas.mjs(三 schema)、
6
- // codebuddy-models-tool.mjs(codebuddy_models 工具)。build 会把 codebuddy-*.mjs 全部
7
- // 内联进沙箱同一作用域。
8
- //
9
- // 红线:本文件会被内联进 dynamic 沙箱 —— 绝不出现元环境字面量;process/env/fs 只经
10
- // deps 注入或 typeof 守卫读取;宿主能力全部走 attach(hostOpts)。isLimited 用本文件
11
- // 自定义的中文限流表(旧 core verbatim),不用 kit 通用表。
12
- import { createStatusEngine, createRunner, clampInt } from '../core/bridge-kit.mjs'
13
-
14
- // ── 提示词策略段(旧 preset POLICY_TEXT verbatim;systemPrompt order 5)────────
15
- // (LIMIT_RE 在 kit L33 已有,故此处带 CODEBUDDY_ 前缀)。
16
- // codebuddy-en 凭据的第三条通道:DSH 凭据库里可能已有的 workbuddy key(旧 core verbatim)。
17
-
18
- // 离线/读不到描述文件时的回退静态表(真机按账号实测快照,2026-09)。
19
- // 模型目录相关常量与纯逻辑已拆到 ./codebuddy-catalog.mjs —— 主文件原 864 行远超
20
- // 600 行红线(规则 §26)。
21
- // • ESM 形态(preset / MCP / 测试):直接 import。
22
- // • dynamic 沙箱形态:scripts/build.mjs 把该文件内联进同一作用域(模板顶部的
23
- // 第三个占位标记),import 行被 stripModuleSyntax 剥掉,同名标识符天然可见。
24
- // ⚠️ 本注释里绝不写出那个标记的字面量:build 做全局文本替换,注释中出现同样
25
- // 会被替换成正文代码,把注释劈开 → 生成物语法崩。
26
- import {
27
- BACKEND_MODEL_IDS, PRODUCT_DESCRIPTOR_ENV, CREDIT_ID_ALIASES, MODEL_DESCRIPTION, BACKEND_ENUM,
28
- accountConfigCandidates, productDescriptorCandidates, normalizeProductConfig,
29
- extractModelIds, parseCreditValue, formatCreditLabel, extractModelCredits, lookupModelCredits,
30
- backendModelCatalog, rememberAccountCatalog, getAccountCatalog, parseAccountModels,
31
- modelCatalogDetailed, formatModelCatalogText, cbCatalogSetHost,
32
- BACKEND_AUTH_IDS, CREDENTIAL_KEYS, readBackendAuthDomain,
33
- readDshWorkbuddyToken, resolveEnToken,
34
- fetchHelpModelsSync, cachedCliModels, currentPreferredBackend, parseHelpModels, probeAccountModelsSync,
35
-
36
- } from './codebuddy-catalog.mjs'
37
- import { codebuddyModelsTool } from './codebuddy-models-tool.mjs'
38
- import { POLICY_TEXT, CODEBUDDY_LIMIT_RE, BACKENDS, BACKEND_ENDPOINTS, AUTH_DOMAIN_ENDPOINTS, BACKEND_LABELS, BACKEND_ALIASES, DEFAULT_BACKEND, SETTINGS_KEYS } from './codebuddy-consts.mjs'
39
- import { RUN_PARAMS, CONTINUE_PARAMS, STATUS_PARAMS } from './codebuddy-schemas.mjs'
40
-
41
- export default function defineProvider(deps = {}) {
42
- const env = deps.env || {}
43
- let host = null // attach 后保存 hostOpts(preset/dynamic/MCP 形态宿主能力)
44
-
45
- // 宿主 fs/path 访问的统一入口:deps 注入优先;dynamic 内联体(无 import,运行在
46
- // 后端 Node 上下文)回退到 getBuiltinModule —— 与旧 core builtinModule() 的
47
- // typeof 守卫模式一致(这是旧 dynamic 端点修复生效的同一条通道,缺了它默认
48
- // 后端会回到 401 老病)。两者皆不可用时自然降级:读不到文件 = 静态表兜底。
49
- function builtinModule(name) {
50
- try {
51
- const proc = globalThis.process
52
- return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
53
- } catch (e) { return null }
54
- }
55
- function fsRead(p) {
56
- const nf = deps.nodeFs || builtinModule('node:fs')
57
- if (!nf || typeof nf.readFileSync !== 'function') return null
58
- try { return nf.readFileSync(p, 'utf8') } catch (e) { return null }
59
- }
60
- function pathJoin(...parts) {
61
- const np = deps.nodePath || builtinModule('node:path')
62
- if (np && typeof np.join === 'function') return np.join(...parts)
63
- return parts.join('/')
64
- }
65
- const pathSep = (deps.nodePath && deps.nodePath.sep) || (/[A-Za-z]:[\\/]/.test(env.USERPROFILE || env.APPDATA || '') ? '\\' : '/')
66
-
67
- // 把宿主真实能力注入 catalog 模块:它从本闭包搬出后拿不到 env / fsRead / pathJoin /
68
- // pathSep。未注入时 catalog 退到 getBuiltinModule 守卫(绝不抛)。内联到 dynamic
69
- // 沙箱时 cbCatalogSetHost 同作用域可见,行为一致。
70
- try {
71
- cbCatalogSetHost(env, deps.nodePath || builtinModule('node:path'), deps.nodeFs || builtinModule('node:fs'))
72
- } catch (e) { }
73
-
74
- // ── 端点/登录域(旧 core endpointHost verbatim:非法 URL 容错)───────────────
75
- function endpointHost(url) {
76
- const s = String(url || '').trim()
77
- if (!s) return ''
78
- try { return new URL(s).hostname.toLowerCase() } catch (e) { }
79
- const m = /^[a-z]+:\/\/([^/?#]+)/i.exec(s)
80
- return m ? m[1].toLowerCase() : ''
81
- }
82
-
83
- function resolveEndpoint(explicitBaseUrl, authDomain) {
84
- const explicit = String(explicitBaseUrl || '').trim()
85
- if (explicit) return explicit
86
- const d = String(authDomain || '').trim().toLowerCase()
87
- if (d && AUTH_DOMAIN_ENDPOINTS[d]) return AUTH_DOMAIN_ENDPOINTS[d]
88
- return null
89
- }
90
-
91
- function isBackend(v) { return BACKENDS.indexOf(v) >= 0 }
92
-
93
- // 后端名规范化(v1.3.3 verbatim):别名 + 大小写不敏感;非后端名原样返回。
94
- function normalizeBackend(v) {
95
- if (typeof v !== 'string') return v
96
- const lower = v.trim().toLowerCase()
97
- const aliased = BACKEND_ALIASES[lower] || lower
98
- return BACKENDS.indexOf(aliased) >= 0 ? aliased : v
99
- }
100
-
101
- // 后端解析优先级(旧 core verbatim):显式 > 会话归属 > 用户偏好 > 默认。
102
- function resolveBackend(args, target, preferredBackend) {
103
- if (args && isBackend(normalizeBackend(args.backend))) return normalizeBackend(args.backend)
104
- if (target && target.backend && isBackend(normalizeBackend(target.backend))) return normalizeBackend(target.backend)
105
- if (preferredBackend && isBackend(normalizeBackend(preferredBackend))) return normalizeBackend(preferredBackend)
106
- return DEFAULT_BACKEND
107
- }
108
-
109
- // 本次调用的端点/凭据 env 注入(旧 core endpointEnv verbatim 三规则)。
110
- function endpointEnv(backend, explicitBaseUrl, authDomain, authToken) {
111
- const out = {}
112
- const endpoint = resolveEndpoint(explicitBaseUrl, authDomain)
113
- const product = BACKEND_ENDPOINTS[backend]
114
- if (endpoint && endpointHost(endpoint) !== endpointHost(product)) {
115
- out.CODEBUDDY_BASE_URL = endpoint
116
- }
117
- if (backend === 'codebuddy-en') {
118
- const tok = String(authToken || '').trim()
119
- if (tok) out.CODEBUDDY_AUTH_TOKEN = tok
120
- }
121
- return Object.keys(out).length ? out : null
122
- }
123
-
124
- // 可操作提示(旧 core endpointMismatchHint 三分支 verbatim)。
125
- function endpointMismatchHint(backend, authDomain, hasToken) {
126
- if (backend === 'codebuddy-en' && !hasToken) {
127
- return 'codebuddy-en(WorkBuddy 国际版)没有可用凭据。国际版的登录 token 被桌面 App 的 protector key 封装,该密钥不落盘、headless CLI 无法自行读取(CLI 自身报 category:"missing-key")。桥接会依次尝试:插件设置 codebuddyEnToken → 环境变量 CODEBUDDY_AUTH_TOKEN → DSH 凭据库(.credentials.yaml / .env 里的 WORKBUDDY_TOKEN)——三者皆空。请在 DSH 里配好 workbuddy provider 的 key,或把国际版 token 填入插件设置;也可直接用 backend="workbuddy"(国内版桌面 CLI,免配置)。'
128
- }
129
- if (backend === 'codebuddy-intl') {
130
- return 'codebuddy-intl(CodeBuddy 国际版)使用同一个 npm CLI,但国际面模型由服务端按账号授权放行:未授权时 CLI 返回 400「model [...] is only available for authorized users」。若你用的是国内账号,请改用 backend="codebuddy"(国内面模型清单)或 backend="codebuddy-en"(WorkBuddy 国际版)。'
131
- }
132
- const d = String(authDomain || '').trim().toLowerCase()
133
- if (d && endpointHost(resolveEndpoint(null, d)) !== endpointHost(BACKEND_ENDPOINTS[backend])) {
134
- return backend + ' 的登录域是 ' + d + ',与产品端点 ' + BACKEND_ENDPOINTS[backend] + ' 不一致;桥接层已自动注入 CODEBUDDY_BASE_URL=' + AUTH_DOMAIN_ENDPOINTS[d] + '。若仍 401,请重新登录对应客户端。'
135
- }
136
- return null
137
- }
138
-
139
- // 从 auth 库读某后端登录域(旧 core readBackendAuthDomain verbatim 语义;fs 经
140
- // deps 注入。io 可覆盖 readFileSync/dir/env,供测试夹具与 MCP 面注入真实 fs)。
141
-
142
- // ── 模型目录(面板/诊断用;无促销角标版,见头注 TODO)───────────────────────
143
- // ── 设置清洗(旧 normalizeBridgeSettings verbatim;updatedAt 透传)───────────
144
- function normalizeSettings(raw) {
145
- const snap = { preferredBackend: DEFAULT_BACKEND, defaultModel: '', codebuddyEnToken: '', endpointOverride: '' }
146
- if (!raw || typeof raw !== 'object') return snap
147
- if (typeof raw.preferredBackend === 'string') {
148
- const nb = normalizeBackend(raw.preferredBackend)
149
- if (typeof nb === 'string' && BACKENDS.indexOf(nb) >= 0) snap.preferredBackend = nb
150
- }
151
- if (typeof raw.defaultModel === 'string') snap.defaultModel = raw.defaultModel.trim()
152
- if (typeof raw.codebuddyEnToken === 'string') snap.codebuddyEnToken = raw.codebuddyEnToken.trim()
153
- if (typeof raw.endpointOverride === 'string') snap.endpointOverride = raw.endpointOverride.trim()
154
- if (typeof raw.updatedAt === 'number' && raw.updatedAt > 0) snap.updatedAt = raw.updatedAt
155
- return snap
156
- }
157
-
158
- // 诊断(旧 diagnoseBackend verbatim;fs/env 走 deps)。
159
- function diagnoseBackend(backend, settings, io) {
160
- const o = io || {}
161
- const e = o.env || env
162
- const s = settings || normalizeSettings(null)
163
- const authDomain = readBackendAuthDomain(backend, Object.assign({ env: e }, o))
164
- const override = String(s.endpointOverride || '').trim()
165
- const byDomain = resolveEndpoint(null, authDomain)
166
- const endpoint = override || byDomain || BACKEND_ENDPOINTS[backend] || null
167
- const endpointSource = override ? 'override' : (byDomain ? 'auth-domain' : 'product')
168
- const tok = resolveEnToken(backend === 'codebuddy-en' ? s.codebuddyEnToken : null, { env })
169
- return {
170
- backend: backend,
171
- authDomain: authDomain,
172
- endpoint: endpoint,
173
- endpointSource: endpointSource,
174
- tokenSource: backend === 'codebuddy-en' ? tok.source : 'not-needed',
175
- tokenHint: tok.source === 'setting' ? '来自设置面板'
176
- : tok.source === 'env' ? '来自环境变量 CODEBUDDY_AUTH_TOKEN'
177
- : tok.source === 'dsh-store' ? '来自 DSH 凭据库(自动复用 WORKBUDDY_TOKEN)'
178
- : backend === 'codebuddy-en' ? '缺失:国际版 token 不落盘,需在设置面板填写或先在 DSH 配 workbuddy provider'
179
- : '无需填写(国内版由 CLI 自行登录)',
180
- mismatch: endpointMismatchHint(backend, authDomain, !!tok.token) !== null,
181
- hint: endpointMismatchHint(backend, authDomain, !!tok.token) || null,
182
- defaultModel: String(s.defaultModel || '').trim() || '(CLI 默认)'
183
- }
184
- }
185
-
186
- // ── 结果判定(旧 isLimited/isTransientCliError/failureHint verbatim)─────────
187
- function isLimitedCodebuddy(res) {
188
- if (!res || res.ok) return false
189
- // 前置凭据缺失:配置问题,不是限流(弹窗重试不会让 token 出现)。
190
- if (res.status === 'AUTH_REQUIRED') return false
191
- if (res.status === 'SPAWN_ERROR' || res.status === 'CODEBUDDY_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
192
- const hay = String(res.stderr || '') + ' ' + String(res.status || '')
193
- // 401/端点域不匹配:需要用户换登录域,不归限流类(旧 v1.3.0 verbatim)。
194
- if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
195
- return CODEBUDDY_LIMIT_RE.test(hay)
196
- }
197
-
198
- function isTransientCliError(res) {
199
- if (!res || res.ok) return false
200
- if (isLimitedCodebuddy(res)) return false
201
- return res.status === 'ERROR_DURING_EXECUTION'
202
- }
203
-
204
- function failureHint(res) {
205
- if (!res || res.ok) return ''
206
- // AUTH_REQUIRED:stderr 已带完整指引,透传(旧 v1.3.1 verbatim)。
207
- if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
208
- if (isTransientCliError(res)) {
209
- return res.retried
210
- ? '[诊断] codebuddy CLI 侧瞬时错误(error_during_execution,CLI 未给出原因),已自动重试 1 次仍失败。可换 model 再试一次;若仍失败请改用原生工具完成,或告知用户。'
211
- : '[诊断] codebuddy CLI 侧瞬时错误(error_during_execution,CLI 未给出原因)。直接重试同一请求通常即可通过;也可显式指定 model。不要据此认为任务本身有问题。'
212
- }
213
- if (res.status === 'PARSE_ERROR') {
214
- return '[诊断] 未能从 codebuddy 输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 codebuddy CLI 版本。'
215
- }
216
- if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
217
- return '[诊断] 认证失败(401):请求被送到的端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 `codebuddy` CLI 登录后重试;这不是限流,重试与回退都无效。'
218
- }
219
- return ''
220
- }
221
-
222
- // stream-json 解析(旧 parseCodebuddyJson verbatim)。
223
- function parseCodebuddyJson(stdoutText) {
224
- const trimmed = String(stdoutText || '').trim()
225
- if (!trimmed) return null
226
- const lines = trimmed.split(/\r?\n/).filter(Boolean)
227
- for (let i = lines.length - 1; i >= 0; i--) {
228
- const ln = lines[i].trim()
229
- if (!ln.startsWith('{')) continue
230
- try {
231
- const obj = JSON.parse(ln)
232
- if (obj && obj.type === 'result') return obj
233
- } catch (e) {}
234
- }
235
- try { return JSON.parse(trimmed) } catch (e) {}
236
- return null
237
- }
238
-
239
- // result 事件 → 统一结果对象(旧 buildResult verbatim,含账号清单收割)。
240
- function buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
241
- const bk = backend || 'codebuddy'
242
- const exitCode = outcome ? outcome.exitCode : null
243
- const errText = parsed && typeof parsed.error === 'string' && parsed.error ? parsed.error : ''
244
- const stderr = (stderrText ? String(stderrText).slice(-2000) : '') + (errText ? (stderrText ? ' ' : '') + errText : '')
245
- // v1.6.0:顺手收割服务端回吐的「本账号可用模型」清单(失败静默)。
246
- try {
247
- const harvest = parseAccountModels(stderr) || parseAccountModels(String(stdoutText || ''))
248
- if (harvest && harvest.length) rememberAccountCatalog(bk, harvest)
249
- } catch (e) { /* 收割失败不影响主流程 */ }
250
- if (parsed && parsed.type === 'result') {
251
- const isOk = exitCode === 0 && parsed.is_error === false && parsed.subtype === 'success'
252
- let modelUsed = null
253
- try {
254
- if (parsed.modelUsage && typeof parsed.modelUsage === 'object') {
255
- const names = Object.keys(parsed.modelUsage)
256
- if (names.length) modelUsed = names.join('+')
257
- }
258
- } catch (e) { }
259
- return {
260
- ok: isOk,
261
- status: String(parsed.subtype || (isOk ? 'SUCCESS' : 'ERROR')).toUpperCase(),
262
- response: typeof parsed.result === 'string' ? parsed.result : '',
263
- sessionId: typeof parsed.session_id === 'string' ? parsed.session_id : null,
264
- durationSeconds: typeof parsed.duration_ms === 'number' ? parsed.duration_ms / 1000 : null,
265
- numTurns: typeof parsed.num_turns === 'number' ? parsed.num_turns : null,
266
- totalTokens: (parsed.usage && typeof parsed.usage.input_tokens === 'number' && typeof parsed.usage.output_tokens === 'number')
267
- ? (parsed.usage.input_tokens + parsed.usage.output_tokens) : null,
268
- exitCode: exitCode,
269
- mode: mode,
270
- backend: bk,
271
- modelUsed: modelUsed,
272
- stderr: stderr
273
- }
274
- }
275
- return {
276
- ok: false,
277
- status: 'PARSE_ERROR',
278
- response: '',
279
- sessionId: null,
280
- durationSeconds: null,
281
- numTurns: null,
282
- totalTokens: null,
283
- exitCode: exitCode,
284
- mode: mode,
285
- backend: bk,
286
- stderr: stderr,
287
- rawStdout: String(stdoutText || '').slice(-2000)
288
- }
289
- }
290
-
291
- // 统一 argv 构造(旧 buildArgv verbatim;env 端点/凭据注入在本 provider 内完成,
292
- // 因为 kit 只透传 opts,宿主三形态都从本函数的返回值取 built.env 下发 spawn)。
293
- function buildArgvCodebuddy(prefix, args, opts) {
294
- const o = opts || {}
295
- const argv = [prefix[0], ...prefix.slice(1), '-p', String(args.prompt), '--output-format', 'stream-json']
296
- let mode = args.mode || o.defaultMode || 'auto'
297
- if (mode === 'auto') mode = o.planActive ? 'plan' : 'bypassPermissions'
298
- if (mode === 'plan') {
299
- argv.push('--permission-mode', 'plan')
300
- argv.push('--allowedTools', 'Bash')
301
- } else {
302
- mode = 'bypassPermissions'
303
- argv.push('--permission-mode', 'bypassPermissions')
304
- }
305
- // 模型优先级:显式 args.model > 用户偏好 defaultModel > CLI 默认。
306
- const model = args.model || o.defaultModel
307
- if (model) argv.push('--model', String(model))
308
- if (args.effort) argv.push('--effort', String(args.effort))
309
- if (args.maxTurns !== undefined && args.maxTurns !== null) argv.push('--max-turns', String(clampInt(args.maxTurns, 50, 1, 500)))
310
- if (Array.isArray(args.addDirs)) for (const d of args.addDirs) { if (d) argv.push('--add-dir', String(d)) }
311
- if (args.sessionId) argv.push('--resume', String(args.sessionId))
312
- else if (args.continueLatest) argv.push('--continue')
313
- const timeoutSec = clampInt(args.timeoutSec, 300, 10, 3600)
314
- // 端点/凭据注入(旧 preset/dynamic/MCP 三形态一致语义,宿主不必自算):
315
- // authDomain 只在有 fs 能力的宿主读得到;dynamic 沙箱 → 沿用 CLI 自身端点。
316
- const bk = o.backend || DEFAULT_BACKEND
317
- const settings = o.settings || null
318
- const authDomain = readBackendAuthDomain(bk, { env })
319
- const tok = resolveEnToken(bk === 'codebuddy-en' && settings ? settings.codebuddyEnToken : null, { env })
320
- const inject = endpointEnv(bk, settings ? settings.endpointOverride : null, authDomain, tok.token)
321
- return { argv: argv, timeoutSec: timeoutSec, mode: mode, env: o.env || inject }
322
- }
323
-
324
- // 流事件折叠(旧 foldEvent 主体 verbatim;project 状态由 kit engine 持有)。
325
- function foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) {
326
- if (!ev || !ev.type) return
327
- if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
328
- for (const block of ev.message.content) {
329
- if (block.type === 'tool_use') {
330
- p._stepCounter = (p._stepCounter || 0) + 1
331
- const stepIndex = p._stepCounter
332
- if (!p._toolMap) p._toolMap = {}
333
- p._toolMap[block.id] = { stepIndex: stepIndex, name: block.name }
334
- const args = summarizeArgs(block.input)
335
- p.trail.push({ stepIndex: stepIndex, state: 'ACTIVE', tool: block.name, args: args, at: new Date().toISOString() })
336
- if (p.trail.length > MAX_TRAIL) p.trail.shift()
337
- p.current = { tool: block.name, args: args, stepIndex: stepIndex, since: new Date().toISOString() }
338
- } else if (block.type === 'thinking') {
339
- p.current = { tool: 'thinking', args: { text: String(block.thinking || '').slice(0, MAX_ARG_LEN) }, stepIndex: 0, since: new Date().toISOString() }
340
- } else if (block.type === 'text') {
341
- p.current = { tool: 'typing', args: { text: String(block.text || '').slice(0, MAX_ARG_LEN) }, stepIndex: 0, since: new Date().toISOString() }
342
- }
343
- }
344
- } else if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {
345
- for (const block of ev.message.content) {
346
- if (block.type === 'tool_result' && p._toolMap && p._toolMap[block.tool_use_id]) {
347
- const rec = p._toolMap[block.tool_use_id]
348
- p.trail.push({ stepIndex: rec.stepIndex, state: block.is_error ? 'ERROR' : 'DONE', tool: rec.name, args: null, at: new Date().toISOString() })
349
- if (p.trail.length > MAX_TRAIL) p.trail.shift()
350
- if (p.current && p.current.stepIndex === rec.stepIndex) p.current = null
351
- }
352
- }
353
- }
354
- }
355
-
356
- const descriptor = {
357
- id: 'codebuddy',
358
- label: 'CB',
359
- presetId: 'codebuddy-first',
360
- defaultBackend: DEFAULT_BACKEND,
361
- defaultMode: 'auto',
362
- exeName: 'codebuddy',
363
- binEnvVars: ['CODEBUDDY_BIN', 'CODEBUDDY_EN_BIN', 'WORKBUDDY_BIN'],
364
- unavailableStatus: 'CODEBUDDY_UNAVAILABLE',
365
- // 旧 renderResult verbatim 语义:标头用具体 backend('workbuddy OK'),仅当结果
366
- // 无 backend 字段时回落 'codebuddy'(= provider.id)。agy/mimo 旧包标头硬编码
367
- // provider 名,不设此旗。
368
- backendLabel: true,
369
-
370
- backends: () => BACKENDS.slice(),
371
- normalizeBackend,
372
- resolveBackend,
373
-
374
- buildArgv: buildArgvCodebuddy,
375
- parseJson: parseCodebuddyJson,
376
- buildResult,
377
- foldStreamEvent,
378
- isLimited: isLimitedCodebuddy,
379
- isTransientCliError,
380
- failureHint,
381
-
382
- // codebuddy-en 无凭据的确定性前置(v1.3.1 语义,经 kit preflight 钩子实现):
383
- // 三通道皆空 → 直接 AUTH_REQUIRED(可操作指引进 stderr),不白跑一次 spawn。
384
- // 其它后端一律放行。dynamic 沙箱无 fs → dsh-store 通道缺席,abort 提示即可。
385
- preflight({ args, backend, engine, o }) {
386
- if (backend !== 'codebuddy-en') return null
387
- const settings = (o && o.getSettings && o.getSettings()) || null
388
- const tok = resolveEnToken(settings ? settings.codebuddyEnToken : null, { env })
389
- if (tok.token) return null
390
- const authDomain = readBackendAuthDomain('codebuddy-en', { env })
391
- const hint = endpointMismatchHint('codebuddy-en', authDomain, false)
392
- return {
393
- abort: {
394
- ok: false,
395
- status: 'AUTH_REQUIRED',
396
- response: '',
397
- sessionId: null,
398
- durationSeconds: null,
399
- numTurns: null,
400
- totalTokens: null,
401
- exitCode: null,
402
- mode: (args && args.mode) || descriptor.defaultMode || 'auto',
403
- backend: 'codebuddy-en',
404
- stderr: hint
405
- }
406
- }
407
- },
408
-
409
- tools: {
410
- run: {
411
- name: 'codebuddy_run',
412
- description: 'Dispatch a coding/build/debug/investigation task to the local codebuddy agent CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls codebuddy: it always runs non-interactively with permissions auto-approved (codebuddy never prompts). Use read-only native tools only for quick lookups and for final build/test verification. In mode=auto the DSH plan state decides plan vs bypassPermissions. Set background=true for long tasks and collect the result via job_output.',
413
- inputSchema: RUN_PARAMS
414
- },
415
- continue: {
416
- name: 'codebuddy_continue',
417
- description: 'Continue an existing codebuddy conversation with a follow-up prompt, reusing codebuddy context. Pass sessionId from a prior codebuddy_run result, or set latest=true to continue the most recent codebuddy conversation. Same DSH-controlled, no-prompt execution as codebuddy_run.',
418
- inputSchema: CONTINUE_PARAMS
419
- },
420
- status: {
421
- name: 'codebuddy_status',
422
- 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 agent_response 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.',
423
- inputSchema: STATUS_PARAMS
424
- }
425
- },
426
-
427
- // codebuddy_models:实时列出本机 codebuddy 可用模型(账号探测优先,回落 CLI 名册)。
428
- // 只在宿主有 spawn/fs 能力时注册(preset 注入 deps.process / MCP 注入 deps.nodeChild;
429
- // dynamic 沙箱形态省略)。**失败必须 ok:false + 可操作错误**,绝不静默返回空列表或
430
- // 伪造模型(工作手册红线 §7;对照 workbuddy-auth 的 catalog-store 静默降级 —— 不照抄)。
431
- extraTools: (deps.process || deps.nodeChild || deps.nodeFs) ? [codebuddyModelsTool({
432
- env, deps, BACKEND_ENUM, normalizeBackend, resolveBackend, modelCatalogDetailed, getAccountCatalog,
433
- // faceEnv:让账号探测打到**请求的那个产品面**(与 buildArgv 完全同一套端点/凭据
434
- // 注入规则)。缺了它,探测会走 CLI 自身默认登录面 —— 标着 codebuddy-en 实际却
435
- // 问的是 codebuddy 面,得出的清单看着对其实是另一个面的(本轮修的就是这类偏差)。
436
- faceEnv: (backend) => {
437
- try {
438
- const settings = (deps.settings && typeof deps.settings === 'object') ? deps.settings : {}
439
- const authDomain = readBackendAuthDomain(backend, { env })
440
- const tok = resolveEnToken(backend === 'codebuddy-en' ? settings.codebuddyEnToken : null, { env })
441
- return endpointEnv(backend, settings.endpointOverride, authDomain, tok.token) || {}
442
- } catch (e) { return {} }
443
- }
444
- })] : [],
445
-
446
- // 设置分区(契约 §3):keystone 统一文件 {codebuddy:{...}};旧扁平文件的字段
447
- // 级迁移由 keystone LEGACY_SOURCES 承担(字段名逐字相同)。
448
- settings: {
449
- keys: SETTINGS_KEYS,
450
- secretKeys: ['codebuddyEnToken'],
451
- defaults: () => normalizeSettings(null),
452
- normalize: (raw) => normalizeSettings(raw),
453
- meta() {
454
- // defaultModel 下拉 = 账号级实时清单(服务端裁决)。走过的弯路留档:
455
- // ① 随包冻结的 product.ioa.json → 16 项、2 个 id 已改名(错)
456
- // ② CLI `--help` 的 "Currently supported:" → 18 项,但那是 CLI 构建期烙进去的,
457
- // 实测同样滞后(含已废弃的 minimax-m3/kimi-k3-1,缺 glm-5.3-flashx 等)
458
- // ③ 现在:用无效模型探测让服务端回「该账号当前支持」的 19 项 —— 唯一权威源。
459
- // 面板同步回调故用 spawnSync 探测并缓存 5 分钟;失败则下拉留空 + 明确提示,
460
- // 绝不展示已知错误的 roster。面跟随 preferredBackend(旧实现硬编码 codebuddy 面)。
461
- const face = (currentPreferredBackend() && normalizeBackend(currentPreferredBackend())) || DEFAULT_BACKEND
462
- const faceEnv = (() => {
463
- try {
464
- const s = deps.settings || {}
465
- const authDomain = readBackendAuthDomain(face, { env })
466
- const tok = resolveEnToken(face === 'codebuddy-en' ? s.codebuddyEnToken : null, { env })
467
- return endpointEnv(face, s.endpointOverride, authDomain, tok.token) || {}
468
- } catch (e) { return {} }
469
- })()
470
- const live = cachedCliModels(face, () => probeAccountModelsSync(env, { envExtra: faceEnv, timeoutMs: 12000 }))
471
- let options = []
472
- let note = ''
473
- if (live && live.ok && live.ids.length) {
474
- const detail = modelCatalogDetailed(face, { env, accountModels: live.ids })
475
- const byId = new Map(detail.map((x) => [x.id, x]))
476
- options = live.ids.map((id) => {
477
- const d = byId.get(id)
478
- return { value: id, label: (d && d.label) || id }
479
- })
480
- note = '账号实时清单(' + live.ids.length + ' 项,服务端裁决);当前面:' + face + '。日常任务优先免费(x0.00)。'
481
- } else {
482
- note = '未能读取该账号的模型清单(' + String((live && live.error) || 'unknown') +
483
- ')。请确认该产品面已登录(在 codebuddy CLI 里完成登录),或手动输入模型 id。'
484
- }
485
- return {
486
- titleSuffix: 'CodeBuddy',
487
- fields: [
488
- {
489
- key: 'preferredBackend', label: '默认后端', type: 'select',
490
- options: BACKENDS.map((b) => ({ value: b, label: BACKEND_LABELS[b] })),
491
- note: '新会话未显式指定 backend 时用这里选的后端;单次调用可覆盖。codebuddy-en 需要 codebuddyEnToken 或 DSH 已配 workbuddy key。'
492
- },
493
- {
494
- key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true, placeholder: '(CLI 默认)',
495
- options,
496
- note
497
- },
498
- { key: 'codebuddyEnToken', label: 'WorkBuddy 国际版 Token', type: 'secret', note: '仅 backend="codebuddy-en" 需要。国际版登录 token 被 protector key 封装不落盘,只能手工提供;留空则自动尝试 DSH 凭据库的 WORKBUDDY_TOKEN。' },
499
- { key: 'endpointOverride', label: '端点覆盖', type: 'text', placeholder: 'https://…/v2', note: '一般留空:端点按登录域自动对齐(www.codebuddy.cn 等)。仅在需要强制指定 API 端点时填写,且必须自带 /v2。' }
500
- ]
501
- }
502
- },
503
- diagnose(snap) {
504
- const s = normalizeSettings(snap)
505
- const lines = []
506
- const seen = new Set()
507
- // 默认后端一行;codebuddy-en 是 4 面里唯一需要凭据的,恒附一行状态。
508
- for (const b of [s.preferredBackend, 'codebuddy-en']) {
509
- if (seen.has(b)) continue
510
- seen.add(b)
511
- const d = diagnoseBackend(b, s, {})
512
- lines.push(b + ': ' + (d.endpoint || '?') + '(登录域 ' + (d.authDomain || '未读到') + ',' + d.tokenHint + ')')
513
- if (d.hint) lines.push(' ⚠ ' + d.hint)
514
- }
515
- return lines.join('\n')
516
- }
517
- },
518
-
519
- mcp: { version: '0.2.2' }
520
- }
521
-
522
- // 三形态共用的工具逻辑(签名保持 keystone/队友接线契约不变)。
523
- descriptor.runToolLogic = async function (args, exec) {
524
- return this.runner.coreExecute(args, exec)
525
- }
526
- // 旧 preset continueTool.execute verbatim 映射(sessionId 优先,否则 latest)。
527
- descriptor.continueToolLogic = async function (args, exec) {
528
- const a = args || {}
529
- const mapped = { prompt: a.prompt, backend: a.backend, mode: a.mode, model: a.model, effort: a.effort, maxTurns: a.maxTurns, cwd: a.cwd, timeoutSec: a.timeoutSec, background: a.background }
530
- if (a.sessionId) mapped.sessionId = a.sessionId
531
- else if (a.latest) mapped.continueLatest = true
532
- return this.runner.coreExecute(mapped, exec)
533
- }
534
- // 旧 statusTool.execute verbatim:cwd 过滤 + 单项目聚合覆写(含 runs/totalTokens)。
535
- descriptor.statusToolLogic = function (args) {
536
- const a = args || {}
537
- const snap = this.engine.statusSnapshot()
538
- if (a.cwd) {
539
- const key = String(a.cwd)
540
- snap.projects = snap.projects.filter((p) => p.cwd === key)
541
- const g = snap.projects[0]
542
- if (g) { snap.state = g.state; snap.running = g.running; snap.current = g.current; snap.trail = g.trail; snap.lastStatus = g.lastStatus; snap.lastAt = g.lastAt; snap.lastSessionId = g.lastSessionId; snap.lastBackend = g.lastBackend; snap.fallbackActive = g.fallbackActive; snap.runs = g.runs; snap.totalTokens = g.totalTokens; snap.updatedAt = g.updatedAt }
543
- }
544
- return snap
545
- }
546
-
547
- // 供 preset/dynamic/MCP/测试形态复用的领域函数(不 import home-plugin,全部经
548
- // 描述符暴露;MCP 面据 buildArgv/parseJson/buildResult/isLimited/failureHint 组
549
- // 装真实 spawn 执行,语义与旧 mcp/codebuddy-mcp-server.mjs 一致)。
550
- Object.assign(descriptor, {
551
- policyText: POLICY_TEXT, limits: { LIMIT_RE: CODEBUDDY_LIMIT_RE },
552
- backendLabels: BACKEND_LABELS, backendEndpoints: BACKEND_ENDPOINTS,
553
- authDomainEndpoints: AUTH_DOMAIN_ENDPOINTS, backendAuthIds: BACKEND_AUTH_IDS,
554
- staticModelIds: BACKEND_MODEL_IDS, endpointHost, resolveEndpoint, endpointEnv,
555
- endpointMismatchHint, resolveEnToken, readDshWorkbuddyToken, readBackendAuthDomain,
556
- diagnoseBackend, parseAccountModels, rememberAccountCatalog, getAccountCatalog,
557
- normalizeProductConfig, extractModelIds, extractModelCredits, parseCreditValue,
558
- formatCreditLabel, lookupModelCredits, normalizeSettings
559
- })
560
- // env 经 io 绑定:catalog 是模块级纯函数,若共用模块级 env,多 provider 实例会互相
561
- // 覆盖(测试串扰)。这里每个实例调用时都带上自己的 env。
562
- const withEnv = (fn) => (backend, io) => fn(backend, Object.assign({ env }, io || {}))
563
- Object.assign(descriptor, {
564
- backendModelCatalog: withEnv(backendModelCatalog),
565
- modelCatalogDetailed: withEnv(modelCatalogDetailed),
566
- formatModelCatalogText: withEnv(formatModelCatalogText)
567
- })
568
-
569
- descriptor.attach = function (hostOpts) {
570
- host = hostOpts || {}
571
- this.engine = createStatusEngine({ provider: this, publish: host.publish })
572
- const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
573
- this.runner = runner
574
- return runner
575
- }
576
- descriptor.hostOptions = () => host
577
- return descriptor
578
- }
1
+ // providers/codebuddy.mjs — codebuddy provider descriptor。
2
+ //
3
+ // 移植基线:codebuddy-first-bridge v1.6.x 的纯逻辑与 preset 内联语义,逐字保持(契约 §8: // 工具入参语义与 schema 不许漂移)。provider 无关骨架在 bridge-kit。纯逻辑片:
4
+ // codebuddy-catalog.mjs(模型目录/凭据)、codebuddy-models-source.mjs(探测+缓存)、
5
+ // codebuddy-models-tool.mjs(codebuddy_models)、codebuddy-consts.mjs(POLICY_TEXT/后端表)、
6
+ // codebuddy-schemas.mjs(三 schema)。
7
+ //
8
+ // 红线:本文件会被内联进 dynamic 沙箱 —— 绝不出现元环境字面量;process/env/fs 只经 deps
9
+ // 注入或 typeof 守卫读取;宿主能力全部走 attach(hostOpts)。isLimited 用本文件自定义的中文
10
+ // 限流表(旧 core verbatim),不用 kit 通用表。命名纪律:顶层名绝不与 bridge-kit 同名
11
+ // (沙箱里同作用域),故限流表带 CODEBUDDY_ 前缀。
12
+ // ⚠️ 各片在沙箱里被内联进**同一作用域**(build 做全局文本替换):注释里绝不写占位标记的
13
+ // 字面量,否则注释会被替换成正文代码而劈开,生成物语法崩。
14
+ import { createStatusEngine, createRunner, clampInt } from '../core/bridge-kit.mjs'
15
+
16
+ import {
17
+ BACKEND_MODEL_IDS, PRODUCT_DESCRIPTOR_ENV, CREDIT_ID_ALIASES, MODEL_DESCRIPTION, BACKEND_ENUM,
18
+ accountConfigCandidates, productDescriptorCandidates, normalizeProductConfig,
19
+ extractModelIds, parseCreditValue, formatCreditLabel, extractModelCredits, lookupModelCredits,
20
+ backendModelCatalog, rememberAccountCatalog, getAccountCatalog, parseAccountModels,
21
+ modelCatalogDetailed, formatModelCatalogText, cbCatalogSetHost,
22
+ BACKEND_AUTH_IDS, CREDENTIAL_KEYS, readBackendAuthDomain,
23
+ readDshWorkbuddyToken, resolveEnToken
24
+ } from './codebuddy-catalog.mjs'
25
+ // 探测/缓存那一层单独一片(要走子进程,与纯解析内聚度低;见 codebuddy-models-source.mjs 头注)。
26
+ import {
27
+ fetchHelpModelsSync, cachedCliModels, currentPreferredBackend, parseHelpModels, probeAccountModelsSync,
28
+ rememberCliModels, probeAccountModels
29
+ } from './codebuddy-models-source.mjs'
30
+ import { codebuddyModelsTool } from './codebuddy-models-tool.mjs'
31
+ import { POLICY_TEXT, CODEBUDDY_LIMIT_RE, BACKENDS, BACKEND_ENDPOINTS, AUTH_DOMAIN_ENDPOINTS, BACKEND_LABELS, BACKEND_ALIASES, DEFAULT_BACKEND, SETTINGS_KEYS } from './codebuddy-consts.mjs'
32
+ import { RUN_PARAMS, CONTINUE_PARAMS, STATUS_PARAMS } from './codebuddy-schemas.mjs'
33
+
34
+ export default function defineProvider(deps = {}) {
35
+ const env = deps.env || {}
36
+ let host = null // attach 后保存 hostOpts(preset/dynamic/MCP 形态宿主能力)
37
+
38
+ // 宿主 fs/path 访问的统一入口:deps 注入优先;dynamic 内联体(无 import,运行在
39
+ // 后端 Node 上下文)回退到 getBuiltinModule —— 与旧 core builtinModule() 的
40
+ // typeof 守卫模式一致(这是旧 dynamic 端点修复生效的同一条通道,缺了它默认
41
+ // 后端会回到 401 老病)。两者皆不可用时自然降级:读不到文件 = 静态表兜底。
42
+ function builtinModule(name) {
43
+ try {
44
+ const proc = globalThis.process
45
+ return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
46
+ } catch (e) { return null }
47
+ }
48
+ function fsRead(p) {
49
+ const nf = deps.nodeFs || builtinModule('node:fs')
50
+ if (!nf || typeof nf.readFileSync !== 'function') return null
51
+ try { return nf.readFileSync(p, 'utf8') } catch (e) { return null }
52
+ }
53
+ function pathJoin(...parts) {
54
+ const np = deps.nodePath || builtinModule('node:path')
55
+ if (np && typeof np.join === 'function') return np.join(...parts)
56
+ return parts.join('/')
57
+ }
58
+ const pathSep = (deps.nodePath && deps.nodePath.sep) || (/[A-Za-z]:[\\/]/.test(env.USERPROFILE || env.APPDATA || '') ? '\\' : '/')
59
+
60
+ // 把宿主真实能力注入 catalog 模块:它从本闭包搬出后拿不到 env / fsRead / pathJoin /
61
+ // pathSep。未注入时 catalog 退到 getBuiltinModule 守卫(绝不抛)。内联到 dynamic
62
+ // 沙箱时 cbCatalogSetHost 同作用域可见,行为一致。
63
+ try {
64
+ cbCatalogSetHost(env, deps.nodePath || builtinModule('node:path'), deps.nodeFs || builtinModule('node:fs'))
65
+ } catch (e) { }
66
+
67
+ // ── 端点/登录域(旧 core endpointHost verbatim:非法 URL 容错)───────────────
68
+ function endpointHost(url) {
69
+ const s = String(url || '').trim()
70
+ if (!s) return ''
71
+ try { return new URL(s).hostname.toLowerCase() } catch (e) { }
72
+ const m = /^[a-z]+:\/\/([^/?#]+)/i.exec(s)
73
+ return m ? m[1].toLowerCase() : ''
74
+ }
75
+
76
+ function resolveEndpoint(explicitBaseUrl, authDomain) {
77
+ const explicit = String(explicitBaseUrl || '').trim()
78
+ if (explicit) return explicit
79
+ const d = String(authDomain || '').trim().toLowerCase()
80
+ if (d && AUTH_DOMAIN_ENDPOINTS[d]) return AUTH_DOMAIN_ENDPOINTS[d]
81
+ return null
82
+ }
83
+
84
+ function isBackend(v) { return BACKENDS.indexOf(v) >= 0 }
85
+
86
+ // 后端名规范化(v1.3.3 verbatim):别名 + 大小写不敏感;非后端名原样返回。
87
+ function normalizeBackend(v) {
88
+ if (typeof v !== 'string') return v
89
+ const lower = v.trim().toLowerCase()
90
+ const aliased = BACKEND_ALIASES[lower] || lower
91
+ return BACKENDS.indexOf(aliased) >= 0 ? aliased : v
92
+ }
93
+
94
+ // 后端解析优先级(旧 core verbatim):显式 > 会话归属 > 用户偏好 > 默认。
95
+ function resolveBackend(args, target, preferredBackend) {
96
+ if (args && isBackend(normalizeBackend(args.backend))) return normalizeBackend(args.backend)
97
+ if (target && target.backend && isBackend(normalizeBackend(target.backend))) return normalizeBackend(target.backend)
98
+ if (preferredBackend && isBackend(normalizeBackend(preferredBackend))) return normalizeBackend(preferredBackend)
99
+ return DEFAULT_BACKEND
100
+ }
101
+
102
+ // 本次调用的端点/凭据 env 注入(旧 core endpointEnv verbatim 三规则)。
103
+ function endpointEnv(backend, explicitBaseUrl, authDomain, authToken) {
104
+ const out = {}
105
+ const endpoint = resolveEndpoint(explicitBaseUrl, authDomain)
106
+ const product = BACKEND_ENDPOINTS[backend]
107
+ if (endpoint && endpointHost(endpoint) !== endpointHost(product)) {
108
+ out.CODEBUDDY_BASE_URL = endpoint
109
+ }
110
+ if (backend === 'codebuddy-en') {
111
+ const tok = String(authToken || '').trim()
112
+ if (tok) out.CODEBUDDY_AUTH_TOKEN = tok
113
+ }
114
+ return Object.keys(out).length ? out : null
115
+ }
116
+
117
+ // 可操作提示(旧 core endpointMismatchHint 三分支 verbatim)。
118
+ function endpointMismatchHint(backend, authDomain, hasToken) {
119
+ if (backend === 'codebuddy-en' && !hasToken) {
120
+ return 'codebuddy-en(WorkBuddy 国际版)没有可用凭据。国际版的登录 token 被桌面 App 的 protector key 封装,该密钥不落盘、headless CLI 无法自行读取(CLI 自身报 category:"missing-key")。桥接会依次尝试:插件设置 codebuddyEnToken → 环境变量 CODEBUDDY_AUTH_TOKEN → DSH 凭据库(.credentials.yaml / .env 里的 WORKBUDDY_TOKEN)——三者皆空。请在 DSH 里配好 workbuddy provider 的 key,或把国际版 token 填入插件设置;也可直接用 backend="workbuddy"(国内版桌面 CLI,免配置)。'
121
+ }
122
+ if (backend === 'codebuddy-intl') {
123
+ return 'codebuddy-intl(CodeBuddy 国际版)使用同一个 npm CLI,但国际面模型由服务端按账号授权放行:未授权时 CLI 返回 400「model [...] is only available for authorized users」。若你用的是国内账号,请改用 backend="codebuddy"(国内面模型清单)或 backend="codebuddy-en"(WorkBuddy 国际版)。'
124
+ }
125
+ const d = String(authDomain || '').trim().toLowerCase()
126
+ if (d && endpointHost(resolveEndpoint(null, d)) !== endpointHost(BACKEND_ENDPOINTS[backend])) {
127
+ return backend + ' 的登录域是 ' + d + ',与产品端点 ' + BACKEND_ENDPOINTS[backend] + ' 不一致;桥接层已自动注入 CODEBUDDY_BASE_URL=' + AUTH_DOMAIN_ENDPOINTS[d] + '。若仍 401,请重新登录对应客户端。'
128
+ }
129
+ return null
130
+ }
131
+
132
+ // 从 auth 库读某后端登录域(旧 core readBackendAuthDomain verbatim 语义;fs 经
133
+ // deps 注入。io 可覆盖 readFileSync/dir/env,供测试夹具与 MCP 面注入真实 fs)。
134
+
135
+ // ── 模型目录(面板/诊断用;无促销角标版,见头注 TODO)───────────────────────
136
+ // ── 设置清洗(旧 normalizeBridgeSettings verbatim;updatedAt 透传)───────────
137
+ function normalizeSettings(raw) {
138
+ const snap = { preferredBackend: DEFAULT_BACKEND, defaultModel: '', codebuddyEnToken: '', endpointOverride: '' }
139
+ if (!raw || typeof raw !== 'object') return snap
140
+ if (typeof raw.preferredBackend === 'string') {
141
+ const nb = normalizeBackend(raw.preferredBackend)
142
+ if (typeof nb === 'string' && BACKENDS.indexOf(nb) >= 0) snap.preferredBackend = nb
143
+ }
144
+ if (typeof raw.defaultModel === 'string') snap.defaultModel = raw.defaultModel.trim()
145
+ if (typeof raw.codebuddyEnToken === 'string') snap.codebuddyEnToken = raw.codebuddyEnToken.trim()
146
+ if (typeof raw.endpointOverride === 'string') snap.endpointOverride = raw.endpointOverride.trim()
147
+ if (typeof raw.updatedAt === 'number' && raw.updatedAt > 0) snap.updatedAt = raw.updatedAt
148
+ return snap
149
+ }
150
+
151
+ // 诊断(旧 diagnoseBackend verbatim;fs/env 走 deps)。
152
+ function diagnoseBackend(backend, settings, io) {
153
+ const o = io || {}
154
+ const e = o.env || env
155
+ const s = settings || normalizeSettings(null)
156
+ const authDomain = readBackendAuthDomain(backend, Object.assign({ env: e }, o))
157
+ const override = String(s.endpointOverride || '').trim()
158
+ const byDomain = resolveEndpoint(null, authDomain)
159
+ const endpoint = override || byDomain || BACKEND_ENDPOINTS[backend] || null
160
+ const endpointSource = override ? 'override' : (byDomain ? 'auth-domain' : 'product')
161
+ const tok = resolveEnToken(backend === 'codebuddy-en' ? s.codebuddyEnToken : null, { env })
162
+ return {
163
+ backend: backend,
164
+ authDomain: authDomain,
165
+ endpoint: endpoint,
166
+ endpointSource: endpointSource,
167
+ tokenSource: backend === 'codebuddy-en' ? tok.source : 'not-needed',
168
+ tokenHint: tok.source === 'setting' ? '来自设置面板'
169
+ : tok.source === 'env' ? '来自环境变量 CODEBUDDY_AUTH_TOKEN'
170
+ : tok.source === 'dsh-store' ? '来自 DSH 凭据库(自动复用 WORKBUDDY_TOKEN)'
171
+ : backend === 'codebuddy-en' ? '缺失:国际版 token 不落盘,需在设置面板填写或先在 DSH 配 workbuddy provider'
172
+ : '无需填写(国内版由 CLI 自行登录)',
173
+ mismatch: endpointMismatchHint(backend, authDomain, !!tok.token) !== null,
174
+ hint: endpointMismatchHint(backend, authDomain, !!tok.token) || null,
175
+ defaultModel: String(s.defaultModel || '').trim() || '(CLI 默认)'
176
+ }
177
+ }
178
+
179
+ // ── 结果判定(旧 isLimited/isTransientCliError/failureHint verbatim)─────────
180
+ function isLimitedCodebuddy(res) {
181
+ if (!res || res.ok) return false
182
+ // 前置凭据缺失:配置问题,不是限流(弹窗重试不会让 token 出现)。
183
+ if (res.status === 'AUTH_REQUIRED') return false
184
+ if (res.status === 'SPAWN_ERROR' || res.status === 'CODEBUDDY_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
185
+ const hay = String(res.stderr || '') + ' ' + String(res.status || '')
186
+ // 401/端点域不匹配:需要用户换登录域,不归限流类(旧 v1.3.0 verbatim)。
187
+ if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
188
+ return CODEBUDDY_LIMIT_RE.test(hay)
189
+ }
190
+
191
+ function isTransientCliError(res) {
192
+ if (!res || res.ok) return false
193
+ if (isLimitedCodebuddy(res)) return false
194
+ return res.status === 'ERROR_DURING_EXECUTION'
195
+ }
196
+
197
+ function failureHint(res) {
198
+ if (!res || res.ok) return ''
199
+ // AUTH_REQUIRED:stderr 已带完整指引,透传(旧 v1.3.1 verbatim)。
200
+ if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
201
+ if (isTransientCliError(res)) {
202
+ return res.retried
203
+ ? '[诊断] codebuddy CLI 侧瞬时错误(error_during_execution,CLI 未给出原因),已自动重试 1 次仍失败。可换 model 再试一次;若仍失败请改用原生工具完成,或告知用户。'
204
+ : '[诊断] codebuddy CLI 侧瞬时错误(error_during_execution,CLI 未给出原因)。直接重试同一请求通常即可通过;也可显式指定 model。不要据此认为任务本身有问题。'
205
+ }
206
+ if (res.status === 'PARSE_ERROR') {
207
+ return '[诊断] 未能从 codebuddy 输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 codebuddy CLI 版本。'
208
+ }
209
+ if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
210
+ return '[诊断] 认证失败(401):请求被送到的端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 `codebuddy` CLI 登录后重试;这不是限流,重试与回退都无效。'
211
+ }
212
+ return ''
213
+ }
214
+
215
+ // stream-json 解析(旧 parseCodebuddyJson verbatim)。
216
+ function parseCodebuddyJson(stdoutText) {
217
+ const trimmed = String(stdoutText || '').trim()
218
+ if (!trimmed) return null
219
+ const lines = trimmed.split(/\r?\n/).filter(Boolean)
220
+ for (let i = lines.length - 1; i >= 0; i--) {
221
+ const ln = lines[i].trim()
222
+ if (!ln.startsWith('{')) continue
223
+ try {
224
+ const obj = JSON.parse(ln)
225
+ if (obj && obj.type === 'result') return obj
226
+ } catch (e) {}
227
+ }
228
+ try { return JSON.parse(trimmed) } catch (e) {}
229
+ return null
230
+ }
231
+
232
+ // result 事件 → 统一结果对象(旧 buildResult verbatim,含账号清单收割)。
233
+ function buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
234
+ const bk = backend || 'codebuddy'
235
+ const exitCode = outcome ? outcome.exitCode : null
236
+ const errText = parsed && typeof parsed.error === 'string' && parsed.error ? parsed.error : ''
237
+ const stderr = (stderrText ? String(stderrText).slice(-2000) : '') + (errText ? (stderrText ? ' ' : '') + errText : '')
238
+ // v1.6.0:顺手收割服务端回吐的「本账号可用模型」清单(失败静默)。
239
+ try {
240
+ const harvest = parseAccountModels(stderr) || parseAccountModels(String(stdoutText || ''))
241
+ if (harvest && harvest.length) rememberAccountCatalog(bk, harvest)
242
+ } catch (e) { /* 收割失败不影响主流程 */ }
243
+ if (parsed && parsed.type === 'result') {
244
+ const isOk = exitCode === 0 && parsed.is_error === false && parsed.subtype === 'success'
245
+ let modelUsed = null
246
+ try {
247
+ if (parsed.modelUsage && typeof parsed.modelUsage === 'object') {
248
+ const names = Object.keys(parsed.modelUsage)
249
+ if (names.length) modelUsed = names.join('+')
250
+ }
251
+ } catch (e) { }
252
+ return {
253
+ ok: isOk,
254
+ status: String(parsed.subtype || (isOk ? 'SUCCESS' : 'ERROR')).toUpperCase(),
255
+ response: typeof parsed.result === 'string' ? parsed.result : '',
256
+ sessionId: typeof parsed.session_id === 'string' ? parsed.session_id : null,
257
+ durationSeconds: typeof parsed.duration_ms === 'number' ? parsed.duration_ms / 1000 : null,
258
+ numTurns: typeof parsed.num_turns === 'number' ? parsed.num_turns : null,
259
+ totalTokens: (parsed.usage && typeof parsed.usage.input_tokens === 'number' && typeof parsed.usage.output_tokens === 'number')
260
+ ? (parsed.usage.input_tokens + parsed.usage.output_tokens) : null,
261
+ exitCode: exitCode,
262
+ mode: mode,
263
+ backend: bk,
264
+ modelUsed: modelUsed,
265
+ stderr: stderr
266
+ }
267
+ }
268
+ return {
269
+ ok: false,
270
+ status: 'PARSE_ERROR',
271
+ response: '',
272
+ sessionId: null,
273
+ durationSeconds: null,
274
+ numTurns: null,
275
+ totalTokens: null,
276
+ exitCode: exitCode,
277
+ mode: mode,
278
+ backend: bk,
279
+ stderr: stderr,
280
+ rawStdout: String(stdoutText || '').slice(-2000)
281
+ }
282
+ }
283
+
284
+ // 统一 argv 构造(旧 buildArgv verbatim;env 端点/凭据注入在本 provider 内完成,
285
+ // 因为 kit 只透传 opts,宿主三形态都从本函数的返回值取 built.env 下发 spawn)。
286
+ function buildArgvCodebuddy(prefix, args, opts) {
287
+ const o = opts || {}
288
+ const argv = [prefix[0], ...prefix.slice(1), '-p', String(args.prompt), '--output-format', 'stream-json']
289
+ let mode = args.mode || o.defaultMode || 'auto'
290
+ if (mode === 'auto') mode = o.planActive ? 'plan' : 'bypassPermissions'
291
+ if (mode === 'plan') {
292
+ argv.push('--permission-mode', 'plan')
293
+ argv.push('--allowedTools', 'Bash')
294
+ } else {
295
+ mode = 'bypassPermissions'
296
+ argv.push('--permission-mode', 'bypassPermissions')
297
+ }
298
+ // 模型优先级:显式 args.model > 用户偏好 defaultModel > CLI 默认。
299
+ const model = args.model || o.defaultModel
300
+ if (model) argv.push('--model', String(model))
301
+ if (args.effort) argv.push('--effort', String(args.effort))
302
+ if (args.maxTurns !== undefined && args.maxTurns !== null) argv.push('--max-turns', String(clampInt(args.maxTurns, 50, 1, 500)))
303
+ if (Array.isArray(args.addDirs)) for (const d of args.addDirs) { if (d) argv.push('--add-dir', String(d)) }
304
+ if (args.sessionId) argv.push('--resume', String(args.sessionId))
305
+ else if (args.continueLatest) argv.push('--continue')
306
+ const timeoutSec = clampInt(args.timeoutSec, 300, 10, 3600)
307
+ // 端点/凭据注入(旧 preset/dynamic/MCP 三形态一致语义,宿主不必自算):
308
+ // authDomain 只在有 fs 能力的宿主读得到;dynamic 沙箱 → 沿用 CLI 自身端点。
309
+ const bk = o.backend || DEFAULT_BACKEND
310
+ const settings = o.settings || null
311
+ const authDomain = readBackendAuthDomain(bk, { env })
312
+ const tok = resolveEnToken(bk === 'codebuddy-en' && settings ? settings.codebuddyEnToken : null, { env })
313
+ const inject = endpointEnv(bk, settings ? settings.endpointOverride : null, authDomain, tok.token)
314
+ return { argv: argv, timeoutSec: timeoutSec, mode: mode, env: o.env || inject }
315
+ }
316
+
317
+ // 流事件折叠(旧 foldEvent 主体 verbatim;project 状态由 kit engine 持有)。
318
+ function foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) {
319
+ if (!ev || !ev.type) return
320
+ if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
321
+ for (const block of ev.message.content) {
322
+ if (block.type === 'tool_use') {
323
+ p._stepCounter = (p._stepCounter || 0) + 1
324
+ const stepIndex = p._stepCounter
325
+ if (!p._toolMap) p._toolMap = {}
326
+ p._toolMap[block.id] = { stepIndex: stepIndex, name: block.name }
327
+ const args = summarizeArgs(block.input)
328
+ p.trail.push({ stepIndex: stepIndex, state: 'ACTIVE', tool: block.name, args: args, at: new Date().toISOString() })
329
+ if (p.trail.length > MAX_TRAIL) p.trail.shift()
330
+ p.current = { tool: block.name, args: args, stepIndex: stepIndex, since: new Date().toISOString() }
331
+ } else if (block.type === 'thinking') {
332
+ p.current = { tool: 'thinking', args: { text: String(block.thinking || '').slice(0, MAX_ARG_LEN) }, stepIndex: 0, since: new Date().toISOString() }
333
+ } else if (block.type === 'text') {
334
+ p.current = { tool: 'typing', args: { text: String(block.text || '').slice(0, MAX_ARG_LEN) }, stepIndex: 0, since: new Date().toISOString() }
335
+ }
336
+ }
337
+ } else if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {
338
+ for (const block of ev.message.content) {
339
+ if (block.type === 'tool_result' && p._toolMap && p._toolMap[block.tool_use_id]) {
340
+ const rec = p._toolMap[block.tool_use_id]
341
+ p.trail.push({ stepIndex: rec.stepIndex, state: block.is_error ? 'ERROR' : 'DONE', tool: rec.name, args: null, at: new Date().toISOString() })
342
+ if (p.trail.length > MAX_TRAIL) p.trail.shift()
343
+ if (p.current && p.current.stepIndex === rec.stepIndex) p.current = null
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ const descriptor = {
350
+ id: 'codebuddy',
351
+ label: 'CB',
352
+ presetId: 'codebuddy-first',
353
+ defaultBackend: DEFAULT_BACKEND,
354
+ defaultMode: 'auto',
355
+ exeName: 'codebuddy',
356
+ binEnvVars: ['CODEBUDDY_BIN', 'CODEBUDDY_EN_BIN', 'WORKBUDDY_BIN'],
357
+ unavailableStatus: 'CODEBUDDY_UNAVAILABLE',
358
+ // 旧 renderResult verbatim 语义:标头用具体 backend('workbuddy OK'),仅当结果
359
+ // 无 backend 字段时回落 'codebuddy'(= provider.id)。agy/mimo 旧包标头硬编码
360
+ // provider 名,不设此旗。
361
+ backendLabel: true,
362
+
363
+ backends: () => BACKENDS.slice(),
364
+ normalizeBackend,
365
+ resolveBackend,
366
+
367
+ buildArgv: buildArgvCodebuddy,
368
+ parseJson: parseCodebuddyJson,
369
+ buildResult,
370
+ foldStreamEvent,
371
+ isLimited: isLimitedCodebuddy,
372
+ isTransientCliError,
373
+ failureHint,
374
+
375
+ // codebuddy-en 无凭据的确定性前置(v1.3.1 语义,经 kit preflight 钩子实现):
376
+ // 三通道皆空 → 直接 AUTH_REQUIRED(可操作指引进 stderr),不白跑一次 spawn。
377
+ // 其它后端一律放行。dynamic 沙箱无 fs → dsh-store 通道缺席,abort 提示即可。
378
+ preflight({ args, backend, engine, o }) {
379
+ if (backend !== 'codebuddy-en') return null
380
+ const settings = (o && o.getSettings && o.getSettings()) || null
381
+ const tok = resolveEnToken(settings ? settings.codebuddyEnToken : null, { env })
382
+ if (tok.token) return null
383
+ const authDomain = readBackendAuthDomain('codebuddy-en', { env })
384
+ const hint = endpointMismatchHint('codebuddy-en', authDomain, false)
385
+ return {
386
+ abort: {
387
+ ok: false,
388
+ status: 'AUTH_REQUIRED',
389
+ response: '',
390
+ sessionId: null,
391
+ durationSeconds: null,
392
+ numTurns: null,
393
+ totalTokens: null,
394
+ exitCode: null,
395
+ mode: (args && args.mode) || descriptor.defaultMode || 'auto',
396
+ backend: 'codebuddy-en',
397
+ stderr: hint
398
+ }
399
+ }
400
+ },
401
+
402
+ tools: {
403
+ run: {
404
+ name: 'codebuddy_run',
405
+ description: 'Dispatch a coding/build/debug/investigation task to the local codebuddy agent CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls codebuddy: it always runs non-interactively with permissions auto-approved (codebuddy never prompts). Use read-only native tools only for quick lookups and for final build/test verification. In mode=auto the DSH plan state decides plan vs bypassPermissions. Set background=true for long tasks and collect the result via job_output.',
406
+ inputSchema: RUN_PARAMS
407
+ },
408
+ continue: {
409
+ name: 'codebuddy_continue',
410
+ description: 'Continue an existing codebuddy conversation with a follow-up prompt, reusing codebuddy context. Pass sessionId from a prior codebuddy_run result, or set latest=true to continue the most recent codebuddy conversation. Same DSH-controlled, no-prompt execution as codebuddy_run.',
411
+ inputSchema: CONTINUE_PARAMS
412
+ },
413
+ status: {
414
+ name: 'codebuddy_status',
415
+ 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 agent_response 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.',
416
+ inputSchema: STATUS_PARAMS
417
+ }
418
+ },
419
+
420
+ // codebuddy_models:只在宿主有 spawn/fs 能力时注册(preset 注 deps.process;MCP 注
421
+ // deps.nodeChild;dynamic 沙箱省略)。**失败必须 ok:false + 可操作错误**,绝不静默返回
422
+ // 空列表或伪造模型(红线 §7;对照 workbuddy-auth 的 catalog-store 静默降级 —— 不照抄)。
423
+ extraTools: (deps.process || deps.nodeChild || deps.nodeFs) ? [codebuddyModelsTool({
424
+ env, deps, BACKEND_ENUM, normalizeBackend, resolveBackend, modelCatalogDetailed, getAccountCatalog,
425
+ // faceEnv:让账号探测打到**请求的那个产品面**(与 buildArgv 同一套端点/凭据注入)。
426
+ // 缺了它探测会走 CLI 自身默认登录面 —— 标着 codebuddy-en 却去问 codebuddy 面。
427
+ faceEnv: (backend) => {
428
+ try {
429
+ const settings = (deps.settings && typeof deps.settings === 'object') ? deps.settings : {}
430
+ const authDomain = readBackendAuthDomain(backend, { env })
431
+ const tok = resolveEnToken(backend === 'codebuddy-en' ? settings.codebuddyEnToken : null, { env })
432
+ return endpointEnv(backend, settings.endpointOverride, authDomain, tok.token) || {}
433
+ } catch (e) { return {} }
434
+ }
435
+ })] : [],
436
+
437
+ // 设置分区(契约 §3):keystone 统一文件 {codebuddy:{...}};旧扁平文件的字段
438
+ // 级迁移由 keystone LEGACY_SOURCES 承担(字段名逐字相同)。
439
+ settings: {
440
+ keys: SETTINGS_KEYS,
441
+ secretKeys: ['codebuddyEnToken'],
442
+ defaults: () => normalizeSettings(null),
443
+ normalize: (raw) => normalizeSettings(raw),
444
+ meta() {
445
+ // defaultModel 下拉 = 缓存里的账号级清单。走过的弯路:静态表层(16 项、2 个 id 已改名)
446
+ // → CLI `--help` 名册(18 项但仍滞后、含已废 id)→ 无效模型探测让服务端回权威清单。
447
+ // 契约 §3.2 起 meta() **只读缓存、绝不 spawn**;探测由「拉取可用模型」触发 refreshModels。
448
+ // 面的取值必须与 refreshModels 同源(先宿主设置、再设置文件),否则会「刷新成功但下拉仍空」。
449
+ const face0 = (deps.settings && deps.settings.preferredBackend) || currentPreferredBackend()
450
+ const face = normalizeBackend(face0) || DEFAULT_BACKEND
451
+ const live = cachedCliModels(face) // 只读:未命中返回 undefined
452
+ let options = []
453
+ let note = '点下方「拉取可用模型」从服务端获取本账号的可用清单;当前面:' + face +
454
+ '。日常任务优先免费(x0.00)。'
455
+ if (live && live.ids && live.ids.length) {
456
+ const detail = modelCatalogDetailed(face, { env, accountModels: live.ids })
457
+ const byId = new Map(detail.map((x) => [x.id, x]))
458
+ options = live.ids.map((id) => {
459
+ const d = byId.get(id)
460
+ return { value: id, label: (d && d.label) || id }
461
+ })
462
+ note = '上次拉取结果(' + live.ids.length + ' 项,服务端裁决);当前面:' + face +
463
+ '。要更新请重新点「拉取可用模型」。'
464
+ } else {
465
+ note = '尚未拉取(或该面未登录)。点「拉取可用模型」让服务端回吐本账号的清单;' +
466
+ '此前也可直接手输模型 id。当前面:' + face + '。'
467
+ }
468
+ return {
469
+ titleSuffix: 'CodeBuddy',
470
+ fields: [
471
+ {
472
+ key: 'preferredBackend', label: '默认后端', type: 'select',
473
+ options: BACKENDS.map((b) => ({ value: b, label: BACKEND_LABELS[b] })),
474
+ note: '新会话未显式指定 backend 时用这里选的后端;单次调用可覆盖。codebuddy-en 需要 codebuddyEnToken 或 DSH 已配 workbuddy key。'
475
+ },
476
+ {
477
+ key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true, placeholder: '(CLI 默认)',
478
+ options,
479
+ refresh: { label: '拉取可用模型' },
480
+ note
481
+ },
482
+ { key: 'codebuddyEnToken', label: 'WorkBuddy 国际版 Token', type: 'secret', note: '仅 backend="codebuddy-en" 需要。国际版登录 token 被 protector key 封装不落盘,只能手工提供;留空则自动尝试 DSH 凭据库的 WORKBUDDY_TOKEN。' },
483
+ { key: 'endpointOverride', label: '端点覆盖', type: 'text', placeholder: 'https://…/v2', note: '一般留空:端点按登录域自动对齐(www.codebuddy.cn 等)。仅在需要强制指定 API 端点时填写,且必须自带 /v2。' }
484
+ ]
485
+ }
486
+ },
487
+ diagnose(snap) {
488
+ const s = normalizeSettings(snap)
489
+ const lines = []
490
+ const seen = new Set()
491
+ // 默认后端一行;codebuddy-en 是 4 面里唯一需要凭据的,恒附一行状态。
492
+ for (const b of [s.preferredBackend, 'codebuddy-en']) {
493
+ if (seen.has(b)) continue
494
+ seen.add(b)
495
+ const d = diagnoseBackend(b, s, {})
496
+ lines.push(b + ': ' + (d.endpoint || '?') + '(登录域 ' + (d.authDomain || '未读到') + ',' + d.tokenHint + ')')
497
+ if (d.hint) lines.push(' ⚠ ' + d.hint)
498
+ }
499
+ return lines.join('\n')
500
+ },
501
+ /**
502
+ * 手动拉取(契约 §3.2):唯一权威源 = 无效模型触发的服务端 400 报文(「Currently
503
+ * supported models for your account」)。成功后回填缓存,settingsView 再取 meta 即显示。
504
+ * 失败一律 ok:false + 可操作错误 —— 绝不返回 {ok:true, ids:[]},也绝不把静态表/CLI
505
+ * 自报名册冒充成账号可用清单(0.2.6~0.2.7 反复踩过的坑)。
506
+ */
507
+ async refreshModels(settings, envOverride) {
508
+ const s = normalizeSettings(settings)
509
+ const face = normalizeBackend(s.preferredBackend) || DEFAULT_BACKEND
510
+ const e = envOverride || env
511
+ // 端点/凭据注入与 buildArgv 同一套规则(否则会拿 A 面凭据去问 B 面)。
512
+ let faceEnv = {}
513
+ try {
514
+ const authDomain = readBackendAuthDomain(face, { env: e })
515
+ const tok = resolveEnToken(face === 'codebuddy-en' ? s.codebuddyEnToken : null, { env: e })
516
+ faceEnv = endpointEnv(face, s.endpointOverride, authDomain, tok.token) || {}
517
+ } catch (err) { faceEnv = {} }
518
+ let probe
519
+ try {
520
+ probe = await probeAccountModels(e, { envExtra: faceEnv, timeoutMs: 90000 })
521
+ } catch (err) {
522
+ return { ok: false, ids: [], source: 'account-live', error: '探测进程异常:' + String((err && err.message) || err) }
523
+ }
524
+ if (!probe || !probe.ok || !probe.ids || !probe.ids.length) {
525
+ return {
526
+ ok: false, ids: [], source: 'account-live',
527
+ error: '未能从「' + face + '」面获取账号模型清单:' + String((probe && probe.error) || 'no roster') +
528
+ '。常见原因:该面未登录(先在 codebuddy CLI 里 /login)、token 缺失或端点不通;' +
529
+ '也可在「默认后端」里换个面再拉。'
530
+ }
531
+ }
532
+ rememberCliModels(face, probe.ids, 'account-live')
533
+ // 标签沿用目录展示名(没有就回落 id),与下拉其它项一致。
534
+ const detail = modelCatalogDetailed(face, { env: e, accountModels: probe.ids })
535
+ const labels = {}
536
+ for (const x of (Array.isArray(detail) ? detail : [])) if (x && x.label) labels[x.id] = x.label
537
+ return { ok: true, ids: probe.ids, labels, source: 'account-live' }
538
+ }
539
+ },
540
+
541
+ mcp: { version: '0.2.2' }
542
+ }
543
+
544
+ // 三形态共用的工具逻辑(签名保持 keystone/队友接线契约不变)。
545
+ descriptor.runToolLogic = async function (args, exec) {
546
+ return this.runner.coreExecute(args, exec)
547
+ }
548
+ // 旧 preset continueTool.execute verbatim 映射(sessionId 优先,否则 latest)。
549
+ descriptor.continueToolLogic = async function (args, exec) {
550
+ const a = args || {}
551
+ const mapped = { prompt: a.prompt, backend: a.backend, mode: a.mode, model: a.model, effort: a.effort, maxTurns: a.maxTurns, cwd: a.cwd, timeoutSec: a.timeoutSec, background: a.background }
552
+ if (a.sessionId) mapped.sessionId = a.sessionId
553
+ else if (a.latest) mapped.continueLatest = true
554
+ return this.runner.coreExecute(mapped, exec)
555
+ }
556
+ // 旧 statusTool.execute verbatim:cwd 过滤 + 单项目聚合覆写(含 runs/totalTokens)。
557
+ descriptor.statusToolLogic = function (args) {
558
+ const a = args || {}
559
+ const snap = this.engine.statusSnapshot()
560
+ if (a.cwd) {
561
+ const key = String(a.cwd)
562
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
563
+ const g = snap.projects[0]
564
+ if (g) { snap.state = g.state; snap.running = g.running; snap.current = g.current; snap.trail = g.trail; snap.lastStatus = g.lastStatus; snap.lastAt = g.lastAt; snap.lastSessionId = g.lastSessionId; snap.lastBackend = g.lastBackend; snap.fallbackActive = g.fallbackActive; snap.runs = g.runs; snap.totalTokens = g.totalTokens; snap.updatedAt = g.updatedAt }
565
+ }
566
+ return snap
567
+ }
568
+
569
+ // 供 preset/dynamic/MCP/测试形态复用的领域函数(不 import home-plugin,全部经
570
+ // 描述符暴露;MCP 面据 buildArgv/parseJson/buildResult/isLimited/failureHint 组
571
+ // 装真实 spawn 执行,语义与旧 mcp/codebuddy-mcp-server.mjs 一致)。
572
+ Object.assign(descriptor, {
573
+ policyText: POLICY_TEXT, limits: { LIMIT_RE: CODEBUDDY_LIMIT_RE },
574
+ backendLabels: BACKEND_LABELS, backendEndpoints: BACKEND_ENDPOINTS,
575
+ authDomainEndpoints: AUTH_DOMAIN_ENDPOINTS, backendAuthIds: BACKEND_AUTH_IDS,
576
+ staticModelIds: BACKEND_MODEL_IDS, endpointHost, resolveEndpoint, endpointEnv,
577
+ endpointMismatchHint, resolveEnToken, readDshWorkbuddyToken, readBackendAuthDomain,
578
+ diagnoseBackend, parseAccountModels, rememberAccountCatalog, getAccountCatalog,
579
+ normalizeProductConfig, extractModelIds, extractModelCredits, parseCreditValue,
580
+ formatCreditLabel, lookupModelCredits, normalizeSettings
581
+ })
582
+ // env 经 io 绑定:catalog 是模块级纯函数,若共用模块级 env,多 provider 实例会互相
583
+ // 覆盖(测试串扰)。这里每个实例调用时都带上自己的 env。
584
+ const withEnv = (fn) => (backend, io) => fn(backend, Object.assign({ env }, io || {}))
585
+ Object.assign(descriptor, {
586
+ backendModelCatalog: withEnv(backendModelCatalog),
587
+ modelCatalogDetailed: withEnv(modelCatalogDetailed),
588
+ formatModelCatalogText: withEnv(formatModelCatalogText)
589
+ })
590
+
591
+ descriptor.attach = function (hostOpts) {
592
+ host = hostOpts || {}
593
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
594
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
595
+ this.runner = runner
596
+ return runner
597
+ }
598
+ descriptor.hostOptions = () => host
599
+ return descriptor
600
+ }