claude-usage-limits 1.7.0 → 1.8.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.
@@ -55,7 +55,7 @@ const RATES = {
55
55
  'claude-opus-4-8': { input: 5, output: 25 },
56
56
  'claude-opus-4-7': { input: 5, output: 25 },
57
57
  'claude-opus-4-6': { input: 5, output: 25 },
58
- 'claude-sonnet-5': { input: 3, output: 15 },
58
+ 'claude-sonnet-5': { input: 2, output: 10 },
59
59
  'claude-sonnet-4-6': { input: 3, output: 15 },
60
60
  'claude-haiku-4-5': { input: 1, output: 5 },
61
61
  };
@@ -296,6 +296,10 @@ function eventFrom(line, seen, project) {
296
296
  };
297
297
  }
298
298
 
299
+ // Interrupts and client-side errors are written as assistant messages from
300
+ // a model called <synthetic>, with a usage block of zeros. Not a call.
301
+ if (entry.message.model === '<synthetic>') return null;
302
+
299
303
  // A resumed or forked session repeats earlier turns in a new file.
300
304
  const id = (entry.message.id || '') + '|' + (entry.requestId || '');
301
305
  if (id !== '|' && seen) {
@@ -303,23 +307,76 @@ function eventFrom(line, seen, project) {
303
307
  seen.add(id);
304
308
  }
305
309
 
310
+ const parts = tokenParts(entry.message.usage);
306
311
  return {
307
312
  at,
308
313
  model: entry.message.model || '',
309
314
  effort: entry.effort || null,
310
315
  cost: costOf(entry.message.usage, entry.message.model),
311
316
  tokens: tokensOf(entry.message.usage),
312
- parts: tokenParts(entry.message.usage),
317
+ parts,
318
+ // What the model was shown on this call: everything except what it wrote.
319
+ // It is re-sent on every later call, so it is the recurring cost of the
320
+ // session, and the one number that says how bloated the context has got.
321
+ context: parts.input + parts.cacheRead + parts.cacheWrite,
313
322
  project: project || null,
314
323
  sessionId: entry.sessionId || null,
324
+ // A subagent's turns are written under the parent session with these set.
325
+ sidechain: Boolean(entry.isSidechain || entry.agentId),
315
326
  };
316
327
  }
317
328
 
329
+ // Whether a transcript line is a prompt the user typed. Tool results are also
330
+ // written as user messages, and so are internal notes marked isMeta; neither
331
+ // is something a person asked for.
332
+ function promptFrom(line) {
333
+ if (line.indexOf('"user"') === -1) return false;
334
+ let entry;
335
+ try {
336
+ entry = JSON.parse(line);
337
+ } catch (err) {
338
+ return false;
339
+ }
340
+ if (!entry || entry.type !== 'user' || entry.isMeta || !entry.message) return false;
341
+ const content = entry.message.content;
342
+ if (typeof content === 'string') return content.length > 0;
343
+ if (!Array.isArray(content)) return false;
344
+ let typed = false;
345
+ for (const block of content) {
346
+ if (!block) continue;
347
+ if (block.type === 'tool_result') return false;
348
+ if (block.type === 'text') typed = true;
349
+ }
350
+ return typed;
351
+ }
352
+
318
353
  async function readEvents(since) {
319
354
  if (isCodex()) return codex.readEvents(since);
320
355
  return readClaudeEvents(since);
321
356
  }
322
357
 
358
+ // A file last touched before the window opened holds nothing useful.
359
+ function fresh(file, since) {
360
+ try {
361
+ return fs.statSync(file).mtimeMs >= since;
362
+ } catch (err) {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ function freshFiles(dir, since) {
368
+ let names;
369
+ try {
370
+ names = fs.readdirSync(dir);
371
+ } catch (err) {
372
+ return [];
373
+ }
374
+ return names
375
+ .filter((name) => name.endsWith('.jsonl'))
376
+ .map((name) => path.join(dir, name))
377
+ .filter((file) => fresh(file, since));
378
+ }
379
+
323
380
  async function readClaudeEvents(since) {
324
381
  const root = path.join(configDir(), 'projects');
325
382
  let dirs = [];
@@ -333,21 +390,25 @@ async function readClaudeEvents(since) {
333
390
  for (const dir of dirs) {
334
391
  if (!dir.isDirectory()) continue;
335
392
  const full = path.join(root, dir.name);
336
- let names = [];
393
+ let entries = [];
337
394
  try {
338
- names = fs.readdirSync(full);
395
+ entries = fs.readdirSync(full, { withFileTypes: true });
339
396
  } catch (err) {
340
397
  continue;
341
398
  }
342
- for (const name of names) {
343
- if (!name.endsWith('.jsonl')) continue;
344
- const file = path.join(full, name);
345
- try {
346
- // A file last touched before the window opened holds nothing useful.
347
- if (fs.statSync(file).mtimeMs < since) continue;
348
- } catch (err) {
399
+ for (const entry of entries) {
400
+ if (entry.isDirectory()) {
401
+ // A session's subagents write their transcripts under
402
+ // <project>/<session id>/subagents/. Same budget, different file, and
403
+ // for a long time an Explore or Plan agent's whole spend went unseen.
404
+ for (const file of freshFiles(path.join(full, entry.name, 'subagents'), since)) {
405
+ files.push({ file, project: dir.name });
406
+ }
349
407
  continue;
350
408
  }
409
+ if (!entry.name.endsWith('.jsonl')) continue;
410
+ const file = path.join(full, entry.name);
411
+ if (!fresh(file, since)) continue;
351
412
  files.push({ file, project: dir.name });
352
413
  }
353
414
  }
@@ -374,13 +435,20 @@ async function readClaudeEvents(since) {
374
435
  return events;
375
436
  }
376
437
 
438
+ // A turn is one main-thread call, the unit the headroom is planned in. A
439
+ // subagent's calls spend the same budget, so they count in the money and the
440
+ // tokens, and are counted apart so they never inflate the turn figures.
377
441
  function totals(events) {
378
442
  let cost = 0;
379
443
  let tokens = 0;
444
+ let turns = 0;
445
+ let subagentTurns = 0;
380
446
  const parts = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 };
381
447
  for (const event of events) {
382
448
  cost += event.cost;
383
449
  tokens += event.tokens;
450
+ if (event.sidechain) subagentTurns += 1;
451
+ else turns += 1;
384
452
  if (event.parts) {
385
453
  parts.input += event.parts.input;
386
454
  parts.cacheWrite += event.parts.cacheWrite;
@@ -389,7 +457,11 @@ function totals(events) {
389
457
  parts.reasoning += event.parts.reasoning || 0;
390
458
  }
391
459
  }
392
- return { cost, tokens, turns: events.length, parts };
460
+ return { cost, tokens, turns, subagentTurns, parts };
461
+ }
462
+
463
+ function mainThread(events) {
464
+ return (events || []).filter((event) => !event.sidechain);
393
465
  }
394
466
 
395
467
  // What each model actually cost, dearest first.
@@ -437,8 +509,9 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
437
509
 
438
510
  // What a turn costs is a fact about how you work, not about which budget it
439
511
  // is being measured against, so a thin window borrows from a wider sample
440
- // rather than inventing a figure from two turns.
441
- const tiers = [recentEvents, windowEvents, allEvents];
512
+ // rather than inventing a figure from two turns. Subagent calls are left
513
+ // out: they are small and many, and would make a turn look cheap.
514
+ const tiers = [mainThread(recentEvents), mainThread(windowEvents), mainThread(allEvents)];
442
515
  let pool = [];
443
516
  for (const tier of tiers) {
444
517
  if (tier && tier.length >= floor) {
@@ -578,7 +651,7 @@ function creditsFrom(utilization) {
578
651
  // files costs many times one that answers from context. A median alone
579
652
  // under-promises on the expensive half, so carry a high end too.
580
653
  function costPercentiles(events) {
581
- const costs = (events || [])
654
+ const costs = mainThread(events)
582
655
  .map((event) => event.cost)
583
656
  .filter((cost) => Number.isFinite(cost) && cost > 0)
584
657
  .sort((a, b) => a - b);
@@ -640,11 +713,25 @@ function activeSessions(events, now, windowMs) {
640
713
 
641
714
  // The slice of the shared budget this session is actually getting. With
642
715
  // another session spending half of it, only half those turns are yours.
643
- function shareOf(sessions, sessionId) {
644
- if (!sessions || sessions.length < 2) return 1;
645
- const mine = sessions.find((row) => row.sessionId === sessionId);
646
- if (!mine) return 1 / sessions.length;
647
- return mine.share > 0 ? mine.share : 1 / sessions.length;
716
+ //
717
+ // Past spend says how the budget has been going, not how it will go. A
718
+ // session that has only just started has almost none of it, and dividing its
719
+ // headroom by that share once turned 208 turns into "about 6 turns left" two
720
+ // tool calls into a session at 13 per cent used. So an equal split is the
721
+ // floor: a measured share can raise it, never lower it. `activeCount` is how
722
+ // many sessions are open, which can exceed how many have spent yet; the split
723
+ // is among all of them.
724
+ function shareOf(sessions, sessionId, activeCount) {
725
+ const spent = sessions || [];
726
+ const n = Math.max(spent.length, Number.isFinite(activeCount) ? activeCount : 0);
727
+ if (n < 2) return 1;
728
+ const equal = 1 / n;
729
+ // One session's spend is not a comparison. Until a second one has spent,
730
+ // being the only one on record says nothing about who owns the budget.
731
+ if (spent.length < 2) return equal;
732
+ const mine = spent.find((row) => row.sessionId === sessionId);
733
+ if (!mine || !(mine.share > 0)) return equal;
734
+ return Math.max(mine.share, equal);
648
735
  }
649
736
 
650
737
  // What a point of a window costs is a property of the plan, not of the moment,
@@ -678,6 +765,53 @@ function writeCalibration(all) {
678
765
  }
679
766
  }
680
767
 
768
+ // Everything learned about a budget belongs to the plan it was learned on.
769
+ //
770
+ // A point of a window is a share of an allowance, so changing the allowance
771
+ // changes what a point is worth, and every figure derived from the old one is
772
+ // then wrong by the ratio between the plans. Upgrading Pro to Max 5x is roughly
773
+ // a fivefold move: a calibration saying a point costs $0.40 keeps being applied
774
+ // to a point now worth several times that, and the turn estimates built on it
775
+ // are wrong in the direction that promises room there is not.
776
+ //
777
+ // There is no timestamp anywhere that says when the plan changed.
778
+ // `subscriptionCreatedAt` is the original signup, not the upgrade. So the plan
779
+ // is stamped onto the calibration instead, and a stamp that no longer matches
780
+ // is itself the proof that it moved.
781
+ function calibrationForPlan(all, planId) {
782
+ const kept = {};
783
+ let dropped = false;
784
+ for (const key of Object.keys(all || {})) {
785
+ const entry = all[key];
786
+ if (!entry || typeof entry !== 'object') continue;
787
+ // A stamp that no longer matches is proof the plan moved, and worth saying
788
+ // so: the reading on disk was measured against the other allowance too.
789
+ if (entry.plan && planId && entry.plan !== planId) {
790
+ dropped = true;
791
+ continue;
792
+ }
793
+ // An entry saved before the stamp existed cannot be shown to belong to this
794
+ // plan. Keeping it would be assuming the answer, and the cost of assuming
795
+ // wrong is the whole bug this guards: a point priced for Pro applied to a
796
+ // Max window, promising several times the turns that exist. It is dropped
797
+ // instead, which costs one relearn and says "unknown" in the meantime.
798
+ // Unknown is not claimed as a plan change, because it is not evidence of
799
+ // one; every install upgrading to this version passes through here once.
800
+ if (!entry.plan && planId) continue;
801
+ kept[key] = entry;
802
+ }
803
+ return { learned: kept, planChanged: dropped };
804
+ }
805
+
806
+ function stampPlan(all, planId) {
807
+ if (!planId) return all;
808
+ const stamped = {};
809
+ for (const key of Object.keys(all || {})) {
810
+ stamped[key] = Object.assign({}, all[key], { plan: planId });
811
+ }
812
+ return stamped;
813
+ }
814
+
681
815
  // A sample is better when it rests on more turns. Percentages read in whole
682
816
  // numbers, so a bigger percentage also divides more precisely.
683
817
  function betterCalibration(current, candidate) {
@@ -721,6 +855,7 @@ function buildWindow(spec, snapshot, events, now, options) {
721
855
  spentUSD: spent.cost,
722
856
  spentTokens: spent.tokens,
723
857
  turns: spent.turns,
858
+ subagentTurns: spent.subagentTurns,
724
859
  recentTurns: recent.turns,
725
860
  recentUSDPerHour: recent.cost / recentHours,
726
861
  recentUSDPerTurn: recent.turns ? recent.cost / recent.turns : null,
@@ -742,6 +877,14 @@ function buildWindow(spec, snapshot, events, now, options) {
742
877
  correctionUnreliable: false,
743
878
  // The price-per-point this window derived from its own baseline.
744
879
  calibration: null,
880
+ // True when the bucket quoted its limit in dollars, so the price of a
881
+ // point is known rather than learned.
882
+ metered: false,
883
+ // What the account's own list of limits says about this one.
884
+ severity: null,
885
+ isActive: false,
886
+ scoped: false,
887
+ family: null,
745
888
  verdict: 'unknown',
746
889
  };
747
890
 
@@ -793,16 +936,23 @@ function buildWindow(spec, snapshot, events, now, options) {
793
936
  rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
794
937
  ? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
795
938
  : null;
796
- if (selfPriced) window.calibration = selfPriced;
939
+ // A metered window has nothing to learn: its price per point is stated.
940
+ if (selfPriced && !extra.metered) window.calibration = selfPriced;
797
941
 
798
942
  const known = extra.knownCalibration;
799
943
  const usable =
800
944
  known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
801
- // Trust the better-sampled of the two, whichever that is.
945
+ // Trust the better-sampled of the two, whichever that is; a stated price
946
+ // beats both.
947
+ const stated =
948
+ extra.metered && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
949
+ ? { usdPerPercent: extra.usdPerPercent, turns: Infinity }
950
+ : null;
802
951
  const chosen =
803
- selfPriced && usable
952
+ stated ||
953
+ (selfPriced && usable
804
954
  ? (usable.turns > selfPriced.turns ? usable : selfPriced)
805
- : selfPriced || usable;
955
+ : selfPriced || usable);
806
956
 
807
957
  if (after.cost > 0 && chosen) {
808
958
  const pricePerPoint = chosen.usdPerPercent;
@@ -812,6 +962,10 @@ function buildWindow(spec, snapshot, events, now, options) {
812
962
  // not the window. Better to leave the reading uncorrected and say the
813
963
  // snapshot is old than to assert a budget that is gone.
814
964
  if (rawPercent + sinceSnapshot > SATURATION_LIMIT) {
965
+ // Keep the size of the overshoot: the brief needs it to say how far
966
+ // past the snapshot the spending has gone, instead of repeating the
967
+ // snapshot figure for hours as if it were current.
968
+ window.pointsBeyondSnapshot = Math.round(sinceSnapshot);
815
969
  sinceSnapshot = 0;
816
970
  window.correctionUnreliable = true;
817
971
  } else if (sinceSnapshot >= 1) {
@@ -837,14 +991,18 @@ function buildWindow(spec, snapshot, events, now, options) {
837
991
  const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
838
992
  const derived = measured ? spent.cost / percent : null;
839
993
  const known = extra.knownCalibration;
840
- const priced =
841
- derived !== null
994
+ const metered =
995
+ Boolean(extra.metered) && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0;
996
+ const priced = metered
997
+ ? extra.usdPerPercent
998
+ : derived !== null
842
999
  ? derived
843
1000
  : Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
844
1001
  ? extra.usdPerPercent
845
1002
  : known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0
846
1003
  ? known.usdPerPercent
847
1004
  : null;
1005
+ window.metered = metered;
848
1006
 
849
1007
  if (percent !== null && priced !== null) {
850
1008
  window.usdPerPercent = priced;
@@ -942,6 +1100,10 @@ function bindingWindow(windows) {
942
1100
  const theirs = soonest(best);
943
1101
  if (mine !== theirs) return mine < theirs ? w : best;
944
1102
 
1103
+ // Equally urgent by our own measure: the account says which limit it is
1104
+ // enforcing, and that is worth more than a rule of thumb.
1105
+ if (Boolean(w.isActive) !== Boolean(best.isActive)) return w.isActive ? w : best;
1106
+
945
1107
  // Equally urgent: the shorter window is the one hit first in practice, so
946
1108
  // the 5-hour limit wins a tie against the weekly one.
947
1109
  const myspan = Number.isFinite(w.spanMs) ? w.spanMs : Infinity;
@@ -976,6 +1138,14 @@ function formatUSD(value) {
976
1138
  return '$' + value.toFixed(3);
977
1139
  }
978
1140
 
1141
+ // Money to two places. Three below a dollar is right for a per-turn price and
1142
+ // wrong for a total someone reads after every reply.
1143
+ function formatMoney(value) {
1144
+ if (value === null || !Number.isFinite(value)) return '-';
1145
+ if (value >= 100) return '$' + Math.round(value);
1146
+ return '$' + value.toFixed(2);
1147
+ }
1148
+
979
1149
  function formatTokens(value) {
980
1150
  if (!Number.isFinite(value)) return '-';
981
1151
  if (value >= 1e9) return (value / 1e9).toFixed(1) + 'B';
@@ -1052,6 +1222,49 @@ function otherLimits(utilization, threshold) {
1052
1222
  return rows.sort((a, b) => b.percentUsed - a.percentUsed);
1053
1223
  }
1054
1224
 
1225
+ // Beside the per-window buckets, the snapshot carries `limits`: one entry per
1226
+ // limit the account enforces, with a severity, whether it is the active one,
1227
+ // and for the per-model weeklies which model it scopes to. It is the account's
1228
+ // own description of its limits, and it can name a window the bucket table
1229
+ // does not: on a Max plan the Fable weekly sat at 17% while the shared weekly
1230
+ // read 11%, and nothing reported the higher of the two.
1231
+ const LIMIT_KINDS = { session: 'five_hour', weekly_all: 'seven_day' };
1232
+
1233
+ function limitWindows(utilization) {
1234
+ const list = utilization && Array.isArray(utilization.limits) ? utilization.limits : [];
1235
+ const rows = [];
1236
+ for (const limit of list) {
1237
+ if (!limit || typeof limit !== 'object' || typeof limit.percent !== 'number') continue;
1238
+ const resetsAt = limit.resets_at ? Date.parse(limit.resets_at) : null;
1239
+ const base = {
1240
+ kind: limit.kind,
1241
+ percent: limit.percent,
1242
+ severity: typeof limit.severity === 'string' ? limit.severity : null,
1243
+ isActive: Boolean(limit.is_active),
1244
+ resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
1245
+ family: null,
1246
+ };
1247
+ if (LIMIT_KINDS[limit.kind]) {
1248
+ rows.push(Object.assign(base, { key: LIMIT_KINDS[limit.kind] }));
1249
+ continue;
1250
+ }
1251
+ if (limit.kind === 'weekly_scoped' && limit.scope && limit.scope.model) {
1252
+ const model = limit.scope.model;
1253
+ const name = model.display_name || model.id || 'model';
1254
+ const family = familyOf(name) || familyOf(model.id) || String(name).toLowerCase();
1255
+ rows.push(
1256
+ Object.assign(base, {
1257
+ key: 'seven_day_scoped:' + family,
1258
+ label: 'weekly (' + name + ')',
1259
+ family,
1260
+ spanMs: 7 * DAY,
1261
+ })
1262
+ );
1263
+ }
1264
+ }
1265
+ return rows;
1266
+ }
1267
+
1055
1268
  function collect(now) {
1056
1269
  if (isCodex()) return codex.collect(now);
1057
1270
  return collectClaude(now);
@@ -1189,11 +1402,10 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
1189
1402
  // of its two windows in the payload, so it hands its own spans in rather than
1190
1403
  // having them assumed.
1191
1404
  const table = specs && specs.length ? specs : WINDOWS;
1192
- return table.map((spec) => {
1193
- const snapshot = utilization[spec.key];
1194
- // The per-model weekly windows only exist on some plans.
1195
- if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
1405
+ const limits = limitWindows(utilization);
1406
+ const limitByKey = new Map(limits.map((limit) => [limit.key, limit]));
1196
1407
 
1408
+ const one = (spec, snapshot, own, limit) => {
1197
1409
  const known = learned ? learned[spec.key] : null;
1198
1410
  const refusal = refused.get(spec.key);
1199
1411
 
@@ -1214,34 +1426,91 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
1214
1426
  }
1215
1427
  }
1216
1428
 
1217
- const window = buildWindow(spec, anchored, events, now, {
1218
- fetchedAt,
1219
- knownCalibration: known,
1220
- ...(windowStart === undefined ? {} : { windowStart }),
1221
- });
1429
+ // A bucket that quotes its limit in dollars needs no calibration: a
1430
+ // hundred dollars is a hundred points. Null on the plans seen so far, but
1431
+ // the field is there, and when it fills in it beats any estimate.
1432
+ const dollars =
1433
+ snapshot && Number.isFinite(snapshot.limit_dollars) && snapshot.limit_dollars > 0
1434
+ ? snapshot.limit_dollars / 100
1435
+ : null;
1436
+
1437
+ const window = buildWindow(
1438
+ spec,
1439
+ anchored,
1440
+ own,
1441
+ now,
1442
+ Object.assign(
1443
+ { fetchedAt, knownCalibration: known },
1444
+ windowStart === undefined ? {} : { windowStart },
1445
+ dollars === null ? {} : { metered: true, usdPerPercent: dollars }
1446
+ )
1447
+ );
1222
1448
  if (refusal) {
1223
1449
  window.refusedAt = refusal.at;
1224
1450
  window.refusedResetsAt = refusal.resetsAt;
1225
1451
  }
1452
+
1453
+ let result = window;
1226
1454
  if (!window.stale) {
1227
- return reconstructUnanchored(spec, window, events, now, known) || window;
1455
+ result = reconstructUnanchored(spec, window, own, now, known) || window;
1456
+ } else {
1457
+ // Rolled over. Rebuild from local history rather than going blind on it.
1458
+ const rebuilt = reconstructWindow(spec, snapshot, own, now);
1459
+ if (rebuilt) {
1460
+ result = buildWindow(
1461
+ spec,
1462
+ { utilization: rebuilt.percentUsed, resets_at: null },
1463
+ own,
1464
+ now,
1465
+ {
1466
+ estimated: true,
1467
+ windowStart: rebuilt.windowStart,
1468
+ usdPerPercent: rebuilt.usdPerPercent,
1469
+ }
1470
+ );
1471
+ }
1228
1472
  }
1473
+ // What the account itself says about this limit rides along.
1474
+ if (limit) {
1475
+ result.severity = limit.severity;
1476
+ result.isActive = limit.isActive;
1477
+ }
1478
+ return result;
1479
+ };
1229
1480
 
1230
- // Rolled over. Rebuild from local history rather than going blind on it.
1231
- const rebuilt = reconstructWindow(spec, snapshot, events, now);
1232
- if (!rebuilt) return window;
1233
- return buildWindow(
1234
- spec,
1235
- { utilization: rebuilt.percentUsed, resets_at: null },
1236
- events,
1237
- now,
1238
- {
1239
- estimated: true,
1240
- windowStart: rebuilt.windowStart,
1241
- usdPerPercent: rebuilt.usdPerPercent,
1481
+ const windows = table
1482
+ .map((spec) => {
1483
+ let snapshot = utilization[spec.key];
1484
+ const limit = limitByKey.get(spec.key);
1485
+ // The account's own list can carry a window the bucket table does not.
1486
+ if ((!snapshot || typeof snapshot.utilization !== 'number') && limit) {
1487
+ snapshot = {
1488
+ utilization: limit.percent,
1489
+ resets_at: limit.resetsAt ? new Date(limit.resetsAt).toISOString() : null,
1490
+ };
1242
1491
  }
1243
- );
1244
- }).filter(Boolean);
1492
+ // The per-model weekly windows only exist on some plans.
1493
+ if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
1494
+ return one(spec, snapshot, events, limit);
1495
+ })
1496
+ .filter(Boolean);
1497
+
1498
+ // A per-model weekly is a limit on one model's spend, so it is priced from
1499
+ // that model's calls alone; the shared windows still see everything.
1500
+ for (const limit of limits) {
1501
+ if (!limit.family) continue;
1502
+ const spec = { key: limit.key, label: limit.label, span: limit.spanMs };
1503
+ const own = events.filter((event) => familyOf(event.model) === limit.family);
1504
+ const snapshot = {
1505
+ utilization: limit.percent,
1506
+ resets_at: limit.resetsAt ? new Date(limit.resetsAt).toISOString() : null,
1507
+ };
1508
+ const window = one(spec, snapshot, own, limit);
1509
+ window.scoped = true;
1510
+ window.family = limit.family;
1511
+ windows.push(window);
1512
+ }
1513
+ return windows;
1245
1514
  }
1246
1515
 
1247
1516
  // What one session has spent, out of everything on record.
@@ -1249,12 +1518,14 @@ function sessionSpend(events, sessionId) {
1249
1518
  if (!sessionId) return null;
1250
1519
  let cost = 0;
1251
1520
  let turns = 0;
1521
+ let tokens = 0;
1252
1522
  for (const event of events) {
1253
1523
  if (event.sessionId !== sessionId) continue;
1254
1524
  cost += event.cost;
1255
- turns += 1;
1525
+ tokens += event.tokens || 0;
1526
+ if (!event.sidechain) turns += 1;
1256
1527
  }
1257
- return turns ? { turns, cost } : null;
1528
+ return turns ? { turns, cost, tokens } : null;
1258
1529
  }
1259
1530
 
1260
1531
  async function report(now, options) {
@@ -1280,7 +1551,11 @@ async function report(now, options) {
1280
1551
  }
1281
1552
 
1282
1553
  const onDisk = readCalibration();
1283
- const learned = Object.assign({}, onDisk);
1554
+ // Anything learned on a different plan is void, and its absence is what makes
1555
+ // the report say so rather than quietly pricing this plan with the last one's
1556
+ // numbers.
1557
+ const calibrated = calibrationForPlan(onDisk, base.planId);
1558
+ const learned = Object.assign({}, calibrated.learned);
1284
1559
 
1285
1560
  // Codex logs the meter next to every request, so the price of a point can be
1286
1561
  // measured outright instead of inferred. A measurement from this session
@@ -1299,7 +1574,11 @@ async function report(now, options) {
1299
1574
  base.snapshotFetchedAt,
1300
1575
  learned,
1301
1576
  base.windowSpecs,
1302
- rejections
1577
+ // A refusal describes the allowance that refused it. After a plan change
1578
+ // that allowance is gone, so anchoring the new window to it, or warning
1579
+ // that the room "ran out last time", is describing a budget that no longer
1580
+ // exists.
1581
+ calibrated.planChanged ? new Map() : rejections
1303
1582
  );
1304
1583
 
1305
1584
  // Keep the best sample seen so far, so a thin baseline never has to guess.
@@ -1311,11 +1590,11 @@ async function report(now, options) {
1311
1590
  }
1312
1591
  // Compared against what is actually on disk, so a measurement taken during
1313
1592
  // this run is saved too rather than only the ones inferred from a window.
1314
- let changed = false;
1593
+ let changed = calibrated.planChanged;
1315
1594
  for (const key of Object.keys(updated)) {
1316
1595
  if (updated[key] !== onDisk[key]) changed = true;
1317
1596
  }
1318
- if (changed) writeCalibration(updated);
1597
+ if (changed) writeCalibration(stampPlan(updated, base.planId));
1319
1598
 
1320
1599
  const recentEvents = events.filter((event) => event.at >= now - HOUR);
1321
1600
  const recent = totals(recentEvents);
@@ -1332,6 +1611,10 @@ async function report(now, options) {
1332
1611
  windows,
1333
1612
  binding,
1334
1613
  otherLimits: otherLimits(base.utilization),
1614
+ // The plan moved since anything was last learned about it, so the cached
1615
+ // percentage was measured against a different allowance and everything
1616
+ // derived from the old one has been dropped.
1617
+ planChanged: calibrated.planChanged,
1335
1618
  // The last time the account actually refused work, so a report taken just
1336
1619
  // after a cutoff says so rather than describing the fresh window as though
1337
1620
  // nothing happened.
@@ -1354,7 +1637,8 @@ async function report(now, options) {
1354
1637
  tokens: recent.tokens,
1355
1638
  effort: dominantEffort(recentEvents),
1356
1639
  },
1357
- measuredTurns: events.length,
1640
+ measuredTurns: mainThread(events).length,
1641
+ subagentTurns: events.length - mainThread(events).length,
1358
1642
  });
1359
1643
  }
1360
1644
 
@@ -1462,6 +1746,12 @@ function render(data) {
1462
1746
  (data.snapshotAgeMs === null ? 'none on disk' : formatDuration(data.snapshotAgeMs) + ' old')
1463
1747
  );
1464
1748
  lines.push(' Settings model=' + data.settings.model + ' effort=' + data.settings.effortLevel);
1749
+ if (data.planChanged) {
1750
+ lines.push(' Plan change this is a different plan from the one the figures below');
1751
+ lines.push(' were learned on, so what a point of a window is worth has');
1752
+ lines.push(' changed with it. The cached reading may predate the change:');
1753
+ lines.push(' run /usage for one measured against this plan.');
1754
+ }
1465
1755
  const credits = data.credits;
1466
1756
  if (credits && credits.unlimited) {
1467
1757
  lines.push(' Credits unlimited');
@@ -1513,7 +1803,14 @@ function render(data) {
1513
1803
  (money ? padLeft('Left', 10) : '') + padLeft('Turns left', 12)
1514
1804
  );
1515
1805
  for (const window of data.windows) {
1516
- const marker = data.binding && window.key === data.binding.key ? ' <- binding' : '';
1806
+ const bound = data.binding && window.key === data.binding.key;
1807
+ // The account's own severity, when it says critical, is worth a word.
1808
+ const critical = window.severity === 'critical';
1809
+ const marker = bound
1810
+ ? ' <- binding' + (critical ? ', critical' : '')
1811
+ : critical
1812
+ ? ' critical'
1813
+ : '';
1517
1814
  lines.push(
1518
1815
  ' ' + pad(window.label, 15) +
1519
1816
  padLeft(
@@ -1628,7 +1925,10 @@ function render(data) {
1628
1925
  } else {
1629
1926
  lines.push(' Recent pace no turns in the last hour');
1630
1927
  }
1631
- lines.push(' Measured ' + formatCount(data.measuredTurns) + ' turns of local transcript');
1928
+ lines.push(
1929
+ ' Measured ' + formatCount(data.measuredTurns) + ' turns of local transcript' +
1930
+ (data.subagentTurns > 0 ? ' (+' + formatCount(data.subagentTurns) + ' subagent calls)' : '')
1931
+ );
1632
1932
 
1633
1933
  if (data.windows.some((window) => window.adjusted)) {
1634
1934
  lines.push(
@@ -1796,6 +2096,102 @@ function renderForecast(data, turns) {
1796
2096
  return lines.join('\n');
1797
2097
  }
1798
2098
 
2099
+ // The session history kept by the Stop hook, one row per session.
2100
+ function sessionTokens(session) {
2101
+ const t = (session && session.tokens) || {};
2102
+ return (t.input || 0) + (t.cacheWrite || 0) + (t.cacheRead || 0) + (t.output || 0);
2103
+ }
2104
+
2105
+ function shortId(sessionId) {
2106
+ return String(sessionId || '').slice(0, 8);
2107
+ }
2108
+
2109
+ function renderSessions(list, now) {
2110
+ const lines = [];
2111
+ lines.push('Sessions on this machine, newest first');
2112
+ lines.push('');
2113
+ if (!list || !list.length) {
2114
+ lines.push(' No sessions on record yet. The Stop hook writes one after each reply, so');
2115
+ lines.push(' this fills in as soon as a session with the hook installed has run.');
2116
+ return lines.join('\n');
2117
+ }
2118
+ lines.push(
2119
+ ' ' + pad('Id', 10) + pad('When', 12) + pad('Project', 24) + padLeft('Prompts', 7) +
2120
+ padLeft('Turns', 9) + padLeft('Tokens', 9) + padLeft('Cost', 9)
2121
+ );
2122
+ for (const session of list) {
2123
+ const turns =
2124
+ String(session.turns || 0) + (session.subagentTurns > 0 ? '+' + session.subagentTurns : '');
2125
+ lines.push(
2126
+ ' ' + pad(shortId(session.sessionId), 10) +
2127
+ pad(Number.isFinite(session.lastAt) ? formatDuration(now - session.lastAt) + ' ago' : '-', 12) +
2128
+ pad(shortenProject(session.project || '-', 22), 24) +
2129
+ padLeft(session.prompts || 0, 7) +
2130
+ padLeft(turns, 9) +
2131
+ padLeft(formatTokens(sessionTokens(session)), 9) +
2132
+ padLeft(formatMoney(session.cost || 0), 9) +
2133
+ (Number.isFinite(session.endedAt) ? '' : ' open')
2134
+ );
2135
+ }
2136
+ lines.push('');
2137
+ lines.push(' Turns are main-thread calls; +N is what subagents made on top.');
2138
+ return lines.join('\n');
2139
+ }
2140
+
2141
+ function renderSession(session, now) {
2142
+ const lines = [];
2143
+ lines.push('Session ' + shortId(session.sessionId) + (session.project ? ' (' + session.project + ')' : ''));
2144
+ lines.push('');
2145
+ const started = Number.isFinite(session.firstAt)
2146
+ ? formatClock(session.firstAt) + ', ' + formatDuration(now - session.firstAt) + ' ago, '
2147
+ : '';
2148
+ const ended = Number.isFinite(session.endedAt)
2149
+ ? 'closed ' + formatDuration(now - session.endedAt) + ' ago' + (session.reason ? ' (' + session.reason + ')' : '')
2150
+ : 'still open';
2151
+ lines.push(' Started ' + started + ended);
2152
+ lines.push(' Prompts ' + (session.prompts || 0));
2153
+ lines.push(
2154
+ ' Turns ' + (session.turns || 0) +
2155
+ (session.subagentTurns > 0 ? ', plus ' + session.subagentTurns + ' by subagents' : '')
2156
+ );
2157
+ const t = session.tokens || {};
2158
+ lines.push(
2159
+ ' Tokens ' + formatTokens(sessionTokens(session)) + ': input ' + formatTokens(t.input || 0) +
2160
+ ', cache write ' + formatTokens(t.cacheWrite || 0) + ', cache read ' + formatTokens(t.cacheRead || 0) +
2161
+ ', output ' + formatTokens(t.output || 0) +
2162
+ (t.reasoning > 0 ? ' (' + formatTokens(t.reasoning) + ' of it reasoning)' : '')
2163
+ );
2164
+ lines.push(' Cost ' + formatMoney(session.cost || 0));
2165
+ if (Number.isFinite(session.context) && session.context > 0) {
2166
+ lines.push(' Context ' + formatTokens(session.context) + ' tokens at the last call');
2167
+ }
2168
+ const models = Object.keys(session.models || {}).sort(
2169
+ (a, b) => (session.models[b].cost || 0) - (session.models[a].cost || 0)
2170
+ );
2171
+ models.forEach((id, index) => {
2172
+ const row = session.models[id];
2173
+ lines.push(
2174
+ (index === 0 ? ' Models ' : ' ') + pad(id, 24) +
2175
+ padLeft((row.turns || 0) + ' turns', 12) + padLeft(formatMoney(row.cost || 0), 10)
2176
+ );
2177
+ });
2178
+ return lines.join('\n');
2179
+ }
2180
+
2181
+ // "last", an exact id, or an unambiguous prefix of one.
2182
+ function pickSession(list, which) {
2183
+ const rows = list || [];
2184
+ if (!rows.length) return null;
2185
+ const want = String(which || 'last');
2186
+ if (want === 'last') {
2187
+ return rows.slice().sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0))[0];
2188
+ }
2189
+ const exact = rows.find((row) => row.sessionId === want);
2190
+ if (exact) return exact;
2191
+ const prefixed = rows.filter((row) => String(row.sessionId || '').startsWith(want));
2192
+ return prefixed.length === 1 ? prefixed[0] : null;
2193
+ }
2194
+
1799
2195
  async function main(argv) {
1800
2196
  // Settle the host before anything reads a file, so one run never mixes one
1801
2197
  // agent's percentages with the other's turns.
@@ -1807,6 +2203,29 @@ async function main(argv) {
1807
2203
  return 0;
1808
2204
  }
1809
2205
 
2206
+ // The session history is a file the Stop hook keeps, so neither of these
2207
+ // opens a transcript. Required lazily: tally.js depends on this module.
2208
+ const sessionAt = argv.indexOf('--session');
2209
+ if (sessionAt !== -1 || argv.indexOf('--sessions') !== -1) {
2210
+ const tally = require('./tally.js');
2211
+ const list = tally.sessions(tally.readState());
2212
+ const now = Date.now();
2213
+ const json = argv.indexOf('--json') !== -1;
2214
+ if (sessionAt !== -1) {
2215
+ const next = argv[sessionAt + 1];
2216
+ const which = next && next.indexOf('--') !== 0 ? next : 'last';
2217
+ const picked = pickSession(list, which);
2218
+ if (!picked) {
2219
+ process.stderr.write('usage: no session matches "' + which + '". Run --sessions to list them.\n');
2220
+ return 2;
2221
+ }
2222
+ process.stdout.write((json ? JSON.stringify(picked, null, 2) : renderSession(picked, now)) + '\n');
2223
+ return 0;
2224
+ }
2225
+ process.stdout.write((json ? JSON.stringify(list, null, 2) : renderSessions(list, now)) + '\n');
2226
+ return 0;
2227
+ }
2228
+
1810
2229
  // Codex writes its meter into the session rollouts, so the cached reading is
1811
2230
  // only as fresh as the last request it made. Asking Codex itself is a second
1812
2231
  // and a child process, which is why it is opt-in rather than the default.
@@ -1850,13 +2269,8 @@ async function main(argv) {
1850
2269
  return 0;
1851
2270
  }
1852
2271
 
1853
- if (require.main === module) {
1854
- main(process.argv.slice(2)).catch((err) => {
1855
- process.stderr.write('usage: ' + (err && err.message ? err.message : String(err)) + '\n');
1856
- process.exitCode = 1;
1857
- });
1858
- }
1859
-
2272
+ // Assigned before main() can run: tally.js requires this module back, and a
2273
+ // lazy require from inside main() would otherwise see an empty exports object.
1860
2274
  module.exports = {
1861
2275
  main,
1862
2276
  setHost,
@@ -1874,16 +2288,21 @@ module.exports = {
1874
2288
  costOf,
1875
2289
  tokensOf,
1876
2290
  eventFrom,
2291
+ promptFrom,
1877
2292
  readEvents,
2293
+ readCalibration,
1878
2294
  buildWindow,
1879
2295
  reconstructWindow,
1880
2296
  SATURATION_LIMIT,
1881
2297
  MIN_BASELINE_TURNS,
1882
2298
  buildWindows,
2299
+ limitWindows,
1883
2300
  lastRejections,
1884
2301
  bindingWindow,
1885
2302
  criticalOthers,
1886
2303
  betterCalibration,
2304
+ calibrationForPlan,
2305
+ stampPlan,
1887
2306
  calibrationFile,
1888
2307
  CRITICAL_PERCENT,
1889
2308
  dominantEffort,
@@ -1895,7 +2314,11 @@ module.exports = {
1895
2314
  MIN_PACE_SAMPLE,
1896
2315
  formatDuration,
1897
2316
  formatUSD,
2317
+ formatMoney,
1898
2318
  formatCount,
2319
+ renderSessions,
2320
+ renderSession,
2321
+ pickSession,
1899
2322
  verdictLine,
1900
2323
  render,
1901
2324
  report,
@@ -1916,3 +2339,10 @@ module.exports = {
1916
2339
  formatTokens,
1917
2340
  PLANS,
1918
2341
  };
2342
+
2343
+ if (require.main === module) {
2344
+ main(process.argv.slice(2)).catch((err) => {
2345
+ process.stderr.write('usage: ' + (err && err.message ? err.message : String(err)) + '\n');
2346
+ process.exitCode = 1;
2347
+ });
2348
+ }