claude-usage-limits 1.7.1 → 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,
@@ -768,6 +855,7 @@ function buildWindow(spec, snapshot, events, now, options) {
768
855
  spentUSD: spent.cost,
769
856
  spentTokens: spent.tokens,
770
857
  turns: spent.turns,
858
+ subagentTurns: spent.subagentTurns,
771
859
  recentTurns: recent.turns,
772
860
  recentUSDPerHour: recent.cost / recentHours,
773
861
  recentUSDPerTurn: recent.turns ? recent.cost / recent.turns : null,
@@ -789,6 +877,14 @@ function buildWindow(spec, snapshot, events, now, options) {
789
877
  correctionUnreliable: false,
790
878
  // The price-per-point this window derived from its own baseline.
791
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,
792
888
  verdict: 'unknown',
793
889
  };
794
890
 
@@ -840,16 +936,23 @@ function buildWindow(spec, snapshot, events, now, options) {
840
936
  rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
841
937
  ? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
842
938
  : null;
843
- 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;
844
941
 
845
942
  const known = extra.knownCalibration;
846
943
  const usable =
847
944
  known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
848
- // 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;
849
951
  const chosen =
850
- selfPriced && usable
952
+ stated ||
953
+ (selfPriced && usable
851
954
  ? (usable.turns > selfPriced.turns ? usable : selfPriced)
852
- : selfPriced || usable;
955
+ : selfPriced || usable);
853
956
 
854
957
  if (after.cost > 0 && chosen) {
855
958
  const pricePerPoint = chosen.usdPerPercent;
@@ -859,6 +962,10 @@ function buildWindow(spec, snapshot, events, now, options) {
859
962
  // not the window. Better to leave the reading uncorrected and say the
860
963
  // snapshot is old than to assert a budget that is gone.
861
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);
862
969
  sinceSnapshot = 0;
863
970
  window.correctionUnreliable = true;
864
971
  } else if (sinceSnapshot >= 1) {
@@ -884,14 +991,18 @@ function buildWindow(spec, snapshot, events, now, options) {
884
991
  const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
885
992
  const derived = measured ? spent.cost / percent : null;
886
993
  const known = extra.knownCalibration;
887
- const priced =
888
- 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
889
999
  ? derived
890
1000
  : Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
891
1001
  ? extra.usdPerPercent
892
1002
  : known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0
893
1003
  ? known.usdPerPercent
894
1004
  : null;
1005
+ window.metered = metered;
895
1006
 
896
1007
  if (percent !== null && priced !== null) {
897
1008
  window.usdPerPercent = priced;
@@ -989,6 +1100,10 @@ function bindingWindow(windows) {
989
1100
  const theirs = soonest(best);
990
1101
  if (mine !== theirs) return mine < theirs ? w : best;
991
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
+
992
1107
  // Equally urgent: the shorter window is the one hit first in practice, so
993
1108
  // the 5-hour limit wins a tie against the weekly one.
994
1109
  const myspan = Number.isFinite(w.spanMs) ? w.spanMs : Infinity;
@@ -1023,6 +1138,14 @@ function formatUSD(value) {
1023
1138
  return '$' + value.toFixed(3);
1024
1139
  }
1025
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
+
1026
1149
  function formatTokens(value) {
1027
1150
  if (!Number.isFinite(value)) return '-';
1028
1151
  if (value >= 1e9) return (value / 1e9).toFixed(1) + 'B';
@@ -1099,6 +1222,49 @@ function otherLimits(utilization, threshold) {
1099
1222
  return rows.sort((a, b) => b.percentUsed - a.percentUsed);
1100
1223
  }
1101
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
+
1102
1268
  function collect(now) {
1103
1269
  if (isCodex()) return codex.collect(now);
1104
1270
  return collectClaude(now);
@@ -1236,11 +1402,10 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
1236
1402
  // of its two windows in the payload, so it hands its own spans in rather than
1237
1403
  // having them assumed.
1238
1404
  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;
1405
+ const limits = limitWindows(utilization);
1406
+ const limitByKey = new Map(limits.map((limit) => [limit.key, limit]));
1243
1407
 
1408
+ const one = (spec, snapshot, own, limit) => {
1244
1409
  const known = learned ? learned[spec.key] : null;
1245
1410
  const refusal = refused.get(spec.key);
1246
1411
 
@@ -1261,34 +1426,91 @@ function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejec
1261
1426
  }
1262
1427
  }
1263
1428
 
1264
- const window = buildWindow(spec, anchored, events, now, {
1265
- fetchedAt,
1266
- knownCalibration: known,
1267
- ...(windowStart === undefined ? {} : { windowStart }),
1268
- });
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
+ );
1269
1448
  if (refusal) {
1270
1449
  window.refusedAt = refusal.at;
1271
1450
  window.refusedResetsAt = refusal.resetsAt;
1272
1451
  }
1452
+
1453
+ let result = window;
1273
1454
  if (!window.stale) {
1274
- 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
+ }
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;
1275
1477
  }
1478
+ return result;
1479
+ };
1276
1480
 
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,
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
+ };
1289
1491
  }
1290
- );
1291
- }).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;
1292
1514
  }
1293
1515
 
1294
1516
  // What one session has spent, out of everything on record.
@@ -1296,12 +1518,14 @@ function sessionSpend(events, sessionId) {
1296
1518
  if (!sessionId) return null;
1297
1519
  let cost = 0;
1298
1520
  let turns = 0;
1521
+ let tokens = 0;
1299
1522
  for (const event of events) {
1300
1523
  if (event.sessionId !== sessionId) continue;
1301
1524
  cost += event.cost;
1302
- turns += 1;
1525
+ tokens += event.tokens || 0;
1526
+ if (!event.sidechain) turns += 1;
1303
1527
  }
1304
- return turns ? { turns, cost } : null;
1528
+ return turns ? { turns, cost, tokens } : null;
1305
1529
  }
1306
1530
 
1307
1531
  async function report(now, options) {
@@ -1413,7 +1637,8 @@ async function report(now, options) {
1413
1637
  tokens: recent.tokens,
1414
1638
  effort: dominantEffort(recentEvents),
1415
1639
  },
1416
- measuredTurns: events.length,
1640
+ measuredTurns: mainThread(events).length,
1641
+ subagentTurns: events.length - mainThread(events).length,
1417
1642
  });
1418
1643
  }
1419
1644
 
@@ -1578,7 +1803,14 @@ function render(data) {
1578
1803
  (money ? padLeft('Left', 10) : '') + padLeft('Turns left', 12)
1579
1804
  );
1580
1805
  for (const window of data.windows) {
1581
- 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
+ : '';
1582
1814
  lines.push(
1583
1815
  ' ' + pad(window.label, 15) +
1584
1816
  padLeft(
@@ -1693,7 +1925,10 @@ function render(data) {
1693
1925
  } else {
1694
1926
  lines.push(' Recent pace no turns in the last hour');
1695
1927
  }
1696
- 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
+ );
1697
1932
 
1698
1933
  if (data.windows.some((window) => window.adjusted)) {
1699
1934
  lines.push(
@@ -1861,6 +2096,102 @@ function renderForecast(data, turns) {
1861
2096
  return lines.join('\n');
1862
2097
  }
1863
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
+
1864
2195
  async function main(argv) {
1865
2196
  // Settle the host before anything reads a file, so one run never mixes one
1866
2197
  // agent's percentages with the other's turns.
@@ -1872,6 +2203,29 @@ async function main(argv) {
1872
2203
  return 0;
1873
2204
  }
1874
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
+
1875
2229
  // Codex writes its meter into the session rollouts, so the cached reading is
1876
2230
  // only as fresh as the last request it made. Asking Codex itself is a second
1877
2231
  // and a child process, which is why it is opt-in rather than the default.
@@ -1915,13 +2269,8 @@ async function main(argv) {
1915
2269
  return 0;
1916
2270
  }
1917
2271
 
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
-
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.
1925
2274
  module.exports = {
1926
2275
  main,
1927
2276
  setHost,
@@ -1939,12 +2288,15 @@ module.exports = {
1939
2288
  costOf,
1940
2289
  tokensOf,
1941
2290
  eventFrom,
2291
+ promptFrom,
1942
2292
  readEvents,
2293
+ readCalibration,
1943
2294
  buildWindow,
1944
2295
  reconstructWindow,
1945
2296
  SATURATION_LIMIT,
1946
2297
  MIN_BASELINE_TURNS,
1947
2298
  buildWindows,
2299
+ limitWindows,
1948
2300
  lastRejections,
1949
2301
  bindingWindow,
1950
2302
  criticalOthers,
@@ -1962,7 +2314,11 @@ module.exports = {
1962
2314
  MIN_PACE_SAMPLE,
1963
2315
  formatDuration,
1964
2316
  formatUSD,
2317
+ formatMoney,
1965
2318
  formatCount,
2319
+ renderSessions,
2320
+ renderSession,
2321
+ pickSession,
1966
2322
  verdictLine,
1967
2323
  render,
1968
2324
  report,
@@ -1983,3 +2339,10 @@ module.exports = {
1983
2339
  formatTokens,
1984
2340
  PLANS,
1985
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
+ }