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