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 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 ~250-line Gherkin parser + runner that supports
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. See [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the
110
- deliberate limits, and the API.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Bing Ho <reps-attic-riot@duck.com>",
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"
@@ -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
- d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
143
- copyToClipboard(sidecarCmd, d);
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). The temp settings file is
146
- // always removed; the "session ended" sentinel is only dropped if Claude
147
- // actually ran a failed spawn must NOT flip the sidecar to "ended".
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 stop = () => {
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) { sinceRender = 0; tick(); }
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
- module.exports = { run, updateFeed, composeFrame };
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 };