claude-usage-limits 1.7.1 → 1.9.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,
@@ -725,13 +812,24 @@ function stampPlan(all, planId) {
725
812
  return stamped;
726
813
  }
727
814
 
728
- // A sample is better when it rests on more turns. Percentages read in whole
729
- // numbers, so a bigger percentage also divides more precisely.
815
+ // A sample is better when it rests on more of the meter. Percentages read in
816
+ // whole numbers, so a reading at 1% prices a point against a bracket that is
817
+ // mostly rounding, while one at 60% divides by a number that means something.
818
+ // Turn count only breaks the tie: it says how much local spend sat behind the
819
+ // reading, not how precise the denominator was, and preferring it outright is
820
+ // how a 44-turn baseline read at 1% once beat every honest sample after it.
730
821
  function betterCalibration(current, candidate) {
731
822
  if (!candidate || !Number.isFinite(candidate.usdPerPercent) || candidate.usdPerPercent <= 0) {
732
823
  return current || null;
733
824
  }
734
825
  if (!current || !Number.isFinite(current.turns)) return candidate;
826
+ if (
827
+ Number.isFinite(candidate.percent) &&
828
+ Number.isFinite(current.percent) &&
829
+ candidate.percent !== current.percent
830
+ ) {
831
+ return candidate.percent > current.percent ? candidate : current;
832
+ }
735
833
  return candidate.turns > current.turns ? candidate : current;
736
834
  }
737
835
 
@@ -768,6 +866,7 @@ function buildWindow(spec, snapshot, events, now, options) {
768
866
  spentUSD: spent.cost,
769
867
  spentTokens: spent.tokens,
770
868
  turns: spent.turns,
869
+ subagentTurns: spent.subagentTurns,
771
870
  recentTurns: recent.turns,
772
871
  recentUSDPerHour: recent.cost / recentHours,
773
872
  recentUSDPerTurn: recent.turns ? recent.cost / recent.turns : null,
@@ -789,6 +888,14 @@ function buildWindow(spec, snapshot, events, now, options) {
789
888
  correctionUnreliable: false,
790
889
  // The price-per-point this window derived from its own baseline.
791
890
  calibration: null,
891
+ // True when the bucket quoted its limit in dollars, so the price of a
892
+ // point is known rather than learned.
893
+ metered: false,
894
+ // What the account's own list of limits says about this one.
895
+ severity: null,
896
+ isActive: false,
897
+ scoped: false,
898
+ family: null,
792
899
  verdict: 'unknown',
793
900
  };
794
901
 
@@ -837,19 +944,31 @@ function buildWindow(spec, snapshot, events, now, options) {
837
944
  // holding. The learned price covers it: what a point costs is a property
838
945
  // of the plan, not of this reading.
839
946
  const selfPriced =
840
- rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
947
+ rawPercent >= MIN_BASELINE_PERCENT && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
841
948
  ? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
842
949
  : null;
843
- if (selfPriced) window.calibration = selfPriced;
950
+ // A metered window has nothing to learn: its price per point is stated.
951
+ if (selfPriced && !extra.metered) window.calibration = selfPriced;
844
952
 
845
953
  const known = extra.knownCalibration;
954
+ // A remembered price read off a near-empty meter is the same rounding
955
+ // bracket in disguise, so it is no more usable than measuring one now.
846
956
  const usable =
847
- known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
848
- // Trust the better-sampled of the two, whichever that is.
957
+ known &&
958
+ Number.isFinite(known.usdPerPercent) &&
959
+ known.usdPerPercent > 0 &&
960
+ !(Number.isFinite(known.percent) && known.percent < MIN_BASELINE_PERCENT)
961
+ ? known
962
+ : null;
963
+ // Trust the better-measured of the two, whichever that is; a stated price
964
+ // beats both.
965
+ const stated =
966
+ extra.metered && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
967
+ ? { usdPerPercent: extra.usdPerPercent, turns: Infinity }
968
+ : null;
849
969
  const chosen =
850
- selfPriced && usable
851
- ? (usable.turns > selfPriced.turns ? usable : selfPriced)
852
- : selfPriced || usable;
970
+ stated ||
971
+ (selfPriced && usable ? betterCalibration(usable, selfPriced) : selfPriced || usable);
853
972
 
854
973
  if (after.cost > 0 && chosen) {
855
974
  const pricePerPoint = chosen.usdPerPercent;
@@ -859,6 +978,10 @@ function buildWindow(spec, snapshot, events, now, options) {
859
978
  // not the window. Better to leave the reading uncorrected and say the
860
979
  // snapshot is old than to assert a budget that is gone.
861
980
  if (rawPercent + sinceSnapshot > SATURATION_LIMIT) {
981
+ // Keep the size of the overshoot: the brief needs it to say how far
982
+ // past the snapshot the spending has gone, instead of repeating the
983
+ // snapshot figure for hours as if it were current.
984
+ window.pointsBeyondSnapshot = Math.round(sinceSnapshot);
862
985
  sinceSnapshot = 0;
863
986
  window.correctionUnreliable = true;
864
987
  } else if (sinceSnapshot >= 1) {
@@ -881,23 +1004,35 @@ function buildWindow(spec, snapshot, events, now, options) {
881
1004
  // so when the two disagree that badly it is the local history that is
882
1005
  // incomplete, not the meter.
883
1006
  const thin = spent.turns < MIN_BASELINE_TURNS && percent >= UNEXPLAINED_PERCENT;
884
- const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
1007
+ // The same rounding bracket again, in the optimistic direction this time: a
1008
+ // window reading 1% divided a full hour of spend by one and priced the
1009
+ // remaining 99 points at thousands of dollars. Below the floor the learned
1010
+ // price takes over through the fallback chain.
1011
+ const measured =
1012
+ percent !== null && percent >= MIN_BASELINE_PERCENT && spent.cost > 0 && !thin;
885
1013
  const derived = measured ? spent.cost / percent : null;
886
1014
  const known = extra.knownCalibration;
887
- const priced =
888
- derived !== null
1015
+ const metered =
1016
+ Boolean(extra.metered) && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0;
1017
+ const priced = metered
1018
+ ? extra.usdPerPercent
1019
+ : derived !== null
889
1020
  ? derived
890
1021
  : Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
891
1022
  ? extra.usdPerPercent
892
1023
  : known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0
893
1024
  ? known.usdPerPercent
894
1025
  : null;
1026
+ window.metered = metered;
1027
+
1028
+ // The API reports whole numbers, so a low reading is a wide bracket. A fact
1029
+ // about the reading, not the pricing, so it is set whether or not a price
1030
+ // per point could be found.
1031
+ window.coarse = percent !== null && percent < MIN_BASELINE_PERCENT;
895
1032
 
896
1033
  if (percent !== null && priced !== null) {
897
1034
  window.usdPerPercent = priced;
898
1035
  window.remainingUSD = window.usdPerPercent * window.percentLeft;
899
- // The API reports whole numbers, so a low reading is a wide bracket.
900
- window.coarse = percent < 5;
901
1036
 
902
1037
  const perTurn = typicalTurnCost(recentEvents, inWindow, events, MIN_PACE_SAMPLE);
903
1038
  window.percentPerTurn = perTurn === null ? null : perTurn / window.usdPerPercent;
@@ -989,6 +1124,10 @@ function bindingWindow(windows) {
989
1124
  const theirs = soonest(best);
990
1125
  if (mine !== theirs) return mine < theirs ? w : best;
991
1126
 
1127
+ // Equally urgent by our own measure: the account says which limit it is
1128
+ // enforcing, and that is worth more than a rule of thumb.
1129
+ if (Boolean(w.isActive) !== Boolean(best.isActive)) return w.isActive ? w : best;
1130
+
992
1131
  // Equally urgent: the shorter window is the one hit first in practice, so
993
1132
  // the 5-hour limit wins a tie against the weekly one.
994
1133
  const myspan = Number.isFinite(w.spanMs) ? w.spanMs : Infinity;
@@ -1023,6 +1162,14 @@ function formatUSD(value) {
1023
1162
  return '$' + value.toFixed(3);
1024
1163
  }
1025
1164
 
1165
+ // Money to two places. Three below a dollar is right for a per-turn price and
1166
+ // wrong for a total someone reads after every reply.
1167
+ function formatMoney(value) {
1168
+ if (value === null || !Number.isFinite(value)) return '-';
1169
+ if (value >= 100) return '$' + Math.round(value);
1170
+ return '$' + value.toFixed(2);
1171
+ }
1172
+
1026
1173
  function formatTokens(value) {
1027
1174
  if (!Number.isFinite(value)) return '-';
1028
1175
  if (value >= 1e9) return (value / 1e9).toFixed(1) + 'B';
@@ -1099,6 +1246,49 @@ function otherLimits(utilization, threshold) {
1099
1246
  return rows.sort((a, b) => b.percentUsed - a.percentUsed);
1100
1247
  }
1101
1248
 
1249
+ // Beside the per-window buckets, the snapshot carries `limits`: one entry per
1250
+ // limit the account enforces, with a severity, whether it is the active one,
1251
+ // and for the per-model weeklies which model it scopes to. It is the account's
1252
+ // own description of its limits, and it can name a window the bucket table
1253
+ // does not: on a Max plan the Fable weekly sat at 17% while the shared weekly
1254
+ // read 11%, and nothing reported the higher of the two.
1255
+ const LIMIT_KINDS = { session: 'five_hour', weekly_all: 'seven_day' };
1256
+
1257
+ function limitWindows(utilization) {
1258
+ const list = utilization && Array.isArray(utilization.limits) ? utilization.limits : [];
1259
+ const rows = [];
1260
+ for (const limit of list) {
1261
+ if (!limit || typeof limit !== 'object' || typeof limit.percent !== 'number') continue;
1262
+ const resetsAt = limit.resets_at ? Date.parse(limit.resets_at) : null;
1263
+ const base = {
1264
+ kind: limit.kind,
1265
+ percent: limit.percent,
1266
+ severity: typeof limit.severity === 'string' ? limit.severity : null,
1267
+ isActive: Boolean(limit.is_active),
1268
+ resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
1269
+ family: null,
1270
+ };
1271
+ if (LIMIT_KINDS[limit.kind]) {
1272
+ rows.push(Object.assign(base, { key: LIMIT_KINDS[limit.kind] }));
1273
+ continue;
1274
+ }
1275
+ if (limit.kind === 'weekly_scoped' && limit.scope && limit.scope.model) {
1276
+ const model = limit.scope.model;
1277
+ const name = model.display_name || model.id || 'model';
1278
+ const family = familyOf(name) || familyOf(model.id) || String(name).toLowerCase();
1279
+ rows.push(
1280
+ Object.assign(base, {
1281
+ key: 'seven_day_scoped:' + family,
1282
+ label: 'weekly (' + name + ')',
1283
+ family,
1284
+ spanMs: 7 * DAY,
1285
+ })
1286
+ );
1287
+ }
1288
+ }
1289
+ return rows;
1290
+ }
1291
+
1102
1292
  function collect(now) {
1103
1293
  if (isCodex()) return codex.collect(now);
1104
1294
  return collectClaude(now);
@@ -1147,6 +1337,15 @@ const SATURATION_LIMIT = 105;
1147
1337
  // Fewer turns than this before the snapshot and a point cannot be priced.
1148
1338
  const MIN_BASELINE_TURNS = 5;
1149
1339
 
1340
+ // A reading below this cannot price a point either. The API reports whole
1341
+ // numbers, so at 1% the denominator is mostly rounding: the true figure is
1342
+ // anywhere in a bracket as wide as the reading itself, and a point priced
1343
+ // against it converts later spend into several times the points it really
1344
+ // moved. A snapshot taken just after a reset is the common case - one sat at
1345
+ // 1% while the local spend divided by it asserted 97% of a window that was
1346
+ // truly at 35.
1347
+ const MIN_BASELINE_PERCENT = 5;
1348
+
1150
1349
  // Past this much of a window, a handful of local turns is not what spent it,
1151
1350
  // so their total is not a fair price for a point.
1152
1351
  const UNEXPLAINED_PERCENT = 20;
@@ -1162,6 +1361,10 @@ function reconstructWindow(spec, snapshot, events, now) {
1162
1361
  const past = totals(events.filter((e) => e.at >= pastStart && e.at <= resetsAt));
1163
1362
  if (past.cost <= 0) return null;
1164
1363
 
1364
+ // The same rounding bracket that poisons the live correction poisons a
1365
+ // rebuild: a closed window that read 1% prices a point off almost nothing.
1366
+ if (snapshot.utilization < MIN_BASELINE_PERCENT) return null;
1367
+
1165
1368
  const usdPerPercent = past.cost / snapshot.utilization;
1166
1369
 
1167
1370
  // The window running now began when the old one reset, not five hours ago.
@@ -1236,11 +1439,10 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
1236
1439
  // of its two windows in the payload, so it hands its own spans in rather than
1237
1440
  // having them assumed.
1238
1441
  const table = specs && specs.length ? specs : WINDOWS;
1239
- return table.map((spec) => {
1240
- const snapshot = utilization[spec.key];
1241
- // The per-model weekly windows only exist on some plans.
1242
- if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
1442
+ const limits = limitWindows(utilization);
1443
+ const limitByKey = new Map(limits.map((limit) => [limit.key, limit]));
1243
1444
 
1445
+ const one = (spec, snapshot, own, limit) => {
1244
1446
  const known = learned ? learned[spec.key] : null;
1245
1447
  const refusal = refused.get(spec.key);
1246
1448
 
@@ -1261,34 +1463,91 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
1261
1463
  }
1262
1464
  }
1263
1465
 
1264
- const window = buildWindow(spec, anchored, events, now, {
1265
- fetchedAt,
1266
- knownCalibration: known,
1267
- ...(windowStart === undefined ? {} : { windowStart }),
1268
- });
1466
+ // A bucket that quotes its limit in dollars needs no calibration: a
1467
+ // hundred dollars is a hundred points. Null on the plans seen so far, but
1468
+ // the field is there, and when it fills in it beats any estimate.
1469
+ const dollars =
1470
+ snapshot && Number.isFinite(snapshot.limit_dollars) && snapshot.limit_dollars > 0
1471
+ ? snapshot.limit_dollars / 100
1472
+ : null;
1473
+
1474
+ const window = buildWindow(
1475
+ spec,
1476
+ anchored,
1477
+ own,
1478
+ now,
1479
+ Object.assign(
1480
+ { fetchedAt, knownCalibration: known },
1481
+ windowStart === undefined ? {} : { windowStart },
1482
+ dollars === null ? {} : { metered: true, usdPerPercent: dollars }
1483
+ )
1484
+ );
1269
1485
  if (refusal) {
1270
1486
  window.refusedAt = refusal.at;
1271
1487
  window.refusedResetsAt = refusal.resetsAt;
1272
1488
  }
1489
+
1490
+ let result = window;
1273
1491
  if (!window.stale) {
1274
- return reconstructUnanchored(spec, window, events, now, known) || window;
1492
+ result = reconstructUnanchored(spec, window, own, now, known) || window;
1493
+ } else {
1494
+ // Rolled over. Rebuild from local history rather than going blind on it.
1495
+ const rebuilt = reconstructWindow(spec, snapshot, own, now);
1496
+ if (rebuilt) {
1497
+ result = buildWindow(
1498
+ spec,
1499
+ { utilization: rebuilt.percentUsed, resets_at: null },
1500
+ own,
1501
+ now,
1502
+ {
1503
+ estimated: true,
1504
+ windowStart: rebuilt.windowStart,
1505
+ usdPerPercent: rebuilt.usdPerPercent,
1506
+ }
1507
+ );
1508
+ }
1275
1509
  }
1510
+ // What the account itself says about this limit rides along.
1511
+ if (limit) {
1512
+ result.severity = limit.severity;
1513
+ result.isActive = limit.isActive;
1514
+ }
1515
+ return result;
1516
+ };
1276
1517
 
1277
- // Rolled over. Rebuild from local history rather than going blind on it.
1278
- const rebuilt = reconstructWindow(spec, snapshot, events, now);
1279
- if (!rebuilt) return window;
1280
- return buildWindow(
1281
- spec,
1282
- { utilization: rebuilt.percentUsed, resets_at: null },
1283
- events,
1284
- now,
1285
- {
1286
- estimated: true,
1287
- windowStart: rebuilt.windowStart,
1288
- usdPerPercent: rebuilt.usdPerPercent,
1518
+ const windows = table
1519
+ .map((spec) => {
1520
+ let snapshot = utilization[spec.key];
1521
+ const limit = limitByKey.get(spec.key);
1522
+ // The account's own list can carry a window the bucket table does not.
1523
+ if ((!snapshot || typeof snapshot.utilization !== 'number') && limit) {
1524
+ snapshot = {
1525
+ utilization: limit.percent,
1526
+ resets_at: limit.resetsAt ? new Date(limit.resetsAt).toISOString() : null,
1527
+ };
1289
1528
  }
1290
- );
1291
- }).filter(Boolean);
1529
+ // The per-model weekly windows only exist on some plans.
1530
+ if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
1531
+ return one(spec, snapshot, events, limit);
1532
+ })
1533
+ .filter(Boolean);
1534
+
1535
+ // A per-model weekly is a limit on one model's spend, so it is priced from
1536
+ // that model's calls alone; the shared windows still see everything.
1537
+ for (const limit of limits) {
1538
+ if (!limit.family) continue;
1539
+ const spec = { key: limit.key, label: limit.label, span: limit.spanMs };
1540
+ const own = events.filter((event) => familyOf(event.model) === limit.family);
1541
+ const snapshot = {
1542
+ utilization: limit.percent,
1543
+ resets_at: limit.resetsAt ? new Date(limit.resetsAt).toISOString() : null,
1544
+ };
1545
+ const window = one(spec, snapshot, own, limit);
1546
+ window.scoped = true;
1547
+ window.family = limit.family;
1548
+ windows.push(window);
1549
+ }
1550
+ return windows;
1292
1551
  }
1293
1552
 
1294
1553
  // What one session has spent, out of everything on record.
@@ -1296,12 +1555,14 @@ function sessionSpend(events, sessionId) {
1296
1555
  if (!sessionId) return null;
1297
1556
  let cost = 0;
1298
1557
  let turns = 0;
1558
+ let tokens = 0;
1299
1559
  for (const event of events) {
1300
1560
  if (event.sessionId !== sessionId) continue;
1301
1561
  cost += event.cost;
1302
- turns += 1;
1562
+ tokens += event.tokens || 0;
1563
+ if (!event.sidechain) turns += 1;
1303
1564
  }
1304
- return turns ? { turns, cost } : null;
1565
+ return turns ? { turns, cost, tokens } : null;
1305
1566
  }
1306
1567
 
1307
1568
  async function report(now, options) {
@@ -1413,7 +1674,8 @@ async function report(now, options) {
1413
1674
  tokens: recent.tokens,
1414
1675
  effort: dominantEffort(recentEvents),
1415
1676
  },
1416
- measuredTurns: events.length,
1677
+ measuredTurns: mainThread(events).length,
1678
+ subagentTurns: events.length - mainThread(events).length,
1417
1679
  });
1418
1680
  }
1419
1681
 
@@ -1578,7 +1840,14 @@ function render(data) {
1578
1840
  (money ? padLeft('Left', 10) : '') + padLeft('Turns left', 12)
1579
1841
  );
1580
1842
  for (const window of data.windows) {
1581
- const marker = data.binding && window.key === data.binding.key ? ' <- binding' : '';
1843
+ const bound = data.binding && window.key === data.binding.key;
1844
+ // The account's own severity, when it says critical, is worth a word.
1845
+ const critical = window.severity === 'critical';
1846
+ const marker = bound
1847
+ ? ' <- binding' + (critical ? ', critical' : '')
1848
+ : critical
1849
+ ? ' critical'
1850
+ : '';
1582
1851
  lines.push(
1583
1852
  ' ' + pad(window.label, 15) +
1584
1853
  padLeft(
@@ -1693,7 +1962,10 @@ function render(data) {
1693
1962
  } else {
1694
1963
  lines.push(' Recent pace no turns in the last hour');
1695
1964
  }
1696
- lines.push(' Measured ' + formatCount(data.measuredTurns) + ' turns of local transcript');
1965
+ lines.push(
1966
+ ' Measured ' + formatCount(data.measuredTurns) + ' turns of local transcript' +
1967
+ (data.subagentTurns > 0 ? ' (+' + formatCount(data.subagentTurns) + ' subagent calls)' : '')
1968
+ );
1697
1969
 
1698
1970
  if (data.windows.some((window) => window.adjusted)) {
1699
1971
  lines.push(
@@ -1861,6 +2133,102 @@ function renderForecast(data, turns) {
1861
2133
  return lines.join('\n');
1862
2134
  }
1863
2135
 
2136
+ // The session history kept by the Stop hook, one row per session.
2137
+ function sessionTokens(session) {
2138
+ const t = (session && session.tokens) || {};
2139
+ return (t.input || 0) + (t.cacheWrite || 0) + (t.cacheRead || 0) + (t.output || 0);
2140
+ }
2141
+
2142
+ function shortId(sessionId) {
2143
+ return String(sessionId || '').slice(0, 8);
2144
+ }
2145
+
2146
+ function renderSessions(list, now) {
2147
+ const lines = [];
2148
+ lines.push('Sessions on this machine, newest first');
2149
+ lines.push('');
2150
+ if (!list || !list.length) {
2151
+ lines.push(' No sessions on record yet. The Stop hook writes one after each reply, so');
2152
+ lines.push(' this fills in as soon as a session with the hook installed has run.');
2153
+ return lines.join('\n');
2154
+ }
2155
+ lines.push(
2156
+ ' ' + pad('Id', 10) + pad('When', 12) + pad('Project', 24) + padLeft('Prompts', 7) +
2157
+ padLeft('Turns', 9) + padLeft('Tokens', 9) + padLeft('Cost', 9)
2158
+ );
2159
+ for (const session of list) {
2160
+ const turns =
2161
+ String(session.turns || 0) + (session.subagentTurns > 0 ? '+' + session.subagentTurns : '');
2162
+ lines.push(
2163
+ ' ' + pad(shortId(session.sessionId), 10) +
2164
+ pad(Number.isFinite(session.lastAt) ? formatDuration(now - session.lastAt) + ' ago' : '-', 12) +
2165
+ pad(shortenProject(session.project || '-', 22), 24) +
2166
+ padLeft(session.prompts || 0, 7) +
2167
+ padLeft(turns, 9) +
2168
+ padLeft(formatTokens(sessionTokens(session)), 9) +
2169
+ padLeft(formatMoney(session.cost || 0), 9) +
2170
+ (Number.isFinite(session.endedAt) ? '' : ' open')
2171
+ );
2172
+ }
2173
+ lines.push('');
2174
+ lines.push(' Turns are main-thread calls; +N is what subagents made on top.');
2175
+ return lines.join('\n');
2176
+ }
2177
+
2178
+ function renderSession(session, now) {
2179
+ const lines = [];
2180
+ lines.push('Session ' + shortId(session.sessionId) + (session.project ? ' (' + session.project + ')' : ''));
2181
+ lines.push('');
2182
+ const started = Number.isFinite(session.firstAt)
2183
+ ? formatClock(session.firstAt) + ', ' + formatDuration(now - session.firstAt) + ' ago, '
2184
+ : '';
2185
+ const ended = Number.isFinite(session.endedAt)
2186
+ ? 'closed ' + formatDuration(now - session.endedAt) + ' ago' + (session.reason ? ' (' + session.reason + ')' : '')
2187
+ : 'still open';
2188
+ lines.push(' Started ' + started + ended);
2189
+ lines.push(' Prompts ' + (session.prompts || 0));
2190
+ lines.push(
2191
+ ' Turns ' + (session.turns || 0) +
2192
+ (session.subagentTurns > 0 ? ', plus ' + session.subagentTurns + ' by subagents' : '')
2193
+ );
2194
+ const t = session.tokens || {};
2195
+ lines.push(
2196
+ ' Tokens ' + formatTokens(sessionTokens(session)) + ': input ' + formatTokens(t.input || 0) +
2197
+ ', cache write ' + formatTokens(t.cacheWrite || 0) + ', cache read ' + formatTokens(t.cacheRead || 0) +
2198
+ ', output ' + formatTokens(t.output || 0) +
2199
+ (t.reasoning > 0 ? ' (' + formatTokens(t.reasoning) + ' of it reasoning)' : '')
2200
+ );
2201
+ lines.push(' Cost ' + formatMoney(session.cost || 0));
2202
+ if (Number.isFinite(session.context) && session.context > 0) {
2203
+ lines.push(' Context ' + formatTokens(session.context) + ' tokens at the last call');
2204
+ }
2205
+ const models = Object.keys(session.models || {}).sort(
2206
+ (a, b) => (session.models[b].cost || 0) - (session.models[a].cost || 0)
2207
+ );
2208
+ models.forEach((id, index) => {
2209
+ const row = session.models[id];
2210
+ lines.push(
2211
+ (index === 0 ? ' Models ' : ' ') + pad(id, 24) +
2212
+ padLeft((row.turns || 0) + ' turns', 12) + padLeft(formatMoney(row.cost || 0), 10)
2213
+ );
2214
+ });
2215
+ return lines.join('\n');
2216
+ }
2217
+
2218
+ // "last", an exact id, or an unambiguous prefix of one.
2219
+ function pickSession(list, which) {
2220
+ const rows = list || [];
2221
+ if (!rows.length) return null;
2222
+ const want = String(which || 'last');
2223
+ if (want === 'last') {
2224
+ return rows.slice().sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0))[0];
2225
+ }
2226
+ const exact = rows.find((row) => row.sessionId === want);
2227
+ if (exact) return exact;
2228
+ const prefixed = rows.filter((row) => String(row.sessionId || '').startsWith(want));
2229
+ return prefixed.length === 1 ? prefixed[0] : null;
2230
+ }
2231
+
1864
2232
  async function main(argv) {
1865
2233
  // Settle the host before anything reads a file, so one run never mixes one
1866
2234
  // agent's percentages with the other's turns.
@@ -1872,6 +2240,29 @@ async function main(argv) {
1872
2240
  return 0;
1873
2241
  }
1874
2242
 
2243
+ // The session history is a file the Stop hook keeps, so neither of these
2244
+ // opens a transcript. Required lazily: tally.js depends on this module.
2245
+ const sessionAt = argv.indexOf('--session');
2246
+ if (sessionAt !== -1 || argv.indexOf('--sessions') !== -1) {
2247
+ const tally = require('./tally.js');
2248
+ const list = tally.sessions(tally.readState());
2249
+ const now = Date.now();
2250
+ const json = argv.indexOf('--json') !== -1;
2251
+ if (sessionAt !== -1) {
2252
+ const next = argv[sessionAt + 1];
2253
+ const which = next && next.indexOf('--') !== 0 ? next : 'last';
2254
+ const picked = pickSession(list, which);
2255
+ if (!picked) {
2256
+ process.stderr.write('usage: no session matches "' + which + '". Run --sessions to list them.\n');
2257
+ return 2;
2258
+ }
2259
+ process.stdout.write((json ? JSON.stringify(picked, null, 2) : renderSession(picked, now)) + '\n');
2260
+ return 0;
2261
+ }
2262
+ process.stdout.write((json ? JSON.stringify(list, null, 2) : renderSessions(list, now)) + '\n');
2263
+ return 0;
2264
+ }
2265
+
1875
2266
  // Codex writes its meter into the session rollouts, so the cached reading is
1876
2267
  // only as fresh as the last request it made. Asking Codex itself is a second
1877
2268
  // and a child process, which is why it is opt-in rather than the default.
@@ -1907,6 +2298,23 @@ async function main(argv) {
1907
2298
  }
1908
2299
  return 0;
1909
2300
  }
2301
+ const recommendAt = argv.indexOf('--recommend');
2302
+ if (recommendAt !== -1) {
2303
+ // The turn count is optional: with one the verdict is about that job,
2304
+ // without one it is about the headroom in general.
2305
+ const next = argv[recommendAt + 1];
2306
+ const turns = next && next.indexOf('--') !== 0 ? Number(next) : null;
2307
+ const recommend = require('./recommend.js');
2308
+ if (wantsJson) {
2309
+ process.stdout.write(
2310
+ JSON.stringify(recommend.decide(recommend.fromReport(data, turns)), null, 2) + '\n'
2311
+ );
2312
+ } else {
2313
+ process.stdout.write(recommend.renderRecommend(data, turns) + '\n');
2314
+ }
2315
+ return 0;
2316
+ }
2317
+
1910
2318
  if (wantsJson) {
1911
2319
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
1912
2320
  } else {
@@ -1915,13 +2323,8 @@ async function main(argv) {
1915
2323
  return 0;
1916
2324
  }
1917
2325
 
1918
- if (require.main === module) {
1919
- main(process.argv.slice(2)).catch((err) => {
1920
- process.stderr.write('usage: ' + (err && err.message ? err.message : String(err)) + '\n');
1921
- process.exitCode = 1;
1922
- });
1923
- }
1924
-
2326
+ // Assigned before main() can run: tally.js requires this module back, and a
2327
+ // lazy require from inside main() would otherwise see an empty exports object.
1925
2328
  module.exports = {
1926
2329
  main,
1927
2330
  setHost,
@@ -1939,12 +2342,16 @@ module.exports = {
1939
2342
  costOf,
1940
2343
  tokensOf,
1941
2344
  eventFrom,
2345
+ promptFrom,
1942
2346
  readEvents,
2347
+ readCalibration,
1943
2348
  buildWindow,
1944
2349
  reconstructWindow,
1945
2350
  SATURATION_LIMIT,
1946
2351
  MIN_BASELINE_TURNS,
2352
+ MIN_BASELINE_PERCENT,
1947
2353
  buildWindows,
2354
+ limitWindows,
1948
2355
  lastRejections,
1949
2356
  bindingWindow,
1950
2357
  criticalOthers,
@@ -1962,7 +2369,11 @@ module.exports = {
1962
2369
  MIN_PACE_SAMPLE,
1963
2370
  formatDuration,
1964
2371
  formatUSD,
2372
+ formatMoney,
1965
2373
  formatCount,
2374
+ renderSessions,
2375
+ renderSession,
2376
+ pickSession,
1966
2377
  verdictLine,
1967
2378
  render,
1968
2379
  report,
@@ -1983,3 +2394,10 @@ module.exports = {
1983
2394
  formatTokens,
1984
2395
  PLANS,
1985
2396
  };
2397
+
2398
+ if (require.main === module) {
2399
+ main(process.argv.slice(2)).catch((err) => {
2400
+ process.stderr.write('usage: ' + (err && err.message ? err.message : String(err)) + '\n');
2401
+ process.exitCode = 1;
2402
+ });
2403
+ }