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,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) {
@@ -61,6 +61,8 @@ function parseArgs(argv) {
61
61
  for (let i = 0; i < argv.length; i += 1) {
62
62
  const arg = argv[i];
63
63
  if (arg === '--dry-run') args.dryRun = true;
64
+ // Codex only: leave the [agents] subagent clamp alone.
65
+ else if (arg === '--no-agents') args.agents = false;
64
66
  else if (arg === '--host') args.host = argv[++i];
65
67
  else if (arg.startsWith('--host=')) args.host = arg.slice(7);
66
68
  else if (arg === '--effort') args.effort = argv[++i];
@@ -169,6 +171,50 @@ function describe(settings, state) {
169
171
  return lines.join('\n');
170
172
  }
171
173
 
174
+ // Write down what was changed here, so "change my effort back" has a referent.
175
+ //
176
+ // This script is the ONLY thing in the plugin that writes settings.json, and
177
+ // it was the only change the log could not name: mode.js recorded fourteen
178
+ // kinds of mode-plane change and nothing ever wrote a user-plane entry, so
179
+ // `mode --history` answered "nothing has been changed through this plugin yet"
180
+ // immediately after this had rewritten the user's baseline, and undo's whole
181
+ // user-plane branch was unreachable code.
182
+ //
183
+ // Required lazily and inside a try: a settings write that already succeeded
184
+ // must not be reported as a failure because a log line could not be added, and
185
+ // this file otherwise depends on nothing.
186
+ //
187
+ // `by` is a reading of the environment, not a claim about intent. Claude Code
188
+ // and Codex both put a session id into the environment of everything they
189
+ // launch, so a run from inside an agent session is recorded as the agent's and
190
+ // a run from the user's own shell as theirs. It is the honest half of "at
191
+ // whose instruction": who typed it, not who wanted it.
192
+ function logSettingsChange(changes, direction, env) {
193
+ if (!changes || !changes.length) return;
194
+ const e = env || process.env;
195
+ const by = e.CLAUDE_SESSION_ID || e.CODEX_SESSION_ID ? 'claude' : 'user';
196
+ try {
197
+ const mode = require('./mode.js');
198
+ for (const change of changes) {
199
+ // "effortLevel: xhigh -> low", as planApply and planRestore build them.
200
+ const at = change.indexOf(': ');
201
+ const key = at === -1 ? change : change.slice(0, at);
202
+ const rest = at === -1 ? '' : change.slice(at + 2);
203
+ const arrow = rest.indexOf(' -> ');
204
+ mode.logChange({
205
+ plane: 'user',
206
+ key,
207
+ from: arrow === -1 ? null : rest.slice(0, arrow),
208
+ to: arrow === -1 ? rest || null : rest.slice(arrow + 4),
209
+ by,
210
+ reason: direction === 'off' ? 'lowpower off' : 'lowpower on',
211
+ });
212
+ }
213
+ } catch (err) {
214
+ // A record of the change is worth less than the change itself.
215
+ }
216
+ }
217
+
172
218
  function main(argv) {
173
219
  const args = parseArgs(argv);
174
220
  const host = require('./host.js');
@@ -196,6 +242,7 @@ function main(argv) {
196
242
  if (!state && fs.existsSync(file)) fs.copyFileSync(file, file + '.usage-limits-backup');
197
243
  writeJson(file, plan.settings);
198
244
  writeJson(stateFile(), plan.state);
245
+ logSettingsChange(plan.changes, 'on');
199
246
  process.stdout.write(
200
247
  'Low power on.\n ' + (plan.changes.join('\n ') || '(nothing to change)') + '\n' +
201
248
  'Applies to new sessions. For the session you are in, run /effort ' +
@@ -216,6 +263,7 @@ function main(argv) {
216
263
  }
217
264
  writeJson(file, plan.settings);
218
265
  fs.unlinkSync(stateFile());
266
+ logSettingsChange(plan.changes, 'off');
219
267
  process.stdout.write(
220
268
  'Low power off.\n ' + (plan.changes.join('\n ') || '(nothing to change)') + '\n'
221
269
  );