@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
@@ -19,13 +19,29 @@ import { dirname, join } from 'node:path'
19
19
  import { existsSync } from 'node:fs'
20
20
  import { fileURLToPath } from 'node:url'
21
21
  import { resolveScheduling } from '../../../core/scheduling.mjs'
22
- import { PROVIDER_IDS } from '../../../providers/registry.mjs'
22
+ import { PROVIDER_IDS, PRESET_DIRS } from '../../../providers/registry.mjs'
23
23
 
24
24
  export const PKG_NAME = '@luchenglong/cli-bridge'
25
25
 
26
- // preset 目录名映射(与 scripts/build.mjs 的 PRESET_DIRS 一致;目录名带 -first 后缀是
26
+ // preset 目录名映射:单一来源见 providers/registry.mjs 的 PRESET_DIRS(勿在此重复硬编码)。
27
27
  // 三个旧包时代的遗留布局,provider id 不带 —— 这里不映射就永远找不到 plugin-list)。
28
- const PRESET_DIRS = { agy: 'agy-first', codebuddy: 'codebuddy-first', mimo: 'mimo-first', opencode: 'opencode-first' }
28
+
29
+ // 【成员预设】0.2.1 起 dsh-tool-subagent 的 config 不再接受 persona/toolFilter(apply()
30
+ // 里显式 throw),角色人格只能作为独立 Agent Preset 存在、由委派行经 agentPreset 选择。
31
+ // key = provider id(决定它是否随该 provider 的启用面注册,与主预设的启用语义一致);
32
+ // value = { id: 预设身份(委派行按它选择), dir: preset/<dir>/plugin-list.mjs }。
33
+ // ⚠ 必须**先于**对应 provider 的主预设注册:主预设委派行的 validate() 会对每个
34
+ // agentPreset id 调 presets.readDocument(id),未注册即抛 agent-preset/not-found,
35
+ // 而 apply() 一抛就拖垮整组预设挂载(这正是 mimo 在 0.2.1 上挂载失败的根因链)。
36
+ const MEMBER_PRESETS = { mimo: { id: 'cordis-mimo-member', dir: 'mimo-member' } }
37
+
38
+ /** 只读视图:provider id → 成员预设 id(测试与诊断从它派生,避免第二处硬编码)。 */
39
+ export function memberPresetIds(ids) {
40
+ const list = ids || []
41
+ return Object.entries(MEMBER_PRESETS)
42
+ .filter(([providerId]) => list.includes(providerId))
43
+ .map(([, member]) => member.id)
44
+ }
29
45
 
30
46
  const HERE = dirname(fileURLToPath(import.meta.url))
31
47
  const ROOT = join(HERE, '..', '..', '..')
@@ -46,26 +62,85 @@ function realize(v, seen) {
46
62
  return out
47
63
  }
48
64
 
49
- async function loadPluginList(id) {
50
- const abs = join(ROOT, 'preset', PRESET_DIRS[id] || id, 'plugin-list.mjs')
65
+ /** 按 preset 目录名加载单源 plugin-list(provider 预设与成员预设共用)。 */
66
+ async function loadPluginListDir(dir) {
67
+ const abs = join(ROOT, 'preset', dir, 'plugin-list.mjs')
51
68
  if (!existsSync(abs)) return null
52
69
  const mod = await import(pathToFileURL(abs).href)
53
70
  if (!mod || typeof mod.buildPlugins !== 'function' || !mod.meta) return null
54
71
  return mod
55
72
  }
56
73
 
74
+ async function loadPluginList(id) {
75
+ return loadPluginListDir(PRESET_DIRS[id] || id)
76
+ }
77
+
57
78
  /**
58
- * 生成某 provider 的 agentPresets 定义(真函数已落地;不含任何 DSH 活对象)。
79
+ * 按【目录名】生成 agentPresets 定义(真函数已落地;不含任何 DSH 活对象)。
80
+ * bridgeName 决定桥接行的入口子路径:provider 预设用 '/preset-<id>',成员预设用
81
+ * 其所属 provider 的同一入口(子 agent 要能调到该 provider 的 run 工具)。
59
82
  * @returns {Promise<{id,name,description,order,plugins}|null>}
60
83
  */
61
- export async function buildPresetDefinition(id) {
62
- const mod = await loadPluginList(id)
84
+ async function buildDefinitionFromDir(dir, bridgeName) {
85
+ const mod = await loadPluginListDir(dir)
63
86
  if (!mod) return null
64
87
  const meta = mod.meta
65
- const plugins = realize(mod.buildPlugins({ bridgeName: PKG_NAME + '/preset-' + id }) || [], [])
88
+ const plugins = realize(mod.buildPlugins({ bridgeName }) || [], [])
66
89
  return { id: meta.id, name: meta.name || meta.id, description: meta.description || '', order: meta.order != null ? meta.order : 10, plugins }
67
90
  }
68
91
 
92
+ /**
93
+ * 生成某 provider 的 agentPresets 定义。
94
+ * @returns {Promise<{id,name,description,order,plugins}|null>}
95
+ */
96
+ export async function buildPresetDefinition(id) {
97
+ return buildDefinitionFromDir(PRESET_DIRS[id] || id, PKG_NAME + '/preset-' + id)
98
+ }
99
+
100
+ /**
101
+ * 注册某 provider 的成员预设(先行注册)。只注册启用面里的 provider,与主预设一致。
102
+ * @param ctx - 指示灯插件的 context。
103
+ * @param out - 与主预设共用的诊断数组。
104
+ * @param ids - 已启用(按调度顺序)的 provider id 列表。
105
+ */
106
+ function declareMemberPresets(ctx, out, ids) {
107
+ if (typeof ctx.inject !== 'function') return
108
+ for (const [providerId, member] of Object.entries(MEMBER_PRESETS)) {
109
+ if (!ids.includes(providerId)) continue
110
+ try {
111
+ ctx.inject(['agentPresets'], (child) => {
112
+ child.effect(() => {
113
+ let disposer = null
114
+ const registering = (async () => {
115
+ try {
116
+ const def = await buildDefinitionFromDir(member.dir, PKG_NAME + '/preset-' + providerId)
117
+ if (!def) { out.push({ id: member.id, ok: false, error: 'plugin-list missing' }); return }
118
+ disposer = await child.agentPresets.register(def)
119
+ out.push({ id: member.id, ok: true, member: true })
120
+ } catch (e) {
121
+ out.push({ id: member.id, ok: false, error: String(e && e.message || e) })
122
+ try {
123
+ if (child.logger && typeof child.logger.warn === 'function') {
124
+ child.logger.warn('cli-bridge: member preset ' + member.id + ' registration skipped: ' + (e && e.message || e))
125
+ }
126
+ } catch (_) { }
127
+ }
128
+ })()
129
+ return async () => {
130
+ try { await registering } catch (_) { }
131
+ if (typeof disposer === 'function') {
132
+ try { await disposer() } catch (_) { }
133
+ }
134
+ disposer = null
135
+ }
136
+ }, 'cli-bridge: member preset declaration ' + member.id)
137
+ })
138
+ } catch (e) {
139
+ out.push({ id: member.id, ok: false, error: String(e && e.message || e) })
140
+ }
141
+ }
142
+ }
143
+
69
144
  /**
70
145
  * 在 indicator 的 ctx 上挂「agentPresets 就绪 → 注册启用面的 preset」等待器。
71
146
  * 返回每个 provider 的注册状态数组(诊断用);任何异常都不外抛。
@@ -89,6 +164,9 @@ export function declareBridgePresets(ctx, opts) {
89
164
  }
90
165
  const out = []
91
166
  if (typeof ctx.inject !== 'function') return out
167
+ // 成员预设先注册:主预设委派行的 validate() 会经 presets.readDocument(id) 校验
168
+ // agentPreset 引用存在,未注册即抛 agent-preset/not-found。
169
+ declareMemberPresets(ctx, out, ids)
92
170
  for (const id of ids) {
93
171
  try {
94
172
  ctx.inject(['agentPresets'], (child) => {
@@ -28,13 +28,15 @@ import { resolveScheduling } from '../core/scheduling.mjs'
28
28
  import * as agyMcp from './providers/agy.mjs'
29
29
  import * as codebuddyMcp from './providers/codebuddy.mjs'
30
30
  import * as mimoMcp from './providers/mimo.mjs'
31
- import * as opencodeMcp from './providers/opencode.mjs'
31
+ import * as opencodeMcp from './providers/opencode.mjs'
32
+ import * as claudeMcp from './providers/claude.mjs'
33
+ import * as codexMcp from './providers/codex.mjs'
32
34
 
33
35
  const SERVER_NAME = 'cli-bridge-mcp'
34
- const SERVER_VERSION = '0.2.7'
36
+ const SERVER_VERSION = '0.2.9'
35
37
  const PROTOCOL = '2024-11-05'
36
38
 
37
- const PROVIDERS = [agyMcp, codebuddyMcp, mimoMcp, opencodeMcp]
39
+ const PROVIDERS = [agyMcp, codebuddyMcp, mimoMcp, opencodeMcp, claudeMcp, codexMcp]
38
40
 
39
41
  function detectHomeDir() {
40
42
  if (process.env.DSH_HOME) return process.env.DSH_HOME
@@ -183,4 +185,4 @@ process.stdin.on('data', (d) => {
183
185
  process.stdin.on('end', () => { stdinClosed = true; maybeExit() })
184
186
  process.on('SIGINT', () => process.exit(0))
185
187
  process.on('SIGTERM', () => process.exit(0))
186
-
188
+
@@ -0,0 +1,231 @@
1
+ // mcp/providers/claude.mjs — claude MCP 工具面(契约 §4)。
2
+ // framing/生命周期归 keystone 统一服务器 mcp/cli-bridge-mcp.mjs。
3
+ //
4
+ // 与 opencode 面结构同构:直接 spawn claude(原生 exe 直接用;npm 的 .cmd 经 shell:true,
5
+ // Windows 限制;.js/.mjs 目标经 process.execPath 包装,供测试 fixture 使用)。
6
+ // 无 ctx.emit:状态走快照文件 {homeDir}/cli-bridge-mcp-claude.json(bridge-kit
7
+ // buildMcpBridgePayload + 原子写 tmp→rename;写失败静默,绝不影响工具结果)。
8
+ //
9
+ // 工具名/schema 从描述符取(单一来源,防两处漂移),描述与 background 语义按 MCP 面重写:
10
+ // MCP 没有 DSH jobs,background 参数在该形态无意义,故 schema 里裁掉。
11
+ import { spawn as nodeSpawn } from 'node:child_process'
12
+ import { readFileSync } from 'node:fs'
13
+ import { resolve as resolvePath, dirname } from 'node:path'
14
+
15
+ import defineProvider, { resolveExePrefix } from '../../providers/claude.mjs'
16
+ import { splitExeSpec, quoteShellArg } from '../../providers/claude-exe.mjs'
17
+ import { createStatusEngine, createLineStream, buildMcpBridgePayload, isLimited, renderStatus } from '../../core/bridge-kit.mjs'
18
+ import { packageVersion } from '../version.mjs'
19
+
20
+ export const meta = { id: 'claude', label: 'CLAUDE', version: packageVersion() }
21
+
22
+ const SETTINGS_FILE_NAME = 'cli-bridge-settings.json'
23
+
24
+ // 统一设置文件分区读(MCP 子进程无 ctx.get,只读 env/文件)—— 设置面板里的
25
+ // binPath/defaultModel 在 MCP 形态同样生效(与 preset 行为一致)。
26
+ function resolveSettingsFile(env) {
27
+ try {
28
+ if (env.CLI_BRIDGE_SETTINGS_FILE) return env.CLI_BRIDGE_SETTINGS_FILE
29
+ if (env.DSH_HOME) return resolvePath(env.DSH_HOME, SETTINGS_FILE_NAME)
30
+ if (process.platform === 'win32' && env.APPDATA) return resolvePath(env.APPDATA, 'DSH Desktop', 'dsh-home', SETTINGS_FILE_NAME)
31
+ if (env.HOME) return resolvePath(env.HOME, '.dsh', SETTINGS_FILE_NAME)
32
+ } catch (e) { }
33
+ return null
34
+ }
35
+ function readPartition(env) {
36
+ const p = resolveSettingsFile(env)
37
+ if (!p) return { partition: null, path: null, home: null }
38
+ try {
39
+ const all = JSON.parse(readFileSync(p, 'utf8'))
40
+ return { partition: (all && typeof all === 'object' && all.claude) ? all.claude : null, path: p, home: dirname(p) }
41
+ } catch (e) {
42
+ return { partition: null, path: p, home: dirname(p) }
43
+ }
44
+ }
45
+
46
+ const provider = defineProvider({
47
+ env: process.env,
48
+ process,
49
+ nodeChild: { spawn: nodeSpawn },
50
+ readSettings: () => readPartition(process.env)
51
+ })
52
+
53
+ let lastIo = null
54
+ function publishSnapshot(snapshot) {
55
+ const io = lastIo
56
+ if (!io) return
57
+ try {
58
+ const target = io.path.join(io.homeDir, 'cli-bridge-mcp-claude.json')
59
+ const payload = buildMcpBridgePayload(snapshot, process.pid, Date.now())
60
+ const tmp = target + '.' + process.pid + '.tmp'
61
+ try { io.fs.mkdirSync(io.path.dirname(target), { recursive: true }) } catch (e) {}
62
+ io.fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8')
63
+ io.fs.renameSync(tmp, target)
64
+ } catch (e) { /* 灯数据尽力而为,绝不影响工具调用 */ }
65
+ }
66
+ const engine = createStatusEngine({ provider, publish: publishSnapshot })
67
+ provider.engine = engine
68
+
69
+ function exePrefix() { return resolveExePrefix(process.env, '') }
70
+ function resolveCwd(a) {
71
+ if (a && a.cwd) return resolvePath(String(a.cwd))
72
+ return process.env.CLAUDE_MCP_CWD || process.cwd()
73
+ }
74
+
75
+ // ── spawn(实时折叠走 bridge-kit 行流;.cmd 走 shell)─────────────────────────
76
+ function runClaude(args) {
77
+ return new Promise((resolve) => {
78
+ const built = provider.buildArgv(exePrefix(), args, { planActive: false, defaultMode: 'auto' })
79
+ const cwd = resolveCwd(args)
80
+ const cmd = built.argv[0]
81
+ const argv = built.argv.slice(1)
82
+ const isShell = /\.(cmd|bat)$/i.test(cmd)
83
+ const spawnCmd = isShell ? [cmd].concat(argv).map(quoteShellArg).join(' ') : cmd
84
+ let child
85
+ try {
86
+ child = nodeSpawn(spawnCmd, isShell ? [] : argv, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: isShell })
87
+ } catch (e) {
88
+ resolve({ ok: false, status: 'SPAWN_ERROR', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: built.mode, backend: null, stderr: String(e && e.message || e) })
89
+ return
90
+ }
91
+ engine.begin(cwd)
92
+ let out = ''
93
+ let err = ''
94
+ let killed = false
95
+ const timer = setTimeout(() => { killed = true; try { child.kill() } catch (e) {} }, (built.timeoutSec + 60) * 1000)
96
+ const stream = createLineStream((ln) => {
97
+ const t = ln.trim()
98
+ if (!t.startsWith('{')) return
99
+ try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
100
+ })
101
+ child.stdout.on('data', (d) => {
102
+ out += d
103
+ if (out.length > 4000000) out = out.slice(-2000000)
104
+ stream.pushChunk(String(d))
105
+ })
106
+ child.stderr.on('data', (d) => { err += d; if (err.length > 1000000) err = err.slice(-500000) })
107
+ child.on('error', (e) => {
108
+ clearTimeout(timer)
109
+ stream.flush()
110
+ const res = { ok: false, status: 'CLAUDE_UNAVAILABLE', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: built.mode, backend: null, stderr: 'claude spawn failed: ' + String(e && e.message || e) }
111
+ engine.end(res, cwd)
112
+ resolve(res)
113
+ })
114
+ child.on('close', (code) => {
115
+ clearTimeout(timer)
116
+ stream.flush()
117
+ const outcome = { exitCode: killed ? 124 : code }
118
+ let res = provider.buildResult(provider.parseJson(out), outcome, built.mode, err, out, null)
119
+ if (killed && !res.ok) {
120
+ res.status = 'HUNG_TIMEOUT'
121
+ res.stderr = (res.stderr ? res.stderr + ' ' : '') + '[killed by DSH timeout guard after ' + built.timeoutSec + 's; if this was a long-running task raise timeoutSec]'
122
+ }
123
+ engine.end(res, cwd)
124
+ resolve(res)
125
+ })
126
+ })
127
+ }
128
+
129
+ function textResult(res) {
130
+ const head = 'claude ' + (res.ok ? 'OK' : 'FAILED') + ' [status=' + res.status +
131
+ (res.sessionId ? ' session=' + res.sessionId : '') +
132
+ (res.modelUsed ? ' model=' + res.modelUsed : '') +
133
+ (res.durationSeconds != null ? ' ' + res.durationSeconds + 's' : '') + ']'
134
+ let note = ''
135
+ if (!res.ok && isLimited(res)) note = '\n\n[Note: this looks like a rate-limit / network failure. Do NOT retry claude in a loop; finish the task with your own tools, or ask the user.]'
136
+ const hint = provider.failureHint(res)
137
+ const body = res.response || (res.stderr ? '[stderr] ' + res.stderr : (res.rawStdout ? '[raw] ' + res.rawStdout : ''))
138
+ return head + (body ? '\n\n' + body : '') + (hint ? '\n\n' + hint : '') + note
139
+ }
140
+
141
+ // ── 工具面 ────────────────────────────────────────────────────────────────────
142
+ function mcpSchema(name) {
143
+ const src = provider.tools[name].inputSchema
144
+ const clone = JSON.parse(JSON.stringify(src))
145
+ if (clone.properties) delete clone.properties.background
146
+ return clone
147
+ }
148
+
149
+ export function tools(env) {
150
+ const d = provider.tools
151
+ return [
152
+ {
153
+ name: d.run.name,
154
+ description: 'Dispatch a coding/build/debug/investigation task to the local Claude Code CLI and return its final answer. claude runs fully non-interactively (permissions approved, never blocks on a prompt) and streams live steps. Prefer it for implementation, multi-file edits, refactors and debugging; use your own tools for quick read-only lookups and final build/test verification. While it runs, call claude_status to watch what claude is doing live.',
155
+ inputSchema: mcpSchema('run')
156
+ },
157
+ {
158
+ name: d.continue.name,
159
+ description: 'Continue an existing claude session with a follow-up prompt, reusing claude context (passes --resume <sessionId>, or --continue with latest=true).',
160
+ inputSchema: mcpSchema('continue')
161
+ },
162
+ {
163
+ name: d.status.name,
164
+ description: 'Read a live snapshot of what the local claude agent is currently doing. Returns one section per project (working directory): running count, the current step (tool name + arguments being executed, or agent_response thinking/typing), the recent step trail, and the last completed run status + session id. Optional cwd filters to a single project.',
165
+ inputSchema: JSON.parse(JSON.stringify(d.status.inputSchema))
166
+ },
167
+ {
168
+ name: 'claude_models',
169
+ description: 'List the models the current claude account/endpoint really supports, read LIVE from the server (probes it once with a deliberately invalid model id and parses the "The supported API model names are …" answer). Use it to pick a model before claude_run. Returns ok:false with an actionable error when no live roster can be read — it never invents a list and never presents ANTHROPIC_*_MODEL aliases as the usable set.',
170
+ inputSchema: (function () {
171
+ const et = (provider.extraTools || [])[0]
172
+ return JSON.parse(JSON.stringify(et ? et.inputSchema : { type: 'object', properties: {} }))
173
+ })()
174
+ }
175
+ ]
176
+ }
177
+
178
+ export async function call(name, args, io) {
179
+ if (io) lastIo = io
180
+ if (!lastIo) return 'claude error: MCP io not initialized'
181
+ const a = args || {}
182
+ if (name === 'claude_status') {
183
+ const snap = engine.statusSnapshot()
184
+ if (a.cwd) {
185
+ snap.projects = snap.projects.filter((p) => p.cwd === String(a.cwd))
186
+ const g = snap.projects[0]
187
+ if (g) Object.assign(snap, { state: g.state, running: g.running, current: g.current, trail: g.trail, lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId, lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt })
188
+ }
189
+ return renderStatus(snap, provider)[0].text
190
+ }
191
+ if (name === 'claude_models') {
192
+ const et = (provider.extraTools || [])[0]
193
+ const res = await provider.listModels(a)
194
+ if (et && typeof et.render === 'function') return et.render(a, res)[0].text
195
+ return res.ok ? 'claude models: ' + res.models.map((m) => m.id).join(', ') : 'claude_models failed: ' + String(res.error || 'unknown')
196
+ }
197
+ if (!a.prompt || !String(a.prompt).trim()) return 'claude error: prompt is required'
198
+ const mapped = Object.assign({}, a)
199
+ if (name === 'claude_continue') {
200
+ if (!mapped.sessionId && mapped.latest) mapped.continueLatest = true
201
+ }
202
+ const res = await runClaude(mapped)
203
+ return textResult(res)
204
+ }
205
+
206
+ export function check(env) {
207
+ const ts = tools(env)
208
+ const names = ts.map((t) => t.name)
209
+ for (const req of ['claude_run', 'claude_continue', 'claude_status', 'claude_models']) {
210
+ if (names.indexOf(req) < 0) throw new Error('claude mcp: missing tool ' + req)
211
+ }
212
+ for (const t of ts) {
213
+ if (!t.name || !t.description || !t.inputSchema || t.inputSchema.type !== 'object') throw new Error('claude mcp: bad tool def ' + JSON.stringify(t && t.name))
214
+ }
215
+ // MCP 面必须覆盖 descriptor 三件套(名字同源,提示词连续性红线)。
216
+ for (const k of ['run', 'continue', 'status']) {
217
+ if (names.indexOf(provider.tools[k].name) < 0) throw new Error('claude mcp: tools surface missing descriptor name ' + provider.tools[k].name)
218
+ }
219
+ // background 在 MCP 形态无 jobs 语义 → 必须裁掉(防止智能体误以为可后台派发丢结果)。
220
+ if (ts[0].inputSchema.properties && ts[0].inputSchema.properties.background) throw new Error('claude mcp: run schema must not advertise background')
221
+ // argv 契约漂移锁(实测 Claude Code 2.0.76 的 flag 集)。
222
+ const b = provider.buildArgv(['claude'], { prompt: 'x' }, { planActive: false })
223
+ for (const f of ['-p', 'x', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions']) {
224
+ if (b.argv.indexOf(f) < 0) throw new Error('claude mcp: argv contract drift: ' + f)
225
+ }
226
+ const b2 = provider.buildArgv(['claude'], { prompt: 'x', mode: 'plan' }, {})
227
+ if (b2.argv.indexOf('--permission-mode') < 0 || b2.argv.indexOf('plan') < 0) throw new Error('claude mcp: plan mode must pass --permission-mode plan')
228
+ const b3 = provider.buildArgv(['claude'], { prompt: 'x', sessionId: 'sid-1' }, {})
229
+ if (b3.argv.indexOf('--resume') < 0 || b3.argv.indexOf('sid-1') < 0) throw new Error('claude mcp: resume must pass --resume <id>')
230
+ return true
231
+ }
@@ -0,0 +1,239 @@
1
+ // mcp/providers/codex.mjs — codex MCP 工具面(契约 §4)。
2
+ // framing/生命周期归 keystone 统一服务器 mcp/cli-bridge-mcp.mjs。
3
+ //
4
+ // 与 opencode/agy 面结构同构:直接 spawn codex(CODEX_BIN 覆盖;.js/.mjs 目标经
5
+ // process.execPath 包装,供测试夹具使用)。无 ctx.emit:状态走快照文件
6
+ // {homeDir}/cli-bridge-mcp-codex.json(bridge-kit buildMcpBridgePayload + 原子写 tmp→rename)。
7
+ //
8
+ // codex 面的两处特有事实(实测 codex-cli 0.98.0):
9
+ // • 续聊走 `codex exec resume <thread_id> <prompt>`,且 resume **不接受 -C/-s/-o/-add-dir**;
10
+ // 所以 cwd 只由 spawn 的 cwd 决定,argv 里的沙箱经 -c sandbox_mode 落地。
11
+ // • 模型清单没有 CLI 子命令,只能走 HTTP /models(descriptor 的 codex_models 负责),
12
+ // 这里只做工具面转接,不复制解析逻辑。
13
+ import { spawn as nodeSpawn } from 'node:child_process'
14
+ import { resolve as resolvePath } from 'node:path'
15
+ import { readFileSync } from 'node:fs'
16
+ import { dirname } from 'node:path'
17
+
18
+ import defineProvider, { codexResolveExePrefix, codexExeCandidates } from '../../providers/codex.mjs'
19
+ import { codexShimRejectReason } from '../../providers/codex-cli.mjs'
20
+ import { createStatusEngine, createLineStream, buildMcpBridgePayload, isLimited, renderStatus } from '../../core/bridge-kit.mjs'
21
+ import { packageVersion } from '../version.mjs'
22
+
23
+ export const meta = { id: 'codex', label: 'CODEX', version: packageVersion() }
24
+
25
+ // ── 统一设置文件分区读(MCP 子进程无 ctx.get,只读 env/文件)───────────────────
26
+ // 与 opencode/mimo 面同规则:注入 descriptor 的 readSettings,使面板里指定的
27
+ // binPath/defaultModel/sandboxMode 在 MCP 形态同样生效(三形态行为一致)。
28
+ const SETTINGS_FILE_NAME = 'cli-bridge-settings.json'
29
+
30
+ function resolveSettingsFile(env) {
31
+ try {
32
+ if (env.CLI_BRIDGE_SETTINGS_FILE) return env.CLI_BRIDGE_SETTINGS_FILE
33
+ if (env.DSH_HOME) return resolvePath(env.DSH_HOME, SETTINGS_FILE_NAME)
34
+ if (process.platform === 'win32' && env.APPDATA) return resolvePath(env.APPDATA, 'DSH Desktop', 'dsh-home', SETTINGS_FILE_NAME)
35
+ if (env.HOME) return resolvePath(env.HOME, '.dsh', SETTINGS_FILE_NAME)
36
+ } catch (e) { /* 无 env 能力 */ }
37
+ return null
38
+ }
39
+
40
+ function readPartition(env) {
41
+ const p = resolveSettingsFile(env)
42
+ if (!p) return { partition: null, path: null, home: null }
43
+ try {
44
+ const all = JSON.parse(readFileSync(p, 'utf8'))
45
+ return { partition: (all && typeof all === 'object' && all.codex) ? all.codex : null, path: p, home: dirname(p) }
46
+ } catch (e) {
47
+ return { partition: null, path: p, home: dirname(p) }
48
+ }
49
+ }
50
+
51
+ const provider = defineProvider({
52
+ env: process.env,
53
+ process,
54
+ nodeChild: { spawn: nodeSpawn },
55
+ readSettings: () => readPartition(process.env)
56
+ })
57
+
58
+ let lastIo = null
59
+ function publishSnapshot(snapshot) {
60
+ const io = lastIo
61
+ if (!io) return
62
+ try {
63
+ const target = io.path.join(io.homeDir, 'cli-bridge-mcp-codex.json')
64
+ const payload = buildMcpBridgePayload(snapshot, process.pid, Date.now())
65
+ const tmp = target + '.' + process.pid + '.tmp'
66
+ try { io.fs.mkdirSync(io.path.dirname(target), { recursive: true }) } catch (e) { /* 目录已存在 */ }
67
+ io.fs.writeFileSync(tmp, JSON.stringify(payload), 'utf8')
68
+ io.fs.renameSync(tmp, target)
69
+ } catch (e) { /* 灯数据尽力而为,绝不影响工具调用 */ }
70
+ }
71
+
72
+ // provider.engine 在 attach 后才存在;MCP 形态不 attach,用本模块的 engine。
73
+ const engine = createStatusEngine({ provider, publish: publishSnapshot })
74
+ provider.engine = engine
75
+
76
+ // ── exe / cwd 解析 ────────────────────────────────────────────────────────────
77
+ function exePrefix() {
78
+ return codexResolveExePrefix(process.env, '')
79
+ }
80
+
81
+ function resolveCwd(a) {
82
+ if (a && a.cwd) return resolvePath(String(a.cwd))
83
+ return process.env.CODEX_BRIDGE_CWD || process.cwd()
84
+ }
85
+
86
+ function baseResult(over) {
87
+ return Object.assign({
88
+ ok: false, status: 'ERROR', response: '', sessionId: null, durationSeconds: null,
89
+ numTurns: null, totalTokens: null, exitCode: null, mode: 'auto', backend: null, stderr: ''
90
+ }, over)
91
+ }
92
+
93
+ // ── spawn(实时折叠走 bridge-kit 行流)────────────────────────────────────────
94
+ function runCodex(args) {
95
+ return new Promise((resolve) => {
96
+ const a = args || {}
97
+ const built = provider.buildArgv(exePrefix(), a, { planActive: false, defaultMode: 'auto' })
98
+ const cwd = resolveCwd(a)
99
+ // .cmd/.bat shim:无 shell 的 spawn 解析不了它,走 shell 会二次解释 prompt。
100
+ const shim = codexShimRejectReason(built.argv[0]) || codexShimRejectReason(built.argv[1])
101
+ if (shim) {
102
+ const res = baseResult({ status: 'CODEX_UNAVAILABLE', mode: built.mode, stderr: shim })
103
+ engine.begin(cwd); engine.end(res, cwd)
104
+ resolve(res)
105
+ return
106
+ }
107
+ let child
108
+ try {
109
+ child = nodeSpawn(built.argv[0], built.argv.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
110
+ } catch (e) {
111
+ resolve(baseResult({ status: 'SPAWN_ERROR', mode: built.mode, stderr: String(e && e.message || e) }))
112
+ return
113
+ }
114
+ engine.begin(cwd)
115
+ let out = ''
116
+ let err = ''
117
+ let killed = false
118
+ const timer = setTimeout(() => { killed = true; try { child.kill() } catch (e) { /* 已退出 */ } }, (built.timeoutSec + 60) * 1000)
119
+ const stream = createLineStream((ln) => {
120
+ const t = ln.trim()
121
+ if (t.charAt(0) !== '{') return
122
+ try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) { /* 坏行跳过 */ }
123
+ })
124
+ child.stdout.on('data', (d) => {
125
+ out += d
126
+ if (out.length > 4000000) out = out.slice(-2000000)
127
+ stream.pushChunk(String(d))
128
+ })
129
+ child.stderr.on('data', (d) => { err += d; if (err.length > 1000000) err = err.slice(-500000) })
130
+ child.on('error', (e) => {
131
+ clearTimeout(timer)
132
+ stream.flush()
133
+ const res = baseResult({ status: 'CODEX_UNAVAILABLE', mode: built.mode, stderr: 'codex spawn failed: ' + String(e && e.message || e) })
134
+ engine.end(res, cwd)
135
+ resolve(res)
136
+ })
137
+ child.on('close', (code) => {
138
+ clearTimeout(timer)
139
+ stream.flush()
140
+ const outcome = { exitCode: killed ? 124 : code }
141
+ let res = provider.buildResult(provider.parseJson(out), outcome, built.mode, err, out, null)
142
+ if (killed && !res.ok) {
143
+ res.status = 'HUNG_TIMEOUT'
144
+ res.stderr = (res.stderr ? res.stderr + ' ' : '') + '[killed by DSH timeout guard after ' + built.timeoutSec + 's; if this was a long-running script (build/test) raise timeoutSec]'
145
+ }
146
+ engine.end(res, cwd)
147
+ resolve(res)
148
+ })
149
+ })
150
+ }
151
+
152
+ function textResult(res) {
153
+ if (res.fallback) {
154
+ return 'codex unavailable (' + res.reason + '). Use native tools / the local model for this task; do not retry codex.'
155
+ }
156
+ const limited = !res.ok && isLimited(res)
157
+ const head = 'codex ' + (res.ok ? 'OK' : 'FAILED') + ' [status=' + res.status +
158
+ (res.sessionId ? ' session=' + res.sessionId : '') +
159
+ (res.totalTokens != null ? ' tokens=' + res.totalTokens : '') + ']'
160
+ const hint = typeof provider.failureHint === 'function' ? provider.failureHint(res) : ''
161
+ let note = ''
162
+ if (limited) note = '\n\n[Note: this looks like a rate-limit / network failure. Do NOT retry codex in a loop; finish the task with your own tools, or ask the user.]'
163
+ const body = res.response || (res.stderr ? '[stderr] ' + res.stderr : (res.rawStdout ? '[raw] ' + res.rawStdout : ''))
164
+ return head + (body ? '\n\n' + body : '') + (hint ? '\n\n' + hint : '') + note
165
+ }
166
+
167
+ // ── 工具面(与描述符同源:名字/schema 一律从 descriptor 取,禁止第二份定义)──────
168
+ function toolList(env) {
169
+ const d = provider.tools
170
+ const extra = provider.extraTools || []
171
+ return [d.run, d.continue, d.status].concat(extra.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })))
172
+ .map((t) => Object.assign({}, t))
173
+ }
174
+
175
+ export function tools(env) {
176
+ return toolList(env)
177
+ }
178
+
179
+ export async function call(name, args, io) {
180
+ if (io) lastIo = io
181
+ if (!lastIo) return 'codex error: MCP io not initialized'
182
+ const a = args || {}
183
+ const d = provider.tools
184
+ if (name === d.status.name) {
185
+ const snap = provider.statusToolLogic(a)
186
+ if (a.cwd) {
187
+ // statusToolLogic 已经按 cwd 过滤并升格顶层字段,这里直接渲染。
188
+ }
189
+ return renderStatus(snap, provider)[0].text
190
+ }
191
+ if (name === (provider.extraTools[0] && provider.extraTools[0].name)) {
192
+ const res = await provider.extraTools[0].logic(a)
193
+ const rendered = provider.extraTools[0].render(a, res)
194
+ return rendered[0].text
195
+ }
196
+ if (!a.prompt || !String(a.prompt).trim()) return 'codex error: prompt is required'
197
+ const mapped = Object.assign({}, a)
198
+ if (name === d.continue.name) {
199
+ // continue 的会话语义:sessionId 优先,否则 latest → exec resume --last。
200
+ if (!mapped.sessionId && mapped.latest) mapped.continueLatest = true
201
+ }
202
+ const res = await runCodex(mapped)
203
+ return textResult(res)
204
+ }
205
+
206
+ export function check(env) {
207
+ const ts = tools(env)
208
+ const names = ts.map((t) => t.name)
209
+ for (const t of ts) {
210
+ if (!t.name || !t.description || !t.inputSchema || t.inputSchema.type !== 'object') throw new Error('codex mcp: bad tool def ' + JSON.stringify(t && t.name))
211
+ }
212
+ // MCP 面必须覆盖 descriptor 三件套(名字一致,提示词连续性红线)。
213
+ for (const k of ['run', 'continue', 'status']) {
214
+ if (!names.includes(provider.tools[k].name)) throw new Error('codex mcp: tools surface missing descriptor name ' + provider.tools[k].name)
215
+ }
216
+ // argv 契约漂移锁(实测 codex-cli 0.98.0 的旗集)。
217
+ const b = provider.buildArgv(['codex'], { prompt: 'x' }, { planActive: false })
218
+ for (const f of ['exec', '--json', '--skip-git-repo-check']) {
219
+ if (b.argv.indexOf(f) < 0) throw new Error('codex mcp: argv contract drift: ' + f)
220
+ }
221
+ // ⚠️ codex exec 没有 -a;出现即参数错误(实测 clap: unexpected argument '-a')。
222
+ if (b.argv.indexOf('-a') >= 0 || b.argv.indexOf('--ask-for-approval') >= 0) throw new Error('codex mcp: codex exec rejects -a/--ask-for-approval; use -c approval_policy')
223
+ // resume 子命令不接受 -C/-s/-L:argv 必须改成 -c sandbox_mode。
224
+ const r = provider.buildArgv(['codex'], { prompt: 'x', sessionId: 'sid-1' }, { planActive: false })
225
+ for (const f of ['resume', 'sid-1', '--json']) {
226
+ if (r.argv.indexOf(f) < 0) throw new Error('codex mcp: resume argv contract drift: ' + f)
227
+ }
228
+ for (const f of ['-C', '-s', '--add-dir']) {
229
+ if (r.argv.indexOf(f) >= 0) throw new Error('codex mcp: resume must not emit ' + f + ' (codex exec resume rejects it)')
230
+ }
231
+ if (r.argv.indexOf('-c') < 0 || String(r.argv[r.argv.indexOf('-c') + 1] || '').indexOf('sandbox_mode=') < 0) {
232
+ throw new Error('codex mcp: resume must set the sandbox through -c sandbox_mode')
233
+ }
234
+ // 模型清单在 MCP 面必须点名真实端点而非本地目录(红线:不把本地目录冒充实时)。
235
+ const mt = ts.find((t) => /_models$/.test(t.name))
236
+ if (!mt) throw new Error('codex mcp: missing codex_models tool')
237
+ if (!/live/.test(String(mt.description))) throw new Error('codex mcp: codex_models must document the live source')
238
+ return true
239
+ }