claude-usage-limits 1.11.6 → 1.13.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.
@@ -443,7 +443,10 @@ function readingsOf(meter) {
443
443
  for (const entry of SLOTS) {
444
444
  const window = meter[entry.slot];
445
445
  if (!window || typeof window !== 'object') continue;
446
- const percent = Number(window.used_percent);
446
+ // Number(null) is 0, and a null percentage read as "0% used" is worse
447
+ // than no reading at all. Only an actual number counts.
448
+ const raw = window.used_percent;
449
+ const percent = raw === null || raw === undefined || raw === '' ? NaN : Number(raw);
447
450
  if (!Number.isFinite(percent)) continue;
448
451
 
449
452
  const minutes = Number(window.window_minutes);
@@ -482,9 +485,21 @@ function utilizationFrom(meter) {
482
485
  // account has no rolling limit and usage scales with credits. Returning null
483
486
  // here would throw away the plan and the credit balance, which on such an
484
487
  // account are the only figures there are.
488
+ //
489
+ // A slot that IS there but carries no readable percentage is a different
490
+ // thing again: the meter answered without numbers, which happens around a
491
+ // limit hit. Calling that flexible pricing told Codex on 2026-09-07 that its
492
+ // Plus account had no window to run down, minutes after the window ran out.
493
+ const slotsPresent = SLOTS.filter((entry) => meter[entry.slot] && typeof meter[entry.slot] === 'object').length;
494
+ // Consumer plans are metered by windows without exception; only the
495
+ // business-side plans can be on flexible pricing. So on Plus, Pro, Go or
496
+ // Free a meter with no windows in it is a meter that failed to read them.
497
+ const planType = typeof meter.plan_type === 'string' ? meter.plan_type : '';
498
+ const consumer = /^(free|go|plus|pro|prolite)$/i.test(planType);
485
499
  const credits = meter.credits && typeof meter.credits === 'object' ? meter.credits : null;
486
500
  return {
487
- windowless: specs.length === 0,
501
+ windowless: specs.length === 0 && slotsPresent === 0 && !consumer,
502
+ unreadable: specs.length === 0 && (slotsPresent > 0 || consumer),
488
503
  utilization: specs.length ? utilization : null,
489
504
  specs,
490
505
  planType: typeof meter.plan_type === 'string' ? meter.plan_type : null,
@@ -599,31 +614,81 @@ function latestMeter(events) {
599
614
  return null;
600
615
  }
601
616
 
617
+ // The meter is written next to every request, so the newest one is always near
618
+ // the END of a rollout. Only the tail is read.
619
+ //
620
+ // This used to read whole files, and a rollout on this machine reaches 32 MB;
621
+ // twelve of those is a third of a gigabyte pulled through a string on a path
622
+ // that the status line takes every few hundred milliseconds. A megabyte of
623
+ // tail holds hundreds of token_count lines, which is far more than enough.
624
+ const TAIL_BYTES = 1024 * 1024;
625
+
626
+ function readTail(file, bytes) {
627
+ let size = 0;
628
+ try {
629
+ size = fs.statSync(file).size;
630
+ } catch (err) {
631
+ return null;
632
+ }
633
+ const from = Math.max(0, size - bytes);
634
+ let fd;
635
+ try {
636
+ fd = fs.openSync(file, 'r');
637
+ } catch (err) {
638
+ return null;
639
+ }
640
+ try {
641
+ const length = size - from;
642
+ if (length <= 0) return { text: '', partial: false };
643
+ const buffer = Buffer.allocUnsafe(length);
644
+ let read = 0;
645
+ while (read < length) {
646
+ const got = fs.readSync(fd, buffer, read, length - read, from + read);
647
+ if (got <= 0) break;
648
+ read += got;
649
+ }
650
+ return { text: buffer.toString('utf8', 0, read), partial: from > 0 };
651
+ } catch (err) {
652
+ return null;
653
+ } finally {
654
+ try {
655
+ fs.closeSync(fd);
656
+ } catch (err) {
657
+ // Already closed.
658
+ }
659
+ }
660
+ }
661
+
662
+ function meterFromLines(text, partial) {
663
+ const lines = text.split('\n');
664
+ // The first line of a tail is a fragment of whatever it landed in the middle
665
+ // of, so it is never parsed.
666
+ const floor = partial ? 1 : 0;
667
+ for (let index = lines.length - 1; index >= floor; index -= 1) {
668
+ const line = lines[index];
669
+ if (!line || line.indexOf('"token_count"') === -1) continue;
670
+ let parsed;
671
+ try {
672
+ parsed = JSON.parse(line);
673
+ } catch (err) {
674
+ continue;
675
+ }
676
+ const meter = parsed && parsed.payload && parsed.payload.rate_limits;
677
+ const at = Date.parse(parsed && parsed.timestamp);
678
+ if (meter && Number.isFinite(at)) return { meter, at };
679
+ }
680
+ return null;
681
+ }
682
+
602
683
  // Scanning only the newest few rollouts, for the meter alone. `collect` runs on
603
684
  // the status-line path where a full scan would be far too slow.
604
685
  function meterFromDisk() {
605
686
  const files = rolloutFiles(NaN).slice(-12).reverse();
606
687
  for (const entry of files) {
607
- let raw;
608
- try {
609
- raw = fs.readFileSync(entry.file, 'utf8');
610
- } catch (err) {
611
- continue;
612
- }
613
- const lines = raw.split('\n');
614
- for (let index = lines.length - 1; index >= 0; index -= 1) {
615
- const line = lines[index];
616
- if (!line || line.indexOf('"token_count"') === -1) continue;
617
- let parsed;
618
- try {
619
- parsed = JSON.parse(line);
620
- } catch (err) {
621
- continue;
622
- }
623
- const meter = parsed && parsed.payload && parsed.payload.rate_limits;
624
- const at = Date.parse(parsed && parsed.timestamp);
625
- if (meter && Number.isFinite(at)) return { meter, at };
626
- }
688
+ const tail = readTail(entry.file, TAIL_BYTES);
689
+ if (!tail) continue;
690
+ const found = meterFromLines(tail.text, tail.partial);
691
+ if (found) return found;
627
692
  }
628
693
  return null;
629
694
  }
@@ -752,6 +817,8 @@ function collect(now, options) {
752
817
  // what flexible pricing looks like. That is a different thing from having
753
818
  // found nothing to read, and it needs to be said differently.
754
819
  windowless: Boolean(mapped && mapped.windowless),
820
+ // The meter reported window slots but no readable percentage in them.
821
+ unreadable: Boolean(mapped && mapped.unreadable),
755
822
  windowSpecs: mapped ? mapped.specs : null,
756
823
  reachedType: mapped ? mapped.reachedType : null,
757
824
  spendControlReached: Boolean(mapped && mapped.spendControlReached),
@@ -948,6 +1015,9 @@ module.exports = {
948
1015
  planFrom,
949
1016
  latestMeter,
950
1017
  meterFromDisk,
1018
+ readTail,
1019
+ meterFromLines,
1020
+ TAIL_BYTES,
951
1021
  liveFile,
952
1022
  attemptFile,
953
1023
  readLiveMeter,
@@ -44,6 +44,10 @@ const STATUSLINE_MARGIN = 4;
44
44
  const SHORT = { five_hour: 'session', seven_day: 'week', spend_limit: 'spend' };
45
45
  // When even that is too wide.
46
46
  const SHORTER = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' };
47
+ // Codex names its own windows "5h limit" and "Weekly limit"; on one line they
48
+ // are the same two abbreviations Claude's get.
49
+ const SHORT_CODEX = { five_hour: '5h', seven_day: 'week' };
50
+ const SHORTER_CODEX = { five_hour: '5h', seven_day: 'wk' };
47
51
 
48
52
  function configDir() {
49
53
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
@@ -198,7 +202,10 @@ function line(built, options) {
198
202
  : effort.shimmer && built.working
199
203
  ? bars.shimmer(effortName, tick, effort.rgb, effort.shimmer, { mode, reduced })
200
204
  : bars.paint(effortName, effort.rgb, mode);
201
- const head = glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '');
205
+ // The word, in the rainbow, the way Claude Code paints it in the prompt.
206
+ const thinking = built.ultrathink ? ' ' + bars.dim('·', mode) + ' ' + bars.rainbow('ultrathink', tick, { mode, reduced }) : '';
207
+ const head =
208
+ glyph + ' ' + built.modelLabel + (effortText ? ' ' + bars.dim('·', mode) + ' ' + effortText : '') + thinking;
202
209
 
203
210
  const segment = (row, width, shorter) => {
204
211
  const label = shortLabel(row, shorter);
@@ -218,6 +225,14 @@ function line(built, options) {
218
225
  { width: 0, head: false, shorter: true },
219
226
  { width: 0, head: false, shorter: true, gap: ' ' },
220
227
  ];
228
+
229
+ // The Codex tail.
230
+ //
231
+ // It always says "left", and if that does not fit it is not shown at all.
232
+ // Codex reports what remains and Claude reports what is spent, so a bare
233
+ // "85%" sitting beside a bare "15%" would be read as the same kind of
234
+ // number when they run in opposite directions - which is precisely the
235
+ // confusion this row exists to remove.
221
236
  // Another Claude spending the same budget is worth a word on the line.
222
237
  const others =
223
238
  Number.isFinite(built.othersWorking) && built.othersWorking > 0
@@ -234,6 +249,76 @@ function line(built, options) {
234
249
  return text;
235
250
  }
236
251
 
252
+ // The Codex line, drawn UNDERNEATH the Claude one.
253
+ //
254
+ // It began as a tail on the same line, and that was wrong twice over. It read
255
+ // as one more Claude window when it is a different account with a different
256
+ // budget, and being last it was the first thing the width ladder dropped, so on
257
+ // an ordinary terminal it was simply never there. A line of its own is what the
258
+ // panel already does and what was asked for.
259
+ //
260
+ // Every figure says "left", because Codex counts down where Claude counts up.
261
+ function codexLine(built, options) {
262
+ const opts = options || {};
263
+ const columns = Number.isFinite(opts.columns) && opts.columns > 0 ? opts.columns : 80;
264
+ const mode = opts.mode || 'none';
265
+ const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
266
+ const reduced = Boolean(opts.reduced);
267
+ const ascii = Boolean(opts.ascii);
268
+
269
+ const block = built && built.codex;
270
+ if (!block || !Array.isArray(block.rows) || !block.rows.length) return '';
271
+ // Nothing readable at all is silence, not a line of dashes.
272
+ if (!block.rows.some((row) => row.percentLeft !== null)) return '';
273
+
274
+ // The name, not a mark: there is no ChatGPT logo a terminal font can draw.
275
+ const glyph = bars.paint(bars.CODEX_LABEL, bars.THEME.codex, mode);
276
+ const plan = block.plan ? bars.dim(String(block.plan), mode) : '';
277
+
278
+ const segment = (row, width, shorter) => {
279
+ const label = (shorter ? SHORTER_CODEX : SHORT_CODEX)[row.key] || row.key;
280
+ // A window whose reading has rolled over says so rather than vanishing: on
281
+ // a line of its own there is room, and dropping it silently would read as
282
+ // "Codex has one window" when it has two.
283
+ if (row.percentLeft === null) return label + ' ' + bars.dim(row.percentText, mode);
284
+ const text = Math.floor(row.percentLeft) + '% left';
285
+ const painted = row.level === 'fill' ? text : bars.paint(text, bars.levelColour(row.level), mode);
286
+ if (!width) return label + ' ' + painted;
287
+ return (
288
+ label + ' ' + bars.bar(row.percentLeft, width, { mode, level: row.level, ascii, tick, reduced }) + ' ' + painted
289
+ );
290
+ };
291
+
292
+ const attempts = [
293
+ { width: 10, head: true },
294
+ { width: 8, head: true },
295
+ { width: 6, head: true },
296
+ { width: 6, head: false },
297
+ { width: 4, head: false },
298
+ { width: 0, head: false },
299
+ { width: 0, head: false, shorter: true },
300
+ { width: 0, head: false, shorter: true, gap: ' ' },
301
+ // Last of all, only the windows that have a number. "5h rolling" is ten
302
+ // columns saying nothing a figure would not, and in a pane this narrow it
303
+ // is the difference between the line fitting and being clipped.
304
+ { width: 0, head: false, shorter: true, gap: ' ', readable: true },
305
+ ];
306
+
307
+ let text = '';
308
+ for (const attempt of attempts) {
309
+ const gap = attempt.gap || ' ';
310
+ const rows = attempt.readable ? block.rows.filter((row) => row.percentLeft !== null) : block.rows;
311
+ if (!rows.length) continue;
312
+ const body = rows.map((row) => segment(row, attempt.width, attempt.shorter)).join(gap);
313
+ // "Codex · ChatGPT Plus", the way the Claude line separates its model from
314
+ // its effort. Without the dot the two ran together as "Codex ChatGPT Plus".
315
+ const label = attempt.head && plan ? glyph + ' ' + bars.dim('·', mode) + ' ' + plan + gap : glyph + ' ';
316
+ text = label + body;
317
+ if (bars.visibleWidth(text) <= columns) return text;
318
+ }
319
+ return text;
320
+ }
321
+
237
322
  function readStdin() {
238
323
  return new Promise((resolve) => {
239
324
  if (process.stdin.isTTY) return resolve('');
@@ -317,6 +402,12 @@ async function main(argv) {
317
402
  input = null;
318
403
  }
319
404
  if (input && typeof input !== 'object') input = null;
405
+ // Claude Code handing this its own status-line JSON settles which agent is
406
+ // running, and it beats any guess made from the environment. Anyone with
407
+ // CODEX_HOME set for their Codex install was otherwise detected as Codex
408
+ // here and got a blank status line under Claude Code, with nothing to say
409
+ // why - detection is only meant to be the fallback for a hand-run script.
410
+ if (input && (input.session_id || input.model)) usage.setHost(host.CLAUDE);
320
411
 
321
412
  const state = statusline.readState();
322
413
  if (state && state.chain && state.previous && state.previous.type === 'command' && state.previous.command) {
@@ -361,13 +452,48 @@ async function main(argv) {
361
452
  headersAt: slot ? slot.headersAt : null,
362
453
  model: slot ? slot.model : null,
363
454
  modelName: slot ? slot.modelName : null,
364
- effort: slot ? slot.effort : null,
455
+ // Claude Code hands this line the effort outright, so the slot is
456
+ // already current and nothing else need be read. It is only when the
457
+ // slot has none - the very first update of a session, or a build that
458
+ // does not send it - that the transcript is worth a look.
459
+ effort:
460
+ slot && slot.effort
461
+ ? slot.effort
462
+ : view.pickEffort(
463
+ null,
464
+ usage.liveEffort(mine || (slot && slot.sessionId) || null),
465
+ collected.settings ? collected.settings.effortLevel : null
466
+ ),
365
467
  working: own.working || (gapMeansWorking(settings) && isWorking(slot, now)),
366
468
  ultrathink: Boolean(own.ultrathink),
469
+ ultracode: Boolean(own.ultracode) || settings.ultracode === true,
367
470
  settingsModel: collected.settings ? collected.settings.model : null,
368
471
  env,
369
472
  });
370
- const text = line(built, {
473
+ // The other agent's meter, from its rollouts on disk. Required lazily and
474
+ // guarded by a single stat, so a machine without Codex pays nothing, and
475
+ // wrapped because a status line must never fail over an optional row.
476
+ // USAGE_LIMITS_CODEX_ROW=off turns it off.
477
+ built.codex = null;
478
+ if (String(env.USAGE_LIMITS_CODEX_ROW || '').toLowerCase() !== 'off' && host.codexHasSessions()) {
479
+ try {
480
+ const codex = require('./codex.js');
481
+ const other = codex.collect(now);
482
+ const block = view.buildCodex({
483
+ now,
484
+ utilization: other.utilization,
485
+ fetchedAtMs: other.snapshotFetchedAt,
486
+ windowSpecs: other.windowSpecs,
487
+ plan: other.plan,
488
+ windowless: other.windowless,
489
+ });
490
+ if (block.present) built.codex = block;
491
+ } catch (err) {
492
+ // No Codex row, and the Claude line is unaffected.
493
+ }
494
+ }
495
+
496
+ const drawn = {
371
497
  columns: Math.max(20, (Number(env.COLUMNS) || 80) - STATUSLINE_MARGIN),
372
498
  // Claude Code captures the output, so stdout is never a TTY here, and
373
499
  // ANSI is supported all the same.
@@ -376,8 +502,12 @@ async function main(argv) {
376
502
  reduced: motionOff(settings, env),
377
503
  ascii: String(env.USAGE_LIMITS_ASCII || '') === '1',
378
504
  clock: clockFor(settings, env),
379
- });
380
- await out((chained ? chained + '\n' : '') + text + '\n');
505
+ };
506
+ const text = line(built, drawn);
507
+ // Underneath, on its own line. Claude Code draws every line the status
508
+ // line prints, which is how a chained status line already works.
509
+ const other = codexLine(built, drawn);
510
+ await out((chained ? chained + '\n' : '') + text + (other ? '\n' + other : '') + '\n');
381
511
  return 0;
382
512
  } catch (err) {
383
513
  if (chained) await out(chained + '\n');
@@ -401,6 +531,7 @@ module.exports = {
401
531
  gapMeansWorking,
402
532
  ownState,
403
533
  line,
534
+ codexLine,
404
535
  runPrevious,
405
536
  clockFor,
406
537
  motionOff,
@@ -10,18 +10,27 @@
10
10
  // - Codex has the whole hook engine. The binary carries UserPromptSubmit,
11
11
  // SessionStart, PreToolUse and the rest, and `codex features list` reports
12
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.
13
+ // - A plugin cannot ship one. `plugin_hooks` is reported as `removed`, so a
14
+ // `hooks` field in .codex-plugin/plugin.json is accepted and ignored.
15
+ // - On codex-cli 0.151.0-alpha.7.2 nothing fired hooks from anywhere. On
16
+ // 0.153.4 the user-level ~/.codex/hooks.json does run them, but only after
17
+ // a one-time review: the terminal UI opens with "Hooks need review - hooks
18
+ // can run outside the sandbox after you trust them", and the choices are
19
+ // "Trust all and continue" or "Continue without trusting (hooks won't
20
+ // run)". The trust is persisted as a hash of the hooks, so a hook this
21
+ // script rewrites has to be trusted again. The desktop app never shows
22
+ // that review, which is why a machine using only the app can have the
23
+ // hooks installed for weeks and never once run them.
18
24
  //
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
+ // So the hooks are written, and `status` says whether anything has ever run
26
+ // them. But the trust review is the user's to accept, deliberately - this
27
+ // script does not forge a trust hash to get round a safety prompt - and until
28
+ // it is accepted the hooks are inert. AGENTS.md is what works regardless:
29
+ // Codex reads it at the top of every session in scope, which is the one
30
+ // always-on instruction channel. It cannot carry live numbers the way a hook
31
+ // can, so instead it tells Codex to go and read them at the start of a piece
32
+ // of work - and, since the effort setting is what actually empties a Codex
33
+ // window, to look at that.
25
34
  //
26
35
  // Both halves are marked and reversible, and neither touches anything else in
27
36
  // the files it edits.
@@ -40,6 +49,10 @@ const host = require('./host.js');
40
49
  const EVENTS = [
41
50
  { event: 'UserPromptSubmit', script: 'brief.js', status: 'Checking usage limits' },
42
51
  { event: 'PostToolUse', script: 'pulse.js', status: 'Checking usage limits' },
52
+ // Codex has subagents too, and a turn that hands its work to them makes no
53
+ // tool calls of its own for as long as they run. Same quiet refresh as on
54
+ // Claude Code; pulse.js sees the event name and says nothing.
55
+ { event: 'SubagentStop', script: 'pulse.js', status: 'Checking usage limits' },
43
56
  ];
44
57
  const EVENT = EVENTS[0].event;
45
58
  // Ten seconds is the same budget the Claude hook gets. The brief caches the
@@ -50,6 +63,23 @@ function hooksFile() {
50
63
  return path.join(host.codexHome(), 'hooks.json');
51
64
  }
52
65
 
66
+ // Whether Codex has ever actually run one of these hooks. Each writes a small
67
+ // state file beside the rollouts the first time it fires, so the hooks being
68
+ // installed with none of those present means the trust review has never been
69
+ // accepted - which is the state a machine using only the desktop app sits in
70
+ // indefinitely, with no error anywhere to say so.
71
+ function everRan() {
72
+ const dir = host.codexHome();
73
+ return ['usage-limits-pulse.json', 'usage-limits-brief.json'].some((name) =>
74
+ host.exists(path.join(dir, name))
75
+ );
76
+ }
77
+
78
+ const TRUST_STEP =
79
+ 'Codex runs hooks from this file only after a one-time review. Start `codex` in a\n' +
80
+ ' terminal once and choose "Trust all and continue"; the desktop app never shows\n' +
81
+ ' that review. Rewriting the hooks changes their hash, so `on` means reviewing again.';
82
+
53
83
  function briefScript(name) {
54
84
  return path.join(__dirname, name || 'brief.js');
55
85
  }
@@ -120,8 +150,7 @@ function agentsBlock() {
120
150
  AGENTS_START,
121
151
  '## Usage limits',
122
152
  '',
123
- 'Before starting any substantial piece of work, and again if it grows or starts',
124
- 'looping, run:',
153
+ 'Before starting any substantial piece of work, run:',
125
154
  '',
126
155
  ' node ' + quote(briefScript('usage.js')) + ' --host codex',
127
156
  '',
@@ -130,10 +159,28 @@ function agentsBlock() {
130
159
  'it does not fit, say what you will do now and what you will leave for after the',
131
160
  'reset, rather than starting and being cut off partway.',
132
161
  '',
162
+ 'The number is an estimate that shrinks as the work runs, so run it again after',
163
+ 'every ten or so tool-heavy turns, after any step that took minutes, and before',
164
+ 'the last long step of a job. When the turns left are fewer than the steps still',
165
+ 'ahead, or under fifteen, stop at the next clean boundary: save the work, write',
166
+ 'what is done and what is next to WORK-PLAN.md in the working directory, commit',
167
+ 'if there is a repository, and say so. Never push on into the limit: a job cut',
168
+ 'off partway loses the handoff as well as the work.',
169
+ '',
170
+ 'Below ten percent left, or a few turns, the check itself was the last cheap',
171
+ 'thing: start nothing new, write the handoff in this same turn, and end the',
172
+ 'turn. On 8 September a session read the meter at five percent, carried on,',
173
+ 'and lost the rest of the window inside that turn.',
174
+ '',
133
175
  'This is about the order of the work, not the amount. Do not economise, shorten',
134
176
  '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.',
177
+ 'reset, not carried over.',
178
+ '',
179
+ 'The reasoning effort in config.toml is what actually empties a window: one',
180
+ 'ordinary task at ultra effort on a Plus plan can take a whole five-hour window.',
181
+ 'The report measures what each effort costs here and prices the window at the',
182
+ 'one set now; if it says the window holds only a few turns at this effort, say',
183
+ 'so, and use a lower effort for work that does not need the thinking.',
137
184
  AGENTS_END,
138
185
  ].join('\n');
139
186
  }
@@ -283,10 +330,14 @@ function status() {
283
330
  (agentsStale() ? '\n It points at another copy of the plugin, so run `on`.' : '')
284
331
  : 'AGENTS.md block missing from ' + agentsFile() + '. Run `on`.'
285
332
  );
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
- );
333
+ if (!missing.length) {
334
+ lines.push(
335
+ everRan()
336
+ ? 'Codex has run these hooks: their state files are beside its rollouts.'
337
+ : 'Codex has never run these hooks. ' + TRUST_STEP + '\n' +
338
+ ' Until then the AGENTS.md block is what makes this work.'
339
+ );
340
+ }
290
341
 
291
342
  return {
292
343
  installed: !missing.length && agents,
@@ -344,7 +395,7 @@ function enable() {
344
395
  ' of work. This is the part that works today.\n' +
345
396
  ' ' + hooksFile() + '\n' +
346
397
  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' +
398
+ ' ' + TRUST_STEP.replace(/\n /g, '\n ') + '\n' +
348
399
  'Start a new thread for it to take effect. Run `off` to remove both.',
349
400
  };
350
401
  }
@@ -61,12 +61,17 @@ 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
+ else if (arg === '--host') args.host = argv[++i];
65
+ else if (arg.startsWith('--host=')) args.host = arg.slice(7);
64
66
  else if (arg === '--effort') args.effort = argv[++i];
65
67
  else if (arg === '--model') args.model = argv[++i];
66
68
  else if (arg.startsWith('--effort=')) args.effort = arg.slice('--effort='.length);
67
69
  else if (arg.startsWith('--model=')) args.model = arg.slice('--model='.length);
68
- else if (!args.command) args.command = arg;
70
+ else if (!args.command && !arg.startsWith('-')) args.command = arg;
71
+ else throw new Error('Unknown argument: ' + arg);
72
+ if (['--host', '--effort', '--model'].includes(arg) && (!argv[i] || argv[i].startsWith('--'))) throw new Error('Missing value for ' + arg);
69
73
  }
74
+ for (const key of ['host', 'effort', 'model']) if (Object.hasOwn(args, key) && args[key] === '') throw new Error('Missing value for --' + key);
70
75
  if (!args.command) args.command = 'status';
71
76
  return args;
72
77
  }
@@ -166,6 +171,9 @@ function describe(settings, state) {
166
171
 
167
172
  function main(argv) {
168
173
  const args = parseArgs(argv);
174
+ const host = require('./host.js');
175
+ if (args.host && !['codex', 'claude'].includes(args.host)) throw new Error('Expected --host codex or --host claude');
176
+ if ((args.host || host.detect(argv)) === host.CODEX) return require('./codex-lowpower.js').main(args);
169
177
  const file = settingsFile();
170
178
  const settings = readJson(file) || {};
171
179
  const state = readJson(stateFile());
@@ -214,7 +222,7 @@ function main(argv) {
214
222
  return 0;
215
223
  }
216
224
 
217
- process.stderr.write('usage: lowpower.js [status|on|off] [--effort level] [--model name] [--dry-run]\n');
225
+ process.stderr.write('usage: lowpower.js [status|on|off] [--host claude|codex] [--effort level] [--model name] [--dry-run]\n');
218
226
  return 1;
219
227
  }
220
228