@luchenglong/cli-bridge 0.2.2 → 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.
@@ -0,0 +1,1443 @@
1
+ // dynamic/opencode.host.template.mjs — opencode 动态沙箱宿主模板(cordis_define code.host 源)。
2
+ //
3
+ // 构建(node scripts/build.mjs --only=opencode)把 core/bridge-kit.mjs 与
4
+ // providers/opencode.mjs 分别内联进下方两个占位标记行(各占一行、行内仅此标记),
5
+ // 生成 dynamic/opencode.host.js。注意:标记字面量绝不能出现在本注释里 —— 构建做全局
6
+ // 替换,注释中出现同样会被替换成正文代码而炸掉语法。
7
+ // 沙箱纪律(旧仓 dynamic/host.js 头注 verbatim 精神):
8
+ // - 禁 import/require、无 TypeScript;无 AbortController/process 裸引用/Buffer/原生
9
+ // 定时器 —— process 只经 typeof 守卫探测;宿主能力全部来自注入的 harness.*/ctx。
10
+ // - 顶层 return { inject, apply } 为动态宿主约定形态(生成物是 .js/CJS 包装,顶层
11
+ // return 合法;本模板文件是 .mjs,不要直接 node --check 模板本身,只 check 生成物)。
12
+ // - 无 ctx.emit:状态经 ctx.get('cliBridgeCollector').mergeSnapshot('opencode', snap)
13
+ // 推给家级灯;动态形态不宣告 mode(旧语义:普通模式灯仅在调用时临时显示)。
14
+ // core/bridge-kit.mjs — CLI Bridge 单一事实来源:通用执行/状态骨架。
15
+ //
16
+ // 深度重构核心(合并 agy-first-bridge / codebuddy-first-bridge / mimo-first-bridge):
17
+ // 三条 bridge 的 host 半此前各自复制了一份几乎相同的「状态引擎 + 行流 + 执行编排
18
+ // + 限流回退弹窗 + MCP 快照上报 + 展示层」。本文件把这些**与 provider 无关**的骨架
19
+ // 收敛成一份参数化实现,每个 provider 只提供一个 descriptor(见 providers/*.mjs 与
20
+ // PROVIDER-CONTRACT.md),由 createRunner/createStatusEngine 回调它完成差异化步骤。
21
+ //
22
+ // 骨架覆盖:
23
+ // - 纯函数:projectName / clampInt / shortLabel / summarizeArgs / endpointHost
24
+ // - 限流/网络判定:LIMIT_RE + isLimited(provider 可收窄)
25
+ // - 半行安全行流:createLineStream
26
+ // - 按项目(cwd)聚合、按 provider 分桶的状态引擎:createStatusEngine
27
+ // - 执行编排(前台/后台/超时守卫/瞬时错误重试/限流三选一弹窗/回退):createRunner
28
+ // - 工具结果与状态的人类可读渲染:renderResult / renderStatus
29
+ // - MCP 子进程快照上报:buildMcpBridgePayload / normalizeMcpBridge(provider 分文件)
30
+ // - 回退弹窗三选一文案(provider 名注入)
31
+ //
32
+ // 约束(沙箱与真实 Node 共用,勿违反):
33
+ // - 本模块**不 import 任何模块**、不用 process/Buffer/原生定时器/AbortController;
34
+ // 宿主能力(subprocess.spawn / ctx.interval / ctx.timeout / jobs / userQuestions)
35
+ // 全部经 createRunner 的 opts 注入,取消用 exec.signal + handle.terminate()。
36
+ // - 不序列化任何 DSH 活对象;跨 RPC 只传无 Host 引用的纯 JSON 叶子字段。
37
+
38
+ // ── 常量 ──────────────────────────────────────────────────────────────────────
39
+
40
+ const MAX_TRAIL = 12
41
+ const MAX_ARG_LEN = 120
42
+ const MAX_PROJECTS = 24
43
+ const MAX_SESSIONS = 256
44
+
45
+ // 限流/网络类错误的宽松正则(英文 + 中文)。provider descriptor 可用自己的收窄版覆盖。
46
+ const LIMIT_RE =
47
+ /rate.?limit|ratelimit|\b429\b|too many|quota|insufficient|credit|balance|exhausted|exceed|\bnetwork\b|offline|ENETUNREACH|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|\btimeouts?\b|timed out|unavailable|\b50[023]\b|\b40[13]\b|unauthorized|invalid api|api key|proxy|socket|tls|ssl|\bdns\b|网络|超时|限流|流量|受限|配额|金额|余额|额度|认证|连接|断开/i
48
+
49
+ // 家级插件与 MCP 子进程之间的快照文件:{dsh-home}/cli-bridge-mcp-<provider>.json
50
+ const MCP_BRIDGE_PREFIX = 'cli-bridge-mcp-'
51
+ const MCP_BRIDGE_STALE_MS = 90000
52
+ function mcpBridgeFileName(providerId) { return MCP_BRIDGE_PREFIX + providerId + '.json' }
53
+
54
+ // ── 纯函数 ────────────────────────────────────────────────────────────────────
55
+
56
+ function projectName(cwd) {
57
+ const s = String(cwd || '')
58
+ const parts = s.split(/[\\/]/).filter(Boolean)
59
+ return parts.length ? parts[parts.length - 1] : s
60
+ }
61
+
62
+ function clampInt(v, def, min, max) {
63
+ const n = Number(v)
64
+ if (!Number.isFinite(n)) return def
65
+ const i = Math.floor(n)
66
+ if (i < min) return min
67
+ if (i > max) return max
68
+ return i
69
+ }
70
+
71
+ function shortLabel(prompt) {
72
+ const s = String(prompt || '').replace(/\s+/g, ' ').trim()
73
+ return s.length > 80 ? s.slice(0, 77) + '...' : s
74
+ }
75
+
76
+ // 工具入参摘要(状态灯/活动面板)。返回可无损 JSON 化的数据;undefined 值跳过
77
+ // (DSH tool 渲染层不接受 undefined)。
78
+ function summarizeArgs(parameters) {
79
+ if (!parameters || typeof parameters !== 'object') return null
80
+ const out = {}
81
+ for (const k of Object.keys(parameters)) {
82
+ let v = parameters[k]
83
+ if (v === undefined) continue
84
+ if (typeof v === 'string' && v.length > MAX_ARG_LEN) v = v.slice(0, MAX_ARG_LEN) + '…'
85
+ out[k] = v
86
+ }
87
+ return out
88
+ }
89
+
90
+ function endpointHost(url) {
91
+ const m = String(url || '').match(/^https?:\/\/([^/]+)/i)
92
+ return m ? m[1] : ''
93
+ }
94
+
95
+ // 限流/网络类失败(要问用户,重试可能烧额度)。
96
+ function isLimited(res) {
97
+ if (!res || res.ok) return false
98
+ // AUTH_REQUIRED 是配置问题(缺凭据),弹回退/重试无意义 —— 直接给可操作指引。
99
+ if (res.status === 'AUTH_REQUIRED') return false
100
+ if (res.status === 'SPAWN_ERROR' || res.status === 'CLI_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
101
+ const hay = String(res.stderr || '') + ' ' + String(res.status || '')
102
+ // 端点域与登录域不匹配的 401:需要用户换登录域,不是限流。
103
+ if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
104
+ return LIMIT_RE.test(hay)
105
+ }
106
+
107
+ // ── 半行安全的行流(跨 chunk 的 NDJSON 行拼接)───────────────────────────────
108
+ function createLineStream(consumeLine) {
109
+ let pending = ''
110
+ function pushChunk(text) {
111
+ if (typeof text !== 'string' || !text) return
112
+ const lines = (pending + text).split(/\r?\n/)
113
+ pending = lines.pop() || ''
114
+ for (const ln of lines) consumeLine(ln)
115
+ }
116
+ function flush() {
117
+ const rest = pending
118
+ pending = ''
119
+ if (rest) consumeLine(rest)
120
+ }
121
+ return { pushChunk, flush }
122
+ }
123
+
124
+ // ── 状态引擎(按 provider + 项目目录聚合的运行状态表)─────────────────────────
125
+ // opts: { provider: <descriptor>, publish(snapshot) }
126
+ // 快照结构(顶层为全局聚合,向后兼容单 provider 消费者):
127
+ // { provider, state, running, lastStatus, lastAt, lastSessionId, lastBackend,
128
+ // fallbackActive, current, trail, runs, totalTokens, updatedAt,
129
+ // projects:[{ cwd,name,provider,state,running,current,trail,lastStatus,lastAt,
130
+ // lastSessionId,lastBackend,fallbackActive,runs,totalTokens,updatedAt }] }
131
+ function createStatusEngine(opts) {
132
+ const o = opts || {}
133
+ const provider = o.provider
134
+ const providerId = provider && provider.id ? provider.id : 'cli'
135
+ const publish = typeof o.publish === 'function' ? o.publish : null
136
+ const projects = Object.create(null)
137
+ // sessionId -> { cwd, backend, provider }。会话按 provider 归属(登录域互斥),
138
+ // 续接时一次解析出 cwd 与 backend。容量上限 MAX_SESSIONS,FIFO 淘汰。
139
+ const sessions = Object.create(null)
140
+
141
+ function ensureProject(cwd) {
142
+ const key = String(cwd || '')
143
+ let p = projects[key]
144
+ if (!p) {
145
+ if (Object.keys(projects).length >= MAX_PROJECTS) {
146
+ const idle = Object.keys(projects).filter((k) => projects[k].running === 0)
147
+ const pool = idle.length ? idle : Object.keys(projects)
148
+ const victim = pool.sort((a, b) => projects[a].updatedAt - projects[b].updatedAt)[0]
149
+ delete projects[victim]
150
+ }
151
+ p = { cwd: key, name: projectName(key), provider: providerId, state: 'idle', running: 0, lastStatus: null, lastAt: 0, lastSessionId: null, lastBackend: null, lastOk: false, lastFailed: false, fallbackActive: false, current: null, trail: [], runs: 0, totalTokens: 0, updatedAt: 0 }
152
+ projects[key] = p
153
+ }
154
+ return p
155
+ }
156
+
157
+ function globalStatus() {
158
+ const list = Object.keys(projects).map((k) => projects[k])
159
+ let running = 0, lastStatus = null, lastAt = 0, lastSessionId = null, lastBackend = null, fallbackActive = false, current = null, trail = [], updatedAt = 0, runs = 0, totalTokens = 0
160
+ for (const p of list) {
161
+ running += p.running
162
+ if (p.updatedAt > updatedAt) updatedAt = p.updatedAt
163
+ if (p.running > 0 && !current && p.current) current = p.current
164
+ if (p.lastAt > lastAt) { lastAt = p.lastAt; lastStatus = p.lastStatus; lastSessionId = p.lastSessionId; lastBackend = p.lastBackend }
165
+ if (p.fallbackActive) fallbackActive = true
166
+ runs += p.runs || 0
167
+ totalTokens += p.totalTokens || 0
168
+ for (const e of p.trail) trail.push(e)
169
+ }
170
+ trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
171
+ trail = trail.slice(-MAX_TRAIL)
172
+ const state = running > 0 ? 'running' : (fallbackActive ? 'fallback' : (lastStatus ? (lastStatus === 'SUCCESS' ? 'ok' : 'failed') : 'idle'))
173
+ return { state, running, lastStatus, lastAt, lastSessionId, lastBackend, fallbackActive, current, trail, runs, totalTokens, updatedAt }
174
+ }
175
+
176
+ function statusSnapshot() {
177
+ const g = globalStatus()
178
+ const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
179
+ return Object.assign({ provider: providerId }, g, {
180
+ projects: list.map((p) => ({ cwd: p.cwd, name: p.name, provider: providerId, state: p.state, running: p.running, current: p.current, trail: p.trail.slice(-MAX_TRAIL), lastStatus: p.lastStatus, lastAt: p.lastAt, lastSessionId: p.lastSessionId, lastBackend: p.lastBackend, fallbackActive: p.fallbackActive, runs: p.runs || 0, totalTokens: p.totalTokens || 0, updatedAt: p.updatedAt }))
181
+ })
182
+ }
183
+
184
+ function emit() { if (publish) { try { publish(statusSnapshot()) } catch (e) {} } }
185
+
186
+ function begin(cwd) {
187
+ const p = ensureProject(cwd)
188
+ p.running += 1
189
+ p.state = 'running'
190
+ p.updatedAt = Date.now()
191
+ emit()
192
+ }
193
+
194
+ function end(res, cwd) {
195
+ const p = ensureProject(cwd)
196
+ p.running = Math.max(0, p.running - 1)
197
+ p.lastStatus = res ? res.status : null
198
+ p.lastAt = Date.now()
199
+ if (res && res.sessionId) {
200
+ p.lastSessionId = res.sessionId
201
+ sessions[res.sessionId] = { cwd: p.cwd, backend: res.backend || (provider && provider.defaultBackend), provider: providerId }
202
+ const keys = Object.keys(sessions)
203
+ if (keys.length > MAX_SESSIONS) delete sessions[keys[0]]
204
+ }
205
+ if (res && res.backend) p.lastBackend = res.backend
206
+ p.runs = (p.runs || 0) + 1
207
+ if (res && typeof res.totalTokens === 'number' && res.totalTokens > 0) p.totalTokens = (p.totalTokens || 0) + res.totalTokens
208
+ if (res && res.fallback) { p.fallbackActive = true; p.state = 'fallback' }
209
+ else if (p.running > 0) { p.state = 'running' }
210
+ else { p.state = res && res.ok ? 'ok' : 'failed'; p.lastOk = !!(res && res.ok); p.lastFailed = !(res && res.ok) }
211
+ p.current = null
212
+ p.updatedAt = Date.now()
213
+ emit()
214
+ }
215
+
216
+ // 流事件折叠为实时状态。provider 若有 stream-json,提供 foldStreamEvent(ev, project);
217
+ // 否则回退到 generic 形态(不产生实时步骤)。
218
+ function foldEvent(ev, cwd) {
219
+ const p = ensureProject(cwd)
220
+ p.updatedAt = Date.now()
221
+ if (provider && typeof provider.foldStreamEvent === 'function') {
222
+ try { provider.foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) } catch (e) {}
223
+ }
224
+ emit()
225
+ }
226
+
227
+ // 会话路由(cwd + backend 一次解析)。续接(--resume/--continue)时未给 cwd
228
+ // 则回落到该 session 所在项目的 cwd(否则换目录报找不到会话)。
229
+ function resolveTarget(args, fallbackCwd) {
230
+ const a = args || {}
231
+ let backend = null, cwd = null
232
+ if (a.sessionId) { const hit = sessions[a.sessionId]; if (hit && hit.provider === providerId) { backend = hit.backend; cwd = hit.cwd } }
233
+ if (a.cwd) {
234
+ cwd = String(a.cwd)
235
+ } else if (!cwd && (a.sessionId || a.continueLatest)) {
236
+ const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
237
+ if (a.sessionId) {
238
+ const projHit = list.find((p) => p.lastSessionId === a.sessionId)
239
+ if (projHit) { cwd = projHit.cwd; if (!backend) backend = projHit.lastBackend }
240
+ } else if (list.length && list[0].lastSessionId) { cwd = list[0].cwd; if (!backend) backend = list[0].lastBackend }
241
+ }
242
+ return { cwd: cwd || fallbackCwd, backend }
243
+ }
244
+
245
+ function resolveCwd(args, fallbackCwd) { return resolveTarget(args, fallbackCwd).cwd }
246
+
247
+ return { providerId, projects, sessions, ensureProject, globalStatus, statusSnapshot, begin, end, foldEvent, resolveCwd, resolveTarget }
248
+ }
249
+
250
+ // ── 回退弹窗三选一(provider 名注入)──────────────────────────────────────────
251
+ function fallbackLabels(provider) {
252
+ const bk = (provider && provider.id) || 'CLI'
253
+ return {
254
+ fallback: '使用 DSH 本地 API 配置(回退)',
255
+ retry: '重试 ' + bk + ' 一次',
256
+ cancel: '不回退(返回错误)'
257
+ }
258
+ }
259
+
260
+ // 失败结果的可行动指引;provider.failureHint 优先,未给则通用兜底。
261
+ function failureHint(res, provider) {
262
+ if (!res || res.ok) return ''
263
+ if (provider && typeof provider.failureHint === 'function') {
264
+ try { const h = provider.failureHint(res); if (h) return h } catch (e) {}
265
+ }
266
+ if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
267
+ if (res.status === 'PARSE_ERROR') return '[诊断] 未能从输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 CLI 版本。'
268
+ if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
269
+ return '[诊断] 认证失败(401):请求端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 CLI 登录后重试;这不是限流。'
270
+ }
271
+ return ''
272
+ }
273
+
274
+ function fallbackResult(res, mode, provider) {
275
+ const bk = (provider && provider.id) || 'CLI'
276
+ return Object.assign({}, res, { ok: false, fallback: true, status: 'FALLBACK_TO_DSH', response: '', stderr: (res && res.stderr ? res.stderr : '') + ' [user chose DSH fallback for ' + bk + ']' })
277
+ }
278
+
279
+ // ── 展示层 ────────────────────────────────────────────────────────────────────
280
+ function renderResult(value, provider) {
281
+ const v = value || {}
282
+ // backendLabel(codebuddy 旧语义 v1.1.0 verbatim:'workbuddy OK'/'codebuddy-intl OK'):
283
+ // 结果带具体 backend 时用 backend 标头;agy/mimo 旧包标头是硬编码 provider 名,不开此旗。
284
+ const bk = (provider && provider.backendLabel && v.backend) || (provider && provider.id) || v.backend || 'CLI'
285
+ if (v.background) return [{ type: 'text', text: bk + ' dispatched in background (mode=' + v.mode + '). jobId=' + v.jobId + '. Collect with job_output.' }]
286
+ if (v.fallback) return [{ type: 'text', text: bk + ' 回退:用户选择使用 DSH 本地 API 配置(原因 ' + v.reason + ')。请改用原生工具/本地模型完成本任务,不要再调 ' + bk + '。' }]
287
+ const head = bk + ' ' + (v.ok ? 'OK' : 'FAILED') + ' [status=' + v.status + ' mode=' + v.mode + (v.modelUsed ? ' model=' + v.modelUsed : '') + (v.retried ? ' retried=1' : '') + (v.sessionId ? ' session=' + v.sessionId : '') + (v.totalTokens != null ? ' tokens=' + v.totalTokens : '') + (v.durationSeconds != null ? ' ' + v.durationSeconds + 's' : '') + ']'
288
+ const hint = failureHint(v, provider)
289
+ let body = v.response ? v.response : (v.stderr ? '[stderr] ' + v.stderr : (v.rawStdout ? '[raw] ' + v.rawStdout : ''))
290
+ if (hint) body = (body ? body + '\n\n' : '') + hint
291
+ return [{ type: 'text', text: head + (body ? '\n\n' + body : '') }]
292
+ }
293
+
294
+ function renderStatus(value, provider) {
295
+ const v = value || {}
296
+ const bk = (provider && provider.id) || 'CLI'
297
+ const lines = []
298
+ lines.push(bk + ' status: ' + v.state + (v.running > 0 ? ' (' + v.running + ' running)' : '') + (v.projects && v.projects.length > 1 ? ' across ' + v.projects.length + ' projects' : '') + (v.totalTokens ? ' | total ' + v.runs + ' runs, ' + v.totalTokens + ' tokens' : ''))
299
+ const projList = (v.projects && v.projects.length) ? v.projects : null
300
+ if (projList) {
301
+ for (const p of projList) {
302
+ const cur = p.current ? (' step ' + p.current.stepIndex + ' → ' + p.current.tool + (p.current.args ? ' ' + JSON.stringify(p.current.args) : '')) : (p.running > 0 ? ' (starting / thinking)' : '')
303
+ const usage = (p.runs ? ' | total ' + p.runs + ' runs, ' + (p.totalTokens || 0) + ' tokens' : '')
304
+ const bkTag = (p.lastBackend && p.lastBackend !== bk) ? ' [' + p.lastBackend + ']' : ''
305
+ lines.push('· ' + p.name + ' [' + p.state + (p.running > 0 ? ' ×' + p.running : '') + ']' + bkTag + cur + (p.lastStatus ? ' | last=' + p.lastStatus + (p.lastSessionId ? ' ' + p.lastSessionId.slice(0, 8) : '') : '') + usage)
306
+ if (p.trail && p.trail.length) {
307
+ lines.push(' steps:')
308
+ for (const e of p.trail.slice(-3)) { const a = e.args ? ' ' + JSON.stringify(e.args) : ''; lines.push(' [' + e.state + '] step ' + e.stepIndex + ' ' + e.tool + a) }
309
+ }
310
+ }
311
+ } else {
312
+ if (v.current) { const c = v.current; lines.push('current: step ' + c.stepIndex + ' → ' + c.tool + (c.args ? ' ' + JSON.stringify(c.args) : '')) }
313
+ else if (v.state === 'running') lines.push('current: (starting / thinking)')
314
+ if (v.trail && v.trail.length) {
315
+ lines.push('recent steps:')
316
+ for (const e of v.trail.slice(-6)) { const a = e.args ? ' ' + JSON.stringify(e.args) : ''; lines.push(' [' + e.state + '] step ' + e.stepIndex + ' ' + e.tool + a) }
317
+ }
318
+ if (v.lastStatus) lines.push('last: ' + v.lastStatus + (v.lastSessionId ? ' session=' + v.lastSessionId : '') + (v.lastAt ? ' @ ' + new Date(v.lastAt).toISOString() : ''))
319
+ }
320
+ if (v.updatedAt) lines.push('updatedAt: ' + new Date(v.updatedAt).toISOString())
321
+ return [{ type: 'text', text: lines.join('\n') }]
322
+ }
323
+
324
+ // ── MCP 子进程快照上报 ────────────────────────────────────────────────────────
325
+ // MCP server 是独立 stdio 子进程:没有 ctx.emit、拿不到 collector 服务,所以把快照
326
+ // 原子写进 {dsh-home}/cli-bridge-mcp-<provider>.json,家级灯在响应请求时读取合并。
327
+ function buildMcpBridgePayload(snap, pid, nowMs) {
328
+ const s = snap && typeof snap === 'object' ? snap : {}
329
+ const src = Array.isArray(s.projects) ? s.projects : []
330
+ const projects = []
331
+ for (const p of src) {
332
+ if (!p || !p.cwd) continue
333
+ projects.push({
334
+ cwd: String(p.cwd), name: p.name ? String(p.name) : '', provider: p.provider || s.provider || '', state: p.state ? String(p.state) : 'idle',
335
+ running: Number(p.running) || 0,
336
+ current: p.current ? { stepIndex: Number(p.current.stepIndex) || 0, tool: String(p.current.tool || ''), args: p.current.args || null } : null,
337
+ trail: Array.isArray(p.trail) ? p.trail.slice(-MAX_TRAIL).map((e) => ({ stepIndex: Number(e && e.stepIndex) || 0, tool: String((e && e.tool) || ''), state: String((e && e.state) || ''), args: (e && e.args) || null })) : [],
338
+ lastStatus: p.lastStatus ? String(p.lastStatus) : null, lastAt: Number(p.lastAt) || 0, lastSessionId: p.lastSessionId ? String(p.lastSessionId) : null,
339
+ lastBackend: p.lastBackend ? String(p.lastBackend) : null, fallbackActive: !!p.fallbackActive, runs: Number(p.runs) || 0, totalTokens: Number(p.totalTokens) || 0, updatedAt: Number(p.updatedAt) || 0
340
+ })
341
+ }
342
+ return { source: 'mcp', provider: s.provider || '', pid: Number(pid) || 0, updatedAt: Number(nowMs) || 0, projects }
343
+ }
344
+
345
+ function normalizeMcpBridge(payload, nowMs, staleMs) {
346
+ if (!payload || typeof payload !== 'object') return null
347
+ const projects = Array.isArray(payload.projects) ? payload.projects : []
348
+ const updatedAt = Number(payload.updatedAt) || 0
349
+ const stale = (Number(nowMs) || 0) - updatedAt > (Number(staleMs) || MCP_BRIDGE_STALE_MS)
350
+ const out = []
351
+ for (const p of projects) {
352
+ if (!p || !p.cwd) continue
353
+ if (stale && (Number(p.running) || 0) > 0) out.push(Object.assign({}, p, { running: 0, current: null, state: p.state === 'running' ? 'idle' : p.state }))
354
+ else out.push(p)
355
+ }
356
+ return { source: 'mcp', provider: payload.provider || '', pid: Number(payload.pid) || 0, updatedAt, stale, projects: out }
357
+ }
358
+
359
+ // ── 执行编排(三形态共用;provider 差异全走 descriptor)──────────────────────
360
+ // o: {
361
+ // ctx, // Cordis ctx(interval/timeout/get)
362
+ // subprocess, // DSH subprocess 服务
363
+ // provider, // descriptor(见 PROVIDER-CONTRACT.md)
364
+ // engine, // createStatusEngine({provider}) 实例
365
+ // resolveExe, // async (backend, signal) => 'exe' | [nodeExe, binPath] | throw
366
+ // getCwdFallback, // () => 默认 cwd
367
+ // getPreferredBackend, // () => 用户偏好后端(可缺省)
368
+ // getSettings, // () => 当前设置对象(可缺省;provider 用它读 defaultModel 等)
369
+ // planActiveFor, // (exec) => bool(plan 模式判定,可缺省)
370
+ // defaultMode // 'auto'
371
+ // }
372
+ function createRunner(o) {
373
+ const ctx = o.ctx
374
+ const subprocess = o.subprocess
375
+ const provider = o.provider
376
+ const engine = o.engine
377
+ const stdio = { stdin: 'ignore', stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } }, stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } } }
378
+
379
+ function readStreams(handle) {
380
+ const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
381
+ const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
382
+ return { stdoutText, stderrText }
383
+ }
384
+
385
+ let built_env = null
386
+ function spawnSpec(argv, cwd, callerSignal) {
387
+ const spec = { argv, cwd, stdio, graceMs: 5000 }
388
+ if (callerSignal) spec.signal = callerSignal
389
+ if (built_env && typeof built_env === 'object') spec.env = built_env
390
+ return spec
391
+ }
392
+
393
+ function startLiveParser(handle, cwd) {
394
+ let cursor = 0
395
+ const stream = createLineStream((ln) => {
396
+ const t = ln.trim()
397
+ if (!t.startsWith('{')) return
398
+ try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
399
+ })
400
+ const tick = function () {
401
+ try {
402
+ const full = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
403
+ if (full.length <= cursor) return
404
+ const fresh = full.slice(cursor)
405
+ cursor = full.length
406
+ stream.pushChunk(fresh)
407
+ } catch (e) {}
408
+ }
409
+ const disposeInterval = ctx.interval(tick, 250)
410
+ return function disposeLive() { try { stream.flush() } catch (e) {} disposeInterval() }
411
+ }
412
+
413
+ async function runSync(argv, cwd, timeoutSec, callerSignal, extraEnv) {
414
+ const spec = spawnSpec(argv, cwd, callerSignal)
415
+ if (extraEnv && typeof extraEnv === 'object') spec.env = Object.assign({}, spec.env, extraEnv)
416
+ const handle = subprocess.spawn(spec)
417
+ let lastEventSummary = '(no events yet)'
418
+ let timedOut = false
419
+ const disposeTimer = ctx.timeout(function () {
420
+ timedOut = true
421
+ try {
422
+ const p = engine.ensureProject(cwd)
423
+ lastEventSummary = (p.current ? ('last step ' + p.current.stepIndex + ' -> ' + p.current.tool) : '') +
424
+ ' | trail=' + (p.trail.length ? p.trail.slice(-2).map((e) => '[' + e.state + ']' + e.tool).join(',') : 'empty') +
425
+ ' | elapsed=' + Math.round((Date.now() - (p.updatedAt || Date.now())) / 1000) + 's since last activity'
426
+ } catch (e) {}
427
+ try { handle.terminate() } catch (e) {}
428
+ }, (timeoutSec + 60) * 1000)
429
+ const disposeLive = startLiveParser(handle, cwd)
430
+ try {
431
+ const outcome = await handle.done
432
+ const s = readStreams(handle)
433
+ return { outcome, stdoutText: s.stdoutText, stderrText: s.stderrText, timedOut, lastEventSummary }
434
+ } finally { disposeLive(); disposeTimer() }
435
+ }
436
+
437
+ async function askFallback(exec, res, canRetry) {
438
+ const uq = ctx.get('userQuestions')
439
+ if (!uq || !exec || !exec.agent) return 'error'
440
+ const labels = fallbackLabels(provider)
441
+ const bk = provider.id
442
+ const detail = String(res.stderr || res.response || res.status || '').slice(-600)
443
+ const opts = [{ label: labels.fallback, description: '本次改由 DSH 本地模型/原生工具完成,不再走 ' + bk }]
444
+ if (canRetry) opts.push({ label: labels.retry, description: '再调用一次 ' + bk + '(网络抖动时可用)' })
445
+ opts.push({ label: labels.cancel, description: '不回退,直接返回 ' + bk + ' 错误' })
446
+ try {
447
+ const ans = await uq.ask({ agent: exec.agent, signal: exec.signal, questions: [{ id: bk + '-fallback', header: bk + ' 受限', question: bk + ' 调用失败(疑似流量受限/网络不通,状态=' + String(res.status) + ')。是否改用 DSH 本地 API 配置继续?', detail, options: opts }] })
448
+ const sel = (ans && ans.answers && ans.answers[0] && ans.answers[0].selected) || []
449
+ if (sel.indexOf(labels.fallback) >= 0) return 'fallback'
450
+ if (sel.indexOf(labels.retry) >= 0) return 'retry'
451
+ return 'error'
452
+ } catch (e) { return 'error' }
453
+ }
454
+
455
+ async function coreExecute(rawArgs, exec) {
456
+ const args = rawArgs || {}
457
+ built_env = null
458
+ const mk = (over) => Object.assign({ ok: false, status: 'BAD_ARGS', response: '', sessionId: null, durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode: provider.defaultMode || 'auto', backend: null, stderr: '' }, over)
459
+ if (!args.prompt || !String(args.prompt).trim()) return mk({ stderr: 'prompt is required' })
460
+
461
+ const target = engine.resolveTarget(args, o.getCwdFallback())
462
+ const backend = provider.resolveBackend ? provider.resolveBackend(args, target, o.getPreferredBackend ? o.getPreferredBackend() : null) : (o.getPreferredBackend ? o.getPreferredBackend() : provider.defaultBackend)
463
+ const cwd = target.cwd
464
+
465
+ // provider 前置检查(凭据/桌面健康等):返回 { abort: res } 则直接结束。
466
+ if (provider.preflight) {
467
+ const pre = provider.preflight({ args, exec, backend, cwd, engine, ctx, o })
468
+ if (pre && pre.abort) { engine.begin(cwd); engine.end(pre.abort, cwd); return pre.abort }
469
+ }
470
+
471
+ let exePrefix = provider.fallbackExePrefix ? provider.fallbackExePrefix(backend) : [provider.exeName || provider.id]
472
+ let exeOk = true, resolveErr = ''
473
+ try { const resExe = await o.resolveExe(backend, exec ? exec.signal : undefined); exePrefix = Array.isArray(resExe) ? resExe : [resExe] }
474
+ catch (e) { exeOk = false; resolveErr = String(e && e.message || e) }
475
+
476
+ const built = provider.buildArgv(exePrefix, args, {
477
+ planActive: o.planActiveFor ? o.planActiveFor(exec) : false,
478
+ defaultMode: provider.defaultMode || 'auto',
479
+ defaultModel: (!args.model && o.getSettings) ? (o.getSettings() || {}).defaultModel || null : null,
480
+ backend, settings: o.getSettings ? o.getSettings() : null, engine
481
+ })
482
+ built_env = (built.env && typeof built.env === 'object') ? built.env : null
483
+ const unavailableStatus = provider.unavailableStatus || 'CLI_UNAVAILABLE'
484
+
485
+ if (!exeOk) {
486
+ engine.begin(cwd)
487
+ let res = mk({ status: unavailableStatus, mode: built.mode, backend, stderr: (backend || provider.id) + ' executable not found: ' + resolveErr })
488
+ if (await askFallback(exec, res, true) === 'fallback') res = fallbackResult(res, built.mode, provider)
489
+ engine.end(res, cwd); return res
490
+ }
491
+
492
+ // 非 spawn 路径(如 mimo 桌面走本地 HTTP):provider 若给 execute 且(未给 delegates
493
+ // 或 delegates(backend)=true)则完全交给它;否则落回通用 pipeline(jobs/重试/弹窗)。
494
+ if (provider.execute && (!provider.delegates || provider.delegates(backend, args))) {
495
+ engine.begin(cwd)
496
+ try {
497
+ const res = await provider.execute({ args, exec, backend, cwd, built, engine, ctx, o, runSync })
498
+ engine.end(res, cwd); return res
499
+ } catch (e) {
500
+ const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
501
+ engine.end(res, cwd); return res
502
+ }
503
+ }
504
+
505
+ const jobs = ctx.get('jobs')
506
+ if (args.background && jobs && exec && exec.agent) {
507
+ try {
508
+ engine.begin(cwd)
509
+ const jobId = jobs.start({
510
+ kind: 'bash', label: provider.id + ': ' + shortLabel(args.prompt), owner: exec.agent,
511
+ run() {
512
+ const handle = subprocess.spawn(spawnSpec(built.argv, cwd))
513
+ const disposeLive = startLiveParser(handle, cwd)
514
+ let bgKilled = false
515
+ const disposeGuard = ctx.timeout(function () { bgKilled = true; try { handle.terminate() } catch (e) {} }, (built.timeoutSec + 60) * 1000)
516
+ const done = handle.done.then(function (outcome) {
517
+ disposeLive(); disposeGuard()
518
+ const s = readStreams(handle)
519
+ const res = provider.buildResult(provider.parseJson(s.stdoutText), outcome, built.mode, s.stderrText, s.stdoutText, backend)
520
+ if (bgKilled && !res.ok) { res.status = 'HUNG_TIMEOUT'; res.stderr = (res.stderr ? res.stderr + ' ' : '') + '[killed by timeout guard after ' + built.timeoutSec + 's; raise timeoutSec for long builds]' }
521
+ engine.end(res, cwd)
522
+ return { status: res.ok ? 'completed' : 'failed', detail: provider.id + ' ' + res.status, output: JSON.stringify(res) }
523
+ }).catch(function (err) { disposeLive(); disposeGuard(); engine.end(mk({ status: 'JOB_ERROR', backend }), cwd); return { status: 'failed', detail: String(err && err.message || err) } })
524
+ return { cancel: function () { try { handle.terminate() } catch (e) {} }, done }
525
+ }
526
+ })
527
+ return { ok: true, background: true, jobId: String(jobId), mode: built.mode, backend, note: provider.id + ' running in background; collect with job_output ' + String(jobId) + '. Background failures do NOT open the fallback dialog; on failure re-run in foreground to be prompted.' }
528
+ } catch (e) {
529
+ const res = mk({ status: 'JOB_START_ERROR', mode: built.mode, backend, stderr: 'failed to start background job: ' + String(e && e.message || e) })
530
+ engine.end(res, cwd); return res
531
+ }
532
+ }
533
+
534
+ engine.begin(cwd)
535
+ try {
536
+ let attempt = 0, cliRetried = false, res
537
+ while (true) {
538
+ attempt += 1
539
+ const r = await runSync(built.argv, cwd, built.timeoutSec, exec ? exec.signal : undefined, built.env)
540
+ if (r.timedOut) { res = mk({ status: 'HUNG_TIMEOUT', mode: built.mode, backend, exitCode: r.outcome ? r.outcome.exitCode : null, stderr: provider.id + ' did not finish within ' + built.timeoutSec + 's (DSH hard timeout). Last activity: ' + r.lastEventSummary + '. NOTE: raise timeoutSec for long builds; this was a hang guard.' }); break }
541
+ res = provider.buildResult(provider.parseJson(r.stdoutText), r.outcome, built.mode, r.stderrText, r.stdoutText, backend)
542
+ if (res.ok) break
543
+ if (provider.isTransientCliError && provider.isTransientCliError(res)) {
544
+ if (!cliRetried) { cliRetried = true; attempt -= 1; continue }
545
+ res.retried = true; break
546
+ }
547
+ if (!(provider.isLimited ? provider.isLimited(res) : isLimited(res))) break
548
+ const decision = await askFallback(exec, res, attempt < 2)
549
+ if (decision === 'fallback') { res = fallbackResult(res, built.mode, provider); break }
550
+ if (decision === 'retry' && attempt < 2) continue
551
+ break
552
+ }
553
+ engine.end(res, cwd); return res
554
+ } catch (e) {
555
+ const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
556
+ const out = (await askFallback(exec, res, true) === 'fallback') ? fallbackResult(res, built.mode, provider) : res
557
+ engine.end(out, cwd); return out
558
+ }
559
+ }
560
+
561
+ return { coreExecute, runSync, askFallback }
562
+ }
563
+
564
+ // providers/opencode.mjs — opencode provider descriptor(第四家 CLI)。
565
+ //
566
+ // 移植基线 = 本机实测的 OpenCode **V2** CLI(v2.0.29,桌面版自带二进制
567
+ // opencode-cli.exe)+ 官方 CLI 源码 emit 全集。不是旧包(本包新增,无旧包可迁)。
568
+ //
569
+ // 实测事实(2026-10-15,CLI v2.0.29):
570
+ // opencode run [message..] 非交互主命令;prompt 是【位置参数】(run 子命令无
571
+ // --prompt;-p 是 --password,切勿当 prompt 用)
572
+ // --format json NDJSON 事件流(每行一个 JSON 对象)
573
+ // --auto auto-approve 未显式拒绝的权限(不加会卡在权限提示)
574
+ // -m, --model provider/model V2 支持 provider/model#variant
575
+ // --session <id> / --continue 会话续接(无 `opencode continue` 子命令)
576
+ // --agent / --title / --file / --thinking / --standalone
577
+ // ⚠️ V2 无 --dir(V1 有)→ cwd 只能靠 spawn 的工作目录
578
+ // ⚠️ V2 无 --variant(V1 有)→ variant 并入 --model 的 # 后缀
579
+ // 退出码:0 成功 / 1 错误 / 130 SIGINT(退出码才是成败判据)
580
+ // 事件 type(源码全集):step_start / text / reasoning / tool_use / step_finish / error
581
+ // ⚠️ 不存在 result/complete/init 事件
582
+ // ⚠️ 文本在 part.text;统计在 step_finish.part(cost/tokens)——两者是不同事件
583
+ //
584
+ // 红线(PROVIDER-CONTRACT.md §8 + §1):
585
+ // - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
586
+ // process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
587
+ // - 只允许 import '../core/bridge-kit.mjs'。
588
+
589
+ function nowIso() { return new Date().toISOString() }
590
+
591
+ // ── 可执行文件解析(env > 桌面版已知路径 > PATH)───────────────────────────────
592
+ // 二进制名是 opencode-cli.exe(不是 opencode.exe):V2 桌面版自带,且 V1/V2 不能并存
593
+ // 安装(官方原话),故不做「opencode.exe 兜底」以免误命中另一个版本。
594
+ const EXE_BASENAMES = ['opencode-cli', 'opencode']
595
+ // 桌面版各版本目录形如 ...\ai.opencode.desktop\cli\<ver>-<arch>\opencode-cli.exe。
596
+ // 沙箱内可能没有 fs —— 全部读取走 typeof 守卫,读不到就返回空数组(退回 PATH 查找)。
597
+ const DESKTOP_CLI_REL = ['ai.opencode.desktop', 'cli']
598
+
599
+ function schedBuiltin(name) {
600
+ try {
601
+ const proc = globalThis.process
602
+ return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
603
+ } catch (e) { return null }
604
+ }
605
+
606
+ /**
607
+ * 探测本机 opencode 可执行文件候选路径(优先桌面版 V2)。
608
+ * 纯函数、零副作用;无 fs 能力时返回空数组 —— 由调用方退回 PATH 查找。
609
+ * @param {object} env 环境变量(读 APPDATA / LOCALAPPDATA / PROGRAMFILES / HOME)
610
+ */
611
+ function candidateExePaths(env) {
612
+ const e = env || {}
613
+ const out = []
614
+ const fs = schedBuiltin('node:fs')
615
+ const path = schedBuiltin('node:path')
616
+ if (!fs || typeof fs.readdirSync !== 'function' || !path || typeof path.join !== 'function') return out
617
+ const roots = []
618
+ for (const k of ['APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES']) if (e[k]) roots.push(String(e[k]))
619
+ const home = e.HOME || e.USERPROFILE
620
+ if (home && !roots.length) roots.push(String(home))
621
+ for (const root of roots) {
622
+ const cliDir = path.join.apply(path, [root].concat(DESKTOP_CLI_REL))
623
+ let versions = []
624
+ try { versions = fs.readdirSync(cliDir, { withFileTypes: true }) } catch (err) { continue }
625
+ // 版本目录按 semver 降序(新版本优先);解析失败的排末尾。
626
+ const dirs = versions.filter((d) => d && d.isDirectory && d.isDirectory()).map((d) => d.name)
627
+ const key = (n) => {
628
+ const m = String(n).match(/(\d+)\.(\d+)\.(\d+)/)
629
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [-1, -1, -1]
630
+ }
631
+ dirs.sort((a, b) => {
632
+ const ka = key(a), kb = key(b)
633
+ for (let i = 0; i < 3; i++) if (ka[i] !== kb[i]) return kb[i] - ka[i]
634
+ return 0
635
+ })
636
+ for (const d of dirs) {
637
+ for (const base of EXE_BASENAMES) {
638
+ const exe = path.join(cliDir, d, base + (String(e.OS || '').toLowerCase().startsWith('win') || e.APPDATA ? '.exe' : ''))
639
+ try { if (fs.existsSync(exe)) out.push(exe) } catch (err) { }
640
+ }
641
+ }
642
+ }
643
+ return out
644
+ }
645
+
646
+ /**
647
+ * 解析本次调用使用的 exe 前缀(数组或字符串)。
648
+ * 优先级:binPath 设置 > env OPENCODE_BIN/OPENCODE_CLI > 桌面版探测 > PATH 名字。
649
+ * @param {object} env
650
+ * @param {string} [binPath] 设置面板里的显式路径(可空)
651
+ */
652
+ function resolveExePrefix(env, binPath) {
653
+ const e = env || {}
654
+ const bin = binPath || e.OPENCODE_BIN || e.OPENCODE_CLI || ''
655
+ if (bin) {
656
+ if (/\.(mjs|cjs|js)$/i.test(bin)) {
657
+ const proc = globalThis.process
658
+ const node = (proc && proc.execPath) ? proc.execPath : 'node'
659
+ return [node, bin]
660
+ }
661
+ return [String(bin)]
662
+ }
663
+ const found = candidateExePaths(e)
664
+ if (found.length) return [found[0]]
665
+ return [EXE_BASENAMES[0]]
666
+ }
667
+
668
+ // ── 模型列表(`opencode models`;纯文本每行 provider/model)─────────────────────
669
+ // 同步版给 settings.meta(面板下拉);异步版给 MCP 工具。解析宽容:只认
670
+ // provider/model 形态的行,其余(表头/说明/空行)一律丢弃。
671
+ const MODEL_LINE_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.#@+-]+)$/
672
+
673
+ function parseModelList(text) {
674
+ const out = []
675
+ const seen = Object.create(null)
676
+ for (const raw of String(text || '').split(/\r?\n/)) {
677
+ const ln = raw.trim()
678
+ if (!ln) continue
679
+ const m = ln.match(MODEL_LINE_RE)
680
+ if (!m) continue
681
+ const id = m[1] + '/' + m[2]
682
+ if (seen[id]) continue
683
+ seen[id] = true
684
+ out.push({ id: id, provider: m[1], name: m[2], free: /(-free$|free)/i.test(m[2]) })
685
+ }
686
+ return out
687
+ }
688
+
689
+ /**
690
+ * 同步抓取模型列表(供设置面板下拉)。失败返回空数组 —— 下拉退化为自由输入,
691
+ * 绝不因探测失败影响面板渲染。仅 spawnSync 可用时生效(宿主形态)。
692
+ */
693
+ function fetchModelsSync(env, binPath, timeoutMs) {
694
+ try {
695
+ const child = schedBuiltin('node:child_process')
696
+ if (!child || typeof child.spawnSync !== 'function') return []
697
+ const prefix = resolveExePrefix(env, binPath)
698
+ const r = child.spawnSync(prefix[0], prefix.slice(1).concat(['models']), {
699
+ encoding: 'utf8', timeout: clampInt(timeoutMs, 8000, 1000, 30000),
700
+ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
701
+ })
702
+ if (!r || r.error || r.status !== 0) return []
703
+ return parseModelList(String(r.stdout || ''))
704
+ } catch (e) { return [] }
705
+ }
706
+
707
+ // ── 三工具 schema ────────────────────────────────────────────────────────────
708
+ const RUN_PARAMS = {
709
+ type: 'object',
710
+ additionalProperties: false,
711
+ required: ['prompt'],
712
+ properties: {
713
+ 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).' },
714
+ 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.' },
715
+ agent: { type: 'string', description: 'Optional opencode agent name (primary agents only; subagent names are rejected by opencode itself).' },
716
+ cwd: { type: 'string', description: 'Working directory for opencode. Defaults to the DSH workspace root.' },
717
+ files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach to the message.' },
718
+ title: { type: 'string', description: 'Optional session title (opencode truncates the prompt when omitted).' },
719
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
720
+ 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).' },
721
+ 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.' }
722
+ }
723
+ }
724
+ const CONTINUE_PARAMS = {
725
+ type: 'object',
726
+ additionalProperties: false,
727
+ required: ['prompt'],
728
+ properties: {
729
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing opencode session. State only what is new; opencode preserves its session context.' },
730
+ sessionId: { type: 'string', description: 'opencode session id to continue (from a prior opencode_run result).' },
731
+ latest: { type: 'boolean', description: 'Continue the most recent opencode session instead of a specific id.' },
732
+ model: { type: 'string', description: 'Optional model in provider/model form.' },
733
+ agent: { type: 'string', description: 'Optional opencode agent name.' },
734
+ cwd: { type: 'string', description: 'Working directory for opencode.' },
735
+ files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach.' },
736
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
737
+ autoApprove: { type: 'boolean', description: 'Default true: pass --auto. Set false to require explicit approval.' },
738
+ 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.' }
739
+ }
740
+ }
741
+ const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
742
+
743
+ // opencode_models(附加工具):列出本机 opencode 可用模型(免费模型优先展示)。
744
+ const MODELS_PARAMS = {
745
+ type: 'object',
746
+ additionalProperties: false,
747
+ required: [],
748
+ properties: {
749
+ freeOnly: { type: 'boolean', description: 'Return only free models (id ends with -free). Default false = all.' },
750
+ provider: { type: 'string', description: 'Optional provider filter, e.g. "opencode" or "opencode-go".' }
751
+ }
752
+ }
753
+ 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.'
754
+
755
+ function renderModels(value) {
756
+ const v = value || {}
757
+ if (!v.ok) return [{ type: 'text', text: 'opencode_models failed: ' + String(v.error || 'unknown') }]
758
+ const list = Array.isArray(v.models) ? v.models : []
759
+ if (!list.length) return [{ type: 'text', text: 'opencode_models: no models returned' }]
760
+ const lines = ['opencode models (' + list.length + '):']
761
+ for (const m of list.slice(0, 40)) lines.push(' ' + m.id + (m.free ? ' [free]' : ''))
762
+ if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
763
+ const free = list.filter((m) => m.free).length
764
+ lines.push('free: ' + free + ' / ' + list.length)
765
+ return [{ type: 'text', text: lines.join('\n') }]
766
+ }
767
+
768
+ function defineProvider (deps = {}) {
769
+ const env = deps.env || {}
770
+ let host = null
771
+ let settingsRef = { binPath: '', defaultModel: '', defaultAgent: '' }
772
+
773
+ /**
774
+ * 取当前生效的偏好设置(宿主经 hostOpts.getSettings 注入;无则退回 env/默认)。
775
+ */
776
+ function currentSettings() {
777
+ try {
778
+ if (host && typeof host.getSettings === 'function') {
779
+ const s = host.getSettings('opencode')
780
+ if (s && typeof s === 'object') return Object.assign({}, settingsRef, s)
781
+ }
782
+ } catch (e) { }
783
+ return settingsRef
784
+ }
785
+
786
+ const descriptor = {
787
+ id: 'opencode', label: 'OPENCODE', presetId: 'opencode-first', defaultBackend: null, defaultMode: 'auto',
788
+ exeName: 'opencode-cli', binEnvVars: ['OPENCODE_BIN', 'OPENCODE_CLI'], unavailableStatus: 'OPENCODE_UNAVAILABLE',
789
+
790
+ // 单后端:opencode 自身选择 provider/model,桥不引入第二后端概念。
791
+ backends() { return null },
792
+ normalizeBackend() { return null },
793
+ resolveBackend(args, target, preferred) { return null },
794
+
795
+ // 实测 flag 集(V2.0.29)。
796
+ buildArgv(exePrefix, args, opts) {
797
+ const o = opts || {}
798
+ const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
799
+ argv.push('run', String(args.prompt), '--format', 'json')
800
+ if (args.autoApprove !== false) argv.push('--auto')
801
+ const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
802
+ // 模型优先级:调用参数 > 设置面板 defaultModel > 不传(交给 opencode 自身默认)。
803
+ // 用户要求「不设默认 → 由 opencode 自决」,故 settingsRef.defaultModel 为空时不落地 --model。
804
+ const model = args.model || currentSettings().defaultModel || ''
805
+ if (model) argv.push('--model', String(model))
806
+ const agent = args.agent || currentSettings().defaultAgent || ''
807
+ if (agent) argv.push('--agent', String(agent))
808
+ if (args.title) argv.push('--title', String(args.title))
809
+ if (Array.isArray(args.files)) for (const f of args.files) { if (f) argv.push('--file', String(f)) }
810
+ if (args.sessionId) argv.push('--session', String(args.sessionId))
811
+ else if (args.continueLatest) argv.push('--continue')
812
+ return { argv, mode: 'auto', timeoutSec }
813
+ },
814
+
815
+ // JSONL(每行一个 JSON 对象)。事件 type 取自 CLI 源码 emit 全集(实测一致):
816
+ // step_start / text / reasoning / tool_use / step_finish / error。
817
+ // ⚠️ 不存在 result/complete/init 事件 —— 终止判据是「进程退出码」,不是某个终态事件。
818
+ // ⚠️ 文本与统计在【不同】事件里:text.part.text 是回复正文,step_finish.part 只有
819
+ // cost/tokens。若直接取 step_finish 会得到「成功但正文为空」的结果。
820
+ // ⚠️ 因此必须【正向】扫完整个流再决策:倒序会在读到 text 之前就命中末尾的
821
+ // step_finish 并返回,回填永远为空(第一版实现的真实 bug)。
822
+ // 实测一次真实调用只产生 step_start + text(无 step_finish)—— 末段 text 兜底
823
+ // 是活的必经分支,不是死代码。
824
+ // 决策优先级:error > step_finish > 末段 text > 末条事件;命中前两者时把末段文本
825
+ // 回填进 part.text,让 buildResult 统一从 part.text 读正文。
826
+ // 无 JSON 时返回 null,由 buildResult 走纯文本兜底(绝不假造 status)。
827
+ parseJson(stdoutText) {
828
+ const trimmed = String(stdoutText || '').trim()
829
+ if (!trimmed) return null
830
+ const lines = trimmed.split(/\r?\n/).filter(Boolean)
831
+ let lastText = null
832
+ let lastTextStr = ''
833
+ let terminal = null
834
+ let fallback = null
835
+ for (const raw of lines) {
836
+ const ln = raw.trim()
837
+ if (!ln.startsWith('{')) continue
838
+ let obj
839
+ try { obj = JSON.parse(ln) } catch (e) { continue }
840
+ if (!obj || typeof obj !== 'object') continue
841
+ const part = obj.part && typeof obj.part === 'object' ? obj.part : null
842
+ if (obj.type === 'text' && part && typeof part.text === 'string') {
843
+ lastText = obj
844
+ lastTextStr = part.text
845
+ } else if (obj.type === 'error') {
846
+ terminal = obj
847
+ } else if (obj.type === 'step_finish') {
848
+ if (!terminal || terminal.type !== 'error') terminal = obj
849
+ }
850
+ fallback = obj
851
+ }
852
+ if (terminal) {
853
+ const part = terminal.part && typeof terminal.part === 'object' ? terminal.part : null
854
+ if (lastTextStr && !(part && typeof part.text === 'string')) {
855
+ terminal.part = Object.assign({}, part || {}, { text: lastTextStr })
856
+ }
857
+ return terminal
858
+ }
859
+ return lastText || fallback
860
+ },
861
+
862
+ // 统一结果对象(契约 §1 buildResult 行)。
863
+ // 事件负载一律在 part 里(CLI emit: {type,timestamp,sessionID,...data},data = {part})。
864
+ // 终止判据 = exit code(0 成功 / 1 错误 / 130 SIGINT),不存在 result 事件可依赖。
865
+ buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
866
+ const exitCode = outcome ? outcome.exitCode : null
867
+ const stderr = stderrText ? String(stderrText).slice(-2000) : ''
868
+ if (parsed && typeof parsed === 'object') {
869
+ const sessionId = typeof parsed.sessionID === 'string' ? parsed.sessionID
870
+ : (typeof parsed.sessionId === 'string' ? parsed.sessionId : null)
871
+ const part = parsed.part && typeof parsed.part === 'object' ? parsed.part : {}
872
+ const errObj = parsed.error && typeof parsed.error === 'object' ? parsed.error : null
873
+ const dataObj = errObj && errObj.data && typeof errObj.data === 'object' ? errObj.data : null
874
+ const errMsg = dataObj && typeof dataObj.message === 'string' ? dataObj.message
875
+ : (errObj && typeof errObj.message === 'string' ? errObj.message
876
+ : (typeof parsed.error === 'string' ? parsed.error : ''))
877
+ let response = ''
878
+ if (typeof part.text === 'string') response = part.text
879
+ else if (typeof parsed.response === 'string') response = parsed.response
880
+ const isErrorEvent = parsed.type === 'error'
881
+ let status
882
+ if (exitCode === 130) status = 'ABORTED'
883
+ else if (isErrorEvent) status = 'ERROR'
884
+ else if (typeof parsed.status === 'string') status = parsed.status
885
+ else status = exitCode === 0 ? 'SUCCESS' : 'ERROR'
886
+ const tokens = part.tokens && typeof part.tokens === 'object' ? part.tokens : null
887
+ let totalTokens = null
888
+ if (tokens) {
889
+ if (typeof tokens.total === 'number') totalTokens = tokens.total
890
+ else if (typeof tokens.totalTokens === 'number') totalTokens = tokens.totalTokens
891
+ else {
892
+ let sum = 0
893
+ let any = false
894
+ for (const k of Object.keys(tokens)) {
895
+ if (typeof tokens[k] === 'number') { sum += tokens[k]; any = true }
896
+ }
897
+ if (any) totalTokens = sum
898
+ }
899
+ }
900
+ return {
901
+ ok: exitCode === 0 && !isErrorEvent,
902
+ status,
903
+ response,
904
+ sessionId,
905
+ durationSeconds: null,
906
+ numTurns: null,
907
+ totalTokens,
908
+ exitCode, mode, backend: backend || null,
909
+ stderr: (stderr ? stderr + ' ' : '') + errMsg,
910
+ cost: typeof part.cost === 'number' ? part.cost : null
911
+ }
912
+ }
913
+ const text = String(stdoutText || '').trim()
914
+ return {
915
+ ok: exitCode === 0,
916
+ status: exitCode === 0 ? 'SUCCESS' : 'ERROR',
917
+ response: text,
918
+ sessionId: null,
919
+ durationSeconds: null, numTurns: null, totalTokens: null,
920
+ exitCode, mode, backend: backend || null, stderr,
921
+ rawStdout: text.slice(-2000)
922
+ }
923
+ },
924
+
925
+ // 实时步骤折叠(写 project.current/trail)。
926
+ // tool_use.part = { tool:"bash", state:{status,input,output,title,time}, … }
927
+ // text.part = { text:"…", time:{start,end} }
928
+ foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
929
+ if (!ev || typeof ev !== 'object') return
930
+ const type = ev.type
931
+ if (!type) return
932
+ const part = ev.part && typeof ev.part === 'object' ? ev.part : {}
933
+ p.updatedAt = Date.now()
934
+ if (type === 'tool_use') {
935
+ const tool = part.tool || part.name || 'tool'
936
+ const st = part.state && typeof part.state === 'object' ? part.state : {}
937
+ const args = summarizeArgs(st.input || part.input)
938
+ const state = st.status === 'completed' ? 'DONE' : (st.status === 'failed' ? 'ERROR' : 'ACTIVE')
939
+ const entry = { stepIndex: p.trail.length, state: state, tool: tool, args: args || null, at: nowIso() }
940
+ p.trail.push(entry)
941
+ const cap = maxTrail || 12
942
+ if (p.trail.length > cap) p.trail.shift()
943
+ if (p.current) p.current = null
944
+ } else if (type === 'text' || type === 'reasoning') {
945
+ const delta = typeof part.text === 'string' ? part.text : ''
946
+ if (!delta) return
947
+ const capLen = maxArgLen || 120
948
+ p.current = { tool: type === 'reasoning' ? 'reasoning' : 'agent_response', args: { text_delta: String(delta).slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
949
+ } else if (type === 'step_finish') {
950
+ if (p.current) p.current = null
951
+ }
952
+ },
953
+
954
+ isTransientCliError(res) {
955
+ if (!res || res.ok) return false
956
+ if (isLimited(res)) return false
957
+ return res.status === 'ERROR_DURING_EXECUTION'
958
+ },
959
+
960
+ failureHint(res) {
961
+ if (!res || res.ok) return ''
962
+ const st = String(res.status || '')
963
+ if (st === 'OPENCODE_UNAVAILABLE') return '[诊断] 未找到 opencode 可执行文件:请安装 OpenCode(桌面版自带 CLI),或在 CLI Bridge 设置面板填写 opencode 可执行文件路径。'
964
+ const hay = String(res.stderr || '') + ' ' + String(res.response || '')
965
+ if (/Incorrect API key|invalid_api_key|401|unauthorized|not logged|login/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
966
+ return '[诊断] opencode 凭据问题(未登录 / API key 无效):请让用户执行 `opencode auth login` 登录 provider。这不是限流。'
967
+ }
968
+ if (/No models available|no models|model not found|Unknown provider|not found.*model/i.test(hay)) {
969
+ return '[诊断] opencode 没有可用模型或模型名无效:先用 opencode_models(或 `opencode models`)列出可用模型,再传 model。'
970
+ }
971
+ if (/quota|额度|exhausted|insufficient|credit|balance/i.test(hay)) {
972
+ return '[诊断] opencode 额度可能耗尽:换一个(优先 -free)模型,或改用原生工具完成本任务。'
973
+ }
974
+ return ''
975
+ },
976
+
977
+ isSuccessStatus(st) { return st === 'SUCCESS' },
978
+
979
+ tools: {
980
+ 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 },
981
+ 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 },
982
+ 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 }
983
+ },
984
+
985
+ // opencode_models:宿主有 spawn 能力时注册(preset 注入 deps.process / MCP 注入
986
+ // deps.nodeChild;dynamic 沙箱形态省略)。
987
+ extraTools: (deps.process || deps.nodeChild) ? [{
988
+ name: 'opencode_models',
989
+ description: MODELS_DESCRIPTION,
990
+ inputSchema: MODELS_PARAMS,
991
+ render: renderModels,
992
+ async logic(args) {
993
+ return await descriptor.listModels(args || {})
994
+ }
995
+ }] : [],
996
+
997
+ /**
998
+ * 列出可用模型(三形态共用)。真实执行 `opencode models`;
999
+ * CLI 缺失/未登录 → { ok:false, error }(绝不凭空造列表)。
1000
+ */
1001
+ async listModels(args) {
1002
+ const a = args || {}
1003
+ const child = deps.nodeChild
1004
+ if (!child || typeof child.spawn !== 'function') {
1005
+ // preset 形态:宿主 subprocess(attach 后可用)。
1006
+ if (!(host && host.subprocess)) return { ok: false, error: 'no spawn capability in this host form' }
1007
+ }
1008
+ const prefix = resolveExePrefix(env, currentSettings().binPath)
1009
+ const argv = prefix.slice(1).concat(['models'])
1010
+ const run = child && typeof child.spawn === 'function'
1011
+ ? spawnModelsNode(child, prefix[0], argv)
1012
+ : spawnModelsHost(argv)
1013
+ let res
1014
+ try { res = await run } catch (e) { return { ok: false, error: String(e && e.message || e) } }
1015
+ if (!res.ok) return res
1016
+ let list = res.models
1017
+ if (a.provider) {
1018
+ const pv = String(a.provider).toLowerCase()
1019
+ list = list.filter((m) => String(m.provider).toLowerCase() === pv)
1020
+ }
1021
+ if (a.freeOnly) list = list.filter((m) => m.free)
1022
+ // 免费模型优先展示(用户要求:优先用性能较好的免费模型),其次按字母序。
1023
+ list.sort((x, y) => (x.free === y.free ? String(x.id).localeCompare(String(y.id)) : (x.free ? -1 : 1)))
1024
+ return { ok: true, models: list, count: list.length }
1025
+ },
1026
+
1027
+ // 设置分区:用户可选「用哪个 opencode(exe 路径)」+「默认模型」+「默认 agent」。
1028
+ settings: {
1029
+ keys: ['binPath', 'defaultModel', 'defaultAgent'],
1030
+ defaults: () => ({ binPath: '', defaultModel: '', defaultAgent: '' }),
1031
+ normalize: (raw) => {
1032
+ const r = raw && typeof raw === 'object' ? raw : {}
1033
+ const out = { binPath: '', defaultModel: '', defaultAgent: '' }
1034
+ for (const k of ['binPath', 'defaultModel', 'defaultAgent']) {
1035
+ if (typeof r[k] === 'string') out[k] = String(r[k]).trim()
1036
+ }
1037
+ return out
1038
+ },
1039
+ meta: (env2) => {
1040
+ const e = env2 && typeof env2 === 'object' ? env2 : {}
1041
+ const s = currentSettings()
1042
+ // 下拉选项:实时 `opencode models`(失败则退化为空 → 面板仍可自由输入)。
1043
+ const models = fetchModelsSync(e, s.binPath, 8000)
1044
+ const options = models.map((m) => ({ value: m.id, label: m.id + (m.free ? ' [free]' : '') }))
1045
+ // 默认给「可用 exe 路径」的下拉候选(env 覆盖 > 桌面版探测)。
1046
+ const exeCandidates = []
1047
+ if (e.OPENCODE_BIN || e.OPENCODE_CLI) exeCandidates.push(String(e.OPENCODE_BIN || e.OPENCODE_CLI))
1048
+ for (const p of candidateExePaths(e)) if (exeCandidates.indexOf(p) < 0) exeCandidates.push(p)
1049
+ return {
1050
+ titleSuffix: 'OpenCode',
1051
+ fields: [
1052
+ {
1053
+ key: 'binPath', label: 'opencode 可执行文件路径', type: 'select', allowCustom: true,
1054
+ options: exeCandidates.map((p) => ({ value: p, label: p })),
1055
+ placeholder: '留空 = 自动探测桌面版 / PATH',
1056
+ note: '检测到 ' + exeCandidates.length + ' 个候选;也可手动粘贴任意路径(含 .mjs 会经 node 启动)。'
1057
+ },
1058
+ {
1059
+ key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
1060
+ options: options,
1061
+ placeholder: '留空 = 由 opencode 自身配置决定',
1062
+ note: options.length
1063
+ ? '共 ' + options.length + ' 个可用模型,免费模型已排在前面([free])。调用时传 model 可覆盖本项。'
1064
+ : '未能读取模型列表(CLI 未安装/未登录/超时)—— 仍可手动输入 provider/model。'
1065
+ },
1066
+ {
1067
+ key: 'defaultAgent', label: '默认 agent', type: 'select', allowCustom: true,
1068
+ options: [{ value: 'build', label: 'build(默认,全权限)' }, { value: 'plan', label: 'plan(只读)' }],
1069
+ placeholder: '留空 = opencode 默认 agent',
1070
+ note: 'opencode 只接受 primary agent;subagent 名会被 opencode 自身拒绝并回退。'
1071
+ }
1072
+ ]
1073
+ }
1074
+ },
1075
+ diagnose: (settings, env2) => {
1076
+ const s = settings && typeof settings === 'object' ? settings : {}
1077
+ const e = env2 && typeof env2 === 'object' ? env2 : {}
1078
+ const bits = []
1079
+ const found = candidateExePaths(e)
1080
+ if (s.binPath) bits.push('bin=设置:' + s.binPath)
1081
+ else if (e.OPENCODE_BIN || e.OPENCODE_CLI) bits.push('bin=env:' + (e.OPENCODE_BIN || e.OPENCODE_CLI))
1082
+ else if (found.length) bits.push('bin=桌面版:' + found[0].split(/[\\/]/).slice(-3).join('/'))
1083
+ else bits.push('bin=PATH:opencode-cli')
1084
+ bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(opencode 自决)')
1085
+ if (s.defaultAgent) bits.push('agent=' + s.defaultAgent)
1086
+ return bits.join(' · ')
1087
+ }
1088
+ },
1089
+
1090
+ mcp: { version: '0.2.2' }
1091
+ }
1092
+
1093
+ // MCP/测试形态:真 child_process。
1094
+ function spawnModelsNode(child, exe, argv) {
1095
+ return new Promise((resolve) => {
1096
+ let c
1097
+ try { c = child.spawn(exe, argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) }
1098
+ catch (e) { resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }); return }
1099
+ let out = ''
1100
+ let err = ''
1101
+ const timer = setTimeout(() => { try { c.kill() } catch (e) {} }, 15000)
1102
+ c.stdout.on('data', (d) => { out += d })
1103
+ c.stderr.on('data', (d) => { err += d })
1104
+ c.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }) })
1105
+ c.on('close', (code) => {
1106
+ clearTimeout(timer)
1107
+ if (code !== 0) { resolve({ ok: false, error: '`opencode models` exited ' + code + ': ' + String(err || out).slice(0, 300) }); return }
1108
+ const list = parseModelList(out)
1109
+ if (!list.length) { resolve({ ok: false, error: 'no models parsed from output: ' + String(out).slice(0, 200) }); return }
1110
+ resolve({ ok: true, models: list })
1111
+ })
1112
+ })
1113
+ }
1114
+
1115
+ // preset 形态:DSH subprocess。
1116
+ async function spawnModelsHost(argv) {
1117
+ try {
1118
+ const spec = { argv: [resolveExePrefix(env, currentSettings().binPath)[0]].concat(argv), stdio: { stdin: 'ignore', stdout: { maxBytes: 1000000 }, stderr: { maxBytes: 200000 } }, graceMs: 5000 }
1119
+ if (deps.process && typeof deps.process.cwd === 'function') spec.cwd = deps.process.cwd()
1120
+ const handle = host.subprocess.spawn(spec)
1121
+ const outcome = await handle.done
1122
+ const stdoutText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
1123
+ const stderrText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
1124
+ if (!outcome || outcome.exitCode !== 0) return { ok: false, error: '`opencode models` exited ' + (outcome && outcome.exitCode) + ': ' + String(stderrText || stdoutText).slice(0, 300) }
1125
+ const list = parseModelList(stdoutText)
1126
+ if (!list.length) return { ok: false, error: 'no models parsed from output: ' + String(stdoutText).slice(0, 200) }
1127
+ return { ok: true, models: list }
1128
+ } catch (e) { return { ok: false, error: String(e && e.message || e) } }
1129
+ }
1130
+
1131
+ descriptor.runToolLogic = async function (args, exec) {
1132
+ const a = args || {}
1133
+ const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
1134
+ return this.runner.coreExecute(normalized, exec)
1135
+ }
1136
+ descriptor.continueToolLogic = async function (args, exec) {
1137
+ const a = args || {}
1138
+ const mapped = {
1139
+ prompt: a.prompt, model: a.model, agent: a.agent,
1140
+ cwd: a.cwd, files: a.files, timeoutSec: a.timeoutSec, autoApprove: a.autoApprove,
1141
+ background: a.background === undefined ? true : a.background
1142
+ }
1143
+ const sid = a.sessionId || null
1144
+ if (sid) mapped.sessionId = sid
1145
+ else if (a.latest) mapped.continueLatest = true
1146
+ return this.runner.coreExecute(mapped, exec)
1147
+ }
1148
+ descriptor.statusToolLogic = function (args) {
1149
+ const a = args || {}
1150
+ const snap = this.engine.statusSnapshot()
1151
+ if (a.cwd) {
1152
+ const key = String(a.cwd)
1153
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
1154
+ const g = snap.projects[0]
1155
+ if (g) {
1156
+ Object.assign(snap, {
1157
+ state: g.state, running: g.running, current: g.current, trail: g.trail,
1158
+ lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
1159
+ lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
1160
+ })
1161
+ }
1162
+ }
1163
+ return snap
1164
+ }
1165
+
1166
+ descriptor.attach = function (hostOpts) {
1167
+ host = hostOpts || {}
1168
+ // 宿主注入 getSettings 时才能把面板偏好带进 buildArgv(preset/dynamic 形态)。
1169
+ try {
1170
+ if (typeof host.getSettings === 'function') {
1171
+ const s = host.getSettings('opencode')
1172
+ if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
1173
+ }
1174
+ } catch (e) { }
1175
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
1176
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
1177
+ this.runner = runner
1178
+ return runner
1179
+ }
1180
+ return descriptor
1181
+ }
1182
+
1183
+ // core/scheduling.mjs — CLI 调度(启用集合 + 调用优先级)单源纯逻辑。
1184
+ //
1185
+ // 语义(用户可见的两件事):
1186
+ // • enabled = 勾选启用哪些 CLI(数量随之而定);未启用的一面不注册工具、不进提示词。
1187
+ // • priority = 智能体调用 CLI 的建议顺序,数组顺序即优先级(首位最高)。
1188
+ //
1189
+ // 落盘位置:统一设置文件 {DSH_HOME}/cli-bridge-settings.json 的顶层 `_scheduling` 区。
1190
+ // 下划线前缀 = 非 provider 分区(registry 的 PROVIDER_IDS 永远不含它,逐分区语义不受影响),
1191
+ // 因此新代码写出的文件被旧包读取时整区被忽略 = 纯增量,向后兼容。
1192
+ //
1193
+ // 缺省语义(文件缺失/无该区):全部 provider 启用、按注册表顺序 —— 与调度特性上线前
1194
+ // 的行为逐字一致(读不到配置永远不等于关掉所有桥)。
1195
+ //
1196
+ // 沙箱纪律:与 bridge-kit 同源约束 —— 不得 import;Node 内建一律经
1197
+ // globalThis.process.getBuiltinModule + typeof 守卫(动态宿主沙箱可能没有 fs,
1198
+ // 那种环境读不到文件 → 走缺省全开,绝不炸 apply)。
1199
+
1200
+ /** 设置文件里调度区的键名(下划线前缀避开 provider id 命名空间)。 */
1201
+ const SCHEDULING_SECTION = '_scheduling'
1202
+
1203
+ /** 人类可读的默认注册顺序(调用建议顺序);enabled 缺省 = 全部启用。 */
1204
+ function defaultPriority(ids) {
1205
+ return (Array.isArray(ids) ? ids : []).slice()
1206
+ }
1207
+
1208
+ /**
1209
+ * 清洗任意来源的调度对象。
1210
+ * @param {unknown} raw 文件/POST 里的原始值(可能是任何东西,全部容错)
1211
+ * @param {string[]} ids 全量 provider id(顺序 = 缺省优先级)
1212
+ * @param {(id: string) => string} [labelOf] 展示名(仅用于诊断文本,不参与判定)
1213
+ */
1214
+ function normalizeScheduling(raw, ids, labelOf) {
1215
+ const all = Array.isArray(ids) ? ids.slice() : []
1216
+ const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
1217
+ const enabled = all.slice()
1218
+ let priority = all.slice()
1219
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1220
+ if (Array.isArray(raw.enabled)) {
1221
+ const seen = Object.create(null)
1222
+ const picked = []
1223
+ for (const x of raw.enabled) {
1224
+ const id = String(x == null ? '' : x)
1225
+ if (!id || all.indexOf(id) < 0 || seen[id]) continue
1226
+ seen[id] = true; picked.push(id)
1227
+ }
1228
+ // 空数组 = 用户真的想把全部关掉(而不是「没配」),保留该语义;
1229
+ // 非空但过滤后为空 = 写的全是未知 id(坏值)→ 回落全开(fail-open,
1230
+ // 绝不让一次手滑/陈旧配置把所有桥关掉)。
1231
+ if (raw.enabled.length === 0 || picked.length > 0) {
1232
+ enabled.length = 0
1233
+ Array.prototype.push.apply(enabled, picked)
1234
+ }
1235
+ }
1236
+ if (Array.isArray(raw.priority)) {
1237
+ const seen2 = Object.create(null)
1238
+ const ordered = []
1239
+ for (const x of raw.priority) {
1240
+ const id = String(x == null ? '' : x)
1241
+ if (!id || all.indexOf(id) < 0 || seen2[id]) continue
1242
+ seen2[id] = true; ordered.push(id)
1243
+ }
1244
+ for (const id of all) if (!seen2[id]) ordered.push(id) // 漏写的排在末尾
1245
+ if (ordered.length) priority = ordered
1246
+ }
1247
+ }
1248
+ const order = priority.filter((id) => enabled.indexOf(id) >= 0)
1249
+ for (const id of enabled) if (order.indexOf(id) < 0) order.push(id)
1250
+ return { enabled, priority, order }
1251
+ }
1252
+
1253
+ /** 展示文本:「CB → MIMO」或「全部启用 · CB → AGY → MIMO」。 */
1254
+ function describeScheduling(s, labelOf) {
1255
+ const st = s || {}
1256
+ const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
1257
+ const order = Array.isArray(st.order) ? st.order : []
1258
+ const names = order.map(label).join(' → ')
1259
+ return names ? names : '(全部禁用)'
1260
+ }
1261
+
1262
+ // ── 读盘(四形态共用;无 fs 时安全返回 null)────────────────────────────────────
1263
+ // 名字带 sched 前缀:本文件会与 bridge-kit / provider 描述符【文本内联进同一个动态
1264
+ // 沙箱脚本作用域】(providers/codebuddy.mjs 已有 builtinModule),撞名即隐患。
1265
+ function schedBuiltinModule(name) {
1266
+ try {
1267
+ const proc = globalThis.process
1268
+ return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
1269
+ } catch (e) { return null }
1270
+ }
1271
+
1272
+ function schedulingSettingsPath(env) {
1273
+ const e = env || (globalThis.process && globalThis.process.env) || {}
1274
+ try {
1275
+ if (e.CLI_BRIDGE_SETTINGS_FILE) return String(e.CLI_BRIDGE_SETTINGS_FILE)
1276
+ const nodePath = schedBuiltinModule('node:path')
1277
+ if (nodePath && e.DSH_HOME) return nodePath.join(String(e.DSH_HOME), 'cli-bridge-settings.json')
1278
+ } catch (e2) { }
1279
+ return null
1280
+ }
1281
+
1282
+ /**
1283
+ * 生效调度:文件 _scheduling > 缺省全开。
1284
+ * env/argv CLI_BRIDGE_PROVIDER(单面钉住,旧薄封装兼容语义)存在时视为最高优先 ——
1285
+ * 它代表「这个 MCP 进程只为这一家而启动」(部署者的显式意志,比面板勾选更具体、
1286
+ * 更靠近进程生命周期),调度不再削减它。
1287
+ * 宿主形态(preset / dynamic 桥接插件)应传 ignorePin:钉住只是 MCP 进程的启动参数,
1288
+ * 不该让全局 env 反过来把用户会话里的其他桥关掉。
1289
+ * @param {string[]} ids 全量 provider id
1290
+ * @param {{ env?: Record<string,string>, readFileSync?: Function, path?: string|null, ignorePin?: boolean }} [io]
1291
+ */
1292
+ function resolveScheduling(ids, io) {
1293
+ const o = io || {}
1294
+ const env = o.env || (globalThis.process && globalThis.process.env) || {}
1295
+ let raw = null
1296
+ try {
1297
+ const p = ('path' in o) ? o.path : schedulingSettingsPath(env)
1298
+ const nodeFs = o.readFileSync ? { readFileSync: o.readFileSync } : schedBuiltinModule('node:fs')
1299
+ if (p && nodeFs && typeof nodeFs.readFileSync === 'function') {
1300
+ raw = JSON.parse(nodeFs.readFileSync(p, 'utf8'))
1301
+ }
1302
+ } catch (e) { raw = null }
1303
+ const file = normalizeScheduling(raw && typeof raw === 'object' ? raw[SCHEDULING_SECTION] : null, ids)
1304
+ const pinned = o.ignorePin ? '' : String(env.CLI_BRIDGE_PROVIDER || '').trim()
1305
+ if (pinned && ids.indexOf(pinned) >= 0) {
1306
+ return Object.assign(file, {
1307
+ enabled: [pinned],
1308
+ order: [pinned],
1309
+ pinned: pinned,
1310
+ source: raw ? 'env:CLI_BRIDGE_PROVIDER+file' : 'env:CLI_BRIDGE_PROVIDER'
1311
+ })
1312
+ }
1313
+ return Object.assign(file, { pinned: '', source: raw ? 'file' : 'default' })
1314
+ }
1315
+
1316
+ /**
1317
+ * 桥接面门禁(preset / dynamic 的 apply() 首行用):本 provider 是否被调度启用。
1318
+ * fail-open —— 任何异常(无 fs、文件损坏、沙箱限制)都返回 true:读不到配置永远
1319
+ * 不等于关掉桥(否则一次文件损坏就把三条 CLI 全灭了)。ignorePin 固定 true:
1320
+ * 钉住语义属于 MCP 独立进程,宿主形态的全局 env 不该反向影响用户会话。
1321
+ */
1322
+ function isBridgeEnabled(selfId, ids, io) {
1323
+ try {
1324
+ const list = Array.isArray(ids) ? ids : []
1325
+ // 全量清单本身不可得(空/不含自己)= 无门禁语义,fail-open。
1326
+ if (!list.length || list.indexOf(String(selfId)) < 0) return true
1327
+ const o = Object.assign({ ignorePin: true }, io || {})
1328
+ return resolveScheduling(list, o).enabled.indexOf(String(selfId)) >= 0
1329
+ } catch (e) { return true }
1330
+ }
1331
+
1332
+
1333
+ const SCHED_ALL_IDS = ["agy","codebuddy","mimo","opencode"]
1334
+
1335
+
1336
+ return {
1337
+ inject: ['tools', 'subprocess', 'systemPrompt', 'timer'],
1338
+ apply(ctx) {
1339
+ // 调度门禁(0.2.0):设置面板关掉 opencode → 本形态不注册工具/政策段;fail-open。
1340
+ if (!isBridgeEnabled('opencode', SCHED_ALL_IDS)) return
1341
+ const subprocess = ctx.subprocess
1342
+ const sandboxPolicy = ctx.get('sandboxPolicy')
1343
+ // 沙箱可能运行在无 process 的环境:typeof 守卫读 env(旧仓 dynamic 的等价面)。
1344
+ const env = (typeof process !== 'undefined' && process.env) ? process.env : {}
1345
+
1346
+ const provider = defineProvider({ env })
1347
+
1348
+ function publish(snapshot) {
1349
+ try {
1350
+ const collector = ctx.get('cliBridgeCollector')
1351
+ if (collector && typeof collector.mergeSnapshot === 'function') { collector.mergeSnapshot('opencode', snapshot); return }
1352
+ } catch (e) { /* fall through to legacy alias */ }
1353
+ try {
1354
+ const legacy = ctx.get('opencodeCollector')
1355
+ if (legacy && typeof legacy.mergeSnapshot === 'function') legacy.mergeSnapshot(snapshot)
1356
+ } catch (e) { /* 灯数据尽力而为 */ }
1357
+ }
1358
+
1359
+ // exe 优先级(与描述符同一套):env OPENCODE_BIN/OPENCODE_CLI > 桌面版 V2 探测
1360
+ // > PATH 查找 opencode-cli(subprocess.resolveExecutable)。
1361
+ // 二进制名 opencode-cli(V2 桌面版),不做 opencode.exe 兜底(V1/V2 不能并存)。
1362
+ const exeCandidates = resolveExePrefix(env, '')
1363
+ async function resolveExe(backend, signal) {
1364
+ for (const c of exeCandidates) {
1365
+ if (/\.(mjs|cjs|js)$/i.test(c) && typeof process !== 'undefined' && process.execPath) return [process.execPath, c]
1366
+ return c
1367
+ }
1368
+ return await subprocess.resolveExecutable('opencode-cli', undefined, signal)
1369
+ }
1370
+
1371
+ // cwd 兜底:OPENCODE_MCP_CWD > sandboxPolicy.workspaceRoot > process.cwd() > '.'。
1372
+ function getCwdFallback() {
1373
+ if (env.OPENCODE_MCP_CWD) return String(env.OPENCODE_MCP_CWD)
1374
+ if (sandboxPolicy && typeof sandboxPolicy.workspaceRoot === 'string' && sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot
1375
+ if (typeof process !== 'undefined' && typeof process.cwd === 'function') { try { return process.cwd() } catch (e) {} }
1376
+ return '.'
1377
+ }
1378
+
1379
+ provider.attach({ ctx, subprocess, resolveExe, getCwdFallback, publish })
1380
+
1381
+ const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
1382
+
1383
+ harness.registerTool(ctx, harness.defineTool({
1384
+ name: provider.tools.run.name,
1385
+ description: provider.tools.run.description,
1386
+ parameters: provider.tools.run.inputSchema,
1387
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
1388
+ execute: function (args, exec) { return provider.runToolLogic(args, exec) }
1389
+ }))
1390
+ harness.registerTool(ctx, harness.defineTool({
1391
+ name: provider.tools.continue.name,
1392
+ description: provider.tools.continue.description,
1393
+ parameters: provider.tools.continue.inputSchema,
1394
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
1395
+ execute: function (args, exec) { return provider.continueToolLogic(args, exec) }
1396
+ }))
1397
+ harness.registerTool(ctx, harness.defineTool({
1398
+ name: provider.tools.status.name,
1399
+ description: provider.tools.status.description,
1400
+ parameters: provider.tools.status.inputSchema,
1401
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderStatus(value, provider) } },
1402
+ execute: function (args) { return provider.statusToolLogic(args) }
1403
+ }))
1404
+
1405
+ // 浏览器指示灯轮询的 RPC 面(旧 dynamic/handle('<id>_status') verbatim 语义)。
1406
+ harness.handle('opencode_status', function () { return provider.statusToolLogic({}) })
1407
+
1408
+ const policyText = [
1409
+ 'opencode-first execution policy (local opencode CLI bridge).',
1410
+ '',
1411
+ 'Local agent CLI "opencode" handles coding tasks under full DSH control (permissions auto-approved, never blocks on a prompt). By default, opencode runs in the background and returns a jobId immediately; collect results via job_output and watch live progress via opencode_status anytime.',
1412
+ '',
1413
+ 'Decision table for tool selection:',
1414
+ '- Single read/glob/grep or short (<30s) command -> Native tools.',
1415
+ '- Implementing, editing, refactoring, debugging, builds, multi-file investigation -> opencode_run.',
1416
+ '- Follow-ups on the same topic -> opencode_continue (with latest=true or sessionId; opencode preserves session context).',
1417
+ '- Final build/test verification of opencode output -> Native tools.',
1418
+ '',
1419
+ 'Context protocol (one topic = one opencode session):',
1420
+ '- First dispatch on a topic: provide the task with a compact CONTEXT preamble (point to paths, do not paste file contents):',
1421
+ ' CONTEXT: 2-6 lines — goal; repo root; key paths; decisions/constraints; current state.',
1422
+ ' TASK: the complete instruction.',
1423
+ '- Follow-ups: call opencode_continue and state only what is new.',
1424
+ '',
1425
+ 'Execution & Fallback protocol:',
1426
+ '- Dispatch defaults to background=true (set background=false only to block). Collect via job_output.',
1427
+ '- On rate-limit or network failure, a confirmation dialog opens in BOTH foreground and background while in flight.',
1428
+ '- If result has fallback=true (status FALLBACK_TO_DSH), the user chose DSH-local: finish with native tools and NEVER call opencode again for this task.',
1429
+ '- Never loop opencode calls; never ask opencode to call back into DSH.',
1430
+ '',
1431
+ 'Model & credentials:',
1432
+ '- Prefer a free model for routine work: pass a model id ending in -free (e.g. "opencode/nemotron-3-ultra-free") unless this task genuinely needs a frontier model.',
1433
+ '- Omit the model unless you have a reason: opencode then uses its own configured default (or the one chosen in CLI Bridge settings). Pass model in provider/model form to override.',
1434
+ '- Credentials are owned by opencode, not by this bridge: auth/API-key failures mean the user should run `opencode auth login`. Do not retry in a loop.'
1435
+ ].join('\n')
1436
+
1437
+ ctx.systemPrompt.section({
1438
+ name: 'opencode:policy',
1439
+ order: (typeof ctx.systemPrompt.getSectionOrder === 'function' ? ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT') : 2800) + 10,
1440
+ text: policyText
1441
+ })
1442
+ }
1443
+ }