@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,1732 @@
|
|
|
1
|
+
// dynamic/mimo.host.template.mjs — mimo 动态沙箱宿主模板(cordis_define code.host 源)。
|
|
2
|
+
//
|
|
3
|
+
// 构建(node scripts/build.mjs --only=mimo)把 core/bridge-kit.mjs 与
|
|
4
|
+
// providers/mimo.mjs 分别内联进下方两个占位标记行(各占一行、行内仅此标记),
|
|
5
|
+
// 生成 dynamic/mimo.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('mimo', snap)
|
|
13
|
+
// 推给家级灯(旧 mimoCollector 服务名在 keystone 里保留别名);动态形态不宣告
|
|
14
|
+
// mode(旧语义:普通模式灯仅在调用 mimo 时临时显示)。
|
|
15
|
+
// - mimo-desktop 在沙箱的形态(契约 §9):无 fs 发现 desktop-api.json,且多数沙箱
|
|
16
|
+
// 无 fetch —— 描述符 preflight 直接 abort 为 DESKTOP_UNREACHABLE 并提示改用
|
|
17
|
+
// backend=mimocode(旧仓返回 DESKTOP_UNSUPPORTED 的替代;语义升级,Lead 定案)。
|
|
18
|
+
// 宿主若注入了 globalThis.fetch 且给出 MIMO_DESKTOP_PORT/MIMO_DESKTOP_TOKEN env,
|
|
19
|
+
// 委托 execute 仍可走通(fetch 路径全部 typeof 守卫)。
|
|
20
|
+
// - mimo_settings 在沙箱不持久化(无 nodeFs → 描述符回显语义;旧 dynamic echo 形态)。
|
|
21
|
+
// - exe 解析(旧动态候选无 existsSync 校验,盲取候选 [0] 易 spawn 失败):升级走
|
|
22
|
+
// agy 模板同款链路 —— MIMO_BIN/MIMO_CLI env > subprocess.resolveExecutable(PATH)。
|
|
23
|
+
// core/bridge-kit.mjs — CLI Bridge 单一事实来源:通用执行/状态骨架。
|
|
24
|
+
//
|
|
25
|
+
// 深度重构核心(合并 agy-first-bridge / codebuddy-first-bridge / mimo-first-bridge):
|
|
26
|
+
// 三条 bridge 的 host 半此前各自复制了一份几乎相同的「状态引擎 + 行流 + 执行编排
|
|
27
|
+
// + 限流回退弹窗 + MCP 快照上报 + 展示层」。本文件把这些**与 provider 无关**的骨架
|
|
28
|
+
// 收敛成一份参数化实现,每个 provider 只提供一个 descriptor(见 providers/*.mjs 与
|
|
29
|
+
// PROVIDER-CONTRACT.md),由 createRunner/createStatusEngine 回调它完成差异化步骤。
|
|
30
|
+
//
|
|
31
|
+
// 骨架覆盖:
|
|
32
|
+
// - 纯函数:projectName / clampInt / shortLabel / summarizeArgs / endpointHost
|
|
33
|
+
// - 限流/网络判定:LIMIT_RE + isLimited(provider 可收窄)
|
|
34
|
+
// - 半行安全行流:createLineStream
|
|
35
|
+
// - 按项目(cwd)聚合、按 provider 分桶的状态引擎:createStatusEngine
|
|
36
|
+
// - 执行编排(前台/后台/超时守卫/瞬时错误重试/限流三选一弹窗/回退):createRunner
|
|
37
|
+
// - 工具结果与状态的人类可读渲染:renderResult / renderStatus
|
|
38
|
+
// - MCP 子进程快照上报:buildMcpBridgePayload / normalizeMcpBridge(provider 分文件)
|
|
39
|
+
// - 回退弹窗三选一文案(provider 名注入)
|
|
40
|
+
//
|
|
41
|
+
// 约束(沙箱与真实 Node 共用,勿违反):
|
|
42
|
+
// - 本模块**不 import 任何模块**、不用 process/Buffer/原生定时器/AbortController;
|
|
43
|
+
// 宿主能力(subprocess.spawn / ctx.interval / ctx.timeout / jobs / userQuestions)
|
|
44
|
+
// 全部经 createRunner 的 opts 注入,取消用 exec.signal + handle.terminate()。
|
|
45
|
+
// - 不序列化任何 DSH 活对象;跨 RPC 只传无 Host 引用的纯 JSON 叶子字段。
|
|
46
|
+
|
|
47
|
+
// ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const MAX_TRAIL = 12
|
|
50
|
+
const MAX_ARG_LEN = 120
|
|
51
|
+
const MAX_PROJECTS = 24
|
|
52
|
+
const MAX_SESSIONS = 256
|
|
53
|
+
|
|
54
|
+
// 限流/网络类错误的宽松正则(英文 + 中文)。provider descriptor 可用自己的收窄版覆盖。
|
|
55
|
+
const LIMIT_RE =
|
|
56
|
+
/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
|
|
57
|
+
|
|
58
|
+
// 家级插件与 MCP 子进程之间的快照文件:{dsh-home}/cli-bridge-mcp-<provider>.json
|
|
59
|
+
const MCP_BRIDGE_PREFIX = 'cli-bridge-mcp-'
|
|
60
|
+
const MCP_BRIDGE_STALE_MS = 90000
|
|
61
|
+
function mcpBridgeFileName(providerId) { return MCP_BRIDGE_PREFIX + providerId + '.json' }
|
|
62
|
+
|
|
63
|
+
// ── 纯函数 ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function projectName(cwd) {
|
|
66
|
+
const s = String(cwd || '')
|
|
67
|
+
const parts = s.split(/[\\/]/).filter(Boolean)
|
|
68
|
+
return parts.length ? parts[parts.length - 1] : s
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clampInt(v, def, min, max) {
|
|
72
|
+
const n = Number(v)
|
|
73
|
+
if (!Number.isFinite(n)) return def
|
|
74
|
+
const i = Math.floor(n)
|
|
75
|
+
if (i < min) return min
|
|
76
|
+
if (i > max) return max
|
|
77
|
+
return i
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shortLabel(prompt) {
|
|
81
|
+
const s = String(prompt || '').replace(/\s+/g, ' ').trim()
|
|
82
|
+
return s.length > 80 ? s.slice(0, 77) + '...' : s
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 工具入参摘要(状态灯/活动面板)。返回可无损 JSON 化的数据;undefined 值跳过
|
|
86
|
+
// (DSH tool 渲染层不接受 undefined)。
|
|
87
|
+
function summarizeArgs(parameters) {
|
|
88
|
+
if (!parameters || typeof parameters !== 'object') return null
|
|
89
|
+
const out = {}
|
|
90
|
+
for (const k of Object.keys(parameters)) {
|
|
91
|
+
let v = parameters[k]
|
|
92
|
+
if (v === undefined) continue
|
|
93
|
+
if (typeof v === 'string' && v.length > MAX_ARG_LEN) v = v.slice(0, MAX_ARG_LEN) + '…'
|
|
94
|
+
out[k] = v
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function endpointHost(url) {
|
|
100
|
+
const m = String(url || '').match(/^https?:\/\/([^/]+)/i)
|
|
101
|
+
return m ? m[1] : ''
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 限流/网络类失败(要问用户,重试可能烧额度)。
|
|
105
|
+
function isLimited(res) {
|
|
106
|
+
if (!res || res.ok) return false
|
|
107
|
+
// AUTH_REQUIRED 是配置问题(缺凭据),弹回退/重试无意义 —— 直接给可操作指引。
|
|
108
|
+
if (res.status === 'AUTH_REQUIRED') return false
|
|
109
|
+
if (res.status === 'SPAWN_ERROR' || res.status === 'CLI_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
|
|
110
|
+
const hay = String(res.stderr || '') + ' ' + String(res.status || '')
|
|
111
|
+
// 端点域与登录域不匹配的 401:需要用户换登录域,不是限流。
|
|
112
|
+
if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
|
|
113
|
+
return LIMIT_RE.test(hay)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 半行安全的行流(跨 chunk 的 NDJSON 行拼接)───────────────────────────────
|
|
117
|
+
function createLineStream(consumeLine) {
|
|
118
|
+
let pending = ''
|
|
119
|
+
function pushChunk(text) {
|
|
120
|
+
if (typeof text !== 'string' || !text) return
|
|
121
|
+
const lines = (pending + text).split(/\r?\n/)
|
|
122
|
+
pending = lines.pop() || ''
|
|
123
|
+
for (const ln of lines) consumeLine(ln)
|
|
124
|
+
}
|
|
125
|
+
function flush() {
|
|
126
|
+
const rest = pending
|
|
127
|
+
pending = ''
|
|
128
|
+
if (rest) consumeLine(rest)
|
|
129
|
+
}
|
|
130
|
+
return { pushChunk, flush }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── 状态引擎(按 provider + 项目目录聚合的运行状态表)─────────────────────────
|
|
134
|
+
// opts: { provider: <descriptor>, publish(snapshot) }
|
|
135
|
+
// 快照结构(顶层为全局聚合,向后兼容单 provider 消费者):
|
|
136
|
+
// { provider, state, running, lastStatus, lastAt, lastSessionId, lastBackend,
|
|
137
|
+
// fallbackActive, current, trail, runs, totalTokens, updatedAt,
|
|
138
|
+
// projects:[{ cwd,name,provider,state,running,current,trail,lastStatus,lastAt,
|
|
139
|
+
// lastSessionId,lastBackend,fallbackActive,runs,totalTokens,updatedAt }] }
|
|
140
|
+
function createStatusEngine(opts) {
|
|
141
|
+
const o = opts || {}
|
|
142
|
+
const provider = o.provider
|
|
143
|
+
const providerId = provider && provider.id ? provider.id : 'cli'
|
|
144
|
+
const publish = typeof o.publish === 'function' ? o.publish : null
|
|
145
|
+
const projects = Object.create(null)
|
|
146
|
+
// sessionId -> { cwd, backend, provider }。会话按 provider 归属(登录域互斥),
|
|
147
|
+
// 续接时一次解析出 cwd 与 backend。容量上限 MAX_SESSIONS,FIFO 淘汰。
|
|
148
|
+
const sessions = Object.create(null)
|
|
149
|
+
|
|
150
|
+
function ensureProject(cwd) {
|
|
151
|
+
const key = String(cwd || '')
|
|
152
|
+
let p = projects[key]
|
|
153
|
+
if (!p) {
|
|
154
|
+
if (Object.keys(projects).length >= MAX_PROJECTS) {
|
|
155
|
+
const idle = Object.keys(projects).filter((k) => projects[k].running === 0)
|
|
156
|
+
const pool = idle.length ? idle : Object.keys(projects)
|
|
157
|
+
const victim = pool.sort((a, b) => projects[a].updatedAt - projects[b].updatedAt)[0]
|
|
158
|
+
delete projects[victim]
|
|
159
|
+
}
|
|
160
|
+
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 }
|
|
161
|
+
projects[key] = p
|
|
162
|
+
}
|
|
163
|
+
return p
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function globalStatus() {
|
|
167
|
+
const list = Object.keys(projects).map((k) => projects[k])
|
|
168
|
+
let running = 0, lastStatus = null, lastAt = 0, lastSessionId = null, lastBackend = null, fallbackActive = false, current = null, trail = [], updatedAt = 0, runs = 0, totalTokens = 0
|
|
169
|
+
for (const p of list) {
|
|
170
|
+
running += p.running
|
|
171
|
+
if (p.updatedAt > updatedAt) updatedAt = p.updatedAt
|
|
172
|
+
if (p.running > 0 && !current && p.current) current = p.current
|
|
173
|
+
if (p.lastAt > lastAt) { lastAt = p.lastAt; lastStatus = p.lastStatus; lastSessionId = p.lastSessionId; lastBackend = p.lastBackend }
|
|
174
|
+
if (p.fallbackActive) fallbackActive = true
|
|
175
|
+
runs += p.runs || 0
|
|
176
|
+
totalTokens += p.totalTokens || 0
|
|
177
|
+
for (const e of p.trail) trail.push(e)
|
|
178
|
+
}
|
|
179
|
+
trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
|
|
180
|
+
trail = trail.slice(-MAX_TRAIL)
|
|
181
|
+
const state = running > 0 ? 'running' : (fallbackActive ? 'fallback' : (lastStatus ? (lastStatus === 'SUCCESS' ? 'ok' : 'failed') : 'idle'))
|
|
182
|
+
return { state, running, lastStatus, lastAt, lastSessionId, lastBackend, fallbackActive, current, trail, runs, totalTokens, updatedAt }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function statusSnapshot() {
|
|
186
|
+
const g = globalStatus()
|
|
187
|
+
const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
188
|
+
return Object.assign({ provider: providerId }, g, {
|
|
189
|
+
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 }))
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function emit() { if (publish) { try { publish(statusSnapshot()) } catch (e) {} } }
|
|
194
|
+
|
|
195
|
+
function begin(cwd) {
|
|
196
|
+
const p = ensureProject(cwd)
|
|
197
|
+
p.running += 1
|
|
198
|
+
p.state = 'running'
|
|
199
|
+
p.updatedAt = Date.now()
|
|
200
|
+
emit()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function end(res, cwd) {
|
|
204
|
+
const p = ensureProject(cwd)
|
|
205
|
+
p.running = Math.max(0, p.running - 1)
|
|
206
|
+
p.lastStatus = res ? res.status : null
|
|
207
|
+
p.lastAt = Date.now()
|
|
208
|
+
if (res && res.sessionId) {
|
|
209
|
+
p.lastSessionId = res.sessionId
|
|
210
|
+
sessions[res.sessionId] = { cwd: p.cwd, backend: res.backend || (provider && provider.defaultBackend), provider: providerId }
|
|
211
|
+
const keys = Object.keys(sessions)
|
|
212
|
+
if (keys.length > MAX_SESSIONS) delete sessions[keys[0]]
|
|
213
|
+
}
|
|
214
|
+
if (res && res.backend) p.lastBackend = res.backend
|
|
215
|
+
p.runs = (p.runs || 0) + 1
|
|
216
|
+
if (res && typeof res.totalTokens === 'number' && res.totalTokens > 0) p.totalTokens = (p.totalTokens || 0) + res.totalTokens
|
|
217
|
+
if (res && res.fallback) { p.fallbackActive = true; p.state = 'fallback' }
|
|
218
|
+
else if (p.running > 0) { p.state = 'running' }
|
|
219
|
+
else { p.state = res && res.ok ? 'ok' : 'failed'; p.lastOk = !!(res && res.ok); p.lastFailed = !(res && res.ok) }
|
|
220
|
+
p.current = null
|
|
221
|
+
p.updatedAt = Date.now()
|
|
222
|
+
emit()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 流事件折叠为实时状态。provider 若有 stream-json,提供 foldStreamEvent(ev, project);
|
|
226
|
+
// 否则回退到 generic 形态(不产生实时步骤)。
|
|
227
|
+
function foldEvent(ev, cwd) {
|
|
228
|
+
const p = ensureProject(cwd)
|
|
229
|
+
p.updatedAt = Date.now()
|
|
230
|
+
if (provider && typeof provider.foldStreamEvent === 'function') {
|
|
231
|
+
try { provider.foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) } catch (e) {}
|
|
232
|
+
}
|
|
233
|
+
emit()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 会话路由(cwd + backend 一次解析)。续接(--resume/--continue)时未给 cwd
|
|
237
|
+
// 则回落到该 session 所在项目的 cwd(否则换目录报找不到会话)。
|
|
238
|
+
function resolveTarget(args, fallbackCwd) {
|
|
239
|
+
const a = args || {}
|
|
240
|
+
let backend = null, cwd = null
|
|
241
|
+
if (a.sessionId) { const hit = sessions[a.sessionId]; if (hit && hit.provider === providerId) { backend = hit.backend; cwd = hit.cwd } }
|
|
242
|
+
if (a.cwd) {
|
|
243
|
+
cwd = String(a.cwd)
|
|
244
|
+
} else if (!cwd && (a.sessionId || a.continueLatest)) {
|
|
245
|
+
const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
|
|
246
|
+
if (a.sessionId) {
|
|
247
|
+
const projHit = list.find((p) => p.lastSessionId === a.sessionId)
|
|
248
|
+
if (projHit) { cwd = projHit.cwd; if (!backend) backend = projHit.lastBackend }
|
|
249
|
+
} else if (list.length && list[0].lastSessionId) { cwd = list[0].cwd; if (!backend) backend = list[0].lastBackend }
|
|
250
|
+
}
|
|
251
|
+
return { cwd: cwd || fallbackCwd, backend }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveCwd(args, fallbackCwd) { return resolveTarget(args, fallbackCwd).cwd }
|
|
255
|
+
|
|
256
|
+
return { providerId, projects, sessions, ensureProject, globalStatus, statusSnapshot, begin, end, foldEvent, resolveCwd, resolveTarget }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── 回退弹窗三选一(provider 名注入)──────────────────────────────────────────
|
|
260
|
+
function fallbackLabels(provider) {
|
|
261
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
262
|
+
return {
|
|
263
|
+
fallback: '使用 DSH 本地 API 配置(回退)',
|
|
264
|
+
retry: '重试 ' + bk + ' 一次',
|
|
265
|
+
cancel: '不回退(返回错误)'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 失败结果的可行动指引;provider.failureHint 优先,未给则通用兜底。
|
|
270
|
+
function failureHint(res, provider) {
|
|
271
|
+
if (!res || res.ok) return ''
|
|
272
|
+
if (provider && typeof provider.failureHint === 'function') {
|
|
273
|
+
try { const h = provider.failureHint(res); if (h) return h } catch (e) {}
|
|
274
|
+
}
|
|
275
|
+
if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
|
|
276
|
+
if (res.status === 'PARSE_ERROR') return '[诊断] 未能从输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 CLI 版本。'
|
|
277
|
+
if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
|
|
278
|
+
return '[诊断] 认证失败(401):请求端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 CLI 登录后重试;这不是限流。'
|
|
279
|
+
}
|
|
280
|
+
return ''
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fallbackResult(res, mode, provider) {
|
|
284
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
285
|
+
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 + ']' })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── 展示层 ────────────────────────────────────────────────────────────────────
|
|
289
|
+
function renderResult(value, provider) {
|
|
290
|
+
const v = value || {}
|
|
291
|
+
// backendLabel(codebuddy 旧语义 v1.1.0 verbatim:'workbuddy OK'/'codebuddy-intl OK'):
|
|
292
|
+
// 结果带具体 backend 时用 backend 标头;agy/mimo 旧包标头是硬编码 provider 名,不开此旗。
|
|
293
|
+
const bk = (provider && provider.backendLabel && v.backend) || (provider && provider.id) || v.backend || 'CLI'
|
|
294
|
+
if (v.background) return [{ type: 'text', text: bk + ' dispatched in background (mode=' + v.mode + '). jobId=' + v.jobId + '. Collect with job_output.' }]
|
|
295
|
+
if (v.fallback) return [{ type: 'text', text: bk + ' 回退:用户选择使用 DSH 本地 API 配置(原因 ' + v.reason + ')。请改用原生工具/本地模型完成本任务,不要再调 ' + bk + '。' }]
|
|
296
|
+
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' : '') + ']'
|
|
297
|
+
const hint = failureHint(v, provider)
|
|
298
|
+
let body = v.response ? v.response : (v.stderr ? '[stderr] ' + v.stderr : (v.rawStdout ? '[raw] ' + v.rawStdout : ''))
|
|
299
|
+
if (hint) body = (body ? body + '\n\n' : '') + hint
|
|
300
|
+
return [{ type: 'text', text: head + (body ? '\n\n' + body : '') }]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function renderStatus(value, provider) {
|
|
304
|
+
const v = value || {}
|
|
305
|
+
const bk = (provider && provider.id) || 'CLI'
|
|
306
|
+
const lines = []
|
|
307
|
+
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' : ''))
|
|
308
|
+
const projList = (v.projects && v.projects.length) ? v.projects : null
|
|
309
|
+
if (projList) {
|
|
310
|
+
for (const p of projList) {
|
|
311
|
+
const cur = p.current ? (' step ' + p.current.stepIndex + ' → ' + p.current.tool + (p.current.args ? ' ' + JSON.stringify(p.current.args) : '')) : (p.running > 0 ? ' (starting / thinking)' : '')
|
|
312
|
+
const usage = (p.runs ? ' | total ' + p.runs + ' runs, ' + (p.totalTokens || 0) + ' tokens' : '')
|
|
313
|
+
const bkTag = (p.lastBackend && p.lastBackend !== bk) ? ' [' + p.lastBackend + ']' : ''
|
|
314
|
+
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)
|
|
315
|
+
if (p.trail && p.trail.length) {
|
|
316
|
+
lines.push(' steps:')
|
|
317
|
+
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) }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
if (v.current) { const c = v.current; lines.push('current: step ' + c.stepIndex + ' → ' + c.tool + (c.args ? ' ' + JSON.stringify(c.args) : '')) }
|
|
322
|
+
else if (v.state === 'running') lines.push('current: (starting / thinking)')
|
|
323
|
+
if (v.trail && v.trail.length) {
|
|
324
|
+
lines.push('recent steps:')
|
|
325
|
+
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) }
|
|
326
|
+
}
|
|
327
|
+
if (v.lastStatus) lines.push('last: ' + v.lastStatus + (v.lastSessionId ? ' session=' + v.lastSessionId : '') + (v.lastAt ? ' @ ' + new Date(v.lastAt).toISOString() : ''))
|
|
328
|
+
}
|
|
329
|
+
if (v.updatedAt) lines.push('updatedAt: ' + new Date(v.updatedAt).toISOString())
|
|
330
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── MCP 子进程快照上报 ────────────────────────────────────────────────────────
|
|
334
|
+
// MCP server 是独立 stdio 子进程:没有 ctx.emit、拿不到 collector 服务,所以把快照
|
|
335
|
+
// 原子写进 {dsh-home}/cli-bridge-mcp-<provider>.json,家级灯在响应请求时读取合并。
|
|
336
|
+
function buildMcpBridgePayload(snap, pid, nowMs) {
|
|
337
|
+
const s = snap && typeof snap === 'object' ? snap : {}
|
|
338
|
+
const src = Array.isArray(s.projects) ? s.projects : []
|
|
339
|
+
const projects = []
|
|
340
|
+
for (const p of src) {
|
|
341
|
+
if (!p || !p.cwd) continue
|
|
342
|
+
projects.push({
|
|
343
|
+
cwd: String(p.cwd), name: p.name ? String(p.name) : '', provider: p.provider || s.provider || '', state: p.state ? String(p.state) : 'idle',
|
|
344
|
+
running: Number(p.running) || 0,
|
|
345
|
+
current: p.current ? { stepIndex: Number(p.current.stepIndex) || 0, tool: String(p.current.tool || ''), args: p.current.args || null } : null,
|
|
346
|
+
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 })) : [],
|
|
347
|
+
lastStatus: p.lastStatus ? String(p.lastStatus) : null, lastAt: Number(p.lastAt) || 0, lastSessionId: p.lastSessionId ? String(p.lastSessionId) : null,
|
|
348
|
+
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
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
return { source: 'mcp', provider: s.provider || '', pid: Number(pid) || 0, updatedAt: Number(nowMs) || 0, projects }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function normalizeMcpBridge(payload, nowMs, staleMs) {
|
|
355
|
+
if (!payload || typeof payload !== 'object') return null
|
|
356
|
+
const projects = Array.isArray(payload.projects) ? payload.projects : []
|
|
357
|
+
const updatedAt = Number(payload.updatedAt) || 0
|
|
358
|
+
const stale = (Number(nowMs) || 0) - updatedAt > (Number(staleMs) || MCP_BRIDGE_STALE_MS)
|
|
359
|
+
const out = []
|
|
360
|
+
for (const p of projects) {
|
|
361
|
+
if (!p || !p.cwd) continue
|
|
362
|
+
if (stale && (Number(p.running) || 0) > 0) out.push(Object.assign({}, p, { running: 0, current: null, state: p.state === 'running' ? 'idle' : p.state }))
|
|
363
|
+
else out.push(p)
|
|
364
|
+
}
|
|
365
|
+
return { source: 'mcp', provider: payload.provider || '', pid: Number(payload.pid) || 0, updatedAt, stale, projects: out }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── 执行编排(三形态共用;provider 差异全走 descriptor)──────────────────────
|
|
369
|
+
// o: {
|
|
370
|
+
// ctx, // Cordis ctx(interval/timeout/get)
|
|
371
|
+
// subprocess, // DSH subprocess 服务
|
|
372
|
+
// provider, // descriptor(见 PROVIDER-CONTRACT.md)
|
|
373
|
+
// engine, // createStatusEngine({provider}) 实例
|
|
374
|
+
// resolveExe, // async (backend, signal) => 'exe' | [nodeExe, binPath] | throw
|
|
375
|
+
// getCwdFallback, // () => 默认 cwd
|
|
376
|
+
// getPreferredBackend, // () => 用户偏好后端(可缺省)
|
|
377
|
+
// getSettings, // () => 当前设置对象(可缺省;provider 用它读 defaultModel 等)
|
|
378
|
+
// planActiveFor, // (exec) => bool(plan 模式判定,可缺省)
|
|
379
|
+
// defaultMode // 'auto'
|
|
380
|
+
// }
|
|
381
|
+
function createRunner(o) {
|
|
382
|
+
const ctx = o.ctx
|
|
383
|
+
const subprocess = o.subprocess
|
|
384
|
+
const provider = o.provider
|
|
385
|
+
const engine = o.engine
|
|
386
|
+
const stdio = { stdin: 'ignore', stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } }, stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } } }
|
|
387
|
+
|
|
388
|
+
function readStreams(handle) {
|
|
389
|
+
const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
390
|
+
const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
|
|
391
|
+
return { stdoutText, stderrText }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let built_env = null
|
|
395
|
+
function spawnSpec(argv, cwd, callerSignal) {
|
|
396
|
+
const spec = { argv, cwd, stdio, graceMs: 5000 }
|
|
397
|
+
if (callerSignal) spec.signal = callerSignal
|
|
398
|
+
if (built_env && typeof built_env === 'object') spec.env = built_env
|
|
399
|
+
return spec
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function startLiveParser(handle, cwd) {
|
|
403
|
+
let cursor = 0
|
|
404
|
+
const stream = createLineStream((ln) => {
|
|
405
|
+
const t = ln.trim()
|
|
406
|
+
if (!t.startsWith('{')) return
|
|
407
|
+
try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
|
|
408
|
+
})
|
|
409
|
+
const tick = function () {
|
|
410
|
+
try {
|
|
411
|
+
const full = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
412
|
+
if (full.length <= cursor) return
|
|
413
|
+
const fresh = full.slice(cursor)
|
|
414
|
+
cursor = full.length
|
|
415
|
+
stream.pushChunk(fresh)
|
|
416
|
+
} catch (e) {}
|
|
417
|
+
}
|
|
418
|
+
const disposeInterval = ctx.interval(tick, 250)
|
|
419
|
+
return function disposeLive() { try { stream.flush() } catch (e) {} disposeInterval() }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function runSync(argv, cwd, timeoutSec, callerSignal, extraEnv) {
|
|
423
|
+
const spec = spawnSpec(argv, cwd, callerSignal)
|
|
424
|
+
if (extraEnv && typeof extraEnv === 'object') spec.env = Object.assign({}, spec.env, extraEnv)
|
|
425
|
+
const handle = subprocess.spawn(spec)
|
|
426
|
+
let lastEventSummary = '(no events yet)'
|
|
427
|
+
let timedOut = false
|
|
428
|
+
const disposeTimer = ctx.timeout(function () {
|
|
429
|
+
timedOut = true
|
|
430
|
+
try {
|
|
431
|
+
const p = engine.ensureProject(cwd)
|
|
432
|
+
lastEventSummary = (p.current ? ('last step ' + p.current.stepIndex + ' -> ' + p.current.tool) : '') +
|
|
433
|
+
' | trail=' + (p.trail.length ? p.trail.slice(-2).map((e) => '[' + e.state + ']' + e.tool).join(',') : 'empty') +
|
|
434
|
+
' | elapsed=' + Math.round((Date.now() - (p.updatedAt || Date.now())) / 1000) + 's since last activity'
|
|
435
|
+
} catch (e) {}
|
|
436
|
+
try { handle.terminate() } catch (e) {}
|
|
437
|
+
}, (timeoutSec + 60) * 1000)
|
|
438
|
+
const disposeLive = startLiveParser(handle, cwd)
|
|
439
|
+
try {
|
|
440
|
+
const outcome = await handle.done
|
|
441
|
+
const s = readStreams(handle)
|
|
442
|
+
return { outcome, stdoutText: s.stdoutText, stderrText: s.stderrText, timedOut, lastEventSummary }
|
|
443
|
+
} finally { disposeLive(); disposeTimer() }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function askFallback(exec, res, canRetry) {
|
|
447
|
+
const uq = ctx.get('userQuestions')
|
|
448
|
+
if (!uq || !exec || !exec.agent) return 'error'
|
|
449
|
+
const labels = fallbackLabels(provider)
|
|
450
|
+
const bk = provider.id
|
|
451
|
+
const detail = String(res.stderr || res.response || res.status || '').slice(-600)
|
|
452
|
+
const opts = [{ label: labels.fallback, description: '本次改由 DSH 本地模型/原生工具完成,不再走 ' + bk }]
|
|
453
|
+
if (canRetry) opts.push({ label: labels.retry, description: '再调用一次 ' + bk + '(网络抖动时可用)' })
|
|
454
|
+
opts.push({ label: labels.cancel, description: '不回退,直接返回 ' + bk + ' 错误' })
|
|
455
|
+
try {
|
|
456
|
+
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 }] })
|
|
457
|
+
const sel = (ans && ans.answers && ans.answers[0] && ans.answers[0].selected) || []
|
|
458
|
+
if (sel.indexOf(labels.fallback) >= 0) return 'fallback'
|
|
459
|
+
if (sel.indexOf(labels.retry) >= 0) return 'retry'
|
|
460
|
+
return 'error'
|
|
461
|
+
} catch (e) { return 'error' }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function coreExecute(rawArgs, exec) {
|
|
465
|
+
const args = rawArgs || {}
|
|
466
|
+
built_env = null
|
|
467
|
+
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)
|
|
468
|
+
if (!args.prompt || !String(args.prompt).trim()) return mk({ stderr: 'prompt is required' })
|
|
469
|
+
|
|
470
|
+
const target = engine.resolveTarget(args, o.getCwdFallback())
|
|
471
|
+
const backend = provider.resolveBackend ? provider.resolveBackend(args, target, o.getPreferredBackend ? o.getPreferredBackend() : null) : (o.getPreferredBackend ? o.getPreferredBackend() : provider.defaultBackend)
|
|
472
|
+
const cwd = target.cwd
|
|
473
|
+
|
|
474
|
+
// provider 前置检查(凭据/桌面健康等):返回 { abort: res } 则直接结束。
|
|
475
|
+
if (provider.preflight) {
|
|
476
|
+
const pre = provider.preflight({ args, exec, backend, cwd, engine, ctx, o })
|
|
477
|
+
if (pre && pre.abort) { engine.begin(cwd); engine.end(pre.abort, cwd); return pre.abort }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
let exePrefix = provider.fallbackExePrefix ? provider.fallbackExePrefix(backend) : [provider.exeName || provider.id]
|
|
481
|
+
let exeOk = true, resolveErr = ''
|
|
482
|
+
try { const resExe = await o.resolveExe(backend, exec ? exec.signal : undefined); exePrefix = Array.isArray(resExe) ? resExe : [resExe] }
|
|
483
|
+
catch (e) { exeOk = false; resolveErr = String(e && e.message || e) }
|
|
484
|
+
|
|
485
|
+
const built = provider.buildArgv(exePrefix, args, {
|
|
486
|
+
planActive: o.planActiveFor ? o.planActiveFor(exec) : false,
|
|
487
|
+
defaultMode: provider.defaultMode || 'auto',
|
|
488
|
+
defaultModel: (!args.model && o.getSettings) ? (o.getSettings() || {}).defaultModel || null : null,
|
|
489
|
+
backend, settings: o.getSettings ? o.getSettings() : null, engine
|
|
490
|
+
})
|
|
491
|
+
built_env = (built.env && typeof built.env === 'object') ? built.env : null
|
|
492
|
+
const unavailableStatus = provider.unavailableStatus || 'CLI_UNAVAILABLE'
|
|
493
|
+
|
|
494
|
+
if (!exeOk) {
|
|
495
|
+
engine.begin(cwd)
|
|
496
|
+
let res = mk({ status: unavailableStatus, mode: built.mode, backend, stderr: (backend || provider.id) + ' executable not found: ' + resolveErr })
|
|
497
|
+
if (await askFallback(exec, res, true) === 'fallback') res = fallbackResult(res, built.mode, provider)
|
|
498
|
+
engine.end(res, cwd); return res
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 非 spawn 路径(如 mimo 桌面走本地 HTTP):provider 若给 execute 且(未给 delegates
|
|
502
|
+
// 或 delegates(backend)=true)则完全交给它;否则落回通用 pipeline(jobs/重试/弹窗)。
|
|
503
|
+
if (provider.execute && (!provider.delegates || provider.delegates(backend, args))) {
|
|
504
|
+
engine.begin(cwd)
|
|
505
|
+
try {
|
|
506
|
+
const res = await provider.execute({ args, exec, backend, cwd, built, engine, ctx, o, runSync })
|
|
507
|
+
engine.end(res, cwd); return res
|
|
508
|
+
} catch (e) {
|
|
509
|
+
const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
|
|
510
|
+
engine.end(res, cwd); return res
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const jobs = ctx.get('jobs')
|
|
515
|
+
if (args.background && jobs && exec && exec.agent) {
|
|
516
|
+
try {
|
|
517
|
+
engine.begin(cwd)
|
|
518
|
+
const jobId = jobs.start({
|
|
519
|
+
kind: 'bash', label: provider.id + ': ' + shortLabel(args.prompt), owner: exec.agent,
|
|
520
|
+
run() {
|
|
521
|
+
const handle = subprocess.spawn(spawnSpec(built.argv, cwd))
|
|
522
|
+
const disposeLive = startLiveParser(handle, cwd)
|
|
523
|
+
let bgKilled = false
|
|
524
|
+
const disposeGuard = ctx.timeout(function () { bgKilled = true; try { handle.terminate() } catch (e) {} }, (built.timeoutSec + 60) * 1000)
|
|
525
|
+
const done = handle.done.then(function (outcome) {
|
|
526
|
+
disposeLive(); disposeGuard()
|
|
527
|
+
const s = readStreams(handle)
|
|
528
|
+
const res = provider.buildResult(provider.parseJson(s.stdoutText), outcome, built.mode, s.stderrText, s.stdoutText, backend)
|
|
529
|
+
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]' }
|
|
530
|
+
engine.end(res, cwd)
|
|
531
|
+
return { status: res.ok ? 'completed' : 'failed', detail: provider.id + ' ' + res.status, output: JSON.stringify(res) }
|
|
532
|
+
}).catch(function (err) { disposeLive(); disposeGuard(); engine.end(mk({ status: 'JOB_ERROR', backend }), cwd); return { status: 'failed', detail: String(err && err.message || err) } })
|
|
533
|
+
return { cancel: function () { try { handle.terminate() } catch (e) {} }, done }
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
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.' }
|
|
537
|
+
} catch (e) {
|
|
538
|
+
const res = mk({ status: 'JOB_START_ERROR', mode: built.mode, backend, stderr: 'failed to start background job: ' + String(e && e.message || e) })
|
|
539
|
+
engine.end(res, cwd); return res
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
engine.begin(cwd)
|
|
544
|
+
try {
|
|
545
|
+
let attempt = 0, cliRetried = false, res
|
|
546
|
+
while (true) {
|
|
547
|
+
attempt += 1
|
|
548
|
+
const r = await runSync(built.argv, cwd, built.timeoutSec, exec ? exec.signal : undefined, built.env)
|
|
549
|
+
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 }
|
|
550
|
+
res = provider.buildResult(provider.parseJson(r.stdoutText), r.outcome, built.mode, r.stderrText, r.stdoutText, backend)
|
|
551
|
+
if (res.ok) break
|
|
552
|
+
if (provider.isTransientCliError && provider.isTransientCliError(res)) {
|
|
553
|
+
if (!cliRetried) { cliRetried = true; attempt -= 1; continue }
|
|
554
|
+
res.retried = true; break
|
|
555
|
+
}
|
|
556
|
+
if (!(provider.isLimited ? provider.isLimited(res) : isLimited(res))) break
|
|
557
|
+
const decision = await askFallback(exec, res, attempt < 2)
|
|
558
|
+
if (decision === 'fallback') { res = fallbackResult(res, built.mode, provider); break }
|
|
559
|
+
if (decision === 'retry' && attempt < 2) continue
|
|
560
|
+
break
|
|
561
|
+
}
|
|
562
|
+
engine.end(res, cwd); return res
|
|
563
|
+
} catch (e) {
|
|
564
|
+
const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
|
|
565
|
+
const out = (await askFallback(exec, res, true) === 'fallback') ? fallbackResult(res, built.mode, provider) : res
|
|
566
|
+
engine.end(out, cwd); return out
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { coreExecute, runSync, askFallback }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// providers/mimo.mjs — mimo provider descriptor(真移植,替换 Lead 冒烟占位)。
|
|
574
|
+
//
|
|
575
|
+
// 移植基线:mimo-first-bridge v1.x Xiaomi MiMo-bridge 仓 core/mimo-core.mjs +
|
|
576
|
+
// core/runtime-settings.mjs + preset/mimo-first/mimo-first-bridge.mjs 的内联逻辑
|
|
577
|
+
// (双后端 mimocode/mimo-desktop、buildArgv/parseMimoJson/buildResult/桌面回合编排/
|
|
578
|
+
// 三工具 schema/mimo_settings),语义逐字保持;与 provider 无关的骨架(状态引擎/行流/
|
|
579
|
+
// 回退弹窗/展示层/超时护栏)在 core/bridge-kit.mjs(PROVIDER-CONTRACT.md §1)。
|
|
580
|
+
//
|
|
581
|
+
// 红线(PROVIDER-CONTRACT.md §8 + Lead 定案):
|
|
582
|
+
// - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
|
|
583
|
+
// process/fs/child_process 只经 deps 注入或 typeof/globalThis 守卫读取;原生定时器
|
|
584
|
+
// (setTimeout/AbortController)只在 typeof 守卫下使用(沙箱缺省时自动降级)。
|
|
585
|
+
// - 只允许 import '../core/bridge-kit.mjs'。
|
|
586
|
+
// - mimo-desktop 走本地 HTTP(非 spawn):delegates(backend) 只认 desktop,execute 自己
|
|
587
|
+
// 复刻旧回合编排;mimocode 不写 execute 分支 → kit 通用 pipeline 带 jobs/重试/弹窗。
|
|
588
|
+
|
|
589
|
+
// ── 后端常量与别名归一(旧 mimo-core verbatim)────────────────────────────────
|
|
590
|
+
const BACKEND_MIMOCODE = 'mimocode'
|
|
591
|
+
const BACKEND_DESKTOP = 'mimo-desktop'
|
|
592
|
+
const BACKENDS = [BACKEND_MIMOCODE, BACKEND_DESKTOP]
|
|
593
|
+
const DEFAULT_BACKEND = BACKEND_MIMOCODE
|
|
594
|
+
|
|
595
|
+
// 旧 LIMIT_RE(比 kit 版多 free-API/登录/第三方等 MiMo 文案;provider.isLimited 覆盖)。
|
|
596
|
+
// 命名 MIMO_ 前缀:dynamic 内联时 kit 也声明顶层 LIMIT_RE,同名会在同一脚本作用域
|
|
597
|
+
// 触发「Identifier already declared」构建错误(scripts/build.mjs 文本拼接的固有约束)。
|
|
598
|
+
const MIMO_LIMIT_RE = /rate.?limit|ratelimit|429|too many|quota|insufficient|credit|balance|exhausted|exceed|network|offline|ENETUNREACH|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|timeout|timed out|unavailable|503|502|500|401|403|unauthorized|invalid api|api key|connection|proxy|socket|tls|ssl|dns|free API service has ended|sign in|configure a third-party|网络|超时|限流|流量|受限|配额|金额|余额|额度|认证|连接|断开|登录|第三方/i
|
|
599
|
+
|
|
600
|
+
const DEFAULT_TIMEOUT_SEC = 600
|
|
601
|
+
|
|
602
|
+
// Desktop-local API 路径构造器(旧 DESKTOP_API verbatim)。
|
|
603
|
+
const DESKTOP_API = {
|
|
604
|
+
healthPath: '/v1/health',
|
|
605
|
+
sessionsPath: '/v1/sessions',
|
|
606
|
+
turnsPath: (id) => '/v1/sessions/' + encodeURIComponent(String(id)) + '/turns',
|
|
607
|
+
eventsPath: (id, dir) => '/v1/sessions/' + encodeURIComponent(String(id)) + '/events' + (dir ? ('?dir=' + encodeURIComponent(String(dir))) : '')
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 工具三件套 schema(旧 preset ctx.tools.register parameters verbatim,转 JSON Schema
|
|
611
|
+
// 形态对齐 agy 描述符约定;required 进顶层数组,其余字段语义逐字保持)。
|
|
612
|
+
const RUN_PARAMS = {
|
|
613
|
+
type: 'object',
|
|
614
|
+
additionalProperties: true,
|
|
615
|
+
required: ['prompt'],
|
|
616
|
+
properties: {
|
|
617
|
+
prompt: { type: 'string', description: 'The task instruction for mimo. 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 — mimo reads the repo itself). For follow-ups on the same topic, use mimo_continue with latest=true or sessionId (mimo keeps conversation context; state only what is new).' },
|
|
618
|
+
mode: { type: 'string', enum: ['auto', 'plan', 'build', 'compose', 'accept-edits'], description: 'auto follows DSH plan state (plan vs build); plan = read-only analysis (--agent plan); build = full tools (--agent build); compose = compose workflow.' },
|
|
619
|
+
backend: { type: 'string', enum: BACKENDS, description: 'Dispatch backend for THIS call only. Default comes from settings preselection (plugin config.backend / MIMO_BRIDGE_BACKEND / settings file): mimocode = local `mimo` CLI (MiMoCode); mimo-desktop = Xiaomi MiMo Desktop client local API. Prefer the preselected default; override only when necessary.' },
|
|
620
|
+
model: { type: 'string', description: 'Optional mimo model id as provider/model (e.g. mimo/mimo). For mimo-desktop, desktop model id (e.g. mimo-auto).' },
|
|
621
|
+
effort: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Optional reasoning effort, mapped to mimo --variant (low→minimal, high→high). mimocode only.' },
|
|
622
|
+
cwd: { type: 'string', description: 'Working directory for mimo (--dir / desktop directory).' },
|
|
623
|
+
timeoutSec: { type: 'integer', description: 'Hard timeout seconds (10-3600, default 600). Grace +60s on top.' },
|
|
624
|
+
background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with mimo_status. Set false to block for the final answer.' },
|
|
625
|
+
// desktop 后端**不能创建会话**(API 对任意 id 都回 202 但不落库),所以必须能
|
|
626
|
+
// 显式指定一个**已存在**的 Desktop 会话 id,否则每次都生成自造 id → 静默空操作。
|
|
627
|
+
sessionId: { type: 'string', description: 'mimo-desktop only: id of an ALREADY EXISTING MiMo Desktop session (see the Desktop app). Omit for mimocode.' },
|
|
628
|
+
title: { type: 'string', description: 'Optional session title.' }
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const CONTINUE_PARAMS = {
|
|
632
|
+
type: 'object',
|
|
633
|
+
additionalProperties: true,
|
|
634
|
+
required: ['prompt'],
|
|
635
|
+
properties: {
|
|
636
|
+
prompt: { type: 'string', description: 'Follow-up instruction for the ongoing mimo session. State only what is new; mimo preserves its conversation context.' },
|
|
637
|
+
sessionId: { type: 'string', description: 'mimo session id to resume (--session).' },
|
|
638
|
+
latest: { type: 'boolean', description: 'Continue the most recent mimo session (--continue).' },
|
|
639
|
+
backend: { type: 'string', enum: BACKENDS, description: 'Dispatch backend for THIS call only (see mimo_run.backend). Prefer the preselected default.' },
|
|
640
|
+
mode: { type: 'string', enum: ['auto', 'plan', 'build', 'compose', 'accept-edits'], description: 'Execution mode.' },
|
|
641
|
+
model: { type: 'string', description: 'Optional mimo model id as provider/model.' },
|
|
642
|
+
effort: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Optional reasoning effort.' },
|
|
643
|
+
cwd: { type: 'string', description: 'Working directory for mimo.' },
|
|
644
|
+
timeoutSec: { type: 'integer', description: 'Hard timeout seconds (10-3600, default 600).' },
|
|
645
|
+
background: { type: 'boolean', description: 'Defaults to true (background job).' }
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const STATUS_PARAMS = { type: 'object', additionalProperties: true, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
|
|
649
|
+
const SETTINGS_PARAMS = {
|
|
650
|
+
type: 'object',
|
|
651
|
+
additionalProperties: true,
|
|
652
|
+
required: [],
|
|
653
|
+
properties: {
|
|
654
|
+
backend: { type: 'string', enum: BACKENDS, description: 'If set, save this as the default backend.' },
|
|
655
|
+
desktopModel: { type: 'string', description: 'Optional desktop model id (mimo-desktop backend only), e.g. mimo-auto.' }
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const SETTINGS_DESCRIPTION = 'Read or change the preselected dispatch backend (settings). backend=mimocode (local mimo CLI / MiMoCode) or mimo-desktop (Xiaomi MiMo Desktop client). Call with only backend to set the default for subsequent mimo_run/mimo_continue; call with no args to read current settings. Persisted to the settings file; plugin config and MIMO_BRIDGE_BACKEND env still override when set.'
|
|
659
|
+
|
|
660
|
+
function renderSettings(value) {
|
|
661
|
+
const s = value || {}
|
|
662
|
+
const lines = [
|
|
663
|
+
'mimo settings',
|
|
664
|
+
' settings file : ' + (s.settingsPath || '(unknown)'),
|
|
665
|
+
' file backend : ' + (s.fileBackend || '(unset)'),
|
|
666
|
+
' config backend : ' + (s.configBackend || '(unset)'),
|
|
667
|
+
' env backend : ' + (s.envBackend || '(unset)'),
|
|
668
|
+
' effective : ' + (s.effectiveBackend || '(unknown)'),
|
|
669
|
+
' desktop model : ' + (s.desktopModel || '(default)'),
|
|
670
|
+
' desktop api : ' + (s.desktopAvailable
|
|
671
|
+
? 'available' + (s.desktopApiFile ? ' (' + s.desktopApiFile + ')' : '')
|
|
672
|
+
: 'unavailable' + (s.desktopError ? ' — ' + s.desktopError : '')),
|
|
673
|
+
' backends : ' + ((Array.isArray(s.backends) && s.backends.length ? s.backends.join(' | ') : BACKENDS.join(' | ')))
|
|
674
|
+
]
|
|
675
|
+
if (s.note) lines.push(' note : ' + s.note)
|
|
676
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ── 可纯函数化的 mimo-core 语义(descriptor 方法 + 独立导出双份)───────────────
|
|
680
|
+
function normalizeBackend(v) {
|
|
681
|
+
if (v == null || v === '') return null
|
|
682
|
+
const s = String(v).trim().toLowerCase()
|
|
683
|
+
if (s === 'mimocode' || s === 'mimo-code' || s === 'cli' || s === 'mimo-cli') return BACKEND_MIMOCODE
|
|
684
|
+
if (s === 'mimo-desktop' || s === 'desktop' || s === 'mimo_desktop' || s === 'xiaomi-mimo' || s === 'xiaomi-mimo-desktop') return BACKEND_DESKTOP
|
|
685
|
+
return null
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function parseMimoJson(stdoutText) {
|
|
689
|
+
const trimmed = String(stdoutText || '').trim()
|
|
690
|
+
if (!trimmed) return null
|
|
691
|
+
const MAX_ARG_LEN = 120
|
|
692
|
+
const MAX_TRAIL = 12
|
|
693
|
+
const events = []
|
|
694
|
+
const lines = trimmed.split(/\r?\n/)
|
|
695
|
+
for (const raw of lines) {
|
|
696
|
+
const ln = raw.trim()
|
|
697
|
+
if (!ln.startsWith('{')) continue
|
|
698
|
+
try {
|
|
699
|
+
const obj = JSON.parse(ln)
|
|
700
|
+
if (obj && typeof obj === 'object') events.push(obj)
|
|
701
|
+
} catch (e) { /* tolerate log lines */ }
|
|
702
|
+
}
|
|
703
|
+
if (!events.length) {
|
|
704
|
+
try {
|
|
705
|
+
const obj = JSON.parse(trimmed)
|
|
706
|
+
if (obj && typeof obj === 'object') events.push(obj)
|
|
707
|
+
} catch (e) {
|
|
708
|
+
return { events: [], text: trimmed, sessionId: null, error: null, steps: [] }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
let sessionId = null
|
|
712
|
+
let text = ''
|
|
713
|
+
let errorMessage = null
|
|
714
|
+
let errorName = null
|
|
715
|
+
let numTurns = 0
|
|
716
|
+
let totalTokens = null
|
|
717
|
+
const steps = []
|
|
718
|
+
const textParts = []
|
|
719
|
+
for (const ev of events) {
|
|
720
|
+
if (ev.sessionID && typeof ev.sessionID === 'string') sessionId = ev.sessionID
|
|
721
|
+
if (ev.sessionId && typeof ev.sessionId === 'string') sessionId = ev.sessionId
|
|
722
|
+
if (ev.session && typeof ev.session === 'object' && ev.session.id) sessionId = ev.session.id
|
|
723
|
+
if (ev.type === 'session' && ev.properties && ev.properties.id) sessionId = ev.properties.id
|
|
724
|
+
const t = ev.type
|
|
725
|
+
if (t === 'error') {
|
|
726
|
+
const err = ev.error || ev.properties || {}
|
|
727
|
+
errorMessage = (err.data && err.data.message) || err.message || err.name || 'error'
|
|
728
|
+
errorName = err.name || 'Error'
|
|
729
|
+
} else if (t === 'text' || t === 'message' || t === 'assistant' || t === 'output') {
|
|
730
|
+
const p = ev.properties || ev
|
|
731
|
+
const chunk = p.text || p.content || p.message || p.output || (typeof ev.text === 'string' ? ev.text : '')
|
|
732
|
+
if (typeof chunk === 'string' && chunk) {
|
|
733
|
+
textParts.push(chunk)
|
|
734
|
+
steps.push({ at: Date.now(), kind: 'text', label: shortLabel(chunk) })
|
|
735
|
+
}
|
|
736
|
+
} else if (t === 'step-start' || t === 'step-finish' || t === 'step_update') {
|
|
737
|
+
numTurns += 1
|
|
738
|
+
const p = ev.properties || ev
|
|
739
|
+
if (p.tokens && typeof p.tokens === 'object') {
|
|
740
|
+
const tt = p.tokens.total ?? p.tokens.input ?? p.tokens.output
|
|
741
|
+
if (typeof tt === 'number') totalTokens = (totalTokens || 0) + tt
|
|
742
|
+
}
|
|
743
|
+
if (t === 'step_update' && (p.tool_name || p.tool || p.name)) {
|
|
744
|
+
steps.push({
|
|
745
|
+
at: Date.now(),
|
|
746
|
+
kind: 'tool',
|
|
747
|
+
label: shortLabel((p.tool_name || p.tool || p.name) + ' ' + JSON.stringify(summarizeArgs(p.arguments || p.args || p.input) || {}))
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
} else if (t === 'tool' || t === 'tool-call' || t === 'tool_use') {
|
|
751
|
+
const p = ev.properties || ev
|
|
752
|
+
const name = p.tool || p.name || p.tool_name || 'tool'
|
|
753
|
+
steps.push({
|
|
754
|
+
at: Date.now(),
|
|
755
|
+
kind: 'tool',
|
|
756
|
+
label: shortLabel(name + ' ' + JSON.stringify(summarizeArgs(p.arguments || p.args || p.input) || {}))
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (textParts.length) text = textParts.join('\n')
|
|
761
|
+
else {
|
|
762
|
+
const last = events[events.length - 1] || {}
|
|
763
|
+
const r = last.result || last
|
|
764
|
+
if (typeof r.response === 'string') text = r.response
|
|
765
|
+
else if (typeof r.text === 'string') text = r.text
|
|
766
|
+
}
|
|
767
|
+
return { events, text, sessionId, error: errorMessage, errorName, numTurns, totalTokens, steps }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
|
|
771
|
+
const MAX_TRAIL = 12
|
|
772
|
+
const exitCode = outcome ? outcome.exitCode : null
|
|
773
|
+
const errText = (parsed && parsed.error) ? String(parsed.error) : ''
|
|
774
|
+
const stderr = (stderrText ? String(stderrText).slice(-2000) : '') + (errText ? (stderrText ? ' ' : '') + errText : '')
|
|
775
|
+
const text = parsed && typeof parsed.text === 'string' ? parsed.text : ''
|
|
776
|
+
const failed = exitCode !== 0 || !!(parsed && parsed.error)
|
|
777
|
+
const be = (parsed && parsed.backend) || (outcome && outcome.backend) || backend || null
|
|
778
|
+
if (parsed) {
|
|
779
|
+
return {
|
|
780
|
+
ok: !failed && (!!text || parsed.accepted === true),
|
|
781
|
+
status: failed ? (parsed.errorName || 'ERROR') : (parsed.accepted && !text ? 'ACCEPTED' : (text ? 'SUCCESS' : 'EMPTY')),
|
|
782
|
+
response: text,
|
|
783
|
+
sessionId: parsed.sessionId || null,
|
|
784
|
+
durationSeconds: null,
|
|
785
|
+
numTurns: parsed.numTurns || null,
|
|
786
|
+
totalTokens: parsed.totalTokens || null,
|
|
787
|
+
exitCode, mode, backend: be,
|
|
788
|
+
stderr,
|
|
789
|
+
errorMessage: parsed.error || '',
|
|
790
|
+
accepted: parsed.accepted === true,
|
|
791
|
+
steps: Array.isArray(parsed.steps) ? parsed.steps.slice(-MAX_TRAIL) : []
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
ok: false, status: 'PARSE_ERROR', response: '', sessionId: null,
|
|
796
|
+
durationSeconds: null, numTurns: null, totalTokens: null,
|
|
797
|
+
exitCode, mode, backend: be, stderr, errorMessage: '',
|
|
798
|
+
rawStdout: String(stdoutText || '').slice(-2000)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function desktopResult(ok, opts) {
|
|
803
|
+
const o = opts || {}
|
|
804
|
+
return {
|
|
805
|
+
ok: !!ok,
|
|
806
|
+
fallback: false,
|
|
807
|
+
status: o.status || (ok ? (o.accepted ? 'ACCEPTED' : 'SUCCESS') : 'ERROR'),
|
|
808
|
+
response: o.response || '',
|
|
809
|
+
sessionId: o.sessionId || null,
|
|
810
|
+
durationSeconds: null,
|
|
811
|
+
numTurns: null,
|
|
812
|
+
totalTokens: null,
|
|
813
|
+
exitCode: o.exitCode != null ? o.exitCode : (ok ? 0 : 1),
|
|
814
|
+
mode: o.mode || 'auto',
|
|
815
|
+
backend: BACKEND_DESKTOP,
|
|
816
|
+
stderr: o.stderr || '',
|
|
817
|
+
errorMessage: o.errorMessage || '',
|
|
818
|
+
accepted: o.accepted === true,
|
|
819
|
+
steps: []
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function buildDesktopTurnBody(args) {
|
|
824
|
+
const a = args || {}
|
|
825
|
+
const message = String(a.prompt != null ? a.prompt : (a.message != null ? a.message : ''))
|
|
826
|
+
const model = String(a.model || a.desktopModel || 'mimo-auto')
|
|
827
|
+
const body = { message, model }
|
|
828
|
+
const directory = a.directory || a.cwd
|
|
829
|
+
if (directory) body.directory = String(directory)
|
|
830
|
+
return body
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function newDesktopSessionId() {
|
|
834
|
+
// ses_ + 18-ish random chars, same shape as Desktop API ids
|
|
835
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
836
|
+
let s = ''
|
|
837
|
+
for (let i = 0; i < 18; i++) s += chars[Math.floor(Math.random() * chars.length)]
|
|
838
|
+
return 'ses_' + s
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function extractDesktopText(j) {
|
|
842
|
+
if (!j || typeof j !== 'object') return { text: '', sessionId: null, error: null }
|
|
843
|
+
const sessionId = j.sessionID || j.sessionId || (j.session && j.session.id) || null
|
|
844
|
+
if (j.type === 'error' || j.error) {
|
|
845
|
+
const err = j.error || j
|
|
846
|
+
const msg = (err.data && err.data.message) || err.message || err.name || 'error'
|
|
847
|
+
return { text: '', sessionId, error: String(msg) }
|
|
848
|
+
}
|
|
849
|
+
let text = ''
|
|
850
|
+
if (typeof j.text === 'string') text = j.text
|
|
851
|
+
else if (typeof j.message === 'string') text = j.message
|
|
852
|
+
else if (typeof j.content === 'string') text = j.content
|
|
853
|
+
else if (typeof j.response === 'string') text = j.response
|
|
854
|
+
else if (Array.isArray(j.parts)) {
|
|
855
|
+
for (const p of j.parts) {
|
|
856
|
+
if (p && typeof p.text === 'string') text += (text ? '\n' : '') + p.text
|
|
857
|
+
}
|
|
858
|
+
} else if (j.data && typeof j.data.text === 'string') text = j.data.text
|
|
859
|
+
return { text, sessionId, error: null }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function parseDesktopEvents(stdoutOrJson) {
|
|
863
|
+
const raw = String(stdoutOrJson || '').trim()
|
|
864
|
+
if (!raw) return { text: '', sessionId: null, error: null }
|
|
865
|
+
try {
|
|
866
|
+
const j = JSON.parse(raw)
|
|
867
|
+
return extractDesktopText(j)
|
|
868
|
+
} catch (e) { }
|
|
869
|
+
const texts = []
|
|
870
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
871
|
+
const ln = line.trim()
|
|
872
|
+
if (!ln.startsWith('{')) continue
|
|
873
|
+
try {
|
|
874
|
+
const r = extractDesktopText(JSON.parse(ln))
|
|
875
|
+
if (r.text) texts.push(r.text)
|
|
876
|
+
if (r.error) return { text: texts.join('\n'), sessionId: r.sessionId, error: r.error }
|
|
877
|
+
} catch (e) { }
|
|
878
|
+
}
|
|
879
|
+
return { text: texts.join('\n'), sessionId: null, error: null }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function defineProvider (deps = {}) {
|
|
883
|
+
const env = deps.env || {}
|
|
884
|
+
const nodeFs = deps.nodeFs || null
|
|
885
|
+
const nodePath = deps.nodePath || null
|
|
886
|
+
|
|
887
|
+
// ── Desktop-local API discovery(旧 runtime-settings.loadRuntime 桌面段语义)───
|
|
888
|
+
// fs 缺失(dynamic 沙箱)→ 只认 MIMO_DESKTOP_PORT/TOKEN env;都没有 → 不可用。
|
|
889
|
+
function discoverDesktop() {
|
|
890
|
+
const d = { available: false, baseUrl: null, token: null, apiFile: null, error: null }
|
|
891
|
+
if (env.MIMO_DESKTOP_PORT && env.MIMO_DESKTOP_TOKEN) {
|
|
892
|
+
return { available: true, baseUrl: 'http://127.0.0.1:' + env.MIMO_DESKTOP_PORT, token: String(env.MIMO_DESKTOP_TOKEN), apiFile: null, error: null }
|
|
893
|
+
}
|
|
894
|
+
const candidates = []
|
|
895
|
+
if (env.MIMO_DESKTOP_API_FILE) candidates.push(env.MIMO_DESKTOP_API_FILE)
|
|
896
|
+
const joinP = (...segs) => (nodePath ? nodePath.join(...segs) : segs.join('/'))
|
|
897
|
+
if (env.APPDATA) candidates.push(joinP(env.APPDATA, 'Xiaomi MiMo', 'desktop-api.json'))
|
|
898
|
+
if (env.HOME) {
|
|
899
|
+
candidates.push(joinP(env.HOME, '.config', 'Xiaomi MiMo', 'desktop-api.json'))
|
|
900
|
+
candidates.push(joinP(env.HOME, 'Library', 'Application Support', 'Xiaomi MiMo', 'desktop-api.json'))
|
|
901
|
+
}
|
|
902
|
+
if (!nodeFs) {
|
|
903
|
+
d.error = 'desktop-api.json not readable (no fs in this host; set MIMO_DESKTOP_PORT/MIMO_DESKTOP_TOKEN)'
|
|
904
|
+
return d
|
|
905
|
+
}
|
|
906
|
+
for (const p of candidates) {
|
|
907
|
+
try {
|
|
908
|
+
if (!p || !nodeFs.existsSync || !nodeFs.existsSync(p)) continue
|
|
909
|
+
const j = JSON.parse(nodeFs.readFileSync(p, 'utf8'))
|
|
910
|
+
if (j && j.port && j.token) {
|
|
911
|
+
return { available: true, baseUrl: 'http://127.0.0.1:' + j.port, token: String(j.token), apiFile: p, error: null }
|
|
912
|
+
}
|
|
913
|
+
} catch (e) {
|
|
914
|
+
d.error = String(e && e.message || e)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (!d.error) d.error = 'desktop-api.json not found (start Xiaomi MiMo Desktop)'
|
|
918
|
+
return d
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function fetchFn() {
|
|
922
|
+
return (typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') ? globalThis.fetch.bind(globalThis) : null
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// desktopFetch(旧 runtime-settings verbatim;AbortController/setTimeout 守卫,
|
|
926
|
+
// 沙箱缺省时退化为无超时 fetch —— 仍可用)。
|
|
927
|
+
async function desktopFetch(method, path, body, timeoutMs) {
|
|
928
|
+
const fetch = fetchFn()
|
|
929
|
+
if (!fetch) { const e = new Error('fetch not available in this host'); e.code = 'DESKTOP_UNAVAILABLE'; throw e }
|
|
930
|
+
const d = desktop
|
|
931
|
+
if (!d || !d.available || !d.baseUrl) {
|
|
932
|
+
const e = new Error('MiMo Desktop API unavailable: ' + (d && d.error || 'not configured'))
|
|
933
|
+
e.code = 'DESKTOP_UNAVAILABLE'
|
|
934
|
+
throw e
|
|
935
|
+
}
|
|
936
|
+
const url = d.baseUrl + path
|
|
937
|
+
const ms = timeoutMs == null ? 8000 : timeoutMs
|
|
938
|
+
let ctrl = null
|
|
939
|
+
let timer = null
|
|
940
|
+
const init = {
|
|
941
|
+
method: method || 'GET',
|
|
942
|
+
headers: { Authorization: 'Bearer ' + d.token, 'Content-Type': 'application/json' }
|
|
943
|
+
}
|
|
944
|
+
if (body != null) init.body = typeof body === 'string' ? body : JSON.stringify(body)
|
|
945
|
+
if (typeof AbortController === 'function' && typeof setTimeout === 'function' && typeof clearTimeout === 'function') {
|
|
946
|
+
ctrl = new AbortController()
|
|
947
|
+
init.signal = ctrl.signal
|
|
948
|
+
timer = setTimeout(() => { try { ctrl.abort() } catch (e) {} }, ms)
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
const res = await fetch(url, init)
|
|
952
|
+
const text = await res.text()
|
|
953
|
+
let json = null
|
|
954
|
+
try { json = text ? JSON.parse(text) : null } catch (e) { }
|
|
955
|
+
return { status: res.status, ok: res.ok, text, json }
|
|
956
|
+
} finally {
|
|
957
|
+
if (timer != null) clearTimeout(timer)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function desktopHealth() {
|
|
962
|
+
try {
|
|
963
|
+
const r = await desktopFetch('GET', DESKTOP_API.healthPath)
|
|
964
|
+
return { ok: r.ok, status: r.status, body: r.json || r.text }
|
|
965
|
+
} catch (e) {
|
|
966
|
+
return { ok: false, status: 0, error: String(e && e.message || e) }
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function desktopListSessions(limit) {
|
|
971
|
+
const r = await desktopFetch('GET', DESKTOP_API.sessionsPath + '?limit=' + (limit || 10))
|
|
972
|
+
return r.json
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function desktopSendTurn(sessionId, args) {
|
|
976
|
+
const id = sessionId || newDesktopSessionId()
|
|
977
|
+
const body = buildDesktopTurnBody(args)
|
|
978
|
+
const r = await desktopFetch('POST', DESKTOP_API.turnsPath(id), body, 15000)
|
|
979
|
+
// 【实测事故】Desktop API 对**任意** session id 都无条件返回 202 {"ok":true} ——
|
|
980
|
+
// 它不校验目标会话是否存在。实测:向自造 id POST 得到 202,但
|
|
981
|
+
// GET /v1/sessions 计数不变(19→19),会话从未创建,任务从未执行。
|
|
982
|
+
// 因此 202 不能作为成功判据,必须回读会话列表确认目标 id 确实存在,
|
|
983
|
+
// 否则桥接层会把"静默丢弃"报告成 ACCEPTED,用户在 Desktop UI 里什么都看不到。
|
|
984
|
+
const accepted = r.status === 202 || (r.json && r.json.ok === true)
|
|
985
|
+
let verified = null
|
|
986
|
+
if (accepted) {
|
|
987
|
+
try {
|
|
988
|
+
const list = await desktopListSessions(200)
|
|
989
|
+
if (Array.isArray(list)) verified = list.some((s) => s && String(s.id) === String(id))
|
|
990
|
+
else verified = null
|
|
991
|
+
} catch (e) { verified = null }
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
id, status: r.status, ok: r.ok, body: r.json || r.text,
|
|
995
|
+
accepted, verified,
|
|
996
|
+
// 仅当回读确认存在(或回读不可用)才算真正投递成功
|
|
997
|
+
delivered: accepted && verified !== false
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function desktopReadEvents(sessionId, dir, timeoutMs) {
|
|
1002
|
+
try {
|
|
1003
|
+
const r = await desktopFetch('GET', DESKTOP_API.eventsPath(sessionId, dir), null, timeoutMs || 2500)
|
|
1004
|
+
return parseDesktopEvents(r.text || (r.json ? JSON.stringify(r.json) : ''))
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
return { text: '', sessionId, error: String(e && e.message || e) }
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// 每次调用前刷一次发现结果(Desktop 可能中途启动/退出;env-only 形态零开销)。
|
|
1011
|
+
let desktop = discoverDesktop()
|
|
1012
|
+
|
|
1013
|
+
// ── 统一设置分区读取(keystone 落盘;descriptor 只读)。宿主注入 deps.readSettings
|
|
1014
|
+
// 动态读取(preset/MCP 每次取最新文件值),或静态 deps.settings(测试/dynamic)。
|
|
1015
|
+
function liveSettings() {
|
|
1016
|
+
if (typeof deps.readSettings === 'function') {
|
|
1017
|
+
try {
|
|
1018
|
+
const r = deps.readSettings()
|
|
1019
|
+
if (r && typeof r === 'object') {
|
|
1020
|
+
// 宿主可返回 { partition, path, home } 或直接返回分区对象(宽松兼容)。
|
|
1021
|
+
if ('partition' in r || 'path' in r || 'home' in r) {
|
|
1022
|
+
return { partition: r.partition || null, path: r.path || null, home: r.home || null }
|
|
1023
|
+
}
|
|
1024
|
+
return { partition: r, path: deps.settingsPath || null, home: deps.dshHome || null }
|
|
1025
|
+
}
|
|
1026
|
+
} catch (e) { }
|
|
1027
|
+
}
|
|
1028
|
+
return { partition: (deps.settings && typeof deps.settings === 'object') ? deps.settings : null, path: deps.settingsPath || null, home: deps.dshHome || null }
|
|
1029
|
+
}
|
|
1030
|
+
function partitionSettings() {
|
|
1031
|
+
return liveSettings().partition || null
|
|
1032
|
+
}
|
|
1033
|
+
function fileBackend() {
|
|
1034
|
+
const s = partitionSettings()
|
|
1035
|
+
if (s && s.preferredBackend) return s.preferredBackend
|
|
1036
|
+
return null
|
|
1037
|
+
}
|
|
1038
|
+
function desktopModelChain(cfg) {
|
|
1039
|
+
const s = partitionSettings()
|
|
1040
|
+
return (cfg && cfg.desktopModel) || (s && s.desktopModel) || env.MIMO_DESKTOP_MODEL || 'mimo-auto'
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const descriptor = {
|
|
1044
|
+
id: 'mimo', label: 'MIMO', presetId: 'cordis-mimo', defaultBackend: DEFAULT_BACKEND, defaultMode: 'auto',
|
|
1045
|
+
exeName: 'mimo', binEnvVars: ['MIMO_BIN', 'MIMO_CLI'], unavailableStatus: 'MIMO_UNAVAILABLE',
|
|
1046
|
+
|
|
1047
|
+
backends() { return BACKENDS },
|
|
1048
|
+
normalizeBackend,
|
|
1049
|
+
|
|
1050
|
+
// 预选优先级(旧 resolveBackend 语义):工具参数 > 插件 config > env > 设置文件 > 默认。
|
|
1051
|
+
// keystone runner 已把 config/env/file 折进 getPreferredBackend(),这里只补 arg 级。
|
|
1052
|
+
resolveBackend(args, target, preferred) {
|
|
1053
|
+
const b = normalizeBackend(args && args.backend)
|
|
1054
|
+
if (b) return b
|
|
1055
|
+
return normalizeBackend(preferred) || descriptor.defaultBackend
|
|
1056
|
+
},
|
|
1057
|
+
|
|
1058
|
+
// 委托规则(Lead 定案):只有 desktop 走 provider.execute;mimocode 落 kit 通用管线。
|
|
1059
|
+
delegates(backend) { return backend === BACKEND_DESKTOP },
|
|
1060
|
+
|
|
1061
|
+
buildArgv(exePrefix, args, opts) {
|
|
1062
|
+
const o = opts || {}
|
|
1063
|
+
const timeoutSec = clampInt(args.timeoutSec, DEFAULT_TIMEOUT_SEC, 10, 3600)
|
|
1064
|
+
// mimo-desktop:非 spawn 路径,空壳交给 execute(kit 委托前会调本函数)。
|
|
1065
|
+
if (o.backend === BACKEND_DESKTOP) {
|
|
1066
|
+
return { argv: [], mode: args.mode || 'auto', timeoutSec }
|
|
1067
|
+
}
|
|
1068
|
+
const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
|
|
1069
|
+
argv.push('run')
|
|
1070
|
+
if (args.prompt != null && String(args.prompt).length) argv.push(String(args.prompt))
|
|
1071
|
+
argv.push('--format', 'json', '--dangerously-skip-permissions')
|
|
1072
|
+
let mode = args.mode || o.defaultMode || 'auto'
|
|
1073
|
+
if (mode === 'auto') mode = o.planActive ? 'plan' : 'build'
|
|
1074
|
+
if (mode === 'accept-edits') mode = 'build'
|
|
1075
|
+
// mimo --agent: build | plan | compose
|
|
1076
|
+
const agent = args.agent || mode
|
|
1077
|
+
if (agent === 'plan' || agent === 'build' || agent === 'compose') argv.push('--agent', agent)
|
|
1078
|
+
// mimocode 用 --model provider/model(桌面模型链只在 desktop execute 内使用)。
|
|
1079
|
+
if (args.model) argv.push('--model', String(args.model))
|
|
1080
|
+
// effort (low/medium/high) maps to mimo --variant; pass-through if already a variant name
|
|
1081
|
+
let variant = args.variant || args.effort
|
|
1082
|
+
if (variant === 'low') variant = 'minimal'
|
|
1083
|
+
else if (variant === 'medium') variant = undefined
|
|
1084
|
+
else if (variant === 'high') variant = 'high'
|
|
1085
|
+
if (variant) argv.push('--variant', String(variant))
|
|
1086
|
+
if (args.cwd) argv.push('--dir', String(args.cwd))
|
|
1087
|
+
if (args.sessionId) argv.push('--session', String(args.sessionId))
|
|
1088
|
+
else if (args.continueLatest) argv.push('--continue')
|
|
1089
|
+
if (args.title) argv.push('--title', String(args.title))
|
|
1090
|
+
return { argv, timeoutSec, mode: agent }
|
|
1091
|
+
},
|
|
1092
|
+
|
|
1093
|
+
parseJson: parseMimoJson,
|
|
1094
|
+
buildResult,
|
|
1095
|
+
|
|
1096
|
+
// 旧 foldStepUpdate 语义 verbatim(识别 tool/text/error 事件),但 trail 条目同时
|
|
1097
|
+
// 带 kit 展示层字段(stepIndex/tool/state/args)与旧字段(kind/label),
|
|
1098
|
+
// 这样 renderStatus 与 MCP 快照映射(buildMcpBridgePayload)都能直接渲染。
|
|
1099
|
+
foldStreamEvent(ev, p, summarizeArgsFn, maxArgLen, maxTrail) {
|
|
1100
|
+
if (!ev || typeof ev !== 'object') return
|
|
1101
|
+
const t = ev.type
|
|
1102
|
+
const pr = ev.properties || ev
|
|
1103
|
+
const cap = maxTrail || 12
|
|
1104
|
+
const idx = p.trail ? p.trail.length : 0
|
|
1105
|
+
let entry = null
|
|
1106
|
+
if (t === 'tool' || t === 'tool-call' || t === 'tool_use' || t === 'step_update') {
|
|
1107
|
+
const name = pr.tool_name || pr.tool || pr.name
|
|
1108
|
+
if (name) {
|
|
1109
|
+
const args = (summarizeArgsFn || summarizeArgs)(pr.arguments || pr.args || pr.input) || null
|
|
1110
|
+
const state = pr.state || pr.status || 'ACTIVE'
|
|
1111
|
+
entry = {
|
|
1112
|
+
at: Date.now(), kind: 'tool', stepIndex: idx, tool: String(name), args, state,
|
|
1113
|
+
label: shortLabel(name + ' ' + JSON.stringify(args || {}))
|
|
1114
|
+
}
|
|
1115
|
+
if (state === 'ACTIVE') p.current = { tool: String(name), args, stepIndex: idx, since: entry.at }
|
|
1116
|
+
else if (p.current && p.current.tool === String(name) && p.current.stepIndex === idx) p.current = null
|
|
1117
|
+
}
|
|
1118
|
+
} else if (t === 'text' || t === 'message') {
|
|
1119
|
+
const chunk = pr.text || pr.content || ''
|
|
1120
|
+
if (typeof chunk === 'string' && chunk) {
|
|
1121
|
+
const capLen = maxArgLen || 120
|
|
1122
|
+
entry = { at: Date.now(), kind: 'text', stepIndex: idx, tool: 'agent_response', args: { text: chunk.slice(0, capLen) }, state: 'ACTIVE', label: shortLabel(chunk) }
|
|
1123
|
+
p.current = { tool: 'agent_response', args: entry.args, stepIndex: idx, since: entry.at }
|
|
1124
|
+
}
|
|
1125
|
+
} else if (t === 'error') {
|
|
1126
|
+
const err = pr.error || pr
|
|
1127
|
+
const msg = (err.data && err.data.message) || err.message || 'error'
|
|
1128
|
+
entry = { at: Date.now(), kind: 'error', stepIndex: idx, tool: 'error', args: { message: String(msg).slice(0, maxArgLen || 120) }, state: 'FAILED', label: shortLabel(msg) }
|
|
1129
|
+
}
|
|
1130
|
+
if (entry && p.trail) {
|
|
1131
|
+
p.trail.push(entry)
|
|
1132
|
+
while (p.trail.length > cap) p.trail.shift()
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
|
|
1136
|
+
// 旧 LIMIT_RE 覆盖(kit 版不含 free API/登录/第三方等 MiMo 文案)。
|
|
1137
|
+
isLimited(res) {
|
|
1138
|
+
if (!res || res.ok) return false
|
|
1139
|
+
if (res.status === 'SPAWN_ERROR' || res.status === 'MIMO_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
|
|
1140
|
+
const hay = String(res.stderr || '') + ' ' + String(res.response || '') + ' ' + String(res.status || '') + ' ' + String(res.errorMessage || '')
|
|
1141
|
+
return MIMO_LIMIT_RE.test(hay)
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
failureHint(res) {
|
|
1145
|
+
if (!res || res.ok) return ''
|
|
1146
|
+
const st = String(res.status || '')
|
|
1147
|
+
if (st === 'MIMO_UNAVAILABLE') return '[诊断] 未找到 mimo 可执行文件:请安装 MiMoCode CLI(mimo),或设置 MIMO_BIN 指向其绝对路径;也可改用 backend=mimo-desktop 走 MiMo Desktop 客户端。'
|
|
1148
|
+
if (st === 'DESKTOP_UNREACHABLE') return '[诊断] MiMo Desktop 本地 API 不可达/本宿主不支持 HTTP:请启动 Xiaomi MiMo Desktop 后重试,或改用 backend=mimocode(本地 mimo CLI)。'
|
|
1149
|
+
if (st === 'DESKTOP_SESSION_NOT_CREATED') return '[诊断] MiMo Desktop API 无法创建会话:请在 Desktop 应用中新建或选用现有会话,把它的 id 传给 sessionId 参数。'
|
|
1150
|
+
if (/free API service has ended|sign in|configure a third-party|登录|第三方/i.test(String(res.stderr || '') + String(res.errorMessage || ''))) {
|
|
1151
|
+
return '[诊断] MiMo 免费 API 服务疑似已停止或未登录:请在 mimo CLI 完成登录/配置第三方模型(configure a third-party),或改用 backend=mimo-desktop 走 Desktop 登录态。'
|
|
1152
|
+
}
|
|
1153
|
+
return ''
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1156
|
+
// ACCEPTED 计入成功态(指示灯不红;契约 §1)。
|
|
1157
|
+
isSuccessStatus(st) {
|
|
1158
|
+
if (!st) return false
|
|
1159
|
+
return st === 'SUCCESS' || st === 'ACCEPTED'
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
// 桌面可达性前置检查(契约 §9):dynamic 无 fetch / 无发现 → abort 提示 mimocode。
|
|
1163
|
+
// 有 fs 时做一次健康探测缓存(异步进行中先放行 —— preflight 是同步钩子)。
|
|
1164
|
+
preflight({ backend }) {
|
|
1165
|
+
if (backend !== BACKEND_DESKTOP) return null
|
|
1166
|
+
desktop = discoverDesktop()
|
|
1167
|
+
if (!fetchFn()) {
|
|
1168
|
+
return {
|
|
1169
|
+
abort: desktopResult(false, {
|
|
1170
|
+
status: 'DESKTOP_UNREACHABLE',
|
|
1171
|
+
mode: 'auto',
|
|
1172
|
+
errorMessage: '当前宿主无 HTTP fetch 能力(dynamic 沙箱),mimo-desktop 不可用。请改用 backend=mimocode,或在 DSH 正式宿主(preset/MCP 形态)中使用桌面后端。'
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (!desktop.available) {
|
|
1177
|
+
return {
|
|
1178
|
+
abort: desktopResult(false, {
|
|
1179
|
+
status: 'DESKTOP_UNREACHABLE',
|
|
1180
|
+
mode: 'auto',
|
|
1181
|
+
stderr: String(desktop.error || 'not configured'),
|
|
1182
|
+
errorMessage: 'MiMo Desktop client unavailable. Start Xiaomi MiMo Desktop, or use backend=mimocode.'
|
|
1183
|
+
})
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return null
|
|
1187
|
+
},
|
|
1188
|
+
|
|
1189
|
+
// mimo-desktop 委托执行(旧 runDesktop verbatim 编排;engine.begin/end 由 kit 做)。
|
|
1190
|
+
async execute({ args, cwd, built }) {
|
|
1191
|
+
desktop = discoverDesktop()
|
|
1192
|
+
const a = args || {}
|
|
1193
|
+
const sessionId = a.sessionId || newDesktopSessionId()
|
|
1194
|
+
const mode = built && built.mode ? built.mode : (a.mode || 'auto')
|
|
1195
|
+
try {
|
|
1196
|
+
const health = await desktopHealth()
|
|
1197
|
+
if (!health.ok) {
|
|
1198
|
+
return desktopResult(false, {
|
|
1199
|
+
status: 'DESKTOP_UNAVAILABLE',
|
|
1200
|
+
sessionId,
|
|
1201
|
+
mode,
|
|
1202
|
+
stderr: String(health.error || health.body || 'desktop api not ready'),
|
|
1203
|
+
errorMessage: 'MiMo Desktop client unavailable. Start Xiaomi MiMo Desktop and retry, or set backend=mimocode.'
|
|
1204
|
+
})
|
|
1205
|
+
}
|
|
1206
|
+
const s = partitionSettings()
|
|
1207
|
+
const turn = await desktopSendTurn(sessionId, {
|
|
1208
|
+
prompt: a.prompt,
|
|
1209
|
+
model: a.model || desktopModelChain(s),
|
|
1210
|
+
directory: cwd
|
|
1211
|
+
})
|
|
1212
|
+
const okTurn = turn.delivered === true
|
|
1213
|
+
let text = ''
|
|
1214
|
+
if (okTurn) {
|
|
1215
|
+
try {
|
|
1216
|
+
const ev = await desktopReadEvents(sessionId, cwd, 2500)
|
|
1217
|
+
text = ev.text || ''
|
|
1218
|
+
} catch (e) { }
|
|
1219
|
+
}
|
|
1220
|
+
return desktopResult(okTurn, {
|
|
1221
|
+
status: okTurn ? (text ? 'SUCCESS' : 'ACCEPTED') : 'DESKTOP_SESSION_NOT_CREATED',
|
|
1222
|
+
response: text || (okTurn
|
|
1223
|
+
? '(Desktop turn accepted; watch Desktop UI or call mimo_continue for the reply)'
|
|
1224
|
+
: ''),
|
|
1225
|
+
sessionId,
|
|
1226
|
+
accepted: okTurn,
|
|
1227
|
+
mode,
|
|
1228
|
+
stderr: okTurn ? '' : 'Desktop API returned ' + turn.status + ' but session ' + turn.id + ' does not exist',
|
|
1229
|
+
errorMessage: okTurn ? '' : ('MiMo Desktop 未创建会话(API 返回 ' + turn.status +
|
|
1230
|
+
' 但会话列表中不存在 ' + turn.id + ')。任务未执行。' +
|
|
1231
|
+
'MiMo Desktop API 无法创建会话,请在 Xiaomi MiMo Desktop 应用中新建(或选用现有)' +
|
|
1232
|
+
'会话,再把它的 id 传给 mimo_run 的 sessionId 参数。')
|
|
1233
|
+
})
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
return desktopResult(false, {
|
|
1236
|
+
status: 'DESKTOP_ERROR',
|
|
1237
|
+
sessionId,
|
|
1238
|
+
mode,
|
|
1239
|
+
stderr: String(e && e.message || e),
|
|
1240
|
+
errorMessage: String(e && e.message || e)
|
|
1241
|
+
})
|
|
1242
|
+
}
|
|
1243
|
+
},
|
|
1244
|
+
|
|
1245
|
+
tools: {
|
|
1246
|
+
run: { name: 'mimo_run', description: 'Dispatch a coding/build/debug/investigation task to the local mimo agent CLI (Xiaomi MiMo Desktop / MiMoCode) and return its final answer. DSH fully controls mimo (--dangerously-skip-permissions; mimo never prompts). On rate-limit/network failure DSH pops a fallback dialog; fallback=true means finish with native tools. Runs in the background by default (returns a jobId immediately; collect with job_output); set background=false to block for the final answer. While it runs, call mimo_status to watch what mimo is doing live.', inputSchema: RUN_PARAMS },
|
|
1247
|
+
continue: { name: 'mimo_continue', description: 'Continue an existing mimo session with a follow-up prompt. Pass sessionId or set latest=true. Same DSH-controlled, no-prompt execution and same fallback dialog as mimo_run.', inputSchema: CONTINUE_PARAMS },
|
|
1248
|
+
status: { name: 'mimo_status', description: 'Read a live snapshot of what the local mimo agent is currently doing. Returns one section per project (working directory): running count, current step, recent step trail, last completed run status + session id, per-project runs/token usage. Call this to check on an in-flight mimo_run/mimo_continue without waiting for it to finish.', inputSchema: STATUS_PARAMS }
|
|
1249
|
+
},
|
|
1250
|
+
|
|
1251
|
+
// mimo_settings:入参键保持旧 prompt 连续性(backend/desktopModel),落统一分区
|
|
1252
|
+
// preferredBackend/desktopModel(迁移映射 keystone 已做)。写盘仅在宿主注入
|
|
1253
|
+
// nodeFs + nodePath + settingsWrite 时可用;否则回显(dynamic 沙箱旧 echo 语义)。
|
|
1254
|
+
extraTools: [{
|
|
1255
|
+
name: 'mimo_settings',
|
|
1256
|
+
description: SETTINGS_DESCRIPTION,
|
|
1257
|
+
inputSchema: SETTINGS_PARAMS,
|
|
1258
|
+
render: renderSettings,
|
|
1259
|
+
async logic(args, exec) {
|
|
1260
|
+
const a = args || {}
|
|
1261
|
+
const live = liveSettings()
|
|
1262
|
+
const cur = Object.assign({ preferredBackend: DEFAULT_BACKEND, desktopModel: 'mimo-auto' }, live.partition || {})
|
|
1263
|
+
let settingsPath = live.path || null
|
|
1264
|
+
let saved = false
|
|
1265
|
+
let saveError = null
|
|
1266
|
+
const fsMod = nodeFs
|
|
1267
|
+
const pathMod = nodePath
|
|
1268
|
+
const home = live.home || deps.dshHome || null
|
|
1269
|
+
if ((a.backend || a.desktopModel) && fsMod && pathMod && (settingsPath || home)) {
|
|
1270
|
+
try {
|
|
1271
|
+
settingsPath = await writePartition(fsMod, pathMod, settingsPath, home, env, cur, a)
|
|
1272
|
+
saved = true
|
|
1273
|
+
} catch (e) { saveError = String(e && e.message || e) }
|
|
1274
|
+
}
|
|
1275
|
+
const merged = Object.assign({}, cur)
|
|
1276
|
+
if (a.backend) merged.preferredBackend = a.backend
|
|
1277
|
+
if (a.desktopModel) merged.desktopModel = a.desktopModel
|
|
1278
|
+
const rt = discoverDesktop()
|
|
1279
|
+
return {
|
|
1280
|
+
ok: !saveError,
|
|
1281
|
+
settingsPath: settingsPath || '(unified; not writable in this host)',
|
|
1282
|
+
fileBackend: merged.preferredBackend || null,
|
|
1283
|
+
configBackend: (deps.config && deps.config.backend) || null,
|
|
1284
|
+
envBackend: env.MIMO_BRIDGE_BACKEND || env.MIMO_BACKEND || null,
|
|
1285
|
+
effectiveBackend: descriptor.resolveBackend({ backend: a.backend }, null, normalizeBackend(env.MIMO_BRIDGE_BACKEND || env.MIMO_BACKEND) || merged.preferredBackend),
|
|
1286
|
+
desktopModel: merged.desktopModel || 'mimo-auto',
|
|
1287
|
+
desktopAvailable: !!rt.available,
|
|
1288
|
+
desktopError: rt.error || null,
|
|
1289
|
+
desktopApiFile: rt.apiFile || null,
|
|
1290
|
+
saved,
|
|
1291
|
+
saveError,
|
|
1292
|
+
backends: BACKENDS,
|
|
1293
|
+
note: 'Preselect via settings file / plugin config.backend / MIMO_BRIDGE_BACKEND. Per-call `backend` overrides only that call.'
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}],
|
|
1297
|
+
|
|
1298
|
+
// 设置分区(契约 §3):keystone 读写/GUI;本处纯逻辑。diagnose/meta 必须同步。
|
|
1299
|
+
settings: {
|
|
1300
|
+
keys: ['preferredBackend', 'desktopModel'],
|
|
1301
|
+
defaults: () => ({ preferredBackend: DEFAULT_BACKEND, desktopModel: 'mimo-auto' }),
|
|
1302
|
+
normalize(raw) {
|
|
1303
|
+
const src = raw && typeof raw === 'object' ? raw : {}
|
|
1304
|
+
const out = { preferredBackend: DEFAULT_BACKEND, desktopModel: 'mimo-auto' }
|
|
1305
|
+
const b = normalizeBackend(src.preferredBackend)
|
|
1306
|
+
if (b) out.preferredBackend = b
|
|
1307
|
+
if (typeof src.desktopModel === 'string' && src.desktopModel.trim()) out.desktopModel = src.desktopModel.trim()
|
|
1308
|
+
return out
|
|
1309
|
+
},
|
|
1310
|
+
meta() {
|
|
1311
|
+
return {
|
|
1312
|
+
titleSuffix: 'MiMo',
|
|
1313
|
+
fields: [
|
|
1314
|
+
{ key: 'preferredBackend', label: '预选后端', type: 'select', options: [{ value: 'mimocode', label: 'MiMoCode CLI(本地 mimo 命令,DSH 完全控制)' }, { value: 'mimo-desktop', label: 'MiMo Desktop 客户端(本地 API,共享其登录/模型/会话 UI)' }], note: 'mimo_run/mimo_continue 默认走这里选的后端;单次调用可用 backend 参数覆盖。' },
|
|
1315
|
+
{ key: 'desktopModel', label: '桌面模型', type: 'text', allowCustom: true, placeholder: 'mimo-auto', note: '仅 mimo-desktop 后端使用(Desktop 里的模型 id,如 mimo-auto)。' }
|
|
1316
|
+
]
|
|
1317
|
+
}
|
|
1318
|
+
},
|
|
1319
|
+
// 同步一行诊断:探测缓存 + 惰性 health 后台刷新(结果进缓存,下次面板可见)。
|
|
1320
|
+
diagnose(settings) {
|
|
1321
|
+
const rt = discoverDesktop()
|
|
1322
|
+
const f = fetchFn()
|
|
1323
|
+
if (!f) return ''
|
|
1324
|
+
if (!rt.available) return '桌面 API 未发现(' + (rt.error || '未配置') + ');mimocode 后端不受影响'
|
|
1325
|
+
const s = settings && settings.preferredBackend || partitionSettings() && partitionSettings().preferredBackend || DEFAULT_BACKEND
|
|
1326
|
+
try {
|
|
1327
|
+
f(rt.baseUrl + DESKTOP_API.healthPath, { headers: { Authorization: 'Bearer ' + rt.token } })
|
|
1328
|
+
.then((r) => { desktopHealthCache = { ok: r.ok, at: Date.now() } })
|
|
1329
|
+
.catch(() => { desktopHealthCache = { ok: false, at: Date.now() } })
|
|
1330
|
+
} catch (e) { desktopHealthCache = { ok: false, at: Date.now() } }
|
|
1331
|
+
if (desktopHealthCache.at && Date.now() - desktopHealthCache.at < 60000) {
|
|
1332
|
+
return desktopHealthCache.ok
|
|
1333
|
+
? ('桌面 API 在线(' + rt.baseUrl + ');当前预选 ' + s)
|
|
1334
|
+
: ('桌面 API 不可达(检查 MiMo Desktop 是否在运行);当前预选 ' + s + ',可改用 mimocode')
|
|
1335
|
+
}
|
|
1336
|
+
desktopHealthCache = { ok: null, at: Date.now() }
|
|
1337
|
+
return '桌面 API 已发现(' + rt.baseUrl + (rt.apiFile ? '' : ',env 配置') + ');健康检测中…;当前预选 ' + s
|
|
1338
|
+
}
|
|
1339
|
+
},
|
|
1340
|
+
|
|
1341
|
+
mcp: { version: '0.2.2' }
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
let desktopHealthCache = { ok: null, at: 0 }
|
|
1345
|
+
|
|
1346
|
+
// 统一分区写入(tmp→rename 原子写;与 keystone lib/settings.mjs 同文件同分区规则,
|
|
1347
|
+
// 本工具自实现落盘 —— 不 import home-plugin;宿主给出 settingsPath 或 dshHome)。
|
|
1348
|
+
async function writePartition(fsMod, pathMod, knownPath, home, environment, cur, patch) {
|
|
1349
|
+
const SETTINGS_FILE_NAME = 'cli-bridge-settings.json'
|
|
1350
|
+
const file = knownPath || environment.CLI_BRIDGE_SETTINGS_FILE || (home ? pathMod.join(home, SETTINGS_FILE_NAME) : null)
|
|
1351
|
+
if (!file) throw new Error('dsh-home not resolved (set DSH_HOME or CLI_BRIDGE_SETTINGS_FILE)')
|
|
1352
|
+
let all = {}
|
|
1353
|
+
try { all = JSON.parse(fsMod.readFileSync(file, 'utf8')) } catch (e) { all = {} }
|
|
1354
|
+
if (!all || typeof all !== 'object') all = {}
|
|
1355
|
+
const next = Object.assign({}, all)
|
|
1356
|
+
const part = Object.assign({}, cur, next.mimo || {})
|
|
1357
|
+
if (patch.backend) part.preferredBackend = normalizeBackend(patch.backend) || patch.backend
|
|
1358
|
+
if (patch.desktopModel) part.desktopModel = patch.desktopModel
|
|
1359
|
+
next.mimo = descriptor.settings.normalize(part)
|
|
1360
|
+
next.updatedAt = Date.now()
|
|
1361
|
+
try { fsMod.mkdirSync(pathMod.dirname(file), { recursive: true }) } catch (e) { }
|
|
1362
|
+
const tmp = file + '.tmp-' + ((typeof process !== 'undefined' && process.pid) || 'mimo')
|
|
1363
|
+
fsMod.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8')
|
|
1364
|
+
fsMod.renameSync(tmp, file)
|
|
1365
|
+
return file
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// 三形态共用的工具逻辑。签名保持不变(keystone 与队友按此接线)。
|
|
1369
|
+
descriptor.runToolLogic = async function (args, exec) {
|
|
1370
|
+
const a = args || {}
|
|
1371
|
+
// 旧 mimo preset 语义:background 默认 true(仅显式 false 才前台阻塞)。
|
|
1372
|
+
const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
|
|
1373
|
+
return this.runner.coreExecute(normalized, exec)
|
|
1374
|
+
}
|
|
1375
|
+
descriptor.continueToolLogic = async function (args, exec) {
|
|
1376
|
+
const a = args || {}
|
|
1377
|
+
const mapped = {
|
|
1378
|
+
prompt: a.prompt,
|
|
1379
|
+
mode: a.mode,
|
|
1380
|
+
model: a.model,
|
|
1381
|
+
effort: a.effort,
|
|
1382
|
+
cwd: a.cwd,
|
|
1383
|
+
timeoutSec: a.timeoutSec,
|
|
1384
|
+
background: a.background,
|
|
1385
|
+
backend: a.backend,
|
|
1386
|
+
// 【关键】desktop 后端的 sessionId 必须转发(白名单转发漏了它会导致
|
|
1387
|
+
// mimo_run 传 sessionId 被静默丢弃 → 永远造新 id → desktop 后端不可用)。
|
|
1388
|
+
sessionId: a.sessionId
|
|
1389
|
+
}
|
|
1390
|
+
if (a.sessionId) mapped.sessionId = a.sessionId
|
|
1391
|
+
else if (a.latest) mapped.continueLatest = true
|
|
1392
|
+
return this.runToolLogic(mapped, exec)
|
|
1393
|
+
}
|
|
1394
|
+
descriptor.statusToolLogic = async function (args) {
|
|
1395
|
+
const a = args || {}
|
|
1396
|
+
const snap = this.engine.statusSnapshot()
|
|
1397
|
+
if (a.cwd) {
|
|
1398
|
+
const key = String(a.cwd)
|
|
1399
|
+
snap.projects = (snap.projects || []).filter((p) => p.cwd === key)
|
|
1400
|
+
const g = snap.projects[0]
|
|
1401
|
+
if (g) {
|
|
1402
|
+
snap.state = g.state
|
|
1403
|
+
snap.running = g.running
|
|
1404
|
+
snap.current = g.current
|
|
1405
|
+
snap.trail = g.trail
|
|
1406
|
+
snap.lastStatus = g.lastStatus
|
|
1407
|
+
snap.lastAt = g.lastAt
|
|
1408
|
+
snap.lastSessionId = g.lastSessionId
|
|
1409
|
+
snap.fallbackActive = g.fallbackActive
|
|
1410
|
+
snap.runs = g.runs
|
|
1411
|
+
snap.totalTokens = g.totalTokens
|
|
1412
|
+
snap.updatedAt = g.updatedAt
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return snap
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// 工厂装配(provider 无关骨架 + 描述符):宿主形态通过 deps 注入 runner 依赖。
|
|
1419
|
+
descriptor.attach = function (hostOpts) {
|
|
1420
|
+
this.engine = createStatusEngine({ provider: this, publish: hostOpts.publish })
|
|
1421
|
+
const runner = createRunner(Object.assign({}, hostOpts, { provider: this, engine: this.engine }))
|
|
1422
|
+
this.runner = runner
|
|
1423
|
+
return runner
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// 测试/宿主可见的纯函数面(不进协议字段)。
|
|
1427
|
+
descriptor._internals = {
|
|
1428
|
+
BACKENDS, BACKEND_MIMOCODE, BACKEND_DESKTOP, DEFAULT_BACKEND, MIMO_LIMIT_RE,
|
|
1429
|
+
DESKTOP_API, parseMimoJson, buildResult, desktopResult, buildDesktopTurnBody,
|
|
1430
|
+
newDesktopSessionId, parseDesktopEvents, normalizeBackend, discoverDesktop,
|
|
1431
|
+
desktopFetch, desktopHealth, desktopSendTurn, desktopReadEvents
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return descriptor
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// core/scheduling.mjs — CLI 调度(启用集合 + 调用优先级)单源纯逻辑。
|
|
1438
|
+
//
|
|
1439
|
+
// 语义(用户可见的两件事):
|
|
1440
|
+
// • enabled = 勾选启用哪些 CLI(数量随之而定);未启用的一面不注册工具、不进提示词。
|
|
1441
|
+
// • priority = 智能体调用 CLI 的建议顺序,数组顺序即优先级(首位最高)。
|
|
1442
|
+
//
|
|
1443
|
+
// 落盘位置:统一设置文件 {DSH_HOME}/cli-bridge-settings.json 的顶层 `_scheduling` 区。
|
|
1444
|
+
// 下划线前缀 = 非 provider 分区(registry 的 PROVIDER_IDS 永远不含它,逐分区语义不受影响),
|
|
1445
|
+
// 因此新代码写出的文件被旧包读取时整区被忽略 = 纯增量,向后兼容。
|
|
1446
|
+
//
|
|
1447
|
+
// 缺省语义(文件缺失/无该区):全部 provider 启用、按注册表顺序 —— 与调度特性上线前
|
|
1448
|
+
// 的行为逐字一致(读不到配置永远不等于关掉所有桥)。
|
|
1449
|
+
//
|
|
1450
|
+
// 沙箱纪律:与 bridge-kit 同源约束 —— 不得 import;Node 内建一律经
|
|
1451
|
+
// globalThis.process.getBuiltinModule + typeof 守卫(动态宿主沙箱可能没有 fs,
|
|
1452
|
+
// 那种环境读不到文件 → 走缺省全开,绝不炸 apply)。
|
|
1453
|
+
|
|
1454
|
+
/** 设置文件里调度区的键名(下划线前缀避开 provider id 命名空间)。 */
|
|
1455
|
+
const SCHEDULING_SECTION = '_scheduling'
|
|
1456
|
+
|
|
1457
|
+
/** 人类可读的默认注册顺序(调用建议顺序);enabled 缺省 = 全部启用。 */
|
|
1458
|
+
function defaultPriority(ids) {
|
|
1459
|
+
return (Array.isArray(ids) ? ids : []).slice()
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* 清洗任意来源的调度对象。
|
|
1464
|
+
* @param {unknown} raw 文件/POST 里的原始值(可能是任何东西,全部容错)
|
|
1465
|
+
* @param {string[]} ids 全量 provider id(顺序 = 缺省优先级)
|
|
1466
|
+
* @param {(id: string) => string} [labelOf] 展示名(仅用于诊断文本,不参与判定)
|
|
1467
|
+
*/
|
|
1468
|
+
function normalizeScheduling(raw, ids, labelOf) {
|
|
1469
|
+
const all = Array.isArray(ids) ? ids.slice() : []
|
|
1470
|
+
const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
|
|
1471
|
+
const enabled = all.slice()
|
|
1472
|
+
let priority = all.slice()
|
|
1473
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
1474
|
+
if (Array.isArray(raw.enabled)) {
|
|
1475
|
+
const seen = Object.create(null)
|
|
1476
|
+
const picked = []
|
|
1477
|
+
for (const x of raw.enabled) {
|
|
1478
|
+
const id = String(x == null ? '' : x)
|
|
1479
|
+
if (!id || all.indexOf(id) < 0 || seen[id]) continue
|
|
1480
|
+
seen[id] = true; picked.push(id)
|
|
1481
|
+
}
|
|
1482
|
+
// 空数组 = 用户真的想把全部关掉(而不是「没配」),保留该语义;
|
|
1483
|
+
// 非空但过滤后为空 = 写的全是未知 id(坏值)→ 回落全开(fail-open,
|
|
1484
|
+
// 绝不让一次手滑/陈旧配置把所有桥关掉)。
|
|
1485
|
+
if (raw.enabled.length === 0 || picked.length > 0) {
|
|
1486
|
+
enabled.length = 0
|
|
1487
|
+
Array.prototype.push.apply(enabled, picked)
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (Array.isArray(raw.priority)) {
|
|
1491
|
+
const seen2 = Object.create(null)
|
|
1492
|
+
const ordered = []
|
|
1493
|
+
for (const x of raw.priority) {
|
|
1494
|
+
const id = String(x == null ? '' : x)
|
|
1495
|
+
if (!id || all.indexOf(id) < 0 || seen2[id]) continue
|
|
1496
|
+
seen2[id] = true; ordered.push(id)
|
|
1497
|
+
}
|
|
1498
|
+
for (const id of all) if (!seen2[id]) ordered.push(id) // 漏写的排在末尾
|
|
1499
|
+
if (ordered.length) priority = ordered
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const order = priority.filter((id) => enabled.indexOf(id) >= 0)
|
|
1503
|
+
for (const id of enabled) if (order.indexOf(id) < 0) order.push(id)
|
|
1504
|
+
return { enabled, priority, order }
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** 展示文本:「CB → MIMO」或「全部启用 · CB → AGY → MIMO」。 */
|
|
1508
|
+
function describeScheduling(s, labelOf) {
|
|
1509
|
+
const st = s || {}
|
|
1510
|
+
const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
|
|
1511
|
+
const order = Array.isArray(st.order) ? st.order : []
|
|
1512
|
+
const names = order.map(label).join(' → ')
|
|
1513
|
+
return names ? names : '(全部禁用)'
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// ── 读盘(四形态共用;无 fs 时安全返回 null)────────────────────────────────────
|
|
1517
|
+
// 名字带 sched 前缀:本文件会与 bridge-kit / provider 描述符【文本内联进同一个动态
|
|
1518
|
+
// 沙箱脚本作用域】(providers/codebuddy.mjs 已有 builtinModule),撞名即隐患。
|
|
1519
|
+
function schedBuiltinModule(name) {
|
|
1520
|
+
try {
|
|
1521
|
+
const proc = globalThis.process
|
|
1522
|
+
return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
|
|
1523
|
+
} catch (e) { return null }
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function schedulingSettingsPath(env) {
|
|
1527
|
+
const e = env || (globalThis.process && globalThis.process.env) || {}
|
|
1528
|
+
try {
|
|
1529
|
+
if (e.CLI_BRIDGE_SETTINGS_FILE) return String(e.CLI_BRIDGE_SETTINGS_FILE)
|
|
1530
|
+
const nodePath = schedBuiltinModule('node:path')
|
|
1531
|
+
if (nodePath && e.DSH_HOME) return nodePath.join(String(e.DSH_HOME), 'cli-bridge-settings.json')
|
|
1532
|
+
} catch (e2) { }
|
|
1533
|
+
return null
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* 生效调度:文件 _scheduling > 缺省全开。
|
|
1538
|
+
* env/argv CLI_BRIDGE_PROVIDER(单面钉住,旧薄封装兼容语义)存在时视为最高优先 ——
|
|
1539
|
+
* 它代表「这个 MCP 进程只为这一家而启动」(部署者的显式意志,比面板勾选更具体、
|
|
1540
|
+
* 更靠近进程生命周期),调度不再削减它。
|
|
1541
|
+
* 宿主形态(preset / dynamic 桥接插件)应传 ignorePin:钉住只是 MCP 进程的启动参数,
|
|
1542
|
+
* 不该让全局 env 反过来把用户会话里的其他桥关掉。
|
|
1543
|
+
* @param {string[]} ids 全量 provider id
|
|
1544
|
+
* @param {{ env?: Record<string,string>, readFileSync?: Function, path?: string|null, ignorePin?: boolean }} [io]
|
|
1545
|
+
*/
|
|
1546
|
+
function resolveScheduling(ids, io) {
|
|
1547
|
+
const o = io || {}
|
|
1548
|
+
const env = o.env || (globalThis.process && globalThis.process.env) || {}
|
|
1549
|
+
let raw = null
|
|
1550
|
+
try {
|
|
1551
|
+
const p = ('path' in o) ? o.path : schedulingSettingsPath(env)
|
|
1552
|
+
const nodeFs = o.readFileSync ? { readFileSync: o.readFileSync } : schedBuiltinModule('node:fs')
|
|
1553
|
+
if (p && nodeFs && typeof nodeFs.readFileSync === 'function') {
|
|
1554
|
+
raw = JSON.parse(nodeFs.readFileSync(p, 'utf8'))
|
|
1555
|
+
}
|
|
1556
|
+
} catch (e) { raw = null }
|
|
1557
|
+
const file = normalizeScheduling(raw && typeof raw === 'object' ? raw[SCHEDULING_SECTION] : null, ids)
|
|
1558
|
+
const pinned = o.ignorePin ? '' : String(env.CLI_BRIDGE_PROVIDER || '').trim()
|
|
1559
|
+
if (pinned && ids.indexOf(pinned) >= 0) {
|
|
1560
|
+
return Object.assign(file, {
|
|
1561
|
+
enabled: [pinned],
|
|
1562
|
+
order: [pinned],
|
|
1563
|
+
pinned: pinned,
|
|
1564
|
+
source: raw ? 'env:CLI_BRIDGE_PROVIDER+file' : 'env:CLI_BRIDGE_PROVIDER'
|
|
1565
|
+
})
|
|
1566
|
+
}
|
|
1567
|
+
return Object.assign(file, { pinned: '', source: raw ? 'file' : 'default' })
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* 桥接面门禁(preset / dynamic 的 apply() 首行用):本 provider 是否被调度启用。
|
|
1572
|
+
* fail-open —— 任何异常(无 fs、文件损坏、沙箱限制)都返回 true:读不到配置永远
|
|
1573
|
+
* 不等于关掉桥(否则一次文件损坏就把三条 CLI 全灭了)。ignorePin 固定 true:
|
|
1574
|
+
* 钉住语义属于 MCP 独立进程,宿主形态的全局 env 不该反向影响用户会话。
|
|
1575
|
+
*/
|
|
1576
|
+
function isBridgeEnabled(selfId, ids, io) {
|
|
1577
|
+
try {
|
|
1578
|
+
const list = Array.isArray(ids) ? ids : []
|
|
1579
|
+
// 全量清单本身不可得(空/不含自己)= 无门禁语义,fail-open。
|
|
1580
|
+
if (!list.length || list.indexOf(String(selfId)) < 0) return true
|
|
1581
|
+
const o = Object.assign({ ignorePin: true }, io || {})
|
|
1582
|
+
return resolveScheduling(list, o).enabled.indexOf(String(selfId)) >= 0
|
|
1583
|
+
} catch (e) { return true }
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
const SCHED_ALL_IDS = ["agy","codebuddy","mimo","opencode"]
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
return {
|
|
1591
|
+
inject: ['tools', 'subprocess', 'systemPrompt', 'timer'],
|
|
1592
|
+
apply(ctx) {
|
|
1593
|
+
// 调度门禁(0.2.0):设置面板关掉 mimo → 本形态不注册工具/政策段;fail-open。
|
|
1594
|
+
if (!isBridgeEnabled('mimo', SCHED_ALL_IDS)) return
|
|
1595
|
+
const subprocess = ctx.subprocess
|
|
1596
|
+
const planMode = ctx.get('planMode')
|
|
1597
|
+
const sandboxPolicy = ctx.get('sandboxPolicy')
|
|
1598
|
+
// 沙箱可能运行在无 process 的环境:typeof 守卫读 env(旧仓 dynamic 的等价面)。
|
|
1599
|
+
const env = (typeof process !== 'undefined' && process.env) ? process.env : {}
|
|
1600
|
+
|
|
1601
|
+
const provider = defineProvider({ env })
|
|
1602
|
+
|
|
1603
|
+
function publish(snapshot) {
|
|
1604
|
+
try {
|
|
1605
|
+
const collector = ctx.get('cliBridgeCollector')
|
|
1606
|
+
if (collector && typeof collector.mergeSnapshot === 'function') { collector.mergeSnapshot('mimo', snapshot); return }
|
|
1607
|
+
} catch (e) { /* fall through to legacy alias */ }
|
|
1608
|
+
try {
|
|
1609
|
+
const legacy = ctx.get('mimoCollector')
|
|
1610
|
+
if (legacy && typeof legacy.mergeSnapshot === 'function') legacy.mergeSnapshot(snapshot)
|
|
1611
|
+
} catch (e) { /* 灯数据尽力而为 */ }
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// exe:MIMO_BIN/MIMO_CLI env > PATH 查找 mimo(subprocess.resolveExecutable)。
|
|
1615
|
+
// mimo-desktop 后端不 spawn:返回占位字符串让 kit 直达 delegates→execute/preflight。
|
|
1616
|
+
async function resolveExe(backend, signal) {
|
|
1617
|
+
if (backend === 'mimo-desktop') return 'mimo'
|
|
1618
|
+
const bin = env.MIMO_BIN || env.MIMO_CLI || ''
|
|
1619
|
+
if (bin) {
|
|
1620
|
+
if (/\.(mjs|cjs|js)$/i.test(bin) && typeof process !== 'undefined' && process.execPath) return [process.execPath, bin]
|
|
1621
|
+
return bin
|
|
1622
|
+
}
|
|
1623
|
+
return await subprocess.resolveExecutable('mimo', undefined, signal)
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// 旧仓 CWD_FALLBACK 硬编码机器绝对路径;新语义:MIMO_MCP_CWD > MIMO_BRIDGE_CWD >
|
|
1627
|
+
// workspaceRoot > process.cwd > '.'(沙箱无 process.cwd 可用时以 '.' 兜底)。
|
|
1628
|
+
function getCwdFallback() {
|
|
1629
|
+
if (env.MIMO_MCP_CWD) return String(env.MIMO_MCP_CWD)
|
|
1630
|
+
if (env.MIMO_BRIDGE_CWD) return String(env.MIMO_BRIDGE_CWD)
|
|
1631
|
+
if (sandboxPolicy && typeof sandboxPolicy.workspaceRoot === 'string' && sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot
|
|
1632
|
+
if (typeof process !== 'undefined' && typeof process.cwd === 'function') { try { return process.cwd() } catch (e) {} }
|
|
1633
|
+
return '.'
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// 预选链(沙箱无 fs 读设置文件):env MIMO_BRIDGE_BACKEND / MIMO_BACKEND。
|
|
1637
|
+
function getPreferredBackend() {
|
|
1638
|
+
return env.MIMO_BRIDGE_BACKEND || env.MIMO_BACKEND || null
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function planActiveFor(exec) {
|
|
1642
|
+
try {
|
|
1643
|
+
if (planMode && exec && exec.agent) {
|
|
1644
|
+
const st = planMode.get(exec.agent)
|
|
1645
|
+
return !!(st && st.active)
|
|
1646
|
+
}
|
|
1647
|
+
} catch (e) {}
|
|
1648
|
+
return false
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
provider.attach({ ctx, subprocess, resolveExe, getCwdFallback, getPreferredBackend, planActiveFor, publish })
|
|
1652
|
+
|
|
1653
|
+
const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
|
|
1654
|
+
|
|
1655
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1656
|
+
name: provider.tools.run.name,
|
|
1657
|
+
description: provider.tools.run.description,
|
|
1658
|
+
parameters: provider.tools.run.inputSchema,
|
|
1659
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
|
|
1660
|
+
execute: function (args, exec) { return provider.runToolLogic(args, exec) }
|
|
1661
|
+
}))
|
|
1662
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1663
|
+
name: provider.tools.continue.name,
|
|
1664
|
+
description: provider.tools.continue.description,
|
|
1665
|
+
parameters: provider.tools.continue.inputSchema,
|
|
1666
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
|
|
1667
|
+
execute: function (args, exec) { return provider.continueToolLogic(args, exec) }
|
|
1668
|
+
}))
|
|
1669
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1670
|
+
name: provider.tools.status.name,
|
|
1671
|
+
description: provider.tools.status.description,
|
|
1672
|
+
parameters: provider.tools.status.inputSchema,
|
|
1673
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderStatus(value, provider) } },
|
|
1674
|
+
execute: function (args) { return provider.statusToolLogic(args) }
|
|
1675
|
+
}))
|
|
1676
|
+
// mimo_settings:沙箱无 nodeFs → 描述符内部自动降级为回显(不持久化)。
|
|
1677
|
+
// render 适配:harness 契约是 render(args, value),extraTools[].render 是
|
|
1678
|
+
// renderSettings(value) 单参 —— 直接透传会把 args 当 value(渲染全空)。
|
|
1679
|
+
for (const et of provider.extraTools) {
|
|
1680
|
+
harness.registerTool(ctx, harness.defineTool({
|
|
1681
|
+
name: et.name,
|
|
1682
|
+
description: et.description,
|
|
1683
|
+
parameters: et.inputSchema,
|
|
1684
|
+
output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return et.render ? et.render(value) : renderResult(value, provider) } },
|
|
1685
|
+
execute: function (args, exec) { return et.logic(args, exec) }
|
|
1686
|
+
}))
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// 浏览器指示灯轮询的 RPC 面(旧 dynamic/handle('mimo_status') 语义)。
|
|
1690
|
+
harness.handle('mimo_status', function () { return provider.statusToolLogic({}) })
|
|
1691
|
+
|
|
1692
|
+
// 旧 policy 文案 verbatim(提示词连续性红线)。
|
|
1693
|
+
const policyText = [
|
|
1694
|
+
'mimo-first execution policy (Xiaomi MiMo: MiMoCode CLI or MiMo Desktop client).',
|
|
1695
|
+
'',
|
|
1696
|
+
'Local backends (selectable in settings: backend = mimocode | mimo-desktop):',
|
|
1697
|
+
'- mimocode (default): local `mimo` CLI (MiMoCode). Full DSH control (--dangerously-skip-permissions, never prompts).',
|
|
1698
|
+
'- mimo-desktop: Xiaomi MiMo Desktop client local API (running Desktop app; uses its login/model/session UI).',
|
|
1699
|
+
'',
|
|
1700
|
+
'By default, dispatch runs in the background and returns a jobId immediately; collect via job_output and watch live progress via mimo_status.',
|
|
1701
|
+
'',
|
|
1702
|
+
'Decision table for tool selection:',
|
|
1703
|
+
'- Single read/glob/grep or short (<30s) command -> Native tools.',
|
|
1704
|
+
'- Implementing, editing, refactoring, debugging, builds, multi-file investigation -> mimo_run.',
|
|
1705
|
+
'- Follow-ups on the same topic -> mimo_continue (with latest=true or sessionId; context is preserved).',
|
|
1706
|
+
'- Final build/test verification of mimo output -> Native tools.',
|
|
1707
|
+
'- Plan / read-only analysis -> mimo_run with mode=plan.',
|
|
1708
|
+
'- Desktop-native work (Desktop session UI, shared Desktop login/model) -> prefer backend=mimo-desktop (or preselect it in settings).',
|
|
1709
|
+
'',
|
|
1710
|
+
'Backend preselection (settings):',
|
|
1711
|
+
'- Default backend is configured OUT-OF-BAND (plugin config.backend / env MIMO_BRIDGE_BACKEND / settings file).',
|
|
1712
|
+
'- Per-call `backend` argument overrides the default only when necessary.',
|
|
1713
|
+
'- Do NOT invent a backend every call — use the preselected default.',
|
|
1714
|
+
'',
|
|
1715
|
+
'Context protocol (one topic = one mimo session):',
|
|
1716
|
+
'- First dispatch on a topic: compact CONTEXT preamble (point to paths, do not paste file contents).',
|
|
1717
|
+
'- Follow-ups: call mimo_continue and state only what is new.',
|
|
1718
|
+
'',
|
|
1719
|
+
'Execution & Fallback protocol:',
|
|
1720
|
+
'- Dispatch defaults to background=true. Collect via job_output.',
|
|
1721
|
+
'- On rate-limit/network/auth failure, a confirmation dialog offers DSH-local fallback.',
|
|
1722
|
+
'- If result has fallback=true (status FALLBACK_TO_DSH), finish with native tools and NEVER call mimo again for this task.',
|
|
1723
|
+
'- Never loop mimo calls; never ask mimo to call back into DSH.'
|
|
1724
|
+
].join('\n')
|
|
1725
|
+
|
|
1726
|
+
ctx.systemPrompt.section({
|
|
1727
|
+
name: 'mimo:policy',
|
|
1728
|
+
order: (typeof ctx.systemPrompt.getSectionOrder === 'function' ? ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT') : 2800) + 10,
|
|
1729
|
+
text: policyText
|
|
1730
|
+
})
|
|
1731
|
+
}
|
|
1732
|
+
}
|