claude-usage-limits 1.11.0 → 1.11.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.
@@ -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.2",
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.2",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
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,
@@ -281,7 +281,7 @@ function stateLine(built, mode) {
281
281
  function noteColour(built) {
282
282
  const kind = built.outcome && !built.outcome.ok ? built.outcome.kind : null;
283
283
  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') {
284
+ if (kind === 'offline' || kind === 'expired' || kind === 'rate_limited' || kind === 'server' || kind === 'http' || kind === 'bad_response') {
285
285
  return bars.THEME.warning;
286
286
  }
287
287
  return null;
@@ -291,7 +291,10 @@ function noteColour(built) {
291
291
  // dropping breathing room first and footers second.
292
292
  function render(built, options) {
293
293
  const opts = options || {};
294
- const columns = Math.max(MIN_COLUMNS, Number.isFinite(opts.columns) ? Math.floor(opts.columns) : 40);
294
+ // Laid out for at least MIN_COLUMNS, but cut to the width that really
295
+ // exists: a line wider than the pane wraps, and a wrapped frame scrolls.
296
+ const real = Number.isFinite(opts.columns) ? Math.max(1, Math.floor(opts.columns)) : 40;
297
+ const columns = Math.max(MIN_COLUMNS, real);
295
298
  const height = Number.isFinite(opts.rows) ? Math.floor(opts.rows) : null;
296
299
  const mode = opts.mode || 'none';
297
300
  const tick = Number.isFinite(opts.tick) ? opts.tick : 0;
@@ -399,7 +402,7 @@ function render(built, options) {
399
402
  if (height !== null && lines.length > height) lines = compose(false, true);
400
403
  if (height !== null && lines.length > height) lines = compose(false, false);
401
404
  if (height !== null && lines.length > height) lines = lines.slice(0, height);
402
- return lines.map((line) => fit(line, columns));
405
+ return lines.map((line) => fit(line, real));
403
406
  }
404
407
 
405
408
  function quote(value) {
@@ -408,7 +411,7 @@ function quote(value) {
408
411
 
409
412
  // How to put the panel in a pane to the right of the current one, for the
410
413
  // terminals that can be told to. Pure, so the table can be tested.
411
- function openCommand(env, panelPath, nodePath, platform, extraArgs) {
414
+ function openCommand(env, panelPath, nodePath, platform, extraArgs, options) {
412
415
  const e = env || process.env;
413
416
  const os = platform || process.platform;
414
417
  const node = nodePath || process.execPath;
@@ -417,9 +420,12 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
417
420
  const cmd = [quote(node), quote(panel)].concat(extra.map(quote)).join(' ');
418
421
 
419
422
  if (e.TMUX) {
423
+ // A percentage on -l arrived in tmux 3.1; older ones want the old -p.
424
+ const version = options && options.tmuxVersion ? String(options.tmuxVersion).match(/(\d+)\.(\d+)/) : null;
425
+ const old = version && (Number(version[1]) < 3 || (Number(version[1]) === 3 && Number(version[2]) < 1));
420
426
  return {
421
427
  program: 'tmux',
422
- args: ['split-window', '-h', '-d', '-l', '32%', cmd],
428
+ args: ['split-window', '-h', '-d'].concat(old ? ['-p', '32'] : ['-l', '32%'], [cmd]),
423
429
  note: 'opened a pane to the right in tmux',
424
430
  };
425
431
  }
@@ -468,8 +474,20 @@ function openCommand(env, panelPath, nodePath, platform, extraArgs) {
468
474
  return null;
469
475
  }
470
476
 
477
+ function tmuxVersion() {
478
+ try {
479
+ const result = require('child_process').spawnSync('tmux', ['-V'], { encoding: 'utf8', timeout: 2000, windowsHide: true });
480
+ return result && result.stdout ? String(result.stdout).trim() : null;
481
+ } catch (err) {
482
+ return null;
483
+ }
484
+ }
485
+
471
486
  function openPanel(env, extraArgs) {
472
- const plan = openCommand(env, __filename, process.execPath, process.platform, extraArgs);
487
+ const e = env || process.env;
488
+ const plan = openCommand(e, __filename, process.execPath, process.platform, extraArgs, {
489
+ tmuxVersion: e.TMUX ? tmuxVersion() : null,
490
+ });
473
491
  if (!plan) {
474
492
  process.stdout.write(
475
493
  'This terminal cannot be told to split. Open a second pane to the right and run:\n ' +
@@ -601,21 +619,28 @@ async function interactive(args) {
601
619
  const now = Date.now();
602
620
  const due = fetch && !state.fetching && now - state.lastFetchAt >= state.delayMs;
603
621
  if (due) {
622
+ // The reading runs beside the frames, never in front of them: a slow
623
+ // network must not freeze the spinner or the countdown.
604
624
  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) {
625
+ snapshot({ fetch: true, network: fetch, env, now })
626
+ .then((built) => {
627
+ state.built = built;
628
+ state.outcome = built.outcome;
629
+ })
630
+ .catch((err) => {
631
+ state.outcome = { ok: false, kind: 'bad_response', message: err && err.message ? err.message : String(err) };
632
+ })
633
+ .then(() => {
634
+ state.lastFetchAt = Date.now();
635
+ state.delayMs = live.nextDelayMs(state.outcome, state.delayMs, {
636
+ baseMs: pollBase(state.built, args, env),
637
+ maxMs: POLL_IDLE_MS,
638
+ });
639
+ state.fetching = false;
640
+ state.dirty = true;
641
+ });
642
+ }
643
+ if (!state.built || now - state.lastCheck >= FILE_CHECK_MS) {
619
644
  state.lastCheck = now;
620
645
  try {
621
646
  state.built = await snapshot({ fetch: false, network: fetch, env, now, outcome: state.outcome });
@@ -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']) {