@luchenglong/cli-bridge 0.2.7 → 0.2.9

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