taskchef 7.19.1 → 7.20.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": "7.19.1",
3
+ "version": "7.20.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -25,6 +25,12 @@ request -> recorded TaskChef task -> Codex executor -> request/result turn timel
25
25
  TaskChef requires Node.js 18 or newer, Git, Codex desktop, and local access to
26
26
  the projects that will receive work.
27
27
 
28
+ Install [`ccusage`](https://github.com/ccusage/ccusage) separately when you want
29
+ the optional dashboard token and API-equivalent cost estimates. TaskChef calls
30
+ its structured offline Codex report and does not parse Codex rollout files
31
+ itself. Lifecycle reporting and the dashboard continue to work when `ccusage`
32
+ is absent or incompatible.
33
+
28
34
  ```sh
29
35
  codex plugin marketplace add favoyang/codex-plugins
30
36
  codex plugin add taskchef@favoyang-plugins
@@ -50,6 +56,7 @@ The canonical workspace is `~/.agents/taskchef`. TaskChef owns only:
50
56
  AGENTS.md managed dispatcher instructions plus user additions
51
57
  taskchef.json schema-2 Codex project index, dashboard preference, and delegation metadata
52
58
  tasks.jsonl one task snapshot per line (schema 9; schema 4-8 migration supported)
59
+ .taskchef-usage.json optional mode-0600 ccusage snapshot and turn-boundary cache
53
60
  ```
54
61
 
55
62
  Index or inspect Codex projects conversationally:
@@ -269,6 +276,16 @@ Codex CLI at the canonical ChatGPT or Codex desktop app location under
269
276
  `/Applications`, to archive the exact thread UUID. The Codex chat leaves active chat lists while the TaskChef record
270
277
  and its activity timeline remain unchanged. If the bundled CLI is unavailable,
271
278
  the dashboard does not fall back to another `codex` executable from `PATH`.
279
+
280
+ Task details also show whole-task and per-turn token usage when `ccusage` can
281
+ map the linked Codex thread. A completed turn briefly shows “Tokens:
282
+ calculating…” while TaskChef performs bounded deferred reconciliation, because
283
+ the terminal lifecycle callback precedes Codex's final output write. Historical
284
+ tasks may show a trustworthy task total while older turns remain “Tokens
285
+ unavailable” when no cumulative turn boundaries were recorded. Input, cached
286
+ input, output, reasoning, and total counts retain ccusage's categories. Dollar
287
+ figures are labeled API-equivalent estimates; zero-priced unknown models show
288
+ cost unavailable rather than a misleading `$0.00`.
272
289
  The header shows the running TaskChef package version reported by the same
273
290
  bounded health identity used for compatible-listener checks.
274
291
  The canonical port is owned by a dashboard initialized in the TaskChef MCP host
@@ -304,6 +321,8 @@ or secrets.
304
321
 
305
322
  ![Task detail activity timeline](docs/images/result-history-dashboard.jpg)
306
323
 
324
+ ![Deferred ccusage token consumption in task details](docs/images/ccusage-token-consumption.png)
325
+
307
326
  ![Interrupted turn followed by active recovery](docs/images/interrupted-turn-recovery.jpg)
308
327
 
309
328
  ## Common recovery
package/docs/spec.md CHANGED
@@ -28,7 +28,7 @@ is dated research, not contract.
28
28
  | **Last semantic result** | The final result-history entry, exposed through the derived `lastResult` compatibility alias. |
29
29
  | **Turn reference** | Required lifecycle identity for one executor prompt. It is the native Codex turn ID when available, otherwise a retained client-generated UUID. |
30
30
  | **Current turn ID** | Optional Codex metadata for the reported prompt; null when native turn reading is unavailable. |
31
- | **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots and bounded native actions. |
31
+ | **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots, optional local usage projections, and bounded native actions. |
32
32
  | **Skill** | One packaged agent procedure: `taskchef-bootstrap`, `taskchef-dashboard`, `taskchef-delegate`, `taskchef-executor`, or `taskchef-copilot`. |
33
33
 
34
34
  ## Components and ownership
@@ -56,7 +56,8 @@ is dated research, not contract.
56
56
 
57
57
  ## Workspace contract
58
58
 
59
- TaskChef MUST manage only `AGENTS.md`, `taskchef.json`, and `tasks.jsonl`
59
+ TaskChef MUST manage only `AGENTS.md`, `taskchef.json`, `tasks.jsonl`, and the
60
+ optional `.taskchef-usage.json` cache
60
61
  inside the dispatcher workspace. It MUST preserve unrelated paths.
61
62
 
62
63
  `taskchef.json` MUST have schema version 2, the following required fields, and
@@ -116,6 +117,48 @@ from the final derived semantic result. Interrupted outcomes MUST be excluded.
116
117
  These projections MUST NOT be persisted in schema 9 and remain compatibility
117
118
  aliases for existing callers.
118
119
 
120
+ ## Optional usage projection
121
+
122
+ TaskChef MAY invoke an installed `ccusage` executable as an optional local
123
+ adapter using `ccusage codex session --json --offline`. TaskChef MUST NOT parse,
124
+ store, or serve raw Codex rollout files, prompts, responses, transcripts, or
125
+ reasoning. Analyzer absence, timeout, malformed output, unknown pricing, or an
126
+ unresolved thread MUST NOT block lifecycle tools or dashboard loading.
127
+
128
+ The mode-0600 `.taskchef-usage.json` cache stores only normalized cumulative
129
+ token boundaries, per-turn deltas, model names, estimated cost, source version,
130
+ and freshness. It is independent of schema-9 task records so legacy logs remain
131
+ readable. Writes MUST use the workspace lock and atomic replacement. Symlinked
132
+ or unsupported cache files MUST be rejected. Writes MUST compact the derived
133
+ cache to recent task projections, recent per-turn results, and the latest
134
+ cumulative boundary. An oversized legacy cache MUST be treated as rebuildable
135
+ derived data so it cannot permanently disable usage reporting.
136
+
137
+ A linked Codex thread MAY map to multiple ccusage session segments. TaskChef
138
+ MUST aggregate only records whose primary durable thread UUID is that exact
139
+ identity; a UUID appearing only as a nested suffix MUST NOT be attributed to
140
+ the parent or child TaskChef task. It MUST retain input, cached-input, output,
141
+ reasoning-output, and total fields without adding cached or reasoning subsets
142
+ into totals a second time.
143
+
144
+ After a terminal report, TaskChef MUST mark the turn `calculating` and perform
145
+ bounded deferred reconciliation without delaying the lifecycle response.
146
+ Per-turn usage MUST be a non-negative delta between adjacent reliable cumulative
147
+ boundaries. A first recorded turn MAY use zero as its baseline. Historical
148
+ turns without boundaries and decreasing or ambiguous snapshots MUST be labeled
149
+ `unavailable`, never zero or estimated. A historical task MAY still show its
150
+ resolvable cumulative total. A boundary is reliable only after two consecutive
151
+ samples agree. Exhausted unstable sampling and a newer turn beginning before
152
+ stabilization MUST leave that turn unavailable and MUST NOT establish a delta
153
+ baseline. Zero is valid for the first turn only when TaskChef observed that turn
154
+ in progress before its terminal report; a first historical terminal turn MUST
155
+ remain unavailable even when its cumulative task total is resolvable.
156
+
157
+ Every available projection MUST identify ccusage, its version when available,
158
+ and freshness. Dollar values MUST be labeled API-equivalent estimates. Positive
159
+ token usage with a zero or missing analyzer cost MUST display cost unavailable,
160
+ not `$0.00`.
161
+
119
162
  Task IDs and non-null thread identities MUST be unique. The immutable intent
120
163
  fields MUST NOT change after recording.
121
164
 
package/docs/workflows.md CHANGED
@@ -21,6 +21,8 @@ research.
21
21
  | `src/cli.js` | Administration, normalized cached briefs, inspection, diagnostics, and dashboard startup. |
22
22
  | `src/dashboard.js` | Versioned health identity, validated compact snapshots, SSE fan-out, on-demand details, and bounded open actions. |
23
23
  | `src/dashboard-manager.js` | Concurrent singleton ensure, exact listener reuse, conflicts, and owned shutdown. |
24
+ | `src/usage.js` | Optional bounded ccusage execution, exact primary-thread mapping, normalized aggregation, and the private usage cache. |
25
+ | `src/usage-tracker.js` | Deferred sampling, cumulative boundaries, historical availability, and per-turn deltas. |
24
26
 
25
27
  The MCP process resolves `TASKCHEF_WORKSPACE` once and never accepts a model
26
28
  supplied path. The CLI resolves `--workspace`, then the environment, then the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.19.1",
3
+ "version": "7.20.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -27,6 +27,7 @@
27
27
  "docs/workflows.md",
28
28
  "docs/firstmate-taskchef-comparison.md",
29
29
  "docs/images/dashboard-identity.jpg",
30
+ "docs/images/ccusage-token-consumption.png",
30
31
  "docs/images/notification-event-snapshots.jpg",
31
32
  "docs/images/result-history-dashboard.jpg",
32
33
  "docs/images/interrupted-turn-recovery.jpg",
@@ -25,6 +25,9 @@ import {
25
25
  } from "./github-links.js";
26
26
  import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
27
27
 
28
+ const USAGE_POLL_INTERVAL_MS = 1_500;
29
+ const MAX_USAGE_POLL_ATTEMPTS = 40;
30
+
28
31
  const state = {
29
32
  archivedThreadIds: new Set(),
30
33
  tasks: [],
@@ -57,6 +60,7 @@ const elements = {
57
60
  dialogRelatedLinks: document.querySelector("#dialog-related-links"),
58
61
  dialogResults: document.querySelector("#dialog-results"),
59
62
  dialogTitle: document.querySelector("#dialog-title"),
63
+ dialogUsage: document.querySelector("#dialog-usage"),
60
64
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
61
65
  emptyState: document.querySelector("#empty-state"),
62
66
  notifications: document.querySelector("#notifications"),
@@ -347,6 +351,58 @@ function detailRow(term, value) {
347
351
  return [dt, dd];
348
352
  }
349
353
 
354
+ const tokenFormatter = new Intl.NumberFormat();
355
+
356
+ function formatEstimatedCost(value) {
357
+ if (typeof value !== "number") return "cost unavailable";
358
+ if (value === 0) return "estimated $0.00";
359
+ return `estimated $${value < 0.01 ? value.toFixed(4) : value.toFixed(2)}`;
360
+ }
361
+
362
+ function usageBreakdownText(usage) {
363
+ return [
364
+ `${tokenFormatter.format(usage.inputTokens)} input`,
365
+ `${tokenFormatter.format(usage.cachedInputTokens)} cached input`,
366
+ `${tokenFormatter.format(usage.outputTokens)} output`,
367
+ `${tokenFormatter.format(usage.reasoningOutputTokens)} reasoning`,
368
+ ].join(" · ");
369
+ }
370
+
371
+ function usagePresentation(usage, { wholeTask = false } = {}) {
372
+ const container = document.createElement("div");
373
+ container.className = `usage-summary usage-${usage?.status ?? "calculating"}`;
374
+ if (!usage || usage.status === "calculating") {
375
+ const indicator = document.createElement("span");
376
+ indicator.className = "usage-spinner";
377
+ indicator.setAttribute("aria-hidden", "true");
378
+ const text = document.createElement("span");
379
+ text.textContent = "Tokens: calculating…";
380
+ container.replaceChildren(indicator, text);
381
+ return container;
382
+ }
383
+ if (usage.status !== "available") {
384
+ container.textContent = `Tokens unavailable${usage.reason ? ` — ${usage.reason}` : "."}`;
385
+ return container;
386
+ }
387
+ const headline = document.createElement("strong");
388
+ headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}`;
389
+ const breakdown = document.createElement("span");
390
+ breakdown.textContent = usageBreakdownText(usage);
391
+ container.append(headline, breakdown);
392
+ const provenance = document.createElement("span");
393
+ const version = usage.provenance?.version ? ` ${usage.provenance.version}` : "";
394
+ const freshness = usage.sourceUpdatedAt ?? usage.sampledAt;
395
+ provenance.textContent = `Source: ccusage${version}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
396
+ container.append(provenance);
397
+ return container;
398
+ }
399
+
400
+ function usageStillCalculating(task) {
401
+ return task.usage?.status === "calculating"
402
+ || (task.status !== "working" && Object.values(task.usage?.turns ?? {})
403
+ .some((turn) => turn.status === "calculating"));
404
+ }
405
+
350
406
  function turnTimeline(task) {
351
407
  if (task.turns.length === 0) {
352
408
  const empty = document.createElement("p");
@@ -387,7 +443,20 @@ function turnTimeline(task) {
387
443
  const turnMetadata = document.createElement("p");
388
444
  turnMetadata.className = "result-history-turn";
389
445
  turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
390
- item.append(header, requestLabel, request, resultLabel, result, turnMetadata);
446
+ const turnUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId] ?? (
447
+ turn.result === null
448
+ ? { status: "calculating" }
449
+ : { status: "unavailable", reason: "No reliable turn boundary is available." }
450
+ );
451
+ item.append(
452
+ header,
453
+ requestLabel,
454
+ request,
455
+ resultLabel,
456
+ result,
457
+ usagePresentation(turnUsage),
458
+ turnMetadata,
459
+ );
391
460
  return item;
392
461
  });
393
462
  }
@@ -418,6 +487,12 @@ function renderDialog(task) {
418
487
  (task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
419
488
  );
420
489
  elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
490
+ elements.dialogUsage.replaceChildren(usagePresentation(
491
+ task.usage?.status === "available" && task.usage.task
492
+ ? { status: "available", ...task.usage.task }
493
+ : task.usage ?? { status: task.threadId ? "calculating" : "unavailable" },
494
+ { wholeTask: true },
495
+ ));
421
496
  elements.dialogInstruction.textContent = task.instruction;
422
497
  elements.copyTaskId.disabled = !task.id;
423
498
  setCopyTaskIdLabel("Copy Task ID");
@@ -464,22 +539,28 @@ async function openDialog(task) {
464
539
  const requestGeneration = ++detailRequestGeneration;
465
540
  renderDialog(task);
466
541
  if (!elements.dialog.open) elements.dialog.showModal();
467
- try {
468
- const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
469
- if (!response.ok) throw new Error("Task details are unavailable.");
470
- const detail = await response.json();
471
- if (
472
- requestGeneration === detailRequestGeneration
473
- && state.selectedTask?.id === task.id
474
- && elements.dialog.open
475
- ) {
476
- renderDialog(detail.task);
477
- }
478
- } catch {
479
- if (state.selectedTask?.id === task.id) {
480
- showMessage("Task activity timeline is temporarily unavailable.");
542
+ const load = async (attempt = 0) => {
543
+ try {
544
+ const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
545
+ if (!response.ok) throw new Error("Task details are unavailable.");
546
+ const detail = await response.json();
547
+ if (
548
+ requestGeneration === detailRequestGeneration
549
+ && state.selectedTask?.id === task.id
550
+ && elements.dialog.open
551
+ ) {
552
+ renderDialog(detail.task);
553
+ if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
554
+ setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
555
+ }
556
+ }
557
+ } catch {
558
+ if (state.selectedTask?.id === task.id) {
559
+ showMessage("Task activity timeline is temporarily unavailable.");
560
+ }
481
561
  }
482
- }
562
+ };
563
+ await load();
483
564
  }
484
565
 
485
566
  function taskCard(task) {
@@ -116,6 +116,10 @@
116
116
  <button id="archive-codex" class="danger-button" type="button" aria-label="Archive this chat in Codex" hidden>Archive chat</button>
117
117
  </div>
118
118
  <nav id="dialog-related-links" class="github-links" aria-label="Related GitHub links" hidden></nav>
119
+ <section>
120
+ <h3>Usage</h3>
121
+ <div id="dialog-usage" class="usage-panel" aria-live="polite"></div>
122
+ </section>
119
123
  <section>
120
124
  <h3>Activity timeline</h3>
121
125
  <div id="dialog-results" class="result-history"></div>
@@ -165,6 +165,13 @@ dialog section + section { margin-top: 24px; }
165
165
  .result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
166
166
  .result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
167
167
  .result-history-empty { margin: 0; }
168
+ .usage-panel { margin-top: 8px; }
169
+ .usage-summary { display: grid; gap: 3px; margin: 10px 0 4px; color: var(--muted); font-size: 0.8rem; }
170
+ .usage-summary strong { color: var(--text); font-size: 0.9rem; }
171
+ .usage-calculating { display: flex; align-items: center; gap: 7px; }
172
+ .usage-spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: usage-spin 0.8s linear infinite; }
173
+ @keyframes usage-spin { to { transform: rotate(360deg); } }
174
+ @media (prefers-reduced-motion: reduce) { .usage-spinner { animation: none; border-top-color: var(--border); background: var(--accent); } }
168
175
  pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
169
176
  .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
170
177
  .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
package/src/dashboard.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "./workspace.js";
23
23
  import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
24
24
  import { taskGitHubProjection } from "./dashboard/github-links.js";
25
+ import { createUsageTracker } from "./usage-tracker.js";
25
26
 
26
27
  const TASKS_FILE_NAME = "tasks.jsonl";
27
28
  const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
@@ -455,6 +456,7 @@ export async function createDashboardServer({
455
456
  launcher = "standalone",
456
457
  taskchefVersion = TASKCHEF_VERSION,
457
458
  serverVersion = DASHBOARD_SERVER_VERSION,
459
+ usageTracker = null,
458
460
  } = {}) {
459
461
  if (!LOOPBACK_HOSTS.has(host)) {
460
462
  throw new Error("dashboard host must be a loopback address");
@@ -470,6 +472,7 @@ export async function createDashboardServer({
470
472
  }
471
473
  const monitor = new DashboardMonitor(workspace, monitorOptions);
472
474
  await monitor.start();
475
+ const taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
473
476
  const identity = Object.freeze({
474
477
  schemaVersion: 1,
475
478
  service: "taskchef-dashboard",
@@ -574,7 +577,16 @@ export async function createDashboardServer({
574
577
  response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
575
578
  response.end();
576
579
  } else {
577
- sendJson(response, 200, { schemaVersion: 1, task: taskDetailProjection(task) });
580
+ const usage = await taskUsageTracker.get(task).catch(() => ({
581
+ status: "unavailable",
582
+ reason: "Task usage is temporarily unavailable.",
583
+ task: null,
584
+ turns: {},
585
+ }));
586
+ sendJson(response, 200, {
587
+ schemaVersion: 1,
588
+ task: { ...taskDetailProjection(task), usage },
589
+ });
578
590
  }
579
591
  return;
580
592
  }
package/src/mcp.js CHANGED
@@ -13,6 +13,7 @@ import { parseTaskChefMarker } from "./delegation.js";
13
13
  import { createDashboardManager } from "./dashboard-manager.js";
14
14
  import { resolveWorkspacePath } from "./workspace-path.js";
15
15
  import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
16
+ import { createUsageTracker } from "./usage-tracker.js";
16
17
 
17
18
  const projectSchema = z.object({
18
19
  name: z.string(),
@@ -145,6 +146,7 @@ export function createTaskChefMcpServer({
145
146
  dashboardManager = createDashboardManager({ workspace }),
146
147
  readConfiguration = readConfig,
147
148
  logDashboardDiagnostic,
149
+ usageTracker = createUsageTracker({ workspace }),
148
150
  } = {}) {
149
151
  const server = new McpServer(
150
152
  { name: "taskchef", version: TASKCHEF_VERSION },
@@ -287,6 +289,7 @@ export function createTaskChefMcpServer({
287
289
  },
288
290
  async (input) => {
289
291
  const task = await reportState(workspace, input);
292
+ void usageTracker.observe(task).catch(() => {});
290
293
  return toolResult("task", task, `Recorded ${task.status} state for TaskChef task ${task.id}.`);
291
294
  },
292
295
  );
@@ -314,6 +317,7 @@ export function createTaskChefMcpServer({
314
317
  },
315
318
  async (input) => {
316
319
  const task = await reportResult(workspace, input);
320
+ void usageTracker.observe(task).catch(() => {});
317
321
  return toolResult("task", task, `Recorded ${task.status} result for TaskChef task ${task.id}.`);
318
322
  },
319
323
  );
@@ -0,0 +1,425 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { acquireWorkspaceLock } from "./workspace.js";
5
+ import {
6
+ readCcusageThreadUsage,
7
+ readUsageStore,
8
+ usageDelta,
9
+ writeUsageStore,
10
+ } from "./usage.js";
11
+
12
+ const TERMINAL_STATUSES = new Set(["needs_input", "completed", "failed"]);
13
+ const MAX_TRACKED_TURNS = 250;
14
+
15
+ function hasTerminalLatestTurn(task) {
16
+ return task.latestTurn !== null
17
+ && task.latestTurn !== undefined
18
+ && task.latestTurn.result !== null
19
+ && TERMINAL_STATUSES.has(task.latestTurn.result.status);
20
+ }
21
+
22
+ function lifecycleGeneration(task) {
23
+ return {
24
+ turnCount: task.turns.length,
25
+ terminal: hasTerminalLatestTurn(task),
26
+ };
27
+ }
28
+
29
+ function generationIsOlder(task, existing) {
30
+ if (!existing) return false;
31
+ const incoming = lifecycleGeneration(task);
32
+ if (incoming.turnCount !== existing.generationTurnCount) {
33
+ return incoming.turnCount < existing.generationTurnCount;
34
+ }
35
+ return !incoming.terminal && existing.generationTerminal;
36
+ }
37
+
38
+ function publicReason(error) {
39
+ const message = String(error?.message ?? "");
40
+ if (/not installed/i.test(message)) return "ccusage is not installed.";
41
+ if (/could not resolve/i.test(message)) return "No matching Codex usage session was found.";
42
+ if (/timed out/i.test(message)) return "ccusage did not finish in time.";
43
+ return "Codex usage is unavailable from ccusage.";
44
+ }
45
+
46
+ function calculatingTask(task, existing = null, now = new Date().toISOString()) {
47
+ const latestTurnRef = task.latestTurn?.turnRef ?? null;
48
+ const preserveAvailable = existing?.generationTurnRef === latestTurnRef
49
+ && existing?.status === "available"
50
+ && existing?.turns?.[latestTurnRef]?.status === "available";
51
+ const recentTurns = task.turns.slice(-MAX_TRACKED_TURNS);
52
+ const turns = Object.fromEntries(recentTurns.flatMap((turn) => (
53
+ existing?.turns?.[turn.turnRef]
54
+ ? [[turn.turnRef, existing.turns[turn.turnRef]]]
55
+ : []
56
+ )));
57
+ const generation = lifecycleGeneration(task);
58
+ for (const turn of recentTurns) {
59
+ if (turn.result === null) {
60
+ turns[turn.turnRef] = { status: "calculating", updatedAt: now };
61
+ } else if (turn.result.status === "interrupted") {
62
+ turns[turn.turnRef] = {
63
+ status: "unavailable",
64
+ reason: "The turn ended without a terminal usage boundary.",
65
+ updatedAt: now,
66
+ };
67
+ } else if (turns[turn.turnRef]?.status === "calculating"
68
+ && turn.turnRef !== task.latestTurn?.turnRef) {
69
+ turns[turn.turnRef] = {
70
+ status: "unavailable",
71
+ reason: "A newer turn started before a stable usage boundary was recorded.",
72
+ updatedAt: now,
73
+ };
74
+ } else if (!turns[turn.turnRef]) {
75
+ turns[turn.turnRef] = {
76
+ status: "unavailable",
77
+ reason: "No reliable cumulative boundary was recorded for this historical turn.",
78
+ updatedAt: now,
79
+ };
80
+ }
81
+ }
82
+ if (task.latestTurn?.result
83
+ && TERMINAL_STATUSES.has(task.latestTurn.result.status)
84
+ && !preserveAvailable) {
85
+ turns[task.latestTurn.turnRef] = { status: "calculating", updatedAt: now };
86
+ }
87
+ return {
88
+ threadId: task.threadId,
89
+ generationTurnRef: latestTurnRef,
90
+ generationTurnCount: generation.turnCount,
91
+ generationTerminal: generation.terminal,
92
+ zeroBaselineTurnRef: existing?.zeroBaselineTurnRef ?? (
93
+ task.turns.length === 1 && task.latestTurn?.result === null
94
+ ? task.latestTurn.turnRef
95
+ : null
96
+ ),
97
+ status: preserveAvailable ? "available" : "calculating",
98
+ updatedAt: preserveAvailable ? existing.updatedAt : now,
99
+ retryAfter: null,
100
+ task: existing?.task ?? null,
101
+ turns,
102
+ boundaries: existing?.boundaries ?? {},
103
+ };
104
+ }
105
+
106
+ async function updateStore(workspace, taskId, transform) {
107
+ const release = await acquireWorkspaceLock(workspace);
108
+ try {
109
+ const store = await readUsageStore(workspace);
110
+ const next = await transform(store.tasks[taskId] ?? null);
111
+ store.tasks[taskId] = next;
112
+ await writeUsageStore(workspace, store);
113
+ return next;
114
+ } finally {
115
+ await release();
116
+ }
117
+ }
118
+
119
+ function snapshotFingerprint(snapshot) {
120
+ return JSON.stringify([
121
+ snapshot.inputTokens,
122
+ snapshot.cachedInputTokens,
123
+ snapshot.outputTokens,
124
+ snapshot.reasoningOutputTokens,
125
+ snapshot.totalTokens,
126
+ snapshot.estimatedCostUsd,
127
+ snapshot.sourceUpdatedAt,
128
+ ]);
129
+ }
130
+
131
+ function snapshotSupersedes(current, incoming) {
132
+ if (!current) return true;
133
+ const currentSampledAt = Date.parse(current.sampledAt ?? 0);
134
+ const incomingSampledAt = Date.parse(incoming.sampledAt ?? 0);
135
+ if (!Number.isFinite(incomingSampledAt) || incomingSampledAt < currentSampledAt) return false;
136
+ return [
137
+ "inputTokens",
138
+ "cachedInputTokens",
139
+ "outputTokens",
140
+ "reasoningOutputTokens",
141
+ "totalTokens",
142
+ ].every((field) => incoming[field] >= current[field]);
143
+ }
144
+
145
+ function candidateHasAdvanced(task, existing, snapshot) {
146
+ const latestIndex = task.turns.findIndex((turn) => turn.turnRef === task.latestTurn?.turnRef);
147
+ if (latestIndex <= 0) return true;
148
+ const previousTurn = task.turns[latestIndex - 1];
149
+ const previousBoundary = existing?.boundaries?.[previousTurn.turnRef];
150
+ if (!previousBoundary) return true;
151
+ const delta = usageDelta(snapshot, previousBoundary);
152
+ return delta !== null && delta.totalTokens > 0;
153
+ }
154
+
155
+ function reconcileRecord(task, existing, snapshot, { boundaryReliable = true } = {}) {
156
+ const now = snapshot.sampledAt;
157
+ const boundaries = { ...(existing?.boundaries ?? {}) };
158
+ const recentTurns = task.turns.slice(-MAX_TRACKED_TURNS);
159
+ const turns = Object.fromEntries(recentTurns.flatMap((turn) => (
160
+ existing?.turns?.[turn.turnRef]
161
+ ? [[turn.turnRef, existing.turns[turn.turnRef]]]
162
+ : []
163
+ )));
164
+ const terminalTurns = recentTurns.filter((turn) => (
165
+ turn.result !== null && TERMINAL_STATUSES.has(turn.result.status)
166
+ ));
167
+ const generation = lifecycleGeneration(task);
168
+ for (const turn of terminalTurns) {
169
+ if (!turns[turn.turnRef]) {
170
+ turns[turn.turnRef] = {
171
+ status: "unavailable",
172
+ reason: "No reliable cumulative boundary was recorded for this historical turn.",
173
+ updatedAt: now,
174
+ };
175
+ }
176
+ }
177
+
178
+ const latest = terminalTurns.at(-1);
179
+ if (latest && latest.turnRef === task.latestTurn?.turnRef && boundaryReliable) {
180
+ const index = task.turns.findIndex((turn) => turn.turnRef === latest.turnRef);
181
+ const previousTurn = index > 0 ? task.turns[index - 1] : null;
182
+ const previousBoundary = previousTurn ? boundaries[previousTurn.turnRef] ?? null : null;
183
+ const delta = previousTurn === null
184
+ ? (existing?.zeroBaselineTurnRef === latest.turnRef ? usageDelta(snapshot, null) : null)
185
+ : (previousBoundary !== null ? usageDelta(snapshot, previousBoundary) : null);
186
+ const advanced = previousTurn === null || previousBoundary === null || delta?.totalTokens > 0;
187
+ if (advanced) boundaries[latest.turnRef] = snapshot;
188
+ turns[latest.turnRef] = delta === null || !advanced
189
+ ? {
190
+ status: "unavailable",
191
+ reason: !advanced
192
+ ? "Cumulative usage did not advance beyond the preceding turn."
193
+ : (previousTurn === null
194
+ ? "No live zero-token boundary was recorded for this historical turn."
195
+ : "The preceding turn has no reliable cumulative boundary."),
196
+ updatedAt: now,
197
+ }
198
+ : {
199
+ status: "available",
200
+ ...delta,
201
+ provenance: snapshot.provenance,
202
+ sampledAt: snapshot.sampledAt,
203
+ sourceUpdatedAt: snapshot.sourceUpdatedAt,
204
+ updatedAt: now,
205
+ };
206
+ } else if (latest && latest.turnRef === task.latestTurn?.turnRef) {
207
+ turns[latest.turnRef] = {
208
+ status: "unavailable",
209
+ reason: "Usage did not stabilize before reconciliation finished.",
210
+ updatedAt: now,
211
+ };
212
+ }
213
+
214
+ return {
215
+ threadId: task.threadId,
216
+ generationTurnRef: task.latestTurn?.turnRef ?? null,
217
+ generationTurnCount: generation.turnCount,
218
+ generationTerminal: generation.terminal,
219
+ zeroBaselineTurnRef: existing?.zeroBaselineTurnRef ?? null,
220
+ status: "available",
221
+ updatedAt: now,
222
+ retryAfter: null,
223
+ task: snapshot,
224
+ turns,
225
+ boundaries,
226
+ };
227
+ }
228
+
229
+ export function createUsageTracker({
230
+ workspace,
231
+ readThreadUsage = readCcusageThreadUsage,
232
+ retryDelaysMs = [2_000, 3_000, 4_000, 1_000],
233
+ retryCooldownMs = 60_000,
234
+ setTimer = setTimeout,
235
+ } = {}) {
236
+ const jobs = new Map();
237
+ const observationChains = new Map();
238
+ let canonicalWorkspace = null;
239
+
240
+ const root = async () => {
241
+ canonicalWorkspace ??= await realpath(path.resolve(workspace));
242
+ return canonicalWorkspace;
243
+ };
244
+
245
+ const markCalculating = async (task) => updateStore(await root(), task.id, (existing) => (
246
+ generationIsOlder(task, existing) ? existing : calculatingTask(task, existing)
247
+ ));
248
+
249
+ const reconcile = async (task, {
250
+ finalAttempt = false,
251
+ isCurrent = () => true,
252
+ job,
253
+ } = {}) => {
254
+ let snapshot;
255
+ try {
256
+ snapshot = await readThreadUsage(task.threadId);
257
+ } catch (error) {
258
+ if (!finalAttempt) throw error;
259
+ if (!isCurrent()) return null;
260
+ return updateStore(await root(), task.id, (existing) => {
261
+ if (existing?.generationTurnRef !== task.latestTurn?.turnRef) return existing;
262
+ if (existing?.status === "available"
263
+ && existing?.turns?.[task.latestTurn?.turnRef]?.status === "available") return existing;
264
+ const calculating = calculatingTask(task, existing);
265
+ return {
266
+ ...calculating,
267
+ status: "unavailable",
268
+ reason: publicReason(error),
269
+ updatedAt: new Date().toISOString(),
270
+ retryAfter: new Date(Date.now() + retryCooldownMs).toISOString(),
271
+ turns: Object.fromEntries(Object.entries(calculating.turns).map(
272
+ ([turnRef, usage]) => [turnRef, usage.status === "calculating"
273
+ ? { status: "unavailable", reason: publicReason(error), updatedAt: new Date().toISOString() }
274
+ : usage],
275
+ )),
276
+ };
277
+ });
278
+ }
279
+ if (!isCurrent()) return null;
280
+ const fingerprint = snapshotFingerprint(snapshot);
281
+ const stable = job.previousFingerprint === fingerprint;
282
+ job.previousFingerprint = fingerprint;
283
+ if (!stable && !finalAttempt) return null;
284
+ if (stable && !finalAttempt) {
285
+ const store = await readUsageStore(await root());
286
+ if (!candidateHasAdvanced(task, store.tasks[task.id], snapshot)) return null;
287
+ }
288
+ return updateStore(await root(), task.id, (existing) => {
289
+ const latestTurnRef = task.latestTurn?.turnRef;
290
+ if (existing?.generationTurnRef !== latestTurnRef) return existing;
291
+ const supersedes = snapshotSupersedes(existing?.task, snapshot);
292
+ if (existing?.boundaries?.[latestTurnRef]
293
+ || existing?.turns?.[latestTurnRef]?.status === "available") {
294
+ return supersedes
295
+ ? { ...existing, status: "available", updatedAt: snapshot.sampledAt, task: snapshot }
296
+ : existing;
297
+ }
298
+ if (!supersedes) return existing;
299
+ return reconcileRecord(task, existing, snapshot, { boundaryReliable: stable });
300
+ });
301
+ };
302
+
303
+ const schedule = (task, { immediate = false } = {}) => {
304
+ if (!task.threadId || jobs.has(task.id)) return;
305
+ const job = {
306
+ cancelled: false,
307
+ previousFingerprint: null,
308
+ turnRef: task.latestTurn?.turnRef ?? null,
309
+ };
310
+ let attempt = 0;
311
+ const run = async () => {
312
+ if (job.cancelled || jobs.get(task.id) !== job) return;
313
+ let complete = false;
314
+ try {
315
+ const result = await reconcile(task, {
316
+ finalAttempt: attempt >= retryDelaysMs.length,
317
+ isCurrent: () => !job.cancelled && jobs.get(task.id) === job,
318
+ job,
319
+ });
320
+ complete = result !== null;
321
+ } catch {
322
+ // Retry bounded transient analyzer failures.
323
+ }
324
+ if (job.cancelled || jobs.get(task.id) !== job) return;
325
+ if (complete || attempt >= retryDelaysMs.length) {
326
+ if (jobs.get(task.id) === job) jobs.delete(task.id);
327
+ return;
328
+ }
329
+ const delay = retryDelaysMs[attempt] ?? 0;
330
+ attempt += 1;
331
+ const timer = setTimer(run, delay);
332
+ timer?.unref?.();
333
+ };
334
+ jobs.set(task.id, job);
335
+ const timer = setTimer(run, immediate ? 0 : retryDelaysMs[attempt++]);
336
+ timer?.unref?.();
337
+ };
338
+
339
+ return {
340
+ observe(task) {
341
+ const previous = observationChains.get(task.id) ?? Promise.resolve();
342
+ const observation = previous.catch(() => {}).then(async () => {
343
+ if (!task.threadId) return null;
344
+ const active = jobs.get(task.id);
345
+ const latestTurnRef = task.latestTurn?.turnRef ?? null;
346
+ if (active && active.turnRef !== latestTurnRef) {
347
+ active.cancelled = true;
348
+ jobs.delete(task.id);
349
+ }
350
+ const usage = await markCalculating(task);
351
+ if (hasTerminalLatestTurn(task)) schedule(task);
352
+ return usage;
353
+ });
354
+ observationChains.set(task.id, observation);
355
+ void observation.finally(() => {
356
+ if (observationChains.get(task.id) === observation) observationChains.delete(task.id);
357
+ }).catch(() => {});
358
+ return observation;
359
+ },
360
+ async get(task) {
361
+ const store = await readUsageStore(await root());
362
+ const usage = store.tasks[task.id] ?? null;
363
+ if (!task.threadId) return {
364
+ status: "unavailable",
365
+ reason: "This task has no linked Codex thread.",
366
+ task: null,
367
+ turns: {},
368
+ };
369
+ if (!task.latestTurn) return {
370
+ status: "unavailable",
371
+ reason: "No TaskChef turn has started yet.",
372
+ task: null,
373
+ turns: {},
374
+ };
375
+ const active = jobs.get(task.id);
376
+ const latestTurnRef = task.latestTurn?.turnRef ?? null;
377
+ if (active && active.turnRef !== latestTurnRef) {
378
+ active.cancelled = true;
379
+ jobs.delete(task.id);
380
+ }
381
+ if (!usage || usage.threadId !== task.threadId) {
382
+ const calculating = calculatingTask(task);
383
+ void markCalculating(task)
384
+ .then(() => {
385
+ if (hasTerminalLatestTurn(task)) schedule(task, { immediate: true });
386
+ })
387
+ .catch(() => {});
388
+ return calculating;
389
+ }
390
+ if (usage.generationTurnRef !== latestTurnRef) {
391
+ const calculating = calculatingTask(task, usage);
392
+ void markCalculating(task)
393
+ .then(() => {
394
+ if (hasTerminalLatestTurn(task)) schedule(task, { immediate: true });
395
+ })
396
+ .catch(() => {});
397
+ return calculating;
398
+ }
399
+ const latestTurnUsage = task.latestTurn
400
+ ? usage.turns?.[task.latestTurn.turnRef]
401
+ : null;
402
+ if (!latestTurnUsage) {
403
+ const calculating = calculatingTask(task, usage);
404
+ void markCalculating(task)
405
+ .then(() => {
406
+ if (hasTerminalLatestTurn(task)) schedule(task, { immediate: true });
407
+ })
408
+ .catch(() => {});
409
+ return calculating;
410
+ }
411
+ if (hasTerminalLatestTurn(task) && usage.status !== "available") {
412
+ if (usage.status === "unavailable"
413
+ && usage.retryAfter
414
+ && Date.parse(usage.retryAfter) > Date.now()) return usage;
415
+ const calculating = calculatingTask(task, usage);
416
+ void markCalculating(task)
417
+ .then(() => schedule(task, { immediate: true }))
418
+ .catch(() => {});
419
+ return calculating;
420
+ }
421
+ return usage;
422
+ },
423
+ schedule,
424
+ };
425
+ }
package/src/usage.js ADDED
@@ -0,0 +1,388 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ const execFile = promisify(execFileCallback);
8
+ const USAGE_FILE_NAME = ".taskchef-usage.json";
9
+ const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
10
+ const TOKEN_FIELDS = [
11
+ "inputTokens",
12
+ "cachedInputTokens",
13
+ "outputTokens",
14
+ "reasoningOutputTokens",
15
+ "totalTokens",
16
+ ];
17
+ const USAGE_STATUSES = new Set(["calculating", "available", "unavailable"]);
18
+ const MAX_USAGE_FILE_BYTES = 16 * 1024 * 1024;
19
+ const USAGE_WRITE_BUDGET_BYTES = 8 * 1024 * 1024;
20
+ const MAX_PERSISTED_TASKS = 1_000;
21
+ const MAX_PERSISTED_TURNS = 250;
22
+
23
+ function nonNegativeNumber(value, name) {
24
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
25
+ throw new Error(`${name} must be a non-negative number`);
26
+ }
27
+ return value;
28
+ }
29
+
30
+ function matchingSession(session, threadId) {
31
+ const identity = `${session.sessionId ?? ""} ${session.sessionFile ?? ""}`;
32
+ const [primaryThreadId] = identity.match(UUID_PATTERN) ?? [];
33
+ return primaryThreadId?.toLowerCase() === threadId;
34
+ }
35
+
36
+ function normalizeModelUsage(models, name) {
37
+ if (!models || typeof models !== "object" || Array.isArray(models)) {
38
+ throw new Error(`${name}.models must be an object`);
39
+ }
40
+ return Object.fromEntries(Object.entries(models).map(([model, usage]) => {
41
+ if (model.length === 0 || model.length > 256) throw new Error(`${name}.models has an invalid model name`);
42
+ return [model, {
43
+ inputTokens: nonNegativeNumber(usage.inputTokens, `${name}.models.${model}.inputTokens`),
44
+ cachedInputTokens: nonNegativeNumber(usage.cacheReadTokens, `${name}.models.${model}.cacheReadTokens`),
45
+ outputTokens: nonNegativeNumber(usage.outputTokens, `${name}.models.${model}.outputTokens`),
46
+ reasoningOutputTokens: nonNegativeNumber(
47
+ usage.reasoningOutputTokens,
48
+ `${name}.models.${model}.reasoningOutputTokens`,
49
+ ),
50
+ totalTokens: nonNegativeNumber(usage.totalTokens, `${name}.models.${model}.totalTokens`),
51
+ }];
52
+ }));
53
+ }
54
+
55
+ function addModelUsage(target, source) {
56
+ for (const [model, usage] of Object.entries(source)) {
57
+ if (!Object.hasOwn(target, model)) {
58
+ target[model] = Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
59
+ }
60
+ for (const field of TOKEN_FIELDS) target[model][field] += usage[field];
61
+ }
62
+ }
63
+
64
+ function timestampOrNull(value, name) {
65
+ if (value === null) return null;
66
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
67
+ throw new Error(`${name} must be an ISO timestamp or null`);
68
+ }
69
+ return value;
70
+ }
71
+
72
+ function normalizeProvenance(value, name, { includeSessionCount = false } = {}) {
73
+ if (!value || value.provider !== "ccusage") {
74
+ throw new Error(`${name}.provenance is invalid`);
75
+ }
76
+ return {
77
+ provider: "ccusage",
78
+ version: typeof value.version === "string" ? value.version.slice(0, 64) : null,
79
+ ...(includeSessionCount ? {
80
+ sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
81
+ } : {}),
82
+ };
83
+ }
84
+
85
+ function normalizeStoredSnapshot(value, name) {
86
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
87
+ throw new Error(`${name} must be an object`);
88
+ }
89
+ const snapshot = Object.fromEntries(TOKEN_FIELDS.map((field) => [
90
+ field,
91
+ nonNegativeNumber(value[field], `${name}.${field}`),
92
+ ]));
93
+ if (value.estimatedCostUsd !== null) {
94
+ snapshot.estimatedCostUsd = nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
95
+ } else {
96
+ snapshot.estimatedCostUsd = null;
97
+ }
98
+ snapshot.costStatus = value.costStatus === "estimated" ? "estimated" : "unavailable";
99
+ const models = normalizeModelUsage(Object.fromEntries(Object.entries(value.models ?? {}).map(
100
+ ([model, usage]) => [model, {
101
+ ...usage,
102
+ cacheReadTokens: usage.cachedInputTokens,
103
+ }],
104
+ )), name);
105
+ if (Object.keys(models).length > 64 || Object.keys(models).some((model) => model.length > 256)) {
106
+ throw new Error(`${name}.models exceeds the usage cache limit`);
107
+ }
108
+ snapshot.models = models;
109
+ snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
110
+ snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
111
+ snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
112
+ return snapshot;
113
+ }
114
+
115
+ function normalizeStoredTurn(value, name) {
116
+ if (!value || typeof value !== "object" || !USAGE_STATUSES.has(value.status)) {
117
+ throw new Error(`${name} has an invalid status`);
118
+ }
119
+ const updatedAt = timestampOrNull(value.updatedAt, `${name}.updatedAt`);
120
+ if (value.status === "available") {
121
+ const normalized = Object.fromEntries(TOKEN_FIELDS.map((field) => [
122
+ field,
123
+ nonNegativeNumber(value[field], `${name}.${field}`),
124
+ ]));
125
+ normalized.estimatedCostUsd = value.estimatedCostUsd === null
126
+ ? null
127
+ : nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
128
+ return {
129
+ status: "available",
130
+ ...normalized,
131
+ costStatus: normalized.estimatedCostUsd === null ? "unavailable" : "estimated",
132
+ provenance: normalizeProvenance(value.provenance, name),
133
+ sampledAt: timestampOrNull(value.sampledAt, `${name}.sampledAt`),
134
+ sourceUpdatedAt: timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`),
135
+ updatedAt,
136
+ };
137
+ }
138
+ return {
139
+ status: value.status,
140
+ ...(value.status === "unavailable" ? {
141
+ reason: typeof value.reason === "string" ? value.reason.slice(0, 256) : "Usage is unavailable.",
142
+ } : {}),
143
+ updatedAt,
144
+ };
145
+ }
146
+
147
+ function normalizeStoredRecord(value, name) {
148
+ if (!value || typeof value !== "object" || !USAGE_STATUSES.has(value.status)) {
149
+ throw new Error(`${name} is invalid`);
150
+ }
151
+ const threadId = String(value.threadId ?? "").toLowerCase();
152
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(threadId)) {
153
+ throw new Error(`${name}.threadId is invalid`);
154
+ }
155
+ const generationTurnRef = value.generationTurnRef ?? null;
156
+ if (generationTurnRef !== null
157
+ && (typeof generationTurnRef !== "string" || generationTurnRef.length === 0 || generationTurnRef.length > 256)) {
158
+ throw new Error(`${name}.generationTurnRef is invalid`);
159
+ }
160
+ const zeroBaselineTurnRef = value.zeroBaselineTurnRef ?? null;
161
+ if (zeroBaselineTurnRef !== null
162
+ && (typeof zeroBaselineTurnRef !== "string" || zeroBaselineTurnRef.length === 0 || zeroBaselineTurnRef.length > 256)) {
163
+ throw new Error(`${name}.zeroBaselineTurnRef is invalid`);
164
+ }
165
+ const generationTurnCount = nonNegativeNumber(
166
+ value.generationTurnCount ?? 0,
167
+ `${name}.generationTurnCount`,
168
+ );
169
+ const generationTerminal = value.generationTerminal === true;
170
+ const normalizeMap = (entries, normalizer, mapName) => {
171
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
172
+ throw new Error(`${mapName} must be an object`);
173
+ }
174
+ const pairs = Object.entries(entries);
175
+ if (pairs.length > 10_000) throw new Error(`${mapName} exceeds the usage cache limit`);
176
+ return Object.fromEntries(pairs.map(([key, item]) => [key, normalizer(item, `${mapName}.${key}`)]));
177
+ };
178
+ return {
179
+ threadId,
180
+ generationTurnRef,
181
+ generationTurnCount,
182
+ generationTerminal,
183
+ zeroBaselineTurnRef,
184
+ status: value.status,
185
+ updatedAt: timestampOrNull(value.updatedAt, `${name}.updatedAt`),
186
+ retryAfter: timestampOrNull(value.retryAfter ?? null, `${name}.retryAfter`),
187
+ task: value.task === null ? null : normalizeStoredSnapshot(value.task, `${name}.task`),
188
+ turns: normalizeMap(value.turns, normalizeStoredTurn, `${name}.turns`),
189
+ boundaries: normalizeMap(value.boundaries, normalizeStoredSnapshot, `${name}.boundaries`),
190
+ ...(value.status === "unavailable" ? {
191
+ reason: typeof value.reason === "string" ? value.reason.slice(0, 256) : "Usage is unavailable.",
192
+ } : {}),
193
+ };
194
+ }
195
+
196
+ export function aggregateCcusageSessions(payload, threadId, {
197
+ sampledAt = new Date().toISOString(),
198
+ version = null,
199
+ } = {}) {
200
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.sessions)) {
201
+ throw new Error("ccusage output must contain a sessions array");
202
+ }
203
+ const normalizedThreadId = String(threadId).toLowerCase();
204
+ const sessions = payload.sessions.filter((session) => matchingSession(session, normalizedThreadId));
205
+ if (sessions.length === 0) throw new Error("ccusage could not resolve this Codex thread");
206
+
207
+ const usage = Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
208
+ const models = Object.create(null);
209
+ let estimatedCostUsd = 0;
210
+ let hasUnpricedUsage = false;
211
+ let sourceUpdatedAt = null;
212
+ for (const [index, session] of sessions.entries()) {
213
+ const name = `ccusage.sessions[${index}]`;
214
+ usage.inputTokens += nonNegativeNumber(session.inputTokens, `${name}.inputTokens`);
215
+ usage.cachedInputTokens += nonNegativeNumber(session.cacheReadTokens, `${name}.cacheReadTokens`);
216
+ usage.outputTokens += nonNegativeNumber(session.outputTokens, `${name}.outputTokens`);
217
+ usage.reasoningOutputTokens += nonNegativeNumber(
218
+ session.reasoningOutputTokens,
219
+ `${name}.reasoningOutputTokens`,
220
+ );
221
+ const sessionTotalTokens = nonNegativeNumber(session.totalTokens, `${name}.totalTokens`);
222
+ usage.totalTokens += sessionTotalTokens;
223
+ const sessionCost = session.costUSD === undefined || session.costUSD === null
224
+ ? null
225
+ : nonNegativeNumber(session.costUSD, `${name}.costUSD`);
226
+ if (sessionCost !== null) estimatedCostUsd += sessionCost;
227
+ if (sessionTotalTokens > 0 && (sessionCost === null || sessionCost === 0)) hasUnpricedUsage = true;
228
+ addModelUsage(models, normalizeModelUsage(session.models, name));
229
+ if (typeof session.lastActivity === "string" && !Number.isNaN(Date.parse(session.lastActivity))) {
230
+ if (sourceUpdatedAt === null || Date.parse(session.lastActivity) > Date.parse(sourceUpdatedAt)) {
231
+ sourceUpdatedAt = session.lastActivity;
232
+ }
233
+ }
234
+ }
235
+
236
+ return {
237
+ ...usage,
238
+ estimatedCostUsd: hasUnpricedUsage ? null : estimatedCostUsd,
239
+ costStatus: hasUnpricedUsage ? "unavailable" : "estimated",
240
+ models,
241
+ provenance: {
242
+ provider: "ccusage",
243
+ version,
244
+ sessionCount: sessions.length,
245
+ },
246
+ sampledAt,
247
+ sourceUpdatedAt,
248
+ };
249
+ }
250
+
251
+ export async function readCcusageThreadUsage(threadId, {
252
+ command = "ccusage",
253
+ run = execFile,
254
+ sampledAt = new Date().toISOString(),
255
+ timeoutMs = 8_000,
256
+ } = {}) {
257
+ let version = null;
258
+ try {
259
+ const result = await run(command, ["--version"], {
260
+ timeout: Math.min(timeoutMs, 2_000),
261
+ maxBuffer: 64 * 1024,
262
+ });
263
+ version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
264
+ } catch {
265
+ // Usage remains useful if an older compatible ccusage cannot print its version.
266
+ }
267
+ let result;
268
+ try {
269
+ result = await run(command, ["codex", "session", "--json", "--offline"], {
270
+ timeout: timeoutMs,
271
+ maxBuffer: 32 * 1024 * 1024,
272
+ env: { ...process.env, NO_COLOR: "1" },
273
+ });
274
+ } catch (error) {
275
+ if (error?.code === "ENOENT") throw new Error("ccusage is not installed");
276
+ if (error?.killed || error?.code === "ETIMEDOUT") throw new Error("ccusage timed out");
277
+ throw new Error("ccusage could not read Codex usage");
278
+ }
279
+ let payload;
280
+ try {
281
+ payload = JSON.parse(result.stdout);
282
+ } catch {
283
+ throw new Error("ccusage returned malformed JSON");
284
+ }
285
+ return aggregateCcusageSessions(payload, threadId, { sampledAt, version });
286
+ }
287
+
288
+ function usageFile(workspace) {
289
+ return path.join(workspace, USAGE_FILE_NAME);
290
+ }
291
+
292
+ export async function readUsageStore(workspace) {
293
+ const filePath = usageFile(workspace);
294
+ const details = await lstat(filePath).catch((error) => {
295
+ if (error.code === "ENOENT") return null;
296
+ throw error;
297
+ });
298
+ if (details === null) return { schemaVersion: 1, tasks: {} };
299
+ if (details.isSymbolicLink() || !details.isFile()) {
300
+ throw new Error("TaskChef usage cache must be a regular file");
301
+ }
302
+ if (details.size > MAX_USAGE_FILE_BYTES) {
303
+ return { schemaVersion: 1, tasks: {} };
304
+ }
305
+ const value = JSON.parse(await readFile(filePath, "utf8"));
306
+ if (value?.schemaVersion !== 1 || !value.tasks || typeof value.tasks !== "object") {
307
+ throw new Error("TaskChef usage cache has an unsupported schema");
308
+ }
309
+ const tasks = Object.entries(value.tasks);
310
+ if (tasks.length > 2_000) return { schemaVersion: 1, tasks: {} };
311
+ return {
312
+ schemaVersion: 1,
313
+ tasks: Object.fromEntries(tasks.map(([taskId, record]) => [
314
+ taskId,
315
+ normalizeStoredRecord(record, `usage task ${taskId}`),
316
+ ])),
317
+ };
318
+ }
319
+
320
+ function compactUsageRecord(record) {
321
+ const turnEntries = Object.entries(record.turns ?? {}).slice(-MAX_PERSISTED_TURNS);
322
+ const boundaryEntries = Object.entries(record.boundaries ?? {}).slice(-1);
323
+ return {
324
+ ...record,
325
+ turns: Object.fromEntries(turnEntries),
326
+ boundaries: Object.fromEntries(boundaryEntries),
327
+ };
328
+ }
329
+
330
+ export function compactUsageStore(store) {
331
+ const entries = Object.entries(store.tasks ?? {})
332
+ .sort(([, left], [, right]) => Date.parse(right.updatedAt ?? 0) - Date.parse(left.updatedAt ?? 0))
333
+ .slice(0, MAX_PERSISTED_TASKS);
334
+ const tasks = {};
335
+ let approximateBytes = 64;
336
+ for (const [taskId, record] of entries) {
337
+ const compacted = compactUsageRecord(record);
338
+ const entryBytes = Buffer.byteLength(JSON.stringify([taskId, compacted]), "utf8");
339
+ if (Object.keys(tasks).length > 0 && approximateBytes + entryBytes > USAGE_WRITE_BUDGET_BYTES) break;
340
+ tasks[taskId] = compacted;
341
+ approximateBytes += entryBytes;
342
+ }
343
+ return { schemaVersion: 1, tasks };
344
+ }
345
+
346
+ export async function writeUsageStore(workspace, store) {
347
+ await mkdir(workspace, { recursive: true });
348
+ const filePath = usageFile(workspace);
349
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
350
+ const serialized = `${JSON.stringify(compactUsageStore(store), null, 2)}\n`;
351
+ if (Buffer.byteLength(serialized, "utf8") > MAX_USAGE_FILE_BYTES) {
352
+ throw new Error("TaskChef usage cache cannot be compacted below the size limit");
353
+ }
354
+ await writeFile(temporaryPath, serialized, {
355
+ encoding: "utf8",
356
+ flag: "wx",
357
+ mode: 0o600,
358
+ });
359
+ try {
360
+ await rename(temporaryPath, filePath);
361
+ } catch (error) {
362
+ await unlink(temporaryPath).catch(() => {});
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ export function usageDelta(current, previous = null) {
368
+ const baseline = previous ?? Object.fromEntries(TOKEN_FIELDS.map((field) => [field, 0]));
369
+ const delta = {};
370
+ for (const field of TOKEN_FIELDS) {
371
+ const value = current[field] - baseline[field];
372
+ if (!Number.isFinite(value) || value < 0) return null;
373
+ delta[field] = value;
374
+ }
375
+ let estimatedCostUsd = current.estimatedCostUsd === null
376
+ || (previous !== null && previous.estimatedCostUsd === null)
377
+ ? null
378
+ : current.estimatedCostUsd - (previous?.estimatedCostUsd ?? 0);
379
+ if (estimatedCostUsd !== null && (!Number.isFinite(estimatedCostUsd) || estimatedCostUsd < 0)) {
380
+ estimatedCostUsd = null;
381
+ }
382
+ if (estimatedCostUsd === 0 && delta.totalTokens > 0) estimatedCostUsd = null;
383
+ return {
384
+ ...delta,
385
+ estimatedCostUsd,
386
+ costStatus: estimatedCostUsd === null ? "unavailable" : "estimated",
387
+ };
388
+ }