claude-usage-limits 1.11.7 → 1.13.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.
@@ -24,6 +24,7 @@ const readline = require('readline');
24
24
  const host = require('./host.js');
25
25
  const codex = require('./codex.js');
26
26
  const live = require('./live.js');
27
+ const reading = require('./reading.js');
27
28
 
28
29
  // Which agent's meter to read. Resolved once from the command line or the
29
30
  // environment, because a process that changed its mind halfway through would
@@ -439,6 +440,10 @@ function eventFrom(line, seen, project) {
439
440
  const parts = tokenParts(entry.message.usage);
440
441
  return {
441
442
  at,
443
+ // Carried on the event so the scan cache can dedup across files without
444
+ // re-parsing them: a resumed or forked session repeats earlier turns, and
445
+ // a cached file is never read again to find that out.
446
+ dedupId: id === '|' ? null : id,
442
447
  model: entry.message.model || '',
443
448
  effort: entry.effort || null,
444
449
  cost: costOf(entry.message.usage, entry.message.model),
@@ -479,9 +484,9 @@ function promptFrom(line) {
479
484
  return typed;
480
485
  }
481
486
 
482
- async function readEvents(since) {
483
- if (isCodex()) return codex.readEvents(since);
484
- return readClaudeEvents(since);
487
+ async function readEvents(since, options) {
488
+ if (isCodex()) return codex.readEvents(since, options);
489
+ return readClaudeEvents(since, options);
485
490
  }
486
491
 
487
492
  // A file last touched before the window opened holds nothing useful.
@@ -529,7 +534,7 @@ function subagentTranscripts(dir, since, depth) {
529
534
  return files;
530
535
  }
531
536
 
532
- async function readClaudeEvents(since) {
537
+ function claudeTranscriptFiles(since) {
533
538
  const root = path.join(configDir(), 'projects');
534
539
  let dirs = [];
535
540
  try {
@@ -564,26 +569,459 @@ async function readClaudeEvents(since) {
564
569
  files.push({ file, project: dir.name });
565
570
  }
566
571
  }
572
+ return files;
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // The effort level in force right now
577
+ // ---------------------------------------------------------------------------
578
+ //
579
+ // Claude Code stamps `effort` on every assistant line it writes, which makes
580
+ // the transcript the only source that is always current. It follows /effort
581
+ // the moment the model answers; it exists for a session that has no status
582
+ // line at all, which is every VS Code window; and it can say "max", which
583
+ // settings.json is not allowed to hold at all.
584
+ //
585
+ // Reading it off settings.json instead is what made the panel insist on
586
+ // "xhigh" through a whole session running at max, and made a session with no
587
+ // status line show the setting rather than the session.
588
+ //
589
+ // Only the tail is read, and only whole lines from it are parsed, so a
590
+ // megabyte is enough however long the transcript grows.
591
+ const EFFORT_TAIL_BYTES = 1024 * 1024;
592
+ const EFFORT_TAIL_LINES = 60;
593
+
594
+ function sessionTranscriptFile(sessionId) {
595
+ if (!sessionId) return null;
596
+ const root = path.join(configDir(), 'projects');
597
+ let dirs = [];
598
+ try {
599
+ dirs = fs.readdirSync(root, { withFileTypes: true });
600
+ } catch (err) {
601
+ return null;
602
+ }
603
+ let best = null;
604
+ for (const dir of dirs) {
605
+ if (!dir.isDirectory()) continue;
606
+ const file = path.join(root, dir.name, sessionId + '.jsonl');
607
+ let stat;
608
+ try {
609
+ stat = fs.statSync(file);
610
+ } catch (err) {
611
+ continue;
612
+ }
613
+ // A session id is unique, but a resumed session can leave a copy under an
614
+ // older project directory; the one being written to is the live one.
615
+ if (!best || stat.mtimeMs > best.at) best = { file, at: stat.mtimeMs, size: stat.size };
616
+ }
617
+ return best;
618
+ }
619
+
620
+ // The newest effort this session ran at, with the time it was stamped, so a
621
+ // caller holding a status-line reading can take whichever is newer.
622
+ function liveEffort(sessionId) {
623
+ const found = sessionTranscriptFile(sessionId);
624
+ if (!found) return null;
625
+ const from = Math.max(0, found.size - EFFORT_TAIL_BYTES);
626
+ const text = readSlice(found.file, from, found.size).toString('utf8');
627
+ const lines = text.split('\n');
628
+ // The first line of a mid-file slice is a fragment, and the last is whatever
629
+ // was half-written when the read happened. Neither is parsed.
630
+ const start = Math.max(from > 0 ? 1 : 0, lines.length - EFFORT_TAIL_LINES);
631
+ for (let i = lines.length - 1; i >= start; i -= 1) {
632
+ const line = lines[i];
633
+ if (!line || line.indexOf('"effort"') === -1) continue;
634
+ let entry;
635
+ try {
636
+ entry = JSON.parse(line);
637
+ } catch (err) {
638
+ // A fragment, or the half-written tail.
639
+ continue;
640
+ }
641
+ if (!entry || typeof entry.effort !== 'string' || !entry.effort) continue;
642
+ const at = Date.parse(entry.timestamp);
643
+ return { effort: entry.effort, at: Number.isFinite(at) ? at : found.at };
644
+ }
645
+ return null;
646
+ }
647
+
648
+ // The model a session is running, from the tail of its own transcript. Every
649
+ // assistant line carries message.model, so this is the session's word rather
650
+ // than a setting's or another window's. The VS Code panel has no status line
651
+ // of its own to ask, and until this existed it borrowed the newest status-line
652
+ // slot on the machine - which on 2026-09-08 was a 19-hour-old Opus session,
653
+ // shown over a Fable one.
654
+ function liveModel(sessionId) {
655
+ const found = sessionTranscriptFile(sessionId);
656
+ if (!found) return null;
657
+ const from = Math.max(0, found.size - EFFORT_TAIL_BYTES);
658
+ const text = readSlice(found.file, from, found.size).toString('utf8');
659
+ const lines = text.split('\n');
660
+ const start = Math.max(from > 0 ? 1 : 0, lines.length - EFFORT_TAIL_LINES);
661
+ for (let i = lines.length - 1; i >= start; i -= 1) {
662
+ const line = lines[i];
663
+ if (!line || line.indexOf('"assistant"') === -1 || line.indexOf('"model"') === -1) continue;
664
+ let entry;
665
+ try {
666
+ entry = JSON.parse(line);
667
+ } catch (err) {
668
+ continue;
669
+ }
670
+ const model = entry && entry.message && typeof entry.message.model === 'string' ? entry.message.model : null;
671
+ if (!model || model === '<synthetic>') continue;
672
+ const at = Date.parse(entry.timestamp);
673
+ return { model, at: Number.isFinite(at) ? at : found.at };
674
+ }
675
+ return null;
676
+ }
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // The scan cache
680
+ // ---------------------------------------------------------------------------
681
+ //
682
+ // Reading every transcript from the start, every time, is what the scan used to
683
+ // do, and it stopped being affordable the moment workflows arrived. One machine
684
+ // here had 950 subagent transcripts totalling 114 MB inside the eight-day
685
+ // window, and a full scan took 200 seconds. The prompt hook is given ten, so it
686
+ // was killed on every prompt and the reported percentage froze at whatever a
687
+ // cached view last said: 3% while the account was at 32%.
688
+ //
689
+ // Transcripts are append-only, and almost all of them are finished. So each
690
+ // file's parsed events are kept, keyed by its size and mtime, and a file that
691
+ // has grown is read only from where the last read stopped. The offset is
692
+ // counted in bytes, at the last complete line, so a half-written tail is simply
693
+ // read again next time.
694
+ const SCAN_VERSION = 1;
695
+ // A day wider than the widest window anything asks for, so a report never wants
696
+ // an event the cache has just pruned.
697
+ const SCAN_KEEP_MS = 9 * DAY;
698
+ // Enough for weeks of heavy use; the oldest go first if it is ever reached.
699
+ const SCAN_MAX_EVENTS = 250000;
700
+
701
+ function scanFile() {
702
+ return path.join(configDir(), 'usage-limits-scan.json');
703
+ }
704
+
705
+ function readScanCache() {
706
+ const parsed = readJson(scanFile());
707
+ if (!parsed || parsed.version !== SCAN_VERSION || !parsed.files || typeof parsed.files !== 'object') {
708
+ return { version: SCAN_VERSION, files: {} };
709
+ }
710
+ return { version: SCAN_VERSION, files: parsed.files };
711
+ }
712
+
713
+ // Every state file the plugin shares between processes goes through here.
714
+ //
715
+ // Two windows run the same hooks at the same moment, and the status line runs
716
+ // every few hundred milliseconds. A plain writeFileSync is a truncate followed
717
+ // by a write, and a reader that lands between the two sees an empty or
718
+ // half-written file, parses nothing, and - for anything read, changed and
719
+ // written back, like the tally - then writes its own slot over everyone
720
+ // else's. Writing beside the file and renaming into place means every read
721
+ // sees either the old whole or the new whole. A rename Windows refuses leaves
722
+ // nothing behind, and the caller carries on with what is on disk.
723
+ function writeJsonAtomic(file, value) {
724
+ const temp = file + '.' + process.pid + '.usage-limits-tmp';
725
+ try {
726
+ fs.mkdirSync(path.dirname(file), { recursive: true });
727
+ fs.writeFileSync(temp, JSON.stringify(value), 'utf8');
728
+ fs.renameSync(temp, file);
729
+ return true;
730
+ } catch (err) {
731
+ try {
732
+ fs.unlinkSync(temp);
733
+ } catch (gone) {
734
+ // Nothing to clean up.
735
+ }
736
+ return false;
737
+ }
738
+ }
739
+
740
+ // Losing the scan cache costs one slow scan, never a wrong number.
741
+ function writeScanCache(cache) {
742
+ return writeJsonAtomic(scanFile(), cache);
743
+ }
744
+
745
+ // A byte range of a file, without pulling the whole thing into memory.
746
+ function readSlice(file, start, end) {
747
+ const length = Math.max(0, end - start);
748
+ if (!length) return Buffer.alloc(0);
749
+ let fd;
750
+ try {
751
+ fd = fs.openSync(file, 'r');
752
+ } catch (err) {
753
+ return Buffer.alloc(0);
754
+ }
755
+ try {
756
+ const buffer = Buffer.allocUnsafe(length);
757
+ let read = 0;
758
+ while (read < length) {
759
+ const got = fs.readSync(fd, buffer, read, length - read, start + read);
760
+ if (got <= 0) break;
761
+ read += got;
762
+ }
763
+ return read === length ? buffer : buffer.subarray(0, read);
764
+ } catch (err) {
765
+ return Buffer.alloc(0);
766
+ } finally {
767
+ try {
768
+ fs.closeSync(fd);
769
+ } catch (err) {
770
+ // Already closed.
771
+ }
772
+ }
773
+ }
774
+
775
+ // Events are kept as tuples rather than objects, because in a file of ten
776
+ // thousand of them the key names alone were two thirds of the bytes. The
777
+ // project is a property of the file, so it is stored once on the entry and put
778
+ // back on the way out.
779
+ const ROW_TURN = 0;
780
+ const ROW_REFUSAL = 1;
781
+
782
+ function whole(value) {
783
+ const number = Number(value);
784
+ return Number.isFinite(number) ? number : 0;
785
+ }
786
+
787
+ function packEvent(event) {
788
+ if (event.rejected) {
789
+ const rejected = event.rejected;
790
+ return [
791
+ ROW_REFUSAL,
792
+ event.at,
793
+ event.sessionId || '',
794
+ rejected.status || '',
795
+ rejected.key || '',
796
+ Number.isFinite(rejected.resetsAt) ? rejected.resetsAt : 0,
797
+ ];
798
+ }
799
+ const parts = event.parts || {};
800
+ return [
801
+ ROW_TURN,
802
+ event.at,
803
+ whole(event.cost),
804
+ whole(event.tokens),
805
+ whole(parts.input),
806
+ whole(parts.cacheWrite),
807
+ whole(parts.cacheRead),
808
+ whole(parts.output),
809
+ whole(parts.reasoning),
810
+ event.sidechain ? 1 : 0,
811
+ event.model || '',
812
+ event.sessionId || '',
813
+ event.effort || '',
814
+ event.dedupId || '',
815
+ ];
816
+ }
817
+
818
+ function unpackEvent(row, project) {
819
+ if (!Array.isArray(row)) return null;
820
+ if (row[0] === ROW_REFUSAL) {
821
+ return {
822
+ at: row[1],
823
+ model: '',
824
+ effort: null,
825
+ cost: 0,
826
+ tokens: 0,
827
+ parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 },
828
+ project: project || null,
829
+ sessionId: row[2] || null,
830
+ rejected: {
831
+ status: row[3] || null,
832
+ key: row[4] || null,
833
+ resetsAt: row[5] || null,
834
+ },
835
+ };
836
+ }
837
+ const parts = {
838
+ input: row[4],
839
+ cacheWrite: row[5],
840
+ cacheRead: row[6],
841
+ output: row[7],
842
+ reasoning: row[8],
843
+ };
844
+ return {
845
+ at: row[1],
846
+ dedupId: row[13] || null,
847
+ model: row[10] || '',
848
+ effort: row[12] || null,
849
+ cost: row[2],
850
+ tokens: row[3],
851
+ parts,
852
+ context: parts.input + parts.cacheRead + parts.cacheWrite,
853
+ project: project || null,
854
+ sessionId: row[11] || null,
855
+ sidechain: row[9] === 1,
856
+ };
857
+ }
858
+
859
+ // Split on newlines in the buffer itself rather than after decoding, because
860
+ // the offset has to be a byte count: a transcript is full of characters that
861
+ // are more than one byte, and counting them as one would drift the offset and
862
+ // silently drop turns. A trailing partial line is left unconsumed.
863
+ function parseSlice(buffer, project, baseOffset) {
864
+ const events = [];
865
+ let start = 0;
866
+ let consumed = 0;
867
+ for (let i = 0; i < buffer.length; i += 1) {
868
+ if (buffer[i] !== 0x0a) continue;
869
+ const event = eventFrom(buffer.toString('utf8', start, i), null, project);
870
+ if (event) events.push(event);
871
+ start = i + 1;
872
+ consumed = start;
873
+ }
874
+ return { events, offset: baseOffset + consumed };
875
+ }
876
+
877
+ // One file's events, reusing whatever the cache already holds of it.
878
+ function eventsForFile(entry, cache, keepFrom) {
879
+ let stat;
880
+ try {
881
+ stat = fs.statSync(entry.file);
882
+ } catch (err) {
883
+ delete cache.files[entry.file];
884
+ return null;
885
+ }
886
+
887
+ const cached = cache.files[entry.file];
888
+ const usable =
889
+ cached &&
890
+ Array.isArray(cached.rows) &&
891
+ Number.isFinite(cached.offset) &&
892
+ Number.isFinite(cached.size) &&
893
+ // Appended to, or untouched. Anything else - a rewrite, a truncation, a
894
+ // clock that went backwards - is read again from the beginning, because
895
+ // the offset can no longer be trusted to point where it says.
896
+ stat.size >= cached.size &&
897
+ cached.offset <= stat.size;
898
+
899
+ if (usable && stat.size === cached.size && stat.mtimeMs === cached.mtimeMs) {
900
+ return { rows: cached.rows, project: cached.project || entry.project, changed: false };
901
+ }
902
+
903
+ const from = usable ? cached.offset : 0;
904
+ const parsed = parseSlice(readSlice(entry.file, from, stat.size), entry.project, from);
905
+ const rows = (usable ? cached.rows : [])
906
+ .concat(parsed.events.map(packEvent))
907
+ .filter((row) => row[1] >= keepFrom);
908
+ cache.files[entry.file] = {
909
+ mtimeMs: stat.mtimeMs,
910
+ size: stat.size,
911
+ offset: parsed.offset,
912
+ project: entry.project,
913
+ rows,
914
+ };
915
+ return { rows, project: entry.project, changed: true };
916
+ }
917
+
918
+ async function readClaudeEvents(since, options) {
919
+ const opts = options || {};
920
+ const startedAt = Date.now();
921
+ // Zero is a real budget - spent before the first file - not "no budget";
922
+ // absent or negative means unlimited. A test relies on zero being exact,
923
+ // which Date.now() cannot promise: a scan of a few small files fits inside
924
+ // one millisecond tick, so "elapsed > 0" stayed false all the way through
925
+ // and the scan reported a complete total it had no right to. The budget is
926
+ // measured on the monotonic sub-millisecond clock, and spent means reached.
927
+ const clock = performance.now();
928
+ const budgetMs = Number.isFinite(opts.budgetMs) && opts.budgetMs >= 0 ? opts.budgetMs : null;
929
+ const keepFrom = Math.min(since, startedAt - SCAN_KEEP_MS);
930
+
931
+ const files = claudeTranscriptFiles(since);
932
+ // Newest first, so a scan that runs out of time has done the files that
933
+ // describe the window running now rather than the ones from last Tuesday.
934
+ const ordered = files
935
+ .map((entry) => {
936
+ let at = 0;
937
+ try {
938
+ at = fs.statSync(entry.file).mtimeMs;
939
+ } catch (err) {
940
+ at = 0;
941
+ }
942
+ return Object.assign({ at }, entry);
943
+ })
944
+ .sort((a, b) => b.at - a.at);
945
+
946
+ const cache = opts.cache === false ? { version: SCAN_VERSION, files: {} } : readScanCache();
947
+ const collected = [];
948
+ const alive = new Set();
949
+ let changed = false;
950
+ let partial = false;
951
+
952
+ for (const entry of ordered) {
953
+ if (budgetMs !== null && performance.now() - clock >= budgetMs) {
954
+ partial = true;
955
+ break;
956
+ }
957
+ alive.add(entry.file);
958
+ const result = eventsForFile(entry, cache, keepFrom);
959
+ if (!result) {
960
+ changed = true;
961
+ continue;
962
+ }
963
+ if (result.changed) changed = true;
964
+ collected.push(result);
965
+ }
966
+
967
+ // Files that have gone cold or been deleted, and anything left over the cap.
968
+ if (opts.cache !== false) {
969
+ let total = 0;
970
+ for (const file of Object.keys(cache.files)) {
971
+ const held = cache.files[file];
972
+ if (!held || !Array.isArray(held.rows)) {
973
+ delete cache.files[file];
974
+ changed = true;
975
+ continue;
976
+ }
977
+ // Past the window is gone whatever happened. An entry the scan merely
978
+ // did not reach is dropped only when the scan was complete: a partial
979
+ // one has not seen every file and must not be what decides they are dead.
980
+ const tooOld = !(Number.isFinite(held.mtimeMs) && held.mtimeMs >= keepFrom);
981
+ if (tooOld || (!partial && !alive.has(file))) {
982
+ delete cache.files[file];
983
+ changed = true;
984
+ continue;
985
+ }
986
+ total += held.rows.length;
987
+ }
988
+ if (total > SCAN_MAX_EVENTS) {
989
+ const oldest = Object.keys(cache.files).sort(
990
+ (a, b) => (cache.files[a].mtimeMs || 0) - (cache.files[b].mtimeMs || 0)
991
+ );
992
+ for (const file of oldest) {
993
+ if (total <= SCAN_MAX_EVENTS) break;
994
+ total -= cache.files[file].rows.length;
995
+ delete cache.files[file];
996
+ changed = true;
997
+ }
998
+ }
999
+ // Through a temporary file named for this process, so two windows running
1000
+ // the hook at the same moment cannot interleave a half-written cache.
1001
+ if (changed) writeScanCache(cache);
1002
+ }
567
1003
 
568
1004
  const seen = new Set();
569
1005
  const events = [];
570
- for (const entry of files) {
571
- const stream = fs.createReadStream(entry.file, { encoding: 'utf8' });
572
- const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
573
- try {
574
- for await (const line of lines) {
575
- const event = eventFrom(line, seen, entry.project);
576
- if (event && event.at >= since) events.push(event);
1006
+ for (const held of collected) {
1007
+ for (const row of held.rows) {
1008
+ if (row[1] < since) continue;
1009
+ const id = row[0] === ROW_TURN ? row[13] : '';
1010
+ if (id) {
1011
+ if (seen.has(id)) continue;
1012
+ seen.add(id);
577
1013
  }
578
- } catch (err) {
579
- // A half-written line at the tail of a live session is expected.
580
- } finally {
581
- lines.close();
582
- stream.destroy();
1014
+ const event = unpackEvent(row, held.project);
1015
+ if (event) events.push(event);
583
1016
  }
584
1017
  }
585
1018
 
586
1019
  events.sort((a, b) => a.at - b.at);
1020
+ if (partial) {
1021
+ // Non-enumerable, so nothing that iterates or serialises the events can
1022
+ // trip over it; the report reads it to say the correction may be short.
1023
+ Object.defineProperty(events, 'partial', { value: true, enumerable: false });
1024
+ }
587
1025
  return events;
588
1026
  }
589
1027
 
@@ -656,6 +1094,12 @@ const MIN_PACE_SAMPLE = 5;
656
1094
  // ordinary turn, and treating that as "the" turn cost sends the headroom
657
1095
  // estimate swinging: a single $7 turn once put a 13% full window at nine turns
658
1096
  // left. Too few recent turns to be sure, so widen to the whole window.
1097
+ // A turn that fanned out ten agents is real, and so is the budget it spent, but
1098
+ // it should not price every remaining turn as though it will do the same. Five
1099
+ // is generous enough to catch a habitually agent-heavy session and mean enough
1100
+ // that one workflow does not flatten the estimate to nothing.
1101
+ const SUBAGENT_FACTOR_MAX = 5;
1102
+
659
1103
  function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
660
1104
  const floor = Number.isFinite(minSample) ? minSample : MIN_PACE_SAMPLE;
661
1105
 
@@ -663,14 +1107,20 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
663
1107
  // is being measured against, so a thin window borrows from a wider sample
664
1108
  // rather than inventing a figure from two turns. Subagent calls are left
665
1109
  // out: they are small and many, and would make a turn look cheap.
666
- const tiers = [mainThread(recentEvents), mainThread(windowEvents), mainThread(allEvents)];
1110
+ const sources = [recentEvents, windowEvents, allEvents];
1111
+ const tiers = sources.map((tier) => mainThread(tier));
667
1112
  let pool = [];
668
- for (const tier of tiers) {
669
- if (tier && tier.length >= floor) {
670
- pool = tier;
1113
+ let source = null;
1114
+ for (let i = 0; i < tiers.length; i++) {
1115
+ if (tiers[i] && tiers[i].length >= floor) {
1116
+ pool = tiers[i];
1117
+ source = sources[i];
671
1118
  break;
672
1119
  }
673
- if (tier && tier.length > pool.length) pool = tier;
1120
+ if (tiers[i] && tiers[i].length > pool.length) {
1121
+ pool = tiers[i];
1122
+ source = sources[i];
1123
+ }
674
1124
  }
675
1125
  const costs = pool
676
1126
  .map((event) => event.cost)
@@ -692,7 +1142,34 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
692
1142
  const cut = costs.length >= MIN_PACE_SAMPLE ? Math.max(1, Math.round(costs.length * 0.1)) : 0;
693
1143
  const kept = cut > 0 ? costs.slice(cut, costs.length - cut) : costs;
694
1144
  const middle = kept.length ? kept : costs;
695
- return middle.reduce((sum, cost) => sum + cost, 0) / middle.length;
1145
+ const perMainThreadTurn = middle.reduce((sum, cost) => sum + cost, 0) / middle.length;
1146
+
1147
+ // And then the part that was missing, which is why a fan-out session was
1148
+ // promised four hundred turns and got sixty.
1149
+ //
1150
+ // Subagent calls are excluded from the sample above for a good reason: they
1151
+ // are small and many, and counting each as a turn makes a turn look cheap.
1152
+ // But their spend does not disappear - it comes out of the same window. A
1153
+ // turn that dispatches three research agents costs what the agents cost, and
1154
+ // measuring only its main-thread half prices it as though they were free.
1155
+ //
1156
+ // Rather than attributing each call to a parent turn, which the transcripts
1157
+ // do not reliably say, scale by how much of this pool's spend the main
1158
+ // thread actually accounts for. If subagents were two thirds of it, a turn
1159
+ // costs three times its visible half. Clamped, because a single enormous
1160
+ // fan-out should not price every future turn as another one.
1161
+ const total = sumCost(source);
1162
+ const visible = sumCost(pool);
1163
+ const factor = visible > 0 && total > visible ? Math.min(SUBAGENT_FACTOR_MAX, total / visible) : 1;
1164
+ return perMainThreadTurn * factor;
1165
+ }
1166
+
1167
+ function sumCost(events) {
1168
+ let total = 0;
1169
+ for (const event of events || []) {
1170
+ if (event && Number.isFinite(event.cost) && event.cost > 0) total += event.cost;
1171
+ }
1172
+ return total;
696
1173
  }
697
1174
 
698
1175
  function dominantEffort(events) {
@@ -708,6 +1185,142 @@ function dominantEffort(events) {
708
1185
  return best ? best[0] : null;
709
1186
  }
710
1187
 
1188
+ // What a turn has actually cost at each effort level, measured separately.
1189
+ //
1190
+ // The blended cost per turn is the right answer to "what has this been
1191
+ // costing" and the wrong one the moment the effort changes. An account that
1192
+ // usually runs at medium and switches to ultra is priced at the medium rate
1193
+ // until enough ultra turns have landed to drag the average up - and on a plan
1194
+ // with a small window there is no "enough", because the window is gone first.
1195
+ //
1196
+ // That is not hypothetical either. A ChatGPT Plus account running gpt-6-astra
1197
+ // at ultra effort emptied a whole five-hour window on one ordinary task, with
1198
+ // this plugin reporting room the entire way, because every turn it had on
1199
+ // record was a cheaper one.
1200
+ //
1201
+ // Subagent calls are left out for the same reason turns leave them out: they
1202
+ // are errands, not turns, and averaging them in makes a turn look cheap.
1203
+ function effortRates(events) {
1204
+ const rows = new Map();
1205
+ for (const event of events || []) {
1206
+ if (!event || event.rejected || event.sidechain) continue;
1207
+ if (!event.effort) continue;
1208
+ if (!rows.has(event.effort)) {
1209
+ rows.set(event.effort, { effort: event.effort, turns: 0, cost: 0, tokens: 0, output: 0, reasoning: 0 });
1210
+ }
1211
+ const row = rows.get(event.effort);
1212
+ const parts = event.parts || {};
1213
+ row.turns += 1;
1214
+ row.cost += Number.isFinite(event.cost) ? event.cost : 0;
1215
+ row.tokens += Number.isFinite(event.tokens) ? event.tokens : 0;
1216
+ row.output += Number.isFinite(parts.output) ? parts.output : 0;
1217
+ row.reasoning += Number.isFinite(parts.reasoning) ? parts.reasoning : 0;
1218
+ }
1219
+ return [...rows.values()]
1220
+ .map((row) =>
1221
+ Object.assign(row, {
1222
+ perTurn: row.turns ? row.cost / row.turns : null,
1223
+ // What the effort setting actually moves. Cost per turn is dominated by
1224
+ // how big the context happened to be, which is why measuring it that
1225
+ // way can report "low" as dearer than "ultra": a small ultra turn on a
1226
+ // short context really did cost less than a huge low-effort one. The
1227
+ // output is the part the setting controls, and it is the dear part -
1228
+ // eight times the weight of fresh input on Codex's own meter.
1229
+ outputPerTurn: row.turns ? row.output / row.turns : null,
1230
+ reasoningPerTurn: row.turns ? row.reasoning / row.turns : null,
1231
+ })
1232
+ )
1233
+ .sort((a, b) => (a.outputPerTurn || 0) - (b.outputPerTurn || 0));
1234
+ }
1235
+
1236
+ // Enough turns at one effort to believe the figure at all.
1237
+ const MIN_EFFORT_SAMPLE = 3;
1238
+ // Dearer than the cheapest measured effort by this much before it is worth
1239
+ // saying anything. Below it the setting is not what is spending the budget.
1240
+ const EFFORT_DEARER_BY = 1.5;
1241
+ // At or under this many turns left at the CURRENT effort, say so whether or
1242
+ // not a cheaper effort has ever been measured.
1243
+ const FEW_TURNS_AT_EFFORT = 12;
1244
+
1245
+ // The other agent's meter, for the surfaces that draw both.
1246
+ //
1247
+ // view.js is required at call time rather than at the top of the file: it
1248
+ // requires this module back, and a cycle resolved at load time would hand it a
1249
+ // half-built exports object. By the time a report is being built both are
1250
+ // finished loading.
1251
+ function codexBlock(now) {
1252
+ if (isCodex()) return null;
1253
+ try {
1254
+ if (!host.codexHasSessions()) return null;
1255
+ const display = require('./view.js');
1256
+ const other = codex.collect(now);
1257
+ const block = display.buildCodex({
1258
+ now,
1259
+ utilization: other.utilization,
1260
+ fetchedAtMs: other.snapshotFetchedAt,
1261
+ windowSpecs: other.windowSpecs,
1262
+ plan: other.plan,
1263
+ windowless: other.windowless,
1264
+ unreadable: other.unreadable,
1265
+ });
1266
+ return block.present ? block : null;
1267
+ } catch (err) {
1268
+ return null;
1269
+ }
1270
+ }
1271
+
1272
+ // The warning that would have caught that incident: what the window holds at
1273
+ // the effort actually set, rather than at the average of everything ever run.
1274
+ function effortWarning(events, current, window) {
1275
+ if (!current || !window || window.stale) return null;
1276
+ const rates = effortRates(events);
1277
+ const here = rates.find((row) => row.effort === current);
1278
+ if (!here || here.turns < MIN_EFFORT_SAMPLE || !here.perTurn || here.perTurn <= 0) return null;
1279
+
1280
+ // Compared on output per turn, because that is what the effort setting
1281
+ // moves; cost per turn is mostly a fact about how long the context was.
1282
+ const measure = (row) => (Number.isFinite(row.outputPerTurn) && row.outputPerTurn > 0 ? row.outputPerTurn : null);
1283
+ const mine = measure(here);
1284
+ const cheaper = mine
1285
+ ? rates.find(
1286
+ (row) =>
1287
+ row.effort !== current &&
1288
+ row.turns >= MIN_EFFORT_SAMPLE &&
1289
+ measure(row) &&
1290
+ mine / measure(row) >= EFFORT_DEARER_BY
1291
+ )
1292
+ : null;
1293
+
1294
+ const turnsLeft =
1295
+ Number.isFinite(window.usdPerPercent) && window.usdPerPercent > 0 && Number.isFinite(window.percentLeft)
1296
+ ? Math.max(0, Math.floor((window.percentLeft * window.usdPerPercent) / here.perTurn))
1297
+ : null;
1298
+
1299
+ // Nothing to say when the effort is not the dear one and the window is not
1300
+ // nearly out at it.
1301
+ if (!cheaper && (turnsLeft === null || turnsLeft > FEW_TURNS_AT_EFFORT)) return null;
1302
+
1303
+ return {
1304
+ effort: current,
1305
+ perTurn: here.perTurn,
1306
+ sample: here.turns,
1307
+ turnsLeft,
1308
+ blendedTurnsLeft: Number.isFinite(window.turnsLeft) ? window.turnsLeft : null,
1309
+ outputPerTurn: here.outputPerTurn,
1310
+ cheaper: cheaper
1311
+ ? {
1312
+ effort: cheaper.effort,
1313
+ perTurn: cheaper.perTurn,
1314
+ outputPerTurn: cheaper.outputPerTurn,
1315
+ // How many times more the current setting writes per turn. This is
1316
+ // the number worth saying out loud: "ultra writes 6x the output of
1317
+ // medium" is what makes someone change it.
1318
+ multiple: measure(cheaper) ? mine / measure(cheaper) : null,
1319
+ }
1320
+ : null,
1321
+ };
1322
+ }
1323
+
711
1324
  // What the thinking actually cost, rather than what it is generally said to
712
1325
  // cost. Reasoning is billed as output, so it is priced at the output rate of
713
1326
  // whichever models did the thinking, weighted by how much each of them did.
@@ -1028,8 +1641,7 @@ function readCalibration() {
1028
1641
 
1029
1642
  function writeCalibration(all) {
1030
1643
  try {
1031
- fs.mkdirSync(path.dirname(calibrationFile()), { recursive: true });
1032
- fs.writeFileSync(calibrationFile(), JSON.stringify(all), 'utf8');
1644
+ writeJsonAtomic(calibrationFile(), all);
1033
1645
  } catch (err) {
1034
1646
  // Losing it costs accuracy on the next thin baseline, nothing more.
1035
1647
  }
@@ -1063,8 +1675,7 @@ function readModelRecord() {
1063
1675
 
1064
1676
  function writeModelRecord(all) {
1065
1677
  try {
1066
- fs.mkdirSync(path.dirname(modelRecordFile()), { recursive: true });
1067
- fs.writeFileSync(modelRecordFile(), JSON.stringify(all), 'utf8');
1678
+ writeJsonAtomic(modelRecordFile(), all);
1068
1679
  } catch (err) {
1069
1680
  // The record is a convenience for a thin week, not a source of truth.
1070
1681
  }
@@ -1152,19 +1763,24 @@ function stampPlan(all, planId) {
1152
1763
  // Turn count only breaks the tie: it says how much local spend sat behind the
1153
1764
  // reading, not how precise the denominator was, and preferring it outright is
1154
1765
  // how a 44-turn baseline read at 1% once beat every honest sample after it.
1766
+ // Which of two prices to keep, and which to write back to disk.
1767
+ //
1768
+ // A wider reading is a better measurement, so percent still ranks - but only
1769
+ // between two readings that are measurements at all. An entry learned at either
1770
+ // end of the meter is not one, and ranking on percent alone meant a price
1771
+ // learned at 100 could never be displaced by an honest one from the middle:
1772
+ // 100 is the highest number there is, so it won every comparison and was
1773
+ // written back for ever. The file could not heal itself even once the readers
1774
+ // had started ignoring it.
1155
1775
  function betterCalibration(current, candidate) {
1156
- if (!candidate || !Number.isFinite(candidate.usdPerPercent) || candidate.usdPerPercent <= 0) {
1157
- return current || null;
1158
- }
1159
- if (!current || !Number.isFinite(current.turns)) return candidate;
1160
- if (
1161
- Number.isFinite(candidate.percent) &&
1162
- Number.isFinite(current.percent) &&
1163
- candidate.percent !== current.percent
1164
- ) {
1165
- return candidate.percent > current.percent ? candidate : current;
1776
+ const fresh = usableCalibration(candidate);
1777
+ const held = usableCalibration(current);
1778
+ if (!fresh) return held || null;
1779
+ if (!held || !Number.isFinite(held.turns)) return fresh;
1780
+ if (Number.isFinite(fresh.percent) && Number.isFinite(held.percent) && fresh.percent !== held.percent) {
1781
+ return fresh.percent > held.percent ? fresh : held;
1166
1782
  }
1167
- return candidate.turns > current.turns ? candidate : current;
1783
+ return fresh.turns > held.turns ? fresh : held;
1168
1784
  }
1169
1785
 
1170
1786
  // Everything the report needs about one limit window.
@@ -1285,22 +1901,19 @@ function buildWindow(spec, snapshot, events, now, options) {
1285
1901
  // holding. The learned price covers it: what a point costs is a property
1286
1902
  // of the plan, not of this reading.
1287
1903
  const selfPriced =
1288
- rawPercent >= MIN_BASELINE_PERCENT && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
1904
+ rawPercent >= MIN_BASELINE_PERCENT &&
1905
+ rawPercent <= MAX_BASELINE_PERCENT &&
1906
+ upTo.cost > 0 &&
1907
+ upTo.turns >= MIN_BASELINE_TURNS
1289
1908
  ? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
1290
1909
  : null;
1291
1910
  // A metered window has nothing to learn: its price per point is stated.
1292
1911
  if (selfPriced && !extra.metered) window.calibration = selfPriced;
1293
1912
 
1294
- const known = extra.knownCalibration;
1295
- // A remembered price read off a near-empty meter is the same rounding
1296
- // bracket in disguise, so it is no more usable than measuring one now.
1297
- const usable =
1298
- known &&
1299
- Number.isFinite(known.usdPerPercent) &&
1300
- known.usdPerPercent > 0 &&
1301
- !(Number.isFinite(known.percent) && known.percent < MIN_BASELINE_PERCENT)
1302
- ? known
1303
- : null;
1913
+ // A remembered price is only as good as the reading it was learned from,
1914
+ // and both ends of the meter lie. One gate, shared with the headroom
1915
+ // arithmetic below.
1916
+ const usable = usableCalibration(extra.knownCalibration);
1304
1917
  // Trust the better-measured of the two, whichever that is; a stated price
1305
1918
  // beats both.
1306
1919
  const stated =
@@ -1349,10 +1962,17 @@ function buildWindow(spec, snapshot, events, now, options) {
1349
1962
  // window reading 1% divided a full hour of spend by one and priced the
1350
1963
  // remaining 99 points at thousands of dollars. Below the floor the learned
1351
1964
  // price takes over through the fallback chain.
1965
+ // The ceiling is the same rule the other way up: at 100 the meter has
1966
+ // stopped counting, so the spend past it divides against points that were
1967
+ // never registered and every remaining point looks cheaper than it is.
1352
1968
  const measured =
1353
- percent !== null && percent >= MIN_BASELINE_PERCENT && spent.cost > 0 && !thin;
1969
+ percent !== null &&
1970
+ percent >= MIN_BASELINE_PERCENT &&
1971
+ percent <= MAX_BASELINE_PERCENT &&
1972
+ spent.cost > 0 &&
1973
+ !thin;
1354
1974
  const derived = measured ? spent.cost / percent : null;
1355
- const known = extra.knownCalibration;
1975
+ const known = usableCalibration(extra.knownCalibration);
1356
1976
  const metered =
1357
1977
  Boolean(extra.metered) && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0;
1358
1978
  const priced = metered
@@ -1387,6 +2007,22 @@ function buildWindow(spec, snapshot, events, now, options) {
1387
2007
  }
1388
2008
  }
1389
2009
 
2010
+ // When spending has gone further past the snapshot than the snapshot said was
2011
+ // left, the percentage is a floor and the price per point was derived from a
2012
+ // denominator that is no longer true. Everything downstream of it is worse
2013
+ // than useless: measured live, the line read "13 per cent used, about 1301
2014
+ // turns of headroom" in the same breath as warning that the window was
2015
+ // probably already exhausted. A number that confident, that wrong, beside its
2016
+ // own contradiction, is the failure this whole plugin exists to avoid.
2017
+ //
2018
+ // There is no honest count to print here, so none is printed. The warning
2019
+ // that the snapshot has been outrun stands on its own.
2020
+ if (window.pointsBeyondSnapshot) {
2021
+ window.turnsLeft = null;
2022
+ window.headroomMs = null;
2023
+ window.remainingUSD = null;
2024
+ }
2025
+
1390
2026
  // A reset time in the past means the window already turned over and the
1391
2027
  // cached percentage describes a window that no longer exists. Reporting it
1392
2028
  // as current would claim the budget is gone when it has just come back.
@@ -1730,6 +2366,36 @@ const MIN_BASELINE_TURNS = 5;
1730
2366
  // truly at 35.
1731
2367
  const MIN_BASELINE_PERCENT = 5;
1732
2368
 
2369
+ // And a reading near the top cannot price one either, for the opposite reason.
2370
+ //
2371
+ // The price of a point is the spend inside the window divided by the meter's
2372
+ // own percentage, and that arithmetic assumes the meter is still counting. It
2373
+ // stops at 100. Everything spent past the cap is real money that moved no
2374
+ // points, so dividing by 100 counts it against points that were never
2375
+ // registered and the price comes out too cheap - which then converts later
2376
+ // spend into far more points than it moved, and the reported percentage
2377
+ // overshoots the account.
2378
+ //
2379
+ // Measured here on 2026-09-07: a five-hour price learned at a reading of 100
2380
+ // said a point cost $0.67, while the same window measured between two live
2381
+ // readings in the healthy range - 24% to 47% on $20.85 - said $0.91. Thirty-six
2382
+ // per cent too cheap, and the display was reading 54% against an account at 47.
2383
+ const MAX_BASELINE_PERCENT = 95;
2384
+
2385
+ // Whether a remembered price may be used at all.
2386
+ //
2387
+ // Defined once because the question is asked in two different places, and
2388
+ // gating only one of them was exactly how the capped price kept getting
2389
+ // through: the correction refused it and the headroom arithmetic took it
2390
+ // anyway. A price with no reading recorded against it predates this check and
2391
+ // is taken on trust; a recorded one has to be from the middle of the meter.
2392
+ function usableCalibration(known) {
2393
+ if (!known || !Number.isFinite(known.usdPerPercent) || known.usdPerPercent <= 0) return null;
2394
+ if (!Number.isFinite(known.percent)) return known;
2395
+ if (known.percent < MIN_BASELINE_PERCENT || known.percent > MAX_BASELINE_PERCENT) return null;
2396
+ return known;
2397
+ }
2398
+
1733
2399
  // Past this much of a window, a handful of local turns is not what spent it,
1734
2400
  // so their total is not a fair price for a point.
1735
2401
  const UNEXPLAINED_PERCENT = 20;
@@ -1954,7 +2620,11 @@ async function report(now, options) {
1954
2620
  // A stale snapshot can put a window's start slightly further back than
1955
2621
  // seven days, so give the scan a day of slack.
1956
2622
  const earliest = now - 8 * DAY;
1957
- const all = await readEvents(earliest);
2623
+ // A hook is given ten seconds; the panel and the CLI have all the time they
2624
+ // want. A scan that runs out of its budget returns what it managed to read
2625
+ // and says so, which is how the report knows its correction may be short.
2626
+ const all = await readEvents(earliest, options && options.budgetMs ? { budgetMs: options.budgetMs } : undefined);
2627
+ const scanPartial = Boolean(all && all.partial);
1958
2628
  // A refused request is a record of the limit, not a turn against it, so it is
1959
2629
  // kept apart from everything that measures spend or pace.
1960
2630
  const rejections = lastRejections(all);
@@ -2043,6 +2713,20 @@ async function report(now, options) {
2043
2713
  const recent = totals(recentEvents);
2044
2714
 
2045
2715
  const binding = bindingWindow(windows);
2716
+
2717
+ // The effort the next turn will run at. Codex states it outright in
2718
+ // config.toml; Claude Code stamps it on every assistant line, which is the
2719
+ // only source that follows /effort mid-session. The setting is last.
2720
+ let effortNow = (options && options.effort) || null;
2721
+ if (!effortNow && !isCodex() && options && options.sessionId) {
2722
+ const seen = liveEffort(options.sessionId);
2723
+ if (seen && seen.effort) effortNow = seen.effort;
2724
+ }
2725
+ if (!effortNow) {
2726
+ const configured = base.settings && base.settings.effortLevel;
2727
+ if (configured && configured !== 'default') effortNow = configured;
2728
+ }
2729
+ if (!effortNow) effortNow = dominantEffort(recentEvents) || null;
2046
2730
  const scopeStart = binding ? binding.windowStart : now - 7 * DAY;
2047
2731
  const scoped = events.filter((event) => event.at >= scopeStart && event.at <= now);
2048
2732
  const scopedTotals = totals(scoped);
@@ -2086,6 +2770,17 @@ async function report(now, options) {
2086
2770
  },
2087
2771
  measuredTurns: mainThread(events).length,
2088
2772
  subagentTurns: events.length - mainThread(events).length,
2773
+ // The effort the NEXT turn will run at, and what a turn has cost at each
2774
+ // effort on record. Codex states it in config.toml; Claude Code stamps it
2775
+ // on every line of the transcript.
2776
+ effortNow,
2777
+ effortRates: effortRates(events),
2778
+ effortWarning: effortWarning(events, effortNow, binding),
2779
+ codex: codexBlock(now),
2780
+ // True when the scan hit its time budget before it had read everything, so
2781
+ // the spend since the snapshot is a floor rather than a total. Anything
2782
+ // that warns on the number says "at least" when this is set.
2783
+ scanPartial,
2089
2784
  });
2090
2785
  }
2091
2786
 
@@ -2154,9 +2849,16 @@ function statusLine(collected) {
2154
2849
  if (!snapshot || typeof snapshot.utilization !== 'number') continue;
2155
2850
  const resetsAt = snapshot.resets_at ? Date.parse(snapshot.resets_at) : null;
2156
2851
  const msToReset = Number.isFinite(resetsAt) ? resetsAt - now : null;
2852
+ // The snapshot is what the account last said; the spend since then is
2853
+ // measured by a transcript scan this line can never afford. When a hook has
2854
+ // already paid for that scan recently, use its answer. Measured on a real
2855
+ // session, the difference was 13 per cent here against 73 in the report,
2856
+ // and the flattering one was the one on screen.
2857
+ const corrected = reading.correctedFor(spec.key, now, collected.snapshotFetchedAt);
2157
2858
  parts.push({
2158
2859
  label: SHORT_LABELS[spec.key] || spec.label,
2159
- percent: snapshot.utilization,
2860
+ percent: corrected ? corrected.percentUsed : snapshot.utilization,
2861
+ adjusted: Boolean(corrected && corrected.adjusted),
2160
2862
  msToReset,
2161
2863
  // A per-model weekly for a model that is not running is shown - hiding a
2162
2864
  // limit outright is the one failure worse than over-reporting one, and
@@ -2264,7 +2966,11 @@ function render(data) {
2264
2966
  // Two different situations, and telling them apart matters. Nothing to read
2265
2967
  // is a setup problem. Nothing to report is the correct answer on a plan
2266
2968
  // whose usage scales with credits rather than resetting on a clock.
2267
- if (data.windowless) {
2969
+ if (data.unreadable) {
2970
+ lines.push(' The meter reports usage windows but no readable percentage for them.');
2971
+ lines.push(' That is a reading problem, not flexible pricing: the limit still');
2972
+ lines.push(' applies. Run /status in Codex, or try again in a minute.');
2973
+ } else if (data.windowless) {
2268
2974
  lines.push(' This account reports no rolling usage window.');
2269
2975
  lines.push(' On flexible pricing there is no percentage to run down: usage scales');
2270
2976
  lines.push(' with credits, so the credit balance above is the budget to plan against.');
@@ -2335,6 +3041,26 @@ function render(data) {
2335
3041
  }
2336
3042
  lines.push('');
2337
3043
 
3044
+ // The other agent, directly under this one's windows and counting the other
3045
+ // way: Codex reports what is LEFT, so every figure here says "left" and none
3046
+ // of them can be read as a percentage spent.
3047
+ if (data.codex && data.codex.rows.length) {
3048
+ lines.push(' Codex usage' + (data.codex.plan ? ' ' + data.codex.plan : ''));
3049
+ lines.push(' Window Left Resets in');
3050
+ for (const row of data.codex.rows) {
3051
+ lines.push(
3052
+ ' ' + pad(row.title, 15) +
3053
+ padLeft(row.percentLeft === null ? (row.stale ? 'rolled' : '-') : row.percentLeft + '%', 6) +
3054
+ padLeft(row.stale ? '-' : formatDuration(row.msToReset), 12)
3055
+ );
3056
+ }
3057
+ if (data.codex.note) lines.push(' ' + data.codex.note);
3058
+ else if (Number.isFinite(data.codex.ageMs)) {
3059
+ lines.push(' reading from ' + formatDuration(data.codex.ageMs) + ' ago, out of the rollouts Codex writes');
3060
+ }
3061
+ lines.push('');
3062
+ }
3063
+
2338
3064
  // How much room is left is only half the question. The other half is what
2339
3065
  // that room buys, and the answer is different for every model: the same
2340
3066
  // weekly holds a few hundred Fable turns or several thousand Sonnet ones.
@@ -2476,6 +3202,48 @@ function render(data) {
2476
3202
  (data.subagentTurns > 0 ? ' (+' + formatCount(data.subagentTurns) + ' subagent calls)' : '')
2477
3203
  );
2478
3204
 
3205
+ // What a turn costs at each effort, when more than one has been measured.
3206
+ // The single most useful line for anyone wondering where the budget went:
3207
+ // effort changes the price of every turn rather than how many there are.
3208
+ const rates = (data.effortRates || []).filter((row) => row.turns >= MIN_EFFORT_SAMPLE && row.outputPerTurn);
3209
+ if (rates.length > 1) {
3210
+ lines.push('');
3211
+ lines.push(' What each effort costs, measured on this machine');
3212
+ lines.push(' Effort Turns Output/turn' + (money ? ' Per turn' : ''));
3213
+ for (const row of rates) {
3214
+ lines.push(
3215
+ ' ' +
3216
+ String(row.effort + (row.effort === data.effortNow ? ' *' : '')).padEnd(12) +
3217
+ padLeft(formatCount(row.turns), 7) +
3218
+ padLeft(formatCount(Math.round(row.outputPerTurn)), 14) +
3219
+ (money ? padLeft(formatUSD(row.perTurn), 11) : '')
3220
+ );
3221
+ }
3222
+ if (data.effortNow) lines.push(' * the effort set now');
3223
+ lines.push(' Output per turn is the part the effort setting controls; cost per turn');
3224
+ lines.push(' also moves with how big the context happened to be.');
3225
+ }
3226
+
3227
+ if (data.effortWarning) {
3228
+ const warning = data.effortWarning;
3229
+ lines.push('');
3230
+ if (Number.isFinite(warning.turnsLeft)) {
3231
+ lines.push(
3232
+ ' Careful at ' + warning.effort + ' effort this window holds about ' +
3233
+ formatCount(warning.turnsLeft) + ' more turns' +
3234
+ (Number.isFinite(warning.blendedTurnsLeft) && warning.blendedTurnsLeft > warning.turnsLeft
3235
+ ? ', not the ' + formatCount(warning.blendedTurnsLeft) + ' above'
3236
+ : '')
3237
+ );
3238
+ }
3239
+ if (warning.cheaper && Number.isFinite(warning.cheaper.multiple)) {
3240
+ lines.push(
3241
+ ' ' + warning.effort + ' writes about ' + warning.cheaper.multiple.toFixed(1) +
3242
+ 'x the output per turn that ' + warning.cheaper.effort + ' does'
3243
+ );
3244
+ }
3245
+ }
3246
+
2479
3247
  if (data.windows.some((window) => window.adjusted)) {
2480
3248
  lines.push(
2481
3249
  ' Note ~ includes spend since the snapshot was taken, which its own'
@@ -2850,7 +3618,20 @@ module.exports = {
2850
3618
  preferLive,
2851
3619
  accountUuid,
2852
3620
  subagentTranscripts,
3621
+ claudeTranscriptFiles,
2853
3622
  readClaudeEvents,
3623
+ SCAN_VERSION,
3624
+ SCAN_KEEP_MS,
3625
+ SCAN_MAX_EVENTS,
3626
+ sessionTranscriptFile,
3627
+ liveEffort,
3628
+ liveModel,
3629
+ EFFORT_TAIL_BYTES,
3630
+ scanFile,
3631
+ readScanCache,
3632
+ writeScanCache,
3633
+ writeJsonAtomic,
3634
+ parseSlice,
2854
3635
  RATES,
2855
3636
  WINDOWS,
2856
3637
  rateFor,
@@ -2880,6 +3661,8 @@ module.exports = {
2880
3661
  SATURATION_LIMIT,
2881
3662
  MIN_BASELINE_TURNS,
2882
3663
  MIN_BASELINE_PERCENT,
3664
+ usableCalibration,
3665
+ MAX_BASELINE_PERCENT,
2883
3666
  accountFile,
2884
3667
  buildWindows,
2885
3668
  limitWindows,
@@ -2892,6 +3675,12 @@ module.exports = {
2892
3675
  calibrationFile,
2893
3676
  CRITICAL_PERCENT,
2894
3677
  dominantEffort,
3678
+ effortRates,
3679
+ codexBlock,
3680
+ effortWarning,
3681
+ MIN_EFFORT_SAMPLE,
3682
+ EFFORT_DEARER_BY,
3683
+ FEW_TURNS_AT_EFFORT,
2895
3684
  typicalTurnCost,
2896
3685
  activeSessions,
2897
3686
  sessionSpend,