claude-usage-limits 1.11.7 → 1.13.1

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.
@@ -18,6 +18,9 @@ const host = require('./host.js');
18
18
  const tally = require('./tally.js');
19
19
  const activity = require('./activity.js');
20
20
  const live = require('./live.js');
21
+ const relay = require('./relay.js');
22
+ const reading = require('./reading.js');
23
+ const voice = require('./voice.js');
21
24
 
22
25
  const SECOND = 1000;
23
26
  const DAY = 24 * 60 * 60 * 1000;
@@ -59,6 +62,17 @@ const DEFAULTS = {
59
62
  // A hook has ten seconds; the reading gets four of them at most.
60
63
  const REFRESH_TIMEOUT_MS = 4000;
61
64
 
65
+ // The hook is given ten seconds, and a live reading may take four of them, so
66
+ // the transcript scan gets five and the last second is slack. A warm scan
67
+ // takes about a quarter of a second; this is the guard for the first run on a
68
+ // machine with months of transcripts, where the alternative is the hook being
69
+ // killed and Claude being told nothing at all.
70
+ const SCAN_BUDGET_MS = 5000;
71
+
72
+ // How far into the hook arming may still start. See relayState: the task
73
+ // registration is about a second and the hook is allowed ten.
74
+ const ARM_DEADLINE_MS = 5000;
75
+
62
76
  // Past this much of a per-model week, say how to free it. Below it the advice
63
77
  // is noise: there is room, and the model in use is the right one.
64
78
  const HALF_SPENT = 50;
@@ -177,12 +191,10 @@ function mergeCache(all, sessionId, entry, keep) {
177
191
  }
178
192
 
179
193
  function writeCache(all) {
180
- try {
181
- fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
182
- fs.writeFileSync(cacheFile(), JSON.stringify(all), 'utf8');
183
- } catch (err) {
184
- // A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
185
- }
194
+ // Never throws: a cache miss costs a scan, a crash costs the prompt. Atomic
195
+ // because every open window's prompt hook writes this same file, and a torn
196
+ // read by one of them wiped the others' slots on the way back.
197
+ usage.writeJsonAtomic(cacheFile(), all);
186
198
  }
187
199
 
188
200
  function settings() {
@@ -356,6 +368,41 @@ function count(value, word) {
356
368
  return value + ' ' + word + (Math.abs(value) === 1 ? '' : 's');
357
369
  }
358
370
 
371
+ // The other agent's meter, in one clause.
372
+ //
373
+ // Codex reports what is LEFT where Claude reports what is USED, so every
374
+ // figure here carries the word "left" - a bare percentage next to Claude's
375
+ // would be read as the same kind of number and mean the opposite thing.
376
+ //
377
+ // Read from Codex's rollouts on disk. Nothing spawns Codex, nothing waits on
378
+ // it, and a machine without it pays one stat call.
379
+ function codexSummary(now) {
380
+ try {
381
+ if (usage.isCodex() || !host.codexHasSessions()) return null;
382
+ const display = require('./view.js');
383
+ const codex = require('./codex.js');
384
+ const other = codex.collect(now);
385
+ const block = display.buildCodex({
386
+ now,
387
+ utilization: other.utilization,
388
+ fetchedAtMs: other.snapshotFetchedAt,
389
+ windowSpecs: other.windowSpecs,
390
+ plan: other.plan,
391
+ windowless: other.windowless,
392
+ });
393
+ if (!block.present) return null;
394
+ const names = { five_hour: '5-hour', seven_day: 'weekly' };
395
+ const bits = block.rows
396
+ .filter((row) => row.percentLeft !== null)
397
+ .map((row) => (names[row.key] || row.title) + ' ' + Math.floor(row.percentLeft) + '% left');
398
+ if (!bits.length) return null;
399
+ return { plan: block.plan, bits, stale: block.state !== 'live' };
400
+ } catch (err) {
401
+ // An unreadable Codex is simply no Codex clause.
402
+ return null;
403
+ }
404
+ }
405
+
359
406
  function describeWindow(window) {
360
407
  if (!window) return null;
361
408
  if (window.stale) return window.label + ' rolling over';
@@ -460,6 +507,51 @@ function briefText(parts) {
460
507
  'the amount that ran out last time, not a fresh allowance.'
461
508
  );
462
509
  }
510
+ // The other agent, when there is one on this machine. Its own budget, its
511
+ // own direction: Codex counts down.
512
+ if (parts.codex && parts.codex.bits.length) {
513
+ sentences.push(
514
+ 'Codex' + (parts.codex.plan ? ' (' + parts.codex.plan + ')' : '') + ' has ' +
515
+ parts.codex.bits.join(' and ') +
516
+ (parts.codex.stale ? ', from the last reading it wrote' : '') + '.'
517
+ );
518
+ }
519
+ // The effort setting changes the PRICE of a turn rather than how many there
520
+ // are, and the blended headroom above hides that completely: an account that
521
+ // has just moved to a dearer effort goes on being priced at the old one until
522
+ // enough dear turns have landed to drag the average up, and on a small window
523
+ // there is no "enough" - the window is gone first.
524
+ //
525
+ // A ChatGPT Plus account running gpt-6-astra at ultra effort emptied a whole
526
+ // five-hour window on one ordinary task while this hook reported room the
527
+ // entire way. Saying it here is what turns "plenty of room" into "about
528
+ // twenty turns" before the window is spent rather than after.
529
+ if (parts.effortWarning) {
530
+ const warning = parts.effortWarning;
531
+ const bits = [];
532
+ if (Number.isFinite(warning.turnsLeft)) {
533
+ const blended = warning.blendedTurnsLeft;
534
+ bits.push(
535
+ 'at ' + warning.effort + ' effort this window holds about ' + count(warning.turnsLeft, 'turn') +
536
+ (Number.isFinite(blended) && blended > warning.turnsLeft
537
+ ? ', not the ' + blended + ' the headroom above suggests'
538
+ : '')
539
+ );
540
+ }
541
+ if (warning.cheaper && Number.isFinite(warning.cheaper.multiple)) {
542
+ bits.push(
543
+ warning.effort + ' writes about ' + warning.cheaper.multiple.toFixed(1) +
544
+ ' times the output per turn that ' + warning.cheaper.effort + ' does'
545
+ );
546
+ }
547
+ if (bits.length) {
548
+ sentences.push(
549
+ 'The effort setting is what is spending this: ' + bits.join(', and ') +
550
+ '. Keep it where the work genuinely needs the thinking and drop it where it ' +
551
+ 'does not; it changes what every turn costs, not how many you get.'
552
+ );
553
+ }
554
+ }
463
555
  // A per-model weekly is the one window effort cannot help with. Nothing you
464
556
  // do more cheaply on this model frees it; only running a different model
465
557
  // does, and that has to be said, because the obvious move at 90 percent is
@@ -526,6 +618,47 @@ function briefText(parts) {
526
618
  );
527
619
  }
528
620
 
621
+ if (parts.voiceNote) sentences.push('How this user wants to be written to: ' + parts.voiceNote);
622
+
623
+ // What the relay changes about all of this.
624
+ //
625
+ // With a wake booked, the end of the window stops being the end of the work,
626
+ // and the instruction at the wall changes shape: the handoff is no longer a
627
+ // note somebody has to find and read, it is the prompt this conversation
628
+ // will be handed back. That is worth two minutes of writing, and it is worth
629
+ // saying plainly, because a session told only "you are about to be cut off"
630
+ // spends its last turns hedging.
631
+ const carry = parts.relay;
632
+ if (carry && carry.armed) {
633
+ const wake = new Date(carry.armed.wakeAt);
634
+ sentences.push(
635
+ 'A relay is armed: ' + (carry.justArmed ? 'booked just now' : 'booked') + ' for ' +
636
+ wake.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ', ' +
637
+ carry.config.graceMinutes + ' minutes after this window resets, and it will ' +
638
+ (carry.armed.mode === 'resume' ? 'hand your continuation straight back to this conversation' : 'notify the user with your continuation ready to open') +
639
+ '.' + (carry.armed.warning ? ' Caveat: ' + carry.armed.warning + '.' : '')
640
+ );
641
+ if (!carry.armed.continuation) {
642
+ sentences.push(
643
+ 'Nothing has been written for it yet. Before this session ends, run ' +
644
+ 'node "$CLAUDE_PLUGIN_ROOT/skills/usage-limits/scripts/relay.js" note "<what you would tell yourself>" ' +
645
+ 'with what is done, what is next in order, which files are mid-change and what must be verified first. ' +
646
+ 'That text is the prompt the relay delivers, so write it to be acted on, not read.'
647
+ );
648
+ }
649
+ }
650
+ if (carry && carry.last) {
651
+ sentences.push(
652
+ 'The last relay ' +
653
+ (carry.last.outcome === 'resumed'
654
+ ? 'picked this work back up automatically after the previous reset (' + (carry.last.detail || 'resumed') + '); check what it did before repeating it'
655
+ : carry.last.outcome === 'notified'
656
+ ? 'left a notification after the previous reset rather than starting anything'
657
+ : 'ended as "' + carry.last.outcome + '"' + (carry.last.detail ? ' - ' + carry.last.detail : '')) +
658
+ '.'
659
+ );
660
+ }
661
+
529
662
  // Three states, and only the last one stops anything.
530
663
  //
531
664
  // The middle one is the one that keeps being got wrong. Near the wall the
@@ -538,7 +671,19 @@ function briefText(parts) {
538
671
  parts.pressure === 'gone'
539
672
  ? 'The budget is gone, so nothing further will run. Save what exists and ' +
540
673
  'write the handoff: what is finished, what is next and in what order, ' +
541
- 'which files are mid-change, and when the window resets.'
674
+ 'which files are mid-change, and when the window resets.' +
675
+ (parts.relay && parts.relay.armed
676
+ ? ' Write it into the relay note as well as into your reply - that is ' +
677
+ 'the copy that gets acted on when the window reopens.'
678
+ : '')
679
+ : parts.pressure === 'tight' && parts.relay && parts.relay.armed
680
+ ? 'The budget is nearly gone and the relay has it: being cut off now ' +
681
+ 'costs the wait, not the work. So do not wind down, do not narrow the ' +
682
+ 'request, and do not stop to ask whether to go on. Carry on at full ' +
683
+ 'quality, save at clean boundaries, and put everything the next turn ' +
684
+ 'needs into the relay note rather than into a summary for a person to ' +
685
+ 'read. Say in one line what will land after the reset instead of ' +
686
+ 'before it, then keep working until the window actually ends.'
542
687
  : parts.pressure === 'tight'
543
688
  ? 'The budget is nearly gone, so make being cut off cheap rather than ' +
544
689
  'doing less. Carry on with the whole request at full quality: this is ' +
@@ -631,6 +776,65 @@ function tallyContext(all, sessionId, now) {
631
776
  };
632
777
  }
633
778
 
779
+ // The relay, decided once per prompt.
780
+ //
781
+ // Two things happen here and neither of them slows the work down. Above the
782
+ // arming threshold, with a plan or an unfinished todo list to carry, a one-shot
783
+ // wake is booked for a few minutes after the reset - a scheduled task costs
784
+ // nothing and changes nothing about the turn in progress. And once a wake
785
+ // exists, what the hook tells Claude at the wall changes: the handoff is no
786
+ // longer a note for a person to find, it is the thing that will be handed back
787
+ // automatically, so it is worth writing properly.
788
+ //
789
+ // It also reports what happened last time. A relay that fired while nobody was
790
+ // watching is exactly the sort of thing a session should not have to be asked
791
+ // about.
792
+ function relayState(now, hookInput, binding, sessionId) {
793
+ try {
794
+ const state = relay.read();
795
+ const config = relay.settings(state);
796
+ const last = state.history[state.history.length - 1];
797
+ const recent = last && Number.isFinite(last.endedAt) && now - last.endedAt < 6 * 60 * 60 * 1000 ? last : null;
798
+ if (!config.enabled) return recent ? { enabled: false, last: recent } : null;
799
+
800
+ // Already armed for this session: nothing to decide, just say so.
801
+ if (state.armed && state.armed.id === sessionId) {
802
+ return { enabled: true, armed: state.armed, config, last: recent };
803
+ }
804
+ const work = relay.detectWork(hookInput && hookInput.transcript_path, {});
805
+ const able = relay.armable({ config, binding, sessionId, work });
806
+ if (!able.ok) return { enabled: true, why: able.why, config, last: recent, work };
807
+ // Registering a scheduled task measured 936 ms, and this hook has ten
808
+ // seconds of which the live reading may take four and the scan five. A
809
+ // hook that is killed tells Claude nothing at all, which is far worse than
810
+ // a relay that arms on the next prompt instead of this one - and if there
811
+ // is no next prompt, the session ended and there was nothing to carry.
812
+ if (Date.now() - now > ARM_DEADLINE_MS) {
813
+ return { enabled: true, why: 'no time left in this hook; arming on the next prompt', config, last: recent, work };
814
+ }
815
+ const armed = relay.arm({
816
+ now,
817
+ config,
818
+ // Registration must be finished, not merely started, inside the hook's
819
+ // ten seconds; the scheduler trims its own waits to this.
820
+ deadline: now + ARM_DEADLINE_MS + 4000,
821
+ sessionId,
822
+ binding,
823
+ work,
824
+ resetsAt: binding.resetsAt,
825
+ cwd: (hookInput && hookInput.cwd) || process.cwd(),
826
+ project: path.basename((hookInput && hookInput.cwd) || process.cwd()),
827
+ hostName: usage.currentHost(),
828
+ });
829
+ return armed.ok
830
+ ? { enabled: true, armed: armed.record, justArmed: true, config, last: recent, work }
831
+ : { enabled: true, error: armed.error, config, last: recent, work };
832
+ } catch (err) {
833
+ // Nothing about carrying work forward is worth breaking the prompt for.
834
+ return null;
835
+ }
836
+ }
837
+
634
838
  async function run(now, hookInput) {
635
839
  if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
636
840
 
@@ -645,15 +849,22 @@ async function run(now, hookInput) {
645
849
  activity.mark(
646
850
  'working',
647
851
  sessionId,
648
- {
649
- // Only ultrathink, and only as a whole word: it is a real directive in
650
- // the prompt. Ultracode is an effort level, read from the setting the
651
- // agent reports, never from the text - the word turns up in ordinary
652
- // requests, and these marks are read by every panel on the machine.
653
- ultrathink: Boolean(
654
- hookInput && typeof hookInput.prompt === 'string' && /\bultrathink\b/i.test(hookInput.prompt)
655
- ),
656
- },
852
+ Object.assign(
853
+ {
854
+ // Whole words only: both are real directives in the prompt. Ultrathink
855
+ // is per prompt, so it is set true or false every time.
856
+ ultrathink: Boolean(
857
+ hookInput && typeof hookInput.prompt === 'string' && /\bultrathink\b/i.test(hookInput.prompt)
858
+ ),
859
+ },
860
+ // Ultracode is Claude Code's own keyword trigger and it sticks for the
861
+ // session once used, so it is only ever set on here and otherwise
862
+ // carried forward. It is safe to read from the text now that every
863
+ // display reads the mark of the one session it describes.
864
+ hookInput && typeof hookInput.prompt === 'string' && /\bultracode\b/i.test(hookInput.prompt)
865
+ ? { ultracode: true }
866
+ : {}
867
+ ),
657
868
  now
658
869
  );
659
870
 
@@ -699,7 +910,7 @@ async function run(now, hookInput) {
699
910
  if (!view || !view.binding) {
700
911
  // One call, shared with the report. Building the view twice is how the
701
912
  // snapshot correction reached the report and never reached the hook.
702
- const data = await usage.report(now, { sessionId });
913
+ const data = await usage.report(now, { sessionId, budgetMs: SCAN_BUDGET_MS });
703
914
  const binding = data.binding;
704
915
  view = {
705
916
  at: now,
@@ -716,6 +927,7 @@ async function run(now, hookInput) {
716
927
  })),
717
928
  snapshotAge: usage.formatDuration(data.snapshotAgeMs),
718
929
  binding: cacheableBinding(binding),
930
+ effortWarning: data.effortWarning || null,
719
931
  };
720
932
  writeCache(mergeCache(all, sessionId, view, KEEP_SESSIONS));
721
933
  }
@@ -732,7 +944,33 @@ async function run(now, hookInput) {
732
944
  binding.headroomMs <= RUNWAY_MENTION_MS;
733
945
  // Outside the cache on purpose: the tally moves after every reply.
734
946
  const found = tallyContext(tally.readState(), sessionId, now);
947
+ // Learning how the user writes, from the prompt that just arrived. Counters
948
+ // only, no model call, and it never speaks: what it knows is read back by
949
+ // /usage-limits:voice and used when the relay writes as them.
950
+ try {
951
+ if (hookInput && typeof hookInput.prompt === 'string') voice.observe(hookInput.prompt, now);
952
+ } catch (err) {
953
+ // Style is not worth a failed hook.
954
+ }
955
+ // This hook has just paid for a transcript scan, so the corrected figure is
956
+ // in hand. Leave it where the status line can read it: that line redraws far
957
+ // too often to scan for itself, and without this it shows the raw snapshot,
958
+ // which during a heavy session is wrong by tens of points in the flattering
959
+ // direction.
960
+ reading.record(binding, now, usage.isCodex() ? require('./codex.js').homeDir() : null);
961
+ const carry = relayState(now, hookInput, binding, sessionId);
962
+ // An instruction the user typed at /usage-limits:voice set. The learned
963
+ // traits are for writing AS them and stay out of the way; this is them
964
+ // saying how they want to be talked to, so it is said every time.
965
+ let voiceNote = null;
966
+ try {
967
+ voiceNote = voice.read().note;
968
+ } catch (err) {
969
+ voiceNote = null;
970
+ }
735
971
  return briefText({
972
+ relay: carry,
973
+ voiceNote,
736
974
  lastReply: found.lastReply,
737
975
  context: found.context,
738
976
  lastSession: found.lastSession,
@@ -754,6 +992,10 @@ async function run(now, hookInput) {
754
992
  : null,
755
993
  othersSummary: view.othersSummary,
756
994
  turnsLeft: view.turnsLeft,
995
+ effortWarning: view.effortWarning || null,
996
+ // Outside the cache: it is cheap, and it belongs to the other agent's
997
+ // clock rather than this session's.
998
+ codex: codexSummary(now),
757
999
  resetsIn:
758
1000
  binding && !binding.stale && Number.isFinite(binding.resetsAt)
759
1001
  ? usage.formatDuration(binding.resetsAt - now)
@@ -803,6 +1045,7 @@ module.exports = {
803
1045
  describeWindow,
804
1046
  summariseOthers,
805
1047
  briefText,
1048
+ codexSummary,
806
1049
  tallyContext,
807
1050
  LARGE_CONTEXT_TOKENS,
808
1051
  settings,
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+ // Reversible edits to Codex's top-level defaults. Never changes a running turn.
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const KEYS = ['model', 'model_reasoning_effort'];
7
+ const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
8
+
9
+ function scan(text) {
10
+ // Do not pretend a line editor is a full TOML parser.
11
+ if (text.includes('"""') || text.includes("'''")) throw new Error('Multiline TOML requires manual editing; config was not changed.');
12
+ const lines = text.match(/[^\n]*\n|[^\n]+$/g) || [];
13
+ const found = {};
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const line = lines[i].replace(/^\uFEFF/, '');
16
+ if (/^\s*\[/.test(line)) break;
17
+ for (const key of KEYS) {
18
+ const re = new RegExp('^\\s*(?:' + key + '|"' + key + '"|\\x27' + key + '\\x27)\\s*=');
19
+ if (!re.test(line)) continue;
20
+ if (Object.hasOwn(found, key)) throw new Error('Duplicate top-level ' + key + '; config was not changed.');
21
+ if (!/=\s*(?:"(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?(?:\r?\n)?$/.test(line))
22
+ throw new Error('Unsupported value for ' + key + '; config was not changed.');
23
+ found[key] = { index: i, line: lines[i] };
24
+ }
25
+ }
26
+ return { lines, found };
27
+ }
28
+ function rewrite(text, values) {
29
+ const { lines, found } = scan(text);
30
+ const prepend = [];
31
+ for (const [key, line] of Object.entries(values)) {
32
+ if (!KEYS.includes(key)) throw new Error('Invalid managed setting');
33
+ if (found[key]) lines[found[key].index] = line || '';
34
+ else if (line) prepend.push(line);
35
+ }
36
+ return prepend.join('') + lines.join('');
37
+ }
38
+ function read(file, fallback) {
39
+ try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return fallback; throw e; }
40
+ }
41
+ function atomic(file, text) {
42
+ const temp = file + '.' + process.pid + '.tmp';
43
+ try { fs.writeFileSync(temp, text, { encoding: 'utf8', mode: 0o600 }); fs.renameSync(temp, file); }
44
+ finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
45
+ }
46
+ function checkState(state) {
47
+ if (!state) return;
48
+ if (state.version !== 1 || !state.previous || !state.applied) throw new Error('Unrecognized restore state; config was not changed.');
49
+ const keys = Object.keys(state.previous);
50
+ if (!keys.length || keys.some(k => !KEYS.includes(k)) || JSON.stringify(keys.sort()) !== JSON.stringify(Object.keys(state.applied).sort()))
51
+ throw new Error('Invalid restore state; config was not changed.');
52
+ for (const values of [state.previous, state.applied]) {
53
+ for (const [key, line] of Object.entries(values)) {
54
+ if (line === null) continue;
55
+ if (typeof line !== 'string') throw new Error('Invalid restore line');
56
+ const parsed = scan(line);
57
+ if (parsed.lines.length !== 1 || !parsed.found[key]) throw new Error('Invalid restore line');
58
+ }
59
+ }
60
+ }
61
+ function plan(text, options, state) {
62
+ checkState(state);
63
+ const { found } = scan(text);
64
+ const lineOf = key => found[key] ? found[key].line : null;
65
+ if (state) for (const [key, line] of Object.entries(state.applied)) {
66
+ if (lineOf(key) !== line) throw new Error(key + ' changed outside lowpower; review config and restore state before continuing.');
67
+ }
68
+ if (options.command === 'off') return { text: state ? rewrite(text, state.previous) : text, state: null };
69
+ const effort = options.effort || 'low';
70
+ if (!EFFORTS.includes(effort)) throw new Error('Unknown Codex effort: ' + effort);
71
+ if (options.model !== null && options.model !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(options.model))
72
+ throw new Error('Invalid Codex model identifier');
73
+ const wanted = { model_reasoning_effort: effort };
74
+ if (options.model) wanted.model = options.model;
75
+ const previous = { ...(state && state.previous) };
76
+ const applied = { ...(state && state.applied) };
77
+ const newline = text.includes('\r\n') ? '\r\n' : '\n';
78
+ for (const [key, value] of Object.entries(wanted)) {
79
+ if (!Object.hasOwn(previous, key)) previous[key] = lineOf(key);
80
+ applied[key] = key + ' = ' + JSON.stringify(value) + newline;
81
+ }
82
+ return { text: rewrite(text, applied), state: { version: 1, previous, applied } };
83
+ }
84
+ function main(args) {
85
+ if (!['status', 'on', 'off'].includes(args.command)) throw new Error('Expected status, on or off');
86
+ const dir = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
87
+ const file = path.join(dir, 'config.toml');
88
+ const stateFile = path.join(dir, 'usage-limits-lowpower.json');
89
+ const notice = 'Codex defaults only: new sessions. Active tasks, profiles and command-line overrides keep their own settings.';
90
+ // A lock serializes our writers. Other editors are detected before commit.
91
+ let lock;
92
+ if (args.command !== 'status' && !args.dryRun) {
93
+ fs.mkdirSync(dir, { recursive: true });
94
+ lock = fs.openSync(stateFile + '.lock', 'wx');
95
+ }
96
+ try {
97
+ const text = read(file, '');
98
+ const rawState = read(stateFile, null);
99
+ const state = rawState ? JSON.parse(rawState) : null;
100
+ checkState(state);
101
+ if (args.command === 'status') {
102
+ const { found } = scan(text);
103
+ console.log('Codex low power: ' + (state ? 'on' : 'off'));
104
+ for (const key of KEYS) console.log(found[key] ? found[key].line.trim() : key + ' = (unset)');
105
+ console.log(notice);
106
+ return 0;
107
+ }
108
+ const result = plan(text, args, state);
109
+ if (args.dryRun) {
110
+ console.log('Dry run; no files changed.\n' + notice);
111
+ const { found } = scan(result.text);
112
+ for (const key of KEYS) console.log(found[key] ? found[key].line.trim() : key + ' = (unset)');
113
+ return 0;
114
+ }
115
+ if (read(file, '') !== text || read(stateFile, null) !== rawState) throw new Error('Settings changed concurrently; retry.');
116
+ // Save recovery information before changing defaults; roll it back on error.
117
+ if (result.state) atomic(stateFile, JSON.stringify(result.state, null, 2) + '\n');
118
+ try {
119
+ if (result.text !== text) {
120
+ if (!state && fs.existsSync(file)) fs.copyFileSync(file, file + '.usage-limits-backup');
121
+ atomic(file, result.text);
122
+ }
123
+ } catch (e) {
124
+ if (rawState !== null) atomic(stateFile, rawState);
125
+ else if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
126
+ throw e;
127
+ }
128
+ if (!result.state && fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
129
+ console.log('Codex low power ' + args.command + '.\n' + notice);
130
+ return 0;
131
+ } finally {
132
+ if (lock !== undefined) { fs.closeSync(lock); fs.unlinkSync(stateFile + '.lock'); }
133
+ }
134
+ }
135
+ module.exports = { scan, rewrite, plan, main, EFFORTS };
@@ -443,7 +443,10 @@ function readingsOf(meter) {
443
443
  for (const entry of SLOTS) {
444
444
  const window = meter[entry.slot];
445
445
  if (!window || typeof window !== 'object') continue;
446
- const percent = Number(window.used_percent);
446
+ // Number(null) is 0, and a null percentage read as "0% used" is worse
447
+ // than no reading at all. Only an actual number counts.
448
+ const raw = window.used_percent;
449
+ const percent = raw === null || raw === undefined || raw === '' ? NaN : Number(raw);
447
450
  if (!Number.isFinite(percent)) continue;
448
451
 
449
452
  const minutes = Number(window.window_minutes);
@@ -482,9 +485,21 @@ function utilizationFrom(meter) {
482
485
  // account has no rolling limit and usage scales with credits. Returning null
483
486
  // here would throw away the plan and the credit balance, which on such an
484
487
  // account are the only figures there are.
488
+ //
489
+ // A slot that IS there but carries no readable percentage is a different
490
+ // thing again: the meter answered without numbers, which happens around a
491
+ // limit hit. Calling that flexible pricing told Codex on 2026-09-07 that its
492
+ // Plus account had no window to run down, minutes after the window ran out.
493
+ const slotsPresent = SLOTS.filter((entry) => meter[entry.slot] && typeof meter[entry.slot] === 'object').length;
494
+ // Consumer plans are metered by windows without exception; only the
495
+ // business-side plans can be on flexible pricing. So on Plus, Pro, Go or
496
+ // Free a meter with no windows in it is a meter that failed to read them.
497
+ const planType = typeof meter.plan_type === 'string' ? meter.plan_type : '';
498
+ const consumer = /^(free|go|plus|pro|prolite)$/i.test(planType);
485
499
  const credits = meter.credits && typeof meter.credits === 'object' ? meter.credits : null;
486
500
  return {
487
- windowless: specs.length === 0,
501
+ windowless: specs.length === 0 && slotsPresent === 0 && !consumer,
502
+ unreadable: specs.length === 0 && (slotsPresent > 0 || consumer),
488
503
  utilization: specs.length ? utilization : null,
489
504
  specs,
490
505
  planType: typeof meter.plan_type === 'string' ? meter.plan_type : null,
@@ -599,31 +614,81 @@ function latestMeter(events) {
599
614
  return null;
600
615
  }
601
616
 
617
+ // The meter is written next to every request, so the newest one is always near
618
+ // the END of a rollout. Only the tail is read.
619
+ //
620
+ // This used to read whole files, and a rollout on this machine reaches 32 MB;
621
+ // twelve of those is a third of a gigabyte pulled through a string on a path
622
+ // that the status line takes every few hundred milliseconds. A megabyte of
623
+ // tail holds hundreds of token_count lines, which is far more than enough.
624
+ const TAIL_BYTES = 1024 * 1024;
625
+
626
+ function readTail(file, bytes) {
627
+ let size = 0;
628
+ try {
629
+ size = fs.statSync(file).size;
630
+ } catch (err) {
631
+ return null;
632
+ }
633
+ const from = Math.max(0, size - bytes);
634
+ let fd;
635
+ try {
636
+ fd = fs.openSync(file, 'r');
637
+ } catch (err) {
638
+ return null;
639
+ }
640
+ try {
641
+ const length = size - from;
642
+ if (length <= 0) return { text: '', partial: false };
643
+ const buffer = Buffer.allocUnsafe(length);
644
+ let read = 0;
645
+ while (read < length) {
646
+ const got = fs.readSync(fd, buffer, read, length - read, from + read);
647
+ if (got <= 0) break;
648
+ read += got;
649
+ }
650
+ return { text: buffer.toString('utf8', 0, read), partial: from > 0 };
651
+ } catch (err) {
652
+ return null;
653
+ } finally {
654
+ try {
655
+ fs.closeSync(fd);
656
+ } catch (err) {
657
+ // Already closed.
658
+ }
659
+ }
660
+ }
661
+
662
+ function meterFromLines(text, partial) {
663
+ const lines = text.split('\n');
664
+ // The first line of a tail is a fragment of whatever it landed in the middle
665
+ // of, so it is never parsed.
666
+ const floor = partial ? 1 : 0;
667
+ for (let index = lines.length - 1; index >= floor; index -= 1) {
668
+ const line = lines[index];
669
+ if (!line || line.indexOf('"token_count"') === -1) continue;
670
+ let parsed;
671
+ try {
672
+ parsed = JSON.parse(line);
673
+ } catch (err) {
674
+ continue;
675
+ }
676
+ const meter = parsed && parsed.payload && parsed.payload.rate_limits;
677
+ const at = Date.parse(parsed && parsed.timestamp);
678
+ if (meter && Number.isFinite(at)) return { meter, at };
679
+ }
680
+ return null;
681
+ }
682
+
602
683
  // Scanning only the newest few rollouts, for the meter alone. `collect` runs on
603
684
  // the status-line path where a full scan would be far too slow.
604
685
  function meterFromDisk() {
605
686
  const files = rolloutFiles(NaN).slice(-12).reverse();
606
687
  for (const entry of files) {
607
- let raw;
608
- try {
609
- raw = fs.readFileSync(entry.file, 'utf8');
610
- } catch (err) {
611
- continue;
612
- }
613
- const lines = raw.split('\n');
614
- for (let index = lines.length - 1; index >= 0; index -= 1) {
615
- const line = lines[index];
616
- if (!line || line.indexOf('"token_count"') === -1) continue;
617
- let parsed;
618
- try {
619
- parsed = JSON.parse(line);
620
- } catch (err) {
621
- continue;
622
- }
623
- const meter = parsed && parsed.payload && parsed.payload.rate_limits;
624
- const at = Date.parse(parsed && parsed.timestamp);
625
- if (meter && Number.isFinite(at)) return { meter, at };
626
- }
688
+ const tail = readTail(entry.file, TAIL_BYTES);
689
+ if (!tail) continue;
690
+ const found = meterFromLines(tail.text, tail.partial);
691
+ if (found) return found;
627
692
  }
628
693
  return null;
629
694
  }
@@ -752,6 +817,8 @@ function collect(now, options) {
752
817
  // what flexible pricing looks like. That is a different thing from having
753
818
  // found nothing to read, and it needs to be said differently.
754
819
  windowless: Boolean(mapped && mapped.windowless),
820
+ // The meter reported window slots but no readable percentage in them.
821
+ unreadable: Boolean(mapped && mapped.unreadable),
755
822
  windowSpecs: mapped ? mapped.specs : null,
756
823
  reachedType: mapped ? mapped.reachedType : null,
757
824
  spendControlReached: Boolean(mapped && mapped.spendControlReached),
@@ -948,6 +1015,9 @@ module.exports = {
948
1015
  planFrom,
949
1016
  latestMeter,
950
1017
  meterFromDisk,
1018
+ readTail,
1019
+ meterFromLines,
1020
+ TAIL_BYTES,
951
1021
  liveFile,
952
1022
  attemptFile,
953
1023
  readLiveMeter,