claude-usage-limits 1.39.2 → 1.39.3

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.2",
4
+ "version": "1.39.3",
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.2",
3
+ "version": "1.39.3",
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.2",
3
+ "version": "1.39.3",
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",
@@ -33,7 +33,12 @@ const usage = require('./usage.js');
33
33
  const host = require('./host.js');
34
34
  const mode = require('./mode.js');
35
35
  const ceiling = require('./ceiling.js');
36
- const brief = require('./brief.js');
36
+ // brief.js is loaded only on PreInvocation: PreToolUse runs before tool calls
37
+ // and must stay a few small requires, not the whole brief.
38
+
39
+ // PreToolUse must always name a decision. Antigravity reads an answer with no
40
+ // decision as a refusal, so the old bare {} blocked every tool it ran on.
41
+ const ALLOW = { decision: 'allow' };
37
42
 
38
43
  const EVENTS = ['PreInvocation', 'PostInvocation', 'PreToolUse', 'PostToolUse', 'Stop'];
39
44
 
@@ -116,14 +121,14 @@ async function run(now, input, argv) {
116
121
  usage.setHost(host.GEMINI);
117
122
  const sessionId = input && input.conversationId ? String(input.conversationId) : null;
118
123
  const budget = mode.forSession({ sessionId });
119
- if (budget.policy.briefStyle === 'none') return {};
124
+ if (budget.policy.briefStyle === 'none') return event === 'PreToolUse' ? ALLOW : {};
120
125
 
121
126
  if (event === 'PreToolUse') {
122
127
  const tool = toolNameOf(input);
123
- if (!ceiling.isMultiplier(tool)) return {};
128
+ if (!ceiling.isMultiplier(tool)) return ALLOW;
124
129
  const at = ceiling.assess(Object.assign({ state: budget.state, env: process.env, sessionId }, reading(percentNow(now))));
125
130
  const call = ceiling.verdict(at, tool);
126
- if (call.decision !== 'deny') return {};
131
+ if (call.decision !== 'deny') return ALLOW;
127
132
  return { decision: 'deny', reason: call.reason };
128
133
  }
129
134
 
@@ -133,7 +138,7 @@ async function run(now, input, argv) {
133
138
  // transient system message, which is exactly what a per-turn figure is.
134
139
  let text = '';
135
140
  try {
136
- text = await brief.run(now, input);
141
+ text = await require('./brief.js').run(now, input, { host: host.GEMINI });
137
142
  } catch (err) {
138
143
  text = '';
139
144
  }
@@ -162,11 +167,13 @@ if (require.main === module) {
162
167
  },
163
168
  () => {
164
169
  // Hooks block the agent loop here, so a failure has to be silent and
165
- // well formed rather than loud.
166
- process.stdout.write('{}\n');
170
+ // well formed rather than loud - and on PreToolUse, well formed means
171
+ // an explicit allow, never a bare {} that reads as a refusal.
172
+ const pre = process.argv.slice(2).join(' ').includes('--event PreToolUse');
173
+ process.stdout.write(JSON.stringify(pre ? ALLOW : {}) + '\n');
167
174
  process.exit(0);
168
175
  }
169
176
  );
170
177
  }
171
178
 
172
- module.exports = { EVENTS, eventFrom, toolNameOf, percentNow, run };
179
+ module.exports = { EVENTS, ALLOW, eventFrom, toolNameOf, percentNow, run };
@@ -1411,7 +1411,12 @@ function fastModeFor(sessionId) {
1411
1411
  }
1412
1412
  }
1413
1413
 
1414
- async function run(now, hookInput) {
1414
+ const GEMINI_UNREADABLE =
1415
+ "[usage-limits] Antigravity's quota is not readable by this plugin, so there is no budget figure and no fan-out ceiling here; Antigravity's own usage panel has the number.";
1416
+ const LIMIT_REACHED =
1417
+ '[usage-limits] The meter says the usage limit is reached. Start nothing new: save the work, write the hand-off note in this turn, and end the turn.';
1418
+
1419
+ async function run(now, hookInput, opts) {
1415
1420
  if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
1416
1421
 
1417
1422
  const sessionId = hookInput && (hookInput.session_id || hookInput.conversationId) ? (hookInput.session_id || hookInput.conversationId) : null;
@@ -1424,15 +1429,21 @@ async function run(now, hookInput) {
1424
1429
  const budget = mode.forSession({ sessionId });
1425
1430
  if (budget.policy.briefStyle === 'none') return guardLine(now, budget);
1426
1431
 
1427
- // Settle host here, before any file is read.
1428
- const detectedHost = host.detect(process.argv.slice(2), process.env);
1429
- const effectiveHost = (hookInput && (hookInput.conversationId || hookInput.invocationNum !== undefined))
1430
- ? host.GEMINI
1431
- : detectedHost;
1432
- usage.setHost(effectiveHost);
1432
+ // Settle host here, before any file is read. A caller that already knows the
1433
+ // host (agy-hook runs only inside Antigravity) says so, and that wins over
1434
+ // detection: an empty or unparseable payload used to fall through to
1435
+ // Claude Code and put Claude's usage, model and session into Antigravity.
1436
+ const detectedHost = host.detectFromHook(process.argv.slice(2), process.env, hookInput);
1437
+ const effectiveHost = (opts && opts.host)
1438
+ || ((hookInput && (hookInput.conversationId || hookInput.invocationNum !== undefined))
1439
+ ? host.GEMINI
1440
+ : detectedHost);
1441
+ const gemini = usage.setHost(effectiveHost) === host.GEMINI;
1433
1442
  // A prompt has arrived, so this session is working, and the prompt itself
1434
- // says whether it asked for ultracode. The panel animates from this.
1435
- activity.mark(
1443
+ // says whether it asked for ultracode. The panel animates from this. The
1444
+ // activity file is Claude Code's, so an Antigravity conversation is never
1445
+ // counted in it.
1446
+ if (!gemini) activity.mark(
1436
1447
  'working',
1437
1448
  sessionId,
1438
1449
  Object.assign(
@@ -1469,7 +1480,11 @@ async function run(now, hookInput) {
1469
1480
  // one quick failure and then a widening backoff, never a wait on every
1470
1481
  // prompt; USAGE_LIMITS_FETCH=off turns it off.
1471
1482
  try {
1472
- if (usage.isCodex()) {
1483
+ if (gemini) {
1484
+ // Antigravity publishes no meter this plugin can read, and refreshing
1485
+ // Claude's /usage reading from inside Antigravity spends a request on
1486
+ // the wrong account.
1487
+ } else if (usage.isCodex()) {
1473
1488
  // Codex only writes its meter when it makes a request, so between turns
1474
1489
  // the newest figure can be half an hour old. Ask it, the way /status
1475
1490
  // does, when the reading has aged.
@@ -1492,7 +1507,13 @@ async function run(now, hookInput) {
1492
1507
  // The reading on disk is still there.
1493
1508
  }
1494
1509
  const base = usage.collect(now);
1495
- if (!base.utilization) return '';
1510
+ if (!base.utilization) {
1511
+ if (gemini) return sayOnce(sessionId, GEMINI_UNREADABLE, now);
1512
+ // Codex can say a limit is already hit while giving no percentage; that
1513
+ // is the plainest reading there is, not a missing one.
1514
+ if (base.reachedType || base.spendControlReached) return sayOnce(sessionId, LIMIT_REACHED, now);
1515
+ return '';
1516
+ }
1496
1517
 
1497
1518
  const all = readCache();
1498
1519
  // Which setting the fit question was already asked for, read BEFORE anything
@@ -50,7 +50,8 @@ const MULTIPLIERS = [
50
50
  // Antigravity derives tool names by lowercasing the step type and stripping
51
51
  // CORTEX_STEP_TYPE_, so its names are snake_case and cannot be matched by the
52
52
  // list above.
53
- const MULTIPLIER_PATTERN = /^(agent|task|workflow|subagent|dispatch|spawn_[a-z_]*agent|run_[a-z_]*agent)$/i;
53
+ // invoke_subagent and browser_subagent are the two Antigravity fan-out tools.
54
+ const MULTIPLIER_PATTERN = /^(agent|task|workflow|subagent|dispatch|spawn_[a-z_]*agent|run_[a-z_]*agent|invoke_subagent|browser_subagent)$/i;
54
55
 
55
56
  // How far below the ceiling the warning starts. Ten points is about one long
56
57
  // turn at xhigh on a 5-hour window, which is the last moment a warning can
@@ -777,6 +777,30 @@ function liveFile() {
777
777
  return path.join(homeDir(), 'usage-limits-codex-live.json');
778
778
  }
779
779
 
780
+ // The app-server's account/rateLimits/read answers in camelCase (usedPercent,
781
+ // windowDurationMins, resetsAt, planType ...) while rollouts and every reader
782
+ // here use snake_case. Stored as received, a fresh live reading was invisible
783
+ // to readingsOf() and the brief, pulse and ceiling all went blind on Codex.
784
+ // One conversion, applied where a reading is taken and again where the file
785
+ // is read back, because a camelCase file may already be on disk.
786
+ const METER_RENAMES = { window_duration_mins: 'window_minutes' };
787
+ function snakeKey(key) {
788
+ const snake = String(key).replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
789
+ return METER_RENAMES[snake] || snake;
790
+ }
791
+ function normalizeMeter(meter) {
792
+ if (Array.isArray(meter)) return meter.map(normalizeMeter);
793
+ if (!meter || typeof meter !== 'object') return meter;
794
+ const out = {};
795
+ for (const [key, value] of Object.entries(meter)) {
796
+ const name = snakeKey(key);
797
+ // A snake_case field that is already there wins over a converted twin.
798
+ if (Object.prototype.hasOwnProperty.call(out, name) && name !== key) continue;
799
+ out[name] = normalizeMeter(value);
800
+ }
801
+ return out;
802
+ }
803
+
780
804
  function readLiveMeter() {
781
805
  let parsed;
782
806
  try {
@@ -786,7 +810,7 @@ function readLiveMeter() {
786
810
  }
787
811
  if (!parsed || typeof parsed !== 'object') return null;
788
812
  if (!Number.isFinite(parsed.at) || !parsed.meter || typeof parsed.meter !== 'object') return null;
789
- return parsed;
813
+ return Object.assign({}, parsed, { meter: normalizeMeter(parsed.meter) });
790
814
  }
791
815
 
792
816
  function writeLiveMeter(reading) {
@@ -1045,7 +1069,7 @@ function refresh(options) {
1045
1069
  return;
1046
1070
  }
1047
1071
  const limits = message.result && message.result.rateLimits;
1048
- finish(null, { at: Date.now(), meter: limits || null });
1072
+ finish(null, { at: Date.now(), meter: limits ? normalizeMeter(limits) : null });
1049
1073
  return;
1050
1074
  }
1051
1075
  }
@@ -1073,6 +1097,7 @@ module.exports = {
1073
1097
  SLOTS,
1074
1098
  WEIGHTS,
1075
1099
  PLANS,
1100
+ normalizeMeter,
1076
1101
  homeDir,
1077
1102
  sessionsDir,
1078
1103
  findExecutable,
@@ -112,7 +112,21 @@ function detect(argv, env) {
112
112
  return CLAUDE;
113
113
  }
114
114
 
115
+ // Codex hook payloads always carry turn_id; Claude Code's never do. Codex also
116
+ // loads an installed plugin's Claude-style hooks/hooks.json, which runs these
117
+ // scripts with no --host and may set CLAUDE_PLUGIN_ROOT, so detect() alone
118
+ // called that Codex turn Claude Code and briefed Claude's budget into Codex.
119
+ // An explicit --host or USAGE_LIMITS_HOST still wins.
120
+ function detectFromHook(argv, env, input) {
121
+ const args = argv || [];
122
+ const environment = env || process.env;
123
+ if (args.includes('--host') || normalise(environment.USAGE_LIMITS_HOST)) return detect(args, environment);
124
+ if (input && typeof input === 'object' && input.turn_id !== undefined && input.turn_id !== null) return CODEX;
125
+ return detect(args, environment);
126
+ }
127
+
115
128
  module.exports = {
129
+ detectFromHook,
116
130
  CLAUDE,
117
131
  CODEX,
118
132
  GEMINI,
@@ -77,7 +77,9 @@ function hooks() {
77
77
  'usage-limits-ceiling': {
78
78
  PreToolUse: [
79
79
  {
80
- matcher: '.*',
80
+ // Only the fan-out tools: a node start before every tool call cost
81
+ // 3-6 s each here, and ordinary tools have nothing to ask the ceiling.
82
+ matcher: 'invoke_subagent|browser_subagent',
81
83
  hooks: [{ type: 'command', command: hookCommand('PreToolUse'), timeout: 10 }],
82
84
  },
83
85
  ],
@@ -225,7 +225,7 @@ async function run(now, hookInput) {
225
225
  const budget = mode.forSession({ sessionId });
226
226
  if (budget.policy.refreshSeconds === 0) return '';
227
227
 
228
- usage.setHost(host.detect(process.argv.slice(2), process.env));
228
+ usage.setHost(host.detectFromHook(process.argv.slice(2), process.env, hookInput));
229
229
 
230
230
  // A subagent finishing is the other reason to look, and on a busy afternoon
231
231
  // it is the more important one.