@luchenglong/cli-bridge 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/LICENSE +21 -0
- package/MCP-POLICY.md +53 -0
- package/PROVIDER-CONTRACT.md +235 -0
- package/README.en.md +98 -0
- package/README.md +151 -2
- package/assets/indicator-states.svg +62 -0
- package/bin/agy-quota.mjs +223 -0
- package/core/bridge-kit.mjs +549 -0
- package/core/scheduling.mjs +149 -0
- package/docs/ARCHITECTURE.md +117 -0
- package/docs/FALLBACK-AND-INDICATOR.md +93 -0
- package/docs/HANDOVER.md +226 -0
- package/docs/INSTALL.md +118 -0
- package/docs/MCP-POLICY.zh.md +49 -0
- package/docs/MIGRATION.md +43 -0
- package/docs/RELEASE-SOP.md +80 -0
- package/docs/history/agy/CHANGELOG.md +326 -0
- package/docs/history/codebuddy/CHANGELOG.md +668 -0
- package/docs/history/mimo/CHANGELOG.md +346 -0
- package/dynamic/agy.host.js +1237 -0
- package/dynamic/agy.host.template.mjs +140 -0
- package/dynamic/codebuddy.host.js +1787 -0
- package/dynamic/codebuddy.host.template.mjs +162 -0
- package/dynamic/mimo.host.js +1732 -0
- package/dynamic/mimo.host.template.mjs +169 -0
- package/dynamic/opencode.host.js +1443 -0
- package/dynamic/opencode.host.template.mjs +125 -0
- package/home-plugin/cli-bridge-indicator/cordis.patch.yml +31 -0
- package/home-plugin/cli-bridge-indicator/lib/client.js +606 -0
- package/home-plugin/cli-bridge-indicator/lib/index.mjs +420 -0
- package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +137 -0
- package/home-plugin/cli-bridge-indicator/lib/settings.mjs +300 -0
- package/mcp/agy-mcp-server.mjs +6 -0
- package/mcp/cli-bridge-mcp.mjs +185 -0
- package/mcp/codebuddy-mcp-server.mjs +6 -0
- package/mcp/mimo-mcp-server.mjs +6 -0
- package/mcp/providers/agy.mjs +254 -0
- package/mcp/providers/codebuddy.mjs +397 -0
- package/mcp/providers/mimo.mjs +290 -0
- package/mcp/providers/opencode.mjs +236 -0
- package/package.json +89 -4
- package/preset/agy-first/agent.cordis.yml +141 -0
- package/preset/agy-first/cli-bridge.mjs +169 -0
- package/preset/agy-first/plugin-list.mjs +26 -0
- package/preset/agy-first/preset.yml +3 -0
- package/preset/codebuddy-first/agent.cordis.yml +141 -0
- package/preset/codebuddy-first/cli-bridge.mjs +188 -0
- package/preset/codebuddy-first/plugin-list.mjs +20 -0
- package/preset/codebuddy-first/preset.yml +3 -0
- package/preset/mimo-first/agent.cordis.yml +143 -0
- package/preset/mimo-first/cli-bridge.mjs +236 -0
- package/preset/mimo-first/plugin-list.mjs +39 -0
- package/preset/mimo-first/preset.yml +3 -0
- package/preset/opencode-first/agent.cordis.yml +141 -0
- package/preset/opencode-first/cli-bridge.mjs +147 -0
- package/preset/opencode-first/plugin-list.mjs +26 -0
- package/preset/opencode-first/preset.yml +3 -0
- package/preset/standard-base.mjs +102 -0
- package/providers/agy.mjs +398 -0
- package/providers/codebuddy.mjs +926 -0
- package/providers/mimo.mjs +864 -0
- package/providers/opencode.mjs +619 -0
- package/providers/registry.mjs +39 -0
- package/scripts/build.mjs +235 -0
- package/scripts/deploy.mjs +61 -0
- package/scripts/verify.mjs +148 -0
|
@@ -0,0 +1,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
|
+
}
|