claude-usage-limits 1.39.4 → 1.39.5

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.39.4",
4
+ "version": "1.39.5",
5
5
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usage-limits",
3
- "version": "1.39.4",
3
+ "version": "1.39.5",
4
4
  "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
5
5
  "author": {
6
6
  "name": "Ridelink",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.39.4",
3
+ "version": "1.39.5",
4
4
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
@@ -157,9 +157,14 @@ async function run(now, input, argv) {
157
157
  return {};
158
158
  }
159
159
 
160
- if (require.main === module) {
161
- readInput()
162
- .then((input) => run(Date.now(), input, process.argv.slice(2)))
160
+ // Read stdin, answer, exit zero. A function rather than a bare block because
161
+ // the installer's launcher calls it: see install-antigravity.js, which writes a
162
+ // two-line launcher beside hooks.json so the hook command carries no path and
163
+ // therefore no quotes for `cmd /c` to mangle.
164
+ function main(argv) {
165
+ const args = argv || [];
166
+ return readInput()
167
+ .then((input) => run(Date.now(), input, args))
163
168
  .then(
164
169
  (result) => {
165
170
  process.stdout.write(JSON.stringify(result || {}) + '\n');
@@ -169,11 +174,13 @@ if (require.main === module) {
169
174
  // Hooks block the agent loop here, so a failure has to be silent and
170
175
  // well formed rather than loud - and on PreToolUse, well formed means
171
176
  // an explicit allow, never a bare {} that reads as a refusal.
172
- const pre = process.argv.slice(2).join(' ').includes('--event PreToolUse');
177
+ const pre = args.join(' ').includes('--event PreToolUse');
173
178
  process.stdout.write(JSON.stringify(pre ? ALLOW : {}) + '\n');
174
179
  process.exit(0);
175
180
  }
176
181
  );
177
182
  }
178
183
 
179
- module.exports = { EVENTS, ALLOW, eventFrom, toolNameOf, percentNow, run };
184
+ if (require.main === module) main(process.argv.slice(2));
185
+
186
+ module.exports = { EVENTS, ALLOW, eventFrom, toolNameOf, percentNow, run, main };
@@ -64,6 +64,10 @@ const DEFAULTS = {
64
64
  // A hook has ten seconds; the reading gets four of them at most.
65
65
  const REFRESH_TIMEOUT_MS = 4000;
66
66
 
67
+ // What the hook is actually given before it is killed. Claude Code and Codex
68
+ // both use ten seconds (hooks/hooks.json, install-codex-hook.js).
69
+ const HOOK_BUDGET_MS = 10000;
70
+
67
71
  // The hook is given ten seconds, and a live reading may take four of them, so
68
72
  // the transcript scan gets five and the last second is slack. A warm scan
69
73
  // takes about a quarter of a second; this is the guard for the first run on a
@@ -71,6 +75,28 @@ const REFRESH_TIMEOUT_MS = 4000;
71
75
  // killed and Claude being told nothing at all.
72
76
  const SCAN_BUDGET_MS = 5000;
73
77
 
78
+ // How long the live reading may actually wait, given how much of the hook's ten
79
+ // seconds is already gone.
80
+ //
81
+ // Four seconds for the reading plus five for the scan plus a second of slack
82
+ // only adds up if the hook starts at zero, and it never does: node's own
83
+ // start-up, the requires, and reading the hook payload all come out of the same
84
+ // budget, and on a loaded machine that is most of a second before this line is
85
+ // reached. `process.uptime()` is the honest elapsed figure. Past the point
86
+ // where the scan would no longer fit, the reading on disk is used as it is
87
+ // rather than the hook being killed with nothing to say.
88
+ function refreshBudgetMs(options) {
89
+ const opts = options || {};
90
+ const configured = Number(
91
+ opts.hookBudgetMs !== undefined ? opts.hookBudgetMs : process.env.USAGE_LIMITS_HOOK_BUDGET_MS
92
+ );
93
+ const total = Number.isFinite(configured) && configured > 0 ? configured : HOOK_BUDGET_MS;
94
+ const elapsed = Number.isFinite(opts.elapsedMs) ? opts.elapsedMs : process.uptime() * 1000;
95
+ const reserve = Number.isFinite(opts.reserveMs) ? opts.reserveMs : SCAN_BUDGET_MS + 1000;
96
+ const most = Number.isFinite(opts.maxMs) ? opts.maxMs : REFRESH_TIMEOUT_MS;
97
+ return Math.max(0, Math.min(most, Math.round(total - elapsed - reserve)));
98
+ }
99
+
74
100
  // How far into the hook arming may still start. See relayState: the task
75
101
  // registration is about a second and the hook is allowed ten.
76
102
  const ARM_DEADLINE_MS = 5000;
@@ -1495,21 +1521,29 @@ async function run(now, hookInput, opts) {
1495
1521
  } else if (usage.isCodex()) {
1496
1522
  // Codex only writes its meter when it makes a request, so between turns
1497
1523
  // the newest figure can be half an hour old. Ask it, the way /status
1498
- // does, when the reading has aged.
1524
+ // does, when the reading has aged - but never inside this hook. The
1525
+ // reading means starting `codex app-server`, whose cold start alone has
1526
+ // been measured past four seconds, and Codex kills a hook at ten. So the
1527
+ // refresh is started detached and the next hook reads what it wrote.
1499
1528
  await require('./codex.js').refreshIfStale({
1500
1529
  now,
1501
1530
  maxAgeMs: refreshSeconds * SECOND,
1502
- timeoutMs: REFRESH_TIMEOUT_MS,
1531
+ detach: true,
1503
1532
  });
1504
1533
  } else {
1505
1534
  const cached = usage.collect(now);
1506
- await live.refreshIfStale({
1507
- now,
1508
- maxAgeMs: refreshSeconds * SECOND,
1509
- cacheFetchedAtMs: cached.snapshotFetchedAt,
1510
- accountUuid: usage.accountUuid(),
1511
- timeoutMs: REFRESH_TIMEOUT_MS,
1512
- });
1535
+ const waitMs = refreshBudgetMs();
1536
+ // No room left in the hook for a network call: the figure on disk stands
1537
+ // and the next prompt tries again.
1538
+ if (waitMs > 0) {
1539
+ await live.refreshIfStale({
1540
+ now,
1541
+ maxAgeMs: refreshSeconds * SECOND,
1542
+ cacheFetchedAtMs: cached.snapshotFetchedAt,
1543
+ accountUuid: usage.accountUuid(),
1544
+ timeoutMs: waitMs,
1545
+ });
1546
+ }
1513
1547
  }
1514
1548
  } catch (err) {
1515
1549
  // The reading on disk is still there.
@@ -1773,6 +1807,10 @@ function withBugcheck(text) {
1773
1807
  module.exports = { withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
1774
1808
  readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS,
1775
1809
  DEFAULTS,
1810
+ HOOK_BUDGET_MS,
1811
+ REFRESH_TIMEOUT_MS,
1812
+ SCAN_BUDGET_MS,
1813
+ refreshBudgetMs,
1776
1814
  aheadOfPace,
1777
1815
  pacingMatters,
1778
1816
  PACE_MIN_SPAN_MS,
@@ -830,6 +830,72 @@ function attemptFile() {
830
830
  return path.join(homeDir(), 'usage-limits-codex-fetch.json');
831
831
  }
832
832
 
833
+ // How long a detached refresh is given before another hook may start one. The
834
+ // child is not waited on, so this claim is the only thing stopping every hook
835
+ // in a burst from launching its own app-server.
836
+ const DETACHED_GUARD_MS = 45 * 1000;
837
+
838
+ // A detached refresh sits outside everybody's hook budget, so it can afford to
839
+ // wait out a cold start instead of giving up at four seconds.
840
+ const DETACHED_TIMEOUT_MS = 25 * 1000;
841
+
842
+ // Start the refresh in a child nobody waits for.
843
+ //
844
+ // This is the answer to the hook timeout. Codex kills a hook at ten seconds,
845
+ // and `codex app-server` can take more than four to answer cold - so a hook
846
+ // that waits for the reading is a hook that gets killed, and a killed hook
847
+ // says nothing at all. Here the hook returns immediately and the reading lands
848
+ // in liveFile() a second or two later, where the NEXT hook reads it. One turn
849
+ // of latency for a figure that is refreshed every few minutes anyway.
850
+ function spawnRefresh(options) {
851
+ const opts = options || {};
852
+ const spawn = opts.spawn || require('child_process').spawn;
853
+ const args = [__filename, '--refresh-meter'];
854
+ if (opts.codexPath) args.push('--codex-path', String(opts.codexPath));
855
+ const child = spawn(opts.execPath || process.execPath, args, {
856
+ detached: true,
857
+ stdio: 'ignore',
858
+ windowsHide: true,
859
+ // The child reads CODEX_HOME to find the same home directory this process
860
+ // is using, so a test or a non-default install refreshes the right file.
861
+ env: Object.assign({}, opts.env || process.env, { CODEX_HOME: homeDir() }),
862
+ });
863
+ if (child && typeof child.unref === 'function') child.unref();
864
+ return child;
865
+ }
866
+
867
+ // Take the reading and record it, with no staleness or backoff check. This is
868
+ // the half of refreshIfStale() that actually costs something, split out so the
869
+ // detached child can run it without tripping over the in-flight claim the
870
+ // parent just wrote.
871
+ async function fetchAndRecord(options) {
872
+ const opts = options || {};
873
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
874
+ const maxAgeMs = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : 3 * MINUTE;
875
+ const claim = (delayMs, kind) => {
876
+ try {
877
+ fs.writeFileSync(attemptFile(), JSON.stringify({ attemptedAtMs: now, delayMs, kind }), 'utf8');
878
+ } catch (err) {
879
+ // One extra attempt is survivable.
880
+ }
881
+ };
882
+ try {
883
+ const reading = await refresh({ codexPath: opts.codexPath, timeoutMs: opts.timeoutMs });
884
+ if (!reading || !reading.meter) {
885
+ claim(maxAgeMs, 'empty');
886
+ return { reading: null, error: 'no meter' };
887
+ }
888
+ writeLiveMeter(reading);
889
+ claim(maxAgeMs, 'ok');
890
+ return { reading, error: null };
891
+ } catch (err) {
892
+ // Codex missing or not answering: back off rather than pay for it again on
893
+ // every prompt.
894
+ claim(Math.min(10 * MINUTE, Math.max(2 * MINUTE, maxAgeMs * 2)), (err && err.code) || 'error');
895
+ return { reading: null, error: (err && err.code) || 'error' };
896
+ }
897
+ }
898
+
833
899
  // Ask Codex for the meter, but only when the newest reading on disk has aged,
834
900
  // and never twice in quick succession. Codex writes its meter into a rollout
835
901
  // only when it makes a request, so between turns the newest reading can be
@@ -869,23 +935,32 @@ async function refreshIfStale(options) {
869
935
  // One extra attempt is survivable.
870
936
  }
871
937
  };
872
- claim(Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 8000, 'inflight');
873
-
874
- try {
875
- const reading = await refresh({ codexPath: opts.codexPath, timeoutMs: opts.timeoutMs });
876
- if (!reading || !reading.meter) {
877
- claim(maxAgeMs, 'empty');
878
- return { reading: live, skipped: null, error: 'no meter' };
938
+ // The hook's own path: start the reading and get out of the way. Waiting for
939
+ // `codex app-server` inside a hook is what overran Codex's ten-second limit
940
+ // under load - the cold start alone has been measured past four seconds, and
941
+ // the brief still has a transcript scan to do after it. The child writes the
942
+ // meter file; the next hook reads it.
943
+ if (opts.detach) {
944
+ claim(DETACHED_GUARD_MS, 'detached');
945
+ try {
946
+ spawnRefresh({ codexPath: opts.codexPath, spawn: opts.spawn, execPath: opts.execPath, env: env });
947
+ } catch (err) {
948
+ claim(Math.max(2 * MINUTE, maxAgeMs), 'spawn-failed');
949
+ return { reading: live, skipped: null, error: 'spawn failed' };
879
950
  }
880
- writeLiveMeter(reading);
881
- claim(maxAgeMs, 'ok');
882
- return { reading, skipped: null };
883
- } catch (err) {
884
- // Codex missing or not answering: back off rather than pay for it again on
885
- // every prompt.
886
- claim(Math.min(10 * MINUTE, Math.max(2 * MINUTE, maxAgeMs * 2)), (err && err.code) || 'error');
887
- return { reading: live, skipped: null, error: (err && err.code) || 'error' };
951
+ return { reading: live, skipped: 'detached' };
888
952
  }
953
+
954
+ claim(Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 8000, 'inflight');
955
+
956
+ const done = await fetchAndRecord({
957
+ now,
958
+ maxAgeMs,
959
+ timeoutMs: opts.timeoutMs,
960
+ codexPath: opts.codexPath,
961
+ });
962
+ if (done.error) return { reading: live, skipped: null, error: done.error };
963
+ return { reading: done.reading, skipped: null };
889
964
  }
890
965
 
891
966
  function collect(now, options) {
@@ -1093,10 +1168,39 @@ function refresh(options) {
1093
1168
  });
1094
1169
  }
1095
1170
 
1171
+ // The detached refresh, run as `node codex.js --refresh-meter`. Nothing waits
1172
+ // for this process, so it can spend the cold start a hook cannot.
1173
+ if (require.main === module) {
1174
+ const argv = process.argv.slice(2);
1175
+ if (argv[0] !== '--refresh-meter') {
1176
+ process.stderr.write('codex.js: only --refresh-meter is runnable\n');
1177
+ process.exitCode = 2;
1178
+ } else {
1179
+ const at = argv.indexOf('--codex-path');
1180
+ fetchAndRecord({
1181
+ now: Date.now(),
1182
+ timeoutMs: DETACHED_TIMEOUT_MS,
1183
+ codexPath: at !== -1 ? argv[at + 1] : null,
1184
+ })
1185
+ .catch(() => null)
1186
+ .then(() => {
1187
+ // The app-server is asked to stop by having its stdin closed, which
1188
+ // refresh() has already done. This timer is deliberately not unref'd:
1189
+ // it is the one thing that ends this process if the child it started
1190
+ // does not exit on its own.
1191
+ setTimeout(() => process.exit(0), 2000);
1192
+ });
1193
+ }
1194
+ }
1195
+
1096
1196
  module.exports = {
1097
1197
  SLOTS,
1098
1198
  WEIGHTS,
1099
1199
  PLANS,
1200
+ DETACHED_GUARD_MS,
1201
+ DETACHED_TIMEOUT_MS,
1202
+ spawnRefresh,
1203
+ fetchAndRecord,
1100
1204
  normalizeMeter,
1101
1205
  homeDir,
1102
1206
  sessionsDir,
@@ -44,20 +44,66 @@ function pluginDir() {
44
44
  }
45
45
 
46
46
  // Forward slashes on every platform. They work in Windows paths and keep the
47
- // command free of escapes in both JSON and the shell that runs it. Antigravity
48
- // runs hook commands through `cmd /c` on Windows and `sh -c` elsewhere, so the
49
- // quoting has to survive both.
50
- function quote(file) {
51
- const normalized = String(file).replace(/\\/g, '/');
52
- return normalized.includes(' ') ? '"' + normalized + '"' : normalized;
47
+ // command free of escapes in both JSON and the shell that runs it.
48
+ function slashes(file) {
49
+ return String(file).replace(/\\/g, '/');
53
50
  }
54
51
 
55
52
  function scriptPath(name) {
56
53
  return path.join(__dirname, name);
57
54
  }
58
55
 
59
- function hookCommand(event) {
60
- return 'node ' + quote(scriptPath('agy-hook.js')) + ' --event ' + event;
56
+ // The name of the launcher written beside hooks.json.
57
+ const LAUNCHER = 'agy-hook.js';
58
+
59
+ // A hook command a shell cannot mangle.
60
+ //
61
+ // Antigravity runs hook commands through `cmd /c` on Windows. It does not pass
62
+ // /s, so cmd applies its own quote rule: the outer quotes are stripped only
63
+ // when the string carries exactly one pair. A path with a space in it needs a
64
+ // pair of its own, which makes three, and cmd then keeps them all - the command
65
+ // name becomes the whole quoted string and node is handed `C:/Users/Some` as
66
+ // its script. That is not a theory: a checkout under a directory with a space
67
+ // in its name installed cleanly and every hook then failed silently, because
68
+ // Antigravity reports nothing when a hook cannot start.
69
+ //
70
+ // So the command carries no quotes at all, which means it must carry no spaces
71
+ // either. Two ways to get there, and the one used depends on the path:
72
+ //
73
+ // - the plugin directory has no space: name the launcher absolutely, exactly
74
+ // as before, and nothing depends on the working directory
75
+ // - it does: name it relatively. Hooks run with their working directory set
76
+ // to the folder holding hooks.json, which is where the launcher is written
77
+ //
78
+ // Either way the real path into this checkout lives inside the launcher, as
79
+ // JavaScript, where no shell ever sees it.
80
+ function launcherPath() {
81
+ return path.join(pluginDir(), LAUNCHER);
82
+ }
83
+
84
+ function hookCommand(event, dir) {
85
+ const absolute = slashes(dir === undefined ? launcherPath() : path.join(dir, LAUNCHER));
86
+ const target = absolute.includes(' ') ? LAUNCHER : absolute;
87
+ return 'node ' + target + ' --event ' + event;
88
+ }
89
+
90
+ // The launcher itself. It exists so the command above needs no path: requiring
91
+ // the real script by absolute path is a string in a JS file, which survives any
92
+ // amount of shell quoting, and calling main() is what `node agy-hook.js` would
93
+ // have done.
94
+ function launcherText() {
95
+ return [
96
+ "'use strict';",
97
+ '',
98
+ '// Written by install-antigravity.js. Do not edit: `on` rewrites it.',
99
+ '//',
100
+ '// The hook command that runs this file carries no path and no quotes,',
101
+ "// because Antigravity's `cmd /c` keeps the quotes around a path with a",
102
+ '// space in it and node is then handed a truncated script name. The path',
103
+ '// lives here instead, where only node reads it.',
104
+ 'require(' + JSON.stringify(slashes(scriptPath('agy-hook.js'))) + ').main(process.argv.slice(2));',
105
+ '',
106
+ ].join('\n');
61
107
  }
62
108
 
63
109
  function manifest() {
@@ -155,6 +201,9 @@ function enable() {
155
201
  }
156
202
  const dir = pluginDir();
157
203
  writeFile(path.join(dir, 'plugin.json'), JSON.stringify(manifest(), null, 2) + '\n');
204
+ // Before hooks.json, so the file the commands name is never missing while
205
+ // they are readable.
206
+ writeFile(path.join(dir, LAUNCHER), launcherText());
158
207
  writeFile(path.join(dir, 'hooks.json'), JSON.stringify(hooks(), null, 2) + '\n');
159
208
  const rules = rulesText();
160
209
  if (rules) writeFile(path.join(dir, 'rules', 'AGENTS.md'), rules);
@@ -178,7 +227,7 @@ function disable() {
178
227
  const dir = pluginDir();
179
228
  if (!installed()) return 'Not installed. Nothing to remove.';
180
229
  const removed = [];
181
- for (const relative of ['plugin.json', 'hooks.json', path.join('rules', 'AGENTS.md')]) {
230
+ for (const relative of ['plugin.json', 'hooks.json', LAUNCHER, path.join('rules', 'AGENTS.md')]) {
182
231
  const file = path.join(dir, relative);
183
232
  try {
184
233
  fs.unlinkSync(file);
@@ -312,16 +312,26 @@ async function run(now, hookInput) {
312
312
  // against the account rather than against a guess from its own transcript.
313
313
  try {
314
314
  if (usage.isCodex()) {
315
- await require('./codex.js').refreshIfStale({ now, maxAgeMs: every, timeoutMs: 4000 });
315
+ // Never waited on inside the hook. `codex app-server` can take longer to
316
+ // answer cold than this hook is allowed to live, and a killed PostToolUse
317
+ // hook is a tool call that stalls. The child writes the meter file and
318
+ // the next pulse reads it.
319
+ await require('./codex.js').refreshIfStale({ now, maxAgeMs: every, detach: true });
316
320
  } else {
317
321
  const cached = usage.collect(now);
318
- await live.refreshIfStale({
319
- now,
320
- maxAgeMs: every,
321
- cacheFetchedAtMs: cached.snapshotFetchedAt,
322
- accountUuid: usage.accountUuid(),
323
- timeoutMs: 4000,
324
- });
322
+ // Sized from what is left of the hook's ten seconds, not from a constant:
323
+ // this hook interrupts work in progress, so being late is worse here than
324
+ // anywhere else.
325
+ const waitMs = brief.refreshBudgetMs({ reserveMs: SCAN_BUDGET_MS + 1000 });
326
+ if (waitMs > 0) {
327
+ await live.refreshIfStale({
328
+ now,
329
+ maxAgeMs: every,
330
+ cacheFetchedAtMs: cached.snapshotFetchedAt,
331
+ accountUuid: usage.accountUuid(),
332
+ timeoutMs: waitMs,
333
+ });
334
+ }
325
335
  }
326
336
  } catch (err) {
327
337
  // The reading on disk is still there.