claude-code-runrate 0.2.3 → 0.2.4
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 +14 -3
- package/package.json +1 -1
- package/scripts/launch.sh +16 -3
- package/src/launch-vscode.js +44 -11
- package/src/sidecar.js +96 -4
package/README.md
CHANGED
|
@@ -79,6 +79,13 @@ trigger the split itself, so `ccr` does everything around it:
|
|
|
79
79
|
Lost the banner once Claude takes the screen? Run `ccr sidecar --hint` to reprint
|
|
80
80
|
the steps and re-copy the command.
|
|
81
81
|
|
|
82
|
+
The split is a **one-time** setup per VS Code window: an attached sidecar picks
|
|
83
|
+
each new `ccr` session up automatically, so relaunching prints a short note
|
|
84
|
+
instead of the banner. And if you do paste the one-liner into a second pane, the
|
|
85
|
+
older pane stands down by itself — there is never more than one live sidebar per
|
|
86
|
+
session. Profiles stay independent: a personal `ccr` and a work `ccr <profile>`
|
|
87
|
+
run side by side, each with its own state dir and its own sidebar.
|
|
88
|
+
|
|
82
89
|
On **Windows** this is the default inside VS Code (Windows Terminal otherwise
|
|
83
90
|
opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
|
|
84
91
|
`ccr` defaults to `tmux` (which works inside the VS Code terminal too); set
|
|
@@ -104,10 +111,14 @@ latency.)
|
|
|
104
111
|
|
|
105
112
|
This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
|
|
106
113
|
the source of truth, executed by a hand-rolled zero-dependency harness on top of
|
|
107
|
-
Node's built-in test runner — a
|
|
114
|
+
Node's built-in test runner — a single-file Gherkin parser + runner that supports
|
|
108
115
|
the practical core of the grammar and rejects everything else loudly rather than
|
|
109
|
-
mis-parsing it.
|
|
110
|
-
|
|
116
|
+
mis-parsing it. The harness is available standalone as
|
|
117
|
+
[`gherkin-node-test`](https://github.com/bingh0/gherkin-node-test) on
|
|
118
|
+
[npm](https://www.npmjs.com/package/gherkin-node-test) (that repo is the
|
|
119
|
+
canonical source; `test/gherkin.js` is a vendored copy). See
|
|
120
|
+
[`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the deliberate limits,
|
|
121
|
+
and the API.
|
|
111
122
|
|
|
112
123
|
```bash
|
|
113
124
|
npm test # node --test — harness self-tests + feature scenarios
|
package/package.json
CHANGED
package/scripts/launch.sh
CHANGED
|
@@ -78,9 +78,22 @@ tmux new-session -d -s "$SESSION" \
|
|
|
78
78
|
"$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE/exited'; sleep 2; tmux kill-session -t '$SESSION' 2>/dev/null"
|
|
79
79
|
tmux set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
|
|
80
80
|
|
|
81
|
-
# Pane 1: the live economy sidebar.
|
|
82
|
-
tmux split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" \
|
|
83
|
-
"$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '"
|
|
81
|
+
# Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
|
|
82
|
+
SIDEBAR_PANE="$(tmux split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
|
|
83
|
+
"$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
|
|
84
|
+
|
|
85
|
+
# The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
|
|
86
|
+
# or PageUp over its narrow pane drops tmux into copy-mode, which freezes the pane
|
|
87
|
+
# at a snapshot and swallows the sidecar's per-second redraws — it looks like the
|
|
88
|
+
# sidebar "got lost" (the grid keeps updating underneath; only the view is frozen).
|
|
89
|
+
# Auto-cancel copy-mode the instant this pane enters it. PANE-scoped, so every other
|
|
90
|
+
# pane — and the Claude pane's scrollback — keeps normal copy-mode. The cancel
|
|
91
|
+
# re-fires this hook with pane_in_mode=0, so the guard stops it recursing. Best-effort:
|
|
92
|
+
# pane-scoped hooks need tmux >= 3.2; older tmux just skips the guard (|| true).
|
|
93
|
+
if [ -n "$SIDEBAR_PANE" ]; then
|
|
94
|
+
tmux set-hook -p -t "$SIDEBAR_PANE" pane-mode-changed \
|
|
95
|
+
"if-shell -F '#{pane_in_mode}' 'send-keys -t $SIDEBAR_PANE -X cancel'" 2>/dev/null || true
|
|
96
|
+
fi
|
|
84
97
|
|
|
85
98
|
tmux select-pane -t "$SESSION:0.0"
|
|
86
99
|
tmux source-file -t "$SESSION" "$RUN_CONF"
|
package/src/launch-vscode.js
CHANGED
|
@@ -96,6 +96,21 @@ function copyToClipboard(text, d) {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The quiet replacement for the banner when a sidecar is ALREADY attached to
|
|
101
|
+
* this state dir (heartbeat fresh — see src/sidecar.js). Relaunching used to
|
|
102
|
+
* prompt for a new split+paste every time while every old pane on the same
|
|
103
|
+
* state dir came back to life too, so panes accumulated; an attached sidecar
|
|
104
|
+
* picks the new session up by itself, so all the user needs is one line.
|
|
105
|
+
* @param {{ hintCmd: string, color: boolean }} o
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
function buildAttachedNote(o) {
|
|
109
|
+
const c = o.color ? (/** @type {string} */ code, /** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m` : (/** @type {string} */ _code, /** @type {string} */ s) => s;
|
|
110
|
+
return '\n' + c('1', ' ccr') + c('2', ` · live sidecar already attached — it picks this session up automatically.`)
|
|
111
|
+
+ '\n' + c('2', ` (split steps again: ${o.hintCmd})`) + '\n\n';
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
/**
|
|
100
115
|
* `ccr [profile]` inside a VS Code integrated terminal: wire the split-view
|
|
101
116
|
* sidecar, then run Claude in the current pane. Returns Claude's exit code.
|
|
@@ -136,17 +151,29 @@ function run(profile, deps = {}) {
|
|
|
136
151
|
|
|
137
152
|
// Show the split instructions + copy the sidecar one-liner BEFORE Claude takes
|
|
138
153
|
// over the pane (the clipboard + hint make it recoverable once it scrolls off).
|
|
154
|
+
// Unless a sidecar is already attached to this state dir: it revives on its
|
|
155
|
+
// own once the exited sentinel is cleared above, and re-prompting the split
|
|
156
|
+
// every relaunch is exactly what piled up duplicate panes.
|
|
139
157
|
const ccrBin = d.which('ccr');
|
|
140
158
|
const sidecarCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
|
|
141
159
|
const hintCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
if (d.sidecarAlive(st.stateDir)) {
|
|
161
|
+
d.out(buildAttachedNote({ hintCmd, color: d.color }));
|
|
162
|
+
} else {
|
|
163
|
+
d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
|
|
164
|
+
copyToClipboard(sidecarCmd, d);
|
|
165
|
+
}
|
|
144
166
|
|
|
145
|
-
// Run Claude in the current pane (blocks until exit).
|
|
146
|
-
//
|
|
147
|
-
//
|
|
167
|
+
// Run Claude in the current pane (blocks until exit). CCR_STATE_DIR rides the
|
|
168
|
+
// spawn env so the statusline subprocess (a grandchild via Claude) snapshots
|
|
169
|
+
// into THIS profile's state dir — without it a profile session writes to the
|
|
170
|
+
// default ~/.ccr, starving its own sidecar and clobbering a concurrently
|
|
171
|
+
// running bare session's (the wt.exe launcher injects the same var per pane).
|
|
172
|
+
// The temp settings file is always removed; the "session ended" sentinel is
|
|
173
|
+
// only dropped if Claude actually ran — a failed spawn must NOT flip the
|
|
174
|
+
// sidecar to "ended".
|
|
148
175
|
const parts = st.ccCmd.split(' ');
|
|
149
|
-
const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile]);
|
|
176
|
+
const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile], { CCR_STATE_DIR: st.stateDir });
|
|
150
177
|
d.cleanup(settingsFile);
|
|
151
178
|
if (r && r.error) { d.err(`ccr: failed to launch Claude: ${r.error.message}\n`); return 1; }
|
|
152
179
|
d.dropExited(st.stateDir);
|
|
@@ -215,15 +242,17 @@ function buildClaudeSpawn(bin, args, o) {
|
|
|
215
242
|
*
|
|
216
243
|
* @param {string} bin
|
|
217
244
|
* @param {string[]} args
|
|
245
|
+
* @param {Record<string, string>} [extraEnv] merged over process.env (CCR_STATE_DIR)
|
|
218
246
|
* @returns {{ status: number|null, error?: Error }}
|
|
219
247
|
*/
|
|
220
|
-
function defaultSpawnClaude(bin, args) {
|
|
248
|
+
function defaultSpawnClaude(bin, args, extraEnv) {
|
|
221
249
|
const built = buildClaudeSpawn(bin, args, { platform: process.platform, which: defaultWhich });
|
|
222
250
|
if ('error' in built) return { status: null, error: built.error };
|
|
223
251
|
const { spawnSync } = require('node:child_process');
|
|
252
|
+
const env = { ...process.env, ...extraEnv };
|
|
224
253
|
return built.shell
|
|
225
|
-
? spawnSync(built.command, { stdio: 'inherit', shell: true })
|
|
226
|
-
: spawnSync(built.command, built.args || [], { stdio: 'inherit' });
|
|
254
|
+
? spawnSync(built.command, { stdio: 'inherit', shell: true, env })
|
|
255
|
+
: spawnSync(built.command, built.args || [], { stdio: 'inherit', env });
|
|
227
256
|
}
|
|
228
257
|
|
|
229
258
|
/** @param {string} name @returns {string|null} */
|
|
@@ -260,6 +289,9 @@ function withDefaults(deps) {
|
|
|
260
289
|
cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
|
|
261
290
|
spawnClaude: deps.spawnClaude || defaultSpawnClaude,
|
|
262
291
|
spawnCopy: deps.spawnCopy || ((cmd, args, input) => require('node:child_process').spawnSync(cmd, args, { input, stdio: ['pipe', 'ignore', 'ignore'] })),
|
|
292
|
+
// Lazy require: the heartbeat check single-sources file name + freshness in
|
|
293
|
+
// src/sidecar.js without loading the render stack on the launch path.
|
|
294
|
+
sidecarAlive: deps.sidecarAlive || ((dir) => require('./sidecar').sidecarAlive(dir)),
|
|
263
295
|
};
|
|
264
296
|
}
|
|
265
297
|
|
|
@@ -281,8 +313,9 @@ function withDefaults(deps) {
|
|
|
281
313
|
* @property {(dir: string) => void} dropExited
|
|
282
314
|
* @property {(settings: object) => string} writeSettings
|
|
283
315
|
* @property {(file: string) => void} cleanup
|
|
284
|
-
* @property {(bin: string, args: string[]) => {status: number|null, error?: Error}} spawnClaude
|
|
316
|
+
* @property {(bin: string, args: string[], extraEnv?: Record<string, string>) => {status: number|null, error?: Error}} spawnClaude
|
|
285
317
|
* @property {(cmd: string, args: string[], input: string) => {status: number|null, error?: Error}} spawnCopy
|
|
318
|
+
* @property {(stateDir: string) => boolean} sidecarAlive
|
|
286
319
|
*/
|
|
287
320
|
|
|
288
|
-
module.exports = { splitKeybinding, sidecarPasteCommand, osc52, buildBanner, copyToClipboard, buildClaudeSpawn, run, hint };
|
|
321
|
+
module.exports = { splitKeybinding, sidecarPasteCommand, osc52, buildBanner, buildAttachedNote, copyToClipboard, buildClaudeSpawn, run, hint };
|
package/src/sidecar.js
CHANGED
|
@@ -18,6 +18,79 @@ const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcri
|
|
|
18
18
|
|
|
19
19
|
const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
|
|
20
20
|
|
|
21
|
+
// Single-instance heartbeat: each live sidecar re-claims <stateDir>/sidecar-alive
|
|
22
|
+
// roughly once a second with a "<pid>:<startMs>" nonce. Two readers use it:
|
|
23
|
+
// - the VS Code launcher skips the split+paste banner while the file is fresh
|
|
24
|
+
// (an attached sidecar picks the new session up by itself once the launcher
|
|
25
|
+
// clears the exited sentinel — see launch-vscode.js), so relaunching stops
|
|
26
|
+
// minting duplicate panes;
|
|
27
|
+
// - an older sidecar that sees a NEWER nonce yields its pane (see run()), so
|
|
28
|
+
// pasting the one-liner twice still converges to a single live panel.
|
|
29
|
+
const HEARTBEAT_FILE = 'sidecar-alive';
|
|
30
|
+
// "Fresh" = beaten within this window. Beats land ~1s apart; 5s tolerates a
|
|
31
|
+
// busy machine without ever mistaking a dead pane (minutes old) for live.
|
|
32
|
+
const HEARTBEAT_FRESH_MS = 5000;
|
|
33
|
+
|
|
34
|
+
/** @param {string} s @returns {{ pid: number, start: number } | null} */
|
|
35
|
+
function parseNonce(s) {
|
|
36
|
+
const m = /^(\d+):(\d+)$/.exec(s.trim());
|
|
37
|
+
return m ? { pid: Number(m[1]), start: Number(m[2]) } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One heartbeat: re-claim the file with our nonce, unless a NEWER sidecar
|
|
42
|
+
* (later start; higher pid breaks a same-millisecond tie) holds it — then
|
|
43
|
+
* yield WITHOUT writing, so the newer panel's claim is never clobbered and
|
|
44
|
+
* exactly one of the two keeps beating. Unreadable or unparseable content is
|
|
45
|
+
* claimed over (a garbage file must not wedge the panel), and any fs error
|
|
46
|
+
* claims rather than kills the loop — the heartbeat is strictly best-effort.
|
|
47
|
+
* @param {string} stateDir @param {string} nonce
|
|
48
|
+
* @returns {'claimed' | 'yielded'}
|
|
49
|
+
*/
|
|
50
|
+
function heartbeatTick(stateDir, nonce) {
|
|
51
|
+
const file = path.join(stateDir, HEARTBEAT_FILE);
|
|
52
|
+
const mine = parseNonce(nonce);
|
|
53
|
+
try {
|
|
54
|
+
let cur = '';
|
|
55
|
+
try { cur = fs.readFileSync(file, 'utf8'); } catch { /* no heartbeat yet */ }
|
|
56
|
+
const other = cur && cur.trim() !== nonce ? parseNonce(cur) : null;
|
|
57
|
+
if (other && mine && (other.start > mine.start || (other.start === mine.start && other.pid > mine.pid))) {
|
|
58
|
+
return 'yielded';
|
|
59
|
+
}
|
|
60
|
+
fs.writeFileSync(file, nonce);
|
|
61
|
+
} catch { /* best-effort */ }
|
|
62
|
+
return 'claimed';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove the heartbeat on the way out — but only while it still holds OUR
|
|
67
|
+
* nonce; after a takeover the file belongs to the newer sidecar.
|
|
68
|
+
* @param {string} stateDir @param {string} nonce
|
|
69
|
+
*/
|
|
70
|
+
function clearHeartbeat(stateDir, nonce) {
|
|
71
|
+
const file = path.join(stateDir, HEARTBEAT_FILE);
|
|
72
|
+
try {
|
|
73
|
+
if (fs.readFileSync(file, 'utf8').trim() === nonce) fs.rmSync(file, { force: true });
|
|
74
|
+
} catch { /* already gone / unreadable — nothing to clear */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Is a sidecar attached to this state dir right now? Mtime-based, so a killed
|
|
79
|
+
* pane (whose stale file nobody cleared) reads as dead within seconds. Used by
|
|
80
|
+
* the VS Code launcher to print "already attached" instead of the split banner.
|
|
81
|
+
* @param {string} stateDir @param {{ now?: number, freshMs?: number }} [opts]
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
function sidecarAlive(stateDir, opts = {}) {
|
|
85
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
86
|
+
const freshMs = opts.freshMs != null ? opts.freshMs : HEARTBEAT_FRESH_MS;
|
|
87
|
+
try {
|
|
88
|
+
return now - fs.statSync(path.join(stateDir, HEARTBEAT_FILE)).mtimeMs <= freshMs;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
21
94
|
// Live feed accumulator: tail the current transcript incrementally (by byte
|
|
22
95
|
// offset) and roll up tool/skill events + per-session stats. Reset on session
|
|
23
96
|
// switch. Best-effort — must never break the economy panel.
|
|
@@ -165,8 +238,14 @@ function frame() {
|
|
|
165
238
|
* buildWtArgs) so this RIGHT pane closes first and the border sweeps left→right.
|
|
166
239
|
* Side effects are injectable so the end-sweep is unit-testable.
|
|
167
240
|
*
|
|
241
|
+
* A second sidecar pasted against the same state dir takes the heartbeat over
|
|
242
|
+
* (its nonce is newer); this one then paints a hand-off note and exits WITHOUT
|
|
243
|
+
* clearing the file — it now belongs to the newer panel. `beat`/`clearBeat`/
|
|
244
|
+
* `onYield` are injectable so the takeover is unit-testable too.
|
|
245
|
+
*
|
|
168
246
|
* @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
|
|
169
247
|
* tick?: () => void, sentinelExists?: () => boolean,
|
|
248
|
+
* beat?: () => ('claimed' | 'yielded'), clearBeat?: () => void, onYield?: () => void,
|
|
170
249
|
* setIntervalFn?: Function, setTimeoutFn?: Function,
|
|
171
250
|
* clearIntervalFn?: Function, clearTimeoutFn?: Function,
|
|
172
251
|
* exit?: () => void, onSignal?: (sig: string, handler: () => void) => void }} [opts]
|
|
@@ -180,6 +259,10 @@ function run(opts = {}) {
|
|
|
180
259
|
const graceMs = opts.graceMs != null ? opts.graceMs : 200;
|
|
181
260
|
const tick = opts.tick || frame;
|
|
182
261
|
const sentinelExists = opts.sentinelExists || (() => fs.existsSync(path.join(stateDir, 'exited')));
|
|
262
|
+
const nonce = `${process.pid}:${Date.now()}`;
|
|
263
|
+
const beat = opts.beat || (() => heartbeatTick(stateDir, nonce));
|
|
264
|
+
const clearBeat = opts.clearBeat || (() => clearHeartbeat(stateDir, nonce));
|
|
265
|
+
const onYield = opts.onYield || (() => draw(bold('ccr') + ' ' + dim('another sidecar attached — this pane stood down') + '\n'));
|
|
183
266
|
const setIntervalFn = opts.setIntervalFn || setInterval;
|
|
184
267
|
const setTimeoutFn = opts.setTimeoutFn || setTimeout;
|
|
185
268
|
const clearIntervalFn = opts.clearIntervalFn || clearInterval;
|
|
@@ -194,11 +277,13 @@ function run(opts = {}) {
|
|
|
194
277
|
let id = null;
|
|
195
278
|
let endTimer = null;
|
|
196
279
|
let sinceRender = RENDER_MS; // render on the first loop
|
|
197
|
-
const
|
|
280
|
+
const teardown = (/** @type {boolean} */ clearHb) => {
|
|
198
281
|
if (id != null) clearIntervalFn(id);
|
|
199
282
|
if (endTimer != null) clearTimeoutFn(endTimer);
|
|
283
|
+
if (clearHb) clearBeat();
|
|
200
284
|
exit();
|
|
201
285
|
};
|
|
286
|
+
const stop = () => teardown(true);
|
|
202
287
|
const checkEnd = () => {
|
|
203
288
|
// Once the session has ended, paint it once then sweep this pane closed.
|
|
204
289
|
if (exitOnEnd && endTimer == null && sentinelExists()) {
|
|
@@ -208,7 +293,13 @@ function run(opts = {}) {
|
|
|
208
293
|
};
|
|
209
294
|
const loop = () => {
|
|
210
295
|
sinceRender += pollMs;
|
|
211
|
-
if (sinceRender >= RENDER_MS) {
|
|
296
|
+
if (sinceRender >= RENDER_MS) {
|
|
297
|
+
sinceRender = 0;
|
|
298
|
+
tick();
|
|
299
|
+
// Beat at render cadence (~1s). A newer sidecar owns the dir now →
|
|
300
|
+
// hand the state dir over and fold this pane, leaving ITS heartbeat.
|
|
301
|
+
if (beat() === 'yielded') { onYield(); teardown(false); return; }
|
|
302
|
+
}
|
|
212
303
|
checkEnd();
|
|
213
304
|
};
|
|
214
305
|
loop();
|
|
@@ -220,5 +311,6 @@ function run(opts = {}) {
|
|
|
220
311
|
|
|
221
312
|
// `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
|
|
222
313
|
// session-switch reset and the ended/waiting/render states are the subtle
|
|
223
|
-
// parts); the live loop uses `run`.
|
|
224
|
-
|
|
314
|
+
// parts); the live loop uses `run`. The heartbeat trio is exported for tests
|
|
315
|
+
// and for the VS Code launcher's `sidecarAlive` check.
|
|
316
|
+
module.exports = { run, updateFeed, composeFrame, heartbeatTick, clearHeartbeat, sidecarAlive };
|