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