oc-metricboard 0.1.16 → 0.1.17

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
@@ -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/tui-v2.js CHANGED
@@ -74,15 +74,27 @@ 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
84
  return { type: "session.status", properties: { sessionID, status: { type: "busy" } } };
85
85
  }
86
+ /**
87
+ * Turn-settled execution maps to the V1 `session.idle` shape that drives
88
+ * request completion. Used as a fallback next to the native V2
89
+ * `session.idle`, which some flows never emit. Exported for tests.
90
+ */
91
+ export function translateExecutionSettledToIdle(event) {
92
+ const data = isRecord(event.data) ? event.data : {};
93
+ const sessionID = str(data.sessionID);
94
+ if (!sessionID)
95
+ return null;
96
+ return { type: "session.idle", properties: { sessionID } };
97
+ }
86
98
  function eventSessionID(event) {
87
99
  if (!isRecord(event))
88
100
  return "";
@@ -110,39 +122,76 @@ function createV1EventShim(ctx, holder) {
110
122
  // Family attribution is best-effort; the root session works without it.
111
123
  }
112
124
  };
125
+ const subscribeV2 = (v2Type, subscribee) => {
126
+ try {
127
+ const unsub = ctx.data.on(v2Type, (event) => {
128
+ try {
129
+ subscribee(event);
130
+ }
131
+ catch (error) {
132
+ // One malformed event must never tear down the handler chain.
133
+ log(`v2 event dispatch failed: ${String(error)}`);
134
+ }
135
+ });
136
+ return typeof unsub === "function" ? unsub : () => { };
137
+ }
138
+ catch (error) {
139
+ log(`v2 event subscribe failed (${v2Type}): ${String(error)}`);
140
+ return () => { };
141
+ }
142
+ };
113
143
  return {
114
144
  event: {
115
145
  on(type, handler) {
116
146
  // V1 hosts subscribe `.1`/`.2` delivery variants; V2 has none.
117
147
  if (/\.1$|\.2$/.test(type))
118
148
  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;
149
+ // V1 session lifecycle has no same-name V2 event: synthesize
150
+ // busy from execution.started. (Without this, turns, session
151
+ // timing, and completion never start on V2.)
152
+ if (type === "session.status") {
153
+ return subscribeV2("session.execution.started", (event) => {
154
+ const translated = translateExecutionStartedToBusy(event);
155
+ if (translated !== null)
156
+ handler(translated);
157
+ });
158
+ }
159
+ // Completion: native V2 session.idle where emitted, plus the
160
+ // execution-settled events as fallback — some flows never emit
161
+ // session.idle, which used to leave the turn/request/timing
162
+ // open forever (TPS decaying in real time while idle).
163
+ // completeRequest is idempotent, so a doubled signal is safe.
164
+ if (type === "session.idle") {
165
+ const unsubs = [
166
+ subscribeV2("session.idle", handler),
167
+ ];
168
+ for (const settled of [
169
+ "session.execution.succeeded",
170
+ "session.execution.failed",
171
+ "session.execution.interrupted",
172
+ ]) {
173
+ unsubs.push(subscribeV2(settled, (event) => {
174
+ const translated = translateExecutionSettledToIdle(event);
175
+ if (translated !== null)
176
+ handler(translated);
177
+ }));
178
+ }
179
+ return () => {
180
+ for (const unsub of unsubs) {
181
+ try {
182
+ unsub();
131
183
  }
132
- syncFamily(eventSessionID(event));
133
- handler(event);
184
+ catch { /* ignore */ }
134
185
  }
135
- catch (error) {
136
- // One malformed event must never tear down the handler chain.
137
- log(`v2 event dispatch failed: ${String(error)}`);
138
- }
139
- });
186
+ };
140
187
  }
141
- catch (error) {
142
- log(`v2 event subscribe failed (${type}): ${String(error)}`);
188
+ const v2Type = V2_EVENT_MAP[type];
189
+ if (!v2Type)
143
190
  return () => { };
144
- }
145
- return typeof unsub === "function" ? unsub : () => { };
191
+ return subscribeV2(v2Type, (event) => {
192
+ syncFamily(eventSessionID(event));
193
+ handler(event);
194
+ });
146
195
  },
147
196
  },
148
197
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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
@@ -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/tui-v2.tsx CHANGED
@@ -127,15 +127,27 @@ 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
136
  return { type: "session.status", properties: { sessionID, status: { type: "busy" } } }
137
137
  }
138
138
 
139
+ /**
140
+ * Turn-settled execution maps to the V1 `session.idle` shape that drives
141
+ * request completion. Used as a fallback next to the native V2
142
+ * `session.idle`, which some flows never emit. Exported for tests.
143
+ */
144
+ export function translateExecutionSettledToIdle(event: V2EventLike): unknown {
145
+ const data = isRecord(event.data) ? event.data : {}
146
+ const sessionID = str(data.sessionID)
147
+ if (!sessionID) return null
148
+ return { type: "session.idle", properties: { sessionID } }
149
+ }
150
+
139
151
  function eventSessionID(event: unknown): string {
140
152
  if (!isRecord(event)) return ""
141
153
  return str(isRecord(event.data) ? event.data.sessionID : undefined)
@@ -164,34 +176,73 @@ function createV1EventShim(ctx: V2Context, holder: CollectorHolder): MetricsEven
164
176
  }
165
177
  }
166
178
 
179
+ const subscribeV2 = (
180
+ v2Type: string,
181
+ subscribee: (event: unknown) => void,
182
+ ): (() => void) => {
183
+ try {
184
+ const unsub = ctx.data.on(v2Type, (event: unknown) => {
185
+ try {
186
+ subscribee(event)
187
+ } catch (error) {
188
+ // One malformed event must never tear down the handler chain.
189
+ log(`v2 event dispatch failed: ${String(error)}`)
190
+ }
191
+ })
192
+ return typeof unsub === "function" ? unsub : () => {}
193
+ } catch (error) {
194
+ log(`v2 event subscribe failed (${v2Type}): ${String(error)}`)
195
+ return () => {}
196
+ }
197
+ }
198
+
167
199
  return {
168
200
  event: {
169
201
  on(type: string, handler: (event: unknown) => void): () => void {
170
202
  // V1 hosts subscribe `.1`/`.2` delivery variants; V2 has none.
171
203
  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
- }
204
+ // V1 session lifecycle has no same-name V2 event: synthesize
205
+ // busy from execution.started. (Without this, turns, session
206
+ // timing, and completion never start on V2.)
207
+ if (type === "session.status") {
208
+ return subscribeV2("session.execution.started", (event) => {
209
+ const translated = translateExecutionStartedToBusy(event as V2EventLike)
210
+ if (translated !== null) handler(translated)
189
211
  })
190
- } catch (error) {
191
- log(`v2 event subscribe failed (${type}): ${String(error)}`)
192
- return () => {}
193
212
  }
194
- return typeof unsub === "function" ? unsub : () => {}
213
+ // Completion: native V2 session.idle where emitted, plus the
214
+ // execution-settled events as fallback — some flows never emit
215
+ // session.idle, which used to leave the turn/request/timing
216
+ // open forever (TPS decaying in real time while idle).
217
+ // completeRequest is idempotent, so a doubled signal is safe.
218
+ if (type === "session.idle") {
219
+ const unsubs: Array<() => void> = [
220
+ subscribeV2("session.idle", handler),
221
+ ]
222
+ for (const settled of [
223
+ "session.execution.succeeded",
224
+ "session.execution.failed",
225
+ "session.execution.interrupted",
226
+ ] as const) {
227
+ unsubs.push(subscribeV2(settled, (event) => {
228
+ const translated = translateExecutionSettledToIdle(event as V2EventLike)
229
+ if (translated !== null) handler(translated)
230
+ }))
231
+ }
232
+ return () => {
233
+ for (const unsub of unsubs) {
234
+ try {
235
+ unsub()
236
+ } catch { /* ignore */ }
237
+ }
238
+ }
239
+ }
240
+ const v2Type = V2_EVENT_MAP[type]
241
+ if (!v2Type) return () => {}
242
+ return subscribeV2(v2Type, (event) => {
243
+ syncFamily(eventSessionID(event))
244
+ handler(event)
245
+ })
195
246
  },
196
247
  },
197
248
  }