taskchef 9.3.0 → 9.5.0

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,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.3.0",
3
+ "version": "9.5.0",
4
4
  "description": "Native Codex dashboard with lightweight project delegation.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/docs/mcp-app.md CHANGED
@@ -83,14 +83,13 @@ Open the installed TaskChef plugin details page to edit its native
83
83
  settings. The sidebar receives them at its next visible refresh, or immediately
84
84
  when Refresh is clicked. Settings persist across sidebar and embedded views.
85
85
  The old browser-local visibility keys are no longer used; configure the native
86
- switches once after upgrading. The Board/List choice stays browser-local.
86
+ switches once after upgrading. The Board opens by default.
87
87
  See the [OpenAI structured settings specification](https://github.com/openai/mcp-extensions/blob/main/docs/spec.md#structured-settings).
88
88
 
89
89
  ### Board presentation
90
90
 
91
- TaskChef exposes only Board. The List implementation remains in the
92
- source, but the Board/List selector is hidden and saved List choices are
93
- ignored. Interrupted chats appear in Waiting for input/review with an
91
+ TaskChef exposes Board and Usage. The List implementation remains in the
92
+ source, but List is hidden and saved List choices are ignored. Interrupted chats appear in Waiting for input/review with an
94
93
  Interrupted tag; their recorded status remains available in Details.
95
94
  Scheduled, Running, Waiting, and Done remain visible when empty, with a short
96
95
  message specific to each column. This keeps their positions steady when chats
@@ -300,7 +299,7 @@ UI surfaces. Native font families use locally available fonts with system fallba
300
299
  read the installed desktop application or fetch theme files from the network.
301
300
 
302
301
  The compact **Pinned** toggle button shows chats pinned in Codex and combines with project,
303
- update window, and search filters. It starts off when the dashboard opens. Pin
302
+ update window, and search filters. It defaults to off and remembers the user’s selection locally. Pin
304
303
  changes appear on the next data refresh. The current desktop pin list takes
305
304
  precedence over the database `is_pinned` field. TaskChef ignores old migration
306
305
  snapshots and never writes pin state. Without either source, chats are unpinned.
@@ -365,3 +364,87 @@ Retire the old shared catalog entry only after this repository's catalog is
365
364
  live, the release workflow has completed, and a clean installation from the
366
365
  new marketplace succeeds. Existing users must migrate explicitly; changing
367
366
  a marketplace does not automatically move their installed plugin identity.
367
+
368
+ ## Usage analytics
369
+
370
+ ![Usage analytics with sample data](images/usage-analytics.jpg)
371
+
372
+ The Board/Usage selector sits beside the logo in the navbar, separated from
373
+ Board filters by a divider. It opens an on-demand historical usage view. Ranges are
374
+ 7, 30, or 90 calendar days including today, This month (through today), and
375
+ Last month (the full previous calendar month). The default is 30 days; the
376
+ selected range is remembered locally when Usage is reopened.
377
+ There is no custom range. Dates use the view's local timezone, including DST.
378
+ Range changes and reopening Usage query one shared history snapshot without
379
+ rescanning logs. The snapshot is scoped by CLI/exec visibility, independently of
380
+ range or timezone. After one minute it refreshes in the background while cached
381
+ charts remain visible. Refresh explicitly rebuilds it, reusing existing per-file
382
+ metadata and incremental parsing caches. Unchanged files retain parsed records
383
+ on disk across restarts and turn or price changes. A session filename index
384
+ avoids searching the full inventory for each chat. Missing paths do not incur
385
+ reader retry delays. Empty or mismatched files are checked again when their
386
+ metadata changes; transient I/O failures retry while retaining healthy sources.
387
+ Rescan explicitly reparses available files and refreshes the inventory, while
388
+ retaining known history for missing sources. Board polling never starts a history scan.
389
+
390
+ The combined chart uses cost bars on the left axis and a token line on the
391
+ right. Today's cost bars have a lighter fill and dashed borders in both charts;
392
+ the token segment leading into today is dashed too. Finished days, including
393
+ the last day of Last month, stay solid. The project chart uses stacked cost bars for the period's top five
394
+ projects plus Other. Select a legend entry or a Top 10 row to isolate that
395
+ project in the project chart; All projects restores the stack. Hovering or
396
+ focusing a legend entry highlights its bars and dims other project colors. Totals, the
397
+ combined chart, and rankings always describe the full selected period.
398
+
399
+ Archived chats and descendant usage are included. CLI/exec visibility settings
400
+ still apply to root chats. Descendants are counted once under their root chat's
401
+ current project. Unassigned chats share one Unassigned project. Daily usage
402
+ comes from recorded call timestamps, not chat creation or last-update dates.
403
+ Unexplained cumulative counter gaps stay in lifetime totals but are excluded
404
+ from daily totals because their dates are unknown. Missing records or prices
405
+ remain marked in the analytics response without displaying coverage warnings.
406
+ Unknown costs are excluded from estimated cost.
407
+ Cached input is part of input tokens, and reasoning output is part of output.
408
+ Cost is an estimate at current API-equivalent rates, not subscription billing
409
+ or remaining allowance. The first read shows a progress bar with the actual number of root chats
410
+ processed and polls an app-only tool. Cached charts stay visible during later
411
+ refreshes, with their last-update time and scan progress.
412
+
413
+ ## Month calendar
414
+
415
+ Calendar sits beside Board and Usage. It opens on the current local month;
416
+ previous/next arrows surround the month title. Next is disabled at the current
417
+ local month, so future months cannot be selected. There is no Today control.
418
+ Arrows stay hidden until the month navigation is hovered or contains focus;
419
+ a clicked arrow remains visible while focused. This also applies in embedded
420
+ hosts that do not report mouse hover support. Project, search, update window, and Pinned
421
+ filters are remembered locally. An unavailable saved project resets to All
422
+ projects only after the project inventory loads.
423
+ Project, Search Chats, Pinned, and native visibility settings apply. The Board's
424
+ Updated filter does not apply to Calendar. A Calendar-only Scheduled
425
+ toggle starts enabled and hides chats with an active schedule when disabled,
426
+ including those currently Running or Waiting. Its choice is remembered locally across page visits and does not filter
427
+ Board or Usage. Selecting one project hides
428
+ project prefixes on calendar bars. The grid has no persistent helper captions. Scheduled and Pinned share
429
+ the same toggle styling for their on, off, and hover states.
430
+
431
+ Each chat's recorded turn intervals become calendar dates in the viewer's
432
+ timezone. Adjacent dates merge into one bar. One full idle date breaks the bar,
433
+ including weekends; a later segment has a resumed marker and opens the same
434
+ chat Details. Continuous work across a week boundary uses continuation arrows.
435
+ An exact midnight end does not occupy the following date. A current running
436
+ turn extends to now; completed turns do not extend through idle review waits.
437
+ Project colors stay consistent when navigating or filtering. Crowded weeks show
438
+ three bar rows and a control to expand the remaining chats. Narrow views scroll
439
+ horizontally to retain seven readable day columns.
440
+
441
+ The calendar reads all turn timing rows for each eligible chat's selected
442
+ SQLite history, cached until the databases change. The calendar rereads on
443
+ changed snapshots or database reads, explicit Refresh, and local date rollover;
444
+ an unchanged five-second poll does not reread calendar history. It does not scan usage,
445
+ modify Codex records, or create editable start/end dates. Earlier histories
446
+ replaced by a rewind may be absent. Missing start timestamps are omitted;
447
+ missing durations show only a known start date. Missing timing data does not
448
+ add a notice to the calendar.
449
+
450
+ ![Month calendar with paused and resumed sample chats](images/calendar-month.png)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.3.0",
3
+ "version": "9.5.0",
4
4
  "description": "A native Codex dashboard for chats, usage, pull requests, and schedules.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Favo Yang",
@@ -0,0 +1,30 @@
1
+ // Date-only arithmetic uses UTC; recorded instants are first converted to the
2
+ // viewer's timezone. This keeps full idle days consistent across DST changes.
3
+ const nextDay = date => new Date(Date.parse(`${date}T00:00:00Z`) + 86400000).toISOString().slice(0, 10);
4
+ export function calendarSegments(turns, { month, timeZone, now = Date.now(), latestTurnId }) {
5
+ const format = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
6
+ const day = time => {
7
+ const parts = Object.fromEntries(format.formatToParts(time).map(part => [part.type, part.value]));
8
+ return `${parts.year}-${parts.month}-${parts.day}`;
9
+ };
10
+ let incomplete = false;
11
+ const intervals = [];
12
+ for (const turn of turns) {
13
+ const start = turn.started_at * 1000;
14
+ if (!Number.isFinite(start) || turn.started_at == null || start <= 0 || start > now) { incomplete = true; continue; }
15
+ const running = turn.status === 'inProgress' && turn.turn_id === latestTurnId;
16
+ const hasDuration = Number.isFinite(turn.duration_ms) && turn.duration_ms >= 0;
17
+ if (!running && !hasDuration) incomplete = true;
18
+ const end = running ? now : Math.min(now, start + (hasDuration ? turn.duration_ms : 0));
19
+ // A turn ending exactly at midnight did not work on the following day.
20
+ intervals.push({ start: day(start), end: day(Math.max(start, end - 1)) });
21
+ }
22
+ intervals.sort((a, b) => a.start.localeCompare(b.start) || a.end.localeCompare(b.end));
23
+ const segments = [];
24
+ for (const interval of intervals) {
25
+ const previous = segments.at(-1);
26
+ if (previous && interval.start <= nextDay(previous.end)) previous.end = previous.end > interval.end ? previous.end : interval.end;
27
+ else segments.push({ ...interval, resumed: segments.length > 0 });
28
+ }
29
+ return { incomplete, segments: segments.filter(segment => segment.start.slice(0, 7) <= month && segment.end.slice(0, 7) >= month) };
30
+ }
@@ -4,19 +4,19 @@ import * as zlib from "node:zlib";
4
4
  export function plainRolloutPath(path) { return path.replace(/\.jsonl\.zst$/, ".jsonl"); }
5
5
 
6
6
  // Codex can replace either representation while readers are resolving a path.
7
- async function resolve(path, inspect) {
7
+ async function resolve(path, inspect, retries = 3) {
8
8
  const plain = plainRolloutPath(path);
9
9
  for (let attempt = 0; ; attempt++) {
10
10
  for (const candidate of [plain, `${plain}.zst`]) {
11
11
  try { return { path: candidate, ...await inspect(candidate) }; }
12
12
  catch (error) { if (error.code !== "ENOENT") throw error; }
13
13
  }
14
- if (attempt === 3) { const error = new Error("Rollout is missing."); error.code = "ENOENT"; throw error; }
14
+ if (attempt === retries) { const error = new Error("Rollout is missing."); error.code = "ENOENT"; throw error; }
15
15
  await new Promise(resolve => setTimeout(resolve, 50));
16
16
  }
17
17
  }
18
18
 
19
- export function rolloutMetadata(path) { return resolve(path, async path => ({ info: await stat(path) })); }
19
+ export function rolloutMetadata(path, { retries = 3 } = {}) { return resolve(path, async path => ({ info: await stat(path) }), retries); }
20
20
  export function openRollout(path) {
21
21
  return resolve(path, async path => {
22
22
  const handle = await open(path, "r");
@@ -1,3 +1,5 @@
1
+ import { calendarSegments } from "./calendar-history.js";
2
+ import { aggregateUsage } from "./usage-analytics.js";
1
3
  import { openRollout, rolloutChunks } from "./codex-rollout.js";
2
4
  import { createHash } from "node:crypto";
3
5
  import { normalizeUri } from "micromark-util-sanitize-uri";
@@ -239,9 +241,10 @@ function readDatabaseRecords(state, history) {
239
241
  FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal DESC LIMIT 1`);
240
242
  const turns = new Map();
241
243
  const needsAnswers = new Map();
242
- const rows = state.prepare(`SELECT id, name, title, cwd, archived, ${hasPinned ? "is_pinned" : "0 AS is_pinned"}, source, created_at_ms, updated_at_ms, recency_at_ms, rollout_path, history_mode, project_id,
244
+ const allRows = state.prepare(`SELECT id, name, title, cwd, archived, ${hasPinned ? "is_pinned" : "0 AS is_pinned"}, source, created_at_ms, updated_at_ms, recency_at_ms, rollout_path, history_mode, project_id,
243
245
  (SELECT count(*) FROM thread_spawn_edges WHERE parent_thread_id = threads.id) AS child_count
244
- FROM threads WHERE ${eligible} ORDER BY recency_at_ms DESC`).all().filter((row) => {
246
+ FROM threads WHERE ${eligible} ORDER BY recency_at_ms DESC`).all();
247
+ const rows = allRows.filter((row) => {
245
248
  const key = historyId(row);
246
249
  const turn = latestTurn.get(key);
247
250
  if (!turn) return false;
@@ -285,7 +288,7 @@ function readDatabaseRecords(state, history) {
285
288
  attachments.set(row.thread_id, urls);
286
289
  }
287
290
  }
288
- return { rows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
291
+ return { rows, allRows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
289
292
  }
290
293
 
291
294
  function buildDatabaseSnapshot(records, now, doneMarks, schedules, scheduleErrors, cacheHit) {
@@ -649,6 +652,60 @@ export class CodexSessionScanner {
649
652
  ) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(id);
650
653
  return this.sessionUsage.read({ id, latestTurnId: task.turnId, sessions, savedDurations });
651
654
  }
655
+ async calendarHistory({ month, timeZone, settings }) {
656
+ const snapshot = await this.refresh();
657
+ if (!snapshot.healthy) throw new Error(snapshot.scan.error);
658
+ const records = this.databaseCache.records;
659
+ const cache = records.calendarTurns ??= new Map();
660
+ const rows = new Map(records.rows.map(row => [row.id, row]));
661
+ const query = this.connections.history.prepare(`SELECT turn_id, started_at, duration_ms, status
662
+ FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal`);
663
+ const chats = [];
664
+ for (const task of snapshot.tasks) {
665
+ if ((!settings.showArchived && task.observed?.archive) ||
666
+ (!settings.showCli && task.observed?.source === "cli") ||
667
+ (!settings.showExec && task.observed?.source === "exec")) continue;
668
+ const row = rows.get(task.id);
669
+ let turns = cache.get(task.id);
670
+ if (!turns) { turns = query.all(historyId(row)); cache.set(task.id, turns); }
671
+ const result = calendarSegments(turns, { month, timeZone, now: this.now(), latestTurnId: task.turnId });
672
+ if (result.segments.length || result.incomplete) chats.push({ id: task.id, ...result });
673
+ }
674
+ return { month, timeZone, chats };
675
+ }
676
+ async usageAnalytics({ range, timeZone, settings }) {
677
+ return aggregateUsage(await this.usageHistory({ settings }), { range, timeZone, now: this.now() });
678
+ }
679
+ async usageHistory({ settings, onProgress = () => {}, rescan = false }) {
680
+ if (rescan) this.sessionUsage.inventoryAt = -Infinity;
681
+ const snapshot = await this.refresh();
682
+ if (!snapshot.healthy) throw new Error(snapshot.scan.error);
683
+ const records = this.databaseCache.records;
684
+ // Analytics inventories every root, even if its latest turn is absent from
685
+ // the board's history database. Its rollout can still hold valid usage.
686
+ const grouped = await this.projectCatalog.group(records.allRows.map(row => ({
687
+ id: row.id, turnId: records.turns.get(historyId(row))?.turn_id ?? null,
688
+ project: { id: row.project_id, path: row.cwd || "", githubRepos: [] }, observed: { source: row.source },
689
+ })), records.projects);
690
+ const families = [];
691
+ const seen = new Set();
692
+ // History includes archived chats. CLI/exec preferences still apply.
693
+ const tasks = grouped.tasks.filter(task => (settings.showCli || task.observed?.source !== "cli") && (settings.showExec || task.observed?.source !== "exec"));
694
+ let completed = 0;
695
+ onProgress({ completed, total: tasks.length });
696
+ for (const task of tasks) {
697
+ const sessions = this.connections.state.prepare(`WITH RECURSIVE family(id) AS (
698
+ SELECT ? UNION SELECT child_thread_id FROM thread_spawn_edges JOIN family ON parent_thread_id = family.id
699
+ ) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(task.id).filter(row => !seen.has(row.id));
700
+ if (sessions.length) {
701
+ sessions.forEach(row => seen.add(row.id));
702
+ const usage = await this.sessionUsage.read({ id: task.id, latestTurnId: task.turnId, sessions, rescan });
703
+ families.push({ project: task.project, usage });
704
+ }
705
+ onProgress({ completed: ++completed, total: tasks.length });
706
+ }
707
+ return families;
708
+ }
652
709
  recordedUsage(id, turnId) {
653
710
  const result = (this.recordedPending ?? Promise.resolve()).then(() => this.readRecordedUsage(id, turnId));
654
711
  this.recordedPending = result.catch(() => {});
@@ -711,7 +768,7 @@ export class CodexSessionScanner {
711
768
  let detail = { ...task, ...records.latestContent.get(id), detailPullRequests, observed: { ...task.observed,
712
769
  recordedChatDurationMs: durations.total ?? 0, missingTurnDurations: durations.missing ?? 0 } };
713
770
  try {
714
- const sessionUsage = await this.taskUsage(id, durations.saved);
771
+ const { timeline: _timeline, analyticsPartial: _analyticsPartial, ...sessionUsage } = await this.taskUsage(id, durations.saved);
715
772
  detail = { ...detail, sessionUsage, observed: { ...detail.observed,
716
773
  ...(sessionUsage.durationTurns ? { recordedChatDurationMs: Math.max(durations.total ?? 0, sessionUsage.durationMs) } : {}),
717
774
  historicalTimePartial: sessionUsage.partial || !sessionUsage.durationTurns } };
@@ -19,7 +19,7 @@ const bucket = () => ({ tokens: zero(), costUsd: 0, partial: false, costPartial:
19
19
  // Cumulative totals and call counters are independent. A missing boundary can
20
20
  // leave one turn partial without preventing later calls from being allocated.
21
21
  export function summarizeRecords(records, latestTurnId, models, child = false) {
22
- const total = bucket(), latest = bucket(), durations = new Map(), seen = new Set();
22
+ const total = bucket(), latest = bucket(), durations = new Map(), seen = new Set(), timeline = [];
23
23
  let previous = zero();
24
24
  for (const record of [...records].sort((a, b) => a.time - b.time || a.order - b.order)) {
25
25
  const key = JSON.stringify([record.time, record.kind, record.turn, record.root, record.model, record.total, record.last, record.duration]);
@@ -56,6 +56,10 @@ export function summarizeRecords(records, latestTurnId, models, child = false) {
56
56
  const price = estimateCallCost(last, record.model, models);
57
57
  total.costUsd += price ?? 0;
58
58
  total.costPartial ||= !complete || price == null;
59
+ // Only the last call has a known timestamp. An unexplained cumulative gap
60
+ // belongs in lifetime totals, not arbitrarily on the day it was noticed.
61
+ if (record.time > 0) timeline.push({ time: record.time, tokens: complete || reset ? captured : last,
62
+ costUsd: price ?? 0, samples: 1, partial: !complete, costPartial: !complete || price == null });
59
63
  total.partial ||= reset;
60
64
  const belongs = (child ? record.root : record.turn) === latestTurnId;
61
65
  if (belongs) {
@@ -67,7 +71,7 @@ export function summarizeRecords(records, latestTurnId, models, child = false) {
67
71
  latest.partial = latest.costPartial = true;
68
72
  }
69
73
  }
70
- return { total, latest, durations };
74
+ return { total, latest, durations, timeline };
71
75
  }
72
76
 
73
77
  async function inventory(root, paths = []) {
@@ -84,7 +88,7 @@ async function inventory(root, paths = []) {
84
88
  export class CodexSessionUsage {
85
89
  constructor({ codexHome, now = Date.now, pricing, cacheDir, legacyCacheDir }) {
86
90
  this.home = codexHome; this.now = now; this.pricing = pricing; this.cacheDir = cacheDir; this.legacyCacheDir = legacyCacheDir;
87
- this.files = new Map(); this.paths = []; this.inventoryAt = -Infinity;
91
+ this.files = new Map(); this.paths = []; this.pathsBySession = new Map(); this.inventoryAt = -Infinity;
88
92
  this.pending = Promise.resolve();
89
93
  this.results = new Map();
90
94
  }
@@ -94,7 +98,7 @@ export class CodexSessionUsage {
94
98
  this.pending = result.catch(() => {});
95
99
  return result;
96
100
  }
97
- async readOnce({ id, latestTurnId, sessions, savedDurations = [] }) {
101
+ async readOnce({ id, latestTurnId, sessions, savedDurations = [], rescan = false }) {
98
102
  if (this.legacyCacheDir && this.cacheDir) {
99
103
  try { await migrateCache(this.legacyCacheDir, this.cacheDir); this.legacyCacheDir = null; }
100
104
  catch { /* Optional caches never prevent reading source usage. */ }
@@ -103,6 +107,13 @@ export class CodexSessionUsage {
103
107
  if (this.now() - this.inventoryAt >= 60_000) {
104
108
  try {
105
109
  this.paths = [...await inventory(join(this.home, "sessions")), ...await inventory(join(this.home, "archived_sessions"))];
110
+ this.pathsBySession = new Map();
111
+ for (const path of this.paths) {
112
+ for (const [id] of basename(path).matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)) {
113
+ if (!this.pathsBySession.has(id)) this.pathsBySession.set(id, []);
114
+ this.pathsBySession.get(id).push(path);
115
+ }
116
+ }
106
117
  this.inventoryAt = this.now();
107
118
  } catch { inventoryFailed = true; }
108
119
  }
@@ -111,11 +122,11 @@ export class CodexSessionUsage {
111
122
  const pricing = await this.pricing.get();
112
123
  const sources = [];
113
124
  for (const session of sessions) {
114
- const paths = new Set(this.paths.filter(path => basename(path).includes(`-${session.id}`)));
125
+ const paths = new Set(this.sessionPaths(session.id));
115
126
  if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
116
127
  const versions = [];
117
128
  for (const path of paths) {
118
- try { const { info } = await rolloutMetadata(path); versions.push([path, info.dev, info.ino, info.size, info.mtimeMs]); }
129
+ try { const { info } = await rolloutMetadata(path, { retries: 0 }); versions.push([path, info.dev, info.ino, info.size, info.mtimeMs]); }
119
130
  catch { versions.push([path, "missing"]); }
120
131
  }
121
132
  sources.push([session.id, versions]);
@@ -127,7 +138,7 @@ export class CodexSessionUsage {
127
138
  if (!saved && cachePath) {
128
139
  try {
129
140
  const value = JSON.parse(await readFile(cachePath, "utf8"));
130
- if (value.version === 1 && validResult(value.result)) saved = value;
141
+ if ([1, 2, 3].includes(value.version) && validResult(value.result) && (value.version === 1 || (Array.isArray(value.result.timeline) && value.result.timeline.every(sample => Number.isFinite(sample.time) && usage(sample.tokens) && Number.isFinite(sample.costUsd) && sample.costUsd >= 0 && sample.samples === 1 && typeof sample.partial === "boolean" && typeof sample.costPartial === "boolean")))) saved = value;
131
142
  } catch { /* An absent or damaged cache is rebuilt from source records. */ }
132
143
  }
133
144
  const sourceKeys = (items) => items.flatMap(([sessionId, versions]) => versions
@@ -153,29 +164,41 @@ export class CodexSessionUsage {
153
164
  })) knownSources = [];
154
165
  } catch { knownSources = []; /* No prior valid source coverage is available. */ }
155
166
  const missingSources = knownSources.filter(key => !currentSources.has(key));
156
- if (saved?.recordSources && saved.sourceSignature === sourceSignature) { this.results.set(resultKey, saved); return saved.result; }
167
+ if (!rescan && !saved?.retryRead && saved?.version === 3 && typeof saved.result.analyticsPartial === "boolean" && saved.recordSources && saved.sourceSignature === sourceSignature) { this.results.set(resultKey, saved); return saved.result; }
157
168
  const availablePaths = new Set(sources.flatMap(([, versions]) => versions.filter(version => version.length > 2).map(version => version[0])));
158
169
  let retryRead = false;
159
170
  const parsed = [];
160
171
  const recordSources = {};
172
+ const sourceFiles = {};
161
173
  for (const session of sessions) {
162
- const paths = new Set(this.paths.filter(path => basename(path).includes(`-${session.id}`)));
174
+ const paths = new Set(this.sessionPaths(session.id));
163
175
  if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
164
176
  const records = [], versions = [];
165
177
  let failed = inventoryFailed, latestFailed = false, timePartial = inventoryFailed;
166
178
  if (!paths.size) failed = latestFailed = timePartial = true;
167
179
  for (const path of paths) {
168
180
  try {
169
- const file = await this.readFile(path, session.id);
181
+ const sourceKey = JSON.stringify([session.id, basename(path)]);
182
+ const stamp = sources.find(([sessionId]) => sessionId === session.id)?.[1].find(version => version[0] === path);
183
+ if (!availablePaths.has(path)) throw new Error("Rollout is missing.");
184
+ const previous = saved?.sourceFiles?.[sourceKey];
185
+ let file;
186
+ if (!rescan && previous && JSON.stringify(previous.stamp) === JSON.stringify(stamp) && Array.isArray(saved.recordSources?.[sourceKey])) {
187
+ file = { records: saved.recordSources[sourceKey], partial: previous.partial };
188
+ } else {
189
+ if (rescan) this.files.delete(path);
190
+ file = await this.readFile(path, session.id);
191
+ }
192
+ sourceFiles[sourceKey] = { stamp, partial: file.partial };
170
193
  for (const record of file.records) records.push(record);
171
194
  recordSources[JSON.stringify([session.id, basename(path)])] = file.records;
172
195
  versions.push([path, file.identity, file.size, file.mtime]);
173
196
  timePartial ||= file.partial;
174
- } catch {
197
+ } catch (error) {
175
198
  failed = timePartial = true;
176
199
  const sourceKey = JSON.stringify([session.id, basename(path)]);
177
200
  if (Array.isArray(saved?.recordSources?.[sourceKey]) && !missingSources.includes(sourceKey)) missingSources.push(sourceKey);
178
- retryRead ||= availablePaths.has(path);
201
+ retryRead ||= availablePaths.has(path) && error.code !== "INVALID_ROLLOUT";
179
202
  latestFailed ||= session.id !== id || path === plainRolloutPath(session.rollout_path ?? "");
180
203
  versions.push([path, "missing"]);
181
204
  }
@@ -188,7 +211,7 @@ export class CodexSessionUsage {
188
211
  if (Array.isArray(rows) && rows.every(row => row && Number.isFinite(row.time) && ["usage", "duration", "issue"].includes(row.kind))) recordSources[key] = rows;
189
212
  }
190
213
  const result = { latest: bucket(), total: bucket(), subagents: sessions.length - 1, pricingDate: pricing.date,
191
- durationMs: 0, durationTurns: 0, partial: inventoryFailed };
214
+ durationMs: 0, durationTurns: 0, timeline: [], analyticsPartial: inventoryFailed, partial: inventoryFailed };
192
215
  for (const { session, records, failed, latestFailed, timePartial } of parsed) {
193
216
  const summary = summarizeRecords(records, latestTurnId, pricing.models, session.id !== id);
194
217
  const retained = missingSources.filter(key => JSON.parse(key)[0] === session.id).flatMap(key => recordSources[key] ?? []);
@@ -202,7 +225,10 @@ export class CodexSessionUsage {
202
225
  summary.latest.partial ||= missing.latest.samples > 0 || missing.latest.partial;
203
226
  summary.latest.costPartial ||= missing.latest.samples > 0 || missing.latest.costPartial;
204
227
  summary.durations = combined.durations;
228
+ summary.timeline = combined.timeline;
205
229
  }
230
+ result.analyticsPartial ||= timePartial || summary.total.tokens.total_tokens !== summary.timeline.reduce((sum, sample) => sum + sample.tokens.total_tokens, 0);
231
+ result.timeline.push(...summary.timeline);
206
232
  for (const key of ["total", "latest"]) {
207
233
  add(result[key].tokens, summary[key].tokens);
208
234
  result[key].costUsd += summary[key].costUsd;
@@ -221,6 +247,7 @@ export class CodexSessionUsage {
221
247
  }
222
248
  }
223
249
  if (missingSources.length) {
250
+ result.analyticsPartial = true;
224
251
  result.total.partial = result.total.costPartial = true;
225
252
  const prior = saved?.result.total;
226
253
  if (prior) {
@@ -240,22 +267,29 @@ export class CodexSessionUsage {
240
267
  }
241
268
  if (missingSources.some(key => JSON.parse(key)[0] === id)) result.partial = true;
242
269
  }
243
- const entry = { version: 1, sourceSignature, result, recordSources, knownSources: [...new Set([...knownSources, ...currentSources])] };
244
- // Retry readable-looking sources after I/O failures; do not freeze a transient
245
- // failure under an otherwise unchanged metadata fingerprint.
246
- if (!retryRead) this.results.set(resultKey, entry);
247
- if (cachePath && !inventoryFailed && !retryRead) {
270
+ const entry = { version: 3, sourceSignature, result, recordSources, sourceFiles, retryRead, knownSources: [...new Set([...knownSources, ...currentSources])] };
271
+ // Keep successful sources even if another source failed. Retry failures on
272
+ // the next read without reparsing all healthy historical files.
273
+ this.results.set(resultKey, entry);
274
+ if (cachePath && !inventoryFailed) {
248
275
  try { await writeDurableAtomic(cachePath, JSON.stringify(entry) + "\n", { mode: 0o600 }); }
249
276
  catch { /* Cache write failures do not hide readable source usage. */ }
250
277
  }
251
278
  return result;
252
279
  }
280
+ sessionPaths(id) {
281
+ // Production chat IDs are UUIDs. Index once instead of walking every filename
282
+ // for every root and descendant on each refresh.
283
+ return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)
284
+ ? this.pathsBySession.get(id) ?? []
285
+ : this.paths.filter(path => basename(path).includes(`-${id}`));
286
+ }
253
287
  async readFile(path, expectedId) {
254
288
  const source = await openRollout(path);
255
289
  try {
256
290
  path = plainRolloutPath(path);
257
291
  const { info } = source;
258
- if (!info.isFile() || info.size === 0) throw new Error("Rollout is empty or not a file.");
292
+ if (!info.isFile() || info.size === 0) throw Object.assign(new Error("Rollout is empty or not a file."), { code: "INVALID_ROLLOUT" });
259
293
  let cached = this.files.get(path);
260
294
  if (cached && cached.identity === `${info.dev}:${info.ino}` && cached.size === info.size && cached.mtime === info.mtimeMs) return this.sourceView(cached, expectedId);
261
295
  const compressed = source.path.endsWith(".zst");
@@ -283,7 +317,7 @@ export class CodexSessionUsage {
283
317
  cached.size = info.size; cached.mtime = info.mtimeMs;
284
318
  cached.tail = pending; cached.skipping = skipping;
285
319
  const view = this.sourceView(cached, expectedId);
286
- if (!view.valid) throw new Error("Rollout belongs to a different chat.");
320
+ if (!view.valid) throw Object.assign(new Error("Rollout belongs to a different chat."), { code: "INVALID_ROLLOUT" });
287
321
  this.files.set(path, cached);
288
322
  return view;
289
323
  } finally { await source.handle.close(); }