claude-code-runrate 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -10
- package/bin/ccr.js +223 -26
- package/package.json +4 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +13 -2
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +40 -9
- package/src/migrate.js +155 -0
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +49 -1
- package/src/render/statusline.js +42 -4
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +145 -20
- package/src/state-dir.js +44 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/instance-slot.js — choose the state dir + session name for a BARE `ccr`,
|
|
4
|
+
// so opening a second terminal and typing `ccr` just works.
|
|
5
|
+
//
|
|
6
|
+
// THE PROBLEM. Bare `ccr` used to hardcode all three of its namespaces: session
|
|
7
|
+
// "ccr", tmux socket "ccr", state dir ~/.ccr. A second bare launch therefore
|
|
8
|
+
// 1. ran the launcher's "clean re-launch" kill-session against the FIRST
|
|
9
|
+
// instance's session, tearing down a live Claude in another terminal;
|
|
10
|
+
// 2. shared last-status.json and the `exited` sentinel, so each sidebar showed
|
|
11
|
+
// whichever session ticked last, and quitting one flipped the other to
|
|
12
|
+
// "session ended";
|
|
13
|
+
// 3. collided on the sidecar-alive heartbeat, whose newer-wins rule then stood
|
|
14
|
+
// the first sidebar down.
|
|
15
|
+
// Passing a CCS profile avoided all three by deriving a per-profile namespace —
|
|
16
|
+
// but that requires CCS, which a plain Claude Code user does not have.
|
|
17
|
+
//
|
|
18
|
+
// THE FIX (0.4.0 layout — features/instance-lifecycle.feature). EVERY launch,
|
|
19
|
+
// bare or profiled, takes the lowest FREE slot: state dir ~/.ccr/instances/<n>,
|
|
20
|
+
// session "ccr" for slot 1 (the historical session name) and "ccr-<n>" above.
|
|
21
|
+
// ~/.ccr itself is a CONTAINER now, never a state dir — the old root cause was
|
|
22
|
+
// a path that was simultaneously the container for all instances and instance
|
|
23
|
+
// 1's own state dir, which no guard or scan could treat uniformly. Profiles
|
|
24
|
+
// slot too: their old per-profile namespace ("ccr-<profile>") meant two
|
|
25
|
+
// launches of the SAME profile collided exactly the way two bare launches did.
|
|
26
|
+
// scripts/launch.sh needs no change at all: it already derives session, socket
|
|
27
|
+
// and state dir from CCR_SESSION / CCR_STATE_DIR, so handing it those two
|
|
28
|
+
// values namespaces the whole instance.
|
|
29
|
+
//
|
|
30
|
+
// Instances are EPHEMERAL: a polite exit deletes the instance dir (unless a
|
|
31
|
+
// sidebar is still attached — a live process reading the dir is never deleted
|
|
32
|
+
// under; the sweep collects the dir once that process is gone too), and each
|
|
33
|
+
// launch sweeps dirs whose recorded process no longer exists. The sidecar
|
|
34
|
+
// heartbeat's staleness is DISPLAY-ONLY and never a deletion trigger: suspend,
|
|
35
|
+
// swap and Ctrl-Z all silence a live session's heartbeat for far longer than
|
|
36
|
+
// its 5s window (src/sidecar.js), and deleting a running session's state dir
|
|
37
|
+
// is the one unrecoverable mistake here. Deletion needs a dead process.
|
|
38
|
+
//
|
|
39
|
+
// WHO OWNS A SLOT is the part that has to be exactly right, because getting it
|
|
40
|
+
// wrong hands a second launch the namespace of a session that is still running —
|
|
41
|
+
// which is bug (1) above, the very thing this module exists to prevent.
|
|
42
|
+
//
|
|
43
|
+
// The owner is THE LAUNCHER PROCESS, recorded as a pid in <stateDir>/slot-owner.
|
|
44
|
+
// That is the only signal that tracks the SESSION rather than some artifact of
|
|
45
|
+
// it: `ccr` blocks for the session's whole lifetime on both hosts that matter
|
|
46
|
+
// (tmux, where launch.sh ends in `tmux attach`; and VS Code, where it spawns
|
|
47
|
+
// Claude in the current pane), so "is that pid alive" is exactly "is that
|
|
48
|
+
// session running". An earlier draft used the sidebar's heartbeat instead, and
|
|
49
|
+
// closing or crashing the sidebar pane then freed a slot out from under a live
|
|
50
|
+
// Claude — the heartbeat tracks the SIDEBAR, which is a different question.
|
|
51
|
+
//
|
|
52
|
+
// Two signals, not one, because neither alone is total:
|
|
53
|
+
//
|
|
54
|
+
// owner pid alive → LIVE. Covers a session whose sidebar was
|
|
55
|
+
// closed, and every pre-sidebar moment of
|
|
56
|
+
// startup, with no timing window at all.
|
|
57
|
+
// heartbeat fresh, no `exited` → LIVE. Covers a session whose launcher is
|
|
58
|
+
// gone but which is still running: a
|
|
59
|
+
// detached tmux client, and native Windows,
|
|
60
|
+
// where the launcher exits once wt.exe has
|
|
61
|
+
// the window.
|
|
62
|
+
// heartbeat fresh, `exited` present→ ATTACHED. The session is over but its
|
|
63
|
+
// sidebar is still up. Free to take, and
|
|
64
|
+
// the caller is TOLD, because the VS Code
|
|
65
|
+
// sidebar deliberately outlives its session
|
|
66
|
+
// to pick the next one up — skipping to
|
|
67
|
+
// another slot would strand that pane and
|
|
68
|
+
// re-break the duplicate-pane fix.
|
|
69
|
+
// otherwise → FREE.
|
|
70
|
+
//
|
|
71
|
+
// RESERVING is an exclusive create of the owner file, uniformly — a free slot
|
|
72
|
+
// and an attached one alike. An earlier draft skipped the exclusive create when
|
|
73
|
+
// reusing an attached slot, and since a stale `exited` sentinel outlives every
|
|
74
|
+
// normal session (launch.sh writes it on exit and only the NEXT launch clears
|
|
75
|
+
// it), two launchers starting together both read "attached" and both took the
|
|
76
|
+
// same slot. Reserving always, and never inferring ownership from a file the
|
|
77
|
+
// previous session left behind, is what closes that.
|
|
78
|
+
//
|
|
79
|
+
// The heartbeat is left strictly alone here: it belongs to the sidecar, and
|
|
80
|
+
// writing a newer nonce into it is precisely what makes a live sidebar stand
|
|
81
|
+
// down (see heartbeatTick in src/sidecar.js).
|
|
82
|
+
//
|
|
83
|
+
// A crashed launcher leaves a stale owner file, which the next probe clears
|
|
84
|
+
// because its pid is gone — so slots are REUSED rather than minted, and
|
|
85
|
+
// relaunching after a crash lands back on slot 1 instead of drifting to
|
|
86
|
+
// instances/47. A recycled pid can only make a slot look BUSY, never free, so the
|
|
87
|
+
// worst it costs is a slot number.
|
|
88
|
+
//
|
|
89
|
+
// KNOWN LIMITS, none of them worse than the behavior that predated slots:
|
|
90
|
+
// - Native Windows has no owning process — the launcher returns as soon as
|
|
91
|
+
// wt.exe owns the window — so there a slot is unguarded until pane 1's
|
|
92
|
+
// sidecar starts beating, about a second.
|
|
93
|
+
// - The check that a slot's directory is real (defaultDirUsable) narrows a
|
|
94
|
+
// symlink swap rather than sealing it; Node exposes no openat/O_NOFOLLOW to
|
|
95
|
+
// do better, and the attacker there is already running as the user.
|
|
96
|
+
// - A same-uid process that plants `exited` beside a live session's heartbeat
|
|
97
|
+
// can make that slot look ATTACHED once the owning launcher is gone (a
|
|
98
|
+
// detached tmux session). It could equally kill-session that instance
|
|
99
|
+
// outright, so this grants nothing new.
|
|
100
|
+
|
|
101
|
+
const fs = require('node:fs');
|
|
102
|
+
const path = require('node:path');
|
|
103
|
+
const os = require('node:os');
|
|
104
|
+
const { ensureSecureDir } = require('./state-dir');
|
|
105
|
+
|
|
106
|
+
// The live-instance cap, and it is not arbitrary: cross-instance meter
|
|
107
|
+
// reconciliation caps sibling merges at MAX_PROFILES=32 (src/account-limits.js),
|
|
108
|
+
// so a 33rd live instance would silently under-report the shared 5h/weekly
|
|
109
|
+
// meters — and WHICH 32 win would be readdir order. The 33rd launch is refused
|
|
110
|
+
// instead of falling back to a shared namespace: the old fallback target was
|
|
111
|
+
// the container itself, and sharing it was the reported bug.
|
|
112
|
+
const MAX_SLOTS = 32;
|
|
113
|
+
|
|
114
|
+
// Bounds every directory walk under the container, so a pathological state dir
|
|
115
|
+
// cannot spin the launcher. Mirrors MAX_SCAN_ENTRIES in src/account-limits.js.
|
|
116
|
+
const MAX_SWEEP_ENTRIES = 512;
|
|
117
|
+
|
|
118
|
+
// Holds the launcher's "<pid>:<startedMs>". Deliberately NOT the heartbeat file:
|
|
119
|
+
// that one is the sidecar's, and answers a different question (see the header).
|
|
120
|
+
const OWNER_FILE = 'slot-owner';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Where slot `n` lives: ~/.ccr/instances/<n>, uniformly — the container/member
|
|
124
|
+
* split that is the whole point of the 0.4.0 layout. Slot 1 keeps the
|
|
125
|
+
* historical SESSION name "ccr" (so `tmux -L ccr attach` still works for a
|
|
126
|
+
* lone instance) but its state dir is a member like every other slot's.
|
|
127
|
+
*
|
|
128
|
+
* @param {number} n
|
|
129
|
+
* @param {string} home
|
|
130
|
+
* @returns {{ slot: number, session: string, stateDir: string }}
|
|
131
|
+
*/
|
|
132
|
+
function slotPaths(n, home) {
|
|
133
|
+
return {
|
|
134
|
+
slot: n,
|
|
135
|
+
session: n === 1 ? 'ccr' : `ccr-${n}`,
|
|
136
|
+
stateDir: path.join(home, '.ccr', 'instances', String(n)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The pid recorded in a slot's owner file, or null when there isn't one.
|
|
142
|
+
* @param {string} dir
|
|
143
|
+
* @returns {number | null}
|
|
144
|
+
*/
|
|
145
|
+
function ownerPid(dir) {
|
|
146
|
+
try {
|
|
147
|
+
const m = /^(\d+):/.exec(fs.readFileSync(path.join(dir, OWNER_FILE), 'utf8').trim());
|
|
148
|
+
return m ? Number(m[1]) : null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Does that process still exist? Signal 0 checks without delivering anything.
|
|
156
|
+
* EPERM means it exists under another uid — alive for our purposes, and the
|
|
157
|
+
* safe answer either way, since a false "alive" only costs a slot number while a
|
|
158
|
+
* false "dead" would hand this slot to a second session.
|
|
159
|
+
*
|
|
160
|
+
* @param {number} pid
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
function pidAlive(pid) {
|
|
164
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, 0);
|
|
167
|
+
return true;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return /** @type {NodeJS.ErrnoException} */ (e).code === 'EPERM';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Classify a slot. See the header for why it takes two signals.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} dir
|
|
177
|
+
* @returns {{ live: boolean, attached: boolean }} `live` bars the slot;
|
|
178
|
+
* `attached` means a sidebar is waiting there to be reused rather than
|
|
179
|
+
* re-split.
|
|
180
|
+
*/
|
|
181
|
+
function defaultInspect(dir) {
|
|
182
|
+
const pid = ownerPid(dir);
|
|
183
|
+
if (pid !== null && pidAlive(pid)) return { live: true, attached: false };
|
|
184
|
+
// Lazy require: keeps the render stack off the launch path, and single-sources
|
|
185
|
+
// the heartbeat's freshness window in src/sidecar.js.
|
|
186
|
+
if (!require('./sidecar').sidecarAlive(dir)) return { live: false, attached: false };
|
|
187
|
+
let ended = false;
|
|
188
|
+
try { fs.statSync(path.join(dir, 'exited')); ended = true; } catch { /* still running */ }
|
|
189
|
+
return { live: !ended, attached: ended };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Take ownership of a slot the caller has already found free. Returns false only
|
|
194
|
+
* when another launcher got there first, which is what makes the probe
|
|
195
|
+
* race-free: the loser moves on to the next slot.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} dir
|
|
198
|
+
* @returns {boolean} true when the slot is ours
|
|
199
|
+
*/
|
|
200
|
+
function defaultReserve(dir) {
|
|
201
|
+
const file = path.join(dir, OWNER_FILE);
|
|
202
|
+
const pid = ownerPid(dir);
|
|
203
|
+
if (pid === null || !pidAlive(pid)) {
|
|
204
|
+
// A launcher that is gone: clear its record so the create below can succeed,
|
|
205
|
+
// or the first crash would retire this slot for good. rmSync does not follow
|
|
206
|
+
// a symlink — it removes the link itself — so this cannot reach outside.
|
|
207
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
// 'wx' is O_CREAT|O_EXCL: it fails if anything already occupies the path,
|
|
211
|
+
// including a symlink (even a dangling one), so this can never write through
|
|
212
|
+
// a planted link, and two launchers cannot both create it.
|
|
213
|
+
const fd = fs.openSync(file, 'wx', 0o600);
|
|
214
|
+
try { fs.writeSync(fd, `${process.pid}:${Date.now()}`); } finally { fs.closeSync(fd); }
|
|
215
|
+
return true;
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Give a slot back when the session ends. Purely an optimisation — the pid check
|
|
223
|
+
* already reclaims a slot whose launcher is gone — so it only ever removes a
|
|
224
|
+
* record that is still OURS, and never fails loudly.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} dir
|
|
227
|
+
*/
|
|
228
|
+
function releaseSlot(dir) {
|
|
229
|
+
try {
|
|
230
|
+
if (ownerPid(dir) === process.pid) fs.rmSync(path.join(dir, OWNER_FILE), { force: true });
|
|
231
|
+
} catch { /* best effort */ }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Is this numbered slot's directory safe to use? ccr picks these paths with no
|
|
236
|
+
* user input, so an attacker who plants a symlink at ~/.ccr/<n> would otherwise
|
|
237
|
+
* redirect a whole instance's state — `mkdirSync` succeeds on a symlink to a
|
|
238
|
+
* directory and `chmodSync` follows it, so ensureSecureDir would chmod 0700 and
|
|
239
|
+
* write into wherever it points. An existing entry must therefore be a real
|
|
240
|
+
* directory. Every slot is checked uniformly — under the 0.4.0 layout slot 1
|
|
241
|
+
* is an ordinary member of instances/ like any other.
|
|
242
|
+
*
|
|
243
|
+
* This narrows the window rather than sealing it: a same-uid process can still
|
|
244
|
+
* swap the directory between this check and the writes that follow. Node has no
|
|
245
|
+
* openat/O_NOFOLLOW to close that properly.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} dir
|
|
248
|
+
* @returns {boolean}
|
|
249
|
+
*/
|
|
250
|
+
function defaultDirUsable(dir) {
|
|
251
|
+
try {
|
|
252
|
+
const st = fs.lstatSync(dir);
|
|
253
|
+
return st.isDirectory();
|
|
254
|
+
} catch {
|
|
255
|
+
return true; // absent — ensureDir will create it
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Fill in real-environment implementations for anything the caller didn't
|
|
261
|
+
* inject, so allocateSlot's decision logic can be unit-tested with pure
|
|
262
|
+
* stand-ins. Mirrors the withDefaults pattern in launch-win.js.
|
|
263
|
+
*
|
|
264
|
+
* @param {Partial<Deps>} deps
|
|
265
|
+
* @returns {Deps}
|
|
266
|
+
*/
|
|
267
|
+
function withDefaults(deps) {
|
|
268
|
+
const home = deps.home || os.homedir();
|
|
269
|
+
return {
|
|
270
|
+
env: deps.env || process.env,
|
|
271
|
+
home,
|
|
272
|
+
inspect: deps.inspect || defaultInspect,
|
|
273
|
+
reserve: deps.reserve || defaultReserve,
|
|
274
|
+
dirUsable: deps.dirUsable || defaultDirUsable,
|
|
275
|
+
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
276
|
+
removeDir: deps.removeDir
|
|
277
|
+
|| ((/** @type {string} */ dir) => { fs.rmSync(dir, { recursive: true, force: true }); }),
|
|
278
|
+
listDir: deps.listDir
|
|
279
|
+
|| ((/** @type {string} */ dir) => { try { return fs.readdirSync(dir); } catch { return []; } }),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Delete dead instances' directories — disk housekeeping, demoted by ruling
|
|
285
|
+
* from any correctness role (features/instance-lifecycle.feature: "A quiet
|
|
286
|
+
* heartbeat alone never triggers deletion"). A dir goes only when its recorded
|
|
287
|
+
* process no longer exists AND no sidebar is attached: `inspect` already
|
|
288
|
+
* answers both, and an ATTACHED dir (session over, sidebar waiting to be
|
|
289
|
+
* reused) is skipped because a live process is still reading it — it is
|
|
290
|
+
* collected on a later sweep, once that sidebar is gone too.
|
|
291
|
+
*
|
|
292
|
+
* Scoped hard: only numeric entries directly under <home>/.ccr/instances, and
|
|
293
|
+
* never through a symlink — everything here runs as the user against paths the
|
|
294
|
+
* user did not type. A dir written to in the last minute is skipped (minAgeMs):
|
|
295
|
+
* on native Windows the launcher exits once wt.exe owns the window, so a
|
|
296
|
+
* starting instance has no owner pid and no heartbeat for about a second, and
|
|
297
|
+
* an idle session whose sidebar was closed has neither signal at all — its
|
|
298
|
+
* status captures keep the dir's mtime moving while it renders, and declining
|
|
299
|
+
* to delete a recently-written dir is the safe direction either way.
|
|
300
|
+
*
|
|
301
|
+
* @param {Partial<Deps> & { minAgeMs?: number, now?: number }} [opts]
|
|
302
|
+
* @returns {number} how many instance dirs were removed
|
|
303
|
+
*/
|
|
304
|
+
function sweepDeadInstances(opts = {}) {
|
|
305
|
+
const d = withDefaults(opts);
|
|
306
|
+
const root = path.join(d.home, '.ccr', 'instances');
|
|
307
|
+
const minAge = opts.minAgeMs != null ? opts.minAgeMs : 60_000;
|
|
308
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
309
|
+
let removed = 0, seen = 0;
|
|
310
|
+
for (const name of d.listDir(root)) {
|
|
311
|
+
if (++seen > MAX_SWEEP_ENTRIES) break;
|
|
312
|
+
if (!/^\d+$/.test(name)) continue;
|
|
313
|
+
const dir = path.join(root, name);
|
|
314
|
+
let st;
|
|
315
|
+
try { st = fs.lstatSync(dir); } catch { continue; }
|
|
316
|
+
if (!st.isDirectory()) continue;
|
|
317
|
+
// minAge 0 disables the guard outright: a freshly written file's mtime can
|
|
318
|
+
// land a sub-millisecond AFTER Date.now() (filesystem timestamp rounding),
|
|
319
|
+
// so `now - mtime < 0` would silently re-enable the skip.
|
|
320
|
+
if (minAge > 0 && now - st.mtimeMs < minAge) continue;
|
|
321
|
+
const { live, attached } = d.inspect(dir);
|
|
322
|
+
if (live || attached) continue;
|
|
323
|
+
// The sweep finalizes the dead instance's join key on its behalf before
|
|
324
|
+
// deleting — `swept`, stamped with the last heartbeat's mtime, the honest
|
|
325
|
+
// "ended around here" (src/session-log.js).
|
|
326
|
+
let at = st.mtimeMs;
|
|
327
|
+
try { at = fs.lstatSync(path.join(dir, 'sidecar-alive')).mtimeMs; } catch { /* no heartbeat left */ }
|
|
328
|
+
try { require('./session-log').finalizeFromDir(d.home, dir, 'swept', at); } catch { /* best effort */ }
|
|
329
|
+
try { d.removeDir(dir); removed += 1; } catch { /* best effort */ }
|
|
330
|
+
}
|
|
331
|
+
return removed;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* End-of-session cleanup: instances are ephemeral, so a polite exit deletes
|
|
336
|
+
* the whole instance dir (features/instance-lifecycle.feature: "Exiting
|
|
337
|
+
* politely deletes the instance") — UNLESS a sidebar is still attached, which
|
|
338
|
+
* is a live process reading the dir; then only the slot reservation is
|
|
339
|
+
* released and the dir survives for reuse, to be swept once the sidebar too
|
|
340
|
+
* is gone. Never touches anything outside <home>/.ccr/instances.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} dir the instance's state dir
|
|
343
|
+
* @param {Partial<Deps> & { sidecarAlive?: (dir: string) => boolean }} [opts]
|
|
344
|
+
*/
|
|
345
|
+
function retireInstance(dir, opts = {}) {
|
|
346
|
+
const d = withDefaults(opts);
|
|
347
|
+
const root = path.resolve(d.home, '.ccr', 'instances') + path.sep;
|
|
348
|
+
const alive = opts.sidecarAlive || ((/** @type {string} */ s) => require('./sidecar').sidecarAlive(s));
|
|
349
|
+
try {
|
|
350
|
+
if (!path.resolve(dir).startsWith(root)) { releaseSlot(dir); return; }
|
|
351
|
+
// The polite exit finalizes its own join key (src/session-log.js) —
|
|
352
|
+
// whether or not an attached sidebar keeps the dir alive for reuse.
|
|
353
|
+
try { require('./session-log').finalizeFromDir(d.home, dir, 'ended'); } catch { /* best effort */ }
|
|
354
|
+
if (alive(dir)) { releaseSlot(dir); return; }
|
|
355
|
+
d.removeDir(dir);
|
|
356
|
+
} catch { /* best effort */ }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Pick the namespace for this launch. EVERY launch slots — bare or profiled
|
|
361
|
+
* (a profile's old per-profile namespace let two launches of the same profile
|
|
362
|
+
* kill-session each other, the reported bug on a second path).
|
|
363
|
+
*
|
|
364
|
+
* Returns null only when CCR_SESSION or CCR_STATE_DIR is set — the user named
|
|
365
|
+
* this instance explicitly, and an explicit choice always outranks an
|
|
366
|
+
* automatic one. Returns { exhausted: true } when every slot is held by a
|
|
367
|
+
* live instance: the launch must REFUSE (features/instance-lifecycle.feature:
|
|
368
|
+
* "A thirty-third instance is refused") — the old fallback target, the shared
|
|
369
|
+
* container, is exactly the collision this module exists to prevent.
|
|
370
|
+
*
|
|
371
|
+
* @param {{ profile?: string } & Partial<Deps>} [opts]
|
|
372
|
+
* @returns {{ slot: number, session: string, stateDir: string, attached: boolean } | { exhausted: true } | null}
|
|
373
|
+
*/
|
|
374
|
+
function allocateSlot(opts = {}) {
|
|
375
|
+
const d = withDefaults(opts);
|
|
376
|
+
if (d.env.CCR_SESSION || d.env.CCR_STATE_DIR) return null;
|
|
377
|
+
|
|
378
|
+
// Ephemerality's collector: each launch clears dirs whose process is gone.
|
|
379
|
+
try { sweepDeadInstances(opts); } catch { /* housekeeping must not block a launch */ }
|
|
380
|
+
|
|
381
|
+
for (let n = 1; n <= MAX_SLOTS; n++) {
|
|
382
|
+
const p = slotPaths(n, d.home);
|
|
383
|
+
if (!d.dirUsable(p.stateDir)) continue;
|
|
384
|
+
const { live, attached } = d.inspect(p.stateDir);
|
|
385
|
+
if (live) continue;
|
|
386
|
+
// The owner file lands inside the slot dir, so it has to exist first.
|
|
387
|
+
// Creating a dir we then fail to reserve is harmless and bounded by MAX_SLOTS.
|
|
388
|
+
try { d.ensureDir(p.stateDir); } catch { continue; } // unusable slot → next
|
|
389
|
+
if (d.reserve(p.stateDir)) return { ...p, attached };
|
|
390
|
+
}
|
|
391
|
+
return { exhausted: true };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The two env vars that hand a resolved slot to every launcher — and, through
|
|
396
|
+
* scripts/launch.sh, to tmux's session and socket names.
|
|
397
|
+
*
|
|
398
|
+
* @param {NodeJS.ProcessEnv} env
|
|
399
|
+
* @param {{ session?: string, stateDir?: string, exhausted?: boolean } | null} slot
|
|
400
|
+
* @returns {NodeJS.ProcessEnv} `env` itself when there is no slot to apply
|
|
401
|
+
*/
|
|
402
|
+
function applySlotEnv(env, slot) {
|
|
403
|
+
if (!slot || slot.exhausted || !slot.session || !slot.stateDir) return env;
|
|
404
|
+
return { ...env, CCR_SESSION: slot.session, CCR_STATE_DIR: slot.stateDir };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @typedef {object} Deps
|
|
409
|
+
* @property {NodeJS.ProcessEnv} env
|
|
410
|
+
* @property {string} home
|
|
411
|
+
* @property {(dir: string) => {live: boolean, attached: boolean}} inspect
|
|
412
|
+
* @property {(dir: string) => boolean} reserve
|
|
413
|
+
* @property {(dir: string) => boolean} dirUsable
|
|
414
|
+
* @property {(dir: string) => void} ensureDir
|
|
415
|
+
* @property {(dir: string) => void} removeDir
|
|
416
|
+
* @property {(dir: string) => string[]} listDir
|
|
417
|
+
*/
|
|
418
|
+
|
|
419
|
+
module.exports = {
|
|
420
|
+
MAX_SLOTS,
|
|
421
|
+
OWNER_FILE,
|
|
422
|
+
slotPaths,
|
|
423
|
+
allocateSlot,
|
|
424
|
+
applySlotEnv,
|
|
425
|
+
releaseSlot,
|
|
426
|
+
sweepDeadInstances,
|
|
427
|
+
retireInstance,
|
|
428
|
+
defaultInspect,
|
|
429
|
+
defaultReserve,
|
|
430
|
+
defaultDirUsable,
|
|
431
|
+
ownerPid,
|
|
432
|
+
pidAlive,
|
|
433
|
+
};
|
package/src/launch-vscode.js
CHANGED
|
@@ -18,7 +18,8 @@ const path = require('node:path');
|
|
|
18
18
|
const os = require('node:os');
|
|
19
19
|
const launchWin = require('./launch-win');
|
|
20
20
|
const inject = require('./settings-inject');
|
|
21
|
-
const
|
|
21
|
+
const slots = require('./instance-slot');
|
|
22
|
+
const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* VS Code's "Split Terminal" default keybinding, per platform.
|
|
@@ -33,12 +34,20 @@ function splitKeybinding(platform) {
|
|
|
33
34
|
* The command the user runs in the split pane. Carries the resolved state dir as
|
|
34
35
|
* an explicit arg (shell-agnostic: no per-shell `set`/`$env:`/`export` needed).
|
|
35
36
|
* Prefers the `ccr` binary when on PATH; falls back to node + ccr.js by path.
|
|
37
|
+
*
|
|
38
|
+
* `--keys` rides along because THIS host binds no key. Under tmux the launcher
|
|
39
|
+
* binds F3 and tmux runs `ccr cycle-view`; VS Code offers no equivalent, and its
|
|
40
|
+
* split leaves both panes running a foreground process, so there is not even a
|
|
41
|
+
* shell prompt free to type the command into. `--keys` makes the pasted process
|
|
42
|
+
* the hotkey host and runs the panel as its child — the renderer still reads no
|
|
43
|
+
* input (src/sidecar-keys.js).
|
|
44
|
+
*
|
|
36
45
|
* @param {{ stateDir: string, ccrBin?: string|null, node: string, ccrJs: string, hint?: boolean }} o
|
|
37
46
|
* @returns {string}
|
|
38
47
|
*/
|
|
39
48
|
function sidecarPasteCommand(o) {
|
|
40
49
|
const head = o.ccrBin ? 'ccr' : `"${o.node}" "${o.ccrJs}"`;
|
|
41
|
-
const tail = o.hint ? ' --hint' : ` --state-dir "${o.stateDir}"`;
|
|
50
|
+
const tail = o.hint ? ' --hint' : ` --state-dir "${o.stateDir}" --keys`;
|
|
42
51
|
return `${head} sidecar${tail}`;
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -71,6 +80,10 @@ function buildBanner(o) {
|
|
|
71
80
|
'',
|
|
72
81
|
` ${c('1;92', o.sidecarCmd)}`,
|
|
73
82
|
'',
|
|
83
|
+
// The key is named here because this host binds none of its own: nothing
|
|
84
|
+
// else on screen would tell the user the second view exists.
|
|
85
|
+
c('2', ' Then click that pane and press ') + c('1;97', 'Space') + c('2', ' (or ') + c('1;97', 'F3') + c('2', ') to cycle its views.'),
|
|
86
|
+
'',
|
|
74
87
|
c('2', ` Claude is starting in THIS pane. Lost these steps? Run: ${o.hintCmd}`),
|
|
75
88
|
'',
|
|
76
89
|
].join('\n') + '\n';
|
|
@@ -116,9 +129,10 @@ function buildAttachedNote(o) {
|
|
|
116
129
|
* sidecar, then run Claude in the current pane. Returns Claude's exit code.
|
|
117
130
|
* @param {string} [profile]
|
|
118
131
|
* @param {Partial<Deps>} [deps]
|
|
132
|
+
* @param {{ name?: string|null }} [opts] explicit --name, already validated
|
|
119
133
|
* @returns {number}
|
|
120
134
|
*/
|
|
121
|
-
function run(profile, deps = {}) {
|
|
135
|
+
function run(profile, deps = {}, opts = {}) {
|
|
122
136
|
const d = withDefaults(deps);
|
|
123
137
|
|
|
124
138
|
if (profile !== undefined && !launchWin.validateProfile(profile)) {
|
|
@@ -126,7 +140,20 @@ function run(profile, deps = {}) {
|
|
|
126
140
|
return 1;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
|
|
143
|
+
// Every launch claims a free instance slot first, so a second VS Code window
|
|
144
|
+
// never shares another's state dir (src/instance-slot.js). A slot is only
|
|
145
|
+
// busy while a LIVE session holds it — an attached-but-idle sidebar (the note
|
|
146
|
+
// below) leaves its slot reusable, so relaunching here still lands on the same
|
|
147
|
+
// state dir and that pane picks the new session up, exactly as before.
|
|
148
|
+
const slot = d.allocateSlot({ profile, env: d.env, home: d.home });
|
|
149
|
+
if (slot && 'exhausted' in slot) {
|
|
150
|
+
d.err(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
// The instance's name and profile record — same on every platform. (The
|
|
154
|
+
// editor owns its tab titles, so there is no title surface here.)
|
|
155
|
+
if (slot && !('exhausted' in slot)) d.prepareInstance(slot, { profile, name: opts.name });
|
|
156
|
+
const st = launchWin.resolveProfileState(profile, { env: slots.applySlotEnv(d.env, slot), home: d.home });
|
|
130
157
|
if (st.usesCcs) {
|
|
131
158
|
if (!d.which('ccs')) {
|
|
132
159
|
d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
|
|
@@ -143,6 +170,9 @@ function run(profile, deps = {}) {
|
|
|
143
170
|
}
|
|
144
171
|
|
|
145
172
|
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
173
|
+
// The tab's stable identity for the git pane. Recorded here because only the
|
|
174
|
+
// launcher knows where ccr was started (src/state-dir.js).
|
|
175
|
+
try { d.recordLaunchDir(st.stateDir, process.cwd()); } catch { /* best effort */ }
|
|
146
176
|
d.removeExited(st.stateDir);
|
|
147
177
|
|
|
148
178
|
// statusLine via a per-launch temp settings file (no ~/.claude mutation).
|
|
@@ -157,7 +187,13 @@ function run(profile, deps = {}) {
|
|
|
157
187
|
const ccrBin = d.which('ccr');
|
|
158
188
|
const sidecarCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
|
|
159
189
|
const hintCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
|
|
160
|
-
|
|
190
|
+
// "Already attached?" comes from the allocator when it chose this slot: it
|
|
191
|
+
// inspected the heartbeat BEFORE reserving, and reserving a free slot writes a
|
|
192
|
+
// placeholder heartbeat of our own — so re-reading it here would see our own
|
|
193
|
+
// write and wrongly skip the split banner. Only a launch with no slot (a named
|
|
194
|
+
// profile, an explicit override) asks the heartbeat directly.
|
|
195
|
+
const attached = slot && !('exhausted' in slot) ? slot.attached : d.sidecarAlive(st.stateDir);
|
|
196
|
+
if (attached) {
|
|
161
197
|
d.out(buildAttachedNote({ hintCmd, color: d.color }));
|
|
162
198
|
} else {
|
|
163
199
|
d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
|
|
@@ -175,8 +211,19 @@ function run(profile, deps = {}) {
|
|
|
175
211
|
const parts = st.ccCmd.split(' ');
|
|
176
212
|
const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile], { CCR_STATE_DIR: st.stateDir });
|
|
177
213
|
d.cleanup(settingsFile);
|
|
178
|
-
if (r && r.error) {
|
|
214
|
+
if (r && r.error) {
|
|
215
|
+
// A failed spawn must NOT flip the sidecar to "ended" or delete anything;
|
|
216
|
+
// just hand the reservation back.
|
|
217
|
+
if (slot && !('exhausted' in slot)) d.releaseSlot(slot.stateDir);
|
|
218
|
+
d.err(`ccr: failed to launch Claude: ${r.error.message}\n`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
// Session over: drop the sentinel FIRST (an attached sidebar reads it to show
|
|
222
|
+
// "session ended" and wait for reuse), then retire the instance — ephemeral,
|
|
223
|
+
// so the dir is deleted unless that sidebar is still attached, in which case
|
|
224
|
+
// only the reservation is released and a later sweep collects the dir.
|
|
179
225
|
d.dropExited(st.stateDir);
|
|
226
|
+
if (slot && !('exhausted' in slot)) d.retireInstance(slot.stateDir);
|
|
180
227
|
return r && typeof r.status === 'number' ? r.status : 0;
|
|
181
228
|
}
|
|
182
229
|
|
|
@@ -283,6 +330,13 @@ function withDefaults(deps) {
|
|
|
283
330
|
existsDir: deps.existsDir || ((dir) => { try { return require('node:fs').statSync(dir).isDirectory(); } catch { return false; } }),
|
|
284
331
|
listDir: deps.listDir || ((dir) => { try { return require('node:fs').readdirSync(dir); } catch { return []; } }),
|
|
285
332
|
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
333
|
+
recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
|
|
334
|
+
allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
|
|
335
|
+
releaseSlot: deps.releaseSlot || ((dir) => slots.releaseSlot(dir)),
|
|
336
|
+
retireInstance: deps.retireInstance || ((dir) => slots.retireInstance(dir)),
|
|
337
|
+
prepareInstance: deps.prepareInstance
|
|
338
|
+
|| ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
|
|
339
|
+
require('./instance-name').prepareInstance(s, { ...o, home: deps.home || os.homedir() })),
|
|
286
340
|
removeExited: deps.removeExited || ((dir) => { try { require('node:fs').rmSync(path.join(dir, 'exited'), { force: true }); } catch { /* best effort */ } }),
|
|
287
341
|
dropExited: deps.dropExited || ((dir) => { try { require('node:fs').writeFileSync(path.join(dir, 'exited'), ''); } catch { /* best effort */ } }),
|
|
288
342
|
writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
|
|
@@ -309,6 +363,11 @@ function withDefaults(deps) {
|
|
|
309
363
|
* @property {(dir: string) => boolean} existsDir
|
|
310
364
|
* @property {(dir: string) => string[]} listDir
|
|
311
365
|
* @property {(dir: string) => void} ensureDir
|
|
366
|
+
* @property {(dir: string, cwd: string) => void} recordLaunchDir
|
|
367
|
+
* @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
|
|
368
|
+
* @property {(dir: string) => void} releaseSlot
|
|
369
|
+
* @property {(dir: string) => void} retireInstance
|
|
370
|
+
* @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
|
|
312
371
|
* @property {(dir: string) => void} removeExited
|
|
313
372
|
* @property {(dir: string) => void} dropExited
|
|
314
373
|
* @property {(settings: object) => string} writeSettings
|