claude-usage-limits 1.6.1 → 1.7.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.
@@ -14,30 +14,58 @@ const os = require('os');
14
14
  const path = require('path');
15
15
 
16
16
  const usage = require('./usage.js');
17
+ const host = require('./host.js');
17
18
 
18
19
  const SECOND = 1000;
19
20
  const DAY = 24 * 60 * 60 * 1000;
20
21
 
22
+ // There is one threshold that changes behaviour, and it is the wall.
23
+ //
24
+ // Everything below it is reported and nothing below it is discouraged. That is
25
+ // the whole design, and it is a correction: an earlier version escalated from
26
+ // 40 per cent used, or whenever the runway dropped under three quarters of an
27
+ // hour, and so spent its time telling a session with a third of its budget left
28
+ // to stop starting things. Budget left unspent at the reset is not saved, it is
29
+ // destroyed, so winding down early is not caution. It is waste with a
30
+ // respectable name.
31
+ //
32
+ // Above the wall the instruction is not "hurry" either. It is: write the plan
33
+ // for what is left, save the work, and stop.
21
34
  const DEFAULTS = {
22
- // Close enough to the wall that the wording should change.
23
- near: 80,
24
- // Below this, pace is not worth worrying about.
35
+ // The wall. Below this, work normally at full quality.
36
+ near: 90,
37
+ // Kept for `aheadOfPace`, which is still reported. It no longer decides
38
+ // anything: spending a week's budget faster than the clock is information,
39
+ // not a reason to slow down.
25
40
  floor: 40,
26
- // Points of budget spent beyond the share of the window that has elapsed.
27
41
  ahead: 15,
28
42
  // How long the measured part stays good for. Prompts often arrive in
29
43
  // bursts, and a transcript scan per prompt would be wasteful.
30
44
  cacheSeconds: 60,
31
- // Few enough turns that the count itself is the warning.
32
- fewTurns: 20,
45
+ // Few enough turns that the count itself is the wall.
46
+ fewTurns: 10,
47
+ // Minutes of runway left at the current pace, below which there is no longer
48
+ // time to land the work and write the handoff. Not "too little time to start
49
+ // something ambitious" - that judgement belongs to whoever is doing the work,
50
+ // and it needs the number, not an instruction.
51
+ runwayMinutes: 10,
33
52
  };
34
53
 
54
+ // The runway is worth saying long before it is worth acting on, because it is
55
+ // the figure that stops a turn count from flattering. Two hundred turns sounds
56
+ // like plenty and can be twenty minutes when three sessions are spending.
57
+ const RUNWAY_MENTION_MS = 2 * 60 * 60 * 1000;
58
+
35
59
  function configDir() {
36
60
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
37
61
  }
38
62
 
63
+ // One cache per host. The slots double as the count of open sessions, so mixing
64
+ // two agents' sessions into one file would have each of them reporting the
65
+ // other's windows as competition for a budget they do not share.
39
66
  function cacheFile() {
40
- return path.join(configDir(), 'usage-limits-brief.json');
67
+ const dir = usage.isCodex() ? require('./codex.js').homeDir() : configDir();
68
+ return path.join(dir, 'usage-limits-brief.json');
41
69
  }
42
70
 
43
71
  // One slot per session. A single shared slot meant that alternating between
@@ -75,6 +103,36 @@ function pickCached(all, sessionId, now, ttlMs) {
75
103
  return now - entry.at < ttlMs ? entry : null;
76
104
  }
77
105
 
106
+ // How many sessions are actually open right now.
107
+ //
108
+ // The count derived from spend is the accurate one, but it is always late: a
109
+ // session only appears in it once it has finished a turn and written the cost
110
+ // to its transcript. Three windows that all submit a prompt at the same moment
111
+ // each see a count of one, which is exactly when knowing about the other two
112
+ // would have mattered most.
113
+ //
114
+ // This cache is the earlier signal. Every session writes its own slot when the
115
+ // hook runs, so a slot touched in the last few minutes is a session that was
116
+ // being used, whether or not its spend has landed yet. It costs nothing: the
117
+ // file has already been read.
118
+ const LIVE_WINDOW_MS = 15 * 60 * 1000;
119
+
120
+ // Counts the other sessions, not this one. This session's own slot may not be
121
+ // written yet on its first prompt, so counting slots directly would report two
122
+ // when three windows are open.
123
+ function liveSessions(all, now, windowMs, exceptId) {
124
+ const within = Number.isFinite(windowMs) ? windowMs : LIVE_WINDOW_MS;
125
+ const mine = exceptId || '_';
126
+ let count = 0;
127
+ for (const key of Object.keys(all || {})) {
128
+ if (key === mine) continue;
129
+ const entry = all[key];
130
+ if (!entry || !Number.isFinite(entry.at)) continue;
131
+ if (now - entry.at <= within) count += 1;
132
+ }
133
+ return count;
134
+ }
135
+
78
136
  // Keep the newest few so a machine with many sessions does not grow the file
79
137
  // without bound.
80
138
  function mergeCache(all, sessionId, entry, keep) {
@@ -107,6 +165,7 @@ function settings() {
107
165
  ahead: number(env.USAGE_LIMITS_AHEAD, DEFAULTS.ahead),
108
166
  cacheSeconds: number(env.USAGE_LIMITS_CACHE, DEFAULTS.cacheSeconds),
109
167
  fewTurns: number(env.USAGE_LIMITS_FEW_TURNS, DEFAULTS.fewTurns),
168
+ runwayMinutes: number(env.USAGE_LIMITS_RUNWAY, DEFAULTS.runwayMinutes),
110
169
  };
111
170
  }
112
171
 
@@ -119,35 +178,105 @@ function aheadOfPace(window, now) {
119
178
  return window.percentUsed - Math.min(100, Math.max(0, elapsed));
120
179
  }
121
180
 
181
+ // Spending faster than the clock only means something for a window you have to
182
+ // make last. A window that comes back in hours is meant to be spent in a burst:
183
+ // nothing carries over, so holding budget back buys nothing at all, and the
184
+ // only thing an even pace achieves is getting less done for the same money.
185
+ const PACE_MIN_SPAN_MS = 24 * 60 * 60 * 1000;
186
+ const PACE_MIN_ELAPSED = 0.25;
187
+
188
+ function pacingMatters(window, now) {
189
+ if (!window || !window.spanMs || !Number.isFinite(window.windowStart)) return false;
190
+ if (window.spanMs < PACE_MIN_SPAN_MS) return false;
191
+ // Early on, the comparison is dominated by how little of the window has gone
192
+ // rather than by how much has been spent. Twenty minutes into a five hour
193
+ // window every working session is far "ahead of pace", which is exactly how
194
+ // 44 per cent used came to be reported as tight.
195
+ return (now - window.windowStart) / window.spanMs >= PACE_MIN_ELAPSED;
196
+ }
197
+
122
198
  // Not whether to speak, which is always, but how hard to lean on it.
123
199
  function pressure(window, now, config, turnsLeft) {
124
200
  if (!window || window.percentUsed === null || window.stale) return 'unknown';
125
201
  if (window.verdict === 'exhausted' || window.percentUsed >= 100) return 'gone';
126
202
  if (window.verdict === 'runs-out') return 'tight';
127
203
 
128
- // A rebuilt figure only counts this machine, so it reads low. React to it
129
- // sooner than to a figure the API actually reported.
130
- const near = window.estimated ? Math.min(config.near, 70) : config.near;
131
- if (window.percentUsed >= near) return 'tight';
204
+ // How long the budget lasts at the pace it is actually being spent at. This
205
+ // is the only figure here that answers "am I about to be cut off", and it was
206
+ // being computed and then ignored.
207
+ //
208
+ // When a reset time is known, a short runway already shows up as the
209
+ // 'runs-out' verdict above. When it is not - and a 5-hour window whose
210
+ // resets_at comes back null is exactly that case - the verdict is only
211
+ // 'burning', which fell through every branch below to 'roomy'. Three sessions
212
+ // were told the budget fitted easily while this number said forty-three
213
+ // minutes; nine minutes later all three were rejected.
214
+ //
215
+ // It is also the right figure when several agents share one budget: the pace
216
+ // it is measured from is the whole account's, not this session's, so the
217
+ // runway already shortens as others spend.
218
+ const runwayMs = Math.max(0, config.runwayMinutes) * 60 * 1000;
219
+ if (Number.isFinite(window.headroomMs) && window.headroomMs <= runwayMs) {
220
+ return 'tight';
221
+ }
222
+
223
+ if (window.percentUsed >= config.near) return 'tight';
132
224
 
133
- // Turns are the number the work is planned in, so a short count is tight
134
- // whatever the percentage says.
225
+ // Turns are the number the work is planned in, so a count this short is the
226
+ // wall whatever the percentage says.
135
227
  if (Number.isFinite(turnsLeft) && turnsLeft <= config.fewTurns) {
136
228
  return 'tight';
137
229
  }
138
230
 
139
- // Pace only means something for a window with a real start. A rebuilt one
140
- // is anchored at now minus its span, so it is always "fully elapsed" and
141
- // the comparison can never fire.
142
- if (!window.estimated) {
143
- const lead = aheadOfPace(window, now);
144
- if (window.percentUsed >= config.floor && lead !== null && lead >= config.ahead) {
145
- return 'tight';
146
- }
147
- }
231
+ // Being ahead of the clock is reported and is deliberately not escalated. A
232
+ // five hour window is meant to be spent in a burst, and even a weekly one
233
+ // being spent quickly is a fact about how the week is going rather than a
234
+ // reason to do less today. The figures are in the line; the judgement is the
235
+ // reader's.
148
236
  return 'roomy';
149
237
  }
150
238
 
239
+ // Everything about the binding window that has to survive the cache, because
240
+ // the cached copy is what every later prompt in the minute is judged against.
241
+ //
242
+ // This is a list rather than the window itself so the cache stays small, and it
243
+ // is a named function so it can be checked: leaving `headroomMs` off it once
244
+ // meant the escalation that depends on the runway was dead in production while
245
+ // passing every unit test, which is the quietest way for a warning to fail.
246
+ const CACHED_BINDING_FIELDS = [
247
+ 'key',
248
+ 'label',
249
+ 'percentUsed',
250
+ 'stale',
251
+ 'estimated',
252
+ 'adjusted',
253
+ 'pointsSinceSnapshot',
254
+ 'correctionUnreliable',
255
+ 'resetsAt',
256
+ 'verdict',
257
+ 'windowStart',
258
+ 'spanMs',
259
+ 'headroomMs',
260
+ 'msToReset',
261
+ 'refusedAt',
262
+ 'refusedResetsAt',
263
+ ];
264
+
265
+ function cacheableBinding(binding) {
266
+ if (!binding) return null;
267
+ const copy = {};
268
+ for (const field of CACHED_BINDING_FIELDS) {
269
+ copy[field] = binding[field] === undefined ? null : binding[field];
270
+ }
271
+ return copy;
272
+ }
273
+
274
+ // Every field `pressure` reads has to be one the cache keeps, or the decision
275
+ // it makes on a cache hit is made from missing data.
276
+ function pressureInputs() {
277
+ return ['percentUsed', 'stale', 'verdict', 'headroomMs', 'windowStart', 'spanMs', 'estimated'];
278
+ }
279
+
151
280
  // The hook is handed JSON on stdin. The session id in it is what lets this
152
281
  // report what the current session has cost rather than the whole window.
153
282
  function readHookInput() {
@@ -213,6 +342,16 @@ function briefText(parts) {
213
342
  ' of them yours)'
214
343
  : '';
215
344
  bound.push('about ' + parts.turnsLeft + ' turns of headroom' + shared);
345
+ // A turn count is a poor sense of urgency when several agents are spending
346
+ // at once: two hundred turns sounds like plenty and can be gone in ten
347
+ // minutes. The runway is the figure that does not flatter.
348
+ if (parts.runsOutIn) bound.push('about ' + parts.runsOutIn + ' of that at the current pace');
349
+ } else if (parts.sessions > 1) {
350
+ // The headroom could not be worked out, but the fact that the budget is
351
+ // being shared is still the most important thing about it. Attaching this
352
+ // only to a turn count meant it went unsaid exactly when there was no
353
+ // reading to attach it to.
354
+ bound.push(parts.sessions + ' sessions active and sharing it');
216
355
  }
217
356
  if (parts.resetsIn) bound.push('resets in ' + parts.resetsIn);
218
357
 
@@ -241,6 +380,16 @@ function briefText(parts) {
241
380
  parts.snapshotAge + ' old, so run /usage before trusting the rest.'
242
381
  );
243
382
  }
383
+ // Work having actually been stopped is the most useful thing that can be said
384
+ // about a budget, and the percentages stop showing it the moment the window
385
+ // turns over. Saying it plainly is what stops the next session opening with
386
+ // "plenty of room" an hour after the last one was cut off mid-edit.
387
+ if (parts.refusedAgo) {
388
+ sentences.push(
389
+ 'This limit refused work ' + parts.refusedAgo + ' ago, so treat the room above as ' +
390
+ 'the amount that ran out last time, not a fresh allowance.'
391
+ );
392
+ }
244
393
  if (parts.othersSummary) sentences.push('Other windows: ' + parts.othersSummary + '.');
245
394
 
246
395
  // A window that is not binding can still be the expensive one to exhaust.
@@ -260,15 +409,33 @@ function briefText(parts) {
260
409
  );
261
410
  }
262
411
 
412
+ // Three states, and only the last one stops anything.
413
+ //
414
+ // The middle one is the one that keeps being got wrong. Near the wall the
415
+ // instinct is to do less of what was asked, and that is the wrong trade: it
416
+ // spends the user's request to protect a budget that expires anyway. The job
417
+ // near the wall is to make being cut off cheap - order the work, save as you
418
+ // go, keep a note of where things stand - not to shrink the work until it is
419
+ // guaranteed to fit.
263
420
  const instruction =
264
- parts.pressure === 'tight' || parts.pressure === 'gone'
265
- ? 'Open your reply with one line on where this leaves the budget, then say ' +
266
- 'what you will do now and what you will leave for after the reset. ' +
267
- 'Do not start work that clearly will not finish. If several additions ' +
268
- 'arrive while you are working, say once that sending them together ' +
269
- 'costs less, then carry on; never say it about a correction or a stop.'
270
- : 'Open your reply with one short line stating this and confirming the ' +
271
- 'request fits, then get on with the work. Keep it to a single line.';
421
+ parts.pressure === 'gone'
422
+ ? 'The budget is gone, so nothing further will run. Save what exists and ' +
423
+ 'write the handoff: what is finished, what is next and in what order, ' +
424
+ 'which files are mid-change, and when the window resets.'
425
+ : parts.pressure === 'tight'
426
+ ? 'The budget is nearly gone, so make being cut off cheap rather than ' +
427
+ 'doing less. Carry on with the whole request at full quality: this is ' +
428
+ 'not a reason to narrow the work, drop parts of it, or stop to ask ' +
429
+ 'whether to go on. Order it so the most valuable part lands first, ' +
430
+ 'save at clean boundaries as you go, and keep a short running note of ' +
431
+ 'what is done, what is next, and which files are mid-change, so that ' +
432
+ 'stopping at any moment loses nothing. Say in one line what may not ' +
433
+ 'land before the reset, then keep working.'
434
+ : 'Open your reply with one short line stating this and confirming the ' +
435
+ 'request fits, then get on with the work. Keep it to a single line. ' +
436
+ 'There is room, so use it: work at full quality, take on the whole ' +
437
+ 'request, and do not hold budget back or economise, as anything left ' +
438
+ 'unspent is lost at the reset rather than saved.';
272
439
 
273
440
  // The mistake this guards against: quoting the roomiest window and pinning
274
441
  // the binding window figures to it.
@@ -283,6 +450,11 @@ function briefText(parts) {
283
450
  async function run(now, hookInput) {
284
451
  if (String(process.env.USAGE_LIMITS_BRIEF || '').toLowerCase() === 'off') return '';
285
452
 
453
+ // Codex cannot ship a hook inside a plugin, so its hook is installed into
454
+ // ~/.codex/hooks.json with the host written into the command. Settle it here,
455
+ // before any file is read.
456
+ usage.setHost(host.detect(process.argv.slice(2), process.env));
457
+
286
458
  const config = settings();
287
459
  const base = usage.collect(now);
288
460
  if (!base.utilization) return '';
@@ -312,34 +484,38 @@ async function run(now, hookInput) {
312
484
  resetsIn: Number.isFinite(w.msToReset) ? usage.formatDuration(w.msToReset) : 'an unknown time',
313
485
  })),
314
486
  snapshotAge: usage.formatDuration(data.snapshotAgeMs),
315
- binding: binding
316
- ? {
317
- key: binding.key,
318
- label: binding.label,
319
- percentUsed: binding.percentUsed,
320
- stale: binding.stale,
321
- estimated: binding.estimated,
322
- adjusted: binding.adjusted,
323
- pointsSinceSnapshot: binding.pointsSinceSnapshot,
324
- correctionUnreliable: binding.correctionUnreliable,
325
- resetsAt: binding.resetsAt,
326
- verdict: binding.verdict,
327
- windowStart: binding.windowStart,
328
- spanMs: binding.spanMs,
329
- }
330
- : null,
487
+ binding: cacheableBinding(binding),
331
488
  };
332
489
  writeCache(mergeCache(all, sessionId, view, KEEP_SESSIONS));
333
490
  }
334
491
 
335
492
  const binding = view.binding;
336
493
  const sessions = view.sessions || [];
337
- const share = usage.shareOf(sessions, sessionId);
494
+ // The spend-derived count is the accurate one when it has caught up; the
495
+ // open-session count is the one that is right immediately. Take whichever is
496
+ // higher rather than the one that happens to be handy, because under-counting
497
+ // is what makes the headroom read as more yours than it is.
498
+ const active = Math.max(sessions.length, liveSessions(all, now, LIVE_WINDOW_MS, sessionId) + 1);
499
+ const share =
500
+ sessions.length > 1 ? usage.shareOf(sessions, sessionId) : active > 1 ? 1 / active : 1;
501
+ const yourTurnsLeft = Number.isFinite(view.turnsLeft)
502
+ ? Math.max(1, Math.round(view.turnsLeft * share))
503
+ : null;
504
+ // Only a short runway is worth saying. Quoting it when there are hours left
505
+ // would make the line longer without making it more useful.
506
+ const shortRunway =
507
+ binding && Number.isFinite(binding.headroomMs) &&
508
+ binding.headroomMs <= RUNWAY_MENTION_MS;
338
509
  return briefText({
339
- sessions: sessions.length,
340
- yourTurnsLeft: Number.isFinite(view.turnsLeft)
341
- ? Math.max(1, Math.round(view.turnsLeft * share))
342
- : null,
510
+ sessions: active,
511
+ yourTurnsLeft,
512
+ runsOutIn: shortRunway ? usage.formatDuration(binding.headroomMs) : null,
513
+ // Only while it is still the thing that just happened. A refusal from days
514
+ // ago says nothing about now.
515
+ refusedAgo:
516
+ binding && Number.isFinite(binding.refusedAt) && now - binding.refusedAt < 6 * 60 * 60 * 1000
517
+ ? usage.formatDuration(now - binding.refusedAt)
518
+ : null,
343
519
  binding,
344
520
  othersSummary: view.othersSummary,
345
521
  turnsLeft: view.turnsLeft,
@@ -353,7 +529,13 @@ async function run(now, hookInput) {
353
529
  critical: view.critical || [],
354
530
  pointsSinceSnapshot: (binding && binding.pointsSinceSnapshot) || 0,
355
531
  snapshotAge: view.snapshotAge,
356
- pressure: pressure(binding, now, config, view.turnsLeft),
532
+ // The turn count that matters for this session is its share of a shared
533
+ // budget, not the whole window's. Escalating on the whole window meant a
534
+ // count that looked comfortable while the part actually available here was
535
+ // a third of it.
536
+ pressure: pressure(binding, now, config, Number.isFinite(yourTurnsLeft)
537
+ ? yourTurnsLeft
538
+ : view.turnsLeft),
357
539
  });
358
540
  }
359
541
 
@@ -375,6 +557,9 @@ if (require.main === module) {
375
557
  module.exports = {
376
558
  DEFAULTS,
377
559
  aheadOfPace,
560
+ pacingMatters,
561
+ PACE_MIN_SPAN_MS,
562
+ PACE_MIN_ELAPSED,
378
563
  pressure,
379
564
  sessionSpend,
380
565
  describeWindow,
@@ -384,6 +569,12 @@ module.exports = {
384
569
  keepSlots,
385
570
  pickCached,
386
571
  mergeCache,
572
+ liveSessions,
573
+ cacheableBinding,
574
+ pressureInputs,
575
+ CACHED_BINDING_FIELDS,
576
+ LIVE_WINDOW_MS,
577
+ RUNWAY_MENTION_MS,
387
578
  KEEP_SESSIONS,
388
579
  run,
389
580
  cacheFile,