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.
- package/package.json +1 -1
- package/statusline.js +89 -220
package/package.json
CHANGED
package/statusline.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Claude Code
|
|
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
|
-
//
|
|
14
|
-
|
|
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
|
-
//
|
|
21
|
-
//
|
|
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
|
-
//
|
|
25
|
+
// Context bar width in cells.
|
|
33
26
|
const BAR_WIDTH = 6;
|
|
34
27
|
|
|
35
|
-
//
|
|
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
|
|
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
|
-
//
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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 <
|
|
103
|
-
if (percentage <
|
|
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
|
-
//
|
|
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
|
-
//
|
|
92
|
+
// Drop the context-window suffix: "Opus 5.5 (1M context)" -> "Opus 5.5".
|
|
116
93
|
function shortenModel(name) {
|
|
117
|
-
return name.replace(/\s
|
|
94
|
+
return name.replace(/\s*\([^)]*context\)/i, '');
|
|
118
95
|
}
|
|
119
96
|
|
|
120
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
200
|
-
//
|
|
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
|
-
//
|
|
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"
|
|
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 ███░░░")
|
|
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.
|
|
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
|
-
//
|
|
275
|
-
//
|
|
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
|
|
297
|
-
//
|
|
298
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
462
|
-
//
|
|
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
|
|
484
|
-
//
|
|
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
|
-
//
|
|
498
|
-
// failed refresh doesn't erase the last successful one.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
567
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
588
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
781
|
-
//
|
|
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
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
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
|
-
//
|
|
817
|
-
//
|
|
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) +
|
|
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
|
-
//
|
|
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
|
-
//
|
|
880
|
-
//
|
|
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
|
|
902
|
-
//
|
|
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
|
-
//
|
|
938
|
-
//
|
|
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:
|
|
982
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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);
|