@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,398 @@
1
+ // providers/agy.mjs — agy provider descriptor(真移植,替换 Lead 冒烟占位)。
2
+ //
3
+ // 移植基线:agy-first-bridge v1.7.1 preset/agy-first/agy-first-bridge.mjs 的内联逻辑
4
+ // (buildArgv / parseAgyJson / buildResult / foldStepUpdate / 额度门禁 / 三工具 schema),
5
+ // 语义逐字保持;与 provider 无关的骨架(状态引擎/行流/回退弹窗/展示层)在
6
+ // core/bridge-kit.mjs(PROVIDER-CONTRACT.md §1)。
7
+ //
8
+ // 红线(PROVIDER-CONTRACT.md §8 + Lead 定案):
9
+ // - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
10
+ // process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
11
+ // - 只允许 import '../core/bridge-kit.mjs'。
12
+ import { createStatusEngine, createRunner, clampInt, isLimited } from '../core/bridge-kit.mjs'
13
+
14
+ // subprocess stdio 预算(与 bridge-kit createRunner 内部 stdio 一致;quota 脚本用)。
15
+ const STDIO = {
16
+ stdin: 'ignore',
17
+ stdout: { maxBytes: 4000000, spill: { maxBytes: 40000000 } },
18
+ stderr: { maxBytes: 1000000, spill: { maxBytes: 8000000 } }
19
+ }
20
+
21
+ function nowIso() { return new Date().toISOString() }
22
+
23
+ // ── 三工具 schema(旧 preset verbatim;契约 §8:不许顺手改 schema)──────────────
24
+ const RUN_PARAMS = {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ required: ['prompt'],
28
+ properties: {
29
+ 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).' },
30
+ mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'auto follows DSH plan state; plan = no writes; accept-edits = allow edits. Default auto.' },
31
+ 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.' },
32
+ effort: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Optional reasoning effort.' },
33
+ cwd: { type: 'string', description: 'Working directory for agy. Defaults to the DSH workspace root.' },
34
+ addDirs: { type: 'array', items: { type: 'string' }, description: 'Extra directories to add to agy workspace.' },
35
+ timeoutSec: { type: 'integer', description: 'Print timeout seconds (10-3600, default 600).' },
36
+ 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.' }
37
+ }
38
+ }
39
+ const CONTINUE_PARAMS = {
40
+ type: 'object',
41
+ additionalProperties: false,
42
+ required: ['prompt'],
43
+ properties: {
44
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing agy conversation. State only what is new; agy preserves its conversation context.' },
45
+ conversationId: { type: 'string', description: 'agy conversation id to resume (from a prior agy_run result).' },
46
+ latest: { type: 'boolean', description: 'Continue the most recent agy conversation instead of a specific id.' },
47
+ mode: { type: 'string', enum: ['auto', 'plan', 'accept-edits'], description: 'Execution mode; default auto.' },
48
+ model: { type: 'string', description: 'Optional agy model id.' },
49
+ effort: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Optional reasoning effort.' },
50
+ cwd: { type: 'string', description: 'Working directory for agy.' },
51
+ timeoutSec: { type: 'integer', description: 'Print timeout seconds (10-3600, default 600).' },
52
+ 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.' }
53
+ }
54
+ }
55
+ const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
56
+
57
+ // agy_quota(旧 preset quotaTool verbatim):查 Google AI 套餐(Antigravity OAuth)池子额度。
58
+ const QUOTA_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { summary: { type: 'boolean', description: 'Return only a compact summary line (weekly buckets + top models).' } } }
59
+ 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.'
60
+
61
+ function renderQuota(args, value) {
62
+ const v = value || {}
63
+ if (!v.ok) return [{ type: 'text', text: 'agy_quota failed: ' + String(v.error || 'unknown') }]
64
+ const lines = []
65
+ lines.push('agy quota [tier=' + (v.tier || '?') + ']')
66
+ if (Array.isArray(v.groups)) {
67
+ for (const g of v.groups) {
68
+ for (const b of (g.buckets || [])) {
69
+ const pct = typeof b.remainingFraction === 'number' ? Math.round(b.remainingFraction * 100) : '?'
70
+ lines.push('· ' + (b.bucketId || g.displayName || '') + ' [' + (b.window || '') + '] ' + pct + '%' + (b.resetTime ? ' reset=' + b.resetTime : ''))
71
+ }
72
+ }
73
+ }
74
+ if (Array.isArray(v.models) && v.models.length) {
75
+ lines.push('models (pool %):')
76
+ 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 + ')' : '')) }
77
+ if (v.models.length > 10) lines.push(' … and ' + (v.models.length - 10) + ' more')
78
+ }
79
+ return [{ type: 'text', text: lines.join('\n') }]
80
+ }
81
+
82
+ export default function defineProvider(deps = {}) {
83
+ const env = deps.env || {}
84
+ let host = null // attach 后保存 hostOpts(preset/dynamic 形态提供 subprocess)
85
+ let quotaCache = { at: 0, data: null } // 30 分钟缓存(旧 cachedQuotaCheck 语义)
86
+
87
+ // ── 额度脚本执行(bin/agy-quota.mjs)─────────────────────────────────────────
88
+ // 脚本定位(契约 §8 + Lead 定案 4):CLI_BRIDGE_AGY_QUOTA > AGY_QUOTA_SCRIPT(旧兼容)
89
+ // > deps.quotaScript(preset/MCP 宿主注入)。定位不到或无 spawn 能力 = 静默跳过门禁
90
+ // (旧语义的宽容降级)。
91
+ function quotaScriptPath() {
92
+ return env.CLI_BRIDGE_AGY_QUOTA || env.AGY_QUOTA_SCRIPT || deps.quotaScript || null
93
+ }
94
+ function quotaCapable() {
95
+ return !!quotaScriptPath() && !!(deps.nodeChild || (host && host.subprocess))
96
+ }
97
+ function execQuotaScript() {
98
+ const scriptPath = quotaScriptPath()
99
+ 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)' })
100
+ const node = (deps.process && deps.process.execPath) || 'node'
101
+ if (deps.nodeChild) {
102
+ // MCP/测试形态:真 child_process(旧 agy-mcp-server execQuota verbatim 语义)。
103
+ return new Promise((resolve) => {
104
+ let child
105
+ try { child = deps.nodeChild.spawn(node, [scriptPath], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) }
106
+ catch (e) { resolve({ ok: false, error: String(e && e.message || e) }); return }
107
+ let out = ''
108
+ let err = ''
109
+ const timer = setTimeout(() => { try { child.kill() } catch (e) {} }, 60000)
110
+ child.stdout.on('data', (d) => { out += d })
111
+ child.stderr.on('data', (d) => { err += d })
112
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }) })
113
+ child.on('close', (code) => {
114
+ clearTimeout(timer)
115
+ const m = out.match(/\{[\s\S]*\}/)
116
+ if (!m) { resolve({ ok: false, error: 'no JSON output (exit=' + code + '): ' + (out + ' ' + err).slice(0, 300) }); return }
117
+ try { resolve(JSON.parse(m[0])) } catch (e) { resolve({ ok: false, error: 'parse: ' + e.message }) }
118
+ })
119
+ })
120
+ }
121
+ if (host && host.subprocess) {
122
+ // preset 形态:DSH subprocess(旧 preset execQuotaScript verbatim 语义)。
123
+ return (async () => {
124
+ try {
125
+ const spec = { argv: [node, scriptPath], stdio: STDIO, graceMs: 5000 }
126
+ if (deps.process && typeof deps.process.cwd === 'function') spec.cwd = deps.process.cwd()
127
+ const handle = host.subprocess.spawn(spec)
128
+ const outcome = await handle.done
129
+ const stdoutText = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
130
+ const stderrText = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
131
+ const jsonMatch = String(stdoutText || '').match(/\{[\s\S]*\}/)
132
+ if (!jsonMatch) return { ok: false, error: 'no JSON output (exit=' + (outcome && outcome.exitCode) + '): ' + (String(stdoutText).slice(0, 200) || String(stderrText).slice(0, 200)) }
133
+ try { return JSON.parse(jsonMatch[0]) } catch (e) { return { ok: false, error: 'parse error: ' + e.message } }
134
+ } catch (e) { return { ok: false, error: String(e && e.message || e) } }
135
+ })()
136
+ }
137
+ return Promise.resolve({ ok: false, error: 'no spawn capability in this host form' })
138
+ }
139
+ async function cachedQuotaCheck() {
140
+ const now = Date.now()
141
+ if (quotaCache.data && now - quotaCache.at < 30 * 60 * 1000) return quotaCache.data
142
+ let res = null
143
+ try { res = await execQuotaScript() } catch (e) { res = { ok: false, error: String(e && e.message || e) } }
144
+ quotaCache = { at: now, data: res }
145
+ return res
146
+ }
147
+ // 5h 硬阻断(旧 v1.5.13 门禁 verbatim):Gemini 5h 桶 <10% → QUOTA_BLOCKED(静默,
148
+ // 不弹窗);周用量不参与单次任务判断。预检失败不阻塞调用。
149
+ async function quotaGate5h(args, exec) {
150
+ if (!quotaCapable()) return null
151
+ let q = null
152
+ try { q = await cachedQuotaCheck() } catch (e) { return null }
153
+ if (!q || !q.ok) return null
154
+ const fiveHBuckets = (q.groups || []).flatMap((g) =>
155
+ (g.buckets || [])
156
+ .filter((b) => b.window === '5h' && (String(b.bucketId || '').toLowerCase().includes('gemini') || String(g.displayName || '').toLowerCase().includes('gemini')))
157
+ .map((b) => ({ bucketId: b.bucketId, remainingFraction: b.remainingFraction, resetTime: b.resetTime }))
158
+ )
159
+ const low5h = fiveHBuckets.find((b) => typeof b.remainingFraction === 'number' && b.remainingFraction < 0.10)
160
+ if (!low5h) return null
161
+ let mode = (args && args.mode) || 'auto'
162
+ if (mode === 'auto') {
163
+ let planActive = false
164
+ try { if (host && typeof host.planActiveFor === 'function') planActive = !!host.planActiveFor(exec) } catch (e) {}
165
+ mode = planActive ? 'plan' : 'accept-edits'
166
+ }
167
+ return {
168
+ ok: false, status: 'QUOTA_BLOCKED', response: '', conversationId: null, sessionId: null,
169
+ durationSeconds: null, numTurns: null, totalTokens: null, exitCode: null, mode, backend: null,
170
+ stderr: 'agy blocked: Gemini 5h pool quota < 10% (' + Math.round(low5h.remainingFraction * 100) + '%, reset ' + (low5h.resetTime || '?') + '). Use native tools; do not call agy.'
171
+ }
172
+ }
173
+
174
+ const descriptor = {
175
+ id: 'agy', label: 'AGY', presetId: 'cordis-agy', defaultBackend: null, defaultMode: 'auto',
176
+ exeName: 'agy', binEnvVars: ['AGY_BIN', 'AGY_CLI'], unavailableStatus: 'AGY_UNAVAILABLE',
177
+
178
+ // 单后端:backends() 恒 null;'agy'/'antigravity' 是历史别名,归一为 null;
179
+ // 非法值同样返回 null(契约 §1:非法输入返回 null)。会话路由由 engine.resolveTarget
180
+ // 完成(agy 的 backend 恒 null)。
181
+ backends() { return null },
182
+ normalizeBackend(v) {
183
+ if (v === undefined || v === null) return null
184
+ const s = String(v).trim().toLowerCase()
185
+ if (s === '' || s === 'agy' || s === 'antigravity') return null
186
+ return null
187
+ },
188
+ resolveBackend(args, target, preferred) { return null },
189
+
190
+ // 旧 preset buildArgv(exe, args, planActive) verbatim 语义(execPrefix 可为
191
+ // [nodeExe, binPath] 数组,runner 已归一)。
192
+ buildArgv(exePrefix, args, opts) {
193
+ const o = opts || {}
194
+ const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
195
+ argv.push('-p', String(args.prompt), '--output-format', 'stream-json', '--dangerously-skip-permissions')
196
+ const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
197
+ argv.push('--print-timeout', timeoutSec + 's')
198
+ let mode = args.mode || 'auto'
199
+ if (mode === 'auto') mode = o.planActive ? 'plan' : 'accept-edits'
200
+ if (mode === 'plan' || mode === 'accept-edits') argv.push('--mode', mode)
201
+ if (args.model) argv.push('--model', String(args.model))
202
+ if (args.effort) argv.push('--effort', String(args.effort))
203
+ if (Array.isArray(args.addDirs)) for (const d of args.addDirs) { if (d) argv.push('--add-dir', String(d)) }
204
+ if (args.conversationId) argv.push('--conversation', String(args.conversationId))
205
+ else if (args.continueLatest) argv.push('--continue')
206
+ return { argv, mode, timeoutSec }
207
+ },
208
+
209
+ // 旧 parseAgyJson verbatim:末 result 事件优先,容忍杂日志与整串 JSON。
210
+ parseJson(stdoutText) {
211
+ const trimmed = String(stdoutText || '').trim()
212
+ if (!trimmed) return null
213
+ const lines = trimmed.split(/\r?\n/).filter(Boolean)
214
+ for (let i = lines.length - 1; i >= 0; i--) {
215
+ const ln = lines[i].trim()
216
+ if (!ln.startsWith('{')) continue
217
+ try {
218
+ const obj = JSON.parse(ln)
219
+ if (obj && obj.event === 'result' && obj.result) return obj.result
220
+ if (obj && obj.status && obj.response !== undefined) return obj
221
+ } catch (e) {}
222
+ }
223
+ try { return JSON.parse(trimmed) } catch (e) {}
224
+ return null
225
+ },
226
+
227
+ // 旧 buildResult verbatim + backend 字段 + sessionId/conversationId 双写
228
+ //(agy 的会话字段名旧包是 conversationId;新契约统一 sessionId,两键同值以兼容
229
+ // 旧 status 面板/测试与 keystone engine.end 的会话登记)。
230
+ buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
231
+ const exitCode = outcome ? outcome.exitCode : null
232
+ // agy 超时/错误时信息在 result.error 字段(如 "timeout waiting for response"),
233
+ // 必须并入 stderr 供 isLimited 归类(旧 v1.5.8 修复:网络挂起不再静默 FAILED)。
234
+ const errText = parsed && typeof parsed.error === 'string' && parsed.error ? parsed.error : ''
235
+ const stderr = (stderrText ? String(stderrText).slice(-2000) : '') + (errText ? (stderrText ? ' ' : '') + errText : '')
236
+ if (parsed) {
237
+ const conversationId = typeof parsed.conversation_id === 'string' ? parsed.conversation_id : null
238
+ return {
239
+ ok: exitCode === 0 && parsed.status === 'SUCCESS',
240
+ status: typeof parsed.status === 'string' ? parsed.status : (exitCode === 0 ? 'UNKNOWN' : 'ERROR'),
241
+ response: typeof parsed.response === 'string' ? parsed.response : '',
242
+ conversationId,
243
+ sessionId: conversationId,
244
+ durationSeconds: typeof parsed.duration_seconds === 'number' ? parsed.duration_seconds : null,
245
+ numTurns: typeof parsed.num_turns === 'number' ? parsed.num_turns : null,
246
+ totalTokens: parsed.usage && typeof parsed.usage.total_tokens === 'number' ? parsed.usage.total_tokens : null,
247
+ exitCode, mode, backend: backend || null, stderr
248
+ }
249
+ }
250
+ return {
251
+ ok: false, status: 'PARSE_ERROR', response: '',
252
+ conversationId: null, sessionId: null,
253
+ durationSeconds: null, numTurns: null, totalTokens: null,
254
+ exitCode, mode, backend: backend || null, stderr,
255
+ rawStdout: String(stdoutText || '').slice(-2000)
256
+ }
257
+ },
258
+
259
+ // 旧 foldStepUpdate verbatim(step_update → project.current/trail)。
260
+ // bridge-kit foldEvent 对每行 JSON 都调本函数,故这里自判 event==='step_update'。
261
+ foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
262
+ if (!ev || ev.event !== 'step_update') return
263
+ const s = ev.step_update
264
+ if (!s) return
265
+ p.updatedAt = Date.now()
266
+ if (s.step_type === 'tool') {
267
+ const tool = s.tool_name || (s.tool_info && s.tool_info.name) || 'tool'
268
+ const args = summarizeArgs(s.tool_info && s.tool_info.parameters)
269
+ const entry = { stepIndex: s.step_index, state: s.state, tool: tool, args: args || null, at: nowIso() }
270
+ p.trail.push(entry)
271
+ const cap = maxTrail || 12
272
+ if (p.trail.length > cap) p.trail.shift()
273
+ if (s.state === 'ACTIVE') { p.current = { tool: tool, args: args || null, stepIndex: s.step_index, since: nowIso() } }
274
+ else if (p.current && p.current.stepIndex === s.step_index) { p.current = null }
275
+ } else if (s.step_type === 'agent_response' && s.state === 'ACTIVE' && s.text_delta) {
276
+ const capLen = maxArgLen || 120
277
+ p.current = { tool: 'agent_response', args: { text_delta: String(s.text_delta).slice(0, capLen) }, stepIndex: s.step_index, since: nowIso() }
278
+ }
279
+ },
280
+
281
+ // 旧 agy 仓无此概念(codebuddy v1.1.4 引入);保守版对齐 bridge-kit 语义:
282
+ // CLI 自报 error_during_execution 且非限流/网络类 → 静默重试一次。
283
+ isTransientCliError(res) {
284
+ if (!res || res.ok) return false
285
+ if (isLimited(res)) return false
286
+ return res.status === 'ERROR_DURING_EXECUTION'
287
+ },
288
+
289
+ // 失败指引(契约 §1;定案:401/OAuth 类指引 agy login,额度类指引 agy_quota)。
290
+ failureHint(res) {
291
+ if (!res || res.ok) return ''
292
+ const st = String(res.status || '')
293
+ if (st === 'QUOTA_BLOCKED') return '[诊断] agy 未调用(Gemini 5h 池子额度 <10%):请直接用原生工具完成本任务,本任务内不要再调 agy。'
294
+ if (st === 'AGY_UNAVAILABLE') return '[诊断] 未找到 agy 可执行文件:请安装 agy CLI,或设置 AGY_BIN 指向其绝对路径。'
295
+ const hay = String(res.stderr || '') + ' ' + String(res.response || '')
296
+ if (/invalid_grant|unauthorized|401|not logged|login|oauth/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
297
+ return '[诊断] agy 登录态/OAuth 凭据问题:请让用户在本机执行 `agy login` 重新登录后再试;可用 agy_quota 核对凭据与额度。这不是限流。'
298
+ }
299
+ if (/quota|额度|exhausted|insufficient|credit|balance/i.test(hay)) {
300
+ return '[诊断] agy 池子额度可能耗尽:用 agy_quota 查看各池子余量(Gemini 5h 是唯一任务级门禁);5h 低则改用原生工具完成本任务,不要循环调用 agy。'
301
+ }
302
+ return ''
303
+ },
304
+
305
+ isSuccessStatus(st) { return st === 'SUCCESS' },
306
+
307
+ tools: {
308
+ 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 },
309
+ 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 },
310
+ 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 }
311
+ },
312
+
313
+ // agy_quota 仅在宿主有 spawn 能力时注册(preset 注入 deps.process / MCP 注入
314
+ // deps.nodeChild;dynamic 沙箱形态省略 —— Lead 定案 4)。
315
+ extraTools: (deps.process || deps.nodeChild) ? [{
316
+ name: 'agy_quota',
317
+ description: QUOTA_DESCRIPTION,
318
+ inputSchema: QUOTA_PARAMS,
319
+ render: renderQuota,
320
+ async logic(args) {
321
+ const a = args || {}
322
+ const res = await execQuotaScript()
323
+ if (res && res.ok && a.summary) {
324
+ 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 })))
325
+ const top = (res.models || []).slice(0, 4).map((m) => (m.displayName || m.name) + ' ' + m.percentage + '%').join(', ')
326
+ return { ok: true, summary: { weekly, topModels: top }, models: res.models, groups: res.groups, tier: res.tier }
327
+ }
328
+ return res
329
+ }
330
+ }] : [],
331
+
332
+ // agy 无设置分区字段(契约 §3)。
333
+ settings: {
334
+ keys: [],
335
+ defaults: () => ({}),
336
+ normalize: () => ({}),
337
+ meta: () => ({ fields: [] }),
338
+ diagnose: () => ''
339
+ },
340
+
341
+ mcp: { version: '0.2.2' }
342
+ }
343
+
344
+ // 三形态共用的工具逻辑。签名保持不变(keystone 与队友按此接线)。
345
+ descriptor.runToolLogic = async function (args, exec) {
346
+ const a = args || {}
347
+ // 旧 agy preset/dynamic 语义:background 默认 true(仅显式 false 才前台阻塞)。
348
+ const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
349
+ const block = await quotaGate5h(normalized, exec)
350
+ if (block) return block
351
+ return this.runner.coreExecute(normalized, exec)
352
+ }
353
+ descriptor.continueToolLogic = async function (args, exec) {
354
+ const a = args || {}
355
+ 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 }
356
+ const cid = a.conversationId || a.sessionId || null
357
+ if (cid) {
358
+ mapped.conversationId = cid
359
+ mapped.sessionId = cid // keystone engine.resolveTarget 按 sessionId 路由 cwd
360
+ } else if (a.latest) {
361
+ mapped.continueLatest = true
362
+ }
363
+ const block = await quotaGate5h(mapped, exec)
364
+ if (block) return block
365
+ return this.runner.coreExecute(mapped, exec)
366
+ }
367
+ // 旧 statusTool.execute verbatim:cwd 过滤到单项目。
368
+ descriptor.statusToolLogic = function (args) {
369
+ const a = args || {}
370
+ const snap = this.engine.statusSnapshot()
371
+ if (a.cwd) {
372
+ const key = String(a.cwd)
373
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
374
+ const g = snap.projects[0]
375
+ if (g) {
376
+ Object.assign(snap, {
377
+ state: g.state, running: g.running, current: g.current, trail: g.trail,
378
+ lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
379
+ lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
380
+ })
381
+ }
382
+ }
383
+ return snap
384
+ }
385
+ // 供 MCP/测试形态直接复用门禁与额度执行。
386
+ descriptor.quotaGate5h = quotaGate5h
387
+ descriptor.execQuotaScript = execQuotaScript
388
+ descriptor.cachedQuotaCheck = cachedQuotaCheck
389
+
390
+ descriptor.attach = function (hostOpts) {
391
+ host = hostOpts || {}
392
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
393
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
394
+ this.runner = runner
395
+ return runner
396
+ }
397
+ return descriptor
398
+ }