ctxline-claude 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.
Files changed (3) hide show
  1. package/README.md +21 -16
  2. package/package.json +3 -2
  3. package/statusline.js +346 -149
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="docs/assets/apple-touch-icon.png" width="64" height="64" alt="">
3
+ </p>
4
+
1
5
  <h1 align="center">Claude Code Statusline</h1>
2
6
 
3
7
  <p align="center">
@@ -5,21 +9,14 @@
5
9
  </p>
6
10
 
7
11
  <p align="center">
8
- <a href="https://www.npmjs.com/package/ctxline-claude">
9
- <img src="https://img.shields.io/npm/v/ctxline-claude" alt="npm version">
10
- </a>
11
- <a href="https://www.npmjs.com/package/ctxline-claude">
12
- <img src="https://img.shields.io/npm/dm/ctxline-claude" alt="npm downloads">
13
- </a>
14
- <a href="LICENSE">
15
- <img src="https://img.shields.io/github/license/MithunWijayasiri/ctxline-claude" alt="license">
16
- </a>
17
- <a href="https://github.com/MithunWijayasiri/ctxline-claude/stargazers">
18
- <img src="https://img.shields.io/github/stars/MithunWijayasiri/ctxline-claude" alt="stars">
19
- </a>
20
- <a href="https://ko-fi.com/mithunwijayasiri">
21
- <img src="https://img.shields.io/badge/Ko--fi-support-ff5e5b?logo=ko-fi&logoColor=white" alt="Support on Ko-fi">
22
- </a>
12
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/stargazers"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="GitHub Stars" src="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
13
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/graphs/contributors"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
14
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/commits/main"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="Last commit" src="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
15
+ <a href="https://ko-fi.com/mithunwijayasiri"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=secondary&amp;size=sm&amp;mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
16
+ <br>
17
+ <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Version" src="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
18
+ <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
19
+ <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Total Downloads" src="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
23
20
  </p>
24
21
 
25
22
  <p align="center">
@@ -140,6 +137,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
140
137
  | **Model limit** | Weekly limit scoped to a single model, when your account has one — labelled by the model's initial (`F` = Fable) |
141
138
  | **Cost** | Running session cost in USD (e.g. `$0.42`) |
142
139
  | **Task** | The in-progress todo, when there is one |
140
+ | **Update** | An extra row with the upgrade command when a newer release is on npm — checked once a week, in the background |
143
141
 
144
142
  > [!NOTE]
145
143
  > Usage bars change color automatically as you approach your limits.
@@ -154,7 +152,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
154
152
 
155
153
  The statusline is zero-config by default. To **hide segments you don't want**, set the `CTXLINE_DISABLE` environment variable to a comma-separated list of any of:
156
154
 
157
- `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly + model-scoped)
155
+ `branch` · `effort` · `cost` · `task` · `update` · `usage` (5-hour + weekly + model-scoped)
158
156
 
159
157
  Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
160
158
 
@@ -221,6 +219,13 @@ No, it's imperceptible. Almost every render reads a small local cache (sub-milli
221
219
 
222
220
  </details>
223
221
 
222
+ <details>
223
+ <summary>What is the ⬆ segment, and what does it send?</summary>
224
+
225
+ An extra row appears below the statusline — `⬆ 1.7.0 available · npx ctxline-claude@latest` — so the upgrade command is right there to copy. It shows only while you're behind, and the command works whichever way you installed. Once a week a short-lived background process asks the public npm registry for the package's latest version number and writes it to a local cache; the statusline itself only ever reads that cache, so no render waits on the network. The request carries no session data, credentials, or identifiers — it's a plain GET for a public package, though as with any HTTP request the registry does see your IP address. Hide it (and skip the request entirely) with `CTXLINE_DISABLE=update`.
226
+
227
+ </details>
228
+
224
229
  <details>
225
230
  <summary>Does this use the same data as /usage?</summary>
226
231
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "A customizable statusline for Claude Code that tracks context usage and session limits",
5
5
  "bin": {
6
6
  "ctxline-claude": "bin/install.js"
7
7
  },
8
8
  "scripts": {
9
9
  "test": "node --test",
10
- "preview": "node scripts/preview.js"
10
+ "preview": "node scripts/preview.js",
11
+ "preview:svg": "node scripts/preview-svg.js"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=18"
package/statusline.js CHANGED
@@ -8,12 +8,18 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
10
  const https = require('https');
11
- const { execSync, execFileSync } = require('child_process');
11
+ const { execSync, execFileSync, spawn } = require('child_process');
12
+
13
+ // Installed version, compared against the npm registry's latest for the update nudge.
14
+ // This file is copied standalone into ~/.claude/hooks/ with no package.json beside it,
15
+ // so the version has to live here. Must match package.json "version" (see CLAUDE.md).
16
+ const VERSION = '1.7.0';
12
17
 
13
18
  const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
19
 
15
20
  // Optional segment opt-out: CTXLINE_DISABLE is a comma list of segments to hide.
16
- // Recognized: branch, effort, cost, task, usage (H+W+model-scoped). dir/model/context always render.
21
+ // Recognized: branch, effort, cost, task, update, usage (H+W+model-scoped). dir/model/context
22
+ // always render.
17
23
  // Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
18
24
  // usage fetch).
19
25
  const DISABLED = new Set(
@@ -54,6 +60,17 @@ const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burs
54
60
  const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
55
61
  const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
56
62
 
63
+ // Update check: compares VERSION against the npm registry's dist-tags.latest. The render
64
+ // only ever reads this cache; the refresh runs in a detached child (see refreshUpdateCheck),
65
+ // so no render ever waits on the registry.
66
+ const UPDATE_CACHE_FILE = path.join(CACHE_DIR, 'update-cache.json');
67
+ const UPDATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days between successful checks
68
+ const UPDATE_RETRY_MS = 60 * 60 * 1000; // 1h backoff after a failed/killed check
69
+ const UPDATE_TIMEOUT_MS = 2000; // socket idle AND whole-request deadline
70
+ const REGISTRY_HOST = 'registry.npmjs.org';
71
+ const PACKAGE_NAME = 'ctxline-claude';
72
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/; // releases only: a prerelease never nudges
73
+
57
74
  // Subagent mode reads only stdin (no usage API to race), so its stdin read gets a
58
75
  // short hard cap of its own instead of the main-mode overallTimeout.
59
76
  const SUBAGENT_TIMEOUT_MS = 500;
@@ -249,6 +266,11 @@ function getContextBar(remaining) {
249
266
  return renderContextBar(used);
250
267
  }
251
268
 
269
+ // "model" or "model · effort" (colored). Shared by the main line and subagent rows.
270
+ function renderModelEffort(model, effort) {
271
+ return effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model;
272
+ }
273
+
252
274
  // Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
253
275
  // (e.g. "H81 ↺ 2h21m") — no bar. Called on every read (live or cached) so the reset
254
276
  // countdown is always recomputed from resetsAt rather than frozen at fetch time.
@@ -290,12 +312,15 @@ const LEGACY_MODEL_WEEKLY_KEYS = [
290
312
  { key: 'seven_day_sonnet', label: 'S' }
291
313
  ];
292
314
 
293
- // Build the usage segments from raw entries. fiveHour/weekly are { percentage, resetsAt }
294
- // or null/absent; models is an array of { label, percentage, resetsAt } (possibly empty).
295
- // Returns { current, weekly, models } the first two rendered strings or null, models a
296
- // (possibly empty) array of rendered strings. Scoped bars use getScopedColor instead of the
297
- // H/W thresholds, so the full threshold palette stays exclusive to H/W.
298
- function buildUsageBars(fiveHour, weekly, models) {
315
+ // Build the usage segments from a raw { fiveHour, weekly, models } object the shared
316
+ // shape both buildUsageFromStdin and parseUsagePayload return. fiveHour/weekly are
317
+ // { percentage, resetsAt } or null/absent; models is an array of { label, percentage,
318
+ // resetsAt } (possibly empty). Returns { current, weekly, models } the first two
319
+ // rendered strings or null, models a (possibly empty) array of rendered strings. Scoped
320
+ // bars use getScopedColor instead of the H/W thresholds, so the full threshold palette
321
+ // stays exclusive to H/W.
322
+ function buildUsageBars(raw) {
323
+ const { fiveHour, weekly, models } = raw || {};
299
324
  return {
300
325
  current: fiveHour ? buildUsageBar('H', fiveHour.percentage, fiveHour.resetsAt) : null,
301
326
  weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null,
@@ -344,9 +369,10 @@ function parseScopedLimits(usage) {
344
369
  // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
345
370
  // first API response of a session). Same data as the OAuth usage API, so reading it here
346
371
  // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
347
- // SECONDS (not ISO) — ×1000 before Date. Returns raw { fiveHour, weekly } entries, or null
348
- // when rate_limits is absent or the required five_hour segment is unusable (caller falls
349
- // back). Model-scoped weekly limits are never present here — see LEGACY_MODEL_WEEKLY_KEYS.
372
+ // SECONDS (not ISO) — ×1000 before Date. Returns raw { fiveHour, weekly, models } same
373
+ // shape as parseUsagePayload — or null when rate_limits is absent or the required
374
+ // five_hour segment is unusable (caller falls back). models is always [] here: model-scoped
375
+ // weekly limits are never present in stdin — see LEGACY_MODEL_WEEKLY_KEYS.
350
376
  function buildUsageFromStdin(data) {
351
377
  const rl = data?.rate_limits;
352
378
  if (!rl) return null;
@@ -369,7 +395,29 @@ function buildUsageFromStdin(data) {
369
395
 
370
396
  const fiveHour = toEntry(rl.five_hour);
371
397
  if (!fiveHour) return null; // five_hour is the required bar
372
- return { fiveHour, weekly: toEntry(rl.seven_day) };
398
+ return { fiveHour, weekly: toEntry(rl.seven_day), models: [] };
399
+ }
400
+
401
+ // Parse a raw /usage API response body into { fiveHour, weekly, models } — same shape as
402
+ // buildUsageFromStdin — or null on unparseable JSON or a missing/non-finite five_hour
403
+ // utilization (that bar is required). Normalizes utilization first so a missing/non-finite
404
+ // value omits a bar instead of rendering "NaN%". Pure — no fs/network — so it's unit
405
+ // testable directly, unlike getApiUsage which needs a live socket.
406
+ function parseUsagePayload(body) {
407
+ try {
408
+ const usage = JSON.parse(body);
409
+ const fivePct = usage?.five_hour ? normalizePercentage(usage.five_hour.utilization) : null;
410
+ if (fivePct == null) return null;
411
+
412
+ const fiveHour = { percentage: fivePct, resetsAt: usage.five_hour.resets_at || null };
413
+ const weeklyPct = usage.seven_day ? normalizePercentage(usage.seven_day.utilization) : null;
414
+ const weekly = weeklyPct != null ? { percentage: weeklyPct, resetsAt: usage.seven_day.resets_at || null } : null;
415
+ const models = parseScopedLimits(usage);
416
+
417
+ return { fiveHour, weekly, models };
418
+ } catch (e) {
419
+ return null;
420
+ }
373
421
  }
374
422
 
375
423
  // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
@@ -410,6 +458,15 @@ function readCachedUsage() {
410
458
  }
411
459
  }
412
460
 
461
+ // Serialize usage data into the on-disk cache shape ({ timestamp, data, lastAttempt }). Pure
462
+ // -- no fs -- so setCachedUsage and the test/preview cache seeds all produce exactly the same
463
+ // bytes the real writer would; a reader/writer format mismatch becomes structurally impossible
464
+ // instead of merely untested. `timestamp` defaults to now; tests override it to seed a stale
465
+ // cache. A successful write is itself an attempt, so `lastAttempt` starts equal to `timestamp`.
466
+ function serializeUsageCache(data, timestamp = Date.now()) {
467
+ return JSON.stringify({ timestamp, data, lastAttempt: timestamp });
468
+ }
469
+
413
470
  // Write usage data to cache (shared across all sessions)
414
471
  function setCachedUsage(data) {
415
472
  try {
@@ -417,17 +474,165 @@ function setCachedUsage(data) {
417
474
  fs.mkdirSync(CACHE_DIR, { recursive: true });
418
475
  }
419
476
 
420
- const cache = {
421
- timestamp: Date.now(),
422
- data: data
423
- };
477
+ fs.writeFileSync(USAGE_CACHE_FILE, serializeUsageCache(data), 'utf8');
478
+ } catch (e) {
479
+ // Silently fail
480
+ }
481
+ }
424
482
 
425
- fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(cache), 'utf8');
483
+ // Age in ms since the last API attempt (success or failure), or null if none recorded yet.
484
+ // Read directly from the raw file rather than via readCachedUsage so the cooldown still
485
+ // applies when no valid data has ever been cached (every attempt so far has failed).
486
+ function getLastAttemptAge() {
487
+ try {
488
+ if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
489
+ const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
490
+ if (!cache || !Number.isFinite(cache.lastAttempt) || cache.lastAttempt <= 0) return null;
491
+ return Date.now() - cache.lastAttempt;
492
+ } catch (e) {
493
+ return null;
494
+ }
495
+ }
496
+
497
+ // Record that an API attempt is starting, preserving any existing cached data/timestamp so a
498
+ // failed refresh doesn't erase the last successful one. Written before the request so a hang
499
+ // or a process exit mid-request still counts as an attempt for cooldown purposes.
500
+ function recordUsageAttempt() {
501
+ try {
502
+ if (!fs.existsSync(CACHE_DIR)) {
503
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
504
+ }
505
+ let existing = null;
506
+ try {
507
+ existing = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
508
+ } catch (e) {}
509
+ const merged = existing && typeof existing === 'object' ? { ...existing } : {};
510
+ merged.lastAttempt = Date.now();
511
+ fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(merged), 'utf8');
426
512
  } catch (e) {
427
513
  // Silently fail
428
514
  }
429
515
  }
430
516
 
517
+ // Compare two strict "x.y.z" versions -> -1 | 0 | 1, or null when either side isn't that
518
+ // shape (prerelease tags, missing parts, non-numeric). The nudge is a nicety, so an
519
+ // unparseable version means no segment rather than a guess.
520
+ function compareVersions(a, b) {
521
+ const parse = (v) => SEMVER_RE.test(String(v ?? '')) ? String(v).split('.').map(Number) : null;
522
+ const x = parse(a);
523
+ const y = parse(b);
524
+ if (!x || !y) return null;
525
+ for (let i = 0; i < 3; i++) {
526
+ if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
527
+ }
528
+ return 0;
529
+ }
530
+
531
+ // Pure: an npm registry version-manifest body -> its "x.y.z" version string, or null on
532
+ // unparseable JSON, a missing version, or a non-release version (404 bodies land here too).
533
+ function parseRegistryVersion(body) {
534
+ try {
535
+ const v = JSON.parse(body)?.version;
536
+ return SEMVER_RE.test(String(v ?? '')) ? String(v) : null;
537
+ } catch (e) {
538
+ return null;
539
+ }
540
+ }
541
+
542
+ function readUpdateCache() {
543
+ try {
544
+ const c = JSON.parse(fs.readFileSync(UPDATE_CACHE_FILE, 'utf8'));
545
+ return c && typeof c === 'object' ? c : null;
546
+ } catch (e) {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ function writeUpdateCache(obj) {
552
+ try {
553
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
554
+ fs.writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(obj), 'utf8');
555
+ } catch (e) {}
556
+ }
557
+
558
+ // The cached latest version when it's newer than VERSION, else ''. Cache-only by design:
559
+ // collectFacts calls this on the render path, and the render must never touch the network.
560
+ function getLatestUpdate() {
561
+ const cached = readUpdateCache();
562
+ if (!cached) return '';
563
+ return compareVersions(cached.latest, VERSION) === 1 ? String(cached.latest) : '';
564
+ }
565
+
566
+ // Fire the weekly check in a detached child, so the render neither waits on the registry
567
+ // nor races its own exit against the response. lastAttempt is stamped before the spawn, so
568
+ // an offline machine, a failed spawn, or a child that dies backs off UPDATE_RETRY_MS
569
+ // instead of respawning on every render.
570
+ function refreshUpdateCheck() {
571
+ try {
572
+ const cached = readUpdateCache();
573
+ const now = Date.now();
574
+ if (cached) {
575
+ if (Number.isFinite(cached.checkedAt) && now - cached.checkedAt < UPDATE_TTL_MS) return;
576
+ if (Number.isFinite(cached.lastAttempt) && now - cached.lastAttempt < UPDATE_RETRY_MS) return;
577
+ }
578
+ writeUpdateCache({ ...(cached || {}), lastAttempt: now });
579
+ // windowsHide: a detached console app would otherwise flash its own console window on
580
+ // Windows. detached + unref so the child outlives this render's exit(0).
581
+ spawn(process.execPath, [__filename, 'update-check'], {
582
+ detached: true, stdio: 'ignore', windowsHide: true
583
+ }).unref();
584
+ } catch (e) {}
585
+ }
586
+
587
+ // The 'update-check' entry point: the detached child. Fetches the registry's latest
588
+ // version, stamps the cache, exits. Writes nothing to stdout — it is not a statusline
589
+ // mode, and its stdio is discarded by the parent anyway. A failed fetch leaves checkedAt
590
+ // untouched, so the UPDATE_RETRY_MS backoff (not the weekly TTL) governs the next try.
591
+ function runUpdateCheck() {
592
+ let settled = false;
593
+ let deadline;
594
+
595
+ const done = (latest) => {
596
+ if (settled) return;
597
+ settled = true;
598
+ clearTimeout(deadline);
599
+ if (latest) writeUpdateCache({ ...(readUpdateCache() || {}), latest, checkedAt: Date.now() });
600
+ process.exit(0);
601
+ };
602
+
603
+ try {
604
+ const req = https.request({
605
+ hostname: REGISTRY_HOST,
606
+ path: `/${PACKAGE_NAME}/latest`,
607
+ method: 'GET',
608
+ headers: { 'Accept': 'application/json' },
609
+ timeout: UPDATE_TIMEOUT_MS
610
+ }, (res) => {
611
+ let body = '';
612
+ res.on('data', chunk => body += chunk);
613
+ res.on('end', () => done(parseRegistryVersion(body)));
614
+ });
615
+
616
+ req.on('error', () => done(null));
617
+ req.on('timeout', () => {
618
+ req.destroy();
619
+ done(null);
620
+ });
621
+
622
+ // The `timeout` option above is socket inactivity, not total duration -- a response
623
+ // that trickles bytes would keep this detached child alive indefinitely, and the
624
+ // parent's UPDATE_RETRY_MS only delays the next spawn, it can't reap this one.
625
+ deadline = setTimeout(() => {
626
+ req.destroy();
627
+ done(null);
628
+ }, UPDATE_TIMEOUT_MS);
629
+
630
+ req.end();
631
+ } catch (e) {
632
+ done(null);
633
+ }
634
+ }
635
+
431
636
  function getCredentials() {
432
637
  // Try file first (legacy / Linux / Windows)
433
638
  const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -483,37 +688,10 @@ function getApiUsage(callback) {
483
688
 
484
689
  res.on('data', chunk => data += chunk);
485
690
  res.on('end', () => {
486
- try {
487
- const usage = JSON.parse(data);
488
-
489
- // 5-hour session usage is required; weekly (seven_day) is rendered when present.
490
- // Normalize utilization first so a missing/non-finite value omits the bar
491
- // instead of rendering "NaN%" or an out-of-range percentage.
492
- const fivePct = usage.five_hour ? normalizePercentage(usage.five_hour.utilization) : null;
493
- if (fivePct != null) {
494
- const fiveHour = {
495
- percentage: fivePct,
496
- resetsAt: usage.five_hour.resets_at || null
497
- };
498
- const weeklyPct = usage.seven_day ? normalizePercentage(usage.seven_day.utilization) : null;
499
- const weekly = weeklyPct != null ? {
500
- percentage: weeklyPct,
501
- resetsAt: usage.seven_day.resets_at || null
502
- } : null;
503
-
504
- // Model-scoped weekly limits, rendered only when the account reports them.
505
- const models = parseScopedLimits(usage);
506
-
507
- // Cache the raw data (shared across sessions); callers render from it.
508
- const resolved = { fiveHour, weekly, models };
509
- setCachedUsage(resolved);
510
- callback(resolved);
511
- } else {
512
- callback(null);
513
- }
514
- } catch (e) {
515
- callback(null);
516
- }
691
+ const resolved = parseUsagePayload(data);
692
+ // Cache the raw data (shared across sessions); callers render from it.
693
+ if (resolved) setCachedUsage(resolved);
694
+ callback(resolved);
517
695
  });
518
696
  });
519
697
 
@@ -538,7 +716,17 @@ function getRawUsage(callback) {
538
716
  return callback(cached.data);
539
717
  }
540
718
 
541
- // Cache is stale or missing -> refresh from the API.
719
+ // A refresh (successful or not) was attempted within FRESH_TTL_MS -> still in cooldown,
720
+ // don't hit the API again. Serve stale cached data if it's still within STALE_TTL_MS, else
721
+ // nothing. Without this, a repeatedly failing/timing-out refresh would re-hit the API on
722
+ // every render instead of backing off (issue #41).
723
+ const attemptAge = getLastAttemptAge();
724
+ if (attemptAge != null && attemptAge < FRESH_TTL_MS) {
725
+ return callback(cached && cached.age < STALE_TTL_MS ? cached.data : null);
726
+ }
727
+
728
+ // Cache is stale or missing and no attempt is in cooldown -> refresh from the API.
729
+ recordUsageAttempt();
542
730
  getApiUsage((fresh) => {
543
731
  if (fresh) {
544
732
  callback(fresh);
@@ -551,20 +739,6 @@ function getRawUsage(callback) {
551
739
  });
552
740
  }
553
741
 
554
- // Get usage, cache-first, rendered.
555
- function getUsageWithCache(callback) {
556
- getRawUsage((data) => {
557
- callback(data ? buildUsageBars(data.fiveHour, data.weekly, data.models) : null);
558
- });
559
- }
560
-
561
- // Model-scoped weekly limits only, cache-first. Used alongside the stdin H/W bars, which
562
- // can't carry them. Falls back to the stale cache and finally to [] so a failed or slow
563
- // call costs the scoped bars but never the bars stdin already gave us.
564
- function getScopedModels(callback) {
565
- getRawUsage((data) => callback(data?.models || []));
566
- }
567
-
568
742
  // Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
569
743
  // Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
570
744
  // Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
@@ -604,63 +778,93 @@ function visibleWidth(str) {
604
778
  }
605
779
 
606
780
  // Responsive layout: one line when it fits the terminal, else line1 (identity + context)
607
- // on top and line2 (usage/cost/task) below. Splits only when COLUMNS is known (Claude Code
608
- // v2.1.153+) and the single line overflows — unknown width or an empty line2 stays single,
609
- // so there is no regression on older clients or wide terminals.
610
- function layout(line1Parts, line2Parts) {
781
+ // on top and line2 (usage/cost/task) below. Splits only when cols is known (Claude Code
782
+ // v2.1.153+ sets COLUMNS, read by collectFacts) and the single line overflows — unknown
783
+ // width or an empty line2 stays single, so there is no regression on older clients or wide
784
+ // terminals.
785
+ function layout(line1Parts, line2Parts, cols) {
611
786
  const single = [...line1Parts, ...line2Parts].join(SEGMENT_SEP);
612
787
  if (line2Parts.length === 0) return single;
613
- const cols = parseInt(process.env.COLUMNS, 10);
614
788
  if (Number.isFinite(cols) && cols > 0 && visibleWidth(single) > cols - WIDTH_MARGIN) {
615
789
  return line1Parts.join(SEGMENT_SEP) + '\n' + line2Parts.join(SEGMENT_SEP);
616
790
  }
617
791
  return single;
618
792
  }
619
793
 
620
- // Main
621
- function outputStatus(data, usage) {
794
+ // Gathers everything outputStatus needs that touches fs/child_process/env: git branch +
795
+ // ahead/behind (.git/HEAD, `git rev-list`), the in-progress task (~/.claude/todos), and the
796
+ // terminal width (COLUMNS). Kept separate from renderStatusLine so the render step is pure.
797
+ // Wrapped in its own try/catch (unlike renderStatusLine, it's called outside outputStatus's
798
+ // try/catch in emit()) — a malformed workspace.current_dir (e.g. non-string) can throw from
799
+ // path.basename or resolveGitDir, and this must still degrade to a renderable fallback.
800
+ function collectFacts(data) {
622
801
  try {
623
- const model = shortenModel(data?.model?.display_name || 'Claude');
624
802
  const dir = data?.workspace?.current_dir || process.cwd();
625
803
  const dirname = path.basename(dir);
626
804
  const branch = DISABLED.has('branch') ? '' : getGitBranch(dir);
627
805
  const sync = branch ? formatAheadBehind(getGitAheadBehind(dir)) : '';
628
- const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
629
806
  const sessionId = data?.session_id || '';
630
- const remaining = data?.context_window?.remaining_percentage;
631
-
632
- const contextBar = getContextBar(remaining);
633
- const cost = DISABLED.has('cost') ? '' : getCostSegment(data);
634
807
  const task = DISABLED.has('task') ? '' : getCurrentTask(sessionId);
808
+ const update = DISABLED.has('update') ? '' : getLatestUpdate();
809
+ const cols = parseInt(process.env.COLUMNS, 10);
810
+ return { dirname, branch, sync, task, update, cols };
811
+ } catch (e) {
812
+ return { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
813
+ }
814
+ }
815
+
816
+ // The update nudge gets its own row rather than a segment: Claude Code renders every
817
+ // stdout line as a separate row, and the point of the nudge is the copy-pasteable command,
818
+ // which is too wide to inline without forcing the main line to wrap on most terminals.
819
+ // `npx <pkg>@latest` is the right command for script-installed users too — it recopies the
820
+ // hook. Only the target version is shown — the running one is what you're looking at.
821
+ function renderUpdateLine(latest) {
822
+ return `${colors.green}⬆ ${latest}${colors.reset} `
823
+ + `${colors.dim}available ·${colors.reset} `
824
+ + `${colors.bold}npx ${PACKAGE_NAME}@latest${colors.reset}`;
825
+ }
635
826
 
636
- // line1 = identity + context (always); line2 = usage/cost/task (wrap target).
637
- const line1 = [];
638
- line1.push(branch
639
- ? `${dirname} ${colors.dim}⎇ ${branch}${colors.reset}${sync ? ' ' + sync : ''}`
640
- : dirname);
641
- line1.push(effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model);
642
- line1.push(contextBar);
643
-
644
- const line2 = [];
645
- if (usage?.current) line2.push(usage.current);
646
- if (usage?.weekly) line2.push(usage.weekly);
647
- if (usage?.models?.length) line2.push(...usage.models);
648
- if (cost) line2.push(cost);
649
- if (task) line2.push(`${colors.dim}${task}${colors.reset}`);
650
-
651
- process.stdout.write(layout(line1, line2));
827
+ // Pure: data + facts (see collectFacts) + resolved usage bars -> the rendered line(s).
828
+ // No fs/child_process/network access, so it's callable directly in tests.
829
+ function renderStatusLine(data, facts, usage) {
830
+ const model = shortenModel(data?.model?.display_name || 'Claude');
831
+ const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
832
+ const remaining = data?.context_window?.remaining_percentage;
833
+
834
+ const contextBar = getContextBar(remaining);
835
+ const cost = DISABLED.has('cost') ? '' : getCostSegment(data);
836
+
837
+ // line1 = identity + context (always); line2 = usage/cost/task (wrap target).
838
+ const line1 = [];
839
+ line1.push(facts.branch
840
+ ? `${facts.dirname} ${colors.dim}${facts.branch}${colors.reset}${facts.sync ? ' ' + facts.sync : ''}`
841
+ : facts.dirname);
842
+ line1.push(renderModelEffort(model, effort));
843
+ line1.push(contextBar);
844
+
845
+ const line2 = [];
846
+ if (usage?.current) line2.push(usage.current);
847
+ if (usage?.weekly) line2.push(usage.weekly);
848
+ if (usage?.models?.length) line2.push(...usage.models);
849
+ if (cost) line2.push(cost);
850
+ if (facts.task) line2.push(`${colors.dim}${facts.task}${colors.reset}`);
851
+
852
+ const body = layout(line1, line2, facts.cols);
853
+ return facts.update ? body + '\n' + renderUpdateLine(facts.update) : body;
854
+ }
855
+
856
+ // Main
857
+ function outputStatus(data, facts, usage) {
858
+ try {
859
+ process.stdout.write(renderStatusLine(data, facts, usage));
652
860
  } catch (e) {
653
861
  process.stdout.write('Status unavailable');
654
862
  }
655
863
  }
656
864
 
657
865
  function outputFallback(usage) {
658
- const contextBar = getContextBar(undefined);
659
- const parts = ['~', 'Claude', contextBar];
660
- if (usage?.current) parts.push(usage.current);
661
- if (usage?.weekly) parts.push(usage.weekly);
662
- if (usage?.models?.length) parts.push(...usage.models);
663
- process.stdout.write(parts.join(' \u2502 '));
866
+ const facts = { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
867
+ process.stdout.write(renderStatusLine(null, facts, usage));
664
868
  }
665
869
 
666
870
  // Resolve usage bars for a (possibly null) parsed stdin payload.
@@ -675,14 +879,15 @@ function resolveUsage(data, callback) {
675
879
  // stdin covers H and W with no network. Model-scoped weekly limits only exist in the
676
880
  // API payload, so they come from the cache — refreshed on the same TTL as every other
677
881
  // usage read, which keeps at most one call per FRESH_TTL_MS regardless of render rate.
678
- return getScopedModels((models) => {
679
- callback(buildUsageBars(fromStdin.fiveHour, fromStdin.weekly, models));
882
+ // Falls back to the stale cache and finally to [] so a failed or slow call costs only
883
+ // the scoped bars, never the H/W bars stdin already gave us.
884
+ return getRawUsage((cached) => {
885
+ callback(buildUsageBars({ ...fromStdin, models: cached?.models || [] }));
680
886
  });
681
887
  }
682
- getUsageWithCache(callback);
888
+ getRawUsage((raw) => callback(raw ? buildUsageBars(raw) : null));
683
889
  }
684
890
 
685
- // Process with timeout
686
891
  // Parse the accumulated stdin into a payload object, or null if empty/unparseable.
687
892
  function parseInput(input) {
688
893
  if (!input || input.length === 0) return null;
@@ -693,11 +898,35 @@ function parseInput(input) {
693
898
  }
694
899
  }
695
900
 
901
+ // Accumulate stdin then call fn(input) exactly once, on whichever fires first:
902
+ // timeout, 'end', or 'error' (an unhandled stdin error would otherwise throw,
903
+ // breaking the never-throw contract). Shared by both entry points below, which
904
+ // differ only in timeoutMs.
905
+ function readStdinThen(timeoutMs, fn) {
906
+ let input = '';
907
+ let finished = false;
908
+
909
+ const finish = () => {
910
+ if (finished) return;
911
+ finished = true;
912
+ clearTimeout(timeout);
913
+ fn(input);
914
+ };
915
+
916
+ const timeout = setTimeout(finish, timeoutMs);
917
+
918
+ process.stdin.setEncoding('utf8');
919
+ process.stdin.on('data', chunk => input += chunk);
920
+ process.stdin.on('end', finish);
921
+ process.stdin.on('error', finish);
922
+ }
923
+
696
924
  // Resolve usage for `data` (preferring stdin rate_limits), then render and exit.
697
925
  function emit(data) {
926
+ if (!DISABLED.has('update')) refreshUpdateCheck();
698
927
  resolveUsage(data, (usage) => {
699
928
  if (data) {
700
- outputStatus(data, usage);
929
+ outputStatus(data, collectFacts(data), usage);
701
930
  } else {
702
931
  outputFallback(usage);
703
932
  }
@@ -733,9 +962,7 @@ function renderSubagentTask(t) {
733
962
  // effort absent = subagent inherits the session effort; show model alone then.
734
963
  const effort = t.effort != null ? String(t.effort) : '';
735
964
  if (model) {
736
- parts.push(effort
737
- ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}`
738
- : model);
965
+ parts.push(renderModelEffort(model, effort));
739
966
  } else if (effort) {
740
967
  parts.push(`${getEffortColor(effort)}${effort}${colors.reset}`);
741
968
  }
@@ -777,51 +1004,21 @@ function emitSubagent(data) {
777
1004
  // directly (the /usage response shape is the easiest thing here to get wrong, and it
778
1005
  // can't be reached through stdin). Running the script normally is unchanged.
779
1006
  if (require.main === module) {
780
- if (process.argv[2] === 'subagent') {
781
- if (process.stdin.isTTY) {
782
- emitSubagent(null);
783
- } else {
784
- let input = '';
785
- let finished = false;
786
-
787
- // Single guarded exit shared by all three triggers: timeout, stdin 'end', and
788
- // stdin 'error' (which can fire before 'end' and would otherwise throw unhandled,
789
- // breaking the never-throw contract). Whatever accumulated so far gets rendered.
790
- const finish = () => {
791
- if (finished) return;
792
- finished = true;
793
- clearTimeout(timeout);
794
- emitSubagent(parseInput(input));
795
- };
796
-
797
- const timeout = setTimeout(finish, SUBAGENT_TIMEOUT_MS);
798
-
799
- process.stdin.setEncoding('utf8');
800
- process.stdin.on('data', chunk => input += chunk);
801
- process.stdin.on('end', finish);
802
- process.stdin.on('error', finish);
803
- }
1007
+ const mode = process.argv[2];
1008
+ const isSubagent = mode === 'subagent';
1009
+ const finish = isSubagent ? emitSubagent : emit;
1010
+
1011
+ if (mode === 'update-check') {
1012
+ // Detached child spawned by refreshUpdateCheck: no stdin, no output, just the fetch.
1013
+ runUpdateCheck();
804
1014
  } else if (process.stdin.isTTY) {
805
- emit(null);
1015
+ finish(null);
806
1016
  } else {
807
- let input = '';
808
- let timeoutReached = false;
809
-
810
- const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
811
-
812
- const timeout = setTimeout(() => {
813
- timeoutReached = true;
814
- emit(parseInput(input));
815
- }, overallTimeout);
816
-
817
- process.stdin.setEncoding('utf8');
818
- process.stdin.on('data', chunk => input += chunk);
819
- process.stdin.on('end', () => {
820
- if (timeoutReached) return;
821
- clearTimeout(timeout);
822
- emit(parseInput(input));
823
- });
1017
+ const timeoutMs = isSubagent
1018
+ ? SUBAGENT_TIMEOUT_MS
1019
+ : (IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600));
1020
+ readStdinThen(timeoutMs, (input) => finish(parseInput(input)));
824
1021
  }
825
1022
  } else {
826
- module.exports = { parseScopedLimits, normalizePercentage };
1023
+ module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask, compareVersions, parseRegistryVersion, VERSION };
827
1024
  }