dsh-token-total 1.0.0 → 1.0.2

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.
Files changed (37) hide show
  1. package/README.md +5 -4
  2. package/lib/client.js +566 -159
  3. package/lib/index.js +355 -54
  4. package/lib/types/client/ContextUsageEntry.d.ts +22 -0
  5. package/lib/types/client/context-meter-core.d.ts +26 -0
  6. package/lib/types/client/context-takeover.d.ts +4 -0
  7. package/lib/types/client/locales.d.ts +1 -1
  8. package/lib/types/client/session-usage-core.d.ts +9 -0
  9. package/lib/types/client/use-session-usage.d.ts +2 -18
  10. package/lib/types/collector.d.ts +27 -12
  11. package/lib/types/context-types.d.ts +23 -2
  12. package/lib/types/model-names.d.ts +6 -0
  13. package/lib/types/query.d.ts +8 -0
  14. package/lib/types/session-usage.d.ts +8 -9
  15. package/lib/types/store.d.ts +32 -0
  16. package/lib/types/wire.d.ts +30 -1
  17. package/package.json +1 -1
  18. package/src/client/ContextUsageEntry.module.css +20 -0
  19. package/src/client/ContextUsageEntry.tsx +89 -0
  20. package/src/client/StatsLineEnhanced.tsx +17 -35
  21. package/src/client/TokenTotalPanel.module.css +72 -5
  22. package/src/client/TokenTotalPanel.tsx +204 -56
  23. package/src/client/context-meter-core.ts +54 -0
  24. package/src/client/context-takeover.ts +35 -0
  25. package/src/client/index.tsx +21 -3
  26. package/src/client/locales.ts +4 -0
  27. package/src/client/session-usage-core.ts +54 -0
  28. package/src/client/use-session-usage.ts +45 -98
  29. package/src/collector.ts +138 -67
  30. package/src/context-types.ts +17 -2
  31. package/src/index.ts +29 -0
  32. package/src/model-names.ts +34 -0
  33. package/src/query.ts +84 -13
  34. package/src/routes.ts +20 -2
  35. package/src/session-usage.ts +50 -38
  36. package/src/store.ts +136 -0
  37. package/src/wire.ts +24 -1
@@ -150,6 +150,16 @@ export interface UsageSessionPersistence {
150
150
  export interface UsageLlmService {
151
151
  resolveModelInfo?(provider: string, model: string, signal?: AbortSignal): Promise<{ name: string }>
152
152
  listModels?(provider: string): Promise<readonly { id: string; name: string }[]>
153
+ /** The provider catalog: route id -> the display name the model selector
154
+ * shows. Synchronous on every host line this plugin supports (the llm
155
+ * runtime's own `listProviders`, mirrored by the dsh-llm service). Optional:
156
+ * a host without it leaves every ranked provider row labelled by its raw
157
+ * historical route id. */
158
+ listProviders?(): readonly { id: string; name: string }[]
159
+ /** The configured-provider view (the models page's own list), whose
160
+ * `displayName` is the same label. Read as a fallback for a route that
161
+ * `listProviders` omits. */
162
+ listConfigurableProviders?(): readonly { provider: string; displayName: string }[]
153
163
  }
154
164
 
155
165
  /** The route a session's log records for its model calls (mirror of
@@ -168,8 +178,13 @@ export interface UsageSessionHandle {
168
178
  id: string
169
179
  cwd?: string
170
180
  createdAt?: number
171
- /** The parent session a subagent session was delegated from (mirror of the
172
- * SessionHeader field; absent on a root session). */
181
+ /** Modern hosts retain creation metadata on Session.header, not as getters. */
182
+ header?: UsageSessionHeader
183
+ /** Exact fork prefix, distinct from the resume/construction seed length. */
184
+ inheritedEventCount?: number
185
+ /** Immutable current live log; avoids the persistence backend's batching lag. */
186
+ snapshotEvents?(): readonly UsageSessionEvent[]
187
+ /** Legacy direct parent; modern hosts expose header.parentSession instead. */
173
188
  parentSession?: string
174
189
  /** 0 for a root session; the delegation depth for a subagent. */
175
190
  delegationDepth?: number
package/src/index.ts CHANGED
@@ -212,6 +212,34 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
212
212
  await collector.backfillHours(ctx.sessionPersistence, ctx.sessions, controller.signal)
213
213
  }
214
214
 
215
+ // The failure counter arrived after BOTH token cursors were populated, so
216
+ // an existing store needs one more replay to recover historic failures.
217
+ // Same shape as the hour upgrade: only after the hour pass settled cleanly
218
+ // (a fresh install's passes already folded failures and set the flag, so
219
+ // this returns immediately), and with a live boundary for open sessions.
220
+ const ensureFailureHistory = async (): Promise<void> => {
221
+ await store.readyPromise()
222
+ if (await store.failuresReady()) return
223
+ if (!(await store.hasAnyDayRows())) return
224
+ if (collector.status.error !== undefined) return
225
+ const previous = await store.failuresLiveSequences()
226
+ const boundaries = new Map<string, number>()
227
+ for (const handle of ctx.sessions.list()) {
228
+ const seq = handle.seq
229
+ if (typeof seq === 'number' && Number.isFinite(seq) && seq >= 0) {
230
+ boundaries.set(handle.id, seq)
231
+ continue
232
+ }
233
+ const old = previous.get(handle.id)
234
+ boundaries.set(handle.id, typeof old === 'number' ? old : -1)
235
+ }
236
+ await store.markFailuresLiveSequences(boundaries)
237
+ scanController?.abort()
238
+ const controller = new AbortController()
239
+ scanController = controller
240
+ await collector.backfillFailures(ctx.sessionPersistence, ctx.sessions, controller.signal)
241
+ }
242
+
215
243
  ctx.effect(() => {
216
244
  collector.start()
217
245
  // Keep the per-session tree cache honest: every committed event of any
@@ -226,6 +254,7 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
226
254
  void (async () => {
227
255
  await rescan()
228
256
  await ensureHourHistory()
257
+ await ensureFailureHistory()
229
258
  })()
230
259
  return () => {
231
260
  scanController?.abort()
@@ -26,6 +26,40 @@ async function lookup<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: n
26
26
  }
27
27
  }
28
28
 
29
+ /** Resolve provider route ids to the labels the model selector shows. Both
30
+ * catalogs are presentational and synchronous on the host runtime, so this is
31
+ * a plain in-process read rather than a per-request lookup: a missing service,
32
+ * a throwing adapter, or an unexpected promise all resolve to an empty map and
33
+ * every surface falls back to the raw historical route id. */
34
+ export function resolveProviderNames(llm?: UsageLlmService): Record<string, string> {
35
+ const names: Record<string, string> = Object.create(null)
36
+ if (!llm) return names
37
+ try {
38
+ for (const provider of catalog(llm.listProviders?.())) {
39
+ const name = provider.name?.trim()
40
+ if (provider.id && name) names[provider.id] = name
41
+ }
42
+ } catch {
43
+ // Metadata is auxiliary: a broken catalog must never fail the range query.
44
+ }
45
+ try {
46
+ for (const entry of catalog(llm.listConfigurableProviders?.())) {
47
+ const name = entry.displayName?.trim()
48
+ if (entry.provider && name && names[entry.provider] === undefined) names[entry.provider] = name
49
+ }
50
+ } catch {
51
+ // Same contract as above.
52
+ }
53
+ return names
54
+ }
55
+
56
+ /** Both catalogs are declared synchronous, but a host that publishes them over
57
+ * a remote face would hand back a promise; treat anything that is not an array
58
+ * as "no metadata" instead of throwing mid-query. */
59
+ function catalog<T>(value: readonly T[] | undefined): readonly T[] {
60
+ return Array.isArray(value) ? value : []
61
+ }
62
+
29
63
  /** Resolve each distinct route once per range query. Unknown/deleted providers
30
64
  * fall back to their bare historical model names in aggregateSamples(). */
31
65
  export async function resolveModelNames(
package/src/query.ts CHANGED
@@ -51,6 +51,14 @@ export interface UsageSample {
51
51
  cacheWriteTokens: number
52
52
  turn?: boolean // true for a completed-turn marker
53
53
  request?: boolean // true when this sample also counts one provider call
54
+ /** True when this sample also counts one FAILED provider call. The host
55
+ * appends `assistant/attempt` (instead of `assistant/message`) when a
56
+ * call's stream ends with an error or is cancelled before any content is
57
+ * committed, so that event is the exact boundary of a failed call. The
58
+ * marker is ORTHOGONAL to `request` and to the buckets: a failed call was
59
+ * still opened by its own `step/start` (so it is already one request) and
60
+ * a stream that died mid-flight may still report usage. */
61
+ failed?: boolean
54
62
  }
55
63
 
56
64
  export interface RangeFilter {
@@ -105,6 +113,7 @@ export function aggregateSamples(
105
113
  outputTokens: 0,
106
114
  cacheTokens: 0,
107
115
  requests: 0,
116
+ failures: 0,
108
117
  turns: 0,
109
118
  cacheHit: 0,
110
119
  cacheMiss: 0,
@@ -123,6 +132,12 @@ export function aggregateSamples(
123
132
  // tracked alongside the token volume.
124
133
  const modelCache = new Map<string, { hit: number; miss: number }>()
125
134
  const providerCache = new Map<string, { hit: number; miss: number }>()
135
+ // Cross-dimension splits: the two ranked lists render as side-by-side cards
136
+ // whose rows explain themselves on hover (which providers served this model,
137
+ // which models this provider served), so both directions are tracked here
138
+ // in the same pass rather than by re-walking the samples per row.
139
+ const modelProviderTotals = new Map<string, Map<string, number>>()
140
+ const providerModelTotals = new Map<string, Map<string, number>>()
126
141
  const active = new Set<string>()
127
142
  // Per-bucket accumulation (the reasonix dayTotals): the stacked trend chart
128
143
  // reads the bucket's byModel / byProvider, so every token-bearing sample
@@ -131,6 +146,7 @@ export function aggregateSamples(
131
146
  const bucketByProvider = new Map<string, Map<string, number>>()
132
147
  const bucketTotals = new Map<string, number>()
133
148
  const bucketRequests = new Map<string, number>()
149
+ const bucketFailures = new Map<string, number>()
134
150
  const bucketTurns = new Map<string, number>()
135
151
  const bucketCacheHit = new Map<string, number>()
136
152
  const bucketCacheMiss = new Map<string, number>()
@@ -147,18 +163,22 @@ export function aggregateSamples(
147
163
  // provider billed for this call — uncached input, output, cache reads
148
164
  // and writes. The hit/miss rate denominators below stay input-side only.
149
165
  const total = sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens
166
+ // UsageFold emits signed bucket corrections. Even a net-zero correction
167
+ // can move prompt tokens between cache hit and miss, so only all-zero
168
+ // buckets are a marker with no token accounting.
169
+ const hasTokenDelta = sample.inputTokens !== 0 || sample.outputTokens !== 0
170
+ || sample.cacheReadTokens !== 0 || sample.cacheWriteTokens !== 0
171
+ if (sample.failed) {
172
+ out.failures++
173
+ bucketFailures.set(bucket, (bucketFailures.get(bucket) ?? 0) + 1)
174
+ }
150
175
  if (sample.request) {
151
- // One provider call, whether or not it produced tokens — reasonix counts
152
- // failed calls too. A marker with no buckets is a request and nothing
153
- // else: it must not create a model row, mark its day active, or open an
154
- // empty daily bucket. A marker that DOES carry buckets
155
- // (`compaction/summary` states the call and its usage in one sample) is
156
- // counted here and again through the token accounting below — the two
157
- // count different things, so neither doubles the other.
158
176
  out.requests++
159
177
  bucketRequests.set(bucket, (bucketRequests.get(bucket) ?? 0) + 1)
160
- if (total <= 0) continue
161
178
  }
179
+ // Pure markers must not create a model row or an active day. A marker
180
+ // carrying a correction still updates every billing/cache bucket below.
181
+ if (!hasTokenDelta) continue
162
182
  out.tokens += total
163
183
  out.inputTokens += sample.inputTokens
164
184
  out.outputTokens += sample.outputTokens
@@ -178,6 +198,18 @@ export function aggregateSamples(
178
198
  modelProviders.set(model, sources)
179
199
  modelTotals.set(model, (modelTotals.get(model) ?? 0) + total)
180
200
  providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + total)
201
+ let modelSplitMap = modelProviderTotals.get(model)
202
+ if (!modelSplitMap) {
203
+ modelSplitMap = new Map()
204
+ modelProviderTotals.set(model, modelSplitMap)
205
+ }
206
+ modelSplitMap.set(provider, (modelSplitMap.get(provider) ?? 0) + total)
207
+ let providerSplitMap = providerModelTotals.get(provider)
208
+ if (!providerSplitMap) {
209
+ providerSplitMap = new Map()
210
+ providerModelTotals.set(provider, providerSplitMap)
211
+ }
212
+ providerSplitMap.set(model, (providerSplitMap.get(model) ?? 0) + total)
181
213
  const mc = modelCache.get(model) ?? { hit: 0, miss: 0 }
182
214
  mc.hit += sample.cacheReadTokens
183
215
  mc.miss += missSide
@@ -222,14 +254,15 @@ export function aggregateSamples(
222
254
  byModel: byModelObj,
223
255
  byProvider: byProviderObj,
224
256
  requests: bucketRequests.get(bucket) ?? 0,
257
+ failures: bucketFailures.get(bucket) ?? 0,
225
258
  turns: bucketTurns.get(bucket) ?? 0,
226
259
  cacheHit: bucketCacheHit.get(bucket) ?? 0,
227
260
  cacheMiss: bucketCacheMiss.get(bucket) ?? 0,
228
261
  })
229
262
  }
230
263
 
231
- out.models = modelsSorted(modelTotals, modelCache, modelProviders)
232
- out.providers = providersSorted(providerTotals, providerCache)
264
+ out.models = modelsSorted(modelTotals, modelCache, modelProviders, modelProviderTotals)
265
+ out.providers = providersSorted(providerTotals, providerCache, providerModelTotals)
233
266
  out.topModel = out.models[0]?.model ?? ''
234
267
  out.topProvider = out.providers[0]?.provider ?? ''
235
268
  if (out.tokens > 0) {
@@ -243,23 +276,61 @@ function modelsSorted(
243
276
  totals: Map<string, number>,
244
277
  cache: Map<string, { hit: number; miss: number }>,
245
278
  sources: Map<string, Set<string>>,
279
+ splits: Map<string, Map<string, number>>,
246
280
  ): ModelTokenUsage[] {
247
281
  const out: ModelTokenUsage[] = []
248
282
  for (const [model, tokens] of totals) {
249
283
  const c = cache.get(model)
250
284
  const providers = [...(sources.get(model) ?? [])].sort()
251
- out.push({ model, provider: providers.length === 1 ? providers[0]! : '', providers, tokens, percent: 0, cacheHit: c?.hit ?? 0, cacheMiss: c?.miss ?? 0 })
285
+ out.push({
286
+ model,
287
+ provider: providers.length === 1 ? providers[0]! : '',
288
+ providers,
289
+ providerTokens: providerSplit(splits.get(model)),
290
+ tokens,
291
+ percent: 0,
292
+ cacheHit: c?.hit ?? 0,
293
+ cacheMiss: c?.miss ?? 0,
294
+ })
252
295
  }
253
296
  out.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
254
297
  return out
255
298
  }
256
299
 
257
- function providersSorted(totals: Map<string, number>, cache: Map<string, { hit: number; miss: number }>): ProviderTokenUsage[] {
300
+ function providersSorted(
301
+ totals: Map<string, number>,
302
+ cache: Map<string, { hit: number; miss: number }>,
303
+ splits: Map<string, Map<string, number>>,
304
+ ): ProviderTokenUsage[] {
258
305
  const out: ProviderTokenUsage[] = []
259
306
  for (const [provider, tokens] of totals) {
260
307
  const c = cache.get(provider)
261
- out.push({ provider, tokens, percent: 0, cacheHit: c?.hit ?? 0, cacheMiss: c?.miss ?? 0 })
308
+ out.push({
309
+ provider,
310
+ tokens,
311
+ percent: 0,
312
+ cacheHit: c?.hit ?? 0,
313
+ cacheMiss: c?.miss ?? 0,
314
+ modelTokens: modelSplit(splits.get(provider)),
315
+ })
262
316
  }
263
317
  out.sort((a, b) => b.tokens - a.tokens)
264
318
  return out
265
319
  }
320
+
321
+ /** One model's per-provider split, largest first (ties by id for a stable
322
+ * order across refreshes). Empty when the response carries no cross data. */
323
+ function providerSplit(split: Map<string, number> | undefined): Array<{ provider: string; tokens: number }> {
324
+ if (split === undefined) return []
325
+ return [...split]
326
+ .map(([provider, tokens]) => ({ provider, tokens }))
327
+ .sort((a, b) => b.tokens - a.tokens || a.provider.localeCompare(b.provider))
328
+ }
329
+
330
+ /** One provider's per-model split, largest first (ties by name). */
331
+ function modelSplit(split: Map<string, number> | undefined): Array<{ model: string; tokens: number }> {
332
+ if (split === undefined) return []
333
+ return [...split]
334
+ .map(([model, tokens]) => ({ model, tokens }))
335
+ .sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
336
+ }
package/src/routes.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Context, UsageLlmService } from './context-types.ts'
15
- import { resolveModelNames } from './model-names.ts'
15
+ import { resolveModelNames, resolveProviderNames } from './model-names.ts'
16
16
  import type { UsageHttpRequest, UsageHttpResponse, UsageWebRoute } from './context-types.ts'
17
17
  import type { UsageStore, UsageDayRow, UsageHourRow } from './store.ts'
18
18
  import type { UsageCollector } from './collector.ts'
@@ -162,12 +162,30 @@ export async function aggregateRange(
162
162
  for (let i = 0; i < row.requests; i++) {
163
163
  yield { day, hour, model: row.model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, request: true }
164
164
  }
165
+ // Failed calls ride the row as a count too. They are folded as their own
166
+ // marker (never as an extra request): the call's request is already
167
+ // counted above, and a zero-token failure must not open a model row.
168
+ for (let i = 0; i < (row.failures ?? 0); i++) {
169
+ yield { day, hour, model: row.model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, failed: true }
170
+ }
165
171
  for (let i = 0; i < row.turns; i++) {
166
172
  yield { day, hour, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turn: true }
167
173
  }
168
174
  }
169
175
  })()
170
- return aggregateSamples(samples, { from, to }, granularity)
176
+ const stats = aggregateSamples(samples, { from, to }, granularity)
177
+ // Provider labels ride along with the aggregate: the ranked provider card
178
+ // shows the selector's display name, and the hover detail names the models
179
+ // underneath it. Only providers actually present in the range are included,
180
+ // so the payload stays proportional to the ranking it decorates.
181
+ const resolved = resolveProviderNames(llm)
182
+ const providerNames: Record<string, string> = Object.create(null)
183
+ for (const provider of stats.providers) {
184
+ const label = resolved[provider.provider]
185
+ if (label !== undefined) providerNames[provider.provider] = label
186
+ }
187
+ if (Object.keys(providerNames).length > 0) stats.providerNames = providerNames
188
+ return stats
171
189
  }
172
190
 
173
191
  /** Build the single fenced prefix route serving the /token-total/api JSON RPC.
@@ -25,6 +25,7 @@
25
25
 
26
26
  import type {
27
27
  UsageSessionEvent,
28
+ UsageSessionHandle,
28
29
  UsageSessionHeader,
29
30
  UsageSessionPersistence,
30
31
  UsageSessionSnapshot,
@@ -68,6 +69,10 @@ function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSession
68
69
  return typeof row?.id === 'string' && row.id !== '' ? row : null
69
70
  }
70
71
 
72
+ function inheritedCount(cut: unknown): number {
73
+ return typeof cut === 'number' && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0
74
+ }
75
+
71
76
  /** Read one stored session's events through whichever persistence seam the
72
77
  * host line exposes (alpha open/read/close wins; rc.1 inspect is the tail). */
73
78
  async function readEvents(
@@ -78,13 +83,12 @@ async function readEvents(
78
83
  if (typeof persistence.open === 'function') {
79
84
  const handle = await persistence.open(id, 'read', signal === undefined ? undefined : { signal })
80
85
  try {
81
- const cut = handle.inheritedEventCount
82
- const inheritedCut = typeof cut === 'number' && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0
86
+ const inheritedCut = inheritedCount(handle.inheritedEventCount)
83
87
  const collected: UsageSessionEvent[] = []
84
88
  let offset = 0
85
89
  const PAGE = 500
86
90
  for (;;) {
87
- if (signal?.aborted) break
91
+ signal?.throwIfAborted()
88
92
  const slice = await handle.read(offset, PAGE, signal === undefined ? undefined : { signal })
89
93
  const page = ((slice && slice.events) ? slice.events : []) as readonly UsageSessionEvent[]
90
94
  if (page.length === 0) break
@@ -98,7 +102,7 @@ async function readEvents(
98
102
  }
99
103
  if (typeof persistence.inspect === 'function') {
100
104
  const inspection = await persistence.inspect(id, signal)
101
- return { events: inspection.events, inheritedCut: inspection.inheritedEventCount ?? 0 }
105
+ return { events: inspection.events, inheritedCut: inheritedCount(inspection.inheritedEventCount) }
102
106
  }
103
107
  throw new Error('no readable persistence seam (need open or inspect)')
104
108
  }
@@ -108,49 +112,59 @@ async function sumOneSession(
108
112
  persistence: UsageSessionPersistence,
109
113
  id: string,
110
114
  signal?: AbortSignal,
111
- ): Promise<SessionUsageTotals> {
112
- const { events, inheritedCut } = await readEvents(persistence, id, signal)
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)
113
122
  const fold = new UsageFold()
114
123
  const totals: SessionUsageTotals = { ...ZERO }
124
+ const compaction: SessionUsageTotals = { ...ZERO }
115
125
  for (const ev of events) {
116
126
  if (inheritedCut > 0 && typeof ev.seq === 'number' && ev.seq < inheritedCut) continue
117
127
  const sample = fold.fold(ev)
118
128
  if (sample === null || sample.turn) continue
119
- totals.uncachedInputTokens += sample.inputTokens
120
- totals.outputTokens += sample.outputTokens
121
- totals.cacheReadTokens += sample.cacheReadTokens
122
- totals.cacheWriteTokens += sample.cacheWriteTokens
129
+ const buckets: SessionUsageTotals = {
130
+ uncachedInputTokens: sample.inputTokens,
131
+ outputTokens: sample.outputTokens,
132
+ cacheReadTokens: sample.cacheReadTokens,
133
+ cacheWriteTokens: sample.cacheWriteTokens,
134
+ }
135
+ add(totals, buckets)
136
+ if (ev.type === 'compaction/summary') add(compaction, buckets)
123
137
  }
124
- return totals
138
+ return { totals, compaction }
125
139
  }
126
140
 
127
141
  export class SessionUsageService {
128
- /** Cached trees, each remembering every session id it was built from, so a
129
- * live event on ANY of them (a subagent's own event changes its ancestors'
130
- * totals too) can invalidate exactly the affected trees. */
131
- private cache = new Map<string, { at: number; value: SessionUsageTree; members: Set<string> }>()
132
-
133
- /** Drop every cached tree that counted `sessionId` (its own tree, or any
134
- * ancestor's tree that folded it in as a subagent). Called by the collector
135
- * on each live session event. */
136
- invalidate(sessionId: string): void {
137
- for (const [rootId, entry] of this.cache) {
138
- if (entry.members.has(sessionId)) this.cache.delete(rootId)
139
- }
142
+ /** Short-lived tree reads; any live event can invalidate tree membership. */
143
+ private cache = new Map<string, { at: number; value: SessionUsageTree }>()
144
+ /** Any event can introduce a new descendant while an async read is running. */
145
+ private generation = 0
146
+
147
+ /** Invalidate conservatively: an unseen session may be a newly-created child
148
+ * of ANY cached root, so membership-only invalidation is not safe. */
149
+ invalidate(_sessionId: string): void {
150
+ this.generation++
151
+ this.cache.clear()
140
152
  }
141
153
 
142
154
  /** Resolve the whole session tree rooted at `rootId` and sum its usage.
143
155
  * `self` is the root's own usage, `subagents` every descendant's, `total`
144
- * the sum. Unknown roots answer zeroed totals rather than throwing: the pill
145
- * must never break the composer. */
156
+ * the sum. Unreadable logs reject rather than publishing partial totals;
157
+ * callers can retain their last good display while surfacing the error. */
146
158
  async tree(
147
159
  persistence: UsageSessionPersistence,
148
160
  sessions: UsageSessionStore | undefined,
149
161
  rootId: string,
150
162
  signal?: AbortSignal,
151
163
  ): Promise<SessionUsageTree> {
164
+ signal?.throwIfAborted()
152
165
  const hit = this.cache.get(rootId)
153
166
  if (hit !== undefined && Date.now() - hit.at < CACHE_TTL_MS) return hit.value
167
+ const generation = this.generation
154
168
 
155
169
  const headers = await this.listHeaders(persistence, signal)
156
170
  const children = new Map<string, string[]>()
@@ -163,10 +177,10 @@ export class SessionUsageService {
163
177
  }
164
178
  // A brand-new live subagent may not be materialized in persistence yet;
165
179
  // the live store still knows its parent, so fold those in too.
166
- for (const handle of sessions?.list() ?? []) {
167
- const parent = (handle as { parentSession?: string }).parentSession
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
168
183
  if (typeof parent !== 'string' || parent === '' || parent === handle.id) continue
169
- if (headers.some((h) => h.id === handle.id)) continue
170
184
  const bucket = children.get(parent)
171
185
  if (bucket === undefined) children.set(parent, [handle.id])
172
186
  else if (!bucket.includes(handle.id)) bucket.push(handle.id)
@@ -185,23 +199,21 @@ export class SessionUsageService {
185
199
  }
186
200
  }
187
201
 
188
- const self = await sumOneSession(persistence, rootId, signal).catch(() => ({ ...ZERO }))
202
+ const root = await sumOneSession(persistence, rootId, signal, live.get(rootId))
203
+ const self = root.totals
189
204
  const subagents: SessionUsageTotals = { ...ZERO }
190
205
  for (const id of descendants) {
191
- const one = await sumOneSession(persistence, id, signal).catch(() => ({ ...ZERO }))
192
- add(subagents, one)
206
+ const one = await sumOneSession(persistence, id, signal, live.get(id))
207
+ add(subagents, one.totals)
193
208
  }
194
209
  const total: SessionUsageTotals = { ...self }
195
210
  add(total, subagents)
196
- const value: SessionUsageTree = { self, subagents, total, subagentSessions: descendants.length }
211
+ const value: SessionUsageTree = { self, selfCompaction: root.compaction, subagents, total, subagentSessions: descendants.length }
197
212
 
198
- if (signal?.aborted !== true) {
213
+ signal?.throwIfAborted()
214
+ if (generation === this.generation) {
199
215
  if (this.cache.size >= CACHE_MAX) this.cache.clear()
200
- this.cache.set(rootId, {
201
- at: Date.now(),
202
- value,
203
- members: new Set([rootId, ...descendants]),
204
- })
216
+ this.cache.set(rootId, { at: Date.now(), value })
205
217
  }
206
218
  return value
207
219
  }