claude-code-runrate 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -15,6 +15,16 @@ 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 { 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');
27
+ const { readViewRequests } = require('./cycle-view');
18
28
 
19
29
  const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
20
30
 
@@ -45,18 +55,38 @@ function parseNonce(s) {
45
55
  * claimed over (a garbage file must not wedge the panel), and any fs error
46
56
  * claims rather than kills the loop — the heartbeat is strictly best-effort.
47
57
  * @param {string} stateDir @param {string} nonce
58
+ * @param {{ now?: number, freshMs?: number }} [opts] injectable clock, for tests
48
59
  * @returns {'claimed' | 'yielded'}
49
60
  */
50
- function heartbeatTick(stateDir, nonce) {
61
+ function heartbeatTick(stateDir, nonce, opts = {}) {
51
62
  const file = path.join(stateDir, HEARTBEAT_FILE);
52
63
  const mine = parseNonce(nonce);
64
+ const now = opts.now != null ? opts.now : Date.now();
65
+ const freshMs = opts.freshMs != null ? opts.freshMs : HEARTBEAT_FRESH_MS;
53
66
  try {
54
- let cur = '';
55
- try { cur = fs.readFileSync(file, 'utf8'); } catch { /* no heartbeat yet */ }
56
- const other = cur && cur.trim() !== nonce ? parseNonce(cur) : null;
67
+ const cur = readTextCapped(file, 256) || '';
68
+ // Yield only to a nonce that is BOTH newer and still being beaten. Nonce
69
+ // order alone is a wall-clock comparison against a file that outlives its
70
+ // writer: a hard-killed sidecar leaves its nonce behind, and after any
71
+ // backwards clock step (NTP correction, VM restore) every sidecar launched
72
+ // since reads that dead nonce as "newer" and stands down — so the pane ends
73
+ // up with no live sidecar at all, repeatably, until the clock catches up.
74
+ // Mtime is what distinguishes a live rival from a corpse; sidecarAlive()
75
+ // has always used it, and the takeover decision needs it just as much.
76
+ const fresh = (() => {
77
+ try { return now - fs.lstatSync(file).mtimeMs <= freshMs; } catch { return false; }
78
+ })();
79
+ const other = fresh && cur && cur.trim() !== nonce ? parseNonce(cur) : null;
57
80
  if (other && mine && (other.start > mine.start || (other.start === mine.start && other.pid > mine.pid))) {
58
81
  return 'yielded';
59
82
  }
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 */ }
60
90
  fs.writeFileSync(file, nonce);
61
91
  } catch { /* best-effort */ }
62
92
  return 'claimed';
@@ -70,7 +100,7 @@ function heartbeatTick(stateDir, nonce) {
70
100
  function clearHeartbeat(stateDir, nonce) {
71
101
  const file = path.join(stateDir, HEARTBEAT_FILE);
72
102
  try {
73
- if (fs.readFileSync(file, 'utf8').trim() === nonce) fs.rmSync(file, { force: true });
103
+ if ((readTextCapped(file, 256) || '').trim() === nonce) fs.rmSync(file, { force: true });
74
104
  } catch { /* already gone / unreadable — nothing to clear */ }
75
105
  }
76
106
 
@@ -95,15 +125,30 @@ function sidecarAlive(stateDir, opts = {}) {
95
125
  // offset) and roll up tool/skill events + per-session stats. Reset on session
96
126
  // switch. Best-effort — must never break the economy panel.
97
127
  const FEED_CAP = 200;
98
- 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() };
128
+ 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() };
129
+
130
+ /** Zero the rolling totals — on a session switch, and on a tail restart. */
131
+ function resetFeedState(/** @type {string|null} */ tpath) {
132
+ feedState.path = tpath; feedState.offset = 0; feedState.events = [];
133
+ // Null-prototype: these keys are tool NAMES from the transcript, i.e. attacker
134
+ // -influenceable. On a plain object a tool called "constructor" reads back the
135
+ // inherited function (the feed header rendered its native source), and one
136
+ // called "__proto__" silently vanishes into a prototype write instead of
137
+ // counting. With no prototype there is nothing to inherit or to set.
138
+ feedState.tools = Object.create(null);
139
+ feedState.commands = 0;
140
+ feedState.tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
141
+ feedState.files = new Set();
142
+ }
99
143
 
100
144
  /** @param {string} tpath @returns {any} feed view for renderFeed */
101
145
  function updateFeed(tpath) {
102
- if (feedState.path !== tpath) { // new session → start clean
103
- feedState.path = tpath; feedState.offset = 0; feedState.events = []; feedState.tools = {};
104
- feedState.commands = 0; feedState.tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }; feedState.files = new Set();
105
- }
106
- const { offset, lines } = readNewLines(tpath, feedState.offset);
146
+ if (feedState.path !== tpath) resetFeedState(tpath); // new session → start clean
147
+ const { offset, lines, restarted } = readNewLines(tpath, feedState.offset);
148
+ // The tail went back to 0 because the file shrank, so the lines below are ones
149
+ // we have already counted. Resetting the offset without resetting the totals
150
+ // double-counts every tool, file, and token for the rest of the session.
151
+ if (restarted) resetFeedState(tpath);
107
152
  feedState.offset = offset;
108
153
  if (lines.length) {
109
154
  const p = parseEvents(lines);
@@ -131,6 +176,50 @@ function draw(/** @type {string} */ s) {
131
176
  process.stdout.write('\x1b[H' + s.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
132
177
  }
133
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
+
134
223
  /**
135
224
  * Compose the screen for one tick — the ended / waiting / unreadable / live
136
225
  * states — and return it as a string (no I/O to stdout). Pure enough to test:
@@ -140,23 +229,115 @@ function draw(/** @type {string} */ s) {
140
229
  * is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
141
230
  * redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
142
231
  *
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.
237
+ *
143
238
  * @param {string} stateDir
144
- * @param {{ now?: number, cols?: number }} [opts]
239
+ * @param {{ now?: number, cols?: number, rows?: number, view?: number,
240
+ * panes?: Array<{path: string, source: string}>, home?: string }} [opts]
145
241
  * @returns {string}
146
242
  */
147
243
  function composeFrame(stateDir, opts = {}) {
148
244
  const now = opts.now != null ? opts.now : Date.now();
149
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 */ }
150
254
  const clamp = (/** @type {string} */ s) =>
151
255
  (typeof cols === 'number' && cols > 0
152
- ? s.split('\n').map((l) => clampVisible(l, cols)).join('\n')
153
- : s);
256
+ ? (namePrefix + s).split('\n').map((l) => clampVisible(l, cols)).join('\n')
257
+ : namePrefix + s);
154
258
  const snapshot = path.join(stateDir, 'last-status.json');
155
259
  const exited = path.join(stateDir, 'exited');
156
260
 
157
261
  if (fs.existsSync(exited)) return clamp(bold('ccr') + ' ' + dim('session ended') + '\n');
158
- let raw = '';
159
- try { raw = fs.readFileSync(snapshot, 'utf8'); } catch { /* none yet */ }
262
+
263
+ // External panes. Config is re-read per tick so adding a pane needs no
264
+ // relaunch, and it is best-effort: a broken config costs the panes, never the
265
+ // panel (loadPaneConfig is total — see src/pane-config.js).
266
+ const panes = opts.panes || loadPaneConfig().panes;
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;
274
+ const view = ((Math.trunc(opts.view || 0) % viewCount) + viewCount) % viewCount;
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];
318
+ // Guarded like the economy branch below: "a malformed file must cost a pane
319
+ // state, never the sidecar" is the contract's rule, and leaving it to rest
320
+ // on the renderer never throwing would make it one careless edit from false.
321
+ try {
322
+ const res = loadPaneBlob(pane.path, { now });
323
+ return clamp(renderPane(res, {
324
+ source: pane.source,
325
+ position: positionAt(view),
326
+ width: typeof cols === 'number' && cols > 0 ? cols : 48,
327
+ // Rows the body may use: the pane's height less the chrome (title,
328
+ // basis, blank) and a line of breathing room. Without this the overflow
329
+ // collapse in renderPane is unreachable, and a long blob silently
330
+ // scrolls the pane — which is exactly what obligation 8 forbids.
331
+ maxRows: Math.max(1, (opts.rows || resolveRows() || 24) - 4),
332
+ }) + '\n');
333
+ } catch (e) {
334
+ const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
335
+ return clamp(dim('ccr · pane render error: ' + msg.slice(0, 120)) + '\n');
336
+ }
337
+ }
338
+ // Capped, regular-files-only read: a fifo here would block this synchronous
339
+ // loop forever and an unbounded file can blank the panel (see src/safe-read.js).
340
+ const raw = readTextCapped(snapshot) || '';
160
341
  if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
161
342
  let state;
162
343
  try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
@@ -166,10 +347,17 @@ function composeFrame(stateDir, opts = {}) {
166
347
  // panel lags a busy one. Reconcile the meters against sibling profiles on the
167
348
  // SAME account (see src/account-limits.js) before rendering — best-effort, and
168
349
  // strictly guarded so a different account is never mixed in.
169
- 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 } : {}) };
170
351
  out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0 });
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');
171
355
  } catch (e) {
172
- out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
356
+ // Sanitize and bound the message: it is the one error surface that prints
357
+ // text ccr did not author, and an exception string can quote the input that
358
+ // caused it. Everything else here is a named state naming a path only.
359
+ const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
360
+ out = dim('ccr render error: ' + msg.slice(0, 120));
173
361
  }
174
362
  // Live tool/skills feed below the panel — best-effort; never break the panel.
175
363
  // Its inner width tracks the pane so args truncate cleanly (the clamp below is
@@ -217,11 +405,47 @@ function resolveCols() {
217
405
  return haveLive ? live : undefined;
218
406
  }
219
407
 
220
- function frame() {
221
- // Read columns each tick so a live resize re-flows on the next frame.
222
- draw(composeFrame(STATE_DIR, { now: Date.now(), cols: resolveCols() }));
408
+ /** The pane's height, for the row budget. Unknown (non-TTY) → undefined. */
409
+ function resolveRows() {
410
+ const live = process.stdout.rows;
411
+ return typeof live === 'number' && live > 0 ? live : undefined;
412
+ }
413
+
414
+ // Which whole-pane view is showing. composeFrame takes it modulo the view
415
+ // count, so it never needs clamping here.
416
+ let currentView = 0;
417
+ // Advance-requests already applied. The host's key writes a counter (see
418
+ // src/cycle-view.js) and we consume the DIFFERENCE, so a burst of presses
419
+ // advances by the number pressed and none is lost between ticks.
420
+ /** @type {number|null} */
421
+ let seenRequests = null;
422
+
423
+ /**
424
+ * One tick: consume any pending advance-requests, then paint.
425
+ * The seams exist so a test can observe what reaches composeFrame — without
426
+ * them, "the view index actually reaches the frame" is unobservable, and both
427
+ * halves of the cycling wiring can be broken with the suite still green.
428
+ * @param {{ stateDir?: string, compose?: Function, paint?: Function }} [deps]
429
+ */
430
+ function frame(deps = {}) {
431
+ const stateDir = deps.stateDir || STATE_DIR;
432
+ const compose = deps.compose || composeFrame;
433
+ const paint = deps.paint || draw;
434
+ const requests = readViewRequests(stateDir);
435
+ if (seenRequests == null) seenRequests = requests; // adopt on first tick
436
+ else if (requests !== seenRequests) {
437
+ currentView += Math.max(0, requests - seenRequests);
438
+ seenRequests = requests;
439
+ }
440
+ // Read columns and rows each tick so a live resize re-flows on the next frame.
441
+ paint(compose(stateDir, {
442
+ now: Date.now(), cols: resolveCols(), rows: resolveRows(), view: currentView,
443
+ }));
223
444
  }
224
445
 
446
+ /** Test seam: reset the cycling state between scenarios (module-level by design). */
447
+ function __resetViewState() { currentView = 0; seenRequests = null; }
448
+
225
449
  /**
226
450
  * The live loop. With `exitOnEnd` (the Windows launcher passes `--exit-on-end`),
227
451
  * the sidecar closes its own pane as soon as the `exited` sentinel appears — so a
@@ -243,7 +467,13 @@ function frame() {
243
467
  * clearing the file — it now belongs to the newer panel. `beat`/`clearBeat`/
244
468
  * `onYield` are injectable so the takeover is unit-testable too.
245
469
  *
246
- * @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,
247
477
  * tick?: () => void, sentinelExists?: () => boolean,
248
478
  * beat?: () => ('claimed' | 'yielded'), clearBeat?: () => void, onYield?: () => void,
249
479
  * setIntervalFn?: Function, setTimeoutFn?: Function,
@@ -270,11 +500,20 @@ function run(opts = {}) {
270
500
  const exit = opts.exit || (() => process.exit(0));
271
501
  const onSignal = opts.onSignal || ((sig, handler) => process.on(sig, handler));
272
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
+
273
510
  // Poll the sentinel fast when we have to detect the end; keep the redraw at ~1s.
274
511
  const RENDER_MS = 1000;
275
512
  const pollMs = exitOnEnd ? 120 : RENDER_MS;
276
513
 
514
+ /** @type {ReturnType<typeof setInterval>|null} */
277
515
  let id = null;
516
+ /** @type {ReturnType<typeof setTimeout>|null} */
278
517
  let endTimer = null;
279
518
  let sinceRender = RENDER_MS; // render on the first loop
280
519
  const teardown = (/** @type {boolean} */ clearHb) => {
@@ -304,6 +543,13 @@ function run(opts = {}) {
304
543
  };
305
544
  loop();
306
545
  id = setIntervalFn(loop, pollMs);
546
+ // SIGUSR1 also advances the view, for anyone who can already signal this
547
+ // process (`kill -USR1 $(pgrep -f 'ccr.js sidecar')`). The KEY binding does
548
+ // not use it: routing a keypress through a pid read out of a writable file
549
+ // turned a cosmetic hotkey into an arbitrary-kill primitive, so the host
550
+ // writes a request file instead (see src/cycle-view.js). Cycling still never
551
+ // reaches stdin, which is the invariant that matters.
552
+ onSignal('SIGUSR1', () => { currentView += 1; tick(); });
307
553
  onSignal('SIGINT', stop);
308
554
  onSignal('SIGTERM', stop);
309
555
  return stop;
@@ -313,4 +559,12 @@ function run(opts = {}) {
313
559
  // session-switch reset and the ended/waiting/render states are the subtle
314
560
  // parts); the live loop uses `run`. The heartbeat trio is exported for tests
315
561
  // and for the VS Code launcher's `sidecarAlive` check.
316
- module.exports = { run, updateFeed, composeFrame, heartbeatTick, clearHeartbeat, sidecarAlive };
562
+ module.exports = {
563
+ run, updateFeed, composeFrame, heartbeatTick, clearHeartbeat, sidecarAlive,
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,
570
+ };
package/src/state-dir.js CHANGED
@@ -18,4 +18,47 @@ function ensureSecureDir(dir) {
18
18
  try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
19
19
  }
20
20
 
21
- module.exports = { ensureSecureDir };
21
+ /**
22
+ * Record the directory ccr was launched in, for the git pane's launch-repo
23
+ * identity (features/git-repo-identity.feature: the launch repo is the tab's
24
+ * stable identity, while the current repo follows the session).
25
+ *
26
+ * It is written rather than inferred because only the LAUNCHER knows it. The
27
+ * sidecar's own `process.cwd()` happens to be the launch directory under tmux
28
+ * and wt.exe, where the pane inherits it — but not in VS Code, where the user
29
+ * pastes the sidecar one-liner into a terminal that opened wherever the editor
30
+ * felt like. Inferring would be right most of the time and quietly wrong in the
31
+ * host that needed it most, so the launcher states it.
32
+ *
33
+ * Best-effort by the same rule as everything else in this file: a tab that
34
+ * cannot record its launch directory falls back to naming only the current
35
+ * repo, which is a smaller loss than a launcher that fails.
36
+ *
37
+ * @param {string} dir The state directory.
38
+ * @param {string} cwd The launch directory.
39
+ */
40
+ function recordLaunchDir(dir, cwd) {
41
+ const path = require('node:path');
42
+ const file = path.join(dir, 'launch-cwd');
43
+ try {
44
+ // Never write THROUGH anything but a plain file, for the two reasons the
45
+ // heartbeat write (src/sidecar.js) and the cycle counter (src/cycle-view.js)
46
+ // already guard against at their own paths in this same directory:
47
+ //
48
+ // A SYMLINK turns this into an arbitrary-file overwrite of a directory
49
+ // name — verified: a link planted at launch-cwd had its target replaced.
50
+ //
51
+ // A FIFO is worse and quieter. Opening one for write BLOCKS until a reader
52
+ // appears, and this runs in all three launchers BEFORE Claude is spawned —
53
+ // verified: `ccr` hangs forever with no output at all. That is the silent
54
+ // -abort failure this project already shipped once (scripts/launch.sh's
55
+ // nvm glob under `set -e`), arriving by a different door.
56
+ //
57
+ // Anything under <stateDir> is writable by anything running as the user, so
58
+ // this is the same trust boundary, not a new one.
59
+ try { if (!fs.lstatSync(file).isFile()) fs.rmSync(file, { force: true }); } catch { /* absent */ }
60
+ fs.writeFileSync(file, String(cwd) + '\n', { mode: 0o600 });
61
+ } catch { /* best effort */ }
62
+ }
63
+
64
+ module.exports = { ensureSecureDir, recordLaunchDir };