oc-metricboard 0.1.17 → 0.1.19

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.
@@ -1,7 +1,7 @@
1
1
  import { estimateTokens } from "./metrics";
2
2
  import { parseAssistantTextDelta } from "./event-shapes";
3
3
  import { currentRequest } from "./request-state";
4
- import { eventAggregateID, eventID, eventProperties, stringEventProperty } from "./event-bus";
4
+ import { eventAggregateID, eventID, eventPerfTime, eventProperties, stringEventProperty } from "./event-bus";
5
5
  import { recordLiveTokens } from "./live-speed";
6
6
  import { ensureTurn, recordTurnFirstToken } from "./turn-state";
7
7
  function partEstimateKey(sessionID, messageID, partID) {
@@ -51,7 +51,7 @@ export function applyAssistantDelta(ctx, event) {
51
51
  sessionAliases.add(aggregateID);
52
52
  state.sessionAliases.set(text.sessionID, sessionAliases);
53
53
  }
54
- const now = performance.now();
54
+ const now = eventPerfTime(event, performance.now());
55
55
  actions.startSessionTiming(text.sessionID, now);
56
56
  const key = partEstimateKey(text.sessionID, text.messageID, text.partID);
57
57
  const deltaTokens = estimateTokens(text.delta, ctx.config.estimationRatio);
package/dist/collector.js CHANGED
@@ -1,10 +1,10 @@
1
- import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics";
1
+ import { credibleTps, getDisplayInputTokens, getDisplayOutputTokens, getTtft, MAX_CREDIBLE_TPS } from "./metrics";
2
2
  import { registerEventHandlers } from "./event-handlers";
3
3
  import { hydrateSession, isHydrationApi, callWithFallback } from "./session-hydration";
4
4
  import { createSessionTree } from "./session-tree";
5
5
  import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing";
6
6
  import { clearLiveSpeed, getLiveTps } from "./live-speed";
7
- import { getTurnTtft, liveRequestOutput, turnInputTokens } from "./turn-state";
7
+ import { getTurnTtft, liveRequestOutput, toolOverlapMs, turnInputTokens } from "./turn-state";
8
8
  function isRecord(value) {
9
9
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
10
  }
@@ -358,8 +358,21 @@ export function createCollector(api, config, log) {
358
358
  : latest.isStreaming || latestLastActivity === null ? now : latestLastActivity;
359
359
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
360
360
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
361
+ let primary = null;
361
362
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
362
- displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
363
+ primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
364
+ }
365
+ // Same turn-anchored fallback as the main aggregate, using the latest
366
+ // request's own turn.
367
+ const latestTurn = state.turns.get(latest.sessionID);
368
+ if ((primary === null || primary > MAX_CREDIBLE_TPS) && latestTurn && frTokens > 0) {
369
+ const turnEnd = completeTime ?? latestLastActivity ?? now;
370
+ const turnWindow = turnEnd - latestTurn.turnStartTime
371
+ - toolOverlapMs(latestTurn, latestTurn.turnStartTime, turnEnd);
372
+ displayTps = credibleTps(frTokens, turnWindow) ?? primary;
373
+ }
374
+ else {
375
+ displayTps = primary;
363
376
  }
364
377
  }
365
378
  result.push({
@@ -506,8 +519,22 @@ export function createCollector(api, config, log) {
506
519
  : foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity;
507
520
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
508
521
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
522
+ let primary = null;
509
523
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
510
- displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
524
+ primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
525
+ }
526
+ // Fall back to the turn-anchored window when the per-step window
527
+ // implies a physically incredible rate: per-delta times may be
528
+ // delivery-stamped (batched flushes), while turn admission, step
529
+ // boundaries, and tool spans use discrete truthful anchors.
530
+ if ((primary === null || primary > MAX_CREDIBLE_TPS) && foregroundTurn && frTokens > 0) {
531
+ const turnEnd = completeTime ?? foregroundLastActivity ?? now;
532
+ const turnWindow = turnEnd - foregroundTurn.turnStartTime
533
+ - toolOverlapMs(foregroundTurn, foregroundTurn.turnStartTime, turnEnd);
534
+ displayTps = credibleTps(frTokens, turnWindow) ?? primary;
535
+ }
536
+ else {
537
+ displayTps = primary;
511
538
  }
512
539
  }
513
540
  // NEW: Build per-model breakdown for tree scope
package/dist/event-bus.js CHANGED
@@ -51,6 +51,38 @@ export function statusType(event) {
51
51
  return "";
52
52
  return typeof status.type === "string" ? status.type : "";
53
53
  }
54
+ /**
55
+ * Host-stamped wall-clock time of an event (V2 envelope `created`, epoch ms),
56
+ * or null when absent/implausible. V1 events carry no timestamp at all.
57
+ */
58
+ export function eventCreatedWallTime(event) {
59
+ if (!isRecord(event))
60
+ return null;
61
+ const created = event.created;
62
+ if (typeof created !== "number" || !Number.isFinite(created) || created <= 0)
63
+ return null;
64
+ // Guard against a foreign epoch or clock skew: anything more than a day out
65
+ // is not usable as event time.
66
+ if (Math.abs(created - Date.now()) > 86_400_000)
67
+ return null;
68
+ return created;
69
+ }
70
+ /**
71
+ * Performance-base timestamp for an event. Prefers the host stamp converted
72
+ * into this process's performance.now() base; falls back to `now`.
73
+ *
74
+ * WHY: the V2 host delivers events in batches — a whole turn's deltas can
75
+ * arrive in one flush long after generation. Stamping flush time
76
+ * (performance.now() at handling) collapses the generation window to
77
+ * milliseconds and inflates frozen TPS absurdly (e.g. 259 tokens / 6ms =
78
+ * 42627 t/s). V1 events have no `created`, so this is a no-op for V1.
79
+ */
80
+ export function eventPerfTime(event, now) {
81
+ const wall = eventCreatedWallTime(event);
82
+ if (wall === null)
83
+ return now;
84
+ return now - Math.max(0, Date.now() - wall);
85
+ }
54
86
  export function isMetricsEventApi(value) {
55
87
  if (!isRecord(value))
56
88
  return false;
@@ -3,9 +3,9 @@ import { createFreshMetrics, estimateTokens } from "./metrics";
3
3
  import { parseAssistantMessage, parseAssistantProgressPart, parseAssistantStepEnded, parseAssistantStepStarted, parseSessionInfo, parseTextPart, parseUserMessage, } from "./event-shapes";
4
4
  import { applyAssistantTokens, applySessionModel, hasPositiveAssistantTokens, mergeAssistantModel } from "./request-updates";
5
5
  import { completeRequest, currentRequest } from "./request-state";
6
- import { eventAggregateID, eventID, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus";
6
+ import { eventAggregateID, eventID, eventPerfTime, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus";
7
7
  import { clearLiveSpeed, resetLiveSpeed } from "./live-speed";
8
- import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state";
8
+ import { ensureTurn, recordToolCalled, recordToolSettled, retireRequestIntoTurn, startTurn } from "./turn-state";
9
9
  export function registerEventHandlers(ctx) {
10
10
  const { api, config, log, state, actions } = ctx;
11
11
  const disposers = [];
@@ -86,7 +86,7 @@ export function registerEventHandlers(ctx) {
86
86
  const type = statusType(event);
87
87
  log(`session.status: ${sessionID} -> ${type}`);
88
88
  if (type === "busy") {
89
- const now = performance.now();
89
+ const now = eventPerfTime(event, performance.now());
90
90
  actions.startSessionTiming(sessionID, now);
91
91
  actions.clearHoldTimer(sessionID);
92
92
  const existingTurn = state.turns.get(sessionID);
@@ -108,7 +108,7 @@ export function registerEventHandlers(ctx) {
108
108
  }
109
109
  }
110
110
  if (type === "idle") {
111
- completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log });
111
+ completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log });
112
112
  }
113
113
  });
114
114
  on("session.idle", (event) => {
@@ -116,7 +116,7 @@ export function registerEventHandlers(ctx) {
116
116
  if (sessionID.length === 0)
117
117
  return;
118
118
  recordEventAlias(event, sessionID);
119
- completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log });
119
+ completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log });
120
120
  });
121
121
  // ── message.part.delta ──
122
122
  on("message.part.delta", (event) => {
@@ -130,8 +130,8 @@ export function registerEventHandlers(ctx) {
130
130
  recordEventAlias(event, sessionID);
131
131
  if (field !== "text")
132
132
  return;
133
- actions.startSessionTiming(sessionID, performance.now());
134
- const now = performance.now();
133
+ actions.startSessionTiming(sessionID, eventPerfTime(event, performance.now()));
134
+ const now = eventPerfTime(event, performance.now());
135
135
  ensureTurn(state.turns, sessionID, now);
136
136
  applyAssistantPartDelta(ctx, sessionID, messageID, partID, delta, now);
137
137
  });
@@ -140,7 +140,7 @@ export function registerEventHandlers(ctx) {
140
140
  if (!step)
141
141
  return;
142
142
  recordEventAlias(event, step.sessionID);
143
- const now = performance.now();
143
+ const now = eventPerfTime(event, performance.now());
144
144
  actions.startSessionTiming(step.sessionID, now);
145
145
  const existingTurn = state.turns.get(step.sessionID);
146
146
  if (!existingTurn || existingTurn.isComplete)
@@ -157,6 +157,33 @@ export function registerEventHandlers(ctx) {
157
157
  });
158
158
  on("session.next.text.delta", (event) => applyAssistantDelta(ctx, event));
159
159
  on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event));
160
+ // ── tool execution spans (V2 tool.* events; V1 has none) ──
161
+ // Feeds per-turn tool intervals so frozen TPS can subtract tool time from
162
+ // the turn-anchored window. Unknown shapes are ignored defensively.
163
+ on("session.tool.called", (event) => {
164
+ const sessionID = stringEventProperty(event, "sessionID");
165
+ if (sessionID.length === 0)
166
+ return;
167
+ recordEventAlias(event, sessionID);
168
+ recordToolCalled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
169
+ actions.notify();
170
+ });
171
+ on("session.tool.success", (event) => {
172
+ const sessionID = stringEventProperty(event, "sessionID");
173
+ if (sessionID.length === 0)
174
+ return;
175
+ recordEventAlias(event, sessionID);
176
+ recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
177
+ actions.notify();
178
+ });
179
+ on("session.tool.failed", (event) => {
180
+ const sessionID = stringEventProperty(event, "sessionID");
181
+ if (sessionID.length === 0)
182
+ return;
183
+ recordEventAlias(event, sessionID);
184
+ recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
185
+ actions.notify();
186
+ });
160
187
  on("session.next.step.ended", (event) => {
161
188
  const sessionID = stringEventProperty(event, "sessionID");
162
189
  const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event);
@@ -164,7 +191,7 @@ export function registerEventHandlers(ctx) {
164
191
  if (!step)
165
192
  return;
166
193
  recordEventAlias(event, step.sessionID);
167
- const now = performance.now();
194
+ const now = eventPerfTime(event, performance.now());
168
195
  actions.startSessionTiming(step.sessionID, now);
169
196
  const turn = ensureTurn(state.turns, step.sessionID, now);
170
197
  const existing = state.requests.get(step.sessionID);
@@ -205,7 +232,7 @@ export function registerEventHandlers(ctx) {
205
232
  recordEventAlias(event, sessionID);
206
233
  const rawPart = eventProperty(event, "part");
207
234
  const part = parseTextPart(rawPart);
208
- const now = performance.now();
235
+ const now = eventPerfTime(event, performance.now());
209
236
  const msgIds = state.userMessageIds.get(sessionID);
210
237
  if (part && msgIds?.has(part.messageID)) {
211
238
  const current = state.requests.get(sessionID);
@@ -235,7 +262,7 @@ export function registerEventHandlers(ctx) {
235
262
  const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant);
236
263
  if (nextModel)
237
264
  state.sessionModels.set(sessionID, nextModel);
238
- const now = performance.now();
265
+ const now = eventPerfTime(event, performance.now());
239
266
  const tokens = assistant.tokens;
240
267
  const existing = state.requests.get(sessionID);
241
268
  const turn = ensureTurn(state.turns, sessionID, now);
@@ -278,7 +305,7 @@ export function registerEventHandlers(ctx) {
278
305
  if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
279
306
  return;
280
307
  }
281
- const now = performance.now();
308
+ const now = eventPerfTime(event, performance.now());
282
309
  const previous = state.requests.get(sessionID);
283
310
  const previousTurn = state.turns.get(sessionID);
284
311
  if (previous && previousTurn)
package/dist/metrics.js CHANGED
@@ -113,6 +113,22 @@ export function getTtft(m) {
113
113
  return null;
114
114
  return gateTtft(m.firstTokenTime - m.requestStartTime);
115
115
  }
116
+ /**
117
+ * Upper bound for a believable token rate. Mirrors the live-TPS sanity gate:
118
+ * per-delta times may be delivery-stamped (batched flushes), collapsing the
119
+ * generation window to milliseconds and inflating the quotient absurdly
120
+ * (e.g. 61 tokens / 42ms = 1450 t/s — physically impossible decoding).
121
+ * Windows implying a faster rate fall back to turn-anchored timing.
122
+ */
123
+ export const MAX_CREDIBLE_TPS = 300;
124
+ export function credibleTps(tokens, windowMs) {
125
+ if (!(tokens > 0) || !(windowMs > 0))
126
+ return null;
127
+ const tps = Math.round((tokens / (windowMs / 1000)) * 10) / 10;
128
+ if (!Number.isFinite(tps) || tps < 0 || tps > MAX_CREDIBLE_TPS)
129
+ return null;
130
+ return tps;
131
+ }
116
132
  /**
117
133
  * Calculate average token speed (tokens/second).
118
134
  * Based on cumulative streaming time (now - firstTokenTime).
package/dist/tui-v2.js CHANGED
@@ -64,6 +64,9 @@ const V2_EVENT_MAP = {
64
64
  "session.next.reasoning.delta": "session.reasoning.delta",
65
65
  "session.next.step.started": "session.step.started",
66
66
  "session.next.step.ended": "session.step.ended",
67
+ "session.tool.called": "session.tool.called",
68
+ "session.tool.success": "session.tool.success",
69
+ "session.tool.failed": "session.tool.failed",
67
70
  };
68
71
  function isRecord(value) {
69
72
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -81,7 +84,14 @@ export function translateExecutionStartedToBusy(event) {
81
84
  const sessionID = str(data.sessionID);
82
85
  if (!sessionID)
83
86
  return null;
84
- return { type: "session.status", properties: { sessionID, status: { type: "busy" } } };
87
+ // Preserve the host stamp so downstream timing uses generation time,
88
+ // not the (possibly batched) delivery time.
89
+ const created = typeof event.created === "number" ? event.created : undefined;
90
+ return {
91
+ type: "session.status",
92
+ ...(created !== undefined ? { created } : {}),
93
+ properties: { sessionID, status: { type: "busy" } },
94
+ };
85
95
  }
86
96
  /**
87
97
  * Turn-settled execution maps to the V1 `session.idle` shape that drives
@@ -93,7 +103,12 @@ export function translateExecutionSettledToIdle(event) {
93
103
  const sessionID = str(data.sessionID);
94
104
  if (!sessionID)
95
105
  return null;
96
- return { type: "session.idle", properties: { sessionID } };
106
+ const created = typeof event.created === "number" ? event.created : undefined;
107
+ return {
108
+ type: "session.idle",
109
+ ...(created !== undefined ? { created } : {}),
110
+ properties: { sessionID },
111
+ };
97
112
  }
98
113
  function eventSessionID(event) {
99
114
  if (!isRecord(event))
@@ -12,6 +12,7 @@ export function createTurnMetrics(sessionID, now) {
12
12
  hasStickyContextTokens: false,
13
13
  stickyCacheReadTokens: 0,
14
14
  hasStickyCacheReadTokens: false,
15
+ toolIntervals: [],
15
16
  isComplete: false,
16
17
  };
17
18
  }
@@ -78,6 +79,68 @@ export function recordTurnFirstToken(turn, now) {
78
79
  if (turn.firstTokenTime === null)
79
80
  turn.firstTokenTime = now;
80
81
  }
82
+ /**
83
+ * Record a tool execution span on the session's turn. Tools in one turn may
84
+ * overlap (parallel calls), so every span is kept and the union is computed
85
+ * at query time — a plain sum would double-count overlaps.
86
+ */
87
+ export function recordToolCalled(turns, sessionID, toolID, now) {
88
+ const turn = ensureTurn(turns, sessionID, now);
89
+ turn.toolIntervals.push({ id: toolID, start: now, end: null });
90
+ }
91
+ export function recordToolSettled(turns, sessionID, toolID, now) {
92
+ const turn = turns.get(sessionID);
93
+ if (!turn)
94
+ return;
95
+ if (toolID) {
96
+ const open = turn.toolIntervals.find((interval) => interval.id === toolID && interval.end === null);
97
+ if (open) {
98
+ open.end = Math.max(now, open.start);
99
+ return;
100
+ }
101
+ }
102
+ // Unknown id (or unsettled shape): close the most recent open span so a
103
+ // lost settle event cannot leak an infinite interval.
104
+ for (let i = turn.toolIntervals.length - 1; i >= 0; i--) {
105
+ if (turn.toolIntervals[i].end === null) {
106
+ turn.toolIntervals[i].end = Math.max(now, turn.toolIntervals[i].start);
107
+ return;
108
+ }
109
+ }
110
+ }
111
+ /**
112
+ * Milliseconds of tool execution overlapping [from, to], unioned so parallel
113
+ * tool calls count once. Open spans are clamped at `to`.
114
+ */
115
+ export function toolOverlapMs(turn, from, to) {
116
+ if (!turn || to <= from)
117
+ return 0;
118
+ const spans = [];
119
+ for (const interval of turn.toolIntervals) {
120
+ const start = Math.max(interval.start, from);
121
+ const end = Math.min(interval.end ?? to, to);
122
+ if (end > start)
123
+ spans.push({ start, end });
124
+ }
125
+ spans.sort((a, b) => a.start - b.start);
126
+ let total = 0;
127
+ let cursor = null;
128
+ let cursorEnd = 0;
129
+ for (const span of spans) {
130
+ if (cursor === null || span.start > cursorEnd) {
131
+ if (cursor !== null)
132
+ total += cursorEnd - cursor;
133
+ cursor = span.start;
134
+ cursorEnd = span.end;
135
+ }
136
+ else if (span.end > cursorEnd) {
137
+ cursorEnd = span.end;
138
+ }
139
+ }
140
+ if (cursor !== null)
141
+ total += cursorEnd - cursor;
142
+ return total;
143
+ }
81
144
  /**
82
145
  * Turn-level TTFT (user message → first token), gated. Falls back to the
83
146
  * supplied per-request TTFT when the turn has no first-token timing. This is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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",
@@ -1,7 +1,7 @@
1
1
  import { estimateTokens } from "./metrics"
2
2
  import { parseAssistantTextDelta } from "./event-shapes"
3
3
  import { currentRequest } from "./request-state"
4
- import { eventAggregateID, eventID, eventProperties, stringEventProperty } from "./event-bus"
4
+ import { eventAggregateID, eventID, eventPerfTime, eventProperties, stringEventProperty } from "./event-bus"
5
5
  import type { EventHandlerContext } from "./collector-state"
6
6
  import { recordLiveTokens } from "./live-speed"
7
7
  import { ensureTurn, recordTurnFirstToken } from "./turn-state"
@@ -78,7 +78,7 @@ export function applyAssistantDelta(ctx: EventHandlerContext, event: unknown): v
78
78
  sessionAliases.add(aggregateID)
79
79
  state.sessionAliases.set(text.sessionID, sessionAliases)
80
80
  }
81
- const now = performance.now()
81
+ const now = eventPerfTime(event, performance.now())
82
82
  actions.startSessionTiming(text.sessionID, now)
83
83
  const key = partEstimateKey(text.sessionID, text.messageID, text.partID)
84
84
  const deltaTokens = estimateTokens(text.delta, ctx.config.estimationRatio)
package/src/collector.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
2
  import type { BarConfig, CacheReadCompleteness, MetricsAggregate, MetricsScope, ModelMetrics, RequestMetrics } from "./types"
3
- import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics"
3
+ import { credibleTps, getDisplayInputTokens, getDisplayOutputTokens, getTtft, MAX_CREDIBLE_TPS } from "./metrics"
4
4
  import { registerEventHandlers } from "./event-handlers"
5
5
  import type { CollectorState } from "./collector-state"
6
6
  import type { MetricsEventApi } from "./event-bus"
@@ -8,7 +8,7 @@ import { hydrateSession, isHydrationApi, callWithFallback, type HydrationApi } f
8
8
  import { createSessionTree } from "./session-tree"
9
9
  import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing"
10
10
  import { clearLiveSpeed, getLiveTps } from "./live-speed"
11
- import { getTurnTtft, liveRequestOutput, turnInputTokens } from "./turn-state"
11
+ import { getTurnTtft, liveRequestOutput, toolOverlapMs, turnInputTokens } from "./turn-state"
12
12
 
13
13
  export type MetricsListener = () => void
14
14
  type MetricsHydrationApi = MetricsEventApi & HydrationApi
@@ -426,8 +426,20 @@ export function createCollector(
426
426
  : latest.isStreaming || latestLastActivity === null ? now : latestLastActivity
427
427
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
428
428
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
429
+ let primary: number | null = null
429
430
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
430
- displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
431
+ primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
432
+ }
433
+ // Same turn-anchored fallback as the main aggregate, using the latest
434
+ // request's own turn.
435
+ const latestTurn = state.turns.get(latest.sessionID)
436
+ if ((primary === null || primary > MAX_CREDIBLE_TPS) && latestTurn && frTokens > 0) {
437
+ const turnEnd = completeTime ?? latestLastActivity ?? now
438
+ const turnWindow = turnEnd - latestTurn.turnStartTime
439
+ - toolOverlapMs(latestTurn, latestTurn.turnStartTime, turnEnd)
440
+ displayTps = credibleTps(frTokens, turnWindow) ?? primary
441
+ } else {
442
+ displayTps = primary
431
443
  }
432
444
  }
433
445
 
@@ -580,8 +592,21 @@ export function createCollector(
580
592
  : foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity
581
593
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
582
594
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
595
+ let primary: number | null = null
583
596
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
584
- displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
597
+ primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
598
+ }
599
+ // Fall back to the turn-anchored window when the per-step window
600
+ // implies a physically incredible rate: per-delta times may be
601
+ // delivery-stamped (batched flushes), while turn admission, step
602
+ // boundaries, and tool spans use discrete truthful anchors.
603
+ if ((primary === null || primary > MAX_CREDIBLE_TPS) && foregroundTurn && frTokens > 0) {
604
+ const turnEnd = completeTime ?? foregroundLastActivity ?? now
605
+ const turnWindow = turnEnd - foregroundTurn.turnStartTime
606
+ - toolOverlapMs(foregroundTurn, foregroundTurn.turnStartTime, turnEnd)
607
+ displayTps = credibleTps(frTokens, turnWindow) ?? primary
608
+ } else {
609
+ displayTps = primary
585
610
  }
586
611
  }
587
612
 
package/src/event-bus.ts CHANGED
@@ -54,6 +54,36 @@ export function statusType(event: unknown): string {
54
54
  return typeof status.type === "string" ? status.type : ""
55
55
  }
56
56
 
57
+ /**
58
+ * Host-stamped wall-clock time of an event (V2 envelope `created`, epoch ms),
59
+ * or null when absent/implausible. V1 events carry no timestamp at all.
60
+ */
61
+ export function eventCreatedWallTime(event: unknown): number | null {
62
+ if (!isRecord(event)) return null
63
+ const created = event.created
64
+ if (typeof created !== "number" || !Number.isFinite(created) || created <= 0) return null
65
+ // Guard against a foreign epoch or clock skew: anything more than a day out
66
+ // is not usable as event time.
67
+ if (Math.abs(created - Date.now()) > 86_400_000) return null
68
+ return created
69
+ }
70
+
71
+ /**
72
+ * Performance-base timestamp for an event. Prefers the host stamp converted
73
+ * into this process's performance.now() base; falls back to `now`.
74
+ *
75
+ * WHY: the V2 host delivers events in batches — a whole turn's deltas can
76
+ * arrive in one flush long after generation. Stamping flush time
77
+ * (performance.now() at handling) collapses the generation window to
78
+ * milliseconds and inflates frozen TPS absurdly (e.g. 259 tokens / 6ms =
79
+ * 42627 t/s). V1 events have no `created`, so this is a no-op for V1.
80
+ */
81
+ export function eventPerfTime(event: unknown, now: number): number {
82
+ const wall = eventCreatedWallTime(event)
83
+ if (wall === null) return now
84
+ return now - Math.max(0, Date.now() - wall)
85
+ }
86
+
57
87
  export function isMetricsEventApi(value: unknown): value is MetricsEventApi {
58
88
  if (!isRecord(value)) return false
59
89
  const event = value.event
@@ -11,10 +11,10 @@ import {
11
11
  } from "./event-shapes"
12
12
  import { applyAssistantTokens, applySessionModel, hasPositiveAssistantTokens, mergeAssistantModel } from "./request-updates"
13
13
  import { completeRequest, currentRequest } from "./request-state"
14
- import { eventAggregateID, eventID, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus"
14
+ import { eventAggregateID, eventID, eventPerfTime, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus"
15
15
  import type { EventHandlerContext } from "./collector-state"
16
16
  import { clearLiveSpeed, resetLiveSpeed } from "./live-speed"
17
- import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state"
17
+ import { ensureTurn, recordToolCalled, recordToolSettled, retireRequestIntoTurn, startTurn } from "./turn-state"
18
18
 
19
19
  export function registerEventHandlers(ctx: EventHandlerContext): Array<() => void> {
20
20
  const { api, config, log, state, actions } = ctx
@@ -97,7 +97,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
97
97
  log(`session.status: ${sessionID} -> ${type}`)
98
98
 
99
99
  if (type === "busy") {
100
- const now = performance.now()
100
+ const now = eventPerfTime(event, performance.now())
101
101
  actions.startSessionTiming(sessionID, now)
102
102
  actions.clearHoldTimer(sessionID)
103
103
  const existingTurn = state.turns.get(sessionID)
@@ -118,7 +118,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
118
118
  }
119
119
 
120
120
  if (type === "idle") {
121
- completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log })
121
+ completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log })
122
122
  }
123
123
  })
124
124
 
@@ -126,7 +126,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
126
126
  const sessionID = stringEventProperty(event, "sessionID")
127
127
  if (sessionID.length === 0) return
128
128
  recordEventAlias(event, sessionID)
129
- completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log })
129
+ completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log })
130
130
  })
131
131
 
132
132
  // ── message.part.delta ──
@@ -139,9 +139,9 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
139
139
  if (sessionID.length === 0 || messageID.length === 0) return
140
140
  recordEventAlias(event, sessionID)
141
141
  if (field !== "text") return
142
- actions.startSessionTiming(sessionID, performance.now())
142
+ actions.startSessionTiming(sessionID, eventPerfTime(event, performance.now()))
143
143
 
144
- const now = performance.now()
144
+ const now = eventPerfTime(event, performance.now())
145
145
  ensureTurn(state.turns, sessionID, now)
146
146
  applyAssistantPartDelta(ctx, sessionID, messageID, partID, delta, now)
147
147
  })
@@ -150,7 +150,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
150
150
  const step = parseAssistantStepStarted(eventProperties(event), eventID(event))
151
151
  if (!step) return
152
152
  recordEventAlias(event, step.sessionID)
153
- const now = performance.now()
153
+ const now = eventPerfTime(event, performance.now())
154
154
  actions.startSessionTiming(step.sessionID, now)
155
155
  const existingTurn = state.turns.get(step.sessionID)
156
156
  if (!existingTurn || existingTurn.isComplete) startTurn(state.turns, step.sessionID, now)
@@ -169,13 +169,40 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
169
169
 
170
170
  on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event))
171
171
 
172
+ // ── tool execution spans (V2 tool.* events; V1 has none) ──
173
+ // Feeds per-turn tool intervals so frozen TPS can subtract tool time from
174
+ // the turn-anchored window. Unknown shapes are ignored defensively.
175
+ on("session.tool.called", (event) => {
176
+ const sessionID = stringEventProperty(event, "sessionID")
177
+ if (sessionID.length === 0) return
178
+ recordEventAlias(event, sessionID)
179
+ recordToolCalled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
180
+ actions.notify()
181
+ })
182
+
183
+ on("session.tool.success", (event) => {
184
+ const sessionID = stringEventProperty(event, "sessionID")
185
+ if (sessionID.length === 0) return
186
+ recordEventAlias(event, sessionID)
187
+ recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
188
+ actions.notify()
189
+ })
190
+
191
+ on("session.tool.failed", (event) => {
192
+ const sessionID = stringEventProperty(event, "sessionID")
193
+ if (sessionID.length === 0) return
194
+ recordEventAlias(event, sessionID)
195
+ recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
196
+ actions.notify()
197
+ })
198
+
172
199
  on("session.next.step.ended", (event) => {
173
200
  const sessionID = stringEventProperty(event, "sessionID")
174
201
  const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event)
175
202
  const step = parseAssistantStepEnded(eventProperties(event), fallbackMessageID)
176
203
  if (!step) return
177
204
  recordEventAlias(event, step.sessionID)
178
- const now = performance.now()
205
+ const now = eventPerfTime(event, performance.now())
179
206
  actions.startSessionTiming(step.sessionID, now)
180
207
  const turn = ensureTurn(state.turns, step.sessionID, now)
181
208
  const existing = state.requests.get(step.sessionID)
@@ -215,7 +242,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
215
242
  recordEventAlias(event, sessionID)
216
243
  const rawPart = eventProperty(event, "part")
217
244
  const part = parseTextPart(rawPart)
218
- const now = performance.now()
245
+ const now = eventPerfTime(event, performance.now())
219
246
  const msgIds = state.userMessageIds.get(sessionID)
220
247
  if (part && msgIds?.has(part.messageID)) {
221
248
  const current = state.requests.get(sessionID)
@@ -246,7 +273,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
246
273
  const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant)
247
274
  if (nextModel) state.sessionModels.set(sessionID, nextModel)
248
275
 
249
- const now = performance.now()
276
+ const now = eventPerfTime(event, performance.now())
250
277
  const tokens = assistant.tokens
251
278
  const existing = state.requests.get(sessionID)
252
279
  const turn = ensureTurn(state.turns, sessionID, now)
@@ -288,7 +315,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
288
315
  if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
289
316
  return
290
317
  }
291
- const now = performance.now()
318
+ const now = eventPerfTime(event, performance.now())
292
319
  const previous = state.requests.get(sessionID)
293
320
  const previousTurn = state.turns.get(sessionID)
294
321
  if (previous && previousTurn) retireRequestIntoTurn(previousTurn, previous)
package/src/metrics.ts CHANGED
@@ -127,6 +127,22 @@ export function getTtft(m: RequestMetrics): number | null {
127
127
  return gateTtft(m.firstTokenTime - m.requestStartTime)
128
128
  }
129
129
 
130
+ /**
131
+ * Upper bound for a believable token rate. Mirrors the live-TPS sanity gate:
132
+ * per-delta times may be delivery-stamped (batched flushes), collapsing the
133
+ * generation window to milliseconds and inflating the quotient absurdly
134
+ * (e.g. 61 tokens / 42ms = 1450 t/s — physically impossible decoding).
135
+ * Windows implying a faster rate fall back to turn-anchored timing.
136
+ */
137
+ export const MAX_CREDIBLE_TPS = 300
138
+
139
+ export function credibleTps(tokens: number, windowMs: number): number | null {
140
+ if (!(tokens > 0) || !(windowMs > 0)) return null
141
+ const tps = Math.round((tokens / (windowMs / 1000)) * 10) / 10
142
+ if (!Number.isFinite(tps) || tps < 0 || tps > MAX_CREDIBLE_TPS) return null
143
+ return tps
144
+ }
145
+
130
146
  /**
131
147
  * Calculate average token speed (tokens/second).
132
148
  * Based on cumulative streaming time (now - firstTokenTime).
package/src/tui-v2.tsx CHANGED
@@ -114,6 +114,9 @@ const V2_EVENT_MAP: Readonly<Record<string, string | null>> = {
114
114
  "session.next.reasoning.delta": "session.reasoning.delta",
115
115
  "session.next.step.started": "session.step.started",
116
116
  "session.next.step.ended": "session.step.ended",
117
+ "session.tool.called": "session.tool.called",
118
+ "session.tool.success": "session.tool.success",
119
+ "session.tool.failed": "session.tool.failed",
117
120
  }
118
121
 
119
122
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -133,7 +136,14 @@ export function translateExecutionStartedToBusy(event: V2EventLike): unknown {
133
136
  const data = isRecord(event.data) ? event.data : {}
134
137
  const sessionID = str(data.sessionID)
135
138
  if (!sessionID) return null
136
- return { type: "session.status", properties: { sessionID, status: { type: "busy" } } }
139
+ // Preserve the host stamp so downstream timing uses generation time,
140
+ // not the (possibly batched) delivery time.
141
+ const created = typeof event.created === "number" ? event.created : undefined
142
+ return {
143
+ type: "session.status",
144
+ ...(created !== undefined ? { created } : {}),
145
+ properties: { sessionID, status: { type: "busy" } },
146
+ }
137
147
  }
138
148
 
139
149
  /**
@@ -145,7 +155,12 @@ export function translateExecutionSettledToIdle(event: V2EventLike): unknown {
145
155
  const data = isRecord(event.data) ? event.data : {}
146
156
  const sessionID = str(data.sessionID)
147
157
  if (!sessionID) return null
148
- return { type: "session.idle", properties: { sessionID } }
158
+ const created = typeof event.created === "number" ? event.created : undefined
159
+ return {
160
+ type: "session.idle",
161
+ ...(created !== undefined ? { created } : {}),
162
+ properties: { sessionID },
163
+ }
149
164
  }
150
165
 
151
166
  function eventSessionID(event: unknown): string {
package/src/turn-state.ts CHANGED
@@ -14,6 +14,7 @@ export function createTurnMetrics(sessionID: string, now: number): TurnMetrics {
14
14
  hasStickyContextTokens: false,
15
15
  stickyCacheReadTokens: 0,
16
16
  hasStickyCacheReadTokens: false,
17
+ toolIntervals: [],
17
18
  isComplete: false,
18
19
  }
19
20
  }
@@ -95,6 +96,75 @@ export function recordTurnFirstToken(turn: TurnMetrics, now: number): void {
95
96
  if (turn.firstTokenTime === null) turn.firstTokenTime = now
96
97
  }
97
98
 
99
+ /**
100
+ * Record a tool execution span on the session's turn. Tools in one turn may
101
+ * overlap (parallel calls), so every span is kept and the union is computed
102
+ * at query time — a plain sum would double-count overlaps.
103
+ */
104
+ export function recordToolCalled(
105
+ turns: Map<string, TurnMetrics>,
106
+ sessionID: string,
107
+ toolID: string,
108
+ now: number,
109
+ ): void {
110
+ const turn = ensureTurn(turns, sessionID, now)
111
+ turn.toolIntervals.push({ id: toolID, start: now, end: null })
112
+ }
113
+
114
+ export function recordToolSettled(
115
+ turns: Map<string, TurnMetrics>,
116
+ sessionID: string,
117
+ toolID: string,
118
+ now: number,
119
+ ): void {
120
+ const turn = turns.get(sessionID)
121
+ if (!turn) return
122
+ if (toolID) {
123
+ const open = turn.toolIntervals.find((interval) => interval.id === toolID && interval.end === null)
124
+ if (open) {
125
+ open.end = Math.max(now, open.start)
126
+ return
127
+ }
128
+ }
129
+ // Unknown id (or unsettled shape): close the most recent open span so a
130
+ // lost settle event cannot leak an infinite interval.
131
+ for (let i = turn.toolIntervals.length - 1; i >= 0; i--) {
132
+ if (turn.toolIntervals[i]!.end === null) {
133
+ turn.toolIntervals[i]!.end = Math.max(now, turn.toolIntervals[i]!.start)
134
+ return
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Milliseconds of tool execution overlapping [from, to], unioned so parallel
141
+ * tool calls count once. Open spans are clamped at `to`.
142
+ */
143
+ export function toolOverlapMs(turn: TurnMetrics | undefined, from: number, to: number): number {
144
+ if (!turn || to <= from) return 0
145
+ const spans: Array<{ start: number; end: number }> = []
146
+ for (const interval of turn.toolIntervals) {
147
+ const start = Math.max(interval.start, from)
148
+ const end = Math.min(interval.end ?? to, to)
149
+ if (end > start) spans.push({ start, end })
150
+ }
151
+ spans.sort((a, b) => a.start - b.start)
152
+ let total = 0
153
+ let cursor: number | null = null
154
+ let cursorEnd = 0
155
+ for (const span of spans) {
156
+ if (cursor === null || span.start > cursorEnd) {
157
+ if (cursor !== null) total += cursorEnd - cursor
158
+ cursor = span.start
159
+ cursorEnd = span.end
160
+ } else if (span.end > cursorEnd) {
161
+ cursorEnd = span.end
162
+ }
163
+ }
164
+ if (cursor !== null) total += cursorEnd - cursor
165
+ return total
166
+ }
167
+
98
168
  /**
99
169
  * Turn-level TTFT (user message → first token), gated. Falls back to the
100
170
  * supplied per-request TTFT when the turn has no first-token timing. This is
package/src/types.ts CHANGED
@@ -34,6 +34,12 @@ export interface FinalizedStepUsage {
34
34
  exact: boolean
35
35
  }
36
36
 
37
+ export interface ToolInterval {
38
+ readonly id: string
39
+ readonly start: number
40
+ end: number | null // null = still running
41
+ }
42
+
37
43
  export interface TurnMetrics {
38
44
  sessionID: string
39
45
  turnStartTime: number
@@ -46,6 +52,7 @@ export interface TurnMetrics {
46
52
  hasStickyContextTokens: boolean
47
53
  stickyCacheReadTokens: number
48
54
  hasStickyCacheReadTokens: boolean
55
+ toolIntervals: ToolInterval[] // Tool execution spans (V2 tool.* events; empty on V1)
49
56
  isComplete: boolean
50
57
  }
51
58