claude-usage-limits 1.19.0 → 1.24.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.
Files changed (33) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +220 -2
  4. package/bin/cli.js +16 -0
  5. package/commands/defer.md +47 -0
  6. package/commands/usage-mode.md +64 -0
  7. package/hooks/hooks.json +1 -1
  8. package/package.json +1 -1
  9. package/skills/usage-limits/SKILL.md +196 -19
  10. package/skills/usage-limits/references/tactics.md +40 -9
  11. package/skills/usage-limits/scripts/agy-hook.js +175 -0
  12. package/skills/usage-limits/scripts/brief.js +406 -46
  13. package/skills/usage-limits/scripts/ceiling.js +191 -0
  14. package/skills/usage-limits/scripts/codex-lowpower.js +95 -4
  15. package/skills/usage-limits/scripts/codex.js +87 -6
  16. package/skills/usage-limits/scripts/defer.js +318 -0
  17. package/skills/usage-limits/scripts/drift.js +254 -0
  18. package/skills/usage-limits/scripts/feed.js +23 -1
  19. package/skills/usage-limits/scripts/host.js +23 -3
  20. package/skills/usage-limits/scripts/install-antigravity.js +215 -0
  21. package/skills/usage-limits/scripts/install-codex-hook.js +22 -2
  22. package/skills/usage-limits/scripts/lowpower.js +48 -0
  23. package/skills/usage-limits/scripts/mode.js +1637 -0
  24. package/skills/usage-limits/scripts/net.js +179 -0
  25. package/skills/usage-limits/scripts/pulse.js +254 -17
  26. package/skills/usage-limits/scripts/reading.js +12 -3
  27. package/skills/usage-limits/scripts/relay.js +266 -2
  28. package/skills/usage-limits/scripts/sessionend.js +8 -0
  29. package/skills/usage-limits/scripts/stop.js +145 -1
  30. package/skills/usage-limits/scripts/usage.js +244 -17
  31. package/skills/usage-limits/scripts/view.js +4 -0
  32. package/skills/usage-limits/scripts/voice.js +10 -1
  33. package/skills/usage-limits/scripts/wake.js +210 -30
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The ceiling: the one thing in this plugin that is enforced rather than
5
+ // reported.
6
+ //
7
+ // Everything else here tells an agent where the budget stands and trusts it to
8
+ // act on that. For Claude Code that mostly works. For Codex it does not, and
9
+ // the reason is worth stating plainly rather than blaming the model: a number
10
+ // in the context is an input to a decision, and an agent part-way through a
11
+ // plan it was told to finish will weigh "the window is at 80 per cent" against
12
+ // "finish the whole list" and keep going, every time. It is not ignoring the
13
+ // figure. It is trading it off, and losing the trade.
14
+ //
15
+ // So the ceiling does not argue. Above it, the calls that MULTIPLY spend are
16
+ // refused at the hook, before the model's judgement is involved at all.
17
+ //
18
+ // Three rules shape what it refuses, and they are the whole design:
19
+ //
20
+ // 1. It never refuses work, only the expensive WAY of doing it. A fan-out of
21
+ // eight subagents and doing the same eight things in sequence reach the
22
+ // same place; only one of them can spend half a window between two
23
+ // readings with nothing able to speak in between. Refusing the fan-out
24
+ // leaves the task entirely possible, which is why this is a ceiling and
25
+ // not a stop button.
26
+ //
27
+ // 2. It never refuses the cheap calls. Reading a file, running a test,
28
+ // writing an edit - all of it stays allowed at any percentage, because an
29
+ // agent that cannot save its work is worse than one that overspends.
30
+ //
31
+ // 3. It says why, in terms of what to do instead. A denial that reads "over
32
+ // budget" gets retried. One that reads "do these sequentially yourself"
33
+ // gets obeyed.
34
+ //
35
+ // Off by default. A ceiling nobody set is not a ceiling, and this file returns
36
+ // `allow` for every call until someone names a number.
37
+
38
+ const MULTIPLIERS = [
39
+ // Claude Code, and the Codex equivalents, which use the same names.
40
+ 'Agent',
41
+ 'Task',
42
+ 'Workflow',
43
+ // Codex names its fan-out differently depending on build; both have been
44
+ // seen. Matching the name is cheap and a miss only costs enforcement, never
45
+ // correctness.
46
+ 'Subagent',
47
+ 'Dispatch',
48
+ ];
49
+
50
+ // Antigravity derives tool names by lowercasing the step type and stripping
51
+ // CORTEX_STEP_TYPE_, so its names are snake_case and cannot be matched by the
52
+ // list above.
53
+ const MULTIPLIER_PATTERN = /^(agent|task|workflow|subagent|dispatch|spawn_[a-z_]*agent|run_[a-z_]*agent)$/i;
54
+
55
+ // How far below the ceiling the warning starts. Ten points is about one long
56
+ // turn at xhigh on a 5-hour window, which is the last moment a warning can
57
+ // still change what happens next.
58
+ const NEAR_POINTS = 10;
59
+
60
+ function isMultiplier(tool) {
61
+ const name = String(tool || '').trim();
62
+ if (!name) return false;
63
+ if (MULTIPLIERS.includes(name)) return true;
64
+ return MULTIPLIER_PATTERN.test(name);
65
+ }
66
+
67
+ // The number, and who set it. An explicit environment value is the user saying
68
+ // it outright for this one session and beats the file, the same precedence the
69
+ // mode uses.
70
+ function ceilingFrom(state, env) {
71
+ const environment = env || process.env;
72
+ const raw = environment.USAGE_LIMITS_CEILING;
73
+ if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
74
+ const text = String(raw).trim().toLowerCase();
75
+ if (text === 'off' || text === 'none' || text === 'no') return { percent: null, source: 'environment' };
76
+ const value = Number(text.replace(/%$/, ''));
77
+ if (Number.isFinite(value) && value > 0 && value <= 100) {
78
+ return { percent: value, source: 'environment' };
79
+ }
80
+ // A ceiling that cannot be read is not a ceiling of zero. Fall through to
81
+ // the file rather than enforcing a number nobody typed.
82
+ }
83
+ const stored = state && Number.isFinite(state.ceilingPercent) ? state.ceilingPercent : null;
84
+ if (stored !== null && stored > 0 && stored <= 100) return { percent: stored, source: 'the file' };
85
+ return { percent: null, source: null };
86
+ }
87
+
88
+ // Where the binding window stands against the ceiling.
89
+ //
90
+ // `percent` is the BINDING window's, not the emptiest one's. A ceiling read
91
+ // against whichever window has the most left would never bind at all, which is
92
+ // the same mistake the brief was corrected for.
93
+ function assess(options) {
94
+ const opts = options || {};
95
+ const ceiling = ceilingFrom(opts.state, opts.env);
96
+ const percent = Number.isFinite(opts.percent) ? opts.percent : null;
97
+ if (ceiling.percent === null || percent === null) {
98
+ return {
99
+ set: ceiling.percent !== null,
100
+ source: ceiling.source,
101
+ ceiling: ceiling.percent,
102
+ percent,
103
+ over: false,
104
+ near: false,
105
+ headroomPoints: null,
106
+ };
107
+ }
108
+ const headroomPoints = ceiling.percent - percent;
109
+ return {
110
+ set: true,
111
+ source: ceiling.source,
112
+ ceiling: ceiling.percent,
113
+ percent,
114
+ over: percent >= ceiling.percent,
115
+ near: headroomPoints > 0 && headroomPoints <= NEAR_POINTS,
116
+ headroomPoints,
117
+ };
118
+ }
119
+
120
+ function round(value) {
121
+ return Math.round(value * 10) / 10;
122
+ }
123
+
124
+ // What to do about one tool call.
125
+ //
126
+ // Returns `allow` for everything the ceiling does not cover, which is almost
127
+ // everything. The caller turns a `deny` into whatever its host's hook protocol
128
+ // wants; this file deliberately knows nothing about that.
129
+ function verdict(state, tool) {
130
+ if (!state || !state.set || !state.over) return { decision: 'allow', reason: null };
131
+ if (!isMultiplier(tool)) return { decision: 'allow', reason: null };
132
+ return {
133
+ decision: 'deny',
134
+ reason:
135
+ 'Usage ceiling reached: the binding window is ' +
136
+ round(state.percent) +
137
+ '% used and the ceiling is ' +
138
+ state.ceiling +
139
+ '%. Fan-out calls are refused past the ceiling because subagents spend the same window ' +
140
+ 'in parallel and nothing can report back until they stop. Nothing else is blocked. ' +
141
+ 'Do this work yourself, in this session, one step at a time - that is the same work at ' +
142
+ 'roughly a fifth of the spend. Do not retry this call and do not ask to raise the ' +
143
+ 'ceiling; if the ceiling is genuinely wrong, say so in your reply and let the user change it.',
144
+ };
145
+ }
146
+
147
+ // The line for a session that is close but not over. Advice, not enforcement -
148
+ // there is still room, and the point of saying it here is that it arrives
149
+ // BEFORE the expensive call rather than after it.
150
+ function warning(state) {
151
+ if (!state || !state.set || state.over || !state.near) return null;
152
+ return (
153
+ 'Usage ceiling in ' +
154
+ round(state.headroomPoints) +
155
+ ' points: the binding window is ' +
156
+ round(state.percent) +
157
+ '% used against a ceiling of ' +
158
+ state.ceiling +
159
+ '%. Past the ceiling, fan-out calls are refused outright. Land what is in flight and ' +
160
+ 'prefer doing the next steps in this session over spawning agents for them.'
161
+ );
162
+ }
163
+
164
+ // A short, honest description for the report and the panel.
165
+ function describe(state) {
166
+ if (!state || !state.set) return 'Ceiling not set';
167
+ const where = state.percent === null ? 'no reading' : round(state.percent) + '% used';
168
+ const status = state.over ? 'REACHED' : state.near ? 'close' : 'clear';
169
+ return (
170
+ 'Ceiling ' +
171
+ state.ceiling +
172
+ '% (' +
173
+ status +
174
+ ', ' +
175
+ where +
176
+ ')' +
177
+ (state.source ? ' - set in ' + state.source : '')
178
+ );
179
+ }
180
+
181
+ module.exports = {
182
+ MULTIPLIERS,
183
+ MULTIPLIER_PATTERN,
184
+ NEAR_POINTS,
185
+ isMultiplier,
186
+ ceilingFrom,
187
+ assess,
188
+ verdict,
189
+ warning,
190
+ describe,
191
+ };
@@ -35,6 +35,85 @@ function rewrite(text, values) {
35
35
  }
36
36
  return prepend.join('') + lines.join('');
37
37
  }
38
+ // ---------------------------------------------------------------------------
39
+ // The subagent clamp
40
+ //
41
+ // Effort is the biggest lever on Codex and this is the second. Two facts, both
42
+ // read out of the model catalog Codex itself caches at
43
+ // CODEX_HOME/models_cache.json rather than taken from documentation:
44
+ //
45
+ // gpt-6-astra: default_reasoning_level = "low"
46
+ // multi_agent_reasoning_effort = "xhigh"
47
+ //
48
+ // So Astra's own default effort is the cheapest one, and its subagents run at
49
+ // the dearest one NO MATTER what the main session is set to. A session at
50
+ // medium that delegates is still paying xhigh for everything it delegates, and
51
+ // the "ultra" level is described in that same catalog as "maximum reasoning
52
+ // with automatic task delegation" - it spawns them on its own.
53
+ //
54
+ // The [agents] table is where that is bounded. It is a table rather than a
55
+ // top-level key, and the line editor above deliberately refuses to be a TOML
56
+ // parser, so this is handled the one way that is safe without one: a marked
57
+ // block appended at the end of the file. A TOML table runs until the next
58
+ // header, so appending at the end is always valid, and the editor above stops
59
+ // at the first '[' so the two never interfere.
60
+ //
61
+ // If the file already has an [agents] table this refuses outright rather than
62
+ // writing a second one, because duplicate tables are a TOML error and a config
63
+ // this tool broke would be worse than a window it failed to save.
64
+ const AGENTS_START = '# >>> usage-limits lowpower: subagent clamp';
65
+ const AGENTS_END = '# <<< usage-limits lowpower';
66
+
67
+ function hasAgentsTable(text) {
68
+ // Any [agents] or [agents.x] header that is not inside our own block.
69
+ const outside = stripAgentsBlock(text);
70
+ return /^[ \t]*\[\s*agents\s*[.\]]/m.test(outside);
71
+ }
72
+
73
+ function stripAgentsBlock(text) {
74
+ const from = text.indexOf(AGENTS_START);
75
+ if (from === -1) return text;
76
+ const to = text.indexOf(AGENTS_END, from);
77
+ if (to === -1) return text;
78
+ // The block is written after one blank separator line, so removing it has to
79
+ // take that line too. Leaving it behind meant `off` returned a file one
80
+ // newline longer than `on` found it - which is not a restore, and the
81
+ // round-trip tests that guard this editor said so.
82
+ let start = from;
83
+ if (text.endsWith('\r\n\r\n', from)) start = from - 2;
84
+ else if (text.endsWith('\n\n', from)) start = from - 1;
85
+ const after = to + AGENTS_END.length;
86
+ // Take the newline that ends the marker line with it.
87
+ const end = text.startsWith('\r\n', after) ? after + 2 : text.startsWith('\n', after) ? after + 1 : after;
88
+ return text.slice(0, start) + text.slice(end);
89
+ }
90
+
91
+ function agentsBlock(newline) {
92
+ return [
93
+ AGENTS_START,
94
+ '# Written by the usage-limits plugin. "lowpower off --host codex" removes it.',
95
+ '# Astra runs its subagents at xhigh whatever the session is set to, so this',
96
+ '# is the only place that spend is bounded.',
97
+ '[agents]',
98
+ 'max_concurrent_threads_per_session = 1',
99
+ 'default_subagent_reasoning_effort = "low"',
100
+ AGENTS_END,
101
+ ].join(newline) + newline;
102
+ }
103
+
104
+ function withAgentsBlock(text, wanted, newline) {
105
+ const base = stripAgentsBlock(text);
106
+ if (!wanted) return base;
107
+ if (hasAgentsTable(base)) {
108
+ throw new Error(
109
+ 'config.toml already has an [agents] table; the subagent clamp was not written. ' +
110
+ 'Set max_concurrent_threads_per_session and default_subagent_reasoning_effort there yourself.'
111
+ );
112
+ }
113
+ const padded = base.length && !base.endsWith('\n') ? base + newline : base;
114
+ return padded + (padded.length ? newline : '') + agentsBlock(newline);
115
+ }
116
+
38
117
  function read(file, fallback) {
39
118
  try { return fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return fallback; throw e; }
40
119
  }
@@ -65,7 +144,13 @@ function plan(text, options, state) {
65
144
  if (state) for (const [key, line] of Object.entries(state.applied)) {
66
145
  if (lineOf(key) !== line) throw new Error(key + ' changed outside lowpower; review config and restore state before continuing.');
67
146
  }
68
- if (options.command === 'off') return { text: state ? rewrite(text, state.previous) : text, state: null };
147
+ const newline = text.includes('\r\n') ? '\r\n' : '\n';
148
+ if (options.command === 'off') {
149
+ const restored = state ? rewrite(text, state.previous) : text;
150
+ // The clamp is removed on `off` whether or not this run is the one that
151
+ // wrote it, so a state file lost to a crash cannot strand it in the config.
152
+ return { text: withAgentsBlock(restored, false, newline), state: null };
153
+ }
69
154
  const effort = options.effort || 'low';
70
155
  if (!EFFORTS.includes(effort)) throw new Error('Unknown Codex effort: ' + effort);
71
156
  if (options.model !== null && options.model !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(options.model))
@@ -74,12 +159,18 @@ function plan(text, options, state) {
74
159
  if (options.model) wanted.model = options.model;
75
160
  const previous = { ...(state && state.previous) };
76
161
  const applied = { ...(state && state.applied) };
77
- const newline = text.includes('\r\n') ? '\r\n' : '\n';
78
162
  for (const [key, value] of Object.entries(wanted)) {
79
163
  if (!Object.hasOwn(previous, key)) previous[key] = lineOf(key);
80
164
  applied[key] = key + ' = ' + JSON.stringify(value) + newline;
81
165
  }
82
- return { text: rewrite(text, applied), state: { version: 1, previous, applied } };
166
+ // On unless explicitly refused. Astra's subagents run at xhigh regardless of
167
+ // the session's effort, so lowering effort WITHOUT bounding them leaves the
168
+ // most expensive path in the product untouched.
169
+ const clampAgents = options.agents !== false;
170
+ return {
171
+ text: withAgentsBlock(rewrite(text, applied), clampAgents, newline),
172
+ state: { version: 1, previous, applied, agentsClamp: clampAgents },
173
+ };
83
174
  }
84
175
  function main(args) {
85
176
  if (!['status', 'on', 'off'].includes(args.command)) throw new Error('Expected status, on or off');
@@ -132,4 +223,4 @@ function main(args) {
132
223
  if (lock !== undefined) { fs.closeSync(lock); fs.unlinkSync(stateFile + '.lock'); }
133
224
  }
134
225
  }
135
- module.exports = { scan, rewrite, plan, main, EFFORTS };
226
+ module.exports = { scan, rewrite, plan, main, EFFORTS, AGENTS_START, AGENTS_END, hasAgentsTable, stripAgentsBlock, agentsBlock, withAgentsBlock };
@@ -606,12 +606,74 @@ function calibrate(events, key, now) {
606
606
  return { usdPerPercent: cost / moved, turns, percent: Math.round(last.percent) };
607
607
  }
608
608
 
609
+ function limitIdOf(meter) {
610
+ return meter && typeof meter.limit_id === 'string' ? meter.limit_id : '';
611
+ }
612
+
613
+ // Whether this payload is a reading of a rolling window at all.
614
+ //
615
+ // Percentages count, and so does Codex saying outright that the window is
616
+ // spent: at a limit hit the slots can come back null with
617
+ // `rate_limit_reached_type` set, and that payload is the truest description of
618
+ // the window there is. Skipping it for an older one that still had numbers
619
+ // would report a window as running when it has already stopped.
620
+ function carriesWindows(meter) {
621
+ if (!meter || typeof meter !== 'object') return false;
622
+ if (readingsOf(meter).length) return true;
623
+ return typeof meter.rate_limit_reached_type === 'string' && meter.rate_limit_reached_type !== '';
624
+ }
625
+
626
+ // Codex writes more than one meter, and they are not successive readings of one
627
+ // thing. A Plus rollout carries `limit_id: "codex"`, which holds the 5-hour and
628
+ // weekly windows, interleaved with `limit_id: "premium"`, which holds the credit
629
+ // balance and has `primary` and `secondary` set to null.
630
+ //
631
+ // Taking whichever was written last therefore threw the windows away whenever a
632
+ // `premium` payload happened to land last, which on this machine was two of
633
+ // every six rollouts - and the report went blind at exactly the moment it was
634
+ // wanted. On 2026-09-07 the final line of a rollout was a `premium` payload two
635
+ // lines after a `codex` payload reading 99 per cent of the 5-hour window, and
636
+ // what reached the agent was a meter with no windows in it at all. That is the
637
+ // whole of "Codex does not slow down when the limit is close": nothing ever
638
+ // told it the limit was close.
639
+ //
640
+ // So the newest reading of EACH meter is kept, and the one that actually
641
+ // describes a window wins. Nothing is merged and nothing is synthesised: the
642
+ // payload returned is one Codex really wrote, and `at` is when it wrote it, so
643
+ // a stale reading still ages honestly.
644
+ function pickMeter(candidates) {
645
+ const newestBy = new Map();
646
+ let newest = null;
647
+ for (const entry of candidates || []) {
648
+ if (!entry || !entry.meter || !Number.isFinite(entry.at)) continue;
649
+ if (!newest || entry.at > newest.at) newest = entry;
650
+ const id = limitIdOf(entry.meter);
651
+ const held = newestBy.get(id);
652
+ if (!held || entry.at > held.at) newestBy.set(id, entry);
653
+ }
654
+ let best = null;
655
+ for (const entry of newestBy.values()) {
656
+ if (!carriesWindows(entry.meter)) continue;
657
+ if (!best || entry.at > best.at) best = entry;
658
+ }
659
+ // No meter describes a window: that is a real answer too, and the newest
660
+ // payload is the one that should say it. utilizationFrom then reports it as
661
+ // unreadable or windowless exactly as before.
662
+ return best || newest;
663
+ }
664
+
609
665
  // The newest meter reading in the rollouts, and when it was taken.
610
666
  function latestMeter(events) {
667
+ const candidates = [];
611
668
  for (let index = events.length - 1; index >= 0; index -= 1) {
612
- if (events[index].meter) return { meter: events[index].meter, at: events[index].at };
669
+ const event = events[index];
670
+ if (!event || !event.meter) continue;
671
+ candidates.push({ meter: event.meter, at: event.at });
672
+ // Newest first, so the first payload that describes a window is the newest
673
+ // one that does and nothing older can beat it.
674
+ if (carriesWindows(event.meter)) break;
613
675
  }
614
- return null;
676
+ return pickMeter(candidates);
615
677
  }
616
678
 
617
679
  // The meter is written next to every request, so the newest one is always near
@@ -664,6 +726,7 @@ function meterFromLines(text, partial) {
664
726
  // The first line of a tail is a fragment of whatever it landed in the middle
665
727
  // of, so it is never parsed.
666
728
  const floor = partial ? 1 : 0;
729
+ const candidates = [];
667
730
  for (let index = lines.length - 1; index >= floor; index -= 1) {
668
731
  const line = lines[index];
669
732
  if (!line || line.indexOf('"token_count"') === -1) continue;
@@ -675,22 +738,37 @@ function meterFromLines(text, partial) {
675
738
  }
676
739
  const meter = parsed && parsed.payload && parsed.payload.rate_limits;
677
740
  const at = Date.parse(parsed && parsed.timestamp);
678
- if (meter && Number.isFinite(at)) return { meter, at };
741
+ if (!meter || !Number.isFinite(at)) continue;
742
+ candidates.push({ meter, at });
743
+ // Walking backwards, so the first payload that describes a window is the
744
+ // newest one that does. Stopping there keeps the common case at a handful
745
+ // of parsed lines rather than the whole tail, which matters on the status
746
+ // line path.
747
+ if (carriesWindows(meter)) break;
679
748
  }
680
- return null;
749
+ return pickMeter(candidates);
681
750
  }
682
751
 
683
752
  // Scanning only the newest few rollouts, for the meter alone. `collect` runs on
684
753
  // the status-line path where a full scan would be far too slow.
754
+ //
755
+ // A rollout whose tail holds only the credit meter is not a reason to stop: the
756
+ // windows are in an older one, and reporting nothing when they are three
757
+ // seconds away on disk is the blindness this whole path exists to avoid. The
758
+ // newest windowless reading is still kept, so an account that genuinely has no
759
+ // rolling window says so instead of saying nothing.
685
760
  function meterFromDisk() {
686
761
  const files = rolloutFiles(NaN).slice(-12).reverse();
762
+ let fallback = null;
687
763
  for (const entry of files) {
688
764
  const tail = readTail(entry.file, TAIL_BYTES);
689
765
  if (!tail) continue;
690
766
  const found = meterFromLines(tail.text, tail.partial);
691
- if (found) return found;
767
+ if (!found) continue;
768
+ if (carriesWindows(found.meter)) return found;
769
+ if (!fallback || found.at > fallback.at) fallback = found;
692
770
  }
693
- return null;
771
+ return fallback;
694
772
  }
695
773
 
696
774
  // Where a live reading taken by refreshIfStale() is kept, so the hooks and the
@@ -1013,6 +1091,9 @@ module.exports = {
1013
1091
  utilizationFrom,
1014
1092
  labelFor,
1015
1093
  planFrom,
1094
+ limitIdOf,
1095
+ carriesWindows,
1096
+ pickMeter,
1016
1097
  latestMeter,
1017
1098
  meterFromDisk,
1018
1099
  readTail,