claude-code-runrate 0.3.0 → 0.5.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/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,167 @@
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 every encoding a terminal actually sends it, plus SPACE.
45
+ //
46
+ // \x1bOR SS3. What xterm, screen, tmux and vt220 all send (infocmp: kf3).
47
+ // \x1b[[C The Linux console — infocmp gives `linux: kf3=\E[[C`, and it is
48
+ // the ONLY entry that differs. It is also what arrives on Windows
49
+ // from Node before v22.17.0 / v24.2.0: until "tty: use terminal VT
50
+ // mode on Windows" (db2aae802) setRawMode passed UV_TTY_MODE_RAW,
51
+ // where libuv translates the keypress itself rather than letting
52
+ // the terminal's own sequence through. From UV_TTY_MODE_RAW_VT on
53
+ // it sets ENABLE_VIRTUAL_TERMINAL_INPUT and Windows Terminal sends
54
+ // SS3 like everyone else. Which of the two arrives on Windows is
55
+ // therefore a property of the NODE VERSION, not the terminal.
56
+ // \x1b[13~ The CSI-tilde form VS Code's xterm.js sends. An earlier version
57
+ // of this comment called it the Linux console encoding; it is not,
58
+ // and the console form above was missing entirely.
59
+ //
60
+ // Space is not a fallback for tidiness: an editor that keeps F3 for its own
61
+ // "find next" while the terminal is focused would otherwise leave this pane with
62
+ // no key at all, and that behavior differs across VS Code, Cursor, Positron and
63
+ // Antigravity. The pane is dedicated to the sidecar, so nothing else there is
64
+ // waiting for a space.
65
+ const CYCLE_KEYS = ['\x1bOR', '\x1b[[C', '\x1b[13~', ' '];
66
+
67
+ // In raw mode Ctrl-C arrives as a BYTE, not a signal. Without handling it the
68
+ // pane could not be closed from the keyboard at all.
69
+ const INTERRUPT = '\x03';
70
+
71
+ /**
72
+ * How many cycle keys are in this chunk. A burst advances by the number
73
+ * pressed, which is the request counter's own semantics (src/cycle-view.js):
74
+ * a press that lands while the pane is busy is never lost.
75
+ * @param {string} chunk
76
+ * @returns {number}
77
+ */
78
+ function countCycleKeys(chunk) {
79
+ let n = 0;
80
+ for (const key of CYCLE_KEYS) n += chunk.split(key).length - 1;
81
+ return n;
82
+ }
83
+
84
+ /**
85
+ * Run the sidecar under a key-reading parent.
86
+ *
87
+ * Every side effect is injectable, because the interesting behavior here is
88
+ * ordering — raw mode restored on EVERY exit path, the child killed when the
89
+ * parent is signalled, the exit code carried back — and none of that is
90
+ * observable if the real tty and a real child process are in the way.
91
+ *
92
+ * @param {{ stateDir: string, argv?: string[], node?: string, ccrJs?: string,
93
+ * spawnFn?: Function, stdin?: any, cycle?: (dir: string) => any,
94
+ * exit?: (code: number) => void,
95
+ * onSignal?: (sig: string, handler: () => void) => void }} opts
96
+ * @returns {{ stop: () => void, child: any }}
97
+ */
98
+ function runWithKeys(opts) {
99
+ const stateDir = opts.stateDir;
100
+ const node = opts.node || process.execPath;
101
+ const ccrJs = opts.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js');
102
+ const spawnFn = opts.spawnFn || spawn;
103
+ const stdin = opts.stdin || process.stdin;
104
+ const cycle = opts.cycle || cycleView;
105
+ const exit = opts.exit || ((/** @type {number} */ code) => process.exit(code));
106
+ const onSignal = opts.onSignal || ((/** @type {string} */ sig, /** @type {() => void} */ h) => { process.on(sig, h); });
107
+
108
+ // The child is the panel, unchanged: same command, same flags, and stdin
109
+ // explicitly closed to it. stdout/stderr are inherited so the panel draws
110
+ // straight to this terminal — the parent prints nothing, ever.
111
+ const child = spawnFn(node, [ccrJs, 'sidecar', '--state-dir', stateDir, ...(opts.argv || [])], {
112
+ stdio: ['ignore', 'inherit', 'inherit'],
113
+ });
114
+
115
+ let restored = false;
116
+ /** Put the terminal back. Idempotent, and called on every path out. */
117
+ const restore = () => {
118
+ if (restored) return;
119
+ restored = true;
120
+ // A terminal left in raw mode outlives this process and is the worst
121
+ // failure this file could have: the user's shell stops echoing and stops
122
+ // handling Ctrl-C. Both calls are guarded because either can throw on a
123
+ // stream that has already gone away.
124
+ try { if (stdin.isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false); } catch { /* already gone */ }
125
+ try { stdin.pause(); } catch { /* already gone */ }
126
+ };
127
+
128
+ let stopping = false;
129
+ const stop = () => {
130
+ stopping = true;
131
+ restore();
132
+ try { child.kill('SIGTERM'); } catch { /* already dead */ }
133
+ };
134
+
135
+ // Raw mode only when there IS a terminal. `ccr sidecar --keys` with stdin
136
+ // redirected (a pipe, a service manager, a CI run) must degrade to a plain
137
+ // sidecar rather than throwing on setRawMode.
138
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
139
+ try {
140
+ stdin.setRawMode(true);
141
+ stdin.resume();
142
+ if (typeof stdin.setEncoding === 'function') stdin.setEncoding('utf8');
143
+ stdin.on('data', (/** @type {any} */ d) => {
144
+ const s = String(d);
145
+ if (s.includes(INTERRUPT)) { stop(); return; }
146
+ for (let i = countCycleKeys(s); i > 0; i -= 1) cycle(stateDir);
147
+ });
148
+ } catch {
149
+ // A terminal that refuses raw mode costs the key, never the panel.
150
+ restore();
151
+ }
152
+ }
153
+
154
+ child.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
155
+ restore();
156
+ // A stop WE asked for is a clean close, whatever signal did the work.
157
+ exit(stopping ? 0 : (signal ? 1 : (code == null ? 0 : code)));
158
+ });
159
+ // A child that never started must not leave the terminal in raw mode either.
160
+ child.on('error', () => { restore(); exit(1); });
161
+
162
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) onSignal(sig, stop);
163
+
164
+ return { stop, child };
165
+ }
166
+
167
+ 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';
@@ -159,6 +167,7 @@ function updateFeed(tpath) {
159
167
 
160
168
  const dim = (/** @type {string} */ s) => `\x1b[2m${s}\x1b[0m`;
161
169
  const bold = (/** @type {string} */ s) => `\x1b[1m${s}\x1b[0m`;
170
+ const yellow = (/** @type {string} */ s) => `\x1b[33m${s}\x1b[0m`;
162
171
 
163
172
  let prev = '';
164
173
  function draw(/** @type {string} */ s) {
@@ -168,6 +177,58 @@ function draw(/** @type {string} */ s) {
168
177
  process.stdout.write('\x1b[H' + s.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
169
178
  }
170
179
 
180
+ /**
181
+ * The directory the SESSION is currently working in, as Claude Code last
182
+ * reported it. `workspace.current_dir` is the field that tracks a session that
183
+ * moved; `cwd` is the same value in older payloads and is the fallback rather
184
+ * than the primary for exactly that reason.
185
+ *
186
+ * Returns null when there is no snapshot yet, which the git pane renders as
187
+ * "not a git repository" — correct, because before the first status tick ccr
188
+ * genuinely does not know where the session is.
189
+ *
190
+ * @param {string} stateDir
191
+ * @returns {string|null}
192
+ */
193
+ function sessionDir(stateDir) {
194
+ const raw = readTextCapped(path.join(stateDir, 'last-status.json'));
195
+ if (!raw) return null;
196
+ try {
197
+ const s = JSON.parse(raw);
198
+ if (!s || typeof s !== 'object') return null;
199
+ const w = s.workspace;
200
+ if (w && typeof w.current_dir === 'string' && w.current_dir) return w.current_dir;
201
+ return typeof s.cwd === 'string' && s.cwd ? s.cwd : null;
202
+ } catch { return null; }
203
+ }
204
+
205
+ /**
206
+ * The directory ccr was LAUNCHED in — the tab's stable identity. Written by the
207
+ * launcher (src/state-dir.js: recordLaunchDir); the sidecar's own cwd is the
208
+ * fallback.
209
+ *
210
+ * That fallback is right under tmux, where `new-session` inherits the caller's
211
+ * cwd. It is NOT free under wt.exe: a pane opens in the Windows Terminal
212
+ * profile's own startingDirectory unless the launcher passes `-d`, which it
213
+ * now does (src/launch-win.js: buildWtArgs). This comment previously claimed
214
+ * wt.exe inherited it, and that claim was the only place the assumption
215
+ * surfaced anywhere in the codebase — see features/windows-launcher.feature,
216
+ * "Claude Code opens in the directory ccr was launched from".
217
+ *
218
+ * `process.cwd()` throws when the directory it names has been deleted, which is
219
+ * not hypothetical here: it is the "repository is deleted while the pane is
220
+ * live" scenario, and the pane must keep drawing through it.
221
+ *
222
+ * @param {string} stateDir
223
+ * @returns {string|null}
224
+ */
225
+ function launchDir(stateDir) {
226
+ const raw = readTextCapped(path.join(stateDir, 'launch-cwd'), 4096);
227
+ const line = raw ? raw.split('\n')[0].trim() : '';
228
+ if (line) return line;
229
+ try { return process.cwd(); } catch { return null; }
230
+ }
231
+
171
232
  /**
172
233
  * Compose the screen for one tick — the ended / waiting / unreadable / live
173
234
  * states — and return it as a string (no I/O to stdout). Pure enough to test:
@@ -177,24 +238,32 @@ function draw(/** @type {string} */ s) {
177
238
  * is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
178
239
  * redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
179
240
  *
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.
241
+ * `view` selects which whole-pane view to draw: 0 is ccr's own economy view, 1
242
+ * is the built-in git pane, and 2..N are the configured external panes in config
243
+ * order. It is taken modulo the number of views, so an index that outlives a
244
+ * shrinking config wraps rather than showing nothing. Every view is FULL-HEIGHT,
245
+ * never stacked beside the economy panel — one pane, one subject.
185
246
  *
186
247
  * @param {string} stateDir
187
248
  * @param {{ now?: number, cols?: number, rows?: number, view?: number,
188
- * panes?: Array<{path: string, source: string}> }} [opts]
249
+ * panes?: Array<{path: string, source: string}>, home?: string }} [opts]
189
250
  * @returns {string}
190
251
  */
191
252
  function composeFrame(stateDir, opts = {}) {
192
253
  const now = opts.now != null ? opts.now : Date.now();
193
254
  const cols = opts.cols;
255
+ // The sidebar names its instance (features/instance-identity.feature): the
256
+ // name rides every frame — every view, the waiting line, the ended line —
257
+ // so a glance at any sidebar says which instance it belongs to.
258
+ let namePrefix = '';
259
+ try {
260
+ const name = fs.readFileSync(path.join(stateDir, 'name'), 'utf8').trim();
261
+ if (name) namePrefix = bold(name) + '\n';
262
+ } catch { /* unnamed — no line */ }
194
263
  const clamp = (/** @type {string} */ s) =>
195
264
  (typeof cols === 'number' && cols > 0
196
- ? s.split('\n').map((l) => clampVisible(l, cols)).join('\n')
197
- : s);
265
+ ? (namePrefix + s).split('\n').map((l) => clampVisible(l, cols)).join('\n')
266
+ : namePrefix + s);
198
267
  const snapshot = path.join(stateDir, 'last-status.json');
199
268
  const exited = path.join(stateDir, 'exited');
200
269
 
@@ -203,11 +272,59 @@ function composeFrame(stateDir, opts = {}) {
203
272
  // External panes. Config is re-read per tick so adding a pane needs no
204
273
  // relaunch, and it is best-effort: a broken config costs the panes, never the
205
274
  // panel (loadPaneConfig is total — see src/pane-config.js).
206
- const panes = opts.panes || loadPaneConfig().panes;
207
- const viewCount = 1 + panes.length;
275
+ const cfg = opts.panes ? { panes: opts.panes, error: null } : loadPaneConfig();
276
+ const panes = cfg.panes;
277
+ // View order: 0 economy, 1 the git pane, 2… external panes. The git pane is
278
+ // BUILT IN and therefore always in the cycle — including in a directory that
279
+ // is not a repository at all, where it says so. A view that appeared and
280
+ // vanished with the session's cwd would renumber the cycle underneath the
281
+ // user's F3 key, and "not a git repository" is itself the answer to the
282
+ // question this pane exists to answer.
283
+ const viewCount = 2 + panes.length;
208
284
  const view = ((Math.trunc(opts.view || 0) % viewCount) + viewCount) % viewCount;
209
- if (view > 0) {
210
- const pane = panes[view - 1];
285
+ // The "n/N" position marker appears only once the cycle is longer than the two
286
+ // BUILT-IN views. Ruled 2026-08-05: adding the git pane made viewCount always
287
+ // ≥ 2, which would have put a marker on the economy panel of every user who
288
+ // has configured nothing — a visible change to a shipped surface, bought for
289
+ // nothing, since two self-identifying views need no numbering to tell apart.
290
+ // A user who has configured a pane keeps the markers they already had.
291
+ const showPosition = viewCount > 2;
292
+ const positionAt = (/** @type {number} */ i) => (showPosition ? `${i + 1}/${viewCount}` : '');
293
+ if (view === 1) {
294
+ // Guarded exactly like the external-pane branch: a bad repository must cost
295
+ // its own pane and nothing else (features/git-pane-safety.feature).
296
+ try {
297
+ const identity = readGitRepo({
298
+ currentDir: sessionDir(stateDir),
299
+ launchDir: launchDir(stateDir),
300
+ });
301
+ // The body sections exist only where a working tree does: a located,
302
+ // readable, non-bare repo. readGitRepo does not expose gitDir,
303
+ // deliberately (it is an identity model); re-discovering from the root it
304
+ // named costs one stat and keeps the model boundary clean.
305
+ let workingTree;
306
+ let history;
307
+ const paneCols = typeof cols === 'number' && cols > 0 ? cols : 48;
308
+ if (identity.state === 'ok' && !identity.bare && identity.root) {
309
+ const at = discoverRepo(identity.root);
310
+ if (at.found && at.gitDir) {
311
+ workingTree = computeWorkingTree({ root: identity.root, gitDir: at.gitDir });
312
+ history = readHistory(at.gitDir, { maxLanes: laneBudget(paneCols), maxRows: 32 });
313
+ }
314
+ }
315
+ return clamp(renderGitPane({ identity, workingTree, history }, {
316
+ width: paneCols,
317
+ rows: opts.rows || resolveRows() || 24,
318
+ now,
319
+ position: positionAt(view),
320
+ }) + '\n');
321
+ } catch (e) {
322
+ const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
323
+ return clamp(dim('ccr · git pane error: ' + msg.slice(0, 120)) + '\n');
324
+ }
325
+ }
326
+ if (view > 1) {
327
+ const pane = panes[view - 2];
211
328
  // Guarded like the economy branch below: "a malformed file must cost a pane
212
329
  // state, never the sidecar" is the contract's rule, and leaving it to rest
213
330
  // on the renderer never throwing would make it one careless edit from false.
@@ -215,7 +332,7 @@ function composeFrame(stateDir, opts = {}) {
215
332
  const res = loadPaneBlob(pane.path, { now });
216
333
  return clamp(renderPane(res, {
217
334
  source: pane.source,
218
- position: `${view + 1}/${viewCount}`,
335
+ position: positionAt(view),
219
336
  width: typeof cols === 'number' && cols > 0 ? cols : 48,
220
337
  // Rows the body may use: the pane's height less the chrome (title,
221
338
  // basis, blank) and a line of breathing room. Without this the overflow
@@ -234,17 +351,23 @@ function composeFrame(stateDir, opts = {}) {
234
351
  if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
235
352
  let state;
236
353
  try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
354
+ // Snapshot age drives BOTH the dimming of the used figures inside the panel
355
+ // and the freshness marker appended below — read ONCE so the two can never
356
+ // disagree about whether what you are looking at is live. An unreadable mtime
357
+ // reads as fresh: a missing timestamp is not evidence of staleness.
358
+ let ageMs = 0;
359
+ try { ageMs = Math.max(0, now - fs.statSync(snapshot).mtimeMs); } catch { /* unknown → fresh */ }
237
360
  let out;
238
361
  try {
239
362
  // 5h/weekly are ACCOUNT-WIDE but captured per-profile, so an idle sibling's
240
363
  // panel lags a busy one. Reconcile the meters against sibling profiles on the
241
364
  // SAME account (see src/account-limits.js) before rendering — best-effort, and
242
365
  // strictly guarded so a different account is never mixed in.
243
- const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir) };
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');
366
+ const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir, opts.home ? { home: opts.home } : {}) };
367
+ out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0, ageMs });
368
+ // ccr's own view is position 1 of the cycle, named only when the cycle is
369
+ // long enough for the position to tell you something (see showPosition).
370
+ if (showPosition) out = out.replace(/\n/, dim(` ${positionAt(0)}`) + '\n');
248
371
  } catch (e) {
249
372
  // Sanitize and bound the message: it is the one error surface that prints
250
373
  // text ccr did not author, and an exception string can quote the input that
@@ -268,11 +391,17 @@ function composeFrame(stateDir, opts = {}) {
268
391
  // quiet "updated Nm ago" so a stale panel reads as stale rather than broken —
269
392
  // otherwise a long agent run (or a CC statusLine that stopped firing) looks like
270
393
  // the sidecar just froze. See src/liveness.js + features/liveness.feature.
271
- try {
272
- const ageMs = now - fs.statSync(snapshot).mtimeMs;
273
- const mark = liveness({ exited: false, ageMs }).marker;
274
- if (mark) out += (out.endsWith('\n') ? '' : '\n') + ' ' + dim('· ' + mark);
275
- } catch { /* snapshot mtime unknown no marker */ }
394
+ const mark = liveness({ exited: false, ageMs }).marker;
395
+ if (mark) out += (out.endsWith('\n') ? '' : '\n') + ' ' + dim('· ' + mark);
396
+ // A config the user wrote and got wrong used to cost the panes in silence —
397
+ // the panel rendered exactly as it does for someone who configured nothing,
398
+ // so a typo was indistinguishable from never having tried. Name it here and
399
+ // send them to `ccr doctor`, which has room for the path and the reason.
400
+ // Still never fatal: the panel is whole, only the panes are missing.
401
+ if (cfg.error) {
402
+ out += (out.endsWith('\n') ? '' : '\n')
403
+ + ' ' + yellow('· config: ' + cfg.error) + dim(' — see `ccr doctor`');
404
+ }
276
405
  return clamp(out.endsWith('\n') ? out : out + '\n');
277
406
  }
278
407
 
@@ -360,7 +489,13 @@ function __resetViewState() { currentView = 0; seenRequests = null; }
360
489
  * clearing the file — it now belongs to the newer panel. `beat`/`clearBeat`/
361
490
  * `onYield` are injectable so the takeover is unit-testable too.
362
491
  *
363
- * @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
492
+ * `view` sets which view the panel OPENS on (0 economy, 1 the git pane, 2…N the
493
+ * configured panes). It is a starting point, not a pin: the cycle key still
494
+ * advances from there. It cannot be used to put two panels side by side on one
495
+ * state dir — the heartbeat allows exactly one live sidecar per state dir, and
496
+ * the second to start makes the first stand down.
497
+ *
498
+ * @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number, view?: number,
364
499
  * tick?: () => void, sentinelExists?: () => boolean,
365
500
  * beat?: () => ('claimed' | 'yielded'), clearBeat?: () => void, onYield?: () => void,
366
501
  * setIntervalFn?: Function, setTimeoutFn?: Function,
@@ -387,6 +522,13 @@ function run(opts = {}) {
387
522
  const exit = opts.exit || (() => process.exit(0));
388
523
  const onSignal = opts.onSignal || ((sig, handler) => process.on(sig, handler));
389
524
 
525
+ // The opening view. Set before the first tick so the panel never paints the
526
+ // economy panel for one frame on its way to the requested one.
527
+ if (opts.view != null) {
528
+ const v = Math.trunc(Number(opts.view));
529
+ if (Number.isFinite(v) && v >= 0) currentView = v;
530
+ }
531
+
390
532
  // Poll the sentinel fast when we have to detect the end; keep the redraw at ~1s.
391
533
  const RENDER_MS = 1000;
392
534
  const pollMs = exitOnEnd ? 120 : RENDER_MS;
@@ -442,4 +584,9 @@ function run(opts = {}) {
442
584
  module.exports = {
443
585
  run, updateFeed, composeFrame, heartbeatTick, clearHeartbeat, sidecarAlive,
444
586
  frame, __resetViewState,
587
+ // Exported so slot allocation (src/instance-slot.js) and its tests can reason
588
+ // about this file without duplicating its name or its freshness window. The
589
+ // launcher never WRITES it: the heartbeat is the sidecar's, and a newer nonce
590
+ // here is what makes a live sidebar stand down.
591
+ HEARTBEAT_FILE, HEARTBEAT_FRESH_MS,
445
592
  };