claude-usage-limits 1.6.0 → 1.7.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,96 @@
1
+ 'use strict';
2
+
3
+ // Which agent this is running inside, and therefore whose meter to read.
4
+ //
5
+ // The plugin ships for two hosts. Claude Code keeps its usage figures in
6
+ // ~/.claude.json and its turn history under ~/.claude/projects. Codex keeps
7
+ // both in its session rollouts under ~/.codex/sessions. The maths downstream is
8
+ // the same either way; only the two readers differ.
9
+ //
10
+ // Guessing wrong is worse than not guessing, because a machine with both
11
+ // installed would confidently report the other agent's budget. So anything that
12
+ // installs a hook or a command states the host outright, and detection is only
13
+ // the fallback for someone running the script by hand.
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+
19
+ const CLAUDE = 'claude';
20
+ const CODEX = 'codex';
21
+
22
+ function codexHome() {
23
+ return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
24
+ }
25
+
26
+ function claudeConfigDir() {
27
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
28
+ }
29
+
30
+ function exists(file) {
31
+ try {
32
+ fs.accessSync(file);
33
+ return true;
34
+ } catch (err) {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ // Claude Code only writes this once it has talked to the API, so its presence
40
+ // is a stronger signal than the directory existing.
41
+ function claudeHasSnapshot() {
42
+ const scoped = path.join(claudeConfigDir(), '.claude.json');
43
+ const file = exists(scoped) ? scoped : path.join(os.homedir(), '.claude.json');
44
+ try {
45
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
46
+ return Boolean(parsed && parsed.cachedUsageUtilization);
47
+ } catch (err) {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function codexHasSessions() {
53
+ return exists(path.join(codexHome(), 'sessions'));
54
+ }
55
+
56
+ function normalise(value) {
57
+ const name = String(value || '').trim().toLowerCase();
58
+ if (name === CODEX || name === 'chatgpt' || name === 'openai') return CODEX;
59
+ if (name === CLAUDE || name === 'claude-code' || name === 'anthropic') return CLAUDE;
60
+ return null;
61
+ }
62
+
63
+ // `--host codex` beats everything, then the environment variable, then what is
64
+ // actually on disk. Claude wins ties: it is the host the hook was written for,
65
+ // and its reader fails loudly rather than silently reporting nothing.
66
+ function detect(argv, env) {
67
+ const args = argv || [];
68
+ const at = args.indexOf('--host');
69
+ const explicit = at !== -1 ? normalise(args[at + 1]) : null;
70
+ if (explicit) return explicit;
71
+
72
+ const environment = env || process.env;
73
+ const fromEnv = normalise(environment.USAGE_LIMITS_HOST);
74
+ if (fromEnv) return fromEnv;
75
+
76
+ // Set by Claude Code for plugin hooks and commands.
77
+ if (environment.CLAUDE_PLUGIN_ROOT || environment.CLAUDE_PROJECT_DIR) return CLAUDE;
78
+ // Set by Codex for the processes it launches.
79
+ if (environment.CODEX_HOME || environment.CODEX_CLI_PATH) return CODEX;
80
+
81
+ if (claudeHasSnapshot()) return CLAUDE;
82
+ if (codexHasSessions()) return CODEX;
83
+ return CLAUDE;
84
+ }
85
+
86
+ module.exports = {
87
+ CLAUDE,
88
+ CODEX,
89
+ detect,
90
+ normalise,
91
+ codexHome,
92
+ claudeConfigDir,
93
+ claudeHasSnapshot,
94
+ codexHasSessions,
95
+ exists,
96
+ };
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Installs the budget line into Codex.
5
+ //
6
+ // Claude Code lets a plugin ship its own hooks, so installing the plugin is all
7
+ // there is to it. Codex is not there yet, and it is worth writing down exactly
8
+ // how far it gets, because the answer is not obvious from the outside:
9
+ //
10
+ // - Codex has the whole hook engine. The binary carries UserPromptSubmit,
11
+ // SessionStart, PreToolUse and the rest, and `codex features list` reports
12
+ // `hooks` as stable and enabled.
13
+ // - A plugin cannot ship one. `plugin_hooks` is reported as `removed`.
14
+ // - And on codex-cli 0.151.0-alpha.7.2 nothing fires it. Measured, with a
15
+ // hook whose only job was to write a file: not from ~/.codex/hooks.json,
16
+ // not from a `[hooks]` table in config.toml, not from ~/.codex/hooks/, and
17
+ // not in `codex exec` or the desktop app. The engine is present and inert.
18
+ //
19
+ // So the hooks are still written, because they cost nothing and will start
20
+ // working the day that build ships. But they are not what makes this automatic
21
+ // today. AGENTS.md is: Codex reads it at the top of every session in scope,
22
+ // which is the one always-on instruction channel that actually runs. It cannot
23
+ // carry live numbers the way a hook can, so instead it tells Codex to go and
24
+ // read them at the start of a piece of work.
25
+ //
26
+ // Both halves are marked and reversible, and neither touches anything else in
27
+ // the files it edits.
28
+ //
29
+ // node install-codex-hook.js status
30
+ // node install-codex-hook.js on
31
+ // node install-codex-hook.js off
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const host = require('./host.js');
37
+
38
+ // One before each prompt, one during long turns so the figure does not go
39
+ // stale while work is running.
40
+ const EVENTS = [
41
+ { event: 'UserPromptSubmit', script: 'brief.js', status: 'Checking usage limits' },
42
+ { event: 'PostToolUse', script: 'pulse.js', status: 'Checking usage limits' },
43
+ ];
44
+ const EVENT = EVENTS[0].event;
45
+ // Ten seconds is the same budget the Claude hook gets. The brief caches the
46
+ // expensive half for a minute, so the common case is far under it.
47
+ const TIMEOUT_SECONDS = 10;
48
+
49
+ function hooksFile() {
50
+ return path.join(host.codexHome(), 'hooks.json');
51
+ }
52
+
53
+ function briefScript(name) {
54
+ return path.join(__dirname, name || 'brief.js');
55
+ }
56
+
57
+ // Forward slashes on every platform. They work in Windows paths, and they keep
58
+ // the command free of escapes in both JSON and the shell that runs it.
59
+ function quote(file) {
60
+ return '"' + String(file).replace(/\\/g, '/') + '"';
61
+ }
62
+
63
+ // process.execPath rather than a bare `node`, because a hook does not
64
+ // necessarily inherit a PATH with node on it, and a hook that cannot start is
65
+ // silent: no error, no budget line, and nothing to tell you why.
66
+ function command(script) {
67
+ return quote(process.execPath) + ' ' + quote(briefScript(script)) + ' --host codex';
68
+ }
69
+
70
+ // Ours is any entry that runs one of this plugin's own hook scripts, whatever
71
+ // node or absolute path it was written with. Matching on that rather than on
72
+ // the whole string is what makes reinstalling replace instead of duplicate.
73
+ function isOurs(entry) {
74
+ if (!entry || typeof entry.command !== 'string') return false;
75
+ const text = entry.command.replace(/\\/g, '/');
76
+ return EVENTS.some((one) => text.indexOf('usage-limits/scripts/' + one.script) !== -1);
77
+ }
78
+
79
+ function readHooks() {
80
+ let raw;
81
+ try {
82
+ raw = fs.readFileSync(hooksFile(), 'utf8');
83
+ } catch (err) {
84
+ return { config: { hooks: {} }, existed: false, broken: false };
85
+ }
86
+ try {
87
+ const parsed = JSON.parse(raw.replace(/^/, ''));
88
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
89
+ if (!parsed.hooks || typeof parsed.hooks !== 'object') parsed.hooks = {};
90
+ return { config: parsed, existed: true, broken: false };
91
+ } catch (err) {
92
+ // Never overwrite a file that might be someone's working configuration
93
+ // just because it could not be parsed.
94
+ return { config: null, existed: true, broken: true };
95
+ }
96
+ }
97
+
98
+ function writeHooks(config) {
99
+ const file = hooksFile();
100
+ fs.mkdirSync(path.dirname(file), { recursive: true });
101
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n', 'utf8');
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // AGENTS.md, which is the half that works today
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const AGENTS_START = '<!-- usage-limits:start -->';
109
+ const AGENTS_END = '<!-- usage-limits:end -->';
110
+
111
+ function agentsFile() {
112
+ return path.join(host.codexHome(), 'AGENTS.md');
113
+ }
114
+
115
+ // Deliberately short. This sits in the context of every session in scope, so
116
+ // every line has to earn its place, and the numbers themselves are left to the
117
+ // script rather than being described here.
118
+ function agentsBlock() {
119
+ return [
120
+ AGENTS_START,
121
+ '## Usage limits',
122
+ '',
123
+ 'Before starting any substantial piece of work, and again if it grows or starts',
124
+ 'looping, run:',
125
+ '',
126
+ ' node ' + quote(briefScript('usage.js')) + ' --host codex',
127
+ '',
128
+ 'Open the reply with one line naming the binding window, the turns of headroom',
129
+ 'left in it, and whether the request fits. Size the work against that number: if',
130
+ 'it does not fit, say what you will do now and what you will leave for after the',
131
+ 'reset, rather than starting and being cut off partway.',
132
+ '',
133
+ 'This is about the order of the work, not the amount. Do not economise, shorten',
134
+ 'answers or skip verification to save budget: unspent budget is lost at the',
135
+ 'reset, not carried over. Do not run this on every reply; once at the start of a',
136
+ 'piece of work is enough.',
137
+ AGENTS_END,
138
+ ].join('\n');
139
+ }
140
+
141
+ function readAgents() {
142
+ try {
143
+ return { text: fs.readFileSync(agentsFile(), 'utf8'), existed: true };
144
+ } catch (err) {
145
+ return { text: '', existed: false };
146
+ }
147
+ }
148
+
149
+ // Between the markers and nowhere else, so whatever else the file holds is
150
+ // carried through untouched.
151
+ function stripAgents(text) {
152
+ const start = text.indexOf(AGENTS_START);
153
+ const end = text.indexOf(AGENTS_END);
154
+ if (start === -1 || end === -1 || end < start) return text;
155
+ const before = text.slice(0, start).replace(/\n+$/, '');
156
+ const after = text.slice(end + AGENTS_END.length).replace(/^\n+/, '');
157
+ if (!before) return after;
158
+ if (!after) return before + '\n';
159
+ return before + '\n\n' + after;
160
+ }
161
+
162
+ function agentsInstalled() {
163
+ const { text } = readAgents();
164
+ return text.indexOf(AGENTS_START) !== -1;
165
+ }
166
+
167
+ // One marker without its pair, or more than one of either. Stripping refuses to
168
+ // touch that, because the only safe reading of "start with no end" is that the
169
+ // region runs to the end of the file, and deleting to the end of somebody's
170
+ // AGENTS.md is not a repair. Left alone, the next `on` would strip nothing and
171
+ // then append, leaving two blocks; so `on` refuses too and says what to fix.
172
+ function agentsMalformed(text) {
173
+ const source = typeof text === 'string' ? text : readAgents().text;
174
+ const starts = source.split(AGENTS_START).length - 1;
175
+ const ends = source.split(AGENTS_END).length - 1;
176
+ if (starts === 0 && ends === 0) return false;
177
+ if (starts !== 1 || ends !== 1) return true;
178
+ return source.indexOf(AGENTS_END) < source.indexOf(AGENTS_START);
179
+ }
180
+
181
+ // True when the block is there but points at a different copy of the plugin,
182
+ // which would send Codex to a script that may no longer exist.
183
+ function agentsStale() {
184
+ const { text } = readAgents();
185
+ const start = text.indexOf(AGENTS_START);
186
+ if (start === -1) return false;
187
+ const end = text.indexOf(AGENTS_END);
188
+ const current = text.slice(start, end === -1 ? undefined : end + AGENTS_END.length);
189
+ return current.trim() !== agentsBlock().trim();
190
+ }
191
+
192
+ function writeAgents(text) {
193
+ const file = agentsFile();
194
+ fs.mkdirSync(path.dirname(file), { recursive: true });
195
+ fs.writeFileSync(file, text, 'utf8');
196
+ }
197
+
198
+ function enableAgents() {
199
+ const { text } = readAgents();
200
+ if (agentsMalformed(text)) return false;
201
+ const rest = stripAgents(text).replace(/\n+$/, '');
202
+ writeAgents((rest ? rest + '\n\n' : '') + agentsBlock() + '\n');
203
+ return true;
204
+ }
205
+
206
+ function disableAgents() {
207
+ const { text, existed } = readAgents();
208
+ if (!existed || text.indexOf(AGENTS_START) === -1) return false;
209
+ if (agentsMalformed(text)) return false;
210
+ writeAgents(stripAgents(text));
211
+ return true;
212
+ }
213
+
214
+ // Strips our entry out of one event's groups and drops any group left empty,
215
+ // so removing is a clean reversal rather than a pile of empty objects.
216
+ function withoutOurs(groups) {
217
+ return (Array.isArray(groups) ? groups : [])
218
+ .map((group) => {
219
+ if (!group || typeof group !== 'object') return null;
220
+ const hooks = Array.isArray(group.hooks) ? group.hooks.filter((one) => !isOurs(one)) : [];
221
+ if (!hooks.length) return null;
222
+ return Object.assign({}, group, { hooks });
223
+ })
224
+ .filter(Boolean);
225
+ }
226
+
227
+ function find(config, event) {
228
+ const groups = config && config.hooks ? config.hooks[event || EVENT] : null;
229
+ for (const group of Array.isArray(groups) ? groups : []) {
230
+ for (const one of (group && Array.isArray(group.hooks) ? group.hooks : [])) {
231
+ if (isOurs(one)) return one;
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function status() {
238
+ const { config, existed, broken } = readHooks();
239
+ if (broken) {
240
+ return {
241
+ installed: false,
242
+ broken: true,
243
+ file: hooksFile(),
244
+ text: hooksFile() + ' is not valid JSON, so nothing was changed. Fix or remove it, then run `on` again.',
245
+ };
246
+ }
247
+ const rows = EVENTS.map((one) => ({
248
+ event: one.event,
249
+ script: one.script,
250
+ found: find(config, one.event),
251
+ wanted: command(one.script),
252
+ }));
253
+ const missing = rows.filter((row) => !row.found);
254
+ // A hook installed from a copy of the plugin that has since moved would point
255
+ // at a file that is no longer there, and would fail silently.
256
+ const stale = rows.filter((row) => row.found && row.found.command !== row.wanted);
257
+
258
+ const lines = [];
259
+ if (!missing.length) lines.push('Installed in ' + hooksFile() + '.');
260
+ else if (missing.length === rows.length) {
261
+ lines.push('Not installed. Run `on` to add it to ' + hooksFile() + '.');
262
+ } else {
263
+ lines.push(
264
+ 'Partly installed in ' + hooksFile() + '. Missing: ' +
265
+ missing.map((row) => row.event).join(', ') + '. Run `on`.'
266
+ );
267
+ }
268
+ for (const row of stale) {
269
+ lines.push(' ' + row.event + ' points somewhere else, so run `on`:\n ' + row.found.command);
270
+ }
271
+
272
+ // Reported second and plainly, because this is the half that is actually
273
+ // doing the work. Saying "installed" about the hooks alone would claim an
274
+ // automatic budget line that no current Codex build delivers.
275
+ const malformed = agentsMalformed();
276
+ const agents = agentsInstalled() && !malformed;
277
+ lines.push(
278
+ malformed
279
+ ? 'The usage-limits markers in ' + agentsFile() + ' are not a matched pair, so\n' +
280
+ ' nothing was changed. Delete the block by hand and run `on` again.'
281
+ : agents
282
+ ? 'AGENTS.md block present in ' + agentsFile() + '.' +
283
+ (agentsStale() ? '\n It points at another copy of the plugin, so run `on`.' : '')
284
+ : 'AGENTS.md block missing from ' + agentsFile() + '. Run `on`.'
285
+ );
286
+ lines.push(
287
+ 'Hooks are written for when Codex runs them; on current builds they do not fire, ' +
288
+ 'so the AGENTS.md block is what makes this work.'
289
+ );
290
+
291
+ return {
292
+ installed: !missing.length && agents,
293
+ hooksInstalled: !missing.length,
294
+ agentsInstalled: agents,
295
+ partial: Boolean(missing.length && missing.length < rows.length),
296
+ broken: false,
297
+ existed,
298
+ file: hooksFile(),
299
+ agentsFile: agentsFile(),
300
+ events: rows.map((row) => ({ event: row.event, installed: Boolean(row.found) })),
301
+ stale: stale.length > 0 || agentsStale(),
302
+ text: lines.join('\n'),
303
+ };
304
+ }
305
+
306
+ function enable() {
307
+ const { config, broken } = readHooks();
308
+ if (broken) return { ok: false, text: status().text };
309
+
310
+ for (const one of EVENTS) {
311
+ const rest = withoutOurs(config.hooks[one.event]);
312
+ rest.push({
313
+ hooks: [
314
+ {
315
+ type: 'command',
316
+ command: command(one.script),
317
+ timeout: TIMEOUT_SECONDS,
318
+ statusMessage: one.status,
319
+ },
320
+ ],
321
+ });
322
+ config.hooks[one.event] = rest;
323
+ }
324
+ if (!config.description) {
325
+ config.description = 'Hooks for Codex. Managed entries are marked by the script that wrote them.';
326
+ }
327
+ if (agentsMalformed()) {
328
+ return {
329
+ ok: false,
330
+ text:
331
+ 'The usage-limits markers in ' + agentsFile() + ' are not a matched pair.\n' +
332
+ 'Nothing was changed, in either file. Delete that block by hand and run `on` again.',
333
+ };
334
+ }
335
+
336
+ writeHooks(config);
337
+ enableAgents();
338
+ return {
339
+ ok: true,
340
+ text:
341
+ 'Installed, in two places.\n' +
342
+ ' ' + agentsFile() + '\n' +
343
+ ' A marked block telling Codex to check the budget at the start of a piece\n' +
344
+ ' of work. This is the part that works today.\n' +
345
+ ' ' + hooksFile() + '\n' +
346
+ EVENTS.map((one) => ' ' + one.event + ' ' + command(one.script)).join('\n') + '\n' +
347
+ ' Ready for when Codex runs plugin-less hooks; inert on current builds.\n' +
348
+ 'Start a new thread for it to take effect. Run `off` to remove both.',
349
+ };
350
+ }
351
+
352
+ function disable() {
353
+ const { config, existed, broken } = readHooks();
354
+ if (broken) return { ok: false, text: status().text };
355
+
356
+ const removedAgents = disableAgents();
357
+ const hadHooks = existed && EVENTS.some((one) => find(config, one.event));
358
+ if (hadHooks) {
359
+ for (const one of EVENTS) {
360
+ const rest = withoutOurs(config.hooks[one.event]);
361
+ if (rest.length) config.hooks[one.event] = rest;
362
+ else delete config.hooks[one.event];
363
+ }
364
+ writeHooks(config);
365
+ }
366
+
367
+ if (!hadHooks && !removedAgents) {
368
+ return { ok: true, text: 'Nothing to remove: it was not installed.' };
369
+ }
370
+ const done = [];
371
+ if (removedAgents) done.push('the AGENTS.md block from ' + agentsFile());
372
+ if (hadHooks) done.push('the hooks from ' + hooksFile());
373
+ return {
374
+ ok: true,
375
+ text: 'Removed ' + done.join(' and ') + '. Everything else in those files was left alone.',
376
+ };
377
+ }
378
+
379
+ const HELP = `install-codex-hook - make Codex check the budget without being asked
380
+
381
+ node install-codex-hook.js status what is installed, and does it point here
382
+ node install-codex-hook.js on install into AGENTS.md and hooks.json
383
+ node install-codex-hook.js off take both out again
384
+
385
+ Codex will not load hooks from a plugin, and on current builds it does not run
386
+ them from ~/.codex/hooks.json or config.toml either, though the engine is there.
387
+ So this installs two things: a marked block in ~/.codex/AGENTS.md, which is what
388
+ actually works today, and the hooks themselves for when that lands.
389
+
390
+ Claude Code needs none of this. Installing the plugin is enough there.
391
+ `;
392
+
393
+ function main(argv) {
394
+ const command_ = (argv && argv[0]) || 'status';
395
+ if (command_ === '--help' || command_ === '-h' || command_ === 'help') {
396
+ process.stdout.write(HELP);
397
+ return 0;
398
+ }
399
+ if (command_ === 'status') {
400
+ const result = status();
401
+ process.stdout.write(result.text + '\n');
402
+ return result.broken ? 1 : 0;
403
+ }
404
+ if (command_ === 'on' || command_ === 'install') {
405
+ const result = enable();
406
+ process.stdout.write(result.text + '\n');
407
+ return result.ok ? 0 : 1;
408
+ }
409
+ if (command_ === 'off' || command_ === 'uninstall' || command_ === 'remove') {
410
+ const result = disable();
411
+ process.stdout.write(result.text + '\n');
412
+ return result.ok ? 0 : 1;
413
+ }
414
+ process.stderr.write('install-codex-hook: unknown command "' + command_ + '".\n' + HELP);
415
+ return 2;
416
+ }
417
+
418
+ if (require.main === module) {
419
+ process.exitCode = main(process.argv.slice(2));
420
+ }
421
+
422
+ module.exports = {
423
+ EVENT,
424
+ EVENTS,
425
+ TIMEOUT_SECONDS,
426
+ hooksFile,
427
+ briefScript,
428
+ command,
429
+ isOurs,
430
+ withoutOurs,
431
+ find,
432
+ readHooks,
433
+ AGENTS_START,
434
+ AGENTS_END,
435
+ agentsFile,
436
+ agentsBlock,
437
+ readAgents,
438
+ stripAgents,
439
+ agentsInstalled,
440
+ agentsStale,
441
+ enableAgents,
442
+ disableAgents,
443
+ status,
444
+ enable,
445
+ disable,
446
+ main,
447
+ HELP,
448
+ };