claude-usage-limits 1.6.0 → 1.7.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.
@@ -21,6 +21,28 @@ const os = require('os');
21
21
  const path = require('path');
22
22
  const readline = require('readline');
23
23
 
24
+ const host = require('./host.js');
25
+ const codex = require('./codex.js');
26
+
27
+ // Which agent's meter to read. Resolved once from the command line or the
28
+ // environment, because a process that changed its mind halfway through would
29
+ // mix one host's percentages with the other's turns.
30
+ let activeHost = null;
31
+
32
+ function currentHost() {
33
+ if (!activeHost) activeHost = host.detect(process.argv.slice(2), process.env);
34
+ return activeHost;
35
+ }
36
+
37
+ function setHost(name) {
38
+ activeHost = host.normalise(name) || host.CLAUDE;
39
+ return activeHost;
40
+ }
41
+
42
+ function isCodex() {
43
+ return currentHost() === host.CODEX;
44
+ }
45
+
24
46
  const MINUTE = 60 * 1000;
25
47
  const HOUR = 60 * MINUTE;
26
48
  const DAY = 24 * HOUR;
@@ -206,7 +228,7 @@ function tokensOf(usage) {
206
228
  // The four token classes, kept apart because they are priced differently
207
229
  // and because knowing the split is what makes the totals reasonable about.
208
230
  function tokenParts(usage) {
209
- if (!usage) return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
231
+ if (!usage) return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 };
210
232
  const creation = usage.cache_creation || {};
211
233
  const written =
212
234
  usage.cache_creation_input_tokens ||
@@ -216,6 +238,16 @@ function tokenParts(usage) {
216
238
  cacheWrite: written,
217
239
  cacheRead: usage.cache_read_input_tokens || 0,
218
240
  output: usage.output_tokens || 0,
241
+ // Reasoning is not a fifth class of token, it is a slice of the fourth.
242
+ // Measured over 1,565 turns of this account's transcripts, thinking never
243
+ // once exceeded output, so it is counted inside it and must not be added to
244
+ // any total: doing that would price every thinking turn twice.
245
+ //
246
+ // It is worth carrying separately all the same. Output is the dearest class
247
+ // there is, reasoning is about half of it, and it is the one part of the
248
+ // bill a setting can change. The skill has always said so; this is the
249
+ // number that says how much.
250
+ reasoning: (usage.output_tokens_details && usage.output_tokens_details.thinking_tokens) || 0,
219
251
  };
220
252
  }
221
253
 
@@ -233,6 +265,37 @@ function eventFrom(line, seen, project) {
233
265
  const at = Date.parse(entry.timestamp);
234
266
  if (!Number.isFinite(at)) return null;
235
267
 
268
+ // A request the limit refused is written like an assistant turn, with a
269
+ // synthetic model and a usage block of zeros. Two things follow.
270
+ //
271
+ // It is not a turn, and counting it as one dilutes the measured cost per turn
272
+ // with free ones, which makes the remaining headroom read longer than it is.
273
+ //
274
+ // And it is the only place the account says outright which window stopped the
275
+ // work and when that window comes back. The cached snapshot reports the
276
+ // 5-hour bucket as 0% with a null reset on this plan, so without reading
277
+ // these there is nothing at all to anchor that window to.
278
+ const quota = entry.quotaLimits;
279
+ if (entry.isApiErrorMessage && quota && typeof quota === 'object') {
280
+ const resetsAt = Number(quota.resetsAt);
281
+ return {
282
+ at,
283
+ model: '',
284
+ effort: null,
285
+ cost: 0,
286
+ tokens: 0,
287
+ parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 },
288
+ project: project || null,
289
+ sessionId: entry.sessionId || null,
290
+ rejected: {
291
+ status: typeof quota.status === 'string' ? quota.status : null,
292
+ key: typeof quota.rateLimitType === 'string' ? quota.rateLimitType : null,
293
+ // Seconds on the wire, milliseconds everywhere in here.
294
+ resetsAt: Number.isFinite(resetsAt) && resetsAt > 0 ? resetsAt * 1000 : null,
295
+ },
296
+ };
297
+ }
298
+
236
299
  // A resumed or forked session repeats earlier turns in a new file.
237
300
  const id = (entry.message.id || '') + '|' + (entry.requestId || '');
238
301
  if (id !== '|' && seen) {
@@ -253,6 +316,11 @@ function eventFrom(line, seen, project) {
253
316
  }
254
317
 
255
318
  async function readEvents(since) {
319
+ if (isCodex()) return codex.readEvents(since);
320
+ return readClaudeEvents(since);
321
+ }
322
+
323
+ async function readClaudeEvents(since) {
256
324
  const root = path.join(configDir(), 'projects');
257
325
  let dirs = [];
258
326
  try {
@@ -309,7 +377,7 @@ async function readEvents(since) {
309
377
  function totals(events) {
310
378
  let cost = 0;
311
379
  let tokens = 0;
312
- const parts = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
380
+ const parts = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 };
313
381
  for (const event of events) {
314
382
  cost += event.cost;
315
383
  tokens += event.tokens;
@@ -318,6 +386,7 @@ function totals(events) {
318
386
  parts.cacheWrite += event.parts.cacheWrite;
319
387
  parts.cacheRead += event.parts.cacheRead;
320
388
  parts.output += event.parts.output;
389
+ parts.reasoning += event.parts.reasoning || 0;
321
390
  }
322
391
  }
323
392
  return { cost, tokens, turns: events.length, parts };
@@ -335,7 +404,7 @@ function byModel(events) {
335
404
  turns: 0,
336
405
  tokens: 0,
337
406
  cost: 0,
338
- parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 },
407
+ parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 },
339
408
  });
340
409
  }
341
410
  const row = rows.get(id);
@@ -347,6 +416,7 @@ function byModel(events) {
347
416
  row.parts.cacheWrite += event.parts.cacheWrite;
348
417
  row.parts.cacheRead += event.parts.cacheRead;
349
418
  row.parts.output += event.parts.output;
419
+ row.parts.reasoning += event.parts.reasoning || 0;
350
420
  }
351
421
  }
352
422
  const list = [...rows.values()].sort((a, b) => b.cost - a.cost);
@@ -382,7 +452,22 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
382
452
  .filter((cost) => Number.isFinite(cost) && cost > 0)
383
453
  .sort((a, b) => a - b);
384
454
  if (!costs.length) return null;
385
- return costs[Math.floor(costs.length / 2)];
455
+
456
+ // The middle turn resists a freak one, which is the point, but it is the
457
+ // wrong statistic for counting how many more turns fit. Turn costs are
458
+ // skewed: most are cheap, a few are far dearer, and they get dearer still as
459
+ // the context grows. The remaining budget is divided by the *average*, so
460
+ // taking the middle one systematically promises more turns than there are.
461
+ // Measured on the window that ran out on 2026-08-30: 182 turns promised, 110
462
+ // actually left.
463
+ //
464
+ // Trimming both ends and averaging what is left keeps the resistance to a
465
+ // single $7 turn while respecting the skew, and errs toward under-promising,
466
+ // which is the safe direction for a budget.
467
+ const cut = costs.length >= MIN_PACE_SAMPLE ? Math.max(1, Math.round(costs.length * 0.1)) : 0;
468
+ const kept = cut > 0 ? costs.slice(cut, costs.length - cut) : costs;
469
+ const middle = kept.length ? kept : costs;
470
+ return middle.reduce((sum, cost) => sum + cost, 0) / middle.length;
386
471
  }
387
472
 
388
473
  function dominantEffort(events) {
@@ -398,6 +483,36 @@ function dominantEffort(events) {
398
483
  return best ? best[0] : null;
399
484
  }
400
485
 
486
+ // What the thinking actually cost, rather than what it is generally said to
487
+ // cost. Reasoning is billed as output, so it is priced at the output rate of
488
+ // whichever models did the thinking, weighted by how much each of them did.
489
+ //
490
+ // This deliberately does not try to tell an `ultrathink` turn from a high
491
+ // effort setting from a model that simply chose to think. They are the same
492
+ // spend and the same lever, and the transcript does not reliably separate them
493
+ // anyway. What matters is how much of the bill is reasoning.
494
+ function reasoningSpend(models, tokens) {
495
+ const total = tokens && Number.isFinite(tokens.reasoning) ? tokens.reasoning : 0;
496
+ const output = tokens && Number.isFinite(tokens.output) ? tokens.output : 0;
497
+ if (total <= 0 || output <= 0) return null;
498
+
499
+ let cost = 0;
500
+ let priced = 0;
501
+ for (const row of models || []) {
502
+ const amount = row.parts && Number.isFinite(row.parts.reasoning) ? row.parts.reasoning : 0;
503
+ if (amount <= 0) continue;
504
+ cost += (amount * rateFor(row.model).output) / 1e6;
505
+ priced += amount;
506
+ }
507
+
508
+ return {
509
+ tokens: total,
510
+ shareOfOutput: total / output,
511
+ // Only claim a price when the models that did the thinking were priced.
512
+ cost: priced > 0 ? cost : null,
513
+ };
514
+ }
515
+
401
516
  // Which project directory the spend went to. Claude Code names these after
402
517
  // the working directory, so they are recognisable even though the mangling
403
518
  // is not reversible.
@@ -532,6 +647,47 @@ function shareOf(sessions, sessionId) {
532
647
  return mine.share > 0 ? mine.share : 1 / sessions.length;
533
648
  }
534
649
 
650
+ // What a point of a window costs is a property of the plan, not of the moment,
651
+ // so it should be learned once from a good sample rather than re-derived from
652
+ // whatever slice happens to be to hand. A thin baseline prices a point badly
653
+ // and every correction built on it inherits the error: a 24 minute old
654
+ // snapshot once turned a window truly at 70 per cent into a confident 82.
655
+ // Kept beside whichever agent it describes. The two hosts happen to use the
656
+ // same window keys, so a shared file would price a Codex point with what a
657
+ // Claude point costs and be wrong on both.
658
+ function calibrationFile() {
659
+ const dir = isCodex() ? codex.homeDir() : configDir();
660
+ return path.join(dir, 'usage-limits-calibration.json');
661
+ }
662
+
663
+ function readCalibration() {
664
+ try {
665
+ const parsed = JSON.parse(fs.readFileSync(calibrationFile(), 'utf8'));
666
+ return parsed && typeof parsed === 'object' ? parsed : {};
667
+ } catch (err) {
668
+ return {};
669
+ }
670
+ }
671
+
672
+ function writeCalibration(all) {
673
+ try {
674
+ fs.mkdirSync(path.dirname(calibrationFile()), { recursive: true });
675
+ fs.writeFileSync(calibrationFile(), JSON.stringify(all), 'utf8');
676
+ } catch (err) {
677
+ // Losing it costs accuracy on the next thin baseline, nothing more.
678
+ }
679
+ }
680
+
681
+ // A sample is better when it rests on more turns. Percentages read in whole
682
+ // numbers, so a bigger percentage also divides more precisely.
683
+ function betterCalibration(current, candidate) {
684
+ if (!candidate || !Number.isFinite(candidate.usdPerPercent) || candidate.usdPerPercent <= 0) {
685
+ return current || null;
686
+ }
687
+ if (!current || !Number.isFinite(current.turns)) return candidate;
688
+ return candidate.turns > current.turns ? candidate : current;
689
+ }
690
+
535
691
  // Everything the report needs about one limit window.
536
692
  function buildWindow(spec, snapshot, events, now, options) {
537
693
  const extra = options || {};
@@ -584,6 +740,8 @@ function buildWindow(spec, snapshot, events, now, options) {
584
740
  pointsSinceSnapshot: 0,
585
741
  // Set when spend since the snapshot could not be priced sensibly.
586
742
  correctionUnreliable: false,
743
+ // The price-per-point this window derived from its own baseline.
744
+ calibration: null,
587
745
  verdict: 'unknown',
588
746
  };
589
747
 
@@ -601,11 +759,18 @@ function buildWindow(spec, snapshot, events, now, options) {
601
759
  // was truly at 88% was reported at 49%. Calibrate on spend up to the reading
602
760
  // only, or the very spend being accounted for inflates the price per point
603
761
  // and shrinks its own correction.
762
+ // A reading with no reset time cannot be told apart from a current one by
763
+ // looking at it, so age is the only guide. Once the snapshot is older than
764
+ // the window itself, whatever it says describes a window that has since
765
+ // rolled over at least once, and quoting it as current is how a long gap
766
+ // ends up reported as a full budget.
767
+ window.snapshotOlderThanWindow =
768
+ Number.isFinite(extra.fetchedAt) && now - extra.fetchedAt >= spec.span;
769
+
604
770
  let percent = rawPercent;
605
771
  let sinceSnapshot = 0;
606
772
  if (
607
773
  rawPercent !== null &&
608
- rawPercent > 0 &&
609
774
  !window.stale &&
610
775
  Number.isFinite(extra.fetchedAt) &&
611
776
  extra.fetchedAt > start
@@ -617,8 +782,30 @@ function buildWindow(spec, snapshot, events, now, options) {
617
782
  // turns makes it far too cheap, and every dollar spent since then is then
618
783
  // divided by that, which is how a window truly at 55% got corrected all the
619
784
  // way to a confident 100.
620
- if (upTo.cost > 0 && after.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS) {
621
- const pricePerPoint = upTo.cost / rawPercent;
785
+ //
786
+ // A reading of exactly 0 cannot price itself at all: there is no meter
787
+ // movement to divide the spend by. That used to end the correction here,
788
+ // which was the worst place to stop, because 0% is what a window reads
789
+ // right after a reset and therefore what a snapshot most often goes stale
790
+ // holding. The learned price covers it: what a point costs is a property
791
+ // of the plan, not of this reading.
792
+ const selfPriced =
793
+ rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
794
+ ? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
795
+ : null;
796
+ if (selfPriced) window.calibration = selfPriced;
797
+
798
+ const known = extra.knownCalibration;
799
+ const usable =
800
+ known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
801
+ // Trust the better-sampled of the two, whichever that is.
802
+ const chosen =
803
+ selfPriced && usable
804
+ ? (usable.turns > selfPriced.turns ? usable : selfPriced)
805
+ : selfPriced || usable;
806
+
807
+ if (after.cost > 0 && chosen) {
808
+ const pricePerPoint = chosen.usdPerPercent;
622
809
  sinceSnapshot = after.cost / pricePerPoint;
623
810
 
624
811
  // Same rule as a rebuild: past this it is the calibration that is full,
@@ -637,13 +824,27 @@ function buildWindow(spec, snapshot, events, now, options) {
637
824
  }
638
825
  }
639
826
 
640
- const derived = percent !== null && percent > 0 && spent.cost > 0 ? spent.cost / percent : null;
827
+ // Pricing a point off this window's own spend only works when this machine
828
+ // did most of that spending. It often has not: another device, a cloud task,
829
+ // or simply a window that opened before the local history did, and then a
830
+ // meter reading 61% divides by almost nothing and every remaining point looks
831
+ // free. The visible symptom is a window with plenty left reporting one turn
832
+ // of headroom, which is worse advice than reporting none.
833
+ // A couple of turns cannot account for a meter already well into the window,
834
+ // so when the two disagree that badly it is the local history that is
835
+ // incomplete, not the meter.
836
+ const thin = spent.turns < MIN_BASELINE_TURNS && percent >= UNEXPLAINED_PERCENT;
837
+ const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
838
+ const derived = measured ? spent.cost / percent : null;
839
+ const known = extra.knownCalibration;
641
840
  const priced =
642
841
  derived !== null
643
842
  ? derived
644
843
  : Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
645
844
  ? extra.usdPerPercent
646
- : null;
845
+ : known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0
846
+ ? known.usdPerPercent
847
+ : null;
647
848
 
648
849
  if (percent !== null && priced !== null) {
649
850
  window.usdPerPercent = priced;
@@ -685,6 +886,23 @@ function buildWindow(spec, snapshot, events, now, options) {
685
886
  return window;
686
887
  }
687
888
 
889
+ // The most recent time the account actually refused work, per window. This is
890
+ // ground truth: not an estimate of where the budget stands but a record of it
891
+ // having run out, with the window named and its reset time attached.
892
+ function lastRejections(events) {
893
+ const byKey = new Map();
894
+ for (const event of events || []) {
895
+ const rejected = event && event.rejected;
896
+ if (!rejected || rejected.status !== 'rejected') continue;
897
+ const key = rejected.key || 'unknown';
898
+ const seen = byKey.get(key);
899
+ if (!seen || event.at > seen.at) {
900
+ byKey.set(key, { key, at: event.at, resetsAt: rejected.resetsAt });
901
+ }
902
+ }
903
+ return byKey;
904
+ }
905
+
688
906
  // Which window binds is about what stops you soonest. It says nothing about
689
907
  // what stopping costs. Running out of a 5-hour window waits hours; running out
690
908
  // of the weekly one waits days. So a weekly window near the wall is worth
@@ -806,7 +1024,40 @@ function detectPlan(oauth) {
806
1024
  };
807
1025
  }
808
1026
 
1027
+ // The snapshot carries buckets this table has never heard of, and it gains more
1028
+ // over time: alongside five_hour and seven_day there are per-product and
1029
+ // codenamed limits that come and go. Dropping them silently means a limit that
1030
+ // is actually biting never gets mentioned, so anything carrying real spend is
1031
+ // reported even though there is no span to price it against.
1032
+ const KNOWN_KEYS = new Set(['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet']);
1033
+ const NOT_A_WINDOW = new Set(['extra_usage', 'spend', 'limits', 'member_dashboard_available']);
1034
+
1035
+ function otherLimits(utilization, threshold) {
1036
+ if (!utilization) return [];
1037
+ const floor = Number.isFinite(threshold) ? threshold : 1;
1038
+ const rows = [];
1039
+ for (const key of Object.keys(utilization)) {
1040
+ if (KNOWN_KEYS.has(key) || NOT_A_WINDOW.has(key)) continue;
1041
+ const bucket = utilization[key];
1042
+ if (!bucket || typeof bucket.utilization !== 'number') continue;
1043
+ if (bucket.utilization < floor) continue;
1044
+ const resetsAt = bucket.resets_at ? Date.parse(bucket.resets_at) : null;
1045
+ rows.push({
1046
+ key,
1047
+ label: key.replace(/_/g, ' '),
1048
+ percentUsed: bucket.utilization,
1049
+ resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
1050
+ });
1051
+ }
1052
+ return rows.sort((a, b) => b.percentUsed - a.percentUsed);
1053
+ }
1054
+
809
1055
  function collect(now) {
1056
+ if (isCodex()) return codex.collect(now);
1057
+ return collectClaude(now);
1058
+ }
1059
+
1060
+ function collectClaude(now) {
810
1061
  const account = readJson(accountFile()) || {};
811
1062
  const settings = readJson(path.join(configDir(), 'settings.json')) || {};
812
1063
  const cache = account.cachedUsageUtilization || null;
@@ -816,6 +1067,8 @@ function collect(now) {
816
1067
 
817
1068
  return {
818
1069
  now,
1070
+ host: host.CLAUDE,
1071
+ money: true,
819
1072
  accountFile: accountFile(),
820
1073
  plan: plan.label,
821
1074
  planId: plan.id,
@@ -847,6 +1100,10 @@ const SATURATION_LIMIT = 105;
847
1100
  // Fewer turns than this before the snapshot and a point cannot be priced.
848
1101
  const MIN_BASELINE_TURNS = 5;
849
1102
 
1103
+ // Past this much of a window, a handful of local turns is not what spent it,
1104
+ // so their total is not a fair price for a point.
1105
+ const UNEXPLAINED_PERCENT = 20;
1106
+
850
1107
  function reconstructWindow(spec, snapshot, events, now) {
851
1108
  if (!snapshot || typeof snapshot.utilization !== 'number') return null;
852
1109
  if (snapshot.utilization <= 0) return null;
@@ -885,17 +1142,90 @@ function reconstructWindow(spec, snapshot, events, now) {
885
1142
  };
886
1143
  }
887
1144
 
1145
+ // The snapshot can be older than the window it describes without ever looking
1146
+ // stale, because a window with no reset time has nothing to compare against.
1147
+ // That is the ordinary case after a long gap: the 5-hour reading was taken
1148
+ // seven hours ago and says 0%, the window running now started two hours ago,
1149
+ // and the reading is about a window that no longer exists. Adding spend to it
1150
+ // is not the fix, because none of that spend is inside the window it describes.
1151
+ // Rebuilding is: the learned price per point turns this window's own spend
1152
+ // straight into a percentage.
1153
+ function reconstructUnanchored(spec, window, events, now, learned) {
1154
+ if (!window || window.stale) return null;
1155
+ if (!window.snapshotOlderThanWindow) return null;
1156
+ // Only worth doing when the reading is low enough to be the thing that is
1157
+ // wrong. A high reading that is old is already alarming and is left alone.
1158
+ if (window.percentUsed === null || window.percentUsed > 5) return null;
1159
+ if (!learned || !Number.isFinite(learned.usdPerPercent) || learned.usdPerPercent <= 0) {
1160
+ return null;
1161
+ }
1162
+
1163
+ const start = window.windowStart;
1164
+ const live = totals(events.filter((event) => event.at >= start && event.at <= now));
1165
+ if (live.cost <= 0) return null;
1166
+
1167
+ const raw = live.cost / learned.usdPerPercent;
1168
+ if (raw < 1) return null;
1169
+ // Same rule as every other rebuild: past this it is the calibration that is
1170
+ // full rather than the window, and claiming a spent budget is worse than
1171
+ // admitting the reading could not be rebuilt.
1172
+ if (raw > SATURATION_LIMIT) return null;
1173
+
1174
+ return buildWindow(
1175
+ spec,
1176
+ { utilization: Math.min(100, Math.round(raw)), resets_at: null },
1177
+ events,
1178
+ now,
1179
+ { estimated: true, windowStart: start, usdPerPercent: learned.usdPerPercent }
1180
+ );
1181
+ }
1182
+
888
1183
  // No snapshot at all means no windows, which is what tells the report to
889
1184
  // explain itself rather than print a table of dashes.
890
- function buildWindows(utilization, events, now, fetchedAt) {
1185
+ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejections) {
891
1186
  if (!utilization) return [];
892
- return WINDOWS.map((spec) => {
1187
+ const refused = rejections || new Map();
1188
+ // Claude Code's windows are fixed and known. Codex reports the length of each
1189
+ // of its two windows in the payload, so it hands its own spans in rather than
1190
+ // having them assumed.
1191
+ const table = specs && specs.length ? specs : WINDOWS;
1192
+ return table.map((spec) => {
893
1193
  const snapshot = utilization[spec.key];
894
1194
  // The per-model weekly windows only exist on some plans.
895
1195
  if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
896
1196
 
897
- const window = buildWindow(spec, snapshot, events, now, { fetchedAt });
898
- if (!window.stale) return window;
1197
+ const known = learned ? learned[spec.key] : null;
1198
+ const refusal = refused.get(spec.key);
1199
+
1200
+ // With no reset time the window has to be treated as rolling, which starts
1201
+ // it five hours ago and sweeps in whatever the window before it spent. When
1202
+ // the account has refused work on this window, its reset time is known
1203
+ // exactly: a refusal in the future is this window's own reset, and one in
1204
+ // the past is the moment the window running now began.
1205
+ let anchored = snapshot;
1206
+ let windowStart;
1207
+ if (snapshot && !snapshot.resets_at && refusal && Number.isFinite(refusal.resetsAt)) {
1208
+ if (refusal.resetsAt > now) {
1209
+ anchored = Object.assign({}, snapshot, {
1210
+ resets_at: new Date(refusal.resetsAt).toISOString(),
1211
+ });
1212
+ } else if (now - refusal.resetsAt < spec.span) {
1213
+ windowStart = refusal.resetsAt;
1214
+ }
1215
+ }
1216
+
1217
+ const window = buildWindow(spec, anchored, events, now, {
1218
+ fetchedAt,
1219
+ knownCalibration: known,
1220
+ ...(windowStart === undefined ? {} : { windowStart }),
1221
+ });
1222
+ if (refusal) {
1223
+ window.refusedAt = refusal.at;
1224
+ window.refusedResetsAt = refusal.resetsAt;
1225
+ }
1226
+ if (!window.stale) {
1227
+ return reconstructUnanchored(spec, window, events, now, known) || window;
1228
+ }
899
1229
 
900
1230
  // Rolled over. Rebuild from local history rather than going blind on it.
901
1231
  const rebuilt = reconstructWindow(spec, snapshot, events, now);
@@ -928,13 +1258,64 @@ function sessionSpend(events, sessionId) {
928
1258
  }
929
1259
 
930
1260
  async function report(now, options) {
931
- const base = collect(now);
1261
+ let base = collect(now);
932
1262
  // A stale snapshot can put a window's start slightly further back than
933
1263
  // seven days, so give the scan a day of slack.
934
1264
  const earliest = now - 8 * DAY;
935
- const events = await readEvents(earliest);
1265
+ const all = await readEvents(earliest);
1266
+ // A refused request is a record of the limit, not a turn against it, so it is
1267
+ // kept apart from everything that measures spend or pace.
1268
+ const rejections = lastRejections(all);
1269
+ const events = all.filter((event) => !event.rejected);
1270
+
1271
+ // Codex carries the meter inside the same records as the turns, so the full
1272
+ // scan can find a newer reading than the quick one collect() does. Reuse it
1273
+ // rather than scanning twice or reporting the older of the two.
1274
+ if (isCodex()) {
1275
+ const live = options && options.codexMeter;
1276
+ const latest = live || codex.latestMeter(events);
1277
+ if (latest && (live || !base.snapshotFetchedAt || latest.at > base.snapshotFetchedAt)) {
1278
+ base = codex.collect(now, { meter: latest });
1279
+ }
1280
+ }
1281
+
1282
+ const onDisk = readCalibration();
1283
+ const learned = Object.assign({}, onDisk);
1284
+
1285
+ // Codex logs the meter next to every request, so the price of a point can be
1286
+ // measured outright instead of inferred. A measurement from this session
1287
+ // beats anything remembered from an earlier one.
1288
+ if (isCodex()) {
1289
+ for (const spec of base.windowSpecs || []) {
1290
+ const measured = codex.calibrate(events, spec.key, now);
1291
+ if (measured) learned[spec.key] = betterCalibration(learned[spec.key], measured);
1292
+ }
1293
+ }
1294
+
1295
+ const windows = buildWindows(
1296
+ base.utilization,
1297
+ events,
1298
+ now,
1299
+ base.snapshotFetchedAt,
1300
+ learned,
1301
+ base.windowSpecs,
1302
+ rejections
1303
+ );
936
1304
 
937
- const windows = buildWindows(base.utilization, events, now, base.snapshotFetchedAt);
1305
+ // Keep the best sample seen so far, so a thin baseline never has to guess.
1306
+ const updated = Object.assign({}, learned);
1307
+ for (const window of windows) {
1308
+ if (!window.calibration) continue;
1309
+ const best = betterCalibration(updated[window.key], window.calibration);
1310
+ if (best) updated[window.key] = best;
1311
+ }
1312
+ // Compared against what is actually on disk, so a measurement taken during
1313
+ // this run is saved too rather than only the ones inferred from a window.
1314
+ let changed = false;
1315
+ for (const key of Object.keys(updated)) {
1316
+ if (updated[key] !== onDisk[key]) changed = true;
1317
+ }
1318
+ if (changed) writeCalibration(updated);
938
1319
 
939
1320
  const recentEvents = events.filter((event) => event.at >= now - HOUR);
940
1321
  const recent = totals(recentEvents);
@@ -943,19 +1324,28 @@ async function report(now, options) {
943
1324
  const scopeStart = binding ? binding.windowStart : now - 7 * DAY;
944
1325
  const scoped = events.filter((event) => event.at >= scopeStart && event.at <= now);
945
1326
  const scopedTotals = totals(scoped);
1327
+ // Worked out once and used twice: the table of models, and the price of the
1328
+ // reasoning inside it.
1329
+ const scopedModels = byModel(scoped);
946
1330
 
947
1331
  return Object.assign({}, base, {
948
1332
  windows,
949
1333
  binding,
950
- credits: creditsFrom(base.utilization),
1334
+ otherLimits: otherLimits(base.utilization),
1335
+ // The last time the account actually refused work, so a report taken just
1336
+ // after a cutoff says so rather than describing the fresh window as though
1337
+ // nothing happened.
1338
+ lastRefusal: [...rejections.values()].sort((a, b) => b.at - a.at)[0] || null,
1339
+ credits: base.codexCredits || creditsFrom(base.utilization),
951
1340
  sessions: activeSessions(events, now, CONCURRENT_WINDOW_MS),
952
1341
  session: sessionSpend(events, options && options.sessionId),
953
1342
  staleWindows: windows.filter((w) => w.stale).length,
954
1343
  rates: costPercentiles(recentEvents.length >= 5 ? recentEvents : scoped),
955
1344
  resumeAt: binding ? binding.resetsAt : null,
956
- models: byModel(scoped),
1345
+ models: scopedModels,
957
1346
  projects: byProject(scoped),
958
1347
  tokens: scopedTotals.parts,
1348
+ reasoning: reasoningSpend(scopedModels, scopedTotals.parts),
959
1349
  scopeLabel: binding ? binding.label : 'last 7 days',
960
1350
  recent: {
961
1351
  turns: recent.turns,
@@ -1019,7 +1409,9 @@ function statusLine(collected) {
1019
1409
 
1020
1410
  const now = collected.now || Date.now();
1021
1411
  const parts = [];
1022
- for (const spec of WINDOWS) {
1412
+ for (const spec of collected.windowSpecs && collected.windowSpecs.length
1413
+ ? collected.windowSpecs
1414
+ : WINDOWS) {
1023
1415
  const snapshot = utilization[spec.key];
1024
1416
  if (!snapshot || typeof snapshot.utilization !== 'number') continue;
1025
1417
  const resetsAt = snapshot.resets_at ? Date.parse(snapshot.resets_at) : null;
@@ -1029,11 +1421,16 @@ function statusLine(collected) {
1029
1421
  percent: snapshot.utilization,
1030
1422
  msToReset,
1031
1423
  stale: msToReset !== null && msToReset <= 0,
1424
+ // Zero with no reset time is not an empty window, it is a bucket that is
1425
+ // not reporting: a real window at 0% has just reset and says when it will
1426
+ // do so again. The status line cannot scan transcripts to find out which,
1427
+ // so it must not print the flattering reading as though it were measured.
1428
+ unreported: snapshot.utilization === 0 && !Number.isFinite(resetsAt),
1032
1429
  });
1033
1430
  }
1034
1431
  if (!parts.length) return '';
1035
1432
 
1036
- const trusted = parts.filter((part) => !part.stale);
1433
+ const trusted = parts.filter((part) => !part.stale && !part.unreported);
1037
1434
  const worst = trusted.length
1038
1435
  ? trusted.reduce((a, b) => (b.percent > a.percent ? b : a))
1039
1436
  : null;
@@ -1041,8 +1438,10 @@ function statusLine(collected) {
1041
1438
  .map((part) =>
1042
1439
  part.stale
1043
1440
  ? part.label + ' rolling'
1044
- : part.label + ' ' + part.percent + '%' +
1045
- (part.msToReset === null ? '' : ' ' + formatDuration(part.msToReset))
1441
+ : part.unreported
1442
+ ? part.label + ' ?'
1443
+ : part.label + ' ' + part.percent + '%' +
1444
+ (part.msToReset === null ? '' : ' ' + formatDuration(part.msToReset))
1046
1445
  )
1047
1446
  .join(' ');
1048
1447
 
@@ -1051,7 +1450,11 @@ function statusLine(collected) {
1051
1450
 
1052
1451
  function render(data) {
1053
1452
  const lines = [];
1054
- lines.push('Claude Code usage');
1453
+ // Codex meters an allowance and never quotes a price, so its report has no
1454
+ // honest money column. Everything else in the table means the same thing on
1455
+ // both hosts.
1456
+ const money = data.money !== false;
1457
+ lines.push((data.host === host.CODEX ? 'Codex usage' : 'Claude Code usage'));
1055
1458
  lines.push('');
1056
1459
  lines.push(' Plan ' + data.plan);
1057
1460
  lines.push(
@@ -1060,9 +1463,13 @@ function render(data) {
1060
1463
  );
1061
1464
  lines.push(' Settings model=' + data.settings.model + ' effort=' + data.settings.effortLevel);
1062
1465
  const credits = data.credits;
1063
- if (credits) {
1466
+ if (credits && credits.unlimited) {
1467
+ lines.push(' Credits unlimited');
1468
+ } else if (credits) {
1064
1469
  if (!credits.enabled) {
1065
1470
  lines.push(' Credits off, work stops when the plan allowance runs out');
1471
+ } else if (!money) {
1472
+ lines.push(' Credits on, balance ' + (credits.balance === null ? 'unknown' : credits.balance));
1066
1473
  } else {
1067
1474
  const amounts =
1068
1475
  credits.used === null
@@ -1079,14 +1486,31 @@ function render(data) {
1079
1486
  lines.push('');
1080
1487
 
1081
1488
  if (!data.windows.length) {
1489
+ // Two different situations, and telling them apart matters. Nothing to read
1490
+ // is a setup problem. Nothing to report is the correct answer on a plan
1491
+ // whose usage scales with credits rather than resetting on a clock.
1492
+ if (data.windowless) {
1493
+ lines.push(' This account reports no rolling usage window.');
1494
+ lines.push(' On flexible pricing there is no percentage to run down: usage scales');
1495
+ lines.push(' with credits, so the credit balance above is the budget to plan against.');
1496
+ if (data.planAdvice) {
1497
+ lines.push('');
1498
+ lines.push(data.planAdvice);
1499
+ }
1500
+ return lines.join('\n');
1501
+ }
1082
1502
  lines.push(' No usage snapshot in ' + (data.accountFile || '~/.claude.json') + '.');
1083
- lines.push(' Run /usage once inside Claude Code to populate it, then try again.');
1503
+ lines.push(
1504
+ data.host === host.CODEX
1505
+ ? ' Run a Codex turn once so it writes one, or --refresh to ask for it now.'
1506
+ : ' Run /usage once inside Claude Code to populate it, then try again.'
1507
+ );
1084
1508
  return lines.join('\n');
1085
1509
  }
1086
1510
 
1087
1511
  lines.push(
1088
1512
  ' ' + pad('Window', 15) + padLeft('Used', 6) + padLeft('Resets in', 12) +
1089
- padLeft('Left', 10) + padLeft('Turns left', 12)
1513
+ (money ? padLeft('Left', 10) : '') + padLeft('Turns left', 12)
1090
1514
  );
1091
1515
  for (const window of data.windows) {
1092
1516
  const marker = data.binding && window.key === data.binding.key ? ' <- binding' : '';
@@ -1101,13 +1525,30 @@ function render(data) {
1101
1525
  6
1102
1526
  ) +
1103
1527
  padLeft(formatDuration(window.msToReset), 12) +
1104
- padLeft(formatUSD(window.remainingUSD), 10) +
1528
+ (money ? padLeft(formatUSD(window.remainingUSD), 10) : '') +
1105
1529
  padLeft(window.turnsLeft === null ? '-' : '~' + formatCount(window.turnsLeft), 12) +
1106
1530
  marker
1107
1531
  );
1108
1532
  }
1109
1533
  lines.push('');
1110
1534
 
1535
+ // A bucket with no span cannot be priced or projected, but saying nothing
1536
+ // about one that is nearly full would be the worse failure.
1537
+ if (data.otherLimits && data.otherLimits.length) {
1538
+ lines.push(' Other limits reported by the account');
1539
+ for (const row of data.otherLimits) {
1540
+ lines.push(
1541
+ ' ' + pad(' ' + row.label, 24) + padLeft(row.percentUsed + '%', 6) +
1542
+ padLeft(
1543
+ Number.isFinite(row.resetsAt) ? formatDuration(row.resetsAt - data.now) : '-',
1544
+ 12
1545
+ )
1546
+ );
1547
+ }
1548
+ lines.push(' No window length is reported for these, so they are not projected.');
1549
+ lines.push('');
1550
+ }
1551
+
1111
1552
  if (data.models && data.models.length) {
1112
1553
  lines.push(' Models in the ' + (data.scopeLabel || 'window') + ' window');
1113
1554
  lines.push(
@@ -1116,14 +1557,15 @@ function render(data) {
1116
1557
  );
1117
1558
  for (const row of data.models) {
1118
1559
  lines.push(
1119
- ' ' + pad(' ' + row.model + (row.estimated ? ' *' : ''), 24) +
1560
+ ' ' + pad(' ' + row.model + (money && row.estimated ? ' *' : ''), 24) +
1120
1561
  padLeft(row.turns, 7) +
1121
1562
  padLeft(formatTokens(row.tokens), 10) +
1122
1563
  padLeft(formatTokens(row.parts.output), 9) +
1123
1564
  padLeft(Math.round(row.share * 100) + '%', 8)
1124
1565
  );
1125
1566
  }
1126
- if (data.models.some((row) => row.estimated)) {
1567
+ // The footnote is about the price table, which only the Claude reader uses.
1568
+ if (money && data.models.some((row) => row.estimated)) {
1127
1569
  lines.push(' * no published rate for this one yet, priced at the family average');
1128
1570
  }
1129
1571
  if (data.tokens) {
@@ -1133,6 +1575,19 @@ function render(data) {
1133
1575
  ', cache read ' + formatTokens(data.tokens.cacheRead) +
1134
1576
  ', output ' + formatTokens(data.tokens.output)
1135
1577
  );
1578
+ // Output is the dearest class and reasoning is usually about half of it,
1579
+ // which makes this the largest number on the report that a setting can
1580
+ // actually move. Saying so without measuring it was advice; this is the
1581
+ // measurement.
1582
+ const reasoning = data.reasoning;
1583
+ if (reasoning && reasoning.tokens > 0) {
1584
+ lines.push(
1585
+ ' Of that output, ' + formatTokens(reasoning.tokens) + ' was reasoning (' +
1586
+ Math.round(reasoning.shareOfOutput * 100) + '%' +
1587
+ (money && reasoning.cost !== null ? ', about ' + formatUSD(reasoning.cost) : '') +
1588
+ '), the part effort controls.'
1589
+ );
1590
+ }
1136
1591
  }
1137
1592
  lines.push('');
1138
1593
  }
@@ -1166,8 +1621,8 @@ function render(data) {
1166
1621
 
1167
1622
  if (data.recent.turns) {
1168
1623
  lines.push(
1169
- ' Recent pace ' + data.recent.turns + ' turns in the last hour, ' +
1170
- formatUSD(data.recent.usdPerTurn) + ' per turn' +
1624
+ ' Recent pace ' + data.recent.turns + ' turns in the last hour' +
1625
+ (money ? ', ' + formatUSD(data.recent.usdPerTurn) + ' per turn' : '') +
1171
1626
  (data.recent.effort ? ', effort ' + data.recent.effort : '')
1172
1627
  );
1173
1628
  } else {
@@ -1197,6 +1652,34 @@ function render(data) {
1197
1652
  lines.push(' Note the meter reads in whole percent, so a low reading is a wide bracket');
1198
1653
  }
1199
1654
 
1655
+ // A report taken shortly after a cutoff should say so. The percentages
1656
+ // describe the window running now and look perfectly healthy, which is
1657
+ // exactly why the fact that work was stopped an hour ago has to be stated
1658
+ // rather than left to be inferred from a number that no longer shows it.
1659
+ if (data.lastRefusal && Number.isFinite(data.lastRefusal.at) &&
1660
+ data.now - data.lastRefusal.at < 12 * HOUR) {
1661
+ const window = data.windows.find((one) => one.key === data.lastRefusal.key);
1662
+ lines.push(
1663
+ ' Cut off ' + formatDuration(data.now - data.lastRefusal.at) + ' ago the ' +
1664
+ ((window && window.label) || data.lastRefusal.key) + ' limit refused work' +
1665
+ (Number.isFinite(data.lastRefusal.resetsAt)
1666
+ ? ', and came back at ' + formatClock(data.lastRefusal.resetsAt)
1667
+ : '')
1668
+ );
1669
+ }
1670
+
1671
+ if (data.windows.some((window) => window.snapshotOlderThanWindow && !window.stale)) {
1672
+ lines.push(
1673
+ ' Note the snapshot is older than one of these windows, so its reading'
1674
+ );
1675
+ lines.push(
1676
+ ' describes a window that has since rolled over. ' +
1677
+ (data.host === host.CODEX
1678
+ ? 'Run --refresh for a live one.'
1679
+ : 'Run /usage for a fresh one.')
1680
+ );
1681
+ }
1682
+
1200
1683
  lines.push('');
1201
1684
  lines.push(verdictLine(data.binding));
1202
1685
  const binding = data.binding;
@@ -1273,9 +1756,11 @@ function renderForecast(data, turns) {
1273
1756
  }
1274
1757
  lines.push('');
1275
1758
  lines.push(
1276
- ' Priced from ' + data.rates.sample + ' recent turns: ' +
1277
- formatUSD(data.rates.median) + ' typical, ' + formatUSD(data.rates.high) +
1278
- ' at the expensive end.'
1759
+ data.money === false
1760
+ ? ' Priced from ' + data.rates.sample + ' recent turns, cheapest to dearest.'
1761
+ : ' Priced from ' + data.rates.sample + ' recent turns: ' +
1762
+ formatUSD(data.rates.median) + ' typical, ' + formatUSD(data.rates.high) +
1763
+ ' at the expensive end.'
1279
1764
  );
1280
1765
 
1281
1766
  const blocked = rows.filter((row) => !row.fits);
@@ -1291,6 +1776,15 @@ function renderForecast(data, turns) {
1291
1776
  ' It fits, but only if nothing goes wrong. Order the work so the valuable ' +
1292
1777
  'part lands first.'
1293
1778
  );
1779
+ } else if (data.sessions && data.sessions.length > 1) {
1780
+ // The percentages say it fits, and they would be right if this were the
1781
+ // only thing spending. It is not: the budget drains while these turns run,
1782
+ // so a verdict of "room for this" on its own is the one that gets someone
1783
+ // cut off mid-job.
1784
+ lines.push(
1785
+ ' It fits on its own, but ' + data.sessions.length + ' sessions are spending this ' +
1786
+ 'budget at once, so it will be gone sooner than these figures alone suggest.'
1787
+ );
1294
1788
  } else {
1295
1789
  lines.push(' There is room for this. No need to work around the limit.');
1296
1790
  }
@@ -1303,14 +1797,37 @@ function renderForecast(data, turns) {
1303
1797
  }
1304
1798
 
1305
1799
  async function main(argv) {
1800
+ // Settle the host before anything reads a file, so one run never mixes one
1801
+ // agent's percentages with the other's turns.
1802
+ setHost(host.detect(argv, process.env));
1803
+
1306
1804
  // The status line runs on every redraw, so it must not scan transcripts.
1307
1805
  if (argv.indexOf('--status') !== -1) {
1308
1806
  process.stdout.write(statusLine(collect(Date.now())) + '\n');
1309
1807
  return 0;
1310
1808
  }
1311
1809
 
1810
+ // Codex writes its meter into the session rollouts, so the cached reading is
1811
+ // only as fresh as the last request it made. Asking Codex itself is a second
1812
+ // and a child process, which is why it is opt-in rather than the default.
1813
+ let codexMeter = null;
1814
+ if (argv.indexOf('--refresh') !== -1) {
1815
+ if (!isCodex()) {
1816
+ process.stderr.write('usage: --refresh applies to Codex. Run /usage in Claude Code.\n');
1817
+ return 2;
1818
+ }
1819
+ try {
1820
+ codexMeter = await codex.refresh();
1821
+ } catch (err) {
1822
+ process.stderr.write(
1823
+ 'usage: could not read a live figure from Codex (' +
1824
+ ((err && err.code) || 'unknown') + '). Falling back to the newest one on disk.\n'
1825
+ );
1826
+ }
1827
+ }
1828
+
1312
1829
  const wantsJson = argv.indexOf('--json') !== -1;
1313
- const data = await report(Date.now());
1830
+ const data = await report(Date.now(), { codexMeter });
1314
1831
 
1315
1832
  const forecastAt = argv.indexOf('--forecast');
1316
1833
  if (forecastAt !== -1) {
@@ -1342,6 +1859,12 @@ if (require.main === module) {
1342
1859
 
1343
1860
  module.exports = {
1344
1861
  main,
1862
+ setHost,
1863
+ currentHost,
1864
+ isCodex,
1865
+ otherLimits,
1866
+ collectClaude,
1867
+ readClaudeEvents,
1345
1868
  RATES,
1346
1869
  WINDOWS,
1347
1870
  rateFor,
@@ -1357,8 +1880,11 @@ module.exports = {
1357
1880
  SATURATION_LIMIT,
1358
1881
  MIN_BASELINE_TURNS,
1359
1882
  buildWindows,
1883
+ lastRejections,
1360
1884
  bindingWindow,
1361
1885
  criticalOthers,
1886
+ betterCalibration,
1887
+ calibrationFile,
1362
1888
  CRITICAL_PERCENT,
1363
1889
  dominantEffort,
1364
1890
  typicalTurnCost,
@@ -1376,6 +1902,7 @@ module.exports = {
1376
1902
  collect,
1377
1903
  detectPlan,
1378
1904
  costPercentiles,
1905
+ reasoningSpend,
1379
1906
  forecastWindow,
1380
1907
  renderForecast,
1381
1908
  creditsFrom,