claude-code-runrate 0.2.3 → 0.3.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.
@@ -21,12 +21,61 @@ function bar(/** @type {number} */ p, w = 10) {
21
21
  return '▓'.repeat(f) + '░'.repeat(w - f);
22
22
  }
23
23
 
24
+ // Code-point ranges a terminal renders two columns wide (East Asian Wide and
25
+ // Fullwidth, per UAX #11), condensed to the blocks that actually turn up in a
26
+ // file path, a model name, or a tool argument: CJK, Hangul, Kana, fullwidth
27
+ // forms, and the emoji planes. Not exhaustive — it does not need to be. Every
28
+ // range here converts a "counted 1, occupies 2" error, which overflows the pane
29
+ // and soft-wraps, into a correct count.
30
+ const WIDE_RANGES = [
31
+ [0x1100, 0x115f], // Hangul Jamo
32
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols/punctuation
33
+ [0x3041, 0x33ff], // Kana, Bopomofo, Hangul Compat Jamo, CJK compat
34
+ [0x3400, 0x4dbf], // CJK Ext A
35
+ [0x4e00, 0x9fff], // CJK Unified
36
+ [0xa000, 0xa4cf], // Yi
37
+ [0xac00, 0xd7a3], // Hangul syllables
38
+ [0xf900, 0xfaff], // CJK compat ideographs
39
+ [0xfe30, 0xfe6f], // CJK compat forms, small form variants
40
+ [0xff00, 0xff60], // Fullwidth forms
41
+ [0xffe0, 0xffe6], // Fullwidth signs
42
+ [0x1f300, 0x1f64f], // Emoji: symbols/pictographs, emoticons
43
+ [0x1f900, 0x1f9ff], // Supplemental symbols/pictographs
44
+ [0x20000, 0x3fffd], // CJK Ext B+ (SIP)
45
+ ];
46
+
47
+ /**
48
+ * Terminal columns occupied by one code point: 2 for East Asian Wide/Fullwidth,
49
+ * 0 for combining marks (they stack onto the previous glyph), else 1.
50
+ * @param {number} cp
51
+ * @returns {0|1|2}
52
+ */
53
+ function charWidth(cp) {
54
+ // Combining diacriticals, and the Hebrew/Arabic/Devanagari combining blocks
55
+ // most likely to appear in fetched text. Zero-width formatting characters are
56
+ // already gone by here (src/sanitize.js strips them at ingestion).
57
+ if ((cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x0483 && cp <= 0x0489)
58
+ || (cp >= 0x0591 && cp <= 0x05bd) || (cp >= 0x0610 && cp <= 0x061a)
59
+ || (cp >= 0x064b && cp <= 0x065f) || (cp >= 0x0900 && cp <= 0x0903)
60
+ || (cp >= 0x1ab0 && cp <= 0x1aff) || (cp >= 0x20d0 && cp <= 0x20f0)
61
+ || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0; // incl. variation selectors
62
+ for (const [lo, hi] of WIDE_RANGES) if (cp >= lo && cp <= hi) return 2;
63
+ return 1;
64
+ }
65
+
24
66
  /**
25
- * Clamp one line to `cols` visible columns: SGR escapes (`\x1b[…m`) pass through
26
- * with zero width, printable chars count as 1. Appends a reset if it had to cut,
27
- * so a severed colour run doesn't bleed into the cleared tail. Prevents the soft
28
- * wrap that corrupts the sidecar's cursor-home redraw in a narrow pane. A
67
+ * Clamp one line to `cols` visible COLUMNS: SGR escapes (`\x1b[…m`) pass through
68
+ * with zero width; every other character counts for the columns a terminal will
69
+ * actually give it (see charWidth). Appends a reset if it had to cut, so a
70
+ * severed colour run doesn't bleed into the cleared tail. Prevents the soft wrap
71
+ * that corrupts the sidecar's cursor-home redraw in a narrow pane. A
29
72
  * non-positive `cols` (e.g. a non-TTY where columns is undefined) is a no-op.
73
+ *
74
+ * Iterates by CODE POINT, not by UTF-16 unit: the old per-unit walk counted a
75
+ * CJK glyph as one column (so 8 of them filled a 16-column pane and wrapped —
76
+ * the exact corruption this function exists to prevent) and could cut an astral
77
+ * character in half, emitting a lone surrogate.
78
+ *
30
79
  * @param {string} line
31
80
  * @param {number} [cols]
32
81
  * @returns {string}
@@ -41,17 +90,24 @@ function clampVisible(line, cols) {
41
90
  sgr.lastIndex = i;
42
91
  const m = sgr.exec(line);
43
92
  if (m) { out += m[0]; i = sgr.lastIndex; continue; }
44
- if (width >= cols) return out + '\x1b[0m';
45
- out += line[i];
46
- width += 1;
47
- i += 1;
93
+ const cp = /** @type {number} */ (line.codePointAt(i));
94
+ const ch = String.fromCodePoint(cp);
95
+ const w = charWidth(cp);
96
+ // Cut BEFORE a character that would not fit whole — a wide glyph straddling
97
+ // the last column is what wraps the line.
98
+ if (width + w > cols) return out + '\x1b[0m';
99
+ out += ch;
100
+ width += w;
101
+ i += ch.length;
48
102
  }
49
103
  return out;
50
104
  }
51
105
 
52
106
  function tok(/** @type {number|null} */ n) {
53
- if (n == null) return '?';
54
- if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
107
+ if (n == null || !Number.isFinite(n)) return '?';
108
+ // 999_500 rounds to 1000K, which is a unit the scale never uses — promote it
109
+ // to 1.0M rather than printing a fourth digit.
110
+ if (n >= 999500) return (n / 1e6).toFixed(1) + 'M';
55
111
  if (n >= 1e3) return Math.round(n / 1e3) + 'K';
56
112
  return String(Math.round(n));
57
113
  }
@@ -14,6 +14,9 @@ function renderStatusline(view) {
14
14
  const parts = [];
15
15
  if (view.model) parts.push(view.model);
16
16
 
17
+ // Annotated because Array.isArray does not narrow an `any`: without this the
18
+ // whole chain below decays to `any` and the row callbacks lose their types.
19
+ /** @type {any[]} */
17
20
  const windows = Array.isArray(view.windows) ? view.windows : [];
18
21
  if (!windows.length) {
19
22
  parts.push('API · no limits');
@@ -0,0 +1,66 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/safe-read.js — bounded, non-blocking reads of files ccr does not control.
4
+ //
5
+ // docs/PANE-CONTRACT.md states this rule for external pane blobs ("Safe reads":
6
+ // lstat, regular file only, size cap enforced before the read completes). The
7
+ // rule is not blob-specific — it belongs at every point where the sidecar reads
8
+ // a file some other process writes, which includes ccr's OWN inputs:
9
+ // last-status.json and the heartbeat file both live in a directory anything
10
+ // running as the user can write.
11
+ //
12
+ // Two failure modes it closes, both verified against the pre-fix sidecar:
13
+ //
14
+ // A FIFO at the path. `readFileSync` on a fifo BLOCKS until a writer appears.
15
+ // The sidecar's loop is single-threaded and synchronous, so one mkfifo froze
16
+ // the whole panel forever — no render, no heartbeat, no recovery. `lstat`
17
+ // answers "is this a regular file?" without opening anything, so the block
18
+ // never happens.
19
+ //
20
+ // An unbounded file. The reader had no cap at all (the WRITER caps itself at
21
+ // 1 MB, which says nothing about a planted file). A large planted snapshot
22
+ // drove quadratic label padding into a RangeError and blanked the panel.
23
+ //
24
+ // `lstat` also means a SYMLINK is refused rather than followed: this is state,
25
+ // not configuration, and nothing legitimate links it elsewhere.
26
+
27
+ const fs = require('node:fs');
28
+
29
+ /** Default cap. Generous for a status snapshot (a real one is ~1-2 KB). */
30
+ const DEFAULT_MAX_BYTES = 256 * 1024;
31
+
32
+ /**
33
+ * Read a file as UTF-8 if — and only if — it is a regular file no larger than
34
+ * `maxBytes`. Returns null for every other case (missing, fifo, socket, device,
35
+ * symlink, directory, too large, unreadable). Never throws, never blocks.
36
+ *
37
+ * The size is re-checked from the open descriptor, not just the lstat: the file
38
+ * can be replaced between the two calls, and the fstat describes the bytes we
39
+ * actually hold. The read is capped regardless, so a file that grows after the
40
+ * check still yields at most `maxBytes`.
41
+ *
42
+ * @param {string} file
43
+ * @param {number} [maxBytes]
44
+ * @returns {string|null}
45
+ */
46
+ function readTextCapped(file, maxBytes = DEFAULT_MAX_BYTES) {
47
+ let st;
48
+ try { st = fs.lstatSync(file); } catch { return null; }
49
+ if (!st.isFile() || st.size > maxBytes) return null;
50
+
51
+ let fd;
52
+ try { fd = fs.openSync(file, 'r'); } catch { return null; }
53
+ try {
54
+ const fst = fs.fstatSync(fd);
55
+ if (!fst.isFile() || fst.size > maxBytes) return null;
56
+ const buf = Buffer.alloc(Math.min(fst.size, maxBytes));
57
+ const read = fs.readSync(fd, buf, 0, buf.length, 0);
58
+ return buf.subarray(0, read).toString('utf8');
59
+ } catch {
60
+ return null;
61
+ } finally {
62
+ try { fs.closeSync(fd); } catch { /* already closed */ }
63
+ }
64
+ }
65
+
66
+ module.exports = { readTextCapped, DEFAULT_MAX_BYTES };
package/src/sanitize.js CHANGED
@@ -17,15 +17,56 @@
17
17
  // alone is NOT sufficient — it escapes C0 but leaves DEL/C1 bytes raw — which is
18
18
  // exactly why we sanitize at ingestion rather than rely on the serializer.)
19
19
 
20
- // C0 controls (00-1F, incl. ESC/newline/tab), DEL (7F), and C1 controls (80-9F).
21
- const CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g;
20
+ // The stripped set, as code-point ranges. Spelled numerically and assembled at
21
+ // runtime rather than written as a literal character class: every character in
22
+ // here is invisible or display-altering, so a literal class would be unreadable
23
+ // in a diff — and could hide an added character in plain sight, in the very code
24
+ // meant to remove such characters.
25
+ const CONTROL_RANGES = [
26
+ [0x0000, 0x001f], // C0 controls — ESC, newline, tab
27
+ [0x007f, 0x009f], // DEL, then C1 controls: includes the 8-bit CSI (0x9b) and
28
+ // OSC (0x9d) introducers, not just their ESC-prefixed forms
29
+ [0x200b, 0x200f], // zero-width space/joiners + LRM/RLM — invisible, so two
30
+ // different byte strings can render identically
31
+ [0x2028, 0x2029], // line/paragraph separators — a line break by another name
32
+ [0x202a, 0x202e], // bidi embeddings and overrides
33
+ [0x2066, 0x2069], // bidi isolates — these two ranges reorder the glyphs a
34
+ // reader sees relative to the bytes actually present:
35
+ // "Trojan Source" (CVE-2021-42574) aimed at a status pane.
36
+ // Legitimate RTL text needs neither; scripts carry their
37
+ // own direction.
38
+ [0xfeff, 0xfeff], // zero-width no-break space (BOM) — invisible when not leading
39
+ ];
40
+
41
+ const CONTROL_RE = new RegExp(
42
+ '[' + CONTROL_RANGES.map(([lo, hi]) =>
43
+ (lo === hi ? String.fromCodePoint(lo) : String.fromCodePoint(lo) + '-' + String.fromCodePoint(hi))
44
+ ).join('') + ']',
45
+ 'g',
46
+ );
22
47
 
23
48
  /**
49
+ * Strip control characters, COERCING any non-nullish input to a string first.
50
+ *
51
+ * The coercion is the security-relevant half. Every renderer downstream
52
+ * concatenates or `String()`s whatever it is handed, so returning a non-string
53
+ * unchanged does not keep it out of the terminal — it only skips the strip, and
54
+ * the escape bytes land on screen anyway once something stringifies them. A
55
+ * JSON file chooses its own value *types*, so "this field is a string" is never
56
+ * a safe assumption: `{"display_name": ["…"]}` parses just as well as a bare
57
+ * string, and an array of one string stringifies straight back to that string.
58
+ * Coerce once, here, at the choke point, rather than trusting a dozen call
59
+ * sites to remember.
60
+ *
61
+ * `null`/`undefined` still pass through, because callers use them as "absent"
62
+ * (`x || null`, `x != null`) and "null"/"undefined" are not display text.
63
+ *
24
64
  * @param {any} s
25
- * @returns {any} the string with control chars removed; non-strings pass through
65
+ * @returns {any} a control-char-free string, or null/undefined unchanged
26
66
  */
27
67
  function stripControl(s) {
28
- return typeof s === 'string' ? s.replace(CONTROL_RE, '') : s;
68
+ if (s == null) return s;
69
+ return String(s).replace(CONTROL_RE, '');
29
70
  }
30
71
 
31
72
  module.exports = { stripControl };
package/src/sidecar.js CHANGED
@@ -15,22 +15,132 @@ const { renderFeed } = require('./render/feed');
15
15
  const { clampVisible } = require('./render/shared');
16
16
  const { liveness } = require('./liveness');
17
17
  const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcripts');
18
+ const { readTextCapped } = require('./safe-read');
19
+ const { stripControl } = require('./sanitize');
20
+ const { loadPaneConfig } = require('./pane-config');
21
+ const { loadPaneBlob } = require('./pane-blob');
22
+ const { renderPane } = require('./render/pane');
23
+ const { readViewRequests } = require('./cycle-view');
18
24
 
19
25
  const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
20
26
 
27
+ // Single-instance heartbeat: each live sidecar re-claims <stateDir>/sidecar-alive
28
+ // roughly once a second with a "<pid>:<startMs>" nonce. Two readers use it:
29
+ // - the VS Code launcher skips the split+paste banner while the file is fresh
30
+ // (an attached sidecar picks the new session up by itself once the launcher
31
+ // clears the exited sentinel — see launch-vscode.js), so relaunching stops
32
+ // minting duplicate panes;
33
+ // - an older sidecar that sees a NEWER nonce yields its pane (see run()), so
34
+ // pasting the one-liner twice still converges to a single live panel.
35
+ const HEARTBEAT_FILE = 'sidecar-alive';
36
+ // "Fresh" = beaten within this window. Beats land ~1s apart; 5s tolerates a
37
+ // busy machine without ever mistaking a dead pane (minutes old) for live.
38
+ const HEARTBEAT_FRESH_MS = 5000;
39
+
40
+ /** @param {string} s @returns {{ pid: number, start: number } | null} */
41
+ function parseNonce(s) {
42
+ const m = /^(\d+):(\d+)$/.exec(s.trim());
43
+ return m ? { pid: Number(m[1]), start: Number(m[2]) } : null;
44
+ }
45
+
46
+ /**
47
+ * One heartbeat: re-claim the file with our nonce, unless a NEWER sidecar
48
+ * (later start; higher pid breaks a same-millisecond tie) holds it — then
49
+ * yield WITHOUT writing, so the newer panel's claim is never clobbered and
50
+ * exactly one of the two keeps beating. Unreadable or unparseable content is
51
+ * claimed over (a garbage file must not wedge the panel), and any fs error
52
+ * claims rather than kills the loop — the heartbeat is strictly best-effort.
53
+ * @param {string} stateDir @param {string} nonce
54
+ * @param {{ now?: number, freshMs?: number }} [opts] injectable clock, for tests
55
+ * @returns {'claimed' | 'yielded'}
56
+ */
57
+ function heartbeatTick(stateDir, nonce, opts = {}) {
58
+ const file = path.join(stateDir, HEARTBEAT_FILE);
59
+ const mine = parseNonce(nonce);
60
+ const now = opts.now != null ? opts.now : Date.now();
61
+ const freshMs = opts.freshMs != null ? opts.freshMs : HEARTBEAT_FRESH_MS;
62
+ try {
63
+ const cur = readTextCapped(file, 256) || '';
64
+ // Yield only to a nonce that is BOTH newer and still being beaten. Nonce
65
+ // order alone is a wall-clock comparison against a file that outlives its
66
+ // writer: a hard-killed sidecar leaves its nonce behind, and after any
67
+ // backwards clock step (NTP correction, VM restore) every sidecar launched
68
+ // since reads that dead nonce as "newer" and stands down — so the pane ends
69
+ // up with no live sidecar at all, repeatably, until the clock catches up.
70
+ // Mtime is what distinguishes a live rival from a corpse; sidecarAlive()
71
+ // has always used it, and the takeover decision needs it just as much.
72
+ const fresh = (() => {
73
+ try { return now - fs.lstatSync(file).mtimeMs <= freshMs; } catch { return false; }
74
+ })();
75
+ const other = fresh && cur && cur.trim() !== nonce ? parseNonce(cur) : null;
76
+ if (other && mine && (other.start > mine.start || (other.start === mine.start && other.pid > mine.pid))) {
77
+ return 'yielded';
78
+ }
79
+ // Never write THROUGH a symlink planted at this path — that turns a
80
+ // heartbeat into an arbitrary-file write of "<pid>:<ms>".
81
+ try { if (fs.lstatSync(file).isSymbolicLink()) fs.rmSync(file, { force: true }); } catch { /* absent */ }
82
+ fs.writeFileSync(file, nonce);
83
+ } catch { /* best-effort */ }
84
+ return 'claimed';
85
+ }
86
+
87
+ /**
88
+ * Remove the heartbeat on the way out — but only while it still holds OUR
89
+ * nonce; after a takeover the file belongs to the newer sidecar.
90
+ * @param {string} stateDir @param {string} nonce
91
+ */
92
+ function clearHeartbeat(stateDir, nonce) {
93
+ const file = path.join(stateDir, HEARTBEAT_FILE);
94
+ try {
95
+ if ((readTextCapped(file, 256) || '').trim() === nonce) fs.rmSync(file, { force: true });
96
+ } catch { /* already gone / unreadable — nothing to clear */ }
97
+ }
98
+
99
+ /**
100
+ * Is a sidecar attached to this state dir right now? Mtime-based, so a killed
101
+ * pane (whose stale file nobody cleared) reads as dead within seconds. Used by
102
+ * the VS Code launcher to print "already attached" instead of the split banner.
103
+ * @param {string} stateDir @param {{ now?: number, freshMs?: number }} [opts]
104
+ * @returns {boolean}
105
+ */
106
+ function sidecarAlive(stateDir, opts = {}) {
107
+ const now = opts.now != null ? opts.now : Date.now();
108
+ const freshMs = opts.freshMs != null ? opts.freshMs : HEARTBEAT_FRESH_MS;
109
+ try {
110
+ return now - fs.statSync(path.join(stateDir, HEARTBEAT_FILE)).mtimeMs <= freshMs;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
21
116
  // Live feed accumulator: tail the current transcript incrementally (by byte
22
117
  // offset) and roll up tool/skill events + per-session stats. Reset on session
23
118
  // switch. Best-effort — must never break the economy panel.
24
119
  const FEED_CAP = 200;
25
- const feedState = { path: /** @type {string|null} */ (null), offset: 0, events: /** @type {any[]} */ ([]), tools: /** @type {Record<string,number>} */ ({}), commands: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }, files: new Set() };
120
+ const feedState = { path: /** @type {string|null} */ (null), offset: 0, events: /** @type {any[]} */ ([]), tools: /** @type {Record<string,number>} */ (Object.create(null)), commands: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }, files: new Set() };
121
+
122
+ /** Zero the rolling totals — on a session switch, and on a tail restart. */
123
+ function resetFeedState(/** @type {string|null} */ tpath) {
124
+ feedState.path = tpath; feedState.offset = 0; feedState.events = [];
125
+ // Null-prototype: these keys are tool NAMES from the transcript, i.e. attacker
126
+ // -influenceable. On a plain object a tool called "constructor" reads back the
127
+ // inherited function (the feed header rendered its native source), and one
128
+ // called "__proto__" silently vanishes into a prototype write instead of
129
+ // counting. With no prototype there is nothing to inherit or to set.
130
+ feedState.tools = Object.create(null);
131
+ feedState.commands = 0;
132
+ feedState.tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
133
+ feedState.files = new Set();
134
+ }
26
135
 
27
136
  /** @param {string} tpath @returns {any} feed view for renderFeed */
28
137
  function updateFeed(tpath) {
29
- if (feedState.path !== tpath) { // new session → start clean
30
- feedState.path = tpath; feedState.offset = 0; feedState.events = []; feedState.tools = {};
31
- feedState.commands = 0; feedState.tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }; feedState.files = new Set();
32
- }
33
- const { offset, lines } = readNewLines(tpath, feedState.offset);
138
+ if (feedState.path !== tpath) resetFeedState(tpath); // new session → start clean
139
+ const { offset, lines, restarted } = readNewLines(tpath, feedState.offset);
140
+ // The tail went back to 0 because the file shrank, so the lines below are ones
141
+ // we have already counted. Resetting the offset without resetting the totals
142
+ // double-counts every tool, file, and token for the rest of the session.
143
+ if (restarted) resetFeedState(tpath);
34
144
  feedState.offset = offset;
35
145
  if (lines.length) {
36
146
  const p = parseEvents(lines);
@@ -67,8 +177,15 @@ function draw(/** @type {string} */ s) {
67
177
  * is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
68
178
  * redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
69
179
  *
180
+ * `view` selects which whole-pane view to draw: 0 is ccr's own economy view and
181
+ * 1..N are the configured external panes, in config order. It is taken modulo
182
+ * the number of views, so an index that outlives a shrinking config wraps rather
183
+ * than showing nothing. External panes are FULL-HEIGHT views, never stacked
184
+ * beside the economy panel — one pane, one subject.
185
+ *
70
186
  * @param {string} stateDir
71
- * @param {{ now?: number, cols?: number }} [opts]
187
+ * @param {{ now?: number, cols?: number, rows?: number, view?: number,
188
+ * panes?: Array<{path: string, source: string}> }} [opts]
72
189
  * @returns {string}
73
190
  */
74
191
  function composeFrame(stateDir, opts = {}) {
@@ -82,8 +199,38 @@ function composeFrame(stateDir, opts = {}) {
82
199
  const exited = path.join(stateDir, 'exited');
83
200
 
84
201
  if (fs.existsSync(exited)) return clamp(bold('ccr') + ' ' + dim('session ended') + '\n');
85
- let raw = '';
86
- try { raw = fs.readFileSync(snapshot, 'utf8'); } catch { /* none yet */ }
202
+
203
+ // External panes. Config is re-read per tick so adding a pane needs no
204
+ // relaunch, and it is best-effort: a broken config costs the panes, never the
205
+ // panel (loadPaneConfig is total — see src/pane-config.js).
206
+ const panes = opts.panes || loadPaneConfig().panes;
207
+ const viewCount = 1 + panes.length;
208
+ const view = ((Math.trunc(opts.view || 0) % viewCount) + viewCount) % viewCount;
209
+ if (view > 0) {
210
+ const pane = panes[view - 1];
211
+ // Guarded like the economy branch below: "a malformed file must cost a pane
212
+ // state, never the sidecar" is the contract's rule, and leaving it to rest
213
+ // on the renderer never throwing would make it one careless edit from false.
214
+ try {
215
+ const res = loadPaneBlob(pane.path, { now });
216
+ return clamp(renderPane(res, {
217
+ source: pane.source,
218
+ position: `${view + 1}/${viewCount}`,
219
+ width: typeof cols === 'number' && cols > 0 ? cols : 48,
220
+ // Rows the body may use: the pane's height less the chrome (title,
221
+ // basis, blank) and a line of breathing room. Without this the overflow
222
+ // collapse in renderPane is unreachable, and a long blob silently
223
+ // scrolls the pane — which is exactly what obligation 8 forbids.
224
+ maxRows: Math.max(1, (opts.rows || resolveRows() || 24) - 4),
225
+ }) + '\n');
226
+ } catch (e) {
227
+ const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
228
+ return clamp(dim('ccr · pane render error: ' + msg.slice(0, 120)) + '\n');
229
+ }
230
+ }
231
+ // Capped, regular-files-only read: a fifo here would block this synchronous
232
+ // loop forever and an unbounded file can blank the panel (see src/safe-read.js).
233
+ const raw = readTextCapped(snapshot) || '';
87
234
  if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
88
235
  let state;
89
236
  try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
@@ -95,8 +242,15 @@ function composeFrame(stateDir, opts = {}) {
95
242
  // strictly guarded so a different account is never mixed in.
96
243
  const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir) };
97
244
  out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0 });
245
+ // ccr's own view is position 1 of the cycle. Shown only when there is a
246
+ // cycle to be in — a lone economy view has no position worth naming.
247
+ if (viewCount > 1) out = out.replace(/\n/, dim(` 1/${viewCount}`) + '\n');
98
248
  } catch (e) {
99
- out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
249
+ // Sanitize and bound the message: it is the one error surface that prints
250
+ // text ccr did not author, and an exception string can quote the input that
251
+ // caused it. Everything else here is a named state naming a path only.
252
+ const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
253
+ out = dim('ccr render error: ' + msg.slice(0, 120));
100
254
  }
101
255
  // Live tool/skills feed below the panel — best-effort; never break the panel.
102
256
  // Its inner width tracks the pane so args truncate cleanly (the clamp below is
@@ -144,11 +298,47 @@ function resolveCols() {
144
298
  return haveLive ? live : undefined;
145
299
  }
146
300
 
147
- function frame() {
148
- // Read columns each tick so a live resize re-flows on the next frame.
149
- draw(composeFrame(STATE_DIR, { now: Date.now(), cols: resolveCols() }));
301
+ /** The pane's height, for the row budget. Unknown (non-TTY) → undefined. */
302
+ function resolveRows() {
303
+ const live = process.stdout.rows;
304
+ return typeof live === 'number' && live > 0 ? live : undefined;
150
305
  }
151
306
 
307
+ // Which whole-pane view is showing. composeFrame takes it modulo the view
308
+ // count, so it never needs clamping here.
309
+ let currentView = 0;
310
+ // Advance-requests already applied. The host's key writes a counter (see
311
+ // src/cycle-view.js) and we consume the DIFFERENCE, so a burst of presses
312
+ // advances by the number pressed and none is lost between ticks.
313
+ /** @type {number|null} */
314
+ let seenRequests = null;
315
+
316
+ /**
317
+ * One tick: consume any pending advance-requests, then paint.
318
+ * The seams exist so a test can observe what reaches composeFrame — without
319
+ * them, "the view index actually reaches the frame" is unobservable, and both
320
+ * halves of the cycling wiring can be broken with the suite still green.
321
+ * @param {{ stateDir?: string, compose?: Function, paint?: Function }} [deps]
322
+ */
323
+ function frame(deps = {}) {
324
+ const stateDir = deps.stateDir || STATE_DIR;
325
+ const compose = deps.compose || composeFrame;
326
+ const paint = deps.paint || draw;
327
+ const requests = readViewRequests(stateDir);
328
+ if (seenRequests == null) seenRequests = requests; // adopt on first tick
329
+ else if (requests !== seenRequests) {
330
+ currentView += Math.max(0, requests - seenRequests);
331
+ seenRequests = requests;
332
+ }
333
+ // Read columns and rows each tick so a live resize re-flows on the next frame.
334
+ paint(compose(stateDir, {
335
+ now: Date.now(), cols: resolveCols(), rows: resolveRows(), view: currentView,
336
+ }));
337
+ }
338
+
339
+ /** Test seam: reset the cycling state between scenarios (module-level by design). */
340
+ function __resetViewState() { currentView = 0; seenRequests = null; }
341
+
152
342
  /**
153
343
  * The live loop. With `exitOnEnd` (the Windows launcher passes `--exit-on-end`),
154
344
  * the sidecar closes its own pane as soon as the `exited` sentinel appears — so a
@@ -165,8 +355,14 @@ function frame() {
165
355
  * buildWtArgs) so this RIGHT pane closes first and the border sweeps left→right.
166
356
  * Side effects are injectable so the end-sweep is unit-testable.
167
357
  *
358
+ * A second sidecar pasted against the same state dir takes the heartbeat over
359
+ * (its nonce is newer); this one then paints a hand-off note and exits WITHOUT
360
+ * clearing the file — it now belongs to the newer panel. `beat`/`clearBeat`/
361
+ * `onYield` are injectable so the takeover is unit-testable too.
362
+ *
168
363
  * @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
169
364
  * tick?: () => void, sentinelExists?: () => boolean,
365
+ * beat?: () => ('claimed' | 'yielded'), clearBeat?: () => void, onYield?: () => void,
170
366
  * setIntervalFn?: Function, setTimeoutFn?: Function,
171
367
  * clearIntervalFn?: Function, clearTimeoutFn?: Function,
172
368
  * exit?: () => void, onSignal?: (sig: string, handler: () => void) => void }} [opts]
@@ -180,6 +376,10 @@ function run(opts = {}) {
180
376
  const graceMs = opts.graceMs != null ? opts.graceMs : 200;
181
377
  const tick = opts.tick || frame;
182
378
  const sentinelExists = opts.sentinelExists || (() => fs.existsSync(path.join(stateDir, 'exited')));
379
+ const nonce = `${process.pid}:${Date.now()}`;
380
+ const beat = opts.beat || (() => heartbeatTick(stateDir, nonce));
381
+ const clearBeat = opts.clearBeat || (() => clearHeartbeat(stateDir, nonce));
382
+ const onYield = opts.onYield || (() => draw(bold('ccr') + ' ' + dim('another sidecar attached — this pane stood down') + '\n'));
183
383
  const setIntervalFn = opts.setIntervalFn || setInterval;
184
384
  const setTimeoutFn = opts.setTimeoutFn || setTimeout;
185
385
  const clearIntervalFn = opts.clearIntervalFn || clearInterval;
@@ -191,14 +391,18 @@ function run(opts = {}) {
191
391
  const RENDER_MS = 1000;
192
392
  const pollMs = exitOnEnd ? 120 : RENDER_MS;
193
393
 
394
+ /** @type {ReturnType<typeof setInterval>|null} */
194
395
  let id = null;
396
+ /** @type {ReturnType<typeof setTimeout>|null} */
195
397
  let endTimer = null;
196
398
  let sinceRender = RENDER_MS; // render on the first loop
197
- const stop = () => {
399
+ const teardown = (/** @type {boolean} */ clearHb) => {
198
400
  if (id != null) clearIntervalFn(id);
199
401
  if (endTimer != null) clearTimeoutFn(endTimer);
402
+ if (clearHb) clearBeat();
200
403
  exit();
201
404
  };
405
+ const stop = () => teardown(true);
202
406
  const checkEnd = () => {
203
407
  // Once the session has ended, paint it once then sweep this pane closed.
204
408
  if (exitOnEnd && endTimer == null && sentinelExists()) {
@@ -208,11 +412,24 @@ function run(opts = {}) {
208
412
  };
209
413
  const loop = () => {
210
414
  sinceRender += pollMs;
211
- if (sinceRender >= RENDER_MS) { sinceRender = 0; tick(); }
415
+ if (sinceRender >= RENDER_MS) {
416
+ sinceRender = 0;
417
+ tick();
418
+ // Beat at render cadence (~1s). A newer sidecar owns the dir now →
419
+ // hand the state dir over and fold this pane, leaving ITS heartbeat.
420
+ if (beat() === 'yielded') { onYield(); teardown(false); return; }
421
+ }
212
422
  checkEnd();
213
423
  };
214
424
  loop();
215
425
  id = setIntervalFn(loop, pollMs);
426
+ // SIGUSR1 also advances the view, for anyone who can already signal this
427
+ // process (`kill -USR1 $(pgrep -f 'ccr.js sidecar')`). The KEY binding does
428
+ // not use it: routing a keypress through a pid read out of a writable file
429
+ // turned a cosmetic hotkey into an arbitrary-kill primitive, so the host
430
+ // writes a request file instead (see src/cycle-view.js). Cycling still never
431
+ // reaches stdin, which is the invariant that matters.
432
+ onSignal('SIGUSR1', () => { currentView += 1; tick(); });
216
433
  onSignal('SIGINT', stop);
217
434
  onSignal('SIGTERM', stop);
218
435
  return stop;
@@ -220,5 +437,9 @@ function run(opts = {}) {
220
437
 
221
438
  // `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
222
439
  // session-switch reset and the ended/waiting/render states are the subtle
223
- // parts); the live loop uses `run`.
224
- module.exports = { run, updateFeed, composeFrame };
440
+ // parts); the live loop uses `run`. The heartbeat trio is exported for tests
441
+ // and for the VS Code launcher's `sidecarAlive` check.
442
+ module.exports = {
443
+ run, updateFeed, composeFrame, heartbeatTick, clearHeartbeat, sidecarAlive,
444
+ frame, __resetViewState,
445
+ };