@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,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","opencode"]
|
|
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
|
+
}
|