oc-metricboard 0.1.18 → 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.
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
@@ -5,7 +5,7 @@ import { applyAssistantTokens, applySessionModel, hasPositiveAssistantTokens, me
5
5
  import { completeRequest, currentRequest } from "./request-state";
6
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 = [];
@@ -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);
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);
@@ -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.18",
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",
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
 
@@ -14,7 +14,7 @@ import { completeRequest, currentRequest } from "./request-state"
14
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
@@ -169,6 +169,33 @@ 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)
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> {
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