oc-metricboard 0.1.16 → 0.1.18

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
@@ -350,7 +350,12 @@ export function createCollector(api, config, log) {
350
350
  const latest = metrics.reduce((a, b) => (b.requestStartTime >= a.requestStartTime ? b : a));
351
351
  const frTokens = getDisplayOutputTokens(latest);
352
352
  const genStart = latest.firstTokenTime ?? latest.requestStartTime;
353
- const genEnd = isComplete && completeTime !== null ? completeTime : now;
353
+ // Same freeze rule as the main aggregate: an open-but-silent group
354
+ // ends its window at the latest request's last activity, not `now`.
355
+ const latestLastActivity = latest.lastDeltaTime ?? null;
356
+ const genEnd = isComplete && completeTime !== null
357
+ ? completeTime
358
+ : latest.isStreaming || latestLastActivity === null ? now : latestLastActivity;
354
359
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
355
360
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
356
361
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
@@ -490,7 +495,15 @@ export function createCollector(api, config, log) {
490
495
  // the foreground window would inflate TPS absurdly (1800+ t/s).
491
496
  const frTokens = getDisplayOutputTokens(foregroundRequest);
492
497
  const genStart = firstTokenTime ?? requestStartTime;
493
- const genEnd = isComplete ? completeTime : now;
498
+ // Window end: complete → frozen completeTime; streaming → now;
499
+ // open but silent (e.g. a lost completion event) → last observed
500
+ // activity, so the reading freezes instead of decaying in real time
501
+ // while idle.
502
+ const foregroundActive = foregroundRequest?.isStreaming ?? false;
503
+ const foregroundLastActivity = foregroundRequest?.lastDeltaTime ?? null;
504
+ const genEnd = isComplete
505
+ ? completeTime
506
+ : foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity;
494
507
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
495
508
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
496
509
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
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,7 +3,7 @@ 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
8
  import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state";
9
9
  export function registerEventHandlers(ctx) {
@@ -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)
@@ -164,7 +164,7 @@ export function registerEventHandlers(ctx) {
164
164
  if (!step)
165
165
  return;
166
166
  recordEventAlias(event, step.sessionID);
167
- const now = performance.now();
167
+ const now = eventPerfTime(event, performance.now());
168
168
  actions.startSessionTiming(step.sessionID, now);
169
169
  const turn = ensureTurn(state.turns, step.sessionID, now);
170
170
  const existing = state.requests.get(step.sessionID);
@@ -205,7 +205,7 @@ export function registerEventHandlers(ctx) {
205
205
  recordEventAlias(event, sessionID);
206
206
  const rawPart = eventProperty(event, "part");
207
207
  const part = parseTextPart(rawPart);
208
- const now = performance.now();
208
+ const now = eventPerfTime(event, performance.now());
209
209
  const msgIds = state.userMessageIds.get(sessionID);
210
210
  if (part && msgIds?.has(part.messageID)) {
211
211
  const current = state.requests.get(sessionID);
@@ -235,7 +235,7 @@ export function registerEventHandlers(ctx) {
235
235
  const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant);
236
236
  if (nextModel)
237
237
  state.sessionModels.set(sessionID, nextModel);
238
- const now = performance.now();
238
+ const now = eventPerfTime(event, performance.now());
239
239
  const tokens = assistant.tokens;
240
240
  const existing = state.requests.get(sessionID);
241
241
  const turn = ensureTurn(state.turns, sessionID, now);
@@ -278,7 +278,7 @@ export function registerEventHandlers(ctx) {
278
278
  if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
279
279
  return;
280
280
  }
281
- const now = performance.now();
281
+ const now = eventPerfTime(event, performance.now());
282
282
  const previous = state.requests.get(sessionID);
283
283
  const previousTurn = state.turns.get(sessionID);
284
284
  if (previous && previousTurn)
package/dist/tui-v2.js CHANGED
@@ -74,14 +74,38 @@ function str(value) {
74
74
  /**
75
75
  * `session.execution.started` = "the user's prompt was admitted and work
76
76
  * started" — the V2 equivalent of V1's `session.status -> busy`, which drives
77
- * turn start and session timing.
77
+ * turn start and session timing. Exported for tests.
78
78
  */
79
- function translateExecutionStarted(event) {
79
+ export function translateExecutionStartedToBusy(event) {
80
80
  const data = isRecord(event.data) ? event.data : {};
81
81
  const sessionID = str(data.sessionID);
82
82
  if (!sessionID)
83
83
  return null;
84
- return { type: "session.status", properties: { sessionID, status: { type: "busy" } } };
84
+ // Preserve the host stamp so downstream timing uses generation time,
85
+ // not the (possibly batched) delivery time.
86
+ const created = typeof event.created === "number" ? event.created : undefined;
87
+ return {
88
+ type: "session.status",
89
+ ...(created !== undefined ? { created } : {}),
90
+ properties: { sessionID, status: { type: "busy" } },
91
+ };
92
+ }
93
+ /**
94
+ * Turn-settled execution maps to the V1 `session.idle` shape that drives
95
+ * request completion. Used as a fallback next to the native V2
96
+ * `session.idle`, which some flows never emit. Exported for tests.
97
+ */
98
+ export function translateExecutionSettledToIdle(event) {
99
+ const data = isRecord(event.data) ? event.data : {};
100
+ const sessionID = str(data.sessionID);
101
+ if (!sessionID)
102
+ return null;
103
+ const created = typeof event.created === "number" ? event.created : undefined;
104
+ return {
105
+ type: "session.idle",
106
+ ...(created !== undefined ? { created } : {}),
107
+ properties: { sessionID },
108
+ };
85
109
  }
86
110
  function eventSessionID(event) {
87
111
  if (!isRecord(event))
@@ -110,39 +134,76 @@ function createV1EventShim(ctx, holder) {
110
134
  // Family attribution is best-effort; the root session works without it.
111
135
  }
112
136
  };
137
+ const subscribeV2 = (v2Type, subscribee) => {
138
+ try {
139
+ const unsub = ctx.data.on(v2Type, (event) => {
140
+ try {
141
+ subscribee(event);
142
+ }
143
+ catch (error) {
144
+ // One malformed event must never tear down the handler chain.
145
+ log(`v2 event dispatch failed: ${String(error)}`);
146
+ }
147
+ });
148
+ return typeof unsub === "function" ? unsub : () => { };
149
+ }
150
+ catch (error) {
151
+ log(`v2 event subscribe failed (${v2Type}): ${String(error)}`);
152
+ return () => { };
153
+ }
154
+ };
113
155
  return {
114
156
  event: {
115
157
  on(type, handler) {
116
158
  // V1 hosts subscribe `.1`/`.2` delivery variants; V2 has none.
117
159
  if (/\.1$|\.2$/.test(type))
118
160
  return () => { };
119
- const v2Type = V2_EVENT_MAP[type];
120
- if (!v2Type)
121
- return () => { };
122
- let unsub;
123
- try {
124
- unsub = ctx.data.on(v2Type, (event) => {
125
- try {
126
- if (v2Type === "session.execution.started") {
127
- const translated = translateExecutionStarted(event);
128
- if (translated !== null)
129
- handler(translated);
130
- return;
161
+ // V1 session lifecycle has no same-name V2 event: synthesize
162
+ // busy from execution.started. (Without this, turns, session
163
+ // timing, and completion never start on V2.)
164
+ if (type === "session.status") {
165
+ return subscribeV2("session.execution.started", (event) => {
166
+ const translated = translateExecutionStartedToBusy(event);
167
+ if (translated !== null)
168
+ handler(translated);
169
+ });
170
+ }
171
+ // Completion: native V2 session.idle where emitted, plus the
172
+ // execution-settled events as fallback — some flows never emit
173
+ // session.idle, which used to leave the turn/request/timing
174
+ // open forever (TPS decaying in real time while idle).
175
+ // completeRequest is idempotent, so a doubled signal is safe.
176
+ if (type === "session.idle") {
177
+ const unsubs = [
178
+ subscribeV2("session.idle", handler),
179
+ ];
180
+ for (const settled of [
181
+ "session.execution.succeeded",
182
+ "session.execution.failed",
183
+ "session.execution.interrupted",
184
+ ]) {
185
+ unsubs.push(subscribeV2(settled, (event) => {
186
+ const translated = translateExecutionSettledToIdle(event);
187
+ if (translated !== null)
188
+ handler(translated);
189
+ }));
190
+ }
191
+ return () => {
192
+ for (const unsub of unsubs) {
193
+ try {
194
+ unsub();
131
195
  }
132
- syncFamily(eventSessionID(event));
133
- handler(event);
134
- }
135
- catch (error) {
136
- // One malformed event must never tear down the handler chain.
137
- log(`v2 event dispatch failed: ${String(error)}`);
196
+ catch { /* ignore */ }
138
197
  }
139
- });
198
+ };
140
199
  }
141
- catch (error) {
142
- log(`v2 event subscribe failed (${type}): ${String(error)}`);
200
+ const v2Type = V2_EVENT_MAP[type];
201
+ if (!v2Type)
143
202
  return () => { };
144
- }
145
- return typeof unsub === "function" ? unsub : () => { };
203
+ return subscribeV2(v2Type, (event) => {
204
+ syncFamily(eventSessionID(event));
205
+ handler(event);
206
+ });
146
207
  },
147
208
  },
148
209
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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
@@ -418,7 +418,12 @@ export function createCollector(
418
418
  const latest = metrics.reduce((a, b) => (b.requestStartTime >= a.requestStartTime ? b : a))
419
419
  const frTokens = getDisplayOutputTokens(latest)
420
420
  const genStart = latest.firstTokenTime ?? latest.requestStartTime
421
- const genEnd = isComplete && completeTime !== null ? completeTime : now
421
+ // Same freeze rule as the main aggregate: an open-but-silent group
422
+ // ends its window at the latest request's last activity, not `now`.
423
+ const latestLastActivity = latest.lastDeltaTime ?? null
424
+ const genEnd = isComplete && completeTime !== null
425
+ ? completeTime
426
+ : latest.isStreaming || latestLastActivity === null ? now : latestLastActivity
422
427
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
423
428
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
424
429
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
@@ -564,7 +569,15 @@ export function createCollector(
564
569
  // the foreground window would inflate TPS absurdly (1800+ t/s).
565
570
  const frTokens = getDisplayOutputTokens(foregroundRequest)
566
571
  const genStart = firstTokenTime ?? requestStartTime
567
- const genEnd = isComplete ? completeTime : now
572
+ // Window end: complete → frozen completeTime; streaming → now;
573
+ // open but silent (e.g. a lost completion event) → last observed
574
+ // activity, so the reading freezes instead of decaying in real time
575
+ // while idle.
576
+ const foregroundActive = foregroundRequest?.isStreaming ?? false
577
+ const foregroundLastActivity = foregroundRequest?.lastDeltaTime ?? null
578
+ const genEnd = isComplete
579
+ ? completeTime
580
+ : foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity
568
581
  const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
569
582
  const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
570
583
  if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
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,7 +11,7 @@ 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
17
  import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state"
@@ -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)
@@ -175,7 +175,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
175
175
  const step = parseAssistantStepEnded(eventProperties(event), fallbackMessageID)
176
176
  if (!step) return
177
177
  recordEventAlias(event, step.sessionID)
178
- const now = performance.now()
178
+ const now = eventPerfTime(event, performance.now())
179
179
  actions.startSessionTiming(step.sessionID, now)
180
180
  const turn = ensureTurn(state.turns, step.sessionID, now)
181
181
  const existing = state.requests.get(step.sessionID)
@@ -215,7 +215,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
215
215
  recordEventAlias(event, sessionID)
216
216
  const rawPart = eventProperty(event, "part")
217
217
  const part = parseTextPart(rawPart)
218
- const now = performance.now()
218
+ const now = eventPerfTime(event, performance.now())
219
219
  const msgIds = state.userMessageIds.get(sessionID)
220
220
  if (part && msgIds?.has(part.messageID)) {
221
221
  const current = state.requests.get(sessionID)
@@ -246,7 +246,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
246
246
  const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant)
247
247
  if (nextModel) state.sessionModels.set(sessionID, nextModel)
248
248
 
249
- const now = performance.now()
249
+ const now = eventPerfTime(event, performance.now())
250
250
  const tokens = assistant.tokens
251
251
  const existing = state.requests.get(sessionID)
252
252
  const turn = ensureTurn(state.turns, sessionID, now)
@@ -288,7 +288,7 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
288
288
  if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
289
289
  return
290
290
  }
291
- const now = performance.now()
291
+ const now = eventPerfTime(event, performance.now())
292
292
  const previous = state.requests.get(sessionID)
293
293
  const previousTurn = state.turns.get(sessionID)
294
294
  if (previous && previousTurn) retireRequestIntoTurn(previousTurn, previous)
package/src/tui-v2.tsx CHANGED
@@ -127,13 +127,37 @@ function str(value: unknown): string {
127
127
  /**
128
128
  * `session.execution.started` = "the user's prompt was admitted and work
129
129
  * started" — the V2 equivalent of V1's `session.status -> busy`, which drives
130
- * turn start and session timing.
130
+ * turn start and session timing. Exported for tests.
131
131
  */
132
- function translateExecutionStarted(event: V2EventLike): unknown {
132
+ export function translateExecutionStartedToBusy(event: V2EventLike): unknown {
133
133
  const data = isRecord(event.data) ? event.data : {}
134
134
  const sessionID = str(data.sessionID)
135
135
  if (!sessionID) return null
136
- return { type: "session.status", properties: { sessionID, status: { type: "busy" } } }
136
+ // Preserve the host stamp so downstream timing uses generation time,
137
+ // not the (possibly batched) delivery time.
138
+ const created = typeof event.created === "number" ? event.created : undefined
139
+ return {
140
+ type: "session.status",
141
+ ...(created !== undefined ? { created } : {}),
142
+ properties: { sessionID, status: { type: "busy" } },
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Turn-settled execution maps to the V1 `session.idle` shape that drives
148
+ * request completion. Used as a fallback next to the native V2
149
+ * `session.idle`, which some flows never emit. Exported for tests.
150
+ */
151
+ export function translateExecutionSettledToIdle(event: V2EventLike): unknown {
152
+ const data = isRecord(event.data) ? event.data : {}
153
+ const sessionID = str(data.sessionID)
154
+ if (!sessionID) return null
155
+ const created = typeof event.created === "number" ? event.created : undefined
156
+ return {
157
+ type: "session.idle",
158
+ ...(created !== undefined ? { created } : {}),
159
+ properties: { sessionID },
160
+ }
137
161
  }
138
162
 
139
163
  function eventSessionID(event: unknown): string {
@@ -164,34 +188,73 @@ function createV1EventShim(ctx: V2Context, holder: CollectorHolder): MetricsEven
164
188
  }
165
189
  }
166
190
 
191
+ const subscribeV2 = (
192
+ v2Type: string,
193
+ subscribee: (event: unknown) => void,
194
+ ): (() => void) => {
195
+ try {
196
+ const unsub = ctx.data.on(v2Type, (event: unknown) => {
197
+ try {
198
+ subscribee(event)
199
+ } catch (error) {
200
+ // One malformed event must never tear down the handler chain.
201
+ log(`v2 event dispatch failed: ${String(error)}`)
202
+ }
203
+ })
204
+ return typeof unsub === "function" ? unsub : () => {}
205
+ } catch (error) {
206
+ log(`v2 event subscribe failed (${v2Type}): ${String(error)}`)
207
+ return () => {}
208
+ }
209
+ }
210
+
167
211
  return {
168
212
  event: {
169
213
  on(type: string, handler: (event: unknown) => void): () => void {
170
214
  // V1 hosts subscribe `.1`/`.2` delivery variants; V2 has none.
171
215
  if (/\.1$|\.2$/.test(type)) return () => {}
172
- const v2Type = V2_EVENT_MAP[type]
173
- if (!v2Type) return () => {}
174
- let unsub: (() => void) | undefined
175
- try {
176
- unsub = ctx.data.on(v2Type, (event: unknown) => {
177
- try {
178
- if (v2Type === "session.execution.started") {
179
- const translated = translateExecutionStarted(event as V2EventLike)
180
- if (translated !== null) handler(translated)
181
- return
182
- }
183
- syncFamily(eventSessionID(event))
184
- handler(event)
185
- } catch (error) {
186
- // One malformed event must never tear down the handler chain.
187
- log(`v2 event dispatch failed: ${String(error)}`)
188
- }
216
+ // V1 session lifecycle has no same-name V2 event: synthesize
217
+ // busy from execution.started. (Without this, turns, session
218
+ // timing, and completion never start on V2.)
219
+ if (type === "session.status") {
220
+ return subscribeV2("session.execution.started", (event) => {
221
+ const translated = translateExecutionStartedToBusy(event as V2EventLike)
222
+ if (translated !== null) handler(translated)
189
223
  })
190
- } catch (error) {
191
- log(`v2 event subscribe failed (${type}): ${String(error)}`)
192
- return () => {}
193
224
  }
194
- return typeof unsub === "function" ? unsub : () => {}
225
+ // Completion: native V2 session.idle where emitted, plus the
226
+ // execution-settled events as fallback — some flows never emit
227
+ // session.idle, which used to leave the turn/request/timing
228
+ // open forever (TPS decaying in real time while idle).
229
+ // completeRequest is idempotent, so a doubled signal is safe.
230
+ if (type === "session.idle") {
231
+ const unsubs: Array<() => void> = [
232
+ subscribeV2("session.idle", handler),
233
+ ]
234
+ for (const settled of [
235
+ "session.execution.succeeded",
236
+ "session.execution.failed",
237
+ "session.execution.interrupted",
238
+ ] as const) {
239
+ unsubs.push(subscribeV2(settled, (event) => {
240
+ const translated = translateExecutionSettledToIdle(event as V2EventLike)
241
+ if (translated !== null) handler(translated)
242
+ }))
243
+ }
244
+ return () => {
245
+ for (const unsub of unsubs) {
246
+ try {
247
+ unsub()
248
+ } catch { /* ignore */ }
249
+ }
250
+ }
251
+ }
252
+ const v2Type = V2_EVENT_MAP[type]
253
+ if (!v2Type) return () => {}
254
+ return subscribeV2(v2Type, (event) => {
255
+ syncFamily(eventSessionID(event))
256
+ handler(event)
257
+ })
195
258
  },
196
259
  },
197
260
  }