ctxline-claude 1.7.0 → 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 (2) hide show
  1. package/package.json +1 -1
  2. package/statusline.js +89 -220
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.7.0",
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,7 +1,5 @@
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');
@@ -10,18 +8,13 @@ const os = require('os');
10
8
  const https = require('https');
11
9
  const { execSync, execFileSync, spawn } = require('child_process');
12
10
 
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';
11
+ // Lives here, not package.json: this file ships standalone to ~/.claude/hooks/. Must match package.json.
12
+ const VERSION = '1.7.1';
17
13
 
18
14
  const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
19
15
 
20
- // Optional segment opt-out: CTXLINE_DISABLE is a comma list of segments to hide.
21
- // Recognized: branch, effort, cost, task, update, usage (H+W+model-scoped). dir/model/context
22
- // always render.
23
- // Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
24
- // 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.
25
18
  const DISABLED = new Set(
26
19
  (process.env.CTXLINE_DISABLE || '')
27
20
  .split(',')
@@ -29,40 +22,30 @@ const DISABLED = new Set(
29
22
  .filter(Boolean)
30
23
  );
31
24
 
32
- // Shared width (cells) for all progress bars: context, current, weekly.
25
+ // Context bar width in cells.
33
26
  const BAR_WIDTH = 6;
34
27
 
35
- // Max characters shown for the git branch; longer names are tail-truncated with "…".
36
- // 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.
37
29
  const MAX_BRANCH_LEN = 24;
38
30
 
39
- // Separator between segments on a rendered line.
40
31
  const SEGMENT_SEP = ' │ ';
41
32
 
42
- // Cells reserved at the terminal edge when deciding to wrap to a second line.
43
- // 0 = use the full COLUMNS; bump it if Claude Code reserves columns and the line
44
- // truncates a char or two before wrapping.
33
+ // Cells reserved at the terminal edge when deciding to wrap; 0 = full width.
45
34
  const WIDTH_MARGIN = 0;
46
35
 
47
36
  // Cache configuration
48
37
  const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
49
38
  const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
50
- // Fresh: trust the cache and skip the API call entirely (fewer calls, faster render).
51
- const FRESH_TTL_MS = 30000; // 30 seconds
52
- // Stale: used only as a fallback when a live API call fails, so the usage bar stays
53
- // visible through transient timeouts/errors instead of disappearing.
54
- const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
55
-
56
- // Git ahead/behind cache (single repo entry, keyed by git dir). Throttles the one
57
- // `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.
58
43
  const GIT_CACHE_FILE = path.join(CACHE_DIR, 'git-cache.json');
59
44
  const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burst
60
45
  const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
61
46
  const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
62
47
 
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.
48
+ // Update check: render only reads this cache; the registry fetch runs in a detached child.
66
49
  const UPDATE_CACHE_FILE = path.join(CACHE_DIR, 'update-cache.json');
67
50
  const UPDATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days between successful checks
68
51
  const UPDATE_RETRY_MS = 60 * 60 * 1000; // 1h backoff after a failed/killed check
@@ -71,8 +54,7 @@ const REGISTRY_HOST = 'registry.npmjs.org';
71
54
  const PACKAGE_NAME = 'ctxline-claude';
72
55
  const SEMVER_RE = /^\d+\.\d+\.\d+$/; // releases only: a prerelease never nudges
73
56
 
74
- // Subagent mode reads only stdin (no usage API to race), so its stdin read gets a
75
- // short hard cap of its own instead of the main-mode overallTimeout.
57
+ // Subagent mode reads only stdin (no fetch to race), so its read gets its own short cap.
76
58
  const SUBAGENT_TIMEOUT_MS = 500;
77
59
 
78
60
  // ANSI color codes
@@ -84,13 +66,10 @@ const colors = {
84
66
  yellow: '\x1b[33m',
85
67
  orange: '\x1b[38;5;208m',
86
68
  red: '\x1b[31m',
87
- purple: '\x1b[38;5;135m',
88
- blink: '\x1b[5m'
69
+ purple: '\x1b[38;5;135m'
89
70
  };
90
71
 
91
- // Color for the thinking-effort indicator. Levels rank low < medium < high < xhigh < max
92
- // < ultracode; only the top two are highlighted — "max" red, "ultracode" purple. Every
93
- // 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.
94
73
  function getEffortColor(level) {
95
74
  const lvl = String(level).toLowerCase();
96
75
  if (lvl === 'max') return colors.red;
@@ -99,27 +78,23 @@ function getEffortColor(level) {
99
78
  }
100
79
 
101
80
  function getUsageColor(percentage) {
102
- if (percentage < 50) return colors.green;
103
- if (percentage < 75) return colors.yellow;
81
+ if (percentage < 60) return colors.green;
82
+ if (percentage < 80) return colors.yellow;
104
83
  if (percentage < 90) return colors.orange;
105
84
  return colors.red;
106
85
  }
107
86
 
108
- // Model-scoped bars skip the H/W thresholds: a line can carry several at once, so a flat
109
- // orange keeps them readable as one group. Red at >=90 is the one distinction kept — that
110
- // 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.
111
88
  function getScopedColor(percentage) {
112
89
  return percentage >= 90 ? colors.red : colors.orange;
113
90
  }
114
91
 
115
- // 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".
116
93
  function shortenModel(name) {
117
- return name.replace(/\s+context\)/i, ')');
94
+ return name.replace(/\s*\([^)]*context\)/i, '');
118
95
  }
119
96
 
120
- // Shorten a resolved model ID (subagent task.model, e.g. "claude-opus-5") for the
121
- // subagent row: "claude-opus-5" -> "Opus 5", "claude-haiku-4-5-20251001" -> "Haiku 4.5".
122
- // 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).
123
98
  function shortenModelId(id) {
124
99
  if (!id) return '';
125
100
  const stripped = String(id).replace(/^(us\.)?(anthropic\.)?claude-/, '').replace(/-\d{8}$/, '');
@@ -135,8 +110,7 @@ function truncateBranch(name) {
135
110
  return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
136
111
  }
137
112
 
138
- // Resolve the repo's git dir by walking up from `dir` (no `git` subprocess). Handles
139
- // 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.
140
114
  function resolveGitDir(dir) {
141
115
  let cur = dir;
142
116
  let gitPath = '';
@@ -158,16 +132,14 @@ function resolveGitDir(dir) {
158
132
  return gitPath;
159
133
  }
160
134
 
161
- // Current git branch, read straight from .git/HEAD (no `git` subprocess fast,
162
- // 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.
163
136
  function getGitBranch(dir) {
164
137
  try {
165
138
  const gitDir = resolveGitDir(dir);
166
139
  if (!gitDir) return '';
167
140
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
168
141
  const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
169
- // Strip control chars: HEAD is read raw (not git-validated), so a hand-crafted file
170
- // in an untrusted archive could inject terminal escape sequences.
142
+ // HEAD is read raw, not git-validated: strip control chars (escape-sequence injection).
171
143
  if (ref) return truncateBranch(ref[1].replace(/[\x00-\x1f\x7f]/g, ''));
172
144
  if (/^[0-9a-f]{7,40}$/i.test(head)) return head.slice(0, 7); // detached HEAD -> short sha
173
145
  return '';
@@ -176,8 +148,7 @@ function getGitBranch(dir) {
176
148
  }
177
149
  }
178
150
 
179
- // Read the cached ahead/behind for `gitDir`. Single-entry file: a different repo
180
- // invalidates it. Returns { age, ahead, behind } or null.
151
+ // Cached ahead/behind for gitDir; different repo invalidates. { age, ahead, behind } or null.
181
152
  function readGitCache(gitDir) {
182
153
  try {
183
154
  const c = JSON.parse(fs.readFileSync(GIT_CACHE_FILE, 'utf8'));
@@ -196,10 +167,8 @@ function writeGitCache(gitDir, ahead, behind) {
196
167
  } catch (e) {}
197
168
  }
198
169
 
199
- // Commits ahead/behind the upstream (@{u}), cache-fronted. The single `git` call in the
200
- // whole script gated by GIT_FRESH_TTL_MS so a render burst runs it once. No upstream /
201
- // detached / no git -> the subprocess errors -> null (segment omitted). On a slow/failed
202
- // 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.
203
172
  function getGitAheadBehind(dir) {
204
173
  const gitDir = resolveGitDir(dir);
205
174
  if (!gitDir) return null;
@@ -210,8 +179,7 @@ function getGitAheadBehind(dir) {
210
179
  }
211
180
 
212
181
  try {
213
- // execFileSync (no shell): faster cold spawn than execSync and passes `@{u}` literally.
214
- // `@{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>".
215
183
  const out = execFileSync('git', ['rev-list', '--left-right', '--count', '@{u}...HEAD'], {
216
184
  cwd: dir, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore']
217
185
  }).trim();
@@ -231,9 +199,7 @@ function getGitAheadBehind(dir) {
231
199
  }
232
200
  }
233
201
 
234
- // "↑N↓M" from ahead/behind counts: ahead green (commits to push), behind red (missing
235
- // commits). Each part self-resets so it doesn't inherit the dim branch color. Omit a zero
236
- // side; '' when in sync or null.
202
+ // "↑N↓M": ahead green, behind red, zero side omitted; '' when in sync or null.
237
203
  function formatAheadBehind(ab) {
238
204
  if (!ab) return '';
239
205
  let s = '';
@@ -242,20 +208,16 @@ function formatAheadBehind(ab) {
242
208
  return s;
243
209
  }
244
210
 
245
- // Colored "C<used> <bar>" (e.g. "C45 ███░░░") for an already-clamped 0-100 used
246
- // percentage. Shared by the main context bar (derived from remaining%) and the
247
- // subagent row (derived from tokenCount/contextWindowSize) so both use the same
248
- // thresholds and bar style.
211
+ // Colored "C<used> <bar>" (e.g. "C45 ███░░░"); shared by main line and subagent rows.
249
212
  function renderContextBar(used) {
250
213
  const filled = Math.round((used / 100) * BAR_WIDTH);
251
214
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
252
215
 
253
- // Context color: green <50 / yellow <65 / orange <80 / blink-red >=80.
254
216
  let color;
255
217
  if (used < 50) color = colors.green;
256
218
  else if (used < 65) color = colors.yellow;
257
219
  else if (used < 80) color = colors.orange;
258
- else color = colors.blink + colors.red;
220
+ else color = colors.red;
259
221
 
260
222
  return `${color}C${used} ${bar}${colors.reset}`;
261
223
  }
@@ -271,10 +233,8 @@ function renderModelEffort(model, effort) {
271
233
  return effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model;
272
234
  }
273
235
 
274
- // Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
275
- // (e.g. "H81 2h21m") no bar. Called on every read (live or cached) so the reset
276
- // countdown is always recomputed from resetsAt rather than frozen at fetch time.
277
- // `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).
278
238
  function buildUsageBar(label, percentage, resetsAt, color) {
279
239
  let timeStr = '';
280
240
  if (resetsAt) {
@@ -293,32 +253,16 @@ function buildUsageBar(label, percentage, resetsAt, color) {
293
253
  return `${barColor}${label}${percentage}${colors.reset}${timePart}`;
294
254
  }
295
255
 
296
- // Model-scoped weekly limits (e.g. "Fable weekly limit at 86%"), rendered after the
297
- // account-wide W bar. The /usage payload reports these in a `limits` array, each entry
298
- // carrying the model in scope.model.display_name:
299
- //
300
- // { kind: "weekly_scoped", percent: 86, severity: "warning",
301
- // resets_at: "...", scope: { model: { display_name: "Fable" } } }
302
- //
303
- // The label is the model's first initial (Fable -> F), so a new model family needs no
304
- // code change. Older payloads instead exposed flat seven_day_<model> keys, kept below as
305
- // a fallback for accounts still reporting that shape.
306
- //
307
- // NOTE: these appear only in the API payload. Claude Code's statusline stdin carries just
308
- // five_hour and seven_day under rate_limits, so the scoped limits always come from the
309
- // 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.
310
260
  const LEGACY_MODEL_WEEKLY_KEYS = [
311
261
  { key: 'seven_day_opus', label: 'O' },
312
262
  { key: 'seven_day_sonnet', label: 'S' }
313
263
  ];
314
264
 
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.
265
+ // Raw { fiveHour, weekly, models } -> rendered segments; scoped bars use getScopedColor.
322
266
  function buildUsageBars(raw) {
323
267
  const { fiveHour, weekly, models } = raw || {};
324
268
  return {
@@ -328,18 +272,13 @@ function buildUsageBars(raw) {
328
272
  };
329
273
  }
330
274
 
331
- // Normalize a raw API utilization into the 0-100 integer that the rest of the
332
- // pipeline (cache validation + bar rendering) expects. Returns null when the value
333
- // 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%".
334
276
  function normalizePercentage(value) {
335
277
  if (!Number.isFinite(value)) return null;
336
278
  return Math.max(0, Math.min(100, Math.round(value)));
337
279
  }
338
280
 
339
- // Extract the model-scoped weekly limits from a raw /usage payload as
340
- // [{ label, percentage, resetsAt }], in payload order. Prefers the `limits` array;
341
- // falls back to the legacy flat keys only when it yields nothing, so an account
342
- // reporting both shapes doesn't render the same limit twice.
281
+ // limits[] -> [{ label, percentage, resetsAt }]; legacy flat keys only when limits yields nothing.
343
282
  function parseScopedLimits(usage) {
344
283
  const scoped = [];
345
284
 
@@ -366,13 +305,9 @@ function parseScopedLimits(usage) {
366
305
  return scoped;
367
306
  }
368
307
 
369
- // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
370
- // first API response of a session). Same data as the OAuth usage API, so reading it here
371
- // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
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.
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.
376
311
  function buildUsageFromStdin(data) {
377
312
  const rl = data?.rate_limits;
378
313
  if (!rl) return null;
@@ -381,9 +316,7 @@ function buildUsageFromStdin(data) {
381
316
  if (!seg) return null;
382
317
  const pct = normalizePercentage(seg.used_percentage);
383
318
  if (pct == null) return null;
384
- // resets_at is a Unix epoch in SECONDS. Coerce + validate defensively: a non-numeric
385
- // or out-of-range value would make new Date(...).toISOString() throw, and this path
386
- // 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.
387
320
  let resetsAt = null;
388
321
  const epoch = Number(seg.resets_at);
389
322
  if (Number.isFinite(epoch) && epoch > 0) {
@@ -398,11 +331,9 @@ function buildUsageFromStdin(data) {
398
331
  return { fiveHour, weekly: toEntry(rl.seven_day), models: [] };
399
332
  }
400
333
 
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.
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.
406
337
  function parseUsagePayload(body) {
407
338
  try {
408
339
  const usage = JSON.parse(body);
@@ -420,8 +351,7 @@ function parseUsagePayload(body) {
420
351
  }
421
352
  }
422
353
 
423
- // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
424
- // finite 0-100 percentage and a parseable (or absent) resetsAt.
354
+ // Valid entry: finite 0-100 percentage, parseable (or absent) resetsAt.
425
355
  function isValidUsageEntry(entry) {
426
356
  if (!entry || typeof entry !== 'object') return false;
427
357
  if (!Number.isFinite(entry.percentage) || entry.percentage < 0 || entry.percentage > 100) return false;
@@ -429,9 +359,7 @@ function isValidUsageEntry(entry) {
429
359
  return true;
430
360
  }
431
361
 
432
- // Read the raw cached usage data
433
- // ({ timestamp, data: { fiveHour: {percentage,resetsAt}, weekly: {...}|null } }).
434
- // 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.
435
363
  function readCachedUsage() {
436
364
  try {
437
365
  if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
@@ -439,10 +367,7 @@ function readCachedUsage() {
439
367
  const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
440
368
  if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
441
369
 
442
- // Validate data. fiveHour is required; weekly and models are optional (the API may
443
- // omit either). This also rejects the legacy single-{percentage,resetsAt} format from
444
- // older versions, which had no fiveHour key, so stale caches are ignored on read.
445
- // 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.
446
371
  const data = cache.data;
447
372
  if (!data || typeof data !== 'object') return null;
448
373
  if (!isValidUsageEntry(data.fiveHour)) return null;
@@ -458,11 +383,8 @@ function readCachedUsage() {
458
383
  }
459
384
  }
460
385
 
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`.
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.
466
388
  function serializeUsageCache(data, timestamp = Date.now()) {
467
389
  return JSON.stringify({ timestamp, data, lastAttempt: timestamp });
468
390
  }
@@ -480,9 +402,8 @@ function setCachedUsage(data) {
480
402
  }
481
403
  }
482
404
 
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).
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.
486
407
  function getLastAttemptAge() {
487
408
  try {
488
409
  if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
@@ -494,9 +415,8 @@ function getLastAttemptAge() {
494
415
  }
495
416
  }
496
417
 
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.
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.
500
420
  function recordUsageAttempt() {
501
421
  try {
502
422
  if (!fs.existsSync(CACHE_DIR)) {
@@ -514,9 +434,7 @@ function recordUsageAttempt() {
514
434
  }
515
435
  }
516
436
 
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.
437
+ // Strict "x.y.z" -> -1|0|1, null otherwise (prerelease never nudges — a nicety, not a guess).
520
438
  function compareVersions(a, b) {
521
439
  const parse = (v) => SEMVER_RE.test(String(v ?? '')) ? String(v).split('.').map(Number) : null;
522
440
  const x = parse(a);
@@ -528,8 +446,7 @@ function compareVersions(a, b) {
528
446
  return 0;
529
447
  }
530
448
 
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).
449
+ // Registry body -> "x.y.z" or null (404 bodies land here too).
533
450
  function parseRegistryVersion(body) {
534
451
  try {
535
452
  const v = JSON.parse(body)?.version;
@@ -555,18 +472,15 @@ function writeUpdateCache(obj) {
555
472
  } catch (e) {}
556
473
  }
557
474
 
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.
475
+ // Cached latest when strictly newer than VERSION, else ''. Cache-only: render never touches the network.
560
476
  function getLatestUpdate() {
561
477
  const cached = readUpdateCache();
562
478
  if (!cached) return '';
563
479
  return compareVersions(cached.latest, VERSION) === 1 ? String(cached.latest) : '';
564
480
  }
565
481
 
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.
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.
570
484
  function refreshUpdateCheck() {
571
485
  try {
572
486
  const cached = readUpdateCache();
@@ -576,18 +490,15 @@ function refreshUpdateCheck() {
576
490
  if (Number.isFinite(cached.lastAttempt) && now - cached.lastAttempt < UPDATE_RETRY_MS) return;
577
491
  }
578
492
  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).
493
+ // windowsHide: no console flash on Windows; detached + unref: child outlives the render's exit.
581
494
  spawn(process.execPath, [__filename, 'update-check'], {
582
495
  detached: true, stdio: 'ignore', windowsHide: true
583
496
  }).unref();
584
497
  } catch (e) {}
585
498
  }
586
499
 
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.
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.
591
502
  function runUpdateCheck() {
592
503
  let settled = false;
593
504
  let deadline;
@@ -619,9 +530,7 @@ function runUpdateCheck() {
619
530
  done(null);
620
531
  });
621
532
 
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.
533
+ // The timeout option is socket inactivity, not total a trickling response needs this hard deadline.
625
534
  deadline = setTimeout(() => {
626
535
  req.destroy();
627
536
  done(null);
@@ -634,7 +543,6 @@ function runUpdateCheck() {
634
543
  }
635
544
 
636
545
  function getCredentials() {
637
- // Try file first (legacy / Linux / Windows)
638
546
  const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
639
547
  if (fs.existsSync(credsPath)) {
640
548
  try {
@@ -642,7 +550,7 @@ function getCredentials() {
642
550
  } catch (e) {}
643
551
  }
644
552
 
645
- // Fallback: macOS keychain
553
+ // macOS keychain fallback
646
554
  if (os.platform() === 'darwin') {
647
555
  try {
648
556
  const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', timeout: 1000 });
@@ -655,7 +563,6 @@ function getCredentials() {
655
563
 
656
564
  function getApiUsage(callback) {
657
565
  try {
658
- // Read credentials (file or macOS keychain)
659
566
  const creds = getCredentials();
660
567
  if (!creds) {
661
568
  return callback(null);
@@ -667,12 +574,10 @@ function getApiUsage(callback) {
667
574
  return callback(null);
668
575
  }
669
576
 
670
- // Adaptive timeout: if cache exists, be faster (1200ms); if not, be patient (1500ms)
671
- // 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.
672
578
  const hasCache = fs.existsSync(USAGE_CACHE_FILE);
673
579
  const timeout = hasCache ? 1200 : 1500;
674
580
 
675
- // Make API call with adaptive timeout
676
581
  const req = https.request({
677
582
  hostname: 'api.anthropic.com',
678
583
  path: '/api/oauth/usage',
@@ -689,7 +594,6 @@ function getApiUsage(callback) {
689
594
  res.on('data', chunk => data += chunk);
690
595
  res.on('end', () => {
691
596
  const resolved = parseUsagePayload(data);
692
- // Cache the raw data (shared across sessions); callers render from it.
693
597
  if (resolved) setCachedUsage(resolved);
694
598
  callback(resolved);
695
599
  });
@@ -711,27 +615,21 @@ function getApiUsage(callback) {
711
615
  function getRawUsage(callback) {
712
616
  const cached = readCachedUsage();
713
617
 
714
- // Cache is fresh -> use it and skip the API entirely (fewer calls, faster).
715
618
  if (cached && cached.age < FRESH_TTL_MS) {
716
619
  return callback(cached.data);
717
620
  }
718
621
 
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).
622
+ // Refresh attempted (even failed) within FRESH_TTL_MS -> cooldown: serve stale up to STALE_TTL_MS (issue #41).
723
623
  const attemptAge = getLastAttemptAge();
724
624
  if (attemptAge != null && attemptAge < FRESH_TTL_MS) {
725
625
  return callback(cached && cached.age < STALE_TTL_MS ? cached.data : null);
726
626
  }
727
627
 
728
- // Cache is stale or missing and no attempt is in cooldown -> refresh from the API.
729
628
  recordUsageAttempt();
730
629
  getApiUsage((fresh) => {
731
630
  if (fresh) {
732
631
  callback(fresh);
733
632
  } else if (cached && cached.age < STALE_TTL_MS) {
734
- // API failed/timed out, but recent cache exists -> show it instead of nothing.
735
633
  callback(cached.data);
736
634
  } else {
737
635
  callback(null);
@@ -739,9 +637,7 @@ function getRawUsage(callback) {
739
637
  });
740
638
  }
741
639
 
742
- // Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
743
- // Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
744
- // 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.
745
641
  function getCostSegment(data) {
746
642
  const usd = data?.cost?.total_cost_usd;
747
643
  if (!Number.isFinite(usd)) return '';
@@ -777,11 +673,8 @@ function visibleWidth(str) {
777
673
  return [...str.replace(/\x1b\[[0-9;]*m/g, '')].length;
778
674
  }
779
675
 
780
- // Responsive layout: one line when it fits the terminal, else line1 (identity + context)
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.
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.
785
678
  function layout(line1Parts, line2Parts, cols) {
786
679
  const single = [...line1Parts, ...line2Parts].join(SEGMENT_SEP);
787
680
  if (line2Parts.length === 0) return single;
@@ -791,12 +684,9 @@ function layout(line1Parts, line2Parts, cols) {
791
684
  return single;
792
685
  }
793
686
 
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.
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.
800
690
  function collectFacts(data) {
801
691
  try {
802
692
  const dir = data?.workspace?.current_dir || process.cwd();
@@ -813,19 +703,15 @@ function collectFacts(data) {
813
703
  }
814
704
  }
815
705
 
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.
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.
821
708
  function renderUpdateLine(latest) {
822
709
  return `${colors.green}⬆ ${latest}${colors.reset} `
823
710
  + `${colors.dim}available ·${colors.reset} `
824
711
  + `${colors.bold}npx ${PACKAGE_NAME}@latest${colors.reset}`;
825
712
  }
826
713
 
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.
714
+ // Pure: data + facts (see collectFacts) + usage bars -> rendered line(s); callable directly in tests.
829
715
  function renderStatusLine(data, facts, usage) {
830
716
  const model = shortenModel(data?.model?.display_name || 'Claude');
831
717
  const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
@@ -853,7 +739,6 @@ function renderStatusLine(data, facts, usage) {
853
739
  return facts.update ? body + '\n' + renderUpdateLine(facts.update) : body;
854
740
  }
855
741
 
856
- // Main
857
742
  function outputStatus(data, facts, usage) {
858
743
  try {
859
744
  process.stdout.write(renderStatusLine(data, facts, usage));
@@ -867,20 +752,15 @@ function outputFallback(usage) {
867
752
  process.stdout.write(renderStatusLine(null, facts, usage));
868
753
  }
869
754
 
870
- // Resolve usage bars for a (possibly null) parsed stdin payload.
871
- // Order: API-key users get none; otherwise prefer stdin `rate_limits` (no network),
872
- // 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).
873
756
  function resolveUsage(data, callback) {
874
757
  if (IS_API_KEY || DISABLED.has('usage')) {
875
758
  return callback(null);
876
759
  }
877
760
  const fromStdin = buildUsageFromStdin(data);
878
761
  if (fromStdin) {
879
- // stdin covers H and W with no network. Model-scoped weekly limits only exist in the
880
- // API payload, so they come from the cache refreshed on the same TTL as every other
881
- // usage read, which keeps at most one call per FRESH_TTL_MS regardless of render rate.
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.
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.
884
764
  return getRawUsage((cached) => {
885
765
  callback(buildUsageBars({ ...fromStdin, models: cached?.models || [] }));
886
766
  });
@@ -898,10 +778,8 @@ function parseInput(input) {
898
778
  }
899
779
  }
900
780
 
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.
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.
905
783
  function readStdinThen(timeoutMs, fn) {
906
784
  let input = '';
907
785
  let finished = false;
@@ -934,10 +812,8 @@ function emit(data) {
934
812
  });
935
813
  }
936
814
 
937
- // now - startTime as "45s" / "4m12s" / "2h5m". '' when startTime is missing/unparseable.
938
- // Format isn't documented by Claude Code, so accept epoch-seconds, epoch-ms, or an ISO
939
- // string: numbers below 1e12 are epoch-seconds (today's epoch-seconds ~1.7e9, epoch-ms
940
- // ~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.
941
817
  function formatElapsed(startTime) {
942
818
  if (startTime == null) return '';
943
819
  const ms = typeof startTime === 'number' && startTime < 1e12 ? startTime * 1000 : startTime;
@@ -978,10 +854,8 @@ function renderSubagentTask(t) {
978
854
  return parts.join(SEGMENT_SEP);
979
855
  }
980
856
 
981
- // subagentStatusLine mode: emit one {id, content} JSON line per task with an id, then
982
- // exit. No usage/git/todos/cache work the task objects carry everything needed.
983
- // Bad payload or a task that fails to render -> emit nothing, keeping default
984
- // 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).
985
859
  function emitSubagent(data) {
986
860
  try {
987
861
  const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
@@ -990,9 +864,7 @@ function emitSubagent(data) {
990
864
  .map(t => JSON.stringify({ id: t.id, content: renderSubagentTask(t) }))
991
865
  .join('\n');
992
866
  if (out) {
993
- // Exit from the write callback: process.exit() would drop output still queued
994
- // behind stdout backpressure. A write error (e.g. EPIPE) also lands here — the
995
- // 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.
996
868
  process.stdout.write(out + '\n', () => process.exit(0));
997
869
  return;
998
870
  }
@@ -1000,16 +872,13 @@ function emitSubagent(data) {
1000
872
  process.exit(0);
1001
873
  }
1002
874
 
1003
- // Entry point, guarded so tests can require this file to exercise payload parsing
1004
- // directly (the /usage response shape is the easiest thing here to get wrong, and it
1005
- // 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.
1006
876
  if (require.main === module) {
1007
877
  const mode = process.argv[2];
1008
878
  const isSubagent = mode === 'subagent';
1009
879
  const finish = isSubagent ? emitSubagent : emit;
1010
880
 
1011
881
  if (mode === 'update-check') {
1012
- // Detached child spawned by refreshUpdateCheck: no stdin, no output, just the fetch.
1013
882
  runUpdateCheck();
1014
883
  } else if (process.stdin.isTTY) {
1015
884
  finish(null);