claude-usage-limits 1.11.0 → 1.11.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.11.0",
4
+ "version": "1.11.3",
5
5
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usage-limits",
3
- "version": "1.11.0",
3
+ "version": "1.11.3",
4
4
  "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
5
5
  "author": {
6
6
  "name": "Ridelink",
package/README.md CHANGED
@@ -347,6 +347,12 @@ every tool call keeps it so, the Stop hook marks it idle), so the list is live
347
347
  without anyone polling anything. The status line adds `+1 working` when
348
348
  another session is spending.
349
349
 
350
+ Under the bars it says when the binding window runs out at the pace of the
351
+ last hour, measured from every session's spend, whenever that comes before
352
+ the reset: `at this pace the 5-hour window runs out in 25m, about 12 turns`,
353
+ yellow inside half an hour and red inside ten minutes. The pane rings the
354
+ terminal bell once when a window turns yellow and once more when it turns red.
355
+
350
356
  `panel` alone runs it in the current pane, `--once` prints one frame, `--json`
351
357
  prints the fields, `--no-fetch` (or `USAGE_LIMITS_FETCH=off`) keeps it
352
358
  entirely offline on the reading already on disk, and `--poll N` sets the
@@ -461,6 +467,7 @@ minute, so it costs about 400ms cold and 120ms warm.
461
467
  | `USAGE_LIMITS_CLOCK` | from settings | `12h` or `24h` for reset times; otherwise follows Claude Code's `timeFormat`. |
462
468
  | `USAGE_LIMITS_COLOUR` | detected | `256` or `none` to override colour detection. `NO_COLOR` and `FORCE_COLOR` are honoured. |
463
469
  | `USAGE_LIMITS_ASCII` | off | `1` draws the bars and the spinner with plain characters. |
470
+ | `USAGE_LIMITS_BELL` | on | `off` silences the panel's terminal bell when a window turns yellow or red (`--no-bell` does the same). |
464
471
 
465
472
  ## What a session cost
466
473
 
@@ -666,12 +673,10 @@ allowance and never quotes a price, so the percentages stand alone. And
666
673
 
667
674
  The Claude Code extension for VS Code shows the limits only when you ask with
668
675
  `/usage`, and it does not render a custom status line. So there is an
669
- extension of its own in [vscode/](vscode/): the same bars as a view that sits
670
- directly under the Claude Code chat in the secondary side bar (it contributes
671
- into the Claude Code extension's own view container, so there is no gap and
672
- nothing to arrange), a status bar item with the percentages that turns yellow
673
- and red at the same thresholds, the Sessions list, and the same animations in
674
- CSS. It carries the plugin's scripts inside it, so it has no dependencies and
676
+ extension of its own in [vscode/](vscode/): the same bars as one full-height
677
+ view in the right sidebar, beside the chat, opened for you when VS Code
678
+ starts, with the Sessions list and the same animations in CSS. Nothing at the
679
+ bottom unless you turn `claudeUsageLimits.statusBar` on. It carries the plugin's scripts inside it, so it has no dependencies and
675
680
  reads the same files and takes the same reading as the terminal panel.
676
681
 
677
682
  ```
@@ -859,7 +864,7 @@ test/ node --test, no dependencies
859
864
  node --test
860
865
  ```
861
866
 
862
- 469 tests over the pricing, the window arithmetic, plan and credit detection,
867
+ 477 tests over the pricing, the window arithmetic, plan and credit detection,
863
868
  the status line, the before-prompt line, the mid-turn pulse, the after-reply tally and the session history, job forecasting,
864
869
  per-project attribution, the Codex reader and its installer, the CLI,
865
870
  packaging, and the settings save/restore.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.11.0",
3
+ "version": "1.11.3",
4
4
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
@@ -68,10 +68,20 @@ function mark(state, sessionId, extra, now) {
68
68
  else if (previous.model) entry.model = previous.model;
69
69
  all[sessionId || '_'] = entry;
70
70
  const file = activityFile();
71
- fs.mkdirSync(path.dirname(file), { recursive: true });
72
71
  const temp = file + '.' + process.pid + '.usage-limits-tmp';
73
- fs.writeFileSync(temp, JSON.stringify(trim(all)), 'utf8');
74
- fs.renameSync(temp, file);
72
+ try {
73
+ fs.mkdirSync(path.dirname(file), { recursive: true });
74
+ fs.writeFileSync(temp, JSON.stringify(trim(all)), 'utf8');
75
+ fs.renameSync(temp, file);
76
+ } catch (err) {
77
+ // A rename Windows refused leaves nothing behind.
78
+ try {
79
+ fs.unlinkSync(temp);
80
+ } catch (gone) {
81
+ // Nothing to clean up.
82
+ }
83
+ return false;
84
+ }
75
85
  return true;
76
86
  } catch (err) {
77
87
  return false;
@@ -26,6 +26,7 @@ const bars = require('./bars.js');
26
26
  const view = require('./view.js');
27
27
  const activity = require('./activity.js');
28
28
  const statusline = require('./statusline.js');
29
+ const live = require('./live.js');
29
30
 
30
31
  const KEEP_SESSIONS = 8;
31
32
  // Two updates this close together mean Claude is mid-turn.
@@ -69,16 +70,24 @@ function readFeed() {
69
70
  }
70
71
 
71
72
  function writeFeed(all) {
72
- try {
73
- const file = feedFile();
74
- fs.mkdirSync(path.dirname(file), { recursive: true });
75
- const temp = file + '.' + process.pid + '.usage-limits-tmp';
76
- fs.writeFileSync(temp, JSON.stringify(all), 'utf8');
77
- fs.renameSync(temp, file);
78
- return true;
79
- } catch (err) {
80
- return false;
81
- }
73
+ return live.writeAtomic(feedFile(), JSON.stringify(all));
74
+ }
75
+
76
+ // Two updates a few seconds apart mean a turn in progress, unless the status
77
+ // line is on a timer that fires that often anyway, in which case the gap says
78
+ // nothing and only the hooks' marks do.
79
+ function gapMeansWorking(settings) {
80
+ const line = settings && settings.statusLine;
81
+ const every = line && Number.isFinite(line.refreshInterval) ? line.refreshInterval * 1000 : null;
82
+ return !(every !== null && every <= WORKING_GAP_MS);
83
+ }
84
+
85
+ // This session's own mark, from the hooks. Another window working must not
86
+ // spin this one's line.
87
+ function ownState(marks, sessionId, now) {
88
+ const mine = sessionId && marks ? marks[sessionId] : null;
89
+ if (!mine || !Number.isFinite(mine.at) || now - mine.at > activity.STALE_MS) return { working: false, ultracode: false };
90
+ return { working: mine.state === 'working', ultracode: Boolean(mine.ultracode) };
82
91
  }
83
92
 
84
93
  function number(value) {
@@ -231,13 +240,13 @@ function readStdin() {
231
240
 
232
241
  // The status line that was there before ours, run with the same stdin, so
233
242
  // installing this one loses nothing.
234
- function runPrevious(command, raw, env) {
243
+ function runPrevious(command, raw, env, budgetMs) {
235
244
  try {
236
245
  const result = spawnSync(command, {
237
246
  shell: true,
238
247
  input: raw,
239
248
  encoding: 'utf8',
240
- timeout: CHAIN_TIMEOUT_MS,
249
+ timeout: Math.max(300, Number.isFinite(budgetMs) ? budgetMs : CHAIN_TIMEOUT_MS),
241
250
  env: env || process.env,
242
251
  windowsHide: true,
243
252
  stdio: ['pipe', 'pipe', 'ignore'],
@@ -268,9 +277,18 @@ function motionOff(settings, env) {
268
277
  return Boolean(settings && settings.prefersReducedMotion === true);
269
278
  }
270
279
 
280
+ // Written and flushed before the process is allowed to end: stdout is a pipe
281
+ // here, and a pipe write can still be in flight when process.exit runs.
282
+ function out(text) {
283
+ return new Promise((resolve) => {
284
+ process.stdout.write(text, () => resolve());
285
+ });
286
+ }
287
+
271
288
  async function main(argv) {
272
289
  const env = process.env;
273
- const now = Date.now();
290
+ const started = Date.now();
291
+ const now = started;
274
292
  let chained = '';
275
293
  try {
276
294
  usage.setHost(host.detect(argv || [], env));
@@ -285,7 +303,8 @@ async function main(argv) {
285
303
 
286
304
  const state = statusline.readState();
287
305
  if (state && state.chain && state.previous && state.previous.type === 'command' && state.previous.command) {
288
- chained = runPrevious(state.previous.command, raw, env);
306
+ // Whatever the stdin wait used comes out of the previous line's time.
307
+ chained = runPrevious(state.previous.command, raw, env, CHAIN_TIMEOUT_MS - (Date.now() - started));
289
308
  }
290
309
 
291
310
  let all = readFeed();
@@ -296,11 +315,11 @@ async function main(argv) {
296
315
 
297
316
  const off = String(env.USAGE_LIMITS_STATUSLINE || '').toLowerCase();
298
317
  if (off === 'off' || off === '0' || off === 'false') {
299
- if (chained) process.stdout.write(chained + '\n');
318
+ if (chained) await out(chained + '\n');
300
319
  return 0;
301
320
  }
302
321
  if (usage.isCodex()) {
303
- if (chained) process.stdout.write(chained + '\n');
322
+ if (chained) await out(chained + '\n');
304
323
  return 0;
305
324
  }
306
325
 
@@ -308,9 +327,11 @@ async function main(argv) {
308
327
  const collected = usage.collect(now);
309
328
  const settings = settingsFor(configDir());
310
329
  const marks = activity.read();
311
- const seen = activity.summarise(marks, now);
312
- // The other sessions working right now, so the line can say so.
313
330
  const mine = input && input.session_id ? input.session_id : null;
331
+ // This session's own state; only with no session id at all does the
332
+ // machine-wide picture stand in for it.
333
+ const own = mine ? ownState(marks, mine, now) : activity.summarise(marks, now);
334
+ // The other sessions working right now, so the line can say so.
314
335
  const othersWorking = activity
315
336
  .combine({ marks, feed: all }, now, activity.STALE_MS)
316
337
  .filter((row) => row.state === 'working' && row.sessionId !== mine).length;
@@ -324,8 +345,8 @@ async function main(argv) {
324
345
  model: slot ? slot.model : null,
325
346
  modelName: slot ? slot.modelName : null,
326
347
  effort: slot ? slot.effort : null,
327
- working: isWorking(slot, now) || seen.working,
328
- ultracode: seen.ultracode || settings.ultracode === true,
348
+ working: own.working || (gapMeansWorking(settings) && isWorking(slot, now)),
349
+ ultracode: own.ultracode || settings.ultracode === true,
329
350
  settingsModel: collected.settings ? collected.settings.model : null,
330
351
  env,
331
352
  });
@@ -339,10 +360,10 @@ async function main(argv) {
339
360
  ascii: String(env.USAGE_LIMITS_ASCII || '') === '1',
340
361
  clock: clockFor(settings, env),
341
362
  });
342
- process.stdout.write((chained ? chained + '\n' : '') + text + '\n');
363
+ await out((chained ? chained + '\n' : '') + text + '\n');
343
364
  return 0;
344
365
  } catch (err) {
345
- if (chained) process.stdout.write(chained + '\n');
366
+ if (chained) await out(chained + '\n');
346
367
  return 0;
347
368
  }
348
369
  }
@@ -358,6 +379,8 @@ module.exports = {
358
379
  record,
359
380
  newest,
360
381
  isWorking,
382
+ gapMeansWorking,
383
+ ownState,
361
384
  line,
362
385
  runPrevious,
363
386
  clockFor,
@@ -105,12 +105,21 @@ function readToken(options) {
105
105
  }
106
106
 
107
107
  if (platform === 'darwin') {
108
+ // The keychain is a child process each time, so a long-lived panel or the
109
+ // VS Code extension remembers the answer for a few minutes.
110
+ const nowMs = Date.now();
111
+ // Only the real keychain is remembered; an injected reader (the tests)
112
+ // is asked every time.
113
+ const remember = opts.cache !== false && !opts.exec;
114
+ if (remember && keychainMemo && nowMs - keychainMemo.at < KEYCHAIN_MEMO_MS) return keychainMemo.result;
108
115
  const exec = opts.exec || defaultExec;
109
116
  try {
110
117
  const out = exec(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
111
118
  const parsed = parseCredentials(String(out || '').trim());
112
119
  if (parsed && parsed.token) {
113
- return { token: parsed.token, expiresAt: parsed.expiresAt, source: 'keychain' };
120
+ const result = { token: parsed.token, expiresAt: parsed.expiresAt, source: 'keychain' };
121
+ if (remember) keychainMemo = { at: nowMs, result };
122
+ return result;
114
123
  }
115
124
  } catch (err) {
116
125
  // No keychain entry, or no permission to read it: same answer as no file.
@@ -120,6 +129,34 @@ function readToken(options) {
120
129
  return { token: null, reason: 'no_credentials' };
121
130
  }
122
131
 
132
+ let keychainMemo = null;
133
+ const KEYCHAIN_MEMO_MS = 5 * MINUTE;
134
+
135
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
136
+
137
+ function isLoopback(hostname) {
138
+ return LOOPBACK.has(String(hostname || '').toLowerCase());
139
+ }
140
+
141
+ // Where the login may be sent: Anthropic over https, or this machine (the
142
+ // tests run a stub on a loopback port). A project's settings file can put
143
+ // anything into the environment of a hook, and USAGE_LIMITS_USAGE_URL must
144
+ // not be a way to walk off with the token.
145
+ function allowedUrl(raw) {
146
+ if (!raw) return null;
147
+ let url;
148
+ try {
149
+ url = new URL(String(raw));
150
+ } catch (err) {
151
+ return null;
152
+ }
153
+ if (isLoopback(url.hostname)) return url.toString();
154
+ if (url.protocol !== 'https:') return null;
155
+ const host = url.hostname.toLowerCase();
156
+ if (host === 'anthropic.com' || host.endsWith('.anthropic.com')) return url.toString();
157
+ return null;
158
+ }
159
+
123
160
  function bad(kind, status, retryAfterMs, message) {
124
161
  return {
125
162
  ok: false,
@@ -150,12 +187,11 @@ function fetchUsage(options) {
150
187
  return new Promise((resolve) => {
151
188
  if (!opts.token) return resolve(bad('no_credentials', null, null, 'no Claude login found'));
152
189
 
153
- let url;
154
- try {
155
- url = new URL(opts.url || USAGE_URL);
156
- } catch (err) {
157
- return resolve(bad('bad_response', null, null, 'the usage url is not a url'));
190
+ const permitted = allowedUrl(opts.url || USAGE_URL);
191
+ if (!permitted) {
192
+ return resolve(bad('bad_response', null, null, 'refusing to send the login anywhere but Anthropic over https'));
158
193
  }
194
+ const url = new URL(permitted);
159
195
  const client = url.protocol === 'http:' ? http : https;
160
196
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
161
197
 
@@ -242,6 +278,7 @@ function nextDelayMs(outcome, previousMs, options) {
242
278
  ? Math.min(Math.max(outcome.retryAfterMs, 1000), 10 * MINUTE)
243
279
  : 60 * 1000;
244
280
  case 'unauthorized':
281
+ case 'expired':
245
282
  case 'forbidden':
246
283
  case 'no_credentials':
247
284
  return 30 * 1000;
@@ -263,6 +300,8 @@ function describe(outcome) {
263
300
  return 'offline';
264
301
  case 'unauthorized':
265
302
  return 'sign in to Claude Code again';
303
+ case 'expired':
304
+ return 'login expired, Claude Code renews it on its next call';
266
305
  case 'forbidden':
267
306
  return 'usage not available for this login';
268
307
  case 'no_credentials':
@@ -287,20 +326,30 @@ function readLive() {
287
326
  return parsed;
288
327
  }
289
328
 
290
- // Through a temporary file, so a reader never sees half a reading.
291
- function writeLive(snapshot) {
329
+ // Through a temporary file named for this process, so a reader never sees
330
+ // half a reading and two writers never share a temp file. A rename that fails
331
+ // (Windows, with the target held open) leaves nothing behind.
332
+ function writeAtomic(file, text) {
333
+ const temp = file + '.' + process.pid + '.usage-limits-tmp';
292
334
  try {
293
- const file = liveFile();
294
335
  fs.mkdirSync(path.dirname(file), { recursive: true });
295
- const temp = file + '.usage-limits-tmp';
296
- fs.writeFileSync(temp, JSON.stringify(snapshot), 'utf8');
336
+ fs.writeFileSync(temp, text, 'utf8');
297
337
  fs.renameSync(temp, file);
298
338
  return true;
299
339
  } catch (err) {
340
+ try {
341
+ fs.unlinkSync(temp);
342
+ } catch (gone) {
343
+ // Nothing to clean up.
344
+ }
300
345
  return false;
301
346
  }
302
347
  }
303
348
 
349
+ function writeLive(snapshot) {
350
+ return writeAtomic(liveFile(), JSON.stringify(snapshot));
351
+ }
352
+
304
353
  function fetchDisabled(env) {
305
354
  return String((env || process.env).USAGE_LIMITS_FETCH || '').toLowerCase() === 'off';
306
355
  }
@@ -320,9 +369,14 @@ async function refresh(options) {
320
369
  snapshot: readLive(),
321
370
  };
322
371
  }
372
+ // A token past its expiry gets a 401 and nothing else. Claude Code renews it
373
+ // on its own next call, so do not send it, and say what is being waited for.
374
+ if (Number.isFinite(creds.expiresAt) && creds.expiresAt < (Number.isFinite(opts.now) ? opts.now : Date.now())) {
375
+ return { outcome: bad('expired', null, null, 'the login has expired; Claude Code renews it on its next call'), snapshot: readLive() };
376
+ }
323
377
  const outcome = await fetchUsage({
324
378
  token: creds.token,
325
- url: opts.url || env.USAGE_LIMITS_USAGE_URL || USAGE_URL,
379
+ url: allowedUrl(opts.url || env.USAGE_LIMITS_USAGE_URL) || USAGE_URL,
326
380
  timeoutMs: opts.timeoutMs,
327
381
  });
328
382
  if (!outcome.ok) return { outcome, snapshot: readLive() };
@@ -352,11 +406,9 @@ function readAttempt() {
352
406
  }
353
407
 
354
408
  function writeAttempt(record) {
355
- try {
356
- fs.writeFileSync(attemptFile(), JSON.stringify(record), 'utf8');
357
- } catch (err) {
358
- // Without it the next caller merely tries again a little sooner.
359
- }
409
+ // Atomic, so a concurrent reader never parses half a record and skips the
410
+ // backoff by accident.
411
+ writeAtomic(attemptFile(), JSON.stringify(record));
360
412
  }
361
413
 
362
414
  // A reading only when the one on disk has aged. This is what the hooks call:
@@ -381,10 +433,19 @@ async function refreshIfStale(options) {
381
433
  if (newestAt > 0 && now - newestAt < maxAgeMs) return { outcome: null, snapshot: current, skipped: 'fresh' };
382
434
 
383
435
  const attempt = readAttempt();
384
- if (attempt && Number.isFinite(attempt.delayMs) && now - attempt.attemptedAtMs < attempt.delayMs) {
436
+ // A record from a clock that was ahead would hold the backoff until the
437
+ // wall clock caught up with it; a minute of skew is all that is honoured.
438
+ const sinceAttempt = attempt ? now - attempt.attemptedAtMs : 0;
439
+ if (attempt && Number.isFinite(attempt.delayMs) && sinceAttempt >= -MINUTE && sinceAttempt < attempt.delayMs) {
385
440
  return { outcome: null, snapshot: current, skipped: 'backoff' };
386
441
  }
387
442
 
443
+ // Claim the attempt before making it, so the other hooks that fire in the
444
+ // same second (parallel agents, several windows) skip rather than each
445
+ // sending its own request and each waiting out its own timeout.
446
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
447
+ writeAttempt({ attemptedAtMs: now, delayMs: timeoutMs, kind: 'inflight' });
448
+
388
449
  const result = await refresh({
389
450
  now,
390
451
  accountUuid: opts.accountUuid,
@@ -395,7 +456,10 @@ async function refreshIfStale(options) {
395
456
  });
396
457
  writeAttempt({
397
458
  attemptedAtMs: now,
398
- delayMs: nextDelayMs(result.outcome, attempt ? attempt.delayMs : 0, { baseMs: maxAgeMs, maxMs: 10 * MINUTE }),
459
+ delayMs: nextDelayMs(result.outcome, attempt && attempt.kind !== 'inflight' ? attempt.delayMs : 0, {
460
+ baseMs: maxAgeMs,
461
+ maxMs: 10 * MINUTE,
462
+ }),
399
463
  kind: result.outcome.ok ? 'ok' : result.outcome.kind,
400
464
  });
401
465
  return { outcome: result.outcome, snapshot: result.snapshot, skipped: null };
@@ -409,6 +473,9 @@ module.exports = {
409
473
  credentialsFile,
410
474
  liveFile,
411
475
  attemptFile,
476
+ allowedUrl,
477
+ isLoopback,
478
+ writeAtomic,
412
479
  userAgent,
413
480
  readToken,
414
481
  fetchUsage,
@@ -47,6 +47,7 @@ const FILE_CHECK_MS = SECOND;
47
47
  const FRAME_MS = 100;
48
48
  const MIN_COLUMNS = 24;
49
49
  const TITLE = 'Claude usage';
50
+ const LEVEL_RANK = { fill: 0, warning: 1, error: 2 };
50
51
 
51
52
  const HELP = `claude-usage-limits panel - live limits in a pane beside the chat
52
53
 
@@ -58,6 +59,7 @@ const HELP = `claude-usage-limits panel - live limits in a pane beside the chat
58
59
  panel --poll 45 seconds between readings (default 30 working, 120 idle)
59
60
  panel --width 40 draw for this many columns instead of the terminal's
60
61
  panel --ascii plain characters instead of block glyphs
62
+ panel --no-bell no terminal bell when a window turns yellow or red
61
63
 
62
64
  Shows the current session (5-hour) window, the current week, and the week for
63
65
  the model in use when the account caps that model on its own. Bars turn yellow
@@ -88,12 +90,14 @@ function parseArgs(argv) {
88
90
  width: null,
89
91
  help: false,
90
92
  ascii: String(process.env.USAGE_LIMITS_ASCII || '') === '1',
93
+ bell: true,
91
94
  hostName: null,
92
95
  };
93
96
  const list = argv || [];
94
97
  for (let i = 0; i < list.length; i += 1) {
95
98
  const arg = list[i];
96
99
  if (arg === '--once') args.once = true;
100
+ else if (arg === '--no-bell') args.bell = false;
97
101
  else if (arg === '--json') args.json = true;
98
102
  else if (arg === '--open') args.open = true;
99
103
  else if (arg === '--no-fetch') args.fetch = false;
@@ -182,6 +186,11 @@ async function snapshot(options) {
182
186
  built.sessionsList = onCodex ? [] : loadSessions(now);
183
187
  built.sessions = onCodex ? 0 : Math.max(built.sessionsList.length, brief.liveSessions(brief.readCache(), now, brief.LIVE_WINDOW_MS, null));
184
188
  built.othersWorking = built.sessionsList.filter((row) => row.state === 'working').length;
189
+ // What the pace says: when the binding window runs out at the current rate,
190
+ // if that comes before its reset. The transcript scan behind it belongs to
191
+ // the report, so it is taken with the readings, not with every frame; the
192
+ // frames in between carry the last answer forward.
193
+ built.pace = opts.pace !== undefined ? opts.pace : await paceOf(now);
185
194
  // Whether the panel is allowed the network at all, which is what the footer
186
195
  // reports. A frame rebuilt from disk between readings is not "network off".
187
196
  built.fetch = opts.network !== undefined ? Boolean(opts.network) : Boolean(opts.fetch);
@@ -189,6 +198,28 @@ async function snapshot(options) {
189
198
  return built;
190
199
  }
191
200
 
201
+ // The report's runway: how long the binding window lasts at the pace of the
202
+ // last hour, measured from every session's spend. The percentage says where
203
+ // you are; this says when you hit the wall, which is the number the rival
204
+ // monitors lead with and the one that matters on a busy afternoon.
205
+ async function paceOf(now) {
206
+ try {
207
+ const data = await usage.report(now, {});
208
+ const binding = data && data.binding;
209
+ if (!binding || !Number.isFinite(binding.headroomMs)) return null;
210
+ const resetsInMs = Number.isFinite(binding.resetsAt) ? binding.resetsAt - now : null;
211
+ return {
212
+ label: binding.label,
213
+ headroomMs: binding.headroomMs,
214
+ resetsInMs,
215
+ turnsLeft: Number.isFinite(binding.turnsLeft) ? binding.turnsLeft : null,
216
+ runsOut: binding.verdict === 'runs-out' || (resetsInMs !== null && binding.headroomMs < resetsInMs),
217
+ };
218
+ } catch (err) {
219
+ return null;
220
+ }
221
+ }
222
+
192
223
  // Codex has no hooks to say when it is working, but it appends to its rollout
193
224
  // file as it goes, so a rollout touched in the last few seconds is a turn in
194
225
  // progress.
@@ -252,6 +283,13 @@ function fit(text, width) {
252
283
  return out + '\x1b[0m';
253
284
  }
254
285
 
286
+ // In a narrow pane the titles are the short ones.
287
+ function shortTitle(row) {
288
+ if (row.key === 'five_hour') return 'Session';
289
+ if (row.key === 'seven_day') return 'Week';
290
+ return String(row.title).replace(/^Current week /, 'Week ').replace(/^Current /, '');
291
+ }
292
+
255
293
  function subline(row, mode, opts) {
256
294
  if (row.stale) return bars.dim('window rolled over, taking a fresh reading', mode);
257
295
  if (row.unreported) return bars.dim('not reported yet, run /usage in Claude Code', mode);
@@ -281,7 +319,7 @@ function stateLine(built, mode) {
281
319
  function noteColour(built) {
282
320
  const kind = built.outcome && !built.outcome.ok ? built.outcome.kind : null;
283
321
  if (kind === 'unauthorized' || kind === 'forbidden' || kind === 'no_credentials') return bars.THEME.error;
284
- if (kind === 'offline' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
322
+ if (kind === 'offline' || kind === 'expired' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
285
323
  return bars.THEME.warning;
286
324
  }
287
325
  return null;
@@ -291,7 +329,10 @@ function noteColour(built) {
291
329
  // dropping breathing room first and footers second.
292
330
  function render(built, options) {
293
331
  const opts = options || {};
294
- const columns = Math.max(MIN_COLUMNS, Number.isFinite(opts.columns) ? Math.floor(opts.columns) : 40);
332
+ // Laid out for at least MIN_COLUMNS, but cut to the width that really
333
+ // exists: a line wider than the pane wraps, and a wrapped frame scrolls.
334
+ const real = Number.isFinite(opts.columns) ? Math.max(1, Math.floor(opts.columns)) : 40;
335
+ const columns = Math.max(MIN_COLUMNS, real);
295
336
  const height = Number.isFinite(opts.rows) ? Math.floor(opts.rows) : null;
296
337
  const mode = opts.mode || 'none';
297
338
  const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
@@ -333,7 +374,7 @@ function render(built, options) {
333
374
  row.level === 'fill' ? row.percentText : bars.paint(row.percentText, bars.levelColour(row.level), mode);
334
375
  body.push({
335
376
  lines: [
336
- bars.bold(row.title, mode),
377
+ bars.bold(columns < 34 ? shortTitle(row) : row.title, mode),
337
378
  (row.percent === null ? bars.paint((ascii ? '-' : '░').repeat(barWidth), bars.THEME.empty, mode) : bars.bar(row.percent, barWidth, { mode, level: row.level, ascii })) +
338
379
  ' ' +
339
380
  percent,
@@ -379,6 +420,13 @@ function render(built, options) {
379
420
  const colour = noteColour(built);
380
421
  footer.push(colour ? bars.paint(built.note, colour, mode) : bars.dim(built.note, mode));
381
422
  }
423
+ if (built.pace && built.pace.runsOut && Number.isFinite(built.pace.headroomMs)) {
424
+ const text =
425
+ 'at this pace the ' + built.pace.label + ' window runs out in ' + usage.formatDuration(built.pace.headroomMs) +
426
+ (Number.isFinite(built.pace.turnsLeft) ? ', about ' + built.pace.turnsLeft + ' turns' : '');
427
+ const colour = built.pace.headroomMs < 10 * MINUTE ? bars.THEME.error : built.pace.headroomMs < 30 * MINUTE ? bars.THEME.warning : null;
428
+ footer.push(colour ? bars.paint(text, colour, mode) : bars.dim(text, mode));
429
+ }
382
430
  footer.push(stateLine(built, mode));
383
431
  if (opts.interactive !== false) footer.push(bars.dim('q quit · r refresh', mode));
384
432
 
@@ -399,7 +447,7 @@ function render(built, options) {
399
447
  if (height !== null && lines.length > height) lines = compose(false, true);
400
448
  if (height !== null && lines.length > height) lines = compose(false, false);
401
449
  if (height !== null && lines.length > height) lines = lines.slice(0, height);
402
- return lines.map((line) => fit(line, columns));
450
+ return lines.map((line) => fit(line, real));
403
451
  }
404
452
 
405
453
  function quote(value) {
@@ -408,7 +456,7 @@ function quote(value) {
408
456
 
409
457
  // How to put the panel in a pane to the right of the current one, for the
410
458
  // terminals that can be told to. Pure, so the table can be tested.
411
- function openCommand(env, panelPath, nodePath, platform, extraArgs) {
459
+ function openCommand(env, panelPath, nodePath, platform, extraArgs, options) {
412
460
  const e = env || process.env;
413
461
  const os = platform || process.platform;
414
462
  const node = nodePath || process.execPath;
@@ -417,23 +465,26 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
417
465
  const cmd = [quote(node), quote(panel)].concat(extra.map(quote)).join(' ');
418
466
 
419
467
  if (e.TMUX) {
468
+ // A percentage on -l arrived in tmux 3.1; older ones want the old -p.
469
+ const version = options && options.tmuxVersion ? String(options.tmuxVersion).match(/(\d+)\.(\d+)/) : null;
470
+ const old = version && (Number(version[1]) < 3 || (Number(version[1]) === 3 && Number(version[2]) < 1));
420
471
  return {
421
472
  program: 'tmux',
422
- args: ['split-window', '-h', '-d', '-l', '32%', cmd],
473
+ args: ['split-window', '-h', '-d'].concat(old ? ['-p', '24'] : ['-l', '24%'], [cmd]),
423
474
  note: 'opened a pane to the right in tmux',
424
475
  };
425
476
  }
426
477
  if (e.WEZTERM_PANE) {
427
478
  return {
428
479
  program: 'wezterm',
429
- args: ['cli', 'split-pane', '--right', '--percent', '32', '--', node, panel].concat(extra),
480
+ args: ['cli', 'split-pane', '--right', '--percent', '24', '--', node, panel].concat(extra),
430
481
  note: 'opened a pane to the right in WezTerm',
431
482
  };
432
483
  }
433
484
  if (e.KITTY_WINDOW_ID) {
434
485
  return {
435
486
  program: 'kitten',
436
- args: ['@', 'launch', '--location=vsplit', '--bias=32', '--cwd=current', node, panel].concat(extra),
487
+ args: ['@', 'launch', '--location=vsplit', '--bias=24', '--cwd=current', node, panel].concat(extra),
437
488
  note: 'opened a pane to the right in kitty (needs allow_remote_control)',
438
489
  };
439
490
  }
@@ -448,7 +499,7 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
448
499
  if (e.WT_SESSION) {
449
500
  return {
450
501
  command:
451
- 'start "" wt.exe -w 0 sp -V --size 0.32 --title "Claude usage" --suppressApplicationTitle ' + cmd,
502
+ 'start "" wt.exe -w 0 sp -V --size 0.24 --title "Claude usage" --suppressApplicationTitle ' + cmd,
452
503
  shell: true,
453
504
  note: 'opened a pane to the right in Windows Terminal',
454
505
  };
@@ -468,8 +519,20 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
468
519
  return null;
469
520
  }
470
521
 
522
+ function tmuxVersion() {
523
+ try {
524
+ const result = require('child_process').spawnSync('tmux', ['-V'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
525
+ return result && result.stdout ? String(result.stdout).trim() : null;
526
+ } catch (err) {
527
+ return null;
528
+ }
529
+ }
530
+
471
531
  function openPanel(env, extraArgs) {
472
- const plan = openCommand(env, __filename, process.execPath, process.platform, extraArgs);
532
+ const e = env || process.env;
533
+ const plan = openCommand(e, __filename, process.execPath, process.platform, extraArgs, {
534
+ tmuxVersion: e.TMUX ? tmuxVersion() : null,
535
+ });
473
536
  if (!plan) {
474
537
  process.stdout.write(
475
538
  'This terminal cannot be told to split. Open a second pane to the right and run:\n ' +
@@ -521,8 +584,10 @@ async function interactive(args) {
521
584
  const clock = feed.clockFor(settings, env);
522
585
  const mode = bars.colourMode(env, out.isTTY);
523
586
  const fetch = args.fetch && !live.fetchDisabled(env);
587
+ const bell = args.bell && String(env.USAGE_LIMITS_BELL || '').toLowerCase() !== 'off';
524
588
 
525
589
  const state = {
590
+ levels: {},
526
591
  built: null,
527
592
  outcome: null,
528
593
  lastFetchAt: 0,
@@ -601,27 +666,49 @@ async function interactive(args) {
601
666
  const now = Date.now();
602
667
  const due = fetch && !state.fetching && now - state.lastFetchAt >= state.delayMs;
603
668
  if (due) {
669
+ // The reading runs beside the frames, never in front of them: a slow
670
+ // network must not freeze the spinner or the countdown.
604
671
  state.fetching = true;
605
- try {
606
- state.built = await snapshot({ fetch: true, network: fetch, env, now });
607
- state.outcome = state.built.outcome;
608
- } catch (err) {
609
- state.outcome = { ok: false, kind: 'bad_response', message: err.message };
610
- }
611
- state.lastFetchAt = Date.now();
612
- state.delayMs = live.nextDelayMs(state.outcome, state.delayMs, {
613
- baseMs: pollBase(state.built, args, env),
614
- maxMs: POLL_IDLE_MS,
615
- });
616
- state.fetching = false;
617
- state.dirty = true;
618
- } else if (!state.built || now - state.lastCheck >= FILE_CHECK_MS) {
672
+ snapshot({ fetch: true, network: fetch, env, now })
673
+ .then((built) => {
674
+ state.built = built;
675
+ state.outcome = built.outcome;
676
+ })
677
+ .catch((err) => {
678
+ state.outcome = { ok: false, kind: 'bad_response', message: err && err.message ? err.message : String(err) };
679
+ })
680
+ .then(() => {
681
+ state.lastFetchAt = Date.now();
682
+ state.delayMs = live.nextDelayMs(state.outcome, state.delayMs, {
683
+ baseMs: pollBase(state.built, args, env),
684
+ maxMs: POLL_IDLE_MS,
685
+ });
686
+ state.fetching = false;
687
+ state.dirty = true;
688
+ });
689
+ }
690
+ if (!state.built || now - state.lastCheck >= FILE_CHECK_MS) {
619
691
  state.lastCheck = now;
620
692
  try {
621
- state.built = await snapshot({ fetch: false, network: fetch, env, now, outcome: state.outcome });
693
+ state.built = await snapshot({
694
+ fetch: false,
695
+ network: fetch,
696
+ env,
697
+ now,
698
+ outcome: state.outcome,
699
+ pace: state.built ? state.built.pace : null,
700
+ });
622
701
  } catch (err) {
623
702
  // Keep the last frame; a transient read error is not worth a blank.
624
703
  }
704
+ // One bell when a window first turns yellow, another when it turns red.
705
+ if (bell && state.built) {
706
+ for (const row of state.built.rows) {
707
+ const before = state.levels[row.key];
708
+ if (before !== undefined && LEVEL_RANK[row.level] > LEVEL_RANK[before]) out.write('\x07');
709
+ state.levels[row.key] = row.level;
710
+ }
711
+ }
625
712
  // A window that rolled over deserves a reading sooner than the timer.
626
713
  if (fetch && state.built && state.built.rows.some((row) => row.stale) && state.delayMs > 5 * SECOND) {
627
714
  state.delayMs = 5 * SECOND;
@@ -116,6 +116,8 @@ function noteFor(outcome, ageMs) {
116
116
  return 'network off' + suffix;
117
117
  case 'unauthorized':
118
118
  return 'sign in to Claude Code again' + suffix;
119
+ case 'expired':
120
+ return 'the login has expired, Claude Code renews it on its next call' + suffix;
119
121
  case 'forbidden':
120
122
  return 'usage is not available for this login' + suffix;
121
123
  case 'no_credentials':
@@ -141,7 +143,10 @@ function build(input) {
141
143
  // knows for certain; the setting is the fallback; nothing is hidden when
142
144
  // neither says.
143
145
  const model = opts.model || opts.settingsModel || env.ANTHROPIC_MODEL || null;
144
- const families = usage.familiesInUse(null, null, [opts.model, opts.settingsModel, env.ANTHROPIC_MODEL]);
146
+ // When Claude Code has said which model is running, that is the whole
147
+ // answer: adding the setting on top would keep a Fable week on screen after
148
+ // /model moved the session to Opus. The setting is only the fallback.
149
+ const families = usage.familiesInUse(null, null, opts.model ? [opts.model] : [opts.settingsModel, env.ANTHROPIC_MODEL]);
145
150
 
146
151
  const rows = [];
147
152
  for (const key of ['five_hour', 'seven_day']) {