@luchenglong/cli-bridge 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/LICENSE +21 -0
  3. package/MCP-POLICY.md +53 -0
  4. package/PROVIDER-CONTRACT.md +235 -0
  5. package/README.en.md +98 -0
  6. package/README.md +150 -2
  7. package/assets/indicator-states.svg +62 -0
  8. package/bin/agy-quota.mjs +223 -0
  9. package/core/bridge-kit.mjs +549 -0
  10. package/core/scheduling.mjs +149 -0
  11. package/docs/ARCHITECTURE.md +117 -0
  12. package/docs/FALLBACK-AND-INDICATOR.md +93 -0
  13. package/docs/INSTALL.md +118 -0
  14. package/docs/MCP-POLICY.zh.md +49 -0
  15. package/docs/MIGRATION.md +43 -0
  16. package/docs/RELEASE-SOP.md +77 -0
  17. package/docs/history/agy/CHANGELOG.md +326 -0
  18. package/docs/history/codebuddy/CHANGELOG.md +668 -0
  19. package/docs/history/mimo/CHANGELOG.md +346 -0
  20. package/dynamic/agy.host.js +1237 -0
  21. package/dynamic/agy.host.template.mjs +140 -0
  22. package/dynamic/codebuddy.host.js +1787 -0
  23. package/dynamic/codebuddy.host.template.mjs +162 -0
  24. package/dynamic/mimo.host.js +1732 -0
  25. package/dynamic/mimo.host.template.mjs +169 -0
  26. package/home-plugin/cli-bridge-indicator/cordis.patch.yml +31 -0
  27. package/home-plugin/cli-bridge-indicator/lib/client.js +606 -0
  28. package/home-plugin/cli-bridge-indicator/lib/index.mjs +420 -0
  29. package/home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs +137 -0
  30. package/home-plugin/cli-bridge-indicator/lib/settings.mjs +300 -0
  31. package/mcp/agy-mcp-server.mjs +6 -0
  32. package/mcp/cli-bridge-mcp.mjs +184 -0
  33. package/mcp/codebuddy-mcp-server.mjs +6 -0
  34. package/mcp/mimo-mcp-server.mjs +6 -0
  35. package/mcp/providers/agy.mjs +254 -0
  36. package/mcp/providers/codebuddy.mjs +397 -0
  37. package/mcp/providers/mimo.mjs +290 -0
  38. package/package.json +87 -4
  39. package/preset/agy-first/agent.cordis.yml +141 -0
  40. package/preset/agy-first/cli-bridge.mjs +169 -0
  41. package/preset/agy-first/plugin-list.mjs +26 -0
  42. package/preset/agy-first/preset.yml +3 -0
  43. package/preset/codebuddy-first/agent.cordis.yml +141 -0
  44. package/preset/codebuddy-first/cli-bridge.mjs +188 -0
  45. package/preset/codebuddy-first/plugin-list.mjs +20 -0
  46. package/preset/codebuddy-first/preset.yml +3 -0
  47. package/preset/mimo-first/agent.cordis.yml +143 -0
  48. package/preset/mimo-first/cli-bridge.mjs +236 -0
  49. package/preset/mimo-first/plugin-list.mjs +39 -0
  50. package/preset/mimo-first/preset.yml +3 -0
  51. package/preset/standard-base.mjs +102 -0
  52. package/providers/agy.mjs +398 -0
  53. package/providers/codebuddy.mjs +926 -0
  54. package/providers/mimo.mjs +864 -0
  55. package/providers/registry.mjs +34 -0
  56. package/scripts/build.mjs +235 -0
  57. package/scripts/deploy.mjs +61 -0
  58. package/scripts/verify.mjs +148 -0
@@ -0,0 +1,1237 @@
1
+ // dynamic/agy.host.template.mjs — agy 动态沙箱宿主模板(cordis_define code.host 源)。
2
+ //
3
+ // 构建(node scripts/build.mjs --only=agy)把 core/bridge-kit.mjs 与
4
+ // providers/agy.mjs 分别内联进下方两个占位标记行(各占一行、行内仅此标记),
5
+ // 生成 dynamic/agy.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('agy', snap)
13
+ // 推给家级灯(旧 agyCollector 服务名在 keystone 里保留别名);动态形态不宣告
14
+ // mode(旧语义:普通模式灯仅在调用 agy 时临时显示)。
15
+ // - agy_quota 与 5h 额度门禁在沙箱形态省略(deps 无 process/nodeChild → 描述符
16
+ // 静默降级;Lead 定案 4)。
17
+ // core/bridge-kit.mjs — CLI Bridge 单一事实来源:通用执行/状态骨架。
18
+ //
19
+ // 深度重构核心(合并 agy-first-bridge / codebuddy-first-bridge / mimo-first-bridge):
20
+ // 三条 bridge 的 host 半此前各自复制了一份几乎相同的「状态引擎 + 行流 + 执行编排
21
+ // + 限流回退弹窗 + MCP 快照上报 + 展示层」。本文件把这些**与 provider 无关**的骨架
22
+ // 收敛成一份参数化实现,每个 provider 只提供一个 descriptor(见 providers/*.mjs 与
23
+ // PROVIDER-CONTRACT.md),由 createRunner/createStatusEngine 回调它完成差异化步骤。
24
+ //
25
+ // 骨架覆盖:
26
+ // - 纯函数:projectName / clampInt / shortLabel / summarizeArgs / endpointHost
27
+ // - 限流/网络判定:LIMIT_RE + isLimited(provider 可收窄)
28
+ // - 半行安全行流:createLineStream
29
+ // - 按项目(cwd)聚合、按 provider 分桶的状态引擎:createStatusEngine
30
+ // - 执行编排(前台/后台/超时守卫/瞬时错误重试/限流三选一弹窗/回退):createRunner
31
+ // - 工具结果与状态的人类可读渲染:renderResult / renderStatus
32
+ // - MCP 子进程快照上报:buildMcpBridgePayload / normalizeMcpBridge(provider 分文件)
33
+ // - 回退弹窗三选一文案(provider 名注入)
34
+ //
35
+ // 约束(沙箱与真实 Node 共用,勿违反):
36
+ // - 本模块**不 import 任何模块**、不用 process/Buffer/原生定时器/AbortController;
37
+ // 宿主能力(subprocess.spawn / ctx.interval / ctx.timeout / jobs / userQuestions)
38
+ // 全部经 createRunner 的 opts 注入,取消用 exec.signal + handle.terminate()。
39
+ // - 不序列化任何 DSH 活对象;跨 RPC 只传无 Host 引用的纯 JSON 叶子字段。
40
+
41
+ // ── 常量 ──────────────────────────────────────────────────────────────────────
42
+
43
+ const MAX_TRAIL = 12
44
+ const MAX_ARG_LEN = 120
45
+ const MAX_PROJECTS = 24
46
+ const MAX_SESSIONS = 256
47
+
48
+ // 限流/网络类错误的宽松正则(英文 + 中文)。provider descriptor 可用自己的收窄版覆盖。
49
+ const LIMIT_RE =
50
+ /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
51
+
52
+ // 家级插件与 MCP 子进程之间的快照文件:{dsh-home}/cli-bridge-mcp-<provider>.json
53
+ const MCP_BRIDGE_PREFIX = 'cli-bridge-mcp-'
54
+ const MCP_BRIDGE_STALE_MS = 90000
55
+ function mcpBridgeFileName(providerId) { return MCP_BRIDGE_PREFIX + providerId + '.json' }
56
+
57
+ // ── 纯函数 ────────────────────────────────────────────────────────────────────
58
+
59
+ function projectName(cwd) {
60
+ const s = String(cwd || '')
61
+ const parts = s.split(/[\\/]/).filter(Boolean)
62
+ return parts.length ? parts[parts.length - 1] : s
63
+ }
64
+
65
+ function clampInt(v, def, min, max) {
66
+ const n = Number(v)
67
+ if (!Number.isFinite(n)) return def
68
+ const i = Math.floor(n)
69
+ if (i < min) return min
70
+ if (i > max) return max
71
+ return i
72
+ }
73
+
74
+ function shortLabel(prompt) {
75
+ const s = String(prompt || '').replace(/\s+/g, ' ').trim()
76
+ return s.length > 80 ? s.slice(0, 77) + '...' : s
77
+ }
78
+
79
+ // 工具入参摘要(状态灯/活动面板)。返回可无损 JSON 化的数据;undefined 值跳过
80
+ // (DSH tool 渲染层不接受 undefined)。
81
+ function summarizeArgs(parameters) {
82
+ if (!parameters || typeof parameters !== 'object') return null
83
+ const out = {}
84
+ for (const k of Object.keys(parameters)) {
85
+ let v = parameters[k]
86
+ if (v === undefined) continue
87
+ if (typeof v === 'string' && v.length > MAX_ARG_LEN) v = v.slice(0, MAX_ARG_LEN) + '…'
88
+ out[k] = v
89
+ }
90
+ return out
91
+ }
92
+
93
+ function endpointHost(url) {
94
+ const m = String(url || '').match(/^https?:\/\/([^/]+)/i)
95
+ return m ? m[1] : ''
96
+ }
97
+
98
+ // 限流/网络类失败(要问用户,重试可能烧额度)。
99
+ function isLimited(res) {
100
+ if (!res || res.ok) return false
101
+ // AUTH_REQUIRED 是配置问题(缺凭据),弹回退/重试无意义 —— 直接给可操作指引。
102
+ if (res.status === 'AUTH_REQUIRED') return false
103
+ if (res.status === 'SPAWN_ERROR' || res.status === 'CLI_UNAVAILABLE' || res.status === 'HUNG_TIMEOUT') return true
104
+ const hay = String(res.stderr || '') + ' ' + String(res.status || '')
105
+ // 端点域与登录域不匹配的 401:需要用户换登录域,不是限流。
106
+ if (/401 authentication failed|differs from the current product endpoint/i.test(hay)) return false
107
+ return LIMIT_RE.test(hay)
108
+ }
109
+
110
+ // ── 半行安全的行流(跨 chunk 的 NDJSON 行拼接)───────────────────────────────
111
+ function createLineStream(consumeLine) {
112
+ let pending = ''
113
+ function pushChunk(text) {
114
+ if (typeof text !== 'string' || !text) return
115
+ const lines = (pending + text).split(/\r?\n/)
116
+ pending = lines.pop() || ''
117
+ for (const ln of lines) consumeLine(ln)
118
+ }
119
+ function flush() {
120
+ const rest = pending
121
+ pending = ''
122
+ if (rest) consumeLine(rest)
123
+ }
124
+ return { pushChunk, flush }
125
+ }
126
+
127
+ // ── 状态引擎(按 provider + 项目目录聚合的运行状态表)─────────────────────────
128
+ // opts: { provider: <descriptor>, publish(snapshot) }
129
+ // 快照结构(顶层为全局聚合,向后兼容单 provider 消费者):
130
+ // { provider, state, running, lastStatus, lastAt, lastSessionId, lastBackend,
131
+ // fallbackActive, current, trail, runs, totalTokens, updatedAt,
132
+ // projects:[{ cwd,name,provider,state,running,current,trail,lastStatus,lastAt,
133
+ // lastSessionId,lastBackend,fallbackActive,runs,totalTokens,updatedAt }] }
134
+ function createStatusEngine(opts) {
135
+ const o = opts || {}
136
+ const provider = o.provider
137
+ const providerId = provider && provider.id ? provider.id : 'cli'
138
+ const publish = typeof o.publish === 'function' ? o.publish : null
139
+ const projects = Object.create(null)
140
+ // sessionId -> { cwd, backend, provider }。会话按 provider 归属(登录域互斥),
141
+ // 续接时一次解析出 cwd 与 backend。容量上限 MAX_SESSIONS,FIFO 淘汰。
142
+ const sessions = Object.create(null)
143
+
144
+ function ensureProject(cwd) {
145
+ const key = String(cwd || '')
146
+ let p = projects[key]
147
+ if (!p) {
148
+ if (Object.keys(projects).length >= MAX_PROJECTS) {
149
+ const idle = Object.keys(projects).filter((k) => projects[k].running === 0)
150
+ const pool = idle.length ? idle : Object.keys(projects)
151
+ const victim = pool.sort((a, b) => projects[a].updatedAt - projects[b].updatedAt)[0]
152
+ delete projects[victim]
153
+ }
154
+ 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 }
155
+ projects[key] = p
156
+ }
157
+ return p
158
+ }
159
+
160
+ function globalStatus() {
161
+ const list = Object.keys(projects).map((k) => projects[k])
162
+ let running = 0, lastStatus = null, lastAt = 0, lastSessionId = null, lastBackend = null, fallbackActive = false, current = null, trail = [], updatedAt = 0, runs = 0, totalTokens = 0
163
+ for (const p of list) {
164
+ running += p.running
165
+ if (p.updatedAt > updatedAt) updatedAt = p.updatedAt
166
+ if (p.running > 0 && !current && p.current) current = p.current
167
+ if (p.lastAt > lastAt) { lastAt = p.lastAt; lastStatus = p.lastStatus; lastSessionId = p.lastSessionId; lastBackend = p.lastBackend }
168
+ if (p.fallbackActive) fallbackActive = true
169
+ runs += p.runs || 0
170
+ totalTokens += p.totalTokens || 0
171
+ for (const e of p.trail) trail.push(e)
172
+ }
173
+ trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
174
+ trail = trail.slice(-MAX_TRAIL)
175
+ const state = running > 0 ? 'running' : (fallbackActive ? 'fallback' : (lastStatus ? (lastStatus === 'SUCCESS' ? 'ok' : 'failed') : 'idle'))
176
+ return { state, running, lastStatus, lastAt, lastSessionId, lastBackend, fallbackActive, current, trail, runs, totalTokens, updatedAt }
177
+ }
178
+
179
+ function statusSnapshot() {
180
+ const g = globalStatus()
181
+ const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
182
+ return Object.assign({ provider: providerId }, g, {
183
+ 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 }))
184
+ })
185
+ }
186
+
187
+ function emit() { if (publish) { try { publish(statusSnapshot()) } catch (e) {} } }
188
+
189
+ function begin(cwd) {
190
+ const p = ensureProject(cwd)
191
+ p.running += 1
192
+ p.state = 'running'
193
+ p.updatedAt = Date.now()
194
+ emit()
195
+ }
196
+
197
+ function end(res, cwd) {
198
+ const p = ensureProject(cwd)
199
+ p.running = Math.max(0, p.running - 1)
200
+ p.lastStatus = res ? res.status : null
201
+ p.lastAt = Date.now()
202
+ if (res && res.sessionId) {
203
+ p.lastSessionId = res.sessionId
204
+ sessions[res.sessionId] = { cwd: p.cwd, backend: res.backend || (provider && provider.defaultBackend), provider: providerId }
205
+ const keys = Object.keys(sessions)
206
+ if (keys.length > MAX_SESSIONS) delete sessions[keys[0]]
207
+ }
208
+ if (res && res.backend) p.lastBackend = res.backend
209
+ p.runs = (p.runs || 0) + 1
210
+ if (res && typeof res.totalTokens === 'number' && res.totalTokens > 0) p.totalTokens = (p.totalTokens || 0) + res.totalTokens
211
+ if (res && res.fallback) { p.fallbackActive = true; p.state = 'fallback' }
212
+ else if (p.running > 0) { p.state = 'running' }
213
+ else { p.state = res && res.ok ? 'ok' : 'failed'; p.lastOk = !!(res && res.ok); p.lastFailed = !(res && res.ok) }
214
+ p.current = null
215
+ p.updatedAt = Date.now()
216
+ emit()
217
+ }
218
+
219
+ // 流事件折叠为实时状态。provider 若有 stream-json,提供 foldStreamEvent(ev, project);
220
+ // 否则回退到 generic 形态(不产生实时步骤)。
221
+ function foldEvent(ev, cwd) {
222
+ const p = ensureProject(cwd)
223
+ p.updatedAt = Date.now()
224
+ if (provider && typeof provider.foldStreamEvent === 'function') {
225
+ try { provider.foldStreamEvent(ev, p, summarizeArgs, MAX_ARG_LEN, MAX_TRAIL) } catch (e) {}
226
+ }
227
+ emit()
228
+ }
229
+
230
+ // 会话路由(cwd + backend 一次解析)。续接(--resume/--continue)时未给 cwd
231
+ // 则回落到该 session 所在项目的 cwd(否则换目录报找不到会话)。
232
+ function resolveTarget(args, fallbackCwd) {
233
+ const a = args || {}
234
+ let backend = null, cwd = null
235
+ if (a.sessionId) { const hit = sessions[a.sessionId]; if (hit && hit.provider === providerId) { backend = hit.backend; cwd = hit.cwd } }
236
+ if (a.cwd) {
237
+ cwd = String(a.cwd)
238
+ } else if (!cwd && (a.sessionId || a.continueLatest)) {
239
+ const list = Object.keys(projects).map((k) => projects[k]).sort((a, b) => b.updatedAt - a.updatedAt)
240
+ if (a.sessionId) {
241
+ const projHit = list.find((p) => p.lastSessionId === a.sessionId)
242
+ if (projHit) { cwd = projHit.cwd; if (!backend) backend = projHit.lastBackend }
243
+ } else if (list.length && list[0].lastSessionId) { cwd = list[0].cwd; if (!backend) backend = list[0].lastBackend }
244
+ }
245
+ return { cwd: cwd || fallbackCwd, backend }
246
+ }
247
+
248
+ function resolveCwd(args, fallbackCwd) { return resolveTarget(args, fallbackCwd).cwd }
249
+
250
+ return { providerId, projects, sessions, ensureProject, globalStatus, statusSnapshot, begin, end, foldEvent, resolveCwd, resolveTarget }
251
+ }
252
+
253
+ // ── 回退弹窗三选一(provider 名注入)──────────────────────────────────────────
254
+ function fallbackLabels(provider) {
255
+ const bk = (provider && provider.id) || 'CLI'
256
+ return {
257
+ fallback: '使用 DSH 本地 API 配置(回退)',
258
+ retry: '重试 ' + bk + ' 一次',
259
+ cancel: '不回退(返回错误)'
260
+ }
261
+ }
262
+
263
+ // 失败结果的可行动指引;provider.failureHint 优先,未给则通用兜底。
264
+ function failureHint(res, provider) {
265
+ if (!res || res.ok) return ''
266
+ if (provider && typeof provider.failureHint === 'function') {
267
+ try { const h = provider.failureHint(res); if (h) return h } catch (e) {}
268
+ }
269
+ if (res.status === 'AUTH_REQUIRED') return '[诊断] ' + String(res.stderr || '')
270
+ if (res.status === 'PARSE_ERROR') return '[诊断] 未能从输出解析出 result 事件(进程可能被中断或输出被截断)。可重试;持续出现请检查 CLI 版本。'
271
+ if (/401 authentication failed|differs from the current product endpoint/i.test(String(res.stderr || ''))) {
272
+ return '[诊断] 认证失败(401):请求端点与当前 CLI 登录域不匹配(国际/国内登录互斥)。请让用户在对应产品域完成 CLI 登录后重试;这不是限流。'
273
+ }
274
+ return ''
275
+ }
276
+
277
+ function fallbackResult(res, mode, provider) {
278
+ const bk = (provider && provider.id) || 'CLI'
279
+ 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 + ']' })
280
+ }
281
+
282
+ // ── 展示层 ────────────────────────────────────────────────────────────────────
283
+ function renderResult(value, provider) {
284
+ const v = value || {}
285
+ // backendLabel(codebuddy 旧语义 v1.1.0 verbatim:'workbuddy OK'/'codebuddy-intl OK'):
286
+ // 结果带具体 backend 时用 backend 标头;agy/mimo 旧包标头是硬编码 provider 名,不开此旗。
287
+ const bk = (provider && provider.backendLabel && v.backend) || (provider && provider.id) || v.backend || 'CLI'
288
+ if (v.background) return [{ type: 'text', text: bk + ' dispatched in background (mode=' + v.mode + '). jobId=' + v.jobId + '. Collect with job_output.' }]
289
+ if (v.fallback) return [{ type: 'text', text: bk + ' 回退:用户选择使用 DSH 本地 API 配置(原因 ' + v.reason + ')。请改用原生工具/本地模型完成本任务,不要再调 ' + bk + '。' }]
290
+ 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' : '') + ']'
291
+ const hint = failureHint(v, provider)
292
+ let body = v.response ? v.response : (v.stderr ? '[stderr] ' + v.stderr : (v.rawStdout ? '[raw] ' + v.rawStdout : ''))
293
+ if (hint) body = (body ? body + '\n\n' : '') + hint
294
+ return [{ type: 'text', text: head + (body ? '\n\n' + body : '') }]
295
+ }
296
+
297
+ function renderStatus(value, provider) {
298
+ const v = value || {}
299
+ const bk = (provider && provider.id) || 'CLI'
300
+ const lines = []
301
+ 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' : ''))
302
+ const projList = (v.projects && v.projects.length) ? v.projects : null
303
+ if (projList) {
304
+ for (const p of projList) {
305
+ const cur = p.current ? (' step ' + p.current.stepIndex + ' → ' + p.current.tool + (p.current.args ? ' ' + JSON.stringify(p.current.args) : '')) : (p.running > 0 ? ' (starting / thinking)' : '')
306
+ const usage = (p.runs ? ' | total ' + p.runs + ' runs, ' + (p.totalTokens || 0) + ' tokens' : '')
307
+ const bkTag = (p.lastBackend && p.lastBackend !== bk) ? ' [' + p.lastBackend + ']' : ''
308
+ 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)
309
+ if (p.trail && p.trail.length) {
310
+ lines.push(' steps:')
311
+ 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) }
312
+ }
313
+ }
314
+ } else {
315
+ if (v.current) { const c = v.current; lines.push('current: step ' + c.stepIndex + ' → ' + c.tool + (c.args ? ' ' + JSON.stringify(c.args) : '')) }
316
+ else if (v.state === 'running') lines.push('current: (starting / thinking)')
317
+ if (v.trail && v.trail.length) {
318
+ lines.push('recent steps:')
319
+ 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) }
320
+ }
321
+ if (v.lastStatus) lines.push('last: ' + v.lastStatus + (v.lastSessionId ? ' session=' + v.lastSessionId : '') + (v.lastAt ? ' @ ' + new Date(v.lastAt).toISOString() : ''))
322
+ }
323
+ if (v.updatedAt) lines.push('updatedAt: ' + new Date(v.updatedAt).toISOString())
324
+ return [{ type: 'text', text: lines.join('\n') }]
325
+ }
326
+
327
+ // ── MCP 子进程快照上报 ────────────────────────────────────────────────────────
328
+ // MCP server 是独立 stdio 子进程:没有 ctx.emit、拿不到 collector 服务,所以把快照
329
+ // 原子写进 {dsh-home}/cli-bridge-mcp-<provider>.json,家级灯在响应请求时读取合并。
330
+ function buildMcpBridgePayload(snap, pid, nowMs) {
331
+ const s = snap && typeof snap === 'object' ? snap : {}
332
+ const src = Array.isArray(s.projects) ? s.projects : []
333
+ const projects = []
334
+ for (const p of src) {
335
+ if (!p || !p.cwd) continue
336
+ projects.push({
337
+ cwd: String(p.cwd), name: p.name ? String(p.name) : '', provider: p.provider || s.provider || '', state: p.state ? String(p.state) : 'idle',
338
+ running: Number(p.running) || 0,
339
+ current: p.current ? { stepIndex: Number(p.current.stepIndex) || 0, tool: String(p.current.tool || ''), args: p.current.args || null } : null,
340
+ 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 })) : [],
341
+ lastStatus: p.lastStatus ? String(p.lastStatus) : null, lastAt: Number(p.lastAt) || 0, lastSessionId: p.lastSessionId ? String(p.lastSessionId) : null,
342
+ 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
343
+ })
344
+ }
345
+ return { source: 'mcp', provider: s.provider || '', pid: Number(pid) || 0, updatedAt: Number(nowMs) || 0, projects }
346
+ }
347
+
348
+ function normalizeMcpBridge(payload, nowMs, staleMs) {
349
+ if (!payload || typeof payload !== 'object') return null
350
+ const projects = Array.isArray(payload.projects) ? payload.projects : []
351
+ const updatedAt = Number(payload.updatedAt) || 0
352
+ const stale = (Number(nowMs) || 0) - updatedAt > (Number(staleMs) || MCP_BRIDGE_STALE_MS)
353
+ const out = []
354
+ for (const p of projects) {
355
+ if (!p || !p.cwd) continue
356
+ if (stale && (Number(p.running) || 0) > 0) out.push(Object.assign({}, p, { running: 0, current: null, state: p.state === 'running' ? 'idle' : p.state }))
357
+ else out.push(p)
358
+ }
359
+ return { source: 'mcp', provider: payload.provider || '', pid: Number(payload.pid) || 0, updatedAt, stale, projects: out }
360
+ }
361
+
362
+ // ── 执行编排(三形态共用;provider 差异全走 descriptor)──────────────────────
363
+ // o: {
364
+ // ctx, // Cordis ctx(interval/timeout/get)
365
+ // subprocess, // DSH subprocess 服务
366
+ // provider, // descriptor(见 PROVIDER-CONTRACT.md)
367
+ // engine, // createStatusEngine({provider}) 实例
368
+ // resolveExe, // async (backend, signal) => 'exe' | [nodeExe, binPath] | throw
369
+ // getCwdFallback, // () => 默认 cwd
370
+ // getPreferredBackend, // () => 用户偏好后端(可缺省)
371
+ // getSettings, // () => 当前设置对象(可缺省;provider 用它读 defaultModel 等)
372
+ // planActiveFor, // (exec) => bool(plan 模式判定,可缺省)
373
+ // defaultMode // 'auto'
374
+ // }
375
+ function createRunner(o) {
376
+ const ctx = o.ctx
377
+ const subprocess = o.subprocess
378
+ const provider = o.provider
379
+ const engine = o.engine
380
+ const stdio = { stdin: 'ignore', stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } }, stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } } }
381
+
382
+ function readStreams(handle) {
383
+ const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
384
+ const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
385
+ return { stdoutText, stderrText }
386
+ }
387
+
388
+ let built_env = null
389
+ function spawnSpec(argv, cwd, callerSignal) {
390
+ const spec = { argv, cwd, stdio, graceMs: 5000 }
391
+ if (callerSignal) spec.signal = callerSignal
392
+ if (built_env && typeof built_env === 'object') spec.env = built_env
393
+ return spec
394
+ }
395
+
396
+ function startLiveParser(handle, cwd) {
397
+ let cursor = 0
398
+ const stream = createLineStream((ln) => {
399
+ const t = ln.trim()
400
+ if (!t.startsWith('{')) return
401
+ try { engine.foldEvent(JSON.parse(t), cwd) } catch (e) {}
402
+ })
403
+ const tick = function () {
404
+ try {
405
+ const full = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
406
+ if (full.length <= cursor) return
407
+ const fresh = full.slice(cursor)
408
+ cursor = full.length
409
+ stream.pushChunk(fresh)
410
+ } catch (e) {}
411
+ }
412
+ const disposeInterval = ctx.interval(tick, 250)
413
+ return function disposeLive() { try { stream.flush() } catch (e) {} disposeInterval() }
414
+ }
415
+
416
+ async function runSync(argv, cwd, timeoutSec, callerSignal, extraEnv) {
417
+ const spec = spawnSpec(argv, cwd, callerSignal)
418
+ if (extraEnv && typeof extraEnv === 'object') spec.env = Object.assign({}, spec.env, extraEnv)
419
+ const handle = subprocess.spawn(spec)
420
+ let lastEventSummary = '(no events yet)'
421
+ let timedOut = false
422
+ const disposeTimer = ctx.timeout(function () {
423
+ timedOut = true
424
+ try {
425
+ const p = engine.ensureProject(cwd)
426
+ lastEventSummary = (p.current ? ('last step ' + p.current.stepIndex + ' -> ' + p.current.tool) : '') +
427
+ ' | trail=' + (p.trail.length ? p.trail.slice(-2).map((e) => '[' + e.state + ']' + e.tool).join(',') : 'empty') +
428
+ ' | elapsed=' + Math.round((Date.now() - (p.updatedAt || Date.now())) / 1000) + 's since last activity'
429
+ } catch (e) {}
430
+ try { handle.terminate() } catch (e) {}
431
+ }, (timeoutSec + 60) * 1000)
432
+ const disposeLive = startLiveParser(handle, cwd)
433
+ try {
434
+ const outcome = await handle.done
435
+ const s = readStreams(handle)
436
+ return { outcome, stdoutText: s.stdoutText, stderrText: s.stderrText, timedOut, lastEventSummary }
437
+ } finally { disposeLive(); disposeTimer() }
438
+ }
439
+
440
+ async function askFallback(exec, res, canRetry) {
441
+ const uq = ctx.get('userQuestions')
442
+ if (!uq || !exec || !exec.agent) return 'error'
443
+ const labels = fallbackLabels(provider)
444
+ const bk = provider.id
445
+ const detail = String(res.stderr || res.response || res.status || '').slice(-600)
446
+ const opts = [{ label: labels.fallback, description: '本次改由 DSH 本地模型/原生工具完成,不再走 ' + bk }]
447
+ if (canRetry) opts.push({ label: labels.retry, description: '再调用一次 ' + bk + '(网络抖动时可用)' })
448
+ opts.push({ label: labels.cancel, description: '不回退,直接返回 ' + bk + ' 错误' })
449
+ try {
450
+ 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 }] })
451
+ const sel = (ans && ans.answers && ans.answers[0] && ans.answers[0].selected) || []
452
+ if (sel.indexOf(labels.fallback) >= 0) return 'fallback'
453
+ if (sel.indexOf(labels.retry) >= 0) return 'retry'
454
+ return 'error'
455
+ } catch (e) { return 'error' }
456
+ }
457
+
458
+ async function coreExecute(rawArgs, exec) {
459
+ const args = rawArgs || {}
460
+ built_env = null
461
+ 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)
462
+ if (!args.prompt || !String(args.prompt).trim()) return mk({ stderr: 'prompt is required' })
463
+
464
+ const target = engine.resolveTarget(args, o.getCwdFallback())
465
+ const backend = provider.resolveBackend ? provider.resolveBackend(args, target, o.getPreferredBackend ? o.getPreferredBackend() : null) : (o.getPreferredBackend ? o.getPreferredBackend() : provider.defaultBackend)
466
+ const cwd = target.cwd
467
+
468
+ // provider 前置检查(凭据/桌面健康等):返回 { abort: res } 则直接结束。
469
+ if (provider.preflight) {
470
+ const pre = provider.preflight({ args, exec, backend, cwd, engine, ctx, o })
471
+ if (pre && pre.abort) { engine.begin(cwd); engine.end(pre.abort, cwd); return pre.abort }
472
+ }
473
+
474
+ let exePrefix = provider.fallbackExePrefix ? provider.fallbackExePrefix(backend) : [provider.exeName || provider.id]
475
+ let exeOk = true, resolveErr = ''
476
+ try { const resExe = await o.resolveExe(backend, exec ? exec.signal : undefined); exePrefix = Array.isArray(resExe) ? resExe : [resExe] }
477
+ catch (e) { exeOk = false; resolveErr = String(e && e.message || e) }
478
+
479
+ const built = provider.buildArgv(exePrefix, args, {
480
+ planActive: o.planActiveFor ? o.planActiveFor(exec) : false,
481
+ defaultMode: provider.defaultMode || 'auto',
482
+ defaultModel: (!args.model && o.getSettings) ? (o.getSettings() || {}).defaultModel || null : null,
483
+ backend, settings: o.getSettings ? o.getSettings() : null, engine
484
+ })
485
+ built_env = (built.env && typeof built.env === 'object') ? built.env : null
486
+ const unavailableStatus = provider.unavailableStatus || 'CLI_UNAVAILABLE'
487
+
488
+ if (!exeOk) {
489
+ engine.begin(cwd)
490
+ let res = mk({ status: unavailableStatus, mode: built.mode, backend, stderr: (backend || provider.id) + ' executable not found: ' + resolveErr })
491
+ if (await askFallback(exec, res, true) === 'fallback') res = fallbackResult(res, built.mode, provider)
492
+ engine.end(res, cwd); return res
493
+ }
494
+
495
+ // 非 spawn 路径(如 mimo 桌面走本地 HTTP):provider 若给 execute 且(未给 delegates
496
+ // 或 delegates(backend)=true)则完全交给它;否则落回通用 pipeline(jobs/重试/弹窗)。
497
+ if (provider.execute && (!provider.delegates || provider.delegates(backend, args))) {
498
+ engine.begin(cwd)
499
+ try {
500
+ const res = await provider.execute({ args, exec, backend, cwd, built, engine, ctx, o, runSync })
501
+ engine.end(res, cwd); return res
502
+ } catch (e) {
503
+ const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
504
+ engine.end(res, cwd); return res
505
+ }
506
+ }
507
+
508
+ const jobs = ctx.get('jobs')
509
+ if (args.background && jobs && exec && exec.agent) {
510
+ try {
511
+ engine.begin(cwd)
512
+ const jobId = jobs.start({
513
+ kind: 'bash', label: provider.id + ': ' + shortLabel(args.prompt), owner: exec.agent,
514
+ run() {
515
+ const handle = subprocess.spawn(spawnSpec(built.argv, cwd))
516
+ const disposeLive = startLiveParser(handle, cwd)
517
+ let bgKilled = false
518
+ const disposeGuard = ctx.timeout(function () { bgKilled = true; try { handle.terminate() } catch (e) {} }, (built.timeoutSec + 60) * 1000)
519
+ const done = handle.done.then(function (outcome) {
520
+ disposeLive(); disposeGuard()
521
+ const s = readStreams(handle)
522
+ const res = provider.buildResult(provider.parseJson(s.stdoutText), outcome, built.mode, s.stderrText, s.stdoutText, backend)
523
+ 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]' }
524
+ engine.end(res, cwd)
525
+ return { status: res.ok ? 'completed' : 'failed', detail: provider.id + ' ' + res.status, output: JSON.stringify(res) }
526
+ }).catch(function (err) { disposeLive(); disposeGuard(); engine.end(mk({ status: 'JOB_ERROR', backend }), cwd); return { status: 'failed', detail: String(err && err.message || err) } })
527
+ return { cancel: function () { try { handle.terminate() } catch (e) {} }, done }
528
+ }
529
+ })
530
+ 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.' }
531
+ } catch (e) {
532
+ const res = mk({ status: 'JOB_START_ERROR', mode: built.mode, backend, stderr: 'failed to start background job: ' + String(e && e.message || e) })
533
+ engine.end(res, cwd); return res
534
+ }
535
+ }
536
+
537
+ engine.begin(cwd)
538
+ try {
539
+ let attempt = 0, cliRetried = false, res
540
+ while (true) {
541
+ attempt += 1
542
+ const r = await runSync(built.argv, cwd, built.timeoutSec, exec ? exec.signal : undefined, built.env)
543
+ 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 }
544
+ res = provider.buildResult(provider.parseJson(r.stdoutText), r.outcome, built.mode, r.stderrText, r.stdoutText, backend)
545
+ if (res.ok) break
546
+ if (provider.isTransientCliError && provider.isTransientCliError(res)) {
547
+ if (!cliRetried) { cliRetried = true; attempt -= 1; continue }
548
+ res.retried = true; break
549
+ }
550
+ if (!(provider.isLimited ? provider.isLimited(res) : isLimited(res))) break
551
+ const decision = await askFallback(exec, res, attempt < 2)
552
+ if (decision === 'fallback') { res = fallbackResult(res, built.mode, provider); break }
553
+ if (decision === 'retry' && attempt < 2) continue
554
+ break
555
+ }
556
+ engine.end(res, cwd); return res
557
+ } catch (e) {
558
+ const res = mk({ status: 'SPAWN_ERROR', mode: built.mode, backend, stderr: String(e && e.message || e) })
559
+ const out = (await askFallback(exec, res, true) === 'fallback') ? fallbackResult(res, built.mode, provider) : res
560
+ engine.end(out, cwd); return out
561
+ }
562
+ }
563
+
564
+ return { coreExecute, runSync, askFallback }
565
+ }
566
+
567
+ // providers/agy.mjs — agy provider descriptor(真移植,替换 Lead 冒烟占位)。
568
+ //
569
+ // 移植基线:agy-first-bridge v1.7.1 preset/agy-first/agy-first-bridge.mjs 的内联逻辑
570
+ // (buildArgv / parseAgyJson / buildResult / foldStepUpdate / 额度门禁 / 三工具 schema),
571
+ // 语义逐字保持;与 provider 无关的骨架(状态引擎/行流/回退弹窗/展示层)在
572
+ // core/bridge-kit.mjs(PROVIDER-CONTRACT.md §1)。
573
+ //
574
+ // 红线(PROVIDER-CONTRACT.md §8 + Lead 定案):
575
+ // - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
576
+ // process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
577
+ // - 只允许 import '../core/bridge-kit.mjs'。
578
+
579
+ // subprocess stdio 预算(与 bridge-kit createRunner 内部 stdio 一致;quota 脚本用)。
580
+ const STDIO = {
581
+ stdin: 'ignore',
582
+ stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } },
583
+ stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } }
584
+ }
585
+
586
+ function nowIso() { return new Date().toISOString() }
587
+
588
+ // ── 三工具 schema(旧 preset verbatim;契约 §8:不许顺手改 schema)──────────────
589
+ const RUN_PARAMS = {
590
+ type: 'object',
591
+ additionalProperties: false,
592
+ required: ['prompt'],
593
+ properties: {
594
+ prompt: { type: 'string', description: 'The task instruction for agy. 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 — agy reads the repo itself). For follow-ups on the same topic, use agy_continue with latest=true or conversationId (agy keeps conversation context; state only what is new).' },
595
+ mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'auto follows DSH plan state; plan = no writes; accept-edits = allow edits. Default auto.' },
596
+ model: { type: 'string', description: 'Optional agy model id — pick from the Gemini pool or utility models per the model-selection policy (use agy_quota to see the recommended:true list). Do NOT pass a Claude/GPT (3p) model.' },
597
+ effort: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Optional reasoning effort.' },
598
+ cwd: { type: 'string', description: 'Working directory for agy. Defaults to the DSH workspace root.' },
599
+ addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra directories to add to agy workspace.' },
600
+ timeoutSec: { type: 'integer', description: 'Print timeout seconds (10-3600, default 600).' },
601
+ background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with agy_status. Set false to block for the final answer.' }
602
+ }
603
+ }
604
+ const CONTINUE_PARAMS = {
605
+ type: 'object',
606
+ additionalProperties: false,
607
+ required: ['prompt'],
608
+ properties: {
609
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing agy conversation. State only what is new; agy preserves its conversation context.' },
610
+ conversationId: { type: 'string', description: 'agy conversation id to resume (from a prior agy_run result).' },
611
+ latest: { type: 'boolean', description: 'Continue the most recent agy conversation instead of a specific id.' },
612
+ mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'Execution mode; default auto.' },
613
+ model: { type: 'string', description: 'Optional agy model id.' },
614
+ effort: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Optional reasoning effort.' },
615
+ cwd: { type: 'string', description: 'Working directory for agy.' },
616
+ timeoutSec: { type: 'integer', description: 'Print timeout seconds (10-3600, default 600).' },
617
+ background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with agy_status. Set false to block for the final answer.' }
618
+ }
619
+ }
620
+ const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
621
+
622
+ // agy_quota(旧 preset quotaTool verbatim):查 Google AI 套餐(Antigravity OAuth)池子额度。
623
+ const QUOTA_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { summary: { type: 'boolean', description: 'Return only a compact summary line (weekly buckets + top models).' } } }
624
+ const QUOTA_DESCRIPTION = 'Query the Google AI plan (Antigravity OAuth consumer) quota pool behind the local agy CLI. Reads the Windows credential manager entry (gemini:antigravity) that agy wrote at login, refreshes the access token, then calls Google Cloud Code quota APIs: fetchAvailableModels (per-model remaining pool percentage) and retrieveUserQuotaSummary (grouped plan buckets: weekly + 5h windows). Returns { ok, models: [{name, percentage, resetTime, displayName}], groups: [{displayName, buckets: [{bucketId, window, remainingFraction, resetTime}]}], tier }. GATING RULE: only the Gemini 5h window decides whether agy can run this task — agy_run/agy_continue already refuse silently below 10% of the 5h pool, so you normally do not need to pre-check. The weekly window is NOT a per-task gate: a drained weekly bucket only means agy is a poor choice for the rest of the week (pick another subagent or your own tools), never a reason to abort or shrink the current task. If the credential is missing the tool returns { ok:false, error } and agy_run still works normally.'
625
+
626
+ function renderQuota(args, value) {
627
+ const v = value || {}
628
+ if (!v.ok) return [{ type: 'text', text: 'agy_quota failed: ' + String(v.error || 'unknown') }]
629
+ const lines = []
630
+ lines.push('agy quota [tier=' + (v.tier || '?') + ']')
631
+ if (Array.isArray(v.groups)) {
632
+ for (const g of v.groups) {
633
+ for (const b of (g.buckets || [])) {
634
+ const pct = typeof b.remainingFraction === 'number' ? Math.round(b.remainingFraction * 100) : '?'
635
+ lines.push('· ' + (b.bucketId || g.displayName || '') + ' [' + (b.window || '') + '] ' + pct + '%' + (b.resetTime ? ' reset=' + b.resetTime : ''))
636
+ }
637
+ }
638
+ }
639
+ if (Array.isArray(v.models) && v.models.length) {
640
+ lines.push('models (pool %):')
641
+ for (const m of v.models.slice(0, 10)) { lines.push(' ' + (m.displayName || m.name) + ' : ' + m.percentage + '%' + (m.recommended === false ? ' [3p: Claude/GPT 不推荐]' : '') + (m.resetTime ? ' (reset ' + m.resetTime + ')' : '')) }
642
+ if (v.models.length > 10) lines.push(' … and ' + (v.models.length - 10) + ' more')
643
+ }
644
+ return [{ type: 'text', text: lines.join('\n') }]
645
+ }
646
+
647
+ function defineProvider (deps = {}) {
648
+ const env = deps.env || {}
649
+ let host = null // attach 后保存 hostOpts(preset/dynamic 形态提供 subprocess)
650
+ let quotaCache = { at: 0, data: null } // 30 分钟缓存(旧 cachedQuotaCheck 语义)
651
+
652
+ // ── 额度脚本执行(bin/agy-quota.mjs)─────────────────────────────────────────
653
+ // 脚本定位(契约 §8 + Lead 定案 4):CLI_BRIDGE_AGY_QUOTA > AGY_QUOTA_SCRIPT(旧兼容)
654
+ // > deps.quotaScript(preset/MCP 宿主注入)。定位不到或无 spawn 能力 = 静默跳过门禁
655
+ // (旧语义的宽容降级)。
656
+ function quotaScriptPath() {
657
+ return env.CLI_BRIDGE_AGY_QUOTA || env.AGY_QUOTA_SCRIPT || deps.quotaScript || null
658
+ }
659
+ function quotaCapable() {
660
+ return !!quotaScriptPath() && !!(deps.nodeChild || (host && host.subprocess))
661
+ }
662
+ function execQuotaScript() {
663
+ const scriptPath = quotaScriptPath()
664
+ if (!scriptPath) return Promise.resolve({ ok: false, error: 'quota script not found (set CLI_BRIDGE_AGY_QUOTA or install the package bin/agy-quota.mjs)' })
665
+ const node = (deps.process && deps.process.execPath) || 'node'
666
+ if (deps.nodeChild) {
667
+ // MCP/测试形态:真 child_process(旧 agy-mcp-server execQuota verbatim 语义)。
668
+ return new Promise((resolve) => {
669
+ let child
670
+ try { child = deps.nodeChild.spawn(node, [scriptPath], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) }
671
+ catch (e) { resolve({ ok: false, error: String(e && e.message || e) }); return }
672
+ let out = ''
673
+ let err = ''
674
+ const timer = setTimeout(() => { try { child.kill() } catch (e) {} }, 60000)
675
+ child.stdout.on('data', (d) => { out += d })
676
+ child.stderr.on('data', (d) => { err += d })
677
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }) })
678
+ child.on('close', (code) => {
679
+ clearTimeout(timer)
680
+ const m = out.match(/\{[\s\S]*\}/)
681
+ if (!m) { resolve({ ok: false, error: 'no JSON output (exit=' + code + '): ' + (out + ' ' + err).slice(0, 300) }); return }
682
+ try { resolve(JSON.parse(m[0])) } catch (e) { resolve({ ok: false, error: 'parse: ' + e.message }) }
683
+ })
684
+ })
685
+ }
686
+ if (host && host.subprocess) {
687
+ // preset 形态:DSH subprocess(旧 preset execQuotaScript verbatim 语义)。
688
+ return (async () => {
689
+ try {
690
+ const spec = { argv: [node, scriptPath], stdio: STDIO, graceMs: 5000 }
691
+ if (deps.process && typeof deps.process.cwd === 'function') spec.cwd = deps.process.cwd()
692
+ const handle = host.subprocess.spawn(spec)
693
+ const outcome = await handle.done
694
+ const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
695
+ const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
696
+ const jsonMatch = String(stdoutText || '').match(/\{[\s\S]*\}/)
697
+ if (!jsonMatch) return { ok: false, error: 'no JSON output (exit=' + (outcome && outcome.exitCode) + '): ' + (String(stdoutText).slice(0, 200) || String(stderrText).slice(0, 200)) }
698
+ try { return JSON.parse(jsonMatch[0]) } catch (e) { return { ok: false, error: 'parse error: ' + e.message } }
699
+ } catch (e) { return { ok: false, error: String(e && e.message || e) } }
700
+ })()
701
+ }
702
+ return Promise.resolve({ ok: false, error: 'no spawn capability in this host form' })
703
+ }
704
+ async function cachedQuotaCheck() {
705
+ const now = Date.now()
706
+ if (quotaCache.data && now - quotaCache.at < 30 * 60 * 1000) return quotaCache.data
707
+ let res = null
708
+ try { res = await execQuotaScript() } catch (e) { res = { ok: false, error: String(e && e.message || e) } }
709
+ quotaCache = { at: now, data: res }
710
+ return res
711
+ }
712
+ // 5h 硬阻断(旧 v1.5.13 门禁 verbatim):Gemini 5h 桶 <10% → QUOTA_BLOCKED(静默,
713
+ // 不弹窗);周用量不参与单次任务判断。预检失败不阻塞调用。
714
+ async function quotaGate5h(args, exec) {
715
+ if (!quotaCapable()) return null
716
+ let q = null
717
+ try { q = await cachedQuotaCheck() } catch (e) { return null }
718
+ if (!q || !q.ok) return null
719
+ const fiveHBuckets = (q.groups || []).flatMap((g) =>
720
+ (g.buckets || [])
721
+ .filter((b) => b.window === '5h' && (String(b.bucketId || '').toLowerCase().includes('gemini') || String(g.displayName || '').toLowerCase().includes('gemini')))
722
+ .map((b) => ({ bucketId: b.bucketId, remainingFraction: b.remainingFraction, resetTime: b.resetTime }))
723
+ )
724
+ const low5h = fiveHBuckets.find((b) => typeof b.remainingFraction === 'number' && b.remainingFraction < 0.10)
725
+ if (!low5h) return null
726
+ let mode = (args && args.mode) || 'auto'
727
+ if (mode === 'auto') {
728
+ let planActive = false
729
+ try { if (host && typeof host.planActiveFor === 'function') planActive = !!host.planActiveFor(exec) } catch (e) {}
730
+ mode = planActive ? 'plan' : 'accept-edits'
731
+ }
732
+ return {
733
+ ok: false, status: 'QUOTA_BLOCKED', response: '', conversationId: null, sessionId: null,
734
+ durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode, backend: null,
735
+ stderr: 'agy blocked: Gemini 5h pool quota < 10% (' + Math.round(low5h.remainingFraction * 100) + '%, reset ' + (low5h.resetTime || '?') + '). Use native tools; do not call agy.'
736
+ }
737
+ }
738
+
739
+ const descriptor = {
740
+ id: 'agy', label: 'AGY', presetId: 'cordis-agy', defaultBackend: null, defaultMode: 'auto',
741
+ exeName: 'agy', binEnvVars: ['AGY_BIN', 'AGY_CLI'], unavailableStatus: 'AGY_UNAVAILABLE',
742
+
743
+ // 单后端:backends() 恒 null;'agy'/'antigravity' 是历史别名,归一为 null;
744
+ // 非法值同样返回 null(契约 §1:非法输入返回 null)。会话路由由 engine.resolveTarget
745
+ // 完成(agy 的 backend 恒 null)。
746
+ backends() { return null },
747
+ normalizeBackend(v) {
748
+ if (v === undefined || v === null) return null
749
+ const s = String(v).trim().toLowerCase()
750
+ if (s === '' || s === 'agy' || s === 'antigravity') return null
751
+ return null
752
+ },
753
+ resolveBackend(args, target, preferred) { return null },
754
+
755
+ // 旧 preset buildArgv(exe, args, planActive) verbatim 语义(execPrefix 可为
756
+ // [nodeExe, binPath] 数组,runner 已归一)。
757
+ buildArgv(exePrefix, args, opts) {
758
+ const o = opts || {}
759
+ const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
760
+ argv.push('-p', String(args.prompt), '--output-format', 'stream-json', '--dangerously-skip-permissions')
761
+ const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
762
+ argv.push('--print-timeout', timeoutSec + 's')
763
+ let mode = args.mode || 'auto'
764
+ if (mode === 'auto') mode = o.planActive ? 'plan' : 'accept-edits'
765
+ if (mode === 'plan' || mode === 'accept-edits') argv.push('--mode', mode)
766
+ if (args.model) argv.push('--model', String(args.model))
767
+ if (args.effort) argv.push('--effort', String(args.effort))
768
+ if (Array.isArray(args.addDirs)) for (const d of args.addDirs) { if (d) argv.push('--add-dir', String(d)) }
769
+ if (args.conversationId) argv.push('--conversation', String(args.conversationId))
770
+ else if (args.continueLatest) argv.push('--continue')
771
+ return { argv, mode, timeoutSec }
772
+ },
773
+
774
+ // 旧 parseAgyJson verbatim:末 result 事件优先,容忍杂日志与整串 JSON。
775
+ parseJson(stdoutText) {
776
+ const trimmed = String(stdoutText || '').trim()
777
+ if (!trimmed) return null
778
+ const lines = trimmed.split(/\r?\n/).filter(Boolean)
779
+ for (let i = lines.length - 1; i >= 0; i--) {
780
+ const ln = lines[i].trim()
781
+ if (!ln.startsWith('{')) continue
782
+ try {
783
+ const obj = JSON.parse(ln)
784
+ if (obj && obj.event === 'result' && obj.result) return obj.result
785
+ if (obj && obj.status && obj.response !== undefined) return obj
786
+ } catch (e) {}
787
+ }
788
+ try { return JSON.parse(trimmed) } catch (e) {}
789
+ return null
790
+ },
791
+
792
+ // 旧 buildResult verbatim + backend 字段 + sessionId/conversationId 双写
793
+ //(agy 的会话字段名旧包是 conversationId;新契约统一 sessionId,两键同值以兼容
794
+ // 旧 status 面板/测试与 keystone engine.end 的会话登记)。
795
+ buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
796
+ const exitCode = outcome ? outcome.exitCode : null
797
+ // agy 超时/错误时信息在 result.error 字段(如 "timeout waiting for response"),
798
+ // 必须并入 stderr 供 isLimited 归类(旧 v1.5.8 修复:网络挂起不再静默 FAILED)。
799
+ const errText = parsed && typeof parsed.error === 'string' && parsed.error ? parsed.error : ''
800
+ const stderr = (stderrText ? String(stderrText).slice(-2000) : '') + (errText ? (stderrText ? ' ' : '') + errText : '')
801
+ if (parsed) {
802
+ const conversationId = typeof parsed.conversation_id === 'string' ? parsed.conversation_id : null
803
+ return {
804
+ ok: exitCode === 0 && parsed.status === 'SUCCESS',
805
+ status: typeof parsed.status === 'string' ? parsed.status : (exitCode === 0 ? 'UNKNOWN' : 'ERROR'),
806
+ response: typeof parsed.response === 'string' ? parsed.response : '',
807
+ conversationId,
808
+ sessionId: conversationId,
809
+ durationSeconds: typeof parsed.duration_seconds === 'number' ? parsed.duration_seconds : null,
810
+ numTurns: typeof parsed.num_turns === 'number' ? parsed.num_turns : null,
811
+ totalTokens: parsed.usage && typeof parsed.usage.total_tokens === 'number' ? parsed.usage.total_tokens : null,
812
+ exitCode, mode, backend: backend || null, stderr
813
+ }
814
+ }
815
+ return {
816
+ ok: false, status: 'PARSE_ERROR', response: '',
817
+ conversationId: null, sessionId: null,
818
+ durationSeconds: null, numTurns: null, totalTokens: null,
819
+ exitCode, mode, backend: backend || null, stderr,
820
+ rawStdout: String(stdoutText || '').slice(-2000)
821
+ }
822
+ },
823
+
824
+ // 旧 foldStepUpdate verbatim(step_update → project.current/trail)。
825
+ // bridge-kit foldEvent 对每行 JSON 都调本函数,故这里自判 event==='step_update'。
826
+ foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
827
+ if (!ev || ev.event !== 'step_update') return
828
+ const s = ev.step_update
829
+ if (!s) return
830
+ p.updatedAt = Date.now()
831
+ if (s.step_type === 'tool') {
832
+ const tool = s.tool_name || (s.tool_info && s.tool_info.name) || 'tool'
833
+ const args = summarizeArgs(s.tool_info && s.tool_info.parameters)
834
+ const entry = { stepIndex: s.step_index, state: s.state, tool: tool, args: args || null, at: nowIso() }
835
+ p.trail.push(entry)
836
+ const cap = maxTrail || 12
837
+ if (p.trail.length > cap) p.trail.shift()
838
+ if (s.state === 'ACTIVE') { p.current = { tool: tool, args: args || null, stepIndex: s.step_index, since: nowIso() } }
839
+ else if (p.current && p.current.stepIndex === s.step_index) { p.current = null }
840
+ } else if (s.step_type === 'agent_response' && s.state === 'ACTIVE' && s.text_delta) {
841
+ const capLen = maxArgLen || 120
842
+ p.current = { tool: 'agent_response', args: { text_delta: String(s.text_delta).slice(0, capLen) }, stepIndex: s.step_index, since: nowIso() }
843
+ }
844
+ },
845
+
846
+ // 旧 agy 仓无此概念(codebuddy v1.1.4 引入);保守版对齐 bridge-kit 语义:
847
+ // CLI 自报 error_during_execution 且非限流/网络类 → 静默重试一次。
848
+ isTransientCliError(res) {
849
+ if (!res || res.ok) return false
850
+ if (isLimited(res)) return false
851
+ return res.status === 'ERROR_DURING_EXECUTION'
852
+ },
853
+
854
+ // 失败指引(契约 §1;定案:401/OAuth 类指引 agy login,额度类指引 agy_quota)。
855
+ failureHint(res) {
856
+ if (!res || res.ok) return ''
857
+ const st = String(res.status || '')
858
+ if (st === 'QUOTA_BLOCKED') return '[诊断] agy 未调用(Gemini 5h 池子额度 <10%):请直接用原生工具完成本任务,本任务内不要再调 agy。'
859
+ if (st === 'AGY_UNAVAILABLE') return '[诊断] 未找到 agy 可执行文件:请安装 agy CLI,或设置 AGY_BIN 指向其绝对路径。'
860
+ const hay = String(res.stderr || '') + ' ' + String(res.response || '')
861
+ if (/invalid_grant|unauthorized|401|not logged|login|oauth/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
862
+ return '[诊断] agy 登录态/OAuth 凭据问题:请让用户在本机执行 `agy login` 重新登录后再试;可用 agy_quota 核对凭据与额度。这不是限流。'
863
+ }
864
+ if (/quota|额度|exhausted|insufficient|credit|balance/i.test(hay)) {
865
+ return '[诊断] agy 池子额度可能耗尽:用 agy_quota 查看各池子余量(Gemini 5h 是唯一任务级门禁);5h 低则改用原生工具完成本任务,不要循环调用 agy。'
866
+ }
867
+ return ''
868
+ },
869
+
870
+ isSuccessStatus(st) { return st === 'SUCCESS' },
871
+
872
+ tools: {
873
+ run: { name: 'agy_run', description: 'Dispatch a coding/build/debug/investigation task to the local agy agent CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls agy: it always runs non-interactively with permissions auto-approved (agy never prompts). Use read-only native tools only for quick lookups and for final build/test verification. In mode=auto the DSH plan state decides agy plan vs accept-edits. Runs in the background by default (returns a jobId immediately; collect with job_output); set background=false to block for the final answer.', inputSchema: RUN_PARAMS },
874
+ continue: { name: 'agy_continue', description: 'Continue an existing agy conversation with a follow-up prompt, reusing agy context. Pass conversationId from a prior agy_run result, or set latest=true to continue the most recent agy conversation. Same DSH-controlled, no-prompt execution as agy_run.', inputSchema: CONTINUE_PARAMS },
875
+ status: { name: 'agy_status', description: 'Read a live snapshot of what the local agy agent is currently doing. Returns one section per project (working directory): running count, the current step (tool name + arguments being executed, or agent_response thinking/typing), the recent step trail (tools executed, done/error), and the last completed run status + conversation id. Optional cwd filters to a single project. Call this to check on an in-flight agy_run/agy_continue without waiting for it to finish.', inputSchema: STATUS_PARAMS }
876
+ },
877
+
878
+ // agy_quota 仅在宿主有 spawn 能力时注册(preset 注入 deps.process / MCP 注入
879
+ // deps.nodeChild;dynamic 沙箱形态省略 —— Lead 定案 4)。
880
+ extraTools: (deps.process || deps.nodeChild) ? [{
881
+ name: 'agy_quota',
882
+ description: QUOTA_DESCRIPTION,
883
+ inputSchema: QUOTA_PARAMS,
884
+ render: renderQuota,
885
+ async logic(args) {
886
+ const a = args || {}
887
+ const res = await execQuotaScript()
888
+ if (res && res.ok && a.summary) {
889
+ const weekly = (res.groups || []).flatMap((g) => (g.buckets || []).filter((b) => b.window === 'weekly').map((b) => ({ g: g.displayName, id: b.bucketId, pct: Math.round((b.remainingFraction || 0) * 100), reset: b.resetTime })))
890
+ const top = (res.models || []).slice(0, 4).map((m) => (m.displayName || m.name) + ' ' + m.percentage + '%').join(', ')
891
+ return { ok: true, summary: { weekly, topModels: top }, models: res.models, groups: res.groups, tier: res.tier }
892
+ }
893
+ return res
894
+ }
895
+ }] : [],
896
+
897
+ // agy 无设置分区字段(契约 §3)。
898
+ settings: {
899
+ keys: [],
900
+ defaults: () => ({}),
901
+ normalize: () => ({}),
902
+ meta: () => ({ fields: [] }),
903
+ diagnose: () => ''
904
+ },
905
+
906
+ mcp: { version: '0.2.2' }
907
+ }
908
+
909
+ // 三形态共用的工具逻辑。签名保持不变(keystone 与队友按此接线)。
910
+ descriptor.runToolLogic = async function (args, exec) {
911
+ const a = args || {}
912
+ // 旧 agy preset/dynamic 语义:background 默认 true(仅显式 false 才前台阻塞)。
913
+ const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
914
+ const block = await quotaGate5h(normalized, exec)
915
+ if (block) return block
916
+ return this.runner.coreExecute(normalized, exec)
917
+ }
918
+ descriptor.continueToolLogic = async function (args, exec) {
919
+ const a = args || {}
920
+ const mapped = { prompt: a.prompt, mode: a.mode, model: a.model, effort: a.effort, cwd: a.cwd, timeoutSec: a.timeoutSec, background: a.background === undefined ? true : a.background }
921
+ const cid = a.conversationId || a.sessionId || null
922
+ if (cid) {
923
+ mapped.conversationId = cid
924
+ mapped.sessionId = cid // keystone engine.resolveTarget 按 sessionId 路由 cwd
925
+ } else if (a.latest) {
926
+ mapped.continueLatest = true
927
+ }
928
+ const block = await quotaGate5h(mapped, exec)
929
+ if (block) return block
930
+ return this.runner.coreExecute(mapped, exec)
931
+ }
932
+ // 旧 statusTool.execute verbatim:cwd 过滤到单项目。
933
+ descriptor.statusToolLogic = function (args) {
934
+ const a = args || {}
935
+ const snap = this.engine.statusSnapshot()
936
+ if (a.cwd) {
937
+ const key = String(a.cwd)
938
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
939
+ const g = snap.projects[0]
940
+ if (g) {
941
+ Object.assign(snap, {
942
+ state: g.state, running: g.running, current: g.current, trail: g.trail,
943
+ lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
944
+ lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
945
+ })
946
+ }
947
+ }
948
+ return snap
949
+ }
950
+ // 供 MCP/测试形态直接复用门禁与额度执行。
951
+ descriptor.quotaGate5h = quotaGate5h
952
+ descriptor.execQuotaScript = execQuotaScript
953
+ descriptor.cachedQuotaCheck = cachedQuotaCheck
954
+
955
+ descriptor.attach = function (hostOpts) {
956
+ host = hostOpts || {}
957
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
958
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
959
+ this.runner = runner
960
+ return runner
961
+ }
962
+ return descriptor
963
+ }
964
+
965
+ // core/scheduling.mjs — CLI 调度(启用集合 + 调用优先级)单源纯逻辑。
966
+ //
967
+ // 语义(用户可见的两件事):
968
+ // • enabled = 勾选启用哪些 CLI(数量随之而定);未启用的一面不注册工具、不进提示词。
969
+ // • priority = 智能体调用 CLI 的建议顺序,数组顺序即优先级(首位最高)。
970
+ //
971
+ // 落盘位置:统一设置文件 {DSH_HOME}/cli-bridge-settings.json 的顶层 `_scheduling` 区。
972
+ // 下划线前缀 = 非 provider 分区(registry 的 PROVIDER_IDS 永远不含它,逐分区语义不受影响),
973
+ // 因此新代码写出的文件被旧包读取时整区被忽略 = 纯增量,向后兼容。
974
+ //
975
+ // 缺省语义(文件缺失/无该区):全部 provider 启用、按注册表顺序 —— 与调度特性上线前
976
+ // 的行为逐字一致(读不到配置永远不等于关掉所有桥)。
977
+ //
978
+ // 沙箱纪律:与 bridge-kit 同源约束 —— 不得 import;Node 内建一律经
979
+ // globalThis.process.getBuiltinModule + typeof 守卫(动态宿主沙箱可能没有 fs,
980
+ // 那种环境读不到文件 → 走缺省全开,绝不炸 apply)。
981
+
982
+ /** 设置文件里调度区的键名(下划线前缀避开 provider id 命名空间)。 */
983
+ const SCHEDULING_SECTION = '_scheduling'
984
+
985
+ /** 人类可读的默认注册顺序(调用建议顺序);enabled 缺省 = 全部启用。 */
986
+ function defaultPriority(ids) {
987
+ return (Array.isArray(ids) ? ids : []).slice()
988
+ }
989
+
990
+ /**
991
+ * 清洗任意来源的调度对象。
992
+ * @param {unknown} raw 文件/POST 里的原始值(可能是任何东西,全部容错)
993
+ * @param {string[]} ids 全量 provider id(顺序 = 缺省优先级)
994
+ * @param {(id: string) => string} [labelOf] 展示名(仅用于诊断文本,不参与判定)
995
+ */
996
+ function normalizeScheduling(raw, ids, labelOf) {
997
+ const all = Array.isArray(ids) ? ids.slice() : []
998
+ const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
999
+ const enabled = all.slice()
1000
+ let priority = all.slice()
1001
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1002
+ if (Array.isArray(raw.enabled)) {
1003
+ const seen = Object.create(null)
1004
+ const picked = []
1005
+ for (const x of raw.enabled) {
1006
+ const id = String(x == null ? '' : x)
1007
+ if (!id || all.indexOf(id) < 0 || seen[id]) continue
1008
+ seen[id] = true; picked.push(id)
1009
+ }
1010
+ // 空数组 = 用户真的想把全部关掉(而不是「没配」),保留该语义;
1011
+ // 非空但过滤后为空 = 写的全是未知 id(坏值)→ 回落全开(fail-open,
1012
+ // 绝不让一次手滑/陈旧配置把所有桥关掉)。
1013
+ if (raw.enabled.length === 0 || picked.length > 0) {
1014
+ enabled.length = 0
1015
+ Array.prototype.push.apply(enabled, picked)
1016
+ }
1017
+ }
1018
+ if (Array.isArray(raw.priority)) {
1019
+ const seen2 = Object.create(null)
1020
+ const ordered = []
1021
+ for (const x of raw.priority) {
1022
+ const id = String(x == null ? '' : x)
1023
+ if (!id || all.indexOf(id) < 0 || seen2[id]) continue
1024
+ seen2[id] = true; ordered.push(id)
1025
+ }
1026
+ for (const id of all) if (!seen2[id]) ordered.push(id) // 漏写的排在末尾
1027
+ if (ordered.length) priority = ordered
1028
+ }
1029
+ }
1030
+ const order = priority.filter((id) => enabled.indexOf(id) >= 0)
1031
+ for (const id of enabled) if (order.indexOf(id) < 0) order.push(id)
1032
+ return { enabled, priority, order }
1033
+ }
1034
+
1035
+ /** 展示文本:「CB → MIMO」或「全部启用 · CB → AGY → MIMO」。 */
1036
+ function describeScheduling(s, labelOf) {
1037
+ const st = s || {}
1038
+ const label = typeof labelOf === 'function' ? labelOf : ((id) => String(id))
1039
+ const order = Array.isArray(st.order) ? st.order : []
1040
+ const names = order.map(label).join(' → ')
1041
+ return names ? names : '(全部禁用)'
1042
+ }
1043
+
1044
+ // ── 读盘(四形态共用;无 fs 时安全返回 null)────────────────────────────────────
1045
+ // 名字带 sched 前缀:本文件会与 bridge-kit / provider 描述符【文本内联进同一个动态
1046
+ // 沙箱脚本作用域】(providers/codebuddy.mjs 已有 builtinModule),撞名即隐患。
1047
+ function schedBuiltinModule(name) {
1048
+ try {
1049
+ const proc = globalThis.process
1050
+ return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
1051
+ } catch (e) { return null }
1052
+ }
1053
+
1054
+ function schedulingSettingsPath(env) {
1055
+ const e = env || (globalThis.process && globalThis.process.env) || {}
1056
+ try {
1057
+ if (e.CLI_BRIDGE_SETTINGS_FILE) return String(e.CLI_BRIDGE_SETTINGS_FILE)
1058
+ const nodePath = schedBuiltinModule('node:path')
1059
+ if (nodePath && e.DSH_HOME) return nodePath.join(String(e.DSH_HOME), 'cli-bridge-settings.json')
1060
+ } catch (e2) { }
1061
+ return null
1062
+ }
1063
+
1064
+ /**
1065
+ * 生效调度:文件 _scheduling > 缺省全开。
1066
+ * env/argv CLI_BRIDGE_PROVIDER(单面钉住,旧薄封装兼容语义)存在时视为最高优先 ——
1067
+ * 它代表「这个 MCP 进程只为这一家而启动」(部署者的显式意志,比面板勾选更具体、
1068
+ * 更靠近进程生命周期),调度不再削减它。
1069
+ * 宿主形态(preset / dynamic 桥接插件)应传 ignorePin:钉住只是 MCP 进程的启动参数,
1070
+ * 不该让全局 env 反过来把用户会话里的其他桥关掉。
1071
+ * @param {string[]} ids 全量 provider id
1072
+ * @param {{ env?: Record<string,string>, readFileSync?: Function, path?: string|null, ignorePin?: boolean }} [io]
1073
+ */
1074
+ function resolveScheduling(ids, io) {
1075
+ const o = io || {}
1076
+ const env = o.env || (globalThis.process && globalThis.process.env) || {}
1077
+ let raw = null
1078
+ try {
1079
+ const p = ('path' in o) ? o.path : schedulingSettingsPath(env)
1080
+ const nodeFs = o.readFileSync ? { readFileSync: o.readFileSync } : schedBuiltinModule('node:fs')
1081
+ if (p && nodeFs && typeof nodeFs.readFileSync === 'function') {
1082
+ raw = JSON.parse(nodeFs.readFileSync(p, 'utf8'))
1083
+ }
1084
+ } catch (e) { raw = null }
1085
+ const file = normalizeScheduling(raw && typeof raw === 'object' ? raw[SCHEDULING_SECTION] : null, ids)
1086
+ const pinned = o.ignorePin ? '' : String(env.CLI_BRIDGE_PROVIDER || '').trim()
1087
+ if (pinned && ids.indexOf(pinned) >= 0) {
1088
+ return Object.assign(file, {
1089
+ enabled: [pinned],
1090
+ order: [pinned],
1091
+ pinned: pinned,
1092
+ source: raw ? 'env:CLI_BRIDGE_PROVIDER+file' : 'env:CLI_BRIDGE_PROVIDER'
1093
+ })
1094
+ }
1095
+ return Object.assign(file, { pinned: '', source: raw ? 'file' : 'default' })
1096
+ }
1097
+
1098
+ /**
1099
+ * 桥接面门禁(preset / dynamic 的 apply() 首行用):本 provider 是否被调度启用。
1100
+ * fail-open —— 任何异常(无 fs、文件损坏、沙箱限制)都返回 true:读不到配置永远
1101
+ * 不等于关掉桥(否则一次文件损坏就把三条 CLI 全灭了)。ignorePin 固定 true:
1102
+ * 钉住语义属于 MCP 独立进程,宿主形态的全局 env 不该反向影响用户会话。
1103
+ */
1104
+ function isBridgeEnabled(selfId, ids, io) {
1105
+ try {
1106
+ const list = Array.isArray(ids) ? ids : []
1107
+ // 全量清单本身不可得(空/不含自己)= 无门禁语义,fail-open。
1108
+ if (!list.length || list.indexOf(String(selfId)) < 0) return true
1109
+ const o = Object.assign({ ignorePin: true }, io || {})
1110
+ return resolveScheduling(list, o).enabled.indexOf(String(selfId)) >= 0
1111
+ } catch (e) { return true }
1112
+ }
1113
+
1114
+
1115
+ const SCHED_ALL_IDS = ["agy","codebuddy","mimo"]
1116
+
1117
+
1118
+ return {
1119
+ inject: ['tools', 'subprocess', 'systemPrompt', 'timer'],
1120
+ apply(ctx) {
1121
+ // 调度门禁(0.2.0):设置面板关掉 agy → 本形态不注册工具/政策段;fail-open。
1122
+ if (!isBridgeEnabled('agy', SCHED_ALL_IDS)) return
1123
+ const subprocess = ctx.subprocess
1124
+ const planMode = ctx.get('planMode')
1125
+ const sandboxPolicy = ctx.get('sandboxPolicy')
1126
+ // 沙箱可能运行在无 process 的环境:typeof 守卫读 env(旧仓 dynamic 的等价面)。
1127
+ const env = (typeof process !== 'undefined' && process.env) ? process.env : {}
1128
+
1129
+ const provider = defineProvider({ env })
1130
+
1131
+ function publish(snapshot) {
1132
+ try {
1133
+ const collector = ctx.get('cliBridgeCollector')
1134
+ if (collector && typeof collector.mergeSnapshot === 'function') { collector.mergeSnapshot('agy', snapshot); return }
1135
+ } catch (e) { /* fall through to legacy alias */ }
1136
+ try {
1137
+ const legacy = ctx.get('agyCollector')
1138
+ if (legacy && typeof legacy.mergeSnapshot === 'function') legacy.mergeSnapshot(snapshot)
1139
+ } catch (e) { /* 灯数据尽力而为 */ }
1140
+ }
1141
+
1142
+ // exe:AGY_BIN/AGY_CLI env > PATH 查找 agy(subprocess.resolveExecutable)。
1143
+ async function resolveExe(backend, signal) {
1144
+ const bin = env.AGY_BIN || env.AGY_CLI || ''
1145
+ if (bin) {
1146
+ if (/\.(mjs|cjs|js)$/i.test(bin) && typeof process !== 'undefined' && process.execPath) return [process.execPath, bin]
1147
+ return bin
1148
+ }
1149
+ return await subprocess.resolveExecutable('agy', undefined, signal)
1150
+ }
1151
+
1152
+ // 旧仓 CWD_FALLBACK 硬编码机器绝对路径;新语义:AGY_MCP_CWD > workspaceRoot > '.'
1153
+ //(沙箱无 process.cwd 可用时以 '.' 交给宿主进程工作目录兜底)。
1154
+ function getCwdFallback() {
1155
+ if (env.AGY_MCP_CWD) return String(env.AGY_MCP_CWD)
1156
+ if (sandboxPolicy && typeof sandboxPolicy.workspaceRoot === 'string' && sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot
1157
+ if (typeof process !== 'undefined' && typeof process.cwd === 'function') { try { return process.cwd() } catch (e) {} }
1158
+ return '.'
1159
+ }
1160
+
1161
+ function planActiveFor(exec) {
1162
+ try {
1163
+ if (planMode && exec && exec.agent) {
1164
+ const st = planMode.get(exec.agent)
1165
+ return !!(st && st.active)
1166
+ }
1167
+ } catch (e) {}
1168
+ return false
1169
+ }
1170
+
1171
+ provider.attach({ ctx, subprocess, resolveExe, getCwdFallback, planActiveFor, publish })
1172
+
1173
+ const OUTPUT_SCHEMA = { type: 'object', additionalProperties: true }
1174
+
1175
+ harness.registerTool(ctx, harness.defineTool({
1176
+ name: provider.tools.run.name,
1177
+ description: provider.tools.run.description,
1178
+ parameters: provider.tools.run.inputSchema,
1179
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
1180
+ execute: function (args, exec) { return provider.runToolLogic(args, exec) }
1181
+ }))
1182
+ harness.registerTool(ctx, harness.defineTool({
1183
+ name: provider.tools.continue.name,
1184
+ description: provider.tools.continue.description,
1185
+ parameters: provider.tools.continue.inputSchema,
1186
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderResult(value, provider) } },
1187
+ execute: function (args, exec) { return provider.continueToolLogic(args, exec) }
1188
+ }))
1189
+ harness.registerTool(ctx, harness.defineTool({
1190
+ name: provider.tools.status.name,
1191
+ description: provider.tools.status.description,
1192
+ parameters: provider.tools.status.inputSchema,
1193
+ output: { schema: OUTPUT_SCHEMA, render: function (args, value) { return renderStatus(value, provider) } },
1194
+ execute: function (args) { return provider.statusToolLogic(args) }
1195
+ }))
1196
+
1197
+ // 浏览器指示灯轮询的 RPC 面(旧 dynamic/handle('agy_status') verbatim 语义)。
1198
+ harness.handle('agy_status', function () { return provider.statusToolLogic({}) })
1199
+
1200
+ // 旧 policy 文案 verbatim(提示词连续性红线)。
1201
+ const policyText = [
1202
+ 'agy-first execution policy (local agy CLI bridge).',
1203
+ '',
1204
+ 'Local agent CLI "agy" handles coding tasks under full DSH control (permissions auto-approved, never prompts). By default, agy runs in the background and returns a jobId immediately; collect results via job_output and watch live progress via agy_status anytime.',
1205
+ '',
1206
+ 'Decision table for tool selection:',
1207
+ '- Single read/glob/grep or short (<30s) command -> Native tools.',
1208
+ '- Implementing, editing, refactoring, debugging, builds, multi-file investigation -> agy_run.',
1209
+ '- Follow-ups on the same topic -> agy_continue (with latest=true or conversationId; agy preserves session context).',
1210
+ '- Final build/test verification of agy output -> Native tools.',
1211
+ '- Image/video generation -> agy_run WITHOUT picking a model (agy selects the appropriate model).',
1212
+ '',
1213
+ 'Context protocol (one topic = one agy conversation):',
1214
+ '- First dispatch on a topic: provide the task with a compact CONTEXT preamble (point to paths, do not paste file contents):',
1215
+ ' CONTEXT: 2-6 lines — goal; repo root; key paths; decisions/constraints; current state.',
1216
+ ' TASK: the complete instruction.',
1217
+ '- Follow-ups: call agy_continue and state only what is new.',
1218
+ '',
1219
+ 'Execution & Fallback protocol:',
1220
+ '- Dispatch defaults to background=true (set background=false only to block). Collect via job_output.',
1221
+ '- On rate-limit or network failure, a confirmation dialog opens in BOTH foreground and background while in flight.',
1222
+ '- If result has fallback=true (status FALLBACK_TO_DSH), the user chose DSH-local: finish with native tools and NEVER call agy again for this task.',
1223
+ '- If status is QUOTA_BLOCKED (Gemini 5h pool <10%), finish natively. Never loop agy calls; never ask agy to call back into DSH.',
1224
+ '',
1225
+ 'Model selection & Quota guard:',
1226
+ '- The Gemini 5h pool (<10% threshold) is enforced automatically as the sole per-task gate; do NOT pre-call agy_quota before every dispatch. Call agy_quota only when you want to compare model availability.',
1227
+ '- Use Gemini pool (gemini-*) or utility models (tab_*, chat_*). Never pass Claude or GPT (3p) models.',
1228
+ '- Weekly quota is NOT a per-task gate: low weekly quota only informs future planning, never aborts current work.'
1229
+ ].join('\n')
1230
+
1231
+ ctx.systemPrompt.section({
1232
+ name: 'agy:policy',
1233
+ order: (typeof ctx.systemPrompt.getSectionOrder === 'function' ? ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT') : 2800) + 10,
1234
+ text: policyText
1235
+ })
1236
+ }
1237
+ }