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.
@@ -21,6 +21,7 @@ const live = require('./live.js');
21
21
  const relay = require('./relay.js');
22
22
  const reading = require('./reading.js');
23
23
  const voice = require('./voice.js');
24
+ const mode = require('./mode.js');
24
25
 
25
26
  const SECOND = 1000;
26
27
  const DAY = 24 * 60 * 60 * 1000;
@@ -73,6 +74,12 @@ const SCAN_BUDGET_MS = 5000;
73
74
  // registration is about a second and the hook is allowed ten.
74
75
  const ARM_DEADLINE_MS = 5000;
75
76
 
77
+ // How old the account snapshot may be before the figure built on it is called
78
+ // a floor rather than a reading. The refresh cadence is three minutes, so a
79
+ // snapshot this old means several refreshes in a row failed or were throttled,
80
+ // and by then the correction is carrying the number rather than adjusting it.
81
+ const SNAPSHOT_TRUST_MS = 15 * 60 * 1000;
82
+
76
83
  // Past this much of a per-model week, say how to free it. Below it the advice
77
84
  // is noise: there is room, and the model in use is the right one.
78
85
  const HALF_SPENT = 50;
@@ -423,7 +430,68 @@ function summariseOthers(windows, bindingKey) {
423
430
  .join(', ');
424
431
  }
425
432
 
426
- function briefText(parts) {
433
+ // The user's bounds, applied at the rendering boundary rather than at each of
434
+ // the half-dozen places a cheaper tier can be suggested.
435
+ //
436
+ // The invariant is about what reaches the reader: with a floor of opus/high
437
+ // set, NOTHING the plugin says may point below opus/high. Filtering here means
438
+ // a new suggestion path cannot quietly bypass the bound by being added
439
+ // somewhere else, which is exactly how a rule like this rots.
440
+ function applyBounds(parts, bounds) {
441
+ if (!bounds || (!bounds.floor && !bounds.ceiling && !bounds.pin)) return parts;
442
+ const next = Object.assign({}, parts);
443
+ const ok = (suggestion) => mode.allows(bounds, suggestion);
444
+ if (next.escape) {
445
+ const target =
446
+ next.escape.kind === 'model' ? { model: next.escape.suggest } : { effort: next.escape.to };
447
+ if (!ok(target)) next.escape = null;
448
+ // Pinned means the plugin observes and keeps its hands off. The route is
449
+ // still true and still worth knowing; what changes is that it is reported
450
+ // rather than instructed.
451
+ else if (bounds.pin) next.escape = Object.assign({}, next.escape, { report: true });
452
+ // A model escape with no named target passes allows() by construction:
453
+ // an unranked name is not evidence of a breach, and a NULL name has no
454
+ // rank at all. That is the right call for a spelling nobody recognises and
455
+ // the wrong one here, because with a model floor set, "switch to another
456
+ // model" is an instruction to go somewhere that may be below it. The route
457
+ // is still true, so it is reported rather than instructed.
458
+ else if (next.escape.kind === 'model' && !next.escape.suggest && bounds.floor && bounds.floor.model) {
459
+ next.escape = Object.assign({}, next.escape, { report: true });
460
+ }
461
+ }
462
+ if (next.fit && !ok({ effort: next.fit.cheaper })) next.fit = null;
463
+ if (next.effortWarning && next.effortWarning.cheaper && !ok({ effort: next.effortWarning.cheaper.effort })) {
464
+ next.effortWarning = Object.assign({}, next.effortWarning, { cheaper: null });
465
+ }
466
+ // The per-model weekly sentence is a fourth suggestion path, and it was the
467
+ // one this function did not touch. With `--floor opus --pin` set the brief
468
+ // stated both bounds and then, in the next clause, told the agent to switch
469
+ // to another model "for work that does not need opus" - below the floor, in
470
+ // a session that had pinned self-switching off, naming the settings.json
471
+ // writer to do it with. The window being scoped to one model is a FACT and
472
+ // stays; the instruction half is what the bounds govern.
473
+ if (next.family) {
474
+ const floorModel = bounds.floor && bounds.floor.model ? mode.modelRank(bounds.floor.model) : null;
475
+ const here = next.familyKey ? mode.modelRank(next.familyKey) : null;
476
+ // A floor at or above the family this window counts leaves no cheaper
477
+ // model to point at, so the only honest form is the report.
478
+ const noRoomBelow = floorModel !== null && here !== null && floorModel >= here;
479
+ next.familySwitch = !bounds.pin && !noRoomBelow;
480
+ }
481
+ return next;
482
+ }
483
+
484
+ function briefText(input) {
485
+ // The mode decides how much of this is said, and `off` decides that none of
486
+ // it is - at any percentage, at any pressure. That is the whole promise of
487
+ // that mode and it is honoured here, before a single sentence is built.
488
+ const policy = (input.mode && input.mode.policy) || null;
489
+ const style = policy ? policy.briefStyle : 'normal';
490
+ if (style === 'none') return '';
491
+ const bounds = (input.mode && input.mode.bounds) || null;
492
+ const pinned = Boolean(bounds && bounds.pin);
493
+ const parts = applyBounds(input, bounds);
494
+
427
495
  // The turns and the reset time belong to one specific window. Listing every
428
496
  // window and then the numbers invites reading them against the wrong one, so
429
497
  // the binding window is named and its figures are attached to it.
@@ -456,11 +524,27 @@ function briefText(parts) {
456
524
  if (parts.resetsIn) bound.push('resets in ' + parts.resetsIn);
457
525
 
458
526
  const sentences = [];
527
+ // The mode token, only when the mode is not the one the plugin has always
528
+ // been in. In `standard` the line is what it has always been, down to the
529
+ // first character; anywhere else the reader is owed the reason the line
530
+ // looks different from the one they are used to.
531
+ const token = parts.mode && parts.mode.name !== 'standard' ? '(' + (parts.mode.label || parts.mode.name) + ') ' : '';
459
532
  sentences.push(
460
533
  bound.length
461
- ? '[usage-limits] binding window is ' + bound.join(', ') + '.'
462
- : '[usage-limits] no usable window reading.'
534
+ ? '[usage-limits] ' + token + 'binding window is ' + bound.join(', ') + '.'
535
+ : '[usage-limits] ' + token + 'no usable window reading.'
463
536
  );
537
+ // What tier is producing this turn.
538
+ //
539
+ // The line used to report the window, the turns, the session cost and the
540
+ // context - everything about how much is being spent, and nothing about what
541
+ // is doing the spending. The number that decides the cost of a turn was the
542
+ // one number the line never printed. It says where the reading came from as
543
+ // well as what it is, because the source is the whole point: settings.json
544
+ // said xhigh for an entire session that was running something else.
545
+ if (parts.tier) sentences.push(parts.tier);
546
+ const bounded = mode.boundsNote(bounds);
547
+ if (bounded) sentences.push(bounded);
464
548
  if (parts.planChanged) {
465
549
  sentences.push(
466
550
  'The plan has changed since these figures were learned, so the reading ' +
@@ -496,6 +580,29 @@ function briefText(parts) {
496
580
  ' have been spent since, more than it said was left. Either the window is ' +
497
581
  'already exhausted or the snapshot is wrong; /usage refreshes it.'
498
582
  );
583
+ } else if (parts.snapshotStale) {
584
+ // The other way the figure goes wrong, and the one that actually happened.
585
+ //
586
+ // correctionUnreliable only fires when the measured spend EXCEEDS what the
587
+ // snapshot said was left, so a snapshot taken at the very start of a window
588
+ // can never trip it: everything spent since is still inside the remainder.
589
+ // On 2026-09-13 that combination reported 7 per cent for most of a session
590
+ // in which the account was at 41 - Claude Code's own cache had not moved in
591
+ // 50 minutes, the plugin's live reading was rate-limited into backoff, and
592
+ // the correction was quietly carrying the entire difference on its own.
593
+ //
594
+ // A correction is a measurement of local transcripts priced by a learned
595
+ // rate. It is a good adjustment to a recent snapshot and a bad substitute
596
+ // for an old one, because the pricing error compounds with every point it
597
+ // has to bridge. So an old snapshot makes the figure a floor, whether or
598
+ // not it has overrun anything, and that is said rather than assumed.
599
+ sentences.push(
600
+ 'Treat that percentage as a floor rather than a reading: the account snapshot ' +
601
+ 'behind it is ' + parts.snapshotAge + ' old, so most of the figure is measured ' +
602
+ 'from local history at a learned price rather than read from the account, and ' +
603
+ 'that gap widens the longer the snapshot stands. Run /usage to refresh it before ' +
604
+ 'making a decision that depends on the exact number.'
605
+ );
499
606
  }
500
607
  // Work having actually been stopped is the most useful thing that can be said
501
608
  // about a budget, and the percentages stop showing it the moment the window
@@ -572,10 +679,18 @@ function briefText(parts) {
572
679
  // every prompt.
573
680
  const namesTheSwitch = parts.escape && parts.escape.kind === 'model';
574
681
  if (parts.family && !namesTheSwitch) {
575
- sentences.push(
682
+ // The fact first, because it is true under every bound: nothing done more
683
+ // cheaply on this model frees a window that counts only this model.
684
+ const fact =
576
685
  'That window counts ' + parts.family + ' turns only, so lowering effort does not free it: ' +
577
- 'switching model does. Use /model (or scripts/lowpower.js on --model <other>) for work ' +
578
- 'that does not need ' + parts.family + ', and keep this one for what does.'
686
+ 'switching model does.';
687
+ sentences.push(
688
+ parts.familySwitch === false
689
+ ? fact + ' The bounds above rule that switch out, so this is a report: leave the model ' +
690
+ 'where it is and say in one line that the window is scoped to ' + parts.family + '.'
691
+ : fact + ' Running work that does not need ' + parts.family + ' on a cheaper model is the ' +
692
+ 'lever, and it is the user\'s own setting to change - say so in one line rather than ' +
693
+ 'changing it. What IS yours is the model on anything you spawn: size that to the stage.'
579
694
  );
580
695
  }
581
696
  if (parts.othersSummary) sentences.push('Other windows: ' + parts.othersSummary + '.');
@@ -584,6 +699,15 @@ function briefText(parts) {
584
699
  // difference is a command. Said as soon as the window is half gone, so it is
585
700
  // already known by the time it matters.
586
701
  const escape = parts.escape;
702
+ // Pinned is the pure form of "this is the user wanting to decide for
703
+ // themselves": the plugin observes, reports the gap, and keeps its hands
704
+ // off. The route is unchanged - it is still true, and hiding it would be
705
+ // withholding a fact - but every sentence built from it becomes a report
706
+ // rather than an instruction.
707
+ const lever = (command) =>
708
+ pinned
709
+ ? 'The lever is ' + command + ', and it is yours to take or leave: self-switching is pinned, so this is a report and not a switch.'
710
+ : 'Use ' + command + '.';
587
711
  const escapeText =
588
712
  escape && escape.kind === 'model'
589
713
  ? 'This window is scoped to one model, so it is not the account\'s budget: switching model retires it. ' +
@@ -593,25 +717,43 @@ function briefText(parts) {
593
717
  // No command rather than a wrong one: the vocabulary differs by host,
594
718
  // and "Use undefined" is worse than saying which lever it is and
595
719
  // leaving the reader to reach for it.
596
- (escape.command ? 'Use ' + escape.command + '.' : '')
720
+ (escape.command ? lever(escape.command) : '')
597
721
  : escape && escape.kind === 'effort'
598
722
  ? 'A model switch does not free this window - it follows the account - but effort does: ' +
599
723
  escape.to + ' measured ' + (escape.multiple ? escape.multiple + 'x ' : '') + 'cheaper a turn than ' +
600
- (escape.from || 'the current effort') + '.' + (escape.command ? ' Use ' + escape.command + '.' : '')
724
+ (escape.from || 'the current effort') + '.' + (escape.command ? ' ' + lever(escape.command) : '')
601
725
  : null;
602
726
  // Not only an emergency exit. The same lever is the right one whenever the
603
727
  // setting is dearer than the work in front of you needs - a mechanical edit
604
728
  // does not need the model a hard design decision does. Say so, because an
605
729
  // agent that only ever reads this as a wall notice will run every trivial
606
730
  // turn at the top setting and then wonder where the window went.
607
- const chooseText = escapeText
608
- ? ' You may make that change yourself, at any point and without being asked, ' +
609
- 'whenever the current setting is dearer than the work needs rather than only ' +
610
- 'when the window is nearly gone. Say in one line that you changed it and why.'
731
+ //
732
+ // What it must NOT say is that the agent may take it unasked. The commands
733
+ // above are user-plane: /model and /effort are typed by a person, and
734
+ // lowpower.js writes settings.json. This is the one channel that actually
735
+ // reaches the model, so a sentence here saying "you may make that change
736
+ // yourself, without being asked" was the plugin's own doctrine - "Claude may
737
+ // never make one unasked" - broken in the place it does the most damage.
738
+ // Naming it early and clearly is still right; making it is still the user's.
739
+ const chooseText = escapeText && !pinned
740
+ ? ' Raise it whenever the current setting is dearer than the work needs, not ' +
741
+ 'only when the window is nearly gone: say in one line that it is worth ' +
742
+ 'changing and why, and leave the change itself to the user, whose setting ' +
743
+ 'it is. What is yours without asking is the tier of what you SPAWN - the ' +
744
+ 'model on an Agent call, the model and effort inside a Workflow - so size ' +
745
+ 'that to the stage.'
611
746
  : '';
612
- if (escapeText && parts.binding && Number.isFinite(parts.binding.percentUsed) && parts.binding.percentUsed >= HALF_SPENT) {
613
- sentences.push(escapeText + chooseText);
614
- }
747
+ // Kept in a variable as well as pushed: the terse style drops the sentences
748
+ // that read the same every turn, and this is not one of them. Being at the
749
+ // wall and being out of budget are different things, and the difference is a
750
+ // command - dropping THAT to save characters would be the mode buying its
751
+ // saving out of the one fact that changes what happens next.
752
+ const escapeSentence =
753
+ escapeText && parts.binding && Number.isFinite(parts.binding.percentUsed) && parts.binding.percentUsed >= HALF_SPENT
754
+ ? escapeText + chooseText
755
+ : null;
756
+ if (escapeSentence) sentences.push(escapeSentence);
615
757
 
616
758
  // A window that is not binding can still be the expensive one to exhaust.
617
759
  if (parts.critical && parts.critical.length) {
@@ -680,10 +822,11 @@ function briefText(parts) {
680
822
  parts.fit.sample + ' turns against ' + parts.fit.cheaperSample + '). Judge what is ' +
681
823
  'actually in front of you before the next stretch: mechanical work - a rename, a docs ' +
682
824
  'pass, running tests, applying a fix you have already worked out - does not need it. ' +
683
- 'If this stretch is that, take one of the three levers yourself and say which in one ' +
684
- 'line: drop the effort (' + parts.fit.command + '), hand the stretch to a cheaper ' +
685
- 'model, or do less of it at this setting - a fan-out multiplies the setting across ' +
686
- 'every agent. Put it back when the work gets hard again.'
825
+ 'If this stretch is that, say so in one line and name the lever. Dropping your own ' +
826
+ 'effort (' + parts.fit.command + ') is the user\'s setting to change, so offer it ' +
827
+ 'rather than making it; handing the stretch to a cheaper model and doing less of it at ' +
828
+ 'this setting are yours - a fan-out multiplies the setting across every agent. Put it ' +
829
+ 'back when the work gets hard again.'
687
830
  );
688
831
  }
689
832
 
@@ -698,9 +841,14 @@ function briefText(parts) {
698
841
  // saying plainly, because a session told only "you are about to be cut off"
699
842
  // spends its last turns hedging.
700
843
  const carry = parts.relay;
844
+ // Kept as well as pushed, for the same reason as the escape: a relay changes
845
+ // what being cut off COSTS, which is the difference between winding down and
846
+ // carrying on. The terse style drops what reads the same every turn; this is
847
+ // not that.
848
+ const relaySentences = [];
701
849
  if (carry && carry.armed) {
702
850
  const wake = new Date(carry.armed.wakeAt);
703
- sentences.push(
851
+ relaySentences.push(
704
852
  'A relay is armed: ' + (carry.justArmed ? 'booked just now' : 'booked') + ' for ' +
705
853
  wake.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ', ' +
706
854
  carry.config.graceMinutes + ' minutes after this window resets, and it will ' +
@@ -708,7 +856,7 @@ function briefText(parts) {
708
856
  '.' + (carry.armed.warning ? ' Caveat: ' + carry.armed.warning + '.' : '')
709
857
  );
710
858
  if (!carry.armed.continuation) {
711
- sentences.push(
859
+ relaySentences.push(
712
860
  'Nothing has been written for it yet. Before this session ends, run ' +
713
861
  'node "$CLAUDE_PLUGIN_ROOT/skills/usage-limits/scripts/relay.js" note "<what you would tell yourself>" ' +
714
862
  'with what is done, what is next in order, which files are mid-change and what must be verified first. ' +
@@ -717,7 +865,7 @@ function briefText(parts) {
717
865
  }
718
866
  }
719
867
  if (carry && carry.last) {
720
- sentences.push(
868
+ relaySentences.push(
721
869
  'The last relay ' +
722
870
  (carry.last.outcome === 'resumed'
723
871
  ? 'picked this work back up automatically after the previous reset (' + (carry.last.detail || 'resumed') + '); check what it did before repeating it'
@@ -728,6 +876,8 @@ function briefText(parts) {
728
876
  );
729
877
  }
730
878
 
879
+ for (const line of relaySentences) sentences.push(line);
880
+
731
881
  // Three states, and only the last one stops anything.
732
882
  //
733
883
  // The middle one is the one that keeps being got wrong. Near the wall the
@@ -752,10 +902,17 @@ function briefText(parts) {
752
902
  escapeText &&
753
903
  (parts.pressure === 'tight' || (parts.pressure === 'gone' && escape && escape.kind === 'model'))
754
904
  ? 'This window is nearly gone, but you are not out of budget and you must ' +
755
- 'not stop as though you were. ' + escapeText + ' Do that, say in one ' +
756
- 'line that you switched and why, and carry on with the whole request at ' +
757
- 'full quality. Only if the switch is impossible or the user has ruled it ' +
758
- 'out: save the work, write the handoff, and say what is left.'
905
+ 'not stop as though you were. ' + escapeText +
906
+ (pinned
907
+ ? ' Do not take it yourself - self-switching is pinned. Say in one line ' +
908
+ 'that the lever is there, and carry on with the whole request at full ' +
909
+ 'quality until the window actually ends; when it does, save the work, ' +
910
+ 'write the handoff, and say what is left.'
911
+ : ' Say in one line that the lever is there and what it would ' +
912
+ 'free - it is the user\'s own setting, so it is theirs to pull - and ' +
913
+ 'carry on with the whole request at full quality meanwhile. Only if the ' +
914
+ 'switch is refused or impossible: save the work, write the handoff, and ' +
915
+ 'say what is left.')
759
916
  : parts.pressure === 'gone'
760
917
  ? 'The budget is gone, so nothing further will run. Save what exists and ' +
761
918
  'write the handoff: what is finished, what is next and in what order, ' +
@@ -806,7 +963,67 @@ function briefText(parts) {
806
963
  'with one plain line giving the session total above (turns, tokens and cost). ' +
807
964
  'Skip it on partial progress; the hook prints the exact figure after you stop.';
808
965
 
809
- return sentences.join(' ') + '\n' + instruction + closing + care;
966
+ // What the mode adds, and what it takes away.
967
+ //
968
+ // The directive is the half of a mode that changes what the agent does. It
969
+ // is injected verbatim, last, so it is the freshest thing in the line.
970
+ //
971
+ // It is dropped in two places. When the line is telling the agent to stop,
972
+ // there is no budget left for it to govern, and a token-economy lecture at
973
+ // 100 per cent is pure cost. And in the terse style once the wall is reached,
974
+ // because the wall's own instruction is more specific than the directive and
975
+ // says the same thing better - repeating both at 95 per cent would spend
976
+ // exactly what the mode is asking to save.
977
+ const stopping = parts.pressure === 'gone' && !(escape && escape.kind === 'model');
978
+ // The wall, not "anything that is not roomy". pressure() returns roomy,
979
+ // tight, gone and unknown, so `!== 'roomy'` dropped the directive at `tight`
980
+ // - which is a normal working state, not the wall - and at `unknown`, which
981
+ // is a stale window or a missing percentage and is not the wall either. Both
982
+ // matter because autoPick resolves to `max` exactly at tight and above, so
983
+ // the mode's behavioural half was dropped precisely where auto selects it.
984
+ // At `gone` the wall's own instruction is more specific and `stopping` has
985
+ // usually dropped it already; this is what keeps the two agreeing.
986
+ const pastTheWall = style === 'terse' && parts.pressure === 'gone';
987
+ const directive =
988
+ parts.mode && parts.mode.directive && !stopping && !pastTheWall ? ' ' + parts.mode.directive : '';
989
+
990
+ // Terse is one line plus the decision: the binding window, the tier, what to
991
+ // do about it, and the directive. It drops the table, the per-model rows, the
992
+ // session totals and the two standing reminders - the parts that read the
993
+ // same every turn - and keeps every part that changes what happens next.
994
+ //
995
+ // What it must NOT drop is the decision itself. At the wall the instruction
996
+ // is identical in both styles, word for word: a mode that says "budget gone"
997
+ // less clearly to save forty characters has bought its saving out of the one
998
+ // sentence that matters.
999
+ if (style === 'terse') {
1000
+ // The number's own health, in one clause. Terseness may cost the table; it
1001
+ // may not cost the reader the knowledge that the figure is a floor.
1002
+ const caveat =
1003
+ parts.correctionUnreliable || (parts.binding && parts.binding.stale)
1004
+ ? ' Last real reading, not a current one; /usage refreshes it.'
1005
+ : '';
1006
+ // A percentage measured against a different allowance is not a shorter
1007
+ // truth, it is a different number. Terseness may cost the table; it may
1008
+ // not cost the reader the knowledge that the figure predates a plan
1009
+ // change - that would be the mode buying its saving out of the number
1010
+ // itself, which is the one thing the header forbids.
1011
+ const planCaveat = parts.planChanged
1012
+ ? ' Measured against a different allowance: the plan has changed since, so /usage before trusting it.'
1013
+ : '';
1014
+ // The one recommendation this session is allowed, in its short form. It
1015
+ // still cites the measurement, still names the command: terse is fewer
1016
+ // words, not less evidence.
1017
+ const adviceText = parts.adviceText ? ' ' + parts.adviceText : '';
1018
+ return (
1019
+ sentences[0] + caveat + (parts.tier ? ' ' + parts.tier : '') + (bounded ? ' ' + bounded : '') + adviceText +
1020
+ (escapeSentence ? ' ' + escapeSentence : '') +
1021
+ (parts.pressure !== 'roomy' && relaySentences.length ? ' ' + relaySentences.join(' ') : '') +
1022
+ '\n' + instruction + directive
1023
+ );
1024
+ }
1025
+
1026
+ return sentences.join(' ') + '\n' + instruction + closing + care + directive;
810
1027
  }
811
1028
 
812
1029
  // Past this the context is the cost of the session, not a detail of it.
@@ -923,15 +1140,80 @@ function relayState(now, hookInput, binding, sessionId) {
923
1140
  }
924
1141
  }
925
1142
 
1143
+ // The one line `off` will ever say, and only when it has been asked for.
1144
+ //
1145
+ // `off` means off, including at 100 per cent: that is what was asked and it is
1146
+ // honoured literally. A silent cutoff at the wall is also the exact failure
1147
+ // this plugin exists to prevent, and it has already cost real work more than
1148
+ // once, so `mode off --guard 95` stores a percentage at which one short line
1149
+ // is still allowed. The default stays null - discoverable, not imposed.
1150
+ //
1151
+ // It never scans. The snapshot, plus whatever correction an earlier turn has
1152
+ // already paid for, and nothing else: a mode whose promise is that the plugin
1153
+ // costs nothing cannot buy its one line with a five-second transcript scan.
1154
+ //
1155
+ // Three things it must get right, because it is the ONLY line this mode will
1156
+ // ever emit and there is nothing else to correct it:
1157
+ //
1158
+ // The windows come from snapshotWindows(), not from `utilization.limits`
1159
+ // alone. That array is optional - every fixture in this repo and every other
1160
+ // reader here works from the top-level five_hour/seven_day keys - so a guard
1161
+ // wired to it was silent at 97 per cent used on the payload shape everything
1162
+ // else treats as primary.
1163
+ //
1164
+ // A per-model weekly for a model this session is not running cannot be the
1165
+ // window that stops the work, so it cannot be the thing that fires the
1166
+ // guard either. Without the applies filter it fired at 99 per cent on an
1167
+ // Opus weekly for a user with 80 per cent of their real budget left - the
1168
+ // exact false alarm bindingWindow() exists to prevent, in the one mode with
1169
+ // no second line to take it back.
1170
+ //
1171
+ // And it says "5-hour", not "five_hour". A raw key in the one sentence the
1172
+ // user gets is the plugin talking to itself.
1173
+ function guardLine(now, budget) {
1174
+ if (!Number.isFinite(budget.guardPercent)) return '';
1175
+ try {
1176
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
1177
+ const base = usage.collect(now);
1178
+ if (!base.utilization) return '';
1179
+ const codexHome = usage.isCodex() ? require('./codex.js').homeDir() : null;
1180
+ let worst = null;
1181
+ for (const window of usage.snapshotWindows(base, now, codexHome)) {
1182
+ if (window.applies === false || window.stale) continue;
1183
+ const percent = window.percentUsed;
1184
+ if (!Number.isFinite(percent)) continue;
1185
+ if (!worst || percent > worst.percent) worst = { percent, label: window.label || window.key };
1186
+ }
1187
+ if (!worst || worst.percent < budget.guardPercent) return '';
1188
+ return (
1189
+ '[usage-limits] (off, guard at ' + budget.guardPercent + '%) ' + worst.label + ' is ' +
1190
+ Math.round(worst.percent) + '% used. Mode is off, so this is the only line you get.'
1191
+ );
1192
+ } catch (err) {
1193
+ // A guard that throws would be worse than a guard that is quiet.
1194
+ return '';
1195
+ }
1196
+ }
1197
+
926
1198
  async function run(now, hookInput) {
927
1199
  if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
928
1200
 
929
- // Codex cannot ship a hook inside a plugin, so its hook is installed into
930
- // ~/.codex/hooks.json with the host written into the command. Settle it here,
931
- // before any file is read.
932
- usage.setHost(host.detect(process.argv.slice(2), process.env));
933
-
934
- const sessionId = hookInput && hookInput.session_id ? hookInput.session_id : null;
1201
+ const sessionId = hookInput && (hookInput.session_id || hookInput.conversationId) ? (hookInput.session_id || hookInput.conversationId) : null;
1202
+ // Which budget mode is in force, settled before anything expensive happens.
1203
+ // In `off` this whole hook is one small file read and then nothing: no
1204
+ // reading, no scan, no activity mark, no injection. That mode's promise is
1205
+ // that the plugin costs nothing, and a promise with a scan behind it is not
1206
+ // one. The visible consequence, stated in the docs: the panel does not
1207
+ // animate in `off`, because nothing runs to tell it anything.
1208
+ const budget = mode.forSession({ sessionId });
1209
+ if (budget.policy.briefStyle === 'none') return guardLine(now, budget);
1210
+
1211
+ // Settle host here, before any file is read.
1212
+ const detectedHost = host.detect(process.argv.slice(2), process.env);
1213
+ const effectiveHost = (hookInput && (hookInput.conversationId || hookInput.invocationNum !== undefined))
1214
+ ? host.GEMINI
1215
+ : detectedHost;
1216
+ usage.setHost(effectiveHost);
935
1217
  // A prompt has arrived, so this session is working, and the prompt itself
936
1218
  // says whether it asked for ultracode. The panel animates from this.
937
1219
  activity.mark(
@@ -957,6 +1239,13 @@ async function run(now, hookInput) {
957
1239
  );
958
1240
 
959
1241
  const config = settings();
1242
+ // How old the reading may be before this hook takes a fresh one is a mode
1243
+ // decision - the reading itself costs a request and a wait - but an explicit
1244
+ // environment setting is the user saying it outright, and that still wins.
1245
+ const envRefresh = Number(process.env.USAGE_LIMITS_REFRESH);
1246
+ const refreshSeconds = Number.isFinite(envRefresh)
1247
+ ? envRefresh
1248
+ : budget.policy.refreshSeconds || config.refreshSeconds;
960
1249
  // The reading ages during long turns, and a burst of parallel agents can
961
1250
  // spend half a window between two of them. Before the numbers go in front
962
1251
  // of Claude, take the same reading Claude Code takes for /usage when the
@@ -970,14 +1259,14 @@ async function run(now, hookInput) {
970
1259
  // does, when the reading has aged.
971
1260
  await require('./codex.js').refreshIfStale({
972
1261
  now,
973
- maxAgeMs: config.refreshSeconds * SECOND,
1262
+ maxAgeMs: refreshSeconds * SECOND,
974
1263
  timeoutMs: REFRESH_TIMEOUT_MS,
975
1264
  });
976
1265
  } else {
977
1266
  const cached = usage.collect(now);
978
1267
  await live.refreshIfStale({
979
1268
  now,
980
- maxAgeMs: config.refreshSeconds * SECOND,
1269
+ maxAgeMs: refreshSeconds * SECOND,
981
1270
  cacheFetchedAtMs: cached.snapshotFetchedAt,
982
1271
  accountUuid: usage.accountUuid(),
983
1272
  timeoutMs: REFRESH_TIMEOUT_MS,
@@ -1011,7 +1300,10 @@ async function run(now, hookInput) {
1011
1300
  othersSummary: summariseOthers(data.windows, binding && binding.key),
1012
1301
  // The way out that is not stopping. Cached with the rest of the view
1013
1302
  // because it is derived from the same one pass over the windows.
1014
- escape: usage.escapeRoute(data.windows, binding, data.effortWarning || null, usage.currentHost()),
1303
+ // The mode's own appetite goes in with it: how much emptier another
1304
+ // window has to be before a switch is worth naming, and whether the user
1305
+ // has pinned self-switching off altogether.
1306
+ escape: usage.escapeRoute(data.windows, binding, data.effortWarning || null, usage.currentHost(), budget.policy, budget.bounds),
1015
1307
  // Every window, trimmed to the cacheable fields, so the corrected reading
1016
1308
  // can be recorded for all three columns of the status line on a cache
1017
1309
  // hit too. Recording the binding window alone left the other two at
@@ -1026,9 +1318,16 @@ async function run(now, hookInput) {
1026
1318
  resetsIn: Number.isFinite(w.msToReset) ? usage.formatDuration(w.msToReset) : 'an unknown time',
1027
1319
  })),
1028
1320
  snapshotAge: usage.formatDuration(data.snapshotAgeMs),
1321
+ snapshotAgeMs: Number.isFinite(data.snapshotAgeMs) ? data.snapshotAgeMs : null,
1029
1322
  binding: cacheableBinding(binding),
1030
1323
  effortWarning: data.effortWarning || null,
1031
- fit: usage.settingFit(data.events || null, data.effortNow || null, usage.currentHost()),
1324
+ // From the per-effort TABLE, which is what report() returns. It was
1325
+ // being asked for from `data.events`, a field report() has never had -
1326
+ // it builds the event list internally and returns effortRates derived
1327
+ // from it - so this was null on every call on every machine, and with it
1328
+ // the whole recommendation channel: no fit sentence, nothing to offer,
1329
+ // nothing to decline.
1330
+ fit: usage.fitFromRates(data.effortRates || [], data.effortNow || null, usage.currentHost()),
1032
1331
  };
1033
1332
  view.fitFor = view.fit ? view.fit.effort : askedFitFor;
1034
1333
  writeCache(mergeCache(all, sessionId, view, KEEP_SESSIONS));
@@ -1070,7 +1369,51 @@ async function run(now, hookInput) {
1070
1369
  } catch (err) {
1071
1370
  voiceNote = null;
1072
1371
  }
1372
+
1373
+ // What tier is producing this turn, and what the user's own baseline is.
1374
+ // Read, displayed, never written.
1375
+ const terse = budget.policy.briefStyle === 'terse';
1376
+ const tier = mode.tierLine(mode.tierNow({ sessionId, now, usage, env: process.env }), { terse });
1377
+
1378
+ // The recommendation channel. The measured fit sentence IS the
1379
+ // recommendation - it cites this account's own numbers and names the exact
1380
+ // command - so it goes out through the advice rules rather than beside them:
1381
+ // at most one per session, never once declined, never in `off`, never
1382
+ // pointing outside the bounds the user set.
1383
+ const fitCandidate = view.fit && askedFitFor !== view.fit.effort ? view.fit : null;
1384
+ const advice = mode.advicePending({ decided: budget, fit: fitCandidate, sessionId });
1385
+ const offering = advice.ok && !advice.alreadyOffered;
1386
+ if (offering) mode.adviceOffer(advice.id, sessionId, now);
1387
+
1388
+ const pressureNow = pressure(binding, now, config, Number.isFinite(yourTurnsLeft) ? yourTurnsLeft : view.turnsLeft);
1389
+
1390
+ // Say nothing when nothing a decision depends on has moved.
1391
+ //
1392
+ // Only `max` asks for this, and only while there is room: repeating the same
1393
+ // figure every prompt is the plugin charging for its own presence. The
1394
+ // pressure is in the digest and the wall is excluded outright, so the one
1395
+ // line that must never be swallowed cannot be.
1396
+ const digest = [
1397
+ budget.name,
1398
+ pressureNow,
1399
+ binding && Number.isFinite(binding.percentUsed) ? Math.round(binding.percentUsed / 5) * 5 : 'x',
1400
+ view.escape ? view.escape.kind : '-',
1401
+ tier || '-',
1402
+ active > 1 ? 'shared' : 'solo',
1403
+ carry && carry.armed ? 'relay' : '-',
1404
+ offering ? 'advice' : '-',
1405
+ ].join('|');
1406
+ if (!budget.policy.briefWhenUnchanged && pressureNow === 'roomy') {
1407
+ const slots = readCache();
1408
+ const slot = slots[sessionId || '_'];
1409
+ if (slot && slot.said === digest) return '';
1410
+ writeCache(mergeCache(slots, sessionId, Object.assign({}, slot || { at: now }, { said: digest }), KEEP_SESSIONS));
1411
+ }
1412
+
1073
1413
  return briefText({
1414
+ mode: budget,
1415
+ tier,
1416
+ adviceText: terse && offering ? advice.text : null,
1074
1417
  relay: carry,
1075
1418
  voiceNote,
1076
1419
  lastReply: found.lastReply,
@@ -1097,9 +1440,9 @@ async function run(now, hookInput) {
1097
1440
  host: usage.currentHost(),
1098
1441
  turnsLeft: view.turnsLeft,
1099
1442
  effortWarning: view.effortWarning || null,
1100
- // Once per setting. The slot below records which effort it was said for,
1101
- // so a change of setting asks the question again and a repeat does not.
1102
- fit: view.fit && askedFitFor !== view.fit.effort ? view.fit : null,
1443
+ // Once per setting, and once per session, and never after a decline: the
1444
+ // advice rules above decide, and the sentence itself is unchanged.
1445
+ fit: offering && !terse ? fitCandidate : null,
1103
1446
  // Outside the cache: it is cheap, and it belongs to the other agent's
1104
1447
  // clock rather than this session's.
1105
1448
  codex: codexSummary(now),
@@ -1116,22 +1459,36 @@ async function run(now, hookInput) {
1116
1459
  correctionUnreliable: Boolean(binding && binding.correctionUnreliable),
1117
1460
  pointsBeyondSnapshot: (binding && binding.pointsBeyondSnapshot) || 0,
1118
1461
  snapshotAge: view.snapshotAge,
1462
+ snapshotStale: Number.isFinite(view.snapshotAgeMs) && view.snapshotAgeMs >= SNAPSHOT_TRUST_MS,
1119
1463
  // The turn count that matters for this session is its share of a shared
1120
1464
  // budget, not the whole window's. Escalating on the whole window meant a
1121
1465
  // count that looked comfortable while the part actually available here was
1122
1466
  // a third of it.
1123
- pressure: pressure(binding, now, config, Number.isFinite(yourTurnsLeft)
1124
- ? yourTurnsLeft
1125
- : view.turnsLeft),
1467
+ pressure: pressureNow,
1126
1468
  });
1127
1469
  }
1128
1470
 
1129
1471
  if (require.main === module) {
1130
1472
  readHookInput()
1131
- .then((input) => run(Date.now(), input))
1473
+ .then(async (input) => {
1474
+ const isGeminiHook = Boolean(
1475
+ (input && (input.conversationId || input.invocationNum !== undefined)) ||
1476
+ (process.argv.includes('--host') && process.argv[process.argv.indexOf('--host') + 1] === 'gemini') ||
1477
+ process.argv.includes('--gemini-hook')
1478
+ );
1479
+ const text = await run(Date.now(), input);
1480
+ return { text, isGeminiHook };
1481
+ })
1132
1482
  .then(
1133
- (text) => {
1134
- if (text) process.stdout.write(text + '\n');
1483
+ ({ text, isGeminiHook }) => {
1484
+ if (isGeminiHook) {
1485
+ const payload = {
1486
+ injectSteps: text ? [{ ephemeralMessage: text }] : []
1487
+ };
1488
+ process.stdout.write(JSON.stringify(payload) + '\n');
1489
+ } else {
1490
+ if (text) process.stdout.write(text + '\n');
1491
+ }
1135
1492
  process.exit(0);
1136
1493
  },
1137
1494
  () => {
@@ -1155,6 +1512,7 @@ module.exports = {
1155
1512
  codexSummary,
1156
1513
  tallyContext,
1157
1514
  LARGE_CONTEXT_TOKENS,
1515
+ SNAPSHOT_TRUST_MS,
1158
1516
  settings,
1159
1517
  keepSlots,
1160
1518
  pickCached,
@@ -1164,6 +1522,8 @@ module.exports = {
1164
1522
  readCache,
1165
1523
  cacheableBinding,
1166
1524
  pressureInputs,
1525
+ applyBounds,
1526
+ guardLine,
1167
1527
  CACHED_BINDING_FIELDS,
1168
1528
  LIVE_WINDOW_MS,
1169
1529
  RUNWAY_MENTION_MS,