@luchenglong/cli-bridge 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/PROVIDER-CONTRACT.md +53 -4
- package/README.md +12 -11
- package/docs/HANDOVER.md +9 -0
- package/docs/audits/2026-10-18-preset-0.2.1-contract-fix.md +160 -0
- package/docs/audits/README.md +15 -0
- package/dynamic/agy.host.js +1 -1
- package/dynamic/claude.host.js +1801 -0
- package/dynamic/claude.host.template.mjs +108 -0
- package/dynamic/codebuddy.host.js +1 -1
- package/dynamic/codex.host.js +1955 -0
- package/dynamic/codex.host.template.mjs +137 -0
- package/dynamic/mimo.host.js +1 -1
- package/dynamic/opencode.host.js +1 -1
- package/home-plugin/cli-bridge-indicator/lib/client.js +600 -606
- package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +87 -9
- package/mcp/cli-bridge-mcp.mjs +6 -4
- package/mcp/providers/claude.mjs +231 -0
- package/mcp/providers/codex.mjs +239 -0
- package/package.json +10 -6
- package/preset/agy-first/agent.cordis.yml +0 -5
- package/preset/agy-first/plugin-list.mjs +4 -1
- 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/codebuddy-first/agent.cordis.yml +0 -5
- 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/preset/mimo-first/agent.cordis.yml +1 -7
- package/preset/mimo-first/plugin-list.mjs +18 -12
- package/preset/mimo-member/plugin-list.mjs +49 -0
- package/preset/opencode-first/agent.cordis.yml +0 -5
- package/preset/opencode-first/plugin-list.mjs +4 -2
- package/preset/standard-base.mjs +18 -6
- package/providers/claude-exe.mjs +112 -0
- package/providers/claude-models.mjs +334 -0
- package/providers/claude-policy.mjs +37 -0
- package/providers/claude.mjs +508 -0
- package/providers/codex-cli.mjs +471 -0
- package/providers/codex-models.mjs +107 -0
- package/providers/codex.mjs +544 -0
- package/providers/registry.mjs +25 -1
- package/scripts/build.mjs +1 -2
- package/scripts/verify.mjs +8 -2
|
@@ -0,0 +1,1955 @@
|
|
|
1
|
+
// dynamic/codex.host.template.mjs — codex 动态沙箱宿主模板(cordis_define code.host 源)。
|
|
2
|
+
//
|
|
3
|
+
// 构建(node scripts/build.mjs --only=codex)把 core/bridge-kit.mjs、providers/codex.mjs、
|
|
4
|
+
// providers/codex-*.mjs 片文件、core/scheduling.mjs 分别内联进下方四个占位标记行
|
|
5
|
+
// (各占一行、行内仅此标记),生成 dynamic/codex.host.js。
|
|
6
|
+
// 注意:标记字面量绝不能出现在本文件注释里 —— 构建做全局文本替换,注释中出现同样会被
|
|
7
|
+
// 替换成正文代码而炸掉语法(历史事故)。
|
|
8
|
+
//
|
|
9
|
+
// 沙箱纪律(旧仓 dynamic/host.js 头注 verbatim 精神):
|
|
10
|
+
// - 禁 import/require、无 TypeScript;无 AbortController/process 裸引用/Buffer/原生
|
|
11
|
+
// 定时器 —— process 只经 typeof 守卫探测;宿主能力全部来自注入的 harness.*/ctx。
|
|
12
|
+
// - 顶层 return { inject, apply } 为动态宿主约定形态(生成物是 .js/CJS 包装,顶层
|
|
13
|
+
// return 合法;本模板文件是 .mjs,不要直接 node --check 模板本身,只 check 生成物)。
|
|
14
|
+
// - 无 ctx.emit:状态经 ctx.get('cliBridgeCollector').mergeSnapshot('codex', snap)
|
|
15
|
+
// 推给家级灯;动态形态不宣告 mode(普通模式灯仅在调用时临时显示)。
|
|
16
|
+
// core/bridge-kit.mjs — CLI Bridge 单一事实来源:通用执行/状态骨架。
|
|
17
|
+
//
|
|
18
|
+
// 深度重构核心(合并 agy-first-bridge / codebuddy-first-bridge / mimo-first-bridge):
|
|
19
|
+
// 三条 bridge 的 host 半此前各自复制了一份几乎相同的「状态引擎 + 行流 + 执行编排
|
|
20
|
+
// + 限流回退弹窗 + MCP 快照上报 + 展示层」。本文件把这些**与 provider 无关**的骨架
|
|
21
|
+
// 收敛成一份参数化实现,每个 provider 只提供一个 descriptor(见 providers/*.mjs 与
|
|
22
|
+
// PROVIDER-CONTRACT.md),由 createRunner/createStatusEngine 回调它完成差异化步骤。
|
|
23
|
+
//
|
|
24
|
+
// 骨架覆盖:
|
|
25
|
+
// - 纯函数:projectName / clampInt / shortLabel / summarizeArgs / endpointHost
|
|
26
|
+
// - 限流/网络判定:LIMIT_RE + isLimited(provider 可收窄)
|
|
27
|
+
// - 半行安全行流:createLineStream
|
|
28
|
+
// - 按项目(cwd)聚合、按 provider 分桶的状态引擎:createStatusEngine
|
|
29
|
+
// - 执行编排(前台/后台/超时守卫/瞬时错误重试/限流三选一弹窗/回退):createRunner
|
|
30
|
+
// - 工具结果与状态的人类可读渲染:renderResult / renderStatus
|
|
31
|
+
// - MCP 子进程快照上报:buildMcpBridgePayload / normalizeMcpBridge(provider 分文件)
|
|
32
|
+
// - 回退弹窗三选一文案(provider 名注入)
|
|
33
|
+
//
|
|
34
|
+
// 约束(沙箱与真实 Node 共用,勿违反):
|
|
35
|
+
// - 本模块**不 import 任何模块**、不用 process/Buffer/原生定时器/AbortController;
|
|
36
|
+
// 宿主能力(subprocess.spawn / ctx.interval / ctx.timeout / jobs / userQuestions)
|
|
37
|
+
// 全部经 createRunner 的 opts 注入,取消用 exec.signal + handle.terminate()。
|
|
38
|
+
// - 不序列化任何 DSH 活对象;跨 RPC 只传无 Host 引用的纯 JSON 叶子字段。
|
|
39
|
+
|
|
40
|
+
// ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const MAX_TRAIL = 12
|
|
43
|
+
const MAX_ARG_LEN = 120
|
|
44
|
+
const MAX_PROJECTS = 24
|
|
45
|
+
const MAX_SESSIONS = 256
|
|
46
|
+
|
|
47
|
+
// 限流/网络类错误的宽松正则(英文 + 中文)。provider descriptor 可用自己的收窄版覆盖。
|
|
48
|
+
const LIMIT_RE =
|
|
49
|
+
/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
|
|
50
|
+
|
|
51
|
+
// 家级插件与 MCP 子进程之间的快照文件:{dsh-home}/cli-bridge-mcp-<provider>.json
|
|
52
|
+
const MCP_BRIDGE_PREFIX = 'cli-bridge-mcp-'
|
|
53
|
+
const MCP_BRIDGE_STALE_MS = 90000
|
|
54
|
+
function mcpBridgeFileName(providerId) { return MCP_BRIDGE_PREFIX + providerId + '.json' }
|
|
55
|
+
|
|
56
|
+
// ── 纯函数 ────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function projectName(cwd) {
|
|
59
|
+
const s = String(cwd || '')
|
|
60
|
+
const parts = s.split(/[\\/]/).filter(Boolean)
|
|
61
|
+
return parts.length ? parts[parts.length - 1] : s
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clampInt(v, def, min, max) {
|
|
65
|
+
const n = Number(v)
|
|
66
|
+
if (!Number.isFinite(n)) return def
|
|
67
|
+
const i = Math.floor(n)
|
|
68
|
+
if (i < min) return min
|
|
69
|
+
if (i > max) return max
|
|
70
|
+
return i
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shortLabel(prompt) {
|
|
74
|
+
const s = String(prompt || '').replace(/\s+/g, ' ').trim()
|
|
75
|
+
return s.length > 80 ? s.slice(0, 77) + '...' : s
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 工具入参摘要(状态灯/活动面板)。返回可无损 JSON 化的数据;undefined 值跳过
|
|
79
|
+
// (DSH tool 渲染层不接受 undefined)。
|
|
80
|
+
function summarizeArgs(parameters) {
|
|
81
|
+
if (!parameters || typeof parameters !== 'object') return null
|
|
82
|
+
const out = {}
|
|
83
|
+
for (const k of Object.keys(parameters)) {
|
|
84
|
+
let v = parameters[k]
|
|
85
|
+
if (v === undefined) continue
|
|
86
|
+
if (typeof v === 'string' && v.length > MAX_ARG_LEN) v = v.slice(0, MAX_ARG_LEN) + '…'
|
|
87
|
+
out[k] = v
|
|
88
|
+
}
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function endpointHost(url) {
|
|
93
|
+
const m = String(url || '').match(/^https?:\/\/([^/]+)/i)
|
|
94
|
+
return m ? m[1] : ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 限流/网络类失败(要问用户,重试可能烧额度)。
|
|
98
|
+
function isLimited(res) {
|
|
99
|
+
if (!res || res.ok) return false
|
|
100
|
+
// AUTH_REQUIRED 是配置问题(缺凭据),弹回退/重试无意义 —— 直接给可操作指引。
|
|
101
|
+
if (res.status === 'AUTH_REQUIRED') return false
|
|
102
|
+
if (res.status === 'SPAWN_ERROR' || res.status === 'CLI_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
|
|
103
|
+
const hay = String(res.stderr || '') + ' ' + String(res.status || '')
|
|
104
|
+
// 端点域与登录域不匹配的 401:需要用户换登录域,不是限流。
|
|
105
|
+
if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
|
|
106
|
+
return LIMIT_RE.test(hay)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── 半行安全的行流(跨 chunk 的 NDJSON 行拼接)───────────────────────────────
|
|
110
|
+
function createLineStream(consumeLine) {
|
|
111
|
+
let pending = ''
|
|
112
|
+
function pushChunk(text) {
|
|
113
|
+
if (typeof text !== 'string' || !text) return
|
|
114
|
+
const lines = (pending + text).split(/\r?\n/)
|
|
115
|
+
pending = lines.pop() || ''
|
|
116
|
+
for (const ln of lines) consumeLine(ln)
|
|
117
|
+
}
|
|
118
|
+
function flush() {
|
|
119
|
+
const rest = pending
|
|
120
|
+
pending = ''
|
|
121
|
+
if (rest) consumeLine(rest)
|
|
122
|
+
}
|
|
123
|
+
return { pushChunk, flush }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 状态引擎(按 provider + 项目目录聚合的运行状态表)─────────────────────────
|
|
127
|
+
// opts: { provider: <descriptor>, publish(snapshot) }
|
|
128
|
+
// 快照结构(顶层为全局聚合,向后兼容单 provider 消费者):
|
|
129
|
+
// { provider, state, running, lastStatus, lastAt, lastSessionId, lastBackend,
|
|
130
|
+
// fallbackActive, current, trail, runs, totalTokens, updatedAt,
|
|
131
|
+
// projects:[{ cwd,name,provider,state,running,current,trail,lastStatus,lastAt,
|
|
132
|
+
// lastSessionId,lastBackend,fallbackActive,runs,totalTokens,updatedAt }] }
|
|
133
|
+
function createStatusEngine(opts) {
|
|
134
|
+
const o = opts || {}
|
|
135
|
+
const provider = o.provider
|
|
136
|
+
const providerId = provider && provider.id ? provider.id : 'cli'
|
|
137
|
+
const publish = typeof o.publish === 'function' ? o.publish : null
|
|
138
|
+
const projects = Object.create(null)
|
|
139
|
+
// sessionId -> { cwd, backend, provider }。会话按 provider 归属(登录域互斥),
|
|
140
|
+
// 续接时一次解析出 cwd 与 backend。容量上限 MAX_SESSIONS,FIFO 淘汰。
|
|
141
|
+
const sessions = Object.create(null)
|
|
142
|
+
|
|
143
|
+
function ensureProject(cwd) {
|
|
144
|
+
const key = String(cwd || '')
|
|
145
|
+
let p = projects[key]
|
|
146
|
+
if (!p) {
|
|
147
|
+
if (Object.keys(projects).length >= MAX_PROJECTS) {
|
|
148
|
+
const idle = Object.keys(projects).filter((k) => projects[k].running === 0)
|
|
149
|
+
const pool = idle.length ? idle : Object.keys(projects)
|
|
150
|
+
const victim = pool.sort((a, b) => projects[a].updatedAt - projects[b].updatedAt)[0]
|
|
151
|
+
delete projects[victim]
|
|
152
|
+
}
|
|
153
|
+
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 }
|
|
154
|
+
projects[key] = p
|
|
155
|
+
}
|
|
156
|
+
return p
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function globalStatus() {
|
|
160
|
+
const list = Object.keys(projects).map((k) => projects[k])
|
|
161
|
+
let running = 0, lastStatus = null, lastAt = 0, lastSessionId = null, lastBackend = null, fallbackActive = false, current = null, trail = [], updatedAt = 0, runs = 0, totalTokens = 0
|
|
162
|
+
for (const p of list) {
|
|
163
|
+
running += p.running
|
|
164
|
+
if (p.updatedAt > updatedAt) updatedAt = p.updatedAt
|
|
165
|
+
if (p.running > 0 && !current && p.current) current = p.current
|
|
166
|
+
if (p.lastAt > lastAt) { lastAt = p.lastAt; lastStatus = p.lastStatus; lastSessionId = p.lastSessionId; lastBackend = p.lastBackend }
|
|
167
|
+
if (p.fallbackActive) fallbackActive = true
|
|
168
|
+
runs += p.runs || 0
|
|
169
|
+
totalTokens += p.totalTokens || 0
|
|
170
|
+
for (const e of p.trail) trail.push(e)
|
|
171
|
+
}
|
|
172
|
+
trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
|
|
173
|
+
trail = trail.slice(-MAX_TRAIL)
|
|
174
|
+
const state = running > 0 ? 'running' : (fallbackActive ? 'fallback' : (lastStatus ? (lastStatus === 'SUCCESS' ? 'ok' : 'failed') : 'idle'))
|
|
175
|
+
return { state, running, lastStatus, lastAt, lastSessionId, lastBackend, fallbackActive, current, trail, runs, totalTokens, updatedAt }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function statusSnapshot() {
|
|
179
|
+
const g = globalStatus()
|
|
180
|
+
const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
181
|
+
return Object.assign({ provider: providerId }, g, {
|
|
182
|
+
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 }))
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function emit() { if (publish) { try { publish(statusSnapshot()) } catch (e) {} } }
|
|
187
|
+
|
|
188
|
+
function begin(cwd) {
|
|
189
|
+
const p = ensureProject(cwd)
|
|
190
|
+
p.running += 1
|
|
191
|
+
p.state = 'running'
|
|
192
|
+
p.updatedAt = Date.now()
|
|
193
|
+
emit()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function end(res, cwd) {
|
|
197
|
+
const p = ensureProject(cwd)
|
|
198
|
+
p.running = Math.max(0, p.running - 1)
|
|
199
|
+
p.lastStatus = res ? res.status : null
|
|
200
|
+
p.lastAt = Date.now()
|
|
201
|
+
if (res && res.sessionId) {
|
|
202
|
+
p.lastSessionId = res.sessionId
|
|
203
|
+
sessions[res.sessionId] = { cwd: p.cwd, backend: res.backend || (provider && provider.defaultBackend), provider: providerId }
|
|
204
|
+
const keys = Object.keys(sessions)
|
|
205
|
+
if (keys.length > MAX_SESSIONS) delete sessions[keys[0]]
|
|
206
|
+
}
|
|
207
|
+
if (res && res.backend) p.lastBackend = res.backend
|
|
208
|
+
p.runs = (p.runs || 0) + 1
|
|
209
|
+
if (res && typeof res.totalTokens === 'number' && res.totalTokens > 0) p.totalTokens = (p.totalTokens || 0) + res.totalTokens
|
|
210
|
+
if (res && res.fallback) { p.fallbackActive = true; p.state = 'fallback' }
|
|
211
|
+
else if (p.running > 0) { p.state = 'running' }
|
|
212
|
+
else { p.state = res && res.ok ? 'ok' : 'failed'; p.lastOk = !!(res && res.ok); p.lastFailed = !(res && res.ok) }
|
|
213
|
+
p.current = null
|
|
214
|
+
p.updatedAt = Date.now()
|
|
215
|
+
emit()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 流事件折叠为实时状态。provider 若有 stream-json,提供 foldStreamEvent(ev, project);
|
|
219
|
+
// 否则回退到 generic 形态(不产生实时步骤)。
|
|
220
|
+
function foldEvent(ev, cwd) {
|
|
221
|
+
const p = ensureProject(cwd)
|
|
222
|
+
p.updatedAt = Date.now()
|
|
223
|
+
if (provider && typeof provider.foldStreamEvent === 'function') {
|
|
224
|
+
try { provider.foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) } catch (e) {}
|
|
225
|
+
}
|
|
226
|
+
emit()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 会话路由(cwd + backend 一次解析)。续接(--resume/--continue)时未给 cwd
|
|
230
|
+
// 则回落到该 session 所在项目的 cwd(否则换目录报找不到会话)。
|
|
231
|
+
function resolveTarget(args, fallbackCwd) {
|
|
232
|
+
const a = args || {}
|
|
233
|
+
let backend = null, cwd = null
|
|
234
|
+
if (a.sessionId) { const hit = sessions[a.sessionId]; if (hit && hit.provider === providerId) { backend = hit.backend; cwd = hit.cwd } }
|
|
235
|
+
if (a.cwd) {
|
|
236
|
+
cwd = String(a.cwd)
|
|
237
|
+
} else if (!cwd && (a.sessionId || a.continueLatest)) {
|
|
238
|
+
const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
239
|
+
if (a.sessionId) {
|
|
240
|
+
const projHit = list.find((p) => p.lastSessionId === a.sessionId)
|
|
241
|
+
if (projHit) { cwd = projHit.cwd; if (!backend) backend = projHit.lastBackend }
|
|
242
|
+
} else if (list.length && list[0].lastSessionId) { cwd = list[0].cwd; if (!backend) backend = list[0].lastBackend }
|
|
243
|
+
}
|
|
244
|
+
return { cwd: cwd || fallbackCwd, backend }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolveCwd(args, fallbackCwd) { return resolveTarget(args, fallbackCwd).cwd }
|
|
248
|
+
|
|
249
|
+
return { providerId, projects, sessions, ensureProject, globalStatus, statusSnapshot, begin, end, foldEvent, resolveCwd, resolveTarget }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── 回退弹窗三选一(provider 名注入)──────────────────────────────────────────
|
|
253
|
+
function fallbackLabels(provider) {
|
|
254
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
255
|
+
return {
|
|
256
|
+
fallback: '使用 DSH 本地 API 配置(回退)',
|
|
257
|
+
retry: '重试 ' + bk + ' 一次',
|
|
258
|
+
cancel: '不回退(返回错误)'
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 失败结果的可行动指引;provider.failureHint 优先,未给则通用兜底。
|
|
263
|
+
function failureHint(res, provider) {
|
|
264
|
+
if (!res || res.ok) return ''
|
|
265
|
+
if (provider && typeof provider.failureHint === 'function') {
|
|
266
|
+
try { const h = provider.failureHint(res); if (h) return h } catch (e) {}
|
|
267
|
+
}
|
|
268
|
+
if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
|
|
269
|
+
if (res.status === 'PARSE_ERROR') return '[诊断] 未能从输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 CLI 版本。'
|
|
270
|
+
if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
|
|
271
|
+
return '[诊断] 认证失败(401):请求端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 CLI 登录后重试;这不是限流。'
|
|
272
|
+
}
|
|
273
|
+
return ''
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function fallbackResult(res, mode, provider) {
|
|
277
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
278
|
+
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 + ']' })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── 展示层 ────────────────────────────────────────────────────────────────────
|
|
282
|
+
function renderResult(value, provider) {
|
|
283
|
+
const v = value || {}
|
|
284
|
+
// backendLabel(codebuddy 旧语义 v1.1.0 verbatim:'workbuddy OK'/'codebuddy-intl OK'):
|
|
285
|
+
// 结果带具体 backend 时用 backend 标头;agy/mimo 旧包标头是硬编码 provider 名,不开此旗。
|
|
286
|
+
const bk = (provider && provider.backendLabel && v.backend) || (provider && provider.id) || v.backend || 'CLI'
|
|
287
|
+
if (v.background) return [{ type: 'text', text: bk + ' dispatched in background (mode=' + v.mode + '). jobId=' + v.jobId + '. Collect with job_output.' }]
|
|
288
|
+
if (v.fallback) return [{ type: 'text', text: bk + ' 回退:用户选择使用 DSH 本地 API 配置(原因 ' + v.reason + ')。请改用原生工具/本地模型完成本任务,不要再调 ' + bk + '。' }]
|
|
289
|
+
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' : '') + ']'
|
|
290
|
+
const hint = failureHint(v, provider)
|
|
291
|
+
let body = v.response ? v.response : (v.stderr ? '[stderr] ' + v.stderr : (v.rawStdout ? '[raw] ' + v.rawStdout : ''))
|
|
292
|
+
if (hint) body = (body ? body + '\n\n' : '') + hint
|
|
293
|
+
return [{ type: 'text', text: head + (body ? '\n\n' + body : '') }]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderStatus(value, provider) {
|
|
297
|
+
const v = value || {}
|
|
298
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
299
|
+
const lines = []
|
|
300
|
+
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' : ''))
|
|
301
|
+
const projList = (v.projects && v.projects.length) ? v.projects : null
|
|
302
|
+
if (projList) {
|
|
303
|
+
for (const p of projList) {
|
|
304
|
+
const cur = p.current ? (' step ' + p.current.stepIndex + ' → ' + p.current.tool + (p.current.args ? ' ' + JSON.stringify(p.current.args) : '')) : (p.running > 0 ? ' (starting / thinking)' : '')
|
|
305
|
+
const usage = (p.runs ? ' | total ' + p.runs + ' runs, ' + (p.totalTokens || 0) + ' tokens' : '')
|
|
306
|
+
const bkTag = (p.lastBackend && p.lastBackend !== bk) ? ' [' + p.lastBackend + ']' : ''
|
|
307
|
+
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)
|
|
308
|
+
if (p.trail && p.trail.length) {
|
|
309
|
+
lines.push(' steps:')
|
|
310
|
+
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) }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
if (v.current) { const c = v.current; lines.push('current: step ' + c.stepIndex + ' → ' + c.tool + (c.args ? ' ' + JSON.stringify(c.args) : '')) }
|
|
315
|
+
else if (v.state === 'running') lines.push('current: (starting / thinking)')
|
|
316
|
+
if (v.trail && v.trail.length) {
|
|
317
|
+
lines.push('recent steps:')
|
|
318
|
+
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) }
|
|
319
|
+
}
|
|
320
|
+
if (v.lastStatus) lines.push('last: ' + v.lastStatus + (v.lastSessionId ? ' session=' + v.lastSessionId : '') + (v.lastAt ? ' @ ' + new Date(v.lastAt).toISOString() : ''))
|
|
321
|
+
}
|
|
322
|
+
if (v.updatedAt) lines.push('updatedAt: ' + new Date(v.updatedAt).toISOString())
|
|
323
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── MCP 子进程快照上报 ────────────────────────────────────────────────────────
|
|
327
|
+
// MCP server 是独立 stdio 子进程:没有 ctx.emit、拿不到 collector 服务,所以把快照
|
|
328
|
+
// 原子写进 {dsh-home}/cli-bridge-mcp-<provider>.json,家级灯在响应请求时读取合并。
|
|
329
|
+
function buildMcpBridgePayload(snap, pid, nowMs) {
|
|
330
|
+
const s = snap && typeof snap === 'object' ? snap : {}
|
|
331
|
+
const src = Array.isArray(s.projects) ? s.projects : []
|
|
332
|
+
const projects = []
|
|
333
|
+
for (const p of src) {
|
|
334
|
+
if (!p || !p.cwd) continue
|
|
335
|
+
projects.push({
|
|
336
|
+
cwd: String(p.cwd), name: p.name ? String(p.name) : '', provider: p.provider || s.provider || '', state: p.state ? String(p.state) : 'idle',
|
|
337
|
+
running: Number(p.running) || 0,
|
|
338
|
+
current: p.current ? { stepIndex: Number(p.current.stepIndex) || 0, tool: String(p.current.tool || ''), args: p.current.args || null } : null,
|
|
339
|
+
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 })) : [],
|
|
340
|
+
lastStatus: p.lastStatus ? String(p.lastStatus) : null, lastAt: Number(p.lastAt) || 0, lastSessionId: p.lastSessionId ? String(p.lastSessionId) : null,
|
|
341
|
+
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
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
return { source: 'mcp', provider: s.provider || '', pid: Number(pid) || 0, updatedAt: Number(nowMs) || 0, projects }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function normalizeMcpBridge(payload, nowMs, staleMs) {
|
|
348
|
+
if (!payload || typeof payload !== 'object') return null
|
|
349
|
+
const projects = Array.isArray(payload.projects) ? payload.projects : []
|
|
350
|
+
const updatedAt = Number(payload.updatedAt) || 0
|
|
351
|
+
const stale = (Number(nowMs) || 0) - updatedAt > (Number(staleMs) || MCP_BRIDGE_STALE_MS)
|
|
352
|
+
const out = []
|
|
353
|
+
for (const p of projects) {
|
|
354
|
+
if (!p || !p.cwd) continue
|
|
355
|
+
if (stale && (Number(p.running) || 0) > 0) out.push(Object.assign({}, p, { running: 0, current: null, state: p.state === 'running' ? 'idle' : p.state }))
|
|
356
|
+
else out.push(p)
|
|
357
|
+
}
|
|
358
|
+
return { source: 'mcp', provider: payload.provider || '', pid: Number(payload.pid) || 0, updatedAt, stale, projects: out }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── 执行编排(三形态共用;provider 差异全走 descriptor)──────────────────────
|
|
362
|
+
// o: {
|
|
363
|
+
// ctx, // Cordis ctx(interval/timeout/get)
|
|
364
|
+
// subprocess, // DSH subprocess 服务
|
|
365
|
+
// provider, // descriptor(见 PROVIDER-CONTRACT.md)
|
|
366
|
+
// engine, // createStatusEngine({provider}) 实例
|
|
367
|
+
// resolveExe, // async (backend, signal) => 'exe' | [nodeExe, binPath] | throw
|
|
368
|
+
// getCwdFallback, // () => 默认 cwd
|
|
369
|
+
// getPreferredBackend, // () => 用户偏好后端(可缺省)
|
|
370
|
+
// getSettings, // () => 当前设置对象(可缺省;provider 用它读 defaultModel 等)
|
|
371
|
+
// planActiveFor, // (exec) => bool(plan 模式判定,可缺省)
|
|
372
|
+
// defaultMode // 'auto'
|
|
373
|
+
// }
|
|
374
|
+
function createRunner(o) {
|
|
375
|
+
const ctx = o.ctx
|
|
376
|
+
const subprocess = o.subprocess
|
|
377
|
+
const provider = o.provider
|
|
378
|
+
const engine = o.engine
|
|
379
|
+
const stdio = { stdin: 'ignore', stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } }, stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } } }
|
|
380
|
+
|
|
381
|
+
function readStreams(handle) {
|
|
382
|
+
const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
383
|
+
const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
|
|
384
|
+
return { stdoutText, stderrText }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let built_env = null
|
|
388
|
+
function spawnSpec(argv, cwd, callerSignal) {
|
|
389
|
+
const spec = { argv, cwd, stdio, graceMs: 5000 }
|
|
390
|
+
if (callerSignal) spec.signal = callerSignal
|
|
391
|
+
if (built_env && typeof built_env === 'object') spec.env = built_env
|
|
392
|
+
return spec
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function startLiveParser(handle, cwd) {
|
|
396
|
+
let cursor = 0
|
|
397
|
+
const stream = createLineStream((ln) => {
|
|
398
|
+
const t = ln.trim()
|
|
399
|
+
if (!t.startsWith('{')) return
|
|
400
|
+
try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
|
|
401
|
+
})
|
|
402
|
+
const tick = function () {
|
|
403
|
+
try {
|
|
404
|
+
const full = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
405
|
+
if (full.length <= cursor) return
|
|
406
|
+
const fresh = full.slice(cursor)
|
|
407
|
+
cursor = full.length
|
|
408
|
+
stream.pushChunk(fresh)
|
|
409
|
+
} catch (e) {}
|
|
410
|
+
}
|
|
411
|
+
const disposeInterval = ctx.interval(tick, 250)
|
|
412
|
+
return function disposeLive() { try { stream.flush() } catch (e) {} disposeInterval() }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function runSync(argv, cwd, timeoutSec, callerSignal, extraEnv) {
|
|
416
|
+
const spec = spawnSpec(argv, cwd, callerSignal)
|
|
417
|
+
if (extraEnv && typeof extraEnv === 'object') spec.env = Object.assign({}, spec.env, extraEnv)
|
|
418
|
+
const handle = subprocess.spawn(spec)
|
|
419
|
+
let lastEventSummary = '(no events yet)'
|
|
420
|
+
let timedOut = false
|
|
421
|
+
const disposeTimer = ctx.timeout(function () {
|
|
422
|
+
timedOut = true
|
|
423
|
+
try {
|
|
424
|
+
const p = engine.ensureProject(cwd)
|
|
425
|
+
lastEventSummary = (p.current ? ('last step ' + p.current.stepIndex + ' -> ' + p.current.tool) : '') +
|
|
426
|
+
' | trail=' + (p.trail.length ? p.trail.slice(-2).map((e) => '[' + e.state + ']' + e.tool).join(',') : 'empty') +
|
|
427
|
+
' | elapsed=' + Math.round((Date.now() - (p.updatedAt || Date.now())) / 1000) + 's since last activity'
|
|
428
|
+
} catch (e) {}
|
|
429
|
+
try { handle.terminate() } catch (e) {}
|
|
430
|
+
}, (timeoutSec + 60) * 1000)
|
|
431
|
+
const disposeLive = startLiveParser(handle, cwd)
|
|
432
|
+
try {
|
|
433
|
+
const outcome = await handle.done
|
|
434
|
+
const s = readStreams(handle)
|
|
435
|
+
return { outcome, stdoutText: s.stdoutText, stderrText: s.stderrText, timedOut, lastEventSummary }
|
|
436
|
+
} finally { disposeLive(); disposeTimer() }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function askFallback(exec, res, canRetry) {
|
|
440
|
+
const uq = ctx.get('userQuestions')
|
|
441
|
+
if (!uq || !exec || !exec.agent) return 'error'
|
|
442
|
+
const labels = fallbackLabels(provider)
|
|
443
|
+
const bk = provider.id
|
|
444
|
+
const detail = String(res.stderr || res.response || res.status || '').slice(-600)
|
|
445
|
+
const opts = [{ label: labels.fallback, description: '本次改由 DSH 本地模型/原生工具完成,不再走 ' + bk }]
|
|
446
|
+
if (canRetry) opts.push({ label: labels.retry, description: '再调用一次 ' + bk + '(网络抖动时可用)' })
|
|
447
|
+
opts.push({ label: labels.cancel, description: '不回退,直接返回 ' + bk + ' 错误' })
|
|
448
|
+
try {
|
|
449
|
+
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 }] })
|
|
450
|
+
const sel = (ans && ans.answers && ans.answers[0] && ans.answers[0].selected) || []
|
|
451
|
+
if (sel.indexOf(labels.fallback) >= 0) return 'fallback'
|
|
452
|
+
if (sel.indexOf(labels.retry) >= 0) return 'retry'
|
|
453
|
+
return 'error'
|
|
454
|
+
} catch (e) { return 'error' }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function coreExecute(rawArgs, exec) {
|
|
458
|
+
const args = rawArgs || {}
|
|
459
|
+
built_env = null
|
|
460
|
+
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)
|
|
461
|
+
if (!args.prompt || !String(args.prompt).trim()) return mk({ stderr: 'prompt is required' })
|
|
462
|
+
|
|
463
|
+
const target = engine.resolveTarget(args, o.getCwdFallback())
|
|
464
|
+
const backend = provider.resolveBackend ? provider.resolveBackend(args, target, o.getPreferredBackend ? o.getPreferredBackend() : null) : (o.getPreferredBackend ? o.getPreferredBackend() : provider.defaultBackend)
|
|
465
|
+
const cwd = target.cwd
|
|
466
|
+
|
|
467
|
+
// provider 前置检查(凭据/桌面健康等):返回 { abort: res } 则直接结束。
|
|
468
|
+
if (provider.preflight) {
|
|
469
|
+
const pre = provider.preflight({ args, exec, backend, cwd, engine, ctx, o })
|
|
470
|
+
if (pre && pre.abort) { engine.begin(cwd); engine.end(pre.abort, cwd); return pre.abort }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let exePrefix = provider.fallbackExePrefix ? provider.fallbackExePrefix(backend) : [provider.exeName || provider.id]
|
|
474
|
+
let exeOk = true, resolveErr = ''
|
|
475
|
+
try { const resExe = await o.resolveExe(backend, exec ? exec.signal : undefined); exePrefix = Array.isArray(resExe) ? resExe : [resExe] }
|
|
476
|
+
catch (e) { exeOk = false; resolveErr = String(e && e.message || e) }
|
|
477
|
+
|
|
478
|
+
const built = provider.buildArgv(exePrefix, args, {
|
|
479
|
+
planActive: o.planActiveFor ? o.planActiveFor(exec) : false,
|
|
480
|
+
defaultMode: provider.defaultMode || 'auto',
|
|
481
|
+
defaultModel: (!args.model && o.getSettings) ? (o.getSettings() || {}).defaultModel || null : null,
|
|
482
|
+
backend, settings: o.getSettings ? o.getSettings() : null, engine
|
|
483
|
+
})
|
|
484
|
+
built_env = (built.env && typeof built.env === 'object') ? built.env : null
|
|
485
|
+
const unavailableStatus = provider.unavailableStatus || 'CLI_UNAVAILABLE'
|
|
486
|
+
|
|
487
|
+
if (!exeOk) {
|
|
488
|
+
engine.begin(cwd)
|
|
489
|
+
let res = mk({ status: unavailableStatus, mode: built.mode, backend, stderr: (backend || provider.id) + ' executable not found: ' + resolveErr })
|
|
490
|
+
if (await askFallback(exec, res, true) === 'fallback') res = fallbackResult(res, built.mode, provider)
|
|
491
|
+
engine.end(res, cwd); return res
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 非 spawn 路径(如 mimo 桌面走本地 HTTP):provider 若给 execute 且(未给 delegates
|
|
495
|
+
// 或 delegates(backend)=true)则完全交给它;否则落回通用 pipeline(jobs/重试/弹窗)。
|
|
496
|
+
if (provider.execute && (!provider.delegates || provider.delegates(backend, args))) {
|
|
497
|
+
engine.begin(cwd)
|
|
498
|
+
try {
|
|
499
|
+
const res = await provider.execute({ args, exec, backend, cwd, built, engine, ctx, o, runSync })
|
|
500
|
+
engine.end(res, cwd); return res
|
|
501
|
+
} catch (e) {
|
|
502
|
+
const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
|
|
503
|
+
engine.end(res, cwd); return res
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const jobs = ctx.get('jobs')
|
|
508
|
+
if (args.background && jobs && exec && exec.agent) {
|
|
509
|
+
try {
|
|
510
|
+
engine.begin(cwd)
|
|
511
|
+
const jobId = jobs.start({
|
|
512
|
+
kind: 'bash', label: provider.id + ': ' + shortLabel(args.prompt), owner: exec.agent,
|
|
513
|
+
run() {
|
|
514
|
+
const handle = subprocess.spawn(spawnSpec(built.argv, cwd))
|
|
515
|
+
const disposeLive = startLiveParser(handle, cwd)
|
|
516
|
+
let bgKilled = false
|
|
517
|
+
const disposeGuard = ctx.timeout(function () { bgKilled = true; try { handle.terminate() } catch (e) {} }, (built.timeoutSec + 60) * 1000)
|
|
518
|
+
const done = handle.done.then(function (outcome) {
|
|
519
|
+
disposeLive(); disposeGuard()
|
|
520
|
+
const s = readStreams(handle)
|
|
521
|
+
const res = provider.buildResult(provider.parseJson(s.stdoutText), outcome, built.mode, s.stderrText, s.stdoutText, backend)
|
|
522
|
+
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]' }
|
|
523
|
+
engine.end(res, cwd)
|
|
524
|
+
return { status: res.ok ? 'completed' : 'failed', detail: provider.id + ' ' + res.status, output: JSON.stringify(res) }
|
|
525
|
+
}).catch(function (err) { disposeLive(); disposeGuard(); engine.end(mk({ status: 'JOB_ERROR', backend }), cwd); return { status: 'failed', detail: String(err && err.message || err) } })
|
|
526
|
+
return { cancel: function () { try { handle.terminate() } catch (e) {} }, done }
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
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.' }
|
|
530
|
+
} catch (e) {
|
|
531
|
+
const res = mk({ status: 'JOB_START_ERROR', mode: built.mode, backend, stderr: 'failed to start background job: ' + String(e && e.message || e) })
|
|
532
|
+
engine.end(res, cwd); return res
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
engine.begin(cwd)
|
|
537
|
+
try {
|
|
538
|
+
let attempt = 0, cliRetried = false, res
|
|
539
|
+
while (true) {
|
|
540
|
+
attempt += 1
|
|
541
|
+
const r = await runSync(built.argv, cwd, built.timeoutSec, exec ? exec.signal : undefined, built.env)
|
|
542
|
+
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 }
|
|
543
|
+
res = provider.buildResult(provider.parseJson(r.stdoutText), r.outcome, built.mode, r.stderrText, r.stdoutText, backend)
|
|
544
|
+
if (res.ok) break
|
|
545
|
+
if (provider.isTransientCliError && provider.isTransientCliError(res)) {
|
|
546
|
+
if (!cliRetried) { cliRetried = true; attempt -= 1; continue }
|
|
547
|
+
res.retried = true; break
|
|
548
|
+
}
|
|
549
|
+
if (!(provider.isLimited ? provider.isLimited(res) : isLimited(res))) break
|
|
550
|
+
const decision = await askFallback(exec, res, attempt < 2)
|
|
551
|
+
if (decision === 'fallback') { res = fallbackResult(res, built.mode, provider); break }
|
|
552
|
+
if (decision === 'retry' && attempt < 2) continue
|
|
553
|
+
break
|
|
554
|
+
}
|
|
555
|
+
engine.end(res, cwd); return res
|
|
556
|
+
} catch (e) {
|
|
557
|
+
const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
|
|
558
|
+
const out = (await askFallback(exec, res, true) === 'fallback') ? fallbackResult(res, built.mode, provider) : res
|
|
559
|
+
engine.end(out, cwd); return out
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { coreExecute, runSync, askFallback }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// providers/codex.mjs — codex provider descriptor(第五家 CLI,无旧包可迁)。
|
|
567
|
+
//
|
|
568
|
+
// 基线 = 本机实测 codex-cli 0.98.0(Windows,mise/cherrystudio shim)+ 真实 argv/事件流。
|
|
569
|
+
// 写代码时最容易踩的几条(全部实机验证过,见 docs 里 codex 任务卡):
|
|
570
|
+
// • 非交互 = `codex exec [OPTIONS] "PROMPT"`,prompt 是**位置参数**,必须放最后。
|
|
571
|
+
// • ⚠️ `codex exec` **不接受** `-a/--ask-for-approval`(clap 直接报 unexpected argument;
|
|
572
|
+
// 那是根命令 `codex` 的旗)。要设审批策略只能走 `-c approval_policy="never"`。
|
|
573
|
+
// • ⚠️ `codex exec resume` 只认 [SESSION_ID] [PROMPT] + -c/-m/-i/--json/--last/--all/
|
|
574
|
+
// --skip-git-repo-check/--full-auto;**没有 -C、没有 -s、没有 -o、没有 --add-dir**
|
|
575
|
+
// (实测传 -C 报 unexpected argument)。续聊的 cwd 只能靠 spawn 的 cwd。
|
|
576
|
+
// • 非 git 目录必须带 --skip-git-repo-check,否则直接拒绝运行。
|
|
577
|
+
// • `--json` → NDJSON 事件流:thread.started / turn.started / item.started /
|
|
578
|
+
// item.completed / turn.completed;失败另加顶层 `error` + `turn.failed`。
|
|
579
|
+
// ⚠️ item.completed 里也可能出现 item.type==="error" 的**告警**(如「此会话原模型与
|
|
580
|
+
// 当前模型不同」),那是正常续聊的提示,绝不可当成致命错误(实测 --last 续聊就带)。
|
|
581
|
+
// • 退出码:0 成功 / 1 运行失败(stdout 仍是合法 JSON)/ 2 参数错误。
|
|
582
|
+
// • 无 --json 时 stdout 就是纯文本答复,stderr 有元信息头(version/workdir/model/
|
|
583
|
+
// provider/approval/sandbox/session id)—— 诊断只读它,不解析成结果。
|
|
584
|
+
//
|
|
585
|
+
// 红线(PROVIDER-CONTRACT.md §1/§8):
|
|
586
|
+
// - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
|
|
587
|
+
// process 只经 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
|
|
588
|
+
// - 只允许 import '../core/bridge-kit.mjs' 与同目录 codex-*.mjs 片文件(片文件由 build
|
|
589
|
+
// 自动内联进同一作用域;主文件超 600 行红线所以必须拆)。
|
|
590
|
+
// import { createStatusEngine, createRunner, clampInt, isLimited, endpointHost } from '../core/bridge-kit.mjs'
|
|
591
|
+
// import { codexSetHost, codexResolveExePrefix, codexExeCandidates, codexReadConfig, codexModelsEndpoint, codexModelOptions, codexCachedLiveModels, codexLiveModels, codexCatalogModels, codexBuiltinModels, codexHomeDir } from './codex-cli.mjs'
|
|
592
|
+
// import { codexModelsTool } from './codex-models.mjs'
|
|
593
|
+
|
|
594
|
+
function nowIso() { return new Date().toISOString() }
|
|
595
|
+
|
|
596
|
+
// 实测枚举(根命令 help + exec help)。
|
|
597
|
+
const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
|
|
598
|
+
const APPROVAL_POLICIES = ['untrusted', 'on-failure', 'on-request', 'never']
|
|
599
|
+
const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh']
|
|
600
|
+
|
|
601
|
+
// ── 三工具 schema ────────────────────────────────────────────────────────────
|
|
602
|
+
const RUN_PARAMS = {
|
|
603
|
+
type: 'object',
|
|
604
|
+
additionalProperties: false,
|
|
605
|
+
required: ['prompt'],
|
|
606
|
+
properties: {
|
|
607
|
+
prompt: { type: 'string', description: 'The task instruction for codex. 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 — codex reads the repo itself). For follow-ups on the same topic, use codex_continue with latest=true or sessionId (codex keeps the thread context; state only what is new).' },
|
|
608
|
+
mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'auto follows the DSH plan state; plan = read-only sandbox (no writes), accept-edits = workspace-write. Default auto. An explicit sandboxMode overrides this.' },
|
|
609
|
+
model: { type: 'string', description: 'Optional codex model id (e.g. "minimax/minimax-m3.1-flash:free"). Omit to use the CLI Bridge settings default, or codex\'s own ~/.codex/config.toml model. Run codex_models first; the server rejects unknown ids with HTTP 400.' },
|
|
610
|
+
sandboxMode: { type: 'string', enum: ['read-only', 'workspace-write', 'danger-full-access'], description: 'Optional codex sandbox policy (-s). Overrides the mode-derived default and the settings panel value.' },
|
|
611
|
+
reasoningEffort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh'], description: 'Optional model_reasoning_effort override (-c model_reasoning_effort="…"). Ignored by models that do not support it.' },
|
|
612
|
+
cwd: { type: 'string', description: 'Working directory for codex (-C, and the spawn cwd). Defaults to the DSH workspace root.' },
|
|
613
|
+
addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra writable directories (--add-dir). Ignored on resume (codex exec resume has no --add-dir).' },
|
|
614
|
+
images: { type: 'array', items: { type: 'string' }, description: 'Image files to attach to the prompt (-i).' },
|
|
615
|
+
timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 900).' },
|
|
616
|
+
background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with codex_status. Set false to block for the final answer.' }
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const CONTINUE_PARAMS = {
|
|
620
|
+
type: 'object',
|
|
621
|
+
additionalProperties: false,
|
|
622
|
+
required: ['prompt'],
|
|
623
|
+
properties: {
|
|
624
|
+
prompt: { type: 'string', description: 'Follow-up instruction for the ongoing codex thread. State only what is new; codex preserves the thread context.' },
|
|
625
|
+
sessionId: { type: 'string', description: 'codex thread id to resume (the thread_id from a prior codex_run result).' },
|
|
626
|
+
latest: { type: 'boolean', description: 'Resume the most recent codex thread in this working directory (exec resume --last) instead of a specific id.' },
|
|
627
|
+
mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'Execution mode; default auto. On resume codex has no -s flag, so the sandbox is applied via -c sandbox_mode="…".' },
|
|
628
|
+
model: { type: 'string', description: 'Optional codex model id.' },
|
|
629
|
+
sandboxMode: { type: 'string', enum: ['read-only', 'workspace-write', 'danger-full-access'], description: 'Optional sandbox policy applied through -c sandbox_mode="…" (resume has no -s).' },
|
|
630
|
+
reasoningEffort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh'], description: 'Optional model_reasoning_effort override.' },
|
|
631
|
+
cwd: { type: 'string', description: 'Working directory (spawn cwd only — resume has no -C).' },
|
|
632
|
+
images: { type: 'array', items: { type: 'string' }, description: 'Image files to attach (-i).' },
|
|
633
|
+
timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 900).' },
|
|
634
|
+
background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with codex_status. Set false to block.' }
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
|
|
638
|
+
|
|
639
|
+
function defineProvider (deps = {}) {
|
|
640
|
+
const env = deps.env || {}
|
|
641
|
+
let host = null
|
|
642
|
+
let settingsRef = { binPath: '', defaultModel: '', sandboxMode: '', approvalPolicy: '' }
|
|
643
|
+
codexSetHost(env, deps.nodeFs || null, deps.nodePath || null)
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 取当前生效的偏好设置(三条来源按优先级合并,每次 buildArgv 重读 —— 面板改默认
|
|
647
|
+
* 模型后无需重启即生效):
|
|
648
|
+
* 1) deps.readSettings():MCP 形态(真 Node,每次现读设置文件);
|
|
649
|
+
* 2) host.getSettings('codex'):preset/dynamic 形态(attach 注入);
|
|
650
|
+
* 3) settingsRef:attach 时固化的一次性快照 / 缺省值。
|
|
651
|
+
*/
|
|
652
|
+
function currentSettings() {
|
|
653
|
+
const out = Object.assign({}, settingsRef)
|
|
654
|
+
try {
|
|
655
|
+
if (typeof deps.readSettings === 'function') {
|
|
656
|
+
const r = deps.readSettings()
|
|
657
|
+
if (r && typeof r === 'object') {
|
|
658
|
+
const part = ('partition' in r || 'path' in r || 'home' in r) ? r.partition : r
|
|
659
|
+
if (part && typeof part === 'object') Object.assign(out, part)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
} catch (e) { /* 设置读失败 = 用缺省,不影响本次调用 */ }
|
|
663
|
+
try {
|
|
664
|
+
if (host && typeof host.getSettings === 'function') {
|
|
665
|
+
const s = host.getSettings('codex')
|
|
666
|
+
if (s && typeof s === 'object') Object.assign(out, s)
|
|
667
|
+
}
|
|
668
|
+
} catch (e) { /* 同上 */ }
|
|
669
|
+
return out
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function sandboxFor(mode, args, settings) {
|
|
673
|
+
if (args && args.sandboxMode && SANDBOX_MODES.indexOf(String(args.sandboxMode)) >= 0) return String(args.sandboxMode)
|
|
674
|
+
if (settings && settings.sandboxMode && SANDBOX_MODES.indexOf(String(settings.sandboxMode)) >= 0) return String(settings.sandboxMode)
|
|
675
|
+
return mode === 'plan' ? 'read-only' : 'workspace-write'
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const descriptor = {
|
|
679
|
+
id: 'codex', label: 'CODEX', presetId: 'codex-first', defaultBackend: null, defaultMode: 'auto',
|
|
680
|
+
exeName: 'codex', binEnvVars: ['CODEX_BIN', 'CODEX_CLI'], unavailableStatus: 'CODEX_UNAVAILABLE',
|
|
681
|
+
|
|
682
|
+
// 单后端:model_provider/base_url 属于 codex 自身配置,桥不引入第二后端概念。
|
|
683
|
+
backends() { return null },
|
|
684
|
+
normalizeBackend() { return null },
|
|
685
|
+
resolveBackend(args, target, preferred) { return null },
|
|
686
|
+
|
|
687
|
+
// 实测 flag 集(exec 与 exec resume 的可用旗不同 —— 见文件头)。
|
|
688
|
+
buildArgv(exePrefix, args, opts) {
|
|
689
|
+
const o = opts || {}
|
|
690
|
+
const a = args || {}
|
|
691
|
+
const settings = currentSettings()
|
|
692
|
+
const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
|
|
693
|
+
const resume = !!(a.sessionId || a.continueLatest || a.latest === true)
|
|
694
|
+
argv.push('exec')
|
|
695
|
+
if (resume) {
|
|
696
|
+
argv.push('resume')
|
|
697
|
+
if (a.sessionId) argv.push(String(a.sessionId))
|
|
698
|
+
else argv.push('--last')
|
|
699
|
+
}
|
|
700
|
+
// --json 是所有折叠/解析的前提;--skip-git-repo-check 让非 git 目录也能跑。
|
|
701
|
+
argv.push('--json', '--skip-git-repo-check')
|
|
702
|
+
// -C 只有 exec 有;resume 的 cwd 由 spawn 的 cwd 决定(实测 resume 传 -C 直接报错)。
|
|
703
|
+
const cwd = a.cwd ? String(a.cwd) : null
|
|
704
|
+
if (cwd && !resume) argv.push('-C', cwd)
|
|
705
|
+
|
|
706
|
+
let mode = a.mode || 'auto'
|
|
707
|
+
if (mode === 'auto') mode = o.planActive ? 'plan' : 'accept-edits'
|
|
708
|
+
const sandbox = sandboxFor(mode, a, settings)
|
|
709
|
+
if (resume) argv.push('-c', 'sandbox_mode="' + sandbox + '"')
|
|
710
|
+
else argv.push('-s', sandbox)
|
|
711
|
+
|
|
712
|
+
const approval = (settings && settings.approvalPolicy && APPROVAL_POLICIES.indexOf(String(settings.approvalPolicy)) >= 0)
|
|
713
|
+
? String(settings.approvalPolicy)
|
|
714
|
+
: ''
|
|
715
|
+
// exec 没有 -a/--ask-for-approval;等价效果只能经 -c 覆盖(TOML 字符串值)。
|
|
716
|
+
if (approval) argv.push('-c', 'approval_policy="' + approval + '"')
|
|
717
|
+
|
|
718
|
+
const model = a.model || o.defaultModel || settings.defaultModel || ''
|
|
719
|
+
if (model) argv.push('-m', String(model))
|
|
720
|
+
if (a.reasoningEffort && REASONING_EFFORTS.indexOf(String(a.reasoningEffort)) >= 0) {
|
|
721
|
+
argv.push('-c', 'model_reasoning_effort="' + String(a.reasoningEffort) + '"')
|
|
722
|
+
}
|
|
723
|
+
if (!resume && Array.isArray(a.addDirs)) for (const d of a.addDirs) { if (d) argv.push('--add-dir', String(d)) }
|
|
724
|
+
if (Array.isArray(a.images)) for (const f of a.images) { if (f) argv.push('-i', String(f)) }
|
|
725
|
+
// prompt 必须最后(位置参数)。
|
|
726
|
+
argv.push(String(a.prompt))
|
|
727
|
+
const timeoutSec = clampInt(a.timeoutSec, 900, 10, 3600)
|
|
728
|
+
return { argv, mode, timeoutSec }
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
// NDJSON(--json)。宽容:坏行跳过;无任何 JSON 行返回 null(交 buildResult 走纯文本)。
|
|
732
|
+
// 终止判据 = turn.completed + 退出码 0;顶层 error / turn.failed 才算致命。
|
|
733
|
+
// ⚠️ item.completed 里 item.type==="error" 只是告警(实测续聊会带),绝不致命。
|
|
734
|
+
parseJson(stdoutText) {
|
|
735
|
+
const trimmed = String(stdoutText || '').trim()
|
|
736
|
+
if (!trimmed) return null
|
|
737
|
+
let sawJson = false
|
|
738
|
+
let threadId = null
|
|
739
|
+
let response = ''
|
|
740
|
+
let usage = null
|
|
741
|
+
let completed = false
|
|
742
|
+
let fatal = null
|
|
743
|
+
let turns = 0
|
|
744
|
+
for (const raw of trimmed.split(/\r?\n/)) {
|
|
745
|
+
const ln = raw.trim()
|
|
746
|
+
if (ln.charAt(0) !== '{') continue
|
|
747
|
+
let ev
|
|
748
|
+
try { ev = JSON.parse(ln) } catch (e) { continue }
|
|
749
|
+
if (!ev || typeof ev !== 'object') continue
|
|
750
|
+
sawJson = true
|
|
751
|
+
const t = ev.type
|
|
752
|
+
if (t === 'thread.started') {
|
|
753
|
+
if (typeof ev.thread_id === 'string') threadId = ev.thread_id
|
|
754
|
+
} else if (t === 'turn.started') {
|
|
755
|
+
turns += 1
|
|
756
|
+
} else if (t === 'turn.completed') {
|
|
757
|
+
completed = true
|
|
758
|
+
if (ev.usage && typeof ev.usage === 'object') usage = ev.usage
|
|
759
|
+
} else if (t === 'turn.failed') {
|
|
760
|
+
const msg = ev.error && typeof ev.error.message === 'string' ? ev.error.message
|
|
761
|
+
: (typeof ev.error === 'string' ? ev.error : 'turn failed')
|
|
762
|
+
fatal = { message: msg }
|
|
763
|
+
} else if (t === 'error') {
|
|
764
|
+
// 顶层 error 事件:message 常常是嵌套 JSON 字符串(实测)。
|
|
765
|
+
fatal = { message: typeof ev.message === 'string' ? ev.message : 'error event' }
|
|
766
|
+
} else if (t === 'item.completed' && ev.item && typeof ev.item === 'object') {
|
|
767
|
+
if (ev.item.type === 'agent_message' && typeof ev.item.text === 'string') response = ev.item.text
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (!sawJson) return null
|
|
771
|
+
return {
|
|
772
|
+
type: completed ? 'turn.completed' : (fatal ? 'turn.failed' : 'incomplete'),
|
|
773
|
+
threadId: threadId || null,
|
|
774
|
+
response: String(response || ''),
|
|
775
|
+
usage: usage || null,
|
|
776
|
+
completed: completed,
|
|
777
|
+
error: fatal,
|
|
778
|
+
numTurns: turns || null
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
// 统一结果对象(契约 §1)。成功必须同时满足:退出码 0 + 收到 turn.completed +
|
|
783
|
+
// 无顶层错误事件。只看退出码不够(实测 400 时 stdout 仍是合法事件流,退出码 1)。
|
|
784
|
+
buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
|
|
785
|
+
const exitCode = outcome ? outcome.exitCode : null
|
|
786
|
+
const stderr = stderrText ? String(stderrText).slice(-2000) : ''
|
|
787
|
+
const mk = (over) => Object.assign({
|
|
788
|
+
ok: false, status: 'ERROR', response: '', sessionId: null, durationSeconds: null,
|
|
789
|
+
numTurns: null, totalTokens: null, duration: null, exitCode, mode, backend: backend || null, stderr
|
|
790
|
+
}, over)
|
|
791
|
+
const usageOf = (u) => {
|
|
792
|
+
if (!u || typeof u !== 'object') return null
|
|
793
|
+
const i = Number(u.input_tokens) || 0
|
|
794
|
+
const o = Number(u.output_tokens) || 0
|
|
795
|
+
return (i + o) > 0 ? i + o : null
|
|
796
|
+
}
|
|
797
|
+
if (parsed && typeof parsed === 'object') {
|
|
798
|
+
const sessionId = parsed.threadId || null
|
|
799
|
+
const totalTokens = usageOf(parsed.usage)
|
|
800
|
+
if (parsed.error) {
|
|
801
|
+
const msg = String(parsed.error.message || 'turn failed')
|
|
802
|
+
return mk({
|
|
803
|
+
status: 'TURN_FAILED', response: parsed.response || '', sessionId,
|
|
804
|
+
numTurns: parsed.numTurns, totalTokens, errorMessage: msg,
|
|
805
|
+
stderr: (stderr ? stderr + ' ' : '') + msg
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
if (exitCode === 0 && parsed.completed) {
|
|
809
|
+
return {
|
|
810
|
+
ok: true, status: 'SUCCESS', response: parsed.response || '', sessionId,
|
|
811
|
+
durationSeconds: null, numTurns: parsed.numTurns || 1, totalTokens,
|
|
812
|
+
exitCode, mode, backend: backend || null, stderr
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (exitCode === 0) {
|
|
816
|
+
return mk({
|
|
817
|
+
status: 'INCOMPLETE', response: parsed.response || '', sessionId, totalTokens,
|
|
818
|
+
numTurns: parsed.numTurns,
|
|
819
|
+
stderr: (stderr ? stderr + ' ' : '') + 'codex event stream ended without turn.completed (process exited 0). The run may have been interrupted; retry, and check `codex --version` if it persists.'
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
return mk({
|
|
823
|
+
status: exitCode === 2 ? 'BAD_ARGS' : 'ERROR',
|
|
824
|
+
response: parsed.response || '', sessionId, numTurns: parsed.numTurns, totalTokens,
|
|
825
|
+
stderr: (stderr ? stderr + ' ' : '') + 'codex exited ' + exitCode
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
const text = String(stdoutText || '').trim()
|
|
829
|
+
if (exitCode === 0) {
|
|
830
|
+
return {
|
|
831
|
+
ok: true, status: 'SUCCESS', response: text, sessionId: null,
|
|
832
|
+
durationSeconds: null, numTurns: null, totalTokens: null, exitCode, mode,
|
|
833
|
+
backend: backend || null, stderr
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return mk({
|
|
837
|
+
status: exitCode === 2 ? 'BAD_ARGS' : 'ERROR',
|
|
838
|
+
response: '',
|
|
839
|
+
stderr: stderr || ('codex exited ' + exitCode),
|
|
840
|
+
rawStdout: text.slice(-2000)
|
|
841
|
+
})
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
// 实时步骤折叠(写 project.current/trail)。事件形态见文件头。
|
|
845
|
+
// item 类型实测:command_execution / reasoning / agent_message / error(告警) /
|
|
846
|
+
// file_change / mcp_tool_call / web_search —— 未知类型也按通用形态记账,绝不吞事件。
|
|
847
|
+
foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
|
|
848
|
+
if (!ev || typeof ev !== 'object') return
|
|
849
|
+
const t = ev.type
|
|
850
|
+
if (!t) return
|
|
851
|
+
p.updatedAt = Date.now()
|
|
852
|
+
const cap = maxTrail || 12
|
|
853
|
+
const capLen = maxArgLen || 120
|
|
854
|
+
const pushTrail = (entry) => { p.trail.push(entry); if (p.trail.length > cap) p.trail.shift() }
|
|
855
|
+
if (t === 'thread.started') {
|
|
856
|
+
p.current = { tool: 'session', args: { thread_id: String(ev.thread_id || '').slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
if (t === 'turn.completed' || t === 'turn.failed') { p.current = null; return }
|
|
860
|
+
if (t === 'error') {
|
|
861
|
+
pushTrail({ stepIndex: p.trail.length, state: 'ERROR', tool: 'error', args: { message: String(ev.message || '').slice(0, capLen) }, at: nowIso() })
|
|
862
|
+
p.current = null
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
if (t !== 'item.started' && t !== 'item.completed') return
|
|
866
|
+
const item = ev.item && typeof ev.item === 'object' ? ev.item : null
|
|
867
|
+
if (!item) return
|
|
868
|
+
const kind = String(item.type || 'item')
|
|
869
|
+
const failed = kind === 'error' || item.status === 'failed' || (typeof item.exit_code === 'number' && item.exit_code !== 0)
|
|
870
|
+
const state = t === 'item.started' ? 'ACTIVE' : (failed ? 'ERROR' : 'DONE')
|
|
871
|
+
let tool = kind
|
|
872
|
+
let args = null
|
|
873
|
+
if (kind === 'command_execution') {
|
|
874
|
+
tool = 'command'
|
|
875
|
+
args = summarizeArgs({ command: item.command })
|
|
876
|
+
} else if (kind === 'agent_message') {
|
|
877
|
+
tool = 'agent_response'
|
|
878
|
+
args = { text_delta: String(item.text || '').slice(0, capLen) }
|
|
879
|
+
} else if (kind === 'reasoning') {
|
|
880
|
+
tool = 'reasoning'
|
|
881
|
+
args = { text_delta: String(item.text || item.summary || '').slice(0, capLen) }
|
|
882
|
+
} else if (kind === 'file_change') {
|
|
883
|
+
tool = 'file_change'
|
|
884
|
+
const changes = Array.isArray(item.changes) ? item.changes.map((c) => (c && (c.path || c.file)) || '').filter(Boolean) : []
|
|
885
|
+
args = summarizeArgs({ paths: changes.slice(0, 6) })
|
|
886
|
+
} else if (kind === 'mcp_tool_call') {
|
|
887
|
+
tool = 'mcp:' + String(item.server || '?') + '/' + String(item.tool || item.name || '?')
|
|
888
|
+
args = summarizeArgs(item.arguments || null)
|
|
889
|
+
} else if (kind === 'web_search') {
|
|
890
|
+
tool = 'web_search'
|
|
891
|
+
args = summarizeArgs({ query: item.query })
|
|
892
|
+
} else if (kind === 'error') {
|
|
893
|
+
tool = 'warning'
|
|
894
|
+
args = { message: String(item.message || '').slice(0, capLen) }
|
|
895
|
+
} else {
|
|
896
|
+
args = summarizeArgs({ type: kind })
|
|
897
|
+
}
|
|
898
|
+
const key = item.id ? String(item.id) : null
|
|
899
|
+
p.codexByItem = p.codexByItem || Object.create(null)
|
|
900
|
+
const prior = key ? p.codexByItem[key] : null
|
|
901
|
+
if (prior) {
|
|
902
|
+
prior.state = state
|
|
903
|
+
if (key) delete p.codexByItem[key]
|
|
904
|
+
if (p.current && p.current.itemKey === key) p.current = null
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
const entry = { stepIndex: p.trail.length, state, tool, args: args || null, at: nowIso() }
|
|
908
|
+
pushTrail(entry)
|
|
909
|
+
if (t === 'item.started') {
|
|
910
|
+
if (key) p.codexByItem[key] = entry
|
|
911
|
+
p.current = { tool, args: args || null, stepIndex: entry.stepIndex, since: nowIso(), itemKey: key }
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
// 瞬时错误重试:codex 自己会重试网络,这里只兜「流断了但非限流」这一类。
|
|
916
|
+
isTransientCliError(res) {
|
|
917
|
+
if (!res || res.ok) return false
|
|
918
|
+
if (isLimited(res)) return false
|
|
919
|
+
if (res.status === 'INCOMPLETE') return true
|
|
920
|
+
const hay = String(res.errorMessage || '') + ' ' + String(res.stderr || '')
|
|
921
|
+
return /stream disconnected|connection reset|ECONNRESET|ETIMEDOUT|temporarily unavailable|overloaded|try again/i.test(hay)
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
failureHint(res) {
|
|
925
|
+
if (!res || res.ok) return ''
|
|
926
|
+
const st = String(res.status || '')
|
|
927
|
+
if (st === 'CODEX_UNAVAILABLE') return '[诊断] 未找到 codex 可执行文件:请安装 Codex CLI,或在 CLI Bridge 设置面板填写 codex 可执行文件路径(也可设 CODEX_BIN)。'
|
|
928
|
+
if (st === 'BAD_ARGS') return '[诊断] codex 拒绝了参数(exit 2):请检查 model/sandboxMode 等取值;codex_models 可列出可用模型 id。'
|
|
929
|
+
const hay = String(res.stderr || '') + ' ' + String(res.errorMessage || '') + ' ' + String(res.response || '')
|
|
930
|
+
if (/not logged in|please run.*login|unauthorized|invalid api key|401|403/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
|
|
931
|
+
return '[诊断] codex 凭据问题(未登录 / API key 无效):请让用户执行 `codex login`,或检查 ~/.codex/config.toml 里 model_providers.<name> 的 base_url 与 experimental_bearer_token。这不是限流。'
|
|
932
|
+
}
|
|
933
|
+
if (/not a valid model|invalid model|model not found|unknown model|unsupported model/i.test(hay)) {
|
|
934
|
+
return '[诊断] 模型 id 不被服务端接受:先用 codex_models(默认 source=live)列出该端点真实可用的 id,再传 model。注意 ~/.codex/models.json 与 model_catalog_json 只是本地目录,不代表服务端可用。'
|
|
935
|
+
}
|
|
936
|
+
if (/not inside a trusted directory|not a git repository|skip-git-repo-check/i.test(hay)) {
|
|
937
|
+
return '[诊断] codex 拒绝了当前工作目录(非 git 仓库):本桥已默认加 --skip-git-repo-check;若仍出现,说明 argv 被外部改写,请检查 buildArgv。'
|
|
938
|
+
}
|
|
939
|
+
if (/approval|sandbox|permission denied|operation not permitted/i.test(hay)) {
|
|
940
|
+
return '[诊断] 沙箱/审批拒绝了操作:非交互 exec 无法弹出审批,请调高 sandboxMode(workspace-write → danger-full-access)或在设置面板改「沙箱策略」,然后重跑。'
|
|
941
|
+
}
|
|
942
|
+
if (/quota|额度|rate.?limit|insufficient|credit|balance|402/i.test(hay)) {
|
|
943
|
+
return '[诊断] 端点额度可能耗尽:换一个(优先 :free)模型,或改用原生工具完成本任务;不要循环重试。'
|
|
944
|
+
}
|
|
945
|
+
return ''
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
isSuccessStatus(st) { return st === 'SUCCESS' },
|
|
949
|
+
|
|
950
|
+
tools: {
|
|
951
|
+
run: { name: 'codex_run', description: 'Dispatch a coding/build/debug/investigation task to the local OpenAI Codex CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls codex: it always runs non-interactively (`codex exec --json`, approvals cannot block). Use read-only native tools only for quick lookups and for final build/test verification. In mode=auto the DSH plan state decides the sandbox (plan → read-only, otherwise workspace-write). Runs in the background by default (returns a jobId immediately; collect with job_output, watch progress with codex_status); set background=false to block for the final answer.', inputSchema: RUN_PARAMS },
|
|
952
|
+
continue: { name: 'codex_continue', description: 'Continue an existing codex thread with a follow-up prompt, reusing codex context (`codex exec resume`). Pass sessionId (the thread_id from a prior codex_run result), or set latest=true to resume the most recent thread in this working directory. codex exec resume has no -C/-s, so the sandbox is applied via -c and the working directory comes from the bridge session registry. Same DSH-controlled, no-prompt execution as codex_run.', inputSchema: CONTINUE_PARAMS },
|
|
953
|
+
status: { name: 'codex_status', description: 'Read a live snapshot of what the local codex agent is currently doing. Returns one section per project (working directory): running count, the current step (the command/text being produced, from the --json event stream), the recent step trail (commands, file changes, messages, errors), and the last completed run status + thread id. Optional cwd filters to a single project. Call this to check on an in-flight codex_run/codex_continue without waiting for it to finish.', inputSchema: STATUS_PARAMS }
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
// codex_models:宿主有 spawn/fetch 能力时注册(preset 注入 deps.process / MCP 注入
|
|
957
|
+
// deps.nodeChild;dynamic 沙箱形态省略 —— 与 agy/opencode 同约定)。
|
|
958
|
+
extraTools: (deps.process || deps.nodeChild || typeof deps.fetch === 'function')
|
|
959
|
+
? [codexModelsTool({ env, deps })]
|
|
960
|
+
: [],
|
|
961
|
+
|
|
962
|
+
settings: {
|
|
963
|
+
keys: ['binPath', 'defaultModel', 'sandboxMode', 'approvalPolicy'],
|
|
964
|
+
defaults: () => ({ binPath: '', defaultModel: '', sandboxMode: '', approvalPolicy: '' }),
|
|
965
|
+
normalize: (raw) => {
|
|
966
|
+
const r = raw && typeof raw === 'object' ? raw : {}
|
|
967
|
+
const pick = (v) => (typeof v === 'string' ? String(v).trim() : '')
|
|
968
|
+
const sandbox = pick(r.sandboxMode)
|
|
969
|
+
const approval = pick(r.approvalPolicy)
|
|
970
|
+
return {
|
|
971
|
+
binPath: pick(r.binPath),
|
|
972
|
+
defaultModel: pick(r.defaultModel),
|
|
973
|
+
sandboxMode: SANDBOX_MODES.indexOf(sandbox) >= 0 ? sandbox : '',
|
|
974
|
+
approvalPolicy: APPROVAL_POLICIES.indexOf(approval) >= 0 ? approval : ''
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
// 面板是同步回调:defaultModel 的候选走 codexModelOptions(实时清单优先,5 分钟
|
|
978
|
+
// 缓存;实时不可用时**降级但标注**为本地目录/内置定义,绝不假装是实时)。
|
|
979
|
+
meta: (env2) => {
|
|
980
|
+
const e = env2 && typeof env2 === 'object' ? env2 : env
|
|
981
|
+
const opts = codexModelOptions(e, { timeoutMs: 6000 })
|
|
982
|
+
const exeCandidates = codexExeCandidates(e)
|
|
983
|
+
return {
|
|
984
|
+
titleSuffix: 'Codex',
|
|
985
|
+
fields: [
|
|
986
|
+
{
|
|
987
|
+
key: 'binPath', label: 'codex 可执行文件路径', type: 'select', allowCustom: true,
|
|
988
|
+
options: exeCandidates.map((p) => ({ value: p, label: p })),
|
|
989
|
+
placeholder: '留空 = CODEX_BIN / CODEX_CLI / PATH 的 codex',
|
|
990
|
+
note: '留空即按 env › PATH 顺序解析;填 .mjs/.js 会经 node 启动(测试夹具形态)。'
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
|
|
994
|
+
options: opts.options,
|
|
995
|
+
placeholder: '留空 = 由 ~/.codex/config.toml 的 model 决定',
|
|
996
|
+
note: opts.note
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
key: 'sandboxMode', label: '沙箱策略', type: 'select',
|
|
1000
|
+
options: [
|
|
1001
|
+
{ value: '', label: '(跟随 mode / codex 自身 config)' },
|
|
1002
|
+
{ value: 'read-only', label: 'read-only(只读,plan 模式默认)' },
|
|
1003
|
+
{ value: 'workspace-write', label: 'workspace-write(可写工作区,默认)' },
|
|
1004
|
+
{ value: 'danger-full-access', label: 'danger-full-access(无沙箱,慎用)' }
|
|
1005
|
+
],
|
|
1006
|
+
note: '对应 codex 的 -s;续聊(resume)没有 -s,改经 -c sandbox_mode 生效。'
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
key: 'approvalPolicy', label: '审批策略', type: 'select',
|
|
1010
|
+
options: [
|
|
1011
|
+
{ value: '', label: '(不覆盖,用 codex 自身 config)' },
|
|
1012
|
+
{ value: 'never', label: 'never(从不审批,推荐)' },
|
|
1013
|
+
{ value: 'on-request', label: 'on-request' },
|
|
1014
|
+
{ value: 'on-failure', label: 'on-failure' },
|
|
1015
|
+
{ value: 'untrusted', label: 'untrusted' }
|
|
1016
|
+
],
|
|
1017
|
+
note: '经 -c approval_policy="…" 传入 —— codex exec 没有 -a 旗(实测报 unexpected argument)。非交互场景建议 never。'
|
|
1018
|
+
}
|
|
1019
|
+
]
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
diagnose: (settings, env2) => {
|
|
1023
|
+
const s = settings && typeof settings === 'object' ? settings : {}
|
|
1024
|
+
const e = env2 && typeof env2 === 'object' ? env2 : env
|
|
1025
|
+
const bits = []
|
|
1026
|
+
bits.push(s.binPath ? 'bin=设置:' + s.binPath : 'bin=' + codexExeCandidates(e)[0])
|
|
1027
|
+
bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(codex config 自决)')
|
|
1028
|
+
const ep = codexModelsEndpoint(e)
|
|
1029
|
+
if (ep.ok) {
|
|
1030
|
+
const cached = codexCachedLiveModels(e)
|
|
1031
|
+
bits.push('endpoint=' + endpointHost(ep.url) + (cached && cached.ok ? ' live=' + cached.count : ' live=未缓存'))
|
|
1032
|
+
} else bits.push('endpoint=未配置')
|
|
1033
|
+
bits.push('sandbox=' + (s.sandboxMode || 'mode 派生'))
|
|
1034
|
+
bits.push('approval=' + (s.approvalPolicy || 'codex config'))
|
|
1035
|
+
const cfg = codexReadConfig(e)
|
|
1036
|
+
if (cfg.model) bits.push('cfg.model=' + cfg.model)
|
|
1037
|
+
bits.push('home=' + (codexHomeDir(e) || '?'))
|
|
1038
|
+
return bits.join(' · ')
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
|
|
1042
|
+
mcp: { version: '0.2.8' }
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// 三形态共用的工具逻辑(kit 的 coreExecute 负责 jobs/超时/回退/瞬时重试)。
|
|
1046
|
+
descriptor.runToolLogic = async function (args, exec) {
|
|
1047
|
+
const a = args || {}
|
|
1048
|
+
const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
|
|
1049
|
+
return this.runner.coreExecute(normalized, exec)
|
|
1050
|
+
}
|
|
1051
|
+
descriptor.continueToolLogic = async function (args, exec) {
|
|
1052
|
+
const a = args || {}
|
|
1053
|
+
const mapped = {
|
|
1054
|
+
prompt: a.prompt, model: a.model, mode: a.mode, sandboxMode: a.sandboxMode,
|
|
1055
|
+
reasoningEffort: a.reasoningEffort, cwd: a.cwd, images: a.images,
|
|
1056
|
+
timeoutSec: a.timeoutSec,
|
|
1057
|
+
background: a.background === undefined ? true : a.background
|
|
1058
|
+
}
|
|
1059
|
+
if (a.sessionId) mapped.sessionId = String(a.sessionId)
|
|
1060
|
+
else if (a.latest) mapped.continueLatest = true
|
|
1061
|
+
return this.runner.coreExecute(mapped, exec)
|
|
1062
|
+
}
|
|
1063
|
+
descriptor.statusToolLogic = function (args) {
|
|
1064
|
+
const a = args || {}
|
|
1065
|
+
const snap = this.engine.statusSnapshot()
|
|
1066
|
+
if (a.cwd) {
|
|
1067
|
+
const key = String(a.cwd)
|
|
1068
|
+
snap.projects = snap.projects.filter((p) => p.cwd === key)
|
|
1069
|
+
const g = snap.projects[0]
|
|
1070
|
+
if (g) {
|
|
1071
|
+
Object.assign(snap, {
|
|
1072
|
+
state: g.state, running: g.running, current: g.current, trail: g.trail,
|
|
1073
|
+
lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
|
|
1074
|
+
lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return snap
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// MCP/测试形态复用同一套模型读取(与 extraTools 同源,避免两处实现漂移)。
|
|
1082
|
+
descriptor.listModels = async function (args) {
|
|
1083
|
+
return codexModelsTool({ env, deps }).logic(args || {})
|
|
1084
|
+
}
|
|
1085
|
+
descriptor.liveModels = codexLiveModels
|
|
1086
|
+
descriptor.catalogModels = codexCatalogModels
|
|
1087
|
+
descriptor.builtinModels = codexBuiltinModels
|
|
1088
|
+
|
|
1089
|
+
descriptor.attach = function (hostOpts) {
|
|
1090
|
+
host = hostOpts || {}
|
|
1091
|
+
codexSetHost(env, deps.nodeFs || null, deps.nodePath || null)
|
|
1092
|
+
try {
|
|
1093
|
+
if (typeof host.getSettings === 'function') {
|
|
1094
|
+
const s = host.getSettings('codex')
|
|
1095
|
+
if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
|
|
1096
|
+
}
|
|
1097
|
+
} catch (e) { /* 设置缺省不影响装配 */ }
|
|
1098
|
+
this.engine = createStatusEngine({ provider: this, publish: host.publish })
|
|
1099
|
+
const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
|
|
1100
|
+
this.runner = runner
|
|
1101
|
+
return runner
|
|
1102
|
+
}
|
|
1103
|
+
return descriptor
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ── inlined from providers/codex-cli.mjs ──
|
|
1107
|
+
// providers/codex-cli.mjs — codex 事实层:配置发现 / 可执行探测 / 三层模型清单。
|
|
1108
|
+
//
|
|
1109
|
+
// 从 providers/codex.mjs 拆出(规则 §26:主文件必须留在 600 物理行内)。这里只放
|
|
1110
|
+
// 与「本机 codex 装成什么样」有关的纯逻辑:~/.codex 布局、config.toml 最小 TOML 解析、
|
|
1111
|
+
// 可执行候选、三层模型清单(实时 HTTP / 本地目录 / 内置定义)。不含状态机、不含 argv。
|
|
1112
|
+
//
|
|
1113
|
+
// 沙箱纪律(与 providers/*.mjs 同):本文件会被 scripts/build.mjs 经模板的 EXTRA 标记
|
|
1114
|
+
// 内联进同一个 dynamic 沙箱作用域 —— 因为 codex.mjs 主文件已超红线,必须走片文件:
|
|
1115
|
+
// 1) 不得 import 任何模块(import 行会被剥掉,函数体留下但依赖丢失 → 运行期炸);
|
|
1116
|
+
// Node 内建一律经 globalThis.process.getBuiltinModule + typeof 守卫。
|
|
1117
|
+
// 2) 顶层标识符一律 codex 前缀,禁止与 bridge-kit / core/scheduling.mjs / codex.mjs
|
|
1118
|
+
// 撞名(同一作用域重复声明 = 构建期 Identifier already declared)。
|
|
1119
|
+
// 3) 不得出现 import.meta 字面量。
|
|
1120
|
+
// 4) ⚠️ 注释里绝不写出占位标记字面量:build 做全局文本替换,注释里出现同样会被
|
|
1121
|
+
// 替换成正文代码,把注释从中间劈开 → 生成物语法崩。
|
|
1122
|
+
//
|
|
1123
|
+
// 实测事实(codex-cli 0.98.0,本机 Windows):
|
|
1124
|
+
// • 没有 `codex models` 子命令;`codex debug` 只有 app-server。故「实时模型清单」
|
|
1125
|
+
// 只能走 HTTP(config.toml 里 model_providers.<active>.base_url + bearer token)。
|
|
1126
|
+
// • 用无效 model id 探测不会吐名录(只报 "xxx is not a valid model ID")——
|
|
1127
|
+
// 与 codebuddy/claude 的 400 名录机制不同,别照抄那套。
|
|
1128
|
+
// • GET {base_url}/models 实测 200,返回 { data: [ {id,name,pricing,context_length,…} ] }。
|
|
1129
|
+
// • ~/.codex/{model_catalog_json} 与 ~/.codex/models.json 都是 { models: [ {slug,…} ] }。
|
|
1130
|
+
|
|
1131
|
+
// ── 宿主能力(env / fs / path)───────────────────────────────────────────────
|
|
1132
|
+
// keystone 的 settings 面板只用 createProvider(id,{env}) 构造描述符(不 attach、不注入
|
|
1133
|
+
// nodeFs),所以这里必须能自己摸到 Node 内建;摸不到(dynamic 沙箱)时优雅缺席,
|
|
1134
|
+
// 由调用方给出「本形态没有 fs」的明确错误,绝不静默返回空列表。
|
|
1135
|
+
let codexHostEnv = {}
|
|
1136
|
+
let codexHostFs = null
|
|
1137
|
+
let codexHostPath = null
|
|
1138
|
+
|
|
1139
|
+
function codexSetHost(envObj, fsMod, pathMod) {
|
|
1140
|
+
if (envObj && typeof envObj === 'object') codexHostEnv = envObj
|
|
1141
|
+
if (fsMod && typeof fsMod.readFileSync === 'function') codexHostFs = fsMod
|
|
1142
|
+
if (pathMod && typeof pathMod.join === 'function') codexHostPath = pathMod
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function codexProcess() {
|
|
1146
|
+
try {
|
|
1147
|
+
const p = globalThis.process
|
|
1148
|
+
return p && typeof p === 'object' ? p : null
|
|
1149
|
+
} catch (e) { return null }
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function codexBuiltin(name) {
|
|
1153
|
+
try {
|
|
1154
|
+
const p = codexProcess()
|
|
1155
|
+
return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule(name) : null
|
|
1156
|
+
} catch (e) { return null }
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function codexFs() {
|
|
1160
|
+
if (codexHostFs) return codexHostFs
|
|
1161
|
+
const nf = codexBuiltin('node:fs')
|
|
1162
|
+
return nf && typeof nf.readFileSync === 'function' ? nf : null
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function codexPathJoin() {
|
|
1166
|
+
const parts = Array.prototype.slice.call(arguments).filter((x) => x !== undefined && x !== null && x !== '')
|
|
1167
|
+
if (!parts.length) return ''
|
|
1168
|
+
const mod = codexHostPath || codexBuiltin('node:path')
|
|
1169
|
+
if (mod && typeof mod.join === 'function') return mod.join.apply(mod, parts)
|
|
1170
|
+
return parts.map((x) => String(x)).join('/')
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function codexReadText(p) {
|
|
1174
|
+
if (!p) return null
|
|
1175
|
+
const fs = codexFs()
|
|
1176
|
+
if (!fs) return null
|
|
1177
|
+
try { return fs.readFileSync(p, 'utf8') } catch (e) { return null }
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** CODEX_HOME 覆盖 ~/.codex(CLI 自身语义)。 */
|
|
1181
|
+
function codexHomeDir(env) {
|
|
1182
|
+
const e = env || codexHostEnv || {}
|
|
1183
|
+
if (e.CODEX_HOME) return String(e.CODEX_HOME)
|
|
1184
|
+
const home = e.USERPROFILE || e.HOME || ''
|
|
1185
|
+
return home ? codexPathJoin(home, '.codex') : ''
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ── config.toml 最小解析 ─────────────────────────────────────────────────────
|
|
1189
|
+
// 只需要字符串标量与表头:本模块读的键是 model / model_provider / model_catalog_json,
|
|
1190
|
+
// 以及 [model_providers.<name>] 下的 base_url / experimental_bearer_token。
|
|
1191
|
+
// 数组(notify = [...])、布尔、数字一律跳过 —— 宁可少读,也不猜错类型。
|
|
1192
|
+
function codexParseToml(text) {
|
|
1193
|
+
const top = Object.create(null)
|
|
1194
|
+
const tables = Object.create(null)
|
|
1195
|
+
let cur = top
|
|
1196
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
1197
|
+
const line = raw.trim()
|
|
1198
|
+
if (!line || line.charAt(0) === '#') continue
|
|
1199
|
+
const hdr = /^\[([^\]]+)\]$/.exec(line)
|
|
1200
|
+
if (hdr) {
|
|
1201
|
+
const name = hdr[1].trim()
|
|
1202
|
+
if (!tables[name]) tables[name] = Object.create(null)
|
|
1203
|
+
cur = tables[name]
|
|
1204
|
+
continue
|
|
1205
|
+
}
|
|
1206
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line)
|
|
1207
|
+
if (!kv) continue
|
|
1208
|
+
const rawVal = kv[2].trim()
|
|
1209
|
+
let str = null
|
|
1210
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(rawVal)) str = rawVal.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
1211
|
+
else if (/^'[^']*'$/.test(rawVal)) str = rawVal.slice(1, -1)
|
|
1212
|
+
else if (/^[A-Za-z0-9_.:/@-]+$/.test(rawVal) && rawVal !== 'true' && rawVal !== 'false' && !/^-?[0-9]+(\.[0-9]+)?$/.test(rawVal)) str = rawVal
|
|
1213
|
+
if (str === null) continue
|
|
1214
|
+
cur[kv[1]] = str
|
|
1215
|
+
}
|
|
1216
|
+
return { top, tables }
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* 汇总本机 codex 的生效配置。
|
|
1221
|
+
* @returns {{ home, configPath, configFound, providerName, baseUrl, token, model,
|
|
1222
|
+
* catalogFile, authPath, authFound, authBaseUrl, authApiKey }}
|
|
1223
|
+
*/
|
|
1224
|
+
function codexReadConfig(env) {
|
|
1225
|
+
const e = env || codexHostEnv || {}
|
|
1226
|
+
const home = codexHomeDir(e)
|
|
1227
|
+
const configPath = home ? codexPathJoin(home, 'config.toml') : ''
|
|
1228
|
+
const configText = codexReadText(configPath)
|
|
1229
|
+
const parsed = configText ? codexParseToml(configText) : { top: Object.create(null), tables: Object.create(null) }
|
|
1230
|
+
const providerName = typeof parsed.top.model_provider === 'string' ? parsed.top.model_provider : ''
|
|
1231
|
+
const block = providerName && parsed.tables['model_providers.' + providerName]
|
|
1232
|
+
? parsed.tables['model_providers.' + providerName]
|
|
1233
|
+
: Object.create(null)
|
|
1234
|
+
const authPath = home ? codexPathJoin(home, 'auth.json') : ''
|
|
1235
|
+
const authText = codexReadText(authPath)
|
|
1236
|
+
let auth = null
|
|
1237
|
+
try { auth = authText ? JSON.parse(authText) : null } catch (err) { auth = null }
|
|
1238
|
+
const authBaseUrl = auth && typeof auth.OPENAI_BASE_URL === 'string' ? auth.OPENAI_BASE_URL : ''
|
|
1239
|
+
const authApiKey = auth && typeof auth.OPENAI_API_KEY === 'string' ? auth.OPENAI_API_KEY : ''
|
|
1240
|
+
const baseUrl = typeof block.base_url === 'string' && block.base_url ? block.base_url : authBaseUrl
|
|
1241
|
+
const token = typeof block.experimental_bearer_token === 'string' && block.experimental_bearer_token
|
|
1242
|
+
? block.experimental_bearer_token
|
|
1243
|
+
: authApiKey
|
|
1244
|
+
return {
|
|
1245
|
+
home,
|
|
1246
|
+
configPath,
|
|
1247
|
+
configFound: configText !== null,
|
|
1248
|
+
providerName,
|
|
1249
|
+
baseUrl: String(baseUrl || ''),
|
|
1250
|
+
token: String(token || ''),
|
|
1251
|
+
model: typeof parsed.top.model === 'string' ? parsed.top.model : '',
|
|
1252
|
+
catalogFile: typeof parsed.top.model_catalog_json === 'string' ? parsed.top.model_catalog_json : '',
|
|
1253
|
+
authPath,
|
|
1254
|
+
authFound: auth !== null,
|
|
1255
|
+
authBaseUrl: String(authBaseUrl || ''),
|
|
1256
|
+
authApiKey: String(authApiKey || '')
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** 实时模型清单的端点(base_url + /models)。缺 base_url 时明确失败,不猜端点。 */
|
|
1261
|
+
function codexModelsEndpoint(env) {
|
|
1262
|
+
const e = env || codexHostEnv || {}
|
|
1263
|
+
const cfg = codexReadConfig(env)
|
|
1264
|
+
// 显式覆盖(诊断/测试/临时换端点用):CODEX_BRIDGE_MODELS_URL 既可是完整 URL,
|
|
1265
|
+
// 也可是 base_url(自动补 /models);token 同理可覆盖。
|
|
1266
|
+
const override = String(e.CODEX_BRIDGE_MODELS_URL || '').trim()
|
|
1267
|
+
const base = String(override || cfg.baseUrl || '').trim()
|
|
1268
|
+
const token = String(e.CODEX_BRIDGE_MODELS_TOKEN || cfg.token || '')
|
|
1269
|
+
if (!base) {
|
|
1270
|
+
return {
|
|
1271
|
+
ok: false, cfg, url: '', token: '', baseUrl: '', providerName: cfg.providerName,
|
|
1272
|
+
error: 'no base_url configured: ' + (cfg.configFound
|
|
1273
|
+
? 'config.toml has no model_providers.' + (cfg.providerName || '<none>') + '.base_url and auth.json has no OPENAI_BASE_URL'
|
|
1274
|
+
: 'config.toml not found at ' + (cfg.configPath || '(unknown)')) +
|
|
1275
|
+
'. Codex CLI has no `models` subcommand, so a live list can only come from the provider HTTP API (/models).'
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
const url = /\/models\/*$/.test(base) ? base : base.replace(/\/+$/, '') + '/models'
|
|
1279
|
+
return {
|
|
1280
|
+
ok: true,
|
|
1281
|
+
cfg,
|
|
1282
|
+
baseUrl: base,
|
|
1283
|
+
url,
|
|
1284
|
+
token,
|
|
1285
|
+
providerName: cfg.providerName
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// ── 可执行文件 ───────────────────────────────────────────────────────────────
|
|
1290
|
+
/** 候选顺序:面板 binPath(调用方传入)> CODEX_BIN > CODEX_CLI > CODEX_CLI_PATH > PATH 名。 */
|
|
1291
|
+
function codexExeCandidates(env) {
|
|
1292
|
+
const e = env || codexHostEnv || {}
|
|
1293
|
+
const out = []
|
|
1294
|
+
const push = (v) => { const s = String(v || '').trim(); if (s && out.indexOf(s) < 0) out.push(s) }
|
|
1295
|
+
push(e.CODEX_BIN)
|
|
1296
|
+
push(e.CODEX_CLI)
|
|
1297
|
+
push(e.CODEX_CLI_PATH)
|
|
1298
|
+
push('codex')
|
|
1299
|
+
return out
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** .mjs/.cjs/.js 目标经 node 包装(测试夹具与自定义入口都走这条)。 */
|
|
1303
|
+
function codexResolveExePrefix(env, binPath) {
|
|
1304
|
+
const bin = String(binPath || '').trim() || codexExeCandidates(env)[0]
|
|
1305
|
+
if (/\.(mjs|cjs|js)$/i.test(bin)) {
|
|
1306
|
+
const p = codexProcess()
|
|
1307
|
+
return [p && p.execPath ? p.execPath : 'node', String(bin)]
|
|
1308
|
+
}
|
|
1309
|
+
return [String(bin)]
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Windows npm 全局安装的 `codex.cmd` 不能直接 spawn(CreateProcess 不解析 .cmd/.bat;
|
|
1314
|
+
* 走 shell 又会让含空格/引号的 prompt 被二次解释 —— 正是本桥刻意避免的形态)。
|
|
1315
|
+
* 与其抛出难懂 EINVAL,不如在解析阶段给一条可行动的错误。
|
|
1316
|
+
* @returns {string|null} 拒绝原因(null = 可以用)
|
|
1317
|
+
*/
|
|
1318
|
+
function codexShimRejectReason(binPath) {
|
|
1319
|
+
const bin = String(binPath || '').trim()
|
|
1320
|
+
if (!/\.(cmd|bat)$/i.test(bin)) return null
|
|
1321
|
+
return 'codex executable "' + bin + '" is a Windows .cmd/.bat shim, which cannot be spawned directly without a shell ' +
|
|
1322
|
+
'(and a shell would re-interpret the prompt). Point binPath / CODEX_BIN at the real codex.exe instead — ' +
|
|
1323
|
+
'for an npm global install that is under %APPDATA%\\npm\\node_modules\\@openai\\codex\\vendor\\<platform>\\codex.exe.'
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ── 模型清单:三层来源(顺序即权威性)────────────────────────────────────────
|
|
1327
|
+
// A live —— GET {base_url}/models(服务端裁决,唯一权威)
|
|
1328
|
+
// B catalog—— config.toml 的 model_catalog_json(codex 自用的模型目录)
|
|
1329
|
+
// C builtin—— ~/.codex/models.json(CLI 内置定义)
|
|
1330
|
+
// 红线:B/C 绝不允许冒充 A。清单项与结果对象都带 source,调用方据此展示。
|
|
1331
|
+
function codexNormalizeModelItem(raw, source) {
|
|
1332
|
+
const r = raw && typeof raw === 'object' ? raw : null
|
|
1333
|
+
if (!r) return null
|
|
1334
|
+
const id = String(r.id || r.slug || r.model || r.name || '').trim()
|
|
1335
|
+
if (!id) return null
|
|
1336
|
+
let free = false
|
|
1337
|
+
if (r.pricing && typeof r.pricing === 'object') {
|
|
1338
|
+
const p = r.pricing.prompt
|
|
1339
|
+
if (p === 0 || p === '0' || p === '0.0') free = true
|
|
1340
|
+
}
|
|
1341
|
+
if (!free && /(?::free$|-free$)/i.test(id)) free = true
|
|
1342
|
+
const ctxNum = Number(r.context_window || r.context_length || r.max_context_window || 0)
|
|
1343
|
+
return {
|
|
1344
|
+
id,
|
|
1345
|
+
name: String(r.display_name || r.name || id),
|
|
1346
|
+
source,
|
|
1347
|
+
free,
|
|
1348
|
+
contextWindow: Number.isFinite(ctxNum) && ctxNum > 0 ? ctxNum : null,
|
|
1349
|
+
visibility: typeof r.visibility === 'string' ? r.visibility : null
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** 解析 {data:[…]} 或 {models:[…]} 两种载荷。畸形载荷返回明确错误。 */
|
|
1354
|
+
function codexParseModelsPayload(text, source) {
|
|
1355
|
+
let json = null
|
|
1356
|
+
try { json = JSON.parse(String(text || '')) } catch (e) {
|
|
1357
|
+
return { ok: false, models: [], error: 'models payload is not JSON: ' + String(text || '').slice(0, 160) }
|
|
1358
|
+
}
|
|
1359
|
+
const arr = json && Array.isArray(json.data) ? json.data
|
|
1360
|
+
: (json && Array.isArray(json.models) ? json.models : null)
|
|
1361
|
+
if (!arr) return { ok: false, models: [], error: 'models payload has neither .data nor .models array: ' + String(text || '').slice(0, 160) }
|
|
1362
|
+
const models = []
|
|
1363
|
+
const seen = Object.create(null)
|
|
1364
|
+
let hidden = 0
|
|
1365
|
+
for (const raw of arr) {
|
|
1366
|
+
const item = codexNormalizeModelItem(raw, source)
|
|
1367
|
+
if (!item || seen[item.id]) continue
|
|
1368
|
+
if (item.visibility && item.visibility !== 'list') { hidden += 1; continue }
|
|
1369
|
+
seen[item.id] = true
|
|
1370
|
+
models.push(item)
|
|
1371
|
+
}
|
|
1372
|
+
return { ok: true, models, hidden, total: arr.length }
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// 进程内缓存(面板每次渲染都会来取,避免反复打网络 / 反复起子进程)。
|
|
1376
|
+
const CODEX_MODEL_CACHE = { at: 0, url: '', payload: null }
|
|
1377
|
+
const CODEX_MODEL_TTL_MS = 5 * 60 * 1000
|
|
1378
|
+
|
|
1379
|
+
function codexCacheNow() { return Date.now() }
|
|
1380
|
+
|
|
1381
|
+
/** 已缓存的实时清单(无则 null)。供同步面板路径做零阻塞快读。 */
|
|
1382
|
+
function codexCachedLiveModels(env, nowMs) {
|
|
1383
|
+
const ep = codexModelsEndpoint(env)
|
|
1384
|
+
if (!ep.ok) return null
|
|
1385
|
+
const hit = CODEX_MODEL_CACHE
|
|
1386
|
+
const at = Number(nowMs) || Date.now()
|
|
1387
|
+
if (hit.payload && hit.url === ep.url && at - hit.at < CODEX_MODEL_TTL_MS) return hit.payload
|
|
1388
|
+
return null
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function codexRememberLive(url, payload) {
|
|
1392
|
+
CODEX_MODEL_CACHE.at = codexCacheNow()
|
|
1393
|
+
CODEX_MODEL_CACHE.url = url
|
|
1394
|
+
CODEX_MODEL_CACHE.payload = payload
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function codexFetchInit(token) {
|
|
1398
|
+
const headers = { accept: 'application/json' }
|
|
1399
|
+
if (token) headers.authorization = 'Bearer ' + token
|
|
1400
|
+
return { method: 'GET', headers }
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function codexDescribeFetchError(status, body) {
|
|
1404
|
+
const head = String(body || '').replace(/\s+/g, ' ').trim().slice(0, 200)
|
|
1405
|
+
if (status === 401 || status === 403) {
|
|
1406
|
+
return 'HTTP ' + status + ' — the provider rejected the credential. Set model_providers.<name>.experimental_bearer_token (or auth.json OPENAI_API_KEY) for the endpoint you actually use. ' + head
|
|
1407
|
+
}
|
|
1408
|
+
return 'HTTP ' + status + (head ? ' — ' + head : '')
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** A 层(异步):GET {base_url}/models。deps.fetch 可注入(测试),否则用全局 fetch。 */
|
|
1412
|
+
async function codexLiveModels(env, opts) {
|
|
1413
|
+
const o = opts || {}
|
|
1414
|
+
const ep = codexModelsEndpoint(env)
|
|
1415
|
+
if (!ep.ok) return { ok: false, source: 'live', models: [], error: ep.error }
|
|
1416
|
+
const cached = codexCachedLiveModels(env, o.nowMs)
|
|
1417
|
+
if (cached && !o.refresh) return cached
|
|
1418
|
+
const f = o.fetch || ((typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') ? globalThis.fetch : null)
|
|
1419
|
+
if (!f) {
|
|
1420
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'no fetch capability in this host form, so the live model list from ' + ep.url + ' cannot be read. Use source="catalog" (config.toml model_catalog_json) or source="builtin" (~/.codex/models.json) explicitly — they are local and are NOT authoritative.' }
|
|
1421
|
+
}
|
|
1422
|
+
let res
|
|
1423
|
+
try { res = await f(ep.url, codexFetchInit(ep.token)) } catch (e) {
|
|
1424
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' failed: ' + String(e && e.message || e) + '. Check network/proxy, or set CODEX_BRIDGE_MODELS_URL to the provider catalogue endpoint.' }
|
|
1425
|
+
}
|
|
1426
|
+
let body = ''
|
|
1427
|
+
try { body = typeof res.text === 'function' ? await res.text() : JSON.stringify(res.json || {}) } catch (e) { body = '' }
|
|
1428
|
+
const status = Number(res && res.status) || 0
|
|
1429
|
+
if (!res || status < 200 || status >= 300) {
|
|
1430
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' → ' + codexDescribeFetchError(status, body) }
|
|
1431
|
+
}
|
|
1432
|
+
const parsed = codexParseModelsPayload(body, 'live')
|
|
1433
|
+
if (!parsed.ok) return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ': ' + parsed.error }
|
|
1434
|
+
if (!parsed.models.length) {
|
|
1435
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ' returned 0 usable models (payload items=' + parsed.total + '). Refusing to report an empty list as if it were authoritative.' }
|
|
1436
|
+
}
|
|
1437
|
+
const payload = {
|
|
1438
|
+
ok: true, source: 'live', models: parsed.models, count: parsed.models.length,
|
|
1439
|
+
url: ep.url, baseUrl: ep.baseUrl, providerName: ep.providerName, fetchedAt: Date.now()
|
|
1440
|
+
}
|
|
1441
|
+
codexRememberLive(ep.url, payload)
|
|
1442
|
+
return payload
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// 子进程探针脚本:spawnSync 里不能 await,所以让子进程自己 async fetch 再打印。
|
|
1446
|
+
// token/url 只经 env 传递(绝不进命令行,避免凭据出现在进程参数里被别的进程看到)。
|
|
1447
|
+
// 子进程只回吐原始 body,解析仍由上面的 parse 统一做(单一解析实现,避免漂移)。
|
|
1448
|
+
const CODEX_PROBE_SCRIPT = [
|
|
1449
|
+
'const url = process.env.CODEX_BRIDGE_PROBE_URL',
|
|
1450
|
+
'const token = process.env.CODEX_BRIDGE_PROBE_TOKEN || ""',
|
|
1451
|
+
'const ms = Number(process.env.CODEX_BRIDGE_PROBE_TIMEOUT || 8000)',
|
|
1452
|
+
'const headers = { accept: "application/json" }',
|
|
1453
|
+
'if (token) headers.authorization = "Bearer " + token',
|
|
1454
|
+
'const ctrl = new AbortController()',
|
|
1455
|
+
'const timer = setTimeout(() => { try { ctrl.abort() } catch (e) {} }, ms)',
|
|
1456
|
+
'fetch(url, { headers, signal: ctrl.signal })',
|
|
1457
|
+
' .then(async (r) => { const b = await r.text(); process.stdout.write(JSON.stringify({ status: r.status, body: b })) })',
|
|
1458
|
+
' .catch((e) => { process.stdout.write(JSON.stringify({ status: 0, error: String((e && e.message) || e) })) })',
|
|
1459
|
+
' .finally(() => clearTimeout(timer))'
|
|
1460
|
+
].join('\n')
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* A 层(同步):设置面板是同步回调,而 HTTP 客户端没有同步 API —— 用一个 node 子进程
|
|
1464
|
+
* 跑上面的探针脚本,spawnSync 拿回 body。结果进 5 分钟缓存(面板反复渲染不至于反复起进程)。
|
|
1465
|
+
*/
|
|
1466
|
+
function codexLiveModelsSync(env, opts) {
|
|
1467
|
+
const o = opts || {}
|
|
1468
|
+
const ep = codexModelsEndpoint(env)
|
|
1469
|
+
if (!ep.ok) return { ok: false, source: 'live', models: [], error: ep.error }
|
|
1470
|
+
const cached = codexCachedLiveModels(env, o.nowMs)
|
|
1471
|
+
if (cached) return cached
|
|
1472
|
+
const proc = codexProcess()
|
|
1473
|
+
const child = codexBuiltin('node:child_process')
|
|
1474
|
+
if (!proc || !proc.execPath || !child || typeof child.spawnSync !== 'function') {
|
|
1475
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'no spawnSync capability to read ' + ep.url + ' synchronously' }
|
|
1476
|
+
}
|
|
1477
|
+
const r = child.spawnSync(proc.execPath, ['-e', CODEX_PROBE_SCRIPT], {
|
|
1478
|
+
encoding: 'utf8',
|
|
1479
|
+
timeout: Math.max(2000, Math.min(Number(o.timeoutMs) || 9000, 30000)),
|
|
1480
|
+
windowsHide: true,
|
|
1481
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1482
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1483
|
+
env: Object.assign({}, proc.env, {
|
|
1484
|
+
CODEX_BRIDGE_PROBE_URL: ep.url,
|
|
1485
|
+
CODEX_BRIDGE_PROBE_TOKEN: ep.token,
|
|
1486
|
+
CODEX_BRIDGE_PROBE_TIMEOUT: String(Math.max(2000, Math.min(Number(o.timeoutMs) || 9000, 30000)) - 500)
|
|
1487
|
+
})
|
|
1488
|
+
})
|
|
1489
|
+
if (!r || r.error) {
|
|
1490
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'probe spawn failed: ' + String((r && r.error && (r.error.code || r.error.message)) || 'unknown') }
|
|
1491
|
+
}
|
|
1492
|
+
let out = null
|
|
1493
|
+
try { out = JSON.parse(String(r.stdout || '')) } catch (e) { out = null }
|
|
1494
|
+
if (!out) {
|
|
1495
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'probe produced no JSON (exit=' + r.status + '): ' + String(r.stderr || r.stdout || '').slice(0, 200) }
|
|
1496
|
+
}
|
|
1497
|
+
if (!out.status && out.error) return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' failed: ' + String(out.error) }
|
|
1498
|
+
if (out.status < 200 || out.status >= 300) {
|
|
1499
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' → ' + codexDescribeFetchError(out.status, out.body) }
|
|
1500
|
+
}
|
|
1501
|
+
const parsed = codexParseModelsPayload(out.body, 'live')
|
|
1502
|
+
if (!parsed.ok) return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ': ' + parsed.error }
|
|
1503
|
+
if (!parsed.models.length) {
|
|
1504
|
+
return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ' returned 0 usable models (payload items=' + parsed.total + '). Refusing to report an empty list as authoritative.' }
|
|
1505
|
+
}
|
|
1506
|
+
const payload = {
|
|
1507
|
+
ok: true, source: 'live', models: parsed.models, count: parsed.models.length,
|
|
1508
|
+
url: ep.url, baseUrl: ep.baseUrl, providerName: ep.providerName, fetchedAt: Date.now()
|
|
1509
|
+
}
|
|
1510
|
+
codexRememberLive(ep.url, payload)
|
|
1511
|
+
return payload
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/** B 层:config.toml 的 model_catalog_json(相对 CODEX_HOME)。 */
|
|
1515
|
+
function codexCatalogModels(env) {
|
|
1516
|
+
const cfg = codexReadConfig(env)
|
|
1517
|
+
const rel = cfg.catalogFile
|
|
1518
|
+
if (!rel) {
|
|
1519
|
+
return { ok: false, source: 'catalog', models: [], error: 'config.toml has no model_catalog_json key, so there is no local catalog to read (set it to a file name under ' + (cfg.home || 'CODEX_HOME') + ').' }
|
|
1520
|
+
}
|
|
1521
|
+
const p = /[\\/]/.test(rel) || /^[A-Za-z]:/.test(rel) ? rel : codexPathJoin(cfg.home, rel)
|
|
1522
|
+
const text = codexReadText(p)
|
|
1523
|
+
if (text === null) return { ok: false, source: 'catalog', models: [], file: p, error: 'catalog file not readable: ' + p }
|
|
1524
|
+
const parsed = codexParseModelsPayload(text, 'catalog')
|
|
1525
|
+
if (!parsed.ok) return { ok: false, source: 'catalog', models: [], file: p, error: p + ': ' + parsed.error }
|
|
1526
|
+
if (!parsed.models.length) return { ok: false, source: 'catalog', models: [], file: p, error: p + ' contains 0 visible models' }
|
|
1527
|
+
return { ok: true, source: 'catalog', models: parsed.models, count: parsed.models.length, file: p }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** C 层:~/.codex/models.json(CLI 内置定义)。 */
|
|
1531
|
+
function codexBuiltinModels(env) {
|
|
1532
|
+
const cfg = codexReadConfig(env)
|
|
1533
|
+
if (!cfg.home) return { ok: false, source: 'builtin', models: [], error: 'CODEX_HOME/USERPROFILE unknown, cannot locate ~/.codex/models.json' }
|
|
1534
|
+
const p = codexPathJoin(cfg.home, 'models.json')
|
|
1535
|
+
const text = codexReadText(p)
|
|
1536
|
+
if (text === null) return { ok: false, source: 'builtin', models: [], file: p, error: 'builtin model definitions not readable: ' + p }
|
|
1537
|
+
const parsed = codexParseModelsPayload(text, 'builtin')
|
|
1538
|
+
if (!parsed.ok) return { ok: false, source: 'builtin', models: [], file: p, error: p + ': ' + parsed.error }
|
|
1539
|
+
if (!parsed.models.length) return { ok: false, source: 'builtin', models: [], file: p, error: p + ' contains 0 visible models' }
|
|
1540
|
+
return { ok: true, source: 'builtin', models: parsed.models, count: parsed.models.length, file: p }
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* 设置面板下拉的候选池。
|
|
1545
|
+
* 优先实时(缓存或同步探测);实时拿不到时**明确标注**地退到本地目录,
|
|
1546
|
+
* 让用户至少有东西可选,同时 note 里说清「这不是服务端权威清单」。
|
|
1547
|
+
*/
|
|
1548
|
+
function codexModelOptions(env, opts) {
|
|
1549
|
+
const o = opts || {}
|
|
1550
|
+
const live = o.skipLive ? null : (codexCachedLiveModels(env) || codexLiveModelsSync(env, { timeoutMs: o.timeoutMs }))
|
|
1551
|
+
if (live && live.ok && live.models.length) {
|
|
1552
|
+
return {
|
|
1553
|
+
options: live.models.map((m) => ({ value: m.id, label: m.id + (m.free ? ' [free]' : '') + ' [实时]' })),
|
|
1554
|
+
source: 'live', url: live.url, note: '实时清单来自 ' + live.url + '(' + live.count + ' 个,免费模型标 [free])。'
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const cat = codexCatalogModels(env)
|
|
1558
|
+
if (cat.ok) {
|
|
1559
|
+
return {
|
|
1560
|
+
options: cat.models.map((m) => ({ value: m.id, label: m.id + ' [本地目录]' })),
|
|
1561
|
+
source: 'catalog', file: cat.file,
|
|
1562
|
+
note: '实时清单不可用(' + String((live && live.error) || '未探测') + ')。以下 ' + cat.count + ' 项来自 config.toml 的 model_catalog_json,仅代表 codex 本地目录,不是服务端权威清单。'
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
const builtin = codexBuiltinModels(env)
|
|
1566
|
+
if (builtin.ok) {
|
|
1567
|
+
return {
|
|
1568
|
+
options: builtin.models.map((m) => ({ value: m.id, label: m.id + ' [内置定义]' })),
|
|
1569
|
+
source: 'builtin', file: builtin.file,
|
|
1570
|
+
note: '实时清单与本地目录都不可用(' + String((cat && cat.error) || '') + ')。以下来自 ~/.codex/models.json 内置定义,不是服务端权威清单。'
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return {
|
|
1574
|
+
options: [], source: 'none',
|
|
1575
|
+
note: '未能读取任何模型清单:' + String((live && live.error) || 'live unavailable') + ';可直接手动输入模型 id。'
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// ── inlined from providers/codex-models.mjs ──
|
|
1580
|
+
// providers/codex-models.mjs — codex_models 工具定义(纯逻辑)。
|
|
1581
|
+
//
|
|
1582
|
+
// 从 providers/codex.mjs 再拆出:两个文件都要留在 600 物理行红线内(规则 §26)。
|
|
1583
|
+
// scripts/build.mjs 会把 providers/codex-*.mjs 全部内联进 dynamic 沙箱的同一作用域,
|
|
1584
|
+
// 所以沙箱里 codexCl* 系列函数天然可见;ESM 形态走本文件的 import。
|
|
1585
|
+
// ⚠️ 注释里绝不写出占位标记字面量(build 全局替换会劈开注释 → 生成物语法崩)。
|
|
1586
|
+
//
|
|
1587
|
+
// 红线(用户明确要求,本仓铁律):拿不到实时清单必须 ok:false + 可操作错误。
|
|
1588
|
+
// 绝不把 catalog/builtin 冒充成 live,也绝不静默返回空数组。清单项必须带 source。
|
|
1589
|
+
// import { codexLiveModels, codexCatalogModels, codexBuiltinModels } from './codex-cli.mjs'
|
|
1590
|
+
const MODELS_PARAMS = {
|
|
1591
|
+
type: 'object',
|
|
1592
|
+
additionalProperties: false,
|
|
1593
|
+
required: [],
|
|
1594
|
+
properties: {
|
|
1595
|
+
source: {
|
|
1596
|
+
type: 'string',
|
|
1597
|
+
enum: ['live', 'catalog', 'builtin'],
|
|
1598
|
+
description: 'Which layer to read. live = GET {base_url}/models from the provider API configured in ~/.codex/config.toml — the only authoritative list. catalog = the file named by config.toml model_catalog_json. builtin = ~/.codex/models.json. Local layers are codex-local metadata, NOT a claim about what the server will accept.'
|
|
1599
|
+
},
|
|
1600
|
+
filter: { type: 'string', description: 'Optional case-insensitive substring filter on model id/name (e.g. "minimax" or "free").' },
|
|
1601
|
+
freeOnly: { type: 'boolean', description: 'Return only models that look free (:free suffix or pricing.prompt == 0).' },
|
|
1602
|
+
limit: { type: 'integer', description: 'Max models to return (default 80, 1-1000). The result always reports the full match count.' },
|
|
1603
|
+
refresh: { type: 'boolean', description: 'Bypass the 5-minute live cache and re-fetch.' }
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const MODELS_DESCRIPTION = 'List the models the local codex CLI can actually use, with the source of every id made explicit. Default source=live performs GET {base_url}/models against the provider endpoint configured in ~/.codex/config.toml (base_url + experimental_bearer_token, else auth.json OPENAI_BASE_URL/OPENAI_API_KEY) — codex-cli has no `models` subcommand and probing with an invalid model id does NOT print a roster, so this HTTP call is the only authoritative source. If it cannot be read, this tool returns ok:false with an actionable error and never invents or silently downgrades the list; pass source="catalog"/"builtin" only when you deliberately want the local (non-authoritative) catalog. Use this before codex_run when the model id matters, and prefer free models (:free / pricing 0) for routine work.'
|
|
1608
|
+
|
|
1609
|
+
function renderModels(args, value) {
|
|
1610
|
+
const v = value || {}
|
|
1611
|
+
if (!v.ok) return [{ type: 'text', text: 'codex_models failed: ' + String(v.error || 'unknown') }]
|
|
1612
|
+
const list = Array.isArray(v.models) ? v.models : []
|
|
1613
|
+
const lines = ['codex models [' + (v.source || '?') + '] (' + (v.count != null ? v.count : list.length) + ' matched' + (v.truncated ? ', showing ' + list.length : '') + '):']
|
|
1614
|
+
for (const m of list.slice(0, 60)) {
|
|
1615
|
+
const bits = []
|
|
1616
|
+
if (m.free) bits.push('free')
|
|
1617
|
+
if (m.contextWindow) bits.push(Math.round(m.contextWindow / 1000) + 'k ctx')
|
|
1618
|
+
lines.push(' ' + m.id + (bits.length ? ' [' + bits.join(',') + ']' : ''))
|
|
1619
|
+
}
|
|
1620
|
+
if (list.length > 60) lines.push(' … and ' + (list.length - 60) + ' more')
|
|
1621
|
+
if (v.source === 'live') lines.push('source: live — ' + String(v.url || ''))
|
|
1622
|
+
else lines.push('source: ' + String(v.source || '') + ' — ' + String(v.file || '') + ' (local, NOT authoritative for the server)')
|
|
1623
|
+
if (v.note) lines.push('note: ' + v.note)
|
|
1624
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function modelsErrorHint(source, extra) {
|
|
1628
|
+
const base = String(extra || 'unknown')
|
|
1629
|
+
if (source !== 'live') return base
|
|
1630
|
+
return base + ' — pass source="catalog" or source="builtin" to inspect the local, non-authoritative lists, or fix the endpoint/credential above and retry.'
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* codex_models 工具定义。
|
|
1635
|
+
* @param {{env?:object, deps?:object}} ctx env 用来定位 CODEX_HOME;deps.fetch 可注入。
|
|
1636
|
+
*/
|
|
1637
|
+
function codexModelsTool(ctx) {
|
|
1638
|
+
const c = ctx || {}
|
|
1639
|
+
const env = c.env || {}
|
|
1640
|
+
const deps = c.deps || {}
|
|
1641
|
+
return {
|
|
1642
|
+
name: 'codex_models',
|
|
1643
|
+
description: MODELS_DESCRIPTION,
|
|
1644
|
+
inputSchema: MODELS_PARAMS,
|
|
1645
|
+
render: renderModels,
|
|
1646
|
+
async logic(args) {
|
|
1647
|
+
const a = args || {}
|
|
1648
|
+
const source = a.source || 'live'
|
|
1649
|
+
let res
|
|
1650
|
+
if (source === 'live') res = await codexLiveModels(env, { fetch: deps.fetch, refresh: a.refresh === true })
|
|
1651
|
+
else if (source === 'catalog') res = codexCatalogModels(env)
|
|
1652
|
+
else if (source === 'builtin') res = codexBuiltinModels(env)
|
|
1653
|
+
else return { ok: false, source, models: [], error: 'unknown source ' + JSON.stringify(String(source)) + ' (use live|catalog|builtin)' }
|
|
1654
|
+
|
|
1655
|
+
if (!res.ok || !Array.isArray(res.models) || !res.models.length) {
|
|
1656
|
+
return { ok: false, source, models: [], error: modelsErrorHint(source, res.error) }
|
|
1657
|
+
}
|
|
1658
|
+
let list = res.models
|
|
1659
|
+
if (a.filter) {
|
|
1660
|
+
const q = String(a.filter).toLowerCase()
|
|
1661
|
+
list = list.filter((m) => String(m.id).toLowerCase().indexOf(q) >= 0 || String(m.name || '').toLowerCase().indexOf(q) >= 0)
|
|
1662
|
+
}
|
|
1663
|
+
if (a.freeOnly) list = list.filter((m) => m.free)
|
|
1664
|
+
if (!list.length) {
|
|
1665
|
+
return { ok: false, source, models: [], error: 'no models matched filter=' + JSON.stringify(String(a.filter || '')) + ' freeOnly=' + String(a.freeOnly === true) + ' on source=' + source + ' (that is an empty *filter result*, not an empty provider list)' }
|
|
1666
|
+
}
|
|
1667
|
+
const limit = Number.isFinite(Number(a.limit)) ? Math.max(1, Math.min(Math.floor(Number(a.limit)), 1000)) : 80
|
|
1668
|
+
const matched = list.length
|
|
1669
|
+
const truncated = matched > limit
|
|
1670
|
+
const models = list.slice(0, limit)
|
|
1671
|
+
const out = {
|
|
1672
|
+
ok: true, source,
|
|
1673
|
+
models, count: matched, truncated,
|
|
1674
|
+
freeCount: res.models.filter((m) => m.free).length
|
|
1675
|
+
}
|
|
1676
|
+
if (res.url) out.url = res.url
|
|
1677
|
+
if (res.file) out.file = res.file
|
|
1678
|
+
if (res.providerName) out.providerName = res.providerName
|
|
1679
|
+
if (res.fetchedAt) out.fetchedAt = res.fetchedAt
|
|
1680
|
+
if (truncated) out.note = 'showing ' + limit + ' of ' + matched + ' matched models; raise limit or narrow with filter/freeOnly.'
|
|
1681
|
+
return out
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// core/scheduling.mjs — CLI 调度(启用集合 + 调用优先级)单源纯逻辑。
|
|
1687
|
+
//
|
|
1688
|
+
// 语义(用户可见的两件事):
|
|
1689
|
+
// • enabled = 勾选启用哪些 CLI(数量随之而定);未启用的一面不注册工具、不进提示词。
|
|
1690
|
+
// • priority = 智能体调用 CLI 的建议顺序,数组顺序即优先级(首位最高)。
|
|
1691
|
+
//
|
|
1692
|
+
// 落盘位置:统一设置文件 {DSH_HOME}/cli-bridge-settings.json 的顶层 `_scheduling` 区。
|
|
1693
|
+
// 下划线前缀 = 非 provider 分区(registry 的 PROVIDER_IDS 永远不含它,逐分区语义不受影响),
|
|
1694
|
+
// 因此新代码写出的文件被旧包读取时整区被忽略 = 纯增量,向后兼容。
|
|
1695
|
+
//
|
|
1696
|
+
// 缺省语义(文件缺失/无该区):全部 provider 启用、按注册表顺序 —— 与调度特性上线前
|
|
1697
|
+
// 的行为逐字一致(读不到配置永远不等于关掉所有桥)。
|
|
1698
|
+
//
|
|
1699
|
+
// 沙箱纪律:与 bridge-kit 同源约束 —— 不得 import;Node 内建一律经
|
|
1700
|
+
// globalThis.process.getBuiltinModule + typeof 守卫(动态宿主沙箱可能没有 fs,
|
|
1701
|
+
// 那种环境读不到文件 → 走缺省全开,绝不炸 apply)。
|
|
1702
|
+
|
|
1703
|
+
/** 设置文件里调度区的键名(下划线前缀避开 provider id 命名空间)。 */
|
|
1704
|
+
const SCHEDULING_SECTION = '_scheduling'
|
|
1705
|
+
|
|
1706
|
+
/** 人类可读的默认注册顺序(调用建议顺序);enabled 缺省 = 全部启用。 */
|
|
1707
|
+
function defaultPriority(ids) {
|
|
1708
|
+
return (Array.isArray(ids) ? ids : []).slice()
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* 清洗任意来源的调度对象。
|
|
1713
|
+
* @param {unknown} raw 文件/POST 里的原始值(可能是任何东西,全部容错)
|
|
1714
|
+
* @param {string[]} ids 全量 provider id(顺序 = 缺省优先级)
|
|
1715
|
+
* @param {(id: string) => string} [labelOf] 展示名(仅用于诊断文本,不参与判定)
|
|
1716
|
+
*/
|
|
1717
|
+
function normalizeScheduling(raw, ids, labelOf) {
|
|
1718
|
+
const all = Array.isArray(ids) ? ids.slice() : []
|
|
1719
|
+
const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
|
|
1720
|
+
const enabled = all.slice()
|
|
1721
|
+
let priority = all.slice()
|
|
1722
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
1723
|
+
if (Array.isArray(raw.enabled)) {
|
|
1724
|
+
const seen = Object.create(null)
|
|
1725
|
+
const picked = []
|
|
1726
|
+
for (const x of raw.enabled) {
|
|
1727
|
+
const id = String(x == null ? '' : x)
|
|
1728
|
+
if (!id || all.indexOf(id) < 0 || seen[id]) continue
|
|
1729
|
+
seen[id] = true; picked.push(id)
|
|
1730
|
+
}
|
|
1731
|
+
// 空数组 = 用户真的想把全部关掉(而不是「没配」),保留该语义;
|
|
1732
|
+
// 非空但过滤后为空 = 写的全是未知 id(坏值)→ 回落全开(fail-open,
|
|
1733
|
+
// 绝不让一次手滑/陈旧配置把所有桥关掉)。
|
|
1734
|
+
if (raw.enabled.length === 0 || picked.length > 0) {
|
|
1735
|
+
enabled.length = 0
|
|
1736
|
+
Array.prototype.push.apply(enabled, picked)
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
if (Array.isArray(raw.priority)) {
|
|
1740
|
+
const seen2 = Object.create(null)
|
|
1741
|
+
const ordered = []
|
|
1742
|
+
for (const x of raw.priority) {
|
|
1743
|
+
const id = String(x == null ? '' : x)
|
|
1744
|
+
if (!id || all.indexOf(id) < 0 || seen2[id]) continue
|
|
1745
|
+
seen2[id] = true; ordered.push(id)
|
|
1746
|
+
}
|
|
1747
|
+
for (const id of all) if (!seen2[id]) ordered.push(id) // 漏写的排在末尾
|
|
1748
|
+
if (ordered.length) priority = ordered
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const order = priority.filter((id) => enabled.indexOf(id) >= 0)
|
|
1752
|
+
for (const id of enabled) if (order.indexOf(id) < 0) order.push(id)
|
|
1753
|
+
return { enabled, priority, order }
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** 展示文本:「CB → MIMO」或「全部启用 · CB → AGY → MIMO」。 */
|
|
1757
|
+
function describeScheduling(s, labelOf) {
|
|
1758
|
+
const st = s || {}
|
|
1759
|
+
const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
|
|
1760
|
+
const order = Array.isArray(st.order) ? st.order : []
|
|
1761
|
+
const names = order.map(label).join(' → ')
|
|
1762
|
+
return names ? names : '(全部禁用)'
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// ── 读盘(四形态共用;无 fs 时安全返回 null)────────────────────────────────────
|
|
1766
|
+
// 名字带 sched 前缀:本文件会与 bridge-kit / provider 描述符【文本内联进同一个动态
|
|
1767
|
+
// 沙箱脚本作用域】(providers/codebuddy.mjs 已有 builtinModule),撞名即隐患。
|
|
1768
|
+
function schedBuiltinModule(name) {
|
|
1769
|
+
try {
|
|
1770
|
+
const proc = globalThis.process
|
|
1771
|
+
return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
|
|
1772
|
+
} catch (e) { return null }
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function schedulingSettingsPath(env) {
|
|
1776
|
+
const e = env || (globalThis.process && globalThis.process.env) || {}
|
|
1777
|
+
try {
|
|
1778
|
+
if (e.CLI_BRIDGE_SETTINGS_FILE) return String(e.CLI_BRIDGE_SETTINGS_FILE)
|
|
1779
|
+
const nodePath = schedBuiltinModule('node:path')
|
|
1780
|
+
if (nodePath && e.DSH_HOME) return nodePath.join(String(e.DSH_HOME), 'cli-bridge-settings.json')
|
|
1781
|
+
} catch (e2) { }
|
|
1782
|
+
return null
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* 生效调度:文件 _scheduling > 缺省全开。
|
|
1787
|
+
* env/argv CLI_BRIDGE_PROVIDER(单面钉住,旧薄封装兼容语义)存在时视为最高优先 ——
|
|
1788
|
+
* 它代表「这个 MCP 进程只为这一家而启动」(部署者的显式意志,比面板勾选更具体、
|
|
1789
|
+
* 更靠近进程生命周期),调度不再削减它。
|
|
1790
|
+
* 宿主形态(preset / dynamic 桥接插件)应传 ignorePin:钉住只是 MCP 进程的启动参数,
|
|
1791
|
+
* 不该让全局 env 反过来把用户会话里的其他桥关掉。
|
|
1792
|
+
* @param {string[]} ids 全量 provider id
|
|
1793
|
+
* @param {{ env?: Record<string,string>, readFileSync?: Function, path?: string|null, ignorePin?: boolean }} [io]
|
|
1794
|
+
*/
|
|
1795
|
+
function resolveScheduling(ids, io) {
|
|
1796
|
+
const o = io || {}
|
|
1797
|
+
const env = o.env || (globalThis.process && globalThis.process.env) || {}
|
|
1798
|
+
let raw = null
|
|
1799
|
+
try {
|
|
1800
|
+
const p = ('path' in o) ? o.path : schedulingSettingsPath(env)
|
|
1801
|
+
const nodeFs = o.readFileSync ? { readFileSync: o.readFileSync } : schedBuiltinModule('node:fs')
|
|
1802
|
+
if (p && nodeFs && typeof nodeFs.readFileSync === 'function') {
|
|
1803
|
+
raw = JSON.parse(nodeFs.readFileSync(p, 'utf8'))
|
|
1804
|
+
}
|
|
1805
|
+
} catch (e) { raw = null }
|
|
1806
|
+
const file = normalizeScheduling(raw && typeof raw === 'object' ? raw[SCHEDULING_SECTION] : null, ids)
|
|
1807
|
+
const pinned = o.ignorePin ? '' : String(env.CLI_BRIDGE_PROVIDER || '').trim()
|
|
1808
|
+
if (pinned && ids.indexOf(pinned) >= 0) {
|
|
1809
|
+
return Object.assign(file, {
|
|
1810
|
+
enabled: [pinned],
|
|
1811
|
+
order: [pinned],
|
|
1812
|
+
pinned: pinned,
|
|
1813
|
+
source: raw ? 'env:CLI_BRIDGE_PROVIDER+file' : 'env:CLI_BRIDGE_PROVIDER'
|
|
1814
|
+
})
|
|
1815
|
+
}
|
|
1816
|
+
return Object.assign(file, { pinned: '', source: raw ? 'file' : 'default' })
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* 桥接面门禁(preset / dynamic 的 apply() 首行用):本 provider 是否被调度启用。
|
|
1821
|
+
* fail-open —— 任何异常(无 fs、文件损坏、沙箱限制)都返回 true:读不到配置永远
|
|
1822
|
+
* 不等于关掉桥(否则一次文件损坏就把三条 CLI 全灭了)。ignorePin 固定 true:
|
|
1823
|
+
* 钉住语义属于 MCP 独立进程,宿主形态的全局 env 不该反向影响用户会话。
|
|
1824
|
+
*/
|
|
1825
|
+
function isBridgeEnabled(selfId, ids, io) {
|
|
1826
|
+
try {
|
|
1827
|
+
const list = Array.isArray(ids) ? ids : []
|
|
1828
|
+
// 全量清单本身不可得(空/不含自己)= 无门禁语义,fail-open。
|
|
1829
|
+
if (!list.length || list.indexOf(String(selfId)) < 0) return true
|
|
1830
|
+
const o = Object.assign({ ignorePin: true }, io || {})
|
|
1831
|
+
return resolveScheduling(list, o).enabled.indexOf(String(selfId)) >= 0
|
|
1832
|
+
} catch (e) { return true }
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
const SCHED_ALL_IDS = ["agy","codebuddy","mimo","opencode","claude","codex"]
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
return {
|
|
1840
|
+
inject: ['tools', 'subprocess', 'systemPrompt', 'timer'],
|
|
1841
|
+
apply(ctx) {
|
|
1842
|
+
// 调度门禁(0.2.0):设置面板关掉 codex → 本形态不注册工具/政策段;fail-open。
|
|
1843
|
+
if (!isBridgeEnabled('codex', SCHED_ALL_IDS)) return
|
|
1844
|
+
const subprocess = ctx.subprocess
|
|
1845
|
+
const sandboxPolicy = ctx.get('sandboxPolicy')
|
|
1846
|
+
// 沙箱可能运行在无 process 的环境:typeof 守卫读 env(旧仓 dynamic 的等价面)。
|
|
1847
|
+
const env = (typeof process !== 'undefined' && process.env) ? process.env : {}
|
|
1848
|
+
|
|
1849
|
+
const provider = defineProvider({ env })
|
|
1850
|
+
|
|
1851
|
+
function publish(snapshot) {
|
|
1852
|
+
try {
|
|
1853
|
+
const collector = ctx.get('cliBridgeCollector')
|
|
1854
|
+
if (collector && typeof collector.mergeSnapshot === 'function') { collector.mergeSnapshot('codex', snapshot); return }
|
|
1855
|
+
} catch (e) { /* fall through to legacy alias */ }
|
|
1856
|
+
try {
|
|
1857
|
+
const legacy = ctx.get('codexCollector')
|
|
1858
|
+
if (legacy && typeof legacy.mergeSnapshot === 'function') legacy.mergeSnapshot(snapshot)
|
|
1859
|
+
} catch (e) { /* 灯数据尽力而为 */ }
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// exe 优先级(与描述符同一套):CODEX_BIN > CODEX_CLI > CODEX_CLI_PATH > PATH 的 codex。
|
|
1863
|
+
// .js/.mjs 目标经 process.execPath 包装(测试夹具形态);绝对路径直接交 spawn;
|
|
1864
|
+
// .cmd/.bat shim 明确拒绝(无 shell spawn 不解析它,走 shell 会二次解释 prompt)。
|
|
1865
|
+
const exeCandidates = codexExeCandidates(env)
|
|
1866
|
+
async function resolveExe(backend, signal) {
|
|
1867
|
+
let bare = ''
|
|
1868
|
+
for (let i = 0; i < exeCandidates.length; i++) {
|
|
1869
|
+
const c = exeCandidates[i]
|
|
1870
|
+
const shim = codexShimRejectReason(c)
|
|
1871
|
+
if (shim) throw new Error(shim)
|
|
1872
|
+
if (/\.(mjs|cjs|js)$/i.test(c) && typeof process !== 'undefined' && process.execPath) return [process.execPath, c]
|
|
1873
|
+
if (/[\\/]/.test(c)) return c
|
|
1874
|
+
if (!bare) bare = c
|
|
1875
|
+
}
|
|
1876
|
+
return await subprocess.resolveExecutable(bare || exeCandidates[exeCandidates.length - 1], undefined, signal)
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// cwd 兜底:CODEX_BRIDGE_CWD > sandboxPolicy.workspaceRoot > process.cwd() > '.'。
|
|
1880
|
+
function getCwdFallback() {
|
|
1881
|
+
if (env.CODEX_BRIDGE_CWD) return String(env.CODEX_BRIDGE_CWD)
|
|
1882
|
+
if (sandboxPolicy && typeof sandboxPolicy.workspaceRoot === 'string' && sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot
|
|
1883
|
+
if (typeof process !== 'undefined' && typeof process.cwd === 'function') { try { return process.cwd() } catch (e) { /* 沙箱受限 */ } }
|
|
1884
|
+
return '.'
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
provider.attach({ ctx, subprocess, resolveExe, getCwdFallback, publish })
|
|
1888
|
+
|
|
1889
|
+
const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
|
|
1890
|
+
|
|
1891
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1892
|
+
name: provider.tools.run.name,
|
|
1893
|
+
description: provider.tools.run.description,
|
|
1894
|
+
parameters: provider.tools.run.inputSchema,
|
|
1895
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
|
|
1896
|
+
execute: function (args, exec) { return provider.runToolLogic(args, exec) }
|
|
1897
|
+
}))
|
|
1898
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1899
|
+
name: provider.tools.continue.name,
|
|
1900
|
+
description: provider.tools.continue.description,
|
|
1901
|
+
parameters: provider.tools.continue.inputSchema,
|
|
1902
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
|
|
1903
|
+
execute: function (args, exec) { return provider.continueToolLogic(args, exec) }
|
|
1904
|
+
}))
|
|
1905
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1906
|
+
name: provider.tools.status.name,
|
|
1907
|
+
description: provider.tools.status.description,
|
|
1908
|
+
parameters: provider.tools.status.inputSchema,
|
|
1909
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderStatus(value, provider) } },
|
|
1910
|
+
execute: function (args) { return provider.statusToolLogic(args) }
|
|
1911
|
+
}))
|
|
1912
|
+
|
|
1913
|
+
// 浏览器指示灯轮询的 RPC 面(旧 dynamic/handle('<id>_status') verbatim 语义)。
|
|
1914
|
+
harness.handle('codex_status', function () { return provider.statusToolLogic({}) })
|
|
1915
|
+
|
|
1916
|
+
const policyText = [
|
|
1917
|
+
'codex-first execution policy (local OpenAI Codex CLI bridge).',
|
|
1918
|
+
'',
|
|
1919
|
+
'Local agent CLI "codex" handles coding tasks under full DSH control. Every dispatch is `codex exec --json --skip-git-repo-check` (never interactive, so approvals cannot block). By default codex runs in the background and returns a jobId immediately; collect results via job_output and watch live progress via codex_status anytime.',
|
|
1920
|
+
'',
|
|
1921
|
+
'Decision table for tool selection:',
|
|
1922
|
+
'- Single read/glob/grep or short (<30s) command -> Native tools.',
|
|
1923
|
+
'- Implementing, editing, refactoring, debugging, builds, multi-file investigation -> codex_run.',
|
|
1924
|
+
'- Follow-ups on the same topic -> codex_continue (with latest=true or sessionId; codex keeps the thread).',
|
|
1925
|
+
'- Final build/test verification of codex output -> Native tools.',
|
|
1926
|
+
'',
|
|
1927
|
+
'Context protocol (one topic = one codex thread):',
|
|
1928
|
+
'- First dispatch on a topic: provide the task with a compact CONTEXT preamble (point to paths, do not paste file contents):',
|
|
1929
|
+
' CONTEXT: 2-6 lines — goal; repo root; key paths; decisions/constraints; current state.',
|
|
1930
|
+
' TASK: the complete instruction.',
|
|
1931
|
+
'- Follow-ups: call codex_continue and state only what is new.',
|
|
1932
|
+
'',
|
|
1933
|
+
'Sandbox & approval:',
|
|
1934
|
+
'- mode=auto follows the DSH plan state: plan -> read-only sandbox, otherwise workspace-write.',
|
|
1935
|
+
'- codex exec has NO -a/--ask-for-approval flag; approval policy goes through -c approval_policy="…" only when configured. On resume the sandbox also goes through -c ("codex exec resume" has no -s and no -C).',
|
|
1936
|
+
'- If the sandbox denies an operation, raise sandboxMode instead of looping retries.',
|
|
1937
|
+
'',
|
|
1938
|
+
'Execution & Fallback protocol:',
|
|
1939
|
+
'- Dispatch defaults to background=true (set background=false only to block). Collect via job_output.',
|
|
1940
|
+
'- On rate-limit or network failure, a confirmation dialog opens in BOTH foreground and background while in flight.',
|
|
1941
|
+
'- If result has fallback=true (status FALLBACK_TO_DSH), the user chose DSH-local: finish with native tools and NEVER call codex again for this task.',
|
|
1942
|
+
'- Never loop codex calls; never ask codex to call back into DSH.',
|
|
1943
|
+
'',
|
|
1944
|
+
'Models & credentials:',
|
|
1945
|
+
'- Omit model unless you have a reason: codex then uses the CLI Bridge settings default, or the model from ~/.codex/config.toml.',
|
|
1946
|
+
'- Credentials belong to codex (~/.codex/config.toml model_providers.<name>, auth.json): on auth failures ask the user to run `codex login`; do not retry in a loop.'
|
|
1947
|
+
].join('\n')
|
|
1948
|
+
|
|
1949
|
+
ctx.systemPrompt.section({
|
|
1950
|
+
name: 'codex:policy',
|
|
1951
|
+
order: (typeof ctx.systemPrompt.getSectionOrder === 'function' ? ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT') : 2800) + 10,
|
|
1952
|
+
text: policyText
|
|
1953
|
+
})
|
|
1954
|
+
}
|
|
1955
|
+
}
|