claude-usage-limits 1.26.0 → 1.39.2

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.
@@ -71,14 +71,14 @@ function bindingFromCollect(collected, now) {
71
71
  function armAtCompletion(now, hookInput, sessionId) {
72
72
  let state;
73
73
  try {
74
- state = relay.read();
74
+ state = (relay.reapLost(Date.now()), relay.read());
75
75
  } catch (err) {
76
76
  return null;
77
77
  }
78
78
  const config = relay.settings(state);
79
79
  if (!config.enabled || config.armOn !== 'completion') return null;
80
80
  // Already carrying this session forward: nothing to decide.
81
- if (state.armed && state.armed.id === sessionId) return null;
81
+ if (relay.armedFor(state, sessionId)) return null;
82
82
 
83
83
  const transcript = hookInput && hookInput.transcript_path ? hookInput.transcript_path : null;
84
84
  const work = relay.detectWork(transcript, {});
@@ -188,6 +188,14 @@ function update(all, sessionId, transcriptPath, now, options) {
188
188
  subagentFiles(transcriptPath, sessionId).map((file) => ({ file, sidechain: true }))
189
189
  );
190
190
 
191
+ // Each subagent transcript is priced at the model its own records name. One
192
+ // that names none is priced at this session's model: the newest main-thread
193
+ // turn read here, or failing that the tail of the session transcript, read
194
+ // only if a record actually asks.
195
+ let mainModel = null;
196
+ const readParent = usage.sessionModelReader(transcriptPath);
197
+ const sessionModel = () => mainModel || readParent();
198
+
191
199
  for (const entry of files) {
192
200
  const key = path.resolve(entry.file);
193
201
  const read = readNewLines(entry.file, session.cursors[key]);
@@ -197,8 +205,9 @@ function update(all, sessionId, transcriptPath, now, options) {
197
205
  session.prompts += 1;
198
206
  continue;
199
207
  }
200
- const event = usage.eventFrom(line, seen, project);
208
+ const event = usage.eventFrom(line, seen, project, entry.sidechain ? sessionModel : null);
201
209
  if (!event || event.rejected) continue;
210
+ if (!entry.sidechain && !event.sidechain && event.model) mainModel = event.model;
202
211
  apply(session, event, entry.sidechain || Boolean(event.sidechain), delta);
203
212
  }
204
213
  session.cursors[key] = read.next;
@@ -386,7 +386,14 @@ function tokenParts(usage) {
386
386
  }
387
387
 
388
388
  // One transcript line to an event, or null if it is not a billable turn.
389
- function eventFrom(line, seen, project) {
389
+ //
390
+ // Priced at the model the line itself names. A subagent's transcript names its
391
+ // own model on every assistant record - a Sonnet agent under a Fable session
392
+ // says claude-sonnet-5 - and that, not the session's model, is what its tokens
393
+ // cost. `fallbackModel` is only for a record that names none: a string, or a
394
+ // function called at most once so the parent transcript is read only when a
395
+ // record actually needs it.
396
+ function eventFrom(line, seen, project, fallbackModel) {
390
397
  if (line.indexOf('"assistant"') === -1 || line.indexOf('"usage"') === -1) return null;
391
398
  let entry;
392
399
  try {
@@ -442,15 +449,20 @@ function eventFrom(line, seen, project) {
442
449
  }
443
450
 
444
451
  const parts = tokenParts(entry.message.usage);
452
+ let model = typeof entry.message.model === 'string' ? entry.message.model : '';
453
+ if (!model && fallbackModel) {
454
+ const assumed = typeof fallbackModel === 'function' ? fallbackModel() : fallbackModel;
455
+ if (typeof assumed === 'string') model = assumed;
456
+ }
445
457
  return {
446
458
  at,
447
459
  // Carried on the event so the scan cache can dedup across files without
448
460
  // re-parsing them: a resumed or forked session repeats earlier turns, and
449
461
  // a cached file is never read again to find that out.
450
462
  dedupId: id === '|' ? null : id,
451
- model: entry.message.model || '',
463
+ model,
452
464
  effort: entry.effort || null,
453
- cost: costOf(entry.message.usage, entry.message.model),
465
+ cost: costOf(entry.message.usage, model),
454
466
  tokens: tokensOf(entry.message.usage),
455
467
  parts,
456
468
  // What the model was shown on this call: everything except what it wrote.
@@ -631,8 +643,11 @@ function claudeTranscriptFiles(since) {
631
643
  // A session's subagents write their transcripts under
632
644
  // <project>/<session id>/subagents/. Same budget, different file, and
633
645
  // for a long time an Explore or Plan agent's whole spend went unseen.
646
+ // The directory is named for the session, and the session's own
647
+ // transcript sits beside it: that is the model a subagent record
648
+ // falls back to when it names none of its own.
634
649
  for (const file of subagentTranscripts(path.join(full, entry.name, 'subagents'), since)) {
635
- files.push({ file, project: dir.name });
650
+ files.push({ file, project: dir.name, parent: path.join(full, entry.name + '.jsonl') });
636
651
  }
637
652
  continue;
638
653
  }
@@ -725,7 +740,34 @@ function liveEffort(sessionId) {
725
740
  // slot on the machine - which on 2026-09-08 was a 19-hour-old Opus session,
726
741
  // shown over a Fable one.
727
742
  function liveModel(sessionId) {
728
- const found = sessionTranscriptFile(sessionId);
743
+ return modelFromTail(sessionTranscriptFile(sessionId));
744
+ }
745
+
746
+ // The same answer for a transcript named by path rather than by session id.
747
+ function transcriptModel(file) {
748
+ let stat;
749
+ try {
750
+ stat = fs.statSync(file);
751
+ } catch (err) {
752
+ return null;
753
+ }
754
+ return modelFromTail({ file, at: stat.mtimeMs, size: stat.size });
755
+ }
756
+
757
+ // The fallback a subagent record without a model is priced at: the parent
758
+ // session's model, looked up once and only when asked for.
759
+ function sessionModelReader(parentTranscript) {
760
+ let resolved;
761
+ return () => {
762
+ if (resolved === undefined) {
763
+ const seen = transcriptModel(parentTranscript);
764
+ resolved = seen && seen.model ? seen.model : null;
765
+ }
766
+ return resolved;
767
+ };
768
+ }
769
+
770
+ function modelFromTail(found) {
729
771
  if (!found) return null;
730
772
  const from = Math.max(0, found.size - EFFORT_TAIL_BYTES);
731
773
  const text = readSlice(found.file, from, found.size).toString('utf8');
@@ -933,13 +975,13 @@ function unpackEvent(row, project) {
933
975
  // the offset has to be a byte count: a transcript is full of characters that
934
976
  // are more than one byte, and counting them as one would drift the offset and
935
977
  // silently drop turns. A trailing partial line is left unconsumed.
936
- function parseSlice(buffer, project, baseOffset) {
978
+ function parseSlice(buffer, project, baseOffset, fallbackModel) {
937
979
  const events = [];
938
980
  let start = 0;
939
981
  let consumed = 0;
940
982
  for (let i = 0; i < buffer.length; i += 1) {
941
983
  if (buffer[i] !== 0x0a) continue;
942
- const event = eventFrom(buffer.toString('utf8', start, i), null, project);
984
+ const event = eventFrom(buffer.toString('utf8', start, i), null, project, fallbackModel);
943
985
  if (event) events.push(event);
944
986
  start = i + 1;
945
987
  consumed = start;
@@ -974,7 +1016,15 @@ function eventsForFile(entry, cache, keepFrom) {
974
1016
  }
975
1017
 
976
1018
  const from = usable ? cached.offset : 0;
977
- const parsed = parseSlice(readSlice(entry.file, from, stat.size), entry.project, from);
1019
+ // A subagent record that names no model is priced at its session's model,
1020
+ // read from the tail of the parent transcript - and read only if such a
1021
+ // record turns up, which on 14,778 cached rows here it never has.
1022
+ const parsed = parseSlice(
1023
+ readSlice(entry.file, from, stat.size),
1024
+ entry.project,
1025
+ from,
1026
+ entry.parent ? sessionModelReader(entry.parent) : null
1027
+ );
978
1028
  const rows = (usable ? cached.rows : [])
979
1029
  .concat(parsed.events.map(packEvent))
980
1030
  .filter((row) => row[1] >= keepFrom);
@@ -1074,18 +1124,32 @@ async function readClaudeEvents(since, options) {
1074
1124
  if (changed) writeScanCache(cache);
1075
1125
  }
1076
1126
 
1077
- const seen = new Set();
1127
+ // One event per message id, and it is the LAST line that carries it.
1128
+ //
1129
+ // A message that streams several content blocks is written as several
1130
+ // lines sharing one message id, each with a usage block, and in subagent
1131
+ // transcripts only the last has the final output count: measured on
1132
+ // 2026-09-21 across 179 agent files, 1,991 of 2,504 such groups ran like
1133
+ // [1, 1, 202], so keeping the first block priced a 202-token answer at one
1134
+ // token. Main transcripts repeat the same figure on every line (1,811
1135
+ // groups, all identical), so for them this is the same answer either way.
1136
+ const seen = new Map();
1078
1137
  const events = [];
1079
1138
  for (const held of collected) {
1080
1139
  for (const row of held.rows) {
1081
1140
  if (row[1] < since) continue;
1082
1141
  const id = row[0] === ROW_TURN ? row[13] : '';
1142
+ const event = unpackEvent(row, held.project);
1143
+ if (!event) continue;
1083
1144
  if (id) {
1084
- if (seen.has(id)) continue;
1085
- seen.add(id);
1145
+ const index = seen.get(id);
1146
+ if (index !== undefined) {
1147
+ events[index] = event;
1148
+ continue;
1149
+ }
1150
+ seen.set(id, events.length);
1086
1151
  }
1087
- const event = unpackEvent(row, held.project);
1088
- if (event) events.push(event);
1152
+ events.push(event);
1089
1153
  }
1090
1154
  }
1091
1155
 
@@ -2636,16 +2700,20 @@ function limitWindows(utilization) {
2636
2700
  // So: the bucket keys first, the `limits` array for anything they did not
2637
2701
  // cover, real labels on both, corrections applied, and the same applies-flag
2638
2702
  // every other reader makes its decisions from.
2639
- function snapshotWindows(collected, now, codexHome) {
2703
+ function snapshotWindows(collected, now, codexHome, extraModels) {
2640
2704
  const utilization = collected && collected.utilization;
2641
2705
  if (!utilization) return [];
2642
2706
  const at = Number.isFinite(now) ? now : Date.now();
2643
2707
  // No transcripts here on purpose - that is the cost being avoided - so the
2644
2708
  // setting is the only thing that says which model is running. It is enough
2645
2709
  // to keep a weekly for a model this agent is not using out of a worst-of.
2710
+ // A caller that knows what the session is actually running - the pulse reads
2711
+ // it off the transcript tail - adds it, so a /model switch the setting never
2712
+ // heard about cannot suppress the window being spent.
2646
2713
  const families = familiesInUse(null, null, [
2647
2714
  collected.settings && collected.settings.model,
2648
2715
  process.env.ANTHROPIC_MODEL,
2716
+ ...(Array.isArray(extraModels) ? extraModels : []),
2649
2717
  ]);
2650
2718
  const specs = collected.windowSpecs && collected.windowSpecs.length ? collected.windowSpecs : WINDOWS;
2651
2719
  const byKey = new Map();
@@ -2777,6 +2845,23 @@ function accountUuid() {
2777
2845
  : null;
2778
2846
  }
2779
2847
 
2848
+ // Per window key, the percentage each account source reports and which one
2849
+ // the report is using: { five_hour: { cache: 14, live: 20, used: 'live' } }.
2850
+ // Null unless both sources have the window.
2851
+ function sourcePercents(cache, fresh, used) {
2852
+ if (!cache || !cache.utilization || typeof cache.utilization !== 'object') return null;
2853
+ if (!fresh || !fresh.utilization || typeof fresh.utilization !== 'object') return null;
2854
+ const out = {};
2855
+ for (const key of Object.keys(cache.utilization)) {
2856
+ const a = cache.utilization[key];
2857
+ const b = fresh.utilization[key];
2858
+ if (a && b && typeof a.utilization === 'number' && typeof b.utilization === 'number') {
2859
+ out[key] = { cache: a.utilization, live: b.utilization, used };
2860
+ }
2861
+ }
2862
+ return Object.keys(out).length ? out : null;
2863
+ }
2864
+
2780
2865
  function collectClaude(now) {
2781
2866
  const account = readJson(accountFile()) || {};
2782
2867
  const settings = readJson(path.join(configDir(), 'settings.json')) || {};
@@ -2790,6 +2875,12 @@ function collectClaude(now) {
2790
2875
  const snapshot = useLive ? fresh : cache;
2791
2876
  const utilization = snapshot && snapshot.utilization ? snapshot.utilization : null;
2792
2877
  const plan = detectPlan(oauth);
2878
+ // The reading that was not chosen, kept beside the one that was, so a
2879
+ // window can be described by both when they disagree. A live file for
2880
+ // another account or from a clock that is ahead is not a second reading of
2881
+ // this account and is left out, by the same rule that keeps it from being
2882
+ // preferred.
2883
+ const comparable = fresh && preferLive(null, fresh, oauth.accountUuid, now) ? fresh : null;
2793
2884
 
2794
2885
  return {
2795
2886
  now,
@@ -2803,6 +2894,7 @@ function collectClaude(now) {
2803
2894
  snapshotAgeMs: snapshot && snapshot.fetchedAtMs ? now - snapshot.fetchedAtMs : null,
2804
2895
  snapshotFetchedAt: snapshot && snapshot.fetchedAtMs ? snapshot.fetchedAtMs : null,
2805
2896
  snapshotSource: utilization ? (useLive ? 'live' : 'cache') : null,
2897
+ sources: sourcePercents(cache, comparable, useLive ? 'live' : 'cache'),
2806
2898
  utilization,
2807
2899
  settings: {
2808
2900
  model: settings.model || 'default',
@@ -3141,6 +3233,11 @@ async function report(now, options) {
3141
3233
  // exists.
3142
3234
  calibrated.planChanged ? new Map() : rejections
3143
3235
  );
3236
+ // Both account readings ride on the window, so whatever describes it can
3237
+ // name them when they disagree.
3238
+ if (base.sources) {
3239
+ for (const window of windows) if (base.sources[window.key]) window.sources = base.sources[window.key];
3240
+ }
3144
3241
 
3145
3242
  // Which of those windows this agent can actually spend into. Everything that
3146
3243
  // ranks or warns about a window reads the answer off the window itself.
@@ -4180,6 +4277,8 @@ module.exports = {
4180
4277
  sessionTranscriptFile,
4181
4278
  liveEffort,
4182
4279
  liveModel,
4280
+ transcriptModel,
4281
+ sessionModelReader,
4183
4282
  EFFORT_TAIL_BYTES,
4184
4283
  scanFile,
4185
4284
  readScanCache,