opencode-subagent-magazine 1.6.1 → 1.7.1

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/v2/db.ts ADDED
@@ -0,0 +1,346 @@
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
+ /** 仍在运行:最后一条消息晚于 time_idle(会话被恢复时 time_idle 不会被清空)。 */
29
+ active?: boolean
30
+ }
31
+
32
+ export interface SessionDbIndex {
33
+ /** 数据库可用且设置项开启时为 true。 */
34
+ enabled(): boolean
35
+ /** call id → 子会话 id(前台/后台通用)。 */
36
+ resolveCall(parentId: string, callId: string): string | undefined
37
+ /** 无 call id 时的兜底:按 agent + 起始时间匹配子会话。 */
38
+ matchChild(parentId: string, agent: string | undefined, startedAt: number | undefined): string | undefined
39
+ /** 子会话汇总(model/tokens/cost/time_idle)。 */
40
+ info(sid: string): ChildSessionInfo | undefined
41
+ /** 该会话派生的子会话(spawn),用于补全没有工具条目的衍生会话。 */
42
+ children(parentId: string): ChildSessionInfo[] | undefined
43
+ }
44
+
45
+ type SqlRow = Record<string, unknown>
46
+ interface SqlStatement {
47
+ all(...params: unknown[]): SqlRow[]
48
+ get(...params: unknown[]): SqlRow | undefined
49
+ }
50
+ interface SqlDatabase {
51
+ query(sql: string): SqlStatement
52
+ close?(): void
53
+ }
54
+
55
+ // 动态加载:仅 Bun 运行时可用。字符串变量使 tsc/esbuild 不做静态解析,
56
+ // 打包产物保留运行时 import;Node(CI 测试)加载失败会静默禁用。
57
+ const BUN_SQLITE = "bun:sqlite"
58
+
59
+ /** 从宿主 model 字段提取模型 id。
60
+ * 兼容三种形态:名字字符串、{ id } 对象,以及序列化后的 JSON 字符串
61
+ * (session_v2.model 列存的就是 `{"id":...}` 文本)。 */
62
+ export function modelIdOf(value: unknown): string | undefined {
63
+ if (typeof value === "string") {
64
+ const s = value.trim()
65
+ if (s.length === 0) return undefined
66
+ if (s.startsWith("{")) {
67
+ try { return modelIdOf(JSON.parse(s)) } catch { return undefined }
68
+ }
69
+ return s
70
+ }
71
+ if (value && typeof value === "object") {
72
+ const id = (value as { id?: unknown }).id
73
+ if (id === undefined) return undefined
74
+ const direct = modelIdOf(id)
75
+ if (direct !== undefined) return direct
76
+ const s = String(id)
77
+ return s.length > 0 ? s : undefined
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ /** 宿主 readSessionTokens 同语义:最后一条 assistant 消息的上下文规模。 */
83
+ export function contextTokens(tokens: unknown): number | undefined {
84
+ if (!tokens || typeof tokens !== "object") return undefined
85
+ const t = tokens as Record<string, unknown>
86
+ const cache = (t.cache && typeof t.cache === "object") ? (t.cache as Record<string, unknown>) : {}
87
+ const sum =
88
+ (Number(t.input) || 0) +
89
+ (Number(t.output) || 0) +
90
+ (Number(t.reasoning) || 0) +
91
+ (Number(cache.read) || 0) +
92
+ (Number(cache.write) || 0)
93
+ return sum > 0 ? sum : undefined
94
+ }
95
+
96
+ /** 子会话是否仍在运行。
97
+ * 恢复过的会话会保留旧的 time_idle(host 不清空),因此以
98
+ * 「最后一条消息是否晚于 time_idle」为准;最后一条是 idle
99
+ * 消息则视为已停止。 */
100
+ export function isSessionActive(input: {
101
+ timeIdle?: number
102
+ lastMessageType?: string
103
+ lastMessageAt?: number
104
+ }): boolean {
105
+ const { timeIdle, lastMessageType, lastMessageAt } = input
106
+ if (lastMessageType === "idle") return false
107
+ if (lastMessageAt === undefined) return false
108
+ if (timeIdle === undefined) return true
109
+ return lastMessageAt > timeIdle
110
+ }
111
+
112
+ /** 子会话终态:time_idle 存在且没有恢复活动才算结束;interrupted 视为取消。
113
+ * 没有 time_idle 时不作判断(交给宿主实时状态)——避免时间阈值猜测。 */
114
+ export function statusOfChild(info: ChildSessionInfo | undefined): "done" | "cancelled" | undefined {
115
+ if (!info) return undefined
116
+ if (info.active === true) return undefined
117
+ if (info.timeIdle == null) return undefined
118
+ return info.idleOutcome === "interrupted" ? "cancelled" : "done"
119
+ }
120
+
121
+ /** 定位宿主数据库(可用 OPENCODE_DB 覆盖,遵循 XDG_DATA_HOME)。 */
122
+ export function findDbPath(
123
+ env: Record<string, string | undefined> = process.env,
124
+ home = safeHomedir(),
125
+ ): string | undefined {
126
+ if (env.OPENCODE_DB) return env.OPENCODE_DB
127
+ const base = env.XDG_DATA_HOME && env.XDG_DATA_HOME.length > 0 ? env.XDG_DATA_HOME : `${home}/.local/share`
128
+ if (!base || base === "/.local/share") return undefined
129
+ return `${base}/opencode/opencode.db`
130
+ }
131
+
132
+ function safeHomedir(): string {
133
+ try { return homedir() } catch { return "" }
134
+ }
135
+
136
+ const CALL_MAP_SQL = `
137
+ SELECT json_extract(p.value, '$.id') AS call_id,
138
+ json_extract(p.value, '$.state.metadata.sessionID') AS child_sid
139
+ FROM session_message m, json_each(m.data, '$.content') p
140
+ WHERE m.session_id = ? AND m.type = 'assistant'
141
+ AND json_extract(p.value, '$.id') LIKE 'call_%'
142
+ AND json_extract(p.value, '$.state.metadata.sessionID') IS NOT NULL`
143
+
144
+ const CHILD_INFO_SQL = `
145
+ SELECT parent_id, agent, title, model, cost,
146
+ tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
147
+ time_created, time_idle, idle_outcome
148
+ FROM session_v2 WHERE id = ?`
149
+
150
+ const LAST_ASSISTANT_SQL = `
151
+ SELECT json_extract(data, '$.tokens.input') AS input,
152
+ json_extract(data, '$.tokens.output') AS output,
153
+ json_extract(data, '$.tokens.reasoning') AS reasoning,
154
+ json_extract(data, '$.tokens.cache.read') AS cache_read,
155
+ json_extract(data, '$.tokens.cache.write') AS cache_write,
156
+ json_extract(data, '$.model.id') AS model_id
157
+ FROM session_message
158
+ WHERE session_id = ? AND type = 'assistant' AND json_extract(data, '$.tokens.output') > 0
159
+ ORDER BY seq DESC LIMIT 1`
160
+
161
+ const LAST_MESSAGE_SQL = `
162
+ SELECT type, COALESCE(time_updated, time_created) AS at
163
+ FROM session_message WHERE session_id = ? ORDER BY seq DESC LIMIT 1`
164
+
165
+ const ASSISTANT_COST_SQL = `
166
+ SELECT COUNT(*) AS n, SUM(COALESCE(json_extract(data, '$.cost'), 0)) AS total
167
+ FROM session_message WHERE session_id = ? AND type = 'assistant'`
168
+
169
+ const CHILDREN_SQL = `
170
+ SELECT id, agent, title, model, cost,
171
+ tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
172
+ time_created, time_idle, idle_outcome
173
+ FROM session_v2
174
+ WHERE parent_id = ? AND (fork_session_id IS NULL OR fork_session_id = '')
175
+ ORDER BY time_created`
176
+
177
+ const num = (v: unknown): number | undefined => {
178
+ const n = Number(v)
179
+ return Number.isFinite(n) ? n : undefined
180
+ }
181
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined)
182
+
183
+ export function createSessionDbIndex(enabled: () => boolean): SessionDbIndex | undefined {
184
+ const dbPath = findDbPath()
185
+ if (!dbPath) return undefined
186
+
187
+ let db: SqlDatabase | undefined
188
+ let failed = false
189
+ void (async () => {
190
+ try {
191
+ const mod = (await import(BUN_SQLITE)) as { Database?: new (p: string, o?: Record<string, unknown>) => SqlDatabase }
192
+ const Database = mod?.Database
193
+ if (!Database) { failed = true; return }
194
+ db = new Database(dbPath, { readonly: true })
195
+ try { db.query("pragma query_only = on").all() } catch {}
196
+ } catch {
197
+ failed = true
198
+ db = undefined
199
+ }
200
+ })()
201
+
202
+ const CALL_MAP_TTL_MS = 15000
203
+ const CALL_MAP_RETRY_MS = 2000
204
+ const INFO_TTL_MS = 5000
205
+ const callMaps = new Map<string, { map: Map<string, string>; at: number }>()
206
+ const infos = new Map<string, { info: ChildSessionInfo | undefined; at: number }>()
207
+ const childrenCache = new Map<string, { list: SqlRow[]; at: number }>()
208
+
209
+ const ready = (): boolean => {
210
+ if (failed || db === undefined) return false
211
+ try { return enabled() } catch { return false }
212
+ }
213
+
214
+ const buildCallMap = (parentId: string): Map<string, string> => {
215
+ const now = Date.now()
216
+ const prev = callMaps.get(parentId)
217
+ if (prev && now - prev.at < CALL_MAP_RETRY_MS) return prev.map
218
+ const map = new Map<string, string>()
219
+ try {
220
+ for (const row of db!.query(CALL_MAP_SQL).all(parentId)) {
221
+ const id = str(row.call_id)
222
+ const sid = str(row.child_sid)
223
+ if (id && sid) map.set(id, sid)
224
+ }
225
+ } catch {}
226
+ callMaps.set(parentId, { map, at: now })
227
+ return map
228
+ }
229
+
230
+ const resolveCall = (parentId: string, callId: string): string | undefined => {
231
+ if (!ready() || !parentId || !callId) return undefined
232
+ const prev = callMaps.get(parentId)
233
+ const map = prev && Date.now() - prev.at < CALL_MAP_TTL_MS ? prev.map : buildCallMap(parentId)
234
+ return map.get(callId) ?? buildCallMap(parentId).get(callId)
235
+ }
236
+
237
+ const matchChild = (parentId: string, agent: string | undefined, startedAt: number | undefined): string | undefined => {
238
+ if (!ready() || !parentId || startedAt === undefined) return undefined
239
+ const now = Date.now()
240
+ let cached = childrenCache.get(parentId)
241
+ if (!cached || now - cached.at > CALL_MAP_TTL_MS) {
242
+ let list: SqlRow[] = []
243
+ try { list = db!.query(CHILDREN_SQL).all(parentId) } catch {}
244
+ cached = { list, at: now }
245
+ childrenCache.set(parentId, cached)
246
+ }
247
+ let best: string | undefined
248
+ let bestAt = Number.POSITIVE_INFINITY
249
+ for (const row of cached.list) {
250
+ const sid = str(row.id)
251
+ const t = num(row.time_created)
252
+ if (!sid || t === undefined) continue
253
+ if (t < startedAt - 2000) continue
254
+ if (agent !== undefined && str(row.agent) !== agent) continue
255
+ if (t < bestAt) { bestAt = t; best = sid }
256
+ }
257
+ return best
258
+ }
259
+
260
+ const info = (sid: string): ChildSessionInfo | undefined => {
261
+ if (!sid) return undefined
262
+ const hit = infos.get(sid)
263
+ if (hit && Date.now() - hit.at < INFO_TTL_MS) return hit.info
264
+ if (!ready()) return undefined
265
+ let result: ChildSessionInfo | undefined
266
+ try {
267
+ const row = db!.query(CHILD_INFO_SQL).get(sid)
268
+ if (row) {
269
+ const last = db!.query(LAST_ASSISTANT_SQL).get(sid)
270
+ const lastMsg = db!.query(LAST_MESSAGE_SQL).get(sid)
271
+ let tokens = contextTokens(last ? {
272
+ input: last.input, output: last.output, reasoning: last.reasoning,
273
+ cache: { read: last.cache_read, write: last.cache_write },
274
+ } : undefined)
275
+ if (tokens === undefined) {
276
+ const agg = (num(row.tokens_input) || 0) + (num(row.tokens_output) || 0) + (num(row.tokens_reasoning) || 0) +
277
+ (num(row.tokens_cache_read) || 0) + (num(row.tokens_cache_write) || 0)
278
+ if (agg > 0) tokens = agg
279
+ }
280
+ // cost 语义对齐宿主 readSessionCost:优先会话聚合值,
281
+ // 0 时回退 assistant 消息求和(有消息即权威,0 表示确实免费)。
282
+ let cost = num(row.cost)
283
+ if (!(typeof cost === "number" && cost > 0)) {
284
+ const aggCost = db!.query(ASSISTANT_COST_SQL).get(sid)
285
+ if (aggCost && Number(aggCost.n) > 0) cost = Number(aggCost.total) || 0
286
+ }
287
+ result = {
288
+ id: sid,
289
+ parentId: str(row.parent_id),
290
+ agent: str(row.agent),
291
+ title: str(row.title),
292
+ model: modelIdOf(last?.model_id) ?? modelIdOf(row.model),
293
+ cost,
294
+ tokens,
295
+ timeCreated: num(row.time_created),
296
+ timeIdle: num(row.time_idle),
297
+ idleOutcome: str(row.idle_outcome),
298
+ active: isSessionActive({
299
+ timeIdle: num(row.time_idle),
300
+ lastMessageType: str(lastMsg?.type),
301
+ lastMessageAt: num(lastMsg?.at),
302
+ }),
303
+ }
304
+ }
305
+ } catch { result = undefined }
306
+ infos.set(sid, { info: result, at: Date.now() })
307
+ return result
308
+ }
309
+
310
+ const children = (parentId: string): ChildSessionInfo[] | undefined => {
311
+ if (!ready() || !parentId) return undefined
312
+ const now = Date.now()
313
+ let cached = childrenCache.get(parentId)
314
+ if (!cached || now - cached.at > CALL_MAP_TTL_MS) {
315
+ let list: SqlRow[] = []
316
+ try { list = db!.query(CHILDREN_SQL).all(parentId) } catch {}
317
+ cached = { list, at: now }
318
+ childrenCache.set(parentId, cached)
319
+ }
320
+ const out: ChildSessionInfo[] = []
321
+ for (const row of cached.list) {
322
+ const sid = str(row.id)
323
+ if (!sid) continue
324
+ const full = info(sid)
325
+ if (full) { out.push(full); continue }
326
+ // info 缓存未命中时不丢字段:用列表行自身的汇总兜底。
327
+ const agg = (num(row.tokens_input) || 0) + (num(row.tokens_output) || 0) + (num(row.tokens_reasoning) || 0) +
328
+ (num(row.tokens_cache_read) || 0) + (num(row.tokens_cache_write) || 0)
329
+ out.push({
330
+ id: sid,
331
+ parentId,
332
+ agent: str(row.agent),
333
+ title: str(row.title),
334
+ model: modelIdOf(row.model),
335
+ cost: num(row.cost),
336
+ tokens: agg > 0 ? agg : undefined,
337
+ timeCreated: num(row.time_created),
338
+ timeIdle: num(row.time_idle),
339
+ idleOutcome: str(row.idle_outcome),
340
+ })
341
+ }
342
+ return out
343
+ }
344
+
345
+ return { enabled: ready, resolveCall, matchChild, info, children }
346
+ }
package/src/v2/index.tsx CHANGED
@@ -7,22 +7,31 @@ import { makeCommands } from "./commands"
7
7
  import { mapTheme } from "./theme"
8
8
  import { SubAgentPanel } from "../panel/SubAgentPanel"
9
9
  import type { PanelApi } from "../panel/panel-api"
10
- import type { Lang, SortOrder, ScrollMode, SharedSignals } from "../core/types"
10
+ import type { Lang, SortOrder, ScrollMode, SharedSignals, TimeFormat } from "../core/types"
11
+ import { TIME_FORMATS } from "../core/format"
11
12
  import { SETTING_KEYS } from "../core/kv"
12
13
  import { LANG_META, detectLang } from "../i18n"
13
14
 
14
- /** 面板根组件:keymap.layer 必须在组件渲染上下文注册(setup 内调用报
15
- * Keymap.Provider missing),再渲染共享 SubAgentPanel。 */
16
- function PluginRoot(props: {
15
+ /** 命令层必须在 app 槽注册:侧栏隐藏(sidebar: auto)时命令仍需可用。 */
16
+ function CommandRoot(props: {
17
17
  context: Context
18
18
  api: PanelApi
19
19
  signals: SharedSignals
20
- sessionID: string
21
20
  }) {
22
21
  props.context.keymap.layer(() => ({
23
22
  mode: "global" as const,
24
23
  commands: makeCommands(props.context, props.api, props.signals),
25
24
  }))
25
+ return null
26
+ }
27
+
28
+ /** 面板根组件:渲染共享 SubAgentPanel(命令层见 CommandRoot)。 */
29
+ function PluginRoot(props: {
30
+ context: Context
31
+ api: PanelApi
32
+ signals: SharedSignals
33
+ sessionID: string
34
+ }) {
26
35
  return (
27
36
  <SubAgentPanel
28
37
  api={props.api}
@@ -32,6 +41,11 @@ function PluginRoot(props: {
32
41
  sortOrder={props.signals.sortOrder}
33
42
  scrollMode={props.signals.scrollMode}
34
43
  borderVisible={props.signals.borderVisible}
44
+ showEntryCost={props.signals.showEntryCost}
45
+ showEntryTime={props.signals.showEntryTime}
46
+ showEntryTokens={props.signals.showEntryTokens}
47
+ timeFormat={props.signals.timeFormat}
48
+ showOrigin={props.signals.showOrigin}
35
49
  sessionId={props.sessionID}
36
50
  />
37
51
  )
@@ -69,16 +83,49 @@ const mod: PluginModule & { server: () => Promise<Record<string, never>> } = {
69
83
  const [borderSignal, setBorderVisible] = createSignal<boolean>(
70
84
  (api.kv.get(SETTING_KEYS.border, false) as boolean) === true,
71
85
  )
86
+ const [showEntryCostSignal, setShowEntryCost] = createSignal<boolean>(
87
+ (api.kv.get(SETTING_KEYS.showEntryCost, false) as boolean) === true,
88
+ )
89
+ const [showEntryTimeSignal, setShowEntryTime] = createSignal<boolean>(
90
+ (api.kv.get(SETTING_KEYS.showEntryTime, true) as boolean) !== false,
91
+ )
92
+ const [showEntryTokensSignal, setShowEntryTokens] = createSignal<boolean>(
93
+ (api.kv.get(SETTING_KEYS.showEntryTokens, true) as boolean) !== false,
94
+ )
95
+ const storedTimeFormat = String(api.kv.get(SETTING_KEYS.timeFormat, "short"))
96
+ const [timeFormatSignal, setTimeFormat] = createSignal<TimeFormat>(
97
+ (TIME_FORMATS as readonly string[]).includes(storedTimeFormat) ? (storedTimeFormat as TimeFormat) : "short",
98
+ )
99
+ const [showOriginSignal, setShowOrigin] = createSignal<boolean>(
100
+ (api.kv.get(SETTING_KEYS.showOrigin, false) as boolean) === true,
101
+ )
102
+ const [dbSyncSignal, setDbSync] = createSignal<boolean>(
103
+ (api.kv.get(SETTING_KEYS.dbSync, true) as boolean) !== false,
104
+ )
72
105
  lang = langSignal
73
106
  maxEntries = maxSignal
74
107
  sortOrder = orderSignal
75
108
  scrollMode = scrollSignal
76
109
 
77
110
  const signals: SharedSignals = {
78
- lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible: borderSignal, setBorderVisible, sessionId: "",
111
+ lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible: borderSignal, setBorderVisible,
112
+ showEntryCost: showEntryCostSignal, setShowEntryCost,
113
+ showEntryTime: showEntryTimeSignal, setShowEntryTime,
114
+ showEntryTokens: showEntryTokensSignal, setShowEntryTokens,
115
+ timeFormat: timeFormatSignal, setTimeFormat,
116
+ showOrigin: showOriginSignal, setShowOrigin,
117
+ dbSync: dbSyncSignal, setDbSync,
118
+ sessionId: "",
79
119
  }
80
120
 
81
- // 命令 layer(组件内注册——见 PluginRoot)
121
+ // 命令层挂 app 槽:侧栏隐藏时斜杠命令仍需可用(见 CommandRoot)
122
+ context.ui.slot({
123
+ append: "app",
124
+ render: () => (
125
+ <CommandRoot context={context} api={api} signals={signals} />
126
+ ),
127
+ })
128
+
82
129
  // 侧边栏面板(共享 SubAgentPanel——V1/V2 同一组件)
83
130
  context.ui.slot({
84
131
  prepend: "sidebar.content",