claude-code-runrate 0.2.4 → 0.4.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/src/launch-win.js CHANGED
@@ -82,11 +82,15 @@ function resolveProfileState(profile, opts = {}) {
82
82
  const env = opts.env || process.env;
83
83
  const home = opts.home || os.homedir();
84
84
 
85
+ // The stateDir fallbacks are reachable only when called with neither a slot's
86
+ // env nor the user's own override — never from the launchers, which refuse an
87
+ // exhausted allocation before this. They point at slot 1's member dir so no
88
+ // path here can ever name the container itself as a state dir.
85
89
  if (profile) {
86
90
  return {
87
91
  ccCmd: `ccs ${profile}`,
88
- session: env.CCR_SESSION || `ccr-${profile}`,
89
- stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', profile),
92
+ session: env.CCR_SESSION || 'ccr',
93
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
90
94
  instanceDir: path.join(home, '.ccs', 'instances', profile),
91
95
  usesCcs: true,
92
96
  };
@@ -94,7 +98,7 @@ function resolveProfileState(profile, opts = {}) {
94
98
  return {
95
99
  ccCmd: env.CC_BIN || 'claude',
96
100
  session: env.CCR_SESSION || 'ccr',
97
- stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
101
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
98
102
  instanceDir: null,
99
103
  usesCcs: false,
100
104
  };
@@ -179,7 +183,7 @@ function sidecarCols(termCols, fracNum, splitFlag) {
179
183
  *
180
184
  * @param {{ ccCmd: string, settingsFile: string, stateDir: string,
181
185
  * node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
182
- * termCols?: number }} o
186
+ * termCols?: number, title?: string }} o
183
187
  * @returns {string[]}
184
188
  */
185
189
  function buildWtArgs(o) {
@@ -200,6 +204,9 @@ function buildWtArgs(o) {
200
204
  }
201
205
  const frac = sidebarFraction(o.sidebarPct);
202
206
  const splitFlag = sidebarSplitFlag(o.sidebarSide);
207
+ // The tab's ADDRESS ("[profile / ]name") when the instance has one — both
208
+ // halves allow-listed, so it is wt-arg-safe by construction.
209
+ const title = o.title || 'Claude';
203
210
  const exited = path.win32.join(stateDir, 'exited');
204
211
 
205
212
  // After Claude exits we drop the sentinel + clean the settings file, then idle
@@ -224,7 +231,7 @@ function buildWtArgs(o) {
224
231
  const pane1 = paneCommand(stateDir, sidecarBody);
225
232
 
226
233
  return [
227
- '-w', '0', 'new-tab', '--title', 'Claude', 'cmd', '/c', pane0,
234
+ '-w', '0', 'new-tab', '--title', title, 'cmd', '/c', pane0,
228
235
  ';',
229
236
  'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
230
237
  ];
@@ -255,7 +262,8 @@ function defaultWhere(name) {
255
262
  }
256
263
 
257
264
  const inject = require('./settings-inject');
258
- const { ensureSecureDir } = require('./state-dir');
265
+ const slots = require('./instance-slot');
266
+ const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
259
267
 
260
268
  /**
261
269
  * Fill in real-environment implementations for anything the caller didn't
@@ -283,6 +291,11 @@ function withDefaults(deps) {
283
291
  existsDir: deps.existsDir || defaultExistsDir,
284
292
  listDir: deps.listDir || defaultListDir,
285
293
  ensureDir: deps.ensureDir || ensureSecureDir,
294
+ recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
295
+ allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
296
+ prepareInstance: deps.prepareInstance
297
+ || ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
298
+ require('./instance-name').prepareInstance(s, { ...o, home: deps.home || os.homedir() })),
286
299
  removeExited: deps.removeExited || defaultRemoveExited,
287
300
  writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
288
301
  cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
@@ -347,9 +360,10 @@ function fallbackNoWt(d) {
347
360
  *
348
361
  * @param {string} [profile]
349
362
  * @param {Partial<Deps>} [deps]
363
+ * @param {{ name?: string|null }} [opts] explicit --name, already validated
350
364
  * @returns {number}
351
365
  */
352
- function run(profile, deps = {}) {
366
+ function run(profile, deps = {}, opts = {}) {
353
367
  const d = withDefaults(deps);
354
368
 
355
369
  // 1. Validate the profile (it lands in paths and a spawned command).
@@ -362,8 +376,18 @@ function run(profile, deps = {}) {
362
376
  const wt = d.findWt();
363
377
  if (!wt) return fallbackNoWt(d);
364
378
 
365
- // 3. Resolve profile state + required binaries.
366
- const st = resolveProfileState(profile, { env: d.env, home: d.home });
379
+ // 3. Resolve profile state + required binaries. Every launch first claims a
380
+ // free instance slot (src/instance-slot.js) so a second window never shares
381
+ // another's state dir; only an explicit override skips it.
382
+ const slot = d.allocateSlot({ profile, env: d.env, home: d.home });
383
+ if (slot && 'exhausted' in slot) {
384
+ d.err(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
385
+ return 1;
386
+ }
387
+ // The instance's name, profile record and title — same on every platform.
388
+ const inst = slot && !('exhausted' in slot)
389
+ ? d.prepareInstance(slot, { profile, name: opts.name }) : null;
390
+ const st = resolveProfileState(profile, { env: slots.applySlotEnv(d.env, slot), home: d.home });
367
391
  if (st.usesCcs) {
368
392
  if (!d.which('ccs')) {
369
393
  d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
@@ -385,6 +409,9 @@ function run(profile, deps = {}) {
385
409
 
386
410
  // 4. Prepare the per-profile state dir; clear a stale sentinel.
387
411
  try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
412
+ // The tab's stable identity for the git pane. Recorded here because only the
413
+ // launcher knows where ccr was started (src/state-dir.js).
414
+ try { d.recordLaunchDir(st.stateDir, process.cwd()); } catch { /* best effort */ }
388
415
  d.removeExited(st.stateDir);
389
416
 
390
417
  // 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
@@ -404,6 +431,7 @@ function run(profile, deps = {}) {
404
431
  sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
405
432
  sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
406
433
  termCols: d.cols,
434
+ title: inst ? inst.title : undefined,
407
435
  });
408
436
  } catch (e) {
409
437
  d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
@@ -434,6 +462,9 @@ function run(profile, deps = {}) {
434
462
  * @property {(dir: string) => boolean} existsDir
435
463
  * @property {(dir: string) => string[]} listDir
436
464
  * @property {(dir: string) => void} ensureDir
465
+ * @property {(dir: string, cwd: string) => void} recordLaunchDir
466
+ * @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
467
+ * @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
437
468
  * @property {(dir: string) => void} removeExited
438
469
  * @property {(settings: object) => string} writeSettings
439
470
  * @property {(file: string) => void} cleanup
package/src/liveness.js CHANGED
@@ -20,6 +20,23 @@ function envStaleMs() {
20
20
  return Number.isFinite(v) && v > 0 ? v : null;
21
21
  }
22
22
 
23
+ /**
24
+ * Snapshot age for the freshness note. Minutes roll into hours and hours into
25
+ * days: an overnight-idle pane used to read "updated 1500m ago", which is
26
+ * accurate and unreadable.
27
+ * @param {number} ageMs
28
+ * @returns {string}
29
+ */
30
+ function fmtAge(ageMs) {
31
+ const m = Math.floor(ageMs / 60000);
32
+ if (m < 60) return `${m}m`;
33
+ const h = Math.floor(m / 60);
34
+ if (h < 24) { const r = m % 60; return r ? `${h}h${String(r).padStart(2, '0')}m` : `${h}h`; }
35
+ const d = Math.floor(h / 24);
36
+ const rh = h % 24;
37
+ return rh ? `${d}d${rh}h` : `${d}d`;
38
+ }
39
+
23
40
  /**
24
41
  * @param {{ exited?: boolean, ageMs?: number, staleMs?: number }} input
25
42
  * @returns {{ mode: 'ended' | 'live', marker: string | null }}
@@ -32,7 +49,7 @@ function liveness(input) {
32
49
 
33
50
  const ageMs = input.ageMs ?? 0;
34
51
  const staleMs = input.staleMs ?? envStaleMs() ?? DEFAULT_STALE_MS;
35
- const marker = ageMs >= staleMs ? `updated ${Math.floor(ageMs / 60000)}m ago` : null;
52
+ const marker = ageMs >= staleMs ? `updated ${fmtAge(ageMs)} ago` : null;
36
53
  return { mode: 'live', marker };
37
54
  }
38
55
 
package/src/migrate.js ADDED
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/migrate.js — move a 0.3 ccr home to the 0.4 container layout, once,
4
+ // safely (features/instance-migration.feature).
5
+ //
6
+ // There is nothing to relocate but HISTORY: the only persistent content the
7
+ // 0.3 layout ever held was account burn history, misplaced inside profile
8
+ // dirs because the logger wrote to whatever the state dir was. So migration
9
+ // HARVESTS burnlogs to the container's top level, SWEEPS the profile dirs and
10
+ // the loose ephemeral droppings (a dead session's captured status, heartbeat,
11
+ // sentinel), and writes the ".layout" marker LAST — an interrupted migration
12
+ // is indistinguishable from one that has not started, and every move is
13
+ // move-if-present, so the next launch simply completes it.
14
+ //
15
+ // THE GENERAL SAFETY PROPERTY, ruled: if the source is not what migration
16
+ // expects, it STOPS AND CHANGES NOTHING. That is also the entire handling of
17
+ // an entry named "instances" or "profiles" (reserved by the new layout) and
18
+ // of a CCS profile literally named either — declined as real scope ("no other
19
+ // users will have this problem"); the stop makes it fail loudly for free.
20
+ //
21
+ // Runs AT LAUNCH ONLY. `ccr statusline` — invoked headlessly by Claude
22
+ // mid-session — never calls this. Removed at 1.0.0.
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const os = require('node:os');
27
+
28
+ const MARKER = '.layout';
29
+
30
+ // The 0.3 layout's loose per-session droppings — dead state the ephemeral
31
+ // rule says dies. Everything here may appear at the container root (bare
32
+ // launches) or inside a profile dir.
33
+ const DROPPINGS = ['last-status.json', 'launch-cwd', 'exited', 'sidecar-alive', 'slot-owner', 'view-request'];
34
+
35
+ const BURNLOG_RE = /^burnlog-[A-Za-z0-9_-]+\.jsonl$/;
36
+ // 0.4's own session join key (src/session-log.js). An upgrade while a session
37
+ // is open leaves the new statusline ticking against an unmigrated home, so
38
+ // these appear at the root BEFORE any launch migrates it — already at their
39
+ // final location, they are kept, not a surprise.
40
+ const SESSION_RE = /^session-[A-Za-z0-9_-]+\.jsonl$/;
41
+ // Same allow-list the launcher enforces for profile names.
42
+ const PROFILE_DIR_RE = /^[A-Za-z0-9._-]+$/;
43
+ // Names the new layout owns; their presence in an unmigrated home is exactly
44
+ // the surprise the safety property exists for.
45
+ const RESERVED = new Set(['instances', 'profiles']);
46
+
47
+ /**
48
+ * Is a 0.3 session still running in this dir, as far as 0.3 state can say?
49
+ * The published 0.3 recorded no owning pid, so the freshest signal it left is
50
+ * the sidecar heartbeat. Refusing on a live heartbeat is the safe direction:
51
+ * a false "live" delays migration by seconds; a false "dead" would harvest
52
+ * burnlogs out from under a writing session.
53
+ * @param {string} dir
54
+ */
55
+ function legacyLive(dir) {
56
+ try {
57
+ if (fs.existsSync(path.join(dir, 'exited'))) return false;
58
+ return require('./sidecar').sidecarAlive(dir);
59
+ } catch { return false; }
60
+ }
61
+
62
+ /**
63
+ * Move a burnlog to the container root, first-wins on a name collision (the
64
+ * name carries the Claude session id, so a collision is the same session's
65
+ * data twice — the copy already at the root is kept).
66
+ * @param {string} from @param {string} rootDir @param {string} name
67
+ */
68
+ function harvestBurnlog(from, rootDir, name) {
69
+ const dest = path.join(rootDir, name);
70
+ try {
71
+ if (fs.existsSync(dest)) { fs.rmSync(from, { force: true }); return; }
72
+ fs.renameSync(from, dest);
73
+ } catch { /* best effort — a later launch completes it */ }
74
+ }
75
+
76
+ /**
77
+ * Bring <home>/.ccr to the 0.4 layout. Total: never throws.
78
+ *
79
+ * @param {{ home?: string }} [opts]
80
+ * @returns {{ ok: true } | { ok: false, error: string }}
81
+ */
82
+ function ensureLayout(opts = {}) {
83
+ const home = opts.home || os.homedir();
84
+ const root = path.join(home, '.ccr');
85
+ const marker = path.join(root, MARKER);
86
+
87
+ try {
88
+ if (!fs.existsSync(root)) {
89
+ // Fresh install: create the container already migrated.
90
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
91
+ fs.writeFileSync(marker, '1\n', { mode: 0o600 });
92
+ return { ok: true };
93
+ }
94
+ if (fs.existsSync(marker)) {
95
+ sweepLegacyDroppings(root);
96
+ return { ok: true };
97
+ }
98
+
99
+ // ---- Classify EVERYTHING before touching ANYTHING. ----
100
+ /** @type {{ dir: string, name: string }[]} */
101
+ const profileDirs = [];
102
+ /** @type {string[]} */
103
+ const looseBurnlogs = [];
104
+ let rootHasDroppings = false;
105
+ for (const name of fs.readdirSync(root)) {
106
+ const p = path.join(root, name);
107
+ if (RESERVED.has(name)) return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}' (reserved by the new layout); move it aside and relaunch` };
108
+ if (name.startsWith('.')) continue; // dotted container entries are left alone
109
+ if (BURNLOG_RE.test(name)) { looseBurnlogs.push(name); continue; }
110
+ if (SESSION_RE.test(name)) continue; // 0.4 join key — already at its final home
111
+ if (DROPPINGS.includes(name)) { rootHasDroppings = true; continue; }
112
+ let st; try { st = fs.lstatSync(p); } catch { continue; }
113
+ if (st.isDirectory() && PROFILE_DIR_RE.test(name)) { profileDirs.push({ dir: p, name }); continue; }
114
+ return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}'; move it aside and relaunch` };
115
+ }
116
+
117
+ // A live 0.3 session anywhere refuses the whole migration, by name.
118
+ if (legacyLive(root)) return { ok: false, error: 'ccr: cannot migrate ~/.ccr while a session is running there — close it first' };
119
+ for (const { dir, name } of profileDirs) {
120
+ if (legacyLive(dir)) return { ok: false, error: `ccr: cannot migrate ~/.ccr while an instance is running — close '${name}' first` };
121
+ }
122
+
123
+ // ---- Harvest, then sweep, then mark. ----
124
+ for (const { dir } of profileDirs) {
125
+ let names; try { names = fs.readdirSync(dir); } catch { continue; }
126
+ for (const n of names) if (BURNLOG_RE.test(n)) harvestBurnlog(path.join(dir, n), root, n);
127
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* next launch retries */ }
128
+ }
129
+ if (rootHasDroppings) for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
130
+ try { fs.chmodSync(root, 0o700); } catch { /* best effort */ }
131
+ fs.writeFileSync(marker, '1\n', { mode: 0o600 });
132
+ return { ok: true };
133
+ } catch (e) {
134
+ return { ok: false, error: `ccr: migration failed: ${e instanceof Error ? e.message : String(e)}` };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A DOWNGRADE-then-re-upgrade leaves a migrated container plus a 0.3 session's
140
+ * loose droppings at the root — a dead instance's live state, which the
141
+ * ephemeral rule says dies. Its burnlogs already live where the pool is, so
142
+ * the day's history merges for free. Swept only once that session shows no
143
+ * heartbeat (the safe direction, same as everywhere else).
144
+ * @param {string} root
145
+ */
146
+ function sweepLegacyDroppings(root) {
147
+ try {
148
+ if (!fs.existsSync(path.join(root, 'last-status.json'))
149
+ && !fs.existsSync(path.join(root, 'sidecar-alive'))) return;
150
+ if (legacyLive(root)) return;
151
+ for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
152
+ } catch { /* best effort */ }
153
+ }
154
+
155
+ module.exports = { ensureLayout, MARKER };
package/src/normalize.js CHANGED
@@ -7,6 +7,21 @@
7
7
  const { discoverWindows } = require('./rate-limits');
8
8
  const { stripControl } = require('./sanitize');
9
9
 
10
+ /**
11
+ * A finite number, or null. The snapshot is a JSON file on disk: it chooses its
12
+ * own value types, and a `!= null` check accepts the string "1.5" as happily as
13
+ * 1.5. Downstream does arithmetic and calls `.toFixed()`, so one wrong type is
14
+ * a TypeError inside the draw loop — which the sidecar catches, but only by
15
+ * replacing the whole economy panel with an error line, every tick, until the
16
+ * file changes. Type-check at ingestion; a bad field costs itself and nothing
17
+ * else. (NaN/Infinity are excluded too — they render as "NaN%" meters.)
18
+ * @param {any} v
19
+ * @returns {number|null}
20
+ */
21
+ function num(v) {
22
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
23
+ }
24
+
10
25
  /**
11
26
  * @param {any} state CC status-line JSON
12
27
  * @param {number} [nowSec] override for testing
@@ -15,16 +30,20 @@ const { stripControl } = require('./sanitize');
15
30
  function normalizeStatus(state, nowSec) {
16
31
  const rl = (state && state.rate_limits) || {};
17
32
  const cw = (state && state.context_window) || {};
33
+ const cost = (state && state.cost) || {};
34
+ const durationMs = num(cost.total_duration_ms);
18
35
  return {
19
36
  model: stripControl((state && state.model && state.model.display_name) || null),
20
- windowSize: cw.context_window_size || 200000,
37
+ // Must be positive: it is a divisor for the ctx meter, and `?? ` (unlike the
38
+ // `||` this replaced) would let a literal 0 through to divide by zero.
39
+ windowSize: (num(cw.context_window_size) || 0) > 0 ? cw.context_window_size : 200000,
21
40
  windows: discoverWindows(rl, nowSec),
22
- contextTokens: cw.total_input_tokens
23
- ?? (cw.current_usage && cw.current_usage.cache_read_input_tokens) ?? null,
41
+ contextTokens: num(cw.total_input_tokens)
42
+ ?? num(cw.current_usage && cw.current_usage.cache_read_input_tokens),
24
43
  cachedPct: null,
25
44
  baselineTok: 14000,
26
- costUsd: state && state.cost && state.cost.total_cost_usd != null ? state.cost.total_cost_usd : null,
27
- durationMin: state && state.cost && state.cost.total_duration_ms != null ? state.cost.total_duration_ms / 60000 : null,
45
+ costUsd: num(cost.total_cost_usd),
46
+ durationMin: durationMs != null ? durationMs / 60000 : null,
28
47
  branch: null,
29
48
  };
30
49
  }
@@ -0,0 +1,249 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/pane-blob.js — THE verifier. Between a blob file's bytes and any renderer
4
+ // sits exactly this function, and it returns either a validated v1 blob or one
5
+ // named failure. No renderer ever sees unvalidated input.
6
+ // Contract: docs/PANE-CONTRACT.md § The verifier.
7
+ //
8
+ // Three properties this file is built around, all of them load-bearing:
9
+ //
10
+ // TOTAL. Nothing here throws. The sidebar is ONE pane: an exception raised
11
+ // into the draw loop would take the burn-rate display down with it, so a
12
+ // malformed blob must cost a pane state and never the sidecar.
13
+ //
14
+ // WHITELIST-CONSTRUCT. Every returned object is built fresh from the fields
15
+ // v1 names. The parsed input is never spread, never Object.assign'd, never
16
+ // merged. That is the prototype-pollution path, and it is the one
17
+ // injection-style attack a JSON consumer in Node gets handed for free.
18
+ //
19
+ // TYPES CHECKED, NOT COERCED. A JSON file chooses its own value types, so
20
+ // `typeof` is the only thing that makes "this field is a string" true. (The
21
+ // sidecar learned this the hard way elsewhere: an array where a string was
22
+ // expected put raw terminal escapes on screen. See src/sanitize.js.)
23
+
24
+ const fs = require('node:fs');
25
+ const { stripControl } = require('./sanitize');
26
+
27
+ const BLOB_VERSION = 1;
28
+ const MAX_BLOB_BYTES = 256 * 1024;
29
+ const MAX_ROWS = 256;
30
+ const MAX_FIELD_CHARS = 512;
31
+ const MAX_SPARK = 32;
32
+
33
+ /** The closed row-status enum. Anything else renders as `dark` (never green). */
34
+ const ROW_STATUSES = new Set(['ok', 'warn', 'alert', 'dark', 'off']);
35
+
36
+ /**
37
+ * A display string: sanitized, then truncated. Order matters — validation
38
+ * checks shape, never bytes, so the strip is unconditional and comes after.
39
+ * @param {string} s
40
+ * @returns {string}
41
+ */
42
+ const display = (s) => {
43
+ const clean = String(stripControl(s) ?? '');
44
+ return clean.length > MAX_FIELD_CHARS ? clean.slice(0, MAX_FIELD_CHARS) : clean;
45
+ };
46
+
47
+ const isStr = (/** @type {any} */ v) => typeof v === 'string';
48
+
49
+ /**
50
+ * Read the blob file safely enough to name WHY it could not be read.
51
+ *
52
+ * This does not use readTextCapped: that collapses every failure to null, and
53
+ * the contract requires "cannot-read" (a chmod mistake, a fifo, a symlink) to
54
+ * be visibly distinct from "waiting" (the producer simply hasn't run yet).
55
+ * Conflating them would make a permissions bug look like patience.
56
+ *
57
+ * @param {string} file
58
+ * @returns {{ ok: true, text: string, mtimeMs: number }
59
+ * | { ok: false, state: 'waiting' }
60
+ * | { ok: false, state: 'cannot-read', reason: string }
61
+ * | { ok: false, state: 'oversized' }}
62
+ */
63
+ function readBlobFile(file) {
64
+ let st;
65
+ try {
66
+ // lstat, not stat: a symlink must be REFUSED rather than followed, and a
67
+ // fifo must be identified without opening it (opening one blocks forever).
68
+ st = fs.lstatSync(file);
69
+ } catch (e) {
70
+ const code = e && /** @type {any} */ (e).code;
71
+ if (code === 'ENOENT') return { ok: false, state: 'waiting' };
72
+ return { ok: false, state: 'cannot-read', reason: code === 'EACCES' ? 'permission' : 'unavailable' };
73
+ }
74
+ if (st.isSymbolicLink()) return { ok: false, state: 'cannot-read', reason: 'symlink' };
75
+ if (st.isDirectory()) return { ok: false, state: 'cannot-read', reason: 'directory' };
76
+ if (!st.isFile()) return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
77
+ if (st.size > MAX_BLOB_BYTES) return { ok: false, state: 'oversized' };
78
+
79
+ // The lstat above is ADVISORY, not a guarantee: the path can be replaced
80
+ // between the check and the open, and a producer that writes atomically is
81
+ // renaming over this path constantly, so a swap looks like normal operation.
82
+ // The open itself must therefore be safe on its own terms:
83
+ // O_NOFOLLOW — refuse a symlink at open time, so "symlinks are refused"
84
+ // is enforced by the kernel rather than by a stale stat.
85
+ // O_NONBLOCK — a FIFO opened for reading blocks until a writer appears,
86
+ // which in this single-threaded draw loop means FOREVER: no
87
+ // render, no heartbeat, no recovery. With O_NONBLOCK the open
88
+ // returns immediately (ENXIO) instead of hanging.
89
+ // Both flags are absent on Windows; `|| 0` degrades to the old behaviour
90
+ // there, where neither fifos nor symlinks-without-privilege are a concern.
91
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
92
+ const O_NONBLOCK = fs.constants.O_NONBLOCK || 0;
93
+ let fd;
94
+ try {
95
+ fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
96
+ } catch (e) {
97
+ const code = e && /** @type {any} */ (e).code;
98
+ if (code === 'ELOOP') return { ok: false, state: 'cannot-read', reason: 'symlink' };
99
+ if (code === 'ENXIO' || code === 'EWOULDBLOCK' || code === 'EAGAIN') {
100
+ return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
101
+ }
102
+ if (code === 'ENOENT') return { ok: false, state: 'waiting' };
103
+ return { ok: false, state: 'cannot-read', reason: code === 'EACCES' ? 'permission' : 'unavailable' };
104
+ }
105
+ try {
106
+ // Re-stat from the descriptor: the file may have been replaced since the
107
+ // lstat, and this describes the bytes actually held open.
108
+ const fst = fs.fstatSync(fd);
109
+ if (!fst.isFile()) return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
110
+ if (fst.size > MAX_BLOB_BYTES) return { ok: false, state: 'oversized' };
111
+ const buf = Buffer.alloc(Math.min(fst.size, MAX_BLOB_BYTES));
112
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
113
+ return { ok: true, text: buf.subarray(0, n).toString('utf8'), mtimeMs: fst.mtimeMs };
114
+ } catch {
115
+ return { ok: false, state: 'cannot-read', reason: 'unavailable' };
116
+ } finally {
117
+ try { fs.closeSync(fd); } catch { /* already closed */ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Validate one row. Returns null when the row itself is malformed (which makes
123
+ * the whole blob invalid — a row without a value is a shape violation, not a
124
+ * decoration). An UNRECOGNIZED status is not a violation: it renders as `dark`,
125
+ * because a producer naming a state ccr doesn't know must never come out green.
126
+ * @param {any} r
127
+ * @returns {{ label: string, value: string, status: string, detail: string|null, spark: number[]|null }|null}
128
+ */
129
+ function validateRow(r) {
130
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
131
+ if (!isStr(r.label) || !isStr(r.value) || !isStr(r.status)) return null;
132
+
133
+ // Spark is DECORATION, so it degrades locally: a non-conforming spark drops
134
+ // the sparkline and keeps the row. Ruled 2026-08-02 — a decoration never
135
+ // costs more than itself, the same principle as clamping an overlong field.
136
+ /** @type {number[]|null} */
137
+ let spark = null;
138
+ if (Array.isArray(r.spark) && r.spark.length && r.spark.length <= MAX_SPARK
139
+ && r.spark.every((/** @type {any} */ n) => typeof n === 'number' && Number.isFinite(n))) {
140
+ spark = r.spark.slice();
141
+ }
142
+
143
+ return {
144
+ label: display(r.label),
145
+ value: display(r.value),
146
+ status: ROW_STATUSES.has(r.status) ? r.status : 'dark',
147
+ detail: isStr(r.detail) ? display(r.detail) : null,
148
+ spark,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Turn a parsed blob into a validated v1 blob, or name the single failure.
154
+ * Split out from the file read so it is directly testable and so the render
155
+ * path can never reach a shape that skipped it.
156
+ * @param {any} input
157
+ * @returns {{ state: 'ok', blob: any } | { state: 'invalid' }
158
+ * | { state: 'unsupported', version: number|null } | { state: 'oversized-rows' }}
159
+ */
160
+ function validateBlob(input) {
161
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return { state: 'invalid' };
162
+
163
+ // Version first: an unrecognized `v` is its own named state, so it must be
164
+ // decided before any other field can call the blob "invalid".
165
+ if (!Number.isInteger(input.v)) return { state: 'invalid' };
166
+ if (input.v !== BLOB_VERSION) return { state: 'unsupported', version: input.v };
167
+
168
+ if (!isStr(input.tool) || !isStr(input.title) || !isStr(input.status)) return { state: 'invalid' };
169
+ if (input.status !== 'ok' && input.status !== 'broken') return { state: 'invalid' };
170
+
171
+ const basis = input.basis;
172
+ if (!basis || typeof basis !== 'object' || Array.isArray(basis)) return { state: 'invalid' };
173
+ if (!isStr(basis.label) || !isStr(basis.at)) return { state: 'invalid' };
174
+
175
+ // A broken blob must carry a non-empty message. Without one it would render
176
+ // as a failure with nothing to say, which is indistinguishable from a bug in
177
+ // ccr — so it is invalid rather than a silent half-render.
178
+ const broken = input.status === 'broken';
179
+ if (broken && !(isStr(input.message) && input.message.trim())) return { state: 'invalid' };
180
+
181
+ if (!Array.isArray(input.rows)) return { state: 'invalid' };
182
+ if (input.rows.length > MAX_ROWS) return { state: 'oversized-rows' };
183
+
184
+ /** @type {any[]} */
185
+ const rows = [];
186
+ // A broken blob's rows are IGNORED per the contract — not validated, not
187
+ // rendered. Validating them anyway would let a stray row turn a producer's
188
+ // honest failure report into "invalid", burying the message it exists to show.
189
+ if (!broken) {
190
+ for (const r of input.rows) {
191
+ const row = validateRow(r);
192
+ if (!row) return { state: 'invalid' };
193
+ rows.push(row);
194
+ }
195
+ }
196
+
197
+ return {
198
+ state: 'ok',
199
+ blob: {
200
+ v: BLOB_VERSION,
201
+ tool: display(input.tool),
202
+ title: display(input.title),
203
+ status: input.status,
204
+ basis: { label: display(basis.label), at: display(basis.at) },
205
+ message: isStr(input.message) ? display(input.message) : null,
206
+ rows,
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * The whole pipeline for one configured pane, once per tick.
213
+ * @param {string} file absolute path from config
214
+ * @param {{ now?: number }} [opts]
215
+ * @returns {{ state: string, blob?: any, version?: number|null, reason?: string, ageMs?: number }}
216
+ */
217
+ function loadPaneBlob(file, opts = {}) {
218
+ const read = readBlobFile(file);
219
+ if (!read.ok) {
220
+ // `strict` is off in jsconfig.json, and without strictNullChecks TypeScript
221
+ // will not narrow this union by its boolean `ok` discriminant — the whole
222
+ // union survives into this branch. So name the failure shape once here
223
+ // instead of re-testing it at runtime: the runtime predicate stays `ok`,
224
+ // which is the property readBlobFile actually guarantees, and this replaces
225
+ // the `any` cast that was already covering the same gap for `reason`.
226
+ const fail = /** @type {{ ok: false, state: string, reason?: string }} */ (read);
227
+ return fail.state === 'cannot-read'
228
+ ? { state: 'cannot-read', reason: fail.reason }
229
+ : { state: fail.state };
230
+ }
231
+ if (!read.text.trim()) return { state: 'waiting' };
232
+
233
+ let parsed;
234
+ try { parsed = JSON.parse(read.text); } catch { return { state: 'unreadable' }; }
235
+
236
+ const now = opts.now != null ? opts.now : Date.now();
237
+ const ageMs = Math.max(0, now - read.mtimeMs);
238
+
239
+ const v = validateBlob(parsed);
240
+ if (v.state === 'ok') return { state: 'ok', blob: v.blob, ageMs };
241
+ if (v.state === 'unsupported') return { state: 'unsupported', version: v.version, ageMs };
242
+ if (v.state === 'oversized-rows') return { state: 'oversized', ageMs };
243
+ return { state: 'invalid', ageMs };
244
+ }
245
+
246
+ module.exports = {
247
+ loadPaneBlob, validateBlob, readBlobFile,
248
+ BLOB_VERSION, MAX_BLOB_BYTES, MAX_ROWS, MAX_FIELD_CHARS, MAX_SPARK, ROW_STATUSES,
249
+ };