claude-usage-limits 1.18.0 → 1.23.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.
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ // A ledger of how wrong the reading was, measured instead of argued about.
4
+ //
5
+ // The known complaint about this plugin is that its numbers lag reality
6
+ // mid-session - reading.js exists because of one measured instance of it (13%
7
+ // shown, 73% actual). But "it lags" is an anecdote until someone can say by
8
+ // how much, how often, and whether it is getting better or worse as the
9
+ // plugin changes. So every time reading.js records a fresh, trustworthy
10
+ // correction for a window, and there was already a figure sitting there for
11
+ // readers to trust, this writes down what that older figure said next to what
12
+ // the new one says. The gap between them is the drift a real session lived
13
+ // through between two corrections.
14
+ //
15
+ // Same constraints as reading.js: cheap, bounded, and it must never be the
16
+ // reason a hook is late or fails.
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+
22
+ const host = require('./host.js');
23
+ const codex = require('./codex.js');
24
+
25
+ // Not "how many windows" but "how many measurements": a busy day produces a
26
+ // handful of corrections per window, and a fixed cap keeps the file the same
27
+ // size whether the plugin has run for a day or a year.
28
+ const MAX_ENTRIES = 200;
29
+
30
+ function configDir() {
31
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
32
+ }
33
+
34
+ function driftFile(codexHome) {
35
+ return path.join(codexHome || configDir(), 'usage-limits-drift.json');
36
+ }
37
+
38
+ // The other measurement that belongs here: what a turn actually cost while
39
+ // each budget mode was on. Same file, same bounded append, for the same
40
+ // reason - it is an after-the-fact measurement of a prediction, and a second
41
+ // store would be a second thing to keep correct. Modes should be evidence
42
+ // rather than vibes, and this is where the evidence lands.
43
+ const MAX_MODE_ENTRIES = 400;
44
+
45
+ function read(codexHome) {
46
+ try {
47
+ const parsed = JSON.parse(fs.readFileSync(driftFile(codexHome), 'utf8'));
48
+ if (!parsed || typeof parsed !== 'object') return { entries: [] };
49
+ const state = { entries: Array.isArray(parsed.entries) ? parsed.entries : [] };
50
+ // The mode ledger is carried only when the file actually has one. A reader
51
+ // that has never recorded a mode gets back exactly the shape it wrote, so
52
+ // adding this second ledger did not change what the first one reads.
53
+ if (Array.isArray(parsed.modes)) state.modes = parsed.modes;
54
+ return state;
55
+ } catch (err) {
56
+ return { entries: [] };
57
+ }
58
+ }
59
+
60
+ // One reply, priced, tagged with the mode that was in force while it ran.
61
+ // Called from the Stop hook, which already has the exact figures.
62
+ function recordTurns(mode, turns, cost, now, codexHome) {
63
+ if (!mode || !Number.isFinite(turns) || turns <= 0) return false;
64
+ try {
65
+ const state = read(codexHome);
66
+ if (!Array.isArray(state.modes)) state.modes = [];
67
+ state.modes.push({
68
+ mode: String(mode),
69
+ at: Number.isFinite(now) ? now : Date.now(),
70
+ turns,
71
+ cost: Number.isFinite(cost) ? cost : 0,
72
+ });
73
+ if (state.modes.length > MAX_MODE_ENTRIES) state.modes = state.modes.slice(-MAX_MODE_ENTRIES);
74
+ writeAtomic(driftFile(codexHome), state);
75
+ return true;
76
+ } catch (err) {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ // Turns and observed cost per turn, per mode. Only what was measured: a mode
82
+ // nothing has run in is absent rather than shown at zero, because a zero here
83
+ // reads as "free" when it means "unknown".
84
+ function modeSummary(codexHome) {
85
+ const rows = {};
86
+ for (const entry of read(codexHome).modes || []) {
87
+ if (!entry || !entry.mode || !Number.isFinite(entry.turns)) continue;
88
+ const row = rows[entry.mode] || (rows[entry.mode] = { mode: entry.mode, turns: 0, usd: 0 });
89
+ row.turns += entry.turns;
90
+ row.usd += Number.isFinite(entry.cost) ? entry.cost : 0;
91
+ }
92
+ return Object.keys(rows)
93
+ .map((key) => {
94
+ const row = rows[key];
95
+ return {
96
+ mode: row.mode,
97
+ turns: row.turns,
98
+ usd: row.usd,
99
+ usdPerTurn: row.turns > 0 && row.usd > 0 ? row.usd / row.turns : null,
100
+ };
101
+ })
102
+ .sort((a, b) => b.turns - a.turns);
103
+ }
104
+
105
+ function writeAtomic(file, data) {
106
+ fs.mkdirSync(path.dirname(file), { recursive: true });
107
+ // Same beside-and-rename as reading.js: the pulse and the prompt hook can
108
+ // both land here in the same second.
109
+ const tmp = file + '.' + process.pid + '.tmp';
110
+ fs.writeFileSync(tmp, JSON.stringify(data));
111
+ fs.renameSync(tmp, file);
112
+ }
113
+
114
+ // `previous` and `next` are both entries in reading.js's own shape - see
115
+ // reading.record(). Called from there, right before it overwrites the slot,
116
+ // so `previous` is what readers were trusting and `next` is what the fresh
117
+ // scan just found for the same window.
118
+ function record(key, previous, next, now, codexHome) {
119
+ if (!key || !previous || !next) return false;
120
+ if (!Number.isFinite(previous.percentUsed) || !Number.isFinite(next.percentUsed)) return false;
121
+ if (!Number.isFinite(previous.at)) return false;
122
+ const at = Number.isFinite(now) ? now : Date.now();
123
+ // A window that reset between the two readings did not "drift" - it started
124
+ // over, and treating the jump as error would swamp every real measurement.
125
+ if (Number.isFinite(previous.resetsAt) && previous.resetsAt <= next.at) return false;
126
+ try {
127
+ const state = read(codexHome);
128
+ state.entries.push({
129
+ key,
130
+ at,
131
+ // How long the older figure had been sitting in front of readers before
132
+ // this measurement replaced it - a drift found after two minutes and
133
+ // one found after twenty are not the same kind of evidence.
134
+ ageMs: Math.max(0, next.at - previous.at),
135
+ predictedPercentUsed: previous.percentUsed,
136
+ actualPercentUsed: next.percentUsed,
137
+ percentDrift: next.percentUsed - previous.percentUsed,
138
+ predictedTurnsLeft: Number.isFinite(previous.turnsLeft) ? previous.turnsLeft : null,
139
+ actualTurnsLeft: Number.isFinite(next.turnsLeft) ? next.turnsLeft : null,
140
+ });
141
+ if (state.entries.length > MAX_ENTRIES) state.entries = state.entries.slice(-MAX_ENTRIES);
142
+ writeAtomic(driftFile(codexHome), state);
143
+ return true;
144
+ } catch (err) {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function median(values) {
150
+ if (!values.length) return null;
151
+ const sorted = values.slice().sort((a, b) => a - b);
152
+ const mid = Math.floor(sorted.length / 2);
153
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
154
+ }
155
+
156
+ // What the ledger has actually shown: the typical gap and the worst one, in
157
+ // percentage points, over whatever window filter is asked for (default:
158
+ // everything on record). Turns get the same treatment where both sides of a
159
+ // pair have a turns figure to compare.
160
+ function summary(codexHome, options) {
161
+ const opts = options || {};
162
+ const state = read(codexHome);
163
+ let entries = state.entries;
164
+ if (opts.key) entries = entries.filter((e) => e.key === opts.key);
165
+ if (!entries.length) return { sample: 0, medianAbsPercent: null, worstAbsPercent: null, medianAbsTurns: null, worstAbsTurns: null };
166
+
167
+ const percentAbs = entries.map((e) => Math.abs(e.percentDrift));
168
+ const turnsAbs = entries
169
+ .filter((e) => Number.isFinite(e.predictedTurnsLeft) && Number.isFinite(e.actualTurnsLeft))
170
+ .map((e) => Math.abs(e.actualTurnsLeft - e.predictedTurnsLeft));
171
+
172
+ // A number of points on its own says nothing: sixty points off is a scandal
173
+ // over two minutes and unremarkable over five hours. The gap the figure was
174
+ // wrong for is what makes it readable, and it is already on every entry.
175
+ const ages = entries.map((e) => e.ageMs).filter((ms) => Number.isFinite(ms));
176
+ const worst = entries.reduce(
177
+ (found, e) => (!found || Math.abs(e.percentDrift) > Math.abs(found.percentDrift) ? e : found),
178
+ null
179
+ );
180
+
181
+ return {
182
+ sample: entries.length,
183
+ medianAbsPercent: median(percentAbs),
184
+ worstAbsPercent: percentAbs.length ? Math.max(...percentAbs) : null,
185
+ medianAbsTurns: turnsAbs.length ? median(turnsAbs) : null,
186
+ worstAbsTurns: turnsAbs.length ? Math.max(...turnsAbs) : null,
187
+ medianGapMs: ages.length ? median(ages) : null,
188
+ worstGapMs: worst && Number.isFinite(worst.ageMs) ? worst.ageMs : null,
189
+ };
190
+ }
191
+
192
+ // Minutes, because that is the scale a correction interval lives on and the
193
+ // only one anybody compares against the pulse interval.
194
+ function gap(ms) {
195
+ if (!Number.isFinite(ms)) return null;
196
+ const minutes = ms / 60000;
197
+ return (minutes >= 10 ? Math.round(minutes) : Math.round(minutes * 10) / 10) + 'm';
198
+ }
199
+
200
+ function describe(codexHome) {
201
+ const stats = summary(codexHome);
202
+ if (!stats.sample) return 'Drift ledger: no corrections measured against an earlier one yet.';
203
+ const lines = [];
204
+ lines.push('Drift ledger: ' + stats.sample + ' measured correction' + (stats.sample === 1 ? '' : 's') + '.');
205
+ const medianGap = gap(stats.medianGapMs);
206
+ const worstGap = gap(stats.worstGapMs);
207
+ lines.push(
208
+ ' Percent used: median ' + fmt(stats.medianAbsPercent) + ' points off' +
209
+ (medianGap ? ' over a typical ' + medianGap + ' gap' : '') + ', worst ' +
210
+ fmt(stats.worstAbsPercent) + ' points off' + (worstGap ? ' over ' + worstGap : '') + '.'
211
+ );
212
+ if (stats.medianAbsTurns !== null) {
213
+ lines.push(
214
+ ' Turns left: median ' + fmt(stats.medianAbsTurns) + ' off, worst ' + fmt(stats.worstAbsTurns) + ' off.'
215
+ );
216
+ }
217
+ return lines.join('\n');
218
+ }
219
+
220
+ function fmt(value) {
221
+ if (!Number.isFinite(value)) return '-';
222
+ return Math.round(value * 10) / 10;
223
+ }
224
+
225
+ function activeCodexHome(argv, env) {
226
+ return host.detect(argv, env) === host.CODEX ? codex.homeDir() : null;
227
+ }
228
+
229
+ function main(argv) {
230
+ const args = argv || [];
231
+ const codexHome = activeCodexHome(args, process.env);
232
+ if (args[0] === '--json') return JSON.stringify(summary(codexHome, {}), null, 2);
233
+ return describe(codexHome);
234
+ }
235
+
236
+ if (require.main === module) {
237
+ process.stdout.write(main(process.argv.slice(2)) + '\n');
238
+ process.exit(0);
239
+ }
240
+
241
+ module.exports = {
242
+ MAX_ENTRIES,
243
+ MAX_MODE_ENTRIES,
244
+ configDir,
245
+ driftFile,
246
+ gap,
247
+ read,
248
+ record,
249
+ recordTurns,
250
+ modeSummary,
251
+ summary,
252
+ describe,
253
+ main,
254
+ };
@@ -204,8 +204,18 @@ function line(built, options) {
204
204
  : bars.paint(effortName, effort.rgb, mode);
205
205
  // The word, in the rainbow, the way Claude Code paints it in the prompt.
206
206
  const thinking = built.ultrathink ? ' ' + bars.dim('·', mode) + ' ' + bars.rainbow('ultrathink', tick, { mode, reduced }) : '';
207
+ // The budget mode, only when it is not the one the plugin has always been
208
+ // in. `standard` is today's behaviour and today's line, unchanged to the
209
+ // character; anything else changes what the hooks say, and a line that does
210
+ // not mention it leaves the user guessing why the briefing went quiet.
211
+ // It rides on the head so the width ladder drops it with the head, before it
212
+ // ever costs a percentage its place.
213
+ const budgetText =
214
+ built.budget && built.budget.name !== 'standard'
215
+ ? ' ' + bars.dim('·', mode) + ' ' + bars.dim('budget ' + built.budget.label, mode)
216
+ : '';
207
217
  const head =
208
- glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '') + thinking;
218
+ glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '') + thinking + budgetText;
209
219
 
210
220
  const segment = (row, width, shorter) => {
211
221
  const label = shortLabel(row, shorter);
@@ -396,6 +406,17 @@ function motionOff(settings, env) {
396
406
 
397
407
  // Written and flushed before the process is allowed to end: stdout is a pipe
398
408
  // here, and a pipe write can still be in flight when process.exit runs.
409
+ // The budget mode, for the token on the line. Wrapped and lazy: the status
410
+ // line must never fail over an optional word, and a machine that has never set
411
+ // a mode should not pay for a require to be told so.
412
+ function budgetNow(sessionId) {
413
+ try {
414
+ return require('./mode.js').forSession({ sessionId });
415
+ } catch (err) {
416
+ return null;
417
+ }
418
+ }
419
+
399
420
  function out(text) {
400
421
  return new Promise((resolve) => {
401
422
  process.stdout.write(text, () => resolve());
@@ -461,6 +482,7 @@ async function main(argv) {
461
482
  const built = view.build({
462
483
  now,
463
484
  agents: usage.liveAgents(now),
485
+ budget: budgetNow(mine || (slot && slot.sessionId) || null),
464
486
  utilization: collected.utilization,
465
487
  fetchedAtMs: collected.snapshotFetchedAt,
466
488
  source: collected.snapshotSource,
@@ -18,6 +18,15 @@ const path = require('path');
18
18
 
19
19
  const CLAUDE = 'claude';
20
20
  const CODEX = 'codex';
21
+ const GEMINI = 'gemini';
22
+
23
+ function geminiConfigDir() {
24
+ return process.env.GEMINI_CONFIG_DIR || path.join(os.homedir(), '.gemini');
25
+ }
26
+
27
+ function geminiHome() {
28
+ return process.env.GEMINI_HOME || path.join(geminiConfigDir(), 'antigravity-cli');
29
+ }
21
30
 
22
31
  function codexHome() {
23
32
  return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
@@ -66,16 +75,20 @@ function codexHasSessions() {
66
75
  return exists(path.join(codexHome(), 'sessions'));
67
76
  }
68
77
 
78
+ function geminiHasSessions() {
79
+ return exists(geminiHome());
80
+ }
81
+
69
82
  function normalise(value) {
70
83
  const name = String(value || '').trim().toLowerCase();
84
+ if (name === GEMINI || name === 'agy' || name === 'antigravity' || name === 'google') return GEMINI;
71
85
  if (name === CODEX || name === 'chatgpt' || name === 'openai') return CODEX;
72
86
  if (name === CLAUDE || name === 'claude-code' || name === 'anthropic') return CLAUDE;
73
87
  return null;
74
88
  }
75
89
 
76
- // `--host codex` beats everything, then the environment variable, then what is
77
- // actually on disk. Claude wins ties: it is the host the hook was written for,
78
- // and its reader fails loudly rather than silently reporting nothing.
90
+ // `--host gemini` beats everything, then the environment variable, then what is
91
+ // actually on disk.
79
92
  function detect(argv, env) {
80
93
  const args = argv || [];
81
94
  const at = args.indexOf('--host');
@@ -86,6 +99,8 @@ function detect(argv, env) {
86
99
  const fromEnv = normalise(environment.USAGE_LIMITS_HOST);
87
100
  if (fromEnv) return fromEnv;
88
101
 
102
+ // Set by Antigravity / Gemini CLI
103
+ if (environment.ANTIGRAVITY_CLI || environment.GEMINI_CLI || environment.GEMINI_WORKSPACE) return GEMINI;
89
104
  // Set by Claude Code for plugin hooks and commands.
90
105
  if (environment.CLAUDE_PLUGIN_ROOT || environment.CLAUDE_PROJECT_DIR) return CLAUDE;
91
106
  // Set by Codex for the processes it launches.
@@ -93,14 +108,19 @@ function detect(argv, env) {
93
108
 
94
109
  if (claudeHasSnapshot()) return CLAUDE;
95
110
  if (codexHasSessions()) return CODEX;
111
+ if (geminiHasSessions()) return GEMINI;
96
112
  return CLAUDE;
97
113
  }
98
114
 
99
115
  module.exports = {
100
116
  CLAUDE,
101
117
  CODEX,
118
+ GEMINI,
102
119
  detect,
103
120
  normalise,
121
+ geminiHome,
122
+ geminiConfigDir,
123
+ geminiHasSessions,
104
124
  codexHome,
105
125
  claudeConfigDir,
106
126
  claudeHasSnapshot,
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Installs the plugin into Antigravity.
5
+ //
6
+ // Antigravity discovers customizations from ~/.gemini/config/ on a machine, and
7
+ // a plugin there is a directory holding plugin.json, optionally hooks.json,
8
+ // rules/ and skills/. That is documented on the machine itself, in the built-in
9
+ // agy-customizations skill, and this installer follows it rather than guessing:
10
+ //
11
+ // ~/.gemini/config/plugins/usage-limits/
12
+ // plugin.json the marker that makes the directory a plugin
13
+ // hooks.json PreInvocation for the budget line, PreToolUse for the ceiling
14
+ // rules/AGENTS.md the always-on rules
15
+ //
16
+ // The installed directory is deliberately small. Hooks run with their working
17
+ // directory set to the folder containing hooks.json, so a copied plugin would
18
+ // need the whole script tree copied with it and would then go stale the moment
19
+ // the real one was updated. Instead the commands carry an absolute path back to
20
+ // this checkout, so there is one copy of the code and updating it updates what
21
+ // Antigravity runs.
22
+ //
23
+ // node install-antigravity.js status
24
+ // node install-antigravity.js on
25
+ // node install-antigravity.js off
26
+ //
27
+ // Everything written is confined to that one directory, and `off` removes
28
+ // exactly what `on` created and nothing else.
29
+
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const path = require('path');
33
+
34
+ const host = require('./host.js');
35
+
36
+ const PLUGIN = 'usage-limits';
37
+
38
+ function pluginsDir() {
39
+ return path.join(host.geminiConfigDir(), 'config', 'plugins');
40
+ }
41
+
42
+ function pluginDir() {
43
+ return path.join(pluginsDir(), PLUGIN);
44
+ }
45
+
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
+ return '"' + String(file).replace(/\\/g, '/') + '"';
52
+ }
53
+
54
+ function scriptPath(name) {
55
+ return path.join(__dirname, name);
56
+ }
57
+
58
+ function hookCommand(event) {
59
+ return 'node ' + quote(scriptPath('agy-hook.js')) + ' --event ' + event;
60
+ }
61
+
62
+ function manifest() {
63
+ return {
64
+ name: PLUGIN,
65
+ description:
66
+ 'Puts the remaining usage budget in front of the agent before each turn, and enforces a ' +
67
+ 'ceiling past which fan-out calls are refused.',
68
+ };
69
+ }
70
+
71
+ function hooks() {
72
+ return {
73
+ 'usage-limits-brief': {
74
+ PreInvocation: [{ type: 'command', command: hookCommand('PreInvocation'), timeout: 10 }],
75
+ },
76
+ 'usage-limits-ceiling': {
77
+ PreToolUse: [
78
+ {
79
+ matcher: '.*',
80
+ hooks: [{ type: 'command', command: hookCommand('PreToolUse'), timeout: 10 }],
81
+ },
82
+ ],
83
+ },
84
+ };
85
+ }
86
+
87
+ function rulesText() {
88
+ // Shipped from the repo so there is one copy of the wording, but never fails
89
+ // the install over a missing file: the hooks are the part that matters.
90
+ try {
91
+ return fs.readFileSync(path.join(__dirname, '..', '..', '..', 'rules', 'AGENTS.md'), 'utf8');
92
+ } catch (err) {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function writeFile(file, text) {
98
+ fs.mkdirSync(path.dirname(file), { recursive: true });
99
+ const tmp = file + '.' + process.pid + '.tmp';
100
+ fs.writeFileSync(tmp, text, 'utf8');
101
+ fs.renameSync(tmp, file);
102
+ }
103
+
104
+ function installed() {
105
+ try {
106
+ fs.accessSync(path.join(pluginDir(), 'plugin.json'));
107
+ return true;
108
+ } catch (err) {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ // Whether Antigravity is on this machine at all. Its config directory is the
114
+ // evidence; ~/.gemini alone is not, because the Gemini CLI uses that too.
115
+ function present() {
116
+ return host.exists(path.join(host.geminiConfigDir(), 'antigravity-cli')) ||
117
+ host.exists(path.join(host.geminiConfigDir(), 'antigravity'));
118
+ }
119
+
120
+ function status() {
121
+ const lines = [];
122
+ lines.push('Antigravity ' + (present() ? 'found at ' + host.geminiConfigDir() : 'not found on this machine'));
123
+ lines.push('Plugin ' + (installed() ? 'installed at ' + pluginDir() : 'not installed'));
124
+ if (installed()) {
125
+ let current = null;
126
+ try {
127
+ current = JSON.parse(fs.readFileSync(path.join(pluginDir(), 'hooks.json'), 'utf8'));
128
+ } catch (err) {
129
+ // An unreadable hooks.json is worth saying rather than throwing.
130
+ }
131
+ const events = current ? Object.keys(current).map((name) => Object.keys(current[name]).filter((k) => k !== 'enabled').join(', ')) : [];
132
+ lines.push('Hooks ' + (events.length ? events.join(', ') : 'none readable'));
133
+ lines.push(
134
+ 'Enabled recorded in ' +
135
+ path.join(host.geminiConfigDir(), 'config', 'config.json') +
136
+ ' under plugins.' + PLUGIN + '; a plugin with no entry there is on by default.'
137
+ );
138
+ }
139
+ if (!present()) {
140
+ lines.push('');
141
+ lines.push('Nothing to install into. Antigravity keeps its configuration in ~/.gemini/config.');
142
+ }
143
+ return lines.join('\n');
144
+ }
145
+
146
+ function enable() {
147
+ if (!present()) {
148
+ return (
149
+ 'Antigravity was not found on this machine, so there is nothing to install into.\n' +
150
+ 'Expected its configuration at ' + host.geminiConfigDir() + '.'
151
+ );
152
+ }
153
+ const dir = pluginDir();
154
+ writeFile(path.join(dir, 'plugin.json'), JSON.stringify(manifest(), null, 2) + '\n');
155
+ writeFile(path.join(dir, 'hooks.json'), JSON.stringify(hooks(), null, 2) + '\n');
156
+ const rules = rulesText();
157
+ if (rules) writeFile(path.join(dir, 'rules', 'AGENTS.md'), rules);
158
+
159
+ return [
160
+ 'Installed into ' + dir + '.',
161
+ ' PreInvocation the budget line, as an injected ephemeral message',
162
+ ' PreToolUse the ceiling, which refuses fan-out calls past it',
163
+ rules ? ' rules/AGENTS.md the always-on rules' : ' (rules/AGENTS.md was not found in this checkout and was skipped)',
164
+ '',
165
+ 'The hooks run this checkout directly, so updating it updates what Antigravity runs.',
166
+ 'Antigravity picks the plugin up when it next starts. There is no quota figure for it: ' +
167
+ 'Antigravity refreshes its own quota but writes it nowhere readable, so the budget line ' +
168
+ 'reports what it can and says so where it cannot.',
169
+ ].join('\n');
170
+ }
171
+
172
+ // Removes only what enable() wrote, one named file at a time, and only takes
173
+ // the directory away when nothing else has been put in it.
174
+ function disable() {
175
+ const dir = pluginDir();
176
+ if (!installed()) return 'Not installed. Nothing to remove.';
177
+ const removed = [];
178
+ for (const relative of ['plugin.json', 'hooks.json', path.join('rules', 'AGENTS.md')]) {
179
+ const file = path.join(dir, relative);
180
+ try {
181
+ fs.unlinkSync(file);
182
+ removed.push(relative);
183
+ } catch (err) {
184
+ // Already gone is the outcome that was asked for.
185
+ }
186
+ }
187
+ for (const relative of ['rules', '']) {
188
+ try {
189
+ fs.rmdirSync(path.join(dir, relative));
190
+ } catch (err) {
191
+ // Not empty, or already gone. Either way it is not ours to force.
192
+ }
193
+ }
194
+ return 'Removed ' + (removed.length ? removed.join(', ') : 'nothing') + ' from ' + dir + '.';
195
+ }
196
+
197
+ function main(argv) {
198
+ const command = (argv || []).find((arg) => !arg.startsWith('-')) || 'status';
199
+ if (command === 'status') return status();
200
+ if (command === 'on') return enable();
201
+ if (command === 'off') return disable();
202
+ throw new Error('Expected status, on or off');
203
+ }
204
+
205
+ if (require.main === module) {
206
+ try {
207
+ process.stdout.write(main(process.argv.slice(2)) + '\n');
208
+ process.exitCode = 0;
209
+ } catch (err) {
210
+ process.stderr.write('install-antigravity: ' + (err && err.message ? err.message : String(err)) + '\n');
211
+ process.exitCode = 1;
212
+ }
213
+ }
214
+
215
+ module.exports = { PLUGIN, pluginsDir, pluginDir, hookCommand, manifest, hooks, installed, present, status, enable, disable, main };
@@ -53,6 +53,22 @@ const EVENTS = [
53
53
  // tool calls of its own for as long as they run. Same quiet refresh as on
54
54
  // Claude Code; pulse.js sees the event name and says nothing.
55
55
  { event: 'SubagentStop', script: 'pulse.js', status: 'Checking usage limits' },
56
+ // The ceiling, and the only entry here that can refuse anything.
57
+ //
58
+ // Codex is the host where a reported figure demonstrably does not change
59
+ // behaviour, and the reason is not stubbornness: gpt-6-astra's own system
60
+ // prompt, shipped in models_cache.json, tells it "do not settle for a partial
61
+ // or helpful enough solution that does not fully satisfy the user's task to
62
+ // save time, effort or tokens", and ranks the live user instruction above
63
+ // anything an AGENTS.md or a skill says. A line asking it to economise is
64
+ // arguing with its own instructions and losing.
65
+ //
66
+ // So this one does not ask. PreToolUse takes a permissionDecision of "deny",
67
+ // and past the ceiling the fan-out calls get one. The matcher is broad
68
+ // because ceiling.js decides what is actually a multiplier; a hook that fires
69
+ // and returns nothing costs a few milliseconds, and a matcher that misses a
70
+ // renamed tool costs the window.
71
+ { event: 'PreToolUse', script: 'pulse.js', status: 'Checking usage limits', matcher: '.*' },
56
72
  ];
57
73
  const EVENT = EVENTS[0].event;
58
74
  // Ten seconds is the same budget the Claude hook gets. The brief caches the
@@ -360,7 +376,7 @@ function enable() {
360
376
 
361
377
  for (const one of EVENTS) {
362
378
  const rest = withoutOurs(config.hooks[one.event]);
363
- rest.push({
379
+ const group = {
364
380
  hooks: [
365
381
  {
366
382
  type: 'command',
@@ -369,7 +385,11 @@ function enable() {
369
385
  statusMessage: one.status,
370
386
  },
371
387
  ],
372
- });
388
+ };
389
+ // Tool-scoped events take a matcher; the others ignore one, and writing a
390
+ // matcher where none belongs is the kind of thing a strict parser rejects.
391
+ if (one.matcher) group.matcher = one.matcher;
392
+ rest.push(group);
373
393
  config.hooks[one.event] = rest;
374
394
  }
375
395
  if (!config.description) {