dsh-token-total 1.1.2 → 1.1.4

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.
@@ -31,14 +31,14 @@
31
31
  * cannot see.
32
32
  *
33
33
  * Streaming throughput replaces the gauge pill's speed
34
- * figure with a live estimate of what is happening now. The figure is the
35
- * streaming step's measured rate while one streams and the LAST measured rate
36
- * once it settles — a session average describes the whole session's past, and
37
- * it cannot match the pace of the answer on screen, which is what users read
38
- * as the pill disagreeing with the stream in front of them. The official
39
- * session figure only shows on a session that has never streamed. That
40
- * subscription lives in
41
- * its own child component so stream deltas never re-render the pills. The live
34
+ * figure with a live estimate of what is happening now, and hands the slot back
35
+ * to the session's decode average when nothing is streaming. While a step
36
+ * streams the live rate is the only reading that can match the pace of the
37
+ * answer on screen; once the step settles it becomes a figure from a window
38
+ * nobody is watching any more, so the pill returns to the same quotient the
39
+ * usage view quotes (ΣoutputTokens / ΣdecodeMs) instead of freezing on the last
40
+ * measurement. That subscription lives in its own child component, so stream
41
+ * deltas never re-render the pills. The live
42
42
  * figure itself is measured by a bounded sliding window over observed token
43
43
  * growth (stats-line-core's StreamingRateSampler): the earlier cumulative
44
44
  * quotient charged a whole step's text to a window that had just opened, so a
@@ -181,14 +181,23 @@ const LIVE_SPEED_REFRESH_MS = 1_000
181
181
  * The gauge pill's always-on streaming speed slot.
182
182
  *
183
183
  * It subscribes to `legacy.partial` itself so per-frame deltas re-render only
184
- * this child, not the surrounding pills. The figure it carries is the CURRENT
185
- * stream's measured rate, and it stays that rate once the step settles:
186
- * tool execution and the gap between steps happen inside the same answer, and
187
- * the session average this slot used to fall back to there is a figure from
188
- * the whole session's past — a number that cannot match the pace of the answer
189
- * on screen, which is what reads as "not the real-time speed". The official
190
- * session figure is only a session's opening state, shown until the first
191
- * window measures anything.
184
+ * this child, not the surrounding pills. Two figures share the slot, chosen by
185
+ * whether a step is streaming right now:
186
+ *
187
+ * - while one is (`partial != null`), the measured rate of THAT stream — a
188
+ * bounded sliding window over observed token growth (stats-line-core's
189
+ * StreamingRateSampler), the only reading that can track the pace of the
190
+ * answer currently on screen;
191
+ * - otherwise the session's own decode average, ΣoutputTokens / ΣdecodeMs —
192
+ * the same quotient `decodeRateOf` quotes in the usage view. An idle composer
193
+ * must not freeze on whatever the last window happened to measure: a settled
194
+ * thirty-step turn kept showing its final second-long reading (57 tok/s)
195
+ * beside the usage view's 156 tok/s for the same work, and the two read as a
196
+ * contradiction rather than as two scopes of one number.
197
+ *
198
+ * The measured reading carries the step it was taken in, so a new step cannot
199
+ * inherit the previous one's figure before its own window publishes; nothing is
200
+ * held across the gap — the gap is exactly when the average applies.
192
201
  *
193
202
  * One window covers a whole step, across every output kind the accumulator
194
203
  * carries — reasoning, answer text, and Tool-call arguments — because the
@@ -199,13 +208,16 @@ const LIVE_SPEED_REFRESH_MS = 1_000
199
208
  * state, so no render reads a clock or mutates the window: a render React starts
200
209
  * and then discards must not decide what the next committed one displays. The
201
210
  * rate itself comes from stats-line-core's StreamingRateSampler — a bounded
202
- * window over observed token growth, smoothed, and held while nothing arrives.
211
+ * window over observed token growth, smoothed, and held while nothing arrives
212
+ * inside the step it belongs to.
203
213
  */
204
214
  function LiveSpeed({ useChat, scale, fallback, t }: {
205
215
  useChat: ChatSnapshotSelectorHook
206
216
  /** Correction measured from this session's settled steps (1 = raw prior). */
207
217
  scale: number
208
- /** The official session figure, shown until the slot has measured its own. */
218
+ /** The session's own decode average (ΣoutputTokens / ΣdecodeMs): what the
219
+ * slot shows whenever no step is streaming, and before the streaming step's
220
+ * window has published its first reading. */
209
221
  fallback: string | null
210
222
  t: T
211
223
  }) {
@@ -223,18 +235,22 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
223
235
  sampler.current = { key, rate: new StreamingRateSampler() }
224
236
  }
225
237
  const rate = sampler.current.rate
226
- // The latest figure this slot has measured, held across step boundaries. A
227
- // settled step's rate is still the conversation's current speed until the
228
- // next step measures its own, so the display keeps it instead of switching to
229
- // a session average the moment the stream goes quiet — holding it is the
230
- // point, not a stale value to guard against, which is why no step tag rides
231
- // along any more.
232
- const [reading, setReading] = useState<number | null>(null)
238
+ // The figure last measured, tagged with the step it came from. The tag is
239
+ // what retires it: a reading taken in 3:4 never shows during 3:5, and with
240
+ // no step streaming (`key === null`) no tag can match at all, so the slot
241
+ // falls through to the session average on its own — no timer, no clearing
242
+ // effect, and no stale figure left standing after a turn settles.
243
+ const [reading, setReading] = useState<{ key: string; value: number } | null>(null)
233
244
  const scaleRef = useRef(scale)
234
245
  // Every delta re-runs this (its dependencies move), and the beat re-runs it once
235
246
  // a second so a step that has gone quiet still advances its window.
236
247
  const [beat, setBeat] = useState(0)
237
248
  useLayoutEffect(() => {
249
+ // Nothing is streaming: the slot shows the session average, and there is no
250
+ // window to advance. Skipping the observation also keeps a settled step's
251
+ // sampler from being fed a zero estimate, which would re-anchor it against
252
+ // text that is already counted.
253
+ if (key === null) return
238
254
  // A correction that moved re-prices the whole estimate, so growth already
239
255
  // counted under the old one must not be measured against the new one: that
240
256
  // would read as a burst the stream never produced.
@@ -244,11 +260,13 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
244
260
  }
245
261
  rate.observe(tokens, Date.now())
246
262
  // A null here means the fresh window has not measured anything yet (a step
247
- // that just opened, or one that re-anchored); the figure already on screen
248
- // is still the best current reading, so it is left alone.
263
+ // that just opened, or one that re-anchored); the average stays up until
264
+ // this step's own window publishes something.
249
265
  const next = rate.ratePerSecond()
250
266
  if (next !== null) {
251
- setReading(previous => (previous === next ? previous : next))
267
+ setReading(previous => (previous !== null && previous.key === key && previous.value === next
268
+ ? previous
269
+ : { key, value: next }))
252
270
  }
253
271
  }, [tokens, key, beat, rate, scale])
254
272
  // Keyed on "a step is streaming", not on the step identity: switching steps
@@ -259,9 +277,12 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
259
277
  const handle = setInterval(() => { setBeat(Date.now()) }, LIVE_SPEED_REFRESH_MS)
260
278
  return () => { clearInterval(handle) }
261
279
  }, [streaming])
262
- return <SpeedFigure text={reading === null
280
+ // The tag test is the whole selection rule: a reading only shows while the
281
+ // step that produced it is the step streaming now.
282
+ const measured = reading !== null && reading.key === key ? reading.value : null
283
+ return <SpeedFigure text={measured === null
263
284
  ? fallback
264
- : t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(reading) })} />
285
+ : t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(measured) })} />
265
286
  }
266
287
 
267
288
  function TimePill({ stats, t, dialog, useChat, scale, dock }: {
@@ -278,9 +299,10 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
278
299
  }) {
279
300
  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
280
301
  const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
281
- // The pill's accessible name stays on the official figure: a screen reader
282
- // gets the durable session reading rather than a value that changes every
283
- // second while a step streams.
302
+ // The session figure now serves both jobs: the pill's accessible name (a
303
+ // screen reader gets the durable session reading rather than a value that
304
+ // changes every second while a step streams) and the visible figure whenever
305
+ // no step is streaming.
284
306
  const officialTps = stats.decodeMs > 0
285
307
  ? t('stats.tokensPerSecond', {
286
308
  throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
@@ -383,7 +405,8 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
383
405
  const billed = display.total
384
406
  // Same aggregate as the official pill total: every prompt-side billing bucket plus output.
385
407
  const total = billedTotal(billed)
386
- 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) })
387
410
  // Native formatting, one consistent scope: the ratio comes from the same
388
411
  // aggregate the headline uses, so "33M tok · 87%" can never contradict the
389
412
  // number printed beside it. The integer shape and its >99% guard are the
@@ -399,7 +422,8 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
399
422
  className={css.pill}
400
423
  aria-haspopup="dialog"
401
424
  aria-expanded={open}
402
- 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}
403
427
  onClick={() => { setOpen(!open) }}
404
428
  >
405
429
  <DatabaseIcon />
@@ -429,9 +453,14 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
429
453
  <DatabaseIcon />
430
454
  {t('stats.dialog.usageTitle')}
431
455
  </span>
432
- <span className={css.dialogTitleValue}>{exactCount(total)}</span>
456
+ <span className={css.dialogTitleValue}>{(incomplete ? '≥ ' : '') + exactCount(total)}</span>
433
457
  </div>
434
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
+ )}
435
464
  {/* Exactly the official dialog's rows, in the official order: the
436
465
  takeover adds the tree to the TOTAL, not to the breakdown. */}
437
466
  <dl className={css.dialogDetails} data-session-stats-usage>
@@ -487,7 +516,8 @@ export const StatsLineEnhanced = memo(function StatsLineEnhanced(
487
516
  // Refresh on main-agent settlements and poll for child-only activity.
488
517
  const tree = useSessionUsage(sessionId, usageSignature(usage, stats.steps))
489
518
  // A delegated-only session must still show its usage pill.
490
- 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
491
521
  if (stats.steps === 0 && !hasTokens) return null
492
522
  // data-composer-stats: through host 0.1.6-alpha.1 InputBar tightened the
493
523
  // composer bottom clearance while this row rendered (the official pills'
@@ -560,7 +590,8 @@ export const StatsUsageEntry = memo(function StatsUsageEntry(
560
590
  ) {
561
591
  const usage = useProjection('tokenUsage')
562
592
  const tree = useSessionUsage(sessionId, usageSignature(usage, usage === undefined ? 0 : 1))
563
- 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
564
595
  if (!hasTokens) return null
565
596
  return (
566
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,8 @@ 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 one(log: UsageSessionLog) {
90
+ const { id, header, live, events, inheritedCut: cut } = log
114
91
  const fold = new UsageFold(); const totals = zero(); const records: MutableRecord[] = []
115
92
  const byKey = new Map<string, MutableRecord>(); let route = live?.requestContext?.() ?? {}
116
93
  let ordinal = 0
@@ -214,14 +191,8 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
214
191
  }
215
192
 
216
193
  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))))
194
+ const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId)
195
+ const parts = [root, ...descendants].map(one)
225
196
  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
197
  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
198
  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 +219,5 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
248
219
  // The card is scoped to this session and every subagent, so its speed and
249
220
  // first-token average fold the same records its token totals do.
250
221
  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)) }
222
+ 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
223
  }
@@ -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
+ }