ctxline-claude 1.6.2 → 1.7.1

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 +12 -5
  2. package/package.json +1 -1
  3. package/statusline.js +217 -194
package/README.md CHANGED
@@ -10,13 +10,12 @@
10
10
 
11
11
  <p align="center">
12
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?theme=emerald&amp;variant=ghost&amp;size=sm&amp;mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?theme=emerald&amp;variant=ghost&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
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://github.com/MithunWijayasiri/ctxline-claude/actions"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/ci/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="CI" src="https://www.shieldcn.dev/github/ci/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
16
- <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=outline&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=outline&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>
17
16
  <br>
18
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>
19
- <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=ghost&amp;size=xs&amp;mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=ghost&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>
20
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>
21
20
  </p>
22
21
 
@@ -138,6 +137,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
138
137
  | **Model limit** | Weekly limit scoped to a single model, when your account has one — labelled by the model's initial (`F` = Fable) |
139
138
  | **Cost** | Running session cost in USD (e.g. `$0.42`) |
140
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 |
141
141
 
142
142
  > [!NOTE]
143
143
  > Usage bars change color automatically as you approach your limits.
@@ -152,7 +152,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
152
152
 
153
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:
154
154
 
155
- `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly + model-scoped)
155
+ `branch` · `effort` · `cost` · `task` · `update` · `usage` (5-hour + weekly + model-scoped)
156
156
 
157
157
  Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
158
158
 
@@ -219,6 +219,13 @@ No, it's imperceptible. Almost every render reads a small local cache (sub-milli
219
219
 
220
220
  </details>
221
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
+
222
229
  <details>
223
230
  <summary>Does this use the same data as /usage?</summary>
224
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.6.2",
3
+ "version": "1.7.1",
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"
package/statusline.js CHANGED
@@ -1,21 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // Claude Code Enhanced Statusline
3
- // Shows: directory | model | context usage | 5-hour + weekly + model-scoped usage | current task
4
- // Auto-detects API key vs subscription usage
2
+ // Claude Code statusline: dir │ model │ context │ usage │ cost │ task
5
3
  // https://github.com/MithunWijayasiri/ctxline-claude
6
4
 
7
5
  const fs = require('fs');
8
6
  const path = require('path');
9
7
  const os = require('os');
10
8
  const https = require('https');
11
- const { execSync, execFileSync } = require('child_process');
9
+ const { execSync, execFileSync, spawn } = require('child_process');
10
+
11
+ // Lives here, not package.json: this file ships standalone to ~/.claude/hooks/. Must match package.json.
12
+ const VERSION = '1.7.1';
12
13
 
13
14
  const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
15
 
15
- // 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.
17
- // Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
18
- // usage fetch).
16
+ // Segment opt-out: comma list. Recognized: branch, effort, cost, task, update, usage.
17
+ // Disabling skips the work, not just the output; dir/model/context always render.
19
18
  const DISABLED = new Set(
20
19
  (process.env.CTXLINE_DISABLE || '')
21
20
  .split(',')
@@ -23,39 +22,39 @@ const DISABLED = new Set(
23
22
  .filter(Boolean)
24
23
  );
25
24
 
26
- // Shared width (cells) for all progress bars: context, current, weekly.
25
+ // Context bar width in cells.
27
26
  const BAR_WIDTH = 6;
28
27
 
29
- // Max characters shown for the git branch; longer names are tail-truncated with "…".
30
- // Tail-truncation keeps the start (ticket IDs like "TAMA5-32796" live there) visible.
28
+ // Branch names tail-truncated to this with "…", keeping leading ticket IDs visible.
31
29
  const MAX_BRANCH_LEN = 24;
32
30
 
33
- // Separator between segments on a rendered line.
34
31
  const SEGMENT_SEP = ' │ ';
35
32
 
36
- // Cells reserved at the terminal edge when deciding to wrap to a second line.
37
- // 0 = use the full COLUMNS; bump it if Claude Code reserves columns and the line
38
- // truncates a char or two before wrapping.
33
+ // Cells reserved at the terminal edge when deciding to wrap; 0 = full width.
39
34
  const WIDTH_MARGIN = 0;
40
35
 
41
36
  // Cache configuration
42
37
  const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
43
38
  const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
44
- // Fresh: trust the cache and skip the API call entirely (fewer calls, faster render).
45
- const FRESH_TTL_MS = 30000; // 30 seconds
46
- // Stale: used only as a fallback when a live API call fails, so the usage bar stays
47
- // visible through transient timeouts/errors instead of disappearing.
48
- const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
49
-
50
- // Git ahead/behind cache (single repo entry, keyed by git dir). Throttles the one
51
- // `git rev-list` subprocess so a burst of renders in a turn runs it once, not per render.
39
+ const FRESH_TTL_MS = 30000; // fresh: render cache, skip API
40
+ const STALE_TTL_MS = 10 * 60 * 1000; // stale: fallback only when a live call fails
41
+
42
+ // Single-entry ahead/behind cache: throttles the one git subprocess to once per render burst.
52
43
  const GIT_CACHE_FILE = path.join(CACHE_DIR, 'git-cache.json');
53
44
  const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burst
54
45
  const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
55
46
  const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
56
47
 
57
- // Subagent mode reads only stdin (no usage API to race), so its stdin read gets a
58
- // short hard cap of its own instead of the main-mode overallTimeout.
48
+ // Update check: render only reads this cache; the registry fetch runs in a detached child.
49
+ const UPDATE_CACHE_FILE = path.join(CACHE_DIR, 'update-cache.json');
50
+ const UPDATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days between successful checks
51
+ const UPDATE_RETRY_MS = 60 * 60 * 1000; // 1h backoff after a failed/killed check
52
+ const UPDATE_TIMEOUT_MS = 2000; // socket idle AND whole-request deadline
53
+ const REGISTRY_HOST = 'registry.npmjs.org';
54
+ const PACKAGE_NAME = 'ctxline-claude';
55
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/; // releases only: a prerelease never nudges
56
+
57
+ // Subagent mode reads only stdin (no fetch to race), so its read gets its own short cap.
59
58
  const SUBAGENT_TIMEOUT_MS = 500;
60
59
 
61
60
  // ANSI color codes
@@ -67,13 +66,10 @@ const colors = {
67
66
  yellow: '\x1b[33m',
68
67
  orange: '\x1b[38;5;208m',
69
68
  red: '\x1b[31m',
70
- purple: '\x1b[38;5;135m',
71
- blink: '\x1b[5m'
69
+ purple: '\x1b[38;5;135m'
72
70
  };
73
71
 
74
- // Color for the thinking-effort indicator. Levels rank low < medium < high < xhigh < max
75
- // < ultracode; only the top two are highlighted — "max" red, "ultracode" purple. Every
76
- // other level (including xhigh) renders dim like the rest of the metadata.
72
+ // Levels rank low<medium<high<xhigh<max<ultracode; only max (red) and ultracode (purple) stand out.
77
73
  function getEffortColor(level) {
78
74
  const lvl = String(level).toLowerCase();
79
75
  if (lvl === 'max') return colors.red;
@@ -82,27 +78,23 @@ function getEffortColor(level) {
82
78
  }
83
79
 
84
80
  function getUsageColor(percentage) {
85
- if (percentage < 50) return colors.green;
86
- if (percentage < 75) return colors.yellow;
81
+ if (percentage < 60) return colors.green;
82
+ if (percentage < 80) return colors.yellow;
87
83
  if (percentage < 90) return colors.orange;
88
84
  return colors.red;
89
85
  }
90
86
 
91
- // Model-scoped bars skip the H/W thresholds: a line can carry several at once, so a flat
92
- // orange keeps them readable as one group. Red at >=90 is the one distinction kept — that
93
- // bar is about to block the model it names.
87
+ // Flat orange keeps several scoped bars readable as one group; >=90 red flags a nearly-spent cap.
94
88
  function getScopedColor(percentage) {
95
89
  return percentage >= 90 ? colors.red : colors.orange;
96
90
  }
97
91
 
98
- // Shorten verbose model names for the statusline: "Opus 4.8 (1M context)" -> "Opus 4.8 (1M)".
92
+ // Drop the context-window suffix: "Opus 5.5 (1M context)" -> "Opus 5.5".
99
93
  function shortenModel(name) {
100
- return name.replace(/\s+context\)/i, ')');
94
+ return name.replace(/\s*\([^)]*context\)/i, '');
101
95
  }
102
96
 
103
- // Shorten a resolved model ID (subagent task.model, e.g. "claude-opus-5") for the
104
- // subagent row: "claude-opus-5" -> "Opus 5", "claude-haiku-4-5-20251001" -> "Haiku 4.5".
105
- // Distinct from shortenModel, which trims a display name rather than parsing an ID.
97
+ // Resolved model ID -> "Opus 5" / "Haiku 4.5" (strips prefixes + trailing -YYYYMMDD).
106
98
  function shortenModelId(id) {
107
99
  if (!id) return '';
108
100
  const stripped = String(id).replace(/^(us\.)?(anthropic\.)?claude-/, '').replace(/-\d{8}$/, '');
@@ -118,8 +110,7 @@ function truncateBranch(name) {
118
110
  return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
119
111
  }
120
112
 
121
- // Resolve the repo's git dir by walking up from `dir` (no `git` subprocess). Handles
122
- // worktrees/submodules (".git" as a file pointing at the real dir). '' on any failure.
113
+ // Walks up from `dir` to the git dir (no subprocess); handles worktrees (".git" file). '' on failure.
123
114
  function resolveGitDir(dir) {
124
115
  let cur = dir;
125
116
  let gitPath = '';
@@ -141,16 +132,14 @@ function resolveGitDir(dir) {
141
132
  return gitPath;
142
133
  }
143
134
 
144
- // Current git branch, read straight from .git/HEAD (no `git` subprocess fast,
145
- // dependency-free). Detached HEAD -> short sha. Best-effort: '' on any failure.
135
+ // Branch read straight from .git/HEAD (no subprocess); detached HEAD -> short sha; '' on failure.
146
136
  function getGitBranch(dir) {
147
137
  try {
148
138
  const gitDir = resolveGitDir(dir);
149
139
  if (!gitDir) return '';
150
140
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
151
141
  const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
152
- // Strip control chars: HEAD is read raw (not git-validated), so a hand-crafted file
153
- // in an untrusted archive could inject terminal escape sequences.
142
+ // HEAD is read raw, not git-validated: strip control chars (escape-sequence injection).
154
143
  if (ref) return truncateBranch(ref[1].replace(/[\x00-\x1f\x7f]/g, ''));
155
144
  if (/^[0-9a-f]{7,40}$/i.test(head)) return head.slice(0, 7); // detached HEAD -> short sha
156
145
  return '';
@@ -159,8 +148,7 @@ function getGitBranch(dir) {
159
148
  }
160
149
  }
161
150
 
162
- // Read the cached ahead/behind for `gitDir`. Single-entry file: a different repo
163
- // invalidates it. Returns { age, ahead, behind } or null.
151
+ // Cached ahead/behind for gitDir; different repo invalidates. { age, ahead, behind } or null.
164
152
  function readGitCache(gitDir) {
165
153
  try {
166
154
  const c = JSON.parse(fs.readFileSync(GIT_CACHE_FILE, 'utf8'));
@@ -179,10 +167,8 @@ function writeGitCache(gitDir, ahead, behind) {
179
167
  } catch (e) {}
180
168
  }
181
169
 
182
- // Commits ahead/behind the upstream (@{u}), cache-fronted. The single `git` call in the
183
- // whole script gated by GIT_FRESH_TTL_MS so a render burst runs it once. No upstream /
184
- // detached / no git -> the subprocess errors -> null (segment omitted). On a slow/failed
185
- // call, falls back to the last counts up to GIT_STALE_TTL_MS so they don't flicker.
170
+ // The only `git` subprocess, cache-fronted. null on no upstream/detached/failed (segment omitted);
171
+ // slow/failed call falls back to last counts up to GIT_STALE_TTL_MS so counts don't flicker.
186
172
  function getGitAheadBehind(dir) {
187
173
  const gitDir = resolveGitDir(dir);
188
174
  if (!gitDir) return null;
@@ -193,8 +179,7 @@ function getGitAheadBehind(dir) {
193
179
  }
194
180
 
195
181
  try {
196
- // execFileSync (no shell): faster cold spawn than execSync and passes `@{u}` literally.
197
- // `@{u}...HEAD` with --left-right --count prints "<behind>\t<ahead>" (left = upstream).
182
+ // No shell (faster cold spawn, @{u} literal); --left-right --count prints "<behind>\t<ahead>".
198
183
  const out = execFileSync('git', ['rev-list', '--left-right', '--count', '@{u}...HEAD'], {
199
184
  cwd: dir, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore']
200
185
  }).trim();
@@ -214,9 +199,7 @@ function getGitAheadBehind(dir) {
214
199
  }
215
200
  }
216
201
 
217
- // "↑N↓M" from ahead/behind counts: ahead green (commits to push), behind red (missing
218
- // commits). Each part self-resets so it doesn't inherit the dim branch color. Omit a zero
219
- // side; '' when in sync or null.
202
+ // "↑N↓M": ahead green, behind red, zero side omitted; '' when in sync or null.
220
203
  function formatAheadBehind(ab) {
221
204
  if (!ab) return '';
222
205
  let s = '';
@@ -225,20 +208,16 @@ function formatAheadBehind(ab) {
225
208
  return s;
226
209
  }
227
210
 
228
- // Colored "C<used> <bar>" (e.g. "C45 ███░░░") for an already-clamped 0-100 used
229
- // percentage. Shared by the main context bar (derived from remaining%) and the
230
- // subagent row (derived from tokenCount/contextWindowSize) so both use the same
231
- // thresholds and bar style.
211
+ // Colored "C<used> <bar>" (e.g. "C45 ███░░░"); shared by main line and subagent rows.
232
212
  function renderContextBar(used) {
233
213
  const filled = Math.round((used / 100) * BAR_WIDTH);
234
214
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
235
215
 
236
- // Context color: green <50 / yellow <65 / orange <80 / blink-red >=80.
237
216
  let color;
238
217
  if (used < 50) color = colors.green;
239
218
  else if (used < 65) color = colors.yellow;
240
219
  else if (used < 80) color = colors.orange;
241
- else color = colors.blink + colors.red;
220
+ else color = colors.red;
242
221
 
243
222
  return `${color}C${used} ${bar}${colors.reset}`;
244
223
  }
@@ -254,10 +233,8 @@ function renderModelEffort(model, effort) {
254
233
  return effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model;
255
234
  }
256
235
 
257
- // Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
258
- // (e.g. "H81 2h21m") no bar. Called on every read (live or cached) so the reset
259
- // countdown is always recomputed from resetsAt rather than frozen at fetch time.
260
- // `color` overrides the threshold color — the model-scoped bars pass getScopedColor.
236
+ // "<label><pct> ↺ <countdown>" (e.g. "H81 ↺ 2h21m"), no bar. Called on every read so the
237
+ // countdown recomputes from resetsAt; `color` overrides the thresholds (scoped bars).
261
238
  function buildUsageBar(label, percentage, resetsAt, color) {
262
239
  let timeStr = '';
263
240
  if (resetsAt) {
@@ -276,32 +253,16 @@ function buildUsageBar(label, percentage, resetsAt, color) {
276
253
  return `${barColor}${label}${percentage}${colors.reset}${timePart}`;
277
254
  }
278
255
 
279
- // Model-scoped weekly limits (e.g. "Fable weekly limit at 86%"), rendered after the
280
- // account-wide W bar. The /usage payload reports these in a `limits` array, each entry
281
- // carrying the model in scope.model.display_name:
282
- //
283
- // { kind: "weekly_scoped", percent: 86, severity: "warning",
284
- // resets_at: "...", scope: { model: { display_name: "Fable" } } }
285
- //
286
- // The label is the model's first initial (Fable -> F), so a new model family needs no
287
- // code change. Older payloads instead exposed flat seven_day_<model> keys, kept below as
288
- // a fallback for accounts still reporting that shape.
289
- //
290
- // NOTE: these appear only in the API payload. Claude Code's statusline stdin carries just
291
- // five_hour and seven_day under rate_limits, so the scoped limits always come from the
292
- // cache/API path even when stdin supplies the H and W bars.
256
+ // Model-scoped weekly limits, rendered after W. /usage payload `limits[]` entries:
257
+ // { kind: "weekly_scoped", percent, resets_at, scope: { model: { display_name } } }
258
+ // Label = first initial of the model name (Fable -> F). Legacy flat seven_day_<model> keys
259
+ // kept as fallback. Only ever in the API payload — stdin rate_limits never carries them.
293
260
  const LEGACY_MODEL_WEEKLY_KEYS = [
294
261
  { key: 'seven_day_opus', label: 'O' },
295
262
  { key: 'seven_day_sonnet', label: 'S' }
296
263
  ];
297
264
 
298
- // Build the usage segments from a raw { fiveHour, weekly, models } object the shared
299
- // shape both buildUsageFromStdin and parseUsagePayload return. fiveHour/weekly are
300
- // { percentage, resetsAt } or null/absent; models is an array of { label, percentage,
301
- // resetsAt } (possibly empty). Returns { current, weekly, models } — the first two
302
- // rendered strings or null, models a (possibly empty) array of rendered strings. Scoped
303
- // bars use getScopedColor instead of the H/W thresholds, so the full threshold palette
304
- // stays exclusive to H/W.
265
+ // Raw { fiveHour, weekly, models } -> rendered segments; scoped bars use getScopedColor.
305
266
  function buildUsageBars(raw) {
306
267
  const { fiveHour, weekly, models } = raw || {};
307
268
  return {
@@ -311,18 +272,13 @@ function buildUsageBars(raw) {
311
272
  };
312
273
  }
313
274
 
314
- // Normalize a raw API utilization into the 0-100 integer that the rest of the
315
- // pipeline (cache validation + bar rendering) expects. Returns null when the value
316
- // isn't a finite number, so callers can omit that bar instead of rendering "NaN%".
275
+ // Clamp to 0-100 int; null on non-finite so callers omit the bar instead of rendering "NaN%".
317
276
  function normalizePercentage(value) {
318
277
  if (!Number.isFinite(value)) return null;
319
278
  return Math.max(0, Math.min(100, Math.round(value)));
320
279
  }
321
280
 
322
- // Extract the model-scoped weekly limits from a raw /usage payload as
323
- // [{ label, percentage, resetsAt }], in payload order. Prefers the `limits` array;
324
- // falls back to the legacy flat keys only when it yields nothing, so an account
325
- // reporting both shapes doesn't render the same limit twice.
281
+ // limits[] -> [{ label, percentage, resetsAt }]; legacy flat keys only when limits yields nothing.
326
282
  function parseScopedLimits(usage) {
327
283
  const scoped = [];
328
284
 
@@ -349,13 +305,9 @@ function parseScopedLimits(usage) {
349
305
  return scoped;
350
306
  }
351
307
 
352
- // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
353
- // first API response of a session). Same data as the OAuth usage API, so reading it here
354
- // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
355
- // SECONDS (not ISO) — ×1000 before Date. Returns raw { fiveHour, weekly, models } — same
356
- // shape as parseUsagePayload — or null when rate_limits is absent or the required
357
- // five_hour segment is unusable (caller falls back). models is always [] here: model-scoped
358
- // weekly limits are never present in stdin — see LEGACY_MODEL_WEEKLY_KEYS.
308
+ // Usage from stdin `rate_limits` (Pro/Max only, absent at cold start) — skips the
309
+ // network/cache path entirely. resets_at is Unix epoch SECONDS (not ISO). Same raw shape as
310
+ // parseUsagePayload; models always [] scoped limits never arrive via stdin.
359
311
  function buildUsageFromStdin(data) {
360
312
  const rl = data?.rate_limits;
361
313
  if (!rl) return null;
@@ -364,9 +316,7 @@ function buildUsageFromStdin(data) {
364
316
  if (!seg) return null;
365
317
  const pct = normalizePercentage(seg.used_percentage);
366
318
  if (pct == null) return null;
367
- // resets_at is a Unix epoch in SECONDS. Coerce + validate defensively: a non-numeric
368
- // or out-of-range value would make new Date(...).toISOString() throw, and this path
369
- // runs outside outputStatus's try/catch. Fall back to resetsAt: null on anything bad.
319
+ // Defensive: this path runs outside outputStatus's try/catch bad value -> null, never a throw.
370
320
  let resetsAt = null;
371
321
  const epoch = Number(seg.resets_at);
372
322
  if (Number.isFinite(epoch) && epoch > 0) {
@@ -381,11 +331,9 @@ function buildUsageFromStdin(data) {
381
331
  return { fiveHour, weekly: toEntry(rl.seven_day), models: [] };
382
332
  }
383
333
 
384
- // Parse a raw /usage API response body into { fiveHour, weekly, models } same shape as
385
- // buildUsageFromStdin or null on unparseable JSON or a missing/non-finite five_hour
386
- // utilization (that bar is required). Normalizes utilization first so a missing/non-finite
387
- // value omits a bar instead of rendering "NaN%". Pure — no fs/network — so it's unit
388
- // testable directly, unlike getApiUsage which needs a live socket.
334
+ // /usage response body -> { fiveHour, weekly, models }, or null on unparseable JSON or a
335
+ // missing/non-finite five_hour utilization (that bar is required). Pure, so unit-testable
336
+ // directly unlike getApiUsage, which needs a live socket.
389
337
  function parseUsagePayload(body) {
390
338
  try {
391
339
  const usage = JSON.parse(body);
@@ -403,8 +351,7 @@ function parseUsagePayload(body) {
403
351
  }
404
352
  }
405
353
 
406
- // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
407
- // finite 0-100 percentage and a parseable (or absent) resetsAt.
354
+ // Valid entry: finite 0-100 percentage, parseable (or absent) resetsAt.
408
355
  function isValidUsageEntry(entry) {
409
356
  if (!entry || typeof entry !== 'object') return false;
410
357
  if (!Number.isFinite(entry.percentage) || entry.percentage < 0 || entry.percentage > 100) return false;
@@ -412,9 +359,7 @@ function isValidUsageEntry(entry) {
412
359
  return true;
413
360
  }
414
361
 
415
- // Read the raw cached usage data
416
- // ({ timestamp, data: { fiveHour: {percentage,resetsAt}, weekly: {...}|null } }).
417
- // Returns { age, data } or null. Age-vs-TTL decisions are made by the caller.
362
+ // Cached usage -> { age, data } or null; caller applies TTLs. Invalid shape -> null.
418
363
  function readCachedUsage() {
419
364
  try {
420
365
  if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
@@ -422,10 +367,7 @@ function readCachedUsage() {
422
367
  const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
423
368
  if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
424
369
 
425
- // Validate data. fiveHour is required; weekly and models are optional (the API may
426
- // omit either). This also rejects the legacy single-{percentage,resetsAt} format from
427
- // older versions, which had no fiveHour key, so stale caches are ignored on read.
428
- // A cache written before model bars existed simply has no models key — still valid.
370
+ // fiveHour required; weekly/models optional. Rejects legacy formats lacking fiveHour.
429
371
  const data = cache.data;
430
372
  if (!data || typeof data !== 'object') return null;
431
373
  if (!isValidUsageEntry(data.fiveHour)) return null;
@@ -441,11 +383,8 @@ function readCachedUsage() {
441
383
  }
442
384
  }
443
385
 
444
- // Serialize usage data into the on-disk cache shape ({ timestamp, data, lastAttempt }). Pure
445
- // -- no fs -- so setCachedUsage and the test/preview cache seeds all produce exactly the same
446
- // bytes the real writer would; a reader/writer format mismatch becomes structurally impossible
447
- // instead of merely untested. `timestamp` defaults to now; tests override it to seed a stale
448
- // cache. A successful write is itself an attempt, so `lastAttempt` starts equal to `timestamp`.
386
+ // On-disk cache shape; pure so test/preview seeds produce writer-identical bytes. lastAttempt
387
+ // starts equal to timestamp: a successful write is itself an attempt.
449
388
  function serializeUsageCache(data, timestamp = Date.now()) {
450
389
  return JSON.stringify({ timestamp, data, lastAttempt: timestamp });
451
390
  }
@@ -463,9 +402,8 @@ function setCachedUsage(data) {
463
402
  }
464
403
  }
465
404
 
466
- // Age in ms since the last API attempt (success or failure), or null if none recorded yet.
467
- // Read directly from the raw file rather than via readCachedUsage so the cooldown still
468
- // applies when no valid data has ever been cached (every attempt so far has failed).
405
+ // Age in ms since the last attempt (success or failure), or null. Read from the raw file, not
406
+ // readCachedUsage, so the cooldown applies even when no valid data has ever been cached.
469
407
  function getLastAttemptAge() {
470
408
  try {
471
409
  if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
@@ -477,9 +415,8 @@ function getLastAttemptAge() {
477
415
  }
478
416
  }
479
417
 
480
- // Record that an API attempt is starting, preserving any existing cached data/timestamp so a
481
- // failed refresh doesn't erase the last successful one. Written before the request so a hang
482
- // or a process exit mid-request still counts as an attempt for cooldown purposes.
418
+ // Stamp lastAttempt before the request so failed attempts still enter cooldown; preserves
419
+ // existing cached data so a failed refresh doesn't erase the last successful one.
483
420
  function recordUsageAttempt() {
484
421
  try {
485
422
  if (!fs.existsSync(CACHE_DIR)) {
@@ -497,8 +434,115 @@ function recordUsageAttempt() {
497
434
  }
498
435
  }
499
436
 
437
+ // Strict "x.y.z" -> -1|0|1, null otherwise (prerelease never nudges — a nicety, not a guess).
438
+ function compareVersions(a, b) {
439
+ const parse = (v) => SEMVER_RE.test(String(v ?? '')) ? String(v).split('.').map(Number) : null;
440
+ const x = parse(a);
441
+ const y = parse(b);
442
+ if (!x || !y) return null;
443
+ for (let i = 0; i < 3; i++) {
444
+ if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
445
+ }
446
+ return 0;
447
+ }
448
+
449
+ // Registry body -> "x.y.z" or null (404 bodies land here too).
450
+ function parseRegistryVersion(body) {
451
+ try {
452
+ const v = JSON.parse(body)?.version;
453
+ return SEMVER_RE.test(String(v ?? '')) ? String(v) : null;
454
+ } catch (e) {
455
+ return null;
456
+ }
457
+ }
458
+
459
+ function readUpdateCache() {
460
+ try {
461
+ const c = JSON.parse(fs.readFileSync(UPDATE_CACHE_FILE, 'utf8'));
462
+ return c && typeof c === 'object' ? c : null;
463
+ } catch (e) {
464
+ return null;
465
+ }
466
+ }
467
+
468
+ function writeUpdateCache(obj) {
469
+ try {
470
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
471
+ fs.writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(obj), 'utf8');
472
+ } catch (e) {}
473
+ }
474
+
475
+ // Cached latest when strictly newer than VERSION, else ''. Cache-only: render never touches the network.
476
+ function getLatestUpdate() {
477
+ const cached = readUpdateCache();
478
+ if (!cached) return '';
479
+ return compareVersions(cached.latest, VERSION) === 1 ? String(cached.latest) : '';
480
+ }
481
+
482
+ // Spawn the check in a detached child — the render never waits on the registry. lastAttempt is
483
+ // stamped before the spawn, so an offline/failed/killed child backs off UPDATE_RETRY_MS.
484
+ function refreshUpdateCheck() {
485
+ try {
486
+ const cached = readUpdateCache();
487
+ const now = Date.now();
488
+ if (cached) {
489
+ if (Number.isFinite(cached.checkedAt) && now - cached.checkedAt < UPDATE_TTL_MS) return;
490
+ if (Number.isFinite(cached.lastAttempt) && now - cached.lastAttempt < UPDATE_RETRY_MS) return;
491
+ }
492
+ writeUpdateCache({ ...(cached || {}), lastAttempt: now });
493
+ // windowsHide: no console flash on Windows; detached + unref: child outlives the render's exit.
494
+ spawn(process.execPath, [__filename, 'update-check'], {
495
+ detached: true, stdio: 'ignore', windowsHide: true
496
+ }).unref();
497
+ } catch (e) {}
498
+ }
499
+
500
+ // Detached 'update-check' entry point: fetch, stamp cache, exit. A failed fetch leaves
501
+ // checkedAt untouched, so the UPDATE_RETRY_MS backoff (not the weekly TTL) governs the next try.
502
+ function runUpdateCheck() {
503
+ let settled = false;
504
+ let deadline;
505
+
506
+ const done = (latest) => {
507
+ if (settled) return;
508
+ settled = true;
509
+ clearTimeout(deadline);
510
+ if (latest) writeUpdateCache({ ...(readUpdateCache() || {}), latest, checkedAt: Date.now() });
511
+ process.exit(0);
512
+ };
513
+
514
+ try {
515
+ const req = https.request({
516
+ hostname: REGISTRY_HOST,
517
+ path: `/${PACKAGE_NAME}/latest`,
518
+ method: 'GET',
519
+ headers: { 'Accept': 'application/json' },
520
+ timeout: UPDATE_TIMEOUT_MS
521
+ }, (res) => {
522
+ let body = '';
523
+ res.on('data', chunk => body += chunk);
524
+ res.on('end', () => done(parseRegistryVersion(body)));
525
+ });
526
+
527
+ req.on('error', () => done(null));
528
+ req.on('timeout', () => {
529
+ req.destroy();
530
+ done(null);
531
+ });
532
+
533
+ // The timeout option is socket inactivity, not total — a trickling response needs this hard deadline.
534
+ deadline = setTimeout(() => {
535
+ req.destroy();
536
+ done(null);
537
+ }, UPDATE_TIMEOUT_MS);
538
+
539
+ req.end();
540
+ } catch (e) {
541
+ done(null);
542
+ }
543
+ }
544
+
500
545
  function getCredentials() {
501
- // Try file first (legacy / Linux / Windows)
502
546
  const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
503
547
  if (fs.existsSync(credsPath)) {
504
548
  try {
@@ -506,7 +550,7 @@ function getCredentials() {
506
550
  } catch (e) {}
507
551
  }
508
552
 
509
- // Fallback: macOS keychain
553
+ // macOS keychain fallback
510
554
  if (os.platform() === 'darwin') {
511
555
  try {
512
556
  const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', timeout: 1000 });
@@ -519,7 +563,6 @@ function getCredentials() {
519
563
 
520
564
  function getApiUsage(callback) {
521
565
  try {
522
- // Read credentials (file or macOS keychain)
523
566
  const creds = getCredentials();
524
567
  if (!creds) {
525
568
  return callback(null);
@@ -531,12 +574,10 @@ function getApiUsage(callback) {
531
574
  return callback(null);
532
575
  }
533
576
 
534
- // Adaptive timeout: if cache exists, be faster (1200ms); if not, be patient (1500ms)
535
- // API typically takes ~850ms, so 1200ms gives reasonable headroom
577
+ // Tighter timeout when the cache is warm a fresh render already has data to print.
536
578
  const hasCache = fs.existsSync(USAGE_CACHE_FILE);
537
579
  const timeout = hasCache ? 1200 : 1500;
538
580
 
539
- // Make API call with adaptive timeout
540
581
  const req = https.request({
541
582
  hostname: 'api.anthropic.com',
542
583
  path: '/api/oauth/usage',
@@ -553,7 +594,6 @@ function getApiUsage(callback) {
553
594
  res.on('data', chunk => data += chunk);
554
595
  res.on('end', () => {
555
596
  const resolved = parseUsagePayload(data);
556
- // Cache the raw data (shared across sessions); callers render from it.
557
597
  if (resolved) setCachedUsage(resolved);
558
598
  callback(resolved);
559
599
  });
@@ -575,27 +615,21 @@ function getApiUsage(callback) {
575
615
  function getRawUsage(callback) {
576
616
  const cached = readCachedUsage();
577
617
 
578
- // Cache is fresh -> use it and skip the API entirely (fewer calls, faster).
579
618
  if (cached && cached.age < FRESH_TTL_MS) {
580
619
  return callback(cached.data);
581
620
  }
582
621
 
583
- // A refresh (successful or not) was attempted within FRESH_TTL_MS -> still in cooldown,
584
- // don't hit the API again. Serve stale cached data if it's still within STALE_TTL_MS, else
585
- // nothing. Without this, a repeatedly failing/timing-out refresh would re-hit the API on
586
- // every render instead of backing off (issue #41).
622
+ // Refresh attempted (even failed) within FRESH_TTL_MS -> cooldown: serve stale up to STALE_TTL_MS (issue #41).
587
623
  const attemptAge = getLastAttemptAge();
588
624
  if (attemptAge != null && attemptAge < FRESH_TTL_MS) {
589
625
  return callback(cached && cached.age < STALE_TTL_MS ? cached.data : null);
590
626
  }
591
627
 
592
- // Cache is stale or missing and no attempt is in cooldown -> refresh from the API.
593
628
  recordUsageAttempt();
594
629
  getApiUsage((fresh) => {
595
630
  if (fresh) {
596
631
  callback(fresh);
597
632
  } else if (cached && cached.age < STALE_TTL_MS) {
598
- // API failed/timed out, but recent cache exists -> show it instead of nothing.
599
633
  callback(cached.data);
600
634
  } else {
601
635
  callback(null);
@@ -603,9 +637,7 @@ function getRawUsage(callback) {
603
637
  });
604
638
  }
605
639
 
606
- // Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
607
- // Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
608
- // Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
640
+ // "$0.00" (dim) from stdin cost.total_cost_usd client-side estimate, no network; '' when absent.
609
641
  function getCostSegment(data) {
610
642
  const usd = data?.cost?.total_cost_usd;
611
643
  if (!Number.isFinite(usd)) return '';
@@ -641,11 +673,8 @@ function visibleWidth(str) {
641
673
  return [...str.replace(/\x1b\[[0-9;]*m/g, '')].length;
642
674
  }
643
675
 
644
- // Responsive layout: one line when it fits the terminal, else line1 (identity + context)
645
- // on top and line2 (usage/cost/task) below. Splits only when cols is known (Claude Code
646
- // v2.1.153+ sets COLUMNS, read by collectFacts) and the single line overflows — unknown
647
- // width or an empty line2 stays single, so there is no regression on older clients or wide
648
- // terminals.
676
+ // Two lines only when cols is known (COLUMNS, set by Claude Code v2.1.153+) and the single
677
+ // line overflows; unknown width or empty line2 stays single. cols is a parameter — no env read.
649
678
  function layout(line1Parts, line2Parts, cols) {
650
679
  const single = [...line1Parts, ...line2Parts].join(SEGMENT_SEP);
651
680
  if (line2Parts.length === 0) return single;
@@ -655,12 +684,9 @@ function layout(line1Parts, line2Parts, cols) {
655
684
  return single;
656
685
  }
657
686
 
658
- // Gathers everything outputStatus needs that touches fs/child_process/env: git branch +
659
- // ahead/behind (.git/HEAD, `git rev-list`), the in-progress task (~/.claude/todos), and the
660
- // terminal width (COLUMNS). Kept separate from renderStatusLine so the render step is pure.
661
- // Wrapped in its own try/catch (unlike renderStatusLine, it's called outside outputStatus's
662
- // try/catch in emit()) — a malformed workspace.current_dir (e.g. non-string) can throw from
663
- // path.basename or resolveGitDir, and this must still degrade to a renderable fallback.
687
+ // Everything the render needs that touches fs/child_process/env (git, todos, update cache,
688
+ // COLUMNS), so renderStatusLine stays pure. Own try/catch: called outside outputStatus's, and
689
+ // a malformed current_dir must still degrade to a renderable fallback.
664
690
  function collectFacts(data) {
665
691
  try {
666
692
  const dir = data?.workspace?.current_dir || process.cwd();
@@ -669,15 +695,23 @@ function collectFacts(data) {
669
695
  const sync = branch ? formatAheadBehind(getGitAheadBehind(dir)) : '';
670
696
  const sessionId = data?.session_id || '';
671
697
  const task = DISABLED.has('task') ? '' : getCurrentTask(sessionId);
698
+ const update = DISABLED.has('update') ? '' : getLatestUpdate();
672
699
  const cols = parseInt(process.env.COLUMNS, 10);
673
- return { dirname, branch, sync, task, cols };
700
+ return { dirname, branch, sync, task, update, cols };
674
701
  } catch (e) {
675
- return { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
702
+ return { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
676
703
  }
677
704
  }
678
705
 
679
- // Pure: data + facts (see collectFacts) + resolved usage bars -> the rendered line(s).
680
- // No fs/child_process/network access, so it's callable directly in tests.
706
+ // Own stdout row, not a segment: the copy-pasteable command is too wide to inline without
707
+ // forcing a wrap. Appended after layout() so it never joins the wrap decision.
708
+ function renderUpdateLine(latest) {
709
+ return `${colors.green}⬆ ${latest}${colors.reset} `
710
+ + `${colors.dim}available ·${colors.reset} `
711
+ + `${colors.bold}npx ${PACKAGE_NAME}@latest${colors.reset}`;
712
+ }
713
+
714
+ // Pure: data + facts (see collectFacts) + usage bars -> rendered line(s); callable directly in tests.
681
715
  function renderStatusLine(data, facts, usage) {
682
716
  const model = shortenModel(data?.model?.display_name || 'Claude');
683
717
  const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
@@ -701,10 +735,10 @@ function renderStatusLine(data, facts, usage) {
701
735
  if (cost) line2.push(cost);
702
736
  if (facts.task) line2.push(`${colors.dim}${facts.task}${colors.reset}`);
703
737
 
704
- return layout(line1, line2, facts.cols);
738
+ const body = layout(line1, line2, facts.cols);
739
+ return facts.update ? body + '\n' + renderUpdateLine(facts.update) : body;
705
740
  }
706
741
 
707
- // Main
708
742
  function outputStatus(data, facts, usage) {
709
743
  try {
710
744
  process.stdout.write(renderStatusLine(data, facts, usage));
@@ -714,24 +748,19 @@ function outputStatus(data, facts, usage) {
714
748
  }
715
749
 
716
750
  function outputFallback(usage) {
717
- const facts = { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
751
+ const facts = { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
718
752
  process.stdout.write(renderStatusLine(null, facts, usage));
719
753
  }
720
754
 
721
- // Resolve usage bars for a (possibly null) parsed stdin payload.
722
- // Order: API-key users get none; otherwise prefer stdin `rate_limits` (no network),
723
- // then fall back to the cache+API flow when stdin lacks it (cold start / non-Pro/Max).
755
+ // Usage bars: API-key users none; prefer stdin rate_limits, else cache+API (cold start / non-Pro/Max).
724
756
  function resolveUsage(data, callback) {
725
757
  if (IS_API_KEY || DISABLED.has('usage')) {
726
758
  return callback(null);
727
759
  }
728
760
  const fromStdin = buildUsageFromStdin(data);
729
761
  if (fromStdin) {
730
- // stdin covers H and W with no network. Model-scoped weekly limits only exist in the
731
- // API payload, so they come from the cache refreshed on the same TTL as every other
732
- // usage read, which keeps at most one call per FRESH_TTL_MS regardless of render rate.
733
- // Falls back to the stale cache and finally to [] so a failed or slow call costs only
734
- // the scoped bars, never the H/W bars stdin already gave us.
762
+ // Scoped limits only exist in the API payload -> fetch from cache; a failed/slow call
763
+ // costs only those bars, never the H/W bars stdin already gave us.
735
764
  return getRawUsage((cached) => {
736
765
  callback(buildUsageBars({ ...fromStdin, models: cached?.models || [] }));
737
766
  });
@@ -749,10 +778,8 @@ function parseInput(input) {
749
778
  }
750
779
  }
751
780
 
752
- // Accumulate stdin then call fn(input) exactly once, on whichever fires first:
753
- // timeout, 'end', or 'error' (an unhandled stdin error would otherwise throw,
754
- // breaking the never-throw contract). Shared by both entry points below, which
755
- // differ only in timeoutMs.
781
+ // Accumulate stdin, call fn(input) exactly once — timeout, 'end', or 'error' whichever fires
782
+ // first (the error handler preserves the never-throw contract). Shared by both entry points.
756
783
  function readStdinThen(timeoutMs, fn) {
757
784
  let input = '';
758
785
  let finished = false;
@@ -774,6 +801,7 @@ function readStdinThen(timeoutMs, fn) {
774
801
 
775
802
  // Resolve usage for `data` (preferring stdin rate_limits), then render and exit.
776
803
  function emit(data) {
804
+ if (!DISABLED.has('update')) refreshUpdateCheck();
777
805
  resolveUsage(data, (usage) => {
778
806
  if (data) {
779
807
  outputStatus(data, collectFacts(data), usage);
@@ -784,10 +812,8 @@ function emit(data) {
784
812
  });
785
813
  }
786
814
 
787
- // now - startTime as "45s" / "4m12s" / "2h5m". '' when startTime is missing/unparseable.
788
- // Format isn't documented by Claude Code, so accept epoch-seconds, epoch-ms, or an ISO
789
- // string: numbers below 1e12 are epoch-seconds (today's epoch-seconds ~1.7e9, epoch-ms
790
- // ~1.7e12 — far enough apart that the threshold is unambiguous for any real timestamp).
815
+ // "45s" / "4m12s" / "2h5m". startTime's format is undocumented upstream, so accept epoch-seconds
816
+ // (< 1e12), epoch-ms, or an ISO string. Revisit if a real payload contradicts.
791
817
  function formatElapsed(startTime) {
792
818
  if (startTime == null) return '';
793
819
  const ms = typeof startTime === 'number' && startTime < 1e12 ? startTime * 1000 : startTime;
@@ -828,10 +854,8 @@ function renderSubagentTask(t) {
828
854
  return parts.join(SEGMENT_SEP);
829
855
  }
830
856
 
831
- // subagentStatusLine mode: emit one {id, content} JSON line per task with an id, then
832
- // exit. No usage/git/todos/cache work the task objects carry everything needed.
833
- // Bad payload or a task that fails to render -> emit nothing, keeping default
834
- // rendering for every task, rather than a partial/broken output.
857
+ // subagentStatusLine mode: one {id, content} JSON line per task. No usage/git/todos/cache
858
+ // work. Bad payload or a task that fails to render -> emit nothing (default rendering stays).
835
859
  function emitSubagent(data) {
836
860
  try {
837
861
  const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
@@ -840,9 +864,7 @@ function emitSubagent(data) {
840
864
  .map(t => JSON.stringify({ id: t.id, content: renderSubagentTask(t) }))
841
865
  .join('\n');
842
866
  if (out) {
843
- // Exit from the write callback: process.exit() would drop output still queued
844
- // behind stdout backpressure. A write error (e.g. EPIPE) also lands here — the
845
- // callback form reports it instead of throwing, and the answer is the same: exit 0.
867
+ // Exit from the write callback: process.exit() would drop output queued behind backpressure.
846
868
  process.stdout.write(out + '\n', () => process.exit(0));
847
869
  return;
848
870
  }
@@ -850,14 +872,15 @@ function emitSubagent(data) {
850
872
  process.exit(0);
851
873
  }
852
874
 
853
- // Entry point, guarded so tests can require this file to exercise payload parsing
854
- // directly (the /usage response shape is the easiest thing here to get wrong, and it
855
- // can't be reached through stdin). Running the script normally is unchanged.
875
+ // Guarded so tests can require() the exports instead of spawning the script.
856
876
  if (require.main === module) {
857
- const isSubagent = process.argv[2] === 'subagent';
877
+ const mode = process.argv[2];
878
+ const isSubagent = mode === 'subagent';
858
879
  const finish = isSubagent ? emitSubagent : emit;
859
880
 
860
- if (process.stdin.isTTY) {
881
+ if (mode === 'update-check') {
882
+ runUpdateCheck();
883
+ } else if (process.stdin.isTTY) {
861
884
  finish(null);
862
885
  } else {
863
886
  const timeoutMs = isSubagent
@@ -866,5 +889,5 @@ if (require.main === module) {
866
889
  readStdinThen(timeoutMs, (input) => finish(parseInput(input)));
867
890
  }
868
891
  } else {
869
- module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask };
892
+ module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask, compareVersions, parseRegistryVersion, VERSION };
870
893
  }