@luchenglong/cli-bridge 0.2.8 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/PROVIDER-CONTRACT.md +87 -5
  3. package/README.md +12 -11
  4. package/docs/HANDOVER.md +109 -15
  5. package/dynamic/agy.host.js +1 -1
  6. package/dynamic/claude.host.js +1858 -0
  7. package/dynamic/claude.host.template.mjs +108 -0
  8. package/dynamic/codebuddy.host.js +1427 -1379
  9. package/dynamic/codex.host.js +1930 -0
  10. package/dynamic/codex.host.template.mjs +137 -0
  11. package/dynamic/mimo.host.js +721 -515
  12. package/dynamic/mimo.host.template.mjs +2 -0
  13. package/dynamic/opencode.host.js +741 -585
  14. package/dynamic/opencode.host.template.mjs +2 -0
  15. package/home-plugin/cli-bridge-indicator/lib/client.js +74 -90
  16. package/home-plugin/cli-bridge-indicator/lib/index.mjs +48 -0
  17. package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +2 -3
  18. package/mcp/cli-bridge-mcp.mjs +4 -2
  19. package/mcp/providers/claude.mjs +231 -0
  20. package/mcp/providers/codebuddy.mjs +427 -427
  21. package/mcp/providers/codex.mjs +239 -0
  22. package/package.json +6 -2
  23. package/preset/claude-first/agent.cordis.yml +136 -0
  24. package/preset/claude-first/cli-bridge.mjs +131 -0
  25. package/preset/claude-first/plugin-list.mjs +28 -0
  26. package/preset/claude-first/preset.yml +3 -0
  27. package/preset/codex-first/agent.cordis.yml +136 -0
  28. package/preset/codex-first/cli-bridge.mjs +160 -0
  29. package/preset/codex-first/plugin-list.mjs +28 -0
  30. package/preset/codex-first/preset.yml +3 -0
  31. package/providers/claude-exe.mjs +112 -0
  32. package/providers/claude-models.mjs +338 -0
  33. package/providers/claude-policy.mjs +37 -0
  34. package/providers/claude.mjs +561 -0
  35. package/providers/codebuddy-catalog.mjs +367 -599
  36. package/providers/codebuddy-consts.mjs +72 -72
  37. package/providers/codebuddy-models-source.mjs +254 -0
  38. package/providers/codebuddy-models-tool.mjs +106 -106
  39. package/providers/codebuddy-schemas.mjs +46 -46
  40. package/providers/codebuddy.mjs +600 -578
  41. package/providers/codex-cli.mjs +405 -0
  42. package/providers/codex-models.mjs +107 -0
  43. package/providers/codex.mjs +585 -0
  44. package/providers/mimo-pure.mjs +270 -111
  45. package/providers/mimo.mjs +453 -517
  46. package/providers/opencode-exe.mjs +190 -63
  47. package/providers/opencode.mjs +553 -587
  48. package/providers/registry.mjs +25 -1
  49. package/scripts/build.mjs +261 -262
  50. package/scripts/verify.mjs +8 -2
@@ -0,0 +1,561 @@
1
+ // providers/claude.mjs — claude provider descriptor(Claude Code CLI;本仓第五家 CLI)。
2
+ //
3
+ // 基线 = 本机实测 Claude Code 2.0.76(`claude` = 原生 claude.exe),端点被用户指向第三方
4
+ // Anthropic 兼容服务(~/.claude/settings.json 的 env: ANTHROPIC_BASE_URL/AUTH_TOKEN +
5
+ // ANTHROPIC_DEFAULT_*_MODEL 别名映射)。写代码时最容易踩的几条事实:
6
+ // • 非交互 = `claude -p "<prompt>"`,prompt 是**单参数**(不是位置参数流,也不是 stdin)。
7
+ // • `--output-format json` → stdout 单行 result 事件;`stream-json` 必须配 `--verbose`,
8
+ // 实测 4 行:system(init) / assistant(thinking) / assistant(text) / result。
9
+ // • ⚠️ 成功判定陷阱:result 事件 `is_error:true` 时退出码**可能是 0 也可能 1**
10
+ // (实测无效模型 = exit 1,但 stdout 仍是合法 JSON 且 `subtype` 竟然还是 "success")。
11
+ // 故 buildResult 只认 `is_error` + `subtype`,绝不只看 exit code。
12
+ // • 续聊 = `--resume <session_id>`;`-c/--continue` 续最近一次。session_id 来自
13
+ // result 事件(json/stream-json 都有)。
14
+ // • 权限:`--permission-mode plan|acceptEdits|...` 或 `--dangerously-skip-permissions`;
15
+ // -p 模式没有 TTY,任何需要交互批准的路径都必须走 bypass,否则会挂到超时。
16
+ //
17
+ // 红线(PROVIDER-CONTRACT.md §8 + §1):
18
+ // - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
19
+ // process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
20
+ // - 只允许 import '../core/bridge-kit.mjs' 与同目录片文件(claude-exe / claude-models)。
21
+ // - 模型清单只认服务端权威探测;拿不到 = ok:false + 可操作错误(绝不静默空数组/配置冒充)。
22
+ import { createStatusEngine, createRunner, clampInt, isLimited } from '../core/bridge-kit.mjs'
23
+ import { claudeExeCandidates, claudePathHint, resolveExePrefix, splitExeSpec, CLAUDE_BIN_ENV_VARS } from './claude-exe.mjs'
24
+ import { PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache } from './claude-models.mjs'
25
+ // 单行 re-export(build 内联时整行剥离;多行形态风险见 build.mjs 的 stripModuleSyntax 注释)。
26
+ export { claudeExeCandidates, claudePathHint, resolveExePrefix, splitExeSpec, PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache }
27
+
28
+ function nowIso() { return new Date().toISOString() }
29
+
30
+ // 权限模式的用户可见取值(auto 由 DSH plan 状态决定;其余直通 CLI)。
31
+ const MODE_ENUM = ['auto', 'plan', 'accept-edits', 'bypass']
32
+
33
+ // 手动拉取(契约 §3.2)的探测时限与失败指引。
34
+ // 失败文案必须「可操作」:面板只会把 error 原样显示给用户,没写下一步等于没帮上忙。
35
+ const REFRESH_TIMEOUT_MS = 30000
36
+ const REFRESH_HINT = '排查顺序:① 确认设置面板的「claude 可执行文件路径」或 CLAUDE_BIN 指向真实 claude(原生 .exe 优先);② 确认 ~/.claude/settings.json 的 env 里 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN 指向可用端点(该面若未登录,先在终端跑一次 claude 完成登录);③ 再点一次「拉取可用模型」。'
37
+
38
+ /** 缓存年龄的短文案(面板 note 用;只描述事实,不假装新鲜)。 */
39
+ function ageText(ms) {
40
+ const min = Math.floor(Math.max(0, Number(ms) || 0) / 60000)
41
+ if (min < 1) return '刚刚拉取'
42
+ if (min < 60) return min + ' 分钟前拉取'
43
+ return Math.floor(min / 60) + ' 小时前拉取'
44
+ }
45
+
46
+ // ── 三工具 schema ─────────────────────────────────────────────────────────────
47
+ const RUN_PARAMS = {
48
+ type: 'object',
49
+ additionalProperties: false,
50
+ required: ['prompt'],
51
+ properties: {
52
+ prompt: { type: 'string', description: 'The task instruction for claude. On first dispatch for a topic, include a compact CONTEXT preamble (goal, repo root, key paths, decisions/constraints; point at paths, do not paste file contents — claude reads the repo itself). For follow-ups on the same topic, use claude_continue with latest=true or sessionId (claude keeps session context; state only what is new).' },
53
+ mode: { type: 'string', enum: MODE_ENUM, description: 'Permission mode. auto (default) follows the DSH plan state: plan when the session is in plan mode, otherwise bypass (permissions auto-approved so claude never blocks). plan = read-only planning; accept-edits = allow file edits but still ask for risky commands; bypass = --dangerously-skip-permissions.' },
54
+ model: { type: 'string', description: 'Optional model id or alias (e.g. "sonnet", "opus", or a full id). Which ids actually work is decided by the endpoint, not by this bridge — call claude_models for the authoritative server-side list. Omit to use the model chosen in CLI Bridge settings, else claude\'s own configuration.' },
55
+ maxBudgetUsd: { type: 'number', description: 'Optional hard cap on API spend for this run (passes --max-budget-usd).' },
56
+ cwd: { type: 'string', description: 'Working directory for claude. Defaults to the DSH workspace root.' },
57
+ addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra directories to allow tool access to (--add-dir).' },
58
+ allowedTools: { type: 'array', items: { type: 'string' }, description: 'Optional allow-list of tool names (--allowedTools).' },
59
+ disallowedTools: { type: 'array', items: { type: 'string' }, description: 'Optional deny-list of tool names (--disallowedTools).' },
60
+ systemPrompt: { type: 'string', description: 'Optional replacement system prompt (--system-prompt).' },
61
+ appendSystemPrompt: { type: 'string', description: 'Optional system prompt appended to the default one (--append-system-prompt).' },
62
+ forkSession: { type: 'boolean', description: 'When resuming, create a new session id instead of reusing the original (--fork-session).' },
63
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
64
+ background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with claude_status. Set false to block for the final answer.' }
65
+ }
66
+ }
67
+ const CONTINUE_PARAMS = {
68
+ type: 'object',
69
+ additionalProperties: false,
70
+ required: ['prompt'],
71
+ properties: {
72
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing claude session. State only what is new; claude preserves its session context.' },
73
+ sessionId: { type: 'string', description: 'claude session id to resume (from a prior claude_run result; passes --resume).' },
74
+ latest: { type: 'boolean', description: 'Continue the most recent claude conversation instead of a specific id (--continue).' },
75
+ mode: { type: 'string', enum: MODE_ENUM, description: 'Permission mode; default auto.' },
76
+ model: { type: 'string', description: 'Optional model id or alias.' },
77
+ maxBudgetUsd: { type: 'number', description: 'Optional hard cap on API spend for this run.' },
78
+ cwd: { type: 'string', description: 'Working directory for claude.' },
79
+ forkSession: { type: 'boolean', description: 'Create a new session id instead of reusing the original (--fork-session).' },
80
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
81
+ background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output. Set false to block for the final answer.' }
82
+ }
83
+ }
84
+ const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
85
+
86
+ // claude_models(附加工具):服务端权威模型清单(探测 400 报文,绝不用配置冒充)。
87
+ const MODELS_PARAMS = {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ required: [],
91
+ properties: {
92
+ refresh: { type: 'boolean', description: 'Bypass the 5-minute probe cache and ask the endpoint again.' },
93
+ timeoutMs: { type: 'integer', description: 'Probe timeout in ms (5000-90000, default 30000).' }
94
+ }
95
+ }
96
+ const MODELS_DESCRIPTION = 'List the models the current claude account/endpoint really supports, read LIVE from the server. Mechanism: the CLI is run once with a deliberately invalid model id, which makes the endpoint answer HTTP 400 with "The supported API model names are X, Y" — that roster is authoritative for this endpoint+key. Returns { ok, source:"account-live", models:[{id, configuredAs}], count }. The ANTHROPIC_*_MODEL entries in ~/.claude/settings.json are alias mappings, NOT the full supported set, so they are only returned as a diagnostic hint (never as models). If no live roster can be read this returns ok:false with an actionable error — it never invents a list.'
97
+
98
+ // 渲染函数签名遵循 harness 契约 render(args, value)(extraTools[].render 同签名,
99
+ // preset/dynamic/MCP 三处都按两参数调用 —— 只写一个参数会渲染出空壳)。
100
+ function renderModels(args, value) {
101
+ const v = value || {}
102
+ if (!v.ok) {
103
+ const lines = ['claude_models failed: ' + String(v.error || 'unknown')]
104
+ const cfg = v.diagnostic && Array.isArray(v.diagnostic.configured) ? v.diagnostic.configured : []
105
+ if (cfg.length) {
106
+ lines.push('configured aliases (NOT authoritative): ' + cfg.map((m) => m.id).join(', '))
107
+ if (v.diagnostic && v.diagnostic.settingsPath) lines.push(' from ' + v.diagnostic.settingsPath)
108
+ }
109
+ return [{ type: 'text', text: lines.join('\n') }]
110
+ }
111
+ const list = Array.isArray(v.models) ? v.models : []
112
+ if (!list.length) return [{ type: 'text', text: 'claude_models: server returned an empty roster' }]
113
+ const lines = ['claude models (' + list.length + ') [source=' + (v.source || 'account-live') + ']:']
114
+ for (const m of list.slice(0, 40)) lines.push(' ' + m.id + (m.configuredAs ? ' [configured as ' + m.configuredAs + ']' : ''))
115
+ if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
116
+ if (v.diagnostic && v.diagnostic.settingsPath) lines.push('settings: ' + v.diagnostic.settingsPath)
117
+ return [{ type: 'text', text: lines.join('\n') }]
118
+ }
119
+
120
+ // usage 汇总:input/output + 两个 cache 计数(claude 的 input_tokens 不含 cache 命中)。
121
+ function sumTokens(usage) {
122
+ if (!usage || typeof usage !== 'object') return null
123
+ let total = 0
124
+ let any = false
125
+ for (const k of ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens']) {
126
+ if (typeof usage[k] === 'number' && Number.isFinite(usage[k])) { total += usage[k]; any = true }
127
+ }
128
+ return any ? total : null
129
+ }
130
+
131
+ // 本次实际跑的主模型:modelUsage 里按 costUSD(再退 token 量)挑最大者。
132
+ function pickModelUsed(modelUsage) {
133
+ if (!modelUsage || typeof modelUsage !== 'object') return null
134
+ let best = null
135
+ let bestScore = -1
136
+ for (const k of Object.keys(modelUsage)) {
137
+ const u = modelUsage[k] && typeof modelUsage[k] === 'object' ? modelUsage[k] : {}
138
+ const score = (Number(u.costUSD) || 0) * 1e6 + (Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0)
139
+ if (score > bestScore) { bestScore = score; best = k }
140
+ }
141
+ return best
142
+ }
143
+
144
+ export default function defineProvider(deps = {}) {
145
+ const env = deps.env || {}
146
+ let host = null
147
+ let settingsRef = { binPath: '', defaultModel: '', maxBudgetUsd: '' }
148
+
149
+ /**
150
+ * 当前生效设置(面板改完即时生效,无需重启)。三源合并,后者覆盖前者:
151
+ * ① settingsRef(attach 时快照/缺省)② deps.readSettings(MCP 形态现读文件)
152
+ * ③ host.getSettings('claude')(preset/dynamic 形态)。
153
+ */
154
+ function currentSettings() {
155
+ const out = Object.assign({}, settingsRef)
156
+ try {
157
+ if (typeof deps.readSettings === 'function') {
158
+ const r = deps.readSettings()
159
+ if (r && typeof r === 'object') {
160
+ const part = ('partition' in r || 'path' in r || 'home' in r) ? r.partition : r
161
+ if (part && typeof part === 'object') Object.assign(out, part)
162
+ }
163
+ }
164
+ } catch (e) { }
165
+ try {
166
+ if (host && typeof host.getSettings === 'function') {
167
+ const s = host.getSettings('claude')
168
+ if (s && typeof s === 'object') Object.assign(out, s)
169
+ }
170
+ } catch (e) { }
171
+ return out
172
+ }
173
+
174
+ const descriptor = {
175
+ id: 'claude', label: 'CLAUDE', presetId: 'claude-first', defaultBackend: null, defaultMode: 'auto',
176
+ exeName: 'claude', binEnvVars: CLAUDE_BIN_ENV_VARS, unavailableStatus: 'CLAUDE_UNAVAILABLE',
177
+
178
+ // 单后端:模型/凭据由 claude 自身配置(~/.claude/settings.json)决定,桥不引入第二后端。
179
+ backends() { return null },
180
+ normalizeBackend() { return null },
181
+ resolveBackend(args, target, preferred) { return null },
182
+
183
+ // 实测 flag 集(Claude Code 2.0.76 `claude --help`)。
184
+ buildArgv(exePrefix, args, opts) {
185
+ const o = opts || {}
186
+ const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
187
+ argv.push('-p', String(args.prompt))
188
+ // stream-json 才能实时折叠步骤(foldStreamEvent 吃 system/assistant/result 事件);
189
+ // stream=false 时退单行 json(更省字节,仍可解析)。
190
+ const stream = args.stream !== false
191
+ if (stream) argv.push('--output-format', 'stream-json', '--verbose')
192
+ else argv.push('--output-format', 'json')
193
+ if (stream && args.includePartialMessages) argv.push('--include-partial-messages')
194
+ const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
195
+
196
+ // 权限:auto → 跟随 DSH plan 状态;plan 用 CLI 的 plan 模式,其余走 bypass
197
+ //(-p 没有 TTY,任何交互批准都会挂到超时 —— 与 agy/codebuddy/opencode 同语义)。
198
+ let mode = String(args.mode || descriptor.defaultMode || 'auto')
199
+ if (MODE_ENUM.indexOf(mode) < 0) mode = 'auto'
200
+ if (mode === 'auto') mode = o.planActive ? 'plan' : 'bypass'
201
+ if (mode === 'plan') argv.push('--permission-mode', 'plan')
202
+ else if (mode === 'accept-edits') argv.push('--permission-mode', 'acceptEdits')
203
+ else { mode = 'bypass'; argv.push('--dangerously-skip-permissions') }
204
+
205
+ // 模型优先级:调用参数 > 设置面板 defaultModel > kit 注入的 defaultModel > 不传(claude 自决)。
206
+ const model = args.model || currentSettings().defaultModel || o.defaultModel || ''
207
+ if (model) argv.push('--model', String(model))
208
+
209
+ const budgetRaw = args.maxBudgetUsd != null ? args.maxBudgetUsd : currentSettings().maxBudgetUsd
210
+ const budget = budgetRaw == null ? NaN : Number(budgetRaw)
211
+ if (Number.isFinite(budget) && budget > 0) argv.push('--max-budget-usd', String(budget))
212
+
213
+ if (args.systemPrompt) argv.push('--system-prompt', String(args.systemPrompt))
214
+ if (args.appendSystemPrompt) argv.push('--append-system-prompt', String(args.appendSystemPrompt))
215
+ if (Array.isArray(args.allowedTools) && args.allowedTools.length) argv.push('--allowedTools', args.allowedTools.map(String).join(','))
216
+ if (Array.isArray(args.disallowedTools) && args.disallowedTools.length) argv.push('--disallowedTools', args.disallowedTools.map(String).join(','))
217
+ if (Array.isArray(args.addDirs)) for (const d of args.addDirs) { if (d) argv.push('--add-dir', String(d)) }
218
+ if (args.noSessionPersistence) argv.push('--no-session-persistence')
219
+
220
+ if (args.sessionId) argv.push('--resume', String(args.sessionId))
221
+ else if (args.continueLatest) argv.push('--continue')
222
+ if (args.forkSession && (args.sessionId || args.continueLatest)) argv.push('--fork-session')
223
+ return { argv, mode, timeoutSec }
224
+ },
225
+
226
+ // stdout 解析:json = 单行 result;stream-json = 多行事件,取**最后**一个 result。
227
+ // 无 result 事件 → null(buildResult 据此判 PARSE_ERROR,绝不假造成功)。
228
+ parseJson(stdoutText) {
229
+ const trimmed = String(stdoutText || '').trim()
230
+ if (!trimmed) return null
231
+ let last = null
232
+ for (const raw of trimmed.split(/\r?\n/)) {
233
+ const t = raw.trim()
234
+ if (!t.startsWith('{')) continue
235
+ let obj
236
+ try { obj = JSON.parse(t) } catch (e) { continue }
237
+ if (obj && obj.type === 'result') last = obj
238
+ }
239
+ if (last) return last
240
+ try {
241
+ const obj = JSON.parse(trimmed)
242
+ if (obj && obj.type === 'result') return obj
243
+ } catch (e) { }
244
+ return null
245
+ },
246
+
247
+ // 统一结果对象。⚠️ 成功判据 = is_error === false(且 subtype 非错误态)**并且** exit 0;
248
+ // 只看 exit code 会把「无效模型」误判成成功(实测该场景 exit 1 + subtype "success"),
249
+ // 反过来也存在 exit 0 + is_error true 的形态,故两者必须同时成立。
250
+ buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
251
+ const exitCode = outcome ? outcome.exitCode : null
252
+ const stderr = stderrText ? String(stderrText).slice(-2000) : ''
253
+ if (parsed && typeof parsed === 'object') {
254
+ const isError = parsed.is_error === true
255
+ const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : ''
256
+ const response = typeof parsed.result === 'string' ? parsed.result : ''
257
+ const errorText = isError ? (response || (typeof parsed.error === 'string' ? parsed.error : '')) : ''
258
+ let status
259
+ if (exitCode === 130) status = 'ABORTED'
260
+ else if (isError) status = subtype === 'error_max_turns' ? 'MAX_TURNS' : 'EXEC_ERROR'
261
+ else if (exitCode !== 0) status = 'CLI_ERROR'
262
+ else status = 'SUCCESS'
263
+ const ok = !isError && exitCode === 0 && status === 'SUCCESS'
264
+ const denials = Array.isArray(parsed.permission_denials) ? parsed.permission_denials.length : 0
265
+ return {
266
+ ok,
267
+ status,
268
+ response,
269
+ sessionId: typeof parsed.session_id === 'string' ? parsed.session_id : null,
270
+ durationSeconds: typeof parsed.duration_ms === 'number' ? Math.round(parsed.duration_ms / 100) / 10 : null,
271
+ numTurns: typeof parsed.num_turns === 'number' ? parsed.num_turns : null,
272
+ totalTokens: sumTokens(parsed.usage),
273
+ exitCode, mode, backend: backend || null,
274
+ // is_error 的正文也必须进 stderr:限流/凭据判定与失败指引都读它。
275
+ stderr: (stderr ? stderr + ' ' : '') + errorText,
276
+ subtype: subtype || null,
277
+ modelUsed: pickModelUsed(parsed.modelUsage),
278
+ costUsd: typeof parsed.total_cost_usd === 'number' ? parsed.total_cost_usd : null,
279
+ permissionDenials: denials
280
+ }
281
+ }
282
+ const text = String(stdoutText || '').trim()
283
+ const status = exitCode === 0 ? 'PARSE_ERROR' : 'CLI_ERROR'
284
+ return {
285
+ ok: false,
286
+ status,
287
+ response: '',
288
+ sessionId: null,
289
+ durationSeconds: null, numTurns: null, totalTokens: null,
290
+ exitCode, mode, backend: backend || null,
291
+ stderr: (stderr ? stderr + ' ' : '') + (exitCode === 0 ? 'no result event in claude output' : ''),
292
+ subtype: null, modelUsed: null, costUsd: null, permissionDenials: 0,
293
+ rawStdout: text.slice(-2000)
294
+ }
295
+ },
296
+
297
+ // 实时步骤折叠(stream-json → project.current/trail)。
298
+ // 事件形态(实测):system/subtype=init 带 model+permissionMode;assistant.message.content
299
+ // 是 Anthropic 内容块数组(thinking / text / tool_use);user.message.content 带 tool_result。
300
+ foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
301
+ if (!ev || typeof ev !== 'object') return
302
+ const type = ev.type
303
+ if (!type) return
304
+ const cap = maxTrail || 12
305
+ const capLen = maxArgLen || 120
306
+ const push = (entry) => { p.trail.push(entry); while (p.trail.length > cap) p.trail.shift() }
307
+ const blocksOf = (m) => (m && typeof m === 'object' && Array.isArray(m.content) ? m.content : [])
308
+ p.updatedAt = Date.now()
309
+ if (type === 'system') {
310
+ if (ev.subtype === 'init') {
311
+ p.current = { tool: 'session', args: { model: ev.model || '', permissionMode: ev.permissionMode || '' }, stepIndex: 0, since: nowIso() }
312
+ return
313
+ }
314
+ return
315
+ }
316
+ if (type === 'assistant') {
317
+ for (const b of blocksOf(ev.message)) {
318
+ if (!b || typeof b !== 'object') continue
319
+ if (b.type === 'tool_use') {
320
+ const tool = b.name || 'tool'
321
+ const args = summarizeArgs(b.input)
322
+ const idx = p.trail.length
323
+ push({ stepIndex: idx, state: 'ACTIVE', tool, args: args || null, at: nowIso() })
324
+ p.current = { tool, args: args || null, stepIndex: idx, since: nowIso() }
325
+ } else if (b.type === 'text' && typeof b.text === 'string' && b.text) {
326
+ p.current = { tool: 'agent_response', args: { text_delta: b.text.slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
327
+ } else if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking) {
328
+ p.current = { tool: 'reasoning', args: { text_delta: b.thinking.slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
329
+ }
330
+ }
331
+ return
332
+ }
333
+ if (type === 'user') {
334
+ for (const b of blocksOf(ev.message)) {
335
+ if (!b || typeof b !== 'object' || b.type !== 'tool_result') continue
336
+ // 把最近一个仍在 ACTIVE 的步骤收尾(tool_result 不带 tool name,按时间序配对)。
337
+ for (let i = p.trail.length - 1; i >= 0; i--) {
338
+ if (p.trail[i].state === 'ACTIVE') { p.trail[i].state = b.is_error === true ? 'ERROR' : 'DONE'; break }
339
+ }
340
+ p.current = null
341
+ }
342
+ return
343
+ }
344
+ if (type === 'result') { p.current = null }
345
+ },
346
+
347
+ // 瞬时错误静默重试一次:只认网络/过载类,且明确排除模型名/凭据类(那些重试无用)。
348
+ isTransientCliError(res) {
349
+ if (!res || res.ok) return false
350
+ if (isLimited(res)) return false
351
+ const hay = String(res.stderr || '') + ' ' + String(res.response || '')
352
+ if (/supported API model names|invalid_request_error|model .* not|authentication|invalid api key|401|403/i.test(hay)) return false
353
+ return /overloaded|529|503|ECONNRESET|socket hang up|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|terminated|connection (reset|closed)/i.test(hay)
354
+ },
355
+
356
+ failureHint(res) {
357
+ if (!res || res.ok) return ''
358
+ const st = String(res.status || '')
359
+ if (st === 'CLAUDE_UNAVAILABLE') return '[诊断] 未找到 claude 可执行文件:请安装 Claude Code(或 npm i -g @anthropic-ai/claude-code),或在 CLI Bridge 设置面板填写 claude 可执行文件路径(也可设 CLAUDE_BIN)。'
360
+ if (st === 'PARSE_ERROR') return '[诊断] claude 没有输出 result 事件(进程可能被中断/输出被截断)。若你手动改了 output-format,请改回;否则重试一次,持续失败请检查 CLI 版本(--output-format json 是 2.x 的行为)。'
361
+ const hay = String(res.stderr || '') + ' ' + String(res.response || '')
362
+ const supported = parseSupportedModels(hay)
363
+ if (supported) return '[诊断] 模型名被端点拒绝。该端点当前支持的模型:' + supported.join(', ') + ' —— 用 claude_models 取权威清单,然后在调用参数或设置面板里改 model;字符串 "[1M]" 之类的后缀属于配置里的别名写法,不能直接当 --model 传入。'
364
+ if (/supported API model names|invalid_request_error/i.test(hay)) return '[诊断] 模型名被端点拒绝(400 invalid_request_error):先用 claude_models 确认服务端支持的模型,再传 model。'
365
+ if (res.permissionDenials) return '[诊断] 有 ' + res.permissionDenials + ' 次工具调用被权限拒绝:-p 模式没有交互确认,请用 mode=bypass(默认 auto 在非 plan 模式下就是 bypass),或在 allowedTools 里放行所需工具。'
366
+ if (/authentication_error|invalid x-api-key|invalid api key|401|unauthorized|not logged in|oauth|api key/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
367
+ return '[诊断] claude 凭据/端点问题:请让用户检查 ~/.claude/settings.json 的 env 块(ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)或重新登录 claude;这不是限流。'
368
+ }
369
+ if (/rate.?limit|\b429\b|quota|insufficient|credit|balance|额度/i.test(hay)) {
370
+ return '[诊断] 端点报告限流/额度不足:不要循环重试;换 model(用 claude_models 挑一个可用的)或改用 DSH 原生工具完成本任务。'
371
+ }
372
+ return ''
373
+ },
374
+
375
+ isSuccessStatus(st) { return st === 'SUCCESS' },
376
+
377
+ tools: {
378
+ run: { name: 'claude_run', description: 'Dispatch a coding/build/debug/investigation task to the local Claude Code CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls claude: it always runs non-interactively with permissions auto-approved (claude never blocks on a prompt), in stream-json mode so live steps reach claude_status. Use read-only native tools only for quick lookups and for final build/test verification. Runs in the background by default (returns a jobId immediately; collect with job_output); set background=false to block for the final answer.', inputSchema: RUN_PARAMS },
379
+ continue: { name: 'claude_continue', description: 'Continue an existing claude session with a follow-up prompt, reusing claude context. Pass sessionId from a prior claude_run result (--resume), or set latest=true to continue the most recent conversation (--continue). Same DSH-controlled, no-prompt execution as claude_run.', inputSchema: CONTINUE_PARAMS },
380
+ status: { name: 'claude_status', description: 'Read a live snapshot of what the local claude 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), and the last completed run status + session id. Optional cwd filters to a single project. Call this to check on an in-flight claude_run/claude_continue without waiting for it to finish.', inputSchema: STATUS_PARAMS }
381
+ },
382
+
383
+ // claude_models:三形态都注册(探测只需 node child_process;拿不到 spawn 能力时
384
+ // logic 明确返回 ok:false,而不是静默缺席或伪造列表)。
385
+ extraTools: [{
386
+ name: 'claude_models',
387
+ description: MODELS_DESCRIPTION,
388
+ inputSchema: MODELS_PARAMS,
389
+ render: renderModels,
390
+ async logic(args) {
391
+ return await descriptor.listModels(args || {})
392
+ }
393
+ }],
394
+
395
+ /**
396
+ * 列出服务端权威可用模型(三形态共用)。
397
+ * 铁律:只认账号/端点探测;探测失败 = ok:false + 可操作错误 + 配置线索(非权威)。
398
+ */
399
+ async listModels(args) {
400
+ const a = args || {}
401
+ return await listAccountModels(env, {
402
+ binPath: currentSettings().binPath,
403
+ timeoutMs: clampInt(a.timeoutMs, 30000, 5000, 90000),
404
+ refresh: !!a.refresh
405
+ })
406
+ },
407
+
408
+ // ── 设置分区(契约 §3)─────────────────────────────────────────────────────
409
+ settings: {
410
+ keys: ['binPath', 'defaultModel', 'maxBudgetUsd'],
411
+ defaults: () => ({ binPath: '', defaultModel: '', maxBudgetUsd: '' }),
412
+ normalize: (raw) => {
413
+ const r = raw && typeof raw === 'object' ? raw : {}
414
+ const out = { binPath: '', defaultModel: '', maxBudgetUsd: '' }
415
+ for (const k of ['binPath', 'defaultModel', 'maxBudgetUsd']) {
416
+ if (typeof r[k] === 'string') out[k] = String(r[k]).trim()
417
+ }
418
+ return out
419
+ },
420
+ /**
421
+ * 设置面板元数据(契约 §3.2):**只读缓存、绝不探测**。
422
+ * 面板是同步回调,而探测要起子进程打网络(3-5s)——放在这里会把首屏拖慢,且探不到时
423
+ * 用户无法区分「没模型」与「没拉到」。故探测改为用户点「拉取可用模型」→
424
+ * settings.refreshModels() 显式触发;成功后写回同一份缓存,这里下次渲染直接命中。
425
+ */
426
+ meta: (env2) => {
427
+ const e = env2 && typeof env2 === 'object' ? env2 : {}
428
+ const s = currentSettings()
429
+ const cached = readCachedModels(e, { binPath: s.binPath })
430
+ const options = (cached ? cached.ids : []).map((id) => ({ value: id, label: id }))
431
+ const exeCandidates = claudeExeCandidates(e, '')
432
+ return {
433
+ titleSuffix: 'Claude Code',
434
+ fields: [
435
+ {
436
+ key: 'binPath', label: 'claude 可执行文件路径', type: 'select', allowCustom: true,
437
+ options: exeCandidates.map((p) => ({ value: p, label: p })),
438
+ placeholder: '留空 = 自动探测本地安装 / PATH',
439
+ note: '检测到 ' + exeCandidates.length + ' 个候选;npm 全局安装给的是 .cmd(模型探测与 MCP 形态经 shell 启动),原生安装给 .exe —— preset 形态建议指向原生 .exe(.cmd 能否执行取决于宿主 subprocess)。也可手动粘贴任意路径(.mjs/.js 经 node 启动)。'
440
+ },
441
+ {
442
+ key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
443
+ options,
444
+ // 面板据此渲染「手动拉取」按钮(契约 §3.2);meta() 自身永不探测。
445
+ refresh: { label: '拉取可用模型' },
446
+ placeholder: '留空 = 由 claude 自身配置(~/.claude/settings.json)决定',
447
+ note: options.length
448
+ ? '共 ' + options.length + ' 个服务端权威可用模型(' + ageText(cached.ageMs) + ',源:' + cached.mode + ')。点「拉取可用模型」重新获取;调用时传 model 可覆盖本项。'
449
+ : '还没拉取过(或上次没拉到):点「拉取可用模型」向端点取权威清单;空表示尚未拉取 / 该面未登录。可直接手动输入模型名 —— 配置里的 ANTHROPIC_*_MODEL 只是别名映射,不作为候选展示。'
450
+ },
451
+ {
452
+ key: 'maxBudgetUsd', label: '单次最大花费(美元)', type: 'text',
453
+ placeholder: '留空 = 不限制',
454
+ note: '非空时自动附加 --max-budget-usd;调用参数 maxBudgetUsd 可覆盖。'
455
+ }
456
+ ]
457
+ }
458
+ },
459
+
460
+ /**
461
+ * 手动拉取(契约 §3.2):面板「拉取可用模型」按钮 → keystone
462
+ * POST /cli-bridge/settings/models → 这里。这是本 provider 唯一的面板探测入口。
463
+ *
464
+ * 复用 listAccountModels(异步 probeSupportedModels + 配置诊断),不写第二套探测;
465
+ * 其成功路径已把权威清单写回 modelsCache,随后 meta() 直接命中(无需再探测)。
466
+ * 铁律:失败 = ok:false + 可操作错误 + 空 ids,绝不返回 {ok:true, ids:[]};
467
+ * 也绝不把 settings.json 里的别名映射冒充成服务端可用全集。
468
+ */
469
+ async refreshModels(settings, env2) {
470
+ const e = (env2 && typeof env2 === 'object') ? env2 : env
471
+ const s = descriptor.settings.normalize(settings)
472
+ const got = await listAccountModels(e, {
473
+ binPath: s.binPath,
474
+ timeoutMs: REFRESH_TIMEOUT_MS,
475
+ refresh: true
476
+ })
477
+ if (!got.ok) {
478
+ return { ok: false, ids: [], source: 'account-live', error: String(got.error || 'probe failed') + ' ' + REFRESH_HINT }
479
+ }
480
+ const ids = (got.models || []).map((m) => m.id).filter(Boolean)
481
+ if (!ids.length) {
482
+ // 走到这里说明探测自称成功却没给出任何 id —— 那是 bug,不是「无模型」,
483
+ // 必须显式失败,绝不能让面板显示一个空清单当成功。
484
+ return { ok: false, ids: [], source: 'account-live', error: '探测声称成功但未返回任何模型 id(内部不一致)。' + REFRESH_HINT }
485
+ }
486
+ // labels 只给「配置里还映射着某个 ANTHROPIC_*_MODEL」的项加注(纯展示,value 仍是 id)。
487
+ const labels = {}
488
+ for (const m of got.models) if (m.configuredAs) labels[m.id] = m.id + ' [' + m.configuredAs + ']'
489
+ return { ok: true, ids, labels, source: 'account-live' }
490
+ },
491
+ diagnose: (settings, env2) => {
492
+ const s = settings && typeof settings === 'object' ? settings : {}
493
+ const e = env2 && typeof env2 === 'object' ? env2 : {}
494
+ const bits = []
495
+ if (s.binPath) bits.push('bin=设置:' + s.binPath)
496
+ else if (claudePathHint(e)) bits.push('bin=' + claudePathHint(e))
497
+ else bits.push('bin=PATH:claude')
498
+ bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(claude 自决)')
499
+ if (s.maxBudgetUsd) bits.push('maxBudget=$' + s.maxBudgetUsd)
500
+ const cfg = readConfiguredModels(e)
501
+ if (cfg.ok && cfg.models.length) bits.push('settings.json env: ' + cfg.models.length + ' 个别名映射(非权威清单)')
502
+ if (!cfg.ok && cfg.error) bits.push('settings.json: ' + cfg.error.slice(0, 120))
503
+ return bits.join(' · ')
504
+ }
505
+ },
506
+
507
+ // MCP 面版本由宿主注入(mcp/version.mjs 单源派生),描述符不复制 version 字面量。
508
+ mcp: (deps.mcp && typeof deps.mcp === 'object') ? Object.assign({}, deps.mcp) : {}
509
+ }
510
+
511
+ // ── 三形态共用的工具逻辑(签名固定:宿主接线见 preset/dynamic/mcp 三处)──────────
512
+ descriptor.runToolLogic = async function (args, exec) {
513
+ const a = args || {}
514
+ const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
515
+ return this.runner.coreExecute(normalized, exec)
516
+ }
517
+ descriptor.continueToolLogic = async function (args, exec) {
518
+ const a = args || {}
519
+ const mapped = {
520
+ prompt: a.prompt, mode: a.mode, model: a.model, maxBudgetUsd: a.maxBudgetUsd,
521
+ cwd: a.cwd, forkSession: a.forkSession, timeoutSec: a.timeoutSec,
522
+ background: a.background === undefined ? true : a.background
523
+ }
524
+ const sid = a.sessionId || null
525
+ if (sid) mapped.sessionId = sid
526
+ else if (a.latest) mapped.continueLatest = true
527
+ return this.runner.coreExecute(mapped, exec)
528
+ }
529
+ descriptor.statusToolLogic = function (args) {
530
+ const a = args || {}
531
+ const snap = this.engine.statusSnapshot()
532
+ if (a.cwd) {
533
+ const key = String(a.cwd)
534
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
535
+ const g = snap.projects[0]
536
+ if (g) {
537
+ Object.assign(snap, {
538
+ state: g.state, running: g.running, current: g.current, trail: g.trail,
539
+ lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
540
+ lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
541
+ })
542
+ }
543
+ }
544
+ return snap
545
+ }
546
+
547
+ descriptor.attach = function (hostOpts) {
548
+ host = hostOpts || {}
549
+ try {
550
+ if (typeof host.getSettings === 'function') {
551
+ const s = host.getSettings('claude')
552
+ if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
553
+ }
554
+ } catch (e) { }
555
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
556
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
557
+ this.runner = runner
558
+ return runner
559
+ }
560
+ return descriptor
561
+ }