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.
@@ -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
+ };
@@ -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 { ensureSecureDir } = require('./state-dir');
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
- const st = launchWin.resolveProfileState(profile, { env: d.env, home: d.home });
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
- if (d.sidecarAlive(st.stateDir)) {
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) { d.err(`ccr: failed to launch Claude: ${r.error.message}\n`); return 1; }
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