@luchenglong/cli-bridge 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/PROVIDER-CONTRACT.md +53 -4
- package/README.md +12 -11
- package/docs/HANDOVER.md +9 -0
- package/docs/audits/2026-10-18-preset-0.2.1-contract-fix.md +160 -0
- package/docs/audits/README.md +15 -0
- package/dynamic/agy.host.js +1 -1
- package/dynamic/claude.host.js +1801 -0
- package/dynamic/claude.host.template.mjs +108 -0
- package/dynamic/codebuddy.host.js +1 -1
- package/dynamic/codex.host.js +1955 -0
- package/dynamic/codex.host.template.mjs +137 -0
- package/dynamic/mimo.host.js +1 -1
- package/dynamic/opencode.host.js +1 -1
- package/home-plugin/cli-bridge-indicator/lib/client.js +600 -606
- package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +87 -9
- package/mcp/cli-bridge-mcp.mjs +6 -4
- package/mcp/providers/claude.mjs +231 -0
- package/mcp/providers/codex.mjs +239 -0
- package/package.json +10 -6
- package/preset/agy-first/agent.cordis.yml +0 -5
- package/preset/agy-first/plugin-list.mjs +4 -1
- package/preset/claude-first/agent.cordis.yml +136 -0
- package/preset/claude-first/cli-bridge.mjs +131 -0
- package/preset/claude-first/plugin-list.mjs +28 -0
- package/preset/claude-first/preset.yml +3 -0
- package/preset/codebuddy-first/agent.cordis.yml +0 -5
- package/preset/codex-first/agent.cordis.yml +136 -0
- package/preset/codex-first/cli-bridge.mjs +160 -0
- package/preset/codex-first/plugin-list.mjs +28 -0
- package/preset/codex-first/preset.yml +3 -0
- package/preset/mimo-first/agent.cordis.yml +1 -7
- package/preset/mimo-first/plugin-list.mjs +18 -12
- package/preset/mimo-member/plugin-list.mjs +49 -0
- package/preset/opencode-first/agent.cordis.yml +0 -5
- package/preset/opencode-first/plugin-list.mjs +4 -2
- package/preset/standard-base.mjs +18 -6
- package/providers/claude-exe.mjs +112 -0
- package/providers/claude-models.mjs +334 -0
- package/providers/claude-policy.mjs +37 -0
- package/providers/claude.mjs +508 -0
- package/providers/codex-cli.mjs +471 -0
- package/providers/codex-models.mjs +107 -0
- package/providers/codex.mjs +544 -0
- package/providers/registry.mjs +25 -1
- package/scripts/build.mjs +1 -2
- package/scripts/verify.mjs +8 -2
|
@@ -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
|
|
26
|
+
// preset 目录名映射:单一来源见 providers/registry.mjs 的 PRESET_DIRS(勿在此重复硬编码)。
|
|
27
27
|
// 三个旧包时代的遗留布局,provider id 不带 —— 这里不映射就永远找不到 plugin-list)。
|
|
28
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
62
|
-
const mod = await
|
|
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
|
|
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) => {
|
package/mcp/cli-bridge-mcp.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|