codexmeter 1.0.7 → 1.0.9

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/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-DxtrMXuP.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-DQDmrG5E.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-CVvcsIkn.css">
12
12
  </head>
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmeter",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Local telemetry dashboard for Codex CLI usage",
5
5
  "type": "module",
6
6
  "bin": {
@@ -262,11 +262,10 @@ function buildDaily(rawSessions, groupedSessions, tz) {
262
262
  }
263
263
  }
264
264
 
265
- for (const s of groupedSessions) {
265
+ for (const s of rawSessions) {
266
266
  if (!s.active_by_day) continue;
267
267
  for (const [dayKey, seconds] of Object.entries(s.active_by_day)) {
268
- if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {} });
269
- dayMap.get(dayKey).elapsed_seconds += seconds;
268
+ addElapsedToDay(dayMap, dayKey, s, seconds || 0);
270
269
  }
271
270
  }
272
271
 
@@ -282,10 +281,10 @@ function buildDaily(rawSessions, groupedSessions, tz) {
282
281
  Object.entries(d.by_model).map(([k, v]) => [k, { tokens: Math.round(v.tokens), cost: v.cost, elapsed_seconds: Math.round(v.elapsed_seconds) }])
283
282
  ),
284
283
  by_family: Object.fromEntries(
285
- Object.entries(d.by_family).map(([k, v]) => [k, { tokens: Math.round(v.tokens), cost: v.cost, sessions: v.sessions }])
284
+ Object.entries(d.by_family).map(([k, v]) => [k, { tokens: Math.round(v.tokens), cost: v.cost, elapsed_seconds: Math.round(v.elapsed_seconds), sessions: v.sessions }])
286
285
  ),
287
286
  by_repo: Object.fromEntries(
288
- Object.entries(d.by_repo || {}).map(([k, v]) => [k, { tokens: Math.round(v.tokens), cost: v.cost, sessions: v.sessions }])
287
+ Object.entries(d.by_repo || {}).map(([k, v]) => [k, { tokens: Math.round(v.tokens), cost: v.cost, elapsed_seconds: Math.round(v.elapsed_seconds), sessions: v.sessions }])
289
288
  ),
290
289
  approximate: true,
291
290
  }));
@@ -304,13 +303,13 @@ function addToDay(dayMap, dayKey, session, fraction) {
304
303
  if (session.cost !== null) d.by_model[mKey].cost += session.cost * fraction;
305
304
 
306
305
  const fKey = session.agent_family;
307
- if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, sessions: 0 };
306
+ if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
308
307
  d.by_family[fKey].tokens += session.tokens_used * fraction;
309
308
  if (session.cost !== null) d.by_family[fKey].cost += session.cost * fraction;
310
309
  if (fraction > 0.001) d.by_family[fKey].sessions++;
311
310
 
312
311
  const rKey = session.repo_label || 'unknown';
313
- if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, sessions: 0 };
312
+ if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
314
313
  d.by_repo[rKey].tokens += session.tokens_used * fraction;
315
314
  if (session.cost !== null) d.by_repo[rKey].cost += session.cost * fraction;
316
315
  if (fraction > 0.001) d.by_repo[rKey].sessions++;
@@ -375,11 +374,11 @@ function addPresenceToDay(dayMap, dayKey, session, fraction) {
375
374
  if (fraction > 0.001) d.sessions++;
376
375
 
377
376
  const fKey = session.agent_family;
378
- if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, sessions: 0 };
377
+ if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
379
378
  if (fraction > 0.001) d.by_family[fKey].sessions++;
380
379
 
381
380
  const rKey = session.repo_label || 'unknown';
382
- if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, sessions: 0 };
381
+ if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
383
382
  if (fraction > 0.001) d.by_repo[rKey].sessions++;
384
383
  }
385
384
 
@@ -397,17 +396,36 @@ function addUsageByDay(dayMap, session) {
397
396
  if (usageDay.cost !== null) d.by_model[mKey].cost += usageDay.cost;
398
397
 
399
398
  const fKey = session.agent_family;
400
- if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, sessions: 0 };
399
+ if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
401
400
  d.by_family[fKey].tokens += usageDay.tokens || 0;
402
401
  if (usageDay.cost !== null) d.by_family[fKey].cost += usageDay.cost;
403
402
 
404
403
  const rKey = session.repo_label || 'unknown';
405
- if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, sessions: 0 };
404
+ if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
406
405
  d.by_repo[rKey].tokens += usageDay.tokens || 0;
407
406
  if (usageDay.cost !== null) d.by_repo[rKey].cost += usageDay.cost;
408
407
  }
409
408
  }
410
409
 
410
+ function addElapsedToDay(dayMap, dayKey, session, seconds) {
411
+ if (!seconds) return;
412
+ if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {}, by_repo: {} });
413
+ const d = dayMap.get(dayKey);
414
+ d.elapsed_seconds += seconds;
415
+
416
+ const mKey = session.model_name || 'unknown';
417
+ if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
418
+ d.by_model[mKey].elapsed_seconds += seconds;
419
+
420
+ const fKey = session.agent_family || 'generic';
421
+ if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
422
+ d.by_family[fKey].elapsed_seconds += seconds;
423
+
424
+ const rKey = session.repo_label || 'unknown';
425
+ if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
426
+ d.by_repo[rKey].elapsed_seconds += seconds;
427
+ }
428
+
411
429
  function dayStartMs(dayKey) {
412
430
  const [y, m, d] = dayKey.split('-').map(Number);
413
431
  return new Date(y, m - 1, d).getTime();
@@ -462,11 +480,11 @@ function collapseSessionGroup(rootThreadId, group, rootLookup) {
462
480
  thread_id: root.thread_id,
463
481
  root_thread_id: rootThreadId,
464
482
  repo_label: repoLabels.size === 1 ? [...repoLabels][0] : (root.repo_label || 'mixed'),
465
- model_name: root.model_name || pickSummaryValue(modelNames),
466
- reasoning_effort: root.reasoning_effort || pickSummaryValue(efforts),
467
- agent_role: root.agent_role,
468
- agent_nickname: root.agent_nickname,
469
- agent_family: root.agent_family,
483
+ model_name: summarizeGroupedValue(root.model_name, modelNames),
484
+ reasoning_effort: summarizeGroupedValue(root.reasoning_effort, efforts),
485
+ agent_role: summarizeGroupedValue(root.agent_role, agentRoles),
486
+ agent_nickname: summarizeGroupedValue(root.agent_nickname, agentNicknames),
487
+ agent_family: summarizeGroupedValue(root.agent_family, agentFamilySet),
470
488
  is_subagent: false,
471
489
  started_at: rootStartedAt,
472
490
  ended_at: rootEndedAt,
@@ -490,10 +508,10 @@ function collapseSessionGroup(rootThreadId, group, rootLookup) {
490
508
  };
491
509
  }
492
510
 
493
- function pickSummaryValue(values) {
494
- if (values.size === 0) return null;
511
+ function summarizeGroupedValue(rootValue, values) {
512
+ if (values.size === 0) return rootValue || null;
495
513
  if (values.size === 1) return [...values][0];
496
- return `${[...values][0]} +${values.size - 1}`;
514
+ return 'mixed';
497
515
  }
498
516
 
499
517
  function mergeActiveByDay(target, source) {
@@ -77,7 +77,7 @@ export function getModelPricing(modelName) {
77
77
  return getPricing()[modelName] || null;
78
78
  }
79
79
 
80
- export const CATALOG_VERSION = '2026-03-13';
80
+ export const CATALOG_VERSION = '2026-03-17';
81
81
  export const CACHE_ASSUMPTIONS = {
82
82
  input_fraction: INPUT_FRACTION,
83
83
  output_fraction: OUTPUT_FRACTION,
package/server/ingest.js CHANGED
@@ -2,7 +2,7 @@ import { readThreads } from './sqlite-reader.js';
2
2
  import { randomUUID } from 'crypto';
3
3
  import {
4
4
  normalizeCwd, deriveRepoKey, deriveRepoLabel,
5
- classifyAgentFamily, isSubagent, normalizeModelName,
5
+ classifyAgentFamily, deriveAgentRole, isReviewLauncherSession, isSubagent, normalizeModelName,
6
6
  } from './normalize.js';
7
7
  import { calculateCostFromUsage, initPricing, priceSession } from './cost-catalog.js';
8
8
  import { buildAggregates, buildSessionView } from './aggregator.js';
@@ -10,6 +10,7 @@ import { createDayKeyFormatter } from './day-key.js';
10
10
  import { createLiveAggregateState, createEmptyLivePatch, applySessionToLiveState, buildLiveBootstrap, buildLivePatch } from './live-state.js';
11
11
  import { createRolloutWorkerPool } from './rollout-worker-pool.js';
12
12
  import { beginReplayCapture, createReplayCaptureState, failReplayCapture, getReplaySnapshot, recordReplayEvent, resetReplayCapture } from './export-replay.js';
13
+ import { findUsageEntryAtOrBefore, hasUsageTotals, readUsageTimeline, subtractUsageTotals } from './rollout-reader.js';
13
14
  import { OVERVIEW_INGEST_ANIMATION } from '../src/utils/animationsDefault.js';
14
15
 
15
16
  const LIVE_FRAME_INTERVAL_MS = Math.max(
@@ -99,9 +100,11 @@ export async function runIngest(codexHome, state, opts = {}) {
99
100
  const sessions = [];
100
101
  for (const t of threads) {
101
102
  const nc = normalizeCwd(t.cwd_raw);
103
+ const agentRole = deriveAgentRole(t.agent_role, t.source_raw, t.title);
102
104
  sessions.push({
103
105
  thread_id: t.thread_id,
104
106
  rollout_path: t.rollout_path,
107
+ source_raw: t.source_raw,
105
108
  cwd_raw: t.cwd_raw,
106
109
  repo_key: deriveRepoKey(nc),
107
110
  repo_label: deriveRepoLabel(nc),
@@ -117,11 +120,12 @@ export async function runIngest(codexHome, state, opts = {}) {
117
120
  has_usage_by_day: false,
118
121
  live_sort_ts: null,
119
122
  active_by_day: null,
120
- agent_role: t.agent_role,
123
+ agent_role: agentRole,
121
124
  agent_nickname: t.agent_nickname,
122
- agent_family: classifyAgentFamily(t.agent_role),
123
- is_subagent: isSubagent(t.agent_role),
125
+ agent_family: classifyAgentFamily(agentRole),
126
+ is_subagent: isSubagent(agentRole),
124
127
  parent_thread_id: null,
128
+ forked_from_id: null,
125
129
  cost: null,
126
130
  cost_source: 'unavailable',
127
131
  materialized: !t.rollout_path,
@@ -133,9 +137,10 @@ export async function runIngest(codexHome, state, opts = {}) {
133
137
  state.phase = 'enrichment';
134
138
  queueLiveProgress(state);
135
139
 
136
- const bootstrapSessions = sessions.filter((session) => !session.rollout_path);
137
- if (bootstrapSessions.length) {
140
+ const bootstrapCandidates = sessions.filter((session) => !session.rollout_path);
141
+ if (bootstrapCandidates.length) {
138
142
  assignRootThreadIds(sessions);
143
+ const bootstrapSessions = filterVisibleSessions(bootstrapCandidates);
139
144
  const bootstrapPatch = createEmptyLivePatch();
140
145
  for (const session of bootstrapSessions) {
141
146
  finalizeSessionMetrics(session, toDayKey);
@@ -144,14 +149,13 @@ export async function runIngest(codexHome, state, opts = {}) {
144
149
  queueLivePatch(state, bootstrapPatch);
145
150
  }
146
151
 
147
- const candidates = sessions
148
- .filter(s => s.rollout_path)
149
- .sort((a, b) => (a.started_at || 0) - (b.started_at || 0));
152
+ const candidates = selectEnrichmentCandidates(sessions);
150
153
 
151
154
  state.needs_enrichment = candidates.length;
152
155
  state.percent = candidates.length > 0 ? 0.08 : 0.90;
153
156
  const BATCH_SIZE = opts.batchSize || 40;
154
157
  const ROOT_REFRESH_EVERY = opts.rootRefreshEvery || 1000;
158
+ const resolveForkUsageSnapshot = createForkUsageSnapshotResolver(sessions);
155
159
  let lastRootRefreshCount = 0;
156
160
 
157
161
  for (let i = 0; i < candidates.length; i += BATCH_SIZE) {
@@ -177,12 +181,18 @@ export async function runIngest(codexHome, state, opts = {}) {
177
181
  if (data.model_name) s.model_name = normalizeModelName(data.model_name);
178
182
  if (data.reasoning_effort) s.reasoning_effort = data.reasoning_effort;
179
183
  if (data.parent_thread_id) s.parent_thread_id = data.parent_thread_id;
184
+ if (data.forked_from_id) s.forked_from_id = data.forked_from_id;
180
185
  if (data.usage_total) s.usage_total = data.usage_total;
186
+ if (data.usage_by_day) s._usage_by_day_raw = structuredClone(data.usage_by_day);
181
187
  if (data.usage_by_day) {
182
188
  s.usage_by_day = buildUsageByDayMetrics(s.model_name, data.usage_by_day);
183
189
  s.has_usage_by_day = s.usage_by_day.length > 0;
184
190
  }
191
+ if (data.usage_reset_detected) {
192
+ s._usage_reset_detected = true;
193
+ }
185
194
  if (data.first_usage_timestamp) {
195
+ s._first_usage_timestamp_ms = data.first_usage_timestamp;
186
196
  s.live_sort_ts = Math.floor(data.first_usage_timestamp / 1000);
187
197
  }
188
198
  if (data.active_seconds && data.active_seconds > 0) {
@@ -190,6 +200,17 @@ export async function runIngest(codexHome, state, opts = {}) {
190
200
  s.active_by_day = data.active_by_day || null;
191
201
  }
192
202
  }
203
+ }
204
+
205
+ await applyForkUsageCorrections(batch, resolveForkUsageSnapshot);
206
+ for (const s of batch) {
207
+ if (s._usage_by_day_raw) {
208
+ s.usage_by_day = buildUsageByDayMetrics(s.model_name, s._usage_by_day_raw);
209
+ s.has_usage_by_day = s.usage_by_day.length > 0;
210
+ }
211
+ delete s._usage_by_day_raw;
212
+ delete s._first_usage_timestamp_ms;
213
+ delete s._usage_reset_detected;
193
214
  finalizeSessionMetrics(s, toDayKey);
194
215
  s.live_sort_day = deriveLiveSortDay(s, toDayKey);
195
216
  s.materialized = true;
@@ -204,13 +225,12 @@ export async function runIngest(codexHome, state, opts = {}) {
204
225
  lastRootRefreshCount = state.enriched;
205
226
  }
206
227
  const liveReady = bufferLiveSessionsForPresentation(state, batch);
207
- if (state.live_session_buffer[0]?.live_sort_day) {
208
- state.current_date_bucket = state.live_session_buffer[0].live_sort_day;
209
- } else if (liveReady[0]?.live_sort_day) {
210
- state.current_date_bucket = liveReady[0].live_sort_day;
211
- }
228
+ state.current_date_bucket = pickVisibleDateBucket(
229
+ filterVisibleSessions(state.live_session_buffer),
230
+ filterVisibleSessions(liveReady)
231
+ );
212
232
  const livePatch = createEmptyLivePatch();
213
- for (const session of liveReady) {
233
+ for (const session of filterVisibleSessions(liveReady)) {
214
234
  applySessionToLiveState(state.live_state, session, livePatch);
215
235
  }
216
236
  queueLivePatch(state, livePatch);
@@ -230,7 +250,7 @@ export async function runIngest(codexHome, state, opts = {}) {
230
250
 
231
251
  assignRootThreadIds(sessions);
232
252
  const finalLivePatch = createEmptyLivePatch();
233
- for (const session of flushBufferedLiveSessions(state)) {
253
+ for (const session of filterVisibleSessions(flushBufferedLiveSessions(state))) {
234
254
  applySessionToLiveState(state.live_state, session, finalLivePatch);
235
255
  }
236
256
  queueLivePatch(state, finalLivePatch);
@@ -296,13 +316,28 @@ export function restartIngest(codexHome, state, opts = {}) {
296
316
 
297
317
  function rebuildAggregates(sessions, state, opts, tz, mode = {}) {
298
318
  const source = mode.partial ? sessions.filter(session => session.materialized) : sessions;
299
- const filtered = applyFilters(source, opts);
319
+ const visibleSource = filterVisibleSessions(source);
320
+ const filtered = applyFilters(visibleSource, opts);
300
321
  const sessionView = buildSessionView(filtered, source);
301
322
  state.sessions = sessionView;
302
323
  state.aggregates = buildAggregates(filtered, tz, sessionView);
303
324
  state.generated_at = new Date().toISOString();
304
325
  }
305
326
 
327
+ export function filterVisibleSessions(sessions) {
328
+ return sessions.filter((session) => !isReviewLauncherSession(session));
329
+ }
330
+
331
+ export function selectEnrichmentCandidates(sessions) {
332
+ return sessions
333
+ .filter((session) => session.rollout_path)
334
+ .sort((a, b) => (a.started_at || 0) - (b.started_at || 0));
335
+ }
336
+
337
+ export function pickVisibleDateBucket(bufferedSessions, liveReadySessions) {
338
+ return bufferedSessions[0]?.live_sort_day || liveReadySessions[0]?.live_sort_day || null;
339
+ }
340
+
306
341
  function applyFilters(sessions, opts) {
307
342
  let result = sessions;
308
343
  if (opts.from) {
@@ -325,7 +360,7 @@ function applyFilters(sessions, opts) {
325
360
  return result;
326
361
  }
327
362
 
328
- function assignRootThreadIds(sessions) {
363
+ export function assignRootThreadIds(sessions, toDayKey = createDayKeyFormatter(Intl.DateTimeFormat().resolvedOptions().timeZone)) {
329
364
  const byId = new Map(sessions.map(session => [session.thread_id, session]));
330
365
  const memo = new Map();
331
366
 
@@ -336,37 +371,63 @@ function assignRootThreadIds(sessions) {
336
371
  const trail = [];
337
372
  const seen = new Set();
338
373
  let current = session;
339
- let rootId = session.thread_id;
374
+ let root = {
375
+ id: session.thread_id,
376
+ dayKey: session.started_at ? toDayKey(session.started_at * 1000) : null,
377
+ };
340
378
 
341
379
  while (current) {
342
380
  trail.push(current.thread_id);
343
381
  const parentId = current.parent_thread_id;
344
382
 
345
383
  if (!parentId || parentId === current.thread_id || seen.has(parentId)) {
346
- rootId = current.thread_id;
384
+ root = {
385
+ id: current.thread_id,
386
+ dayKey: current.started_at ? toDayKey(current.started_at * 1000) : null,
387
+ };
347
388
  break;
348
389
  }
349
390
 
350
391
  if (memo.has(parentId)) {
351
- rootId = memo.get(parentId);
392
+ const parentRoot = memo.get(parentId);
393
+ const currentDayKey = current.started_at ? toDayKey(current.started_at * 1000) : null;
394
+ root = parentRoot?.dayKey && currentDayKey === parentRoot.dayKey
395
+ ? parentRoot
396
+ : {
397
+ id: current.thread_id,
398
+ dayKey: currentDayKey,
399
+ };
352
400
  break;
353
401
  }
354
402
 
355
403
  seen.add(parentId);
356
404
  const parent = byId.get(parentId);
357
405
  if (!parent) {
358
- rootId = parentId;
406
+ root = {
407
+ id: parentId,
408
+ dayKey: current.started_at ? toDayKey(current.started_at * 1000) : null,
409
+ };
410
+ break;
411
+ }
412
+
413
+ const parentDayKey = parent.started_at ? toDayKey(parent.started_at * 1000) : null;
414
+ const currentDayKey = current.started_at ? toDayKey(current.started_at * 1000) : null;
415
+ if (currentDayKey && parentDayKey && currentDayKey !== parentDayKey) {
416
+ root = {
417
+ id: current.thread_id,
418
+ dayKey: currentDayKey,
419
+ };
359
420
  break;
360
421
  }
361
422
  current = parent;
362
423
  }
363
424
 
364
- for (const threadId of trail) memo.set(threadId, rootId);
365
- return rootId;
425
+ for (const threadId of trail) memo.set(threadId, root);
426
+ return root;
366
427
  };
367
428
 
368
429
  for (const session of sessions) {
369
- session.root_thread_id = resolveRoot(session) || session.thread_id;
430
+ session.root_thread_id = resolveRoot(session)?.id || session.thread_id;
370
431
  }
371
432
  }
372
433
 
@@ -407,6 +468,130 @@ function buildUsageByDayMetrics(modelName, usageByDay) {
407
468
  return entries;
408
469
  }
409
470
 
471
+ function createForkUsageSnapshotResolver(sessions) {
472
+ const byId = new Map(sessions.map((session) => [session.thread_id, session]));
473
+ const timelineCache = new Map();
474
+
475
+ return async function resolveForkUsageSnapshot(parentThreadId, boundary) {
476
+ if (!parentThreadId || !boundary) return null;
477
+ const parent = byId.get(parentThreadId);
478
+ if (!parent?.rollout_path) return null;
479
+
480
+ let timelinePromise = timelineCache.get(parentThreadId);
481
+ if (!timelinePromise) {
482
+ timelinePromise = readUsageTimeline(parent.rollout_path);
483
+ timelineCache.set(parentThreadId, timelinePromise);
484
+ }
485
+
486
+ const timeline = await timelinePromise;
487
+ return selectForkParentUsageEntry(timeline, boundary)?.usage || null;
488
+ };
489
+ }
490
+
491
+ export function selectForkParentUsageEntry(timeline, {
492
+ startedAtMs = null,
493
+ firstUsageTimestampMs = null,
494
+ } = {}) {
495
+ if (!Array.isArray(timeline) || !timeline.length) return null;
496
+ const startEntry = findUsageEntryAtOrBefore(timeline, startedAtMs);
497
+ const firstUsageEntry = findUsageEntryAtOrBefore(timeline, firstUsageTimestampMs);
498
+ if (startEntry && firstUsageEntry && startEntry.segment_id !== firstUsageEntry.segment_id) {
499
+ return startEntry;
500
+ }
501
+ return firstUsageEntry || startEntry || null;
502
+ }
503
+
504
+ async function applyForkUsageCorrections(sessions, resolveForkUsageSnapshot) {
505
+ for (const session of sessions) {
506
+ const parentThreadId = getForkLineageParentThreadId(session);
507
+ if (!parentThreadId) continue;
508
+ if (session._usage_reset_detected) continue;
509
+ // Use the child's first observed token snapshot as the primary fork
510
+ // boundary. On the real fork-heavy March chains, this consistently
511
+ // produced better attribution than started_at: the delay from start to
512
+ // first token_count is usually sub-second, while started_at retains
513
+ // slightly more inherited parent usage. We still pass started_at through
514
+ // to the resolver so a parent reset between fork start and first child
515
+ // stream can fall back to the pre-reset parent segment instead of
516
+ // under-subtracting inherited usage. If the child rollout later resets its
517
+ // token counters, we treat the post-reset segment as the authoritative
518
+ // child-only usage and do not subtract the parent again, because that
519
+ // would double-normalize the same inherited baseline and undercount the
520
+ // child.
521
+ const inheritedUsage = await resolveForkUsageSnapshot(parentThreadId, {
522
+ startedAtMs: session.started_at ? session.started_at * 1000 : null,
523
+ firstUsageTimestampMs: session._first_usage_timestamp_ms || null,
524
+ });
525
+ applyForkUsageCorrection(session, inheritedUsage);
526
+ }
527
+ }
528
+
529
+ export function getForkLineageParentThreadId(session) {
530
+ return session?.forked_from_id || null;
531
+ }
532
+
533
+ export function applyForkUsageCorrection(session, inheritedUsage) {
534
+ if (!hasUsageTotals(inheritedUsage)) return false;
535
+
536
+ let changed = false;
537
+
538
+ if (session?.usage_total) {
539
+ session.usage_total = subtractUsageTotals(session.usage_total, inheritedUsage);
540
+ session.tokens_used = session.usage_total.total_tokens || 0;
541
+ changed = true;
542
+ } else if (typeof session?.tokens_used === 'number') {
543
+ session.tokens_used = Math.max(session.tokens_used - (inheritedUsage.total_tokens || 0), 0);
544
+ changed = true;
545
+ }
546
+
547
+ if (session._usage_by_day_raw) {
548
+ session._usage_by_day_raw = subtractUsageFromDayBuckets(session._usage_by_day_raw, inheritedUsage);
549
+ changed = true;
550
+ }
551
+
552
+ return changed;
553
+ }
554
+
555
+ function subtractUsageFromDayBuckets(usageByDay, inheritedUsage) {
556
+ const remaining = {
557
+ input_tokens: inheritedUsage?.input_tokens || 0,
558
+ cached_input_tokens: inheritedUsage?.cached_input_tokens || 0,
559
+ output_tokens: inheritedUsage?.output_tokens || 0,
560
+ reasoning_output_tokens: inheritedUsage?.reasoning_output_tokens || 0,
561
+ total_tokens: inheritedUsage?.total_tokens || 0,
562
+ };
563
+
564
+ const next = {};
565
+ for (const dayKey of Object.keys(usageByDay || {}).sort()) {
566
+ const current = usageByDay[dayKey] || {};
567
+ const adjusted = subtractUsageTotals({
568
+ input_tokens: current.input_tokens || 0,
569
+ cached_input_tokens: current.cached_input_tokens || 0,
570
+ output_tokens: current.output_tokens || 0,
571
+ total_tokens: (current.input_tokens || 0) + (current.output_tokens || 0),
572
+ }, remaining);
573
+
574
+ const consumed = subtractUsageTotals({
575
+ input_tokens: current.input_tokens || 0,
576
+ cached_input_tokens: current.cached_input_tokens || 0,
577
+ output_tokens: current.output_tokens || 0,
578
+ total_tokens: (current.input_tokens || 0) + (current.output_tokens || 0),
579
+ }, adjusted);
580
+
581
+ Object.assign(remaining, subtractUsageTotals(remaining, consumed));
582
+
583
+ if (adjusted.input_tokens > 0 || adjusted.cached_input_tokens > 0 || adjusted.output_tokens > 0) {
584
+ next[dayKey] = {
585
+ input_tokens: adjusted.input_tokens,
586
+ cached_input_tokens: adjusted.cached_input_tokens,
587
+ output_tokens: adjusted.output_tokens,
588
+ };
589
+ }
590
+ }
591
+
592
+ return next;
593
+ }
594
+
410
595
  function deriveLiveSortDay(session, toDayKey) {
411
596
  if (session.has_usage_by_day && session.usage_by_day?.length) {
412
597
  let best = session.usage_by_day[0];
@@ -706,5 +891,3 @@ function broadcastBootstrap(state) {
706
891
  };
707
892
  broadcastLive(state, 'bootstrap', payload);
708
893
  }
709
-
710
-
@@ -242,8 +242,7 @@ function applyDailyBucket(dayMap, session, toDayKey, dirtySet) {
242
242
 
243
243
  if (session.active_by_day) {
244
244
  for (const [dayKey, seconds] of Object.entries(session.active_by_day)) {
245
- const day = ensureDay(dayMap, dayKey);
246
- day.elapsed_seconds += seconds || 0;
245
+ addElapsedToDay(dayMap, dayKey, session, seconds || 0);
247
246
  dirtySet.add(dayKey);
248
247
  }
249
248
  }
@@ -307,6 +306,14 @@ function addSessionPresence(dayMap, startDay, endDay, startMs, endMs, totalDur,
307
306
  function addPresenceToDay(dayMap, dayKey, session, fraction) {
308
307
  const day = ensureDay(dayMap, dayKey);
309
308
  if (fraction > 0.001) day.sessions += 1;
309
+
310
+ const familyKey = session.agent_family || 'generic';
311
+ if (!day.by_family[familyKey]) day.by_family[familyKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
312
+ if (fraction > 0.001) day.by_family[familyKey].sessions += 1;
313
+
314
+ const repoKey = session.repo_label || 'unknown';
315
+ if (!day.by_repo[repoKey]) day.by_repo[repoKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
316
+ if (fraction > 0.001) day.by_repo[repoKey].sessions += 1;
310
317
  }
311
318
 
312
319
  function addUsageByDay(dayMap, session, dirtySet) {
@@ -320,6 +327,16 @@ function addUsageByDay(dayMap, session, dirtySet) {
320
327
  if (!day.by_model[modelKey]) day.by_model[modelKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
321
328
  day.by_model[modelKey].tokens += usageDay.tokens || 0;
322
329
  if (usageDay.cost !== null) day.by_model[modelKey].cost += usageDay.cost;
330
+
331
+ const familyKey = session.agent_family || 'generic';
332
+ if (!day.by_family[familyKey]) day.by_family[familyKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
333
+ day.by_family[familyKey].tokens += usageDay.tokens || 0;
334
+ if (usageDay.cost !== null) day.by_family[familyKey].cost += usageDay.cost;
335
+
336
+ const repoKey = session.repo_label || 'unknown';
337
+ if (!day.by_repo[repoKey]) day.by_repo[repoKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
338
+ day.by_repo[repoKey].tokens += usageDay.tokens || 0;
339
+ if (usageDay.cost !== null) day.by_repo[repoKey].cost += usageDay.cost;
323
340
  dirtySet.add(dayKey);
324
341
  }
325
342
  }
@@ -334,15 +351,45 @@ function addToDay(dayMap, dayKey, session, fraction) {
334
351
  if (!day.by_model[modelKey]) day.by_model[modelKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
335
352
  day.by_model[modelKey].tokens += (session.tokens_used || 0) * fraction;
336
353
  if (session.cost !== null) day.by_model[modelKey].cost += session.cost * fraction;
354
+
355
+ const familyKey = session.agent_family || 'generic';
356
+ if (!day.by_family[familyKey]) day.by_family[familyKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
357
+ day.by_family[familyKey].tokens += (session.tokens_used || 0) * fraction;
358
+ if (session.cost !== null) day.by_family[familyKey].cost += session.cost * fraction;
359
+ if (fraction > 0.001) day.by_family[familyKey].sessions += 1;
360
+
361
+ const repoKey = session.repo_label || 'unknown';
362
+ if (!day.by_repo[repoKey]) day.by_repo[repoKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
363
+ day.by_repo[repoKey].tokens += (session.tokens_used || 0) * fraction;
364
+ if (session.cost !== null) day.by_repo[repoKey].cost += session.cost * fraction;
365
+ if (fraction > 0.001) day.by_repo[repoKey].sessions += 1;
337
366
  }
338
367
 
339
368
  function ensureDay(dayMap, dayKey) {
340
369
  if (!dayMap.has(dayKey)) {
341
- dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {} });
370
+ dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {}, by_repo: {} });
342
371
  }
343
372
  return dayMap.get(dayKey);
344
373
  }
345
374
 
375
+ function addElapsedToDay(dayMap, dayKey, session, seconds) {
376
+ if (!seconds) return;
377
+ const day = ensureDay(dayMap, dayKey);
378
+ day.elapsed_seconds += seconds;
379
+
380
+ const modelKey = session.model_name || 'unknown';
381
+ if (!day.by_model[modelKey]) day.by_model[modelKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
382
+ day.by_model[modelKey].elapsed_seconds += seconds;
383
+
384
+ const familyKey = session.agent_family || 'generic';
385
+ if (!day.by_family[familyKey]) day.by_family[familyKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
386
+ day.by_family[familyKey].elapsed_seconds += seconds;
387
+
388
+ const repoKey = session.repo_label || 'unknown';
389
+ if (!day.by_repo[repoKey]) day.by_repo[repoKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
390
+ day.by_repo[repoKey].elapsed_seconds += seconds;
391
+ }
392
+
346
393
  function ensureHeatmapDay(dayMap, dayKey) {
347
394
  if (!dayMap.has(dayKey)) {
348
395
  dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed: 0, sessions: 0 });
@@ -424,6 +471,8 @@ function serializeDailyEntry(value) {
424
471
  elapsed_seconds: Math.round(value?.elapsed_seconds || 0),
425
472
  sessions: value?.sessions || 0,
426
473
  by_model: deepRoundClone(value?.by_model || {}, ['tokens', 'cost', 'elapsed_seconds']),
474
+ by_family: deepRoundClone(value?.by_family || {}, ['tokens', 'cost', 'elapsed_seconds', 'sessions']),
475
+ by_repo: deepRoundClone(value?.by_repo || {}, ['tokens', 'cost', 'elapsed_seconds', 'sessions']),
427
476
  approximate: true,
428
477
  };
429
478
  }