@luchenglong/cli-bridge 0.0.0-stage → 0.2.3
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 +191 -0
- package/LICENSE +21 -0
- package/MCP-POLICY.md +53 -0
- package/PROVIDER-CONTRACT.md +235 -0
- package/README.en.md +98 -0
- package/README.md +151 -2
- package/assets/indicator-states.svg +62 -0
- package/bin/agy-quota.mjs +223 -0
- package/core/bridge-kit.mjs +549 -0
- package/core/scheduling.mjs +149 -0
- package/docs/ARCHITECTURE.md +117 -0
- package/docs/FALLBACK-AND-INDICATOR.md +93 -0
- package/docs/HANDOVER.md +226 -0
- package/docs/INSTALL.md +118 -0
- package/docs/MCP-POLICY.zh.md +49 -0
- package/docs/MIGRATION.md +43 -0
- package/docs/RELEASE-SOP.md +80 -0
- package/docs/history/agy/CHANGELOG.md +326 -0
- package/docs/history/codebuddy/CHANGELOG.md +668 -0
- package/docs/history/mimo/CHANGELOG.md +346 -0
- package/dynamic/agy.host.js +1237 -0
- package/dynamic/agy.host.template.mjs +140 -0
- package/dynamic/codebuddy.host.js +1787 -0
- package/dynamic/codebuddy.host.template.mjs +162 -0
- package/dynamic/mimo.host.js +1732 -0
- package/dynamic/mimo.host.template.mjs +169 -0
- package/dynamic/opencode.host.js +1443 -0
- package/dynamic/opencode.host.template.mjs +125 -0
- package/home-plugin/cli-bridge-indicator/cordis.patch.yml +31 -0
- package/home-plugin/cli-bridge-indicator/lib/client.js +606 -0
- package/home-plugin/cli-bridge-indicator/lib/index.mjs +420 -0
- package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +137 -0
- package/home-plugin/cli-bridge-indicator/lib/settings.mjs +300 -0
- package/mcp/agy-mcp-server.mjs +6 -0
- package/mcp/cli-bridge-mcp.mjs +185 -0
- package/mcp/codebuddy-mcp-server.mjs +6 -0
- package/mcp/mimo-mcp-server.mjs +6 -0
- package/mcp/providers/agy.mjs +254 -0
- package/mcp/providers/codebuddy.mjs +397 -0
- package/mcp/providers/mimo.mjs +290 -0
- package/mcp/providers/opencode.mjs +236 -0
- package/package.json +89 -4
- package/preset/agy-first/agent.cordis.yml +141 -0
- package/preset/agy-first/cli-bridge.mjs +169 -0
- package/preset/agy-first/plugin-list.mjs +26 -0
- package/preset/agy-first/preset.yml +3 -0
- package/preset/codebuddy-first/agent.cordis.yml +141 -0
- package/preset/codebuddy-first/cli-bridge.mjs +188 -0
- package/preset/codebuddy-first/plugin-list.mjs +20 -0
- package/preset/codebuddy-first/preset.yml +3 -0
- package/preset/mimo-first/agent.cordis.yml +143 -0
- package/preset/mimo-first/cli-bridge.mjs +236 -0
- package/preset/mimo-first/plugin-list.mjs +39 -0
- package/preset/mimo-first/preset.yml +3 -0
- package/preset/opencode-first/agent.cordis.yml +141 -0
- package/preset/opencode-first/cli-bridge.mjs +147 -0
- package/preset/opencode-first/plugin-list.mjs +26 -0
- package/preset/opencode-first/preset.yml +3 -0
- package/preset/standard-base.mjs +102 -0
- package/providers/agy.mjs +398 -0
- package/providers/codebuddy.mjs +926 -0
- package/providers/mimo.mjs +864 -0
- package/providers/opencode.mjs +619 -0
- package/providers/registry.mjs +39 -0
- package/scripts/build.mjs +235 -0
- package/scripts/deploy.mjs +61 -0
- package/scripts/verify.mjs +148 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// providers/opencode.mjs — opencode provider descriptor(第四家 CLI)。
|
|
2
|
+
//
|
|
3
|
+
// 移植基线 = 本机实测的 OpenCode **V2** CLI(v2.0.29,桌面版自带二进制
|
|
4
|
+
// opencode-cli.exe)+ 官方 CLI 源码 emit 全集。不是旧包(本包新增,无旧包可迁)。
|
|
5
|
+
//
|
|
6
|
+
// 实测事实(2026-10-15,CLI v2.0.29):
|
|
7
|
+
// opencode run [message..] 非交互主命令;prompt 是【位置参数】(run 子命令无
|
|
8
|
+
// --prompt;-p 是 --password,切勿当 prompt 用)
|
|
9
|
+
// --format json NDJSON 事件流(每行一个 JSON 对象)
|
|
10
|
+
// --auto auto-approve 未显式拒绝的权限(不加会卡在权限提示)
|
|
11
|
+
// -m, --model provider/model V2 支持 provider/model#variant
|
|
12
|
+
// --session <id> / --continue 会话续接(无 `opencode continue` 子命令)
|
|
13
|
+
// --agent / --title / --file / --thinking / --standalone
|
|
14
|
+
// ⚠️ V2 无 --dir(V1 有)→ cwd 只能靠 spawn 的工作目录
|
|
15
|
+
// ⚠️ V2 无 --variant(V1 有)→ variant 并入 --model 的 # 后缀
|
|
16
|
+
// 退出码:0 成功 / 1 错误 / 130 SIGINT(退出码才是成败判据)
|
|
17
|
+
// 事件 type(源码全集):step_start / text / reasoning / tool_use / step_finish / error
|
|
18
|
+
// ⚠️ 不存在 result/complete/init 事件
|
|
19
|
+
// ⚠️ 文本在 part.text;统计在 step_finish.part(cost/tokens)——两者是不同事件
|
|
20
|
+
//
|
|
21
|
+
// 红线(PROVIDER-CONTRACT.md §8 + §1):
|
|
22
|
+
// - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
|
|
23
|
+
// process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
|
|
24
|
+
// - 只允许 import '../core/bridge-kit.mjs'。
|
|
25
|
+
import { createStatusEngine, createRunner, clampInt, isLimited } from '../core/bridge-kit.mjs'
|
|
26
|
+
|
|
27
|
+
function nowIso() { return new Date().toISOString() }
|
|
28
|
+
|
|
29
|
+
// ── 可执行文件解析(env > 桌面版已知路径 > PATH)───────────────────────────────
|
|
30
|
+
// 二进制名是 opencode-cli.exe(不是 opencode.exe):V2 桌面版自带,且 V1/V2 不能并存
|
|
31
|
+
// 安装(官方原话),故不做「opencode.exe 兜底」以免误命中另一个版本。
|
|
32
|
+
const EXE_BASENAMES = ['opencode-cli', 'opencode']
|
|
33
|
+
// 桌面版各版本目录形如 ...\ai.opencode.desktop\cli\<ver>-<arch>\opencode-cli.exe。
|
|
34
|
+
// 沙箱内可能没有 fs —— 全部读取走 typeof 守卫,读不到就返回空数组(退回 PATH 查找)。
|
|
35
|
+
const DESKTOP_CLI_REL = ['ai.opencode.desktop', 'cli']
|
|
36
|
+
|
|
37
|
+
function schedBuiltin(name) {
|
|
38
|
+
try {
|
|
39
|
+
const proc = globalThis.process
|
|
40
|
+
return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
|
|
41
|
+
} catch (e) { return null }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 探测本机 opencode 可执行文件候选路径(优先桌面版 V2)。
|
|
46
|
+
* 纯函数、零副作用;无 fs 能力时返回空数组 —— 由调用方退回 PATH 查找。
|
|
47
|
+
* @param {object} env 环境变量(读 APPDATA / LOCALAPPDATA / PROGRAMFILES / HOME)
|
|
48
|
+
*/
|
|
49
|
+
export function candidateExePaths(env) {
|
|
50
|
+
const e = env || {}
|
|
51
|
+
const out = []
|
|
52
|
+
const fs = schedBuiltin('node:fs')
|
|
53
|
+
const path = schedBuiltin('node:path')
|
|
54
|
+
if (!fs || typeof fs.readdirSync !== 'function' || !path || typeof path.join !== 'function') return out
|
|
55
|
+
const roots = []
|
|
56
|
+
for (const k of ['APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES']) if (e[k]) roots.push(String(e[k]))
|
|
57
|
+
const home = e.HOME || e.USERPROFILE
|
|
58
|
+
if (home && !roots.length) roots.push(String(home))
|
|
59
|
+
for (const root of roots) {
|
|
60
|
+
const cliDir = path.join.apply(path, [root].concat(DESKTOP_CLI_REL))
|
|
61
|
+
let versions = []
|
|
62
|
+
try { versions = fs.readdirSync(cliDir, { withFileTypes: true }) } catch (err) { continue }
|
|
63
|
+
// 版本目录按 semver 降序(新版本优先);解析失败的排末尾。
|
|
64
|
+
const dirs = versions.filter((d) => d && d.isDirectory && d.isDirectory()).map((d) => d.name)
|
|
65
|
+
const key = (n) => {
|
|
66
|
+
const m = String(n).match(/(\d+)\.(\d+)\.(\d+)/)
|
|
67
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [-1, -1, -1]
|
|
68
|
+
}
|
|
69
|
+
dirs.sort((a, b) => {
|
|
70
|
+
const ka = key(a), kb = key(b)
|
|
71
|
+
for (let i = 0; i < 3; i++) if (ka[i] !== kb[i]) return kb[i] - ka[i]
|
|
72
|
+
return 0
|
|
73
|
+
})
|
|
74
|
+
for (const d of dirs) {
|
|
75
|
+
for (const base of EXE_BASENAMES) {
|
|
76
|
+
const exe = path.join(cliDir, d, base + (String(e.OS || '').toLowerCase().startsWith('win') || e.APPDATA ? '.exe' : ''))
|
|
77
|
+
try { if (fs.existsSync(exe)) out.push(exe) } catch (err) { }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 解析本次调用使用的 exe 前缀(数组或字符串)。
|
|
86
|
+
* 优先级:binPath 设置 > env OPENCODE_BIN/OPENCODE_CLI > 桌面版探测 > PATH 名字。
|
|
87
|
+
* @param {object} env
|
|
88
|
+
* @param {string} [binPath] 设置面板里的显式路径(可空)
|
|
89
|
+
*/
|
|
90
|
+
export function resolveExePrefix(env, binPath) {
|
|
91
|
+
const e = env || {}
|
|
92
|
+
const bin = binPath || e.OPENCODE_BIN || e.OPENCODE_CLI || ''
|
|
93
|
+
if (bin) {
|
|
94
|
+
if (/\.(mjs|cjs|js)$/i.test(bin)) {
|
|
95
|
+
const proc = globalThis.process
|
|
96
|
+
const node = (proc && proc.execPath) ? proc.execPath : 'node'
|
|
97
|
+
return [node, bin]
|
|
98
|
+
}
|
|
99
|
+
return [String(bin)]
|
|
100
|
+
}
|
|
101
|
+
const found = candidateExePaths(e)
|
|
102
|
+
if (found.length) return [found[0]]
|
|
103
|
+
return [EXE_BASENAMES[0]]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 模型列表(`opencode models`;纯文本每行 provider/model)─────────────────────
|
|
107
|
+
// 同步版给 settings.meta(面板下拉);异步版给 MCP 工具。解析宽容:只认
|
|
108
|
+
// provider/model 形态的行,其余(表头/说明/空行)一律丢弃。
|
|
109
|
+
const MODEL_LINE_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.#@+-]+)$/
|
|
110
|
+
|
|
111
|
+
export function parseModelList(text) {
|
|
112
|
+
const out = []
|
|
113
|
+
const seen = Object.create(null)
|
|
114
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
115
|
+
const ln = raw.trim()
|
|
116
|
+
if (!ln) continue
|
|
117
|
+
const m = ln.match(MODEL_LINE_RE)
|
|
118
|
+
if (!m) continue
|
|
119
|
+
const id = m[1] + '/' + m[2]
|
|
120
|
+
if (seen[id]) continue
|
|
121
|
+
seen[id] = true
|
|
122
|
+
out.push({ id: id, provider: m[1], name: m[2], free: /(-free$|free)/i.test(m[2]) })
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 同步抓取模型列表(供设置面板下拉)。失败返回空数组 —— 下拉退化为自由输入,
|
|
129
|
+
* 绝不因探测失败影响面板渲染。仅 spawnSync 可用时生效(宿主形态)。
|
|
130
|
+
*/
|
|
131
|
+
export function fetchModelsSync(env, binPath, timeoutMs) {
|
|
132
|
+
try {
|
|
133
|
+
const child = schedBuiltin('node:child_process')
|
|
134
|
+
if (!child || typeof child.spawnSync !== 'function') return []
|
|
135
|
+
const prefix = resolveExePrefix(env, binPath)
|
|
136
|
+
const r = child.spawnSync(prefix[0], prefix.slice(1).concat(['models']), {
|
|
137
|
+
encoding: 'utf8', timeout: clampInt(timeoutMs, 8000, 1000, 30000),
|
|
138
|
+
windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
|
|
139
|
+
})
|
|
140
|
+
if (!r || r.error || r.status !== 0) return []
|
|
141
|
+
return parseModelList(String(r.stdout || ''))
|
|
142
|
+
} catch (e) { return [] }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 三工具 schema ────────────────────────────────────────────────────────────
|
|
146
|
+
const RUN_PARAMS = {
|
|
147
|
+
type: 'object',
|
|
148
|
+
additionalProperties: false,
|
|
149
|
+
required: ['prompt'],
|
|
150
|
+
properties: {
|
|
151
|
+
prompt: { type: 'string', description: 'The task instruction for opencode. 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 — opencode reads the repo itself). For follow-ups on the same topic, use opencode_continue with latest=true or sessionId (opencode keeps session context; state only what is new).' },
|
|
152
|
+
model: { type: 'string', description: 'Optional model in provider/model form (e.g. "opencode/big-pickle"). Prefer a -free model when the task does not need a frontier model. Omit to use the model chosen in the CLI Bridge settings panel, or (if unset there) opencode\'s own default.' },
|
|
153
|
+
agent: { type: 'string', description: 'Optional opencode agent name (primary agents only; subagent names are rejected by opencode itself).' },
|
|
154
|
+
cwd: { type: 'string', description: 'Working directory for opencode. Defaults to the DSH workspace root.' },
|
|
155
|
+
files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach to the message.' },
|
|
156
|
+
title: { type: 'string', description: 'Optional session title (opencode truncates the prompt when omitted).' },
|
|
157
|
+
timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
|
|
158
|
+
autoApprove: { type: 'boolean', description: 'Default true: pass --auto so opencode auto-approves permissions and never blocks on a prompt. Set false to require explicit approval (opencode may then block and hit the timeout).' },
|
|
159
|
+
background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with opencode_status. Set false to block for the final answer.' }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const CONTINUE_PARAMS = {
|
|
163
|
+
type: 'object',
|
|
164
|
+
additionalProperties: false,
|
|
165
|
+
required: ['prompt'],
|
|
166
|
+
properties: {
|
|
167
|
+
prompt: { type: 'string', description: 'Follow-up instruction for the ongoing opencode session. State only what is new; opencode preserves its session context.' },
|
|
168
|
+
sessionId: { type: 'string', description: 'opencode session id to continue (from a prior opencode_run result).' },
|
|
169
|
+
latest: { type: 'boolean', description: 'Continue the most recent opencode session instead of a specific id.' },
|
|
170
|
+
model: { type: 'string', description: 'Optional model in provider/model form.' },
|
|
171
|
+
agent: { type: 'string', description: 'Optional opencode agent name.' },
|
|
172
|
+
cwd: { type: 'string', description: 'Working directory for opencode.' },
|
|
173
|
+
files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach.' },
|
|
174
|
+
timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
|
|
175
|
+
autoApprove: { type: 'boolean', description: 'Default true: pass --auto. Set false to require explicit approval.' },
|
|
176
|
+
background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with opencode_status. Set false to block for the final answer.' }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
|
|
180
|
+
|
|
181
|
+
// opencode_models(附加工具):列出本机 opencode 可用模型(免费模型优先展示)。
|
|
182
|
+
const MODELS_PARAMS = {
|
|
183
|
+
type: 'object',
|
|
184
|
+
additionalProperties: false,
|
|
185
|
+
required: [],
|
|
186
|
+
properties: {
|
|
187
|
+
freeOnly: { type: 'boolean', description: 'Return only free models (id ends with -free). Default false = all.' },
|
|
188
|
+
provider: { type: 'string', description: 'Optional provider filter, e.g. "opencode" or "opencode-go".' }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const MODELS_DESCRIPTION = 'List the models available to the local opencode CLI (from `opencode models`). Returns { ok, models: [{id, provider, name, free}], count }. Use this to pick a model before opencode_run, or to check which models are free. Prefer a -free model for routine work and reserve paid/frontier models for tasks that genuinely need them. If the CLI is missing or not logged in, this returns ok:false with an actionable error — it never silently invents a model list.'
|
|
192
|
+
|
|
193
|
+
function renderModels(value) {
|
|
194
|
+
const v = value || {}
|
|
195
|
+
if (!v.ok) return [{ type: 'text', text: 'opencode_models failed: ' + String(v.error || 'unknown') }]
|
|
196
|
+
const list = Array.isArray(v.models) ? v.models : []
|
|
197
|
+
if (!list.length) return [{ type: 'text', text: 'opencode_models: no models returned' }]
|
|
198
|
+
const lines = ['opencode models (' + list.length + '):']
|
|
199
|
+
for (const m of list.slice(0, 40)) lines.push(' ' + m.id + (m.free ? ' [free]' : ''))
|
|
200
|
+
if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
|
|
201
|
+
const free = list.filter((m) => m.free).length
|
|
202
|
+
lines.push('free: ' + free + ' / ' + list.length)
|
|
203
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export default function defineProvider(deps = {}) {
|
|
207
|
+
const env = deps.env || {}
|
|
208
|
+
let host = null
|
|
209
|
+
let settingsRef = { binPath: '', defaultModel: '', defaultAgent: '' }
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 取当前生效的偏好设置(宿主经 hostOpts.getSettings 注入;无则退回 env/默认)。
|
|
213
|
+
*/
|
|
214
|
+
function currentSettings() {
|
|
215
|
+
try {
|
|
216
|
+
if (host && typeof host.getSettings === 'function') {
|
|
217
|
+
const s = host.getSettings('opencode')
|
|
218
|
+
if (s && typeof s === 'object') return Object.assign({}, settingsRef, s)
|
|
219
|
+
}
|
|
220
|
+
} catch (e) { }
|
|
221
|
+
return settingsRef
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const descriptor = {
|
|
225
|
+
id: 'opencode', label: 'OPENCODE', presetId: 'opencode-first', defaultBackend: null, defaultMode: 'auto',
|
|
226
|
+
exeName: 'opencode-cli', binEnvVars: ['OPENCODE_BIN', 'OPENCODE_CLI'], unavailableStatus: 'OPENCODE_UNAVAILABLE',
|
|
227
|
+
|
|
228
|
+
// 单后端:opencode 自身选择 provider/model,桥不引入第二后端概念。
|
|
229
|
+
backends() { return null },
|
|
230
|
+
normalizeBackend() { return null },
|
|
231
|
+
resolveBackend(args, target, preferred) { return null },
|
|
232
|
+
|
|
233
|
+
// 实测 flag 集(V2.0.29)。
|
|
234
|
+
buildArgv(exePrefix, args, opts) {
|
|
235
|
+
const o = opts || {}
|
|
236
|
+
const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
|
|
237
|
+
argv.push('run', String(args.prompt), '--format', 'json')
|
|
238
|
+
if (args.autoApprove !== false) argv.push('--auto')
|
|
239
|
+
const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
|
|
240
|
+
// 模型优先级:调用参数 > 设置面板 defaultModel > 不传(交给 opencode 自身默认)。
|
|
241
|
+
// 用户要求「不设默认 → 由 opencode 自决」,故 settingsRef.defaultModel 为空时不落地 --model。
|
|
242
|
+
const model = args.model || currentSettings().defaultModel || ''
|
|
243
|
+
if (model) argv.push('--model', String(model))
|
|
244
|
+
const agent = args.agent || currentSettings().defaultAgent || ''
|
|
245
|
+
if (agent) argv.push('--agent', String(agent))
|
|
246
|
+
if (args.title) argv.push('--title', String(args.title))
|
|
247
|
+
if (Array.isArray(args.files)) for (const f of args.files) { if (f) argv.push('--file', String(f)) }
|
|
248
|
+
if (args.sessionId) argv.push('--session', String(args.sessionId))
|
|
249
|
+
else if (args.continueLatest) argv.push('--continue')
|
|
250
|
+
return { argv, mode: 'auto', timeoutSec }
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// JSONL(每行一个 JSON 对象)。事件 type 取自 CLI 源码 emit 全集(实测一致):
|
|
254
|
+
// step_start / text / reasoning / tool_use / step_finish / error。
|
|
255
|
+
// ⚠️ 不存在 result/complete/init 事件 —— 终止判据是「进程退出码」,不是某个终态事件。
|
|
256
|
+
// ⚠️ 文本与统计在【不同】事件里:text.part.text 是回复正文,step_finish.part 只有
|
|
257
|
+
// cost/tokens。若直接取 step_finish 会得到「成功但正文为空」的结果。
|
|
258
|
+
// ⚠️ 因此必须【正向】扫完整个流再决策:倒序会在读到 text 之前就命中末尾的
|
|
259
|
+
// step_finish 并返回,回填永远为空(第一版实现的真实 bug)。
|
|
260
|
+
// 实测一次真实调用只产生 step_start + text(无 step_finish)—— 末段 text 兜底
|
|
261
|
+
// 是活的必经分支,不是死代码。
|
|
262
|
+
// 决策优先级:error > step_finish > 末段 text > 末条事件;命中前两者时把末段文本
|
|
263
|
+
// 回填进 part.text,让 buildResult 统一从 part.text 读正文。
|
|
264
|
+
// 无 JSON 时返回 null,由 buildResult 走纯文本兜底(绝不假造 status)。
|
|
265
|
+
parseJson(stdoutText) {
|
|
266
|
+
const trimmed = String(stdoutText || '').trim()
|
|
267
|
+
if (!trimmed) return null
|
|
268
|
+
const lines = trimmed.split(/\r?\n/).filter(Boolean)
|
|
269
|
+
let lastText = null
|
|
270
|
+
let lastTextStr = ''
|
|
271
|
+
let terminal = null
|
|
272
|
+
let fallback = null
|
|
273
|
+
for (const raw of lines) {
|
|
274
|
+
const ln = raw.trim()
|
|
275
|
+
if (!ln.startsWith('{')) continue
|
|
276
|
+
let obj
|
|
277
|
+
try { obj = JSON.parse(ln) } catch (e) { continue }
|
|
278
|
+
if (!obj || typeof obj !== 'object') continue
|
|
279
|
+
const part = obj.part && typeof obj.part === 'object' ? obj.part : null
|
|
280
|
+
if (obj.type === 'text' && part && typeof part.text === 'string') {
|
|
281
|
+
lastText = obj
|
|
282
|
+
lastTextStr = part.text
|
|
283
|
+
} else if (obj.type === 'error') {
|
|
284
|
+
terminal = obj
|
|
285
|
+
} else if (obj.type === 'step_finish') {
|
|
286
|
+
if (!terminal || terminal.type !== 'error') terminal = obj
|
|
287
|
+
}
|
|
288
|
+
fallback = obj
|
|
289
|
+
}
|
|
290
|
+
if (terminal) {
|
|
291
|
+
const part = terminal.part && typeof terminal.part === 'object' ? terminal.part : null
|
|
292
|
+
if (lastTextStr && !(part && typeof part.text === 'string')) {
|
|
293
|
+
terminal.part = Object.assign({}, part || {}, { text: lastTextStr })
|
|
294
|
+
}
|
|
295
|
+
return terminal
|
|
296
|
+
}
|
|
297
|
+
return lastText || fallback
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// 统一结果对象(契约 §1 buildResult 行)。
|
|
301
|
+
// 事件负载一律在 part 里(CLI emit: {type,timestamp,sessionID,...data},data = {part})。
|
|
302
|
+
// 终止判据 = exit code(0 成功 / 1 错误 / 130 SIGINT),不存在 result 事件可依赖。
|
|
303
|
+
buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
|
|
304
|
+
const exitCode = outcome ? outcome.exitCode : null
|
|
305
|
+
const stderr = stderrText ? String(stderrText).slice(-2000) : ''
|
|
306
|
+
if (parsed && typeof parsed === 'object') {
|
|
307
|
+
const sessionId = typeof parsed.sessionID === 'string' ? parsed.sessionID
|
|
308
|
+
: (typeof parsed.sessionId === 'string' ? parsed.sessionId : null)
|
|
309
|
+
const part = parsed.part && typeof parsed.part === 'object' ? parsed.part : {}
|
|
310
|
+
const errObj = parsed.error && typeof parsed.error === 'object' ? parsed.error : null
|
|
311
|
+
const dataObj = errObj && errObj.data && typeof errObj.data === 'object' ? errObj.data : null
|
|
312
|
+
const errMsg = dataObj && typeof dataObj.message === 'string' ? dataObj.message
|
|
313
|
+
: (errObj && typeof errObj.message === 'string' ? errObj.message
|
|
314
|
+
: (typeof parsed.error === 'string' ? parsed.error : ''))
|
|
315
|
+
let response = ''
|
|
316
|
+
if (typeof part.text === 'string') response = part.text
|
|
317
|
+
else if (typeof parsed.response === 'string') response = parsed.response
|
|
318
|
+
const isErrorEvent = parsed.type === 'error'
|
|
319
|
+
let status
|
|
320
|
+
if (exitCode === 130) status = 'ABORTED'
|
|
321
|
+
else if (isErrorEvent) status = 'ERROR'
|
|
322
|
+
else if (typeof parsed.status === 'string') status = parsed.status
|
|
323
|
+
else status = exitCode === 0 ? 'SUCCESS' : 'ERROR'
|
|
324
|
+
const tokens = part.tokens && typeof part.tokens === 'object' ? part.tokens : null
|
|
325
|
+
let totalTokens = null
|
|
326
|
+
if (tokens) {
|
|
327
|
+
if (typeof tokens.total === 'number') totalTokens = tokens.total
|
|
328
|
+
else if (typeof tokens.totalTokens === 'number') totalTokens = tokens.totalTokens
|
|
329
|
+
else {
|
|
330
|
+
let sum = 0
|
|
331
|
+
let any = false
|
|
332
|
+
for (const k of Object.keys(tokens)) {
|
|
333
|
+
if (typeof tokens[k] === 'number') { sum += tokens[k]; any = true }
|
|
334
|
+
}
|
|
335
|
+
if (any) totalTokens = sum
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
ok: exitCode === 0 && !isErrorEvent,
|
|
340
|
+
status,
|
|
341
|
+
response,
|
|
342
|
+
sessionId,
|
|
343
|
+
durationSeconds: null,
|
|
344
|
+
numTurns: null,
|
|
345
|
+
totalTokens,
|
|
346
|
+
exitCode, mode, backend: backend || null,
|
|
347
|
+
stderr: (stderr ? stderr + ' ' : '') + errMsg,
|
|
348
|
+
cost: typeof part.cost === 'number' ? part.cost : null
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const text = String(stdoutText || '').trim()
|
|
352
|
+
return {
|
|
353
|
+
ok: exitCode === 0,
|
|
354
|
+
status: exitCode === 0 ? 'SUCCESS' : 'ERROR',
|
|
355
|
+
response: text,
|
|
356
|
+
sessionId: null,
|
|
357
|
+
durationSeconds: null, numTurns: null, totalTokens: null,
|
|
358
|
+
exitCode, mode, backend: backend || null, stderr,
|
|
359
|
+
rawStdout: text.slice(-2000)
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
// 实时步骤折叠(写 project.current/trail)。
|
|
364
|
+
// tool_use.part = { tool:"bash", state:{status,input,output,title,time}, … }
|
|
365
|
+
// text.part = { text:"…", time:{start,end} }
|
|
366
|
+
foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
|
|
367
|
+
if (!ev || typeof ev !== 'object') return
|
|
368
|
+
const type = ev.type
|
|
369
|
+
if (!type) return
|
|
370
|
+
const part = ev.part && typeof ev.part === 'object' ? ev.part : {}
|
|
371
|
+
p.updatedAt = Date.now()
|
|
372
|
+
if (type === 'tool_use') {
|
|
373
|
+
const tool = part.tool || part.name || 'tool'
|
|
374
|
+
const st = part.state && typeof part.state === 'object' ? part.state : {}
|
|
375
|
+
const args = summarizeArgs(st.input || part.input)
|
|
376
|
+
const state = st.status === 'completed' ? 'DONE' : (st.status === 'failed' ? 'ERROR' : 'ACTIVE')
|
|
377
|
+
const entry = { stepIndex: p.trail.length, state: state, tool: tool, args: args || null, at: nowIso() }
|
|
378
|
+
p.trail.push(entry)
|
|
379
|
+
const cap = maxTrail || 12
|
|
380
|
+
if (p.trail.length > cap) p.trail.shift()
|
|
381
|
+
if (p.current) p.current = null
|
|
382
|
+
} else if (type === 'text' || type === 'reasoning') {
|
|
383
|
+
const delta = typeof part.text === 'string' ? part.text : ''
|
|
384
|
+
if (!delta) return
|
|
385
|
+
const capLen = maxArgLen || 120
|
|
386
|
+
p.current = { tool: type === 'reasoning' ? 'reasoning' : 'agent_response', args: { text_delta: String(delta).slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
|
|
387
|
+
} else if (type === 'step_finish') {
|
|
388
|
+
if (p.current) p.current = null
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
isTransientCliError(res) {
|
|
393
|
+
if (!res || res.ok) return false
|
|
394
|
+
if (isLimited(res)) return false
|
|
395
|
+
return res.status === 'ERROR_DURING_EXECUTION'
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
failureHint(res) {
|
|
399
|
+
if (!res || res.ok) return ''
|
|
400
|
+
const st = String(res.status || '')
|
|
401
|
+
if (st === 'OPENCODE_UNAVAILABLE') return '[诊断] 未找到 opencode 可执行文件:请安装 OpenCode(桌面版自带 CLI),或在 CLI Bridge 设置面板填写 opencode 可执行文件路径。'
|
|
402
|
+
const hay = String(res.stderr || '') + ' ' + String(res.response || '')
|
|
403
|
+
if (/Incorrect API key|invalid_api_key|401|unauthorized|not logged|login/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
|
|
404
|
+
return '[诊断] opencode 凭据问题(未登录 / API key 无效):请让用户执行 `opencode auth login` 登录 provider。这不是限流。'
|
|
405
|
+
}
|
|
406
|
+
if (/No models available|no models|model not found|Unknown provider|not found.*model/i.test(hay)) {
|
|
407
|
+
return '[诊断] opencode 没有可用模型或模型名无效:先用 opencode_models(或 `opencode models`)列出可用模型,再传 model。'
|
|
408
|
+
}
|
|
409
|
+
if (/quota|额度|exhausted|insufficient|credit|balance/i.test(hay)) {
|
|
410
|
+
return '[诊断] opencode 额度可能耗尽:换一个(优先 -free)模型,或改用原生工具完成本任务。'
|
|
411
|
+
}
|
|
412
|
+
return ''
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
isSuccessStatus(st) { return st === 'SUCCESS' },
|
|
416
|
+
|
|
417
|
+
tools: {
|
|
418
|
+
run: { name: 'opencode_run', description: 'Dispatch a coding/build/debug/investigation task to the local opencode agent CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls opencode: it always runs non-interactively with permissions auto-approved (opencode never blocks on a prompt). Use read-only native tools only for quick lookups and for final build/test verification. Runs in the background by default (returns a jobId immediately; collect with job_output); set background=false to block for the final answer.', inputSchema: RUN_PARAMS },
|
|
419
|
+
continue: { name: 'opencode_continue', description: 'Continue an existing opencode session with a follow-up prompt, reusing opencode context. Pass sessionId from a prior opencode_run result, or set latest=true to continue the most recent opencode session. Same DSH-controlled, no-prompt execution as opencode_run.', inputSchema: CONTINUE_PARAMS },
|
|
420
|
+
status: { name: 'opencode_status', description: 'Read a live snapshot of what the local opencode 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 (tools executed, done/error), and the last completed run status + session id. Optional cwd filters to a single project. Call this to check on an in-flight opencode_run/opencode_continue without waiting for it to finish.', inputSchema: STATUS_PARAMS }
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
// opencode_models:宿主有 spawn 能力时注册(preset 注入 deps.process / MCP 注入
|
|
424
|
+
// deps.nodeChild;dynamic 沙箱形态省略)。
|
|
425
|
+
extraTools: (deps.process || deps.nodeChild) ? [{
|
|
426
|
+
name: 'opencode_models',
|
|
427
|
+
description: MODELS_DESCRIPTION,
|
|
428
|
+
inputSchema: MODELS_PARAMS,
|
|
429
|
+
render: renderModels,
|
|
430
|
+
async logic(args) {
|
|
431
|
+
return await descriptor.listModels(args || {})
|
|
432
|
+
}
|
|
433
|
+
}] : [],
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 列出可用模型(三形态共用)。真实执行 `opencode models`;
|
|
437
|
+
* CLI 缺失/未登录 → { ok:false, error }(绝不凭空造列表)。
|
|
438
|
+
*/
|
|
439
|
+
async listModels(args) {
|
|
440
|
+
const a = args || {}
|
|
441
|
+
const child = deps.nodeChild
|
|
442
|
+
if (!child || typeof child.spawn !== 'function') {
|
|
443
|
+
// preset 形态:宿主 subprocess(attach 后可用)。
|
|
444
|
+
if (!(host && host.subprocess)) return { ok: false, error: 'no spawn capability in this host form' }
|
|
445
|
+
}
|
|
446
|
+
const prefix = resolveExePrefix(env, currentSettings().binPath)
|
|
447
|
+
const argv = prefix.slice(1).concat(['models'])
|
|
448
|
+
const run = child && typeof child.spawn === 'function'
|
|
449
|
+
? spawnModelsNode(child, prefix[0], argv)
|
|
450
|
+
: spawnModelsHost(argv)
|
|
451
|
+
let res
|
|
452
|
+
try { res = await run } catch (e) { return { ok: false, error: String(e && e.message || e) } }
|
|
453
|
+
if (!res.ok) return res
|
|
454
|
+
let list = res.models
|
|
455
|
+
if (a.provider) {
|
|
456
|
+
const pv = String(a.provider).toLowerCase()
|
|
457
|
+
list = list.filter((m) => String(m.provider).toLowerCase() === pv)
|
|
458
|
+
}
|
|
459
|
+
if (a.freeOnly) list = list.filter((m) => m.free)
|
|
460
|
+
// 免费模型优先展示(用户要求:优先用性能较好的免费模型),其次按字母序。
|
|
461
|
+
list.sort((x, y) => (x.free === y.free ? String(x.id).localeCompare(String(y.id)) : (x.free ? -1 : 1)))
|
|
462
|
+
return { ok: true, models: list, count: list.length }
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
// 设置分区:用户可选「用哪个 opencode(exe 路径)」+「默认模型」+「默认 agent」。
|
|
466
|
+
settings: {
|
|
467
|
+
keys: ['binPath', 'defaultModel', 'defaultAgent'],
|
|
468
|
+
defaults: () => ({ binPath: '', defaultModel: '', defaultAgent: '' }),
|
|
469
|
+
normalize: (raw) => {
|
|
470
|
+
const r = raw && typeof raw === 'object' ? raw : {}
|
|
471
|
+
const out = { binPath: '', defaultModel: '', defaultAgent: '' }
|
|
472
|
+
for (const k of ['binPath', 'defaultModel', 'defaultAgent']) {
|
|
473
|
+
if (typeof r[k] === 'string') out[k] = String(r[k]).trim()
|
|
474
|
+
}
|
|
475
|
+
return out
|
|
476
|
+
},
|
|
477
|
+
meta: (env2) => {
|
|
478
|
+
const e = env2 && typeof env2 === 'object' ? env2 : {}
|
|
479
|
+
const s = currentSettings()
|
|
480
|
+
// 下拉选项:实时 `opencode models`(失败则退化为空 → 面板仍可自由输入)。
|
|
481
|
+
const models = fetchModelsSync(e, s.binPath, 8000)
|
|
482
|
+
const options = models.map((m) => ({ value: m.id, label: m.id + (m.free ? ' [free]' : '') }))
|
|
483
|
+
// 默认给「可用 exe 路径」的下拉候选(env 覆盖 > 桌面版探测)。
|
|
484
|
+
const exeCandidates = []
|
|
485
|
+
if (e.OPENCODE_BIN || e.OPENCODE_CLI) exeCandidates.push(String(e.OPENCODE_BIN || e.OPENCODE_CLI))
|
|
486
|
+
for (const p of candidateExePaths(e)) if (exeCandidates.indexOf(p) < 0) exeCandidates.push(p)
|
|
487
|
+
return {
|
|
488
|
+
titleSuffix: 'OpenCode',
|
|
489
|
+
fields: [
|
|
490
|
+
{
|
|
491
|
+
key: 'binPath', label: 'opencode 可执行文件路径', type: 'select', allowCustom: true,
|
|
492
|
+
options: exeCandidates.map((p) => ({ value: p, label: p })),
|
|
493
|
+
placeholder: '留空 = 自动探测桌面版 / PATH',
|
|
494
|
+
note: '检测到 ' + exeCandidates.length + ' 个候选;也可手动粘贴任意路径(含 .mjs 会经 node 启动)。'
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
|
|
498
|
+
options: options,
|
|
499
|
+
placeholder: '留空 = 由 opencode 自身配置决定',
|
|
500
|
+
note: options.length
|
|
501
|
+
? '共 ' + options.length + ' 个可用模型,免费模型已排在前面([free])。调用时传 model 可覆盖本项。'
|
|
502
|
+
: '未能读取模型列表(CLI 未安装/未登录/超时)—— 仍可手动输入 provider/model。'
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
key: 'defaultAgent', label: '默认 agent', type: 'select', allowCustom: true,
|
|
506
|
+
options: [{ value: 'build', label: 'build(默认,全权限)' }, { value: 'plan', label: 'plan(只读)' }],
|
|
507
|
+
placeholder: '留空 = opencode 默认 agent',
|
|
508
|
+
note: 'opencode 只接受 primary agent;subagent 名会被 opencode 自身拒绝并回退。'
|
|
509
|
+
}
|
|
510
|
+
]
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
diagnose: (settings, env2) => {
|
|
514
|
+
const s = settings && typeof settings === 'object' ? settings : {}
|
|
515
|
+
const e = env2 && typeof env2 === 'object' ? env2 : {}
|
|
516
|
+
const bits = []
|
|
517
|
+
const found = candidateExePaths(e)
|
|
518
|
+
if (s.binPath) bits.push('bin=设置:' + s.binPath)
|
|
519
|
+
else if (e.OPENCODE_BIN || e.OPENCODE_CLI) bits.push('bin=env:' + (e.OPENCODE_BIN || e.OPENCODE_CLI))
|
|
520
|
+
else if (found.length) bits.push('bin=桌面版:' + found[0].split(/[\\/]/).slice(-3).join('/'))
|
|
521
|
+
else bits.push('bin=PATH:opencode-cli')
|
|
522
|
+
bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(opencode 自决)')
|
|
523
|
+
if (s.defaultAgent) bits.push('agent=' + s.defaultAgent)
|
|
524
|
+
return bits.join(' · ')
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
mcp: { version: '0.2.2' }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// MCP/测试形态:真 child_process。
|
|
532
|
+
function spawnModelsNode(child, exe, argv) {
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
let c
|
|
535
|
+
try { c = child.spawn(exe, argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) }
|
|
536
|
+
catch (e) { resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }); return }
|
|
537
|
+
let out = ''
|
|
538
|
+
let err = ''
|
|
539
|
+
const timer = setTimeout(() => { try { c.kill() } catch (e) {} }, 15000)
|
|
540
|
+
c.stdout.on('data', (d) => { out += d })
|
|
541
|
+
c.stderr.on('data', (d) => { err += d })
|
|
542
|
+
c.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }) })
|
|
543
|
+
c.on('close', (code) => {
|
|
544
|
+
clearTimeout(timer)
|
|
545
|
+
if (code !== 0) { resolve({ ok: false, error: '`opencode models` exited ' + code + ': ' + String(err || out).slice(0, 300) }); return }
|
|
546
|
+
const list = parseModelList(out)
|
|
547
|
+
if (!list.length) { resolve({ ok: false, error: 'no models parsed from output: ' + String(out).slice(0, 200) }); return }
|
|
548
|
+
resolve({ ok: true, models: list })
|
|
549
|
+
})
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// preset 形态:DSH subprocess。
|
|
554
|
+
async function spawnModelsHost(argv) {
|
|
555
|
+
try {
|
|
556
|
+
const spec = { argv: [resolveExePrefix(env, currentSettings().binPath)[0]].concat(argv), stdio: { stdin: 'ignore', stdout: { maxBytes: 1000000 }, stderr: { maxBytes: 200000 } }, graceMs: 5000 }
|
|
557
|
+
if (deps.process && typeof deps.process.cwd === 'function') spec.cwd = deps.process.cwd()
|
|
558
|
+
const handle = host.subprocess.spawn(spec)
|
|
559
|
+
const outcome = await handle.done
|
|
560
|
+
const stdoutText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
561
|
+
const stderrText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
|
|
562
|
+
if (!outcome || outcome.exitCode !== 0) return { ok: false, error: '`opencode models` exited ' + (outcome && outcome.exitCode) + ': ' + String(stderrText || stdoutText).slice(0, 300) }
|
|
563
|
+
const list = parseModelList(stdoutText)
|
|
564
|
+
if (!list.length) return { ok: false, error: 'no models parsed from output: ' + String(stdoutText).slice(0, 200) }
|
|
565
|
+
return { ok: true, models: list }
|
|
566
|
+
} catch (e) { return { ok: false, error: String(e && e.message || e) } }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
descriptor.runToolLogic = async function (args, exec) {
|
|
570
|
+
const a = args || {}
|
|
571
|
+
const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
|
|
572
|
+
return this.runner.coreExecute(normalized, exec)
|
|
573
|
+
}
|
|
574
|
+
descriptor.continueToolLogic = async function (args, exec) {
|
|
575
|
+
const a = args || {}
|
|
576
|
+
const mapped = {
|
|
577
|
+
prompt: a.prompt, model: a.model, agent: a.agent,
|
|
578
|
+
cwd: a.cwd, files: a.files, timeoutSec: a.timeoutSec, autoApprove: a.autoApprove,
|
|
579
|
+
background: a.background === undefined ? true : a.background
|
|
580
|
+
}
|
|
581
|
+
const sid = a.sessionId || null
|
|
582
|
+
if (sid) mapped.sessionId = sid
|
|
583
|
+
else if (a.latest) mapped.continueLatest = true
|
|
584
|
+
return this.runner.coreExecute(mapped, exec)
|
|
585
|
+
}
|
|
586
|
+
descriptor.statusToolLogic = function (args) {
|
|
587
|
+
const a = args || {}
|
|
588
|
+
const snap = this.engine.statusSnapshot()
|
|
589
|
+
if (a.cwd) {
|
|
590
|
+
const key = String(a.cwd)
|
|
591
|
+
snap.projects = snap.projects.filter((p) => p.cwd === key)
|
|
592
|
+
const g = snap.projects[0]
|
|
593
|
+
if (g) {
|
|
594
|
+
Object.assign(snap, {
|
|
595
|
+
state: g.state, running: g.running, current: g.current, trail: g.trail,
|
|
596
|
+
lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
|
|
597
|
+
lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return snap
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
descriptor.attach = function (hostOpts) {
|
|
605
|
+
host = hostOpts || {}
|
|
606
|
+
// 宿主注入 getSettings 时才能把面板偏好带进 buildArgv(preset/dynamic 形态)。
|
|
607
|
+
try {
|
|
608
|
+
if (typeof host.getSettings === 'function') {
|
|
609
|
+
const s = host.getSettings('opencode')
|
|
610
|
+
if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
|
|
611
|
+
}
|
|
612
|
+
} catch (e) { }
|
|
613
|
+
this.engine = createStatusEngine({ provider: this, publish: host.publish })
|
|
614
|
+
const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
|
|
615
|
+
this.runner = runner
|
|
616
|
+
return runner
|
|
617
|
+
}
|
|
618
|
+
return descriptor
|
|
619
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// providers/registry.mjs — provider 注册表(keystone,Lead 单作者)。
|
|
2
|
+
//
|
|
3
|
+
// 静态导入 provider 描述符工厂;指示灯 host 半、统一 MCP 服务器、verify 都从这里
|
|
4
|
+
// 取数。描述符契约见 PROVIDER-CONTRACT.md §1。新增 provider = 加一行 import + 一项 defs。
|
|
5
|
+
//
|
|
6
|
+
// 单一 id 轴(契约 §3.1):本数组是全局唯一 provider 清单来源,调度面板、四形态门禁、
|
|
7
|
+
// preset 声明、MCP 工具面、status 视图全部由此派生,禁止第二处硬编码 id 列表。
|
|
8
|
+
import defineAgy from './agy.mjs'
|
|
9
|
+
import defineCodebuddy from './codebuddy.mjs'
|
|
10
|
+
import defineMimo from './mimo.mjs'
|
|
11
|
+
import defineOpencode from './opencode.mjs'
|
|
12
|
+
|
|
13
|
+
/** 稳定顺序(家级灯渲染顺序 = 此顺序;新增项一律追加在末尾,不重排既有顺序)。 */
|
|
14
|
+
export const PROVIDER_IDS = ['agy', 'codebuddy', 'mimo', 'opencode']
|
|
15
|
+
|
|
16
|
+
const defs = {
|
|
17
|
+
agy: defineAgy,
|
|
18
|
+
codebuddy: defineCodebuddy,
|
|
19
|
+
mimo: defineMimo,
|
|
20
|
+
opencode: defineOpencode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 构造某 provider 的描述符实例。
|
|
25
|
+
* @param {string} id PROVIDER_IDS 之一。
|
|
26
|
+
* @param {object} [deps] 宿主形态注入({ env, process, nodeFs, nodeChild, nodePath, log })。
|
|
27
|
+
* @returns descriptor(见 PROVIDER-CONTRACT.md §1)
|
|
28
|
+
*/
|
|
29
|
+
export function createProvider(id, deps) {
|
|
30
|
+
const factory = defs[id]
|
|
31
|
+
if (!factory) throw new Error('cli-bridge: unknown provider ' + JSON.stringify(id))
|
|
32
|
+
return factory(deps || {})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 轻量元信息(不构造描述符也能拿到,用于路由/展示层预建骨架)。 */
|
|
36
|
+
export function providerMeta(id) {
|
|
37
|
+
const d = createProvider(id, { env: {} })
|
|
38
|
+
return { id: d.id, label: d.label, presetId: d.presetId, defaultBackend: d.defaultBackend || null }
|
|
39
|
+
}
|