taskchef 7.22.2 → 7.22.4

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.4",
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.4",
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,23 @@
1
1
  import { execFile as execFileCallback } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ constants as fsConstants,
5
+ existsSync,
6
+ fchmodSync,
7
+ fstatSync,
8
+ openSync,
9
+ } from "node:fs";
2
10
  import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
11
+ import { createRequire } from "node:module";
3
12
  import path from "node:path";
4
- import { promisify } from "node:util";
5
13
  import { randomUUID } from "node:crypto";
14
+ import { fileURLToPath } from "node:url";
6
15
 
7
- const execFile = promisify(execFileCallback);
16
+ const require = createRequire(import.meta.url);
17
+ const taskchefPackage = require("../package.json");
18
+ const PINNED_CCUSAGE_VERSION = taskchefPackage.optionalDependencies?.ccusage;
19
+ const CCUSAGE_RESOLVER = fileURLToPath(new URL("./resolve-ccusage.js", import.meta.url));
20
+ let npxResolvedCcusageInvocation = null;
8
21
  const USAGE_FILE_NAME = ".taskchef-usage.json";
9
22
  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
23
  const TOKEN_FIELDS = [
@@ -76,6 +89,13 @@ function normalizeProvenance(value, name, { includeSessionCount = false } = {})
76
89
  return {
77
90
  provider: "ccusage",
78
91
  version: typeof value.version === "string" ? value.version.slice(0, 64) : null,
92
+ pricingMode: value.pricingMode === "online" || value.pricingMode === "offline"
93
+ ? value.pricingMode
94
+ : null,
95
+ costCoverage: value.costCoverage === "cache_writes_unverified"
96
+ || value.costCoverage === "ccusage_reported"
97
+ ? value.costCoverage
98
+ : null,
79
99
  ...(includeSessionCount ? {
80
100
  sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
81
101
  } : {}),
@@ -107,6 +127,10 @@ function normalizeStoredSnapshot(value, name) {
107
127
  }
108
128
  snapshot.models = models;
109
129
  snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
130
+ if (snapshot.provenance.pricingMode === null || snapshot.provenance.costCoverage === null) {
131
+ snapshot.estimatedCostUsd = null;
132
+ snapshot.costStatus = "unavailable";
133
+ }
110
134
  snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
111
135
  snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
112
136
  return snapshot;
@@ -125,11 +149,15 @@ function normalizeStoredTurn(value, name) {
125
149
  normalized.estimatedCostUsd = value.estimatedCostUsd === null
126
150
  ? null
127
151
  : nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
152
+ const provenance = normalizeProvenance(value.provenance, name);
153
+ if (provenance.pricingMode === null || provenance.costCoverage === null) {
154
+ normalized.estimatedCostUsd = null;
155
+ }
128
156
  return {
129
157
  status: "available",
130
158
  ...normalized,
131
159
  costStatus: normalized.estimatedCostUsd === null ? "unavailable" : "estimated",
132
- provenance: normalizeProvenance(value.provenance, name),
160
+ provenance,
133
161
  sampledAt: timestampOrNull(value.sampledAt, `${name}.sampledAt`),
134
162
  sourceUpdatedAt: timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`),
135
163
  updatedAt,
@@ -196,6 +224,7 @@ function normalizeStoredRecord(value, name) {
196
224
  export function aggregateCcusageSessions(payload, threadId, {
197
225
  sampledAt = new Date().toISOString(),
198
226
  version = null,
227
+ pricingMode = null,
199
228
  } = {}) {
200
229
  if (!payload || typeof payload !== "object" || !Array.isArray(payload.sessions)) {
201
230
  throw new Error("ccusage output must contain a sessions array");
@@ -241,6 +270,10 @@ export function aggregateCcusageSessions(payload, threadId, {
241
270
  provenance: {
242
271
  provider: "ccusage",
243
272
  version,
273
+ pricingMode,
274
+ costCoverage: Object.keys(models).some((model) => /^gpt-5\.6(?:-|$)/i.test(model))
275
+ ? "cache_writes_unverified"
276
+ : "ccusage_reported",
244
277
  sessionCount: sessions.length,
245
278
  },
246
279
  sampledAt,
@@ -248,41 +281,312 @@ export function aggregateCcusageSessions(payload, threadId, {
248
281
  };
249
282
  }
250
283
 
251
- export async function readCcusageThreadUsage(threadId, {
252
- command = "ccusage",
253
- run = execFile,
254
- sampledAt = new Date().toISOString(),
255
- timeoutMs = 8_000,
284
+ export function managedCcusageInvocation({
285
+ platform = process.platform,
286
+ arch = process.arch,
287
+ env = process.env,
288
+ resolvePath = (id) => require.resolve(id),
289
+ exists = existsSync,
290
+ npxCliPath = null,
256
291
  } = {}) {
257
- let version = null;
292
+ if (typeof PINNED_CCUSAGE_VERSION !== "string" || PINNED_CCUSAGE_VERSION.length === 0) {
293
+ throw new Error("TaskChef has no pinned ccusage version");
294
+ }
295
+ const packageName = {
296
+ "darwin-arm64": "@ccusage/ccusage-darwin-arm64",
297
+ "darwin-x64": "@ccusage/ccusage-darwin-x64",
298
+ "linux-arm64": "@ccusage/ccusage-linux-arm64",
299
+ "linux-x64": "@ccusage/ccusage-linux-x64",
300
+ "win32-arm64": "@ccusage/ccusage-win32-arm64",
301
+ "win32-x64": "@ccusage/ccusage-win32-x64",
302
+ }[`${platform}-${arch}`];
303
+ let nativeBinary = null;
304
+ if (packageName !== undefined) {
305
+ try {
306
+ nativeBinary = resolvePath(
307
+ `${packageName}/bin/${platform === "win32" ? "ccusage.exe" : "ccusage"}`,
308
+ );
309
+ } catch {
310
+ // Plugin-only installations can resolve the same pinned release through npx.
311
+ }
312
+ }
313
+ if (nativeBinary !== null) {
314
+ return {
315
+ command: nativeBinary,
316
+ args: [],
317
+ version: PINNED_CCUSAGE_VERSION,
318
+ };
319
+ }
320
+ if (packageName === undefined) {
321
+ throw new Error(`ccusage does not support ${platform}-${arch}`);
322
+ }
323
+ const binaryName = platform === "win32" ? "ccusage.exe" : "ccusage";
324
+ const npxArgs = [
325
+ "--yes",
326
+ "--prefer-offline",
327
+ `--package=ccusage@${PINNED_CCUSAGE_VERSION}`,
328
+ "node",
329
+ CCUSAGE_RESOLVER,
330
+ packageName,
331
+ binaryName,
332
+ ];
333
+ if (platform === "win32") {
334
+ const platformPath = path.win32;
335
+ const nodeDirectory = platformPath.dirname(process.execPath);
336
+ const candidates = [
337
+ npxCliPath,
338
+ typeof env.npm_execpath === "string"
339
+ ? platformPath.join(platformPath.dirname(env.npm_execpath), "npx-cli.js")
340
+ : null,
341
+ platformPath.join(nodeDirectory, "node_modules", "npm", "bin", "npx-cli.js"),
342
+ platformPath.resolve(
343
+ nodeDirectory,
344
+ "..", "lib", "node_modules", "npm", "bin", "npx-cli.js",
345
+ ),
346
+ ];
347
+ const npxCli = candidates.find((candidate) => typeof candidate === "string"
348
+ && platformPath.isAbsolute(candidate)
349
+ && exists(candidate));
350
+ if (npxCli === undefined) throw new Error("TaskChef could not resolve npm's npx CLI safely");
351
+ return {
352
+ command: process.execPath,
353
+ args: [npxCli, ...npxArgs],
354
+ version: PINNED_CCUSAGE_VERSION,
355
+ resolvesNative: true,
356
+ };
357
+ }
358
+ return {
359
+ command: "npx",
360
+ args: npxArgs,
361
+ version: PINNED_CCUSAGE_VERSION,
362
+ resolvesNative: true,
363
+ };
364
+ }
365
+
366
+ export function ensureCcusageExecutable(command, {
367
+ platform = process.platform,
368
+ } = {}) {
369
+ if (platform === "win32") return;
370
+ const descriptor = openSync(command, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
258
371
  try {
259
- const result = await run(command, ["--version"], {
260
- timeout: Math.min(timeoutMs, 2_000),
261
- maxBuffer: 64 * 1024,
372
+ const file = fstatSync(descriptor);
373
+ if (!file.isFile()) throw new Error("ccusage native binary is not a regular file");
374
+ if ((file.mode & 0o100) === 0) fchmodSync(descriptor, file.mode | 0o100);
375
+ } finally {
376
+ closeSync(descriptor);
377
+ }
378
+ }
379
+
380
+ async function resolveManagedCcusageInvocation(invocation, run, timeoutMs) {
381
+ if (invocation.resolvesNative !== true) {
382
+ ensureCcusageExecutable(invocation.command);
383
+ return invocation;
384
+ }
385
+ if (npxResolvedCcusageInvocation !== null
386
+ && existsSync(npxResolvedCcusageInvocation.command)) {
387
+ ensureCcusageExecutable(npxResolvedCcusageInvocation.command);
388
+ return npxResolvedCcusageInvocation;
389
+ }
390
+ npxResolvedCcusageInvocation = null;
391
+ const result = await run(invocation.command, invocation.args, {
392
+ timeout: timeoutMs,
393
+ maxBuffer: 64 * 1024,
394
+ env: { ...process.env, NO_COLOR: "1" },
395
+ });
396
+ const command = String(result.stdout).trim();
397
+ if (!path.isAbsolute(command)
398
+ || !/^ccusage(?:\.exe)?$/iu.test(path.basename(command))
399
+ || !existsSync(command)) {
400
+ throw new Error("npx returned an invalid ccusage executable");
401
+ }
402
+ ensureCcusageExecutable(command);
403
+ npxResolvedCcusageInvocation = {
404
+ command,
405
+ args: [],
406
+ version: invocation.version,
407
+ };
408
+ return npxResolvedCcusageInvocation;
409
+ }
410
+
411
+ function processTable() {
412
+ return new Promise((resolve) => {
413
+ execFileCallback("ps", ["-axo", "pid=,ppid="], {
414
+ maxBuffer: 4 * 1024 * 1024,
415
+ timeout: 250,
416
+ windowsHide: true,
417
+ }, (error, stdout) => {
418
+ if (error) {
419
+ resolve([]);
420
+ return;
421
+ }
422
+ resolve(String(stdout).trim().split("\n").flatMap((line) => {
423
+ const [pid, parentPid] = line.trim().split(/\s+/u).map(Number);
424
+ return Number.isInteger(pid) && Number.isInteger(parentPid)
425
+ ? [{ pid, parentPid }]
426
+ : [];
427
+ }));
428
+ });
429
+ });
430
+ }
431
+
432
+ async function descendantProcessIds(parentPid) {
433
+ const table = await processTable();
434
+ const visit = (pid) => table
435
+ .filter((entry) => entry.parentPid === pid)
436
+ .flatMap((entry) => [...visit(entry.pid), entry.pid]);
437
+ return visit(parentPid);
438
+ }
439
+
440
+ async function terminateProcessTree(child, platform = process.platform) {
441
+ if (!Number.isInteger(child.pid)) return;
442
+ if (platform === "win32") {
443
+ await new Promise((resolve) => {
444
+ execFileCallback(
445
+ "taskkill",
446
+ ["/pid", String(child.pid), "/t", "/f"],
447
+ { timeout: 500, windowsHide: true },
448
+ () => {
449
+ child.kill("SIGKILL");
450
+ resolve();
451
+ },
452
+ );
262
453
  });
263
- version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
454
+ return;
455
+ }
456
+ try {
457
+ process.kill(-child.pid, "SIGKILL");
458
+ return;
264
459
  } catch {
265
- // Usage remains useful if an older compatible ccusage cannot print its version.
460
+ // Resolve descendants only when process-group termination is unavailable.
266
461
  }
267
- let result;
462
+ let descendants = [];
268
463
  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" },
464
+ descendants = await descendantProcessIds(child.pid);
465
+ } catch {
466
+ // The detached process-group kill remains the fallback when ps is unavailable.
467
+ }
468
+ for (const pid of descendants) {
469
+ try {
470
+ process.kill(pid, "SIGKILL");
471
+ } catch {
472
+ // The group kill or normal process exit may have won the race.
473
+ }
474
+ }
475
+ child.kill("SIGKILL");
476
+ }
477
+
478
+ export function runBoundedProcess(command, args, options = {}) {
479
+ const platform = options.platform ?? process.platform;
480
+ const timeout = options.timeout ?? 0;
481
+ const { platform: _platform, ...childOptions } = options;
482
+ return new Promise((resolve, reject) => {
483
+ let timedOut = false;
484
+ let timer = null;
485
+ let settled = false;
486
+ const rejectOnce = (error) => {
487
+ if (settled) return;
488
+ settled = true;
489
+ reject(error);
490
+ };
491
+ const child = execFileCallback(command, args, {
492
+ ...childOptions,
493
+ timeout: undefined,
494
+ detached: platform !== "win32",
495
+ windowsHide: true,
496
+ }, async (error, stdout, stderr) => {
497
+ if (timer !== null) clearTimeout(timer);
498
+ if (timedOut || settled) return;
499
+ if (error) {
500
+ await terminateProcessTree(child, platform);
501
+ rejectOnce(error);
502
+ return;
503
+ }
504
+ settled = true;
505
+ resolve({ stdout, stderr });
273
506
  });
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");
507
+ if (timeout > 0) {
508
+ timer = setTimeout(() => {
509
+ timedOut = true;
510
+ const timeoutError = new Error(`${command} timed out`);
511
+ timeoutError.code = "ETIMEDOUT";
512
+ timeoutError.killed = true;
513
+ Promise.race([
514
+ terminateProcessTree(child, platform),
515
+ new Promise((resolveTermination) => setTimeout(resolveTermination, 750)),
516
+ ]).catch(() => {}).then(() => rejectOnce(timeoutError));
517
+ }, timeout);
518
+ timer.unref();
519
+ }
520
+ });
521
+ }
522
+
523
+ export async function readCcusageThreadUsage(threadId, {
524
+ command = null,
525
+ commandArgs = [],
526
+ run = runBoundedProcess,
527
+ sampledAt = new Date().toISOString(),
528
+ timeoutMs = 8_000,
529
+ } = {}) {
530
+ const invocation = command === null
531
+ ? managedCcusageInvocation()
532
+ : { command, args: commandArgs, version: null };
533
+ const resolvedInvocation = command === null
534
+ ? await resolveManagedCcusageInvocation(invocation, run, timeoutMs)
535
+ : invocation;
536
+ const invoke = (args, options) => run(
537
+ resolvedInvocation.command,
538
+ [...resolvedInvocation.args, ...args],
539
+ options,
540
+ );
541
+ let version = resolvedInvocation.version;
542
+ if (version === null) {
543
+ try {
544
+ const result = await invoke(["--version"], {
545
+ timeout: Math.min(timeoutMs, 2_000),
546
+ maxBuffer: 64 * 1024,
547
+ });
548
+ version = String(result.stdout).trim().replace(/^ccusage\s+/i, "") || null;
549
+ } catch {
550
+ // Usage remains useful if a custom compatible ccusage cannot print its version.
551
+ }
278
552
  }
279
- let payload;
553
+ const report = async (pricingMode) => {
554
+ let result;
555
+ try {
556
+ result = await invoke([
557
+ "codex", "session", "--json",
558
+ pricingMode === "online" ? "--no-offline" : "--offline",
559
+ ], {
560
+ timeout: timeoutMs,
561
+ maxBuffer: 32 * 1024 * 1024,
562
+ env: { ...process.env, NO_COLOR: "1" },
563
+ });
564
+ } catch (error) {
565
+ if (error?.code === "ENOENT") throw new Error("ccusage is not installed");
566
+ if (error?.killed || error?.code === "ETIMEDOUT") throw new Error("ccusage timed out");
567
+ throw new Error("ccusage could not read Codex usage");
568
+ }
569
+ let payload;
570
+ try {
571
+ payload = JSON.parse(result.stdout);
572
+ } catch {
573
+ throw new Error("ccusage returned malformed JSON");
574
+ }
575
+ return aggregateCcusageSessions(payload, threadId, {
576
+ sampledAt,
577
+ version,
578
+ pricingMode,
579
+ });
580
+ };
280
581
  try {
281
- payload = JSON.parse(result.stdout);
282
- } catch {
283
- throw new Error("ccusage returned malformed JSON");
582
+ return await report("online");
583
+ } catch (onlineError) {
584
+ try {
585
+ return await report("offline");
586
+ } catch {
587
+ throw onlineError;
588
+ }
284
589
  }
285
- return aggregateCcusageSessions(payload, threadId, { sampledAt, version });
286
590
  }
287
591
 
288
592
  function usageFile(workspace) {
@@ -372,7 +676,10 @@ export function usageDelta(current, previous = null) {
372
676
  if (!Number.isFinite(value) || value < 0) return null;
373
677
  delta[field] = value;
374
678
  }
375
- let estimatedCostUsd = current.estimatedCostUsd === null
679
+ const samePricingSource = previous === null || ["version", "pricingMode", "costCoverage"]
680
+ .every((field) => current.provenance?.[field] === previous.provenance?.[field]);
681
+ let estimatedCostUsd = !samePricingSource
682
+ || current.estimatedCostUsd === null
376
683
  || (previous !== null && previous.estimatedCostUsd === null)
377
684
  ? null
378
685
  : current.estimatedCostUsd - (previous?.estimatedCostUsd ?? 0);