oc-metricboard 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "OpenCode TUI sidebar plugin showing real-time LLM metrics (TPS, TTFT, tokens) with sub-agent × model breakdown",
5
5
  "keywords": [
6
6
  "opencode",
package/src/collector.ts CHANGED
@@ -303,14 +303,28 @@ export function createCollector(
303
303
  return { rootID: requestedSessionID, ids: requestedIDs }
304
304
  }
305
305
 
306
- function aggregateByModel(ids: readonly string[], now: number, scope: MetricsScope): ModelMetrics[] {
306
+ function aggregateByModel(
307
+ ids: readonly string[],
308
+ now: number,
309
+ scope: MetricsScope,
310
+ rootID: string,
311
+ foregroundTurnStart: number,
312
+ ): ModelMetrics[] {
307
313
  // Group requests by (modelID, providerID) — one row per distinct model
308
- // across all sub-agent sessions, with a ×N session count.
314
+ // across all sub-agent sessions, with a ×N session count. Apply the same
315
+ // foreground-turn membership filter as the token totals: a session whose
316
+ // last turn predates the foreground turn (e.g. a model the user switched
317
+ // away from hours ago) would otherwise linger as a stale zombie row that
318
+ // is absent from the aggregate totals.
309
319
  const modelGroups = new Map<string, RequestMetrics[]>()
310
320
 
311
321
  for (const id of ids) {
312
322
  const request = state.requests.get(id)
313
323
  if (!request) continue
324
+ const turn = state.turns.get(id)
325
+ const belongsToForegroundTurn = id === rootID
326
+ || Boolean(turn && (turn.turnStartTime >= foregroundTurnStart || !turn.isComplete))
327
+ if (!belongsToForegroundTurn) continue
314
328
 
315
329
  // Key: modelID|providerID (model-grouped, not per-session)
316
330
  const key = `${request.modelID}|${request.providerID}`
@@ -557,7 +571,7 @@ export function createCollector(
557
571
  }
558
572
 
559
573
  // NEW: Build per-model breakdown for tree scope
560
- const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope) : []
574
+ const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope, rootID, foregroundTurnStart) : []
561
575
  // Turn-level TTFT: opencode stamps step.started at the first token, so
562
576
  // intra-turn steps cannot measure their own request start (they would
563
577
  // collapse to ~0). Anchor TTFT to the turn's user message instead —
@@ -138,9 +138,8 @@ async function observablePartTimes(api: HydrationApi, messageID: string, now: nu
138
138
  return { first: toPerformanceTime(firstWall, now), last: toPerformanceTime(lastWall, now) }
139
139
  }
140
140
 
141
- function hydrateSessionTiming(
141
+ export function hydrateSessionTiming(
142
142
  rawMessages: readonly unknown[],
143
- status: string,
144
143
  now: number,
145
144
  ): SessionTiming {
146
145
  const intervals: Array<{ start: number; end: number }> = []
@@ -183,10 +182,16 @@ function hydrateSessionTiming(
183
182
  }
184
183
  }
185
184
 
186
- const activeSince = status === "idle" ? null : toPerformanceTime(groupStartWall, now)
187
- if (activeSince === null && groupStartWall !== null) closeGroup()
185
+ // Hydration NEVER leaves an open active interval. The session-status lookup
186
+ // is unreliable across host SDK dialects (may resolve to '' — see the turn
187
+ // completion fix), and an open interval anchored to a pre-process wall time
188
+ // becomes {start: negative, end: now} in performance-time: its span then
189
+ // includes the entire era before this TUI process existed, inflating the
190
+ // Session readout by the session's full age and growing in real time while
191
+ // idle. Activity accounting resumes exclusively via live busy/idle events.
192
+ if (groupStartWall !== null) closeGroup()
188
193
  const elapsedMs = Math.round(intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0))
189
- return { elapsedMs, activeSince, intervals }
194
+ return { elapsedMs, activeSince: null, intervals }
190
195
  }
191
196
 
192
197
  export interface HydrateSessionInput {
@@ -235,6 +240,7 @@ export async function hydrateSession(input: HydrateSessionInput): Promise<boolea
235
240
  await input.api.client.session.status({ id: input.sessionID }),
236
241
  input.sessionID,
237
242
  ))
243
+ log(`hydrate session=${input.sessionID.slice(0, 8)} status='${sessionStatus}'`)
238
244
  const shouldReuseExisting = existing?.messageID === assistant.messageID
239
245
 
240
246
  const current = shouldReuseExisting ? existing : createFreshMetrics(
@@ -287,7 +293,12 @@ export async function hydrateSession(input: HydrateSessionInput): Promise<boolea
287
293
  applyAssistantTokens(stepMetrics, step.tokens)
288
294
  retireRequestIntoTurn(turn, stepMetrics)
289
295
  }
290
- if (sessionStatus === "idle") completeTurn(turn, completedTime ?? input.now)
296
+ // Turn completion: the session-status lookup is unreliable across host SDK
297
+ // dialects (may resolve to an unrecognizable shape → status ''). The last
298
+ // assistant message's own completed flag is authoritative: if it finished,
299
+ // its turn is over. An in-flight resumed message (completed=false) keeps
300
+ // the turn open.
301
+ if (sessionStatus === "idle" || current.isComplete) completeTurn(turn, completedTime ?? input.now)
291
302
  input.state.turns.set(input.sessionID, turn)
292
303
 
293
304
  // Write-point race guard: the checks above ran before several awaits. By
@@ -310,9 +321,18 @@ export async function hydrateSession(input: HydrateSessionInput): Promise<boolea
310
321
  input.state.sessionModels.set(input.sessionID, { modelID: assistant.modelID, providerID: assistant.providerID })
311
322
  }
312
323
  if (!input.state.sessionTimings.has(input.sessionID)) {
313
- input.state.sessionTimings.set(
314
- input.sessionID,
315
- hydrateSessionTiming(rawMessages, sessionStatus, input.now),
324
+ const timing = hydrateSessionTiming(rawMessages, input.now)
325
+ input.state.sessionTimings.set(input.sessionID, timing)
326
+ // Debug: timing hydration summary — pinpoints where inflated Session
327
+ // elapsed values come from (initial value vs runtime accumulation).
328
+ const intervals = timing.intervals ?? []
329
+ const maxSpan = intervals.reduce((m, iv) => Math.max(m, iv.end - iv.start), 0)
330
+ const oldestStart = intervals.reduce((m, iv) => Math.min(m, iv.start), Number.POSITIVE_INFINITY)
331
+ log(
332
+ `timing hydrate session=${input.sessionID.slice(0, 8)} elapsed=${Math.round(timing.elapsedMs)}ms`
333
+ + ` activeSinceAge=${timing.activeSince === null ? "null" : `${Math.round(input.now - timing.activeSince)}ms`}`
334
+ + ` intervals=${intervals.length} maxSpan=${Math.round(maxSpan)}ms`
335
+ + ` oldestStartAge=${Number.isFinite(oldestStart) ? `${Math.round(input.now - oldestStart)}ms` : "n/a"}`,
316
336
  )
317
337
  }
318
338
  return true