opencode-subagent-magazine 1.6.1 → 1.7.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.
@@ -1,4 +1,4 @@
1
- import type { SubEntry } from "../core/types"
1
+ import type { SubEntry, SubStatus } from "../core/types"
2
2
 
3
3
  export type SubEntryPatch = Omit<SubEntry, "startedAt" | "endedAt"> & {
4
4
  startedAt?: number
@@ -37,3 +37,79 @@ export function upsertSubEntry(
37
37
  })
38
38
  return next
39
39
  }
40
+
41
+ /** running < cancel_requested < 终态(done/error/cancelled)。 */
42
+ function statusRank(status: SubStatus): number {
43
+ if (status === "running") return 0
44
+ if (status === "cancel_requested") return 1
45
+ return 2
46
+ }
47
+
48
+ const isTerminalStatus = (status: SubStatus) => statusRank(status) === 2
49
+
50
+ function minDefined(a: number | undefined, b: number | undefined): number | undefined {
51
+ if (a === undefined) return b
52
+ if (b === undefined) return a
53
+ return Math.min(a, b)
54
+ }
55
+
56
+ function maxDefined(a: number | undefined, b: number | undefined): number | undefined {
57
+ if (a === undefined) return b
58
+ if (b === undefined) return a
59
+ return Math.max(a, b)
60
+ }
61
+
62
+ /**
63
+ * 合并同一子代理条目的两个视图,绝不回退状态。
64
+ *
65
+ * 持久化存储由多个 TUI 实例写入,每个写入者都可能
66
+ * 持有过期快照。终态始终优先于 running /
67
+ * cancel_requested,进度计数只增不减,描述性字段
68
+ * 则回退到仍保有它的一侧。
69
+ */
70
+ export function mergeSubEntry(base: SubEntry, incoming: SubEntry): SubEntry {
71
+ const successor = statusRank(incoming.status) > statusRank(base.status) ? incoming : base
72
+ const status = successor.status
73
+ const ended = isTerminalStatus(status)
74
+ return {
75
+ ...base,
76
+ ...incoming,
77
+ id: base.id,
78
+ status,
79
+ sessionId: incoming.sessionId ?? base.sessionId,
80
+ startedAt: minDefined(base.startedAt, incoming.startedAt) ?? base.startedAt,
81
+ endedAt: ended ? (base.endedAt ?? incoming.endedAt ?? Date.now()) : undefined,
82
+ tokens: maxDefined(base.tokens, incoming.tokens),
83
+ cost: maxDefined(base.cost, incoming.cost),
84
+ model: incoming.model ?? base.model,
85
+ todoTotal: maxDefined(base.todoTotal, incoming.todoTotal),
86
+ todoDone: maxDefined(base.todoDone, incoming.todoDone),
87
+ error: incoming.error ?? base.error,
88
+ cancelRequestedAt: minDefined(base.cancelRequestedAt, incoming.cancelRequestedAt),
89
+ abortAccepted: base.abortAccepted || incoming.abortAccepted || undefined,
90
+ cancelReason: incoming.cancelReason ?? base.cancelReason,
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 合并两个条目集合,去重方式与 `upsertSubEntry` 相同
96
+ * (先子会话、后条目 id),冲突用 `mergeSubEntry` 合并。
97
+ * 所有持久化路径都走这里,避免并发实例以整表写入
98
+ * 互相丢弃对方的条目。
99
+ */
100
+ export function mergeSubEntries(
101
+ base: Iterable<SubEntry>,
102
+ incoming: Iterable<SubEntry>,
103
+ ): Map<string, SubEntry> {
104
+ const out = new Map<string, SubEntry>()
105
+ for (const entry of base) out.set(entry.id, entry)
106
+ for (const entry of incoming) {
107
+ const key = findSubEntryKey(out, entry.id, entry.sessionId)
108
+ if (!key) {
109
+ out.set(entry.id, entry)
110
+ continue
111
+ }
112
+ out.set(key, mergeSubEntry(out.get(key)!, entry))
113
+ }
114
+ return out
115
+ }
@@ -51,6 +51,14 @@ export interface PanelApi {
51
51
  messages(sid: string): unknown[] | undefined
52
52
  /** Raw parts of a message (scan). */
53
53
  part(messageID: string): unknown[] | undefined
54
+ /** Optional host-side fallback: map a subagent tool call to its child session
55
+ * when live events/history lack the link (V2 reads the local database). */
56
+ resolveChild?(input: {
57
+ parentId: string
58
+ callId?: string
59
+ agent?: string
60
+ startedAt?: number
61
+ }): string | undefined
54
62
  }
55
63
  event: {
56
64
  on(type: PanelEventType, cb: (e: PanelEvent) => void): () => void
@@ -1,16 +1,24 @@
1
1
  import type { Context, KeymapCommand } from "./types"
2
2
  import type { PanelApi } from "../panel/panel-api"
3
- import type { Lang, SharedSignals, SubStatus } from "../core/types"
4
- import { KV_PREFIX, SETTING_KEYS, loadSessionData, saveSessionData, readTTLDays } from "../core/kv"
3
+ import type { Lang, SharedSignals, SubStatus, TimeFormat } from "../core/types"
4
+ import { TIME_FORMATS, TIME_FORMAT_SAMPLES } from "../core/format"
5
+ import { KV_PREFIX, SETTING_KEYS, updateSessionData, readTTLDays } from "../core/kv"
5
6
  import { PLUGIN_VERSION } from "../_version"
6
7
  import { LANG_META, createT } from "../i18n"
7
8
  import { globalEntryCache, setClearTick } from "../panel/store"
9
+ import { mergeSubEntries } from "../panel/entry-map"
10
+ import { openSettingsMenu } from "./settings-menu"
8
11
 
9
12
  /** V2 命令(对齐 V1 的 9 个斜杠命令——promise 式对话框)。 */
10
- export function makeCommands(context: Context, api: PanelApi, signals: SharedSignals): KeymapCommand[] {
13
+ export function makeCommands(
14
+ context: Context,
15
+ api: PanelApi,
16
+ signals: SharedSignals,
17
+ ): KeymapCommand[] {
11
18
  const t = createT(() => signals.lang())
12
19
  const kv = api.kv
13
20
  const clampMax = (n: number) => Math.max(1, Math.min(50, n))
21
+ const onOff = (v: boolean) => (v ? t("settings.on") : t("settings.off"))
14
22
 
15
23
  const resolveParent = (sid: string): { parentSid: string; isChild: boolean } => {
16
24
  try {
@@ -98,6 +106,111 @@ export function makeCommands(context: Context, api: PanelApi, signals: SharedSig
98
106
  api.ui.toast(`Max entries: ${n}`)
99
107
  },
100
108
  },
109
+ {
110
+ id: "opencode-subagent-magazine.subagent.config",
111
+ title: "SubAgent Magazine: Settings",
112
+ description: "Show current settings (change each via its own command)",
113
+ slash: { name: "subagent-config" },
114
+ palette: true,
115
+ run: () => {
116
+ const ttl = readTTLDays(kv)
117
+ const langLabel = LANG_META.find((m) => m.code === signals.lang())?.label ?? signals.lang()
118
+ const message = [
119
+ `${t("settings.lang")}: ${langLabel}`,
120
+ `${t("settings.max")}: ${signals.maxEntries()}`,
121
+ `${t("settings.order")}: ${signals.sortOrder() === "desc" ? t("order.desc") : t("order.asc")}`,
122
+ `${t("settings.scroll")}: ${signals.scrollMode() === "wheel" ? t("scroll.wheel") : t("scroll.click")}`,
123
+ `${t("settings.ttl")}: ${ttl === 0 ? t("ttl.unlimited") : `${ttl}d`}`,
124
+ `${t("settings.border")}: ${onOff(signals.borderVisible())}`,
125
+ `${t("settings.showEntryCost")}: ${onOff(signals.showEntryCost())}`,
126
+ `${t("settings.showEntryTime")}: ${onOff(signals.showEntryTime())}`,
127
+ `${t("settings.showEntryTokens")}: ${onOff(signals.showEntryTokens())}`,
128
+ `${t("settings.timeFormat")}: ${TIME_FORMAT_SAMPLES[signals.timeFormat()]}`,
129
+ `${t("settings.dbSync")}: ${onOff(signals.dbSync())}`,
130
+ ].join("\n")
131
+ context.ui.toast.show({ title: t("settings.title"), message })
132
+ },
133
+ },
134
+ {
135
+ id: "opencode-subagent-magazine.subagent.sections",
136
+ title: "SubAgent Magazine: Settings Menu",
137
+ description: "Open the interactive settings menu (Esc to close)",
138
+ slash: { name: "subagent-sections" },
139
+ palette: true,
140
+ run: () => {
141
+ openSettingsMenu(context, api, signals)
142
+ },
143
+ },
144
+ {
145
+ id: "opencode-subagent-magazine.subagent.cost",
146
+ title: "SubAgent Magazine: Toggle Entry Cost",
147
+ description: "Show or hide the per-sub-agent cost in the sidebar",
148
+ slash: { name: "subagent-cost" },
149
+ palette: true,
150
+ run: () => {
151
+ const v = !signals.showEntryCost()
152
+ signals.setShowEntryCost(v)
153
+ kv.set(SETTING_KEYS.showEntryCost, v)
154
+ api.ui.toast(`${t("settings.showEntryCost")}: ${onOff(v)}`)
155
+ },
156
+ },
157
+ {
158
+ id: "opencode-subagent-magazine.subagent.time",
159
+ title: "SubAgent Magazine: Toggle Entry Time",
160
+ description: "Show or hide the elapsed time in the sidebar list",
161
+ slash: { name: "subagent-time" },
162
+ palette: true,
163
+ run: () => {
164
+ const v = !signals.showEntryTime()
165
+ signals.setShowEntryTime(v)
166
+ kv.set(SETTING_KEYS.showEntryTime, v)
167
+ api.ui.toast(`${t("settings.showEntryTime")}: ${onOff(v)}`)
168
+ },
169
+ },
170
+ {
171
+ id: "opencode-subagent-magazine.subagent.time-format",
172
+ title: "SubAgent Magazine: Time Format",
173
+ description: "Set how elapsed time is displayed (short / decimal / clock / compact / seconds)",
174
+ slash: { name: "subagent-time-format" },
175
+ palette: true,
176
+ run: async () => {
177
+ const picked = await context.ui.dialog.select<TimeFormat>({
178
+ title: t("settings.timeFormat"),
179
+ options: TIME_FORMATS.map((f) => ({ title: `${f} — ${TIME_FORMAT_SAMPLES[f]}`, value: f })),
180
+ current: signals.timeFormat(),
181
+ })
182
+ if (!picked) return
183
+ signals.setTimeFormat(picked)
184
+ kv.set(SETTING_KEYS.timeFormat, picked)
185
+ api.ui.toast(`${t("settings.timeFormat")}: ${TIME_FORMAT_SAMPLES[picked]}`)
186
+ },
187
+ },
188
+ {
189
+ id: "opencode-subagent-magazine.subagent.tokens",
190
+ title: "SubAgent Magazine: Toggle Entry Tokens",
191
+ description: "Show or hide the token count in the sidebar list",
192
+ slash: { name: "subagent-tokens" },
193
+ palette: true,
194
+ run: () => {
195
+ const v = !signals.showEntryTokens()
196
+ signals.setShowEntryTokens(v)
197
+ kv.set(SETTING_KEYS.showEntryTokens, v)
198
+ api.ui.toast(`${t("settings.showEntryTokens")}: ${onOff(v)}`)
199
+ },
200
+ },
201
+ {
202
+ id: "opencode-subagent-magazine.subagent.db",
203
+ title: "SubAgent Magazine: Toggle Local DB",
204
+ description: "Enrich sub-agent entries from the local OpenCode database (V2)",
205
+ slash: { name: "subagent-db" },
206
+ palette: true,
207
+ run: () => {
208
+ const v = !signals.dbSync()
209
+ signals.setDbSync(v)
210
+ kv.set(SETTING_KEYS.dbSync, v)
211
+ api.ui.toast(`${t("settings.dbSync")}: ${onOff(v)}`)
212
+ },
213
+ },
101
214
  {
102
215
  id: "opencode-subagent-magazine.subagent.version",
103
216
  title: "SubAgent Magazine: Version",
@@ -140,28 +253,49 @@ export function makeCommands(context: Context, api: PanelApi, signals: SharedSig
140
253
  }
141
254
  }
142
255
  if (count > 0) {
256
+ const ok = () => api.ui.toast(signals.lang() === "zh"
257
+ ? `已标记 ${count} 个运行中的条目为完成`
258
+ : `Marked ${count} running entries as done`)
259
+ const fail = () => {
260
+ try { api.ui.toast(t("clear.failed")) } catch {}
261
+ }
143
262
  try {
144
- const data = loadSessionData(kv)
145
263
  const { parentSid, isChild } = resolveParent(sid)
146
- if (isChild) {
147
- if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
148
- if (!data[parentSid].children) data[parentSid].children = {}
149
- if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
150
- data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] }
151
- } else {
152
- data[sid] = {
153
- ts: Date.now(),
154
- entries: [...entries.values()],
155
- scroll: data[sid]?.scroll ?? 0,
156
- expanded: data[sid]?.expanded ?? "",
157
- children: data[sid]?.children ?? {},
264
+ const done = updateSessionData(kv, (data) => {
265
+ // 落定前先把(可能过期的)模块缓存与持久化记录
266
+ // merge:其他实例可能有本实例从未见过的条目,
267
+ // 这次写入不能把它们丢掉。
268
+ if (isChild) {
269
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
270
+ if (!data[parentSid].children) data[parentSid].children = {}
271
+ const prev = data[parentSid].children[sid]?.entries ?? []
272
+ const merged = [...mergeSubEntries(prev, entries.values()).values()].map((e) =>
273
+ e.status === "running" || e.status === "cancel_requested"
274
+ ? { ...e, status: "done" as SubStatus, endedAt: e.endedAt ?? Date.now() }
275
+ : e
276
+ )
277
+ data[parentSid].children[sid] = {
278
+ ...(data[parentSid].children[sid] ?? { scroll: 0, expanded: "" }),
279
+ entries: merged,
280
+ }
281
+ } else {
282
+ const rec = data[parentSid] ?? { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
283
+ const merged = [...mergeSubEntries(rec.entries ?? [], entries.values()).values()].map((e) =>
284
+ e.status === "running" || e.status === "cancel_requested"
285
+ ? { ...e, status: "done" as SubStatus, endedAt: e.endedAt ?? Date.now() }
286
+ : e
287
+ )
288
+ data[parentSid] = { ...rec, ts: Date.now(), entries: merged, children: rec.children ?? {} }
158
289
  }
290
+ })
291
+ if (done && typeof (done as Promise<void>).then === "function") {
292
+ void (done as Promise<void>).then(ok, fail)
293
+ } else {
294
+ ok()
159
295
  }
160
- saveSessionData(kv, data)
161
- } catch {}
162
- api.ui.toast(signals.lang() === "zh"
163
- ? `已标记 ${count} 个运行中的条目为完成`
164
- : `Marked ${count} running entries as done`)
296
+ } catch {
297
+ fail()
298
+ }
165
299
  } else {
166
300
  api.ui.toast(signals.lang() === "zh" ? "没有需要清理的运行中条目" : "No running entries to clear")
167
301
  }
@@ -216,32 +350,42 @@ export function makeCommands(context: Context, api: PanelApi, signals: SharedSig
216
350
  })
217
351
  if (choice !== "yes") return
218
352
  try {
219
- const data = loadSessionData(kv)
220
353
  let count = 0
221
- if (parentID) {
222
- if (data[parentID]?.children?.[sid]) {
223
- const child = data[parentID].children[sid]
224
- const ids = child.entries?.map((e) => e.id) ?? []
225
- count = ids.length
226
- child.entries = []
227
- child.scroll = 0
228
- child.expanded = ""
229
- child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])]
354
+ const finish = () => {
355
+ globalEntryCache.delete(sid)
356
+ setClearTick((v) => v + 1)
357
+ api.ui.toast(t("clear.done", { n: count }))
358
+ }
359
+ const done = updateSessionData(kv, (data) => {
360
+ if (parentID) {
361
+ const child = data[parentID]?.children?.[sid]
362
+ if (child) {
363
+ const ids = child.entries?.map((e) => e.id) ?? []
364
+ count = ids.length
365
+ child.entries = []
366
+ child.scroll = 0
367
+ child.expanded = ""
368
+ child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])]
369
+ }
370
+ } else {
371
+ const rec = data[sid]
372
+ if (rec) {
373
+ const ids = rec.entries?.map((e) => e.id) ?? []
374
+ count = ids.length
375
+ rec.entries = []
376
+ rec.scroll = 0
377
+ rec.expanded = ""
378
+ rec.clearedIds = [...new Set([...(rec.clearedIds ?? []), ...ids])]
379
+ }
230
380
  }
381
+ })
382
+ if (done && typeof (done as Promise<void>).then === "function") {
383
+ void (done as Promise<void>).then(finish, () => {
384
+ try { api.ui.toast(t("clear.failed")) } catch {}
385
+ })
231
386
  } else {
232
- count = data[sid]?.entries?.length ?? 0
233
- if (data[sid]) {
234
- const ids = data[sid].entries?.map((e) => e.id) ?? []
235
- data[sid].entries = []
236
- data[sid].scroll = 0
237
- data[sid].expanded = ""
238
- data[sid].clearedIds = [...new Set([...(data[sid].clearedIds ?? []), ...ids])]
239
- }
387
+ finish()
240
388
  }
241
- saveSessionData(kv, data)
242
- globalEntryCache.delete(sid)
243
- setClearTick((v) => v + 1)
244
- api.ui.toast(t("clear.done", { n: count }))
245
389
  } catch {}
246
390
  },
247
391
  },
package/src/v2/db.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * 本地 opencode.db 只读索引(V2)。
3
+ *
4
+ * 宿主数据层只为当前 TUI 已加载的会话缓存消息:前台子代理(非 background)
5
+ * 的 success 事件经常拿不到 metadata.sessionID,条目会一直停在 running,
6
+ * model/tokens/cost 也无法回填。这些数据在宿主数据库里是完整的:
7
+ * - 父会话 assistant 消息中,工具 part 的 state.metadata.sessionID 指向子会话(前台/后台都有);
8
+ * - session_v2 保存子会话的 agent/model/tokens/cost 与 time_idle/idle_outcome。
9
+ *
10
+ * 本模块以只读方式查询该库作为补充数据源;bun:sqlite 不可用、库不存在或
11
+ * 设置项 dbSync 关闭时静默禁用(回退宿主 API)。库结构属于宿主内部实现,
12
+ * 任何读取失败都返回 undefined,绝不影响面板其余功能。
13
+ */
14
+
15
+ import { homedir } from "node:os"
16
+
17
+ export interface ChildSessionInfo {
18
+ id: string
19
+ parentId?: string
20
+ agent?: string
21
+ title?: string
22
+ model?: string
23
+ cost?: number
24
+ tokens?: number
25
+ timeCreated?: number
26
+ timeIdle?: number
27
+ idleOutcome?: string
28
+ }
29
+
30
+ export interface SessionDbIndex {
31
+ /** 数据库可用且设置项开启时为 true。 */
32
+ enabled(): boolean
33
+ /** call id → 子会话 id(前台/后台通用)。 */
34
+ resolveCall(parentId: string, callId: string): string | undefined
35
+ /** 无 call id 时的兜底:按 agent + 起始时间匹配子会话。 */
36
+ matchChild(parentId: string, agent: string | undefined, startedAt: number | undefined): string | undefined
37
+ /** 子会话汇总(model/tokens/cost/time_idle)。 */
38
+ info(sid: string): ChildSessionInfo | undefined
39
+ }
40
+
41
+ type SqlRow = Record<string, unknown>
42
+ interface SqlStatement {
43
+ all(...params: unknown[]): SqlRow[]
44
+ get(...params: unknown[]): SqlRow | undefined
45
+ }
46
+ interface SqlDatabase {
47
+ query(sql: string): SqlStatement
48
+ close?(): void
49
+ }
50
+
51
+ // 动态加载:仅 Bun 运行时可用。字符串变量使 tsc/esbuild 不做静态解析,
52
+ // 打包产物保留运行时 import;Node(CI 测试)加载失败会静默禁用。
53
+ const BUN_SQLITE = "bun:sqlite"
54
+
55
+ /** 从宿主 model 字段提取模型 id。
56
+ * 兼容三种形态:名字字符串、{ id } 对象,以及序列化后的 JSON 字符串
57
+ * (session_v2.model 列存的就是 `{"id":...}` 文本)。 */
58
+ export function modelIdOf(value: unknown): string | undefined {
59
+ if (typeof value === "string") {
60
+ const s = value.trim()
61
+ if (s.length === 0) return undefined
62
+ if (s.startsWith("{")) {
63
+ try { return modelIdOf(JSON.parse(s)) } catch { return undefined }
64
+ }
65
+ return s
66
+ }
67
+ if (value && typeof value === "object") {
68
+ const id = (value as { id?: unknown }).id
69
+ if (id === undefined) return undefined
70
+ const direct = modelIdOf(id)
71
+ if (direct !== undefined) return direct
72
+ const s = String(id)
73
+ return s.length > 0 ? s : undefined
74
+ }
75
+ return undefined
76
+ }
77
+
78
+ /** 宿主 readSessionTokens 同语义:最后一条 assistant 消息的上下文规模。 */
79
+ export function contextTokens(tokens: unknown): number | undefined {
80
+ if (!tokens || typeof tokens !== "object") return undefined
81
+ const t = tokens as Record<string, unknown>
82
+ const cache = (t.cache && typeof t.cache === "object") ? (t.cache as Record<string, unknown>) : {}
83
+ const sum =
84
+ (Number(t.input) || 0) +
85
+ (Number(t.output) || 0) +
86
+ (Number(t.reasoning) || 0) +
87
+ (Number(cache.read) || 0) +
88
+ (Number(cache.write) || 0)
89
+ return sum > 0 ? sum : undefined
90
+ }
91
+
92
+ /** 子会话终态:time_idle 存在才算结束;interrupted 视为取消。
93
+ * 没有 time_idle 时不作判断(交给宿主实时状态)——避免时间阈值猜测。 */
94
+ export function statusOfChild(info: ChildSessionInfo | undefined): "done" | "cancelled" | undefined {
95
+ if (!info || info.timeIdle == null) return undefined
96
+ return info.idleOutcome === "interrupted" ? "cancelled" : "done"
97
+ }
98
+
99
+ /** 定位宿主数据库(可用 OPENCODE_DB 覆盖,遵循 XDG_DATA_HOME)。 */
100
+ export function findDbPath(
101
+ env: Record<string, string | undefined> = process.env,
102
+ home = safeHomedir(),
103
+ ): string | undefined {
104
+ if (env.OPENCODE_DB) return env.OPENCODE_DB
105
+ const base = env.XDG_DATA_HOME && env.XDG_DATA_HOME.length > 0 ? env.XDG_DATA_HOME : `${home}/.local/share`
106
+ if (!base || base === "/.local/share") return undefined
107
+ return `${base}/opencode/opencode.db`
108
+ }
109
+
110
+ function safeHomedir(): string {
111
+ try { return homedir() } catch { return "" }
112
+ }
113
+
114
+ const CALL_MAP_SQL = `
115
+ SELECT json_extract(p.value, '$.id') AS call_id,
116
+ json_extract(p.value, '$.state.metadata.sessionID') AS child_sid
117
+ FROM session_message m, json_each(m.data, '$.content') p
118
+ WHERE m.session_id = ? AND m.type = 'assistant'
119
+ AND json_extract(p.value, '$.id') LIKE 'call_%'
120
+ AND json_extract(p.value, '$.state.metadata.sessionID') IS NOT NULL`
121
+
122
+ const CHILD_INFO_SQL = `
123
+ SELECT parent_id, agent, title, model, cost,
124
+ tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
125
+ time_created, time_idle, idle_outcome
126
+ FROM session_v2 WHERE id = ?`
127
+
128
+ const LAST_ASSISTANT_SQL = `
129
+ SELECT json_extract(data, '$.tokens.input') AS input,
130
+ json_extract(data, '$.tokens.output') AS output,
131
+ json_extract(data, '$.tokens.reasoning') AS reasoning,
132
+ json_extract(data, '$.tokens.cache.read') AS cache_read,
133
+ json_extract(data, '$.tokens.cache.write') AS cache_write,
134
+ json_extract(data, '$.model.id') AS model_id
135
+ FROM session_message
136
+ WHERE session_id = ? AND type = 'assistant' AND json_extract(data, '$.tokens.output') > 0
137
+ ORDER BY seq DESC LIMIT 1`
138
+
139
+ const ASSISTANT_COST_SQL = `
140
+ SELECT COUNT(*) AS n, SUM(COALESCE(json_extract(data, '$.cost'), 0)) AS total
141
+ FROM session_message WHERE session_id = ? AND type = 'assistant'`
142
+
143
+ const CHILDREN_SQL = `
144
+ SELECT id, agent, time_created FROM session_v2 WHERE parent_id = ? ORDER BY time_created`
145
+
146
+ const num = (v: unknown): number | undefined => {
147
+ const n = Number(v)
148
+ return Number.isFinite(n) ? n : undefined
149
+ }
150
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined)
151
+
152
+ export function createSessionDbIndex(enabled: () => boolean): SessionDbIndex | undefined {
153
+ const dbPath = findDbPath()
154
+ if (!dbPath) return undefined
155
+
156
+ let db: SqlDatabase | undefined
157
+ let failed = false
158
+ void (async () => {
159
+ try {
160
+ const mod = (await import(BUN_SQLITE)) as { Database?: new (p: string, o?: Record<string, unknown>) => SqlDatabase }
161
+ const Database = mod?.Database
162
+ if (!Database) { failed = true; return }
163
+ db = new Database(dbPath, { readonly: true })
164
+ try { db.query("pragma query_only = on").all() } catch {}
165
+ } catch {
166
+ failed = true
167
+ db = undefined
168
+ }
169
+ })()
170
+
171
+ const CALL_MAP_TTL_MS = 15000
172
+ const CALL_MAP_RETRY_MS = 2000
173
+ const INFO_TTL_MS = 5000
174
+ const callMaps = new Map<string, { map: Map<string, string>; at: number }>()
175
+ const infos = new Map<string, { info: ChildSessionInfo | undefined; at: number }>()
176
+ const childrenCache = new Map<string, { list: SqlRow[]; at: number }>()
177
+
178
+ const ready = (): boolean => {
179
+ if (failed || db === undefined) return false
180
+ try { return enabled() } catch { return false }
181
+ }
182
+
183
+ const buildCallMap = (parentId: string): Map<string, string> => {
184
+ const now = Date.now()
185
+ const prev = callMaps.get(parentId)
186
+ if (prev && now - prev.at < CALL_MAP_RETRY_MS) return prev.map
187
+ const map = new Map<string, string>()
188
+ try {
189
+ for (const row of db!.query(CALL_MAP_SQL).all(parentId)) {
190
+ const id = str(row.call_id)
191
+ const sid = str(row.child_sid)
192
+ if (id && sid) map.set(id, sid)
193
+ }
194
+ } catch {}
195
+ callMaps.set(parentId, { map, at: now })
196
+ return map
197
+ }
198
+
199
+ const resolveCall = (parentId: string, callId: string): string | undefined => {
200
+ if (!ready() || !parentId || !callId) return undefined
201
+ const prev = callMaps.get(parentId)
202
+ const map = prev && Date.now() - prev.at < CALL_MAP_TTL_MS ? prev.map : buildCallMap(parentId)
203
+ return map.get(callId) ?? buildCallMap(parentId).get(callId)
204
+ }
205
+
206
+ const matchChild = (parentId: string, agent: string | undefined, startedAt: number | undefined): string | undefined => {
207
+ if (!ready() || !parentId || startedAt === undefined) return undefined
208
+ const now = Date.now()
209
+ let cached = childrenCache.get(parentId)
210
+ if (!cached || now - cached.at > CALL_MAP_TTL_MS) {
211
+ let list: SqlRow[] = []
212
+ try { list = db!.query(CHILDREN_SQL).all(parentId) } catch {}
213
+ cached = { list, at: now }
214
+ childrenCache.set(parentId, cached)
215
+ }
216
+ let best: string | undefined
217
+ let bestAt = Number.POSITIVE_INFINITY
218
+ for (const row of cached.list) {
219
+ const sid = str(row.id)
220
+ const t = num(row.time_created)
221
+ if (!sid || t === undefined) continue
222
+ if (t < startedAt - 2000) continue
223
+ if (agent !== undefined && str(row.agent) !== agent) continue
224
+ if (t < bestAt) { bestAt = t; best = sid }
225
+ }
226
+ return best
227
+ }
228
+
229
+ const info = (sid: string): ChildSessionInfo | undefined => {
230
+ if (!sid) return undefined
231
+ const hit = infos.get(sid)
232
+ if (hit && Date.now() - hit.at < INFO_TTL_MS) return hit.info
233
+ if (!ready()) return undefined
234
+ let result: ChildSessionInfo | undefined
235
+ try {
236
+ const row = db!.query(CHILD_INFO_SQL).get(sid)
237
+ if (row) {
238
+ const last = db!.query(LAST_ASSISTANT_SQL).get(sid)
239
+ let tokens = contextTokens(last ? {
240
+ input: last.input, output: last.output, reasoning: last.reasoning,
241
+ cache: { read: last.cache_read, write: last.cache_write },
242
+ } : undefined)
243
+ if (tokens === undefined) {
244
+ const agg = (num(row.tokens_input) || 0) + (num(row.tokens_output) || 0) + (num(row.tokens_reasoning) || 0) +
245
+ (num(row.tokens_cache_read) || 0) + (num(row.tokens_cache_write) || 0)
246
+ if (agg > 0) tokens = agg
247
+ }
248
+ // cost 语义对齐宿主 readSessionCost:优先会话聚合值,
249
+ // 0 时回退 assistant 消息求和(有消息即权威,0 表示确实免费)。
250
+ let cost = num(row.cost)
251
+ if (!(typeof cost === "number" && cost > 0)) {
252
+ const aggCost = db!.query(ASSISTANT_COST_SQL).get(sid)
253
+ if (aggCost && Number(aggCost.n) > 0) cost = Number(aggCost.total) || 0
254
+ }
255
+ result = {
256
+ id: sid,
257
+ parentId: str(row.parent_id),
258
+ agent: str(row.agent),
259
+ title: str(row.title),
260
+ model: modelIdOf(last?.model_id) ?? modelIdOf(row.model),
261
+ cost,
262
+ tokens,
263
+ timeCreated: num(row.time_created),
264
+ timeIdle: num(row.time_idle),
265
+ idleOutcome: str(row.idle_outcome),
266
+ }
267
+ }
268
+ } catch { result = undefined }
269
+ infos.set(sid, { info: result, at: Date.now() })
270
+ return result
271
+ }
272
+
273
+ return { enabled: ready, resolveCall, matchChild, info }
274
+ }