@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,585 @@
1
+ // providers/codex.mjs — codex provider descriptor(第五家 CLI,无旧包可迁)。
2
+ //
3
+ // 基线 = 本机实测 codex-cli 0.98.0(Windows,mise/cherrystudio shim)+ 真实 argv/事件流。
4
+ // 写代码时最容易踩的几条(全部实机验证过,见 docs 里 codex 任务卡):
5
+ // • 非交互 = `codex exec [OPTIONS] "PROMPT"`,prompt 是**位置参数**,必须放最后。
6
+ // • ⚠️ `codex exec` **不接受** `-a/--ask-for-approval`(clap 直接报 unexpected argument;
7
+ // 那是根命令 `codex` 的旗)。要设审批策略只能走 `-c approval_policy="never"`。
8
+ // • ⚠️ `codex exec resume` 只认 [SESSION_ID] [PROMPT] + -c/-m/-i/--json/--last/--all/
9
+ // --skip-git-repo-check/--full-auto;**没有 -C、没有 -s、没有 -o、没有 --add-dir**
10
+ // (实测传 -C 报 unexpected argument)。续聊的 cwd 只能靠 spawn 的 cwd。
11
+ // • 非 git 目录必须带 --skip-git-repo-check,否则直接拒绝运行。
12
+ // • `--json` → NDJSON 事件流:thread.started / turn.started / item.started /
13
+ // item.completed / turn.completed;失败另加顶层 `error` + `turn.failed`。
14
+ // ⚠️ item.completed 里也可能出现 item.type==="error" 的**告警**(如「此会话原模型与
15
+ // 当前模型不同」),那是正常续聊的提示,绝不可当成致命错误(实测 --last 续聊就带)。
16
+ // • 退出码:0 成功 / 1 运行失败(stdout 仍是合法 JSON)/ 2 参数错误。
17
+ // • 无 --json 时 stdout 就是纯文本答复,stderr 有元信息头(version/workdir/model/
18
+ // provider/approval/sandbox/session id)—— 诊断只读它,不解析成结果。
19
+ //
20
+ // 红线(PROVIDER-CONTRACT.md §1/§8):
21
+ // - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
22
+ // process 只经 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
23
+ // - 只允许 import '../core/bridge-kit.mjs' 与同目录 codex-*.mjs 片文件(片文件由 build
24
+ // 自动内联进同一作用域;主文件超 600 行红线所以必须拆)。
25
+ import { createStatusEngine, createRunner, clampInt, isLimited, endpointHost } from '../core/bridge-kit.mjs'
26
+ import {
27
+ codexSetHost, codexResolveExePrefix, codexExeCandidates, codexReadConfig,
28
+ codexModelsEndpoint, codexCachedModelOptions, codexCachedLiveModels, codexLiveModels,
29
+ codexCatalogModels, codexBuiltinModels, codexHomeDir
30
+ } from './codex-cli.mjs'
31
+ import { codexModelsTool } from './codex-models.mjs'
32
+ export { codexResolveExePrefix, codexExeCandidates }
33
+
34
+ function nowIso() { return new Date().toISOString() }
35
+
36
+ // 实测枚举(根命令 help + exec help)。
37
+ const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
38
+ const APPROVAL_POLICIES = ['untrusted', 'on-failure', 'on-request', 'never']
39
+ const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh']
40
+
41
+ // 手动拉取(契约 §3.2):面板只把 error 原文显示给用户,所以失败文案必须自带下一步。
42
+ // codex-cli 没有 models 子命令,live 清单只能走端点 HTTP —— 排查顺序就围绕端点/凭据写。
43
+ const REFRESH_TIMEOUT_MS = 20000
44
+ 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 读的是同一份清单)。'
45
+
46
+ // ── 三工具 schema ────────────────────────────────────────────────────────────
47
+ const RUN_PARAMS = {
48
+ type: 'object',
49
+ additionalProperties: false,
50
+ required: ['prompt'],
51
+ properties: {
52
+ prompt: { type: 'string', description: 'The task instruction for 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).' },
53
+ 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.' },
54
+ 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.' },
55
+ 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.' },
56
+ 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.' },
57
+ cwd: { type: 'string', description: 'Working directory for codex (-C, and the spawn cwd). Defaults to the DSH workspace root.' },
58
+ addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra writable directories (--add-dir). Ignored on resume (codex exec resume has no --add-dir).' },
59
+ images: { type: 'array', items: { type: 'string' }, description: 'Image files to attach to the prompt (-i).' },
60
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 900).' },
61
+ 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.' }
62
+ }
63
+ }
64
+ const CONTINUE_PARAMS = {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ required: ['prompt'],
68
+ properties: {
69
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing codex thread. State only what is new; codex preserves the thread context.' },
70
+ sessionId: { type: 'string', description: 'codex thread id to resume (the thread_id from a prior codex_run result).' },
71
+ latest: { type: 'boolean', description: 'Resume the most recent codex thread in this working directory (exec resume --last) instead of a specific id.' },
72
+ 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="…".' },
73
+ model: { type: 'string', description: 'Optional codex model id.' },
74
+ sandboxMode: { type: 'string', enum: ['read-only', 'workspace-write', 'danger-full-access'], description: 'Optional sandbox policy applied through -c sandbox_mode="…" (resume has no -s).' },
75
+ reasoningEffort: { type: 'string', enum: ['minimal', 'low', 'medium', 'high', 'xhigh'], description: 'Optional model_reasoning_effort override.' },
76
+ cwd: { type: 'string', description: 'Working directory (spawn cwd only — resume has no -C).' },
77
+ images: { type: 'array', items: { type: 'string' }, description: 'Image files to attach (-i).' },
78
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 900).' },
79
+ 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.' }
80
+ }
81
+ }
82
+ const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
83
+
84
+ export default function defineProvider(deps = {}) {
85
+ const env = deps.env || {}
86
+ let host = null
87
+ let settingsRef = { binPath: '', defaultModel: '', sandboxMode: '', approvalPolicy: '' }
88
+ codexSetHost(env, deps.nodeFs || null, deps.nodePath || null)
89
+
90
+ /**
91
+ * 取当前生效的偏好设置(三条来源按优先级合并,每次 buildArgv 重读 —— 面板改默认
92
+ * 模型后无需重启即生效):
93
+ * 1) deps.readSettings():MCP 形态(真 Node,每次现读设置文件);
94
+ * 2) host.getSettings('codex'):preset/dynamic 形态(attach 注入);
95
+ * 3) settingsRef:attach 时固化的一次性快照 / 缺省值。
96
+ */
97
+ function currentSettings() {
98
+ const out = Object.assign({}, settingsRef)
99
+ try {
100
+ if (typeof deps.readSettings === 'function') {
101
+ const r = deps.readSettings()
102
+ if (r && typeof r === 'object') {
103
+ const part = ('partition' in r || 'path' in r || 'home' in r) ? r.partition : r
104
+ if (part && typeof part === 'object') Object.assign(out, part)
105
+ }
106
+ }
107
+ } catch (e) { /* 设置读失败 = 用缺省,不影响本次调用 */ }
108
+ try {
109
+ if (host && typeof host.getSettings === 'function') {
110
+ const s = host.getSettings('codex')
111
+ if (s && typeof s === 'object') Object.assign(out, s)
112
+ }
113
+ } catch (e) { /* 同上 */ }
114
+ return out
115
+ }
116
+
117
+ function sandboxFor(mode, args, settings) {
118
+ if (args && args.sandboxMode && SANDBOX_MODES.indexOf(String(args.sandboxMode)) >= 0) return String(args.sandboxMode)
119
+ if (settings && settings.sandboxMode && SANDBOX_MODES.indexOf(String(settings.sandboxMode)) >= 0) return String(settings.sandboxMode)
120
+ return mode === 'plan' ? 'read-only' : 'workspace-write'
121
+ }
122
+
123
+ const descriptor = {
124
+ id: 'codex', label: 'CODEX', presetId: 'codex-first', defaultBackend: null, defaultMode: 'auto',
125
+ exeName: 'codex', binEnvVars: ['CODEX_BIN', 'CODEX_CLI'], unavailableStatus: 'CODEX_UNAVAILABLE',
126
+
127
+ // 单后端:model_provider/base_url 属于 codex 自身配置,桥不引入第二后端概念。
128
+ backends() { return null },
129
+ normalizeBackend() { return null },
130
+ resolveBackend(args, target, preferred) { return null },
131
+
132
+ // 实测 flag 集(exec 与 exec resume 的可用旗不同 —— 见文件头)。
133
+ buildArgv(exePrefix, args, opts) {
134
+ const o = opts || {}
135
+ const a = args || {}
136
+ const settings = currentSettings()
137
+ const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
138
+ const resume = !!(a.sessionId || a.continueLatest || a.latest === true)
139
+ argv.push('exec')
140
+ if (resume) {
141
+ argv.push('resume')
142
+ if (a.sessionId) argv.push(String(a.sessionId))
143
+ else argv.push('--last')
144
+ }
145
+ // --json 是所有折叠/解析的前提;--skip-git-repo-check 让非 git 目录也能跑。
146
+ argv.push('--json', '--skip-git-repo-check')
147
+ // -C 只有 exec 有;resume 的 cwd 由 spawn 的 cwd 决定(实测 resume 传 -C 直接报错)。
148
+ const cwd = a.cwd ? String(a.cwd) : null
149
+ if (cwd && !resume) argv.push('-C', cwd)
150
+
151
+ let mode = a.mode || 'auto'
152
+ if (mode === 'auto') mode = o.planActive ? 'plan' : 'accept-edits'
153
+ const sandbox = sandboxFor(mode, a, settings)
154
+ if (resume) argv.push('-c', 'sandbox_mode="' + sandbox + '"')
155
+ else argv.push('-s', sandbox)
156
+
157
+ const approval = (settings && settings.approvalPolicy && APPROVAL_POLICIES.indexOf(String(settings.approvalPolicy)) >= 0)
158
+ ? String(settings.approvalPolicy)
159
+ : ''
160
+ // exec 没有 -a/--ask-for-approval;等价效果只能经 -c 覆盖(TOML 字符串值)。
161
+ if (approval) argv.push('-c', 'approval_policy="' + approval + '"')
162
+
163
+ const model = a.model || o.defaultModel || settings.defaultModel || ''
164
+ if (model) argv.push('-m', String(model))
165
+ if (a.reasoningEffort && REASONING_EFFORTS.indexOf(String(a.reasoningEffort)) >= 0) {
166
+ argv.push('-c', 'model_reasoning_effort="' + String(a.reasoningEffort) + '"')
167
+ }
168
+ if (!resume && Array.isArray(a.addDirs)) for (const d of a.addDirs) { if (d) argv.push('--add-dir', String(d)) }
169
+ if (Array.isArray(a.images)) for (const f of a.images) { if (f) argv.push('-i', String(f)) }
170
+ // prompt 必须最后(位置参数)。
171
+ argv.push(String(a.prompt))
172
+ const timeoutSec = clampInt(a.timeoutSec, 900, 10, 3600)
173
+ return { argv, mode, timeoutSec }
174
+ },
175
+
176
+ // NDJSON(--json)。宽容:坏行跳过;无任何 JSON 行返回 null(交 buildResult 走纯文本)。
177
+ // 终止判据 = turn.completed + 退出码 0;顶层 error / turn.failed 才算致命。
178
+ // ⚠️ item.completed 里 item.type==="error" 只是告警(实测续聊会带),绝不致命。
179
+ parseJson(stdoutText) {
180
+ const trimmed = String(stdoutText || '').trim()
181
+ if (!trimmed) return null
182
+ let sawJson = false
183
+ let threadId = null
184
+ let response = ''
185
+ let usage = null
186
+ let completed = false
187
+ let fatal = null
188
+ let turns = 0
189
+ for (const raw of trimmed.split(/\r?\n/)) {
190
+ const ln = raw.trim()
191
+ if (ln.charAt(0) !== '{') continue
192
+ let ev
193
+ try { ev = JSON.parse(ln) } catch (e) { continue }
194
+ if (!ev || typeof ev !== 'object') continue
195
+ sawJson = true
196
+ const t = ev.type
197
+ if (t === 'thread.started') {
198
+ if (typeof ev.thread_id === 'string') threadId = ev.thread_id
199
+ } else if (t === 'turn.started') {
200
+ turns += 1
201
+ } else if (t === 'turn.completed') {
202
+ completed = true
203
+ if (ev.usage && typeof ev.usage === 'object') usage = ev.usage
204
+ } else if (t === 'turn.failed') {
205
+ const msg = ev.error && typeof ev.error.message === 'string' ? ev.error.message
206
+ : (typeof ev.error === 'string' ? ev.error : 'turn failed')
207
+ fatal = { message: msg }
208
+ } else if (t === 'error') {
209
+ // 顶层 error 事件:message 常常是嵌套 JSON 字符串(实测)。
210
+ fatal = { message: typeof ev.message === 'string' ? ev.message : 'error event' }
211
+ } else if (t === 'item.completed' && ev.item && typeof ev.item === 'object') {
212
+ if (ev.item.type === 'agent_message' && typeof ev.item.text === 'string') response = ev.item.text
213
+ }
214
+ }
215
+ if (!sawJson) return null
216
+ return {
217
+ type: completed ? 'turn.completed' : (fatal ? 'turn.failed' : 'incomplete'),
218
+ threadId: threadId || null,
219
+ response: String(response || ''),
220
+ usage: usage || null,
221
+ completed: completed,
222
+ error: fatal,
223
+ numTurns: turns || null
224
+ }
225
+ },
226
+
227
+ // 统一结果对象(契约 §1)。成功必须同时满足:退出码 0 + 收到 turn.completed +
228
+ // 无顶层错误事件。只看退出码不够(实测 400 时 stdout 仍是合法事件流,退出码 1)。
229
+ buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
230
+ const exitCode = outcome ? outcome.exitCode : null
231
+ const stderr = stderrText ? String(stderrText).slice(-2000) : ''
232
+ const mk = (over) => Object.assign({
233
+ ok: false, status: 'ERROR', response: '', sessionId: null, durationSeconds: null,
234
+ numTurns: null, totalTokens: null, duration: null, exitCode, mode, backend: backend || null, stderr
235
+ }, over)
236
+ const usageOf = (u) => {
237
+ if (!u || typeof u !== 'object') return null
238
+ const i = Number(u.input_tokens) || 0
239
+ const o = Number(u.output_tokens) || 0
240
+ return (i + o) > 0 ? i + o : null
241
+ }
242
+ if (parsed && typeof parsed === 'object') {
243
+ const sessionId = parsed.threadId || null
244
+ const totalTokens = usageOf(parsed.usage)
245
+ if (parsed.error) {
246
+ const msg = String(parsed.error.message || 'turn failed')
247
+ return mk({
248
+ status: 'TURN_FAILED', response: parsed.response || '', sessionId,
249
+ numTurns: parsed.numTurns, totalTokens, errorMessage: msg,
250
+ stderr: (stderr ? stderr + ' ' : '') + msg
251
+ })
252
+ }
253
+ if (exitCode === 0 && parsed.completed) {
254
+ return {
255
+ ok: true, status: 'SUCCESS', response: parsed.response || '', sessionId,
256
+ durationSeconds: null, numTurns: parsed.numTurns || 1, totalTokens,
257
+ exitCode, mode, backend: backend || null, stderr
258
+ }
259
+ }
260
+ if (exitCode === 0) {
261
+ return mk({
262
+ status: 'INCOMPLETE', response: parsed.response || '', sessionId, totalTokens,
263
+ numTurns: parsed.numTurns,
264
+ 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.'
265
+ })
266
+ }
267
+ return mk({
268
+ status: exitCode === 2 ? 'BAD_ARGS' : 'ERROR',
269
+ response: parsed.response || '', sessionId, numTurns: parsed.numTurns, totalTokens,
270
+ stderr: (stderr ? stderr + ' ' : '') + 'codex exited ' + exitCode
271
+ })
272
+ }
273
+ const text = String(stdoutText || '').trim()
274
+ if (exitCode === 0) {
275
+ return {
276
+ ok: true, status: 'SUCCESS', response: text, sessionId: null,
277
+ durationSeconds: null, numTurns: null, totalTokens: null, exitCode, mode,
278
+ backend: backend || null, stderr
279
+ }
280
+ }
281
+ return mk({
282
+ status: exitCode === 2 ? 'BAD_ARGS' : 'ERROR',
283
+ response: '',
284
+ stderr: stderr || ('codex exited ' + exitCode),
285
+ rawStdout: text.slice(-2000)
286
+ })
287
+ },
288
+
289
+ // 实时步骤折叠(写 project.current/trail)。事件形态见文件头。
290
+ // item 类型实测:command_execution / reasoning / agent_message / error(告警) /
291
+ // file_change / mcp_tool_call / web_search —— 未知类型也按通用形态记账,绝不吞事件。
292
+ foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
293
+ if (!ev || typeof ev !== 'object') return
294
+ const t = ev.type
295
+ if (!t) return
296
+ p.updatedAt = Date.now()
297
+ const cap = maxTrail || 12
298
+ const capLen = maxArgLen || 120
299
+ const pushTrail = (entry) => { p.trail.push(entry); if (p.trail.length > cap) p.trail.shift() }
300
+ if (t === 'thread.started') {
301
+ p.current = { tool: 'session', args: { thread_id: String(ev.thread_id || '').slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
302
+ return
303
+ }
304
+ if (t === 'turn.completed' || t === 'turn.failed') { p.current = null; return }
305
+ if (t === 'error') {
306
+ pushTrail({ stepIndex: p.trail.length, state: 'ERROR', tool: 'error', args: { message: String(ev.message || '').slice(0, capLen) }, at: nowIso() })
307
+ p.current = null
308
+ return
309
+ }
310
+ if (t !== 'item.started' && t !== 'item.completed') return
311
+ const item = ev.item && typeof ev.item === 'object' ? ev.item : null
312
+ if (!item) return
313
+ const kind = String(item.type || 'item')
314
+ const failed = kind === 'error' || item.status === 'failed' || (typeof item.exit_code === 'number' && item.exit_code !== 0)
315
+ const state = t === 'item.started' ? 'ACTIVE' : (failed ? 'ERROR' : 'DONE')
316
+ let tool = kind
317
+ let args = null
318
+ if (kind === 'command_execution') {
319
+ tool = 'command'
320
+ args = summarizeArgs({ command: item.command })
321
+ } else if (kind === 'agent_message') {
322
+ tool = 'agent_response'
323
+ args = { text_delta: String(item.text || '').slice(0, capLen) }
324
+ } else if (kind === 'reasoning') {
325
+ tool = 'reasoning'
326
+ args = { text_delta: String(item.text || item.summary || '').slice(0, capLen) }
327
+ } else if (kind === 'file_change') {
328
+ tool = 'file_change'
329
+ const changes = Array.isArray(item.changes) ? item.changes.map((c) => (c && (c.path || c.file)) || '').filter(Boolean) : []
330
+ args = summarizeArgs({ paths: changes.slice(0, 6) })
331
+ } else if (kind === 'mcp_tool_call') {
332
+ tool = 'mcp:' + String(item.server || '?') + '/' + String(item.tool || item.name || '?')
333
+ args = summarizeArgs(item.arguments || null)
334
+ } else if (kind === 'web_search') {
335
+ tool = 'web_search'
336
+ args = summarizeArgs({ query: item.query })
337
+ } else if (kind === 'error') {
338
+ tool = 'warning'
339
+ args = { message: String(item.message || '').slice(0, capLen) }
340
+ } else {
341
+ args = summarizeArgs({ type: kind })
342
+ }
343
+ const key = item.id ? String(item.id) : null
344
+ p.codexByItem = p.codexByItem || Object.create(null)
345
+ const prior = key ? p.codexByItem[key] : null
346
+ if (prior) {
347
+ prior.state = state
348
+ if (key) delete p.codexByItem[key]
349
+ if (p.current && p.current.itemKey === key) p.current = null
350
+ return
351
+ }
352
+ const entry = { stepIndex: p.trail.length, state, tool, args: args || null, at: nowIso() }
353
+ pushTrail(entry)
354
+ if (t === 'item.started') {
355
+ if (key) p.codexByItem[key] = entry
356
+ p.current = { tool, args: args || null, stepIndex: entry.stepIndex, since: nowIso(), itemKey: key }
357
+ }
358
+ },
359
+
360
+ // 瞬时错误重试:codex 自己会重试网络,这里只兜「流断了但非限流」这一类。
361
+ isTransientCliError(res) {
362
+ if (!res || res.ok) return false
363
+ if (isLimited(res)) return false
364
+ if (res.status === 'INCOMPLETE') return true
365
+ const hay = String(res.errorMessage || '') + ' ' + String(res.stderr || '')
366
+ return /stream disconnected|connection reset|ECONNRESET|ETIMEDOUT|temporarily unavailable|overloaded|try again/i.test(hay)
367
+ },
368
+
369
+ failureHint(res) {
370
+ if (!res || res.ok) return ''
371
+ const st = String(res.status || '')
372
+ if (st === 'CODEX_UNAVAILABLE') return '[诊断] 未找到 codex 可执行文件:请安装 Codex CLI,或在 CLI Bridge 设置面板填写 codex 可执行文件路径(也可设 CODEX_BIN)。'
373
+ if (st === 'BAD_ARGS') return '[诊断] codex 拒绝了参数(exit 2):请检查 model/sandboxMode 等取值;codex_models 可列出可用模型 id。'
374
+ const hay = String(res.stderr || '') + ' ' + String(res.errorMessage || '') + ' ' + String(res.response || '')
375
+ if (/not logged in|please run.*login|unauthorized|invalid api key|401|403/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
376
+ return '[诊断] codex 凭据问题(未登录 / API key 无效):请让用户执行 `codex login`,或检查 ~/.codex/config.toml 里 model_providers.<name> 的 base_url 与 experimental_bearer_token。这不是限流。'
377
+ }
378
+ if (/not a valid model|invalid model|model not found|unknown model|unsupported model/i.test(hay)) {
379
+ return '[诊断] 模型 id 不被服务端接受:先用 codex_models(默认 source=live)列出该端点真实可用的 id,再传 model。注意 ~/.codex/models.json 与 model_catalog_json 只是本地目录,不代表服务端可用。'
380
+ }
381
+ if (/not inside a trusted directory|not a git repository|skip-git-repo-check/i.test(hay)) {
382
+ return '[诊断] codex 拒绝了当前工作目录(非 git 仓库):本桥已默认加 --skip-git-repo-check;若仍出现,说明 argv 被外部改写,请检查 buildArgv。'
383
+ }
384
+ if (/approval|sandbox|permission denied|operation not permitted/i.test(hay)) {
385
+ return '[诊断] 沙箱/审批拒绝了操作:非交互 exec 无法弹出审批,请调高 sandboxMode(workspace-write → danger-full-access)或在设置面板改「沙箱策略」,然后重跑。'
386
+ }
387
+ if (/quota|额度|rate.?limit|insufficient|credit|balance|402/i.test(hay)) {
388
+ return '[诊断] 端点额度可能耗尽:换一个(优先 :free)模型,或改用原生工具完成本任务;不要循环重试。'
389
+ }
390
+ return ''
391
+ },
392
+
393
+ isSuccessStatus(st) { return st === 'SUCCESS' },
394
+
395
+ tools: {
396
+ 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 },
397
+ 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 },
398
+ 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 }
399
+ },
400
+
401
+ // codex_models:宿主有 spawn/fetch 能力时注册(preset 注入 deps.process / MCP 注入
402
+ // deps.nodeChild;dynamic 沙箱形态省略 —— 与 agy/opencode 同约定)。
403
+ extraTools: (deps.process || deps.nodeChild || typeof deps.fetch === 'function')
404
+ ? [codexModelsTool({ env, deps })]
405
+ : [],
406
+
407
+ settings: {
408
+ keys: ['binPath', 'defaultModel', 'sandboxMode', 'approvalPolicy'],
409
+ defaults: () => ({ binPath: '', defaultModel: '', sandboxMode: '', approvalPolicy: '' }),
410
+ normalize: (raw) => {
411
+ const r = raw && typeof raw === 'object' ? raw : {}
412
+ const pick = (v) => (typeof v === 'string' ? String(v).trim() : '')
413
+ const sandbox = pick(r.sandboxMode)
414
+ const approval = pick(r.approvalPolicy)
415
+ return {
416
+ binPath: pick(r.binPath),
417
+ defaultModel: pick(r.defaultModel),
418
+ sandboxMode: SANDBOX_MODES.indexOf(sandbox) >= 0 ? sandbox : '',
419
+ approvalPolicy: APPROVAL_POLICIES.indexOf(approval) >= 0 ? approval : ''
420
+ }
421
+ },
422
+ // 面板是同步回调,且**不再自动探测**(契约 §3.2):defaultModel 的候选只来自
423
+ // 已缓存的 live 清单(codexCachedModelOptions 纯读,不 spawnSync、不发请求)。
424
+ // 探测由用户点「拉取可用模型」→ settings.refreshModels() 显式触发。
425
+ meta: (env2) => {
426
+ const e = env2 && typeof env2 === 'object' ? env2 : env
427
+ const opts = codexCachedModelOptions(e)
428
+ const exeCandidates = codexExeCandidates(e)
429
+ return {
430
+ titleSuffix: 'Codex',
431
+ fields: [
432
+ {
433
+ key: 'binPath', label: 'codex 可执行文件路径', type: 'select', allowCustom: true,
434
+ options: exeCandidates.map((p) => ({ value: p, label: p })),
435
+ placeholder: '留空 = CODEX_BIN / CODEX_CLI / PATH 的 codex',
436
+ note: '留空即按 env › PATH 顺序解析;填 .mjs/.js 会经 node 启动(测试夹具形态)。'
437
+ },
438
+ {
439
+ key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
440
+ options: opts.options,
441
+ // 面板据此渲染「手动拉取」按钮(契约 §3.2);meta() 自身永不探测。
442
+ refresh: { label: '拉取可用模型' },
443
+ placeholder: '留空 = 由 ~/.codex/config.toml 的 model 决定',
444
+ note: opts.note
445
+ },
446
+ {
447
+ key: 'sandboxMode', label: '沙箱策略', type: 'select',
448
+ options: [
449
+ { value: '', label: '(跟随 mode / codex 自身 config)' },
450
+ { value: 'read-only', label: 'read-only(只读,plan 模式默认)' },
451
+ { value: 'workspace-write', label: 'workspace-write(可写工作区,默认)' },
452
+ { value: 'danger-full-access', label: 'danger-full-access(无沙箱,慎用)' }
453
+ ],
454
+ note: '对应 codex 的 -s;续聊(resume)没有 -s,改经 -c sandbox_mode 生效。'
455
+ },
456
+ {
457
+ key: 'approvalPolicy', label: '审批策略', type: 'select',
458
+ options: [
459
+ { value: '', label: '(不覆盖,用 codex 自身 config)' },
460
+ { value: 'never', label: 'never(从不审批,推荐)' },
461
+ { value: 'on-request', label: 'on-request' },
462
+ { value: 'on-failure', label: 'on-failure' },
463
+ { value: 'untrusted', label: 'untrusted' }
464
+ ],
465
+ note: '经 -c approval_policy="…" 传入 —— codex exec 没有 -a 旗(实测报 unexpected argument)。非交互场景建议 never。'
466
+ }
467
+ ]
468
+ }
469
+ },
470
+ /**
471
+ * 手动拉取(契约 §3.2):面板「拉取可用模型」按钮 → keystone
472
+ * POST /cli-bridge/settings/models → 这里。这是本 provider 唯一的面板探测入口。
473
+ *
474
+ * 复用 codexLiveModels(工具面同一个实现:GET {base_url}/models + Bearer),不写第二套;
475
+ * 成功时它已把 payload 写回 CODEX_MODEL_CACHE,随后 meta() 直接命中。
476
+ * 铁律:失败 = ok:false + 可操作错误 + 空 ids;绝不返回 {ok:true, ids:[]},
477
+ * 也绝不把 catalog/builtin(本地目录、非权威)混进这份「服务端可用清单」。
478
+ */
479
+ async refreshModels(settings, env2) {
480
+ const e = (env2 && typeof env2 === 'object') ? env2 : env
481
+ const res = await codexLiveModels(e, {
482
+ refresh: true,
483
+ fetch: deps.fetch,
484
+ timeoutMs: REFRESH_TIMEOUT_MS
485
+ })
486
+ if (!res.ok) {
487
+ return { ok: false, ids: [], source: 'live', error: String(res.error || 'live model list unavailable') + ' ' + REFRESH_HINT }
488
+ }
489
+ const ids = []
490
+ const labels = {}
491
+ for (const m of res.models || []) {
492
+ if (!m || !m.id || ids.indexOf(m.id) >= 0) continue
493
+ ids.push(m.id)
494
+ labels[m.id] = m.free ? m.id + ' [free]' : m.id
495
+ }
496
+ if (!ids.length) {
497
+ // 拿到 2xx 却没有任何可用 id:显式失败,绝不把空清单当成功(本项目 0.2.6~0.2.9 的坑)。
498
+ return { ok: false, ids: [], source: 'live', error: '端点 ' + String(res.url || '') + ' 返回 0 个可用模型 id(响应已解析但没有可用项)。' + REFRESH_HINT }
499
+ }
500
+ return { ok: true, ids, labels, source: 'live' }
501
+ },
502
+
503
+ diagnose: (settings, env2) => {
504
+ const s = settings && typeof settings === 'object' ? settings : {}
505
+ const e = env2 && typeof env2 === 'object' ? env2 : env
506
+ const bits = []
507
+ bits.push(s.binPath ? 'bin=设置:' + s.binPath : 'bin=' + codexExeCandidates(e)[0])
508
+ bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(codex config 自决)')
509
+ const ep = codexModelsEndpoint(e)
510
+ if (ep.ok) {
511
+ const cached = codexCachedLiveModels(e)
512
+ bits.push('endpoint=' + endpointHost(ep.url) + (cached && cached.ok ? ' live=' + cached.count : ' live=未缓存'))
513
+ } else bits.push('endpoint=未配置')
514
+ bits.push('sandbox=' + (s.sandboxMode || 'mode 派生'))
515
+ bits.push('approval=' + (s.approvalPolicy || 'codex config'))
516
+ const cfg = codexReadConfig(e)
517
+ if (cfg.model) bits.push('cfg.model=' + cfg.model)
518
+ bits.push('home=' + (codexHomeDir(e) || '?'))
519
+ return bits.join(' · ')
520
+ }
521
+ },
522
+
523
+ mcp: { version: '0.2.8' }
524
+ }
525
+
526
+ // 三形态共用的工具逻辑(kit 的 coreExecute 负责 jobs/超时/回退/瞬时重试)。
527
+ descriptor.runToolLogic = async function (args, exec) {
528
+ const a = args || {}
529
+ const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
530
+ return this.runner.coreExecute(normalized, exec)
531
+ }
532
+ descriptor.continueToolLogic = async function (args, exec) {
533
+ const a = args || {}
534
+ const mapped = {
535
+ prompt: a.prompt, model: a.model, mode: a.mode, sandboxMode: a.sandboxMode,
536
+ reasoningEffort: a.reasoningEffort, cwd: a.cwd, images: a.images,
537
+ timeoutSec: a.timeoutSec,
538
+ background: a.background === undefined ? true : a.background
539
+ }
540
+ if (a.sessionId) mapped.sessionId = String(a.sessionId)
541
+ else if (a.latest) mapped.continueLatest = true
542
+ return this.runner.coreExecute(mapped, exec)
543
+ }
544
+ descriptor.statusToolLogic = function (args) {
545
+ const a = args || {}
546
+ const snap = this.engine.statusSnapshot()
547
+ if (a.cwd) {
548
+ const key = String(a.cwd)
549
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
550
+ const g = snap.projects[0]
551
+ if (g) {
552
+ Object.assign(snap, {
553
+ state: g.state, running: g.running, current: g.current, trail: g.trail,
554
+ lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
555
+ lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
556
+ })
557
+ }
558
+ }
559
+ return snap
560
+ }
561
+
562
+ // MCP/测试形态复用同一套模型读取(与 extraTools 同源,避免两处实现漂移)。
563
+ descriptor.listModels = async function (args) {
564
+ return codexModelsTool({ env, deps }).logic(args || {})
565
+ }
566
+ descriptor.liveModels = codexLiveModels
567
+ descriptor.catalogModels = codexCatalogModels
568
+ descriptor.builtinModels = codexBuiltinModels
569
+
570
+ descriptor.attach = function (hostOpts) {
571
+ host = hostOpts || {}
572
+ codexSetHost(env, deps.nodeFs || null, deps.nodePath || null)
573
+ try {
574
+ if (typeof host.getSettings === 'function') {
575
+ const s = host.getSettings('codex')
576
+ if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
577
+ }
578
+ } catch (e) { /* 设置缺省不影响装配 */ }
579
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
580
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
581
+ this.runner = runner
582
+ return runner
583
+ }
584
+ return descriptor
585
+ }