claude-code-runrate 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -2
- package/bin/ccr.js +235 -22
- package/package.json +4 -2
- package/scripts/launch.sh +121 -16
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/account-limits.js +21 -13
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +15 -2
- package/src/economy-model.js +3 -0
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +40 -9
- package/src/liveness.js +18 -1
- package/src/migrate.js +155 -0
- package/src/normalize.js +24 -5
- package/src/pane-blob.js +249 -0
- package/src/pane-config.js +109 -0
- package/src/rate-limits.js +12 -2
- package/src/render/economy.js +10 -2
- package/src/render/git-pane.js +345 -0
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +115 -11
- package/src/render/statusline.js +45 -4
- package/src/safe-read.js +82 -0
- package/src/sanitize.js +45 -4
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +277 -23
- package/src/state-dir.js +44 -1
- package/src/transcripts.js +31 -15
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 (
|
|
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>} */ (
|
|
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)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
*
|
|
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 = {
|
|
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
|
-
|
|
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 };
|