oc-metricboard 0.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.
@@ -0,0 +1,615 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
+ import type { BarConfig, CacheReadCompleteness, MetricsAggregate, MetricsScope, ModelMetrics, RequestMetrics } from "./types"
3
+ import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics"
4
+ import { registerEventHandlers } from "./event-handlers"
5
+ import type { CollectorState } from "./collector-state"
6
+ import type { MetricsEventApi } from "./event-bus"
7
+ import { hydrateSession, isHydrationApi, type HydrationApi } from "./session-hydration"
8
+ import { createSessionTree } from "./session-tree"
9
+ import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing"
10
+ import { clearLiveSpeed, getLiveTps } from "./live-speed"
11
+ import { liveRequestOutput, turnInputTokens } from "./turn-state"
12
+
13
+ export type MetricsListener = () => void
14
+ type MetricsHydrationApi = MetricsEventApi & HydrationApi
15
+
16
+ interface TreeHydrationApi {
17
+ readonly client: {
18
+ readonly session: {
19
+ children(input: { sessionID: string }): Promise<unknown>
20
+ }
21
+ }
22
+ }
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value)
26
+ }
27
+
28
+ function isTreeHydrationApi(value: unknown): value is TreeHydrationApi {
29
+ if (!isRecord(value) || !isRecord(value.client)) return false
30
+ const session = isRecord(value.client.session) ? value.client.session : null
31
+ return session !== null && typeof session.children === "function"
32
+ }
33
+
34
+ function childSessions(value: unknown): Array<{ id: string; parentID: string | null }> {
35
+ if (isRecord(value) && value.error !== undefined && value.error !== null) {
36
+ throw new Error(`session.children returned an error: ${String(value.error)}`)
37
+ }
38
+ const data = Array.isArray(value)
39
+ ? value
40
+ : isRecord(value) && Array.isArray(value.data) ? value.data : []
41
+ return data.flatMap((item) => {
42
+ if (!isRecord(item) || typeof item.id !== "string" || item.id.length === 0) return []
43
+ return [{ id: item.id, parentID: typeof item.parentID === "string" ? item.parentID : null }]
44
+ })
45
+ }
46
+
47
+ export interface MetricsCollector {
48
+ getCurrent(sessionID: string): RequestMetrics | null
49
+ getAggregate(sessionID: string, scope: MetricsScope, now?: number): MetricsAggregate | null
50
+ getSessionElapsedMs(sessionID: string, scope?: MetricsScope, now?: number): number
51
+ getChildSessionCount(sessionID: string): number
52
+ subscribe(listener: MetricsListener): () => void
53
+ dispose(): void
54
+ }
55
+
56
+ export function createCollector(
57
+ api: TuiPluginApi,
58
+ config: BarConfig,
59
+ log: (msg: string) => void,
60
+ ): MetricsCollector
61
+ export function createCollector(
62
+ api: MetricsEventApi,
63
+ config: BarConfig,
64
+ log: (msg: string) => void,
65
+ ): MetricsCollector
66
+ export function createCollector(
67
+ api: MetricsHydrationApi,
68
+ config: BarConfig,
69
+ log: (msg: string) => void,
70
+ ): MetricsCollector
71
+ export function createCollector(
72
+ api: TuiPluginApi | MetricsEventApi | MetricsHydrationApi,
73
+ config: BarConfig,
74
+ log: (msg: string) => void,
75
+ ): MetricsCollector {
76
+ const state: CollectorState = {
77
+ requests: new Map(),
78
+ turns: new Map(),
79
+ liveSpeeds: new Map(),
80
+ holdTimers: new Map(),
81
+ sessionTree: createSessionTree(),
82
+ sessionModels: new Map(),
83
+ sessionTimings: new Map(),
84
+ userMessageIds: new Map(),
85
+ assistantMessageIds: new Map(),
86
+ partTokenEstimates: new Map(),
87
+ sessionAliases: new Map(),
88
+ seenEventKeys: new Set(),
89
+ seenEventOrder: [],
90
+ lastRequestSessionID: null,
91
+ }
92
+ const listeners = new Set<MetricsListener>()
93
+ const hydrationApi = isHydrationApi(api) ? api : null
94
+ const treeHydrationApi = isTreeHydrationApi(api) ? api : null
95
+ const hydratedSessions = new Set<string>()
96
+ const hydratingSessions = new Set<string>()
97
+ const hydrationRetryAfter = new Map<string, number>()
98
+ // Cap hydration attempts per session: a session with no positive-token
99
+ // history will never succeed, and retrying every 2s on every render tick
100
+ // would fire SDK network requests indefinitely. Give up after N attempts.
101
+ const hydrationRetries = new Map<string, number>()
102
+ const MAX_HYDRATION_RETRIES = 5
103
+ const loggedFallbacks = new Set<string>()
104
+ const hydratedTreeRoots = new Set<string>()
105
+ const hydratingTreeRoots = new Set<string>()
106
+ const treeRetryAfter = new Map<string, number>()
107
+ let disposed = false
108
+
109
+ // Short-TTL aggregate cache: SidebarMetrics calls getAggregate once per row
110
+ // (~10 rows) per 200ms tick, and each call traverses the whole session tree.
111
+ // Reusing the result within one tick avoids redundant O(tree) work.
112
+ const AGGREGATE_CACHE_TTL_MS = 150
113
+ let aggregateCacheKey = ""
114
+ let aggregateCacheAt = 0
115
+ let aggregateCacheValue: MetricsAggregate | null = null
116
+
117
+ function notify(): void {
118
+ if (disposed) return
119
+ // Invalidate the aggregate cache: events mean state changed.
120
+ aggregateCacheKey = ""
121
+ aggregateCacheAt = 0
122
+ aggregateCacheValue = null
123
+ for (const listener of listeners) listener()
124
+ }
125
+
126
+ function clearHoldTimer(sessionID: string): void {
127
+ const timer = state.holdTimers.get(sessionID)
128
+ if (timer) {
129
+ clearTimeout(timer)
130
+ state.holdTimers.delete(sessionID)
131
+ }
132
+ }
133
+
134
+ const disposers = registerEventHandlers({
135
+ api,
136
+ config,
137
+ log,
138
+ state,
139
+ actions: {
140
+ notify,
141
+ startSessionTiming: (sessionID, now) => startSessionTiming(state.sessionTimings, sessionID, now),
142
+ stopSessionTiming: (sessionID, now) => stopSessionTiming(state.sessionTimings, sessionID, now),
143
+ clearHoldTimer,
144
+ },
145
+ })
146
+
147
+ function hydrate(sessionID: string): void {
148
+ if (!hydrationApi || hydratedSessions.has(sessionID) || hydratingSessions.has(sessionID)) return
149
+ const now = performance.now()
150
+ if ((hydrationRetryAfter.get(sessionID) ?? 0) > now) return
151
+ if ((hydrationRetries.get(sessionID) ?? 0) >= MAX_HYDRATION_RETRIES) return
152
+ hydratingSessions.add(sessionID)
153
+
154
+ // Hydration is async (SDK client calls). Kick it off fire-and-forget;
155
+ // completion re-renders the UI via notify().
156
+ void (async () => {
157
+ let hydrated = false
158
+ try {
159
+ hydrated = await hydrateSession({ api: hydrationApi, state, sessionID, now })
160
+ } catch (error) {
161
+ log(`session hydration failed: session=${sessionID} error=${String(error)}`)
162
+ } finally {
163
+ hydratingSessions.delete(sessionID)
164
+ }
165
+ if (disposed) return
166
+ const current = state.requests.get(sessionID)
167
+ if (hydrated && current) {
168
+ hydratedSessions.add(sessionID)
169
+ hydrationRetryAfter.delete(sessionID)
170
+ hydrationRetries.delete(sessionID)
171
+ // Drop live-speed samples: reopening a session replays history deltas
172
+ // in a burst, which would inflate the rolling TPS window to absurd
173
+ // values. Historical token counts are restored via exact tokens, so
174
+ // replay-produced samples are garbage and must be discarded.
175
+ clearLiveSpeed(state.liveSpeeds, sessionID)
176
+ log(`hydrated session state: session=${sessionID} message=${current.messageID} in=${current.exactInputTokens} out=${current.exactOutputTokens}`)
177
+ notify()
178
+ } else {
179
+ hydrationRetryAfter.set(sessionID, now + 2000)
180
+ hydrationRetries.set(sessionID, (hydrationRetries.get(sessionID) ?? 0) + 1)
181
+ }
182
+ })()
183
+ }
184
+
185
+ function hydrateTree(rootSessionID: string): void {
186
+ if (!treeHydrationApi || hydratedTreeRoots.has(rootSessionID) || hydratingTreeRoots.has(rootSessionID)) return
187
+ const now = performance.now()
188
+ if ((treeRetryAfter.get(rootSessionID) ?? 0) > now) return
189
+ hydratingTreeRoots.add(rootSessionID)
190
+
191
+ void (async () => {
192
+ try {
193
+ let parents = [rootSessionID]
194
+ const visited = new Set<string>(parents)
195
+ while (parents.length > 0) {
196
+ const responses = await Promise.all(parents.map(async (parentID) => ({
197
+ parentID,
198
+ response: await treeHydrationApi.client.session.children({ sessionID: parentID }),
199
+ })))
200
+ if (disposed) return
201
+ const next: string[] = []
202
+ for (const { parentID, response } of responses) {
203
+ for (const child of childSessions(response)) {
204
+ const childID = child.id
205
+ state.sessionTree.setParent(childID, child.parentID ?? parentID)
206
+ hydrate(childID)
207
+ if (!visited.has(childID)) {
208
+ visited.add(childID)
209
+ next.push(childID)
210
+ }
211
+ }
212
+ }
213
+ parents = next
214
+ }
215
+ hydratedTreeRoots.add(rootSessionID)
216
+ treeRetryAfter.delete(rootSessionID)
217
+ notify()
218
+ } catch (error) {
219
+ if (!disposed) {
220
+ treeRetryAfter.set(rootSessionID, performance.now() + 2000)
221
+ log(`tree hydration failed: session=${rootSessionID} error=${String(error)}`)
222
+ }
223
+ } finally {
224
+ hydratingTreeRoots.delete(rootSessionID)
225
+ }
226
+ })()
227
+ }
228
+
229
+ function normalizeSessionID(sessionID: string): string {
230
+ return typeof sessionID === "string" ? sessionID : ""
231
+ }
232
+
233
+ function hasUsefulMetrics(metrics: readonly RequestMetrics[]): boolean {
234
+ return metrics.some((item) => (
235
+ getDisplayInputTokens(item) > 0
236
+ || getDisplayOutputTokens(item) > 0
237
+ || item.exactCacheReadTokens > 0
238
+ || item.exactCacheWriteTokens > 0
239
+ || item.firstTokenTime !== null
240
+ || item.lastDeltaTime !== null
241
+ ))
242
+ }
243
+
244
+ function hasUsefulSession(sessionID: string): boolean {
245
+ const request = state.requests.get(sessionID)
246
+ const turn = state.turns.get(sessionID)
247
+ return Boolean(
248
+ (request && hasUsefulMetrics([request]))
249
+ || (turn && (
250
+ turn.finalizedOutputTokens > 0
251
+ || turn.hasStickyContextTokens
252
+ )),
253
+ )
254
+ }
255
+
256
+ function usefulMetricsFor(ids: readonly string[]): readonly RequestMetrics[] {
257
+ const metrics = ids
258
+ .map((id) => state.requests.get(id))
259
+ .filter((item): item is RequestMetrics => item !== undefined)
260
+ return metrics.length > 0 && ids.some(hasUsefulSession) ? metrics : []
261
+ }
262
+
263
+ function aliasScopeSessionIDs(sessionID: string, scope: MetricsScope): { readonly rootID: string; readonly ids: readonly string[] } | null {
264
+ for (const aliasID of state.sessionAliases.get(sessionID) ?? []) {
265
+ const aliasIDs = state.sessionTree.getScopeSessionIDs(aliasID, scope)
266
+ if (usefulMetricsFor(aliasIDs).length > 0) {
267
+ return { rootID: aliasID, ids: aliasIDs }
268
+ }
269
+ }
270
+ return null
271
+ }
272
+
273
+ function resolveMetricsSessionID(sessionID: string): string {
274
+ const requestedSessionID = normalizeSessionID(sessionID)
275
+ const requested = state.requests.get(requestedSessionID)
276
+ if ((requested && hasUsefulMetrics([requested])) || hasUsefulSession(requestedSessionID)) return requestedSessionID
277
+ const alias = aliasScopeSessionIDs(requestedSessionID, "current")
278
+ if (alias) return alias.rootID
279
+ return requestedSessionID
280
+ }
281
+
282
+ function scopeSessionIDs(sessionID: string, scope: MetricsScope): { readonly rootID: string; readonly ids: readonly string[] } {
283
+ const requestedSessionID = normalizeSessionID(sessionID)
284
+ const requestedIDs = state.sessionTree.getScopeSessionIDs(requestedSessionID, scope)
285
+ if (usefulMetricsFor(requestedIDs).length > 0) {
286
+ return { rootID: requestedSessionID, ids: requestedIDs }
287
+ }
288
+
289
+ const alias = aliasScopeSessionIDs(requestedSessionID, scope)
290
+ if (alias) {
291
+ const fallbackKey = `${requestedSessionID}->${alias.rootID}`
292
+ if (!loggedFallbacks.has(fallbackKey)) {
293
+ loggedFallbacks.add(fallbackKey)
294
+ log(`sidebar session alias: requested=${requestedSessionID || "(empty)"} metrics=${alias.rootID}`)
295
+ }
296
+ return alias
297
+ }
298
+
299
+ return { rootID: requestedSessionID, ids: requestedIDs }
300
+ }
301
+
302
+ function aggregateByModel(ids: readonly string[], now: number, scope: MetricsScope): ModelMetrics[] {
303
+ // Group requests by (modelID, providerID) — one row per distinct model
304
+ // across all sub-agent sessions, with a ×N session count.
305
+ const modelGroups = new Map<string, RequestMetrics[]>()
306
+
307
+ for (const id of ids) {
308
+ const request = state.requests.get(id)
309
+ if (!request) continue
310
+
311
+ // Key: modelID|providerID (model-grouped, not per-session)
312
+ const key = `${request.modelID}|${request.providerID}`
313
+ const group = modelGroups.get(key) ?? []
314
+ group.push(request)
315
+ modelGroups.set(key, group)
316
+ }
317
+
318
+ const result: ModelMetrics[] = []
319
+
320
+ for (const [key, metrics] of modelGroups) {
321
+ const [modelID, providerID] = key.split("|")
322
+
323
+ // Use aggregateRequestMetrics logic but per-model
324
+ let inputTokens = 0
325
+ let outputTokens = 0
326
+ let cacheReadTokens = 0
327
+ let exactCacheCount = 0
328
+ let requestStartTime = Number.POSITIVE_INFINITY
329
+ let firstTokenTime: number | null = null
330
+ let completeTime: number | null = null
331
+ let isStreaming = false
332
+ let isComplete = true
333
+ const sessionCount = new Set(metrics.map((m) => m.sessionID)).size
334
+
335
+ for (const m of metrics) {
336
+ inputTokens += getDisplayInputTokens(m)
337
+ outputTokens += getDisplayOutputTokens(m)
338
+ if (m.hasExactCacheReadTokens) {
339
+ cacheReadTokens += Math.max(0, m.exactCacheReadTokens)
340
+ exactCacheCount += 1
341
+ }
342
+ requestStartTime = Math.min(requestStartTime, m.requestStartTime)
343
+ if (m.firstTokenTime !== null) {
344
+ firstTokenTime = firstTokenTime === null ? m.firstTokenTime : Math.min(firstTokenTime, m.firstTokenTime)
345
+ }
346
+ if (m.completeTime !== null) {
347
+ completeTime = completeTime === null ? m.completeTime : Math.max(completeTime, m.completeTime)
348
+ }
349
+ isStreaming = isStreaming || m.isStreaming
350
+ isComplete = isComplete && m.isComplete
351
+ }
352
+
353
+ const cacheReadCompleteness: CacheReadCompleteness =
354
+ exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial"
355
+ const ttft = firstTokenTime === null ? null : Math.round(firstTokenTime - requestStartTime)
356
+
357
+ // Live TPS for this model group
358
+ let liveTps = 0
359
+ let liveRateCount = 0
360
+ for (const m of metrics) {
361
+ const rate = getLiveTps(state.liveSpeeds.get(m.sessionID), now)
362
+ if (rate !== null) {
363
+ liveTps += rate
364
+ liveRateCount += 1
365
+ }
366
+ }
367
+
368
+ // TPS logic (same as main aggregate with the same timing sanity check):
369
+ // - Streaming with live samples: live rolling-window TPS.
370
+ // - Complete: freeze final average TPS (firstTokenTime preferred,
371
+ // requestStartTime as end-to-end fallback).
372
+ // - Active but no live samples: running average to `now`.
373
+ // Negative timestamps (messages predating this TUI process) are unusable
374
+ // for rate math — treated as missing.
375
+ let displayTps: number | null = null
376
+ if (liveRateCount > 0 && !isComplete) {
377
+ // Live TPS must pass the same sanity gate: replay bursts can seed
378
+ // absurd windowed rates (1300+ t/s) which are never real.
379
+ const saneLive = Number.isFinite(liveTps) && liveTps >= 0 && liveTps <= 300
380
+ if (saneLive) {
381
+ displayTps = Math.round(liveTps * 10) / 10
382
+ }
383
+ }
384
+ if (displayTps === null && metrics.length > 0) {
385
+ // Frozen TPS: use the LATEST request's own tokens and timing so the
386
+ // numerator and denominator describe the same generation. The summed
387
+ // `outputTokens` spans multiple requests; dividing it by the group
388
+ // window would inflate the rate (e.g. 1800+ t/s). "Latest" is by
389
+ // requestStartTime (chronological), not array order (tree DFS).
390
+ const latest = metrics.reduce((a, b) => (b.requestStartTime >= a.requestStartTime ? b : a))
391
+ const frTokens = getDisplayOutputTokens(latest)
392
+ const genStart = latest.firstTokenTime ?? latest.requestStartTime
393
+ const genEnd = isComplete && completeTime !== null ? completeTime : now
394
+ const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
395
+ const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
396
+ if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
397
+ displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
398
+ }
399
+ }
400
+
401
+ result.push({
402
+ modelID,
403
+ providerID,
404
+ sessionID: providerID,
405
+ sessionCount,
406
+ inputTokens,
407
+ outputTokens,
408
+ cacheReadTokens,
409
+ cacheReadCompleteness,
410
+ requestStartTime: requestStartTime === Number.POSITIVE_INFINITY ? now : requestStartTime,
411
+ firstTokenTime,
412
+ completeTime: isComplete ? completeTime ?? now : null,
413
+ ttft,
414
+ liveTps: displayTps,
415
+ isStreaming,
416
+ isComplete,
417
+ })
418
+ }
419
+
420
+ // Sort by provider then model
421
+ return result.sort((a, b) => {
422
+ const providerCompare = a.providerID.localeCompare(b.providerID)
423
+ if (providerCompare !== 0) return providerCompare
424
+ return a.modelID.localeCompare(b.modelID)
425
+ })
426
+ }
427
+
428
+ return {
429
+ getCurrent(sessionID: string): RequestMetrics | null {
430
+ const requestedSessionID = normalizeSessionID(sessionID)
431
+ hydrate(requestedSessionID)
432
+ return state.requests.get(resolveMetricsSessionID(requestedSessionID)) ?? null
433
+ },
434
+ getAggregate(sessionID: string, scope: MetricsScope, now = performance.now()): MetricsAggregate | null {
435
+ const requestedSessionID = normalizeSessionID(sessionID)
436
+ hydrate(requestedSessionID)
437
+ if (scope === "tree") hydrateTree(requestedSessionID)
438
+
439
+ // Serve from cache when fresh: multiple rows request the same aggregate
440
+ // within one render tick.
441
+ const cacheKey = `${requestedSessionID}:${scope}`
442
+ if (cacheKey === aggregateCacheKey && now - aggregateCacheAt < AGGREGATE_CACHE_TTL_MS) {
443
+ return aggregateCacheValue
444
+ }
445
+
446
+ const { rootID, ids } = scopeSessionIDs(requestedSessionID, scope)
447
+ const foregroundTurn = state.turns.get(rootID)
448
+ const foregroundRequest = state.requests.get(rootID)
449
+
450
+ if (!foregroundTurn && !foregroundRequest) return null
451
+
452
+ const foregroundTurnStart = foregroundTurn?.turnStartTime ?? foregroundRequest!.requestStartTime
453
+ let inputTokens = 0
454
+ let outputTokens = 0
455
+ let cacheReadTokens = 0
456
+ let cacheExactCount = 0
457
+ let contributingCount = 0
458
+ let liveTps = 0
459
+ let liveRateCount = 0
460
+ let isStreaming = false
461
+ const contributingSessionIDs: string[] = []
462
+
463
+ for (const id of ids) {
464
+ const turn = state.turns.get(id)
465
+ const request = state.requests.get(id)
466
+ if (!turn && !request) continue
467
+ const belongsToForegroundTurn = id === rootID
468
+ || Boolean(turn && (turn.turnStartTime >= foregroundTurnStart || !turn.isComplete))
469
+ if (belongsToForegroundTurn) {
470
+ const sessionInput = turn ? turnInputTokens(turn, request) : request ? getDisplayInputTokens(request) : 0
471
+ const sessionOutput = turn
472
+ ? turn.finalizedOutputTokens + liveRequestOutput(turn, request)
473
+ : request ? getDisplayOutputTokens(request) : 0
474
+ const hasContribution = sessionInput > 0 || sessionOutput > 0 || Boolean(request?.isStreaming)
475
+ if (hasContribution) {
476
+ inputTokens += sessionInput
477
+ outputTokens += sessionOutput
478
+ contributingCount += 1
479
+ contributingSessionIDs.push(id)
480
+ if (turn?.hasStickyCacheReadTokens) {
481
+ cacheReadTokens += turn.stickyCacheReadTokens
482
+ cacheExactCount += 1
483
+ } else if (!turn && request?.hasExactCacheReadTokens) {
484
+ cacheReadTokens += Math.max(0, request.exactCacheReadTokens)
485
+ cacheExactCount += 1
486
+ }
487
+ }
488
+ }
489
+
490
+ const rate = getLiveTps(state.liveSpeeds.get(id), now)
491
+ if (rate !== null) {
492
+ liveTps += rate
493
+ liveRateCount += 1
494
+ }
495
+ isStreaming = isStreaming || Boolean(request?.isStreaming)
496
+ }
497
+
498
+ const cacheReadCompleteness: CacheReadCompleteness = cacheExactCount === 0
499
+ ? "unknown"
500
+ : cacheExactCount === contributingCount ? "exact" : "partial"
501
+ const requestStartTime = foregroundTurn?.turnStartTime ?? foregroundRequest!.requestStartTime
502
+ const firstTokenTime = foregroundRequest?.firstTokenTime ?? null
503
+ // Tree-scope completion semantics: the aggregate is NOT complete while
504
+ // any contributing descendant session is still streaming, even if the
505
+ // foreground root finished. Otherwise the frozen-TPS branch would use the
506
+ // root's completeTime as the window end while outputTokens keeps
507
+ // accumulating from sub-agents, understating the rate.
508
+ const foregroundComplete = foregroundTurn?.isComplete ?? foregroundRequest?.isComplete ?? false
509
+ const isComplete = isStreaming ? false : foregroundComplete
510
+ const completeTime = foregroundTurn?.completeTime ?? foregroundRequest?.completeTime ?? null
511
+
512
+ // TPS logic.
513
+ // Timing sanity: hydration converts wall-clock times into the current
514
+ // process's performance.now() base. Messages created BEFORE this TUI
515
+ // process started convert to NEGATIVE timestamps. Any negative (or
516
+ // future) timestamp is unusable for rate math — treat it as missing.
517
+ // - Streaming with live samples: live rolling-window TPS.
518
+ // - Complete: freeze final average TPS using the generation window
519
+ // (firstTokenTime preferred, requestStartTime as end-to-end fallback).
520
+ // - Active but no live samples (reopened busy session): running
521
+ // average to now. genEnd is ALWAYS `now` here — never a stale
522
+ // historical completeTime.
523
+ let displayTps: number | null = null
524
+ if (liveRateCount > 0 && !isComplete) {
525
+ // Live TPS must pass the same sanity gate: replay bursts can seed
526
+ // absurd windowed rates (e.g. 1379 t/s) which are never real.
527
+ const saneLive = Number.isFinite(liveTps) && liveTps >= 0 && liveTps <= 300
528
+ if (saneLive) {
529
+ displayTps = Math.round(liveTps * 10) / 10
530
+ }
531
+ }
532
+ if (displayTps === null && foregroundRequest) {
533
+ // Frozen TPS: use the FOREGROUND request's own tokens and timing so
534
+ // the numerator and denominator describe the same generation. In tree
535
+ // scope `outputTokens` is summed across sub-agents — dividing that by
536
+ // the foreground window would inflate TPS absurdly (1800+ t/s).
537
+ const frTokens = getDisplayOutputTokens(foregroundRequest)
538
+ const genStart = firstTokenTime ?? requestStartTime
539
+ const genEnd = isComplete ? completeTime : now
540
+ const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
541
+ const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
542
+ if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
543
+ displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
544
+ }
545
+ }
546
+
547
+ // NEW: Build per-model breakdown for tree scope
548
+ const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope) : []
549
+ const ttft = foregroundRequest ? getTtft(foregroundRequest) : null
550
+
551
+ const result: MetricsAggregate = {
552
+ sessionIDs: contributingSessionIDs.length > 0 ? contributingSessionIDs : [rootID],
553
+ childSessionCount: scope === "tree" ? state.sessionTree.getChildSessionCount(rootID) : 0,
554
+ inputTokens,
555
+ outputTokens,
556
+ cacheReadTokens,
557
+ cacheReadCompleteness,
558
+ requestStartTime,
559
+ firstTokenTime,
560
+ completeTime: isComplete ? completeTime ?? now : null,
561
+ ttft,
562
+ liveTps: displayTps,
563
+ isStreaming,
564
+ isComplete,
565
+ modelBreakdown,
566
+ }
567
+ aggregateCacheKey = cacheKey
568
+ aggregateCacheAt = now
569
+ aggregateCacheValue = result
570
+ return result
571
+ },
572
+ getSessionElapsedMs(sessionID: string, scope: MetricsScope = "current", now = performance.now()): number {
573
+ const requestedSessionID = normalizeSessionID(sessionID)
574
+ hydrate(requestedSessionID)
575
+ if (scope === "tree") hydrateTree(requestedSessionID)
576
+ const { rootID, ids } = scopeSessionIDs(requestedSessionID, scope)
577
+ if (scope === "current") return getSessionElapsedMs(state.sessionTimings.get(rootID), now)
578
+ return getScopeElapsedMs(state.sessionTimings, ids, now)
579
+ },
580
+ getChildSessionCount(sessionID: string): number {
581
+ return state.sessionTree.getChildSessionCount(sessionID)
582
+ },
583
+ subscribe(listener: MetricsListener): () => void {
584
+ listeners.add(listener)
585
+ return () => { listeners.delete(listener) }
586
+ },
587
+ dispose(): void {
588
+ disposed = true
589
+ for (const dispose of disposers.splice(0)) dispose()
590
+ for (const timer of state.holdTimers.values()) clearTimeout(timer)
591
+ state.holdTimers.clear()
592
+ state.requests.clear()
593
+ state.turns.clear()
594
+ state.liveSpeeds.clear()
595
+ state.sessionTree.clear()
596
+ state.sessionModels.clear()
597
+ state.sessionTimings.clear()
598
+ state.userMessageIds.clear()
599
+ state.assistantMessageIds.clear()
600
+ state.partTokenEstimates.clear()
601
+ state.sessionAliases.clear()
602
+ state.seenEventKeys.clear()
603
+ state.seenEventOrder.length = 0
604
+ state.lastRequestSessionID = null
605
+ loggedFallbacks.clear()
606
+ hydratedSessions.clear()
607
+ hydratingSessions.clear()
608
+ hydrationRetryAfter.clear()
609
+ hydratedTreeRoots.clear()
610
+ hydratingTreeRoots.clear()
611
+ treeRetryAfter.clear()
612
+ listeners.clear()
613
+ },
614
+ }
615
+ }