claude-usage-limits 1.26.0 → 1.39.2
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 +107 -2
- package/commands/relay.md +10 -5
- package/commands/usage-mode.md +3 -0
- package/package.json +4 -3
- package/skills/usage-limits/SKILL.md +39 -2
- package/skills/usage-limits/references/how-it-works.md +3 -2
- package/skills/usage-limits/scripts/agy-hook.js +11 -14
- package/skills/usage-limits/scripts/brief.js +281 -36
- package/skills/usage-limits/scripts/ceiling.js +47 -9
- package/skills/usage-limits/scripts/defer.js +9 -7
- package/skills/usage-limits/scripts/feed.js +36 -8
- package/skills/usage-limits/scripts/install-antigravity.js +2 -1
- package/skills/usage-limits/scripts/mode.js +98 -3
- package/skills/usage-limits/scripts/pulse.js +80 -29
- package/skills/usage-limits/scripts/relay.js +605 -70
- package/skills/usage-limits/scripts/stop.js +2 -2
- package/skills/usage-limits/scripts/tally.js +10 -1
- package/skills/usage-limits/scripts/usage.js +113 -14
- package/skills/usage-limits/scripts/wake.js +356 -31
|
@@ -22,6 +22,7 @@ const relay = require('./relay.js');
|
|
|
22
22
|
const reading = require('./reading.js');
|
|
23
23
|
const voice = require('./voice.js');
|
|
24
24
|
const mode = require('./mode.js');
|
|
25
|
+
const feed = require('./feed.js');
|
|
25
26
|
|
|
26
27
|
const SECOND = 1000;
|
|
27
28
|
const DAY = 24 * 60 * 60 * 1000;
|
|
@@ -107,6 +108,169 @@ function cacheFile() {
|
|
|
107
108
|
return path.join(dir, 'usage-limits-brief.json');
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
// The same brief seconds apart is what a burst of task notifications makes:
|
|
112
|
+
// each one arrives as a prompt and each one got the whole line - ten copies in
|
|
113
|
+
// a row on 2026-09-20. Nothing has moved in ninety seconds, so the second copy
|
|
114
|
+
// carries nothing and is not said. Only the hook path uses this; run() still
|
|
115
|
+
// returns the text. USAGE_LIMITS_BRIEF_REPEAT=1 turns it off.
|
|
116
|
+
const REPEAT_MS = 90 * 1000;
|
|
117
|
+
function saidFile() {
|
|
118
|
+
return path.join(path.dirname(cacheFile()), 'usage-limits-brief-said.json');
|
|
119
|
+
}
|
|
120
|
+
function shapeOf(text) {
|
|
121
|
+
return String(text || '').replace(/\d+/g, '#');
|
|
122
|
+
}
|
|
123
|
+
// The said file holds three kinds of memo, told apart by key: the plain
|
|
124
|
+
// session key is the ninety-second shape, and the two suffixed keys below
|
|
125
|
+
// are per-session facts that have to outlive an hour's silence.
|
|
126
|
+
function readSaid() {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(fs.readFileSync(saidFile(), 'utf8'));
|
|
129
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function keepSaidFor(key) {
|
|
135
|
+
return /#(standing|cachemiss|stale|relaylast)$/.test(key) ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A plugin update takes effect when Claude Code restarts, so a session that
|
|
139
|
+
// began before one keeps running the old code for as long as it lives. On
|
|
140
|
+
// 2026-09-20 that put a session on 1.34.1 beside one on 1.36.0: the older one
|
|
141
|
+
// saw the single relay slot its version still had, judged it taken, wrote its
|
|
142
|
+
// own scheduled task by hand, and that task failed the way 1.34.1's always
|
|
143
|
+
// did, while the newer session's relay worked. Nothing in the plugin can
|
|
144
|
+
// upgrade a running session, but it can say so, once, with the two numbers.
|
|
145
|
+
function installedVersion(configDir) {
|
|
146
|
+
try {
|
|
147
|
+
const file = path.join(configDir, 'plugins', 'installed_plugins.json');
|
|
148
|
+
const rows = JSON.parse(fs.readFileSync(file, 'utf8')).plugins['usage-limits@usage-limits'];
|
|
149
|
+
if (!Array.isArray(rows) || !rows.length) return null;
|
|
150
|
+
const user = rows.find((r) => r && r.scope === 'user') || rows[rows.length - 1];
|
|
151
|
+
return user && typeof user.version === 'string' ? user.version : null;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function runningVersion() {
|
|
157
|
+
try {
|
|
158
|
+
return require('../../../package.json').version;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function staleVersionFor(sessionId, now, dir) {
|
|
164
|
+
const installed = installedVersion(dir || configDir());
|
|
165
|
+
const running = runningVersion();
|
|
166
|
+
if (!installed || !running || installed === running) return null;
|
|
167
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
168
|
+
const key = String(sessionId || '_') + '#stale';
|
|
169
|
+
const all = readSaid();
|
|
170
|
+
const entry = all[key];
|
|
171
|
+
if (entry && Number.isFinite(entry.at) && at - entry.at < keepSaidFor(key) && entry.seen === installed) return null;
|
|
172
|
+
all[key] = { at, seen: installed };
|
|
173
|
+
writeSaid(all, at);
|
|
174
|
+
return 'usage-limits ' + installed + ' is installed but this session still runs ' + running +
|
|
175
|
+
', because a plugin update applies at the next start; a relay or cap set here follows the older rules until then.';
|
|
176
|
+
}
|
|
177
|
+
// How the last relay ended is news once. It used to ride along for six hours
|
|
178
|
+
// after any relay ended, on every prompt of every session: on 2026-09-22 a wake
|
|
179
|
+
// lost at 5:53 AM was repeated in two sessions' briefs all afternoon, about
|
|
180
|
+
// work neither of them was doing. Each session now hears about a given ended
|
|
181
|
+
// relay once, keyed on when it ended and how.
|
|
182
|
+
function relayNewsFor(sessionId, last, now) {
|
|
183
|
+
if (!last) return null;
|
|
184
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
185
|
+
const key = String(sessionId || '_') + '#relaylast';
|
|
186
|
+
const seen = String(last.endedAt) + ':' + String(last.outcome);
|
|
187
|
+
const all = readSaid();
|
|
188
|
+
const entry = all[key];
|
|
189
|
+
if (entry && entry.seen === seen && Number.isFinite(entry.at) && at - entry.at < keepSaidFor(key)) return null;
|
|
190
|
+
all[key] = { at, seen };
|
|
191
|
+
writeSaid(all, at);
|
|
192
|
+
return last;
|
|
193
|
+
}
|
|
194
|
+
function writeSaid(all, at) {
|
|
195
|
+
const next = {};
|
|
196
|
+
for (const [k, v] of Object.entries(all)) if (v && Number.isFinite(v.at) && at - v.at < keepSaidFor(k)) next[k] = v;
|
|
197
|
+
try {
|
|
198
|
+
usage.writeJsonAtomic(saidFile(), next);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function sayOnce(sessionId, text, now) {
|
|
203
|
+
if (!text || process.env.USAGE_LIMITS_BRIEF_REPEAT === '1') return text;
|
|
204
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
205
|
+
const key = String(sessionId || '_');
|
|
206
|
+
const shape = shapeOf(text);
|
|
207
|
+
const all = readSaid();
|
|
208
|
+
const last = all[key];
|
|
209
|
+
if (last && Number.isFinite(last.at) && at - last.at < REPEAT_MS && last.shape === shape) return '';
|
|
210
|
+
all[key] = { at, shape };
|
|
211
|
+
writeSaid(all, at);
|
|
212
|
+
return text;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The standing instruction - open with the fit line, quote the binding
|
|
216
|
+
// window, close finished work with the total - reads the same on every prompt,
|
|
217
|
+
// and at 93 words it was the largest fixed cost in the line. It is said in
|
|
218
|
+
// full once per session and as twelve words after that. A session that has
|
|
219
|
+
// heard it has heard it; USAGE_LIMITS_BRIEF_FULL=1 says the full form every
|
|
220
|
+
// time for anyone who wants that.
|
|
221
|
+
const STANDING_SHORT = 'Open with the fit line; close finished work with the session total.';
|
|
222
|
+
function standingSaid(sessionId, now) {
|
|
223
|
+
const entry = readSaid()[String(sessionId || '_') + '#standing'];
|
|
224
|
+
return Boolean(entry && Number.isFinite(entry.at) && (Number.isFinite(now) ? now : Date.now()) - entry.at < keepSaidFor('#standing'));
|
|
225
|
+
}
|
|
226
|
+
function markStanding(sessionId, now) {
|
|
227
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
228
|
+
const all = readSaid();
|
|
229
|
+
all[String(sessionId || '_') + '#standing'] = { at };
|
|
230
|
+
writeSaid(all, at);
|
|
231
|
+
}
|
|
232
|
+
function standingShortFor(sessionId, now, env) {
|
|
233
|
+
if ((env || process.env).USAGE_LIMITS_BRIEF_FULL === '1') return false;
|
|
234
|
+
return standingSaid(sessionId, now);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The prompt right after a cache miss the user can do something about.
|
|
238
|
+
//
|
|
239
|
+
// The status line JSON carries prompt_cache.last_miss_at (epoch seconds) and
|
|
240
|
+
// last_miss_cause.causes (Claude Code 2.1.260 and later; documented values
|
|
241
|
+
// tools_changed, system_prompt_changed, ttl_expired_5m, likely_server_side),
|
|
242
|
+
// and feed.js keeps the last one in this session's feed slot. Only the two
|
|
243
|
+
// causes a person can act on are named: a TTL expiry is time passing and a
|
|
244
|
+
// server-side miss is nobody's. It is said on the first brief after the miss
|
|
245
|
+
// and not again for that miss, and never for a miss older than half an hour,
|
|
246
|
+
// because "right after" is the whole point.
|
|
247
|
+
const MISS_RECENT_MS = 30 * 60 * 1000;
|
|
248
|
+
function missReason(causes) {
|
|
249
|
+
const set = new Set(Array.isArray(causes) ? causes : []);
|
|
250
|
+
if (set.has('system_prompt_changed')) return 'the system prompt changed';
|
|
251
|
+
if (set.has('tools_changed')) return 'the tool list changed';
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
function cacheMissWhyFor(sessionId, now) {
|
|
255
|
+
if (!sessionId) return null;
|
|
256
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
257
|
+
let miss = null;
|
|
258
|
+
try {
|
|
259
|
+
const slot = feed.readFeed()[sessionId];
|
|
260
|
+
miss = slot && slot.cacheMiss;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
if (!miss || !Number.isFinite(miss.at) || at - miss.at > MISS_RECENT_MS) return null;
|
|
265
|
+
const why = missReason(miss.causes);
|
|
266
|
+
if (!why) return null;
|
|
267
|
+
const all = readSaid();
|
|
268
|
+
const key = String(sessionId) + '#cachemiss';
|
|
269
|
+
if (all[key] && all[key].at === miss.at) return null;
|
|
270
|
+
all[key] = { at: miss.at };
|
|
271
|
+
writeSaid(all, at);
|
|
272
|
+
return why;
|
|
273
|
+
}
|
|
110
274
|
// One slot per session. A single shared slot meant that alternating between
|
|
111
275
|
// two Claude Code windows invalidated the cache on every prompt, so neither
|
|
112
276
|
// ever got a hit and both paid for a full scan each time.
|
|
@@ -310,6 +474,8 @@ const CACHED_BINDING_FIELDS = [
|
|
|
310
474
|
'pointsSinceSnapshot',
|
|
311
475
|
'correctionUnreliable',
|
|
312
476
|
'pointsBeyondSnapshot',
|
|
477
|
+
// Both account readings, when there are two, so the line can name them.
|
|
478
|
+
'sources',
|
|
313
479
|
'resetsAt',
|
|
314
480
|
'verdict',
|
|
315
481
|
'windowStart',
|
|
@@ -419,7 +585,20 @@ function describeWindow(window) {
|
|
|
419
585
|
// in hand, and the reply that follows sizes the work down for a limit not one
|
|
420
586
|
// turn here can move.
|
|
421
587
|
const idle = window.applies === false ? " (not this session's model)" : '';
|
|
422
|
-
|
|
588
|
+
// Two account readings that disagree are named, not averaged and not
|
|
589
|
+
// silently picked between. On 2026-09-20 one window read 14, 30 and 20 per
|
|
590
|
+
// cent within three minutes as Claude Code's cache and the plugin's own
|
|
591
|
+
// live reading took turns being the newer; a reader who sees one number
|
|
592
|
+
// cannot tell that from the budget moving. Five points is the line: under
|
|
593
|
+
// that the two are the same reading at different moments.
|
|
594
|
+
const s = window.sources;
|
|
595
|
+
const split =
|
|
596
|
+
s && Number.isFinite(s.cache) && Number.isFinite(s.live) && Math.abs(s.cache - s.live) > 5
|
|
597
|
+
? s.used === 'live'
|
|
598
|
+
? " (the live reading; Claude Code's cache says " + s.cache + '%)'
|
|
599
|
+
: " (Claude Code's cache; the live reading says " + s.live + '%)'
|
|
600
|
+
: '';
|
|
601
|
+
return window.label + about + window.percentUsed + '%' + split + idle;
|
|
423
602
|
}
|
|
424
603
|
|
|
425
604
|
// Everything except the window that will actually stop the work.
|
|
@@ -491,6 +670,9 @@ function briefText(input) {
|
|
|
491
670
|
const bounds = (input.mode && input.mode.bounds) || null;
|
|
492
671
|
const pinned = Boolean(bounds && bounds.pin);
|
|
493
672
|
const parts = applyBounds(input, bounds);
|
|
673
|
+
// The short standing form only where the three standing strings would
|
|
674
|
+
// otherwise appear: the tight and gone instructions are their own words.
|
|
675
|
+
const shortStanding = parts.standingShort === true && parts.pressure !== 'tight' && parts.pressure !== 'gone';
|
|
494
676
|
|
|
495
677
|
// The turns and the reset time belong to one specific window. Listing every
|
|
496
678
|
// window and then the numbers invites reading them against the wrong one, so
|
|
@@ -529,10 +711,26 @@ function briefText(input) {
|
|
|
529
711
|
// first character; anywhere else the reader is owed the reason the line
|
|
530
712
|
// looks different from the one they are used to.
|
|
531
713
|
const token = parts.mode && parts.mode.name !== 'standard' ? '(' + (parts.mode.label || parts.mode.name) + ') ' : '';
|
|
714
|
+
// Fast mode changes what the figures above mean, so it is said in the same
|
|
715
|
+
// sentence. Per the Claude Code docs (code.claude.com/docs/en/fast-mode and
|
|
716
|
+
// /prompt-caching): on a subscription it is billed from usage credits and
|
|
717
|
+
// "not included in the subscription rate limits", so the window's turns are
|
|
718
|
+
// not what it spends; and the first request sent with it on "reads the
|
|
719
|
+
// entire conversation history with no cache hits", once per conversation.
|
|
720
|
+
// Only while the status line has said it is on; nothing is assumed.
|
|
721
|
+
const fast = parts.fastMode
|
|
722
|
+
? '; fast mode on, billed from usage credits rather than this window, and its first turn re-reads the whole context uncached'
|
|
723
|
+
: '';
|
|
724
|
+
// One clause, on the prompt right after a cache miss the user caused, and
|
|
725
|
+
// nothing otherwise. The cause comes from the status line's prompt_cache
|
|
726
|
+
// object through feed.js; see cacheMissWhyFor.
|
|
727
|
+
const missed = parts.cacheMissWhy
|
|
728
|
+
? '; the prompt cache missed on the last call because ' + parts.cacheMissWhy + ', so that call re-read the whole context'
|
|
729
|
+
: '';
|
|
532
730
|
sentences.push(
|
|
533
731
|
bound.length
|
|
534
|
-
? '[usage-limits] ' + token + 'binding window is ' + bound.join(', ') + '.'
|
|
535
|
-
: '[usage-limits] ' + token + 'no usable window reading.'
|
|
732
|
+
? '[usage-limits] ' + token + 'binding window is ' + bound.join(', ') + fast + missed + '.'
|
|
733
|
+
: '[usage-limits] ' + token + 'no usable window reading' + fast + missed + '.'
|
|
536
734
|
);
|
|
537
735
|
// What tier is producing this turn.
|
|
538
736
|
//
|
|
@@ -543,6 +741,8 @@ function briefText(input) {
|
|
|
543
741
|
// well as what it is, because the source is the whole point: settings.json
|
|
544
742
|
// said xhigh for an entire session that was running something else.
|
|
545
743
|
if (parts.tier) sentences.push(parts.tier);
|
|
744
|
+
// Once per session, and only when the installed version is not this one.
|
|
745
|
+
if (parts.staleVersion) sentences.push(parts.staleVersion);
|
|
546
746
|
const bounded = mode.boundsNote(bounds);
|
|
547
747
|
if (bounded) sentences.push(bounded);
|
|
548
748
|
if (parts.planChanged) {
|
|
@@ -594,11 +794,14 @@ function briefText(input) {
|
|
|
594
794
|
// A correction is a measurement of local transcripts priced by a learned
|
|
595
795
|
// rate. It is a good adjustment to a recent snapshot and a bad substitute
|
|
596
796
|
// for an old one, because the pricing error compounds with every point it
|
|
597
|
-
// has to bridge.
|
|
598
|
-
//
|
|
797
|
+
// has to bridge. The figure here is snapshot plus correction: a point
|
|
798
|
+
// estimate that can land on either side of the account - on 2026-09-20 it
|
|
799
|
+
// ran 13 to 17 points HIGH - so it is called an estimate. "Floor" is kept
|
|
800
|
+
// for the branch above, where the correction is refused and the raw
|
|
801
|
+
// snapshot is all that is shown, which really is a lower bound.
|
|
599
802
|
sentences.push(
|
|
600
|
-
'Treat that percentage as
|
|
601
|
-
'behind it is ' + parts.snapshotAge + ' old, so most of the figure is measured ' +
|
|
803
|
+
'Treat that percentage as an estimate rather than a reading, high or low: the account ' +
|
|
804
|
+
'snapshot behind it is ' + parts.snapshotAge + ' old, so most of the figure is measured ' +
|
|
602
805
|
'from local history at a learned price rather than read from the account, and ' +
|
|
603
806
|
'that gap widens the longer the snapshot stands. Run /usage to refresh it before ' +
|
|
604
807
|
'making a decision that depends on the exact number.'
|
|
@@ -930,34 +1133,30 @@ function briefText(input) {
|
|
|
930
1133
|
'read. Say in one line what will land after the reset instead of ' +
|
|
931
1134
|
'before it, then keep working until the window actually ends.'
|
|
932
1135
|
: parts.pressure === 'tight'
|
|
933
|
-
? 'The budget is nearly gone, so make being cut off cheap rather than ' +
|
|
934
|
-
'
|
|
935
|
-
'
|
|
936
|
-
'
|
|
937
|
-
'
|
|
938
|
-
'
|
|
939
|
-
'
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
'
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
'There is room, so use it: work at full quality, take on the whole ' +
|
|
946
|
-
'request, and do not hold budget back or economise, as anything left ' +
|
|
947
|
-
'unspent is lost at the reset rather than saved.';
|
|
1136
|
+
? 'The budget is nearly gone, so make being cut off cheap rather than doing ' +
|
|
1137
|
+
'less. Carry on with the whole request at full quality; do not narrow ' +
|
|
1138
|
+
'it or stop to ask. The most valuable part lands first, save at clean ' +
|
|
1139
|
+
'boundaries, with a running note of what is done, what is next and ' +
|
|
1140
|
+
'which files are mid-change. Say in one line what may not land before ' +
|
|
1141
|
+
'the reset, then keep working; for a mechanical remainder, node ' +
|
|
1142
|
+
'scripts/usage.js --recommend (skill directory) names the effort and model.'
|
|
1143
|
+
: shortStanding
|
|
1144
|
+
? STANDING_SHORT
|
|
1145
|
+
: 'Open with one short line stating this and that the request fits, then ' +
|
|
1146
|
+
'get on with the work. There is room: full quality, the whole request, nothing held ' +
|
|
1147
|
+
'back, since unspent budget is lost at the reset.';
|
|
948
1148
|
|
|
949
1149
|
// The mistake this guards against: quoting the roomiest window and pinning
|
|
950
1150
|
// the binding window figures to it.
|
|
951
|
-
const care =
|
|
952
|
-
|
|
953
|
-
'
|
|
954
|
-
'against another window percentage.';
|
|
1151
|
+
const care = shortStanding
|
|
1152
|
+
? ''
|
|
1153
|
+
: ' Quote the binding window; its turns and reset time belong to it alone.';
|
|
955
1154
|
|
|
956
1155
|
// Finished work closes with what it cost. Not every reply: a progress note
|
|
957
1156
|
// mid-task is not the moment, and once the budget is gone nothing further
|
|
958
1157
|
// runs, so there is no reply to close.
|
|
959
1158
|
const closing =
|
|
960
|
-
parts.pressure === 'gone'
|
|
1159
|
+
parts.pressure === 'gone' || shortStanding
|
|
961
1160
|
? ''
|
|
962
1161
|
: ' When this reply completes what was asked, or wraps up the session, end it ' +
|
|
963
1162
|
'with one plain line giving the session total above (turns, tokens and cost). ' +
|
|
@@ -1096,15 +1295,18 @@ function tallyContext(all, sessionId, now) {
|
|
|
1096
1295
|
// about.
|
|
1097
1296
|
function relayState(now, hookInput, binding, sessionId) {
|
|
1098
1297
|
try {
|
|
1099
|
-
const state = relay.read();
|
|
1298
|
+
const state = (relay.reapLost(Date.now()), relay.read());
|
|
1100
1299
|
const config = relay.settings(state);
|
|
1101
1300
|
const last = state.history[state.history.length - 1];
|
|
1102
|
-
const
|
|
1301
|
+
const ended = last && Number.isFinite(last.endedAt) && now - last.endedAt < 6 * 60 * 60 * 1000 ? last : null;
|
|
1302
|
+
// Said once per session per ended relay, not on every prompt for six hours.
|
|
1303
|
+
const recent = ended ? relayNewsFor(sessionId, ended, now) : null;
|
|
1103
1304
|
if (!config.enabled) return recent ? { enabled: false, last: recent } : null;
|
|
1104
1305
|
|
|
1105
1306
|
// Already armed for this session: nothing to decide, just say so.
|
|
1106
|
-
|
|
1107
|
-
|
|
1307
|
+
const mine = relay.armedFor(state, sessionId);
|
|
1308
|
+
if (mine) {
|
|
1309
|
+
return { enabled: true, armed: mine, config, last: recent };
|
|
1108
1310
|
}
|
|
1109
1311
|
const work = relay.detectWork(hookInput && hookInput.transcript_path, {});
|
|
1110
1312
|
const able = relay.armable({ config, binding, sessionId, work });
|
|
@@ -1195,6 +1397,20 @@ function guardLine(now, budget) {
|
|
|
1195
1397
|
}
|
|
1196
1398
|
}
|
|
1197
1399
|
|
|
1400
|
+
// Whether this session has fast mode on. No hook input carries it; the status
|
|
1401
|
+
// line JSON does, as `fast_mode`, and feed.js keeps that in this session's
|
|
1402
|
+
// feed slot. A session with no slot - no status line installed - reads as off,
|
|
1403
|
+
// so the clause is never said on a guess.
|
|
1404
|
+
function fastModeFor(sessionId) {
|
|
1405
|
+
if (!sessionId) return false;
|
|
1406
|
+
try {
|
|
1407
|
+
const slot = feed.readFeed()[sessionId];
|
|
1408
|
+
return Boolean(slot && slot.fastMode === true);
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1198
1414
|
async function run(now, hookInput) {
|
|
1199
1415
|
if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
|
|
1200
1416
|
|
|
@@ -1386,6 +1602,9 @@ async function run(now, hookInput) {
|
|
|
1386
1602
|
if (offering) mode.adviceOffer(advice.id, sessionId, now);
|
|
1387
1603
|
|
|
1388
1604
|
const pressureNow = pressure(binding, now, config, Number.isFinite(yourTurnsLeft) ? yourTurnsLeft : view.turnsLeft);
|
|
1605
|
+
// Fast mode changes what the window's figures mean, so a toggle is a change
|
|
1606
|
+
// worth saying even when nothing else has moved.
|
|
1607
|
+
const fastMode = fastModeFor(sessionId);
|
|
1389
1608
|
|
|
1390
1609
|
// Say nothing when nothing a decision depends on has moved.
|
|
1391
1610
|
//
|
|
@@ -1402,6 +1621,7 @@ async function run(now, hookInput) {
|
|
|
1402
1621
|
active > 1 ? 'shared' : 'solo',
|
|
1403
1622
|
carry && carry.armed ? 'relay' : '-',
|
|
1404
1623
|
offering ? 'advice' : '-',
|
|
1624
|
+
fastMode ? 'fast' : '-',
|
|
1405
1625
|
].join('|');
|
|
1406
1626
|
if (!budget.policy.briefWhenUnchanged && pressureNow === 'roomy') {
|
|
1407
1627
|
const slots = readCache();
|
|
@@ -1410,9 +1630,15 @@ async function run(now, hookInput) {
|
|
|
1410
1630
|
writeCache(mergeCache(slots, sessionId, Object.assign({}, slot || { at: now }, { said: digest }), KEEP_SESSIONS));
|
|
1411
1631
|
}
|
|
1412
1632
|
|
|
1413
|
-
|
|
1633
|
+
// The standing text in full the first time, twelve words after. Decided
|
|
1634
|
+
// here, and marked below only when the full form actually went out.
|
|
1635
|
+
const standingShort = standingShortFor(sessionId, now);
|
|
1636
|
+
const text = briefText({
|
|
1414
1637
|
mode: budget,
|
|
1415
1638
|
tier,
|
|
1639
|
+
standingShort,
|
|
1640
|
+
cacheMissWhy: cacheMissWhyFor(sessionId, now),
|
|
1641
|
+
staleVersion: staleVersionFor(sessionId, now),
|
|
1416
1642
|
adviceText: terse && offering ? advice.text : null,
|
|
1417
1643
|
relay: carry,
|
|
1418
1644
|
voiceNote,
|
|
@@ -1460,12 +1686,17 @@ async function run(now, hookInput) {
|
|
|
1460
1686
|
pointsBeyondSnapshot: (binding && binding.pointsBeyondSnapshot) || 0,
|
|
1461
1687
|
snapshotAge: view.snapshotAge,
|
|
1462
1688
|
snapshotStale: Number.isFinite(view.snapshotAgeMs) && view.snapshotAgeMs >= SNAPSHOT_TRUST_MS,
|
|
1689
|
+
fastMode,
|
|
1463
1690
|
// The turn count that matters for this session is its share of a shared
|
|
1464
1691
|
// budget, not the whole window's. Escalating on the whole window meant a
|
|
1465
1692
|
// count that looked comfortable while the part actually available here was
|
|
1466
1693
|
// a third of it.
|
|
1467
1694
|
pressure: pressureNow,
|
|
1468
1695
|
});
|
|
1696
|
+
// Only the roomy form carries the three standing strings; the terse style
|
|
1697
|
+
// drops them and the tight and gone instructions are their own words.
|
|
1698
|
+
if (text && !standingShort && !terse && pressureNow !== 'tight' && pressureNow !== 'gone') markStanding(sessionId, now);
|
|
1699
|
+
return text;
|
|
1469
1700
|
}
|
|
1470
1701
|
|
|
1471
1702
|
if (require.main === module) {
|
|
@@ -1476,18 +1707,18 @@ if (require.main === module) {
|
|
|
1476
1707
|
(process.argv.includes('--host') && process.argv[process.argv.indexOf('--host') + 1] === 'gemini') ||
|
|
1477
1708
|
process.argv.includes('--gemini-hook')
|
|
1478
1709
|
);
|
|
1479
|
-
const text = await run(Date.now(), input);
|
|
1710
|
+
const text = sayOnce(input && input.session_id, await run(Date.now(), input), Date.now());
|
|
1480
1711
|
return { text, isGeminiHook };
|
|
1481
1712
|
})
|
|
1482
1713
|
.then(
|
|
1483
1714
|
({ text, isGeminiHook }) => {
|
|
1484
1715
|
if (isGeminiHook) {
|
|
1485
1716
|
const payload = {
|
|
1486
|
-
injectSteps: text ? [{ ephemeralMessage: text }] : []
|
|
1717
|
+
injectSteps: text ? [{ ephemeralMessage: withBugcheck(text) }] : []
|
|
1487
1718
|
};
|
|
1488
1719
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
1489
1720
|
} else {
|
|
1490
|
-
if (text) process.stdout.write(text + '\n');
|
|
1721
|
+
if (text) process.stdout.write(withBugcheck(text) + '\n');
|
|
1491
1722
|
}
|
|
1492
1723
|
process.exit(0);
|
|
1493
1724
|
},
|
|
@@ -1498,7 +1729,20 @@ if (require.main === module) {
|
|
|
1498
1729
|
);
|
|
1499
1730
|
}
|
|
1500
1731
|
|
|
1501
|
-
|
|
1732
|
+
// relay bugcheck always: the two passes the user asks for on nearly every
|
|
1733
|
+
// request, asked for on every prompt. on and off touch the hand-off only.
|
|
1734
|
+
function withBugcheck(text) {
|
|
1735
|
+
try {
|
|
1736
|
+
const relayModule = require('./relay.js');
|
|
1737
|
+
if (relayModule.settings(relayModule.read()).bugcheck === 'always') return text + ' ' + relayModule.BUGCHECK_LINE;
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
// The relay module is optional here.
|
|
1740
|
+
}
|
|
1741
|
+
return text;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
module.exports = { withBugcheck, sayOnce, shapeOf, saidFile, REPEAT_MS, staleVersionFor, relayNewsFor, installedVersion, runningVersion,
|
|
1745
|
+
readSaid, standingSaid, markStanding, standingShortFor, STANDING_SHORT, cacheMissWhyFor, missReason, MISS_RECENT_MS,
|
|
1502
1746
|
DEFAULTS,
|
|
1503
1747
|
aheadOfPace,
|
|
1504
1748
|
pacingMatters,
|
|
@@ -1514,6 +1758,7 @@ module.exports = {
|
|
|
1514
1758
|
LARGE_CONTEXT_TOKENS,
|
|
1515
1759
|
SNAPSHOT_TRUST_MS,
|
|
1516
1760
|
settings,
|
|
1761
|
+
fastModeFor,
|
|
1517
1762
|
keepSlots,
|
|
1518
1763
|
pickCached,
|
|
1519
1764
|
mergeCache,
|
|
@@ -92,28 +92,57 @@ function ceilingFrom(state, env, sessionId) {
|
|
|
92
92
|
// A ceiling that cannot be read is not a ceiling of zero. Fall through to
|
|
93
93
|
// the file rather than enforcing a number nobody typed.
|
|
94
94
|
}
|
|
95
|
+
// A STANDING cap applies to every session on this machine, including ones
|
|
96
|
+
// that start after a limit reset. It is the answer to "keep 60 per cent for
|
|
97
|
+
// next session too": a session-owned cap deliberately lapses, so something
|
|
98
|
+
// that outlives a session has to be stored as its own thing rather than by
|
|
99
|
+
// weakening the ownership rule.
|
|
100
|
+
const always =
|
|
101
|
+
state && Number.isFinite(state.ceilingAlways) && state.ceilingAlways > 0 && state.ceilingAlways <= 100
|
|
102
|
+
? state.ceilingAlways
|
|
103
|
+
: null;
|
|
104
|
+
const standing = always === null ? null : { percent: always, source: 'the standing cap' };
|
|
95
105
|
const stored = state && Number.isFinite(state.ceilingPercent) ? state.ceilingPercent : null;
|
|
96
|
-
if (stored === null || stored <= 0 || stored > 100) return { percent: null, source: null };
|
|
106
|
+
if (stored === null || stored <= 0 || stored > 100) return standing || { percent: null, source: null };
|
|
97
107
|
const owner = state && state.ceilingSession ? String(state.ceilingSession) : null;
|
|
98
108
|
if (!owner) {
|
|
99
109
|
// Set before caps were session-scoped. Not this session's instruction.
|
|
100
|
-
return { percent: null, source: null, staleCap: stored };
|
|
110
|
+
return standing || { percent: null, source: null, staleCap: stored };
|
|
101
111
|
}
|
|
102
112
|
if (!sessionId || String(sessionId) !== owner) {
|
|
103
|
-
return { percent: null, source: null, otherSessionCap: stored };
|
|
113
|
+
return standing || { percent: null, source: null, otherSessionCap: stored };
|
|
104
114
|
}
|
|
105
115
|
return { percent: stored, source: 'this session' };
|
|
106
116
|
}
|
|
107
117
|
|
|
108
|
-
//
|
|
118
|
+
// The window a ceiling is judged against: the fullest one this agent can spend
|
|
119
|
+
// into.
|
|
109
120
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
121
|
+
// Not the emptiest - a ceiling read against whichever window has the most left
|
|
122
|
+
// would never bind. And not simply the fullest either: a weekly scoped to one
|
|
123
|
+
// model is only this agent's limit while that model runs. On 2026-09-22 the
|
|
124
|
+
// Fable weekly at 89 per cent refused an Opus session's fan-out and the refusal
|
|
125
|
+
// called it "the binding window", while the brief beside it said 5-hour 15.
|
|
126
|
+
// `windows` is usage.snapshotWindows(), which already carries `applies` and
|
|
127
|
+
// `stale`; a window whose reset has passed describes a window that is over.
|
|
128
|
+
function worstWindow(windows) {
|
|
129
|
+
let worst = null;
|
|
130
|
+
for (const window of Array.isArray(windows) ? windows : []) {
|
|
131
|
+
if (!window || window.applies === false || window.stale) continue;
|
|
132
|
+
if (!Number.isFinite(window.percentUsed)) continue;
|
|
133
|
+
if (!worst || window.percentUsed > worst.percent) {
|
|
134
|
+
worst = { percent: window.percentUsed, label: window.label || window.key || null };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return worst;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Where that window stands against the ceiling.
|
|
113
141
|
function assess(options) {
|
|
114
142
|
const opts = options || {};
|
|
115
143
|
const ceiling = ceilingFrom(opts.state, opts.env, opts.sessionId);
|
|
116
144
|
const percent = Number.isFinite(opts.percent) ? opts.percent : null;
|
|
145
|
+
const label = opts.label ? String(opts.label) : null;
|
|
117
146
|
if (ceiling.percent === null || percent === null) {
|
|
118
147
|
return {
|
|
119
148
|
set: ceiling.percent !== null,
|
|
@@ -123,6 +152,7 @@ function assess(options) {
|
|
|
123
152
|
over: false,
|
|
124
153
|
near: false,
|
|
125
154
|
headroomPoints: null,
|
|
155
|
+
label,
|
|
126
156
|
staleCap: ceiling.staleCap || null,
|
|
127
157
|
otherSessionCap: ceiling.otherSessionCap || null,
|
|
128
158
|
};
|
|
@@ -133,6 +163,7 @@ function assess(options) {
|
|
|
133
163
|
source: ceiling.source,
|
|
134
164
|
ceiling: ceiling.percent,
|
|
135
165
|
percent,
|
|
166
|
+
label,
|
|
136
167
|
over: percent >= ceiling.percent,
|
|
137
168
|
near: headroomPoints > 0 && headroomPoints <= NEAR_POINTS,
|
|
138
169
|
headroomPoints,
|
|
@@ -143,6 +174,12 @@ function round(value) {
|
|
|
143
174
|
return Math.round(value * 10) / 10;
|
|
144
175
|
}
|
|
145
176
|
|
|
177
|
+
// Names the window the number belongs to. A caller that passed no label gets
|
|
178
|
+
// the plain truth rather than a claim that it is the binding one.
|
|
179
|
+
function which(state) {
|
|
180
|
+
return state && state.label ? 'the ' + state.label + ' window' : 'the fullest window';
|
|
181
|
+
}
|
|
182
|
+
|
|
146
183
|
// What to do about one tool call.
|
|
147
184
|
//
|
|
148
185
|
// Returns `allow` for everything the ceiling does not cover, which is almost
|
|
@@ -154,7 +191,7 @@ function verdict(state, tool) {
|
|
|
154
191
|
return {
|
|
155
192
|
decision: 'deny',
|
|
156
193
|
reason:
|
|
157
|
-
'Usage ceiling reached:
|
|
194
|
+
'Usage ceiling reached: ' + which(state) + ' is ' +
|
|
158
195
|
round(state.percent) +
|
|
159
196
|
'% used and the ceiling is ' +
|
|
160
197
|
state.ceiling +
|
|
@@ -174,7 +211,7 @@ function warning(state) {
|
|
|
174
211
|
return (
|
|
175
212
|
'Usage ceiling in ' +
|
|
176
213
|
round(state.headroomPoints) +
|
|
177
|
-
' points:
|
|
214
|
+
' points: ' + which(state) + ' is ' +
|
|
178
215
|
round(state.percent) +
|
|
179
216
|
'% used against a ceiling of ' +
|
|
180
217
|
state.ceiling +
|
|
@@ -206,6 +243,7 @@ module.exports = {
|
|
|
206
243
|
NEAR_POINTS,
|
|
207
244
|
isMultiplier,
|
|
208
245
|
ceilingFrom,
|
|
246
|
+
worstWindow,
|
|
209
247
|
assess,
|
|
210
248
|
verdict,
|
|
211
249
|
warning,
|
|
@@ -210,7 +210,7 @@ function plan(options) {
|
|
|
210
210
|
|
|
211
211
|
function status(now) {
|
|
212
212
|
const state = relay.read();
|
|
213
|
-
const armed = state.armed;
|
|
213
|
+
const armed = relay.armedFor(state, process.env.CLAUDE_CODE_SESSION_ID) || state.armed;
|
|
214
214
|
if (!armed) return 'Nothing is deferred.';
|
|
215
215
|
const when = Number(armed.wakeAt);
|
|
216
216
|
const deferred = armed.deferred === true;
|
|
@@ -230,9 +230,10 @@ function status(now) {
|
|
|
230
230
|
|
|
231
231
|
function cancel() {
|
|
232
232
|
const state = relay.read();
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
233
|
+
const own = relay.armedFor(state, process.env.CLAUDE_CODE_SESSION_ID) || state.armed;
|
|
234
|
+
if (!own) return 'Nothing was deferred.';
|
|
235
|
+
const label = formatClock(Number(own.wakeAt));
|
|
236
|
+
const result = relay.disarm('cancelled by hand', Date.now(), own.id);
|
|
236
237
|
return result && result.ok === false
|
|
237
238
|
? 'Could not cancel: ' + result.error
|
|
238
239
|
: 'Cancelled the run booked for ' + label + '.';
|
|
@@ -286,9 +287,10 @@ function main(argv, now) {
|
|
|
286
287
|
// session can tell the two apart - they read the same record.
|
|
287
288
|
try {
|
|
288
289
|
const held = relay.read();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
const mine = (typeof sessionId !== 'undefined' && relay.armedFor(held, sessionId)) || held.armed;
|
|
291
|
+
if (mine) {
|
|
292
|
+
mine.deferred = true;
|
|
293
|
+
mine.continuation = Boolean(work);
|
|
292
294
|
relay.write(held);
|
|
293
295
|
}
|
|
294
296
|
} catch (err) {
|