claude-usage-limits 1.11.6 → 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.
@@ -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,27 @@ 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
+
76
+ // Past this much of a per-model week, say how to free it. Below it the advice
77
+ // is noise: there is room, and the model in use is the right one.
78
+ const HALF_SPENT = 50;
79
+
80
+ // "fable" -> "Fable", the way the account names the window.
81
+ function familyLabel(family) {
82
+ const name = String(family || '');
83
+ return name ? name.charAt(0).toUpperCase() + name.slice(1) : name;
84
+ }
85
+
62
86
  // The runway is worth saying long before it is worth acting on, because it is
63
87
  // the figure that stops a turn count from flattering. Two hundred turns sounds
64
88
  // like plenty and can be twenty minutes when three sessions are spending.
@@ -167,12 +191,10 @@ function mergeCache(all, sessionId, entry, keep) {
167
191
  }
168
192
 
169
193
  function writeCache(all) {
170
- try {
171
- fs.mkdirSync(path.dirname(cacheFile()), { recursive: true });
172
- fs.writeFileSync(cacheFile(), JSON.stringify(all), 'utf8');
173
- } catch (err) {
174
- // A cache miss costs a scan. A crash costs the prompt. Prefer the scan.
175
- }
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);
176
198
  }
177
199
 
178
200
  function settings() {
@@ -269,6 +291,10 @@ function pressure(window, now, config, turnsLeft) {
269
291
  const CACHED_BINDING_FIELDS = [
270
292
  'key',
271
293
  'label',
294
+ // The model family a per-model weekly is scoped to. Read by the line that
295
+ // says a model switch is the only thing that frees such a window, so it has
296
+ // to survive the cache like every other field the wording depends on.
297
+ 'family',
272
298
  'applies',
273
299
  'percentUsed',
274
300
  'stale',
@@ -342,6 +368,41 @@ function count(value, word) {
342
368
  return value + ' ' + word + (Math.abs(value) === 1 ? '' : 's');
343
369
  }
344
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
+
345
406
  function describeWindow(window) {
346
407
  if (!window) return null;
347
408
  if (window.stale) return window.label + ' rolling over';
@@ -446,6 +507,62 @@ function briefText(parts) {
446
507
  'the amount that ran out last time, not a fresh allowance.'
447
508
  );
448
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
+ }
555
+ // A per-model weekly is the one window effort cannot help with. Nothing you
556
+ // do more cheaply on this model frees it; only running a different model
557
+ // does, and that has to be said, because the obvious move at 90 percent is
558
+ // to drop the effort and keep going, which spends the same window slower.
559
+ if (parts.family) {
560
+ sentences.push(
561
+ 'That window counts ' + parts.family + ' turns only, so lowering effort does not free it: ' +
562
+ 'switching model does. Use /model (or scripts/lowpower.js on --model <other>) for work ' +
563
+ 'that does not need ' + parts.family + ', and keep this one for what does.'
564
+ );
565
+ }
449
566
  if (parts.othersSummary) sentences.push('Other windows: ' + parts.othersSummary + '.');
450
567
 
451
568
  // A window that is not binding can still be the expensive one to exhaust.
@@ -501,6 +618,47 @@ function briefText(parts) {
501
618
  );
502
619
  }
503
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
+
504
662
  // Three states, and only the last one stops anything.
505
663
  //
506
664
  // The middle one is the one that keeps being got wrong. Near the wall the
@@ -513,7 +671,19 @@ function briefText(parts) {
513
671
  parts.pressure === 'gone'
514
672
  ? 'The budget is gone, so nothing further will run. Save what exists and ' +
515
673
  'write the handoff: what is finished, what is next and in what order, ' +
516
- '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.'
517
687
  : parts.pressure === 'tight'
518
688
  ? 'The budget is nearly gone, so make being cut off cheap rather than ' +
519
689
  'doing less. Carry on with the whole request at full quality: this is ' +
@@ -606,6 +776,62 @@ function tallyContext(all, sessionId, now) {
606
776
  };
607
777
  }
608
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
+ sessionId,
819
+ binding,
820
+ work,
821
+ resetsAt: binding.resetsAt,
822
+ cwd: (hookInput && hookInput.cwd) || process.cwd(),
823
+ project: path.basename((hookInput && hookInput.cwd) || process.cwd()),
824
+ hostName: usage.currentHost(),
825
+ });
826
+ return armed.ok
827
+ ? { enabled: true, armed: armed.record, justArmed: true, config, last: recent, work }
828
+ : { enabled: true, error: armed.error, config, last: recent, work };
829
+ } catch (err) {
830
+ // Nothing about carrying work forward is worth breaking the prompt for.
831
+ return null;
832
+ }
833
+ }
834
+
609
835
  async function run(now, hookInput) {
610
836
  if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
611
837
 
@@ -620,15 +846,22 @@ async function run(now, hookInput) {
620
846
  activity.mark(
621
847
  'working',
622
848
  sessionId,
623
- {
624
- // Only ultrathink, and only as a whole word: it is a real directive in
625
- // the prompt. Ultracode is an effort level, read from the setting the
626
- // agent reports, never from the text - the word turns up in ordinary
627
- // requests, and these marks are read by every panel on the machine.
628
- ultrathink: Boolean(
629
- hookInput && typeof hookInput.prompt === 'string' && /\bultrathink\b/i.test(hookInput.prompt)
630
- ),
631
- },
849
+ Object.assign(
850
+ {
851
+ // Whole words only: both are real directives in the prompt. Ultrathink
852
+ // is per prompt, so it is set true or false every time.
853
+ ultrathink: Boolean(
854
+ hookInput && typeof hookInput.prompt === 'string' && /\bultrathink\b/i.test(hookInput.prompt)
855
+ ),
856
+ },
857
+ // Ultracode is Claude Code's own keyword trigger and it sticks for the
858
+ // session once used, so it is only ever set on here and otherwise
859
+ // carried forward. It is safe to read from the text now that every
860
+ // display reads the mark of the one session it describes.
861
+ hookInput && typeof hookInput.prompt === 'string' && /\bultracode\b/i.test(hookInput.prompt)
862
+ ? { ultracode: true }
863
+ : {}
864
+ ),
632
865
  now
633
866
  );
634
867
 
@@ -674,7 +907,7 @@ async function run(now, hookInput) {
674
907
  if (!view || !view.binding) {
675
908
  // One call, shared with the report. Building the view twice is how the
676
909
  // snapshot correction reached the report and never reached the hook.
677
- const data = await usage.report(now, { sessionId });
910
+ const data = await usage.report(now, { sessionId, budgetMs: SCAN_BUDGET_MS });
678
911
  const binding = data.binding;
679
912
  view = {
680
913
  at: now,
@@ -691,6 +924,7 @@ async function run(now, hookInput) {
691
924
  })),
692
925
  snapshotAge: usage.formatDuration(data.snapshotAgeMs),
693
926
  binding: cacheableBinding(binding),
927
+ effortWarning: data.effortWarning || null,
694
928
  };
695
929
  writeCache(mergeCache(all, sessionId, view, KEEP_SESSIONS));
696
930
  }
@@ -707,7 +941,33 @@ async function run(now, hookInput) {
707
941
  binding.headroomMs <= RUNWAY_MENTION_MS;
708
942
  // Outside the cache on purpose: the tally moves after every reply.
709
943
  const found = tallyContext(tally.readState(), sessionId, now);
944
+ // Learning how the user writes, from the prompt that just arrived. Counters
945
+ // only, no model call, and it never speaks: what it knows is read back by
946
+ // /usage-limits:voice and used when the relay writes as them.
947
+ try {
948
+ if (hookInput && typeof hookInput.prompt === 'string') voice.observe(hookInput.prompt, now);
949
+ } catch (err) {
950
+ // Style is not worth a failed hook.
951
+ }
952
+ // This hook has just paid for a transcript scan, so the corrected figure is
953
+ // in hand. Leave it where the status line can read it: that line redraws far
954
+ // too often to scan for itself, and without this it shows the raw snapshot,
955
+ // which during a heavy session is wrong by tens of points in the flattering
956
+ // direction.
957
+ reading.record(binding, now, usage.isCodex() ? require('./codex.js').homeDir() : null);
958
+ const carry = relayState(now, hookInput, binding, sessionId);
959
+ // An instruction the user typed at /usage-limits:voice set. The learned
960
+ // traits are for writing AS them and stay out of the way; this is them
961
+ // saying how they want to be talked to, so it is said every time.
962
+ let voiceNote = null;
963
+ try {
964
+ voiceNote = voice.read().note;
965
+ } catch (err) {
966
+ voiceNote = null;
967
+ }
710
968
  return briefText({
969
+ relay: carry,
970
+ voiceNote,
711
971
  lastReply: found.lastReply,
712
972
  context: found.context,
713
973
  lastSession: found.lastSession,
@@ -721,8 +981,18 @@ async function run(now, hookInput) {
721
981
  ? usage.formatDuration(now - binding.refusedAt)
722
982
  : null,
723
983
  binding,
984
+ // Named only while the window is actually tight: at 20 percent nobody
985
+ // needs telling how to free it.
986
+ family:
987
+ binding && binding.family && Number.isFinite(binding.percentUsed) && binding.percentUsed >= HALF_SPENT
988
+ ? familyLabel(binding.family)
989
+ : null,
724
990
  othersSummary: view.othersSummary,
725
991
  turnsLeft: view.turnsLeft,
992
+ effortWarning: view.effortWarning || null,
993
+ // Outside the cache: it is cheap, and it belongs to the other agent's
994
+ // clock rather than this session's.
995
+ codex: codexSummary(now),
726
996
  resetsIn:
727
997
  binding && !binding.stale && Number.isFinite(binding.resetsAt)
728
998
  ? usage.formatDuration(binding.resetsAt - now)
@@ -772,6 +1042,7 @@ module.exports = {
772
1042
  describeWindow,
773
1043
  summariseOthers,
774
1044
  briefText,
1045
+ codexSummary,
775
1046
  tallyContext,
776
1047
  LARGE_CONTEXT_TOKENS,
777
1048
  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 };