taskchef 7.22.2 → 7.22.3

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.22.2",
3
+ "version": "7.22.3",
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,11 +25,15 @@ 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.
28
+ TaskChef pins [`ccusage`](https://github.com/ccusage/ccusage) as an optional
29
+ dependency for dashboard token and API-equivalent cost estimates. A normal npm
30
+ installation uses that package-local executable; plugin-only installations use
31
+ an npm-cache-friendly `npx` fallback to resolve the same exact version's native
32
+ executable. `npx` never launches the analyzer against Codex data. TaskChef then
33
+ requests ccusage's live-capable online pricing and falls back to its bundled
34
+ offline snapshot, while never parsing Codex rollout files itself. Lifecycle
35
+ reporting and the dashboard continue to work when ccusage, npm, or pricing is
36
+ unavailable.
33
37
 
34
38
  ```sh
35
39
  codex plugin marketplace add favoyang/codex-plugins
@@ -318,8 +322,12 @@ the terminal lifecycle callback precedes Codex's final output write. Historical
318
322
  tasks may show a trustworthy task total while older turns remain “Tokens
319
323
  unavailable” when no cumulative turn boundaries were recorded. Input, cached
320
324
  input, output, reasoning, and total counts retain ccusage's categories. Dollar
321
- figures are labeled API-equivalent estimates; zero-priced unknown models show
322
- cost unavailable rather than a misleading `$0.00`.
325
+ figures are labeled API-equivalent estimates; provenance identifies the online
326
+ pricing request or offline fallback mode, and zero-priced unknown models show
327
+ cost unavailable rather than a misleading `$0.00`. Per-turn cost is also
328
+ unavailable when its cumulative boundaries used different ccusage versions or
329
+ pricing modes. ccusage 20.0.20 may omit GPT-5.6 cache-write charges, so TaskChef
330
+ labels that analyzer limitation instead of presenting the estimate as complete.
323
331
  The header shows the running TaskChef package version reported by the same
324
332
  bounded health identity used for compatible-listener checks.
325
333
  The canonical port is owned by a dashboard initialized in the TaskChef MCP host
package/docs/spec.md CHANGED
@@ -120,11 +120,17 @@ aliases for existing callers.
120
120
 
121
121
  ## Optional usage projection
122
122
 
123
- TaskChef MAY invoke an installed `ccusage` executable as an optional local
124
- adapter using `ccusage codex session --json --offline`. TaskChef MUST NOT parse,
125
- store, or serve raw Codex rollout files, prompts, responses, transcripts, or
126
- reasoning. Analyzer absence, timeout, malformed output, unknown pricing, or an
127
- unresolved thread MUST NOT block lifecycle tools or dashboard loading.
123
+ TaskChef MUST pin one tested `ccusage` version as an optional dependency. It
124
+ MUST prefer that package-local executable and MAY use an exact-version,
125
+ cache-friendly `npx` invocation when a plugin-only installation has no local
126
+ dependency. The `npx` invocation MUST only resolve the pinned native executable
127
+ and MUST NOT launch the analyzer against Codex data. TaskChef MUST execute that
128
+ native binary directly. The adapter MUST first request structured Codex output
129
+ with live-capable online pricing and retry with ccusage's bundled offline
130
+ pricing after a bounded failure. TaskChef MUST NOT parse, store, or serve raw
131
+ Codex rollout files, prompts, responses, transcripts, or reasoning. Analyzer,
132
+ npm, network, timeout, malformed output, unknown pricing, or an unresolved
133
+ thread MUST NOT block lifecycle tools or dashboard loading.
128
134
 
129
135
  Dashboard-manual turns are administrative events, not Codex executions. They
130
136
  MUST immediately report per-turn usage as unavailable, MUST NOT schedule or
@@ -162,9 +168,15 @@ in progress before its terminal report; a first historical terminal turn MUST
162
168
  remain unavailable even when its cumulative task total is resolvable.
163
169
 
164
170
  Every available projection MUST identify ccusage, its version when available,
165
- and freshness. Dollar values MUST be labeled API-equivalent estimates. Positive
166
- token usage with a zero or missing analyzer cost MUST display cost unavailable,
167
- not `$0.00`.
171
+ the requested online or fallback offline pricing mode, and usage freshness.
172
+ The online mode records TaskChef's request, not a claim about ccusage's internal
173
+ cache. Dollar values MUST be labeled API-equivalent estimates. Positive token
174
+ usage with a zero or missing analyzer cost MUST display cost unavailable, not
175
+ `$0.00`. A per-turn dollar delta MUST be unavailable when its adjacent
176
+ cumulative boundaries use different analyzer versions, requested pricing modes,
177
+ or declared cost coverage. For GPT-5.6 with ccusage 20.0.20, provenance MUST
178
+ disclose unverified cache-write coverage and TaskChef MUST NOT claim that the
179
+ estimate includes those charges.
168
180
 
169
181
  Task IDs and non-null thread identities MUST be unique. The immutable intent
170
182
  fields MUST NOT change after recording.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.22.2",
3
+ "version": "7.22.3",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -64,5 +64,8 @@
64
64
  "@modelcontextprotocol/sdk",
65
65
  "proper-lockfile",
66
66
  "zod"
67
- ]
67
+ ],
68
+ "optionalDependencies": {
69
+ "ccusage": "20.0.20"
70
+ }
68
71
  }
@@ -429,8 +429,16 @@ function usagePresentation(usage, { wholeTask = false } = {}) {
429
429
  container.append(headline, breakdown);
430
430
  const provenance = document.createElement("span");
431
431
  const version = usage.provenance?.version ? ` ${usage.provenance.version}` : "";
432
+ const pricingMode = usage.provenance?.pricingMode
433
+ ? ` · ${usage.provenance.pricingMode === "online"
434
+ ? "online pricing requested"
435
+ : "offline pricing fallback"}`
436
+ : "";
432
437
  const freshness = usage.sourceUpdatedAt ?? usage.sampledAt;
433
- provenance.textContent = `Source: ccusage${version}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
438
+ provenance.textContent = `Source: ccusage${version}${pricingMode}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
439
+ if (usage.provenance?.costCoverage === "cache_writes_unverified") {
440
+ provenance.textContent += " ccusage may omit GPT-5.6 cache-write charges.";
441
+ }
434
442
  container.append(provenance);
435
443
  return container;
436
444
  }
@@ -0,0 +1,17 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const [packageName, binaryName] = process.argv.slice(2);
5
+ if (!/^@ccusage\/ccusage-(?:darwin|linux|win32)-(?:arm64|x64)$/u.test(packageName ?? "")
6
+ || !/^ccusage(?:\.exe)?$/u.test(binaryName ?? "")) {
7
+ process.exit(1);
8
+ }
9
+
10
+ const binDirectory = process.env.PATH
11
+ ?.split(path.delimiter)
12
+ .find((entry) => entry.endsWith(path.join("node_modules", ".bin")));
13
+ const binary = binDirectory
14
+ ? path.join(path.dirname(binDirectory), packageName, "bin", binaryName)
15
+ : null;
16
+ if (binary === null || !existsSync(binary)) process.exit(1);
17
+ process.stdout.write(binary);
package/src/usage.js CHANGED
@@ -1,10 +1,16 @@
1
1
  import { execFile as execFileCallback } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
3
5
  import path from "node:path";
4
- import { promisify } from "node:util";
5
6
  import { randomUUID } from "node:crypto";
7
+ import { fileURLToPath } from "node:url";
6
8
 
7
- const execFile = promisify(execFileCallback);
9
+ const require = createRequire(import.meta.url);
10
+ const taskchefPackage = require("../package.json");
11
+ const PINNED_CCUSAGE_VERSION = taskchefPackage.optionalDependencies?.ccusage;
12
+ const CCUSAGE_RESOLVER = fileURLToPath(new URL("./resolve-ccusage.js", import.meta.url));
13
+ let npxResolvedCcusageInvocation = null;
8
14
  const USAGE_FILE_NAME = ".taskchef-usage.json";
9
15
  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
16
  const TOKEN_FIELDS = [
@@ -76,6 +82,13 @@ function normalizeProvenance(value, name, { includeSessionCount = false } = {})
76
82
  return {
77
83
  provider: "ccusage",
78
84
  version: typeof value.version === "string" ? value.version.slice(0, 64) : null,
85
+ pricingMode: value.pricingMode === "online" || value.pricingMode === "offline"
86
+ ? value.pricingMode
87
+ : null,
88
+ costCoverage: value.costCoverage === "cache_writes_unverified"
89
+ || value.costCoverage === "ccusage_reported"
90
+ ? value.costCoverage
91
+ : null,
79
92
  ...(includeSessionCount ? {
80
93
  sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
81
94
  } : {}),
@@ -107,6 +120,10 @@ function normalizeStoredSnapshot(value, name) {
107
120
  }
108
121
  snapshot.models = models;
109
122
  snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
123
+ if (snapshot.provenance.pricingMode === null || snapshot.provenance.costCoverage === null) {
124
+ snapshot.estimatedCostUsd = null;
125
+ snapshot.costStatus = "unavailable";
126
+ }
110
127
  snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
111
128
  snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
112
129
  return snapshot;
@@ -125,11 +142,15 @@ function normalizeStoredTurn(value, name) {
125
142
  normalized.estimatedCostUsd = value.estimatedCostUsd === null
126
143
  ? null
127
144
  : nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
145
+ const provenance = normalizeProvenance(value.provenance, name);
146
+ if (provenance.pricingMode === null || provenance.costCoverage === null) {
147
+ normalized.estimatedCostUsd = null;
148
+ }
128
149
  return {
129
150
  status: "available",
130
151
  ...normalized,
131
152
  costStatus: normalized.estimatedCostUsd === null ? "unavailable" : "estimated",
132
- provenance: normalizeProvenance(value.provenance, name),
153
+ provenance,
133
154
  sampledAt: timestampOrNull(value.sampledAt, `${name}.sampledAt`),
134
155
  sourceUpdatedAt: timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`),
135
156
  updatedAt,
@@ -196,6 +217,7 @@ function normalizeStoredRecord(value, name) {
196
217
  export function aggregateCcusageSessions(payload, threadId, {
197
218
  sampledAt = new Date().toISOString(),
198
219
  version = null,
220
+ pricingMode = null,
199
221
  } = {}) {
200
222
  if (!payload || typeof payload !== "object" || !Array.isArray(payload.sessions)) {
201
223
  throw new Error("ccusage output must contain a sessions array");
@@ -241,6 +263,10 @@ export function aggregateCcusageSessions(payload, threadId, {
241
263
  provenance: {
242
264
  provider: "ccusage",
243
265
  version,
266
+ pricingMode,
267
+ costCoverage: Object.keys(models).some((model) => /^gpt-5\.6(?:-|$)/i.test(model))
268
+ ? "cache_writes_unverified"
269
+ : "ccusage_reported",
244
270
  sessionCount: sessions.length,
245
271
  },
246
272
  sampledAt,
@@ -248,41 +274,291 @@ export function aggregateCcusageSessions(payload, threadId, {
248
274
  };
249
275
  }
250
276
 
251
- export async function readCcusageThreadUsage(threadId, {
252
- command = "ccusage",
253
- run = execFile,
254
- sampledAt = new Date().toISOString(),
255
- timeoutMs = 8_000,
277
+ export function managedCcusageInvocation({
278
+ platform = process.platform,
279
+ arch = process.arch,
280
+ env = process.env,
281
+ resolvePath = (id) => require.resolve(id),
282
+ exists = existsSync,
283
+ npxCliPath = null,
256
284
  } = {}) {
257
- let version = null;
258
- try {
259
- const result = await run(command, ["--version"], {
260
- timeout: Math.min(timeoutMs, 2_000),
261
- maxBuffer: 64 * 1024,
285
+ if (typeof PINNED_CCUSAGE_VERSION !== "string" || PINNED_CCUSAGE_VERSION.length === 0) {
286
+ throw new Error("TaskChef has no pinned ccusage version");
287
+ }
288
+ const packageName = {
289
+ "darwin-arm64": "@ccusage/ccusage-darwin-arm64",
290
+ "darwin-x64": "@ccusage/ccusage-darwin-x64",
291
+ "linux-arm64": "@ccusage/ccusage-linux-arm64",
292
+ "linux-x64": "@ccusage/ccusage-linux-x64",
293
+ "win32-arm64": "@ccusage/ccusage-win32-arm64",
294
+ "win32-x64": "@ccusage/ccusage-win32-x64",
295
+ }[`${platform}-${arch}`];
296
+ let nativeBinary = null;
297
+ if (packageName !== undefined) {
298
+ try {
299
+ nativeBinary = resolvePath(
300
+ `${packageName}/bin/${platform === "win32" ? "ccusage.exe" : "ccusage"}`,
301
+ );
302
+ } catch {
303
+ // Plugin-only installations can resolve the same pinned release through npx.
304
+ }
305
+ }
306
+ if (nativeBinary !== null) {
307
+ return {
308
+ command: nativeBinary,
309
+ args: [],
310
+ version: PINNED_CCUSAGE_VERSION,
311
+ };
312
+ }
313
+ if (packageName === undefined) {
314
+ throw new Error(`ccusage does not support ${platform}-${arch}`);
315
+ }
316
+ const binaryName = platform === "win32" ? "ccusage.exe" : "ccusage";
317
+ const npxArgs = [
318
+ "--yes",
319
+ "--prefer-offline",
320
+ `--package=ccusage@${PINNED_CCUSAGE_VERSION}`,
321
+ "node",
322
+ CCUSAGE_RESOLVER,
323
+ packageName,
324
+ binaryName,
325
+ ];
326
+ if (platform === "win32") {
327
+ const platformPath = path.win32;
328
+ const nodeDirectory = platformPath.dirname(process.execPath);
329
+ const candidates = [
330
+ npxCliPath,
331
+ typeof env.npm_execpath === "string"
332
+ ? platformPath.join(platformPath.dirname(env.npm_execpath), "npx-cli.js")
333
+ : null,
334
+ platformPath.join(nodeDirectory, "node_modules", "npm", "bin", "npx-cli.js"),
335
+ platformPath.resolve(
336
+ nodeDirectory,
337
+ "..", "lib", "node_modules", "npm", "bin", "npx-cli.js",
338
+ ),
339
+ ];
340
+ const npxCli = candidates.find((candidate) => typeof candidate === "string"
341
+ && platformPath.isAbsolute(candidate)
342
+ && exists(candidate));
343
+ if (npxCli === undefined) throw new Error("TaskChef could not resolve npm's npx CLI safely");
344
+ return {
345
+ command: process.execPath,
346
+ args: [npxCli, ...npxArgs],
347
+ version: PINNED_CCUSAGE_VERSION,
348
+ resolvesNative: true,
349
+ };
350
+ }
351
+ return {
352
+ command: "npx",
353
+ args: npxArgs,
354
+ version: PINNED_CCUSAGE_VERSION,
355
+ resolvesNative: true,
356
+ };
357
+ }
358
+
359
+ async function resolveManagedCcusageInvocation(invocation, run, timeoutMs) {
360
+ if (invocation.resolvesNative !== true) return invocation;
361
+ if (npxResolvedCcusageInvocation !== null
362
+ && existsSync(npxResolvedCcusageInvocation.command)) {
363
+ return npxResolvedCcusageInvocation;
364
+ }
365
+ npxResolvedCcusageInvocation = null;
366
+ const result = await run(invocation.command, invocation.args, {
367
+ timeout: timeoutMs,
368
+ maxBuffer: 64 * 1024,
369
+ env: { ...process.env, NO_COLOR: "1" },
370
+ });
371
+ const command = String(result.stdout).trim();
372
+ if (!path.isAbsolute(command)
373
+ || !/^ccusage(?:\.exe)?$/iu.test(path.basename(command))
374
+ || !existsSync(command)) {
375
+ throw new Error("npx returned an invalid ccusage executable");
376
+ }
377
+ npxResolvedCcusageInvocation = {
378
+ command,
379
+ args: [],
380
+ version: invocation.version,
381
+ };
382
+ return npxResolvedCcusageInvocation;
383
+ }
384
+
385
+ function processTable() {
386
+ return new Promise((resolve) => {
387
+ execFileCallback("ps", ["-axo", "pid=,ppid="], {
388
+ maxBuffer: 4 * 1024 * 1024,
389
+ timeout: 250,
390
+ windowsHide: true,
391
+ }, (error, stdout) => {
392
+ if (error) {
393
+ resolve([]);
394
+ return;
395
+ }
396
+ resolve(String(stdout).trim().split("\n").flatMap((line) => {
397
+ const [pid, parentPid] = line.trim().split(/\s+/u).map(Number);
398
+ return Number.isInteger(pid) && Number.isInteger(parentPid)
399
+ ? [{ pid, parentPid }]
400
+ : [];
401
+ }));
402
+ });
403
+ });
404
+ }
405
+
406
+ async function descendantProcessIds(parentPid) {
407
+ const table = await processTable();
408
+ const visit = (pid) => table
409
+ .filter((entry) => entry.parentPid === pid)
410
+ .flatMap((entry) => [...visit(entry.pid), entry.pid]);
411
+ return visit(parentPid);
412
+ }
413
+
414
+ async function terminateProcessTree(child, platform = process.platform) {
415
+ if (!Number.isInteger(child.pid)) return;
416
+ if (platform === "win32") {
417
+ await new Promise((resolve) => {
418
+ execFileCallback(
419
+ "taskkill",
420
+ ["/pid", String(child.pid), "/t", "/f"],
421
+ { timeout: 500, windowsHide: true },
422
+ () => {
423
+ child.kill("SIGKILL");
424
+ resolve();
425
+ },
426
+ );
262
427
  });
263
- version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
428
+ return;
429
+ }
430
+ try {
431
+ process.kill(-child.pid, "SIGKILL");
432
+ return;
264
433
  } catch {
265
- // Usage remains useful if an older compatible ccusage cannot print its version.
434
+ // Resolve descendants only when process-group termination is unavailable.
266
435
  }
267
- let result;
436
+ let descendants = [];
268
437
  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" },
438
+ descendants = await descendantProcessIds(child.pid);
439
+ } catch {
440
+ // The detached process-group kill remains the fallback when ps is unavailable.
441
+ }
442
+ for (const pid of descendants) {
443
+ try {
444
+ process.kill(pid, "SIGKILL");
445
+ } catch {
446
+ // The group kill or normal process exit may have won the race.
447
+ }
448
+ }
449
+ child.kill("SIGKILL");
450
+ }
451
+
452
+ export function runBoundedProcess(command, args, options = {}) {
453
+ const platform = options.platform ?? process.platform;
454
+ const timeout = options.timeout ?? 0;
455
+ const { platform: _platform, ...childOptions } = options;
456
+ return new Promise((resolve, reject) => {
457
+ let timedOut = false;
458
+ let timer = null;
459
+ let settled = false;
460
+ const rejectOnce = (error) => {
461
+ if (settled) return;
462
+ settled = true;
463
+ reject(error);
464
+ };
465
+ const child = execFileCallback(command, args, {
466
+ ...childOptions,
467
+ timeout: undefined,
468
+ detached: platform !== "win32",
469
+ windowsHide: true,
470
+ }, async (error, stdout, stderr) => {
471
+ if (timer !== null) clearTimeout(timer);
472
+ if (timedOut || settled) return;
473
+ if (error) {
474
+ await terminateProcessTree(child, platform);
475
+ rejectOnce(error);
476
+ return;
477
+ }
478
+ settled = true;
479
+ resolve({ stdout, stderr });
273
480
  });
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");
481
+ if (timeout > 0) {
482
+ timer = setTimeout(() => {
483
+ timedOut = true;
484
+ const timeoutError = new Error(`${command} timed out`);
485
+ timeoutError.code = "ETIMEDOUT";
486
+ timeoutError.killed = true;
487
+ Promise.race([
488
+ terminateProcessTree(child, platform),
489
+ new Promise((resolveTermination) => setTimeout(resolveTermination, 750)),
490
+ ]).catch(() => {}).then(() => rejectOnce(timeoutError));
491
+ }, timeout);
492
+ timer.unref();
493
+ }
494
+ });
495
+ }
496
+
497
+ export async function readCcusageThreadUsage(threadId, {
498
+ command = null,
499
+ commandArgs = [],
500
+ run = runBoundedProcess,
501
+ sampledAt = new Date().toISOString(),
502
+ timeoutMs = 8_000,
503
+ } = {}) {
504
+ const invocation = command === null
505
+ ? managedCcusageInvocation()
506
+ : { command, args: commandArgs, version: null };
507
+ const resolvedInvocation = await resolveManagedCcusageInvocation(invocation, run, timeoutMs);
508
+ const invoke = (args, options) => run(
509
+ resolvedInvocation.command,
510
+ [...resolvedInvocation.args, ...args],
511
+ options,
512
+ );
513
+ let version = resolvedInvocation.version;
514
+ if (version === null) {
515
+ try {
516
+ const result = await invoke(["--version"], {
517
+ timeout: Math.min(timeoutMs, 2_000),
518
+ maxBuffer: 64 * 1024,
519
+ });
520
+ version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
521
+ } catch {
522
+ // Usage remains useful if a custom compatible ccusage cannot print its version.
523
+ }
278
524
  }
279
- let payload;
525
+ const report = async (pricingMode) => {
526
+ let result;
527
+ try {
528
+ result = await invoke([
529
+ "codex", "session", "--json",
530
+ pricingMode === "online" ? "--no-offline" : "--offline",
531
+ ], {
532
+ timeout: timeoutMs,
533
+ maxBuffer: 32 * 1024 * 1024,
534
+ env: { ...process.env, NO_COLOR: "1" },
535
+ });
536
+ } catch (error) {
537
+ if (error?.code === "ENOENT") throw new Error("ccusage is not installed");
538
+ if (error?.killed || error?.code === "ETIMEDOUT") throw new Error("ccusage timed out");
539
+ throw new Error("ccusage could not read Codex usage");
540
+ }
541
+ let payload;
542
+ try {
543
+ payload = JSON.parse(result.stdout);
544
+ } catch {
545
+ throw new Error("ccusage returned malformed JSON");
546
+ }
547
+ return aggregateCcusageSessions(payload, threadId, {
548
+ sampledAt,
549
+ version,
550
+ pricingMode,
551
+ });
552
+ };
280
553
  try {
281
- payload = JSON.parse(result.stdout);
282
- } catch {
283
- throw new Error("ccusage returned malformed JSON");
554
+ return await report("online");
555
+ } catch (onlineError) {
556
+ try {
557
+ return await report("offline");
558
+ } catch {
559
+ throw onlineError;
560
+ }
284
561
  }
285
- return aggregateCcusageSessions(payload, threadId, { sampledAt, version });
286
562
  }
287
563
 
288
564
  function usageFile(workspace) {
@@ -372,7 +648,10 @@ export function usageDelta(current, previous = null) {
372
648
  if (!Number.isFinite(value) || value < 0) return null;
373
649
  delta[field] = value;
374
650
  }
375
- let estimatedCostUsd = current.estimatedCostUsd === null
651
+ const samePricingSource = previous === null || ["version", "pricingMode", "costCoverage"]
652
+ .every((field) => current.provenance?.[field] === previous.provenance?.[field]);
653
+ let estimatedCostUsd = !samePricingSource
654
+ || current.estimatedCostUsd === null
376
655
  || (previous !== null && previous.estimatedCostUsd === null)
377
656
  ? null
378
657
  : current.estimatedCostUsd - (previous?.estimatedCostUsd ?? 0);