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,179 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Is the network actually usable, and if a run just failed, was the network
5
+ // the reason?
6
+ //
7
+ // This exists because of a real failure. A relay armed overnight woke on time,
8
+ // found nothing wrong with itself, started the CLI, and got back
9
+ //
10
+ // API Error: Unable to connect to API: SSL certificate hostname mismatch
11
+ //
12
+ // which is what a laptop says when the wifi is off and something - a captive
13
+ // portal, a VPN, a corporate proxy - is answering the TLS handshake in the
14
+ // endpoint's place. The relay treated that as a failed run, recorded "failed",
15
+ // cleared itself and deleted its own scheduled task. The work was not done, the
16
+ // window it had waited for was wide open, and nothing was left to try again.
17
+ //
18
+ // The rule that follows from it: **a machine that cannot reach the API has not
19
+ // failed, it is waiting.** Being offline is a normal state for a laptop at
20
+ // three in the morning and it must cost a retry, never the relay.
21
+ //
22
+ // Nothing here uses a package. `fetch` is in Node 18+, and the only thing that
23
+ // matters is telling three cases apart:
24
+ //
25
+ // online the endpoint answered, with anything at all including a 401
26
+ // offline nothing answered - no DNS, no route, no socket
27
+ // intercepted something answered but it was not the endpoint. This is the
28
+ // nasty one, because a captive portal returns a valid HTTP
29
+ // response and a working TLS session for the wrong certificate,
30
+ // so a naive "did I get bytes back" check says yes.
31
+
32
+ const MINUTE = 60 * 1000;
33
+
34
+ // 401 is the correct, healthy answer from an authenticated endpoint hit without
35
+ // a key: it proves DNS, routing, TLS and the service. Anything in the 2xx-5xx
36
+ // range proves the same thing more loosely. Only a thrown error is offline.
37
+ const PROBES = [
38
+ { name: 'api.anthropic.com', url: 'https://api.anthropic.com/v1/models' },
39
+ { name: 'claude.ai', url: 'https://claude.ai/robots.txt' },
40
+ { name: 'github.com', url: 'https://github.com/robots.txt' },
41
+ ];
42
+
43
+ // The strings a TLS interception actually produces, across Node, curl and the
44
+ // CLIs. Matched case-insensitively against the whole error chain because Node
45
+ // buries the real reason in err.cause.
46
+ const TLS_INTERCEPTION = /(certificate|self.signed|self_signed|hostname\/ip does not match|hostname mismatch|altname|unable to verify|cert_authority|ERR_TLS|DEPTH_ZERO|CERT_HAS_EXPIRED|UNABLE_TO_GET_ISSUER)/i;
47
+
48
+ const OFFLINE = /(ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ENETDOWN|EPIPE|ETIMEDOUT|ECONNABORTED|socket hang up|network is unreachable|getaddrinfo|fetch failed|Unable to connect|Connection (?:error|closed|reset)|dns)/i;
49
+
50
+ // Failures that are the service's, not ours, and are worth waiting out rather
51
+ // than burning the relay on. A 529 is Anthropic's own overload code.
52
+ const TRANSIENT_SERVICE = /(\b429\b|\b500\b|\b502\b|\b503\b|\b504\b|\b529\b|overloaded|rate.?limit|too many requests|temporarily unavailable|service unavailable|internal server error|upstream|gateway|try again|timed? ?out|timeout)/i;
53
+
54
+ // Failures that will still be failures in five hours. Retrying these is how a
55
+ // relay spends a whole window re-running the same refusal.
56
+ const PERMANENT = /(\b401\b|\b403\b|invalid.?api.?key|authentication|unauthorized|forbidden|no conversation found|not logged in|please run .?claude .?login|credit balance|billing|quota exceeded|permission denied|ENOENT|command not found|is not recognized)/i;
57
+
58
+ function chain(err) {
59
+ const seen = [];
60
+ let node = err;
61
+ for (let depth = 0; node && depth < 6; depth++) {
62
+ if (node.message) seen.push(String(node.message));
63
+ if (node.code) seen.push(String(node.code));
64
+ if (node.errno) seen.push(String(node.errno));
65
+ node = node.cause;
66
+ }
67
+ return seen.join(' | ');
68
+ }
69
+
70
+ // One probe. Resolves rather than rejects: a probe that throws is a result.
71
+ async function probe(target, timeoutMs) {
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs || 8000));
74
+ const started = Date.now();
75
+ try {
76
+ const response = await fetch(target.url, {
77
+ method: 'GET',
78
+ signal: controller.signal,
79
+ redirect: 'manual',
80
+ headers: { 'user-agent': 'usage-limits-relay/1 (connectivity probe)' },
81
+ });
82
+ return { name: target.name, ok: true, status: response.status, ms: Date.now() - started };
83
+ } catch (err) {
84
+ const text = chain(err);
85
+ return {
86
+ name: target.name,
87
+ ok: false,
88
+ ms: Date.now() - started,
89
+ intercepted: TLS_INTERCEPTION.test(text),
90
+ aborted: /abort/i.test(text),
91
+ detail: text.split(' | ')[0] || 'unknown',
92
+ };
93
+ } finally {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+
98
+ // The question the wake asks: can this machine reach the service right now.
99
+ //
100
+ // Probes run together and the first success wins, because one endpoint being
101
+ // down is not the same as having no network, and waiting for three timeouts in
102
+ // series burns half a minute for an answer the first probe already had.
103
+ async function reachable(options) {
104
+ const opts = options || {};
105
+ const timeoutMs = opts.timeoutMs || 8000;
106
+ const targets = opts.targets || PROBES;
107
+ let results = [];
108
+ try {
109
+ results = await Promise.all(targets.map((target) => probe(target, timeoutMs)));
110
+ } catch (err) {
111
+ return { online: false, reason: 'offline', detail: err.message, results: [] };
112
+ }
113
+ const good = results.filter((r) => r.ok);
114
+ if (good.length) {
115
+ return {
116
+ online: true,
117
+ reason: 'ok',
118
+ detail: good.map((r) => r.name + ' ' + r.status + ' in ' + r.ms + 'ms').join(', '),
119
+ results,
120
+ };
121
+ }
122
+ // Nothing answered. Interception is worth naming separately: the fix is not
123
+ // "wait for the network", it is "sign in to the wifi" or "turn the VPN off",
124
+ // and a message that says so saves somebody a morning.
125
+ if (results.some((r) => r.intercepted)) {
126
+ return {
127
+ online: false,
128
+ reason: 'intercepted',
129
+ detail: 'something answered in the endpoint\'s place - a captive portal, a VPN or a TLS-inspecting proxy. ' +
130
+ (results.find((r) => r.intercepted) || {}).detail,
131
+ results,
132
+ };
133
+ }
134
+ return {
135
+ online: false,
136
+ reason: 'offline',
137
+ detail: results.map((r) => r.name + ': ' + (r.aborted ? 'timed out' : r.detail)).join('; '),
138
+ results,
139
+ };
140
+ }
141
+
142
+ // Given the text of a failed run, decide whether trying again later is sensible.
143
+ //
144
+ // wait the network or the service. Retry; do not consume the relay.
145
+ // permanent a key, a login, a missing binary. Retrying wastes the window.
146
+ // unknown treated as wait once and permanent after that, because an
147
+ // unrecognised error that repeats is not going to fix itself.
148
+ function classify(text) {
149
+ const message = String(text || '');
150
+ if (!message.trim()) return { kind: 'unknown', why: 'the run failed without saying why' };
151
+ if (PERMANENT.test(message)) return { kind: 'permanent', why: 'this will still be true after the next reset' };
152
+ if (TLS_INTERCEPTION.test(message)) {
153
+ return {
154
+ kind: 'wait',
155
+ why: 'the TLS handshake did not reach the endpoint - offline, a captive portal, or a VPN in the way',
156
+ };
157
+ }
158
+ if (OFFLINE.test(message)) return { kind: 'wait', why: 'the machine could not reach the network' };
159
+ if (TRANSIENT_SERVICE.test(message)) return { kind: 'wait', why: 'the service was busy or unavailable' };
160
+ return { kind: 'unknown', why: 'an error this has not seen before' };
161
+ }
162
+
163
+ // How long to wait before looking again, given how many times it has already
164
+ // looked. Gentle at first because most outages are a router restarting, then
165
+ // backing off so an overnight outage is not a thousand wake-ups.
166
+ function backoffMinutes(attempt, base) {
167
+ const start = Number.isFinite(base) ? base : 10;
168
+ const steps = [start, start, start * 2, start * 3, start * 6, start * 6];
169
+ return steps[Math.min(Math.max(0, attempt), steps.length - 1)];
170
+ }
171
+
172
+ module.exports = { reachable, probe, classify, backoffMinutes, PROBES, MINUTE };
173
+
174
+ if (require.main === module) {
175
+ reachable({ timeoutMs: 8000 }).then((result) => {
176
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
177
+ process.exit(result.online ? 0 : 1);
178
+ });
179
+ }
@@ -27,6 +27,8 @@ const brief = require('./brief.js');
27
27
  const host = require('./host.js');
28
28
  const activity = require('./activity.js');
29
29
  const live = require('./live.js');
30
+ const mode = require('./mode.js');
31
+ const ceiling = require('./ceiling.js');
30
32
 
31
33
  const SECOND = 1000;
32
34
  const DEFAULT_INTERVAL_SECONDS = 120;
@@ -37,9 +39,11 @@ const DEFAULT_INTERVAL_SECONDS = 120;
37
39
  const SCAN_BUDGET_MS = 5000;
38
40
 
39
41
  // One slot per session, same shape and same trimming as the brief's cache.
40
- // Two keys per session now - the spoken pulse and the quiet subagent refresh -
41
- // so this is double what it was.
42
- const KEEP_SESSIONS = 16;
42
+ // Three keys per session now - the spoken pulse, the quiet subagent refresh,
43
+ // and the reading taken before a long call - so this is three times what it
44
+ // started at. Evicting a key only costs one extra scan, but evicting them as
45
+ // fast as they are written would mean nothing is ever throttled.
46
+ const KEEP_SESSIONS = 24;
43
47
 
44
48
  function stateFile() {
45
49
  const dir = usage.isCodex()
@@ -48,12 +52,15 @@ function stateFile() {
48
52
  return path.join(dir, 'usage-limits-pulse.json');
49
53
  }
50
54
 
51
- function intervalMs() {
55
+ // How often this hook is allowed to do real work. The budget mode owns this
56
+ // number - a mode that says the reading itself costs too much has to be able
57
+ // to take fewer of them - but an explicit environment setting is the user
58
+ // saying it outright, and that still wins.
59
+ function intervalMs(policy) {
52
60
  const configured = Number(process.env.USAGE_LIMITS_PULSE_SECONDS);
53
- const seconds = Number.isFinite(configured) && configured > 0
54
- ? configured
55
- : DEFAULT_INTERVAL_SECONDS;
56
- return seconds * SECOND;
61
+ if (Number.isFinite(configured) && configured > 0) return configured * SECOND;
62
+ const fromMode = policy && Number.isFinite(policy.pulseSeconds) ? policy.pulseSeconds : null;
63
+ return (fromMode > 0 ? fromMode : DEFAULT_INTERVAL_SECONDS) * SECOND;
57
64
  }
58
65
 
59
66
  function readState() {
@@ -72,15 +79,42 @@ function writeState(all) {
72
79
  usage.writeJsonAtomic(stateFile(), all);
73
80
  }
74
81
 
75
- function trim(all, sessionId, at) {
82
+ // `extra` carries whatever else the slot has to remember, and it exists for
83
+ // one reason: the re-cost has to know what it said last time. A slot that is
84
+ // only a timestamp can throttle, but it cannot tell "two minutes have passed"
85
+ // from "two minutes have passed and nothing has changed", and those are
86
+ // different questions.
87
+ function trim(all, sessionId, at, extra) {
76
88
  const next = Object.assign({}, all);
77
- next[sessionId || '_'] = { at };
89
+ next[sessionId || '_'] = Object.assign({ at }, extra || null);
78
90
  const ordered = Object.keys(next).sort((a, b) => (next[b].at || 0) - (next[a].at || 0));
79
91
  const kept = {};
80
92
  for (const key of ordered.slice(0, KEEP_SESSIONS)) kept[key] = next[key];
81
93
  return kept;
82
94
  }
83
95
 
96
+ // A tool call the agent itself said would run long.
97
+ //
98
+ // PreToolUse now matches Bash, so a build or a test suite can be measured
99
+ // before it starts rather than only after. But sharing the spoken pulse's one
100
+ // throttle slot makes that almost never happen: a tool call finished seconds
101
+ // ago, that PostToolUse pulse claimed the slot, and the one moment that
102
+ // matters - just before the turn goes blind for ten minutes - says nothing.
103
+ // Measured: a PostToolUse pulse at t, then `npm test` with a ten-minute
104
+ // timeout at t+5s, and the pre-call pulse returned empty.
105
+ //
106
+ // The declaration is the agent's own: a Bash call carries the timeout it was
107
+ // given, and anything above the pulse interval is a call that will outlast the
108
+ // next scheduled reading. Nothing is inferred from the command text. A
109
+ // backgrounded call is not this: it returns at once and PostToolUse fires
110
+ // normally, so the turn never goes blind.
111
+ function longCall(toolInput, every) {
112
+ if (!toolInput || typeof toolInput !== 'object') return false;
113
+ if (toolInput.run_in_background === true) return false;
114
+ const declared = Number(toolInput.timeout);
115
+ return Number.isFinite(declared) && declared > every;
116
+ }
117
+
84
118
  function due(all, sessionId, now, every) {
85
119
  const entry = all ? all[sessionId || '_'] : null;
86
120
  if (!entry || !Number.isFinite(entry.at)) return true;
@@ -128,13 +162,62 @@ function pulseText(parts) {
128
162
  return head + ' Still room; carry on.';
129
163
  }
130
164
 
165
+ // The two-minute re-cost. The distinguishing feature of `high`, and the reason
166
+ // it is not simply `standard` with a shorter interval.
167
+ //
168
+ // A PostToolUse hook CAN put text in front of the model mid-turn: the hook
169
+ // output contract takes additionalContext on this event, and it has been seen
170
+ // arriving mid-turn in a running session. So this is a real nudge, not a note
171
+ // filed for the next prompt.
172
+ //
173
+ // What it must not do is claim a lever nobody has. Nothing a hook emits can
174
+ // change the running session's own model or effort - there is no such field in
175
+ // the whole hook output contract, and PreModelSwitch is a veto on a switch
176
+ // someone else started rather than a way to start one. So for the main loop
177
+ // this names the command and leaves it with the person, and for what the turn
178
+ // SPAWNS it says the thing that is genuinely the agent's to decide.
179
+ //
180
+ // It is built every two minutes and SAID only when it differs from the last
181
+ // thing said in this session. See the caller: a re-cost that repeats itself is
182
+ // not a re-cost, it is a bill for advice already given.
183
+ function recheckText(parts) {
184
+ const bits = [];
185
+ if (parts.fit) {
186
+ bits.push(
187
+ 'this turn is at ' + parts.fit.effort + ', measured ' + parts.fit.multiple + ' times the cost of ' +
188
+ parts.fit.cheaper + ' a turn on this account'
189
+ );
190
+ }
191
+ if (parts.escape && parts.escape.kind === 'effort' && parts.escape.to) {
192
+ bits.push(parts.escape.to + ' would free the window that binds');
193
+ }
194
+ if (!bits.length) return '';
195
+ const command = (parts.fit && parts.fit.command) || (parts.escape && parts.escape.command) || null;
196
+ return (
197
+ '[usage-limits] Re-costed: ' + bits.join(', and ') + '. If the stretch in front of you is ' +
198
+ 'mechanical, the tier is bigger than the work' + (command ? ' - the change is ' + command + ', and it is the ' +
199
+ "user's to make, so say it in one line rather than waiting for it" : '') +
200
+ (parts.pin
201
+ ? '. Self-switching is pinned, so this is a report: change nothing on the strength of it.'
202
+ : '. Size what you spawn the same way: low effort for mechanical stages. Step back up when the work turns hard again.')
203
+ );
204
+ }
205
+
131
206
  async function run(now, hookInput) {
132
207
  if (String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() === 'off') return '';
133
- usage.setHost(host.detect(process.argv.slice(2), process.env));
134
208
 
135
209
  const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
136
210
  const event = hookInput && hookInput.hook_event_name ? String(hookInput.hook_event_name) : 'PostToolUse';
137
211
 
212
+ // The budget mode, settled before anything is read, marked or scanned. In
213
+ // `off` the hook stops here: no reading, no activity mark, no state write.
214
+ // That mode's whole promise is that the plugin costs nothing, and a hook
215
+ // that "returns immediately" after a transcript scan has already broken it.
216
+ const budget = mode.forSession({ sessionId });
217
+ if (budget.policy.refreshSeconds === 0) return '';
218
+
219
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
220
+
138
221
  // A subagent finishing is the other reason to look, and on a busy afternoon
139
222
  // it is the more important one.
140
223
  //
@@ -156,6 +239,30 @@ async function run(now, hookInput) {
156
239
  // pulse had last read as 24%.
157
240
  const tool = hookInput && hookInput.tool_name ? String(hookInput.tool_name) : '';
158
241
  const fanout = event === 'PreToolUse' && /^(Workflow|Agent|Task)$/.test(tool);
242
+
243
+ // The ceiling, checked before the throttle and before any scan.
244
+ //
245
+ // Everything else in this hook is advice, and advice is throttled so it does
246
+ // not become noise. A refusal that only arrives when the pulse happens to be
247
+ // due is not a refusal, so this runs on every fan-out regardless, off the two
248
+ // readings that are already on disk. It is the one thing here that does not
249
+ // ask the model to agree with it.
250
+ if (event === 'PreToolUse' && ceiling.isMultiplier(tool)) {
251
+ try {
252
+ const at = ceiling.assess({
253
+ percent: ceilingPercent(now),
254
+ state: budget.state,
255
+ env: process.env,
256
+ });
257
+ const call = ceiling.verdict(at, tool);
258
+ if (call.decision === 'deny') return { deny: true, reason: call.reason };
259
+ } catch (err) {
260
+ // A ceiling that throws must not block the call it was asked to judge.
261
+ }
262
+ }
263
+ // The other way a turn goes quiet for a long time: one foreground tool call
264
+ // that runs for minutes. See longCall().
265
+ const long = event === 'PreToolUse' && !fanout && longCall(hookInput && hookInput.tool_input, intervalMs(budget.policy));
159
266
  if (!quiet) {
160
267
  // A tool call just finished, so the turn is still running. A few bytes, so
161
268
  // the panel beside the chat keeps animating through a long turn.
@@ -163,12 +270,20 @@ async function run(now, hookInput) {
163
270
  }
164
271
 
165
272
  const all = readState();
166
- const every = intervalMs();
273
+ const every = intervalMs(budget.policy);
167
274
  // The quiet refresh keeps its own throttle. Sharing one with the spoken
168
275
  // pulse would mean a workflow's subagents used up the interval and the tool
169
276
  // call right after it, the first chance to actually tell Claude, said
170
277
  // nothing because something had already "pulsed" two minutes ago.
171
- const throttleKey = quiet ? (sessionId || '_') + '#subagent' : sessionId;
278
+ // Same reasoning for the call about to run long: its own slot, so it is
279
+ // still throttled to one reading per interval and a run of long calls does
280
+ // not scan before every one of them, but the post-tool pulses cannot use up
281
+ // the interval and leave the blind stretch unmeasured.
282
+ const throttleKey = quiet
283
+ ? (sessionId || '_') + '#subagent'
284
+ : long
285
+ ? (sessionId || '_') + '#long'
286
+ : sessionId;
172
287
  // The cheap path, and the one taken almost every time. A fan-out is never
173
288
  // throttled: it is said every time, because every time it is about to cost.
174
289
  if (!fanout && !due(all, throttleKey, now, every)) return '';
@@ -222,13 +337,66 @@ async function run(now, hookInput) {
222
337
  const runwayMs = brief.RUNWAY_MENTION_MS;
223
338
  const pressure = brief.pressure(binding, now, config, turnsLeft);
224
339
 
340
+ // The mode's own mid-turn re-cost, on its own throttle slot.
341
+ //
342
+ // Its own slot because the "quiet when roomy" rule below would otherwise
343
+ // swallow it exactly when it matters most: a turn running a tier far bigger
344
+ // than the work needs is precisely the case where the budget still looks
345
+ // roomy and nothing else would say a word.
346
+ let recheck = '';
347
+ const recheckMs = (budget.policy.recheckSeconds || 0) * SECOND;
348
+ const recheckKey = (sessionId || '_') + '#recheck';
349
+ if (recheckMs > 0 && due(readState(), recheckKey, now, recheckMs)) {
350
+ // From the per-effort TABLE, which is what report() returns. It was asked
351
+ // for from `data.events`, a field report() has never had, so the measured
352
+ // half of the re-cost was null on every call on every machine and `high`
353
+ // was left emitting its escape clause alone.
354
+ const fit = usage.fitFromRates(data.effortRates || [], data.effortNow || null, usage.currentHost());
355
+ // The user's bounds are the user's, and `pin` is one of them: it lives on
356
+ // bounds, not on the mode's policy, so it has to be handed over here or
357
+ // the route comes back unable to say it is a report.
358
+ const route = usage.escapeRoute(data.windows || [binding], binding, data.effortWarning || null, usage.currentHost(), budget.policy, budget.bounds);
359
+ const allowed = (suggestion) => mode.allows(budget.bounds, suggestion);
360
+ // The measured half is a recommendation about the user's own setting, so
361
+ // it goes out through the advice rules rather than around them: never once
362
+ // muted, never once declined, never below a bound, and not a second time
363
+ // in a session where the brief has already made it. Filtering only on the
364
+ // bounds here meant `mode --no-advice` and `mode --decline` were both
365
+ // routed around every two minutes, in the one mode that speaks mid-turn.
366
+ const advice = mode.advicePending({ decided: budget, fit, sessionId });
367
+ const offerFit = advice.ok && !advice.alreadyOffered ? fit : null;
368
+ const text = recheckText({
369
+ fit: offerFit,
370
+ escape: route && route.kind === 'effort' && allowed({ effort: route.to }) ? route : null,
371
+ pin: budget.bounds && budget.bounds.pin,
372
+ });
373
+ // Say it once.
374
+ //
375
+ // Nothing this line is built from moves within a turn, so on the throttle
376
+ // alone it repeated itself verbatim every two minutes: measured, fifteen
377
+ // identical injections in one half-hour turn at 20 per cent used. That is
378
+ // the failure this whole feature exists to avoid, arriving in the mode
379
+ // named for efficiency. `high` re-costs continuously - it takes the
380
+ // measurement every two minutes - and speaks when the answer CHANGES.
381
+ const state = readState();
382
+ const before = state[recheckKey] && state[recheckKey].said;
383
+ recheck = text && text !== before ? text : '';
384
+ // Recorded only when it is actually said, and only for the half that is a
385
+ // recommendation, so that "no, leave it" has something to refuse: without
386
+ // this, `mode --decline` after a mid-turn re-cost had no id to act on.
387
+ if (recheck && offerFit && advice.id) mode.adviceOffer(advice.id, sessionId, now, advice.text);
388
+ // Claimed whether or not it produced a line: the measurement was taken,
389
+ // and taking it again in ten seconds would cost the same and say the same.
390
+ writeState(trim(state, recheckKey, now, text ? { said: text } : null));
391
+ }
392
+
225
393
  // Quiet when there is nothing to act on. A line every two minutes saying the
226
394
  // budget is fine is noise that costs the budget it is reporting on.
227
395
  if (!fanout && pressure === 'roomy' && String(process.env.USAGE_LIMITS_PULSE || '').toLowerCase() !== 'always') {
228
- return '';
396
+ return recheck;
229
397
  }
230
398
 
231
- return pulseText({
399
+ const spoken = pulseText({
232
400
  label: binding.label,
233
401
  percentUsed: binding.percentUsed,
234
402
  approximate: Boolean(binding.estimated || binding.adjusted),
@@ -241,6 +409,49 @@ async function run(now, hookInput) {
241
409
  pressure,
242
410
  fanout,
243
411
  });
412
+ return recheck ? (spoken ? spoken + ' ' + recheck : recheck) : spoken;
413
+ }
414
+
415
+ // The cheapest percentage good enough to enforce a ceiling against.
416
+ //
417
+ // The ceiling is checked before every fan-out, which is far too often to scan
418
+ // transcripts for. Two sources are already paid for: the corrected reading the
419
+ // last scan left behind, and the account snapshot, which is one small file
420
+ // read. The highest of them wins, because a ceiling means "no window past
421
+ // here" - taking the emptiest window would be a ceiling that never binds.
422
+ //
423
+ // Returns null when neither source has anything, and a ceiling with no reading
424
+ // behind it never refuses. Guessing high would block work over a number nobody
425
+ // measured; guessing low would not be a ceiling at all.
426
+ function ceilingPercent(now) {
427
+ let worst = null;
428
+ const consider = (value) => {
429
+ if (!Number.isFinite(value)) return;
430
+ if (worst === null || value > worst) worst = value;
431
+ };
432
+ const codexHome = usage.isCodex() ? require('./codex.js').homeDir() : null;
433
+ try {
434
+ const entries = reading.read(codexHome);
435
+ for (const key of Object.keys(entries)) {
436
+ const entry = reading.correctedFor(key, now, null, codexHome);
437
+ if (entry) consider(entry.percentUsed);
438
+ }
439
+ } catch (err) {
440
+ // A missing or unreadable correction just means the snapshot decides.
441
+ }
442
+ try {
443
+ const snapshot = usage.collect(now);
444
+ const utilization = snapshot && snapshot.utilization;
445
+ if (utilization && typeof utilization === 'object') {
446
+ for (const key of Object.keys(utilization)) {
447
+ const window = utilization[key];
448
+ if (window && typeof window === 'object') consider(Number(window.utilization));
449
+ }
450
+ }
451
+ } catch (err) {
452
+ // Same: no snapshot is a reason not to enforce, not a reason to throw.
453
+ }
454
+ return worst;
244
455
  }
245
456
 
246
457
  // PostToolUse does not take plain stdout as context the way UserPromptSubmit
@@ -256,6 +467,22 @@ function envelope(text, event) {
256
467
  });
257
468
  }
258
469
 
470
+ // The refusal envelope.
471
+ //
472
+ // Claude Code and Codex document the same PreToolUse shape: a permissionDecision
473
+ // of "deny" with a reason, which is shown to the model in place of the tool
474
+ // result. Exit code stays 0 - exit 2 also blocks, but routes the reason through
475
+ // stderr, and a reason the model can read is the entire point of refusing.
476
+ function refusal(reason) {
477
+ return JSON.stringify({
478
+ hookSpecificOutput: {
479
+ hookEventName: 'PreToolUse',
480
+ permissionDecision: 'deny',
481
+ permissionDecisionReason: reason,
482
+ },
483
+ });
484
+ }
485
+
259
486
  function readHookInput() {
260
487
  return new Promise((resolve) => {
261
488
  if (process.stdin.isTTY) return resolve(null);
@@ -289,8 +516,14 @@ if (require.main === module) {
289
516
  return run(Date.now(), input);
290
517
  })
291
518
  .then(
292
- (text) => {
293
- if (text) process.stdout.write(envelope(text, hookEvent) + '\n');
519
+ (result) => {
520
+ // A refusal, not a line. Claude Code and Codex take the same shape
521
+ // here; Antigravity's differs and is handled by its own entry point.
522
+ if (result && typeof result === 'object' && result.deny) {
523
+ process.stdout.write(refusal(result.reason) + '\n');
524
+ process.exit(0);
525
+ }
526
+ if (result) process.stdout.write(envelope(result, hookEvent) + '\n');
294
527
  process.exit(0);
295
528
  },
296
529
  () => {
@@ -301,6 +534,7 @@ if (require.main === module) {
301
534
  }
302
535
 
303
536
  module.exports = {
537
+ recheckText,
304
538
  DEFAULT_INTERVAL_SECONDS,
305
539
  KEEP_SESSIONS,
306
540
  stateFile,
@@ -309,7 +543,10 @@ module.exports = {
309
543
  writeState,
310
544
  trim,
311
545
  due,
546
+ longCall,
312
547
  pulseText,
313
548
  envelope,
549
+ refusal,
550
+ ceilingPercent,
314
551
  run,
315
552
  };
@@ -24,6 +24,8 @@ const fs = require('fs');
24
24
  const os = require('os');
25
25
  const path = require('path');
26
26
 
27
+ const drift = require('./drift.js');
28
+
27
29
  // Older than this and the spend it measured is history: turns have happened
28
30
  // since, and a stale correction that says 40 per cent is worse than an honest
29
31
  // snapshot that says 13, because it looks authoritative.
@@ -71,18 +73,25 @@ function record(binding, now, codexHome) {
71
73
  if (binding.stale || binding.estimated || binding.correctionUnreliable) return false;
72
74
  try {
73
75
  const all = read(codexHome);
74
- all[binding.key] = {
75
- at: Number.isFinite(now) ? now : Date.now(),
76
+ const at = Number.isFinite(now) ? now : Date.now();
77
+ // Whatever was sitting here before is what the cheap readers had been
78
+ // trusting; this fresh, checked correction is what the meter actually
79
+ // said. The gap between them is real drift a session just lived through,
80
+ // and it is worth keeping regardless of what happens to this record next.
81
+ const previous = all[binding.key];
82
+ const next = {
83
+ at,
76
84
  percentUsed: binding.percentUsed,
77
85
  pointsSinceSnapshot: binding.pointsSinceSnapshot || 0,
78
86
  adjusted: Boolean(binding.adjusted),
79
87
  resetsAt: Number.isFinite(binding.resetsAt) ? binding.resetsAt : null,
80
88
  turnsLeft: Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null,
81
89
  };
90
+ if (previous) drift.record(binding.key, previous, next, at, codexHome);
91
+ all[binding.key] = next;
82
92
  // One entry per window key, and there are only ever a handful of those, so
83
93
  // this file cannot grow. Anything whose reset has passed describes a window
84
94
  // that no longer exists.
85
- const at = Number.isFinite(now) ? now : Date.now();
86
95
  for (const key of Object.keys(all)) {
87
96
  const entry = all[key];
88
97
  if (!entry || !Number.isFinite(entry.at) || at - entry.at > 24 * 60 * 60 * 1000) delete all[key];