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 +14 -3
- package/package.json +1 -1
- package/scripts/launch.sh +16 -3
- package/src/account-limits.js +165 -0
- package/src/launch-vscode.js +44 -11
- package/src/sidecar.js +103 -5
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"
|
|
@@ -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 };
|
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
|
@@ -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
|
-
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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 };
|