dsh-token-total 1.1.3 → 1.1.5

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.
@@ -405,7 +405,8 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
405
405
  const billed = display.total
406
406
  // Same aggregate as the official pill total: every prompt-side billing bucket plus output.
407
407
  const total = billedTotal(billed)
408
- const totalText = t('message.turnUsage.count', { count: formatTokensCompact(total) })
408
+ const incomplete = display.coverage?.complete === false
409
+ const totalText = (incomplete ? '≥ ' : '') + t('message.turnUsage.count', { count: formatTokensCompact(total) })
409
410
  // Native formatting, one consistent scope: the ratio comes from the same
410
411
  // aggregate the headline uses, so "33M tok · 87%" can never contradict the
411
412
  // number printed beside it. The integer shape and its >99% guard are the
@@ -421,7 +422,8 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
421
422
  className={css.pill}
422
423
  aria-haspopup="dialog"
423
424
  aria-expanded={open}
424
- aria-label={cacheHitText === null ? totalText : totalText + ' · ' + cacheHitText}
425
+ aria-label={(incomplete ? t('stats.partial') + ' · ' : '') + (cacheHitText === null ? totalText : totalText + ' · ' + cacheHitText)}
426
+ title={incomplete ? t('stats.incomplete', { count: display.coverage!.unreadableSessions.length }) : undefined}
425
427
  onClick={() => { setOpen(!open) }}
426
428
  >
427
429
  <DatabaseIcon />
@@ -451,9 +453,14 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
451
453
  <DatabaseIcon />
452
454
  {t('stats.dialog.usageTitle')}
453
455
  </span>
454
- <span className={css.dialogTitleValue}>{exactCount(total)}</span>
456
+ <span className={css.dialogTitleValue}>{(incomplete ? '≥ ' : '') + exactCount(total)}</span>
455
457
  </div>
456
458
  <div className={css.dialogTitleRule} aria-hidden />
459
+ {incomplete && (
460
+ <p role="status" data-session-usage-partial>
461
+ {t('stats.incomplete', { count: display.coverage!.unreadableSessions.length })}
462
+ </p>
463
+ )}
457
464
  {/* Exactly the official dialog's rows, in the official order: the
458
465
  takeover adds the tree to the TOTAL, not to the breakdown. */}
459
466
  <dl className={css.dialogDetails} data-session-stats-usage>
@@ -509,7 +516,8 @@ export const StatsLineEnhanced = memo(function StatsLineEnhanced(
509
516
  // Refresh on main-agent settlements and poll for child-only activity.
510
517
  const tree = useSessionUsage(sessionId, usageSignature(usage, stats.steps))
511
518
  // A delegated-only session must still show its usage pill.
512
- const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
519
+ const display = sessionUsageForDisplay(usage, tree.tree)
520
+ const hasTokens = hasAnyTokens(display.total) || display.coverage?.complete === false
513
521
  if (stats.steps === 0 && !hasTokens) return null
514
522
  // data-composer-stats: through host 0.1.6-alpha.1 InputBar tightened the
515
523
  // composer bottom clearance while this row rendered (the official pills'
@@ -582,7 +590,8 @@ export const StatsUsageEntry = memo(function StatsUsageEntry(
582
590
  ) {
583
591
  const usage = useProjection('tokenUsage')
584
592
  const tree = useSessionUsage(sessionId, usageSignature(usage, usage === undefined ? 0 : 1))
585
- const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
593
+ const display = sessionUsageForDisplay(usage, tree.tree)
594
+ const hasTokens = hasAnyTokens(display.total) || display.coverage?.complete === false
586
595
  if (!hasTokens) return null
587
596
  return (
588
597
  <UsagePill
@@ -82,6 +82,8 @@ export type TokenTotalKey =
82
82
  | 'stats.cacheMiss'
83
83
  | 'stats.dialog.title'
84
84
  | 'stats.dialog.usageTitle'
85
+ | 'stats.partial'
86
+ | 'stats.incomplete'
85
87
  | 'stats.dialog.llmTime'
86
88
  | 'stats.dialog.toolTime'
87
89
  | 'stats.dialog.ttft'
@@ -170,6 +172,8 @@ export const en: Record<TokenTotalKey, string> = {
170
172
  'stats.cacheMiss': 'Cache miss',
171
173
  'stats.dialog.title': 'Time & speed',
172
174
  'stats.dialog.usageTitle': 'Token usage',
175
+ 'stats.partial': 'Partial usage',
176
+ 'stats.incomplete': '{count} subagent session(s) could not be read; totals are incomplete.',
173
177
  'stats.dialog.llmTime': 'LLM time',
174
178
  'stats.dialog.toolTime': 'Tool time',
175
179
  'stats.dialog.ttft': 'Mean TTFT',
@@ -259,6 +263,8 @@ export const zh: Record<TokenTotalKey, string> = {
259
263
  'stats.cacheMiss': '未命中缓存',
260
264
  'stats.dialog.title': '用时与速度',
261
265
  'stats.dialog.usageTitle': 'Token 用量',
266
+ 'stats.partial': '部分用量',
267
+ 'stats.incomplete': '有 {count} 个子会话暂不可读取,总计尚不完整。',
262
268
  'stats.dialog.llmTime': 'LLM 用时',
263
269
  'stats.dialog.toolTime': '工具用时',
264
270
  'stats.dialog.ttft': '首 token 平均',
@@ -348,6 +354,8 @@ export const zhTW: Record<TokenTotalKey, string> = {
348
354
  'stats.cacheMiss': '未命中快取',
349
355
  'stats.dialog.title': '用時與速度',
350
356
  'stats.dialog.usageTitle': 'Token 用量',
357
+ 'stats.partial': '部分用量',
358
+ 'stats.incomplete': '有 {count} 個子會話暫不可讀取,總計尚不完整。',
351
359
  'stats.dialog.llmTime': 'LLM 用時',
352
360
  'stats.dialog.toolTime': '工具用時',
353
361
  'stats.dialog.ttft': '首 token 平均',
@@ -35,6 +35,7 @@ export function sessionUsageForDisplay(
35
35
  subagents,
36
36
  total: tree === null ? self : combinedTotals(tree, self),
37
37
  subagentSessions: tree?.subagentSessions ?? 0,
38
+ ...(tree?.coverage ? { coverage: tree.coverage } : {}),
38
39
  }
39
40
  }
40
41
 
@@ -10,6 +10,7 @@ export const usageZh = {
10
10
  empty: '本会话还没有请求记录', noChildren: '暂无子代理', noModels: '暂无模型用量',
11
11
  scope: '本会话及所有子代理 · 历史日志累计',
12
12
  note: '统计包含本会话与全部后代的独立请求、重试和压缩调用,不重复计算继承历史。首 token 与速度仅使用已记录的时间信息,缺失时显示 —。',
13
+ partialCoverage: '有 {count} 个子会话暂不可读取。', partialTotals: '当前仅显示已读取用量,未将缺失用量视为零。',
13
14
  failedLoad: '用量读取失败,已保留上次结果。', retry: '重新读取', completed: '已完成', running: '进行中', failed: '失败', interrupted: '已中断',
14
15
  main: '主会话', turn: '轮', step: '步', started: '开始', duration: '耗时', firstDetail: '首 token',
15
16
  provider: '供应商', status: '状态', exact: '完整 token 数',
@@ -50,6 +51,7 @@ export const usageEn: Record<SessionUsageKey, string> = {
50
51
  empty: 'No requests in this session yet', noChildren: 'No subagents yet', noModels: 'No model usage yet',
51
52
  scope: 'This session and all subagents · Historical log totals',
52
53
  note: 'Includes independent requests, retries and compaction calls from this session and all descendants, without counting inherited history twice. Timing uses recorded timestamps only; missing values appear as —.',
54
+ partialCoverage: '{count} subagent session(s) could not be read.', partialTotals: 'Only readable usage is shown; missing usage is not treated as zero.',
53
55
  failedLoad: 'Could not read usage. The last successful result is preserved.', retry: 'Retry', completed: 'Completed', running: 'Running', failed: 'Failed', interrupted: 'Interrupted',
54
56
  main: 'Main session', turn: 'Turn', step: 'Step', started: 'Started', duration: 'Duration', firstDetail: 'First token',
55
57
  provider: 'Provider', status: 'Status', exact: 'Exact token count',
@@ -89,6 +91,7 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
89
91
  loading: '正在讀取會話用量…', empty: '本會話還沒有請求記錄', noChildren: '暫無子代理', noModels: '暫無模型用量',
90
92
  scope: '本會話及所有子代理 · 歷史日誌累計',
91
93
  note: '統計包含本會話與全部後代的獨立請求、重試和壓縮呼叫,不重複計算繼承歷史。首 token 與速度僅使用已記錄的時間資訊,缺失時顯示 —。',
94
+ partialCoverage: '有 {count} 個子會話暫不可讀取。', partialTotals: '目前僅顯示已讀取用量,未將缺失用量視為零。',
92
95
  failedLoad: '用量讀取失敗,已保留上次結果。', retry: '重新讀取', completed: '已完成', running: '進行中', failed: '失敗', interrupted: '已中斷',
93
96
  main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
94
97
  answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
@@ -1,12 +1,5 @@
1
- import type {
2
- Context,
3
- UsageSessionEvent,
4
- UsageSessionHandle,
5
- UsageSessionHeader,
6
- UsageSessionPersistence,
7
- UsageSessionSnapshot,
8
- UsageSessionStore,
9
- } from './context-types.ts'
1
+ import type { Context, UsageSessionHeader, UsageSessionPersistence, UsageSessionStore } from './context-types.ts'
2
+ import { readUsageTree, type UsageSessionLog } from './session-tree.ts'
10
3
  import { UsageFold } from './collector.ts'
11
4
  import { buildSessionContextWindow } from './context-window.ts'
12
5
  import { buildSessionUsageComposition, mergeUsageCompositions } from './usage-composition.ts'
@@ -83,22 +76,6 @@ function usageOutputTokens(x: any): number | null {
83
76
  const value = (x?.usage ?? x)?.outputTokens
84
77
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
85
78
  }
86
- function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
87
- const x: any = item
88
- if (x?.header?.id && typeof x.header.id === 'string') return x.header
89
- return typeof x?.id === 'string' && x.id ? x : null
90
- }
91
- async function read(p: UsageSessionPersistence, id: string, live?: UsageSessionHandle): Promise<{ events: readonly UsageSessionEvent[]; cut: number }> {
92
- if (live?.snapshotEvents && typeof live.inheritedEventCount === 'number') return { events: live.snapshotEvents(), cut: Math.max(0, live.inheritedEventCount) }
93
- if (p.open) {
94
- const h = await p.open(id, 'read'); const out: UsageSessionEvent[] = []
95
- try { for (let off = 0;;) { const page = await h.read(off, 500); if (!page.events.length) break; out.push(...page.events); off += page.events.length } }
96
- finally { await h.close().catch(() => {}) }
97
- return { events: out, cut: Math.max(0, Number(h.inheritedEventCount) || 0) }
98
- }
99
- if (p.inspect) { const x = await p.inspect(id); return { events: x.events, cut: Math.max(0, Number(x.inheritedEventCount) || 0) } }
100
- throw new Error('no readable persistence seam (need open or inspect)')
101
- }
102
79
 
103
80
  type MutableRecord = SessionUsageDetail['records'][number] & { _attempt: number; _lastUsage: SessionUsageTotals }
104
81
  function nameOf(h: UsageSessionHeader | undefined, id: string): string {
@@ -109,8 +86,12 @@ function nameOf(h: UsageSessionHeader | undefined, id: string): string {
109
86
  return id
110
87
  }
111
88
 
112
- async function one(p: UsageSessionPersistence, id: string, header: UsageSessionHeader | undefined, live: UsageSessionHandle | undefined) {
113
- const { events, cut } = await read(p, id, live)
89
+ function ownEventsOf(log: UsageSessionLog) {
90
+ return log.events.filter(e => !(log.inheritedCut > 0 && typeof e.seq === 'number' && e.seq < log.inheritedCut))
91
+ }
92
+
93
+ function one(log: UsageSessionLog, parentCatalogLabel?: string) {
94
+ const { id, header, live } = log
114
95
  const fold = new UsageFold(); const totals = zero(); const records: MutableRecord[] = []
115
96
  const byKey = new Map<string, MutableRecord>(); let route = live?.requestContext?.() ?? {}
116
97
  let ordinal = 0
@@ -119,18 +100,16 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
119
100
  // the prompt usually lands before `turn/start`, so buffer it until the turn opens.
120
101
  let currentTurn = 0; let effort = ''; let pendingTopic = ''
121
102
  const turnTopics = new Map<number, string>()
122
- const own = events.filter(e => !(cut > 0 && typeof e.seq === 'number' && e.seq < cut))
123
- // Real session names: a subagent carries its delegation label on its
124
- // `subagent/descriptor` event ("完成日志明细后端"), every session can carry a
125
- // generated `session/title`; only fall back to the header/cwd/id guesses.
103
+ const own = ownEventsOf(log)
104
+ // Prefer the child's own delegation label or latest title. Older CLI records
105
+ // carry their task name only in the actual parent's subagent/catalog.
126
106
  let label = '', title = ''
127
107
  for (const ev of own) {
128
108
  const d: any = ev.data ?? {}
129
109
  if (!label && ev.type === 'subagent/descriptor' && typeof d.label === 'string' && d.label.trim()) label = d.label.trim()
130
- if (!title && ev.type === 'session/title' && typeof d.title === 'string' && d.title.trim()) title = d.title.trim()
131
- if (label && title) break
110
+ if (ev.type === 'session/title' && typeof d.title === 'string' && d.title.trim()) title = d.title.trim()
132
111
  }
133
- const name = label || title || nameOf(header, id)
112
+ const name = label || title || parentCatalogLabel || nameOf(header, id)
134
113
  // Decode accounting mirrors the official `sessionStats` projection: a step
135
114
  // records its first token once (the earliest attempt wins, surviving an
136
115
  // in-step retry), and only the assembled `assistant/message` closes the
@@ -214,14 +193,24 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
214
193
  }
215
194
 
216
195
  export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
217
- const raw = await persistence.list(typeof persistence.open === 'function' ? undefined : undefined)
218
- const headers = raw.map(headerOf).filter((x): x is UsageSessionHeader => x !== null)
219
- const live = new Map((sessions?.list() ?? []).map(x => [x.id, x])); const children = new Map<string, string[]>()
220
- for (const h of headers) if (h.parentSession) children.set(h.parentSession, [...(children.get(h.parentSession) ?? []), h.id])
221
- for (const h of live.values()) { const p = h.header?.parentSession ?? h.parentSession; if (p) children.set(p, [...(children.get(p) ?? []).filter(x => x !== h.id), h.id]) }
222
- const ids: string[] = []; const seen = new Set([rootId]); const q = [rootId]
223
- while (q.length) for (const c of children.get(q.shift()!) ?? []) if (!seen.has(c)) { seen.add(c); ids.push(c); q.push(c) }
224
- const all = [rootId, ...ids]; const parts = await Promise.all(all.map(id => one(persistence, id, headers.find(h => h.id === id), live.get(id))))
196
+ const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId)
197
+ const logs = [root, ...descendants]
198
+ const catalogLabels = new Map<string, Map<string, string>>()
199
+ for (const log of logs) {
200
+ const labels = new Map<string, string>()
201
+ for (const event of ownEventsOf(log)) {
202
+ if (event.type !== 'subagent/catalog') continue
203
+ const data = event.data as { childId?: unknown; label?: unknown } | null
204
+ if (typeof data?.childId === 'string' && data.childId && typeof data.label === 'string' && data.label.trim()) {
205
+ labels.set(data.childId, data.label.trim())
206
+ }
207
+ }
208
+ catalogLabels.set(log.id, labels)
209
+ }
210
+ const parts = logs.map(log => {
211
+ const parentId = log.header?.parentSession ?? log.live?.header?.parentSession ?? log.live?.parentSession
212
+ return one(log, parentId === undefined ? undefined : catalogLabels.get(parentId)?.get(log.id))
213
+ })
225
214
  const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
226
215
  for (const r of records) { const key = r.model || '(unknown)'; const m = models.get(key) ?? { totals: zero(), requests: 0, providers: new Set<string>() }; add(m.totals, r.totals); m.requests++; if (r.provider) m.providers.add(r.provider); models.set(key, m) }
227
216
  const subagents = parts.slice(1).map(p => ({ sessionId: p.id, name: p.name, totals: p.totals, requests: p.requests, averageFirstTokenMs: p.averageFirstTokenMs, tokensPerSecond: p.tokensPerSecond }))
@@ -248,5 +237,5 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
248
237
  // The card is scoped to this session and every subagent, so its speed and
249
238
  // first-token average fold the same records its token totals do.
250
239
  const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
251
- return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null, composition: mergeUsageCompositions(parts.map(p => p.composition)) }
240
+ return { sessionId: rootId, coverage, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null, composition: mergeUsageCompositions(parts.map(p => p.composition)) }
252
241
  }
@@ -0,0 +1,146 @@
1
+ import type {
2
+ UsageSessionEvent,
3
+ UsageSessionHandle,
4
+ UsageSessionHeader,
5
+ UsageSessionPersistence,
6
+ UsageSessionSnapshot,
7
+ UsageSessionStore,
8
+ } from './context-types.ts'
9
+ import type { SessionUsageCoverage, SessionUsageReadIssue } from './wire.ts'
10
+
11
+ /** Shared membership, ownership and failure semantics for both usage APIs.
12
+ * A bad descendant is isolated; root, catalog and cancellation failures reject. */
13
+ export interface UsageSessionLog {
14
+ id: string
15
+ header?: UsageSessionHeader
16
+ live?: UsageSessionHandle
17
+ events: readonly UsageSessionEvent[]
18
+ inheritedCut: number
19
+ }
20
+
21
+ function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
22
+ if (!item || typeof item !== 'object') return null
23
+ const header = 'header' in item ? item.header : item
24
+ return header && typeof header.id === 'string' && header.id !== '' ? header : null
25
+ }
26
+
27
+ function inheritedCount(value: unknown): number {
28
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0
29
+ }
30
+
31
+ function throwIfCanceled(error: unknown, signal?: AbortSignal): void {
32
+ signal?.throwIfAborted()
33
+ if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') throw error
34
+ }
35
+
36
+ /** Never send raw exceptions to the client: they may contain paths or secrets. */
37
+ function readIssue(sessionId: string, error: unknown): SessionUsageReadIssue {
38
+ const name = error && typeof error === 'object' && 'name' in error ? error.name : ''
39
+ const code = error && typeof error === 'object' && 'code' in error ? error.code : ''
40
+ const reason = name === 'SessionPersistenceCorruptionError' || name === 'SessionFormatError'
41
+ ? 'corrupt'
42
+ : name === 'SessionPersistenceNotFoundError' || code === 'ENOENT' ? 'missing' : 'unavailable'
43
+ return { sessionId, reason }
44
+ }
45
+
46
+ async function readSession(
47
+ persistence: UsageSessionPersistence,
48
+ id: string,
49
+ header: UsageSessionHeader | undefined,
50
+ live: UsageSessionHandle | undefined,
51
+ signal?: AbortSignal,
52
+ ): Promise<UsageSessionLog> {
53
+ signal?.throwIfAborted()
54
+ if (typeof live?.snapshotEvents === 'function' && typeof live.inheritedEventCount === 'number') {
55
+ return { id, header, live, events: live.snapshotEvents(), inheritedCut: inheritedCount(live.inheritedEventCount) }
56
+ }
57
+ if (typeof persistence.open === 'function') {
58
+ const options = signal === undefined ? undefined : { signal }
59
+ const handle = await persistence.open(id, 'read', options)
60
+ try {
61
+ const events: UsageSessionEvent[] = []
62
+ for (let offset = 0;;) {
63
+ signal?.throwIfAborted()
64
+ const page = await handle.read(offset, 500, options)
65
+ if (!page.events.length) break
66
+ for (const event of page.events) events.push(event)
67
+ offset += page.events.length
68
+ }
69
+ return { id, header: header ?? handle.header, live, events, inheritedCut: inheritedCount(handle.inheritedEventCount) }
70
+ } finally {
71
+ await handle.close().catch(() => {})
72
+ }
73
+ }
74
+ if (typeof persistence.inspect === 'function') {
75
+ const inspection = await persistence.inspect(id, signal)
76
+ signal?.throwIfAborted()
77
+ return { id, header: header ?? inspection.meta, live, events: inspection.events, inheritedCut: inheritedCount(inspection.inheritedEventCount) }
78
+ }
79
+ throw new Error('no readable persistence seam (need open or inspect)')
80
+ }
81
+
82
+ export async function readUsageTree(
83
+ persistence: UsageSessionPersistence,
84
+ sessions: UsageSessionStore | undefined,
85
+ rootId: string,
86
+ signal?: AbortSignal,
87
+ ): Promise<{ root: UsageSessionLog; descendants: UsageSessionLog[]; coverage: SessionUsageCoverage }> {
88
+ signal?.throwIfAborted()
89
+ const rows = await persistence.list(typeof persistence.open === 'function'
90
+ ? signal === undefined ? undefined : { signal }
91
+ : signal)
92
+ const headers = new Map<string, UsageSessionHeader>()
93
+ for (const row of rows) {
94
+ const header = headerOf(row)
95
+ if (header) headers.set(header.id, header)
96
+ }
97
+ const live = new Map((sessions?.list() ?? []).map(handle => [handle.id, handle]))
98
+ const children = new Map<string, Set<string>>()
99
+ const link = (id: string, parent: unknown): void => {
100
+ if (typeof parent !== 'string' || parent === '' || parent === id) return
101
+ const bucket = children.get(parent) ?? new Set<string>()
102
+ bucket.add(id)
103
+ children.set(parent, bucket)
104
+ }
105
+ for (const header of headers.values()) link(header.id, header.parentSession)
106
+ for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession)
107
+ const ids: string[] = []
108
+ const seen = new Set([rootId])
109
+ const queue = [rootId]
110
+ for (let index = 0; index < queue.length; index++) {
111
+ for (const id of children.get(queue[index]!) ?? []) {
112
+ if (seen.has(id)) continue
113
+ seen.add(id)
114
+ ids.push(id)
115
+ queue.push(id)
116
+ }
117
+ }
118
+ const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal)
119
+ const logs: Array<UsageSessionLog | undefined> = new Array(ids.length)
120
+ const issues: Array<SessionUsageReadIssue | undefined> = new Array(ids.length)
121
+ let next = 0
122
+ // Bound full-log reads; preserve catalog order regardless of completion order.
123
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(4, ids.length) }, async () => {
124
+ for (;;) {
125
+ signal?.throwIfAborted()
126
+ const index = next++
127
+ if (index >= ids.length) return
128
+ const id = ids[index]!
129
+ try {
130
+ logs[index] = await readSession(persistence, id, headers.get(id), live.get(id), signal)
131
+ } catch (error) {
132
+ throwIfCanceled(error, signal)
133
+ issues[index] = readIssue(id, error)
134
+ }
135
+ }
136
+ }))
137
+ for (const worker of workers) if (worker.status === 'rejected') throw worker.reason
138
+ signal?.throwIfAborted()
139
+ const descendants = logs.filter((log): log is UsageSessionLog => log !== undefined)
140
+ const unreadableSessions = issues.filter((issue): issue is SessionUsageReadIssue => issue !== undefined)
141
+ return {
142
+ root,
143
+ descendants,
144
+ coverage: { complete: unreadableSessions.length === 0, totalSessions: ids.length + 1, readableSessions: descendants.length + 1, unreadableSessions },
145
+ }
146
+ }
@@ -1,48 +1,12 @@
1
- /**
2
- * Per-session usage aggregation, INCLUDING subagent sessions.
3
- *
4
- * Why this exists: dsh's native `tokenUsage` projection covers only the main
5
- * agent of the current session — a session that delegates to subagents (whose
6
- * own logs are separate persisted sessions, linked by `header.parentSession`)
7
- * shows only a fraction of what it actually spent. This module answers "what
8
- * did this whole session tree cost".
9
- *
10
- * The store has no per-session dimension (it accumulates per day x model), so
11
- * this is computed ON DEMAND from the session logs: the root session plus every
12
- * descendant whose `parentSession` chain leads back to it, each replayed
13
- * through a fresh {@link UsageFold} (the same dedupe rules the backfill uses).
14
- *
15
- * Two correctness rules carried over from the backfill engine:
16
- * - a forked log begins with the events it copied from its parent
17
- * (`handle.inheritedEventCount`); that prefix is skipped, the parent owns it;
18
- * - the fold dedupes per (turn, step) within one session, so a streaming
19
- * sample followed by the final message counts once.
20
- *
21
- * Results are cached briefly: the composer pill polls, and replaying a long log
22
- * per poll would be wasteful. The cache is invalidated for a session as soon as
23
- * a new event lands (see the collector's live subscription).
24
- */
25
-
26
- import type {
27
- UsageSessionEvent,
28
- UsageSessionHandle,
29
- UsageSessionHeader,
30
- UsageSessionPersistence,
31
- UsageSessionSnapshot,
32
- UsageSessionStore,
33
- } from './context-types.ts'
1
+ /** Per-session owned usage, including every readable descendant. */
2
+ import type { UsageSessionPersistence, UsageSessionStore } from './context-types.ts'
34
3
  import { UsageFold } from './collector.ts'
4
+ import { readUsageTree, type UsageSessionLog } from './session-tree.ts'
35
5
  import type { SessionUsageTotals, SessionUsageTree } from './wire.ts'
36
6
 
37
7
  export type { SessionUsageTotals, SessionUsageTree } from './wire.ts'
38
8
 
39
- const ZERO: SessionUsageTotals = {
40
- uncachedInputTokens: 0,
41
- outputTokens: 0,
42
- cacheReadTokens: 0,
43
- cacheWriteTokens: 0,
44
- }
45
-
9
+ const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
46
10
  function add(into: SessionUsageTotals, from: SessionUsageTotals): void {
47
11
  into.uncachedInputTokens += from.uncachedInputTokens
48
12
  into.outputTokens += from.outputTokens
@@ -50,81 +14,13 @@ function add(into: SessionUsageTotals, from: SessionUsageTotals): void {
50
14
  into.cacheWriteTokens += from.cacheWriteTokens
51
15
  }
52
16
 
53
- /** How long one computed tree stays hot. The pill refreshes on its own clock,
54
- * and a live session's numbers move at step granularity, not per frame. */
55
- const CACHE_TTL_MS = 4_000
56
- /** Absolute ceiling on cached trees, so a long-lived host with many sessions
57
- * cannot grow this map without bound. */
58
- const CACHE_MAX = 64
59
-
60
- /** Normalize a `list()` row (alpha snapshots or rc.1 header rows). */
61
- function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
62
- const snap = item as { header?: { id?: unknown } }
63
- if (snap !== null && typeof snap === 'object' && 'header' in snap
64
- && snap.header !== null && typeof snap.header === 'object'
65
- && typeof snap.header.id === 'string') {
66
- return snap.header as UsageSessionHeader
67
- }
68
- const row = item as UsageSessionHeader
69
- return typeof row?.id === 'string' && row.id !== '' ? row : null
70
- }
71
-
72
- function inheritedCount(cut: unknown): number {
73
- return typeof cut === 'number' && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0
74
- }
75
-
76
- /** Read one stored session's events through whichever persistence seam the
77
- * host line exposes (alpha open/read/close wins; rc.1 inspect is the tail). */
78
- async function readEvents(
79
- persistence: UsageSessionPersistence,
80
- id: string,
81
- signal?: AbortSignal,
82
- ): Promise<{ events: readonly UsageSessionEvent[]; inheritedCut: number }> {
83
- if (typeof persistence.open === 'function') {
84
- const handle = await persistence.open(id, 'read', signal === undefined ? undefined : { signal })
85
- try {
86
- const inheritedCut = inheritedCount(handle.inheritedEventCount)
87
- const collected: UsageSessionEvent[] = []
88
- let offset = 0
89
- const PAGE = 500
90
- for (;;) {
91
- signal?.throwIfAborted()
92
- const slice = await handle.read(offset, PAGE, signal === undefined ? undefined : { signal })
93
- const page = ((slice && slice.events) ? slice.events : []) as readonly UsageSessionEvent[]
94
- if (page.length === 0) break
95
- for (const ev of page) collected.push(ev)
96
- offset += page.length
97
- }
98
- return { events: collected, inheritedCut }
99
- } finally {
100
- await handle.close().catch(() => {})
101
- }
102
- }
103
- if (typeof persistence.inspect === 'function') {
104
- const inspection = await persistence.inspect(id, signal)
105
- return { events: inspection.events, inheritedCut: inheritedCount(inspection.inheritedEventCount) }
106
- }
107
- throw new Error('no readable persistence seam (need open or inspect)')
108
- }
109
-
110
- /** Sum one session's own usage (its main agent) from its log. */
111
- async function sumOneSession(
112
- persistence: UsageSessionPersistence,
113
- id: string,
114
- signal?: AbortSignal,
115
- live?: UsageSessionHandle,
116
- ): Promise<{ totals: SessionUsageTotals; compaction: SessionUsageTotals }> {
117
- signal?.throwIfAborted()
118
- const { events, inheritedCut } = typeof live?.snapshotEvents === 'function'
119
- && typeof live.inheritedEventCount === 'number'
120
- ? { events: live.snapshotEvents(), inheritedCut: inheritedCount(live.inheritedEventCount) }
121
- : await readEvents(persistence, id, signal)
17
+ function sumOneSession(log: UsageSessionLog): { totals: SessionUsageTotals; compaction: SessionUsageTotals } {
122
18
  const fold = new UsageFold()
123
- const totals: SessionUsageTotals = { ...ZERO }
124
- const compaction: SessionUsageTotals = { ...ZERO }
125
- for (const ev of events) {
126
- if (inheritedCut > 0 && typeof ev.seq === 'number' && ev.seq < inheritedCut) continue
127
- const sample = fold.fold(ev)
19
+ const totals = zero()
20
+ const compaction = zero()
21
+ for (const event of log.events) {
22
+ if (event.seq < log.inheritedCut) continue
23
+ const sample = fold.fold(event)
128
24
  if (sample === null || sample.turn) continue
129
25
  const buckets: SessionUsageTotals = {
130
26
  uncachedInputTokens: sample.inputTokens,
@@ -133,28 +29,27 @@ async function sumOneSession(
133
29
  cacheWriteTokens: sample.cacheWriteTokens,
134
30
  }
135
31
  add(totals, buckets)
136
- if (ev.type === 'compaction/summary') add(compaction, buckets)
32
+ if (event.type === 'compaction/summary') add(compaction, buckets)
137
33
  }
138
34
  return { totals, compaction }
139
35
  }
140
36
 
37
+ const CACHE_TTL_MS = 4_000
38
+ const CACHE_MAX = 64
39
+
141
40
  export class SessionUsageService {
142
- /** Short-lived tree reads; any live event can invalidate tree membership. */
143
41
  private cache = new Map<string, { at: number; value: SessionUsageTree }>()
144
- /** Any event can introduce a new descendant while an async read is running. */
145
42
  private generation = 0
146
43
 
147
- /** Invalidate conservatively: an unseen session may be a newly-created child
148
- * of ANY cached root, so membership-only invalidation is not safe. */
44
+ /** New events can introduce descendants of any cached root. */
149
45
  invalidate(_sessionId: string): void {
150
46
  this.generation++
151
47
  this.cache.clear()
152
48
  }
153
49
 
154
- /** Resolve the whole session tree rooted at `rootId` and sum its usage.
155
- * `self` is the root's own usage, `subagents` every descendant's, `total`
156
- * the sum. Unreadable logs reject rather than publishing partial totals;
157
- * callers can retain their last good display while surfacing the error. */
50
+ /** A bad descendant leaves the readable totals available with coverage.
51
+ * Never cache incomplete results: a repaired log must recover on the next read.
52
+ * Root, catalog and cancellation failures still reject without publishing zero. */
158
53
  async tree(
159
54
  persistence: UsageSessionPersistence,
160
55
  sessions: UsageSessionStore | undefined,
@@ -165,69 +60,25 @@ export class SessionUsageService {
165
60
  const hit = this.cache.get(rootId)
166
61
  if (hit !== undefined && Date.now() - hit.at < CACHE_TTL_MS) return hit.value
167
62
  const generation = this.generation
168
-
169
- const headers = await this.listHeaders(persistence, signal)
170
- const children = new Map<string, string[]>()
171
- for (const header of headers) {
172
- const parent = header.parentSession
173
- if (typeof parent !== 'string' || parent === '') continue
174
- const bucket = children.get(parent)
175
- if (bucket === undefined) children.set(parent, [header.id])
176
- else bucket.push(header.id)
177
- }
178
- // A brand-new live subagent may not be materialized in persistence yet;
179
- // the live store still knows its parent, so fold those in too.
180
- const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]))
181
- for (const handle of live.values()) {
182
- const parent = handle.header?.parentSession ?? handle.parentSession
183
- if (typeof parent !== 'string' || parent === '' || parent === handle.id) continue
184
- const bucket = children.get(parent)
185
- if (bucket === undefined) children.set(parent, [handle.id])
186
- else if (!bucket.includes(handle.id)) bucket.push(handle.id)
187
- }
188
-
189
- const descendants: string[] = []
190
- const seen = new Set<string>([rootId])
191
- const queue = [rootId]
192
- while (queue.length > 0) {
193
- const current = queue.shift() as string
194
- for (const child of children.get(current) ?? []) {
195
- if (seen.has(child)) continue
196
- seen.add(child)
197
- descendants.push(child)
198
- queue.push(child)
199
- }
200
- }
201
-
202
- const root = await sumOneSession(persistence, rootId, signal, live.get(rootId))
203
- const self = root.totals
204
- const subagents: SessionUsageTotals = { ...ZERO }
205
- for (const id of descendants) {
206
- const one = await sumOneSession(persistence, id, signal, live.get(id))
207
- add(subagents, one.totals)
208
- }
209
- const total: SessionUsageTotals = { ...self }
63
+ const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId, signal)
64
+ const own = sumOneSession(root)
65
+ const subagents = zero()
66
+ for (const log of descendants) add(subagents, sumOneSession(log).totals)
67
+ const total = { ...own.totals }
210
68
  add(total, subagents)
211
- const value: SessionUsageTree = { self, selfCompaction: root.compaction, subagents, total, subagentSessions: descendants.length }
212
-
69
+ const value: SessionUsageTree = {
70
+ self: own.totals,
71
+ selfCompaction: own.compaction,
72
+ subagents,
73
+ total,
74
+ subagentSessions: descendants.length,
75
+ coverage,
76
+ }
213
77
  signal?.throwIfAborted()
214
- if (generation === this.generation) {
78
+ if (coverage.complete && generation === this.generation) {
215
79
  if (this.cache.size >= CACHE_MAX) this.cache.clear()
216
80
  this.cache.set(rootId, { at: Date.now(), value })
217
81
  }
218
82
  return value
219
83
  }
220
-
221
- private async listHeaders(
222
- persistence: UsageSessionPersistence,
223
- signal?: AbortSignal,
224
- ): Promise<UsageSessionHeader[]> {
225
- const useAlphaList = typeof persistence.open === 'function'
226
- const raw = useAlphaList
227
- ? await persistence.list(signal === undefined ? undefined : { signal })
228
- : await persistence.list(signal as never)
229
- return (raw as Array<UsageSessionHeader | UsageSessionSnapshot>)
230
- .map(headerOf)
231
- .filter((h): h is UsageSessionHeader => h !== null)
232
- }
233
84
  }