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/CHANGELOG.md +114 -0
- package/README.md +107 -12
- package/bin/ccr.js +233 -26
- package/package.json +8 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +34 -3
- 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/history-privacy.js +435 -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 +202 -11
- package/src/migrate.js +155 -0
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +69 -1
- package/src/render/statusline.js +50 -5
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +167 -0
- package/src/sidecar.js +174 -27
- package/src/state-dir.js +61 -1
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)
|
|
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
|
|
80
|
-
// heartbeat into an arbitrary-file write of "<pid>:<ms>"
|
|
81
|
-
|
|
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
|
|
181
|
-
*
|
|
182
|
-
* the number of views, so an index that outlives a
|
|
183
|
-
* than showing nothing.
|
|
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}
|
|
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
|
|
207
|
-
const
|
|
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
|
-
|
|
210
|
-
|
|
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:
|
|
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
|
|
246
|
-
//
|
|
247
|
-
if (
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
*
|
|
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
|
};
|