@luchenglong/cli-bridge 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/PROVIDER-CONTRACT.md +87 -5
  3. package/README.md +12 -11
  4. package/docs/HANDOVER.md +109 -15
  5. package/dynamic/agy.host.js +1 -1
  6. package/dynamic/claude.host.js +1858 -0
  7. package/dynamic/claude.host.template.mjs +108 -0
  8. package/dynamic/codebuddy.host.js +1427 -1379
  9. package/dynamic/codex.host.js +1930 -0
  10. package/dynamic/codex.host.template.mjs +137 -0
  11. package/dynamic/mimo.host.js +721 -515
  12. package/dynamic/mimo.host.template.mjs +2 -0
  13. package/dynamic/opencode.host.js +741 -585
  14. package/dynamic/opencode.host.template.mjs +2 -0
  15. package/home-plugin/cli-bridge-indicator/lib/client.js +74 -90
  16. package/home-plugin/cli-bridge-indicator/lib/index.mjs +48 -0
  17. package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +2 -3
  18. package/mcp/cli-bridge-mcp.mjs +4 -2
  19. package/mcp/providers/claude.mjs +231 -0
  20. package/mcp/providers/codebuddy.mjs +427 -427
  21. package/mcp/providers/codex.mjs +239 -0
  22. package/package.json +6 -2
  23. package/preset/claude-first/agent.cordis.yml +136 -0
  24. package/preset/claude-first/cli-bridge.mjs +131 -0
  25. package/preset/claude-first/plugin-list.mjs +28 -0
  26. package/preset/claude-first/preset.yml +3 -0
  27. package/preset/codex-first/agent.cordis.yml +136 -0
  28. package/preset/codex-first/cli-bridge.mjs +160 -0
  29. package/preset/codex-first/plugin-list.mjs +28 -0
  30. package/preset/codex-first/preset.yml +3 -0
  31. package/providers/claude-exe.mjs +112 -0
  32. package/providers/claude-models.mjs +338 -0
  33. package/providers/claude-policy.mjs +37 -0
  34. package/providers/claude.mjs +561 -0
  35. package/providers/codebuddy-catalog.mjs +367 -599
  36. package/providers/codebuddy-consts.mjs +72 -72
  37. package/providers/codebuddy-models-source.mjs +254 -0
  38. package/providers/codebuddy-models-tool.mjs +106 -106
  39. package/providers/codebuddy-schemas.mjs +46 -46
  40. package/providers/codebuddy.mjs +600 -578
  41. package/providers/codex-cli.mjs +405 -0
  42. package/providers/codex-models.mjs +107 -0
  43. package/providers/codex.mjs +585 -0
  44. package/providers/mimo-pure.mjs +270 -111
  45. package/providers/mimo.mjs +453 -517
  46. package/providers/opencode-exe.mjs +190 -63
  47. package/providers/opencode.mjs +553 -587
  48. package/providers/registry.mjs +25 -1
  49. package/scripts/build.mjs +261 -262
  50. package/scripts/verify.mjs +8 -2
@@ -0,0 +1,1930 @@
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, codexCachedModelOptions, 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
+ // 手动拉取(契约 §3.2):面板只把 error 原文显示给用户,所以失败文案必须自带下一步。
602
+ // codex-cli 没有 models 子命令,live 清单只能走端点 HTTP —— 排查顺序就围绕端点/凭据写。
603
+ const REFRESH_TIMEOUT_MS = 20000
604
+ const REFRESH_HINT = '排查顺序:① 看 ~/.codex/config.toml 的 [model_providers.<当前 provider>] 里 base_url / experimental_bearer_token(或 auth.json 的 OPENAI_BASE_URL / OPENAI_API_KEY)是否指向可用端点;② 可用 CODEX_BRIDGE_MODELS_URL / CODEX_BRIDGE_MODELS_TOKEN 临时覆盖端点与凭据;③ 再点一次「拉取可用模型」(codex_models 默认 source=live 读的是同一份清单)。'
605
+
606
+ // ── 三工具 schema ────────────────────────────────────────────────────────────
607
+ const RUN_PARAMS = {
608
+ type: 'object',
609
+ additionalProperties: false,
610
+ required: ['prompt'],
611
+ properties: {
612
+ 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).' },
613
+ 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.' },
614
+ 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.' },
615
+ 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.' },
616
+ 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.' },
617
+ cwd: { type: 'string', description: 'Working directory for codex (-C, and the spawn cwd). Defaults to the DSH workspace root.' },
618
+ addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra writable directories (--add-dir). Ignored on resume (codex exec resume has no --add-dir).' },
619
+ images: { type: 'array', items: { type: 'string' }, description: 'Image files to attach to the prompt (-i).' },
620
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 900).' },
621
+ 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.' }
622
+ }
623
+ }
624
+ const CONTINUE_PARAMS = {
625
+ type: 'object',
626
+ additionalProperties: false,
627
+ required: ['prompt'],
628
+ properties: {
629
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing codex thread. State only what is new; codex preserves the thread context.' },
630
+ sessionId: { type: 'string', description: 'codex thread id to resume (the thread_id from a prior codex_run result).' },
631
+ latest: { type: 'boolean', description: 'Resume the most recent codex thread in this working directory (exec resume --last) instead of a specific id.' },
632
+ 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="…".' },
633
+ model: { type: 'string', description: 'Optional codex model id.' },
634
+ sandboxMode: { type: 'string', enum: ['read-only', 'workspace-write', 'danger-full-access'], description: 'Optional sandbox policy applied through -c sandbox_mode="…" (resume has no -s).' },
635
+ reasoningEffort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh'], description: 'Optional model_reasoning_effort override.' },
636
+ cwd: { type: 'string', description: 'Working directory (spawn cwd only — resume has no -C).' },
637
+ images: { type: 'array', items: { type: 'string' }, description: 'Image files to attach (-i).' },
638
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 900).' },
639
+ 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.' }
640
+ }
641
+ }
642
+ const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
643
+
644
+ function defineProvider (deps = {}) {
645
+ const env = deps.env || {}
646
+ let host = null
647
+ let settingsRef = { binPath: '', defaultModel: '', sandboxMode: '', approvalPolicy: '' }
648
+ codexSetHost(env, deps.nodeFs || null, deps.nodePath || null)
649
+
650
+ /**
651
+ * 取当前生效的偏好设置(三条来源按优先级合并,每次 buildArgv 重读 —— 面板改默认
652
+ * 模型后无需重启即生效):
653
+ * 1) deps.readSettings():MCP 形态(真 Node,每次现读设置文件);
654
+ * 2) host.getSettings('codex'):preset/dynamic 形态(attach 注入);
655
+ * 3) settingsRef:attach 时固化的一次性快照 / 缺省值。
656
+ */
657
+ function currentSettings() {
658
+ const out = Object.assign({}, settingsRef)
659
+ try {
660
+ if (typeof deps.readSettings === 'function') {
661
+ const r = deps.readSettings()
662
+ if (r && typeof r === 'object') {
663
+ const part = ('partition' in r || 'path' in r || 'home' in r) ? r.partition : r
664
+ if (part && typeof part === 'object') Object.assign(out, part)
665
+ }
666
+ }
667
+ } catch (e) { /* 设置读失败 = 用缺省,不影响本次调用 */ }
668
+ try {
669
+ if (host && typeof host.getSettings === 'function') {
670
+ const s = host.getSettings('codex')
671
+ if (s && typeof s === 'object') Object.assign(out, s)
672
+ }
673
+ } catch (e) { /* 同上 */ }
674
+ return out
675
+ }
676
+
677
+ function sandboxFor(mode, args, settings) {
678
+ if (args && args.sandboxMode && SANDBOX_MODES.indexOf(String(args.sandboxMode)) >= 0) return String(args.sandboxMode)
679
+ if (settings && settings.sandboxMode && SANDBOX_MODES.indexOf(String(settings.sandboxMode)) >= 0) return String(settings.sandboxMode)
680
+ return mode === 'plan' ? 'read-only' : 'workspace-write'
681
+ }
682
+
683
+ const descriptor = {
684
+ id: 'codex', label: 'CODEX', presetId: 'codex-first', defaultBackend: null, defaultMode: 'auto',
685
+ exeName: 'codex', binEnvVars: ['CODEX_BIN', 'CODEX_CLI'], unavailableStatus: 'CODEX_UNAVAILABLE',
686
+
687
+ // 单后端:model_provider/base_url 属于 codex 自身配置,桥不引入第二后端概念。
688
+ backends() { return null },
689
+ normalizeBackend() { return null },
690
+ resolveBackend(args, target, preferred) { return null },
691
+
692
+ // 实测 flag 集(exec 与 exec resume 的可用旗不同 —— 见文件头)。
693
+ buildArgv(exePrefix, args, opts) {
694
+ const o = opts || {}
695
+ const a = args || {}
696
+ const settings = currentSettings()
697
+ const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
698
+ const resume = !!(a.sessionId || a.continueLatest || a.latest === true)
699
+ argv.push('exec')
700
+ if (resume) {
701
+ argv.push('resume')
702
+ if (a.sessionId) argv.push(String(a.sessionId))
703
+ else argv.push('--last')
704
+ }
705
+ // --json 是所有折叠/解析的前提;--skip-git-repo-check 让非 git 目录也能跑。
706
+ argv.push('--json', '--skip-git-repo-check')
707
+ // -C 只有 exec 有;resume 的 cwd 由 spawn 的 cwd 决定(实测 resume 传 -C 直接报错)。
708
+ const cwd = a.cwd ? String(a.cwd) : null
709
+ if (cwd && !resume) argv.push('-C', cwd)
710
+
711
+ let mode = a.mode || 'auto'
712
+ if (mode === 'auto') mode = o.planActive ? 'plan' : 'accept-edits'
713
+ const sandbox = sandboxFor(mode, a, settings)
714
+ if (resume) argv.push('-c', 'sandbox_mode="' + sandbox + '"')
715
+ else argv.push('-s', sandbox)
716
+
717
+ const approval = (settings && settings.approvalPolicy && APPROVAL_POLICIES.indexOf(String(settings.approvalPolicy)) >= 0)
718
+ ? String(settings.approvalPolicy)
719
+ : ''
720
+ // exec 没有 -a/--ask-for-approval;等价效果只能经 -c 覆盖(TOML 字符串值)。
721
+ if (approval) argv.push('-c', 'approval_policy="' + approval + '"')
722
+
723
+ const model = a.model || o.defaultModel || settings.defaultModel || ''
724
+ if (model) argv.push('-m', String(model))
725
+ if (a.reasoningEffort && REASONING_EFFORTS.indexOf(String(a.reasoningEffort)) >= 0) {
726
+ argv.push('-c', 'model_reasoning_effort="' + String(a.reasoningEffort) + '"')
727
+ }
728
+ if (!resume && Array.isArray(a.addDirs)) for (const d of a.addDirs) { if (d) argv.push('--add-dir', String(d)) }
729
+ if (Array.isArray(a.images)) for (const f of a.images) { if (f) argv.push('-i', String(f)) }
730
+ // prompt 必须最后(位置参数)。
731
+ argv.push(String(a.prompt))
732
+ const timeoutSec = clampInt(a.timeoutSec, 900, 10, 3600)
733
+ return { argv, mode, timeoutSec }
734
+ },
735
+
736
+ // NDJSON(--json)。宽容:坏行跳过;无任何 JSON 行返回 null(交 buildResult 走纯文本)。
737
+ // 终止判据 = turn.completed + 退出码 0;顶层 error / turn.failed 才算致命。
738
+ // ⚠️ item.completed 里 item.type==="error" 只是告警(实测续聊会带),绝不致命。
739
+ parseJson(stdoutText) {
740
+ const trimmed = String(stdoutText || '').trim()
741
+ if (!trimmed) return null
742
+ let sawJson = false
743
+ let threadId = null
744
+ let response = ''
745
+ let usage = null
746
+ let completed = false
747
+ let fatal = null
748
+ let turns = 0
749
+ for (const raw of trimmed.split(/\r?\n/)) {
750
+ const ln = raw.trim()
751
+ if (ln.charAt(0) !== '{') continue
752
+ let ev
753
+ try { ev = JSON.parse(ln) } catch (e) { continue }
754
+ if (!ev || typeof ev !== 'object') continue
755
+ sawJson = true
756
+ const t = ev.type
757
+ if (t === 'thread.started') {
758
+ if (typeof ev.thread_id === 'string') threadId = ev.thread_id
759
+ } else if (t === 'turn.started') {
760
+ turns += 1
761
+ } else if (t === 'turn.completed') {
762
+ completed = true
763
+ if (ev.usage && typeof ev.usage === 'object') usage = ev.usage
764
+ } else if (t === 'turn.failed') {
765
+ const msg = ev.error && typeof ev.error.message === 'string' ? ev.error.message
766
+ : (typeof ev.error === 'string' ? ev.error : 'turn failed')
767
+ fatal = { message: msg }
768
+ } else if (t === 'error') {
769
+ // 顶层 error 事件:message 常常是嵌套 JSON 字符串(实测)。
770
+ fatal = { message: typeof ev.message === 'string' ? ev.message : 'error event' }
771
+ } else if (t === 'item.completed' && ev.item && typeof ev.item === 'object') {
772
+ if (ev.item.type === 'agent_message' && typeof ev.item.text === 'string') response = ev.item.text
773
+ }
774
+ }
775
+ if (!sawJson) return null
776
+ return {
777
+ type: completed ? 'turn.completed' : (fatal ? 'turn.failed' : 'incomplete'),
778
+ threadId: threadId || null,
779
+ response: String(response || ''),
780
+ usage: usage || null,
781
+ completed: completed,
782
+ error: fatal,
783
+ numTurns: turns || null
784
+ }
785
+ },
786
+
787
+ // 统一结果对象(契约 §1)。成功必须同时满足:退出码 0 + 收到 turn.completed +
788
+ // 无顶层错误事件。只看退出码不够(实测 400 时 stdout 仍是合法事件流,退出码 1)。
789
+ buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
790
+ const exitCode = outcome ? outcome.exitCode : null
791
+ const stderr = stderrText ? String(stderrText).slice(-2000) : ''
792
+ const mk = (over) => Object.assign({
793
+ ok: false, status: 'ERROR', response: '', sessionId: null, durationSeconds: null,
794
+ numTurns: null, totalTokens: null, duration: null, exitCode, mode, backend: backend || null, stderr
795
+ }, over)
796
+ const usageOf = (u) => {
797
+ if (!u || typeof u !== 'object') return null
798
+ const i = Number(u.input_tokens) || 0
799
+ const o = Number(u.output_tokens) || 0
800
+ return (i + o) > 0 ? i + o : null
801
+ }
802
+ if (parsed && typeof parsed === 'object') {
803
+ const sessionId = parsed.threadId || null
804
+ const totalTokens = usageOf(parsed.usage)
805
+ if (parsed.error) {
806
+ const msg = String(parsed.error.message || 'turn failed')
807
+ return mk({
808
+ status: 'TURN_FAILED', response: parsed.response || '', sessionId,
809
+ numTurns: parsed.numTurns, totalTokens, errorMessage: msg,
810
+ stderr: (stderr ? stderr + ' ' : '') + msg
811
+ })
812
+ }
813
+ if (exitCode === 0 && parsed.completed) {
814
+ return {
815
+ ok: true, status: 'SUCCESS', response: parsed.response || '', sessionId,
816
+ durationSeconds: null, numTurns: parsed.numTurns || 1, totalTokens,
817
+ exitCode, mode, backend: backend || null, stderr
818
+ }
819
+ }
820
+ if (exitCode === 0) {
821
+ return mk({
822
+ status: 'INCOMPLETE', response: parsed.response || '', sessionId, totalTokens,
823
+ numTurns: parsed.numTurns,
824
+ 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.'
825
+ })
826
+ }
827
+ return mk({
828
+ status: exitCode === 2 ? 'BAD_ARGS' : 'ERROR',
829
+ response: parsed.response || '', sessionId, numTurns: parsed.numTurns, totalTokens,
830
+ stderr: (stderr ? stderr + ' ' : '') + 'codex exited ' + exitCode
831
+ })
832
+ }
833
+ const text = String(stdoutText || '').trim()
834
+ if (exitCode === 0) {
835
+ return {
836
+ ok: true, status: 'SUCCESS', response: text, sessionId: null,
837
+ durationSeconds: null, numTurns: null, totalTokens: null, exitCode, mode,
838
+ backend: backend || null, stderr
839
+ }
840
+ }
841
+ return mk({
842
+ status: exitCode === 2 ? 'BAD_ARGS' : 'ERROR',
843
+ response: '',
844
+ stderr: stderr || ('codex exited ' + exitCode),
845
+ rawStdout: text.slice(-2000)
846
+ })
847
+ },
848
+
849
+ // 实时步骤折叠(写 project.current/trail)。事件形态见文件头。
850
+ // item 类型实测:command_execution / reasoning / agent_message / error(告警) /
851
+ // file_change / mcp_tool_call / web_search —— 未知类型也按通用形态记账,绝不吞事件。
852
+ foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
853
+ if (!ev || typeof ev !== 'object') return
854
+ const t = ev.type
855
+ if (!t) return
856
+ p.updatedAt = Date.now()
857
+ const cap = maxTrail || 12
858
+ const capLen = maxArgLen || 120
859
+ const pushTrail = (entry) => { p.trail.push(entry); if (p.trail.length > cap) p.trail.shift() }
860
+ if (t === 'thread.started') {
861
+ p.current = { tool: 'session', args: { thread_id: String(ev.thread_id || '').slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
862
+ return
863
+ }
864
+ if (t === 'turn.completed' || t === 'turn.failed') { p.current = null; return }
865
+ if (t === 'error') {
866
+ pushTrail({ stepIndex: p.trail.length, state: 'ERROR', tool: 'error', args: { message: String(ev.message || '').slice(0, capLen) }, at: nowIso() })
867
+ p.current = null
868
+ return
869
+ }
870
+ if (t !== 'item.started' && t !== 'item.completed') return
871
+ const item = ev.item && typeof ev.item === 'object' ? ev.item : null
872
+ if (!item) return
873
+ const kind = String(item.type || 'item')
874
+ const failed = kind === 'error' || item.status === 'failed' || (typeof item.exit_code === 'number' && item.exit_code !== 0)
875
+ const state = t === 'item.started' ? 'ACTIVE' : (failed ? 'ERROR' : 'DONE')
876
+ let tool = kind
877
+ let args = null
878
+ if (kind === 'command_execution') {
879
+ tool = 'command'
880
+ args = summarizeArgs({ command: item.command })
881
+ } else if (kind === 'agent_message') {
882
+ tool = 'agent_response'
883
+ args = { text_delta: String(item.text || '').slice(0, capLen) }
884
+ } else if (kind === 'reasoning') {
885
+ tool = 'reasoning'
886
+ args = { text_delta: String(item.text || item.summary || '').slice(0, capLen) }
887
+ } else if (kind === 'file_change') {
888
+ tool = 'file_change'
889
+ const changes = Array.isArray(item.changes) ? item.changes.map((c) => (c && (c.path || c.file)) || '').filter(Boolean) : []
890
+ args = summarizeArgs({ paths: changes.slice(0, 6) })
891
+ } else if (kind === 'mcp_tool_call') {
892
+ tool = 'mcp:' + String(item.server || '?') + '/' + String(item.tool || item.name || '?')
893
+ args = summarizeArgs(item.arguments || null)
894
+ } else if (kind === 'web_search') {
895
+ tool = 'web_search'
896
+ args = summarizeArgs({ query: item.query })
897
+ } else if (kind === 'error') {
898
+ tool = 'warning'
899
+ args = { message: String(item.message || '').slice(0, capLen) }
900
+ } else {
901
+ args = summarizeArgs({ type: kind })
902
+ }
903
+ const key = item.id ? String(item.id) : null
904
+ p.codexByItem = p.codexByItem || Object.create(null)
905
+ const prior = key ? p.codexByItem[key] : null
906
+ if (prior) {
907
+ prior.state = state
908
+ if (key) delete p.codexByItem[key]
909
+ if (p.current && p.current.itemKey === key) p.current = null
910
+ return
911
+ }
912
+ const entry = { stepIndex: p.trail.length, state, tool, args: args || null, at: nowIso() }
913
+ pushTrail(entry)
914
+ if (t === 'item.started') {
915
+ if (key) p.codexByItem[key] = entry
916
+ p.current = { tool, args: args || null, stepIndex: entry.stepIndex, since: nowIso(), itemKey: key }
917
+ }
918
+ },
919
+
920
+ // 瞬时错误重试:codex 自己会重试网络,这里只兜「流断了但非限流」这一类。
921
+ isTransientCliError(res) {
922
+ if (!res || res.ok) return false
923
+ if (isLimited(res)) return false
924
+ if (res.status === 'INCOMPLETE') return true
925
+ const hay = String(res.errorMessage || '') + ' ' + String(res.stderr || '')
926
+ return /stream disconnected|connection reset|ECONNRESET|ETIMEDOUT|temporarily unavailable|overloaded|try again/i.test(hay)
927
+ },
928
+
929
+ failureHint(res) {
930
+ if (!res || res.ok) return ''
931
+ const st = String(res.status || '')
932
+ if (st === 'CODEX_UNAVAILABLE') return '[诊断] 未找到 codex 可执行文件:请安装 Codex CLI,或在 CLI Bridge 设置面板填写 codex 可执行文件路径(也可设 CODEX_BIN)。'
933
+ if (st === 'BAD_ARGS') return '[诊断] codex 拒绝了参数(exit 2):请检查 model/sandboxMode 等取值;codex_models 可列出可用模型 id。'
934
+ const hay = String(res.stderr || '') + ' ' + String(res.errorMessage || '') + ' ' + String(res.response || '')
935
+ if (/not logged in|please run.*login|unauthorized|invalid api key|401|403/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
936
+ return '[诊断] codex 凭据问题(未登录 / API key 无效):请让用户执行 `codex login`,或检查 ~/.codex/config.toml 里 model_providers.<name> 的 base_url 与 experimental_bearer_token。这不是限流。'
937
+ }
938
+ if (/not a valid model|invalid model|model not found|unknown model|unsupported model/i.test(hay)) {
939
+ return '[诊断] 模型 id 不被服务端接受:先用 codex_models(默认 source=live)列出该端点真实可用的 id,再传 model。注意 ~/.codex/models.json 与 model_catalog_json 只是本地目录,不代表服务端可用。'
940
+ }
941
+ if (/not inside a trusted directory|not a git repository|skip-git-repo-check/i.test(hay)) {
942
+ return '[诊断] codex 拒绝了当前工作目录(非 git 仓库):本桥已默认加 --skip-git-repo-check;若仍出现,说明 argv 被外部改写,请检查 buildArgv。'
943
+ }
944
+ if (/approval|sandbox|permission denied|operation not permitted/i.test(hay)) {
945
+ return '[诊断] 沙箱/审批拒绝了操作:非交互 exec 无法弹出审批,请调高 sandboxMode(workspace-write → danger-full-access)或在设置面板改「沙箱策略」,然后重跑。'
946
+ }
947
+ if (/quota|额度|rate.?limit|insufficient|credit|balance|402/i.test(hay)) {
948
+ return '[诊断] 端点额度可能耗尽:换一个(优先 :free)模型,或改用原生工具完成本任务;不要循环重试。'
949
+ }
950
+ return ''
951
+ },
952
+
953
+ isSuccessStatus(st) { return st === 'SUCCESS' },
954
+
955
+ tools: {
956
+ 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 },
957
+ 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 },
958
+ 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 }
959
+ },
960
+
961
+ // codex_models:宿主有 spawn/fetch 能力时注册(preset 注入 deps.process / MCP 注入
962
+ // deps.nodeChild;dynamic 沙箱形态省略 —— 与 agy/opencode 同约定)。
963
+ extraTools: (deps.process || deps.nodeChild || typeof deps.fetch === 'function')
964
+ ? [codexModelsTool({ env, deps })]
965
+ : [],
966
+
967
+ settings: {
968
+ keys: ['binPath', 'defaultModel', 'sandboxMode', 'approvalPolicy'],
969
+ defaults: () => ({ binPath: '', defaultModel: '', sandboxMode: '', approvalPolicy: '' }),
970
+ normalize: (raw) => {
971
+ const r = raw && typeof raw === 'object' ? raw : {}
972
+ const pick = (v) => (typeof v === 'string' ? String(v).trim() : '')
973
+ const sandbox = pick(r.sandboxMode)
974
+ const approval = pick(r.approvalPolicy)
975
+ return {
976
+ binPath: pick(r.binPath),
977
+ defaultModel: pick(r.defaultModel),
978
+ sandboxMode: SANDBOX_MODES.indexOf(sandbox) >= 0 ? sandbox : '',
979
+ approvalPolicy: APPROVAL_POLICIES.indexOf(approval) >= 0 ? approval : ''
980
+ }
981
+ },
982
+ // 面板是同步回调,且**不再自动探测**(契约 §3.2):defaultModel 的候选只来自
983
+ // 已缓存的 live 清单(codexCachedModelOptions 纯读,不 spawnSync、不发请求)。
984
+ // 探测由用户点「拉取可用模型」→ settings.refreshModels() 显式触发。
985
+ meta: (env2) => {
986
+ const e = env2 && typeof env2 === 'object' ? env2 : env
987
+ const opts = codexCachedModelOptions(e)
988
+ const exeCandidates = codexExeCandidates(e)
989
+ return {
990
+ titleSuffix: 'Codex',
991
+ fields: [
992
+ {
993
+ key: 'binPath', label: 'codex 可执行文件路径', type: 'select', allowCustom: true,
994
+ options: exeCandidates.map((p) => ({ value: p, label: p })),
995
+ placeholder: '留空 = CODEX_BIN / CODEX_CLI / PATH 的 codex',
996
+ note: '留空即按 env › PATH 顺序解析;填 .mjs/.js 会经 node 启动(测试夹具形态)。'
997
+ },
998
+ {
999
+ key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
1000
+ options: opts.options,
1001
+ // 面板据此渲染「手动拉取」按钮(契约 §3.2);meta() 自身永不探测。
1002
+ refresh: { label: '拉取可用模型' },
1003
+ placeholder: '留空 = 由 ~/.codex/config.toml 的 model 决定',
1004
+ note: opts.note
1005
+ },
1006
+ {
1007
+ key: 'sandboxMode', label: '沙箱策略', type: 'select',
1008
+ options: [
1009
+ { value: '', label: '(跟随 mode / codex 自身 config)' },
1010
+ { value: 'read-only', label: 'read-only(只读,plan 模式默认)' },
1011
+ { value: 'workspace-write', label: 'workspace-write(可写工作区,默认)' },
1012
+ { value: 'danger-full-access', label: 'danger-full-access(无沙箱,慎用)' }
1013
+ ],
1014
+ note: '对应 codex 的 -s;续聊(resume)没有 -s,改经 -c sandbox_mode 生效。'
1015
+ },
1016
+ {
1017
+ key: 'approvalPolicy', label: '审批策略', type: 'select',
1018
+ options: [
1019
+ { value: '', label: '(不覆盖,用 codex 自身 config)' },
1020
+ { value: 'never', label: 'never(从不审批,推荐)' },
1021
+ { value: 'on-request', label: 'on-request' },
1022
+ { value: 'on-failure', label: 'on-failure' },
1023
+ { value: 'untrusted', label: 'untrusted' }
1024
+ ],
1025
+ note: '经 -c approval_policy="…" 传入 —— codex exec 没有 -a 旗(实测报 unexpected argument)。非交互场景建议 never。'
1026
+ }
1027
+ ]
1028
+ }
1029
+ },
1030
+ /**
1031
+ * 手动拉取(契约 §3.2):面板「拉取可用模型」按钮 → keystone
1032
+ * POST /cli-bridge/settings/models → 这里。这是本 provider 唯一的面板探测入口。
1033
+ *
1034
+ * 复用 codexLiveModels(工具面同一个实现:GET {base_url}/models + Bearer),不写第二套;
1035
+ * 成功时它已把 payload 写回 CODEX_MODEL_CACHE,随后 meta() 直接命中。
1036
+ * 铁律:失败 = ok:false + 可操作错误 + 空 ids;绝不返回 {ok:true, ids:[]},
1037
+ * 也绝不把 catalog/builtin(本地目录、非权威)混进这份「服务端可用清单」。
1038
+ */
1039
+ async refreshModels(settings, env2) {
1040
+ const e = (env2 && typeof env2 === 'object') ? env2 : env
1041
+ const res = await codexLiveModels(e, {
1042
+ refresh: true,
1043
+ fetch: deps.fetch,
1044
+ timeoutMs: REFRESH_TIMEOUT_MS
1045
+ })
1046
+ if (!res.ok) {
1047
+ return { ok: false, ids: [], source: 'live', error: String(res.error || 'live model list unavailable') + ' ' + REFRESH_HINT }
1048
+ }
1049
+ const ids = []
1050
+ const labels = {}
1051
+ for (const m of res.models || []) {
1052
+ if (!m || !m.id || ids.indexOf(m.id) >= 0) continue
1053
+ ids.push(m.id)
1054
+ labels[m.id] = m.free ? m.id + ' [free]' : m.id
1055
+ }
1056
+ if (!ids.length) {
1057
+ // 拿到 2xx 却没有任何可用 id:显式失败,绝不把空清单当成功(本项目 0.2.6~0.2.9 的坑)。
1058
+ return { ok: false, ids: [], source: 'live', error: '端点 ' + String(res.url || '') + ' 返回 0 个可用模型 id(响应已解析但没有可用项)。' + REFRESH_HINT }
1059
+ }
1060
+ return { ok: true, ids, labels, source: 'live' }
1061
+ },
1062
+
1063
+ diagnose: (settings, env2) => {
1064
+ const s = settings && typeof settings === 'object' ? settings : {}
1065
+ const e = env2 && typeof env2 === 'object' ? env2 : env
1066
+ const bits = []
1067
+ bits.push(s.binPath ? 'bin=设置:' + s.binPath : 'bin=' + codexExeCandidates(e)[0])
1068
+ bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(codex config 自决)')
1069
+ const ep = codexModelsEndpoint(e)
1070
+ if (ep.ok) {
1071
+ const cached = codexCachedLiveModels(e)
1072
+ bits.push('endpoint=' + endpointHost(ep.url) + (cached && cached.ok ? ' live=' + cached.count : ' live=未缓存'))
1073
+ } else bits.push('endpoint=未配置')
1074
+ bits.push('sandbox=' + (s.sandboxMode || 'mode 派生'))
1075
+ bits.push('approval=' + (s.approvalPolicy || 'codex config'))
1076
+ const cfg = codexReadConfig(e)
1077
+ if (cfg.model) bits.push('cfg.model=' + cfg.model)
1078
+ bits.push('home=' + (codexHomeDir(e) || '?'))
1079
+ return bits.join(' · ')
1080
+ }
1081
+ },
1082
+
1083
+ mcp: { version: '0.2.8' }
1084
+ }
1085
+
1086
+ // 三形态共用的工具逻辑(kit 的 coreExecute 负责 jobs/超时/回退/瞬时重试)。
1087
+ descriptor.runToolLogic = async function (args, exec) {
1088
+ const a = args || {}
1089
+ const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
1090
+ return this.runner.coreExecute(normalized, exec)
1091
+ }
1092
+ descriptor.continueToolLogic = async function (args, exec) {
1093
+ const a = args || {}
1094
+ const mapped = {
1095
+ prompt: a.prompt, model: a.model, mode: a.mode, sandboxMode: a.sandboxMode,
1096
+ reasoningEffort: a.reasoningEffort, cwd: a.cwd, images: a.images,
1097
+ timeoutSec: a.timeoutSec,
1098
+ background: a.background === undefined ? true : a.background
1099
+ }
1100
+ if (a.sessionId) mapped.sessionId = String(a.sessionId)
1101
+ else if (a.latest) mapped.continueLatest = true
1102
+ return this.runner.coreExecute(mapped, exec)
1103
+ }
1104
+ descriptor.statusToolLogic = function (args) {
1105
+ const a = args || {}
1106
+ const snap = this.engine.statusSnapshot()
1107
+ if (a.cwd) {
1108
+ const key = String(a.cwd)
1109
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
1110
+ const g = snap.projects[0]
1111
+ if (g) {
1112
+ Object.assign(snap, {
1113
+ state: g.state, running: g.running, current: g.current, trail: g.trail,
1114
+ lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
1115
+ lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
1116
+ })
1117
+ }
1118
+ }
1119
+ return snap
1120
+ }
1121
+
1122
+ // MCP/测试形态复用同一套模型读取(与 extraTools 同源,避免两处实现漂移)。
1123
+ descriptor.listModels = async function (args) {
1124
+ return codexModelsTool({ env, deps }).logic(args || {})
1125
+ }
1126
+ descriptor.liveModels = codexLiveModels
1127
+ descriptor.catalogModels = codexCatalogModels
1128
+ descriptor.builtinModels = codexBuiltinModels
1129
+
1130
+ descriptor.attach = function (hostOpts) {
1131
+ host = hostOpts || {}
1132
+ codexSetHost(env, deps.nodeFs || null, deps.nodePath || null)
1133
+ try {
1134
+ if (typeof host.getSettings === 'function') {
1135
+ const s = host.getSettings('codex')
1136
+ if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
1137
+ }
1138
+ } catch (e) { /* 设置缺省不影响装配 */ }
1139
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
1140
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
1141
+ this.runner = runner
1142
+ return runner
1143
+ }
1144
+ return descriptor
1145
+ }
1146
+
1147
+ // ── inlined from providers/codex-cli.mjs ──
1148
+ // providers/codex-cli.mjs — codex 事实层:配置发现 / 可执行探测 / 三层模型清单。
1149
+ //
1150
+ // 从 providers/codex.mjs 拆出(规则 §26:主文件必须留在 600 物理行内)。这里只放
1151
+ // 与「本机 codex 装成什么样」有关的纯逻辑:~/.codex 布局、config.toml 最小 TOML 解析、
1152
+ // 可执行候选、三层模型清单(实时 HTTP / 本地目录 / 内置定义)。不含状态机、不含 argv。
1153
+ //
1154
+ // 沙箱纪律(与 providers/*.mjs 同):本文件会被 scripts/build.mjs 经模板的 EXTRA 标记
1155
+ // 内联进同一个 dynamic 沙箱作用域 —— 因为 codex.mjs 主文件已超红线,必须走片文件:
1156
+ // 1) 不得 import 任何模块(import 行会被剥掉,函数体留下但依赖丢失 → 运行期炸);
1157
+ // Node 内建一律经 globalThis.process.getBuiltinModule + typeof 守卫。
1158
+ // 2) 顶层标识符一律 codex 前缀,禁止与 bridge-kit / core/scheduling.mjs / codex.mjs
1159
+ // 撞名(同一作用域重复声明 = 构建期 Identifier already declared)。
1160
+ // 3) 不得出现 import.meta 字面量。
1161
+ // 4) ⚠️ 注释里绝不写出占位标记字面量:build 做全局文本替换,注释里出现同样会被
1162
+ // 替换成正文代码,把注释从中间劈开 → 生成物语法崩。
1163
+ //
1164
+ // 实测事实(codex-cli 0.98.0,本机 Windows):
1165
+ // • 没有 `codex models` 子命令;`codex debug` 只有 app-server。故「实时模型清单」
1166
+ // 只能走 HTTP(config.toml 里 model_providers.<active>.base_url + bearer token)。
1167
+ // • 用无效 model id 探测不会吐名录(只报 "xxx is not a valid model ID")——
1168
+ // 与 codebuddy/claude 的 400 名录机制不同,别照抄那套。
1169
+ // • GET {base_url}/models 实测 200,返回 { data: [ {id,name,pricing,context_length,…} ] }。
1170
+ // • ~/.codex/{model_catalog_json} 与 ~/.codex/models.json 都是 { models: [ {slug,…} ] }。
1171
+
1172
+ // ── 宿主能力(env / fs / path)───────────────────────────────────────────────
1173
+ // keystone 的 settings 面板只用 createProvider(id,{env}) 构造描述符(不 attach、不注入
1174
+ // nodeFs),所以这里必须能自己摸到 Node 内建;摸不到(dynamic 沙箱)时优雅缺席,
1175
+ // 由调用方给出「本形态没有 fs」的明确错误,绝不静默返回空列表。
1176
+ let codexHostEnv = {}
1177
+ let codexHostFs = null
1178
+ let codexHostPath = null
1179
+
1180
+ function codexSetHost(envObj, fsMod, pathMod) {
1181
+ if (envObj && typeof envObj === 'object') codexHostEnv = envObj
1182
+ if (fsMod && typeof fsMod.readFileSync === 'function') codexHostFs = fsMod
1183
+ if (pathMod && typeof pathMod.join === 'function') codexHostPath = pathMod
1184
+ }
1185
+
1186
+ function codexProcess() {
1187
+ try {
1188
+ const p = globalThis.process
1189
+ return p && typeof p === 'object' ? p : null
1190
+ } catch (e) { return null }
1191
+ }
1192
+
1193
+ function codexBuiltin(name) {
1194
+ try {
1195
+ const p = codexProcess()
1196
+ return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule(name) : null
1197
+ } catch (e) { return null }
1198
+ }
1199
+
1200
+ function codexFs() {
1201
+ if (codexHostFs) return codexHostFs
1202
+ const nf = codexBuiltin('node:fs')
1203
+ return nf && typeof nf.readFileSync === 'function' ? nf : null
1204
+ }
1205
+
1206
+ function codexPathJoin() {
1207
+ const parts = Array.prototype.slice.call(arguments).filter((x) => x !== undefined && x !== null && x !== '')
1208
+ if (!parts.length) return ''
1209
+ const mod = codexHostPath || codexBuiltin('node:path')
1210
+ if (mod && typeof mod.join === 'function') return mod.join.apply(mod, parts)
1211
+ return parts.map((x) => String(x)).join('/')
1212
+ }
1213
+
1214
+ function codexReadText(p) {
1215
+ if (!p) return null
1216
+ const fs = codexFs()
1217
+ if (!fs) return null
1218
+ try { return fs.readFileSync(p, 'utf8') } catch (e) { return null }
1219
+ }
1220
+
1221
+ /** CODEX_HOME 覆盖 ~/.codex(CLI 自身语义)。 */
1222
+ function codexHomeDir(env) {
1223
+ const e = env || codexHostEnv || {}
1224
+ if (e.CODEX_HOME) return String(e.CODEX_HOME)
1225
+ const home = e.USERPROFILE || e.HOME || ''
1226
+ return home ? codexPathJoin(home, '.codex') : ''
1227
+ }
1228
+
1229
+ // ── config.toml 最小解析 ─────────────────────────────────────────────────────
1230
+ // 只需要字符串标量与表头:本模块读的键是 model / model_provider / model_catalog_json,
1231
+ // 以及 [model_providers.<name>] 下的 base_url / experimental_bearer_token。
1232
+ // 数组(notify = [...])、布尔、数字一律跳过 —— 宁可少读,也不猜错类型。
1233
+ function codexParseToml(text) {
1234
+ const top = Object.create(null)
1235
+ const tables = Object.create(null)
1236
+ let cur = top
1237
+ for (const raw of String(text || '').split(/\r?\n/)) {
1238
+ const line = raw.trim()
1239
+ if (!line || line.charAt(0) === '#') continue
1240
+ const hdr = /^\[([^\]]+)\]$/.exec(line)
1241
+ if (hdr) {
1242
+ const name = hdr[1].trim()
1243
+ if (!tables[name]) tables[name] = Object.create(null)
1244
+ cur = tables[name]
1245
+ continue
1246
+ }
1247
+ const kv = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line)
1248
+ if (!kv) continue
1249
+ const rawVal = kv[2].trim()
1250
+ let str = null
1251
+ if (/^"(?:[^"\\]|\\.)*"$/.test(rawVal)) str = rawVal.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
1252
+ else if (/^'[^']*'$/.test(rawVal)) str = rawVal.slice(1, -1)
1253
+ else if (/^[A-Za-z0-9_.:/@-]+$/.test(rawVal) && rawVal !== 'true' && rawVal !== 'false' && !/^-?[0-9]+(\.[0-9]+)?$/.test(rawVal)) str = rawVal
1254
+ if (str === null) continue
1255
+ cur[kv[1]] = str
1256
+ }
1257
+ return { top, tables }
1258
+ }
1259
+
1260
+ /**
1261
+ * 汇总本机 codex 的生效配置。
1262
+ * @returns {{ home, configPath, configFound, providerName, baseUrl, token, model,
1263
+ * catalogFile, authPath, authFound, authBaseUrl, authApiKey }}
1264
+ */
1265
+ function codexReadConfig(env) {
1266
+ const e = env || codexHostEnv || {}
1267
+ const home = codexHomeDir(e)
1268
+ const configPath = home ? codexPathJoin(home, 'config.toml') : ''
1269
+ const configText = codexReadText(configPath)
1270
+ const parsed = configText ? codexParseToml(configText) : { top: Object.create(null), tables: Object.create(null) }
1271
+ const providerName = typeof parsed.top.model_provider === 'string' ? parsed.top.model_provider : ''
1272
+ const block = providerName && parsed.tables['model_providers.' + providerName]
1273
+ ? parsed.tables['model_providers.' + providerName]
1274
+ : Object.create(null)
1275
+ const authPath = home ? codexPathJoin(home, 'auth.json') : ''
1276
+ const authText = codexReadText(authPath)
1277
+ let auth = null
1278
+ try { auth = authText ? JSON.parse(authText) : null } catch (err) { auth = null }
1279
+ const authBaseUrl = auth && typeof auth.OPENAI_BASE_URL === 'string' ? auth.OPENAI_BASE_URL : ''
1280
+ const authApiKey = auth && typeof auth.OPENAI_API_KEY === 'string' ? auth.OPENAI_API_KEY : ''
1281
+ const baseUrl = typeof block.base_url === 'string' && block.base_url ? block.base_url : authBaseUrl
1282
+ const token = typeof block.experimental_bearer_token === 'string' && block.experimental_bearer_token
1283
+ ? block.experimental_bearer_token
1284
+ : authApiKey
1285
+ return {
1286
+ home,
1287
+ configPath,
1288
+ configFound: configText !== null,
1289
+ providerName,
1290
+ baseUrl: String(baseUrl || ''),
1291
+ token: String(token || ''),
1292
+ model: typeof parsed.top.model === 'string' ? parsed.top.model : '',
1293
+ catalogFile: typeof parsed.top.model_catalog_json === 'string' ? parsed.top.model_catalog_json : '',
1294
+ authPath,
1295
+ authFound: auth !== null,
1296
+ authBaseUrl: String(authBaseUrl || ''),
1297
+ authApiKey: String(authApiKey || '')
1298
+ }
1299
+ }
1300
+
1301
+ /** 实时模型清单的端点(base_url + /models)。缺 base_url 时明确失败,不猜端点。 */
1302
+ function codexModelsEndpoint(env) {
1303
+ const e = env || codexHostEnv || {}
1304
+ const cfg = codexReadConfig(env)
1305
+ // 显式覆盖(诊断/测试/临时换端点用):CODEX_BRIDGE_MODELS_URL 既可是完整 URL,
1306
+ // 也可是 base_url(自动补 /models);token 同理可覆盖。
1307
+ const override = String(e.CODEX_BRIDGE_MODELS_URL || '').trim()
1308
+ const base = String(override || cfg.baseUrl || '').trim()
1309
+ const token = String(e.CODEX_BRIDGE_MODELS_TOKEN || cfg.token || '')
1310
+ if (!base) {
1311
+ return {
1312
+ ok: false, cfg, url: '', token: '', baseUrl: '', providerName: cfg.providerName,
1313
+ error: 'no base_url configured: ' + (cfg.configFound
1314
+ ? 'config.toml has no model_providers.' + (cfg.providerName || '<none>') + '.base_url and auth.json has no OPENAI_BASE_URL'
1315
+ : 'config.toml not found at ' + (cfg.configPath || '(unknown)')) +
1316
+ '. Codex CLI has no `models` subcommand, so a live list can only come from the provider HTTP API (/models).'
1317
+ }
1318
+ }
1319
+ const url = /\/models\/*$/.test(base) ? base : base.replace(/\/+$/, '') + '/models'
1320
+ return {
1321
+ ok: true,
1322
+ cfg,
1323
+ baseUrl: base,
1324
+ url,
1325
+ token,
1326
+ providerName: cfg.providerName
1327
+ }
1328
+ }
1329
+
1330
+ // ── 可执行文件 ───────────────────────────────────────────────────────────────
1331
+ /** 候选顺序:面板 binPath(调用方传入)> CODEX_BIN > CODEX_CLI > CODEX_CLI_PATH > PATH 名。 */
1332
+ function codexExeCandidates(env) {
1333
+ const e = env || codexHostEnv || {}
1334
+ const out = []
1335
+ const push = (v) => { const s = String(v || '').trim(); if (s && out.indexOf(s) < 0) out.push(s) }
1336
+ push(e.CODEX_BIN)
1337
+ push(e.CODEX_CLI)
1338
+ push(e.CODEX_CLI_PATH)
1339
+ push('codex')
1340
+ return out
1341
+ }
1342
+
1343
+ /** .mjs/.cjs/.js 目标经 node 包装(测试夹具与自定义入口都走这条)。 */
1344
+ function codexResolveExePrefix(env, binPath) {
1345
+ const bin = String(binPath || '').trim() || codexExeCandidates(env)[0]
1346
+ if (/\.(mjs|cjs|js)$/i.test(bin)) {
1347
+ const p = codexProcess()
1348
+ return [p && p.execPath ? p.execPath : 'node', String(bin)]
1349
+ }
1350
+ return [String(bin)]
1351
+ }
1352
+
1353
+ /**
1354
+ * Windows npm 全局安装的 `codex.cmd` 不能直接 spawn(CreateProcess 不解析 .cmd/.bat;
1355
+ * 走 shell 又会让含空格/引号的 prompt 被二次解释 —— 正是本桥刻意避免的形态)。
1356
+ * 与其抛出难懂 EINVAL,不如在解析阶段给一条可行动的错误。
1357
+ * @returns {string|null} 拒绝原因(null = 可以用)
1358
+ */
1359
+ function codexShimRejectReason(binPath) {
1360
+ const bin = String(binPath || '').trim()
1361
+ if (!/\.(cmd|bat)$/i.test(bin)) return null
1362
+ return 'codex executable "' + bin + '" is a Windows .cmd/.bat shim, which cannot be spawned directly without a shell ' +
1363
+ '(and a shell would re-interpret the prompt). Point binPath / CODEX_BIN at the real codex.exe instead — ' +
1364
+ 'for an npm global install that is under %APPDATA%\\npm\\node_modules\\@openai\\codex\\vendor\\<platform>\\codex.exe.'
1365
+ }
1366
+
1367
+ // ── 模型清单:三层来源(顺序即权威性)────────────────────────────────────────
1368
+ // A live —— GET {base_url}/models(服务端裁决,唯一权威)
1369
+ // B catalog—— config.toml 的 model_catalog_json(codex 自用的模型目录)
1370
+ // C builtin—— ~/.codex/models.json(CLI 内置定义)
1371
+ // 红线:B/C 绝不允许冒充 A。清单项与结果对象都带 source,调用方据此展示。
1372
+ function codexNormalizeModelItem(raw, source) {
1373
+ const r = raw && typeof raw === 'object' ? raw : null
1374
+ if (!r) return null
1375
+ const id = String(r.id || r.slug || r.model || r.name || '').trim()
1376
+ if (!id) return null
1377
+ let free = false
1378
+ if (r.pricing && typeof r.pricing === 'object') {
1379
+ const p = r.pricing.prompt
1380
+ if (p === 0 || p === '0' || p === '0.0') free = true
1381
+ }
1382
+ if (!free && /(?::free$|-free$)/i.test(id)) free = true
1383
+ const ctxNum = Number(r.context_window || r.context_length || r.max_context_window || 0)
1384
+ return {
1385
+ id,
1386
+ name: String(r.display_name || r.name || id),
1387
+ source,
1388
+ free,
1389
+ contextWindow: Number.isFinite(ctxNum) && ctxNum > 0 ? ctxNum : null,
1390
+ visibility: typeof r.visibility === 'string' ? r.visibility : null
1391
+ }
1392
+ }
1393
+
1394
+ /** 解析 {data:[…]} 或 {models:[…]} 两种载荷。畸形载荷返回明确错误。 */
1395
+ function codexParseModelsPayload(text, source) {
1396
+ let json = null
1397
+ try { json = JSON.parse(String(text || '')) } catch (e) {
1398
+ return { ok: false, models: [], error: 'models payload is not JSON: ' + String(text || '').slice(0, 160) }
1399
+ }
1400
+ const arr = json && Array.isArray(json.data) ? json.data
1401
+ : (json && Array.isArray(json.models) ? json.models : null)
1402
+ if (!arr) return { ok: false, models: [], error: 'models payload has neither .data nor .models array: ' + String(text || '').slice(0, 160) }
1403
+ const models = []
1404
+ const seen = Object.create(null)
1405
+ let hidden = 0
1406
+ for (const raw of arr) {
1407
+ const item = codexNormalizeModelItem(raw, source)
1408
+ if (!item || seen[item.id]) continue
1409
+ if (item.visibility && item.visibility !== 'list') { hidden += 1; continue }
1410
+ seen[item.id] = true
1411
+ models.push(item)
1412
+ }
1413
+ return { ok: true, models, hidden, total: arr.length }
1414
+ }
1415
+
1416
+ // 进程内缓存(面板每次渲染都会来取,避免反复打网络 / 反复起子进程)。
1417
+ const CODEX_MODEL_CACHE = { at: 0, url: '', payload: null }
1418
+ const CODEX_MODEL_TTL_MS = 5 * 60 * 1000
1419
+
1420
+ function codexCacheNow() { return Date.now() }
1421
+
1422
+ /** 已缓存的实时清单(无则 null)。供同步面板路径做零阻塞快读。 */
1423
+ function codexCachedLiveModels(env, nowMs) {
1424
+ const ep = codexModelsEndpoint(env)
1425
+ if (!ep.ok) return null
1426
+ const hit = CODEX_MODEL_CACHE
1427
+ const at = Number(nowMs) || Date.now()
1428
+ if (hit.payload && hit.url === ep.url && at - hit.at < CODEX_MODEL_TTL_MS) return hit.payload
1429
+ return null
1430
+ }
1431
+
1432
+ function codexRememberLive(url, payload) {
1433
+ CODEX_MODEL_CACHE.at = codexCacheNow()
1434
+ CODEX_MODEL_CACHE.url = url
1435
+ CODEX_MODEL_CACHE.payload = payload
1436
+ }
1437
+
1438
+ function codexFetchInit(token, signal) {
1439
+ const headers = { accept: 'application/json' }
1440
+ if (token) headers.authorization = 'Bearer ' + token
1441
+ const init = { method: 'GET', headers }
1442
+ // 面板「拉取可用模型」是 HTTP 请求上的同步等待:端点卡住时必须有上界,
1443
+ // 否则设置页会一直转圈(无 signal 能力的宿主就照旧直接请求)。
1444
+ if (signal) init.signal = signal
1445
+ return init
1446
+ }
1447
+
1448
+ function codexDescribeFetchError(status, body) {
1449
+ const head = String(body || '').replace(/\s+/g, ' ').trim().slice(0, 200)
1450
+ if (status === 401 || status === 403) {
1451
+ 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
1452
+ }
1453
+ return 'HTTP ' + status + (head ? ' — ' + head : '')
1454
+ }
1455
+
1456
+ /** A 层(异步):GET {base_url}/models。deps.fetch 可注入(测试),否则用全局 fetch。 */
1457
+ async function codexLiveModels(env, opts) {
1458
+ const o = opts || {}
1459
+ const ep = codexModelsEndpoint(env)
1460
+ if (!ep.ok) return { ok: false, source: 'live', models: [], error: ep.error }
1461
+ const cached = codexCachedLiveModels(env, o.nowMs)
1462
+ if (cached && !o.refresh) return cached
1463
+ const f = o.fetch || ((typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') ? globalThis.fetch : null)
1464
+ if (!f) {
1465
+ 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.' }
1466
+ }
1467
+ let res
1468
+ // 取清单是设置面板的一次同步等待(点按钮 → HTTP → 这里):必须有上界。
1469
+ // 无 AbortController 的宿主退化为不带上界(照旧请求),不静默改变成功语义。
1470
+ const timeoutMs = Math.max(2000, Math.min(Number(o.timeoutMs) || 20000, 60000))
1471
+ const ctrl = (typeof AbortController === 'function') ? new AbortController() : null
1472
+ const timer = ctrl ? setTimeout(() => { try { ctrl.abort() } catch (err) { } }, timeoutMs) : null
1473
+ try { res = await f(ep.url, codexFetchInit(ep.token, ctrl ? ctrl.signal : null)) } catch (e) {
1474
+ return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' failed (timeout ' + timeoutMs + 'ms): ' + String(e && e.message || e) + '. Check network/proxy, or set CODEX_BRIDGE_MODELS_URL to the provider catalogue endpoint.' }
1475
+ } finally {
1476
+ if (timer) clearTimeout(timer)
1477
+ }
1478
+ let body = ''
1479
+ try { body = typeof res.text === 'function' ? await res.text() : JSON.stringify(res.json || {}) } catch (e) { body = '' }
1480
+ const status = Number(res && res.status) || 0
1481
+ if (!res || status < 200 || status >= 300) {
1482
+ return { ok: false, source: 'live', models: [], url: ep.url, error: 'GET ' + ep.url + ' → ' + codexDescribeFetchError(status, body) }
1483
+ }
1484
+ const parsed = codexParseModelsPayload(body, 'live')
1485
+ if (!parsed.ok) return { ok: false, source: 'live', models: [], url: ep.url, error: ep.url + ': ' + parsed.error }
1486
+ if (!parsed.models.length) {
1487
+ 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.' }
1488
+ }
1489
+ const payload = {
1490
+ ok: true, source: 'live', models: parsed.models, count: parsed.models.length,
1491
+ url: ep.url, baseUrl: ep.baseUrl, providerName: ep.providerName, fetchedAt: Date.now()
1492
+ }
1493
+ codexRememberLive(ep.url, payload)
1494
+ return payload
1495
+ }
1496
+
1497
+ // 子进程探针脚本已删除:面板不再自动探测(契约 §3.2 手动拉取)。live 清单只在
1498
+ // settings.refreshModels()(异步、用户点按钮)与时序工具 codex_models 里读取。
1499
+
1500
+ /** B 层:config.toml 的 model_catalog_json(相对 CODEX_HOME)。 */
1501
+ function codexCatalogModels(env) {
1502
+ const cfg = codexReadConfig(env)
1503
+ const rel = cfg.catalogFile
1504
+ if (!rel) {
1505
+ 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') + ').' }
1506
+ }
1507
+ const p = /[\\/]/.test(rel) || /^[A-Za-z]:/.test(rel) ? rel : codexPathJoin(cfg.home, rel)
1508
+ const text = codexReadText(p)
1509
+ if (text === null) return { ok: false, source: 'catalog', models: [], file: p, error: 'catalog file not readable: ' + p }
1510
+ const parsed = codexParseModelsPayload(text, 'catalog')
1511
+ if (!parsed.ok) return { ok: false, source: 'catalog', models: [], file: p, error: p + ': ' + parsed.error }
1512
+ if (!parsed.models.length) return { ok: false, source: 'catalog', models: [], file: p, error: p + ' contains 0 visible models' }
1513
+ return { ok: true, source: 'catalog', models: parsed.models, count: parsed.models.length, file: p }
1514
+ }
1515
+
1516
+ /** C 层:~/.codex/models.json(CLI 内置定义)。 */
1517
+ function codexBuiltinModels(env) {
1518
+ const cfg = codexReadConfig(env)
1519
+ if (!cfg.home) return { ok: false, source: 'builtin', models: [], error: 'CODEX_HOME/USERPROFILE unknown, cannot locate ~/.codex/models.json' }
1520
+ const p = codexPathJoin(cfg.home, 'models.json')
1521
+ const text = codexReadText(p)
1522
+ if (text === null) return { ok: false, source: 'builtin', models: [], file: p, error: 'builtin model definitions not readable: ' + p }
1523
+ const parsed = codexParseModelsPayload(text, 'builtin')
1524
+ if (!parsed.ok) return { ok: false, source: 'builtin', models: [], file: p, error: p + ': ' + parsed.error }
1525
+ if (!parsed.models.length) return { ok: false, source: 'builtin', models: [], file: p, error: p + ' contains 0 visible models' }
1526
+ return { ok: true, source: 'builtin', models: parsed.models, count: parsed.models.length, file: p }
1527
+ }
1528
+
1529
+ /**
1530
+ * 面板下拉的候选池(契约 §3.2):**纯读缓存**,绝不 spawnSync、绝不发请求、绝不碰本地目录。
1531
+ * 探测由 settings.refreshModels() 显式触发(用户点「拉取可用模型」),成功后写回同一份
1532
+ * CODEX_MODEL_CACHE,这里下次渲染直接命中 —— 面板首屏因此不会被网络拖慢。
1533
+ *
1534
+ * 为什么不再回落到 catalog/builtin:那两层是本地元数据,不是服务端可用清单;把它们填进
1535
+ * 这个下拉等于让用户以为「服务端就这些」(0.2.6~0.2.9 反复踩的坑)。宁可空着并说清
1536
+ * 「点按钮拉取」,也不展示可能已经过时的非权威列表 —— 需要本地目录请用 codex_models
1537
+ * 的 source="catalog"/"builtin" 显式查询(那里逐项标注来源)。
1538
+ */
1539
+ function codexCachedModelOptions(env) {
1540
+ const live = codexCachedLiveModels(env)
1541
+ if (!live || !live.ok || !Array.isArray(live.models) || !live.models.length) {
1542
+ return {
1543
+ options: [], source: 'none', count: 0,
1544
+ note: '还没拉取过(或上次没拉到):点「拉取可用模型」向端点取权威清单;空表示尚未拉取 / 端点或 token 未配好。可直接手动输入模型 id。'
1545
+ }
1546
+ }
1547
+ return {
1548
+ options: live.models.map((m) => ({ value: m.id, label: m.id + (m.free ? ' [free]' : '') })),
1549
+ source: 'live', count: live.count, url: live.url, fetchedAt: live.fetchedAt,
1550
+ note: '上次拉取到 ' + live.count + ' 个模型(源:' + live.url + ',免费标 [free])。点「拉取可用模型」重新获取。'
1551
+ }
1552
+ }
1553
+
1554
+ // ── inlined from providers/codex-models.mjs ──
1555
+ // providers/codex-models.mjs — codex_models 工具定义(纯逻辑)。
1556
+ //
1557
+ // 从 providers/codex.mjs 再拆出:两个文件都要留在 600 物理行红线内(规则 §26)。
1558
+ // scripts/build.mjs 会把 providers/codex-*.mjs 全部内联进 dynamic 沙箱的同一作用域,
1559
+ // 所以沙箱里 codexCl* 系列函数天然可见;ESM 形态走本文件的 import。
1560
+ // ⚠️ 注释里绝不写出占位标记字面量(build 全局替换会劈开注释 → 生成物语法崩)。
1561
+ //
1562
+ // 红线(用户明确要求,本仓铁律):拿不到实时清单必须 ok:false + 可操作错误。
1563
+ // 绝不把 catalog/builtin 冒充成 live,也绝不静默返回空数组。清单项必须带 source。
1564
+ // import { codexLiveModels, codexCatalogModels, codexBuiltinModels } from './codex-cli.mjs'
1565
+ const MODELS_PARAMS = {
1566
+ type: 'object',
1567
+ additionalProperties: false,
1568
+ required: [],
1569
+ properties: {
1570
+ source: {
1571
+ type: 'string',
1572
+ enum: ['live', 'catalog', 'builtin'],
1573
+ 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.'
1574
+ },
1575
+ filter: { type: 'string', description: 'Optional case-insensitive substring filter on model id/name (e.g. "minimax" or "free").' },
1576
+ freeOnly: { type: 'boolean', description: 'Return only models that look free (:free suffix or pricing.prompt == 0).' },
1577
+ limit: { type: 'integer', description: 'Max models to return (default 80, 1-1000). The result always reports the full match count.' },
1578
+ refresh: { type: 'boolean', description: 'Bypass the 5-minute live cache and re-fetch.' }
1579
+ }
1580
+ }
1581
+
1582
+ 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.'
1583
+
1584
+ function renderModels(args, value) {
1585
+ const v = value || {}
1586
+ if (!v.ok) return [{ type: 'text', text: 'codex_models failed: ' + String(v.error || 'unknown') }]
1587
+ const list = Array.isArray(v.models) ? v.models : []
1588
+ const lines = ['codex models [' + (v.source || '?') + '] (' + (v.count != null ? v.count : list.length) + ' matched' + (v.truncated ? ', showing ' + list.length : '') + '):']
1589
+ for (const m of list.slice(0, 60)) {
1590
+ const bits = []
1591
+ if (m.free) bits.push('free')
1592
+ if (m.contextWindow) bits.push(Math.round(m.contextWindow / 1000) + 'k ctx')
1593
+ lines.push(' ' + m.id + (bits.length ? ' [' + bits.join(',') + ']' : ''))
1594
+ }
1595
+ if (list.length > 60) lines.push(' … and ' + (list.length - 60) + ' more')
1596
+ if (v.source === 'live') lines.push('source: live — ' + String(v.url || ''))
1597
+ else lines.push('source: ' + String(v.source || '') + ' — ' + String(v.file || '') + ' (local, NOT authoritative for the server)')
1598
+ if (v.note) lines.push('note: ' + v.note)
1599
+ return [{ type: 'text', text: lines.join('\n') }]
1600
+ }
1601
+
1602
+ function modelsErrorHint(source, extra) {
1603
+ const base = String(extra || 'unknown')
1604
+ if (source !== 'live') return base
1605
+ return base + ' — pass source="catalog" or source="builtin" to inspect the local, non-authoritative lists, or fix the endpoint/credential above and retry.'
1606
+ }
1607
+
1608
+ /**
1609
+ * codex_models 工具定义。
1610
+ * @param {{env?:object, deps?:object}} ctx env 用来定位 CODEX_HOME;deps.fetch 可注入。
1611
+ */
1612
+ function codexModelsTool(ctx) {
1613
+ const c = ctx || {}
1614
+ const env = c.env || {}
1615
+ const deps = c.deps || {}
1616
+ return {
1617
+ name: 'codex_models',
1618
+ description: MODELS_DESCRIPTION,
1619
+ inputSchema: MODELS_PARAMS,
1620
+ render: renderModels,
1621
+ async logic(args) {
1622
+ const a = args || {}
1623
+ const source = a.source || 'live'
1624
+ let res
1625
+ if (source === 'live') res = await codexLiveModels(env, { fetch: deps.fetch, refresh: a.refresh === true })
1626
+ else if (source === 'catalog') res = codexCatalogModels(env)
1627
+ else if (source === 'builtin') res = codexBuiltinModels(env)
1628
+ else return { ok: false, source, models: [], error: 'unknown source ' + JSON.stringify(String(source)) + ' (use live|catalog|builtin)' }
1629
+
1630
+ if (!res.ok || !Array.isArray(res.models) || !res.models.length) {
1631
+ return { ok: false, source, models: [], error: modelsErrorHint(source, res.error) }
1632
+ }
1633
+ let list = res.models
1634
+ if (a.filter) {
1635
+ const q = String(a.filter).toLowerCase()
1636
+ list = list.filter((m) => String(m.id).toLowerCase().indexOf(q) >= 0 || String(m.name || '').toLowerCase().indexOf(q) >= 0)
1637
+ }
1638
+ if (a.freeOnly) list = list.filter((m) => m.free)
1639
+ if (!list.length) {
1640
+ 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)' }
1641
+ }
1642
+ const limit = Number.isFinite(Number(a.limit)) ? Math.max(1, Math.min(Math.floor(Number(a.limit)), 1000)) : 80
1643
+ const matched = list.length
1644
+ const truncated = matched > limit
1645
+ const models = list.slice(0, limit)
1646
+ const out = {
1647
+ ok: true, source,
1648
+ models, count: matched, truncated,
1649
+ freeCount: res.models.filter((m) => m.free).length
1650
+ }
1651
+ if (res.url) out.url = res.url
1652
+ if (res.file) out.file = res.file
1653
+ if (res.providerName) out.providerName = res.providerName
1654
+ if (res.fetchedAt) out.fetchedAt = res.fetchedAt
1655
+ if (truncated) out.note = 'showing ' + limit + ' of ' + matched + ' matched models; raise limit or narrow with filter/freeOnly.'
1656
+ return out
1657
+ }
1658
+ }
1659
+ }
1660
+
1661
+ // core/scheduling.mjs — CLI 调度(启用集合 + 调用优先级)单源纯逻辑。
1662
+ //
1663
+ // 语义(用户可见的两件事):
1664
+ // • enabled = 勾选启用哪些 CLI(数量随之而定);未启用的一面不注册工具、不进提示词。
1665
+ // • priority = 智能体调用 CLI 的建议顺序,数组顺序即优先级(首位最高)。
1666
+ //
1667
+ // 落盘位置:统一设置文件 {DSH_HOME}/cli-bridge-settings.json 的顶层 `_scheduling` 区。
1668
+ // 下划线前缀 = 非 provider 分区(registry 的 PROVIDER_IDS 永远不含它,逐分区语义不受影响),
1669
+ // 因此新代码写出的文件被旧包读取时整区被忽略 = 纯增量,向后兼容。
1670
+ //
1671
+ // 缺省语义(文件缺失/无该区):全部 provider 启用、按注册表顺序 —— 与调度特性上线前
1672
+ // 的行为逐字一致(读不到配置永远不等于关掉所有桥)。
1673
+ //
1674
+ // 沙箱纪律:与 bridge-kit 同源约束 —— 不得 import;Node 内建一律经
1675
+ // globalThis.process.getBuiltinModule + typeof 守卫(动态宿主沙箱可能没有 fs,
1676
+ // 那种环境读不到文件 → 走缺省全开,绝不炸 apply)。
1677
+
1678
+ /** 设置文件里调度区的键名(下划线前缀避开 provider id 命名空间)。 */
1679
+ const SCHEDULING_SECTION = '_scheduling'
1680
+
1681
+ /** 人类可读的默认注册顺序(调用建议顺序);enabled 缺省 = 全部启用。 */
1682
+ function defaultPriority(ids) {
1683
+ return (Array.isArray(ids) ? ids : []).slice()
1684
+ }
1685
+
1686
+ /**
1687
+ * 清洗任意来源的调度对象。
1688
+ * @param {unknown} raw 文件/POST 里的原始值(可能是任何东西,全部容错)
1689
+ * @param {string[]} ids 全量 provider id(顺序 = 缺省优先级)
1690
+ * @param {(id: string) => string} [labelOf] 展示名(仅用于诊断文本,不参与判定)
1691
+ */
1692
+ function normalizeScheduling(raw, ids, labelOf) {
1693
+ const all = Array.isArray(ids) ? ids.slice() : []
1694
+ const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
1695
+ const enabled = all.slice()
1696
+ let priority = all.slice()
1697
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1698
+ if (Array.isArray(raw.enabled)) {
1699
+ const seen = Object.create(null)
1700
+ const picked = []
1701
+ for (const x of raw.enabled) {
1702
+ const id = String(x == null ? '' : x)
1703
+ if (!id || all.indexOf(id) < 0 || seen[id]) continue
1704
+ seen[id] = true; picked.push(id)
1705
+ }
1706
+ // 空数组 = 用户真的想把全部关掉(而不是「没配」),保留该语义;
1707
+ // 非空但过滤后为空 = 写的全是未知 id(坏值)→ 回落全开(fail-open,
1708
+ // 绝不让一次手滑/陈旧配置把所有桥关掉)。
1709
+ if (raw.enabled.length === 0 || picked.length > 0) {
1710
+ enabled.length = 0
1711
+ Array.prototype.push.apply(enabled, picked)
1712
+ }
1713
+ }
1714
+ if (Array.isArray(raw.priority)) {
1715
+ const seen2 = Object.create(null)
1716
+ const ordered = []
1717
+ for (const x of raw.priority) {
1718
+ const id = String(x == null ? '' : x)
1719
+ if (!id || all.indexOf(id) < 0 || seen2[id]) continue
1720
+ seen2[id] = true; ordered.push(id)
1721
+ }
1722
+ for (const id of all) if (!seen2[id]) ordered.push(id) // 漏写的排在末尾
1723
+ if (ordered.length) priority = ordered
1724
+ }
1725
+ }
1726
+ const order = priority.filter((id) => enabled.indexOf(id) >= 0)
1727
+ for (const id of enabled) if (order.indexOf(id) < 0) order.push(id)
1728
+ return { enabled, priority, order }
1729
+ }
1730
+
1731
+ /** 展示文本:「CB → MIMO」或「全部启用 · CB → AGY → MIMO」。 */
1732
+ function describeScheduling(s, labelOf) {
1733
+ const st = s || {}
1734
+ const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
1735
+ const order = Array.isArray(st.order) ? st.order : []
1736
+ const names = order.map(label).join(' → ')
1737
+ return names ? names : '(全部禁用)'
1738
+ }
1739
+
1740
+ // ── 读盘(四形态共用;无 fs 时安全返回 null)────────────────────────────────────
1741
+ // 名字带 sched 前缀:本文件会与 bridge-kit / provider 描述符【文本内联进同一个动态
1742
+ // 沙箱脚本作用域】(providers/codebuddy.mjs 已有 builtinModule),撞名即隐患。
1743
+ function schedBuiltinModule(name) {
1744
+ try {
1745
+ const proc = globalThis.process
1746
+ return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
1747
+ } catch (e) { return null }
1748
+ }
1749
+
1750
+ function schedulingSettingsPath(env) {
1751
+ const e = env || (globalThis.process && globalThis.process.env) || {}
1752
+ try {
1753
+ if (e.CLI_BRIDGE_SETTINGS_FILE) return String(e.CLI_BRIDGE_SETTINGS_FILE)
1754
+ const nodePath = schedBuiltinModule('node:path')
1755
+ if (nodePath && e.DSH_HOME) return nodePath.join(String(e.DSH_HOME), 'cli-bridge-settings.json')
1756
+ } catch (e2) { }
1757
+ return null
1758
+ }
1759
+
1760
+ /**
1761
+ * 生效调度:文件 _scheduling > 缺省全开。
1762
+ * env/argv CLI_BRIDGE_PROVIDER(单面钉住,旧薄封装兼容语义)存在时视为最高优先 ——
1763
+ * 它代表「这个 MCP 进程只为这一家而启动」(部署者的显式意志,比面板勾选更具体、
1764
+ * 更靠近进程生命周期),调度不再削减它。
1765
+ * 宿主形态(preset / dynamic 桥接插件)应传 ignorePin:钉住只是 MCP 进程的启动参数,
1766
+ * 不该让全局 env 反过来把用户会话里的其他桥关掉。
1767
+ * @param {string[]} ids 全量 provider id
1768
+ * @param {{ env?: Record<string,string>, readFileSync?: Function, path?: string|null, ignorePin?: boolean }} [io]
1769
+ */
1770
+ function resolveScheduling(ids, io) {
1771
+ const o = io || {}
1772
+ const env = o.env || (globalThis.process && globalThis.process.env) || {}
1773
+ let raw = null
1774
+ try {
1775
+ const p = ('path' in o) ? o.path : schedulingSettingsPath(env)
1776
+ const nodeFs = o.readFileSync ? { readFileSync: o.readFileSync } : schedBuiltinModule('node:fs')
1777
+ if (p && nodeFs && typeof nodeFs.readFileSync === 'function') {
1778
+ raw = JSON.parse(nodeFs.readFileSync(p, 'utf8'))
1779
+ }
1780
+ } catch (e) { raw = null }
1781
+ const file = normalizeScheduling(raw && typeof raw === 'object' ? raw[SCHEDULING_SECTION] : null, ids)
1782
+ const pinned = o.ignorePin ? '' : String(env.CLI_BRIDGE_PROVIDER || '').trim()
1783
+ if (pinned && ids.indexOf(pinned) >= 0) {
1784
+ return Object.assign(file, {
1785
+ enabled: [pinned],
1786
+ order: [pinned],
1787
+ pinned: pinned,
1788
+ source: raw ? 'env:CLI_BRIDGE_PROVIDER+file' : 'env:CLI_BRIDGE_PROVIDER'
1789
+ })
1790
+ }
1791
+ return Object.assign(file, { pinned: '', source: raw ? 'file' : 'default' })
1792
+ }
1793
+
1794
+ /**
1795
+ * 桥接面门禁(preset / dynamic 的 apply() 首行用):本 provider 是否被调度启用。
1796
+ * fail-open —— 任何异常(无 fs、文件损坏、沙箱限制)都返回 true:读不到配置永远
1797
+ * 不等于关掉桥(否则一次文件损坏就把三条 CLI 全灭了)。ignorePin 固定 true:
1798
+ * 钉住语义属于 MCP 独立进程,宿主形态的全局 env 不该反向影响用户会话。
1799
+ */
1800
+ function isBridgeEnabled(selfId, ids, io) {
1801
+ try {
1802
+ const list = Array.isArray(ids) ? ids : []
1803
+ // 全量清单本身不可得(空/不含自己)= 无门禁语义,fail-open。
1804
+ if (!list.length || list.indexOf(String(selfId)) < 0) return true
1805
+ const o = Object.assign({ ignorePin: true }, io || {})
1806
+ return resolveScheduling(list, o).enabled.indexOf(String(selfId)) >= 0
1807
+ } catch (e) { return true }
1808
+ }
1809
+
1810
+
1811
+ const SCHED_ALL_IDS = ["agy","codebuddy","mimo","opencode","claude","codex"]
1812
+
1813
+
1814
+ return {
1815
+ inject: ['tools', 'subprocess', 'systemPrompt', 'timer'],
1816
+ apply(ctx) {
1817
+ // 调度门禁(0.2.0):设置面板关掉 codex → 本形态不注册工具/政策段;fail-open。
1818
+ if (!isBridgeEnabled('codex', SCHED_ALL_IDS)) return
1819
+ const subprocess = ctx.subprocess
1820
+ const sandboxPolicy = ctx.get('sandboxPolicy')
1821
+ // 沙箱可能运行在无 process 的环境:typeof 守卫读 env(旧仓 dynamic 的等价面)。
1822
+ const env = (typeof process !== 'undefined' && process.env) ? process.env : {}
1823
+
1824
+ const provider = defineProvider({ env })
1825
+
1826
+ function publish(snapshot) {
1827
+ try {
1828
+ const collector = ctx.get('cliBridgeCollector')
1829
+ if (collector && typeof collector.mergeSnapshot === 'function') { collector.mergeSnapshot('codex', snapshot); return }
1830
+ } catch (e) { /* fall through to legacy alias */ }
1831
+ try {
1832
+ const legacy = ctx.get('codexCollector')
1833
+ if (legacy && typeof legacy.mergeSnapshot === 'function') legacy.mergeSnapshot(snapshot)
1834
+ } catch (e) { /* 灯数据尽力而为 */ }
1835
+ }
1836
+
1837
+ // exe 优先级(与描述符同一套):CODEX_BIN > CODEX_CLI > CODEX_CLI_PATH > PATH 的 codex。
1838
+ // .js/.mjs 目标经 process.execPath 包装(测试夹具形态);绝对路径直接交 spawn;
1839
+ // .cmd/.bat shim 明确拒绝(无 shell spawn 不解析它,走 shell 会二次解释 prompt)。
1840
+ const exeCandidates = codexExeCandidates(env)
1841
+ async function resolveExe(backend, signal) {
1842
+ let bare = ''
1843
+ for (let i = 0; i < exeCandidates.length; i++) {
1844
+ const c = exeCandidates[i]
1845
+ const shim = codexShimRejectReason(c)
1846
+ if (shim) throw new Error(shim)
1847
+ if (/\.(mjs|cjs|js)$/i.test(c) && typeof process !== 'undefined' && process.execPath) return [process.execPath, c]
1848
+ if (/[\\/]/.test(c)) return c
1849
+ if (!bare) bare = c
1850
+ }
1851
+ return await subprocess.resolveExecutable(bare || exeCandidates[exeCandidates.length - 1], undefined, signal)
1852
+ }
1853
+
1854
+ // cwd 兜底:CODEX_BRIDGE_CWD > sandboxPolicy.workspaceRoot > process.cwd() > '.'。
1855
+ function getCwdFallback() {
1856
+ if (env.CODEX_BRIDGE_CWD) return String(env.CODEX_BRIDGE_CWD)
1857
+ if (sandboxPolicy && typeof sandboxPolicy.workspaceRoot === 'string' && sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot
1858
+ if (typeof process !== 'undefined' && typeof process.cwd === 'function') { try { return process.cwd() } catch (e) { /* 沙箱受限 */ } }
1859
+ return '.'
1860
+ }
1861
+
1862
+ provider.attach({ ctx, subprocess, resolveExe, getCwdFallback, publish })
1863
+
1864
+ const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
1865
+
1866
+ harness.registerTool(ctx, harness.defineTool({
1867
+ name: provider.tools.run.name,
1868
+ description: provider.tools.run.description,
1869
+ parameters: provider.tools.run.inputSchema,
1870
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
1871
+ execute: function (args, exec) { return provider.runToolLogic(args, exec) }
1872
+ }))
1873
+ harness.registerTool(ctx, harness.defineTool({
1874
+ name: provider.tools.continue.name,
1875
+ description: provider.tools.continue.description,
1876
+ parameters: provider.tools.continue.inputSchema,
1877
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
1878
+ execute: function (args, exec) { return provider.continueToolLogic(args, exec) }
1879
+ }))
1880
+ harness.registerTool(ctx, harness.defineTool({
1881
+ name: provider.tools.status.name,
1882
+ description: provider.tools.status.description,
1883
+ parameters: provider.tools.status.inputSchema,
1884
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderStatus(value, provider) } },
1885
+ execute: function (args) { return provider.statusToolLogic(args) }
1886
+ }))
1887
+
1888
+ // 浏览器指示灯轮询的 RPC 面(旧 dynamic/handle('<id>_status') verbatim 语义)。
1889
+ harness.handle('codex_status', function () { return provider.statusToolLogic({}) })
1890
+
1891
+ const policyText = [
1892
+ 'codex-first execution policy (local OpenAI Codex CLI bridge).',
1893
+ '',
1894
+ '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.',
1895
+ '',
1896
+ 'Decision table for tool selection:',
1897
+ '- Single read/glob/grep or short (<30s) command -> Native tools.',
1898
+ '- Implementing, editing, refactoring, debugging, builds, multi-file investigation -> codex_run.',
1899
+ '- Follow-ups on the same topic -> codex_continue (with latest=true or sessionId; codex keeps the thread).',
1900
+ '- Final build/test verification of codex output -> Native tools.',
1901
+ '',
1902
+ 'Context protocol (one topic = one codex thread):',
1903
+ '- First dispatch on a topic: provide the task with a compact CONTEXT preamble (point to paths, do not paste file contents):',
1904
+ ' CONTEXT: 2-6 lines — goal; repo root; key paths; decisions/constraints; current state.',
1905
+ ' TASK: the complete instruction.',
1906
+ '- Follow-ups: call codex_continue and state only what is new.',
1907
+ '',
1908
+ 'Sandbox & approval:',
1909
+ '- mode=auto follows the DSH plan state: plan -> read-only sandbox, otherwise workspace-write.',
1910
+ '- 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).',
1911
+ '- If the sandbox denies an operation, raise sandboxMode instead of looping retries.',
1912
+ '',
1913
+ 'Execution & Fallback protocol:',
1914
+ '- Dispatch defaults to background=true (set background=false only to block). Collect via job_output.',
1915
+ '- On rate-limit or network failure, a confirmation dialog opens in BOTH foreground and background while in flight.',
1916
+ '- 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.',
1917
+ '- Never loop codex calls; never ask codex to call back into DSH.',
1918
+ '',
1919
+ 'Models & credentials:',
1920
+ '- Omit model unless you have a reason: codex then uses the CLI Bridge settings default, or the model from ~/.codex/config.toml.',
1921
+ '- 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.'
1922
+ ].join('\n')
1923
+
1924
+ ctx.systemPrompt.section({
1925
+ name: 'codex:policy',
1926
+ order: (typeof ctx.systemPrompt.getSectionOrder === 'function' ? ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT') : 2800) + 10,
1927
+ text: policyText
1928
+ })
1929
+ }
1930
+ }