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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +220 -2
- package/bin/cli.js +16 -0
- package/commands/defer.md +47 -0
- package/commands/usage-mode.md +64 -0
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +196 -19
- package/skills/usage-limits/references/tactics.md +40 -9
- package/skills/usage-limits/scripts/agy-hook.js +175 -0
- package/skills/usage-limits/scripts/brief.js +406 -46
- package/skills/usage-limits/scripts/ceiling.js +191 -0
- package/skills/usage-limits/scripts/codex-lowpower.js +95 -4
- package/skills/usage-limits/scripts/codex.js +87 -6
- package/skills/usage-limits/scripts/defer.js +318 -0
- package/skills/usage-limits/scripts/drift.js +254 -0
- package/skills/usage-limits/scripts/feed.js +23 -1
- package/skills/usage-limits/scripts/host.js +23 -3
- package/skills/usage-limits/scripts/install-antigravity.js +215 -0
- package/skills/usage-limits/scripts/install-codex-hook.js +22 -2
- package/skills/usage-limits/scripts/lowpower.js +48 -0
- package/skills/usage-limits/scripts/mode.js +1637 -0
- package/skills/usage-limits/scripts/net.js +179 -0
- package/skills/usage-limits/scripts/pulse.js +254 -17
- package/skills/usage-limits/scripts/reading.js +12 -3
- package/skills/usage-limits/scripts/relay.js +266 -2
- package/skills/usage-limits/scripts/sessionend.js +8 -0
- package/skills/usage-limits/scripts/stop.js +145 -1
- package/skills/usage-limits/scripts/usage.js +244 -17
- package/skills/usage-limits/scripts/view.js +4 -0
- package/skills/usage-limits/scripts/voice.js +10 -1
- package/skills/usage-limits/scripts/wake.js +210 -30
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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 ?
|
|
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 ? '
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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,
|
|
684
|
-
'
|
|
685
|
-
'
|
|
686
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1101
|
-
//
|
|
1102
|
-
fit:
|
|
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:
|
|
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) =>
|
|
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 (
|
|
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,
|