@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.
- package/CHANGELOG.md +91 -0
- package/PROVIDER-CONTRACT.md +87 -5
- package/README.md +12 -11
- package/docs/HANDOVER.md +109 -15
- package/dynamic/agy.host.js +1 -1
- package/dynamic/claude.host.js +1858 -0
- package/dynamic/claude.host.template.mjs +108 -0
- package/dynamic/codebuddy.host.js +1427 -1379
- package/dynamic/codex.host.js +1930 -0
- package/dynamic/codex.host.template.mjs +137 -0
- package/dynamic/mimo.host.js +721 -515
- package/dynamic/mimo.host.template.mjs +2 -0
- package/dynamic/opencode.host.js +741 -585
- package/dynamic/opencode.host.template.mjs +2 -0
- package/home-plugin/cli-bridge-indicator/lib/client.js +74 -90
- package/home-plugin/cli-bridge-indicator/lib/index.mjs +48 -0
- package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +2 -3
- package/mcp/cli-bridge-mcp.mjs +4 -2
- package/mcp/providers/claude.mjs +231 -0
- package/mcp/providers/codebuddy.mjs +427 -427
- package/mcp/providers/codex.mjs +239 -0
- package/package.json +6 -2
- package/preset/claude-first/agent.cordis.yml +136 -0
- package/preset/claude-first/cli-bridge.mjs +131 -0
- package/preset/claude-first/plugin-list.mjs +28 -0
- package/preset/claude-first/preset.yml +3 -0
- package/preset/codex-first/agent.cordis.yml +136 -0
- package/preset/codex-first/cli-bridge.mjs +160 -0
- package/preset/codex-first/plugin-list.mjs +28 -0
- package/preset/codex-first/preset.yml +3 -0
- package/providers/claude-exe.mjs +112 -0
- package/providers/claude-models.mjs +338 -0
- package/providers/claude-policy.mjs +37 -0
- package/providers/claude.mjs +561 -0
- package/providers/codebuddy-catalog.mjs +367 -599
- package/providers/codebuddy-consts.mjs +72 -72
- package/providers/codebuddy-models-source.mjs +254 -0
- package/providers/codebuddy-models-tool.mjs +106 -106
- package/providers/codebuddy-schemas.mjs +46 -46
- package/providers/codebuddy.mjs +600 -578
- package/providers/codex-cli.mjs +405 -0
- package/providers/codex-models.mjs +107 -0
- package/providers/codex.mjs +585 -0
- package/providers/mimo-pure.mjs +270 -111
- package/providers/mimo.mjs +453 -517
- package/providers/opencode-exe.mjs +190 -63
- package/providers/opencode.mjs +553 -587
- package/providers/registry.mjs +25 -1
- package/scripts/build.mjs +261 -262
- package/scripts/verify.mjs +8 -2
|
@@ -0,0 +1,1858 @@
|
|
|
1
|
+
// dynamic/claude.host.template.mjs — claude 动态沙箱宿主模板(cordis_define code.host 源)。
|
|
2
|
+
//
|
|
3
|
+
// 构建(node scripts/build.mjs --only=claude)把 core/bridge-kit.mjs、providers/claude.mjs、
|
|
4
|
+
// providers/claude-*.mjs 片文件(含 claude-policy.mjs)与 core/scheduling.mjs 分别内联进下方
|
|
5
|
+
// 四个占位标记行(各占一行、行内仅此标记),生成 dynamic/claude.host.js。
|
|
6
|
+
// 注意:标记字面量绝不能出现在本注释里 —— 构建做全局替换,注释中出现同样会被替换成正文
|
|
7
|
+
// 代码而炸掉语法。
|
|
8
|
+
// 沙箱纪律(旧仓 dynamic/host.js 头注 verbatim 精神):
|
|
9
|
+
// - 禁 import/require、无 TypeScript;无 AbortController/process 裸引用/Buffer/原生定时器
|
|
10
|
+
// —— process 只经 typeof 守卫探测;宿主能力全部来自注入的 harness.*/ctx。
|
|
11
|
+
// - 顶层 return { inject, apply } 为动态宿主约定形态(生成物是 .js/CJS 包装,顶层 return
|
|
12
|
+
// 合法;本模板文件是 .mjs,不要直接 node --check 模板本身,只 check 生成物)。
|
|
13
|
+
// - 无 ctx.emit:状态经 ctx.get('cliBridgeCollector').mergeSnapshot('claude', snap) 推给
|
|
14
|
+
// 家级灯(旧 claudeCollector 别名保留兼容);动态形态不宣告 mode。
|
|
15
|
+
// core/bridge-kit.mjs — CLI Bridge 单一事实来源:通用执行/状态骨架。
|
|
16
|
+
//
|
|
17
|
+
// 深度重构核心(合并 agy-first-bridge / codebuddy-first-bridge / mimo-first-bridge):
|
|
18
|
+
// 三条 bridge 的 host 半此前各自复制了一份几乎相同的「状态引擎 + 行流 + 执行编排
|
|
19
|
+
// + 限流回退弹窗 + MCP 快照上报 + 展示层」。本文件把这些**与 provider 无关**的骨架
|
|
20
|
+
// 收敛成一份参数化实现,每个 provider 只提供一个 descriptor(见 providers/*.mjs 与
|
|
21
|
+
// PROVIDER-CONTRACT.md),由 createRunner/createStatusEngine 回调它完成差异化步骤。
|
|
22
|
+
//
|
|
23
|
+
// 骨架覆盖:
|
|
24
|
+
// - 纯函数:projectName / clampInt / shortLabel / summarizeArgs / endpointHost
|
|
25
|
+
// - 限流/网络判定:LIMIT_RE + isLimited(provider 可收窄)
|
|
26
|
+
// - 半行安全行流:createLineStream
|
|
27
|
+
// - 按项目(cwd)聚合、按 provider 分桶的状态引擎:createStatusEngine
|
|
28
|
+
// - 执行编排(前台/后台/超时守卫/瞬时错误重试/限流三选一弹窗/回退):createRunner
|
|
29
|
+
// - 工具结果与状态的人类可读渲染:renderResult / renderStatus
|
|
30
|
+
// - MCP 子进程快照上报:buildMcpBridgePayload / normalizeMcpBridge(provider 分文件)
|
|
31
|
+
// - 回退弹窗三选一文案(provider 名注入)
|
|
32
|
+
//
|
|
33
|
+
// 约束(沙箱与真实 Node 共用,勿违反):
|
|
34
|
+
// - 本模块**不 import 任何模块**、不用 process/Buffer/原生定时器/AbortController;
|
|
35
|
+
// 宿主能力(subprocess.spawn / ctx.interval / ctx.timeout / jobs / userQuestions)
|
|
36
|
+
// 全部经 createRunner 的 opts 注入,取消用 exec.signal + handle.terminate()。
|
|
37
|
+
// - 不序列化任何 DSH 活对象;跨 RPC 只传无 Host 引用的纯 JSON 叶子字段。
|
|
38
|
+
|
|
39
|
+
// ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const MAX_TRAIL = 12
|
|
42
|
+
const MAX_ARG_LEN = 120
|
|
43
|
+
const MAX_PROJECTS = 24
|
|
44
|
+
const MAX_SESSIONS = 256
|
|
45
|
+
|
|
46
|
+
// 限流/网络类错误的宽松正则(英文 + 中文)。provider descriptor 可用自己的收窄版覆盖。
|
|
47
|
+
const LIMIT_RE =
|
|
48
|
+
/rate.?limit|ratelimit|\b429\b|too many|quota|insufficient|credit|balance|exhausted|exceed|\bnetwork\b|offline|ENETUNREACH|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|\btimeouts?\b|timed out|unavailable|\b50[023]\b|\b40[13]\b|unauthorized|invalid api|api key|proxy|socket|tls|ssl|\bdns\b|网络|超时|限流|流量|受限|配额|金额|余额|额度|认证|连接|断开/i
|
|
49
|
+
|
|
50
|
+
// 家级插件与 MCP 子进程之间的快照文件:{dsh-home}/cli-bridge-mcp-<provider>.json
|
|
51
|
+
const MCP_BRIDGE_PREFIX = 'cli-bridge-mcp-'
|
|
52
|
+
const MCP_BRIDGE_STALE_MS = 90000
|
|
53
|
+
function mcpBridgeFileName(providerId) { return MCP_BRIDGE_PREFIX + providerId + '.json' }
|
|
54
|
+
|
|
55
|
+
// ── 纯函数 ────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function projectName(cwd) {
|
|
58
|
+
const s = String(cwd || '')
|
|
59
|
+
const parts = s.split(/[\\/]/).filter(Boolean)
|
|
60
|
+
return parts.length ? parts[parts.length - 1] : s
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clampInt(v, def, min, max) {
|
|
64
|
+
const n = Number(v)
|
|
65
|
+
if (!Number.isFinite(n)) return def
|
|
66
|
+
const i = Math.floor(n)
|
|
67
|
+
if (i < min) return min
|
|
68
|
+
if (i > max) return max
|
|
69
|
+
return i
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function shortLabel(prompt) {
|
|
73
|
+
const s = String(prompt || '').replace(/\s+/g, ' ').trim()
|
|
74
|
+
return s.length > 80 ? s.slice(0, 77) + '...' : s
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 工具入参摘要(状态灯/活动面板)。返回可无损 JSON 化的数据;undefined 值跳过
|
|
78
|
+
// (DSH tool 渲染层不接受 undefined)。
|
|
79
|
+
function summarizeArgs(parameters) {
|
|
80
|
+
if (!parameters || typeof parameters !== 'object') return null
|
|
81
|
+
const out = {}
|
|
82
|
+
for (const k of Object.keys(parameters)) {
|
|
83
|
+
let v = parameters[k]
|
|
84
|
+
if (v === undefined) continue
|
|
85
|
+
if (typeof v === 'string' && v.length > MAX_ARG_LEN) v = v.slice(0, MAX_ARG_LEN) + '…'
|
|
86
|
+
out[k] = v
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function endpointHost(url) {
|
|
92
|
+
const m = String(url || '').match(/^https?:\/\/([^/]+)/i)
|
|
93
|
+
return m ? m[1] : ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 限流/网络类失败(要问用户,重试可能烧额度)。
|
|
97
|
+
function isLimited(res) {
|
|
98
|
+
if (!res || res.ok) return false
|
|
99
|
+
// AUTH_REQUIRED 是配置问题(缺凭据),弹回退/重试无意义 —— 直接给可操作指引。
|
|
100
|
+
if (res.status === 'AUTH_REQUIRED') return false
|
|
101
|
+
if (res.status === 'SPAWN_ERROR' || res.status === 'CLI_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
|
|
102
|
+
const hay = String(res.stderr || '') + ' ' + String(res.status || '')
|
|
103
|
+
// 端点域与登录域不匹配的 401:需要用户换登录域,不是限流。
|
|
104
|
+
if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
|
|
105
|
+
return LIMIT_RE.test(hay)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── 半行安全的行流(跨 chunk 的 NDJSON 行拼接)───────────────────────────────
|
|
109
|
+
function createLineStream(consumeLine) {
|
|
110
|
+
let pending = ''
|
|
111
|
+
function pushChunk(text) {
|
|
112
|
+
if (typeof text !== 'string' || !text) return
|
|
113
|
+
const lines = (pending + text).split(/\r?\n/)
|
|
114
|
+
pending = lines.pop() || ''
|
|
115
|
+
for (const ln of lines) consumeLine(ln)
|
|
116
|
+
}
|
|
117
|
+
function flush() {
|
|
118
|
+
const rest = pending
|
|
119
|
+
pending = ''
|
|
120
|
+
if (rest) consumeLine(rest)
|
|
121
|
+
}
|
|
122
|
+
return { pushChunk, flush }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 状态引擎(按 provider + 项目目录聚合的运行状态表)─────────────────────────
|
|
126
|
+
// opts: { provider: <descriptor>, publish(snapshot) }
|
|
127
|
+
// 快照结构(顶层为全局聚合,向后兼容单 provider 消费者):
|
|
128
|
+
// { provider, state, running, lastStatus, lastAt, lastSessionId, lastBackend,
|
|
129
|
+
// fallbackActive, current, trail, runs, totalTokens, updatedAt,
|
|
130
|
+
// projects:[{ cwd,name,provider,state,running,current,trail,lastStatus,lastAt,
|
|
131
|
+
// lastSessionId,lastBackend,fallbackActive,runs,totalTokens,updatedAt }] }
|
|
132
|
+
function createStatusEngine(opts) {
|
|
133
|
+
const o = opts || {}
|
|
134
|
+
const provider = o.provider
|
|
135
|
+
const providerId = provider && provider.id ? provider.id : 'cli'
|
|
136
|
+
const publish = typeof o.publish === 'function' ? o.publish : null
|
|
137
|
+
const projects = Object.create(null)
|
|
138
|
+
// sessionId -> { cwd, backend, provider }。会话按 provider 归属(登录域互斥),
|
|
139
|
+
// 续接时一次解析出 cwd 与 backend。容量上限 MAX_SESSIONS,FIFO 淘汰。
|
|
140
|
+
const sessions = Object.create(null)
|
|
141
|
+
|
|
142
|
+
function ensureProject(cwd) {
|
|
143
|
+
const key = String(cwd || '')
|
|
144
|
+
let p = projects[key]
|
|
145
|
+
if (!p) {
|
|
146
|
+
if (Object.keys(projects).length >= MAX_PROJECTS) {
|
|
147
|
+
const idle = Object.keys(projects).filter((k) => projects[k].running === 0)
|
|
148
|
+
const pool = idle.length ? idle : Object.keys(projects)
|
|
149
|
+
const victim = pool.sort((a, b) => projects[a].updatedAt - projects[b].updatedAt)[0]
|
|
150
|
+
delete projects[victim]
|
|
151
|
+
}
|
|
152
|
+
p = { cwd: key, name: projectName(key), provider: providerId, state: 'idle', running: 0, lastStatus: null, lastAt: 0, lastSessionId: null, lastBackend: null, lastOk: false, lastFailed: false, fallbackActive: false, current: null, trail: [], runs: 0, totalTokens: 0, updatedAt: 0 }
|
|
153
|
+
projects[key] = p
|
|
154
|
+
}
|
|
155
|
+
return p
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function globalStatus() {
|
|
159
|
+
const list = Object.keys(projects).map((k) => projects[k])
|
|
160
|
+
let running = 0, lastStatus = null, lastAt = 0, lastSessionId = null, lastBackend = null, fallbackActive = false, current = null, trail = [], updatedAt = 0, runs = 0, totalTokens = 0
|
|
161
|
+
for (const p of list) {
|
|
162
|
+
running += p.running
|
|
163
|
+
if (p.updatedAt > updatedAt) updatedAt = p.updatedAt
|
|
164
|
+
if (p.running > 0 && !current && p.current) current = p.current
|
|
165
|
+
if (p.lastAt > lastAt) { lastAt = p.lastAt; lastStatus = p.lastStatus; lastSessionId = p.lastSessionId; lastBackend = p.lastBackend }
|
|
166
|
+
if (p.fallbackActive) fallbackActive = true
|
|
167
|
+
runs += p.runs || 0
|
|
168
|
+
totalTokens += p.totalTokens || 0
|
|
169
|
+
for (const e of p.trail) trail.push(e)
|
|
170
|
+
}
|
|
171
|
+
trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
|
|
172
|
+
trail = trail.slice(-MAX_TRAIL)
|
|
173
|
+
const state = running > 0 ? 'running' : (fallbackActive ? 'fallback' : (lastStatus ? (lastStatus === 'SUCCESS' ? 'ok' : 'failed') : 'idle'))
|
|
174
|
+
return { state, running, lastStatus, lastAt, lastSessionId, lastBackend, fallbackActive, current, trail, runs, totalTokens, updatedAt }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function statusSnapshot() {
|
|
178
|
+
const g = globalStatus()
|
|
179
|
+
const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
180
|
+
return Object.assign({ provider: providerId }, g, {
|
|
181
|
+
projects: list.map((p) => ({ cwd: p.cwd, name: p.name, provider: providerId, state: p.state, running: p.running, current: p.current, trail: p.trail.slice(-MAX_TRAIL), lastStatus: p.lastStatus, lastAt: p.lastAt, lastSessionId: p.lastSessionId, lastBackend: p.lastBackend, fallbackActive: p.fallbackActive, runs: p.runs || 0, totalTokens: p.totalTokens || 0, updatedAt: p.updatedAt }))
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function emit() { if (publish) { try { publish(statusSnapshot()) } catch (e) {} } }
|
|
186
|
+
|
|
187
|
+
function begin(cwd) {
|
|
188
|
+
const p = ensureProject(cwd)
|
|
189
|
+
p.running += 1
|
|
190
|
+
p.state = 'running'
|
|
191
|
+
p.updatedAt = Date.now()
|
|
192
|
+
emit()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function end(res, cwd) {
|
|
196
|
+
const p = ensureProject(cwd)
|
|
197
|
+
p.running = Math.max(0, p.running - 1)
|
|
198
|
+
p.lastStatus = res ? res.status : null
|
|
199
|
+
p.lastAt = Date.now()
|
|
200
|
+
if (res && res.sessionId) {
|
|
201
|
+
p.lastSessionId = res.sessionId
|
|
202
|
+
sessions[res.sessionId] = { cwd: p.cwd, backend: res.backend || (provider && provider.defaultBackend), provider: providerId }
|
|
203
|
+
const keys = Object.keys(sessions)
|
|
204
|
+
if (keys.length > MAX_SESSIONS) delete sessions[keys[0]]
|
|
205
|
+
}
|
|
206
|
+
if (res && res.backend) p.lastBackend = res.backend
|
|
207
|
+
p.runs = (p.runs || 0) + 1
|
|
208
|
+
if (res && typeof res.totalTokens === 'number' && res.totalTokens > 0) p.totalTokens = (p.totalTokens || 0) + res.totalTokens
|
|
209
|
+
if (res && res.fallback) { p.fallbackActive = true; p.state = 'fallback' }
|
|
210
|
+
else if (p.running > 0) { p.state = 'running' }
|
|
211
|
+
else { p.state = res && res.ok ? 'ok' : 'failed'; p.lastOk = !!(res && res.ok); p.lastFailed = !(res && res.ok) }
|
|
212
|
+
p.current = null
|
|
213
|
+
p.updatedAt = Date.now()
|
|
214
|
+
emit()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 流事件折叠为实时状态。provider 若有 stream-json,提供 foldStreamEvent(ev, project);
|
|
218
|
+
// 否则回退到 generic 形态(不产生实时步骤)。
|
|
219
|
+
function foldEvent(ev, cwd) {
|
|
220
|
+
const p = ensureProject(cwd)
|
|
221
|
+
p.updatedAt = Date.now()
|
|
222
|
+
if (provider && typeof provider.foldStreamEvent === 'function') {
|
|
223
|
+
try { provider.foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) } catch (e) {}
|
|
224
|
+
}
|
|
225
|
+
emit()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 会话路由(cwd + backend 一次解析)。续接(--resume/--continue)时未给 cwd
|
|
229
|
+
// 则回落到该 session 所在项目的 cwd(否则换目录报找不到会话)。
|
|
230
|
+
function resolveTarget(args, fallbackCwd) {
|
|
231
|
+
const a = args || {}
|
|
232
|
+
let backend = null, cwd = null
|
|
233
|
+
if (a.sessionId) { const hit = sessions[a.sessionId]; if (hit && hit.provider === providerId) { backend = hit.backend; cwd = hit.cwd } }
|
|
234
|
+
if (a.cwd) {
|
|
235
|
+
cwd = String(a.cwd)
|
|
236
|
+
} else if (!cwd && (a.sessionId || a.continueLatest)) {
|
|
237
|
+
const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
238
|
+
if (a.sessionId) {
|
|
239
|
+
const projHit = list.find((p) => p.lastSessionId === a.sessionId)
|
|
240
|
+
if (projHit) { cwd = projHit.cwd; if (!backend) backend = projHit.lastBackend }
|
|
241
|
+
} else if (list.length && list[0].lastSessionId) { cwd = list[0].cwd; if (!backend) backend = list[0].lastBackend }
|
|
242
|
+
}
|
|
243
|
+
return { cwd: cwd || fallbackCwd, backend }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveCwd(args, fallbackCwd) { return resolveTarget(args, fallbackCwd).cwd }
|
|
247
|
+
|
|
248
|
+
return { providerId, projects, sessions, ensureProject, globalStatus, statusSnapshot, begin, end, foldEvent, resolveCwd, resolveTarget }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── 回退弹窗三选一(provider 名注入)──────────────────────────────────────────
|
|
252
|
+
function fallbackLabels(provider) {
|
|
253
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
254
|
+
return {
|
|
255
|
+
fallback: '使用 DSH 本地 API 配置(回退)',
|
|
256
|
+
retry: '重试 ' + bk + ' 一次',
|
|
257
|
+
cancel: '不回退(返回错误)'
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 失败结果的可行动指引;provider.failureHint 优先,未给则通用兜底。
|
|
262
|
+
function failureHint(res, provider) {
|
|
263
|
+
if (!res || res.ok) return ''
|
|
264
|
+
if (provider && typeof provider.failureHint === 'function') {
|
|
265
|
+
try { const h = provider.failureHint(res); if (h) return h } catch (e) {}
|
|
266
|
+
}
|
|
267
|
+
if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
|
|
268
|
+
if (res.status === 'PARSE_ERROR') return '[诊断] 未能从输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 CLI 版本。'
|
|
269
|
+
if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
|
|
270
|
+
return '[诊断] 认证失败(401):请求端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 CLI 登录后重试;这不是限流。'
|
|
271
|
+
}
|
|
272
|
+
return ''
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function fallbackResult(res, mode, provider) {
|
|
276
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
277
|
+
return Object.assign({}, res, { ok: false, fallback: true, status: 'FALLBACK_TO_DSH', response: '', stderr: (res && res.stderr ? res.stderr : '') + ' [user chose DSH fallback for ' + bk + ']' })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── 展示层 ────────────────────────────────────────────────────────────────────
|
|
281
|
+
function renderResult(value, provider) {
|
|
282
|
+
const v = value || {}
|
|
283
|
+
// backendLabel(codebuddy 旧语义 v1.1.0 verbatim:'workbuddy OK'/'codebuddy-intl OK'):
|
|
284
|
+
// 结果带具体 backend 时用 backend 标头;agy/mimo 旧包标头是硬编码 provider 名,不开此旗。
|
|
285
|
+
const bk = (provider && provider.backendLabel && v.backend) || (provider && provider.id) || v.backend || 'CLI'
|
|
286
|
+
if (v.background) return [{ type: 'text', text: bk + ' dispatched in background (mode=' + v.mode + '). jobId=' + v.jobId + '. Collect with job_output.' }]
|
|
287
|
+
if (v.fallback) return [{ type: 'text', text: bk + ' 回退:用户选择使用 DSH 本地 API 配置(原因 ' + v.reason + ')。请改用原生工具/本地模型完成本任务,不要再调 ' + bk + '。' }]
|
|
288
|
+
const head = bk + ' ' + (v.ok ? 'OK' : 'FAILED') + ' [status=' + v.status + ' mode=' + v.mode + (v.modelUsed ? ' model=' + v.modelUsed : '') + (v.retried ? ' retried=1' : '') + (v.sessionId ? ' session=' + v.sessionId : '') + (v.totalTokens != null ? ' tokens=' + v.totalTokens : '') + (v.durationSeconds != null ? ' ' + v.durationSeconds + 's' : '') + ']'
|
|
289
|
+
const hint = failureHint(v, provider)
|
|
290
|
+
let body = v.response ? v.response : (v.stderr ? '[stderr] ' + v.stderr : (v.rawStdout ? '[raw] ' + v.rawStdout : ''))
|
|
291
|
+
if (hint) body = (body ? body + '\n\n' : '') + hint
|
|
292
|
+
return [{ type: 'text', text: head + (body ? '\n\n' + body : '') }]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function renderStatus(value, provider) {
|
|
296
|
+
const v = value || {}
|
|
297
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
298
|
+
const lines = []
|
|
299
|
+
lines.push(bk + ' status: ' + v.state + (v.running > 0 ? ' (' + v.running + ' running)' : '') + (v.projects && v.projects.length > 1 ? ' across ' + v.projects.length + ' projects' : '') + (v.totalTokens ? ' | total ' + v.runs + ' runs, ' + v.totalTokens + ' tokens' : ''))
|
|
300
|
+
const projList = (v.projects && v.projects.length) ? v.projects : null
|
|
301
|
+
if (projList) {
|
|
302
|
+
for (const p of projList) {
|
|
303
|
+
const cur = p.current ? (' step ' + p.current.stepIndex + ' → ' + p.current.tool + (p.current.args ? ' ' + JSON.stringify(p.current.args) : '')) : (p.running > 0 ? ' (starting / thinking)' : '')
|
|
304
|
+
const usage = (p.runs ? ' | total ' + p.runs + ' runs, ' + (p.totalTokens || 0) + ' tokens' : '')
|
|
305
|
+
const bkTag = (p.lastBackend && p.lastBackend !== bk) ? ' [' + p.lastBackend + ']' : ''
|
|
306
|
+
lines.push('· ' + p.name + ' [' + p.state + (p.running > 0 ? ' ×' + p.running : '') + ']' + bkTag + cur + (p.lastStatus ? ' | last=' + p.lastStatus + (p.lastSessionId ? ' ' + p.lastSessionId.slice(0, 8) : '') : '') + usage)
|
|
307
|
+
if (p.trail && p.trail.length) {
|
|
308
|
+
lines.push(' steps:')
|
|
309
|
+
for (const e of p.trail.slice(-3)) { const a = e.args ? ' ' + JSON.stringify(e.args) : ''; lines.push(' [' + e.state + '] step ' + e.stepIndex + ' ' + e.tool + a) }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
if (v.current) { const c = v.current; lines.push('current: step ' + c.stepIndex + ' → ' + c.tool + (c.args ? ' ' + JSON.stringify(c.args) : '')) }
|
|
314
|
+
else if (v.state === 'running') lines.push('current: (starting / thinking)')
|
|
315
|
+
if (v.trail && v.trail.length) {
|
|
316
|
+
lines.push('recent steps:')
|
|
317
|
+
for (const e of v.trail.slice(-6)) { const a = e.args ? ' ' + JSON.stringify(e.args) : ''; lines.push(' [' + e.state + '] step ' + e.stepIndex + ' ' + e.tool + a) }
|
|
318
|
+
}
|
|
319
|
+
if (v.lastStatus) lines.push('last: ' + v.lastStatus + (v.lastSessionId ? ' session=' + v.lastSessionId : '') + (v.lastAt ? ' @ ' + new Date(v.lastAt).toISOString() : ''))
|
|
320
|
+
}
|
|
321
|
+
if (v.updatedAt) lines.push('updatedAt: ' + new Date(v.updatedAt).toISOString())
|
|
322
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── MCP 子进程快照上报 ────────────────────────────────────────────────────────
|
|
326
|
+
// MCP server 是独立 stdio 子进程:没有 ctx.emit、拿不到 collector 服务,所以把快照
|
|
327
|
+
// 原子写进 {dsh-home}/cli-bridge-mcp-<provider>.json,家级灯在响应请求时读取合并。
|
|
328
|
+
function buildMcpBridgePayload(snap, pid, nowMs) {
|
|
329
|
+
const s = snap && typeof snap === 'object' ? snap : {}
|
|
330
|
+
const src = Array.isArray(s.projects) ? s.projects : []
|
|
331
|
+
const projects = []
|
|
332
|
+
for (const p of src) {
|
|
333
|
+
if (!p || !p.cwd) continue
|
|
334
|
+
projects.push({
|
|
335
|
+
cwd: String(p.cwd), name: p.name ? String(p.name) : '', provider: p.provider || s.provider || '', state: p.state ? String(p.state) : 'idle',
|
|
336
|
+
running: Number(p.running) || 0,
|
|
337
|
+
current: p.current ? { stepIndex: Number(p.current.stepIndex) || 0, tool: String(p.current.tool || ''), args: p.current.args || null } : null,
|
|
338
|
+
trail: Array.isArray(p.trail) ? p.trail.slice(-MAX_TRAIL).map((e) => ({ stepIndex: Number(e && e.stepIndex) || 0, tool: String((e && e.tool) || ''), state: String((e && e.state) || ''), args: (e && e.args) || null })) : [],
|
|
339
|
+
lastStatus: p.lastStatus ? String(p.lastStatus) : null, lastAt: Number(p.lastAt) || 0, lastSessionId: p.lastSessionId ? String(p.lastSessionId) : null,
|
|
340
|
+
lastBackend: p.lastBackend ? String(p.lastBackend) : null, fallbackActive: !!p.fallbackActive, runs: Number(p.runs) || 0, totalTokens: Number(p.totalTokens) || 0, updatedAt: Number(p.updatedAt) || 0
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
return { source: 'mcp', provider: s.provider || '', pid: Number(pid) || 0, updatedAt: Number(nowMs) || 0, projects }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function normalizeMcpBridge(payload, nowMs, staleMs) {
|
|
347
|
+
if (!payload || typeof payload !== 'object') return null
|
|
348
|
+
const projects = Array.isArray(payload.projects) ? payload.projects : []
|
|
349
|
+
const updatedAt = Number(payload.updatedAt) || 0
|
|
350
|
+
const stale = (Number(nowMs) || 0) - updatedAt > (Number(staleMs) || MCP_BRIDGE_STALE_MS)
|
|
351
|
+
const out = []
|
|
352
|
+
for (const p of projects) {
|
|
353
|
+
if (!p || !p.cwd) continue
|
|
354
|
+
if (stale && (Number(p.running) || 0) > 0) out.push(Object.assign({}, p, { running: 0, current: null, state: p.state === 'running' ? 'idle' : p.state }))
|
|
355
|
+
else out.push(p)
|
|
356
|
+
}
|
|
357
|
+
return { source: 'mcp', provider: payload.provider || '', pid: Number(payload.pid) || 0, updatedAt, stale, projects: out }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── 执行编排(三形态共用;provider 差异全走 descriptor)──────────────────────
|
|
361
|
+
// o: {
|
|
362
|
+
// ctx, // Cordis ctx(interval/timeout/get)
|
|
363
|
+
// subprocess, // DSH subprocess 服务
|
|
364
|
+
// provider, // descriptor(见 PROVIDER-CONTRACT.md)
|
|
365
|
+
// engine, // createStatusEngine({provider}) 实例
|
|
366
|
+
// resolveExe, // async (backend, signal) => 'exe' | [nodeExe, binPath] | throw
|
|
367
|
+
// getCwdFallback, // () => 默认 cwd
|
|
368
|
+
// getPreferredBackend, // () => 用户偏好后端(可缺省)
|
|
369
|
+
// getSettings, // () => 当前设置对象(可缺省;provider 用它读 defaultModel 等)
|
|
370
|
+
// planActiveFor, // (exec) => bool(plan 模式判定,可缺省)
|
|
371
|
+
// defaultMode // 'auto'
|
|
372
|
+
// }
|
|
373
|
+
function createRunner(o) {
|
|
374
|
+
const ctx = o.ctx
|
|
375
|
+
const subprocess = o.subprocess
|
|
376
|
+
const provider = o.provider
|
|
377
|
+
const engine = o.engine
|
|
378
|
+
const stdio = { stdin: 'ignore', stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } }, stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } } }
|
|
379
|
+
|
|
380
|
+
function readStreams(handle) {
|
|
381
|
+
const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
382
|
+
const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
|
|
383
|
+
return { stdoutText, stderrText }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let built_env = null
|
|
387
|
+
function spawnSpec(argv, cwd, callerSignal) {
|
|
388
|
+
const spec = { argv, cwd, stdio, graceMs: 5000 }
|
|
389
|
+
if (callerSignal) spec.signal = callerSignal
|
|
390
|
+
if (built_env && typeof built_env === 'object') spec.env = built_env
|
|
391
|
+
return spec
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function startLiveParser(handle, cwd) {
|
|
395
|
+
let cursor = 0
|
|
396
|
+
const stream = createLineStream((ln) => {
|
|
397
|
+
const t = ln.trim()
|
|
398
|
+
if (!t.startsWith('{')) return
|
|
399
|
+
try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
|
|
400
|
+
})
|
|
401
|
+
const tick = function () {
|
|
402
|
+
try {
|
|
403
|
+
const full = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
404
|
+
if (full.length <= cursor) return
|
|
405
|
+
const fresh = full.slice(cursor)
|
|
406
|
+
cursor = full.length
|
|
407
|
+
stream.pushChunk(fresh)
|
|
408
|
+
} catch (e) {}
|
|
409
|
+
}
|
|
410
|
+
const disposeInterval = ctx.interval(tick, 250)
|
|
411
|
+
return function disposeLive() { try { stream.flush() } catch (e) {} disposeInterval() }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function runSync(argv, cwd, timeoutSec, callerSignal, extraEnv) {
|
|
415
|
+
const spec = spawnSpec(argv, cwd, callerSignal)
|
|
416
|
+
if (extraEnv && typeof extraEnv === 'object') spec.env = Object.assign({}, spec.env, extraEnv)
|
|
417
|
+
const handle = subprocess.spawn(spec)
|
|
418
|
+
let lastEventSummary = '(no events yet)'
|
|
419
|
+
let timedOut = false
|
|
420
|
+
const disposeTimer = ctx.timeout(function () {
|
|
421
|
+
timedOut = true
|
|
422
|
+
try {
|
|
423
|
+
const p = engine.ensureProject(cwd)
|
|
424
|
+
lastEventSummary = (p.current ? ('last step ' + p.current.stepIndex + ' -> ' + p.current.tool) : '') +
|
|
425
|
+
' | trail=' + (p.trail.length ? p.trail.slice(-2).map((e) => '[' + e.state + ']' + e.tool).join(',') : 'empty') +
|
|
426
|
+
' | elapsed=' + Math.round((Date.now() - (p.updatedAt || Date.now())) / 1000) + 's since last activity'
|
|
427
|
+
} catch (e) {}
|
|
428
|
+
try { handle.terminate() } catch (e) {}
|
|
429
|
+
}, (timeoutSec + 60) * 1000)
|
|
430
|
+
const disposeLive = startLiveParser(handle, cwd)
|
|
431
|
+
try {
|
|
432
|
+
const outcome = await handle.done
|
|
433
|
+
const s = readStreams(handle)
|
|
434
|
+
return { outcome, stdoutText: s.stdoutText, stderrText: s.stderrText, timedOut, lastEventSummary }
|
|
435
|
+
} finally { disposeLive(); disposeTimer() }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function askFallback(exec, res, canRetry) {
|
|
439
|
+
const uq = ctx.get('userQuestions')
|
|
440
|
+
if (!uq || !exec || !exec.agent) return 'error'
|
|
441
|
+
const labels = fallbackLabels(provider)
|
|
442
|
+
const bk = provider.id
|
|
443
|
+
const detail = String(res.stderr || res.response || res.status || '').slice(-600)
|
|
444
|
+
const opts = [{ label: labels.fallback, description: '本次改由 DSH 本地模型/原生工具完成,不再走 ' + bk }]
|
|
445
|
+
if (canRetry) opts.push({ label: labels.retry, description: '再调用一次 ' + bk + '(网络抖动时可用)' })
|
|
446
|
+
opts.push({ label: labels.cancel, description: '不回退,直接返回 ' + bk + ' 错误' })
|
|
447
|
+
try {
|
|
448
|
+
const ans = await uq.ask({ agent: exec.agent, signal: exec.signal, questions: [{ id: bk + '-fallback', header: bk + ' 受限', question: bk + ' 调用失败(疑似流量受限/网络不通,状态=' + String(res.status) + ')。是否改用 DSH 本地 API 配置继续?', detail, options: opts }] })
|
|
449
|
+
const sel = (ans && ans.answers && ans.answers[0] && ans.answers[0].selected) || []
|
|
450
|
+
if (sel.indexOf(labels.fallback) >= 0) return 'fallback'
|
|
451
|
+
if (sel.indexOf(labels.retry) >= 0) return 'retry'
|
|
452
|
+
return 'error'
|
|
453
|
+
} catch (e) { return 'error' }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function coreExecute(rawArgs, exec) {
|
|
457
|
+
const args = rawArgs || {}
|
|
458
|
+
built_env = null
|
|
459
|
+
const mk = (over) => Object.assign({ ok: false, status: 'BAD_ARGS', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: provider.defaultMode || 'auto', backend: null, stderr: '' }, over)
|
|
460
|
+
if (!args.prompt || !String(args.prompt).trim()) return mk({ stderr: 'prompt is required' })
|
|
461
|
+
|
|
462
|
+
const target = engine.resolveTarget(args, o.getCwdFallback())
|
|
463
|
+
const backend = provider.resolveBackend ? provider.resolveBackend(args, target, o.getPreferredBackend ? o.getPreferredBackend() : null) : (o.getPreferredBackend ? o.getPreferredBackend() : provider.defaultBackend)
|
|
464
|
+
const cwd = target.cwd
|
|
465
|
+
|
|
466
|
+
// provider 前置检查(凭据/桌面健康等):返回 { abort: res } 则直接结束。
|
|
467
|
+
if (provider.preflight) {
|
|
468
|
+
const pre = provider.preflight({ args, exec, backend, cwd, engine, ctx, o })
|
|
469
|
+
if (pre && pre.abort) { engine.begin(cwd); engine.end(pre.abort, cwd); return pre.abort }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let exePrefix = provider.fallbackExePrefix ? provider.fallbackExePrefix(backend) : [provider.exeName || provider.id]
|
|
473
|
+
let exeOk = true, resolveErr = ''
|
|
474
|
+
try { const resExe = await o.resolveExe(backend, exec ? exec.signal : undefined); exePrefix = Array.isArray(resExe) ? resExe : [resExe] }
|
|
475
|
+
catch (e) { exeOk = false; resolveErr = String(e && e.message || e) }
|
|
476
|
+
|
|
477
|
+
const built = provider.buildArgv(exePrefix, args, {
|
|
478
|
+
planActive: o.planActiveFor ? o.planActiveFor(exec) : false,
|
|
479
|
+
defaultMode: provider.defaultMode || 'auto',
|
|
480
|
+
defaultModel: (!args.model && o.getSettings) ? (o.getSettings() || {}).defaultModel || null : null,
|
|
481
|
+
backend, settings: o.getSettings ? o.getSettings() : null, engine
|
|
482
|
+
})
|
|
483
|
+
built_env = (built.env && typeof built.env === 'object') ? built.env : null
|
|
484
|
+
const unavailableStatus = provider.unavailableStatus || 'CLI_UNAVAILABLE'
|
|
485
|
+
|
|
486
|
+
if (!exeOk) {
|
|
487
|
+
engine.begin(cwd)
|
|
488
|
+
let res = mk({ status: unavailableStatus, mode: built.mode, backend, stderr: (backend || provider.id) + ' executable not found: ' + resolveErr })
|
|
489
|
+
if (await askFallback(exec, res, true) === 'fallback') res = fallbackResult(res, built.mode, provider)
|
|
490
|
+
engine.end(res, cwd); return res
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 非 spawn 路径(如 mimo 桌面走本地 HTTP):provider 若给 execute 且(未给 delegates
|
|
494
|
+
// 或 delegates(backend)=true)则完全交给它;否则落回通用 pipeline(jobs/重试/弹窗)。
|
|
495
|
+
if (provider.execute && (!provider.delegates || provider.delegates(backend, args))) {
|
|
496
|
+
engine.begin(cwd)
|
|
497
|
+
try {
|
|
498
|
+
const res = await provider.execute({ args, exec, backend, cwd, built, engine, ctx, o, runSync })
|
|
499
|
+
engine.end(res, cwd); return res
|
|
500
|
+
} catch (e) {
|
|
501
|
+
const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
|
|
502
|
+
engine.end(res, cwd); return res
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const jobs = ctx.get('jobs')
|
|
507
|
+
if (args.background && jobs && exec && exec.agent) {
|
|
508
|
+
try {
|
|
509
|
+
engine.begin(cwd)
|
|
510
|
+
const jobId = jobs.start({
|
|
511
|
+
kind: 'bash', label: provider.id + ': ' + shortLabel(args.prompt), owner: exec.agent,
|
|
512
|
+
run() {
|
|
513
|
+
const handle = subprocess.spawn(spawnSpec(built.argv, cwd))
|
|
514
|
+
const disposeLive = startLiveParser(handle, cwd)
|
|
515
|
+
let bgKilled = false
|
|
516
|
+
const disposeGuard = ctx.timeout(function () { bgKilled = true; try { handle.terminate() } catch (e) {} }, (built.timeoutSec + 60) * 1000)
|
|
517
|
+
const done = handle.done.then(function (outcome) {
|
|
518
|
+
disposeLive(); disposeGuard()
|
|
519
|
+
const s = readStreams(handle)
|
|
520
|
+
const res = provider.buildResult(provider.parseJson(s.stdoutText), outcome, built.mode, s.stderrText, s.stdoutText, backend)
|
|
521
|
+
if (bgKilled && !res.ok) { res.status = 'HUNG_TIMEOUT'; res.stderr = (res.stderr ? res.stderr + ' ' : '') + '[killed by timeout guard after ' + built.timeoutSec + 's; raise timeoutSec for long builds]' }
|
|
522
|
+
engine.end(res, cwd)
|
|
523
|
+
return { status: res.ok ? 'completed' : 'failed', detail: provider.id + ' ' + res.status, output: JSON.stringify(res) }
|
|
524
|
+
}).catch(function (err) { disposeLive(); disposeGuard(); engine.end(mk({ status: 'JOB_ERROR', backend }), cwd); return { status: 'failed', detail: String(err && err.message || err) } })
|
|
525
|
+
return { cancel: function () { try { handle.terminate() } catch (e) {} }, done }
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
return { ok: true, background: true, jobId: String(jobId), mode: built.mode, backend, note: provider.id + ' running in background; collect with job_output ' + String(jobId) + '. Background failures do NOT open the fallback dialog; on failure re-run in foreground to be prompted.' }
|
|
529
|
+
} catch (e) {
|
|
530
|
+
const res = mk({ status: 'JOB_START_ERROR', mode: built.mode, backend, stderr: 'failed to start background job: ' + String(e && e.message || e) })
|
|
531
|
+
engine.end(res, cwd); return res
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
engine.begin(cwd)
|
|
536
|
+
try {
|
|
537
|
+
let attempt = 0, cliRetried = false, res
|
|
538
|
+
while (true) {
|
|
539
|
+
attempt += 1
|
|
540
|
+
const r = await runSync(built.argv, cwd, built.timeoutSec, exec ? exec.signal : undefined, built.env)
|
|
541
|
+
if (r.timedOut) { res = mk({ status: 'HUNG_TIMEOUT', mode: built.mode, backend, exitCode: r.outcome ? r.outcome.exitCode : null, stderr: provider.id + ' did not finish within ' + built.timeoutSec + 's (DSH hard timeout). Last activity: ' + r.lastEventSummary + '. NOTE: raise timeoutSec for long builds; this was a hang guard.' }); break }
|
|
542
|
+
res = provider.buildResult(provider.parseJson(r.stdoutText), r.outcome, built.mode, r.stderrText, r.stdoutText, backend)
|
|
543
|
+
if (res.ok) break
|
|
544
|
+
if (provider.isTransientCliError && provider.isTransientCliError(res)) {
|
|
545
|
+
if (!cliRetried) { cliRetried = true; attempt -= 1; continue }
|
|
546
|
+
res.retried = true; break
|
|
547
|
+
}
|
|
548
|
+
if (!(provider.isLimited ? provider.isLimited(res) : isLimited(res))) break
|
|
549
|
+
const decision = await askFallback(exec, res, attempt < 2)
|
|
550
|
+
if (decision === 'fallback') { res = fallbackResult(res, built.mode, provider); break }
|
|
551
|
+
if (decision === 'retry' && attempt < 2) continue
|
|
552
|
+
break
|
|
553
|
+
}
|
|
554
|
+
engine.end(res, cwd); return res
|
|
555
|
+
} catch (e) {
|
|
556
|
+
const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
|
|
557
|
+
const out = (await askFallback(exec, res, true) === 'fallback') ? fallbackResult(res, built.mode, provider) : res
|
|
558
|
+
engine.end(out, cwd); return out
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return { coreExecute, runSync, askFallback }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// providers/claude.mjs — claude provider descriptor(Claude Code CLI;本仓第五家 CLI)。
|
|
566
|
+
//
|
|
567
|
+
// 基线 = 本机实测 Claude Code 2.0.76(`claude` = 原生 claude.exe),端点被用户指向第三方
|
|
568
|
+
// Anthropic 兼容服务(~/.claude/settings.json 的 env: ANTHROPIC_BASE_URL/AUTH_TOKEN +
|
|
569
|
+
// ANTHROPIC_DEFAULT_*_MODEL 别名映射)。写代码时最容易踩的几条事实:
|
|
570
|
+
// • 非交互 = `claude -p "<prompt>"`,prompt 是**单参数**(不是位置参数流,也不是 stdin)。
|
|
571
|
+
// • `--output-format json` → stdout 单行 result 事件;`stream-json` 必须配 `--verbose`,
|
|
572
|
+
// 实测 4 行:system(init) / assistant(thinking) / assistant(text) / result。
|
|
573
|
+
// • ⚠️ 成功判定陷阱:result 事件 `is_error:true` 时退出码**可能是 0 也可能 1**
|
|
574
|
+
// (实测无效模型 = exit 1,但 stdout 仍是合法 JSON 且 `subtype` 竟然还是 "success")。
|
|
575
|
+
// 故 buildResult 只认 `is_error` + `subtype`,绝不只看 exit code。
|
|
576
|
+
// • 续聊 = `--resume <session_id>`;`-c/--continue` 续最近一次。session_id 来自
|
|
577
|
+
// result 事件(json/stream-json 都有)。
|
|
578
|
+
// • 权限:`--permission-mode plan|acceptEdits|...` 或 `--dangerously-skip-permissions`;
|
|
579
|
+
// -p 模式没有 TTY,任何需要交互批准的路径都必须走 bypass,否则会挂到超时。
|
|
580
|
+
//
|
|
581
|
+
// 红线(PROVIDER-CONTRACT.md §8 + §1):
|
|
582
|
+
// - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
|
|
583
|
+
// process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
|
|
584
|
+
// - 只允许 import '../core/bridge-kit.mjs' 与同目录片文件(claude-exe / claude-models)。
|
|
585
|
+
// - 模型清单只认服务端权威探测;拿不到 = ok:false + 可操作错误(绝不静默空数组/配置冒充)。
|
|
586
|
+
// import { createStatusEngine, createRunner, clampInt, isLimited } from '../core/bridge-kit.mjs'
|
|
587
|
+
// import { claudeExeCandidates, claudePathHint, resolveExePrefix, splitExeSpec, CLAUDE_BIN_ENV_VARS } from './claude-exe.mjs'
|
|
588
|
+
// import { PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache } from './claude-models.mjs'
|
|
589
|
+
// 单行 re-export(build 内联时整行剥离;多行形态风险见 build.mjs 的 stripModuleSyntax 注释)。
|
|
590
|
+
|
|
591
|
+
function nowIso() { return new Date().toISOString() }
|
|
592
|
+
|
|
593
|
+
// 权限模式的用户可见取值(auto 由 DSH plan 状态决定;其余直通 CLI)。
|
|
594
|
+
const MODE_ENUM = ['auto', 'plan', 'accept-edits', 'bypass']
|
|
595
|
+
|
|
596
|
+
// 手动拉取(契约 §3.2)的探测时限与失败指引。
|
|
597
|
+
// 失败文案必须「可操作」:面板只会把 error 原样显示给用户,没写下一步等于没帮上忙。
|
|
598
|
+
const REFRESH_TIMEOUT_MS = 30000
|
|
599
|
+
const REFRESH_HINT = '排查顺序:① 确认设置面板的「claude 可执行文件路径」或 CLAUDE_BIN 指向真实 claude(原生 .exe 优先);② 确认 ~/.claude/settings.json 的 env 里 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN 指向可用端点(该面若未登录,先在终端跑一次 claude 完成登录);③ 再点一次「拉取可用模型」。'
|
|
600
|
+
|
|
601
|
+
/** 缓存年龄的短文案(面板 note 用;只描述事实,不假装新鲜)。 */
|
|
602
|
+
function ageText(ms) {
|
|
603
|
+
const min = Math.floor(Math.max(0, Number(ms) || 0) / 60000)
|
|
604
|
+
if (min < 1) return '刚刚拉取'
|
|
605
|
+
if (min < 60) return min + ' 分钟前拉取'
|
|
606
|
+
return Math.floor(min / 60) + ' 小时前拉取'
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ── 三工具 schema ─────────────────────────────────────────────────────────────
|
|
610
|
+
const RUN_PARAMS = {
|
|
611
|
+
type: 'object',
|
|
612
|
+
additionalProperties: false,
|
|
613
|
+
required: ['prompt'],
|
|
614
|
+
properties: {
|
|
615
|
+
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).' },
|
|
616
|
+
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.' },
|
|
617
|
+
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.' },
|
|
618
|
+
maxBudgetUsd: { type: 'number', description: 'Optional hard cap on API spend for this run (passes --max-budget-usd).' },
|
|
619
|
+
cwd: { type: 'string', description: 'Working directory for claude. Defaults to the DSH workspace root.' },
|
|
620
|
+
addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra directories to allow tool access to (--add-dir).' },
|
|
621
|
+
allowedTools: { type: 'array', items: { type: 'string' }, description: 'Optional allow-list of tool names (--allowedTools).' },
|
|
622
|
+
disallowedTools: { type: 'array', items: { type: 'string' }, description: 'Optional deny-list of tool names (--disallowedTools).' },
|
|
623
|
+
systemPrompt: { type: 'string', description: 'Optional replacement system prompt (--system-prompt).' },
|
|
624
|
+
appendSystemPrompt: { type: 'string', description: 'Optional system prompt appended to the default one (--append-system-prompt).' },
|
|
625
|
+
forkSession: { type: 'boolean', description: 'When resuming, create a new session id instead of reusing the original (--fork-session).' },
|
|
626
|
+
timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
|
|
627
|
+
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.' }
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const CONTINUE_PARAMS = {
|
|
631
|
+
type: 'object',
|
|
632
|
+
additionalProperties: false,
|
|
633
|
+
required: ['prompt'],
|
|
634
|
+
properties: {
|
|
635
|
+
prompt: { type: 'string', description: 'Follow-up instruction for the ongoing claude session. State only what is new; claude preserves its session context.' },
|
|
636
|
+
sessionId: { type: 'string', description: 'claude session id to resume (from a prior claude_run result; passes --resume).' },
|
|
637
|
+
latest: { type: 'boolean', description: 'Continue the most recent claude conversation instead of a specific id (--continue).' },
|
|
638
|
+
mode: { type: 'string', enum: MODE_ENUM, description: 'Permission mode; default auto.' },
|
|
639
|
+
model: { type: 'string', description: 'Optional model id or alias.' },
|
|
640
|
+
maxBudgetUsd: { type: 'number', description: 'Optional hard cap on API spend for this run.' },
|
|
641
|
+
cwd: { type: 'string', description: 'Working directory for claude.' },
|
|
642
|
+
forkSession: { type: 'boolean', description: 'Create a new session id instead of reusing the original (--fork-session).' },
|
|
643
|
+
timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
|
|
644
|
+
background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output. Set false to block for the final answer.' }
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
|
|
648
|
+
|
|
649
|
+
// claude_models(附加工具):服务端权威模型清单(探测 400 报文,绝不用配置冒充)。
|
|
650
|
+
const MODELS_PARAMS = {
|
|
651
|
+
type: 'object',
|
|
652
|
+
additionalProperties: false,
|
|
653
|
+
required: [],
|
|
654
|
+
properties: {
|
|
655
|
+
refresh: { type: 'boolean', description: 'Bypass the 5-minute probe cache and ask the endpoint again.' },
|
|
656
|
+
timeoutMs: { type: 'integer', description: 'Probe timeout in ms (5000-90000, default 30000).' }
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
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.'
|
|
660
|
+
|
|
661
|
+
// 渲染函数签名遵循 harness 契约 render(args, value)(extraTools[].render 同签名,
|
|
662
|
+
// preset/dynamic/MCP 三处都按两参数调用 —— 只写一个参数会渲染出空壳)。
|
|
663
|
+
function renderModels(args, value) {
|
|
664
|
+
const v = value || {}
|
|
665
|
+
if (!v.ok) {
|
|
666
|
+
const lines = ['claude_models failed: ' + String(v.error || 'unknown')]
|
|
667
|
+
const cfg = v.diagnostic && Array.isArray(v.diagnostic.configured) ? v.diagnostic.configured : []
|
|
668
|
+
if (cfg.length) {
|
|
669
|
+
lines.push('configured aliases (NOT authoritative): ' + cfg.map((m) => m.id).join(', '))
|
|
670
|
+
if (v.diagnostic && v.diagnostic.settingsPath) lines.push(' from ' + v.diagnostic.settingsPath)
|
|
671
|
+
}
|
|
672
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
673
|
+
}
|
|
674
|
+
const list = Array.isArray(v.models) ? v.models : []
|
|
675
|
+
if (!list.length) return [{ type: 'text', text: 'claude_models: server returned an empty roster' }]
|
|
676
|
+
const lines = ['claude models (' + list.length + ') [source=' + (v.source || 'account-live') + ']:']
|
|
677
|
+
for (const m of list.slice(0, 40)) lines.push(' ' + m.id + (m.configuredAs ? ' [configured as ' + m.configuredAs + ']' : ''))
|
|
678
|
+
if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
|
|
679
|
+
if (v.diagnostic && v.diagnostic.settingsPath) lines.push('settings: ' + v.diagnostic.settingsPath)
|
|
680
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// usage 汇总:input/output + 两个 cache 计数(claude 的 input_tokens 不含 cache 命中)。
|
|
684
|
+
function sumTokens(usage) {
|
|
685
|
+
if (!usage || typeof usage !== 'object') return null
|
|
686
|
+
let total = 0
|
|
687
|
+
let any = false
|
|
688
|
+
for (const k of ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens']) {
|
|
689
|
+
if (typeof usage[k] === 'number' && Number.isFinite(usage[k])) { total += usage[k]; any = true }
|
|
690
|
+
}
|
|
691
|
+
return any ? total : null
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 本次实际跑的主模型:modelUsage 里按 costUSD(再退 token 量)挑最大者。
|
|
695
|
+
function pickModelUsed(modelUsage) {
|
|
696
|
+
if (!modelUsage || typeof modelUsage !== 'object') return null
|
|
697
|
+
let best = null
|
|
698
|
+
let bestScore = -1
|
|
699
|
+
for (const k of Object.keys(modelUsage)) {
|
|
700
|
+
const u = modelUsage[k] && typeof modelUsage[k] === 'object' ? modelUsage[k] : {}
|
|
701
|
+
const score = (Number(u.costUSD) || 0) * 1e6 + (Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0)
|
|
702
|
+
if (score > bestScore) { bestScore = score; best = k }
|
|
703
|
+
}
|
|
704
|
+
return best
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function defineProvider (deps = {}) {
|
|
708
|
+
const env = deps.env || {}
|
|
709
|
+
let host = null
|
|
710
|
+
let settingsRef = { binPath: '', defaultModel: '', maxBudgetUsd: '' }
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 当前生效设置(面板改完即时生效,无需重启)。三源合并,后者覆盖前者:
|
|
714
|
+
* ① settingsRef(attach 时快照/缺省)② deps.readSettings(MCP 形态现读文件)
|
|
715
|
+
* ③ host.getSettings('claude')(preset/dynamic 形态)。
|
|
716
|
+
*/
|
|
717
|
+
function currentSettings() {
|
|
718
|
+
const out = Object.assign({}, settingsRef)
|
|
719
|
+
try {
|
|
720
|
+
if (typeof deps.readSettings === 'function') {
|
|
721
|
+
const r = deps.readSettings()
|
|
722
|
+
if (r && typeof r === 'object') {
|
|
723
|
+
const part = ('partition' in r || 'path' in r || 'home' in r) ? r.partition : r
|
|
724
|
+
if (part && typeof part === 'object') Object.assign(out, part)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} catch (e) { }
|
|
728
|
+
try {
|
|
729
|
+
if (host && typeof host.getSettings === 'function') {
|
|
730
|
+
const s = host.getSettings('claude')
|
|
731
|
+
if (s && typeof s === 'object') Object.assign(out, s)
|
|
732
|
+
}
|
|
733
|
+
} catch (e) { }
|
|
734
|
+
return out
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const descriptor = {
|
|
738
|
+
id: 'claude', label: 'CLAUDE', presetId: 'claude-first', defaultBackend: null, defaultMode: 'auto',
|
|
739
|
+
exeName: 'claude', binEnvVars: CLAUDE_BIN_ENV_VARS, unavailableStatus: 'CLAUDE_UNAVAILABLE',
|
|
740
|
+
|
|
741
|
+
// 单后端:模型/凭据由 claude 自身配置(~/.claude/settings.json)决定,桥不引入第二后端。
|
|
742
|
+
backends() { return null },
|
|
743
|
+
normalizeBackend() { return null },
|
|
744
|
+
resolveBackend(args, target, preferred) { return null },
|
|
745
|
+
|
|
746
|
+
// 实测 flag 集(Claude Code 2.0.76 `claude --help`)。
|
|
747
|
+
buildArgv(exePrefix, args, opts) {
|
|
748
|
+
const o = opts || {}
|
|
749
|
+
const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
|
|
750
|
+
argv.push('-p', String(args.prompt))
|
|
751
|
+
// stream-json 才能实时折叠步骤(foldStreamEvent 吃 system/assistant/result 事件);
|
|
752
|
+
// stream=false 时退单行 json(更省字节,仍可解析)。
|
|
753
|
+
const stream = args.stream !== false
|
|
754
|
+
if (stream) argv.push('--output-format', 'stream-json', '--verbose')
|
|
755
|
+
else argv.push('--output-format', 'json')
|
|
756
|
+
if (stream && args.includePartialMessages) argv.push('--include-partial-messages')
|
|
757
|
+
const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
|
|
758
|
+
|
|
759
|
+
// 权限:auto → 跟随 DSH plan 状态;plan 用 CLI 的 plan 模式,其余走 bypass
|
|
760
|
+
//(-p 没有 TTY,任何交互批准都会挂到超时 —— 与 agy/codebuddy/opencode 同语义)。
|
|
761
|
+
let mode = String(args.mode || descriptor.defaultMode || 'auto')
|
|
762
|
+
if (MODE_ENUM.indexOf(mode) < 0) mode = 'auto'
|
|
763
|
+
if (mode === 'auto') mode = o.planActive ? 'plan' : 'bypass'
|
|
764
|
+
if (mode === 'plan') argv.push('--permission-mode', 'plan')
|
|
765
|
+
else if (mode === 'accept-edits') argv.push('--permission-mode', 'acceptEdits')
|
|
766
|
+
else { mode = 'bypass'; argv.push('--dangerously-skip-permissions') }
|
|
767
|
+
|
|
768
|
+
// 模型优先级:调用参数 > 设置面板 defaultModel > kit 注入的 defaultModel > 不传(claude 自决)。
|
|
769
|
+
const model = args.model || currentSettings().defaultModel || o.defaultModel || ''
|
|
770
|
+
if (model) argv.push('--model', String(model))
|
|
771
|
+
|
|
772
|
+
const budgetRaw = args.maxBudgetUsd != null ? args.maxBudgetUsd : currentSettings().maxBudgetUsd
|
|
773
|
+
const budget = budgetRaw == null ? NaN : Number(budgetRaw)
|
|
774
|
+
if (Number.isFinite(budget) && budget > 0) argv.push('--max-budget-usd', String(budget))
|
|
775
|
+
|
|
776
|
+
if (args.systemPrompt) argv.push('--system-prompt', String(args.systemPrompt))
|
|
777
|
+
if (args.appendSystemPrompt) argv.push('--append-system-prompt', String(args.appendSystemPrompt))
|
|
778
|
+
if (Array.isArray(args.allowedTools) && args.allowedTools.length) argv.push('--allowedTools', args.allowedTools.map(String).join(','))
|
|
779
|
+
if (Array.isArray(args.disallowedTools) && args.disallowedTools.length) argv.push('--disallowedTools', args.disallowedTools.map(String).join(','))
|
|
780
|
+
if (Array.isArray(args.addDirs)) for (const d of args.addDirs) { if (d) argv.push('--add-dir', String(d)) }
|
|
781
|
+
if (args.noSessionPersistence) argv.push('--no-session-persistence')
|
|
782
|
+
|
|
783
|
+
if (args.sessionId) argv.push('--resume', String(args.sessionId))
|
|
784
|
+
else if (args.continueLatest) argv.push('--continue')
|
|
785
|
+
if (args.forkSession && (args.sessionId || args.continueLatest)) argv.push('--fork-session')
|
|
786
|
+
return { argv, mode, timeoutSec }
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
// stdout 解析:json = 单行 result;stream-json = 多行事件,取**最后**一个 result。
|
|
790
|
+
// 无 result 事件 → null(buildResult 据此判 PARSE_ERROR,绝不假造成功)。
|
|
791
|
+
parseJson(stdoutText) {
|
|
792
|
+
const trimmed = String(stdoutText || '').trim()
|
|
793
|
+
if (!trimmed) return null
|
|
794
|
+
let last = null
|
|
795
|
+
for (const raw of trimmed.split(/\r?\n/)) {
|
|
796
|
+
const t = raw.trim()
|
|
797
|
+
if (!t.startsWith('{')) continue
|
|
798
|
+
let obj
|
|
799
|
+
try { obj = JSON.parse(t) } catch (e) { continue }
|
|
800
|
+
if (obj && obj.type === 'result') last = obj
|
|
801
|
+
}
|
|
802
|
+
if (last) return last
|
|
803
|
+
try {
|
|
804
|
+
const obj = JSON.parse(trimmed)
|
|
805
|
+
if (obj && obj.type === 'result') return obj
|
|
806
|
+
} catch (e) { }
|
|
807
|
+
return null
|
|
808
|
+
},
|
|
809
|
+
|
|
810
|
+
// 统一结果对象。⚠️ 成功判据 = is_error === false(且 subtype 非错误态)**并且** exit 0;
|
|
811
|
+
// 只看 exit code 会把「无效模型」误判成成功(实测该场景 exit 1 + subtype "success"),
|
|
812
|
+
// 反过来也存在 exit 0 + is_error true 的形态,故两者必须同时成立。
|
|
813
|
+
buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
|
|
814
|
+
const exitCode = outcome ? outcome.exitCode : null
|
|
815
|
+
const stderr = stderrText ? String(stderrText).slice(-2000) : ''
|
|
816
|
+
if (parsed && typeof parsed === 'object') {
|
|
817
|
+
const isError = parsed.is_error === true
|
|
818
|
+
const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : ''
|
|
819
|
+
const response = typeof parsed.result === 'string' ? parsed.result : ''
|
|
820
|
+
const errorText = isError ? (response || (typeof parsed.error === 'string' ? parsed.error : '')) : ''
|
|
821
|
+
let status
|
|
822
|
+
if (exitCode === 130) status = 'ABORTED'
|
|
823
|
+
else if (isError) status = subtype === 'error_max_turns' ? 'MAX_TURNS' : 'EXEC_ERROR'
|
|
824
|
+
else if (exitCode !== 0) status = 'CLI_ERROR'
|
|
825
|
+
else status = 'SUCCESS'
|
|
826
|
+
const ok = !isError && exitCode === 0 && status === 'SUCCESS'
|
|
827
|
+
const denials = Array.isArray(parsed.permission_denials) ? parsed.permission_denials.length : 0
|
|
828
|
+
return {
|
|
829
|
+
ok,
|
|
830
|
+
status,
|
|
831
|
+
response,
|
|
832
|
+
sessionId: typeof parsed.session_id === 'string' ? parsed.session_id : null,
|
|
833
|
+
durationSeconds: typeof parsed.duration_ms === 'number' ? Math.round(parsed.duration_ms / 100) / 10 : null,
|
|
834
|
+
numTurns: typeof parsed.num_turns === 'number' ? parsed.num_turns : null,
|
|
835
|
+
totalTokens: sumTokens(parsed.usage),
|
|
836
|
+
exitCode, mode, backend: backend || null,
|
|
837
|
+
// is_error 的正文也必须进 stderr:限流/凭据判定与失败指引都读它。
|
|
838
|
+
stderr: (stderr ? stderr + ' ' : '') + errorText,
|
|
839
|
+
subtype: subtype || null,
|
|
840
|
+
modelUsed: pickModelUsed(parsed.modelUsage),
|
|
841
|
+
costUsd: typeof parsed.total_cost_usd === 'number' ? parsed.total_cost_usd : null,
|
|
842
|
+
permissionDenials: denials
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const text = String(stdoutText || '').trim()
|
|
846
|
+
const status = exitCode === 0 ? 'PARSE_ERROR' : 'CLI_ERROR'
|
|
847
|
+
return {
|
|
848
|
+
ok: false,
|
|
849
|
+
status,
|
|
850
|
+
response: '',
|
|
851
|
+
sessionId: null,
|
|
852
|
+
durationSeconds: null, numTurns: null, totalTokens: null,
|
|
853
|
+
exitCode, mode, backend: backend || null,
|
|
854
|
+
stderr: (stderr ? stderr + ' ' : '') + (exitCode === 0 ? 'no result event in claude output' : ''),
|
|
855
|
+
subtype: null, modelUsed: null, costUsd: null, permissionDenials: 0,
|
|
856
|
+
rawStdout: text.slice(-2000)
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
|
|
860
|
+
// 实时步骤折叠(stream-json → project.current/trail)。
|
|
861
|
+
// 事件形态(实测):system/subtype=init 带 model+permissionMode;assistant.message.content
|
|
862
|
+
// 是 Anthropic 内容块数组(thinking / text / tool_use);user.message.content 带 tool_result。
|
|
863
|
+
foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
|
|
864
|
+
if (!ev || typeof ev !== 'object') return
|
|
865
|
+
const type = ev.type
|
|
866
|
+
if (!type) return
|
|
867
|
+
const cap = maxTrail || 12
|
|
868
|
+
const capLen = maxArgLen || 120
|
|
869
|
+
const push = (entry) => { p.trail.push(entry); while (p.trail.length > cap) p.trail.shift() }
|
|
870
|
+
const blocksOf = (m) => (m && typeof m === 'object' && Array.isArray(m.content) ? m.content : [])
|
|
871
|
+
p.updatedAt = Date.now()
|
|
872
|
+
if (type === 'system') {
|
|
873
|
+
if (ev.subtype === 'init') {
|
|
874
|
+
p.current = { tool: 'session', args: { model: ev.model || '', permissionMode: ev.permissionMode || '' }, stepIndex: 0, since: nowIso() }
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
if (type === 'assistant') {
|
|
880
|
+
for (const b of blocksOf(ev.message)) {
|
|
881
|
+
if (!b || typeof b !== 'object') continue
|
|
882
|
+
if (b.type === 'tool_use') {
|
|
883
|
+
const tool = b.name || 'tool'
|
|
884
|
+
const args = summarizeArgs(b.input)
|
|
885
|
+
const idx = p.trail.length
|
|
886
|
+
push({ stepIndex: idx, state: 'ACTIVE', tool, args: args || null, at: nowIso() })
|
|
887
|
+
p.current = { tool, args: args || null, stepIndex: idx, since: nowIso() }
|
|
888
|
+
} else if (b.type === 'text' && typeof b.text === 'string' && b.text) {
|
|
889
|
+
p.current = { tool: 'agent_response', args: { text_delta: b.text.slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
|
|
890
|
+
} else if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking) {
|
|
891
|
+
p.current = { tool: 'reasoning', args: { text_delta: b.thinking.slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
if (type === 'user') {
|
|
897
|
+
for (const b of blocksOf(ev.message)) {
|
|
898
|
+
if (!b || typeof b !== 'object' || b.type !== 'tool_result') continue
|
|
899
|
+
// 把最近一个仍在 ACTIVE 的步骤收尾(tool_result 不带 tool name,按时间序配对)。
|
|
900
|
+
for (let i = p.trail.length - 1; i >= 0; i--) {
|
|
901
|
+
if (p.trail[i].state === 'ACTIVE') { p.trail[i].state = b.is_error === true ? 'ERROR' : 'DONE'; break }
|
|
902
|
+
}
|
|
903
|
+
p.current = null
|
|
904
|
+
}
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
if (type === 'result') { p.current = null }
|
|
908
|
+
},
|
|
909
|
+
|
|
910
|
+
// 瞬时错误静默重试一次:只认网络/过载类,且明确排除模型名/凭据类(那些重试无用)。
|
|
911
|
+
isTransientCliError(res) {
|
|
912
|
+
if (!res || res.ok) return false
|
|
913
|
+
if (isLimited(res)) return false
|
|
914
|
+
const hay = String(res.stderr || '') + ' ' + String(res.response || '')
|
|
915
|
+
if (/supported API model names|invalid_request_error|model .* not|authentication|invalid api key|401|403/i.test(hay)) return false
|
|
916
|
+
return /overloaded|529|503|ECONNRESET|socket hang up|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|terminated|connection (reset|closed)/i.test(hay)
|
|
917
|
+
},
|
|
918
|
+
|
|
919
|
+
failureHint(res) {
|
|
920
|
+
if (!res || res.ok) return ''
|
|
921
|
+
const st = String(res.status || '')
|
|
922
|
+
if (st === 'CLAUDE_UNAVAILABLE') return '[诊断] 未找到 claude 可执行文件:请安装 Claude Code(或 npm i -g @anthropic-ai/claude-code),或在 CLI Bridge 设置面板填写 claude 可执行文件路径(也可设 CLAUDE_BIN)。'
|
|
923
|
+
if (st === 'PARSE_ERROR') return '[诊断] claude 没有输出 result 事件(进程可能被中断/输出被截断)。若你手动改了 output-format,请改回;否则重试一次,持续失败请检查 CLI 版本(--output-format json 是 2.x 的行为)。'
|
|
924
|
+
const hay = String(res.stderr || '') + ' ' + String(res.response || '')
|
|
925
|
+
const supported = parseSupportedModels(hay)
|
|
926
|
+
if (supported) return '[诊断] 模型名被端点拒绝。该端点当前支持的模型:' + supported.join(', ') + ' —— 用 claude_models 取权威清单,然后在调用参数或设置面板里改 model;字符串 "[1M]" 之类的后缀属于配置里的别名写法,不能直接当 --model 传入。'
|
|
927
|
+
if (/supported API model names|invalid_request_error/i.test(hay)) return '[诊断] 模型名被端点拒绝(400 invalid_request_error):先用 claude_models 确认服务端支持的模型,再传 model。'
|
|
928
|
+
if (res.permissionDenials) return '[诊断] 有 ' + res.permissionDenials + ' 次工具调用被权限拒绝:-p 模式没有交互确认,请用 mode=bypass(默认 auto 在非 plan 模式下就是 bypass),或在 allowedTools 里放行所需工具。'
|
|
929
|
+
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)) {
|
|
930
|
+
return '[诊断] claude 凭据/端点问题:请让用户检查 ~/.claude/settings.json 的 env 块(ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN)或重新登录 claude;这不是限流。'
|
|
931
|
+
}
|
|
932
|
+
if (/rate.?limit|\b429\b|quota|insufficient|credit|balance|额度/i.test(hay)) {
|
|
933
|
+
return '[诊断] 端点报告限流/额度不足:不要循环重试;换 model(用 claude_models 挑一个可用的)或改用 DSH 原生工具完成本任务。'
|
|
934
|
+
}
|
|
935
|
+
return ''
|
|
936
|
+
},
|
|
937
|
+
|
|
938
|
+
isSuccessStatus(st) { return st === 'SUCCESS' },
|
|
939
|
+
|
|
940
|
+
tools: {
|
|
941
|
+
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 },
|
|
942
|
+
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 },
|
|
943
|
+
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 }
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
// claude_models:三形态都注册(探测只需 node child_process;拿不到 spawn 能力时
|
|
947
|
+
// logic 明确返回 ok:false,而不是静默缺席或伪造列表)。
|
|
948
|
+
extraTools: [{
|
|
949
|
+
name: 'claude_models',
|
|
950
|
+
description: MODELS_DESCRIPTION,
|
|
951
|
+
inputSchema: MODELS_PARAMS,
|
|
952
|
+
render: renderModels,
|
|
953
|
+
async logic(args) {
|
|
954
|
+
return await descriptor.listModels(args || {})
|
|
955
|
+
}
|
|
956
|
+
}],
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* 列出服务端权威可用模型(三形态共用)。
|
|
960
|
+
* 铁律:只认账号/端点探测;探测失败 = ok:false + 可操作错误 + 配置线索(非权威)。
|
|
961
|
+
*/
|
|
962
|
+
async listModels(args) {
|
|
963
|
+
const a = args || {}
|
|
964
|
+
return await listAccountModels(env, {
|
|
965
|
+
binPath: currentSettings().binPath,
|
|
966
|
+
timeoutMs: clampInt(a.timeoutMs, 30000, 5000, 90000),
|
|
967
|
+
refresh: !!a.refresh
|
|
968
|
+
})
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
// ── 设置分区(契约 §3)─────────────────────────────────────────────────────
|
|
972
|
+
settings: {
|
|
973
|
+
keys: ['binPath', 'defaultModel', 'maxBudgetUsd'],
|
|
974
|
+
defaults: () => ({ binPath: '', defaultModel: '', maxBudgetUsd: '' }),
|
|
975
|
+
normalize: (raw) => {
|
|
976
|
+
const r = raw && typeof raw === 'object' ? raw : {}
|
|
977
|
+
const out = { binPath: '', defaultModel: '', maxBudgetUsd: '' }
|
|
978
|
+
for (const k of ['binPath', 'defaultModel', 'maxBudgetUsd']) {
|
|
979
|
+
if (typeof r[k] === 'string') out[k] = String(r[k]).trim()
|
|
980
|
+
}
|
|
981
|
+
return out
|
|
982
|
+
},
|
|
983
|
+
/**
|
|
984
|
+
* 设置面板元数据(契约 §3.2):**只读缓存、绝不探测**。
|
|
985
|
+
* 面板是同步回调,而探测要起子进程打网络(3-5s)——放在这里会把首屏拖慢,且探不到时
|
|
986
|
+
* 用户无法区分「没模型」与「没拉到」。故探测改为用户点「拉取可用模型」→
|
|
987
|
+
* settings.refreshModels() 显式触发;成功后写回同一份缓存,这里下次渲染直接命中。
|
|
988
|
+
*/
|
|
989
|
+
meta: (env2) => {
|
|
990
|
+
const e = env2 && typeof env2 === 'object' ? env2 : {}
|
|
991
|
+
const s = currentSettings()
|
|
992
|
+
const cached = readCachedModels(e, { binPath: s.binPath })
|
|
993
|
+
const options = (cached ? cached.ids : []).map((id) => ({ value: id, label: id }))
|
|
994
|
+
const exeCandidates = claudeExeCandidates(e, '')
|
|
995
|
+
return {
|
|
996
|
+
titleSuffix: 'Claude Code',
|
|
997
|
+
fields: [
|
|
998
|
+
{
|
|
999
|
+
key: 'binPath', label: 'claude 可执行文件路径', type: 'select', allowCustom: true,
|
|
1000
|
+
options: exeCandidates.map((p) => ({ value: p, label: p })),
|
|
1001
|
+
placeholder: '留空 = 自动探测本地安装 / PATH',
|
|
1002
|
+
note: '检测到 ' + exeCandidates.length + ' 个候选;npm 全局安装给的是 .cmd(模型探测与 MCP 形态经 shell 启动),原生安装给 .exe —— preset 形态建议指向原生 .exe(.cmd 能否执行取决于宿主 subprocess)。也可手动粘贴任意路径(.mjs/.js 经 node 启动)。'
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
|
|
1006
|
+
options,
|
|
1007
|
+
// 面板据此渲染「手动拉取」按钮(契约 §3.2);meta() 自身永不探测。
|
|
1008
|
+
refresh: { label: '拉取可用模型' },
|
|
1009
|
+
placeholder: '留空 = 由 claude 自身配置(~/.claude/settings.json)决定',
|
|
1010
|
+
note: options.length
|
|
1011
|
+
? '共 ' + options.length + ' 个服务端权威可用模型(' + ageText(cached.ageMs) + ',源:' + cached.mode + ')。点「拉取可用模型」重新获取;调用时传 model 可覆盖本项。'
|
|
1012
|
+
: '还没拉取过(或上次没拉到):点「拉取可用模型」向端点取权威清单;空表示尚未拉取 / 该面未登录。可直接手动输入模型名 —— 配置里的 ANTHROPIC_*_MODEL 只是别名映射,不作为候选展示。'
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
key: 'maxBudgetUsd', label: '单次最大花费(美元)', type: 'text',
|
|
1016
|
+
placeholder: '留空 = 不限制',
|
|
1017
|
+
note: '非空时自动附加 --max-budget-usd;调用参数 maxBudgetUsd 可覆盖。'
|
|
1018
|
+
}
|
|
1019
|
+
]
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* 手动拉取(契约 §3.2):面板「拉取可用模型」按钮 → keystone
|
|
1025
|
+
* POST /cli-bridge/settings/models → 这里。这是本 provider 唯一的面板探测入口。
|
|
1026
|
+
*
|
|
1027
|
+
* 复用 listAccountModels(异步 probeSupportedModels + 配置诊断),不写第二套探测;
|
|
1028
|
+
* 其成功路径已把权威清单写回 modelsCache,随后 meta() 直接命中(无需再探测)。
|
|
1029
|
+
* 铁律:失败 = ok:false + 可操作错误 + 空 ids,绝不返回 {ok:true, ids:[]};
|
|
1030
|
+
* 也绝不把 settings.json 里的别名映射冒充成服务端可用全集。
|
|
1031
|
+
*/
|
|
1032
|
+
async refreshModels(settings, env2) {
|
|
1033
|
+
const e = (env2 && typeof env2 === 'object') ? env2 : env
|
|
1034
|
+
const s = descriptor.settings.normalize(settings)
|
|
1035
|
+
const got = await listAccountModels(e, {
|
|
1036
|
+
binPath: s.binPath,
|
|
1037
|
+
timeoutMs: REFRESH_TIMEOUT_MS,
|
|
1038
|
+
refresh: true
|
|
1039
|
+
})
|
|
1040
|
+
if (!got.ok) {
|
|
1041
|
+
return { ok: false, ids: [], source: 'account-live', error: String(got.error || 'probe failed') + ' ' + REFRESH_HINT }
|
|
1042
|
+
}
|
|
1043
|
+
const ids = (got.models || []).map((m) => m.id).filter(Boolean)
|
|
1044
|
+
if (!ids.length) {
|
|
1045
|
+
// 走到这里说明探测自称成功却没给出任何 id —— 那是 bug,不是「无模型」,
|
|
1046
|
+
// 必须显式失败,绝不能让面板显示一个空清单当成功。
|
|
1047
|
+
return { ok: false, ids: [], source: 'account-live', error: '探测声称成功但未返回任何模型 id(内部不一致)。' + REFRESH_HINT }
|
|
1048
|
+
}
|
|
1049
|
+
// labels 只给「配置里还映射着某个 ANTHROPIC_*_MODEL」的项加注(纯展示,value 仍是 id)。
|
|
1050
|
+
const labels = {}
|
|
1051
|
+
for (const m of got.models) if (m.configuredAs) labels[m.id] = m.id + ' [' + m.configuredAs + ']'
|
|
1052
|
+
return { ok: true, ids, labels, source: 'account-live' }
|
|
1053
|
+
},
|
|
1054
|
+
diagnose: (settings, env2) => {
|
|
1055
|
+
const s = settings && typeof settings === 'object' ? settings : {}
|
|
1056
|
+
const e = env2 && typeof env2 === 'object' ? env2 : {}
|
|
1057
|
+
const bits = []
|
|
1058
|
+
if (s.binPath) bits.push('bin=设置:' + s.binPath)
|
|
1059
|
+
else if (claudePathHint(e)) bits.push('bin=' + claudePathHint(e))
|
|
1060
|
+
else bits.push('bin=PATH:claude')
|
|
1061
|
+
bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(claude 自决)')
|
|
1062
|
+
if (s.maxBudgetUsd) bits.push('maxBudget=$' + s.maxBudgetUsd)
|
|
1063
|
+
const cfg = readConfiguredModels(e)
|
|
1064
|
+
if (cfg.ok && cfg.models.length) bits.push('settings.json env: ' + cfg.models.length + ' 个别名映射(非权威清单)')
|
|
1065
|
+
if (!cfg.ok && cfg.error) bits.push('settings.json: ' + cfg.error.slice(0, 120))
|
|
1066
|
+
return bits.join(' · ')
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
|
|
1070
|
+
// MCP 面版本由宿主注入(mcp/version.mjs 单源派生),描述符不复制 version 字面量。
|
|
1071
|
+
mcp: (deps.mcp && typeof deps.mcp === 'object') ? Object.assign({}, deps.mcp) : {}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ── 三形态共用的工具逻辑(签名固定:宿主接线见 preset/dynamic/mcp 三处)──────────
|
|
1075
|
+
descriptor.runToolLogic = async function (args, exec) {
|
|
1076
|
+
const a = args || {}
|
|
1077
|
+
const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
|
|
1078
|
+
return this.runner.coreExecute(normalized, exec)
|
|
1079
|
+
}
|
|
1080
|
+
descriptor.continueToolLogic = async function (args, exec) {
|
|
1081
|
+
const a = args || {}
|
|
1082
|
+
const mapped = {
|
|
1083
|
+
prompt: a.prompt, mode: a.mode, model: a.model, maxBudgetUsd: a.maxBudgetUsd,
|
|
1084
|
+
cwd: a.cwd, forkSession: a.forkSession, timeoutSec: a.timeoutSec,
|
|
1085
|
+
background: a.background === undefined ? true : a.background
|
|
1086
|
+
}
|
|
1087
|
+
const sid = a.sessionId || null
|
|
1088
|
+
if (sid) mapped.sessionId = sid
|
|
1089
|
+
else if (a.latest) mapped.continueLatest = true
|
|
1090
|
+
return this.runner.coreExecute(mapped, exec)
|
|
1091
|
+
}
|
|
1092
|
+
descriptor.statusToolLogic = function (args) {
|
|
1093
|
+
const a = args || {}
|
|
1094
|
+
const snap = this.engine.statusSnapshot()
|
|
1095
|
+
if (a.cwd) {
|
|
1096
|
+
const key = String(a.cwd)
|
|
1097
|
+
snap.projects = snap.projects.filter((p) => p.cwd === key)
|
|
1098
|
+
const g = snap.projects[0]
|
|
1099
|
+
if (g) {
|
|
1100
|
+
Object.assign(snap, {
|
|
1101
|
+
state: g.state, running: g.running, current: g.current, trail: g.trail,
|
|
1102
|
+
lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
|
|
1103
|
+
lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
|
|
1104
|
+
})
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return snap
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
descriptor.attach = function (hostOpts) {
|
|
1111
|
+
host = hostOpts || {}
|
|
1112
|
+
try {
|
|
1113
|
+
if (typeof host.getSettings === 'function') {
|
|
1114
|
+
const s = host.getSettings('claude')
|
|
1115
|
+
if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
|
|
1116
|
+
}
|
|
1117
|
+
} catch (e) { }
|
|
1118
|
+
this.engine = createStatusEngine({ provider: this, publish: host.publish })
|
|
1119
|
+
const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
|
|
1120
|
+
this.runner = runner
|
|
1121
|
+
return runner
|
|
1122
|
+
}
|
|
1123
|
+
return descriptor
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ── inlined from providers/claude-exe.mjs ──
|
|
1127
|
+
// providers/claude-exe.mjs — claude 可执行文件探测/包装(无宿主依赖的纯逻辑片)。
|
|
1128
|
+
//
|
|
1129
|
+
// 从 providers/claude.mjs 拆出,让两个文件都留在 600 行红线内(规则 §26)。
|
|
1130
|
+
// scripts/build.mjs 会把 providers/claude-*.mjs 按文件名字典序内联进 dynamic 沙箱的
|
|
1131
|
+
// 同一个作用域,所以沙箱里这些函数天然可见;ESM 形态走本文件的 import。
|
|
1132
|
+
// ⚠️ 注释里绝不写出口令标记字面量(build 全局替换会把注释劈开 → 生成物语法崩)。
|
|
1133
|
+
//
|
|
1134
|
+
// 本机实测事实(Claude Code 2.0.76,Windows):
|
|
1135
|
+
// • PATH 上的 `claude` 是原生 exe(.cherrystudio\bin\claude.exe);官方原生 installer
|
|
1136
|
+
// 给 claude.exe,npm 全局安装给 claude.cmd + node_modules/@anthropic-ai/claude-code/cli.js。
|
|
1137
|
+
// • Windows 上 Node 的 spawn 不能直接执行 .cmd/.bat(CreateProcess 只补 .exe),
|
|
1138
|
+
// 必须 shell:true;.js/.mjs 目标必须经 node 包装。这两条决定下面的 splitExeSpec。
|
|
1139
|
+
|
|
1140
|
+
// 沙箱可能没有 fs/path:一律经 getBuiltinModule + typeof 守卫读取,读不到就退保守值。
|
|
1141
|
+
function claudeBuiltin(name) {
|
|
1142
|
+
try {
|
|
1143
|
+
const proc = globalThis.process
|
|
1144
|
+
return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
|
|
1145
|
+
} catch (e) { return null }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// PATH 兜底名(Windows 由 CreateProcess 自动补 .exe)。
|
|
1149
|
+
const CLAUDE_EXE_BASENAME = 'claude'
|
|
1150
|
+
|
|
1151
|
+
// 用户可以显式指定可执行文件的既有环境变量(按优先级)。
|
|
1152
|
+
const CLAUDE_BIN_ENV_VARS = ['CLAUDE_BIN', 'CLAUDE_CLI', 'CLAUDE_CODE_BIN']
|
|
1153
|
+
|
|
1154
|
+
// 已知的本地安装位置(native installer / 官方 local 目录);存在才收,不做版本猜测。
|
|
1155
|
+
const CLAUDE_LOCAL_REL = [
|
|
1156
|
+
['.claude', 'local', 'claude.exe'],
|
|
1157
|
+
['.claude', 'local', 'claude'],
|
|
1158
|
+
['.local', 'bin', 'claude.exe'],
|
|
1159
|
+
['.local', 'bin', 'claude']
|
|
1160
|
+
]
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* 把「一个可执行文件路径」拆成 spawn 需要的形态。
|
|
1164
|
+
* .js/.cjs/.mjs → 经当前 node 启动(测试 fixture 与 npm 安装的 cli.js 都是这形态);
|
|
1165
|
+
* .cmd/.bat → 只能 shell 执行(Windows 限制),调用方必须带 shell:true;
|
|
1166
|
+
* 其余 → 直接执行(原生 exe / PATH 名字)。
|
|
1167
|
+
* @param {string} exe
|
|
1168
|
+
* @returns {{cmd: string, prefixArgs: string[], shell: boolean, kind: string}}
|
|
1169
|
+
*/
|
|
1170
|
+
function splitExeSpec(exe) {
|
|
1171
|
+
const s = String(exe || '')
|
|
1172
|
+
if (/\.(mjs|cjs|js)$/i.test(s)) {
|
|
1173
|
+
const proc = globalThis.process
|
|
1174
|
+
const node = (proc && proc.execPath) ? proc.execPath : 'node'
|
|
1175
|
+
return { cmd: node, prefixArgs: [s], shell: false, kind: 'node' }
|
|
1176
|
+
}
|
|
1177
|
+
if (/\.(cmd|bat)$/i.test(s)) return { cmd: s, prefixArgs: [], shell: true, kind: 'shell' }
|
|
1178
|
+
return { cmd: s, prefixArgs: [], shell: false, kind: 'binary' }
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** shell:true 下的命令行拼装(只用于参数集合固定的探测调用,不拼装用户 prompt)。 */
|
|
1182
|
+
function quoteShellArg(a) {
|
|
1183
|
+
const s = String(a)
|
|
1184
|
+
return /[\s"&|<>^()]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* 候选可执行文件列表(顺序 = 优先级):设置面板 binPath > env 变量 > 已知本地安装 > PATH 名字。
|
|
1189
|
+
* 纯函数、零副作用;无 fs 能力时只返回设置/env/PATH 名,绝不抛。
|
|
1190
|
+
* @param {object} env 环境变量
|
|
1191
|
+
* @param {string} [binPath] 设置面板里显式填写的路径
|
|
1192
|
+
* @returns {string[]}
|
|
1193
|
+
*/
|
|
1194
|
+
function claudeExeCandidates(env, binPath) {
|
|
1195
|
+
const e = env || {}
|
|
1196
|
+
const out = []
|
|
1197
|
+
const push = (p) => { const v = String(p || '').trim(); if (v && out.indexOf(v) < 0) out.push(v) }
|
|
1198
|
+
if (binPath) push(binPath)
|
|
1199
|
+
for (const k of CLAUDE_BIN_ENV_VARS) if (e[k]) push(e[k])
|
|
1200
|
+
const fs = claudeBuiltin('node:fs')
|
|
1201
|
+
const path = claudeBuiltin('node:path')
|
|
1202
|
+
const home = e.USERPROFILE || e.HOME || ''
|
|
1203
|
+
if (fs && path && typeof fs.existsSync === 'function' && typeof path.join === 'function' && home) {
|
|
1204
|
+
for (const rel of CLAUDE_LOCAL_REL) {
|
|
1205
|
+
const p = path.join.apply(path, [home].concat(rel))
|
|
1206
|
+
try { if (fs.existsSync(p)) push(p) } catch (err) { }
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
push(CLAUDE_EXE_BASENAME)
|
|
1210
|
+
return out
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* 探测时优先尝试的候选(去掉 PATH 兜底名——它由 spawn 的 PATH 搜索处理,且不是绝对路径)。
|
|
1215
|
+
* @returns {string[]}
|
|
1216
|
+
*/
|
|
1217
|
+
function claudeProbeCandidates(env, binPath) {
|
|
1218
|
+
return claudeExeCandidates(env, binPath).filter((c) => c !== CLAUDE_EXE_BASENAME)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* buildArgv 需要的前缀数组:设置 binPath > env > 本地安装 > PATH 名字。
|
|
1223
|
+
* .js/.mjs 目标返回 [node, file]。宿主形态(preset/dynamic)也会用它做 existsSync 校验。
|
|
1224
|
+
* @returns {string[]}
|
|
1225
|
+
*/
|
|
1226
|
+
function resolveExePrefix(env, binPath) {
|
|
1227
|
+
const cands = claudeExeCandidates(env, binPath)
|
|
1228
|
+
const spec = splitExeSpec(cands[0] || CLAUDE_EXE_BASENAME)
|
|
1229
|
+
return [spec.cmd].concat(spec.prefixArgs)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** PATH 里的 claude 是否真的可执行(仅诊断用:返回路径或空串)。 */
|
|
1233
|
+
function claudePathHint(env) {
|
|
1234
|
+
const e = env || {}
|
|
1235
|
+
for (const k of CLAUDE_BIN_ENV_VARS) if (e[k]) return String(e[k])
|
|
1236
|
+
const cands = claudeExeCandidates(e, '')
|
|
1237
|
+
return cands.length ? String(cands[0]) : ''
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ── inlined from providers/claude-models.mjs ──
|
|
1241
|
+
// providers/claude-models.mjs — claude 模型清单探测(账号实时权威源,纯逻辑 + 最小 spawn)。
|
|
1242
|
+
//
|
|
1243
|
+
// 从 providers/claude.mjs 拆出(规则 §26:单文件 600 行红线)。build 会把
|
|
1244
|
+
// providers/claude-*.mjs 按字典序内联进 dynamic 沙箱同一作用域;ESM 形态走 import。
|
|
1245
|
+
// ⚠️ 注释里绝不写出口令标记字面量。
|
|
1246
|
+
//
|
|
1247
|
+
// 为什么必须「探测」而不是读配置(本机 Claude Code 2.0.76 + 第三方 Anthropic 兼容端点实测):
|
|
1248
|
+
// 传一个故意无效的模型名 → 服务端返回 400,CLI 把报文塞进 result 事件:
|
|
1249
|
+
// {"type":"result","subtype":"success","is_error":true,
|
|
1250
|
+
// "result":"API Error: 400 {\"error\":{\"message\":\"The supported API model names are
|
|
1251
|
+
// deepseek-flash, deepseek-v4-pro, but you passed __clb_probe_invalid__.\"}}"}
|
|
1252
|
+
// → 这条 "The supported API model names are X, Y" 就是该端点/该 key 的权威可用清单。
|
|
1253
|
+
// ~/.claude/settings.json 的 env.ANTHROPIC_DEFAULT_*_MODEL 只是「别名映射」,
|
|
1254
|
+
// 不是服务端可用全集 —— 红线:绝不把它冒充成可用清单,也绝不静默返回空数组。
|
|
1255
|
+
//
|
|
1256
|
+
// 缓存与「谁探测」(契约 §3.2 手动拉取):
|
|
1257
|
+
// • settings.meta() 是同步回调,且**不再自动探测** —— 它只调 readCachedModels() 读这里的
|
|
1258
|
+
// 缓存(纯读、不 spawn、不联网);没命中就留空,由 note 提示用户点「拉取可用模型」。
|
|
1259
|
+
// • 探测只有一条路:settings.refreshModels() → 异步 probeSupportedModels()(用户显式触发)。
|
|
1260
|
+
// 成功后经 cachedModels() 写回同一个缓存,下次 meta() 直接命中 —— 面板首屏不再被拖慢。
|
|
1261
|
+
// • 5 分钟 TTL 只约束**工具调用**(claude_models 的重复调用);面板展示的是「上次拉取到的
|
|
1262
|
+
// 权威清单」,过期也照旧展示,但 note 会标注拉取时间(宁可显式标旧,也不让下拉莫名清空)。
|
|
1263
|
+
// import { claudeExeCandidates, claudeProbeCandidates, splitExeSpec, quoteShellArg } from './claude-exe.mjs'
|
|
1264
|
+
// 故意无效的探测模型名(服务端必然拒绝;保持带前缀,便于日志辨认来源)。
|
|
1265
|
+
const PROBE_MODEL = '__clb_probe_invalid__'
|
|
1266
|
+
const MODELS_TTL_MS = 5 * 60 * 1000
|
|
1267
|
+
|
|
1268
|
+
// 探测结果缓存:key(后端/端点标识) → { at, value }
|
|
1269
|
+
const modelsCache = new Map()
|
|
1270
|
+
|
|
1271
|
+
/** 清空探测缓存(测试与「模型变更后刷新」用)。 */
|
|
1272
|
+
function resetModelsCache() { modelsCache.clear() }
|
|
1273
|
+
|
|
1274
|
+
// 缓存键 = 候选可执行文件集合(同一台机器可能切换 CLAUDE_BIN / 面板路径,键必须跟着变,
|
|
1275
|
+
// 否则会把「上一个 exe 的探测结果」错配给新 exe)。
|
|
1276
|
+
function candidateKey(env, binPath) {
|
|
1277
|
+
const list = claudeProbeCandidates(env, binPath)
|
|
1278
|
+
return list.length ? list.join('|') : 'claude'
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/** 缓存的键(面板读缓存与工具探测必须用同一把键,否则写进去读不出来)。 */
|
|
1282
|
+
function modelsCacheKey(env, binPath) {
|
|
1283
|
+
return 'claude:' + candidateKey(env, binPath)
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** 缓存里只可能存「成功的权威清单」;失败绝不入缓存(否则会把坏状态固化)。 */
|
|
1287
|
+
function isUsableProbe(v) {
|
|
1288
|
+
return !!(v && v.ok === true && Array.isArray(v.ids) && v.ids.length)
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* 缓存包装(异步工具路径):TTL 内直接返回上次成功结果;失败不写缓存。
|
|
1293
|
+
* @param {string} key
|
|
1294
|
+
* @param {() => Promise<object>|object} load
|
|
1295
|
+
* @param {number} [nowMs]
|
|
1296
|
+
*/
|
|
1297
|
+
function cachedModels(key, load, nowMs) {
|
|
1298
|
+
const at = Number(nowMs) || Date.now()
|
|
1299
|
+
const hit = modelsCache.get(key)
|
|
1300
|
+
if (hit && at - hit.at < MODELS_TTL_MS) return hit.value
|
|
1301
|
+
return Promise.resolve().then(load).then((v) => {
|
|
1302
|
+
if (isUsableProbe(v)) modelsCache.set(key, { at, value: v })
|
|
1303
|
+
return v
|
|
1304
|
+
})
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* 只读缓存(settings.meta 专用):纯读、不 spawn、不联网。
|
|
1309
|
+
* 与 cachedModels 的区别:这里**不受 TTL 限制** —— 面板展示的是「上次真正拉取到的那份
|
|
1310
|
+
* 服务端清单」,附 ageMs 让 note 如实标注新旧;空/过期与否都不该凭空清空用户看到的下拉。
|
|
1311
|
+
*
|
|
1312
|
+
* 为什么要「精确键未命中 → 取最近一次成功结果」:keystone 渲染面板时只把 env 传给
|
|
1313
|
+
* meta(),**拿不到面板里保存的 binPath**(settingsView 里 createProvider 只带 env),
|
|
1314
|
+
* 而缓存键含候选 exe 集合 → 用户一旦填过 binPath,精确键必然错开,下拉会在拉取成功后
|
|
1315
|
+
* 仍然空着(看起来像按钮没起作用)。这不算降级:清单是 claude 自己 settings.json 里
|
|
1316
|
+
* 端点+凭据裁决的,与走哪个候选 exe 无关(exe 只决定探测能否跑起来),且 note 会带上
|
|
1317
|
+
* 「N 分钟前拉取 / 源 account-live」,用户看到的是真实的探测结果与真实的年龄。
|
|
1318
|
+
* @param {object} env
|
|
1319
|
+
* @param {{binPath?: string, nowMs?: number}} [opts]
|
|
1320
|
+
* @returns {{ids: string[], mode: string, exe: (string|null), at: number, ageMs: number}|null}
|
|
1321
|
+
*/
|
|
1322
|
+
function readCachedModels(env, opts) {
|
|
1323
|
+
const o = opts || {}
|
|
1324
|
+
const at = Number(o.nowMs) || Date.now()
|
|
1325
|
+
const exact = modelsCache.get(modelsCacheKey(env, o.binPath))
|
|
1326
|
+
let hit = isUsableProbe(exact && exact.value) ? exact : null
|
|
1327
|
+
if (!hit) {
|
|
1328
|
+
for (const entry of modelsCache.values()) {
|
|
1329
|
+
if (!isUsableProbe(entry.value)) continue
|
|
1330
|
+
if (!hit || entry.at > hit.at) hit = entry
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (!hit) return null
|
|
1334
|
+
return {
|
|
1335
|
+
ids: hit.value.ids.slice(),
|
|
1336
|
+
mode: hit.value.mode || 'account-live',
|
|
1337
|
+
exe: hit.value.exe || null,
|
|
1338
|
+
at: hit.at,
|
|
1339
|
+
ageMs: Math.max(0, at - hit.at)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* 从 400 报文里解析「服务端支持的模型名」。
|
|
1345
|
+
* 主格式(实测):`The supported API model names are A, B, but you passed Z.`
|
|
1346
|
+
* 兼容:句尾直接是句号(无 "but you passed")、JSON 转义形态、大小写差异。
|
|
1347
|
+
* @param {string} text
|
|
1348
|
+
* @returns {string[]|null} 无该锚点返回 null(调用方必须据此报错,不得伪造清单)
|
|
1349
|
+
*/
|
|
1350
|
+
function parseSupportedModels(text) {
|
|
1351
|
+
const s = String(text || '')
|
|
1352
|
+
if (!s) return null
|
|
1353
|
+
const anchorRe = /the supported API model names are\s+/i
|
|
1354
|
+
const m = anchorRe.exec(s)
|
|
1355
|
+
if (!m) return null
|
|
1356
|
+
const tail = s.slice(m.index + m[0].length)
|
|
1357
|
+
// ① 有 "but you passed" → 这是清单的确定右边界(模型名里可以有 '.',不能按句点切)。
|
|
1358
|
+
let body = ''
|
|
1359
|
+
const end = /,?\s*but you passed/i.exec(tail)
|
|
1360
|
+
if (end) body = tail.slice(0, end.index)
|
|
1361
|
+
else {
|
|
1362
|
+
// ② 无右边界锚点:截到引号/换行/行尾,再 chomp 单个句点。
|
|
1363
|
+
const stop = tail.search(/["\r\n]/)
|
|
1364
|
+
body = (stop >= 0 ? tail.slice(0, stop) : tail).replace(/[.\s]+$/, '')
|
|
1365
|
+
}
|
|
1366
|
+
const ids = []
|
|
1367
|
+
for (const raw of body.split(/\s*,\s*|\s+and\s+/i)) {
|
|
1368
|
+
const id = String(raw || '').trim().replace(/^["'`[]+|["'`\]]+$/g, '')
|
|
1369
|
+
if (!id) continue
|
|
1370
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:@/+-]*$/.test(id)) continue
|
|
1371
|
+
if (id === PROBE_MODEL) continue
|
|
1372
|
+
if (ids.indexOf(id) < 0) ids.push(id)
|
|
1373
|
+
}
|
|
1374
|
+
return ids.length ? ids : null
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* 从探测进程的完整输出里挖出模型清单。
|
|
1379
|
+
* CLI 的 stdout 是 result 事件 JSON(is_error:true);它的 result 字段里才是 400 报文,
|
|
1380
|
+
* 且报文自身是 JSON 字符串(转义)。顺序:raw 文本 → 每行 JSON 的 result 字段。
|
|
1381
|
+
* @param {string} stdoutText
|
|
1382
|
+
* @param {string} [stderrText]
|
|
1383
|
+
* @returns {{ids: string[]|null, errorText: string, sawResult: boolean}}
|
|
1384
|
+
*/
|
|
1385
|
+
function extractProbeIds(stdoutText, stderrText) {
|
|
1386
|
+
const out = String(stdoutText || '')
|
|
1387
|
+
const err = String(stderrText || '')
|
|
1388
|
+
const lines = out.split(/\r?\n/).filter(Boolean)
|
|
1389
|
+
let errorText = ''
|
|
1390
|
+
let sawResult = false
|
|
1391
|
+
for (const ln of lines) {
|
|
1392
|
+
const t = ln.trim()
|
|
1393
|
+
if (!t.startsWith('{')) continue
|
|
1394
|
+
let obj
|
|
1395
|
+
try { obj = JSON.parse(t) } catch (e) { continue }
|
|
1396
|
+
if (!obj || obj.type !== 'result') continue
|
|
1397
|
+
sawResult = true
|
|
1398
|
+
if (typeof obj.result === 'string' && obj.result) errorText = obj.result
|
|
1399
|
+
else if (obj.is_error && typeof obj.error === 'string') errorText = obj.error
|
|
1400
|
+
}
|
|
1401
|
+
if (!errorText) errorText = err || out
|
|
1402
|
+
const ids = parseSupportedModels(errorText) || parseSupportedModels(out) || parseSupportedModels(err)
|
|
1403
|
+
return { ids: ids || null, errorText: errorText.slice(0, 800), sawResult }
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** ~/.claude/settings.json 路径(CLAUDE_CONFIG_DIR 可覆盖配置根,本机实测存在)。 */
|
|
1407
|
+
function claudeConfigDir(env) {
|
|
1408
|
+
const e = env || {}
|
|
1409
|
+
if (e.CLAUDE_CONFIG_DIR) return String(e.CLAUDE_CONFIG_DIR)
|
|
1410
|
+
const home = e.USERPROFILE || e.HOME || ''
|
|
1411
|
+
if (!home) return ''
|
|
1412
|
+
const path = (function () { try { const p = globalThis.process; return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule('node:path') : null } catch (x) { return null } })()
|
|
1413
|
+
if (!path || typeof path.join !== 'function') return ''
|
|
1414
|
+
return path.join(home, '.claude')
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* 读 ~/.claude/settings.json 的 env 块里「用户配置的模型名」。
|
|
1419
|
+
* ⚠️ 这是别名映射(ANTHROPIC_DEFAULT_OPUS_MODEL 之类),**不是**服务端可用全集:
|
|
1420
|
+
* 只作为诊断/解释线索返回,绝不塞进 ok:true 的模型清单。
|
|
1421
|
+
* @returns {{ok: boolean, path: string, models: Array<{id: string, key: string}>, error: string}}
|
|
1422
|
+
*/
|
|
1423
|
+
function readConfiguredModels(env) {
|
|
1424
|
+
const dir = claudeConfigDir(env)
|
|
1425
|
+
if (!dir) return { ok: false, path: '', models: [], error: 'cannot resolve home directory (no USERPROFILE/HOME/CLAUDE_CONFIG_DIR)' }
|
|
1426
|
+
const fs = (function () { try { const p = globalThis.process; return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule('node:fs') : null } catch (x) { return null } })()
|
|
1427
|
+
const path = (function () { try { const p = globalThis.process; return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule('node:path') : null } catch (x) { return null } })()
|
|
1428
|
+
if (!fs || !path) return { ok: false, path: '', models: [], error: 'no fs capability in this host form' }
|
|
1429
|
+
const file = path.join(dir, 'settings.json')
|
|
1430
|
+
let raw
|
|
1431
|
+
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
|
|
1432
|
+
return { ok: false, path: file, models: [], error: 'cannot read ' + file + ': ' + String(e && e.message || e) }
|
|
1433
|
+
}
|
|
1434
|
+
const envBlock = raw && raw.env && typeof raw.env === 'object' ? raw.env : {}
|
|
1435
|
+
const models = []
|
|
1436
|
+
const seen = Object.create(null)
|
|
1437
|
+
for (const key of Object.keys(envBlock)) {
|
|
1438
|
+
if (!/^(ANTHROPIC_(MODEL|DEFAULT_[A-Z]+_MODEL|DEFAULT_[A-Z]+_MODEL_NAME|SMALL_FAST_MODEL))$/i.test(key)) continue
|
|
1439
|
+
const v = envBlock[key]
|
|
1440
|
+
if (typeof v !== 'string' || !v.trim()) continue
|
|
1441
|
+
const id = v.trim()
|
|
1442
|
+
if (seen[id]) continue
|
|
1443
|
+
seen[id] = true
|
|
1444
|
+
models.push({ id, key })
|
|
1445
|
+
}
|
|
1446
|
+
return { ok: true, path: file, models, error: '' }
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** 探测 argv(不含 exe 前缀):固定参数集,绝不拼装用户 prompt。 */
|
|
1450
|
+
function probeArgv() {
|
|
1451
|
+
return ['-p', 'hi', '--model', PROBE_MODEL, '--output-format', 'json', '--no-session-persistence']
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** 探测失败时的可操作文案(区分「没找到 CLI」与「CLI 跑了但服务端没报清单」)。 */
|
|
1455
|
+
function probeFailureText(reasons, tried) {
|
|
1456
|
+
const head = '无法获取服务端权威模型清单(已尝试探测 ' + tried + ' 个候选可执行文件)'
|
|
1457
|
+
const body = reasons.join('; ')
|
|
1458
|
+
return head + (body ? ': ' + body : '') +
|
|
1459
|
+
'。请确认 claude CLI 已安装且端点/凭据可用(claude 在 ~/.claude/settings.json 的 env 里读 ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN),或在设置面板填写正确的可执行文件路径后重试。'
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* 异步探测(唯一探测实现:MCP / preset / dynamic 三形态 + 面板「拉取可用模型」共用):
|
|
1464
|
+
* 起一次真实 CLI 调用,解析 400 报文。
|
|
1465
|
+
* 铁律:只认探测结果;失败 = ok:false + 可操作错误,绝不回落到配置文件/静态表。
|
|
1466
|
+
* @param {object} env
|
|
1467
|
+
* @param {{binPath?: string, timeoutMs?: number}} [opts]
|
|
1468
|
+
* @returns {Promise<{ok: boolean, ids: string[], mode: string, exe?: string, error?: string}>}
|
|
1469
|
+
*/
|
|
1470
|
+
function probeSupportedModels(env, opts) {
|
|
1471
|
+
const o = opts || {}
|
|
1472
|
+
const child = (function () { try { const p = globalThis.process; return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule('node:child_process') : null } catch (x) { return null } })()
|
|
1473
|
+
if (!child || typeof child.spawn !== 'function') {
|
|
1474
|
+
return Promise.resolve({ ok: false, ids: [], mode: 'account-live', error: 'no spawn capability in this host form' })
|
|
1475
|
+
}
|
|
1476
|
+
const cands = claudeProbeCandidates(env, o.binPath)
|
|
1477
|
+
const probeList = cands.length ? cands : ['claude']
|
|
1478
|
+
const proc = globalThis.process
|
|
1479
|
+
const env2 = Object.assign({}, (proc && proc.env) || {})
|
|
1480
|
+
return new Promise((resolve) => {
|
|
1481
|
+
let idx = 0
|
|
1482
|
+
const reasons = []
|
|
1483
|
+
const attempt = () => {
|
|
1484
|
+
if (idx >= probeList.length) {
|
|
1485
|
+
resolve({ ok: false, ids: [], mode: 'account-live', error: probeFailureText(reasons, probeList.length) })
|
|
1486
|
+
return
|
|
1487
|
+
}
|
|
1488
|
+
const exe = probeList[idx++]
|
|
1489
|
+
const spec = splitExeSpec(exe)
|
|
1490
|
+
const argv = spec.prefixArgs.concat(probeArgv())
|
|
1491
|
+
let c
|
|
1492
|
+
try {
|
|
1493
|
+
const spawnCmd = spec.shell ? [spec.cmd].concat(argv).map(quoteShellArg).join(' ') : spec.cmd
|
|
1494
|
+
c = child.spawn(spawnCmd, spec.shell ? [] : argv, {
|
|
1495
|
+
stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: !!spec.shell, env: env2
|
|
1496
|
+
})
|
|
1497
|
+
} catch (e) { reasons.push(exe + ': spawn threw ' + String(e && e.message || e)); attempt(); return }
|
|
1498
|
+
let out = ''
|
|
1499
|
+
let err = ''
|
|
1500
|
+
const timer = setTimeout(() => { try { c.kill() } catch (e) { } }, Math.max(5000, Math.min(Number(o.timeoutMs) || 30000, 90000)))
|
|
1501
|
+
// 一个候选只允许结算一次:spawn 失败时 'error' 与 'close' 会先后都触发,
|
|
1502
|
+
// 两个回调各调一次 attempt() 会把后面的候选整个跳过(idx 多走一格)——
|
|
1503
|
+
// 实测后果是「binPath 填错 → 明明 CLAUDE_BIN 里那个能跑,探测却直接失败」。
|
|
1504
|
+
let settled = false
|
|
1505
|
+
const nextCandidate = (reason) => {
|
|
1506
|
+
if (settled) return
|
|
1507
|
+
settled = true
|
|
1508
|
+
clearTimeout(timer)
|
|
1509
|
+
if (reason) reasons.push(reason)
|
|
1510
|
+
attempt()
|
|
1511
|
+
}
|
|
1512
|
+
c.stdout.on('data', (d) => { out += d; if (out.length > 400000) out = out.slice(-200000) })
|
|
1513
|
+
c.stderr.on('data', (d) => { err += d; if (err.length > 200000) err = err.slice(-100000) })
|
|
1514
|
+
c.on('error', (e) => {
|
|
1515
|
+
nextCandidate(exe + ': ' + String((e && e.code) || (e && e.message) || 'spawn error'))
|
|
1516
|
+
})
|
|
1517
|
+
c.on('close', (code) => {
|
|
1518
|
+
if (settled) return
|
|
1519
|
+
const got = extractProbeIds(out, err)
|
|
1520
|
+
if (got.ids && got.ids.length) {
|
|
1521
|
+
settled = true
|
|
1522
|
+
clearTimeout(timer)
|
|
1523
|
+
resolve({ ok: true, ids: got.ids, mode: 'account-live', exe })
|
|
1524
|
+
return
|
|
1525
|
+
}
|
|
1526
|
+
const head = String(got.errorText || '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
1527
|
+
nextCandidate(exe + ': exit ' + code + ',未解析出可用模型清单' + (head ? ' — ' + head : ''))
|
|
1528
|
+
})
|
|
1529
|
+
}
|
|
1530
|
+
attempt()
|
|
1531
|
+
})
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* 组装 claude_models 的返回值(三形态共用;工具逻辑与 MCP 面共用这一份,杜绝两处漂移)。
|
|
1536
|
+
* 成功 = 服务端清单(source: account-live);失败 = ok:false + configured 诊断线索
|
|
1537
|
+
* (配置里那串名字只作「你可能想用哪个」的提示,明确标注非权威)。
|
|
1538
|
+
* @param {object} env
|
|
1539
|
+
* @param {{binPath?: string, timeoutMs?: number, refresh?: boolean}} [opts]
|
|
1540
|
+
*/
|
|
1541
|
+
async function listAccountModels(env, opts) {
|
|
1542
|
+
const o = opts || {}
|
|
1543
|
+
const key = modelsCacheKey(env, o.binPath)
|
|
1544
|
+
if (o.refresh) modelsCache.delete(key)
|
|
1545
|
+
const probe = await cachedModels(key, () => probeSupportedModels(env, o))
|
|
1546
|
+
const cfg = readConfiguredModels(env)
|
|
1547
|
+
const configured = cfg.models || []
|
|
1548
|
+
if (!probe.ok) {
|
|
1549
|
+
return {
|
|
1550
|
+
ok: false,
|
|
1551
|
+
source: 'account-live',
|
|
1552
|
+
models: [],
|
|
1553
|
+
count: 0,
|
|
1554
|
+
error: String(probe.error || 'probe failed'),
|
|
1555
|
+
diagnostic: {
|
|
1556
|
+
configured: configured,
|
|
1557
|
+
settingsPath: cfg.path,
|
|
1558
|
+
note: 'ANTHROPIC_*_MODEL 只是用户配置的别名映射,不是服务端可用全集;因此不作为 models 返回。'
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const ids = probe.ids.slice()
|
|
1563
|
+
const byId = new Map(configured.map((m) => [m.id, m]))
|
|
1564
|
+
const byIdLoose = new Map(configured.map((m) => [String(m.id).toLowerCase().replace(/\[.*\]$/, ''), m]))
|
|
1565
|
+
return {
|
|
1566
|
+
ok: true,
|
|
1567
|
+
source: 'account-live',
|
|
1568
|
+
models: ids.map((id) => {
|
|
1569
|
+
const hit = byId.get(id) || byIdLoose.get(String(id).toLowerCase())
|
|
1570
|
+
return { id, source: 'account-live', configuredAs: hit ? hit.key : null }
|
|
1571
|
+
}),
|
|
1572
|
+
count: ids.length,
|
|
1573
|
+
ext: { exe: probe.exe || null, mode: probe.mode },
|
|
1574
|
+
diagnostic: { settingsPath: cfg.path, configured: configured }
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// ── inlined from providers/claude-policy.mjs ──
|
|
1579
|
+
// providers/claude-policy.mjs — claude 提示词策略段(preset 与 dynamic 两种宿主唯一来源)。
|
|
1580
|
+
//
|
|
1581
|
+
// 为什么单独成片:同一段策略文本要在 preset/claude-first/cli-bridge.mjs(ESM 导入)与
|
|
1582
|
+
// dynamic/claude.host.template.mjs(沙箱内联,禁 import)两处出现;各写一份必然漂移
|
|
1583
|
+
// (历史上 agy/opencode 两处文案已经出现过不一致)。本文件被 scripts/build.mjs 作为
|
|
1584
|
+
// providers/claude-*.mjs 之一内联进沙箱同一作用域,故两形态共用同一个常量。
|
|
1585
|
+
// ⚠️ 注释里绝不写出口令标记字面量(build 全局替换会把注释劈开)。
|
|
1586
|
+
|
|
1587
|
+
const POLICY_TEXT = [
|
|
1588
|
+
'claude-first execution policy (local Claude Code CLI bridge).',
|
|
1589
|
+
'',
|
|
1590
|
+
'Local agent CLI "claude" handles coding tasks under full DSH control (non-interactive -p mode, permissions auto-approved so claude never blocks on a prompt). By default, claude runs in the background and returns a jobId immediately; collect results via job_output and watch live progress via claude_status anytime.',
|
|
1591
|
+
'',
|
|
1592
|
+
'Decision table for tool selection:',
|
|
1593
|
+
'- Single read/glob/grep or short (<30s) command -> Native tools.',
|
|
1594
|
+
'- Implementing, editing, refactoring, debugging, builds, multi-file investigation -> claude_run.',
|
|
1595
|
+
'- Follow-ups on the same topic -> claude_continue (with latest=true or sessionId; claude preserves session context via --resume).',
|
|
1596
|
+
'- Final build/test verification of claude output -> Native tools.',
|
|
1597
|
+
'',
|
|
1598
|
+
'Context protocol (one topic = one claude session):',
|
|
1599
|
+
'- First dispatch on a topic: provide the task with a compact CONTEXT preamble (point to paths, do not paste file contents):',
|
|
1600
|
+
' CONTEXT: 2-6 lines — goal; repo root; key paths; decisions/constraints; current state.',
|
|
1601
|
+
' TASK: the complete instruction.',
|
|
1602
|
+
'- Follow-ups: call claude_continue and state only what is new.',
|
|
1603
|
+
'',
|
|
1604
|
+
'Execution & Fallback protocol:',
|
|
1605
|
+
'- Dispatch defaults to background=true (set background=false only to block). Collect via job_output.',
|
|
1606
|
+
'- mode=auto follows the DSH plan state: in plan mode claude runs with --permission-mode plan (read-only), otherwise permissions are bypassed so long edits/builds never stall on an approval prompt.',
|
|
1607
|
+
'- On rate-limit or network failure, a confirmation dialog opens in BOTH foreground and background while in flight.',
|
|
1608
|
+
'- If result has fallback=true (status FALLBACK_TO_DSH), the user chose DSH-local: finish with native tools and NEVER call claude again for this task.',
|
|
1609
|
+
'- Never loop claude calls; never ask claude to call back into DSH.',
|
|
1610
|
+
'',
|
|
1611
|
+
'Model & credentials:',
|
|
1612
|
+
'- Which model ids actually work is decided by the ENDPOINT, not by this bridge: call claude_models to read the authoritative server-side roster (it probes the endpoint), then pass model. Never trust ANTHROPIC_*_MODEL entries in ~/.claude/settings.json as the usable set — they are alias mappings only.',
|
|
1613
|
+
'- Omit the model unless you have a reason: claude then uses its own configuration (or the model chosen in CLI Bridge settings). A rejected model surfaces as a run failure with the supported list in the diagnostic hint — fix the model, do not retry blindly.',
|
|
1614
|
+
'- Credentials are owned by claude, not by this bridge: base URL and auth token live in ~/.claude/settings.json (env block). On auth/401 failures tell the user to check that file or re-login; do not retry in a loop.'
|
|
1615
|
+
].join('\n')
|
|
1616
|
+
|
|
1617
|
+
// core/scheduling.mjs — CLI 调度(启用集合 + 调用优先级)单源纯逻辑。
|
|
1618
|
+
//
|
|
1619
|
+
// 语义(用户可见的两件事):
|
|
1620
|
+
// • enabled = 勾选启用哪些 CLI(数量随之而定);未启用的一面不注册工具、不进提示词。
|
|
1621
|
+
// • priority = 智能体调用 CLI 的建议顺序,数组顺序即优先级(首位最高)。
|
|
1622
|
+
//
|
|
1623
|
+
// 落盘位置:统一设置文件 {DSH_HOME}/cli-bridge-settings.json 的顶层 `_scheduling` 区。
|
|
1624
|
+
// 下划线前缀 = 非 provider 分区(registry 的 PROVIDER_IDS 永远不含它,逐分区语义不受影响),
|
|
1625
|
+
// 因此新代码写出的文件被旧包读取时整区被忽略 = 纯增量,向后兼容。
|
|
1626
|
+
//
|
|
1627
|
+
// 缺省语义(文件缺失/无该区):全部 provider 启用、按注册表顺序 —— 与调度特性上线前
|
|
1628
|
+
// 的行为逐字一致(读不到配置永远不等于关掉所有桥)。
|
|
1629
|
+
//
|
|
1630
|
+
// 沙箱纪律:与 bridge-kit 同源约束 —— 不得 import;Node 内建一律经
|
|
1631
|
+
// globalThis.process.getBuiltinModule + typeof 守卫(动态宿主沙箱可能没有 fs,
|
|
1632
|
+
// 那种环境读不到文件 → 走缺省全开,绝不炸 apply)。
|
|
1633
|
+
|
|
1634
|
+
/** 设置文件里调度区的键名(下划线前缀避开 provider id 命名空间)。 */
|
|
1635
|
+
const SCHEDULING_SECTION = '_scheduling'
|
|
1636
|
+
|
|
1637
|
+
/** 人类可读的默认注册顺序(调用建议顺序);enabled 缺省 = 全部启用。 */
|
|
1638
|
+
function defaultPriority(ids) {
|
|
1639
|
+
return (Array.isArray(ids) ? ids : []).slice()
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* 清洗任意来源的调度对象。
|
|
1644
|
+
* @param {unknown} raw 文件/POST 里的原始值(可能是任何东西,全部容错)
|
|
1645
|
+
* @param {string[]} ids 全量 provider id(顺序 = 缺省优先级)
|
|
1646
|
+
* @param {(id: string) => string} [labelOf] 展示名(仅用于诊断文本,不参与判定)
|
|
1647
|
+
*/
|
|
1648
|
+
function normalizeScheduling(raw, ids, labelOf) {
|
|
1649
|
+
const all = Array.isArray(ids) ? ids.slice() : []
|
|
1650
|
+
const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
|
|
1651
|
+
const enabled = all.slice()
|
|
1652
|
+
let priority = all.slice()
|
|
1653
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
1654
|
+
if (Array.isArray(raw.enabled)) {
|
|
1655
|
+
const seen = Object.create(null)
|
|
1656
|
+
const picked = []
|
|
1657
|
+
for (const x of raw.enabled) {
|
|
1658
|
+
const id = String(x == null ? '' : x)
|
|
1659
|
+
if (!id || all.indexOf(id) < 0 || seen[id]) continue
|
|
1660
|
+
seen[id] = true; picked.push(id)
|
|
1661
|
+
}
|
|
1662
|
+
// 空数组 = 用户真的想把全部关掉(而不是「没配」),保留该语义;
|
|
1663
|
+
// 非空但过滤后为空 = 写的全是未知 id(坏值)→ 回落全开(fail-open,
|
|
1664
|
+
// 绝不让一次手滑/陈旧配置把所有桥关掉)。
|
|
1665
|
+
if (raw.enabled.length === 0 || picked.length > 0) {
|
|
1666
|
+
enabled.length = 0
|
|
1667
|
+
Array.prototype.push.apply(enabled, picked)
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
if (Array.isArray(raw.priority)) {
|
|
1671
|
+
const seen2 = Object.create(null)
|
|
1672
|
+
const ordered = []
|
|
1673
|
+
for (const x of raw.priority) {
|
|
1674
|
+
const id = String(x == null ? '' : x)
|
|
1675
|
+
if (!id || all.indexOf(id) < 0 || seen2[id]) continue
|
|
1676
|
+
seen2[id] = true; ordered.push(id)
|
|
1677
|
+
}
|
|
1678
|
+
for (const id of all) if (!seen2[id]) ordered.push(id) // 漏写的排在末尾
|
|
1679
|
+
if (ordered.length) priority = ordered
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
const order = priority.filter((id) => enabled.indexOf(id) >= 0)
|
|
1683
|
+
for (const id of enabled) if (order.indexOf(id) < 0) order.push(id)
|
|
1684
|
+
return { enabled, priority, order }
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** 展示文本:「CB → MIMO」或「全部启用 · CB → AGY → MIMO」。 */
|
|
1688
|
+
function describeScheduling(s, labelOf) {
|
|
1689
|
+
const st = s || {}
|
|
1690
|
+
const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
|
|
1691
|
+
const order = Array.isArray(st.order) ? st.order : []
|
|
1692
|
+
const names = order.map(label).join(' → ')
|
|
1693
|
+
return names ? names : '(全部禁用)'
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ── 读盘(四形态共用;无 fs 时安全返回 null)────────────────────────────────────
|
|
1697
|
+
// 名字带 sched 前缀:本文件会与 bridge-kit / provider 描述符【文本内联进同一个动态
|
|
1698
|
+
// 沙箱脚本作用域】(providers/codebuddy.mjs 已有 builtinModule),撞名即隐患。
|
|
1699
|
+
function schedBuiltinModule(name) {
|
|
1700
|
+
try {
|
|
1701
|
+
const proc = globalThis.process
|
|
1702
|
+
return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
|
|
1703
|
+
} catch (e) { return null }
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function schedulingSettingsPath(env) {
|
|
1707
|
+
const e = env || (globalThis.process && globalThis.process.env) || {}
|
|
1708
|
+
try {
|
|
1709
|
+
if (e.CLI_BRIDGE_SETTINGS_FILE) return String(e.CLI_BRIDGE_SETTINGS_FILE)
|
|
1710
|
+
const nodePath = schedBuiltinModule('node:path')
|
|
1711
|
+
if (nodePath && e.DSH_HOME) return nodePath.join(String(e.DSH_HOME), 'cli-bridge-settings.json')
|
|
1712
|
+
} catch (e2) { }
|
|
1713
|
+
return null
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* 生效调度:文件 _scheduling > 缺省全开。
|
|
1718
|
+
* env/argv CLI_BRIDGE_PROVIDER(单面钉住,旧薄封装兼容语义)存在时视为最高优先 ——
|
|
1719
|
+
* 它代表「这个 MCP 进程只为这一家而启动」(部署者的显式意志,比面板勾选更具体、
|
|
1720
|
+
* 更靠近进程生命周期),调度不再削减它。
|
|
1721
|
+
* 宿主形态(preset / dynamic 桥接插件)应传 ignorePin:钉住只是 MCP 进程的启动参数,
|
|
1722
|
+
* 不该让全局 env 反过来把用户会话里的其他桥关掉。
|
|
1723
|
+
* @param {string[]} ids 全量 provider id
|
|
1724
|
+
* @param {{ env?: Record<string,string>, readFileSync?: Function, path?: string|null, ignorePin?: boolean }} [io]
|
|
1725
|
+
*/
|
|
1726
|
+
function resolveScheduling(ids, io) {
|
|
1727
|
+
const o = io || {}
|
|
1728
|
+
const env = o.env || (globalThis.process && globalThis.process.env) || {}
|
|
1729
|
+
let raw = null
|
|
1730
|
+
try {
|
|
1731
|
+
const p = ('path' in o) ? o.path : schedulingSettingsPath(env)
|
|
1732
|
+
const nodeFs = o.readFileSync ? { readFileSync: o.readFileSync } : schedBuiltinModule('node:fs')
|
|
1733
|
+
if (p && nodeFs && typeof nodeFs.readFileSync === 'function') {
|
|
1734
|
+
raw = JSON.parse(nodeFs.readFileSync(p, 'utf8'))
|
|
1735
|
+
}
|
|
1736
|
+
} catch (e) { raw = null }
|
|
1737
|
+
const file = normalizeScheduling(raw && typeof raw === 'object' ? raw[SCHEDULING_SECTION] : null, ids)
|
|
1738
|
+
const pinned = o.ignorePin ? '' : String(env.CLI_BRIDGE_PROVIDER || '').trim()
|
|
1739
|
+
if (pinned && ids.indexOf(pinned) >= 0) {
|
|
1740
|
+
return Object.assign(file, {
|
|
1741
|
+
enabled: [pinned],
|
|
1742
|
+
order: [pinned],
|
|
1743
|
+
pinned: pinned,
|
|
1744
|
+
source: raw ? 'env:CLI_BRIDGE_PROVIDER+file' : 'env:CLI_BRIDGE_PROVIDER'
|
|
1745
|
+
})
|
|
1746
|
+
}
|
|
1747
|
+
return Object.assign(file, { pinned: '', source: raw ? 'file' : 'default' })
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* 桥接面门禁(preset / dynamic 的 apply() 首行用):本 provider 是否被调度启用。
|
|
1752
|
+
* fail-open —— 任何异常(无 fs、文件损坏、沙箱限制)都返回 true:读不到配置永远
|
|
1753
|
+
* 不等于关掉桥(否则一次文件损坏就把三条 CLI 全灭了)。ignorePin 固定 true:
|
|
1754
|
+
* 钉住语义属于 MCP 独立进程,宿主形态的全局 env 不该反向影响用户会话。
|
|
1755
|
+
*/
|
|
1756
|
+
function isBridgeEnabled(selfId, ids, io) {
|
|
1757
|
+
try {
|
|
1758
|
+
const list = Array.isArray(ids) ? ids : []
|
|
1759
|
+
// 全量清单本身不可得(空/不含自己)= 无门禁语义,fail-open。
|
|
1760
|
+
if (!list.length || list.indexOf(String(selfId)) < 0) return true
|
|
1761
|
+
const o = Object.assign({ ignorePin: true }, io || {})
|
|
1762
|
+
return resolveScheduling(list, o).enabled.indexOf(String(selfId)) >= 0
|
|
1763
|
+
} catch (e) { return true }
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
const SCHED_ALL_IDS = ["agy","codebuddy","mimo","opencode","claude","codex"]
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
return {
|
|
1771
|
+
inject: ['tools', 'subprocess', 'systemPrompt', 'timer'],
|
|
1772
|
+
apply(ctx) {
|
|
1773
|
+
// 调度门禁:设置面板关掉 claude → 本形态不注册工具/政策段;fail-open。
|
|
1774
|
+
if (!isBridgeEnabled('claude', SCHED_ALL_IDS)) return
|
|
1775
|
+
const subprocess = ctx.subprocess
|
|
1776
|
+
const sandboxPolicy = ctx.get('sandboxPolicy')
|
|
1777
|
+
// 沙箱可能运行在无 process 的环境:typeof 守卫读 env(旧仓 dynamic 的等价面)。
|
|
1778
|
+
const env = (typeof process !== 'undefined' && process.env) ? process.env : {}
|
|
1779
|
+
|
|
1780
|
+
const provider = defineProvider({ env })
|
|
1781
|
+
|
|
1782
|
+
function publish(snapshot) {
|
|
1783
|
+
try {
|
|
1784
|
+
const collector = ctx.get('cliBridgeCollector')
|
|
1785
|
+
if (collector && typeof collector.mergeSnapshot === 'function') { collector.mergeSnapshot('claude', snapshot); return }
|
|
1786
|
+
} catch (e) { /* fall through to legacy alias */ }
|
|
1787
|
+
try {
|
|
1788
|
+
const legacy = ctx.get('claudeCollector')
|
|
1789
|
+
if (legacy && typeof legacy.mergeSnapshot === 'function') legacy.mergeSnapshot(snapshot)
|
|
1790
|
+
} catch (e) { /* 灯数据尽力而为 */ }
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// exe 优先级(与描述符同一套):env CLAUDE_BIN/CLAUDE_CLI/CLAUDE_CODE_BIN > 已知本地安装
|
|
1794
|
+
// > PATH 名 claude(subprocess.resolveExecutable 兜底)。动态沙箱拿不到设置面板值。
|
|
1795
|
+
const exeCandidates = claudeExeCandidates(env, '')
|
|
1796
|
+
async function resolveExe(backend, signal) {
|
|
1797
|
+
for (const c of exeCandidates) {
|
|
1798
|
+
if (!c) continue
|
|
1799
|
+
if (/\.(mjs|cjs|js)$/i.test(c) && typeof process !== 'undefined' && process.execPath) return [process.execPath, c]
|
|
1800
|
+
return c
|
|
1801
|
+
}
|
|
1802
|
+
return await subprocess.resolveExecutable('claude', undefined, signal)
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// cwd 兜底:CLAUDE_MCP_CWD > sandboxPolicy.workspaceRoot > process.cwd() > '.'。
|
|
1806
|
+
function getCwdFallback() {
|
|
1807
|
+
if (env.CLAUDE_MCP_CWD) return String(env.CLAUDE_MCP_CWD)
|
|
1808
|
+
if (sandboxPolicy && typeof sandboxPolicy.workspaceRoot === 'string' && sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot
|
|
1809
|
+
if (typeof process !== 'undefined' && typeof process.cwd === 'function') { try { return process.cwd() } catch (e) {} }
|
|
1810
|
+
return '.'
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
provider.attach({ ctx, subprocess, resolveExe, getCwdFallback, publish })
|
|
1814
|
+
|
|
1815
|
+
const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
|
|
1816
|
+
|
|
1817
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1818
|
+
name: provider.tools.run.name,
|
|
1819
|
+
description: provider.tools.run.description,
|
|
1820
|
+
parameters: provider.tools.run.inputSchema,
|
|
1821
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
|
|
1822
|
+
execute: function (args, exec) { return provider.runToolLogic(args, exec) }
|
|
1823
|
+
}))
|
|
1824
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1825
|
+
name: provider.tools.continue.name,
|
|
1826
|
+
description: provider.tools.continue.description,
|
|
1827
|
+
parameters: provider.tools.continue.inputSchema,
|
|
1828
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
|
|
1829
|
+
execute: function (args, exec) { return provider.continueToolLogic(args, exec) }
|
|
1830
|
+
}))
|
|
1831
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1832
|
+
name: provider.tools.status.name,
|
|
1833
|
+
description: provider.tools.status.description,
|
|
1834
|
+
parameters: provider.tools.status.inputSchema,
|
|
1835
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderStatus(value, provider) } },
|
|
1836
|
+
execute: function (args) { return provider.statusToolLogic(args) }
|
|
1837
|
+
}))
|
|
1838
|
+
// 附加工具(claude_models):render 适配 harness 契约 render(args, value)。
|
|
1839
|
+
for (const et of (provider.extraTools || [])) {
|
|
1840
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1841
|
+
name: et.name,
|
|
1842
|
+
description: et.description,
|
|
1843
|
+
parameters: et.inputSchema,
|
|
1844
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return et.render ? et.render(args, value) : renderResult(value, provider) } },
|
|
1845
|
+
execute: function (args, exec) { return et.logic(args, exec) }
|
|
1846
|
+
}))
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// 浏览器指示灯轮询的 RPC 面(旧 dynamic handle('<id>_status') verbatim 语义)。
|
|
1850
|
+
harness.handle('claude_status', function () { return provider.statusToolLogic({}) })
|
|
1851
|
+
|
|
1852
|
+
ctx.systemPrompt.section({
|
|
1853
|
+
name: 'claude:policy',
|
|
1854
|
+
order: (typeof ctx.systemPrompt.getSectionOrder === 'function' ? ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT') : 2800) + 10,
|
|
1855
|
+
text: POLICY_TEXT
|
|
1856
|
+
})
|
|
1857
|
+
}
|
|
1858
|
+
}
|