@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/LICENSE +21 -0
  3. package/MCP-POLICY.md +53 -0
  4. package/PROVIDER-CONTRACT.md +235 -0
  5. package/README.en.md +98 -0
  6. package/README.md +151 -2
  7. package/assets/indicator-states.svg +62 -0
  8. package/bin/agy-quota.mjs +223 -0
  9. package/core/bridge-kit.mjs +549 -0
  10. package/core/scheduling.mjs +149 -0
  11. package/docs/ARCHITECTURE.md +117 -0
  12. package/docs/FALLBACK-AND-INDICATOR.md +93 -0
  13. package/docs/HANDOVER.md +226 -0
  14. package/docs/INSTALL.md +118 -0
  15. package/docs/MCP-POLICY.zh.md +49 -0
  16. package/docs/MIGRATION.md +43 -0
  17. package/docs/RELEASE-SOP.md +80 -0
  18. package/docs/history/agy/CHANGELOG.md +326 -0
  19. package/docs/history/codebuddy/CHANGELOG.md +668 -0
  20. package/docs/history/mimo/CHANGELOG.md +346 -0
  21. package/dynamic/agy.host.js +1237 -0
  22. package/dynamic/agy.host.template.mjs +140 -0
  23. package/dynamic/codebuddy.host.js +1787 -0
  24. package/dynamic/codebuddy.host.template.mjs +162 -0
  25. package/dynamic/mimo.host.js +1732 -0
  26. package/dynamic/mimo.host.template.mjs +169 -0
  27. package/dynamic/opencode.host.js +1443 -0
  28. package/dynamic/opencode.host.template.mjs +125 -0
  29. package/home-plugin/cli-bridge-indicator/cordis.patch.yml +31 -0
  30. package/home-plugin/cli-bridge-indicator/lib/client.js +606 -0
  31. package/home-plugin/cli-bridge-indicator/lib/index.mjs +420 -0
  32. package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +137 -0
  33. package/home-plugin/cli-bridge-indicator/lib/settings.mjs +300 -0
  34. package/mcp/agy-mcp-server.mjs +6 -0
  35. package/mcp/cli-bridge-mcp.mjs +185 -0
  36. package/mcp/codebuddy-mcp-server.mjs +6 -0
  37. package/mcp/mimo-mcp-server.mjs +6 -0
  38. package/mcp/providers/agy.mjs +254 -0
  39. package/mcp/providers/codebuddy.mjs +397 -0
  40. package/mcp/providers/mimo.mjs +290 -0
  41. package/mcp/providers/opencode.mjs +236 -0
  42. package/package.json +89 -4
  43. package/preset/agy-first/agent.cordis.yml +141 -0
  44. package/preset/agy-first/cli-bridge.mjs +169 -0
  45. package/preset/agy-first/plugin-list.mjs +26 -0
  46. package/preset/agy-first/preset.yml +3 -0
  47. package/preset/codebuddy-first/agent.cordis.yml +141 -0
  48. package/preset/codebuddy-first/cli-bridge.mjs +188 -0
  49. package/preset/codebuddy-first/plugin-list.mjs +20 -0
  50. package/preset/codebuddy-first/preset.yml +3 -0
  51. package/preset/mimo-first/agent.cordis.yml +143 -0
  52. package/preset/mimo-first/cli-bridge.mjs +236 -0
  53. package/preset/mimo-first/plugin-list.mjs +39 -0
  54. package/preset/mimo-first/preset.yml +3 -0
  55. package/preset/opencode-first/agent.cordis.yml +141 -0
  56. package/preset/opencode-first/cli-bridge.mjs +147 -0
  57. package/preset/opencode-first/plugin-list.mjs +26 -0
  58. package/preset/opencode-first/preset.yml +3 -0
  59. package/preset/standard-base.mjs +102 -0
  60. package/providers/agy.mjs +398 -0
  61. package/providers/codebuddy.mjs +926 -0
  62. package/providers/mimo.mjs +864 -0
  63. package/providers/opencode.mjs +619 -0
  64. package/providers/registry.mjs +39 -0
  65. package/scripts/build.mjs +235 -0
  66. package/scripts/deploy.mjs +61 -0
  67. package/scripts/verify.mjs +148 -0
@@ -0,0 +1,549 @@
1
+ // core/bridge-kit.mjs — CLI Bridge 单一事实来源:通用执行/状态骨架。
2
+ //
3
+ // 深度重构核心(合并 agy-first-bridge / codebuddy-first-bridge / mimo-first-bridge):
4
+ // 三条 bridge 的 host 半此前各自复制了一份几乎相同的「状态引擎 + 行流 + 执行编排
5
+ // + 限流回退弹窗 + MCP 快照上报 + 展示层」。本文件把这些**与 provider 无关**的骨架
6
+ // 收敛成一份参数化实现,每个 provider 只提供一个 descriptor(见 providers/*.mjs 与
7
+ // PROVIDER-CONTRACT.md),由 createRunner/createStatusEngine 回调它完成差异化步骤。
8
+ //
9
+ // 骨架覆盖:
10
+ // - 纯函数:projectName / clampInt / shortLabel / summarizeArgs / endpointHost
11
+ // - 限流/网络判定:LIMIT_RE + isLimited(provider 可收窄)
12
+ // - 半行安全行流:createLineStream
13
+ // - 按项目(cwd)聚合、按 provider 分桶的状态引擎:createStatusEngine
14
+ // - 执行编排(前台/后台/超时守卫/瞬时错误重试/限流三选一弹窗/回退):createRunner
15
+ // - 工具结果与状态的人类可读渲染:renderResult / renderStatus
16
+ // - MCP 子进程快照上报:buildMcpBridgePayload / normalizeMcpBridge(provider 分文件)
17
+ // - 回退弹窗三选一文案(provider 名注入)
18
+ //
19
+ // 约束(沙箱与真实 Node 共用,勿违反):
20
+ // - 本模块**不 import 任何模块**、不用 process/Buffer/原生定时器/AbortController;
21
+ // 宿主能力(subprocess.spawn / ctx.interval / ctx.timeout / jobs / userQuestions)
22
+ // 全部经 createRunner 的 opts 注入,取消用 exec.signal + handle.terminate()。
23
+ // - 不序列化任何 DSH 活对象;跨 RPC 只传无 Host 引用的纯 JSON 叶子字段。
24
+
25
+ // ── 常量 ──────────────────────────────────────────────────────────────────────
26
+
27
+ export const MAX_TRAIL = 12
28
+ export const MAX_ARG_LEN = 120
29
+ export const MAX_PROJECTS = 24
30
+ export const MAX_SESSIONS = 256
31
+
32
+ // 限流/网络类错误的宽松正则(英文 + 中文)。provider descriptor 可用自己的收窄版覆盖。
33
+ export const LIMIT_RE =
34
+ /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
35
+
36
+ // 家级插件与 MCP 子进程之间的快照文件:{dsh-home}/cli-bridge-mcp-<provider>.json
37
+ export const MCP_BRIDGE_PREFIX = 'cli-bridge-mcp-'
38
+ export const MCP_BRIDGE_STALE_MS = 90000
39
+ export function mcpBridgeFileName(providerId) { return MCP_BRIDGE_PREFIX + providerId + '.json' }
40
+
41
+ // ── 纯函数 ────────────────────────────────────────────────────────────────────
42
+
43
+ export function projectName(cwd) {
44
+ const s = String(cwd || '')
45
+ const parts = s.split(/[\\/]/).filter(Boolean)
46
+ return parts.length ? parts[parts.length - 1] : s
47
+ }
48
+
49
+ export function clampInt(v, def, min, max) {
50
+ const n = Number(v)
51
+ if (!Number.isFinite(n)) return def
52
+ const i = Math.floor(n)
53
+ if (i < min) return min
54
+ if (i > max) return max
55
+ return i
56
+ }
57
+
58
+ export function shortLabel(prompt) {
59
+ const s = String(prompt || '').replace(/\s+/g, ' ').trim()
60
+ return s.length > 80 ? s.slice(0, 77) + '...' : s
61
+ }
62
+
63
+ // 工具入参摘要(状态灯/活动面板)。返回可无损 JSON 化的数据;undefined 值跳过
64
+ // (DSH tool 渲染层不接受 undefined)。
65
+ export function summarizeArgs(parameters) {
66
+ if (!parameters || typeof parameters !== 'object') return null
67
+ const out = {}
68
+ for (const k of Object.keys(parameters)) {
69
+ let v = parameters[k]
70
+ if (v === undefined) continue
71
+ if (typeof v === 'string' && v.length > MAX_ARG_LEN) v = v.slice(0, MAX_ARG_LEN) + '…'
72
+ out[k] = v
73
+ }
74
+ return out
75
+ }
76
+
77
+ export function endpointHost(url) {
78
+ const m = String(url || '').match(/^https?:\/\/([^/]+)/i)
79
+ return m ? m[1] : ''
80
+ }
81
+
82
+ // 限流/网络类失败(要问用户,重试可能烧额度)。
83
+ export function isLimited(res) {
84
+ if (!res || res.ok) return false
85
+ // AUTH_REQUIRED 是配置问题(缺凭据),弹回退/重试无意义 —— 直接给可操作指引。
86
+ if (res.status === 'AUTH_REQUIRED') return false
87
+ if (res.status === 'SPAWN_ERROR' || res.status === 'CLI_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
88
+ const hay = String(res.stderr || '') + ' ' + String(res.status || '')
89
+ // 端点域与登录域不匹配的 401:需要用户换登录域,不是限流。
90
+ if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
91
+ return LIMIT_RE.test(hay)
92
+ }
93
+
94
+ // ── 半行安全的行流(跨 chunk 的 NDJSON 行拼接)───────────────────────────────
95
+ export function createLineStream(consumeLine) {
96
+ let pending = ''
97
+ function pushChunk(text) {
98
+ if (typeof text !== 'string' || !text) return
99
+ const lines = (pending + text).split(/\r?\n/)
100
+ pending = lines.pop() || ''
101
+ for (const ln of lines) consumeLine(ln)
102
+ }
103
+ function flush() {
104
+ const rest = pending
105
+ pending = ''
106
+ if (rest) consumeLine(rest)
107
+ }
108
+ return { pushChunk, flush }
109
+ }
110
+
111
+ // ── 状态引擎(按 provider + 项目目录聚合的运行状态表)─────────────────────────
112
+ // opts: { provider: <descriptor>, publish(snapshot) }
113
+ // 快照结构(顶层为全局聚合,向后兼容单 provider 消费者):
114
+ // { provider, state, running, lastStatus, lastAt, lastSessionId, lastBackend,
115
+ // fallbackActive, current, trail, runs, totalTokens, updatedAt,
116
+ // projects:[{ cwd,name,provider,state,running,current,trail,lastStatus,lastAt,
117
+ // lastSessionId,lastBackend,fallbackActive,runs,totalTokens,updatedAt }] }
118
+ export function createStatusEngine(opts) {
119
+ const o = opts || {}
120
+ const provider = o.provider
121
+ const providerId = provider && provider.id ? provider.id : 'cli'
122
+ const publish = typeof o.publish === 'function' ? o.publish : null
123
+ const projects = Object.create(null)
124
+ // sessionId -> { cwd, backend, provider }。会话按 provider 归属(登录域互斥),
125
+ // 续接时一次解析出 cwd 与 backend。容量上限 MAX_SESSIONS,FIFO 淘汰。
126
+ const sessions = Object.create(null)
127
+
128
+ function ensureProject(cwd) {
129
+ const key = String(cwd || '')
130
+ let p = projects[key]
131
+ if (!p) {
132
+ if (Object.keys(projects).length >= MAX_PROJECTS) {
133
+ const idle = Object.keys(projects).filter((k) => projects[k].running === 0)
134
+ const pool = idle.length ? idle : Object.keys(projects)
135
+ const victim = pool.sort((a, b) => projects[a].updatedAt - projects[b].updatedAt)[0]
136
+ delete projects[victim]
137
+ }
138
+ 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 }
139
+ projects[key] = p
140
+ }
141
+ return p
142
+ }
143
+
144
+ function globalStatus() {
145
+ const list = Object.keys(projects).map((k) => projects[k])
146
+ let running = 0, lastStatus = null, lastAt = 0, lastSessionId = null, lastBackend = null, fallbackActive = false, current = null, trail = [], updatedAt = 0, runs = 0, totalTokens = 0
147
+ for (const p of list) {
148
+ running += p.running
149
+ if (p.updatedAt > updatedAt) updatedAt = p.updatedAt
150
+ if (p.running > 0 && !current && p.current) current = p.current
151
+ if (p.lastAt > lastAt) { lastAt = p.lastAt; lastStatus = p.lastStatus; lastSessionId = p.lastSessionId; lastBackend = p.lastBackend }
152
+ if (p.fallbackActive) fallbackActive = true
153
+ runs += p.runs || 0
154
+ totalTokens += p.totalTokens || 0
155
+ for (const e of p.trail) trail.push(e)
156
+ }
157
+ trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
158
+ trail = trail.slice(-MAX_TRAIL)
159
+ const state = running > 0 ? 'running' : (fallbackActive ? 'fallback' : (lastStatus ? (lastStatus === 'SUCCESS' ? 'ok' : 'failed') : 'idle'))
160
+ return { state, running, lastStatus, lastAt, lastSessionId, lastBackend, fallbackActive, current, trail, runs, totalTokens, updatedAt }
161
+ }
162
+
163
+ function statusSnapshot() {
164
+ const g = globalStatus()
165
+ const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
166
+ return Object.assign({ provider: providerId }, g, {
167
+ 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 }))
168
+ })
169
+ }
170
+
171
+ function emit() { if (publish) { try { publish(statusSnapshot()) } catch (e) {} } }
172
+
173
+ function begin(cwd) {
174
+ const p = ensureProject(cwd)
175
+ p.running += 1
176
+ p.state = 'running'
177
+ p.updatedAt = Date.now()
178
+ emit()
179
+ }
180
+
181
+ function end(res, cwd) {
182
+ const p = ensureProject(cwd)
183
+ p.running = Math.max(0, p.running - 1)
184
+ p.lastStatus = res ? res.status : null
185
+ p.lastAt = Date.now()
186
+ if (res && res.sessionId) {
187
+ p.lastSessionId = res.sessionId
188
+ sessions[res.sessionId] = { cwd: p.cwd, backend: res.backend || (provider && provider.defaultBackend), provider: providerId }
189
+ const keys = Object.keys(sessions)
190
+ if (keys.length > MAX_SESSIONS) delete sessions[keys[0]]
191
+ }
192
+ if (res && res.backend) p.lastBackend = res.backend
193
+ p.runs = (p.runs || 0) + 1
194
+ if (res && typeof res.totalTokens === 'number' && res.totalTokens > 0) p.totalTokens = (p.totalTokens || 0) + res.totalTokens
195
+ if (res && res.fallback) { p.fallbackActive = true; p.state = 'fallback' }
196
+ else if (p.running > 0) { p.state = 'running' }
197
+ else { p.state = res && res.ok ? 'ok' : 'failed'; p.lastOk = !!(res && res.ok); p.lastFailed = !(res && res.ok) }
198
+ p.current = null
199
+ p.updatedAt = Date.now()
200
+ emit()
201
+ }
202
+
203
+ // 流事件折叠为实时状态。provider 若有 stream-json,提供 foldStreamEvent(ev, project);
204
+ // 否则回退到 generic 形态(不产生实时步骤)。
205
+ function foldEvent(ev, cwd) {
206
+ const p = ensureProject(cwd)
207
+ p.updatedAt = Date.now()
208
+ if (provider && typeof provider.foldStreamEvent === 'function') {
209
+ try { provider.foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) } catch (e) {}
210
+ }
211
+ emit()
212
+ }
213
+
214
+ // 会话路由(cwd + backend 一次解析)。续接(--resume/--continue)时未给 cwd
215
+ // 则回落到该 session 所在项目的 cwd(否则换目录报找不到会话)。
216
+ function resolveTarget(args, fallbackCwd) {
217
+ const a = args || {}
218
+ let backend = null, cwd = null
219
+ if (a.sessionId) { const hit = sessions[a.sessionId]; if (hit && hit.provider === providerId) { backend = hit.backend; cwd = hit.cwd } }
220
+ if (a.cwd) {
221
+ cwd = String(a.cwd)
222
+ } else if (!cwd && (a.sessionId || a.continueLatest)) {
223
+ const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
224
+ if (a.sessionId) {
225
+ const projHit = list.find((p) => p.lastSessionId === a.sessionId)
226
+ if (projHit) { cwd = projHit.cwd; if (!backend) backend = projHit.lastBackend }
227
+ } else if (list.length && list[0].lastSessionId) { cwd = list[0].cwd; if (!backend) backend = list[0].lastBackend }
228
+ }
229
+ return { cwd: cwd || fallbackCwd, backend }
230
+ }
231
+
232
+ function resolveCwd(args, fallbackCwd) { return resolveTarget(args, fallbackCwd).cwd }
233
+
234
+ return { providerId, projects, sessions, ensureProject, globalStatus, statusSnapshot, begin, end, foldEvent, resolveCwd, resolveTarget }
235
+ }
236
+
237
+ // ── 回退弹窗三选一(provider 名注入)──────────────────────────────────────────
238
+ export function fallbackLabels(provider) {
239
+ const bk = (provider && provider.id) || 'CLI'
240
+ return {
241
+ fallback: '使用 DSH 本地 API 配置(回退)',
242
+ retry: '重试 ' + bk + ' 一次',
243
+ cancel: '不回退(返回错误)'
244
+ }
245
+ }
246
+
247
+ // 失败结果的可行动指引;provider.failureHint 优先,未给则通用兜底。
248
+ export function failureHint(res, provider) {
249
+ if (!res || res.ok) return ''
250
+ if (provider && typeof provider.failureHint === 'function') {
251
+ try { const h = provider.failureHint(res); if (h) return h } catch (e) {}
252
+ }
253
+ if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
254
+ if (res.status === 'PARSE_ERROR') return '[诊断] 未能从输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 CLI 版本。'
255
+ if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
256
+ return '[诊断] 认证失败(401):请求端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 CLI 登录后重试;这不是限流。'
257
+ }
258
+ return ''
259
+ }
260
+
261
+ export function fallbackResult(res, mode, provider) {
262
+ const bk = (provider && provider.id) || 'CLI'
263
+ 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 + ']' })
264
+ }
265
+
266
+ // ── 展示层 ────────────────────────────────────────────────────────────────────
267
+ export function renderResult(value, provider) {
268
+ const v = value || {}
269
+ // backendLabel(codebuddy 旧语义 v1.1.0 verbatim:'workbuddy OK'/'codebuddy-intl OK'):
270
+ // 结果带具体 backend 时用 backend 标头;agy/mimo 旧包标头是硬编码 provider 名,不开此旗。
271
+ const bk = (provider && provider.backendLabel && v.backend) || (provider && provider.id) || v.backend || 'CLI'
272
+ if (v.background) return [{ type: 'text', text: bk + ' dispatched in background (mode=' + v.mode + '). jobId=' + v.jobId + '. Collect with job_output.' }]
273
+ if (v.fallback) return [{ type: 'text', text: bk + ' 回退:用户选择使用 DSH 本地 API 配置(原因 ' + v.reason + ')。请改用原生工具/本地模型完成本任务,不要再调 ' + bk + '。' }]
274
+ 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' : '') + ']'
275
+ const hint = failureHint(v, provider)
276
+ let body = v.response ? v.response : (v.stderr ? '[stderr] ' + v.stderr : (v.rawStdout ? '[raw] ' + v.rawStdout : ''))
277
+ if (hint) body = (body ? body + '\n\n' : '') + hint
278
+ return [{ type: 'text', text: head + (body ? '\n\n' + body : '') }]
279
+ }
280
+
281
+ export function renderStatus(value, provider) {
282
+ const v = value || {}
283
+ const bk = (provider && provider.id) || 'CLI'
284
+ const lines = []
285
+ 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' : ''))
286
+ const projList = (v.projects && v.projects.length) ? v.projects : null
287
+ if (projList) {
288
+ for (const p of projList) {
289
+ const cur = p.current ? (' step ' + p.current.stepIndex + ' → ' + p.current.tool + (p.current.args ? ' ' + JSON.stringify(p.current.args) : '')) : (p.running > 0 ? ' (starting / thinking)' : '')
290
+ const usage = (p.runs ? ' | total ' + p.runs + ' runs, ' + (p.totalTokens || 0) + ' tokens' : '')
291
+ const bkTag = (p.lastBackend && p.lastBackend !== bk) ? ' [' + p.lastBackend + ']' : ''
292
+ 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)
293
+ if (p.trail && p.trail.length) {
294
+ lines.push(' steps:')
295
+ 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) }
296
+ }
297
+ }
298
+ } else {
299
+ if (v.current) { const c = v.current; lines.push('current: step ' + c.stepIndex + ' → ' + c.tool + (c.args ? ' ' + JSON.stringify(c.args) : '')) }
300
+ else if (v.state === 'running') lines.push('current: (starting / thinking)')
301
+ if (v.trail && v.trail.length) {
302
+ lines.push('recent steps:')
303
+ 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) }
304
+ }
305
+ if (v.lastStatus) lines.push('last: ' + v.lastStatus + (v.lastSessionId ? ' session=' + v.lastSessionId : '') + (v.lastAt ? ' @ ' + new Date(v.lastAt).toISOString() : ''))
306
+ }
307
+ if (v.updatedAt) lines.push('updatedAt: ' + new Date(v.updatedAt).toISOString())
308
+ return [{ type: 'text', text: lines.join('\n') }]
309
+ }
310
+
311
+ // ── MCP 子进程快照上报 ────────────────────────────────────────────────────────
312
+ // MCP server 是独立 stdio 子进程:没有 ctx.emit、拿不到 collector 服务,所以把快照
313
+ // 原子写进 {dsh-home}/cli-bridge-mcp-<provider>.json,家级灯在响应请求时读取合并。
314
+ export function buildMcpBridgePayload(snap, pid, nowMs) {
315
+ const s = snap && typeof snap === 'object' ? snap : {}
316
+ const src = Array.isArray(s.projects) ? s.projects : []
317
+ const projects = []
318
+ for (const p of src) {
319
+ if (!p || !p.cwd) continue
320
+ projects.push({
321
+ cwd: String(p.cwd), name: p.name ? String(p.name) : '', provider: p.provider || s.provider || '', state: p.state ? String(p.state) : 'idle',
322
+ running: Number(p.running) || 0,
323
+ current: p.current ? { stepIndex: Number(p.current.stepIndex) || 0, tool: String(p.current.tool || ''), args: p.current.args || null } : null,
324
+ 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 })) : [],
325
+ lastStatus: p.lastStatus ? String(p.lastStatus) : null, lastAt: Number(p.lastAt) || 0, lastSessionId: p.lastSessionId ? String(p.lastSessionId) : null,
326
+ 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
327
+ })
328
+ }
329
+ return { source: 'mcp', provider: s.provider || '', pid: Number(pid) || 0, updatedAt: Number(nowMs) || 0, projects }
330
+ }
331
+
332
+ export function normalizeMcpBridge(payload, nowMs, staleMs) {
333
+ if (!payload || typeof payload !== 'object') return null
334
+ const projects = Array.isArray(payload.projects) ? payload.projects : []
335
+ const updatedAt = Number(payload.updatedAt) || 0
336
+ const stale = (Number(nowMs) || 0) - updatedAt > (Number(staleMs) || MCP_BRIDGE_STALE_MS)
337
+ const out = []
338
+ for (const p of projects) {
339
+ if (!p || !p.cwd) continue
340
+ if (stale && (Number(p.running) || 0) > 0) out.push(Object.assign({}, p, { running: 0, current: null, state: p.state === 'running' ? 'idle' : p.state }))
341
+ else out.push(p)
342
+ }
343
+ return { source: 'mcp', provider: payload.provider || '', pid: Number(payload.pid) || 0, updatedAt, stale, projects: out }
344
+ }
345
+
346
+ // ── 执行编排(三形态共用;provider 差异全走 descriptor)──────────────────────
347
+ // o: {
348
+ // ctx, // Cordis ctx(interval/timeout/get)
349
+ // subprocess, // DSH subprocess 服务
350
+ // provider, // descriptor(见 PROVIDER-CONTRACT.md)
351
+ // engine, // createStatusEngine({provider}) 实例
352
+ // resolveExe, // async (backend, signal) => 'exe' | [nodeExe, binPath] | throw
353
+ // getCwdFallback, // () => 默认 cwd
354
+ // getPreferredBackend, // () => 用户偏好后端(可缺省)
355
+ // getSettings, // () => 当前设置对象(可缺省;provider 用它读 defaultModel 等)
356
+ // planActiveFor, // (exec) => bool(plan 模式判定,可缺省)
357
+ // defaultMode // 'auto'
358
+ // }
359
+ export function createRunner(o) {
360
+ const ctx = o.ctx
361
+ const subprocess = o.subprocess
362
+ const provider = o.provider
363
+ const engine = o.engine
364
+ const stdio = { stdin: 'ignore', stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } }, stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } } }
365
+
366
+ function readStreams(handle) {
367
+ const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
368
+ const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
369
+ return { stdoutText, stderrText }
370
+ }
371
+
372
+ let built_env = null
373
+ function spawnSpec(argv, cwd, callerSignal) {
374
+ const spec = { argv, cwd, stdio, graceMs: 5000 }
375
+ if (callerSignal) spec.signal = callerSignal
376
+ if (built_env && typeof built_env === 'object') spec.env = built_env
377
+ return spec
378
+ }
379
+
380
+ function startLiveParser(handle, cwd) {
381
+ let cursor = 0
382
+ const stream = createLineStream((ln) => {
383
+ const t = ln.trim()
384
+ if (!t.startsWith('{')) return
385
+ try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
386
+ })
387
+ const tick = function () {
388
+ try {
389
+ const full = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
390
+ if (full.length <= cursor) return
391
+ const fresh = full.slice(cursor)
392
+ cursor = full.length
393
+ stream.pushChunk(fresh)
394
+ } catch (e) {}
395
+ }
396
+ const disposeInterval = ctx.interval(tick, 250)
397
+ return function disposeLive() { try { stream.flush() } catch (e) {} disposeInterval() }
398
+ }
399
+
400
+ async function runSync(argv, cwd, timeoutSec, callerSignal, extraEnv) {
401
+ const spec = spawnSpec(argv, cwd, callerSignal)
402
+ if (extraEnv && typeof extraEnv === 'object') spec.env = Object.assign({}, spec.env, extraEnv)
403
+ const handle = subprocess.spawn(spec)
404
+ let lastEventSummary = '(no events yet)'
405
+ let timedOut = false
406
+ const disposeTimer = ctx.timeout(function () {
407
+ timedOut = true
408
+ try {
409
+ const p = engine.ensureProject(cwd)
410
+ lastEventSummary = (p.current ? ('last step ' + p.current.stepIndex + ' -> ' + p.current.tool) : '') +
411
+ ' | trail=' + (p.trail.length ? p.trail.slice(-2).map((e) => '[' + e.state + ']' + e.tool).join(',') : 'empty') +
412
+ ' | elapsed=' + Math.round((Date.now() - (p.updatedAt || Date.now())) / 1000) + 's since last activity'
413
+ } catch (e) {}
414
+ try { handle.terminate() } catch (e) {}
415
+ }, (timeoutSec + 60) * 1000)
416
+ const disposeLive = startLiveParser(handle, cwd)
417
+ try {
418
+ const outcome = await handle.done
419
+ const s = readStreams(handle)
420
+ return { outcome, stdoutText: s.stdoutText, stderrText: s.stderrText, timedOut, lastEventSummary }
421
+ } finally { disposeLive(); disposeTimer() }
422
+ }
423
+
424
+ async function askFallback(exec, res, canRetry) {
425
+ const uq = ctx.get('userQuestions')
426
+ if (!uq || !exec || !exec.agent) return 'error'
427
+ const labels = fallbackLabels(provider)
428
+ const bk = provider.id
429
+ const detail = String(res.stderr || res.response || res.status || '').slice(-600)
430
+ const opts = [{ label: labels.fallback, description: '本次改由 DSH 本地模型/原生工具完成,不再走 ' + bk }]
431
+ if (canRetry) opts.push({ label: labels.retry, description: '再调用一次 ' + bk + '(网络抖动时可用)' })
432
+ opts.push({ label: labels.cancel, description: '不回退,直接返回 ' + bk + ' 错误' })
433
+ try {
434
+ 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 }] })
435
+ const sel = (ans && ans.answers && ans.answers[0] && ans.answers[0].selected) || []
436
+ if (sel.indexOf(labels.fallback) >= 0) return 'fallback'
437
+ if (sel.indexOf(labels.retry) >= 0) return 'retry'
438
+ return 'error'
439
+ } catch (e) { return 'error' }
440
+ }
441
+
442
+ async function coreExecute(rawArgs, exec) {
443
+ const args = rawArgs || {}
444
+ built_env = null
445
+ 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)
446
+ if (!args.prompt || !String(args.prompt).trim()) return mk({ stderr: 'prompt is required' })
447
+
448
+ const target = engine.resolveTarget(args, o.getCwdFallback())
449
+ const backend = provider.resolveBackend ? provider.resolveBackend(args, target, o.getPreferredBackend ? o.getPreferredBackend() : null) : (o.getPreferredBackend ? o.getPreferredBackend() : provider.defaultBackend)
450
+ const cwd = target.cwd
451
+
452
+ // provider 前置检查(凭据/桌面健康等):返回 { abort: res } 则直接结束。
453
+ if (provider.preflight) {
454
+ const pre = provider.preflight({ args, exec, backend, cwd, engine, ctx, o })
455
+ if (pre && pre.abort) { engine.begin(cwd); engine.end(pre.abort, cwd); return pre.abort }
456
+ }
457
+
458
+ let exePrefix = provider.fallbackExePrefix ? provider.fallbackExePrefix(backend) : [provider.exeName || provider.id]
459
+ let exeOk = true, resolveErr = ''
460
+ try { const resExe = await o.resolveExe(backend, exec ? exec.signal : undefined); exePrefix = Array.isArray(resExe) ? resExe : [resExe] }
461
+ catch (e) { exeOk = false; resolveErr = String(e && e.message || e) }
462
+
463
+ const built = provider.buildArgv(exePrefix, args, {
464
+ planActive: o.planActiveFor ? o.planActiveFor(exec) : false,
465
+ defaultMode: provider.defaultMode || 'auto',
466
+ defaultModel: (!args.model && o.getSettings) ? (o.getSettings() || {}).defaultModel || null : null,
467
+ backend, settings: o.getSettings ? o.getSettings() : null, engine
468
+ })
469
+ built_env = (built.env && typeof built.env === 'object') ? built.env : null
470
+ const unavailableStatus = provider.unavailableStatus || 'CLI_UNAVAILABLE'
471
+
472
+ if (!exeOk) {
473
+ engine.begin(cwd)
474
+ let res = mk({ status: unavailableStatus, mode: built.mode, backend, stderr: (backend || provider.id) + ' executable not found: ' + resolveErr })
475
+ if (await askFallback(exec, res, true) === 'fallback') res = fallbackResult(res, built.mode, provider)
476
+ engine.end(res, cwd); return res
477
+ }
478
+
479
+ // 非 spawn 路径(如 mimo 桌面走本地 HTTP):provider 若给 execute 且(未给 delegates
480
+ // 或 delegates(backend)=true)则完全交给它;否则落回通用 pipeline(jobs/重试/弹窗)。
481
+ if (provider.execute && (!provider.delegates || provider.delegates(backend, args))) {
482
+ engine.begin(cwd)
483
+ try {
484
+ const res = await provider.execute({ args, exec, backend, cwd, built, engine, ctx, o, runSync })
485
+ engine.end(res, cwd); return res
486
+ } catch (e) {
487
+ const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
488
+ engine.end(res, cwd); return res
489
+ }
490
+ }
491
+
492
+ const jobs = ctx.get('jobs')
493
+ if (args.background && jobs && exec && exec.agent) {
494
+ try {
495
+ engine.begin(cwd)
496
+ const jobId = jobs.start({
497
+ kind: 'bash', label: provider.id + ': ' + shortLabel(args.prompt), owner: exec.agent,
498
+ run() {
499
+ const handle = subprocess.spawn(spawnSpec(built.argv, cwd))
500
+ const disposeLive = startLiveParser(handle, cwd)
501
+ let bgKilled = false
502
+ const disposeGuard = ctx.timeout(function () { bgKilled = true; try { handle.terminate() } catch (e) {} }, (built.timeoutSec + 60) * 1000)
503
+ const done = handle.done.then(function (outcome) {
504
+ disposeLive(); disposeGuard()
505
+ const s = readStreams(handle)
506
+ const res = provider.buildResult(provider.parseJson(s.stdoutText), outcome, built.mode, s.stderrText, s.stdoutText, backend)
507
+ 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]' }
508
+ engine.end(res, cwd)
509
+ return { status: res.ok ? 'completed' : 'failed', detail: provider.id + ' ' + res.status, output: JSON.stringify(res) }
510
+ }).catch(function (err) { disposeLive(); disposeGuard(); engine.end(mk({ status: 'JOB_ERROR', backend }), cwd); return { status: 'failed', detail: String(err && err.message || err) } })
511
+ return { cancel: function () { try { handle.terminate() } catch (e) {} }, done }
512
+ }
513
+ })
514
+ 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.' }
515
+ } catch (e) {
516
+ const res = mk({ status: 'JOB_START_ERROR', mode: built.mode, backend, stderr: 'failed to start background job: ' + String(e && e.message || e) })
517
+ engine.end(res, cwd); return res
518
+ }
519
+ }
520
+
521
+ engine.begin(cwd)
522
+ try {
523
+ let attempt = 0, cliRetried = false, res
524
+ while (true) {
525
+ attempt += 1
526
+ const r = await runSync(built.argv, cwd, built.timeoutSec, exec ? exec.signal : undefined, built.env)
527
+ 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 }
528
+ res = provider.buildResult(provider.parseJson(r.stdoutText), r.outcome, built.mode, r.stderrText, r.stdoutText, backend)
529
+ if (res.ok) break
530
+ if (provider.isTransientCliError && provider.isTransientCliError(res)) {
531
+ if (!cliRetried) { cliRetried = true; attempt -= 1; continue }
532
+ res.retried = true; break
533
+ }
534
+ if (!(provider.isLimited ? provider.isLimited(res) : isLimited(res))) break
535
+ const decision = await askFallback(exec, res, attempt < 2)
536
+ if (decision === 'fallback') { res = fallbackResult(res, built.mode, provider); break }
537
+ if (decision === 'retry' && attempt < 2) continue
538
+ break
539
+ }
540
+ engine.end(res, cwd); return res
541
+ } catch (e) {
542
+ const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
543
+ const out = (await askFallback(exec, res, true) === 'fallback') ? fallbackResult(res, built.mode, provider) : res
544
+ engine.end(out, cwd); return out
545
+ }
546
+ }
547
+
548
+ return { coreExecute, runSync, askFallback }
549
+ }