claude-code-runrate 0.2.2 → 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.2",
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"
@@ -0,0 +1,165 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/account-limits.js — reconcile the ACCOUNT-WIDE rate-limit meters across the
4
+ // live ccr profiles (cq/cw/ce/cr, …) so their sidecars agree.
5
+ //
6
+ // THE PROBLEM. The 5h and weekly walls are one shared account resource, but each
7
+ // ccr profile only captures them when ITS OWN Claude session renders the status
8
+ // line. Claude Code re-emits the status line per turn, not on a clock, so an idle
9
+ // profile keeps showing the numbers from its last turn. Two sidecars open
10
+ // side-by-side therefore disagree purely by capture time — the busy one is ahead,
11
+ // the idle one lags. (The model in use is irrelevant: 5h/weekly are not
12
+ // model-scoped.) We fix this by raising each meter the LOCAL profile already knows
13
+ // to the freshest value seen across sibling profiles.
14
+ //
15
+ // THE GUARD — never mix accounts. The snapshot carries no account/org id, so we
16
+ // cannot ask "same account?" directly. Instead we trust bucket IDENTITY: the
17
+ // account-wide windows (5h, weekly — the buckets with no model scope) reset on a
18
+ // per-account schedule, so at any instant every session on one account reports the
19
+ // same resets_at for them. We build an "account fingerprint" from exactly those
20
+ // buckets (key + reset instant) and merge a sibling ONLY when its fingerprint is
21
+ // byte-for-byte the local one. A different account would have to collide on every
22
+ // one of those independent reset timestamps at once (5h AND weekly) — negligible.
23
+ // A sibling from an already-rolled window has a different reset instant, so it is
24
+ // distrusted too (its used% is stale, not fresher). We never import a bucket the
25
+ // local snapshot lacks and never adopt a sibling's resets_at — we only ever raise
26
+ // the used% of a bucket the local profile is already showing. So a profile logged
27
+ // into its own account is never contaminated by another.
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const os = require('node:os');
32
+ const { parseResetsAt } = require('./burn');
33
+ const { modelScope } = require('./rate-limits');
34
+
35
+ const MAX_SNAPSHOT_BYTES = 1_000_000; // a status JSON is a few KB; bound parse/disk
36
+ const MAX_PROFILES = 32; // sanity cap on how many siblings we scan
37
+
38
+ /**
39
+ * Canonical reset instant for fingerprinting/matching — tolerant of CC reporting
40
+ * resets_at as epoch seconds or an ISO string. `null` when absent/unparseable.
41
+ * @param {any} bucket
42
+ * @returns {number | null}
43
+ */
44
+ function resetInstant(bucket) {
45
+ return bucket && bucket.resets_at != null ? parseResetsAt(bucket.resets_at) : null;
46
+ }
47
+
48
+ /**
49
+ * The same-account signal: a stable string built from the ACCOUNT-WIDE buckets
50
+ * only (no model scope), each as `key@reset`. Buckets missing a used% or a reset
51
+ * are excluded — they can't anchor trust. Returns `null` when there is nothing to
52
+ * anchor on (no usable account-wide bucket), which callers treat as "don't merge".
53
+ * @param {any} rateLimits
54
+ * @returns {string | null}
55
+ */
56
+ function accountFingerprint(rateLimits) {
57
+ if (!rateLimits || typeof rateLimits !== 'object') return null;
58
+ const parts = [];
59
+ for (const key of Object.keys(rateLimits)) {
60
+ if (modelScope(key)) continue; // model-scoped ≠ account-wide anchor
61
+ const r = rateLimits[key];
62
+ if (!r || typeof r !== 'object' || r.used_percentage == null) continue;
63
+ const at = resetInstant(r);
64
+ if (at == null) continue;
65
+ parts.push(`${key}@${at}`);
66
+ }
67
+ return parts.length ? parts.sort().join('|') : null;
68
+ }
69
+
70
+ /**
71
+ * Raise each of the local profile's meters to the freshest value seen across
72
+ * sibling profiles ON THE SAME ACCOUNT. Pure: no I/O. Returns the local rate_limits
73
+ * unchanged (same reference) when there is nothing trustworthy to merge.
74
+ *
75
+ * @param {any} localRl the local snapshot's `rate_limits`
76
+ * @param {any[]} siblingRls other profiles' `rate_limits` objects (account-untrusted)
77
+ * @returns {any} a shallow clone with used_percentage bumped where warranted, or `localRl`
78
+ */
79
+ function mergeAccountLimits(localRl, siblingRls) {
80
+ const fp = accountFingerprint(localRl);
81
+ if (!fp) return localRl; // nothing to anchor trust on
82
+ const trusted = (siblingRls || []).filter((rl) => accountFingerprint(rl) === fp);
83
+ if (!trusted.length) return localRl;
84
+
85
+ let changed = false;
86
+ /** @type {any} */
87
+ const out = {};
88
+ for (const key of Object.keys(localRl)) {
89
+ const local = localRl[key];
90
+ out[key] = local;
91
+ if (!local || typeof local !== 'object' || local.used_percentage == null) continue;
92
+ let best = Number(local.used_percentage);
93
+ if (!Number.isFinite(best)) continue;
94
+ const at = resetInstant(local);
95
+ for (const rl of trusted) {
96
+ const s = rl[key];
97
+ if (!s || typeof s !== 'object') continue;
98
+ // Same window only — a sibling whose bucket reset at a different instant is
99
+ // from a rolled (or foreign) window; its used% does not describe this one.
100
+ if (resetInstant(s) !== at) continue;
101
+ const v = Number(s.used_percentage);
102
+ if (Number.isFinite(v) && v > best) best = v;
103
+ }
104
+ if (best !== local.used_percentage) { out[key] = { ...local, used_percentage: best }; changed = true; }
105
+ }
106
+ return changed ? out : localRl;
107
+ }
108
+
109
+ /**
110
+ * Read a sibling snapshot's `rate_limits`, best-effort. Bounded read; any error
111
+ * (missing, oversized, unparseable) yields `null` so a bad sibling is simply
112
+ * skipped rather than breaking the panel.
113
+ * @param {string} file
114
+ * @returns {any | null}
115
+ */
116
+ function readSiblingRateLimits(file) {
117
+ try {
118
+ const st = fs.statSync(file);
119
+ if (!st.isFile() || st.size > MAX_SNAPSHOT_BYTES) return null;
120
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
121
+ return (j && j.rate_limits) || null;
122
+ } catch { return null; }
123
+ }
124
+
125
+ /**
126
+ * Disk wrapper: gather sibling profiles' rate limits from the ccr profile root and
127
+ * reconcile the local meters against them. Best-effort — returns `localRl` on any
128
+ * problem so it can wrap the render path without a guard at the call site.
129
+ *
130
+ * Engages ONLY for the launcher's profile layout (`~/.ccr/<profile>`): the state
131
+ * dir's parent must be `~/.ccr`. For ad-hoc `~/.ccr` or a custom CCR_STATE_DIR we
132
+ * have no sibling set to trust, so we behave exactly as before (no merge).
133
+ *
134
+ * @param {any} localRl the local snapshot's `rate_limits`
135
+ * @param {string} stateDir the local profile's state dir (CCR_STATE_DIR)
136
+ * @param {{ home?: string }} [opts]
137
+ * @returns {any}
138
+ */
139
+ function freshenAccountLimits(localRl, stateDir, opts = {}) {
140
+ try {
141
+ if (!localRl || typeof localRl !== 'object') return localRl;
142
+ const home = opts.home || os.homedir();
143
+ const root = path.dirname(path.resolve(stateDir));
144
+ if (root !== path.resolve(path.join(home, '.ccr'))) return localRl; // not a profile layout
145
+ const selfFile = path.resolve(path.join(stateDir, 'last-status.json'));
146
+
147
+ /** @type {any[]} */
148
+ const siblings = [];
149
+ for (const name of fs.readdirSync(root)) {
150
+ if (siblings.length >= MAX_PROFILES) break;
151
+ const p = path.join(root, name);
152
+ let st; try { st = fs.statSync(p); } catch { continue; }
153
+ // A sibling profile dir (~/.ccr/<name>/last-status.json) or the ad-hoc
154
+ // ~/.ccr/last-status.json file itself.
155
+ const file = st.isDirectory() ? path.join(p, 'last-status.json')
156
+ : (name === 'last-status.json' ? p : null);
157
+ if (!file || path.resolve(file) === selfFile) continue;
158
+ const rl = readSiblingRateLimits(file);
159
+ if (rl) siblings.push(rl);
160
+ }
161
+ return mergeAccountLimits(localRl, siblings);
162
+ } catch { return localRl; }
163
+ }
164
+
165
+ module.exports = { accountFingerprint, mergeAccountLimits, freshenAccountLimits };
@@ -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
@@ -9,6 +9,7 @@ const fs = require('node:fs');
9
9
  const path = require('node:path');
10
10
  const os = require('node:os');
11
11
  const { normalizeStatus } = require('./normalize');
12
+ const { freshenAccountLimits } = require('./account-limits');
12
13
  const { renderEconomy } = require('./render/economy');
13
14
  const { renderFeed } = require('./render/feed');
14
15
  const { clampVisible } = require('./render/shared');
@@ -17,6 +18,79 @@ const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcri
17
18
 
18
19
  const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
19
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
+
20
94
  // Live feed accumulator: tail the current transcript incrementally (by byte
21
95
  // offset) and roll up tool/skill events + per-session stats. Reset on session
22
96
  // switch. Best-effort — must never break the economy panel.
@@ -88,7 +162,12 @@ function composeFrame(stateDir, opts = {}) {
88
162
  try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
89
163
  let out;
90
164
  try {
91
- out = renderEconomy(normalizeStatus(state), { tick: Math.floor(now / 1000) % 2 === 0 });
165
+ // 5h/weekly are ACCOUNT-WIDE but captured per-profile, so an idle sibling's
166
+ // panel lags a busy one. Reconcile the meters against sibling profiles on the
167
+ // SAME account (see src/account-limits.js) before rendering — best-effort, and
168
+ // strictly guarded so a different account is never mixed in.
169
+ const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir) };
170
+ out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0 });
92
171
  } catch (e) {
93
172
  out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
94
173
  }
@@ -159,8 +238,14 @@ function frame() {
159
238
  * buildWtArgs) so this RIGHT pane closes first and the border sweeps left→right.
160
239
  * Side effects are injectable so the end-sweep is unit-testable.
161
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
+ *
162
246
  * @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
163
247
  * tick?: () => void, sentinelExists?: () => boolean,
248
+ * beat?: () => ('claimed' | 'yielded'), clearBeat?: () => void, onYield?: () => void,
164
249
  * setIntervalFn?: Function, setTimeoutFn?: Function,
165
250
  * clearIntervalFn?: Function, clearTimeoutFn?: Function,
166
251
  * exit?: () => void, onSignal?: (sig: string, handler: () => void) => void }} [opts]
@@ -174,6 +259,10 @@ function run(opts = {}) {
174
259
  const graceMs = opts.graceMs != null ? opts.graceMs : 200;
175
260
  const tick = opts.tick || frame;
176
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'));
177
266
  const setIntervalFn = opts.setIntervalFn || setInterval;
178
267
  const setTimeoutFn = opts.setTimeoutFn || setTimeout;
179
268
  const clearIntervalFn = opts.clearIntervalFn || clearInterval;
@@ -188,11 +277,13 @@ function run(opts = {}) {
188
277
  let id = null;
189
278
  let endTimer = null;
190
279
  let sinceRender = RENDER_MS; // render on the first loop
191
- const stop = () => {
280
+ const teardown = (/** @type {boolean} */ clearHb) => {
192
281
  if (id != null) clearIntervalFn(id);
193
282
  if (endTimer != null) clearTimeoutFn(endTimer);
283
+ if (clearHb) clearBeat();
194
284
  exit();
195
285
  };
286
+ const stop = () => teardown(true);
196
287
  const checkEnd = () => {
197
288
  // Once the session has ended, paint it once then sweep this pane closed.
198
289
  if (exitOnEnd && endTimer == null && sentinelExists()) {
@@ -202,7 +293,13 @@ function run(opts = {}) {
202
293
  };
203
294
  const loop = () => {
204
295
  sinceRender += pollMs;
205
- 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
+ }
206
303
  checkEnd();
207
304
  };
208
305
  loop();
@@ -214,5 +311,6 @@ function run(opts = {}) {
214
311
 
215
312
  // `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
216
313
  // session-switch reset and the ended/waiting/render states are the subtle
217
- // parts); the live loop uses `run`.
218
- 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 };