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.
@@ -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
- return window.label + about + window.percentUsed + '%' + idle;
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. So an old snapshot makes the figure a floor, whether or
598
- // not it has overrun anything, and that is said rather than assumed.
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 a floor rather than a reading: the account snapshot ' +
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
- 'doing less. Carry on with the whole request at full quality: this is ' +
935
- 'not a reason to narrow the work, drop parts of it, or stop to ask ' +
936
- 'whether to go on. Order it so the most valuable part lands first, ' +
937
- 'save at clean boundaries as you go, and keep a short running note of ' +
938
- 'what is done, what is next, and which files are mid-change, so that ' +
939
- 'stopping at any moment loses nothing. Say in one line what may not ' +
940
- 'land before the reset, then keep working. If part of what remains is ' +
941
- 'mechanical, node scripts/usage.js --recommend (from the skill directory) ' +
942
- 'names the effort and model it should run at.'
943
- : 'Open your reply with one short line stating this and confirming the ' +
944
- 'request fits, then get on with the work. Keep it to a single line. ' +
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
- ' Quote the binding window, not whichever one has the most left. The turns ' +
953
- 'and reset time above belong to the binding window alone; do not read them ' +
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 recent = last && Number.isFinite(last.endedAt) && now - last.endedAt < 6 * 60 * 60 * 1000 ? last : null;
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
- if (state.armed && state.armed.id === sessionId) {
1107
- return { enabled: true, armed: state.armed, config, last: recent };
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
- return briefText({
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
- module.exports = {
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
- // Where the binding window stands against the ceiling.
118
+ // The window a ceiling is judged against: the fullest one this agent can spend
119
+ // into.
109
120
  //
110
- // `percent` is the BINDING window's, not the emptiest one's. A ceiling read
111
- // against whichever window has the most left would never bind at all, which is
112
- // the same mistake the brief was corrected for.
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: the binding window is ' +
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: the binding window is ' +
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
- if (!state.armed) return 'Nothing was deferred.';
234
- const label = formatClock(Number(state.armed.wakeAt));
235
- const result = relay.disarm('cancelled by hand', Date.now());
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
- if (held.armed) {
290
- held.armed.deferred = true;
291
- held.armed.continuation = Boolean(work);
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) {