ctxline-claude 1.2.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +28 -5
  2. package/package.json +1 -1
  3. package/statusline.js +265 -52
package/README.md CHANGED
@@ -17,10 +17,13 @@
17
17
  <a href="https://github.com/MithunWijayasiri/ctxline-claude/stargazers">
18
18
  <img src="https://img.shields.io/github/stars/MithunWijayasiri/ctxline-claude" alt="stars">
19
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>
20
23
  </p>
21
24
 
22
25
  <p align="center">
23
- <img src="preview.svg" alt="Claude Code Statusline">
26
+ <img src="docs/assets/preview.svg" alt="Claude Code Statusline">
24
27
  </p>
25
28
 
26
29
  <p align="center">
@@ -74,6 +77,17 @@ chmod +x ~/.claude/hooks/statusline.js
74
77
  }
75
78
  ```
76
79
 
80
+ **Subagent rows.** The same script also renders per-task rows in the agent panel for running subagents — wire it as a separate `subagentStatusLine` command:
81
+
82
+ ```json
83
+ {
84
+ "subagentStatusLine": {
85
+ "type": "command",
86
+ "command": "node ~/.claude/hooks/statusline.js subagent"
87
+ }
88
+ }
89
+ ```
90
+
77
91
  </details>
78
92
 
79
93
  ## Update
@@ -121,6 +135,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
121
135
  | **Context** | Visual bar of context-window usage |
122
136
  | **Current** | Live 5-hour session limit + reset countdown (subscription users) |
123
137
  | **Weekly** | Weekly usage allowance + time until the weekly reset (subscription users) |
138
+ | **Model limit** | Weekly limit scoped to a single model, when your account has one — labelled by the model's initial (`F` = Fable) |
124
139
  | **Cost** | Running session cost in USD (e.g. `$0.42`) |
125
140
  | **Task** | The in-progress todo, when there is one |
126
141
 
@@ -137,7 +152,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
137
152
 
138
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:
139
154
 
140
- `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly)
155
+ `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly + model-scoped)
141
156
 
142
157
  Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
143
158
 
@@ -181,7 +196,8 @@ To re-enable a segment, remove it from the list (or delete the variable) and res
181
196
 
182
197
  ## How it works
183
198
 
184
- - **Source** — context comes from Claude Code's session data. Usage bars are read straight from the `rate_limits` field Claude Code pipes in (no network), falling back to `https://api.anthropic.com/api/oauth/usage` (the same `/usage` data — 5-hour and weekly limits) when that field isn't present yet. API-key users skip usage entirely.
199
+ - **Source** — context comes from Claude Code's session data. The 5-hour and weekly bars are read straight from the `rate_limits` field Claude Code pipes in (no network), falling back to `https://api.anthropic.com/api/oauth/usage` when that field isn't present yet. API-key users skip usage entirely.
200
+ - **Model-scoped limits** — `rate_limits` carries only `five_hour` and `seven_day`, so a model-scoped weekly limit can only come from `/usage` (its `limits` array). It's served from the same cache as everything else, so this costs at most one call per 30s no matter how often the line renders.
185
201
  - **No network on the fast path** — when `rate_limits` is in the session data, there's no API call at all. The fetch below only runs as a fallback (e.g. the first render of a session, before the field appears).
186
202
  - **Adaptive timing** — for the fallback fetch: 1.5s timeout on the first prompt (cold start), 1.2s after (connection reused).
187
203
  - **Caching** — the fallback fetch is cached at `~/.claude/cache/usage-cache.json`, shared across sessions. Within 30s the cache renders directly (the API call is skipped); if a live call fails, the last value (up to 10 min old) is shown so the bar never vanishes. The reset countdown recomputes every render.
@@ -238,10 +254,17 @@ No. Your credentials never leave your machine. On the fast path no token is read
238
254
 
239
255
  </details>
240
256
 
241
- ## License
242
257
 
243
- MIT
258
+ ## Support
259
+
260
+ If you find this project useful, consider supporting its development on [Ko-fi](https://ko-fi.com/mithunwijayasiri). Your donations help keep the project maintained, improve existing features, and fund new open-source tools.
261
+
262
+ Thank you for your support! ❤️
244
263
 
245
264
  ## Credits
246
265
 
247
266
  Thanks to [@TahaSabir0](https://github.com/TahaSabir0) for the base config.
267
+
268
+ ## License
269
+
270
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.2.2",
3
+ "version": "1.6.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"
package/statusline.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Claude Code Enhanced Statusline
3
- // Shows: directory | model | context usage | current (5-hour) + weekly usage | current task
3
+ // Shows: directory | model | context usage | 5-hour + weekly + model-scoped usage | current task
4
4
  // Auto-detects API key vs subscription usage
5
5
  // https://github.com/MithunWijayasiri/ctxline-claude
6
6
 
@@ -13,7 +13,7 @@ const { execSync, execFileSync } = require('child_process');
13
13
  const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
14
 
15
15
  // Optional segment opt-out: CTXLINE_DISABLE is a comma list of segments to hide.
16
- // Recognized: branch, effort, cost, task, usage (H+W). dir/model/context always render.
16
+ // Recognized: branch, effort, cost, task, usage (H+W+model-scoped). dir/model/context always render.
17
17
  // Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
18
18
  // usage fetch).
19
19
  const DISABLED = new Set(
@@ -54,6 +54,10 @@ const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burs
54
54
  const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
55
55
  const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
56
56
 
57
+ // Subagent mode reads only stdin (no usage API to race), so its stdin read gets a
58
+ // short hard cap of its own instead of the main-mode overallTimeout.
59
+ const SUBAGENT_TIMEOUT_MS = 500;
60
+
57
61
  // ANSI color codes
58
62
  const colors = {
59
63
  reset: '\x1b[0m',
@@ -84,11 +88,31 @@ function getUsageColor(percentage) {
84
88
  return colors.red;
85
89
  }
86
90
 
91
+ // Model-scoped bars skip the H/W thresholds: a line can carry several at once, so a flat
92
+ // orange keeps them readable as one group. Red at >=90 is the one distinction kept — that
93
+ // bar is about to block the model it names.
94
+ function getScopedColor(percentage) {
95
+ return percentage >= 90 ? colors.red : colors.orange;
96
+ }
97
+
87
98
  // Shorten verbose model names for the statusline: "Opus 4.8 (1M context)" -> "Opus 4.8 (1M)".
88
99
  function shortenModel(name) {
89
100
  return name.replace(/\s+context\)/i, ')');
90
101
  }
91
102
 
103
+ // Shorten a resolved model ID (subagent task.model, e.g. "claude-opus-5") for the
104
+ // subagent row: "claude-opus-5" -> "Opus 5", "claude-haiku-4-5-20251001" -> "Haiku 4.5".
105
+ // Distinct from shortenModel, which trims a display name rather than parsing an ID.
106
+ function shortenModelId(id) {
107
+ if (!id) return '';
108
+ const stripped = String(id).replace(/^(us\.)?(anthropic\.)?claude-/, '').replace(/-\d{8}$/, '');
109
+ const [family, ...rest] = stripped.split('-');
110
+ if (!family) return stripped;
111
+ const name = family[0].toUpperCase() + family.slice(1);
112
+ const version = rest.join('.');
113
+ return version ? `${name} ${version}` : name;
114
+ }
115
+
92
116
  // Tail-truncate an over-long branch name, preserving the leading ticket ID.
93
117
  function truncateBranch(name) {
94
118
  return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
@@ -201,10 +225,11 @@ function formatAheadBehind(ab) {
201
225
  return s;
202
226
  }
203
227
 
204
- function getContextBar(remaining) {
205
- const effectiveRemaining = remaining ?? 100;
206
- const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
207
-
228
+ // Colored "C<used> <bar>" (e.g. "C45 ███░░░") for an already-clamped 0-100 used
229
+ // percentage. Shared by the main context bar (derived from remaining%) and the
230
+ // subagent row (derived from tokenCount/contextWindowSize) so both use the same
231
+ // thresholds and bar style.
232
+ function renderContextBar(used) {
208
233
  const filled = Math.round((used / 100) * BAR_WIDTH);
209
234
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
210
235
 
@@ -215,14 +240,20 @@ function getContextBar(remaining) {
215
240
  else if (used < 80) color = colors.orange;
216
241
  else color = colors.blink + colors.red;
217
242
 
218
- // Compact label form: "C<used> <bar>" (e.g. "C45 ███░░░"), colored as a whole.
219
243
  return `${color}C${used} ${bar}${colors.reset}`;
220
244
  }
221
245
 
246
+ function getContextBar(remaining) {
247
+ const effectiveRemaining = remaining ?? 100;
248
+ const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
249
+ return renderContextBar(used);
250
+ }
251
+
222
252
  // Render a compact usage segment from raw data: "<label><pct> ↺ <countdown>"
223
253
  // (e.g. "H81 ↺ 2h21m") — no bar. Called on every read (live or cached) so the reset
224
254
  // countdown is always recomputed from resetsAt rather than frozen at fetch time.
225
- function buildUsageBar(label, percentage, resetsAt) {
255
+ // `color` overrides the threshold color — the model-scoped bars pass getScopedColor.
256
+ function buildUsageBar(label, percentage, resetsAt, color) {
226
257
  let timeStr = '';
227
258
  if (resetsAt) {
228
259
  const diffMins = Math.max(0, Math.floor((new Date(resetsAt) - new Date()) / 60000));
@@ -234,18 +265,41 @@ function buildUsageBar(label, percentage, resetsAt) {
234
265
  else timeStr = `${mins}m`;
235
266
  }
236
267
 
237
- const color = getUsageColor(percentage);
268
+ const barColor = color || getUsageColor(percentage);
238
269
  const timePart = timeStr ? `${colors.dim} ↺ ${timeStr}${colors.reset}` : '';
239
270
 
240
- return `${color}${label}${percentage}${colors.reset}${timePart}`;
271
+ return `${barColor}${label}${percentage}${colors.reset}${timePart}`;
241
272
  }
242
273
 
243
- // Build both usage segments from raw entries. Each entry is { percentage, resetsAt } or
244
- // null/absent. Returns { current, weekly } where each is a rendered segment string or null.
245
- function buildUsageBars(fiveHour, weekly) {
274
+ // Model-scoped weekly limits (e.g. "Fable weekly limit at 86%"), rendered after the
275
+ // account-wide W bar. The /usage payload reports these in a `limits` array, each entry
276
+ // carrying the model in scope.model.display_name:
277
+ //
278
+ // { kind: "weekly_scoped", percent: 86, severity: "warning",
279
+ // resets_at: "...", scope: { model: { display_name: "Fable" } } }
280
+ //
281
+ // The label is the model's first initial (Fable -> F), so a new model family needs no
282
+ // code change. Older payloads instead exposed flat seven_day_<model> keys, kept below as
283
+ // a fallback for accounts still reporting that shape.
284
+ //
285
+ // NOTE: these appear only in the API payload. Claude Code's statusline stdin carries just
286
+ // five_hour and seven_day under rate_limits, so the scoped limits always come from the
287
+ // cache/API path even when stdin supplies the H and W bars.
288
+ const LEGACY_MODEL_WEEKLY_KEYS = [
289
+ { key: 'seven_day_opus', label: 'O' },
290
+ { key: 'seven_day_sonnet', label: 'S' }
291
+ ];
292
+
293
+ // Build the usage segments from raw entries. fiveHour/weekly are { percentage, resetsAt }
294
+ // or null/absent; models is an array of { label, percentage, resetsAt } (possibly empty).
295
+ // Returns { current, weekly, models } — the first two rendered strings or null, models a
296
+ // (possibly empty) array of rendered strings. Scoped bars use getScopedColor instead of the
297
+ // H/W thresholds, so the full threshold palette stays exclusive to H/W.
298
+ function buildUsageBars(fiveHour, weekly, models) {
246
299
  return {
247
300
  current: fiveHour ? buildUsageBar('H', fiveHour.percentage, fiveHour.resetsAt) : null,
248
- weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null
301
+ weekly: weekly ? buildUsageBar('W', weekly.percentage, weekly.resetsAt) : null,
302
+ models: (models || []).map(m => buildUsageBar(m.label, m.percentage, m.resetsAt, getScopedColor(m.percentage)))
249
303
  };
250
304
  }
251
305
 
@@ -257,11 +311,42 @@ function normalizePercentage(value) {
257
311
  return Math.max(0, Math.min(100, Math.round(value)));
258
312
  }
259
313
 
314
+ // Extract the model-scoped weekly limits from a raw /usage payload as
315
+ // [{ label, percentage, resetsAt }], in payload order. Prefers the `limits` array;
316
+ // falls back to the legacy flat keys only when it yields nothing, so an account
317
+ // reporting both shapes doesn't render the same limit twice.
318
+ function parseScopedLimits(usage) {
319
+ const scoped = [];
320
+
321
+ if (Array.isArray(usage?.limits)) {
322
+ for (const entry of usage.limits) {
323
+ if (!entry || entry.kind !== 'weekly_scoped') continue;
324
+ const name = entry.scope?.model?.display_name;
325
+ const pct = normalizePercentage(entry.percent);
326
+ if (typeof name !== 'string' || !name.trim() || pct == null) continue;
327
+ scoped.push({
328
+ label: name.trim().charAt(0).toUpperCase(),
329
+ percentage: pct,
330
+ resetsAt: entry.resets_at || null
331
+ });
332
+ }
333
+ if (scoped.length) return scoped;
334
+ }
335
+
336
+ for (const { key, label } of LEGACY_MODEL_WEEKLY_KEYS) {
337
+ const seg = usage?.[key];
338
+ const pct = seg ? normalizePercentage(seg.utilization) : null;
339
+ if (pct != null) scoped.push({ label, percentage: pct, resetsAt: seg.resets_at || null });
340
+ }
341
+ return scoped;
342
+ }
343
+
260
344
  // Build usage bars from stdin `rate_limits` (Claude.ai Pro/Max, present only after the
261
345
  // first API response of a session). Same data as the OAuth usage API, so reading it here
262
346
  // skips the network/credentials/cache path entirely. `resets_at` is a Unix epoch in
263
- // SECONDS (not ISO) — ×1000 before Date. Returns { current, weekly } bars, or null when
264
- // rate_limits is absent or the required five_hour segment is unusable (caller falls back).
347
+ // SECONDS (not ISO) — ×1000 before Date. Returns raw { fiveHour, weekly } entries, or null
348
+ // when rate_limits is absent or the required five_hour segment is unusable (caller falls
349
+ // back). Model-scoped weekly limits are never present here — see LEGACY_MODEL_WEEKLY_KEYS.
265
350
  function buildUsageFromStdin(data) {
266
351
  const rl = data?.rate_limits;
267
352
  if (!rl) return null;
@@ -284,8 +369,7 @@ function buildUsageFromStdin(data) {
284
369
 
285
370
  const fiveHour = toEntry(rl.five_hour);
286
371
  if (!fiveHour) return null; // five_hour is the required bar
287
- const weekly = toEntry(rl.seven_day);
288
- return buildUsageBars(fiveHour, weekly);
372
+ return { fiveHour, weekly: toEntry(rl.seven_day) };
289
373
  }
290
374
 
291
375
  // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
@@ -307,13 +391,18 @@ function readCachedUsage() {
307
391
  const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
308
392
  if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
309
393
 
310
- // Validate data. fiveHour is required; weekly is optional (the API may omit it).
311
- // This also rejects the legacy single-{percentage,resetsAt} format from older
312
- // versions, which had no fiveHour key, so stale caches are ignored on read.
394
+ // Validate data. fiveHour is required; weekly and models are optional (the API may
395
+ // omit either). This also rejects the legacy single-{percentage,resetsAt} format from
396
+ // older versions, which had no fiveHour key, so stale caches are ignored on read.
397
+ // A cache written before model bars existed simply has no models key — still valid.
313
398
  const data = cache.data;
314
399
  if (!data || typeof data !== 'object') return null;
315
400
  if (!isValidUsageEntry(data.fiveHour)) return null;
316
401
  if (data.weekly != null && !isValidUsageEntry(data.weekly)) return null;
402
+ if (data.models != null) {
403
+ if (!Array.isArray(data.models)) return null;
404
+ if (!data.models.every(m => typeof m?.label === 'string' && isValidUsageEntry(m))) return null;
405
+ }
317
406
 
318
407
  return { age: Date.now() - cache.timestamp, data };
319
408
  } catch (e) {
@@ -412,9 +501,13 @@ function getApiUsage(callback) {
412
501
  resetsAt: usage.seven_day.resets_at || null
413
502
  } : null;
414
503
 
415
- // Cache the raw data (shared across sessions); render the bars from it.
416
- setCachedUsage({ fiveHour, weekly });
417
- callback(buildUsageBars(fiveHour, weekly));
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);
418
511
  } else {
419
512
  callback(null);
420
513
  }
@@ -436,28 +529,42 @@ function getApiUsage(callback) {
436
529
  }
437
530
  }
438
531
 
439
- // Get usage, cache-first.
440
- function getUsageWithCache(callback) {
532
+ // Resolve raw usage data ({ fiveHour, weekly, models }), cache-first. Callers render it.
533
+ function getRawUsage(callback) {
441
534
  const cached = readCachedUsage();
442
535
 
443
- // Cache is fresh -> render it and skip the API entirely (fewer calls, faster).
536
+ // Cache is fresh -> use it and skip the API entirely (fewer calls, faster).
444
537
  if (cached && cached.age < FRESH_TTL_MS) {
445
- return callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
538
+ return callback(cached.data);
446
539
  }
447
540
 
448
541
  // Cache is stale or missing -> refresh from the API.
449
- getApiUsage((freshBars) => {
450
- if (freshBars) {
451
- callback(freshBars);
542
+ getApiUsage((fresh) => {
543
+ if (fresh) {
544
+ callback(fresh);
452
545
  } else if (cached && cached.age < STALE_TTL_MS) {
453
546
  // API failed/timed out, but recent cache exists -> show it instead of nothing.
454
- callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
547
+ callback(cached.data);
455
548
  } else {
456
549
  callback(null);
457
550
  }
458
551
  });
459
552
  }
460
553
 
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
+
461
568
  // Session cost from stdin `cost.total_cost_usd` (USD float, computed client-side by
462
569
  // Claude Code as tokens × per-model API pricing). Pure stdin — no network/cache.
463
570
  // Returns "$0.00" rendered dim, or '' when absent/non-finite so the segment is omitted.
@@ -537,6 +644,7 @@ function outputStatus(data, usage) {
537
644
  const line2 = [];
538
645
  if (usage?.current) line2.push(usage.current);
539
646
  if (usage?.weekly) line2.push(usage.weekly);
647
+ if (usage?.models?.length) line2.push(...usage.models);
540
648
  if (cost) line2.push(cost);
541
649
  if (task) line2.push(`${colors.dim}${task}${colors.reset}`);
542
650
 
@@ -551,6 +659,7 @@ function outputFallback(usage) {
551
659
  const parts = ['~', 'Claude', contextBar];
552
660
  if (usage?.current) parts.push(usage.current);
553
661
  if (usage?.weekly) parts.push(usage.weekly);
662
+ if (usage?.models?.length) parts.push(...usage.models);
554
663
  process.stdout.write(parts.join(' \u2502 '));
555
664
  }
556
665
 
@@ -563,7 +672,12 @@ function resolveUsage(data, callback) {
563
672
  }
564
673
  const fromStdin = buildUsageFromStdin(data);
565
674
  if (fromStdin) {
566
- return callback(fromStdin);
675
+ // stdin covers H and W with no network. Model-scoped weekly limits only exist in the
676
+ // API payload, so they come from the cache — refreshed on the same TTL as every other
677
+ // usage read, which keeps at most one call per FRESH_TTL_MS regardless of render rate.
678
+ return getScopedModels((models) => {
679
+ callback(buildUsageBars(fromStdin.fiveHour, fromStdin.weekly, models));
680
+ });
567
681
  }
568
682
  getUsageWithCache(callback);
569
683
  }
@@ -591,24 +705,123 @@ function emit(data) {
591
705
  });
592
706
  }
593
707
 
594
- if (process.stdin.isTTY) {
595
- emit(null);
708
+ // now - startTime as "45s" / "4m12s" / "2h5m". '' when startTime is missing/unparseable.
709
+ // Format isn't documented by Claude Code, so accept epoch-seconds, epoch-ms, or an ISO
710
+ // string: numbers below 1e12 are epoch-seconds (today's epoch-seconds ~1.7e9, epoch-ms
711
+ // ~1.7e12 — far enough apart that the threshold is unambiguous for any real timestamp).
712
+ function formatElapsed(startTime) {
713
+ if (startTime == null) return '';
714
+ const ms = typeof startTime === 'number' && startTime < 1e12 ? startTime * 1000 : startTime;
715
+ const start = new Date(ms).getTime();
716
+ if (Number.isNaN(start)) return '';
717
+
718
+ const diffSec = Math.max(0, Math.floor((Date.now() - start) / 1000));
719
+ const hours = Math.floor(diffSec / 3600);
720
+ const mins = Math.floor((diffSec % 3600) / 60);
721
+ const secs = diffSec % 60;
722
+ if (hours > 0) return `${hours}h${mins}m`;
723
+ if (mins > 0) return `${mins}m${secs}s`;
724
+ return `${secs}s`;
725
+ }
726
+
727
+ // One subagentStatusLine row: "name │ Model · effort │ C<used> <bar> │ ⏱ <elapsed>".
728
+ // Every segment past name is conditional on its source being present/finite.
729
+ function renderSubagentTask(t) {
730
+ const parts = [t.label || t.name || t.description || 'agent'];
731
+
732
+ const model = shortenModelId(t.model);
733
+ // effort absent = subagent inherits the session effort; show model alone then.
734
+ const effort = t.effort != null ? String(t.effort) : '';
735
+ if (model) {
736
+ parts.push(effort
737
+ ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}`
738
+ : model);
739
+ } else if (effort) {
740
+ parts.push(`${getEffortColor(effort)}${effort}${colors.reset}`);
741
+ }
742
+
743
+ if (Number.isFinite(t.tokenCount) && Number.isFinite(t.contextWindowSize) && t.contextWindowSize > 0) {
744
+ const used = Math.max(0, Math.min(100, Math.round((t.tokenCount / t.contextWindowSize) * 100)));
745
+ parts.push(renderContextBar(used));
746
+ }
747
+
748
+ const elapsed = formatElapsed(t.startTime);
749
+ if (elapsed) parts.push(`${colors.dim}⏱ ${elapsed}${colors.reset}`);
750
+
751
+ return parts.join(SEGMENT_SEP);
752
+ }
753
+
754
+ // subagentStatusLine mode: emit one {id, content} JSON line per task with an id, then
755
+ // exit. No usage/git/todos/cache work — the task objects carry everything needed.
756
+ // Bad payload or a task that fails to render -> emit nothing, keeping default
757
+ // rendering for every task, rather than a partial/broken output.
758
+ function emitSubagent(data) {
759
+ try {
760
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
761
+ const out = tasks
762
+ .filter(t => t && t.id)
763
+ .map(t => JSON.stringify({ id: t.id, content: renderSubagentTask(t) }))
764
+ .join('\n');
765
+ if (out) {
766
+ // Exit from the write callback: process.exit() would drop output still queued
767
+ // behind stdout backpressure. A write error (e.g. EPIPE) also lands here — the
768
+ // callback form reports it instead of throwing, and the answer is the same: exit 0.
769
+ process.stdout.write(out + '\n', () => process.exit(0));
770
+ return;
771
+ }
772
+ } catch (e) {}
773
+ process.exit(0);
774
+ }
775
+
776
+ // Entry point, guarded so tests can require this file to exercise payload parsing
777
+ // directly (the /usage response shape is the easiest thing here to get wrong, and it
778
+ // can't be reached through stdin). Running the script normally is unchanged.
779
+ if (require.main === module) {
780
+ if (process.argv[2] === 'subagent') {
781
+ if (process.stdin.isTTY) {
782
+ emitSubagent(null);
783
+ } else {
784
+ let input = '';
785
+ let finished = false;
786
+
787
+ // Single guarded exit shared by all three triggers: timeout, stdin 'end', and
788
+ // stdin 'error' (which can fire before 'end' and would otherwise throw unhandled,
789
+ // breaking the never-throw contract). Whatever accumulated so far gets rendered.
790
+ const finish = () => {
791
+ if (finished) return;
792
+ finished = true;
793
+ clearTimeout(timeout);
794
+ emitSubagent(parseInput(input));
795
+ };
796
+
797
+ const timeout = setTimeout(finish, SUBAGENT_TIMEOUT_MS);
798
+
799
+ process.stdin.setEncoding('utf8');
800
+ process.stdin.on('data', chunk => input += chunk);
801
+ process.stdin.on('end', finish);
802
+ process.stdin.on('error', finish);
803
+ }
804
+ } else if (process.stdin.isTTY) {
805
+ emit(null);
806
+ } else {
807
+ let input = '';
808
+ let timeoutReached = false;
809
+
810
+ const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
811
+
812
+ const timeout = setTimeout(() => {
813
+ timeoutReached = true;
814
+ emit(parseInput(input));
815
+ }, overallTimeout);
816
+
817
+ process.stdin.setEncoding('utf8');
818
+ process.stdin.on('data', chunk => input += chunk);
819
+ process.stdin.on('end', () => {
820
+ if (timeoutReached) return;
821
+ clearTimeout(timeout);
822
+ emit(parseInput(input));
823
+ });
824
+ }
596
825
  } else {
597
- let input = '';
598
- let timeoutReached = false;
599
-
600
- const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
601
-
602
- const timeout = setTimeout(() => {
603
- timeoutReached = true;
604
- emit(parseInput(input));
605
- }, overallTimeout);
606
-
607
- process.stdin.setEncoding('utf8');
608
- process.stdin.on('data', chunk => input += chunk);
609
- process.stdin.on('end', () => {
610
- if (timeoutReached) return;
611
- clearTimeout(timeout);
612
- emit(parseInput(input));
613
- });
826
+ module.exports = { parseScopedLimits, normalizePercentage };
614
827
  }