@bd7pil/reeve 0.0.0-stage → 0.1.0

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/src/index.js ADDED
@@ -0,0 +1,613 @@
1
+ // REEVE 记账层 —— opencode v2 插件入口。
2
+ //
3
+ // 施工图(写代码前必读):docs/research/opencode-v2-plugin-api.md
4
+ //
5
+ // 五处挂载点:
6
+ // ① 拦 subagent(并发闸门,子会话创建之前) ctx.tool.hook("execute.before")
7
+ // ② 落台账 ctx.tool.hook("execute.after")
8
+ // ③ 认终态 ctx.event.subscribe() → session.execution.*
9
+ // ④ 墙钟 Supervisor(每任务一次性定时器)
10
+ // ⑤ 落盘 + 对账 ctx.storage + ctx.session.hook("prompt")
11
+ //
12
+ // 外加一处"补名册":ctx.session.hook("context") 把 subagent 名册接进
13
+ // `subagent` 工具的描述 —— 7 个 subagent 都标了 hidden(前台不可见),
14
+ // 引擎自己的"可用 subagent"宣传段会把它们滤掉,这一钩子把名册补回去
15
+ // (人看不见,模型看得见)。见 src/roster.js。
16
+ //
17
+ // 铁律:**本插件绝不写 `agents.*.model`** —— 那是与 openchamber 面板抢配置
18
+ // (openchamber#3881:面板保存 agent 会静默压掉预设)。插件只注册工具 + 挂 hook。
19
+
20
+ import {
21
+ PLUGIN_ID,
22
+ PLUGIN_VERSION,
23
+ REASON,
24
+ STATE,
25
+ TOOL_NAMESPACE,
26
+ } from './constants.js'
27
+ import { homedir } from 'node:os'
28
+ import { join } from 'node:path'
29
+
30
+ import { Ledger } from './ledger.js'
31
+ import { acquireAdmissionRuntime } from './concurrency.js'
32
+ import { Supervisor } from './supervisor.js'
33
+ import { reconcileParent } from './reconcile.js'
34
+ import { SnapshotWriter, buildSnapshot, projectAgentTable } from './snapshot.js'
35
+ import { SUBAGENT_ROSTER, withRoster } from './roster.js'
36
+ import { readSetupToken, startOwnedProxy } from './proxy.js'
37
+ import { REEVE_TIERS, BUILTINS_TO_HIDE, mergeTierModels, pickDefaultAgent, readEndpoint } from './models.js'
38
+ import { STATS_DEFAULTS, aggregateBifrostRows, aggregateLedger, buildStats, fetchBifrostLogs, fetchBifrostModels, mergeModelCandidates } from './stats.js'
39
+ import { loadAgents, loadSkills } from './register.js'
40
+ import { ensureProviderBlock, reeveProviderBlock } from './config-write.js'
41
+
42
+ const SUBAGENT_TOOL = 'subagent'
43
+ /** 自持代理的默认端口(与写进配置的端点一致)。 */
44
+ const PROXY_DEFAULT_PORT = 8080
45
+
46
+ export default {
47
+ id: PLUGIN_ID,
48
+
49
+ async setup(ctx) {
50
+ const logger = makeLogger()
51
+ const options = ctx.options ?? {}
52
+ const limits = options.limits ?? {}
53
+ const wallClockMs = options.wallClockMs ?? 0
54
+ const abortGraceMs = options.abortGraceMs
55
+
56
+ const ledger = new Ledger({ storage: ctx.storage, logger })
57
+ const restored = await ledger.hydrate()
58
+ logger.debug(
59
+ `[reeve] 已启动;从盘上恢复 ${restored.loaded} 条台账记录` +
60
+ (restored.truncated ? '(⚠️ 可能不完整,见上一条告警)' : ''),
61
+ )
62
+
63
+ // agent 表诊断(桌面面板可核对"前台只出现编排者")。
64
+ // 为什么值得常驻:`opencode debug agents` 走的是 CLI 自己连的那个服务,未必是你以为的
65
+ // 那个引擎(实测踩过:它连到另一个 HOME 的服务,报出别人的 agent 表)。这一份是从
66
+ // 引擎进程内部读的,没有歧义。
67
+ //
68
+ // ⚠️ **不能在 setup 里读**:那时其他插件的 `agent.transform`(配置里的 agents 块、
69
+ // 内置 agent 的注册)还没应用,读到的是"transform 之前的表"——实测只看到 7 个内置。
70
+ // 改成第一次 `context` 钩子时惰性读一次,那时表才是最终的。
71
+ let agentTable = null
72
+ let agentTableRead = false
73
+ // 名册注入的证据:注入成功过一次就记下来(进快照)——"藏起来不削弱委派"这条设计
74
+ // 靠它可核对,而不是靠"模型应该看得见"。
75
+ let roster = null
76
+ /** 代理层自持的状态(进快照:面板可显示、上机可核对)。必须在快照块之前声明。 */
77
+ let proxyState = null
78
+ /** 链文件的状态(代理那边在维护,这里只留引用给快照)。必须在快照块之前声明。 */
79
+ let chainRef = null
80
+ /** 性能统计(进快照)。必须在快照块之前声明。 */
81
+ let statsState = null
82
+ /** 模型候选(面板的链编辑选择器用)。必须在快照块之前声明。 */
83
+ let modelsState = null
84
+ /** 从插件包注册进去的 agent / skill 名单(进快照,便于上机核对"没往配置目录拷文件")。 */
85
+ let registeredAgents = []
86
+ let registeredSkills = []
87
+ /** 端点自举的结果(进快照)。 */
88
+ let endpointState = null
89
+ let snapshotFlush = () => {}
90
+
91
+ const readAgentTable = async () => {
92
+ try {
93
+ const res = await ctx.agent.list()
94
+ // 生成的客户端返回 `{ data: [...] }`(CLI 里也是这么取的);也容错裸数组
95
+ const list = Array.isArray(res) ? res : Array.isArray(res?.data) ? res.data : null
96
+ agentTable = projectAgentTable(list)
97
+ if (agentTable) {
98
+ const ids = (rows) => rows.map((a) => `${a.id}[${a.mode}]`).join(', ') || '(无)'
99
+ logger.debug(
100
+ `[reeve] 引擎 agent 表:${agentTable.total} 个;前台可见 [${ids(agentTable.visible)}];` +
101
+ `隐藏 [${ids(agentTable.hidden)}];subagent [${ids(agentTable.subagent)}]`,
102
+ )
103
+ snapshotFlush()
104
+ }
105
+ } catch (err) {
106
+ logger.debug(`[reeve] 读 agent 表失败(忽略):${String(err)}`)
107
+ }
108
+ }
109
+ // 保留期修剪:只动已落终态的旧记录,在飞的一律不动。
110
+ const retentionDays = Number(options.retentionDays ?? 30)
111
+ if (retentionDays > 0) {
112
+ try {
113
+ const removed = await ledger.prune({ olderThanMs: retentionDays * 24 * 60 * 60 * 1000 })
114
+ if (removed > 0) logger.debug(`[reeve] 修剪了 ${removed} 条超过 ${retentionDays} 天的终态记录`)
115
+ } catch (err) {
116
+ logger.debug(`[reeve] 修剪失败(忽略):${String(err)}`)
117
+ }
118
+ }
119
+
120
+ // 只读快照(给 openchamber 面板):**opt-in** —— 只有部署时给了 snapshotPath 才写。
121
+ // 它是投影,不是第二份真相:面板只读它,永不写回。
122
+ // 失败只记日志:**记账的正确性绝不依赖这个文件**。
123
+ // 快照默认落 `~/.reeve/ledger.json`(REEVE 自己的目录,和代理的 app-dir 一处)
124
+ const snapshotPath = options.snapshotPath ?? join(homedir(), '.reeve', 'ledger.json')
125
+ let snapshot = null
126
+ if (snapshotPath) {
127
+ snapshot = new SnapshotWriter({ path: snapshotPath, logger })
128
+ const build = () =>
129
+ buildSnapshot(ledger.all(), {
130
+ version: PLUGIN_VERSION,
131
+ agents: agentTable,
132
+ roster,
133
+ proxy: proxyState,
134
+ chain: chainRef,
135
+ models: modelsState,
136
+ registered: { agents: registeredAgents, skills: registeredSkills },
137
+ endpoint: endpointState,
138
+ stats: statsState,
139
+ })
140
+ snapshotFlush = () => snapshot.schedule(build)
141
+ ledger.onChange = () => snapshot.schedule(build)
142
+ snapshot.schedule(build)
143
+ void snapshot.flush().catch(() => {})
144
+ logger.debug(`[reeve] 台账快照开启:${snapshotPath}`)
145
+ }
146
+
147
+ // ---- 端点自举:首次启动时把 providers.reeve 写进引擎配置(opt-out:options.ownEndpoint === false)----
148
+ // 使用者裁决 2026-10-20:允许插件写自己的这一块。规则见 src/config-write.js 的头注释
149
+ // (只加不改 / 备份 / 写完自校验 / 保留注释)。
150
+ let endpointWrite = null
151
+ if (options.ownEndpoint !== false) {
152
+ const wantPort = Number(options.proxy?.port ?? PROXY_DEFAULT_PORT)
153
+ endpointWrite = await ensureProviderBlock({
154
+ configDir: options.engineConfigDir ?? null,
155
+ block: reeveProviderBlock({ port: wantPort }),
156
+ logger,
157
+ })
158
+ logger.debug(`[reeve] 端点自举:${endpointWrite.status}(${endpointWrite.detail ?? ''})`)
159
+ endpointState = { status: endpointWrite.status, path: endpointWrite.path, detail: endpointWrite.detail }
160
+ }
161
+
162
+ // ---- 代理层自持(opt-in:options.proxy.enabled)----
163
+ // 纯插件形态:取件 → 派生链 → 拉起/复用 → 等就绪 → 收敛 → 守护。
164
+ // 不需要任何外部脚本;失败只记日志(代理起不来时引擎照常跑,模型会吵地报错)。
165
+ let proxyHandle = null
166
+ // 两种写法都行:`proxy: true`(简写)或 `proxy: { enabled: true, ... }`
167
+ // 默认开:这样配置里可以真的只写一行插件(`proxy: false` 关闭;`proxy: true` 或对象都行)
168
+ if (options.proxy !== false && options.proxy?.enabled !== false) {
169
+ // 端口不用用户重复写:从配置里的 `providers.reeve.settings.baseURL` 推(读不到就用 options/默认)
170
+ let opts = options.proxy === true || options.proxy === undefined ? {} : options.proxy
171
+ if (opts.port == null) {
172
+ const endpoint = await readEndpoint({})
173
+ if (endpoint) {
174
+ opts = { ...opts, port: endpoint.port, host: endpoint.host }
175
+ logger.debug(`[reeve] 代理端口取自配置:${endpoint.host}:${endpoint.port}(来自 ${endpoint.from})`)
176
+ }
177
+ }
178
+ // 状态一变就刷新快照 —— 尤其"后台取件完成、代理起来了"那一刻(否则面板会一直显示未就绪)
179
+ const onState = (st) => {
180
+ proxyState = { ready: st.ready, detail: st.detail, port: st.port, host: st.host }
181
+ // 链的状态对象由代理那边持续更新(热更、收敛复核都会写它)—— 这里只留引用,快照时再取
182
+ if (st.chain) chainRef = st.chain
183
+ snapshotFlush()
184
+ }
185
+ proxyHandle = await startOwnedProxy({ opts, logger, onState })
186
+ onState(proxyHandle)
187
+ logger.debug(`[reeve] 代理层自持:${proxyHandle.ready ? '就绪' : '未就绪'} —— ${proxyHandle.detail ?? '?'}`)
188
+ }
189
+
190
+ const admission = acquireAdmissionRuntime(ctx.location?.directory, { limits })
191
+
192
+ // ---- ⑦ 性能统计(Bifrost 归因 + 台账聚合 → 快照)----
193
+ // 为什么在插件里做而不是面板里做:面板跑在 openchamber 的 iframe 里,**没有网络能力**,
194
+ // 只有宿主给的 readFile 与 writeFile。而 `/api/logs`、`/v1/models` 是本机回环的 HTTP ——
195
+ // 只有引擎进程里的插件够得着。所以插件取数 + 聚合,面板只读快照。
196
+ const statsOpts = typeof options.stats === 'object' && options.stats !== null ? options.stats : {}
197
+ const statsIntervalMs = Number(statsOpts.intervalMs ?? STATS_DEFAULTS.intervalMs)
198
+ const statsLimit = Number(statsOpts.limit ?? STATS_DEFAULTS.limit)
199
+ const statsWindowMs = Number(statsOpts.windowMs ?? STATS_DEFAULTS.windowMs)
200
+ // 模型目录多久刷一次。**别跟统计同频**:`/v1/models` 是一次真去上游的探测,
201
+ // Bifrost 会为每个 provider 记一行日志(实测一次探测 = 7 行)—— 一分钟一次会把
202
+ // 用户自己的归因日志灌满探测噪声。模型目录本身变化很慢,半小时足够。
203
+ const modelsIntervalMs = Number(statsOpts.modelsIntervalMs ?? STATS_DEFAULTS.modelsIntervalMs)
204
+ let statsTimer = null
205
+ let statsSignature = null
206
+ let statsInFlight = false
207
+ let lastModelsAt = 0
208
+
209
+ /** 链上已经在用的模型(面板选择器的候选之一:发现不全时至少这些能选)。 */
210
+ const chainedRefs = () => {
211
+ const out = []
212
+ for (const spec of Object.values(chainRef?.tiers ?? {})) {
213
+ for (const t of spec?.targets ?? []) if (t?.model) out.push(`${t.provider}/${t.model}`)
214
+ for (const f of spec?.fallbacks ?? []) {
215
+ const s = String(f ?? '')
216
+ if (s && !s.endsWith('/') && s.includes('/')) out.push(s)
217
+ }
218
+ }
219
+ return out
220
+ }
221
+ const engineModels = () => REEVE_TIERS.map((t) => `reeve/${t.id}`)
222
+
223
+ const refreshStats = async () => {
224
+ if (!proxyHandle || statsInFlight) return
225
+ statsInFlight = true
226
+ const now = Date.now()
227
+ const { port, host, paths } = proxyHandle
228
+ try {
229
+ const token = await readSetupToken(paths.token)
230
+ if (!token) {
231
+ statsState = buildStats({ now, windowMs: statsWindowMs, source: 'unavailable', error: '读不到代理的 setup token(统计接口要它)', checkedAt: now })
232
+ snapshotFlush()
233
+ return
234
+ }
235
+ // 先探一行:`total_count` + 最新 id 没变就说明没有新调用 —— 省掉一次上百行的抓取
236
+ const probe = await fetchBifrostLogs({ port, host, token, limit: 1 })
237
+ if (probe.error) {
238
+ statsState = buildStats({ now, windowMs: statsWindowMs, source: 'unavailable', error: `读 /api/logs 失败:${probe.error}`, checkedAt: now })
239
+ snapshotFlush()
240
+ return
241
+ }
242
+ if (probe.signature && probe.signature === statsSignature && statsState?.bifrost) {
243
+ statsState = { ...statsState, checkedAt: now }
244
+ snapshotFlush()
245
+ return
246
+ }
247
+ const full = await fetchBifrostLogs({ port, host, token, limit: statsLimit })
248
+ statsSignature = full.signature
249
+ const bifrost = full.error ? null : aggregateBifrostRows(full.rows, { now, windowMs: statsWindowMs })
250
+ const ledgerAgg = aggregateLedger(ledger.all(), { now })
251
+ statsState = buildStats({
252
+ now,
253
+ checkedAt: now,
254
+ windowMs: statsWindowMs,
255
+ source: bifrost ? 'bifrost.api' : 'unavailable',
256
+ error: full.error ? `读 /api/logs 失败:${full.error}` : null,
257
+ bifrost,
258
+ orchestrate: {
259
+ ...ledgerAgg,
260
+ queue: { active: admission.activeCount, waiting: admission.waitingCount },
261
+ },
262
+ })
263
+ // 顺手把模型候选也刷一遍(半小时一次;链编辑器的选择器用它)
264
+ if (now - lastModelsAt >= modelsIntervalMs) {
265
+ const discovered = await fetchBifrostModels({ port, host })
266
+ lastModelsAt = now
267
+ const candidates = mergeModelCandidates({ discovered: discovered.models, chained: chainedRefs(), engine: engineModels() })
268
+ modelsState = {
269
+ generatedAt: now,
270
+ source: discovered.error ? 'chain+engine' : 'bifrost',
271
+ error: discovered.error,
272
+ discovered: discovered.models.length,
273
+ candidates,
274
+ }
275
+ }
276
+ snapshotFlush()
277
+ } catch (err) {
278
+ logger.debug(`[reeve] 统计刷新失败(忽略):${String(err).slice(0, 140)}`)
279
+ } finally {
280
+ statsInFlight = false
281
+ }
282
+ }
283
+
284
+ if (statsIntervalMs > 0 && proxyHandle) {
285
+ void refreshStats()
286
+ statsTimer = setInterval(() => void refreshStats(), statsIntervalMs)
287
+ statsTimer.unref?.()
288
+ logger.debug(`[reeve] 性能统计:每 ${Math.round(statsIntervalMs / 1000)}s 刷一次(近 ${statsLimit} 行 / ${Math.round(statsWindowMs / 3600000)}h 窗口)`)
289
+ }
290
+ const supervisor = new Supervisor({
291
+ ledger,
292
+ logger,
293
+ abortGraceMs,
294
+ interrupt: async (record) => {
295
+ if (!record.childSessionID) return
296
+ // v2 的中断:恰好一次,且 resume:false(不要它接着跑)
297
+ await ctx.session.interrupt({ sessionID: record.childSessionID, resume: false })
298
+ },
299
+ })
300
+
301
+ // 工具调用 id -> 槽位释放函数(execute.before 取,execute.after 放)
302
+ const held = new Map()
303
+
304
+ // ---- 工具:reeve_task_status(只读)----
305
+ // ⚠️ options 里 **两个都要**:namespace 负责命名,codemode:false 才是"暴露给模型"的开关。
306
+ await ctx.tool.transform((editor) => {
307
+ editor.add({
308
+ name: 'task_status',
309
+ description:
310
+ '查询本编排层的子任务台账:谁在跑、谁结束了、有没有静默死亡或超时。' +
311
+ '只读,不会启动/中断/修改任何任务。',
312
+ input: {
313
+ type: 'object',
314
+ properties: {
315
+ parentSessionID: { type: 'string', description: '只看这个父会话派出去的子任务;省略则看全部' },
316
+ includeTerminal: { type: 'boolean', description: '是否连已结束的也返回(默认 false)' },
317
+ },
318
+ additionalProperties: false,
319
+ },
320
+ output: { type: 'string' },
321
+ options: { namespace: TOOL_NAMESPACE, codemode: false },
322
+ execute: async (args) => {
323
+ const includeTerminal = args?.includeTerminal === true
324
+ const rows = ledger
325
+ .all()
326
+ .filter((r) => (args?.parentSessionID ? r.parentSessionID === args.parentSessionID : true))
327
+ .filter((r) => (includeTerminal ? true : r.state === STATE.RUNNING))
328
+ .map((r) => ({
329
+ taskID: r.taskID,
330
+ parent: r.parentSessionID,
331
+ child: r.childSessionID,
332
+ agent: r.agent,
333
+ description: r.description,
334
+ background: r.background,
335
+ state: r.state,
336
+ reason: r.reason,
337
+ uncertain: r.statusUncertain,
338
+ timedOut: r.timedOut,
339
+ startedAt: new Date(r.startedAt).toISOString(),
340
+ endedAt: r.endedAt ? new Date(r.endedAt).toISOString() : null,
341
+ }))
342
+ return {
343
+ output: JSON.stringify({ count: rows.length, tasks: rows }, null, 2),
344
+ metadata: { count: rows.length },
345
+ }
346
+ },
347
+ })
348
+ })
349
+
350
+ // ---- ⓪ 注册(让 opencode.jsonc 尽量短)----
351
+ // 三件事都在插件里做,配置里就不用写 models / agents / default_agent:
352
+ // a) 三个档位模型(配置里已写的不动 —— 配置说话时以配置为准)
353
+ // b) 把内置 agent 藏起来(前台只出现编排者)
354
+ // c) 默认 agent = foreman(存在才设)
355
+ // ⚠️ 端点(baseURL / key)**注册不了**:Provider.Info 没有 URL、Connection 只是凭据引用、
356
+ // Integration 只是"怎么拿凭据"。那两行必须留在配置里。
357
+ try {
358
+ await ctx.provider.transform((editor) => {
359
+ const existing = editor.get('reeve')?.models
360
+ const list = existing ? Array.from(existing.values()) : []
361
+ const merged = mergeTierModels(list, REEVE_TIERS, 'reeve')
362
+ if (merged.length !== list.length) editor.models.set('reeve', merged)
363
+ })
364
+ // agent 与 skill 从**插件自己的包**里注册(配置目录里不需要 agent/ 与 skill/ 两个目录)。
365
+ // 配置优先:引擎里已经有的同名 agent(用户在配置目录里写的)一律不动。
366
+ const bundledAgents = await loadAgents()
367
+ const bundledSkills = await loadSkills()
368
+ await ctx.agent.transform((editor) => {
369
+ for (const id of BUILTINS_TO_HIDE) {
370
+ if (editor.get(id)?.hidden === true) continue
371
+ editor.update(id, (agent) => {
372
+ agent.hidden = true
373
+ })
374
+ }
375
+ for (const info of bundledAgents) {
376
+ if (editor.get(info.id)) continue // 配置优先
377
+ editor.update(info.id, (agent) => {
378
+ Object.assign(agent, info)
379
+ })
380
+ }
381
+ // 编排者必须存在才能当默认;不存在(被配置删了/改名了)就保持原样
382
+ pickDefaultAgent(editor, 'foreman')
383
+ })
384
+ registeredAgents = bundledAgents.map((a) => a.id)
385
+ await ctx.skill.transform((editor) => {
386
+ for (const skill of bundledSkills) {
387
+ if (editor.get(skill.id)) continue
388
+ editor.add(skill)
389
+ }
390
+ })
391
+ registeredSkills = bundledSkills.map((s) => s.id)
392
+ logger.debug(`[reeve] 注册:agent ${registeredAgents.length} 个、skill ${registeredSkills.length} 个(配置里已有的不动)`)
393
+ } catch (err) {
394
+ logger.debug(`[reeve] 注册(模型/可见性)失败(忽略):${String(err)}`)
395
+ }
396
+
397
+ // ---- ① 并发闸门(子会话创建之前)+ ② 落台账 ----
398
+ await ctx.tool.hook('execute.before', async (event) => {
399
+ if (event.tool !== SUBAGENT_TOOL) return
400
+ const input = event.input ?? {}
401
+
402
+ // (a) 嵌套的受管任务豁免:调用方自己就是被本插件管着的子会话 —— 它已持槽。
403
+ const nested = ledger.forChildSession(event.sessionID) != null
404
+
405
+ const key = `${event.sessionID}:${event.id}`
406
+ const release = await admission.acquire(key, {
407
+ model: input.model ?? null,
408
+ provider: null,
409
+ nested,
410
+ signal: null,
411
+ })
412
+ held.set(key, release)
413
+ logger.debug(
414
+ `[reeve] 派发 ${input.agent ?? '?'}(父 ${event.sessionID}${nested ? ',嵌套豁免' : ''})` +
415
+ `;在飞 ${admission.activeCount},排队 ${admission.waitingCount}`,
416
+ )
417
+ })
418
+
419
+ await ctx.tool.hook('execute.after', async (event) => {
420
+ if (event.tool !== SUBAGENT_TOOL) return
421
+
422
+ const key = `${event.sessionID}:${event.id}`
423
+ const release = held.get(key)
424
+ if (release) {
425
+ held.delete(key)
426
+ release()
427
+ }
428
+
429
+ const input = event.input ?? {}
430
+ const taskID = `${event.id}`
431
+ const existing = ledger.get(taskID)
432
+
433
+ if (!existing) {
434
+ ledger.create({
435
+ taskID,
436
+ parentSessionID: event.sessionID,
437
+ agent: input.agent ?? null,
438
+ description: input.description ?? null,
439
+ background: input.background === true,
440
+ model: input.model ?? null,
441
+ })
442
+ }
443
+
444
+ if (event.status === 'error') {
445
+ // 工具自身失败(例如权限被拒)—— 这不是"子任务跑了然后死了",直接落终态。
446
+ ledger.commitTerminal(taskID, STATE.ERROR, {
447
+ source: 'tool-result',
448
+ reason: String(event.error ?? 'subagent tool error').slice(0, 200),
449
+ })
450
+ supervisor.disarm(taskID)
451
+ return
452
+ }
453
+
454
+ const childSessionID = extractChildSessionID(event.result)
455
+ if (childSessionID) ledger.attachChild(taskID, childSessionID)
456
+
457
+ const backgrounded = input.background === true
458
+ if (backgrounded) {
459
+ supervisor.arm(taskID, wallClockMs)
460
+ } else {
461
+ // 前台调用:工具已经同步返回,直接落终态(宿主的 execution 事件也会到,
462
+ // 但那条会被 commitTerminal 的单调性挡掉,不会重复写)。
463
+ ledger.commitTerminal(taskID, STATE.COMPLETED, { source: 'tool-result' })
464
+ supervisor.disarm(taskID)
465
+ }
466
+ logger.debug(`[reeve] 落账 ${taskID} → ${ledger.get(taskID)?.state}`)
467
+ })
468
+
469
+ // ---- ③ 认终态 ----
470
+ const abort = new AbortController()
471
+ ;(async () => {
472
+ try {
473
+ for await (const event of ctx.event.subscribe({ signal: abort.signal })) {
474
+ const type = String(event?.type ?? '')
475
+ if (!type.startsWith('session.execution.')) continue
476
+
477
+ const sessionID = event?.sessionID ?? event?.data?.sessionID
478
+ if (!sessionID) continue
479
+ const record = ledger.forChildSession(sessionID)
480
+ if (!record) continue
481
+
482
+ if (type === 'session.execution.succeeded') {
483
+ ledger.commitTerminal(record.taskID, STATE.COMPLETED, { source: 'event' })
484
+ } else if (type === 'session.execution.failed') {
485
+ ledger.commitTerminal(record.taskID, STATE.ERROR, {
486
+ source: 'event',
487
+ reason: String(event?.error ?? '').slice(0, 200),
488
+ })
489
+ } else if (type === 'session.execution.interrupted') {
490
+ // ⚠️ 关键归因:如果我们自己发过中断(墙钟到期),这条事件是**我们造成的**,
491
+ // 必须记成"墙钟超时",不能记成"宿主中断" —— 否则 timedOut 永远是 false。
492
+ if (record.interruptReason === 'wall_clock') {
493
+ ledger.commitTerminal(record.taskID, STATE.ERROR, {
494
+ source: 'wall-clock',
495
+ reason: REASON.WALL_CLOCK_TIMEOUT,
496
+ timedOut: true,
497
+ statusUncertain: true,
498
+ terminalUnreconciled: true,
499
+ })
500
+ } else {
501
+ const reason = event?.reason ?? 'unknown'
502
+ ledger.commitTerminal(record.taskID, STATE.STOPPED, {
503
+ source: 'event',
504
+ reason: `${REASON.HOST_INTERRUPTED}:${reason}`,
505
+ })
506
+ }
507
+ } else {
508
+ continue
509
+ }
510
+ supervisor.disarm(record.taskID)
511
+ }
512
+ } catch (err) {
513
+ if (!abort.signal.aborted) logger.debug(`[reeve] 事件流结束:${String(err)}`)
514
+ }
515
+ })()
516
+
517
+ // ---- ⑤ 惰性对账(父会话下一次活动时)----
518
+ await ctx.session.hook('prompt', async (event) => {
519
+ try {
520
+ const result = await reconcileParent({
521
+ ctx,
522
+ ledger,
523
+ logger,
524
+ parentSessionID: event?.sessionID,
525
+ })
526
+ if (result.reconciled > 0) {
527
+ logger.debug(`[reeve] 对账完成:${JSON.stringify(result)}`)
528
+ }
529
+ } catch (err) {
530
+ logger.debug(`[reeve] 对账失败(忽略):${String(err)}`)
531
+ }
532
+ })
533
+
534
+ // ---- ⑥ 补名册(构造模型调用时)----
535
+ // 7 个 subagent 在前台不可见(hidden),引擎的"可用 subagent"宣传段因此为空;
536
+ // 这里把名册接回 `subagent` 工具的描述。幂等(withRoster 认标记),
537
+ // 失败只记日志 —— 描述补不上不该影响任何一次真实调用。
538
+ let rosterLogged = false
539
+ await ctx.session.hook('context', (event) => {
540
+ // 到这里所有 agent.transform 都已应用 —— 顺手读一次最终的表(只读一次)
541
+ if (!agentTableRead) {
542
+ agentTableRead = true
543
+ void readAgentTable()
544
+ }
545
+ try {
546
+ const tool = event?.tools?.[SUBAGENT_TOOL]
547
+ if (!tool) return
548
+ const before = tool.description
549
+ tool.description = withRoster(before)
550
+ if (tool.description !== before) {
551
+ const first = roster === null
552
+ roster = { count: SUBAGENT_ROSTER.length, at: Date.now(), additions: (roster?.additions ?? 0) + 1 }
553
+ snapshotFlush()
554
+ if (!rosterLogged) {
555
+ rosterLogged = true
556
+ logger.debug(`[reeve] 名册已注入 ${SUBAGENT_TOOL} 工具描述(${SUBAGENT_ROSTER.length} 个 subagent)`)
557
+ } else if (first) {
558
+ logger.debug('[reeve] 名册注入过一次后又被清掉(工具描述被重建)——已重新注入')
559
+ }
560
+ }
561
+ } catch (err) {
562
+ logger.debug(`[reeve] 名册注入失败(忽略):${String(err)}`)
563
+ }
564
+ })
565
+
566
+ return () => {
567
+ abort.abort()
568
+ supervisor.stopAll()
569
+ if (statsTimer) clearInterval(statsTimer)
570
+ if (snapshot) {
571
+ snapshot.cancel()
572
+ void snapshot.flush().catch(() => {})
573
+ }
574
+ if (proxyHandle) void proxyHandle.dispose().catch(() => {})
575
+ logger.debug('[reeve] 已卸载')
576
+ }
577
+ },
578
+ }
579
+
580
+ /**
581
+ * 从 `subagent` 工具的返回里取子会话 id。
582
+ * 优先结构化字段(施工图 §8):`{ sessionID, status, output }`;
583
+ * 退化到文本正则(后台提示里的 `(sessionID: <id>)`、完成通知的 `<subagent sessionID="<id>"`)。
584
+ */
585
+ function extractChildSessionID(result) {
586
+ if (!result || typeof result !== 'object') return null
587
+ const direct = result?.output?.sessionID ?? result?.metadata?.sessionID
588
+ if (typeof direct === 'string' && direct) return direct
589
+
590
+ const text =
591
+ typeof result?.content === 'string'
592
+ ? result.content
593
+ : Array.isArray(result?.content)
594
+ ? result.content.map((c) => c?.text ?? '').join('')
595
+ : typeof result?.output?.output === 'string'
596
+ ? result.output.output
597
+ : ''
598
+ const m =
599
+ text.match(/\(sessionID:\s*(ses_[A-Za-z0-9_-]+)\)/) ??
600
+ text.match(/<subagent\s+sessionID="(ses_[A-Za-z0-9_-]+)"/) ??
601
+ text.match(/sessionID:\s*(ses_[A-Za-z0-9_-]+)/)
602
+ return m ? m[1] : null
603
+ }
604
+
605
+ function makeLogger() {
606
+ const prefix = (msg) => `${msg}`
607
+ return {
608
+ debug: (msg) => {
609
+ // 走 stderr;`opencode run --print-logs` 能看到
610
+ console.error(prefix(String(msg)))
611
+ },
612
+ }
613
+ }