claude-code-runrate 0.3.0 → 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.
@@ -140,4 +140,52 @@ function fmtReset(/** @type {number|null} */ min) {
140
140
  return `${m}m`;
141
141
  }
142
142
 
143
- module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset };
143
+ /**
144
+ * Terminal columns a PLAIN string occupies — the same accounting `clampVisible`
145
+ * does, exposed for the callers that must budget space before they build a line
146
+ * rather than clamp one afterwards. No SGR handling: the strings measured here
147
+ * are display text before any colour is applied.
148
+ * @param {string} s
149
+ * @returns {number}
150
+ */
151
+ function visibleWidth(s) {
152
+ let w = 0;
153
+ for (const ch of s) w += charWidth(/** @type {number} */ (ch.codePointAt(0)));
154
+ return w;
155
+ }
156
+
157
+ /**
158
+ * Fit plain text into `cols` columns, marking the cut with an ellipsis so a
159
+ * shortened value never reads as a complete one. Cutting is by code point and
160
+ * by COLUMN (a wide glyph costs two), and the ellipsis is inside the budget —
161
+ * the result is never wider than `cols`.
162
+ *
163
+ * Distinct from `clampVisible`, which is the hard safety net applied to a
164
+ * finished line: this one is composition, so the caller can lay out around a
165
+ * value it knows will fit. Returns '' for a non-positive budget.
166
+ *
167
+ * @param {string} s
168
+ * @param {number} cols
169
+ * @returns {string}
170
+ */
171
+ function ellipsize(s, cols) {
172
+ if (!(typeof cols === 'number' && cols > 0)) return '';
173
+ if (visibleWidth(s) <= cols) return s;
174
+ // One column is spent on the ellipsis, so the text gets cols-1. At cols === 1
175
+ // that leaves nothing, and the ellipsis alone is the honest answer.
176
+ const budget = cols - 1;
177
+ let out = '';
178
+ let w = 0;
179
+ for (const ch of s) {
180
+ const cw = charWidth(/** @type {number} */ (ch.codePointAt(0)));
181
+ if (w + cw > budget) break;
182
+ out += ch;
183
+ w += cw;
184
+ }
185
+ return out + '…';
186
+ }
187
+
188
+ module.exports = {
189
+ e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset,
190
+ charWidth, visibleWidth, ellipsize,
191
+ };
@@ -6,12 +6,45 @@
6
6
  const { windowEstimate, binding } = require('../burn');
7
7
  const { fmtMins } = require('./shared');
8
8
 
9
+ /**
10
+ * Deterministic middle ellipsis: the same input shortens the same way at
11
+ * every glance — the anti-marquee rule. (Animation was rejected outright:
12
+ * Claude re-renders this line per turn, not on a clock, so anything animated
13
+ * freezes exactly when the user is idle and orienting.)
14
+ * @param {string} s @param {number} max
15
+ */
16
+ function midEllipsis(s, max) {
17
+ if (s.length <= max) return s;
18
+ const head = Math.ceil((max - 1) / 2);
19
+ const tail = max - 1 - head;
20
+ return s.slice(0, head) + '…' + (tail > 0 ? s.slice(-tail) : '');
21
+ }
22
+
9
23
  /**
10
24
  * @param {any} view normalized economy data
11
- * @returns {string} one line, e.g. "Sonnet 4.6 · weekly · Sonnet ~5h · ctx 15% · $2.50"
25
+ * @param {{ name?: string|null, location?: string|null, cols?: number }} [identity]
26
+ * The instance identity, shown FIRST so terminal end-truncation eats meters,
27
+ * never orientation. The location half is LIVE (follows a mid-session cd)
28
+ * and appears only when it differs from the name — "notes @ notes" says
29
+ * nothing twice. `cols` bounds the identity: the location stays whole, the
30
+ * name takes the ellipsis.
31
+ * @returns {string} one line, e.g. "a-is-awesome @ ccr · Opus 4.8 · 5h ~2h · ctx 15% · $2.50"
12
32
  */
13
- function renderStatusline(view) {
33
+ function renderStatusline(view, identity = {}) {
14
34
  const parts = [];
35
+ const name = identity.name || null;
36
+ const loc = identity.location || null;
37
+ if (name) {
38
+ const withLoc = loc && loc !== name;
39
+ let shownName = name;
40
+ if (identity.cols && withLoc) {
41
+ const budget = identity.cols - (' @ '.length + (loc ? loc.length : 0));
42
+ if (name.length > budget) shownName = midEllipsis(name, Math.max(5, budget));
43
+ } else if (identity.cols && name.length > identity.cols) {
44
+ shownName = midEllipsis(name, Math.max(5, identity.cols));
45
+ }
46
+ parts.push(withLoc ? `${shownName} @ ${loc}` : shownName);
47
+ }
15
48
  if (view.model) parts.push(view.model);
16
49
 
17
50
  // Annotated because Array.isArray does not narrow an `any`: without this the
@@ -33,8 +66,13 @@ function renderStatusline(view) {
33
66
  const b = binding(live);
34
67
  if (b && b.minutesLeft != null) {
35
68
  const row = rows.find((r) => r.key === b.window);
36
- const imminent = b.minutesLeft <= 30 ? '⚠ ' : '';
37
- parts.push(`${imminent}${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
69
+ if (b.minutesLeft <= 30) {
70
+ // "About to hit the wall" outranks orientation for the next thing the
71
+ // user types: the warning jumps ahead of everything, identity included.
72
+ parts.unshift(`⚠ ${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
73
+ } else {
74
+ parts.push(`${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
75
+ }
38
76
  } else {
39
77
  parts.push('within limits');
40
78
  }
package/src/safe-read.js CHANGED
@@ -44,6 +44,22 @@ const DEFAULT_MAX_BYTES = 256 * 1024;
44
44
  * @returns {string|null}
45
45
  */
46
46
  function readTextCapped(file, maxBytes = DEFAULT_MAX_BYTES) {
47
+ const buf = readBytesCapped(file, maxBytes);
48
+ return buf === null ? null : buf.toString('utf8');
49
+ }
50
+
51
+ /**
52
+ * The binary form of the same rule, for files that are not text: the git pane
53
+ * reads `.git/index`, object files and packfiles, none of which survive a
54
+ * UTF-8 round trip. Identical guards — lstat first (regular file only, so a
55
+ * fifo never blocks and a symlink is never followed), size re-checked from the
56
+ * open descriptor, capped read, never throws.
57
+ *
58
+ * @param {string} file
59
+ * @param {number} [maxBytes]
60
+ * @returns {Buffer|null}
61
+ */
62
+ function readBytesCapped(file, maxBytes = DEFAULT_MAX_BYTES) {
47
63
  let st;
48
64
  try { st = fs.lstatSync(file); } catch { return null; }
49
65
  if (!st.isFile() || st.size > maxBytes) return null;
@@ -55,7 +71,7 @@ function readTextCapped(file, maxBytes = DEFAULT_MAX_BYTES) {
55
71
  if (!fst.isFile() || fst.size > maxBytes) return null;
56
72
  const buf = Buffer.alloc(Math.min(fst.size, maxBytes));
57
73
  const read = fs.readSync(fd, buf, 0, buf.length, 0);
58
- return buf.subarray(0, read).toString('utf8');
74
+ return buf.subarray(0, read);
59
75
  } catch {
60
76
  return null;
61
77
  } finally {
@@ -63,4 +79,4 @@ function readTextCapped(file, maxBytes = DEFAULT_MAX_BYTES) {
63
79
  }
64
80
  }
65
81
 
66
- module.exports = { readTextCapped, DEFAULT_MAX_BYTES };
82
+ module.exports = { readTextCapped, readBytesCapped, DEFAULT_MAX_BYTES };
@@ -0,0 +1,116 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/session-log.js — the forensic join key and history retention
4
+ // (features/instance-persistence.feature).
5
+ //
6
+ // One file per session, `session-<sid>.jsonl`, at the container's top level
7
+ // beside `burnlog-<sid>.jsonl` — same key, same lifecycle. TWO-PHASE, ruled
8
+ // on the owner's words ("even partial information allows forensic
9
+ // reconstruction of what happened"): the open record is written the moment
10
+ // the session id first exists — deaths are exactly when writes cannot be
11
+ // trusted to happen — and finalized by whoever sees the death: the exiting
12
+ // process (`ended`) if polite, the sweep (`swept`, stamped with the last
13
+ // heartbeat's mtime — the honest "ended around here") if not. A `swept`
14
+ // marker is itself forensic signal: this session died badly.
15
+ //
16
+ // The join key gets its OWN file, never a line inside the burnlog: the
17
+ // burnlog's size cap halves that file by DROPPING THE HEAD
18
+ // (src/instrument.js capFile), which would silently destroy a head-of-file
19
+ // key at 2MB.
20
+ //
21
+ // RETENTION, ruled shape-independent: content survives 30 full days after
22
+ // its session ends and is gone at 31, counted from last write — and with
23
+ // per-session files, last write IS death (the finalize marker), so file-age
24
+ // pruning needs no date parsing.
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ const RETAIN_DAYS = 31;
30
+ const DAY_MS = 24 * 60 * 60 * 1000;
31
+
32
+ /** @param {string} sid */
33
+ const clean = (sid) => String(sid || '').replace(/[^A-Za-z0-9_-]/g, '');
34
+
35
+ /** @param {string} home @param {string} sid */
36
+ function logFile(home, sid) {
37
+ return path.join(home, '.ccr', `session-${clean(sid)}.jsonl`);
38
+ }
39
+
40
+ /**
41
+ * Phase one: the open record, written once, at the first status capture.
42
+ * @param {string} home
43
+ * @param {string} sid
44
+ * @param {{ name?: string|null, profile?: string|null, launch_cwd?: string|null, now?: number }} fields
45
+ */
46
+ function openEntry(home, sid, fields = {}) {
47
+ if (!clean(sid)) return;
48
+ const file = logFile(home, sid);
49
+ try {
50
+ if (fs.existsSync(file)) return;
51
+ const rec = {
52
+ session_id: clean(sid),
53
+ name: fields.name || null,
54
+ profile: fields.profile || null,
55
+ launch_cwd: fields.launch_cwd || null,
56
+ started: fields.now != null ? fields.now : Date.now(),
57
+ };
58
+ fs.writeFileSync(file, JSON.stringify(rec) + '\n', { mode: 0o600 });
59
+ } catch { /* best effort — forensics must never break the status line */ }
60
+ }
61
+
62
+ /**
63
+ * Phase two: whoever sees the death appends the marker.
64
+ * @param {string} home
65
+ * @param {string} sid
66
+ * @param {{ ended?: number, swept?: number }} marker
67
+ */
68
+ function finalize(home, sid, marker) {
69
+ if (!clean(sid)) return;
70
+ const file = logFile(home, sid);
71
+ try {
72
+ if (!fs.existsSync(file)) return; // died before the first tick — nothing to finalize
73
+ fs.appendFileSync(file, JSON.stringify(marker) + '\n');
74
+ } catch { /* best effort */ }
75
+ }
76
+
77
+ /**
78
+ * Finalize on behalf of an instance dir about to be deleted: the dir's own
79
+ * captured status is what still knows the session id.
80
+ * @param {string} home
81
+ * @param {string} dir the instance's state dir
82
+ * @param {'ended'|'swept'} how
83
+ * @param {number} [at]
84
+ */
85
+ function finalizeFromDir(home, dir, how, at) {
86
+ try {
87
+ const raw = fs.readFileSync(path.join(dir, 'last-status.json'), 'utf8');
88
+ const sid = JSON.parse(raw).session_id;
89
+ if (!sid) return;
90
+ finalize(home, sid, { [how]: at != null ? at : Date.now() });
91
+ } catch { /* no capture — the accepted gap: nothing to join, nothing to debug */ }
92
+ }
93
+
94
+ /**
95
+ * The 31-day boundary: history is kept through 30 full days after its
96
+ * session's end and gone at 31 — burnlogs and session logs alike, whole
97
+ * files, by last-write mtime.
98
+ * @param {string} home
99
+ * @param {{ now?: number }} [opts]
100
+ */
101
+ function pruneHistory(home, opts = {}) {
102
+ const now = opts.now != null ? opts.now : Date.now();
103
+ const root = path.join(home, '.ccr');
104
+ let names; try { names = fs.readdirSync(root); } catch { return; }
105
+ for (const n of names) {
106
+ if (!/^(burnlog|session)-[A-Za-z0-9_-]+\.jsonl$/.test(n)) continue;
107
+ const p = path.join(root, n);
108
+ try {
109
+ const st = fs.lstatSync(p);
110
+ if (!st.isFile()) continue;
111
+ if (now - st.mtimeMs >= RETAIN_DAYS * DAY_MS) fs.rmSync(p, { force: true });
112
+ } catch { /* best effort */ }
113
+ }
114
+ }
115
+
116
+ module.exports = { openEntry, finalize, finalizeFromDir, pruneHistory, logFile, RETAIN_DAYS };
@@ -0,0 +1,154 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/sidecar-keys.js — the hotkey host for terminals that have none.
4
+ //
5
+ // WHY THIS EXISTS. Cycling the sidebar's views is a HOST capability: under tmux
6
+ // the launcher binds F3, tmux runs `ccr cycle-view`, and src/sidecar.js never
7
+ // sees the key (docs/PANE-CONTRACT.md, "Hotkeys are a host capability"). VS Code
8
+ // and its forks bind nothing, and their integrated terminal leaves BOTH panes
9
+ // running a foreground process — Claude in one, the sidecar in the other — so
10
+ // there is not even a free shell prompt to type `ccr cycle-view` into.
11
+ //
12
+ // Before the git pane that cost nothing: a user with no configured panes had a
13
+ // one-view cycle, so a key that did not exist took nothing away. Every instance
14
+ // now has two built-in views, and on those hosts no way at all to reach the
15
+ // second one. This file is ccr playing host where the host declines to.
16
+ //
17
+ // WHY IT IS A SEPARATE PROCESS, and not a stdin listener bolted to the panel.
18
+ // The structural invariant is that the sidecar has NO INPUT CHANNEL, so that
19
+ // terminal-response channels and echoed keystrokes are structurally dead rather
20
+ // than filtered. That invariant is about the process which RENDERS UNTRUSTED
21
+ // TEXT, and it survives here intact: this file owns the terminal's stdin and
22
+ // spawns `ccr sidecar` as a CHILD whose stdin is `'ignore'`. The renderer still
23
+ // cannot read a key. It is exactly tmux's separation — host reads the key, the
24
+ // renderer never participates — with ccr standing in for the host.
25
+ //
26
+ // Both directions are pinned structurally in test/sidecar-capabilities.test.js:
27
+ // the renderer's module graph can never reach this file, and this file's graph
28
+ // can never reach a renderer. Widening either is a deliberate act.
29
+ //
30
+ // WHAT AN ATTACKER GETS, stated rather than implied. A hostile blob can emit a
31
+ // terminal query whose response the terminal delivers to whoever owns stdin —
32
+ // which is now this process rather than nobody. That buys exactly what forging
33
+ // <stateDir>/view-request already buys, and the contract prices it: "a different
34
+ // pane on screen". This process draws nothing and writes one counter.
35
+ //
36
+ // THE KEY SET IS A COMPILE-TIME CONSTANT, per the contract's trust rule —
37
+ // configuration may choose a key, ccr's own code chooses what it does. There is
38
+ // no path from configuration, a blob, or a producer to anything here.
39
+
40
+ const path = require('node:path');
41
+ const { spawn } = require('node:child_process');
42
+ const { cycleView } = require('./cycle-view');
43
+
44
+ // F3 in the two encodings terminals actually send — SS3 on xterm and VS Code's
45
+ // xterm.js, CSI on the Linux console — plus SPACE.
46
+ //
47
+ // Space is not a fallback for tidiness: an editor that keeps F3 for its own
48
+ // "find next" while the terminal is focused would otherwise leave this pane with
49
+ // no key at all, and that behavior differs across VS Code, Cursor, Positron and
50
+ // Antigravity. The pane is dedicated to the sidecar, so nothing else there is
51
+ // waiting for a space.
52
+ const CYCLE_KEYS = ['\x1bOR', '\x1b[13~', ' '];
53
+
54
+ // In raw mode Ctrl-C arrives as a BYTE, not a signal. Without handling it the
55
+ // pane could not be closed from the keyboard at all.
56
+ const INTERRUPT = '\x03';
57
+
58
+ /**
59
+ * How many cycle keys are in this chunk. A burst advances by the number
60
+ * pressed, which is the request counter's own semantics (src/cycle-view.js):
61
+ * a press that lands while the pane is busy is never lost.
62
+ * @param {string} chunk
63
+ * @returns {number}
64
+ */
65
+ function countCycleKeys(chunk) {
66
+ let n = 0;
67
+ for (const key of CYCLE_KEYS) n += chunk.split(key).length - 1;
68
+ return n;
69
+ }
70
+
71
+ /**
72
+ * Run the sidecar under a key-reading parent.
73
+ *
74
+ * Every side effect is injectable, because the interesting behavior here is
75
+ * ordering — raw mode restored on EVERY exit path, the child killed when the
76
+ * parent is signalled, the exit code carried back — and none of that is
77
+ * observable if the real tty and a real child process are in the way.
78
+ *
79
+ * @param {{ stateDir: string, argv?: string[], node?: string, ccrJs?: string,
80
+ * spawnFn?: Function, stdin?: any, cycle?: (dir: string) => any,
81
+ * exit?: (code: number) => void,
82
+ * onSignal?: (sig: string, handler: () => void) => void }} opts
83
+ * @returns {{ stop: () => void, child: any }}
84
+ */
85
+ function runWithKeys(opts) {
86
+ const stateDir = opts.stateDir;
87
+ const node = opts.node || process.execPath;
88
+ const ccrJs = opts.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js');
89
+ const spawnFn = opts.spawnFn || spawn;
90
+ const stdin = opts.stdin || process.stdin;
91
+ const cycle = opts.cycle || cycleView;
92
+ const exit = opts.exit || ((/** @type {number} */ code) => process.exit(code));
93
+ const onSignal = opts.onSignal || ((/** @type {string} */ sig, /** @type {() => void} */ h) => { process.on(sig, h); });
94
+
95
+ // The child is the panel, unchanged: same command, same flags, and stdin
96
+ // explicitly closed to it. stdout/stderr are inherited so the panel draws
97
+ // straight to this terminal — the parent prints nothing, ever.
98
+ const child = spawnFn(node, [ccrJs, 'sidecar', '--state-dir', stateDir, ...(opts.argv || [])], {
99
+ stdio: ['ignore', 'inherit', 'inherit'],
100
+ });
101
+
102
+ let restored = false;
103
+ /** Put the terminal back. Idempotent, and called on every path out. */
104
+ const restore = () => {
105
+ if (restored) return;
106
+ restored = true;
107
+ // A terminal left in raw mode outlives this process and is the worst
108
+ // failure this file could have: the user's shell stops echoing and stops
109
+ // handling Ctrl-C. Both calls are guarded because either can throw on a
110
+ // stream that has already gone away.
111
+ try { if (stdin.isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false); } catch { /* already gone */ }
112
+ try { stdin.pause(); } catch { /* already gone */ }
113
+ };
114
+
115
+ let stopping = false;
116
+ const stop = () => {
117
+ stopping = true;
118
+ restore();
119
+ try { child.kill('SIGTERM'); } catch { /* already dead */ }
120
+ };
121
+
122
+ // Raw mode only when there IS a terminal. `ccr sidecar --keys` with stdin
123
+ // redirected (a pipe, a service manager, a CI run) must degrade to a plain
124
+ // sidecar rather than throwing on setRawMode.
125
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
126
+ try {
127
+ stdin.setRawMode(true);
128
+ stdin.resume();
129
+ if (typeof stdin.setEncoding === 'function') stdin.setEncoding('utf8');
130
+ stdin.on('data', (/** @type {any} */ d) => {
131
+ const s = String(d);
132
+ if (s.includes(INTERRUPT)) { stop(); return; }
133
+ for (let i = countCycleKeys(s); i > 0; i -= 1) cycle(stateDir);
134
+ });
135
+ } catch {
136
+ // A terminal that refuses raw mode costs the key, never the panel.
137
+ restore();
138
+ }
139
+ }
140
+
141
+ child.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
142
+ restore();
143
+ // A stop WE asked for is a clean close, whatever signal did the work.
144
+ exit(stopping ? 0 : (signal ? 1 : (code == null ? 0 : code)));
145
+ });
146
+ // A child that never started must not leave the terminal in raw mode either.
147
+ child.on('error', () => { restore(); exit(1); });
148
+
149
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) onSignal(sig, stop);
150
+
151
+ return { stop, child };
152
+ }
153
+
154
+ module.exports = { runWithKeys, countCycleKeys, CYCLE_KEYS, INTERRUPT };
package/src/sidecar.js CHANGED
@@ -20,6 +20,10 @@ const { stripControl } = require('./sanitize');
20
20
  const { loadPaneConfig } = require('./pane-config');
21
21
  const { loadPaneBlob } = require('./pane-blob');
22
22
  const { renderPane } = require('./render/pane');
23
+ const { readGitRepo, discoverRepo } = require('./git-repo');
24
+ const { computeWorkingTree } = require('./git-working-tree');
25
+ const { readHistory } = require('./git-history');
26
+ const { renderGitPane, laneBudget } = require('./render/git-pane');
23
27
  const { readViewRequests } = require('./cycle-view');
24
28
 
25
29
  const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
@@ -76,9 +80,13 @@ function heartbeatTick(stateDir, nonce, opts = {}) {
76
80
  if (other && mine && (other.start > mine.start || (other.start === mine.start && other.pid > mine.pid))) {
77
81
  return 'yielded';
78
82
  }
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 */ }
83
+ // Never write THROUGH anything but a plain file at this path. A symlink
84
+ // would turn a heartbeat into an arbitrary-file write of "<pid>:<ms>"; a
85
+ // FIFO is worse in a quieter way opening one for write BLOCKS until a
86
+ // reader appears, which hangs the draw loop outright and takes the whole
87
+ // sidebar down with no error. Both are plantable by anything running as the
88
+ // user, so anything that is not a regular file is removed first.
89
+ try { if (!fs.lstatSync(file).isFile()) fs.rmSync(file, { force: true }); } catch { /* absent */ }
82
90
  fs.writeFileSync(file, nonce);
83
91
  } catch { /* best-effort */ }
84
92
  return 'claimed';
@@ -168,6 +176,50 @@ function draw(/** @type {string} */ s) {
168
176
  process.stdout.write('\x1b[H' + s.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
169
177
  }
170
178
 
179
+ /**
180
+ * The directory the SESSION is currently working in, as Claude Code last
181
+ * reported it. `workspace.current_dir` is the field that tracks a session that
182
+ * moved; `cwd` is the same value in older payloads and is the fallback rather
183
+ * than the primary for exactly that reason.
184
+ *
185
+ * Returns null when there is no snapshot yet, which the git pane renders as
186
+ * "not a git repository" — correct, because before the first status tick ccr
187
+ * genuinely does not know where the session is.
188
+ *
189
+ * @param {string} stateDir
190
+ * @returns {string|null}
191
+ */
192
+ function sessionDir(stateDir) {
193
+ const raw = readTextCapped(path.join(stateDir, 'last-status.json'));
194
+ if (!raw) return null;
195
+ try {
196
+ const s = JSON.parse(raw);
197
+ if (!s || typeof s !== 'object') return null;
198
+ const w = s.workspace;
199
+ if (w && typeof w.current_dir === 'string' && w.current_dir) return w.current_dir;
200
+ return typeof s.cwd === 'string' && s.cwd ? s.cwd : null;
201
+ } catch { return null; }
202
+ }
203
+
204
+ /**
205
+ * The directory ccr was LAUNCHED in — the tab's stable identity. Written by the
206
+ * launcher (src/state-dir.js: recordLaunchDir); the sidecar's own cwd is the
207
+ * fallback, which is right under tmux and wt.exe where the pane inherits it.
208
+ *
209
+ * `process.cwd()` throws when the directory it names has been deleted, which is
210
+ * not hypothetical here: it is the "repository is deleted while the pane is
211
+ * live" scenario, and the pane must keep drawing through it.
212
+ *
213
+ * @param {string} stateDir
214
+ * @returns {string|null}
215
+ */
216
+ function launchDir(stateDir) {
217
+ const raw = readTextCapped(path.join(stateDir, 'launch-cwd'), 4096);
218
+ const line = raw ? raw.split('\n')[0].trim() : '';
219
+ if (line) return line;
220
+ try { return process.cwd(); } catch { return null; }
221
+ }
222
+
171
223
  /**
172
224
  * Compose the screen for one tick — the ended / waiting / unreadable / live
173
225
  * states — and return it as a string (no I/O to stdout). Pure enough to test:
@@ -177,24 +229,32 @@ function draw(/** @type {string} */ s) {
177
229
  * is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
178
230
  * redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
179
231
  *
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.
232
+ * `view` selects which whole-pane view to draw: 0 is ccr's own economy view, 1
233
+ * is the built-in git pane, and 2..N are the configured external panes in config
234
+ * order. It is taken modulo the number of views, so an index that outlives a
235
+ * shrinking config wraps rather than showing nothing. Every view is FULL-HEIGHT,
236
+ * never stacked beside the economy panel — one pane, one subject.
185
237
  *
186
238
  * @param {string} stateDir
187
239
  * @param {{ now?: number, cols?: number, rows?: number, view?: number,
188
- * panes?: Array<{path: string, source: string}> }} [opts]
240
+ * panes?: Array<{path: string, source: string}>, home?: string }} [opts]
189
241
  * @returns {string}
190
242
  */
191
243
  function composeFrame(stateDir, opts = {}) {
192
244
  const now = opts.now != null ? opts.now : Date.now();
193
245
  const cols = opts.cols;
246
+ // The sidebar names its instance (features/instance-identity.feature): the
247
+ // name rides every frame — every view, the waiting line, the ended line —
248
+ // so a glance at any sidebar says which instance it belongs to.
249
+ let namePrefix = '';
250
+ try {
251
+ const name = fs.readFileSync(path.join(stateDir, 'name'), 'utf8').trim();
252
+ if (name) namePrefix = bold(name) + '\n';
253
+ } catch { /* unnamed — no line */ }
194
254
  const clamp = (/** @type {string} */ s) =>
195
255
  (typeof cols === 'number' && cols > 0
196
- ? s.split('\n').map((l) => clampVisible(l, cols)).join('\n')
197
- : s);
256
+ ? (namePrefix + s).split('\n').map((l) => clampVisible(l, cols)).join('\n')
257
+ : namePrefix + s);
198
258
  const snapshot = path.join(stateDir, 'last-status.json');
199
259
  const exited = path.join(stateDir, 'exited');
200
260
 
@@ -204,10 +264,57 @@ function composeFrame(stateDir, opts = {}) {
204
264
  // relaunch, and it is best-effort: a broken config costs the panes, never the
205
265
  // panel (loadPaneConfig is total — see src/pane-config.js).
206
266
  const panes = opts.panes || loadPaneConfig().panes;
207
- const viewCount = 1 + panes.length;
267
+ // View order: 0 economy, 1 the git pane, 2… external panes. The git pane is
268
+ // BUILT IN and therefore always in the cycle — including in a directory that
269
+ // is not a repository at all, where it says so. A view that appeared and
270
+ // vanished with the session's cwd would renumber the cycle underneath the
271
+ // user's F3 key, and "not a git repository" is itself the answer to the
272
+ // question this pane exists to answer.
273
+ const viewCount = 2 + panes.length;
208
274
  const view = ((Math.trunc(opts.view || 0) % viewCount) + viewCount) % viewCount;
209
- if (view > 0) {
210
- const pane = panes[view - 1];
275
+ // The "n/N" position marker appears only once the cycle is longer than the two
276
+ // BUILT-IN views. Ruled 2026-08-05: adding the git pane made viewCount always
277
+ // ≥ 2, which would have put a marker on the economy panel of every user who
278
+ // has configured nothing — a visible change to a shipped surface, bought for
279
+ // nothing, since two self-identifying views need no numbering to tell apart.
280
+ // A user who has configured a pane keeps the markers they already had.
281
+ const showPosition = viewCount > 2;
282
+ const positionAt = (/** @type {number} */ i) => (showPosition ? `${i + 1}/${viewCount}` : '');
283
+ if (view === 1) {
284
+ // Guarded exactly like the external-pane branch: a bad repository must cost
285
+ // its own pane and nothing else (features/git-pane-safety.feature).
286
+ try {
287
+ const identity = readGitRepo({
288
+ currentDir: sessionDir(stateDir),
289
+ launchDir: launchDir(stateDir),
290
+ });
291
+ // The body sections exist only where a working tree does: a located,
292
+ // readable, non-bare repo. readGitRepo does not expose gitDir,
293
+ // deliberately (it is an identity model); re-discovering from the root it
294
+ // named costs one stat and keeps the model boundary clean.
295
+ let workingTree;
296
+ let history;
297
+ const paneCols = typeof cols === 'number' && cols > 0 ? cols : 48;
298
+ if (identity.state === 'ok' && !identity.bare && identity.root) {
299
+ const at = discoverRepo(identity.root);
300
+ if (at.found && at.gitDir) {
301
+ workingTree = computeWorkingTree({ root: identity.root, gitDir: at.gitDir });
302
+ history = readHistory(at.gitDir, { maxLanes: laneBudget(paneCols), maxRows: 32 });
303
+ }
304
+ }
305
+ return clamp(renderGitPane({ identity, workingTree, history }, {
306
+ width: paneCols,
307
+ rows: opts.rows || resolveRows() || 24,
308
+ now,
309
+ position: positionAt(view),
310
+ }) + '\n');
311
+ } catch (e) {
312
+ const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
313
+ return clamp(dim('ccr · git pane error: ' + msg.slice(0, 120)) + '\n');
314
+ }
315
+ }
316
+ if (view > 1) {
317
+ const pane = panes[view - 2];
211
318
  // Guarded like the economy branch below: "a malformed file must cost a pane
212
319
  // state, never the sidecar" is the contract's rule, and leaving it to rest
213
320
  // on the renderer never throwing would make it one careless edit from false.
@@ -215,7 +322,7 @@ function composeFrame(stateDir, opts = {}) {
215
322
  const res = loadPaneBlob(pane.path, { now });
216
323
  return clamp(renderPane(res, {
217
324
  source: pane.source,
218
- position: `${view + 1}/${viewCount}`,
325
+ position: positionAt(view),
219
326
  width: typeof cols === 'number' && cols > 0 ? cols : 48,
220
327
  // Rows the body may use: the pane's height less the chrome (title,
221
328
  // basis, blank) and a line of breathing room. Without this the overflow
@@ -240,11 +347,11 @@ function composeFrame(stateDir, opts = {}) {
240
347
  // panel lags a busy one. Reconcile the meters against sibling profiles on the
241
348
  // SAME account (see src/account-limits.js) before rendering — best-effort, and
242
349
  // strictly guarded so a different account is never mixed in.
243
- const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir) };
350
+ const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir, opts.home ? { home: opts.home } : {}) };
244
351
  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');
352
+ // ccr's own view is position 1 of the cycle, named only when the cycle is
353
+ // long enough for the position to tell you something (see showPosition).
354
+ if (showPosition) out = out.replace(/\n/, dim(` ${positionAt(0)}`) + '\n');
248
355
  } catch (e) {
249
356
  // Sanitize and bound the message: it is the one error surface that prints
250
357
  // text ccr did not author, and an exception string can quote the input that
@@ -360,7 +467,13 @@ function __resetViewState() { currentView = 0; seenRequests = null; }
360
467
  * clearing the file — it now belongs to the newer panel. `beat`/`clearBeat`/
361
468
  * `onYield` are injectable so the takeover is unit-testable too.
362
469
  *
363
- * @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
470
+ * `view` sets which view the panel OPENS on (0 economy, 1 the git pane, 2…N the
471
+ * configured panes). It is a starting point, not a pin: the cycle key still
472
+ * advances from there. It cannot be used to put two panels side by side on one
473
+ * state dir — the heartbeat allows exactly one live sidecar per state dir, and
474
+ * the second to start makes the first stand down.
475
+ *
476
+ * @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number, view?: number,
364
477
  * tick?: () => void, sentinelExists?: () => boolean,
365
478
  * beat?: () => ('claimed' | 'yielded'), clearBeat?: () => void, onYield?: () => void,
366
479
  * setIntervalFn?: Function, setTimeoutFn?: Function,
@@ -387,6 +500,13 @@ function run(opts = {}) {
387
500
  const exit = opts.exit || (() => process.exit(0));
388
501
  const onSignal = opts.onSignal || ((sig, handler) => process.on(sig, handler));
389
502
 
503
+ // The opening view. Set before the first tick so the panel never paints the
504
+ // economy panel for one frame on its way to the requested one.
505
+ if (opts.view != null) {
506
+ const v = Math.trunc(Number(opts.view));
507
+ if (Number.isFinite(v) && v >= 0) currentView = v;
508
+ }
509
+
390
510
  // Poll the sentinel fast when we have to detect the end; keep the redraw at ~1s.
391
511
  const RENDER_MS = 1000;
392
512
  const pollMs = exitOnEnd ? 120 : RENDER_MS;
@@ -442,4 +562,9 @@ function run(opts = {}) {
442
562
  module.exports = {
443
563
  run, updateFeed, composeFrame, heartbeatTick, clearHeartbeat, sidecarAlive,
444
564
  frame, __resetViewState,
565
+ // Exported so slot allocation (src/instance-slot.js) and its tests can reason
566
+ // about this file without duplicating its name or its freshness window. The
567
+ // launcher never WRITES it: the heartbeat is the sidecar's, and a newer nonce
568
+ // here is what makes a live sidebar stand down.
569
+ HEARTBEAT_FILE, HEARTBEAT_FRESH_MS,
445
570
  };