taskchef 9.2.0 → 9.4.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.2.0",
3
+ "version": "9.4.0",
4
4
  "description": "Native Codex dashboard with lightweight project delegation.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -6,9 +6,33 @@ Chats appear regardless of how they were started.
6
6
 
7
7
  ## Install
8
8
 
9
- Install **TaskChef** from the `favoyang-plugins` Codex marketplace. TaskChef uses
10
- a local Node.js MCP server; Node.js 22.18+, 23.2+, or 24+ is required. Open **TaskChef**
11
- from the Codex sidebar. No dispatcher workspace or project indexing is needed.
9
+ TaskChef's marketplace lives in this repository. In Codex, open **Settings →
10
+ Plugins → Add → Add a marketplace** and use
11
+ `https://github.com/favoyang/taskchef.git`. Leave the Git ref as `main` and Sparse
12
+ paths empty. Open **Plugins → Personal**, find **TaskChef** under **TaskChef**,
13
+ and install it.
14
+
15
+ Alternatively, use the Codex CLI:
16
+
17
+ ```sh
18
+ codex plugin marketplace add favoyang/taskchef
19
+ codex plugin add taskchef@taskchef
20
+ ```
21
+
22
+ Restart Codex. Open **…** below Plugins in the left dock, find **TaskChef**, and
23
+ pin it to keep the board in the sidebar. TaskChef uses a local Node.js MCP
24
+ server and npm. Supported Node.js versions are 22.18+ in the 22 series, or
25
+ 23.2 and newer. Node.js 24 is recommended for a new installation. Server
26
+ dependencies are included in the published package.
27
+
28
+ ### Move from the shared marketplace
29
+
30
+ If you installed `taskchef@favoyang-plugins`, add the new marketplace and install
31
+ `taskchef@taskchef` with the commands above. After it installs successfully,
32
+ remove the old plugin with `codex plugin remove taskchef@favoyang-plugins`, then
33
+ restart Codex. Keep only one released TaskChef installation enabled. Local
34
+ settings, GitHub credentials, and Codex chat history are independent of the
35
+ marketplace cache and do not need to be reset. TaskChef Dev stays separate.
12
36
 
13
37
  Codex databases and logs stay on your computer and remain the source of truth.
14
38
  TaskChef keeps its own settings, manual Done marks, notification state, and
@@ -48,3 +72,8 @@ name; apply that identity only to its local staging package. See
48
72
  [development packaging](docs/mcp-app.md#development-plugin-identity).
49
73
 
50
74
  [Native app guide](docs/mcp-app.md) · [GitHub setup](docs/taskchef-next-github.md)
75
+
76
+ ## Feedback
77
+
78
+ [Report a bug](https://github.com/favoyang/taskchef/issues/new?template=bug_report.yml)
79
+ or [request a feature](https://github.com/favoyang/taskchef/issues/new?template=feature_request.yml).
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
@@ -337,3 +336,75 @@ message does not cause polling to replay it. Other features continue working. Th
337
336
  second, and missing chat snapshots wait at most 250 ms for all subscriptions
338
337
  combined. Hidden views do not initiate refreshes; an existing subscription can
339
338
  still receive live updates while the MCP process remains running.
339
+
340
+ ## Marketplace and release ownership
341
+
342
+ The public catalog is `.agents/plugins/marketplace.json` in this repository.
343
+ Its marketplace name is `taskchef`; the released install is `taskchef@taskchef`.
344
+ The catalog pins a published npm package containing the built UI and production
345
+ server dependencies. A raw Git checkout is not a packaged release.
346
+
347
+ After semantic-release publishes, the marketplace job checks the expected npm
348
+ version and archive, then commits only the catalog pin on the latest `main`.
349
+ A failed check leaves the prior pin in place and fails the workflow. The job
350
+ uses this repository's GitHub token; no shared-marketplace deploy key is needed.
351
+ If the push loses a race with another main update, rerun the Release workflow
352
+ on main to retry after checking the current published version. Do not force push.
353
+
354
+ Keep TaskChef Dev in a separate local marketplace named `taskchef-next-preview`,
355
+ with plugin ID `taskchef-next` and display name **TaskChef Dev**. Store that
356
+ marketplace and its staging packages in a persistent local folder, outside
357
+ this public checkout. Its catalog uses `source.source: local` with a path to
358
+ the prepared staging package. Add that folder with
359
+ `codex plugin marketplace add /path/to/local-dev-marketplace`, then install
360
+ `codex plugin add taskchef-next@taskchef-next-preview`. This can coexist with
361
+ `taskchef@taskchef`. Never rename the source release manifest for Dev.
362
+
363
+ Retire the old shared catalog entry only after this repository's catalog is
364
+ live, the release workflow has completed, and a clean installation from the
365
+ new marketplace succeeds. Existing users must migrate explicitly; changing
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 (the default, through
375
+ today), and Last month (the full previous calendar month).
376
+ There is no custom range. Dates use the view's local timezone, including DST.
377
+ Range changes and reopening Usage query one shared history snapshot without
378
+ rescanning logs. The snapshot is scoped by CLI/exec visibility, independently of
379
+ range or timezone. After one minute it refreshes in the background while cached
380
+ charts remain visible. Refresh explicitly rebuilds it, reusing existing per-file
381
+ metadata and incremental parsing caches. Unchanged files retain parsed records
382
+ on disk across restarts and turn or price changes. A session filename index
383
+ avoids searching the full inventory for each chat. Missing paths do not incur
384
+ reader retry delays. Empty or mismatched files are checked again when their
385
+ metadata changes; transient I/O failures retry while retaining healthy sources.
386
+ Rescan explicitly reparses available files and refreshes the inventory, while
387
+ retaining known history for missing sources. Board polling never starts a history scan.
388
+
389
+ The combined chart uses cost bars on the left axis and a token line on the
390
+ right. Today's cost bars have a lighter fill and dashed borders in both charts;
391
+ the token segment leading into today is dashed too. Finished days, including
392
+ the last day of Last month, stay solid. The project chart uses stacked cost bars for the period's top five
393
+ projects plus Other. Select a legend entry or a Top 10 row to isolate that
394
+ project in the project chart; All projects restores the stack. Hovering or
395
+ focusing a legend entry highlights its bars and dims other project colors. Totals, the
396
+ combined chart, and rankings always describe the full selected period.
397
+
398
+ Archived chats and descendant usage are included. CLI/exec visibility settings
399
+ still apply to root chats. Descendants are counted once under their root chat's
400
+ current project. Unassigned chats share one Unassigned project. Daily usage
401
+ comes from recorded call timestamps, not chat creation or last-update dates.
402
+ Unexplained cumulative counter gaps stay in lifetime totals but are excluded
403
+ from daily totals because their dates are unknown. Missing records or prices
404
+ remain marked in the analytics response without displaying coverage warnings.
405
+ Unknown costs are excluded from estimated cost.
406
+ Cached input is part of input tokens, and reasoning output is part of output.
407
+ Cost is an estimate at current API-equivalent rates, not subscription billing
408
+ or remaining allowance. The first read shows a progress bar with the actual number of root chats
409
+ processed and polls an app-only tool. Cached charts stay visible during later
410
+ refreshes, with their last-update time and scan progress.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.2.0",
3
+ "version": "9.4.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",
@@ -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,4 @@
1
+ import { aggregateUsage } from "./usage-analytics.js";
1
2
  import { openRollout, rolloutChunks } from "./codex-rollout.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { normalizeUri } from "micromark-util-sanitize-uri";
@@ -239,9 +240,10 @@ function readDatabaseRecords(state, history) {
239
240
  FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal DESC LIMIT 1`);
240
241
  const turns = new Map();
241
242
  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,
243
+ 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
244
  (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) => {
245
+ FROM threads WHERE ${eligible} ORDER BY recency_at_ms DESC`).all();
246
+ const rows = allRows.filter((row) => {
245
247
  const key = historyId(row);
246
248
  const turn = latestTurn.get(key);
247
249
  if (!turn) return false;
@@ -285,7 +287,7 @@ function readDatabaseRecords(state, history) {
285
287
  attachments.set(row.thread_id, urls);
286
288
  }
287
289
  }
288
- return { rows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
290
+ return { rows, allRows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
289
291
  }
290
292
 
291
293
  function buildDatabaseSnapshot(records, now, doneMarks, schedules, scheduleErrors, cacheHit) {
@@ -649,6 +651,39 @@ export class CodexSessionScanner {
649
651
  ) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(id);
650
652
  return this.sessionUsage.read({ id, latestTurnId: task.turnId, sessions, savedDurations });
651
653
  }
654
+ async usageAnalytics({ range, timeZone, settings }) {
655
+ return aggregateUsage(await this.usageHistory({ settings }), { range, timeZone, now: this.now() });
656
+ }
657
+ async usageHistory({ settings, onProgress = () => {}, rescan = false }) {
658
+ if (rescan) this.sessionUsage.inventoryAt = -Infinity;
659
+ const snapshot = await this.refresh();
660
+ if (!snapshot.healthy) throw new Error(snapshot.scan.error);
661
+ const records = this.databaseCache.records;
662
+ // Analytics inventories every root, even if its latest turn is absent from
663
+ // the board's history database. Its rollout can still hold valid usage.
664
+ const grouped = await this.projectCatalog.group(records.allRows.map(row => ({
665
+ id: row.id, turnId: records.turns.get(historyId(row))?.turn_id ?? null,
666
+ project: { id: row.project_id, path: row.cwd || "", githubRepos: [] }, observed: { source: row.source },
667
+ })), records.projects);
668
+ const families = [];
669
+ const seen = new Set();
670
+ // History includes archived chats. CLI/exec preferences still apply.
671
+ const tasks = grouped.tasks.filter(task => (settings.showCli || task.observed?.source !== "cli") && (settings.showExec || task.observed?.source !== "exec"));
672
+ let completed = 0;
673
+ onProgress({ completed, total: tasks.length });
674
+ for (const task of tasks) {
675
+ const sessions = this.connections.state.prepare(`WITH RECURSIVE family(id) AS (
676
+ SELECT ? UNION SELECT child_thread_id FROM thread_spawn_edges JOIN family ON parent_thread_id = family.id
677
+ ) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(task.id).filter(row => !seen.has(row.id));
678
+ if (sessions.length) {
679
+ sessions.forEach(row => seen.add(row.id));
680
+ const usage = await this.sessionUsage.read({ id: task.id, latestTurnId: task.turnId, sessions, rescan });
681
+ families.push({ project: task.project, usage });
682
+ }
683
+ onProgress({ completed: ++completed, total: tasks.length });
684
+ }
685
+ return families;
686
+ }
652
687
  recordedUsage(id, turnId) {
653
688
  const result = (this.recordedPending ?? Promise.resolve()).then(() => this.readRecordedUsage(id, turnId));
654
689
  this.recordedPending = result.catch(() => {});
@@ -711,7 +746,7 @@ export class CodexSessionScanner {
711
746
  let detail = { ...task, ...records.latestContent.get(id), detailPullRequests, observed: { ...task.observed,
712
747
  recordedChatDurationMs: durations.total ?? 0, missingTurnDurations: durations.missing ?? 0 } };
713
748
  try {
714
- const sessionUsage = await this.taskUsage(id, durations.saved);
749
+ const { timeline: _timeline, analyticsPartial: _analyticsPartial, ...sessionUsage } = await this.taskUsage(id, durations.saved);
715
750
  detail = { ...detail, sessionUsage, observed: { ...detail.observed,
716
751
  ...(sessionUsage.durationTurns ? { recordedChatDurationMs: Math.max(durations.total ?? 0, sessionUsage.durationMs) } : {}),
717
752
  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(); }