@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,420 @@
|
|
|
1
|
+
// home-plugin/cli-bridge-indicator/lib/index.mjs — CLI Bridge 家级状态灯 host 半(唯一一盏灯)。
|
|
2
|
+
//
|
|
3
|
+
// 合并 agy-indicator / codebuddy-indicator / mimo-indicator 三个家级插件为单个 host 插件:
|
|
4
|
+
// • 每 provider 一个数据桶(按项目 cwd 聚合),来自三个入口:
|
|
5
|
+
// 1) preset 形态:ctx.emit('<id>/status', { snapshot }) + ctx.emit('<id>/mode') 心跳租约
|
|
6
|
+
// 2) dynamic 形态:ctx.get('cliBridgeCollector').mergeSnapshot('<id>', snapshot)
|
|
7
|
+
// (同时保留旧服务名 agyCollector/codebuddyCollector/mimoCollector 别名,
|
|
8
|
+
// 让「新灯 + 旧包 dynamic 形态」并存期不丢数据)
|
|
9
|
+
// 3) MCP 子进程:读 {dsh-home}/cli-bridge-mcp-<id>.json(新)+
|
|
10
|
+
// 旧包路径 plugins/agy-indicator/mcp-live.json / codebuddy-indicator-mcp.json /
|
|
11
|
+
// plugins/mimo-indicator/mcp-live.json(兼容)
|
|
12
|
+
// • 单一状态路由 GET /cli-bridge/status 返回
|
|
13
|
+
// { updatedAt, providers: { agy:{...}, codebuddy:{...}, mimo:{...} } };
|
|
14
|
+
// 同时保留三个旧路由(各返回自己的桶),浏览器旧 client / 其它工具不断流。
|
|
15
|
+
// • 设置路由 GET/POST /cli-bridge/settings(统一文件 cli-bridge-settings.json,按
|
|
16
|
+
// provider 分区;token 只回传掩码)。
|
|
17
|
+
// • v1.2.0 同款声明制 preset:向 agentPresets 注册表登记 cordis-agy /
|
|
18
|
+
// codebuddy-first / cordis-mimo(定义由 scripts/gen-preset-decl.mjs 从
|
|
19
|
+
// preset/<id>/agent.cordis.yml 生成到 lib/preset-definitions/<id>.mjs)。
|
|
20
|
+
// 旧 DSH(≤0.1.6-alpha.2)上 agentPresets 服务永不出现 → 注入器挂起零副作用。
|
|
21
|
+
//
|
|
22
|
+
// 家级插件运行在 host 组合的 root realm;会话内插件的 ctx.emit 事件是 app 级广播,
|
|
23
|
+
// 不受 isolate realm(只隔离服务)影响。
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync, mkdirSync } from 'node:fs'
|
|
26
|
+
import { dirname, join } from 'node:path'
|
|
27
|
+
import { PROVIDER_IDS, createProvider } from '../../../providers/registry.mjs'
|
|
28
|
+
import { MCP_BRIDGE_STALE_MS, normalizeMcpBridge, projectName } from '../../../core/bridge-kit.mjs'
|
|
29
|
+
import { settingsView, parseSettingsBody, readSettingsFile, writeSettingsFile, SETTINGS_FILE_NAME, effectiveSettings, schedulingOf } from './settings.mjs'
|
|
30
|
+
import { declareBridgePresets } from './preset-declarations.mjs'
|
|
31
|
+
|
|
32
|
+
export const PRESET_TTL_MS = 75000
|
|
33
|
+
export const PROJECT_TTL_MS = 10 * 60 * 1000
|
|
34
|
+
export const MAX_PROJECTS = 24
|
|
35
|
+
export const MAX_SESSIONS = 64
|
|
36
|
+
|
|
37
|
+
export function detectDshHome(env) {
|
|
38
|
+
const e = env || (typeof process !== 'undefined' && process.env ? process.env : {})
|
|
39
|
+
if (e.DSH_HOME) return e.DSH_HOME
|
|
40
|
+
if (e.APPDATA) return join(e.APPDATA, 'DSH Desktop', 'dsh-home')
|
|
41
|
+
if (e.HOME) return join(e.HOME, '.dsh')
|
|
42
|
+
return join('.', '.dsh')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 可脱离 Cordis 单测的纯状态机 ──────────────────────────────────────────────
|
|
46
|
+
// opts: { now?, ttlMs?, projectTtlMs?, staleMs?, listPresetSessions?(id), isSuccessStatus?(id,st),
|
|
47
|
+
// readMcpFile?(id) -> payload|null, ids? }
|
|
48
|
+
export function createIndicatorState(opts) {
|
|
49
|
+
const o = opts || {}
|
|
50
|
+
const now = typeof o.now === 'function' ? o.now : Date.now
|
|
51
|
+
const ttlMs = o.ttlMs != null ? o.ttlMs : PRESET_TTL_MS
|
|
52
|
+
const projectTtlMs = o.projectTtlMs != null ? o.projectTtlMs : PROJECT_TTL_MS
|
|
53
|
+
const staleMs = o.staleMs != null ? o.staleMs : MCP_BRIDGE_STALE_MS
|
|
54
|
+
const ids = Array.isArray(o.ids) && o.ids.length ? o.ids.slice() : PROVIDER_IDS.slice()
|
|
55
|
+
const listPresetSessions = typeof o.listPresetSessions === 'function' ? o.listPresetSessions : null
|
|
56
|
+
const isSuccess = typeof o.isSuccessStatus === 'function' ? o.isSuccessStatus : null
|
|
57
|
+
const readMcpFile = typeof o.readMcpFile === 'function' ? o.readMcpFile : null
|
|
58
|
+
|
|
59
|
+
// 每 provider 独立桶:projects / sessionLeases / 租约 / 单调序号
|
|
60
|
+
const buckets = Object.create(null)
|
|
61
|
+
let seqCounter = 0
|
|
62
|
+
for (const id of ids) {
|
|
63
|
+
buckets[id] = { projects: Object.create(null), sessionLeases: Object.create(null), presetActiveUntil: 0, lastModeAt: 0 }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function okStatus(id, st) {
|
|
67
|
+
if (isSuccess) { try { return !!isSuccess(id, st) } catch (e) {} }
|
|
68
|
+
return String(st) === 'SUCCESS'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ensure(b, cwd) {
|
|
72
|
+
const key = String(cwd || '')
|
|
73
|
+
let p = b.projects[key]
|
|
74
|
+
if (!p) {
|
|
75
|
+
const keys = Object.keys(b.projects)
|
|
76
|
+
if (keys.length >= MAX_PROJECTS) {
|
|
77
|
+
const idle = keys.filter((k) => !b.projects[k].running)
|
|
78
|
+
if (idle.length) {
|
|
79
|
+
const victim = idle.sort((a, x) => (b.projects[a].updatedAt || 0) - (b.projects[x].updatedAt || 0))[0]
|
|
80
|
+
delete b.projects[victim]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
p = { cwd: key, name: projectName(key), state: 'idle', running: 0, current: null, trail: [], lastStatus: null, lastAt: 0, lastSessionId: null, lastBackend: null, fallbackActive: false, runs: 0, totalTokens: 0, updatedAt: 0, seq: ++seqCounter }
|
|
84
|
+
b.projects[key] = p
|
|
85
|
+
}
|
|
86
|
+
return p
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mergeSnapshot(id, snap) {
|
|
90
|
+
const b = buckets[id]
|
|
91
|
+
if (!b || !snap) return
|
|
92
|
+
const list = Array.isArray(snap.projects) ? snap.projects : (snap.cwd ? [snap] : [])
|
|
93
|
+
for (const src of list) {
|
|
94
|
+
if (!src || !src.cwd) continue
|
|
95
|
+
const p = ensure(b, src.cwd)
|
|
96
|
+
const srcAt = Number(src.updatedAt) || 0
|
|
97
|
+
if (src.name) p.name = src.name
|
|
98
|
+
if (typeof src.running === 'number') p.running = src.running
|
|
99
|
+
if (src.current !== undefined) p.current = src.current
|
|
100
|
+
if (Array.isArray(src.trail)) p.trail = src.trail.slice(-12)
|
|
101
|
+
if (src.lastStatus) p.lastStatus = src.lastStatus
|
|
102
|
+
if (src.lastAt) p.lastAt = Number(src.lastAt) || p.lastAt
|
|
103
|
+
if (src.lastSessionId) p.lastSessionId = src.lastSessionId
|
|
104
|
+
if (src.lastBackend) p.lastBackend = src.lastBackend
|
|
105
|
+
if (src.fallbackActive !== undefined) p.fallbackActive = !!src.fallbackActive
|
|
106
|
+
if (typeof src.runs === 'number') p.runs = src.runs
|
|
107
|
+
if (typeof src.totalTokens === 'number') p.totalTokens = src.totalTokens
|
|
108
|
+
// state 是派生字段:外部快照只接受它作「运行中」提示,其余本地重算,
|
|
109
|
+
// 避免陈旧/不一致的 state 把灯画错颜色(mimo ACCEPTED 被当 failed 的坑)。
|
|
110
|
+
if (src.state === 'running') p.state = 'running'
|
|
111
|
+
else if (!p.running) p.state = okStatus(id, p.lastStatus) ? 'ok' : (p.fallbackActive ? 'fallback' : (p.lastStatus ? 'failed' : 'idle'))
|
|
112
|
+
p.seq = ++seqCounter
|
|
113
|
+
p.updatedAt = srcAt || now()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// <id>/mode 事件:active:true 续租(可选带 sessionId 记会话租约);否则熄灭。
|
|
118
|
+
function onMode(id, payload) {
|
|
119
|
+
const b = buckets[id]
|
|
120
|
+
if (!b) return
|
|
121
|
+
try {
|
|
122
|
+
const sid = payload && typeof payload.sessionId === 'string' && payload.sessionId ? payload.sessionId : null
|
|
123
|
+
if (payload && payload.active) {
|
|
124
|
+
b.presetActiveUntil = now() + ttlMs
|
|
125
|
+
b.lastModeAt = now()
|
|
126
|
+
if (sid) {
|
|
127
|
+
b.sessionLeases[sid] = now() + ttlMs
|
|
128
|
+
const leaseIds = Object.keys(b.sessionLeases)
|
|
129
|
+
if (leaseIds.length > MAX_SESSIONS) {
|
|
130
|
+
leaseIds.sort((a, x) => b.sessionLeases[a] - b.sessionLeases[x])
|
|
131
|
+
for (let i = 0; i < leaseIds.length - MAX_SESSIONS; i++) delete b.sessionLeases[leaseIds[i]]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else if (sid) {
|
|
135
|
+
delete b.sessionLeases[sid]
|
|
136
|
+
if (Object.keys(b.sessionLeases).length === 0) b.presetActiveUntil = 0
|
|
137
|
+
} else { b.presetActiveUntil = 0 }
|
|
138
|
+
} catch (e) { }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function topOf(list) {
|
|
142
|
+
// 顶层聚合只认「最近一次活动」那一个项目,不用 OR 抬历史 fallback(否则
|
|
143
|
+
// 一个早已回退的陈旧项目永久把灯涂成琥珀)。排序确定:seq > updatedAt > cwd。
|
|
144
|
+
let top = null
|
|
145
|
+
for (const p of list) {
|
|
146
|
+
if (!top) { top = p; continue }
|
|
147
|
+
if ((p.seq || 0) !== (top.seq || 0)) { if ((p.seq || 0) > (top.seq || 0)) top = p; continue }
|
|
148
|
+
if ((p.updatedAt || 0) !== (top.updatedAt || 0)) { if ((p.updatedAt || 0) > (top.updatedAt || 0)) top = p; continue }
|
|
149
|
+
if (String(p.cwd) > String(top.cwd)) top = p
|
|
150
|
+
}
|
|
151
|
+
return top
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function bucketSnapshot(id) {
|
|
155
|
+
const b = buckets[id]
|
|
156
|
+
if (!b) return { state: 'idle', running: 0, projects: [], presetActive: false, presetSessions: [], lastModeAt: 0 }
|
|
157
|
+
// MCP 子进程快照(响应时读盘;客户端本就轮询,天然限频)
|
|
158
|
+
if (readMcpFile) {
|
|
159
|
+
try {
|
|
160
|
+
const raw = readMcpFile(id)
|
|
161
|
+
const norm = normalizeMcpBridge(raw, now(), staleMs)
|
|
162
|
+
if (norm && Array.isArray(norm.projects)) mergeSnapshot(id, { projects: norm.projects })
|
|
163
|
+
} catch (e) { }
|
|
164
|
+
}
|
|
165
|
+
const t = now()
|
|
166
|
+
// 会话名单 = 实时枚举(权威)∪ preset 上报租约(兜底)
|
|
167
|
+
const seen = Object.create(null)
|
|
168
|
+
const presetSessions = []
|
|
169
|
+
if (listPresetSessions) {
|
|
170
|
+
try {
|
|
171
|
+
const live = listPresetSessions(id)
|
|
172
|
+
if (Array.isArray(live)) for (const sid of live) { if (typeof sid === 'string' && sid && !seen[sid]) { seen[sid] = 1; presetSessions.push(sid) } }
|
|
173
|
+
} catch (e) { }
|
|
174
|
+
}
|
|
175
|
+
for (const sid of Object.keys(b.sessionLeases)) {
|
|
176
|
+
if (t < b.sessionLeases[sid]) { if (!seen[sid]) { seen[sid] = 1; presetSessions.push(sid) } }
|
|
177
|
+
else delete b.sessionLeases[sid]
|
|
178
|
+
}
|
|
179
|
+
const presetActive = t < b.presetActiveUntil || presetSessions.length > 0
|
|
180
|
+
const list = Object.keys(b.projects).map((k) => b.projects[k]).sort((a, x) => (x.updatedAt || 0) - (a.updatedAt || 0))
|
|
181
|
+
let running = 0
|
|
182
|
+
for (const p of list) running += p.running || 0
|
|
183
|
+
const isStale = (p) => {
|
|
184
|
+
if (p.running > 0) return false
|
|
185
|
+
const at = Number(p.updatedAt) || Number(p.lastAt) || 0
|
|
186
|
+
return !!at && (t - at) > projectTtlMs
|
|
187
|
+
}
|
|
188
|
+
// 顶层聚合只看新鲜(未 stale)项目:陈旧历史不再决定灯色(0.2.1 修复「指示灯长期
|
|
189
|
+
// 存在」的服务端半边 —— 客户端半边在 pill 过滤;无新鲜项目时顶层回落 idle)。
|
|
190
|
+
const freshList = list.filter((p) => !isStale(p))
|
|
191
|
+
const top = topOf(freshList)
|
|
192
|
+
const lastStatus = top ? top.lastStatus : null
|
|
193
|
+
const fallbackActive = top ? !!top.fallbackActive : false
|
|
194
|
+
const state = running > 0 ? 'running' : (lastStatus ? (okStatus(id, lastStatus) ? 'ok' : (fallbackActive ? 'fallback' : 'failed')) : (fallbackActive ? 'fallback' : 'idle'))
|
|
195
|
+
return {
|
|
196
|
+
state, running, lastStatus,
|
|
197
|
+
lastAt: top ? (top.lastAt || 0) : 0,
|
|
198
|
+
lastSessionId: top ? top.lastSessionId : null,
|
|
199
|
+
lastBackend: top ? top.lastBackend : null,
|
|
200
|
+
fallbackActive, presetActive, presetSessions, lastModeAt: b.lastModeAt,
|
|
201
|
+
projects: list.map((p) => ({
|
|
202
|
+
cwd: p.cwd, name: p.name || projectName(p.cwd), provider: id, state: p.state, running: p.running,
|
|
203
|
+
current: p.current, trail: p.trail, lastStatus: p.lastStatus, lastAt: p.lastAt,
|
|
204
|
+
lastSessionId: p.lastSessionId, lastBackend: p.lastBackend, fallbackActive: p.fallbackActive,
|
|
205
|
+
runs: p.runs, totalTokens: p.totalTokens, updatedAt: p.updatedAt, stale: isStale(p)
|
|
206
|
+
}))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function snapshot() {
|
|
211
|
+
const providers = Object.create(null)
|
|
212
|
+
for (const id of ids) providers[id] = bucketSnapshot(id)
|
|
213
|
+
return { updatedAt: now(), providers }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { ids, mergeSnapshot, onMode, bucketSnapshot, snapshot, buckets }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Cordis 插件面 ─────────────────────────────────────────────────────────────
|
|
220
|
+
export const name = 'cli-bridge-indicator'
|
|
221
|
+
export const inject = []
|
|
222
|
+
|
|
223
|
+
function jsonRes(res, code, obj) {
|
|
224
|
+
try { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)) } catch (e) { }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readBody(req, limit) {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
const chunks = []
|
|
230
|
+
let size = 0
|
|
231
|
+
req.on('data', (c) => {
|
|
232
|
+
size += c.length
|
|
233
|
+
if (size > limit) { reject(new Error('body too large')); try { req.destroy() } catch (e) {} ; return }
|
|
234
|
+
chunks.push(c)
|
|
235
|
+
})
|
|
236
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
237
|
+
req.on('error', reject)
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── MCP 快照读盘工厂(可脱离 Cordis/真实 fs 单测;apply() 只注入 home)──────────
|
|
242
|
+
// opts: { home, joinFn?, fs? } → readMcpFile(id) → payload|null
|
|
243
|
+
// 新文件名 cli-bridge-mcp-<id>.json 优先;旧包名兼容(并存期旧 MCP/dynamic 形态仍写老路径):
|
|
244
|
+
// agy <home>/plugins/agy-indicator/mcp-live.json(旧包 1.5.13 引入、1.6.2 修
|
|
245
|
+
// DSH_HOME 锚定;AGY_MCP_LIVE_FILE 只是旧 server 写侧覆盖,读侧无需跟随)
|
|
246
|
+
// codebuddy <home>/codebuddy-indicator-mcp.json
|
|
247
|
+
// mimo <home>/plugins/mimo-indicator/mcp-live.json
|
|
248
|
+
export const LEGACY_MCP_FILES = {
|
|
249
|
+
agy: ['plugins', 'agy-indicator', 'mcp-live.json'],
|
|
250
|
+
codebuddy: ['codebuddy-indicator-mcp.json'],
|
|
251
|
+
mimo: ['plugins', 'mimo-indicator', 'mcp-live.json']
|
|
252
|
+
}
|
|
253
|
+
export function createMcpFileReader(opts) {
|
|
254
|
+
const o = opts || {}
|
|
255
|
+
const home = o.home
|
|
256
|
+
const joinFn = typeof o.joinFn === 'function' ? o.joinFn : join
|
|
257
|
+
const fsImpl = o.fs || { existsSync, readFileSync }
|
|
258
|
+
if (!home) return () => null
|
|
259
|
+
return function readMcpFile(id) {
|
|
260
|
+
const legacy = LEGACY_MCP_FILES[id]
|
|
261
|
+
const candidates = [joinFn(home, 'cli-bridge-mcp-' + id + '.json')]
|
|
262
|
+
.concat(legacy ? [joinFn(home, ...legacy)] : [])
|
|
263
|
+
for (const p of candidates) {
|
|
264
|
+
try {
|
|
265
|
+
if (!fsImpl.existsSync(p)) continue
|
|
266
|
+
const data = JSON.parse(fsImpl.readFileSync(p, 'utf8'))
|
|
267
|
+
// 兼容 mimo/agy 旧格式 {writtenAt, projects}
|
|
268
|
+
if (data && Number(data.writtenAt) && !data.updatedAt) { data.updatedAt = data.writtenAt }
|
|
269
|
+
return data
|
|
270
|
+
} catch (e) { }
|
|
271
|
+
}
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function apply(ctx) {
|
|
277
|
+
const home = detectDshHome()
|
|
278
|
+
// 构造三个描述符只为拿元信息(isSuccessStatus/backends 等纯逻辑);失败不致命。
|
|
279
|
+
let descriptors = Object.create(null)
|
|
280
|
+
for (const id of PROVIDER_IDS) { try { descriptors[id] = createProvider(id, { env: process.env }) } catch (e) { descriptors[id] = null } }
|
|
281
|
+
|
|
282
|
+
function listPresetSessions(id) {
|
|
283
|
+
try {
|
|
284
|
+
const d = descriptors[id]
|
|
285
|
+
if (!d || !d.presetId) return []
|
|
286
|
+
const agents = typeof ctx.get === 'function' ? ctx.get('agents') : undefined
|
|
287
|
+
const presets = typeof ctx.get === 'function' ? ctx.get('agentPresets') : undefined
|
|
288
|
+
if (!agents || typeof agents.list !== 'function') return []
|
|
289
|
+
if (!presets || typeof presets.composedPreset !== 'function') return []
|
|
290
|
+
const out = []
|
|
291
|
+
for (const agent of agents.list()) {
|
|
292
|
+
try {
|
|
293
|
+
if (!agent || !agent.ctx) continue
|
|
294
|
+
if (presets.composedPreset(agent.ctx) !== d.presetId) continue
|
|
295
|
+
const sid = agent.id || (agent.session && agent.session.id)
|
|
296
|
+
if (typeof sid === 'string' && sid) out.push(sid)
|
|
297
|
+
} catch (e) { }
|
|
298
|
+
}
|
|
299
|
+
return out
|
|
300
|
+
} catch (e) { return [] }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// MCP 快照读盘:新文件名优先,旧包文件名兼容(并存期旧包 dynamic/MCP 形态仍在写老路径)。
|
|
304
|
+
// 表与读盘逻辑见 createMcpFileReader(可注入 fs/join,keystone 测试直接锁三旧路径)。
|
|
305
|
+
const readMcpFile = createMcpFileReader({ home })
|
|
306
|
+
|
|
307
|
+
function isSuccessStatus(id, st) {
|
|
308
|
+
const d = descriptors[id]
|
|
309
|
+
if (d && typeof d.isSuccessStatus === 'function') { try { return d.isSuccessStatus(st) } catch (e) { } }
|
|
310
|
+
return String(st) === 'SUCCESS' || String(st) === 'ACCEPTED'
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const st = createIndicatorState({ listPresetSessions, readMcpFile, isSuccessStatus })
|
|
314
|
+
|
|
315
|
+
// 事件通道(preset 形态)+ 兼容服务通道(dynamic 形态)。
|
|
316
|
+
for (const id of st.ids) {
|
|
317
|
+
try {
|
|
318
|
+
ctx.on(id + '/status', (payload) => {
|
|
319
|
+
const snap = payload && typeof payload === 'object' && payload.snapshot ? payload.snapshot : payload
|
|
320
|
+
st.mergeSnapshot(id, snap)
|
|
321
|
+
})
|
|
322
|
+
} catch (e) { }
|
|
323
|
+
try { ctx.on(id + '/mode', (payload) => st.onMode(id, payload)) } catch (e) { }
|
|
324
|
+
}
|
|
325
|
+
const collector = {
|
|
326
|
+
mergeSnapshot(id, snap) { st.mergeSnapshot(id, snap) },
|
|
327
|
+
markMode(id) { st.onMode(id, { active: true }) }
|
|
328
|
+
}
|
|
329
|
+
try { ctx.provide('cliBridgeCollector', collector) } catch (e) { }
|
|
330
|
+
for (const id of st.ids) {
|
|
331
|
+
try {
|
|
332
|
+
ctx.provide(id + 'Collector', {
|
|
333
|
+
mergeSnapshot(snap) { st.mergeSnapshot(id, snap) },
|
|
334
|
+
markMode() { st.onMode(id, { active: true }) }
|
|
335
|
+
})
|
|
336
|
+
} catch (e) { }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 声明制 preset 注册(新 DSH;旧 DSH 注入器挂起零副作用)。
|
|
340
|
+
try { declareBridgePresets(ctx, { descriptors }) } catch (e) { }
|
|
341
|
+
|
|
342
|
+
// HTTP 路由:单一路由 + 三个旧路由(各返回自己的桶)+ 设置路由 + agy 额度(可选)。
|
|
343
|
+
if (typeof ctx.inject === 'function') {
|
|
344
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
345
|
+
const ws = webCtx.get('webServer')
|
|
346
|
+
if (!ws || typeof ws.register !== 'function') return
|
|
347
|
+
webCtx.effect(() => ws.register({
|
|
348
|
+
kind: 'exact', path: '/cli-bridge/status',
|
|
349
|
+
handler: async (req, res) => {
|
|
350
|
+
const out = st.snapshot()
|
|
351
|
+
// 调度快照(每次轮询现读文件 → 改完即见,无需重启灯);失败不影响状态本体。
|
|
352
|
+
try {
|
|
353
|
+
const snap = effectiveSettings({ env: process.env })
|
|
354
|
+
const sched = schedulingOf(snap, { env: process.env })
|
|
355
|
+
const labels = Object.create(null)
|
|
356
|
+
for (const id of PROVIDER_IDS) labels[id] = (descriptors[id] && descriptors[id].label) || id
|
|
357
|
+
out.scheduling = { enabled: sched.enabled, priority: sched.priority, order: sched.order, all: PROVIDER_IDS.slice(), labels }
|
|
358
|
+
} catch (e) { }
|
|
359
|
+
jsonRes(res, 200, out)
|
|
360
|
+
}
|
|
361
|
+
}, 'cli-bridge: status route'))
|
|
362
|
+
for (const id of st.ids) {
|
|
363
|
+
webCtx.effect(() => ws.register({
|
|
364
|
+
kind: 'exact', path: '/' + id + '-indicator/status',
|
|
365
|
+
handler: async (req, res) => jsonRes(res, 200, st.bucketSnapshot(id))
|
|
366
|
+
}, 'cli-bridge: legacy status ' + id))
|
|
367
|
+
}
|
|
368
|
+
webCtx.effect(() => ws.register({
|
|
369
|
+
kind: 'exact', path: '/cli-bridge/settings',
|
|
370
|
+
handler: (req, res) => {
|
|
371
|
+
const method = String((req && req.method) || 'GET').toUpperCase()
|
|
372
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
373
|
+
try { jsonRes(res, 200, settingsView({ env: process.env })) }
|
|
374
|
+
catch (e) { jsonRes(res, 500, { error: String(e && e.message || e) }) }
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
if (method !== 'POST') { jsonRes(res, 405, { error: 'method not allowed' }); return }
|
|
378
|
+
readBody(req, 64 * 1024).then((text) => {
|
|
379
|
+
const parsed = parseSettingsBody(text, { env: process.env })
|
|
380
|
+
if (!parsed.ok) { jsonRes(res, 400, { error: parsed.error }); return }
|
|
381
|
+
const written = writeSettingsFile(parsed.value, { env: process.env })
|
|
382
|
+
if (!written.ok) { jsonRes(res, 500, { error: written.error }); return }
|
|
383
|
+
jsonRes(res, 200, { ok: true, settings: settingsView({ env: process.env }) })
|
|
384
|
+
}).catch((e) => jsonRes(res, 400, { error: String(e && e.message || e) }))
|
|
385
|
+
}
|
|
386
|
+
}, 'cli-bridge: settings route'))
|
|
387
|
+
// agy 额度(可选增强,脚本缺失返回 { ok:false })
|
|
388
|
+
webCtx.effect(() => ws.register({
|
|
389
|
+
kind: 'exact', path: '/agy-indicator/quota',
|
|
390
|
+
handler: async (req, res) => { agyQuota(res); }
|
|
391
|
+
}, 'cli-bridge: agy quota route'))
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let quotaCache = { at: 0, data: null }
|
|
397
|
+
function agyQuota(res) {
|
|
398
|
+
// 额度脚本定位:CLI_BRIDGE_AGY_QUOTA 显式覆盖 > 本包内 bin/agy-quota.mjs(由模块
|
|
399
|
+
// 真实路径推导)。绝不硬编码用户桌面路径(历史教训:agy 旧版把
|
|
400
|
+
// C:\Users\lcl\Desktop\agy-first-bridge 写死,换机即坏)。
|
|
401
|
+
let script = process.env.CLI_BRIDGE_AGY_QUOTA || null
|
|
402
|
+
if (!script) {
|
|
403
|
+
try {
|
|
404
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
405
|
+
script = join(here, '..', '..', '..', 'bin', 'agy-quota.mjs')
|
|
406
|
+
} catch (e) { }
|
|
407
|
+
}
|
|
408
|
+
if (!script || !existsSync(script)) { jsonRes(res, 200, { ok: false, error: 'quota script not found' }); return }
|
|
409
|
+
const now = Date.now()
|
|
410
|
+
if (quotaCache.data && now - quotaCache.at < 5 * 60 * 1000) { jsonRes(res, 200, quotaCache.data); return }
|
|
411
|
+
import('node:child_process').then(({ execFile }) => {
|
|
412
|
+
execFile('node', [script, '--summary'], { timeout: 60000, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
413
|
+
let out
|
|
414
|
+
if (err) out = { ok: false, error: String(err.message || err) }
|
|
415
|
+
else { const m = String(stdout || '').match(/\{[\s\S]*\}/); out = m ? JSON.parse(m[0]) : { ok: false, error: 'no JSON' } }
|
|
416
|
+
quotaCache = { at: Date.now(), data: out }
|
|
417
|
+
jsonRes(res, 200, out)
|
|
418
|
+
})
|
|
419
|
+
}).catch((e) => jsonRes(res, 200, { ok: false, error: String(e && e.message || e) }))
|
|
420
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs — 声明制 preset 注册(keystone)。
|
|
2
|
+
//
|
|
3
|
+
// v1.2.0 Form-A 修复的三合一版:indicator 以自身为载体,向 agentPresets 注册表登记
|
|
4
|
+
// cordis-agy / codebuddy-first / cordis-mimo 三个 preset(各自 = 对应 preset/<id>/plugin-list.mjs
|
|
5
|
+
// 的单源定义)。旧 DSH(≤0.1.6-alpha.2)上 agentPresets 服务永不出现 → ctx.inject 注入器
|
|
6
|
+
// 挂起零副作用(cordis 语义:等待不是失败,loader 不收集等待中的注入器)——这正是
|
|
7
|
+
// 我们不用 bundle-patch preset 行的原因(旧 loader 把行 import 失败当致命错误,整包拒挂)。
|
|
8
|
+
//
|
|
9
|
+
// plugin-list.mjs 是三个 preset 的唯一权威源(PROVIDER-CONTRACT.md §5);这里运行时
|
|
10
|
+
// import 它(宿主是真 Node)。动态 import 失败(provider 任务未交付/文件损坏)只影响
|
|
11
|
+
// 该 preset 的注册,绝不让状态灯本业崩溃 —— 逐 provider 独立 try。
|
|
12
|
+
//
|
|
13
|
+
// 注意:本文件 import 的 plugin-list 在 **host realm** 执行,其 buildPlugins 返回的
|
|
14
|
+
// 数组随后交给 agentPresets.register();__jsExpr 占位逐字透传(loader 的原生输入
|
|
15
|
+
// 形态),不做求值。
|
|
16
|
+
|
|
17
|
+
import { pathToFileURL } from 'node:url'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { resolveScheduling } from '../../../core/scheduling.mjs'
|
|
22
|
+
import { PROVIDER_IDS } from '../../../providers/registry.mjs'
|
|
23
|
+
|
|
24
|
+
export const PKG_NAME = '@luchenglong/cli-bridge'
|
|
25
|
+
|
|
26
|
+
// preset 目录名映射(与 scripts/build.mjs 的 PRESET_DIRS 一致;目录名带 -first 后缀是
|
|
27
|
+
// 三个旧包时代的遗留布局,provider id 不带 —— 这里不映射就永远找不到 plugin-list)。
|
|
28
|
+
const PRESET_DIRS = { agy: 'agy-first', codebuddy: 'codebuddy-first', mimo: 'mimo-first', opencode: 'opencode-first' }
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
31
|
+
const ROOT = join(HERE, '..', '..', '..')
|
|
32
|
+
|
|
33
|
+
function realize(v, seen) {
|
|
34
|
+
// { __jsExpr: string } 是 loader interpolate 的【原生输入形态】(codebuddy v1.2.0
|
|
35
|
+
// 实测:agentPresets.register 原样接受,由 loader 在激活时求值)—— 必须逐字透传,
|
|
36
|
+
// 绝不能在这里编译成真函数:注册表会 JSON 化跨 realm 传递,函数会静默丢失。
|
|
37
|
+
if (!v || typeof v !== 'object') return v
|
|
38
|
+
if (Array.isArray(v)) return v.map((x) => realize(x, seen))
|
|
39
|
+
if (Object.prototype.hasOwnProperty.call(v, '__jsExpr') && typeof v.__jsExpr === 'string') {
|
|
40
|
+
return { __jsExpr: v.__jsExpr }
|
|
41
|
+
}
|
|
42
|
+
if (seen.indexOf(v) >= 0) return v
|
|
43
|
+
seen.push(v)
|
|
44
|
+
const out = Object.create(null)
|
|
45
|
+
for (const k of Object.keys(v)) out[k] = realize(v[k], seen)
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadPluginList(id) {
|
|
50
|
+
const abs = join(ROOT, 'preset', PRESET_DIRS[id] || id, 'plugin-list.mjs')
|
|
51
|
+
if (!existsSync(abs)) return null
|
|
52
|
+
const mod = await import(pathToFileURL(abs).href)
|
|
53
|
+
if (!mod || typeof mod.buildPlugins !== 'function' || !mod.meta) return null
|
|
54
|
+
return mod
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 生成某 provider 的 agentPresets 定义(真函数已落地;不含任何 DSH 活对象)。
|
|
59
|
+
* @returns {Promise<{id,name,description,order,plugins}|null>}
|
|
60
|
+
*/
|
|
61
|
+
export async function buildPresetDefinition(id) {
|
|
62
|
+
const mod = await loadPluginList(id)
|
|
63
|
+
if (!mod) return null
|
|
64
|
+
const meta = mod.meta
|
|
65
|
+
const plugins = realize(mod.buildPlugins({ bridgeName: PKG_NAME + '/preset-' + id }) || [], [])
|
|
66
|
+
return { id: meta.id, name: meta.name || meta.id, description: meta.description || '', order: meta.order != null ? meta.order : 10, plugins }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 在 indicator 的 ctx 上挂「agentPresets 就绪 → 注册三个 preset」等待器。
|
|
71
|
+
* 返回每个 provider 的注册状态数组(诊断用);任何异常都不外抛。
|
|
72
|
+
*
|
|
73
|
+
* 调度语义(0.2.0):三个 preset 恒常注册(可读性/可回退优先 —— 禁用≠从目录消失,
|
|
74
|
+
* 用户随时可切进禁用 preset 排查),注册【顺序】按 priority 排列;真正的「不启用」
|
|
75
|
+
* 发生在桥接插件 apply():读 _scheduling,禁用面不注册任何工具/政策段(安全且即时,
|
|
76
|
+
* 改设置只影响下次会话,无需重新注册表)。opts.ids 显式传入时跳过调度排序。
|
|
77
|
+
*/
|
|
78
|
+
export function declareBridgePresets(ctx, opts) {
|
|
79
|
+
let ids = (opts && opts.ids) || PROVIDER_IDS_FALLBACK
|
|
80
|
+
if (!opts || !opts.ids) {
|
|
81
|
+
try {
|
|
82
|
+
const sched = resolveScheduling(PROVIDER_IDS_FALLBACK, opts && opts.io)
|
|
83
|
+
// 顺序 = 启用项(order = priority ∩ enabled)在前,禁用项按 priority 顺序补尾。
|
|
84
|
+
// UI 卡片序 / preset 目录序 / MCP 工具序三处因此永远一致(priority 是总轴,
|
|
85
|
+
// enabled 只决定 apply() 里注不注册工具,不决定它排在哪儿)。
|
|
86
|
+
const tail = sched.priority.filter((x) => sched.order.indexOf(x) < 0)
|
|
87
|
+
const ordered = sched.order.slice()
|
|
88
|
+
for (const id of tail) ordered.push(id)
|
|
89
|
+
for (const id of PROVIDER_IDS_FALLBACK) if (ordered.indexOf(id) < 0) ordered.push(id)
|
|
90
|
+
ids = ordered
|
|
91
|
+
} catch (e) { /* 调度不可得 = 缺省顺序,注册不受影响 */ }
|
|
92
|
+
}
|
|
93
|
+
const out = []
|
|
94
|
+
if (typeof ctx.inject !== 'function') return out
|
|
95
|
+
for (const id of ids) {
|
|
96
|
+
try {
|
|
97
|
+
ctx.inject(['agentPresets'], (child) => {
|
|
98
|
+
child.effect(() => {
|
|
99
|
+
let disposer = null
|
|
100
|
+
const registering = (async () => {
|
|
101
|
+
try {
|
|
102
|
+
const def = await buildPresetDefinition(id)
|
|
103
|
+
if (!def) { out.push({ id, ok: false, error: 'plugin-list missing' }); return }
|
|
104
|
+
disposer = await child.agentPresets.register(def)
|
|
105
|
+
out.push({ id, ok: true })
|
|
106
|
+
} catch (e) {
|
|
107
|
+
// 重复注册(双实例/用户手工声明行撞 id)等:本份放弃即可,
|
|
108
|
+
// 绝不向宿主树抛错。
|
|
109
|
+
out.push({ id, ok: false, error: String(e && e.message || e) })
|
|
110
|
+
try {
|
|
111
|
+
if (child.logger && typeof child.logger.warn === 'function') {
|
|
112
|
+
child.logger.warn('cli-bridge: preset ' + id + ' registration skipped: ' + (e && e.message || e))
|
|
113
|
+
}
|
|
114
|
+
} catch (_) { }
|
|
115
|
+
}
|
|
116
|
+
})()
|
|
117
|
+
return async () => {
|
|
118
|
+
try { await registering } catch (_) { }
|
|
119
|
+
if (typeof disposer === 'function') {
|
|
120
|
+
try { await disposer() } catch (_) { }
|
|
121
|
+
}
|
|
122
|
+
disposer = null
|
|
123
|
+
}
|
|
124
|
+
}, 'cli-bridge: preset declaration ' + id)
|
|
125
|
+
})
|
|
126
|
+
} catch (e) {
|
|
127
|
+
out.push({ id, ok: false, error: String(e && e.message || e) })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 单一 id 轴 = registry.PROVIDER_IDS(PROVIDER-CONTRACT.md §3.1):新 provider 进注册表,
|
|
134
|
+
// preset 声明自动跟上;本模块与 settings.mjs 同 realm(host 半启动时已把 registry 加载进
|
|
135
|
+
// 进程),导入 registry 不引入新依赖面。导出名保留 FALLBACK 供旧引用兼容。
|
|
136
|
+
export const PROVIDER_IDS_FALLBACK = PROVIDER_IDS.slice()
|
|
137
|
+
|