claude-code-runrate 0.3.0 → 0.5.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,116 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/instance-resolve.js — find the instance you meant
4
+ // (features/instance-resolution.feature).
5
+ //
6
+ // The chain, ruled: -i (an explicit target typed NOW) → CCR_STATE_DIR (an
7
+ // explicit choice standing since launch; refused when it names the container
8
+ // — that is the old container/member confusion arriving by env var) → the
9
+ // live instance whose launch directory contains the cwd, longest match — a
10
+ // TIE is not "the" instance, so it falls through — → the single live one →
11
+ // else list them and refuse, offering -i, "because you have no idea what the
12
+ // user is looking for."
13
+ //
14
+ // Every caller heads its output with the resolved NAME — the safeguard that
15
+ // replaced bounding names by account: the mistake a user actually makes is
16
+ // reading the right panel about the wrong instance.
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const os = require('node:os');
21
+
22
+ /**
23
+ * @typedef {{ slot: number, stateDir: string, name: string|null, launchCwd: string|null }} LiveInstance
24
+ */
25
+
26
+ /**
27
+ * The live set — the only set names resolve against.
28
+ * @param {{ home: string, inspect?: (dir: string) => {live: boolean, attached: boolean} }} o
29
+ * @returns {LiveInstance[]}
30
+ */
31
+ function listLive(o) {
32
+ const inspect = o.inspect || require('./instance-slot').defaultInspect;
33
+ const root = path.join(o.home, '.ccr', 'instances');
34
+ /** @type {LiveInstance[]} */
35
+ const out = [];
36
+ let entries; try { entries = fs.readdirSync(root); } catch { return out; }
37
+ for (const n of entries.sort((a, b) => Number(a) - Number(b))) {
38
+ if (!/^\d+$/.test(n)) continue;
39
+ const dir = path.join(root, n);
40
+ try {
41
+ if (!inspect(dir).live) continue;
42
+ const read = (/** @type {string} */ f) => {
43
+ try { return fs.readFileSync(path.join(dir, f), 'utf8').trim() || null; } catch { return null; }
44
+ };
45
+ out.push({ slot: Number(n), stateDir: dir, name: read('name'), launchCwd: read('launch-cwd') });
46
+ } catch { /* skip unreadable */ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** @param {LiveInstance[]} live @param {string} cmd */
52
+ function listAndRefuse(live, cmd) {
53
+ const lines = ['ccr: several live instances match — say which:'];
54
+ for (const i of live) lines.push(` ${i.name || `slot ${i.slot}`}`);
55
+ lines.push(`try: ccr ${cmd} -i <name>`);
56
+ return { ok: /** @type {false} */ (false), error: lines.join('\n') };
57
+ }
58
+
59
+ /**
60
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv, cwd?: string, target?: string|null,
61
+ * command?: string, inspect?: (dir: string) => {live: boolean, attached: boolean} }} [o]
62
+ * @returns {{ ok: true, stateDir: string, name: string|null } | { ok: false, error: string, none?: boolean }}
63
+ */
64
+ function resolveInstance(o = {}) {
65
+ const home = o.home || os.homedir();
66
+ const env = o.env || process.env;
67
+ const cwd = o.cwd || process.cwd();
68
+ const cmd = o.command || 'economy';
69
+
70
+ const live = listLive({ home, inspect: o.inspect });
71
+
72
+ // 1. An explicit -i target, from the live set only. A typo matches nothing
73
+ // and errors — it reaches something else only by landing exactly on another
74
+ // live name.
75
+ if (o.target != null) {
76
+ const hit = live.find((i) => i.name === o.target);
77
+ if (!hit) return { ok: false, error: `ccr: no live instance named '${o.target}'` };
78
+ return { ok: true, stateDir: hit.stateDir, name: hit.name };
79
+ }
80
+
81
+ // 2. An explicit state dir standing since launch — unless it names the
82
+ // container itself.
83
+ if (env.CCR_STATE_DIR) {
84
+ const dir = path.resolve(env.CCR_STATE_DIR);
85
+ const container = path.resolve(home, '.ccr');
86
+ if (dir === container || dir === path.join(container, 'instances')) {
87
+ return { ok: false, error: 'ccr: the ccr home is a container, not an instance — point CCR_STATE_DIR at an instance dir or use -i' };
88
+ }
89
+ let name = null;
90
+ try { name = fs.readFileSync(path.join(dir, 'name'), 'utf8').trim() || null; } catch { /* unnamed */ }
91
+ return { ok: true, stateDir: dir, name };
92
+ }
93
+
94
+ // 3. Launch-directory containment, longest match; a tie falls through.
95
+ const here = path.resolve(cwd);
96
+ const containing = live.filter((i) => {
97
+ if (!i.launchCwd) return false;
98
+ const base = path.resolve(i.launchCwd);
99
+ return here === base || here.startsWith(base + path.sep);
100
+ });
101
+ if (containing.length) {
102
+ const longest = Math.max(...containing.map((i) => path.resolve(String(i.launchCwd)).length));
103
+ const best = containing.filter((i) => path.resolve(String(i.launchCwd)).length === longest);
104
+ if (best.length === 1) return { ok: true, stateDir: best[0].stateDir, name: best[0].name };
105
+ return listAndRefuse(best, cmd);
106
+ }
107
+
108
+ // 4. The single live one is unambiguous from anywhere.
109
+ if (live.length === 1) return { ok: true, stateDir: live[0].stateDir, name: live[0].name };
110
+ if (live.length === 0) return { ok: false, none: true, error: 'ccr: no live instance — run `ccr` to launch one' };
111
+
112
+ // 5. Several candidates, no signal.
113
+ return listAndRefuse(live, cmd);
114
+ }
115
+
116
+ module.exports = { resolveInstance, listLive };
@@ -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
+ };