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.
@@ -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
package/src/launch-win.js CHANGED
@@ -39,6 +39,109 @@ function isWtArgSafe(value) {
39
39
  return !WT_UNSAFE_RE.test(String(value));
40
40
  }
41
41
 
42
+ // The starting directory is a DIFFERENT problem from the values above, and
43
+ // reusing WT_UNSAFE_RE for it would be a bug in both directions.
44
+ //
45
+ // `%` is LEGAL in a Windows path (C:\100%done) and harmless here: -d's value
46
+ // is its own argv token handed to wt, never interpolated into a cmd /c
47
+ // payload, so cmd's variable expansion never sees it. WT_UNSAFE_RE rejects
48
+ // `%`, so reusing it would refuse a perfectly valid directory.
49
+ //
50
+ // `;` is legal in a Windows path AND is wt's own command separator — wt
51
+ // re-parses its command line, and Node only quotes an argv token that
52
+ // contains whitespace, so `C:\my;dir` would arrive unquoted and split the
53
+ // command. WT_UNSAFE_RE does NOT reject `;`, so it misses the one character
54
+ // that actually matters here.
55
+ //
56
+ // A directory that fails this does not fail the launch: run() drops -d and
57
+ // says where the panes landed (features/windows-launcher.feature, "A launch
58
+ // directory Windows Terminal cannot be given still launches"). Refusing to
59
+ // start over a legal directory name would trade one broken launch for another.
60
+ //
61
+ // MEASURED on Windows 11 (10.0.26200) with scripts/probe-wt.js, because none of
62
+ // this was knowable from the documentation and none of it is covered by CI —
63
+ // every test injects spawnWt, so the suite proves the argv and nothing about
64
+ // what Windows Terminal does with it.
65
+ const WT_PATH_UNSAFE_RE = /[";\r\n]/;
66
+
67
+ // The backtick used to be in that class and is NOT: a path containing one was
68
+ // passed through and the tab landed exactly where it was asked to. wt is not
69
+ // PowerShell. Refusing it cost a launch that would have worked.
70
+ //
71
+ // The semicolon earns its place — wt splits its own command line on it, and a
72
+ // path containing one opens NO TAB AT ALL.
73
+
74
+ // Longest path wt will accept as `-d`. Measured: 256 characters opens a tab,
75
+ // 259 opens none — so the true limit is 257 or 258, and 256 is the longest
76
+ // length known to work. Two characters of headroom is not worth another round
77
+ // trip on someone else's machine.
78
+ //
79
+ // This is NOT the filesystem's limit. The machine that measured it has long
80
+ // paths enabled: a 299-character directory was created successfully and then
81
+ // refused a tab. The refusal is Windows Terminal's.
82
+ const WT_PATH_MAX = 256;
83
+
84
+ // Naming the character rather than echoing it. Two reasons: a raw CR or LF
85
+ // interpolated into a diagnostic would break the diagnostic, and "a semicolon"
86
+ // is what the reader needs anyway — they are looking at the path already.
87
+ /** @type {Record<string, string>} */
88
+ const WT_PATH_CHAR_NAMES = {
89
+ '"': 'a double quote',
90
+ ';': 'a semicolon',
91
+ '\r': 'a carriage return',
92
+ '\n': 'a newline',
93
+ };
94
+
95
+ /**
96
+ * Why `dir` cannot be given to wt as `-d`, or null when it can.
97
+ *
98
+ * Every branch here degrades rather than refuses to launch: the panes open in
99
+ * Windows Terminal's default directory and stderr says so. The point of the
100
+ * REASON is that "we would not pass this one" is a different sentence for each
101
+ * cause, and the previous single message named characters the path did not
102
+ * contain.
103
+ *
104
+ * @param {string|null|undefined} dir
105
+ * @returns {string|null} a clause completing "the path …", or null if usable
106
+ */
107
+ function wtPathProblem(dir) {
108
+ if (typeof dir !== 'string' || dir.length === 0) return 'is not a directory ccr could read';
109
+ // UNC is the dangerous one, and the only case here that is SILENT. The tab
110
+ // OPENS — cmd.exe simply refuses a UNC working directory and starts in
111
+ // %SystemRoot% without a word. Measured: \\localhost\c$\Users reported
112
+ // C:\Windows. Left unhandled, the git pane would describe the project in
113
+ // full confidence for a terminal sitting in C:\Windows.
114
+ if (/^[\\/]{2}/.test(dir)) return 'is a UNC path — cmd.exe refuses those and starts in %SystemRoot% without saying so';
115
+ if (dir.length > WT_PATH_MAX) return `is ${dir.length} characters — Windows Terminal opens no tab at all past ${WT_PATH_MAX}`;
116
+ const m = WT_PATH_UNSAFE_RE.exec(dir);
117
+ if (m) return `contains ${WT_PATH_CHAR_NAMES[m[0]] || 'a character'} — Windows Terminal parses it and opens no tab at all`;
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * What the user could DO about it, when there is anything.
123
+ *
124
+ * Only the UNC case has an answer: cmd.exe will take a mapped drive letter
125
+ * where it refuses the UNC form, so the directory is reachable — just not by
126
+ * that name. The other causes are the path they have, and telling someone to
127
+ * rename their project or shorten it below 256 characters is not advice.
128
+ *
129
+ * @param {string|null|undefined} dir
130
+ * @returns {string|null}
131
+ */
132
+ function wtPathHint(dir) {
133
+ if (typeof dir !== 'string' || !/^[\\/]{2}/.test(dir)) return null;
134
+ return 'map the share to a drive letter (net use Z: \\\\server\\share) and run ccr from there';
135
+ }
136
+
137
+ /**
138
+ * @param {string|null|undefined} dir
139
+ * @returns {boolean} true if `dir` can be passed to wt.exe as `-d <dir>`
140
+ */
141
+ function isWtPathSafe(dir) {
142
+ return wtPathProblem(dir) === null;
143
+ }
144
+
42
145
  // Upstream default split: the sidecar gets ~34% of the width.
43
146
  const DEFAULT_SIDEBAR_PCT = 34;
44
147
 
@@ -82,11 +185,15 @@ function resolveProfileState(profile, opts = {}) {
82
185
  const env = opts.env || process.env;
83
186
  const home = opts.home || os.homedir();
84
187
 
188
+ // The stateDir fallbacks are reachable only when called with neither a slot's
189
+ // env nor the user's own override — never from the launchers, which refuse an
190
+ // exhausted allocation before this. They point at slot 1's member dir so no
191
+ // path here can ever name the container itself as a state dir.
85
192
  if (profile) {
86
193
  return {
87
194
  ccCmd: `ccs ${profile}`,
88
- session: env.CCR_SESSION || `ccr-${profile}`,
89
- stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', profile),
195
+ session: env.CCR_SESSION || 'ccr',
196
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
90
197
  instanceDir: path.join(home, '.ccs', 'instances', profile),
91
198
  usesCcs: true,
92
199
  };
@@ -94,7 +201,7 @@ function resolveProfileState(profile, opts = {}) {
94
201
  return {
95
202
  ccCmd: env.CC_BIN || 'claude',
96
203
  session: env.CCR_SESSION || 'ccr',
97
- stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
204
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
98
205
  instanceDir: null,
99
206
  usesCcs: false,
100
207
  };
@@ -177,9 +284,16 @@ function sidecarCols(termCols, fracNum, splitFlag) {
177
284
  * the case of %, hijack) the cmd /c payload — see isWtArgSafe. run() catches
178
285
  * this and reports a clean error instead of spawning a broken command.
179
286
  *
287
+ * `cwd` is the directory BOTH panes start in. Windows Terminal does not
288
+ * inherit it the way `tmux new-session` does: without `-d` a pane opens in the
289
+ * WT profile's own `startingDirectory`, which defaults to %USERPROFILE% — so
290
+ * omitting it silently moved Claude Code out of the user's project. Absent or
291
+ * unpassable (see isWtPathSafe), `-d` is left off entirely and the panes fall
292
+ * back to that profile default; run() is what tells the user.
293
+ *
180
294
  * @param {{ ccCmd: string, settingsFile: string, stateDir: string,
181
295
  * node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
182
- * termCols?: number }} o
296
+ * termCols?: number, title?: string, cwd?: string|null }} o
183
297
  * @returns {string[]}
184
298
  */
185
299
  function buildWtArgs(o) {
@@ -200,6 +314,9 @@ function buildWtArgs(o) {
200
314
  }
201
315
  const frac = sidebarFraction(o.sidebarPct);
202
316
  const splitFlag = sidebarSplitFlag(o.sidebarSide);
317
+ // The tab's ADDRESS ("[profile / ]name") when the instance has one — both
318
+ // halves allow-listed, so it is wt-arg-safe by construction.
319
+ const title = o.title || 'Claude';
203
320
  const exited = path.win32.join(stateDir, 'exited');
204
321
 
205
322
  // After Claude exits we drop the sentinel + clean the settings file, then idle
@@ -220,13 +337,17 @@ function buildWtArgs(o) {
220
337
  const cols = sidecarCols(o.termCols, Number(frac), splitFlag);
221
338
  const sidecarBody =
222
339
  (cols != null ? `set "CCR_SIDECAR_COLS=${cols}"&& ` : '') +
223
- `"${node}" "${ccrJs}" sidecar --exit-on-end`;
340
+ `"${node}" "${ccrJs}" sidecar --exit-on-end --keys`;
224
341
  const pane1 = paneCommand(stateDir, sidecarBody);
225
342
 
343
+ // Both panes get the same starting directory, or neither does. `-d` sits
344
+ // with the other options, before each pane's `cmd` payload.
345
+ const startIn = isWtPathSafe(o.cwd) ? ['-d', String(o.cwd)] : [];
346
+
226
347
  return [
227
- '-w', '0', 'new-tab', '--title', 'Claude', 'cmd', '/c', pane0,
348
+ '-w', '0', 'new-tab', '--title', title, ...startIn, 'cmd', '/c', pane0,
228
349
  ';',
229
- 'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
350
+ 'split-pane', splitFlag, '-s', frac, ...startIn, 'cmd', '/c', pane1,
230
351
  ];
231
352
  }
232
353
 
@@ -255,7 +376,8 @@ function defaultWhere(name) {
255
376
  }
256
377
 
257
378
  const inject = require('./settings-inject');
258
- const { ensureSecureDir } = require('./state-dir');
379
+ const slots = require('./instance-slot');
380
+ const { ensureSecureDir, recordLaunchDir, clearLaunchDir } = require('./state-dir');
259
381
 
260
382
  /**
261
383
  * Fill in real-environment implementations for anything the caller didn't
@@ -274,6 +396,15 @@ function withDefaults(deps) {
274
396
  // The launcher's own stdout reports the live terminal width — the sidecar's
275
397
  // does not, inside its cmd /c pane (see sidecarCols). undefined on non-TTY.
276
398
  cols: deps.cols != null ? deps.cols : process.stdout.columns,
399
+ // The launch directory, injected like every other external effect. It is
400
+ // the INPUT to that decision, not the answer: run() narrows it to paneCwd —
401
+ // the directory wt will actually be given — and both the record and `-d`
402
+ // read that, so the pane and the record cannot disagree about where this
403
+ // session is. This comment claimed the invariant before the code held it.
404
+ // process.cwd() throws only if the cwd has been deleted.
405
+ cwd: deps.cwd != null ? deps.cwd : (() => {
406
+ try { return process.cwd(); } catch { return null; }
407
+ })(),
277
408
  node: deps.node || process.execPath,
278
409
  ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
279
410
  out: deps.out || ((s) => { process.stdout.write(s); }),
@@ -283,6 +414,12 @@ function withDefaults(deps) {
283
414
  existsDir: deps.existsDir || defaultExistsDir,
284
415
  listDir: deps.listDir || defaultListDir,
285
416
  ensureDir: deps.ensureDir || ensureSecureDir,
417
+ recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
418
+ clearLaunchDir: deps.clearLaunchDir || clearLaunchDir,
419
+ allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
420
+ prepareInstance: deps.prepareInstance
421
+ || ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
422
+ require('./instance-name').prepareInstance(s, { ...o, home: deps.home || os.homedir() })),
286
423
  removeExited: deps.removeExited || defaultRemoveExited,
287
424
  writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
288
425
  cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
@@ -347,9 +484,10 @@ function fallbackNoWt(d) {
347
484
  *
348
485
  * @param {string} [profile]
349
486
  * @param {Partial<Deps>} [deps]
487
+ * @param {{ name?: string|null }} [opts] explicit --name, already validated
350
488
  * @returns {number}
351
489
  */
352
- function run(profile, deps = {}) {
490
+ function run(profile, deps = {}, opts = {}) {
353
491
  const d = withDefaults(deps);
354
492
 
355
493
  // 1. Validate the profile (it lands in paths and a spawned command).
@@ -362,8 +500,18 @@ function run(profile, deps = {}) {
362
500
  const wt = d.findWt();
363
501
  if (!wt) return fallbackNoWt(d);
364
502
 
365
- // 3. Resolve profile state + required binaries.
366
- const st = resolveProfileState(profile, { env: d.env, home: d.home });
503
+ // 3. Resolve profile state + required binaries. Every launch first claims a
504
+ // free instance slot (src/instance-slot.js) so a second window never shares
505
+ // another's state dir; only an explicit override skips it.
506
+ const slot = d.allocateSlot({ profile, env: d.env, home: d.home });
507
+ if (slot && 'exhausted' in slot) {
508
+ d.err(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
509
+ return 1;
510
+ }
511
+ // The instance's name, profile record and title — same on every platform.
512
+ const inst = slot && !('exhausted' in slot)
513
+ ? d.prepareInstance(slot, { profile, name: opts.name }) : null;
514
+ const st = resolveProfileState(profile, { env: slots.applySlotEnv(d.env, slot), home: d.home });
367
515
  if (st.usesCcs) {
368
516
  if (!d.which('ccs')) {
369
517
  d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
@@ -385,8 +533,39 @@ function run(profile, deps = {}) {
385
533
 
386
534
  // 4. Prepare the per-profile state dir; clear a stale sentinel.
387
535
  try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
536
+ // ONE source for both consumers: the directory the panes will ACTUALLY start
537
+ // in. Everything below reads paneCwd — the record that gives the git pane its
538
+ // identity, and wt's `-d` — so the two cannot describe different directories.
539
+ //
540
+ // Recording d.cwd unconditionally is what CREATED the divergence rather than
541
+ // merely failing to prevent it: src/sidecar.js launchDir() PREFERS the record
542
+ // over the pane's own cwd. readGitRepo self-heals after Claude's first status
543
+ // tick, but before that tick — the state every tab starts in — the pane draws
544
+ // the project's repo, branch, tree and history in full confidence for a
545
+ // session sitting somewhere else entirely.
546
+ const paneCwd = isWtPathSafe(d.cwd) ? d.cwd : null;
547
+ if (paneCwd) {
548
+ try { d.recordLaunchDir(st.stateDir, paneCwd); } catch { /* best effort */ }
549
+ } else {
550
+ // Clearing, not skipping. Slots are reused, so the record from whatever ran
551
+ // in this slot before would otherwise win (src/state-dir.js clearLaunchDir).
552
+ try { d.clearLaunchDir(st.stateDir); } catch { /* best effort */ }
553
+ }
388
554
  d.removeExited(st.stateDir);
389
555
 
556
+ // The panes must open where `ccr` was run. wt does not inherit that, so it
557
+ // is passed explicitly below; when the directory cannot be passed the launch
558
+ // still goes ahead, and saying so is the whole difference between a surprise
559
+ // and a known limitation.
560
+ const pathProblem = d.cwd ? wtPathProblem(d.cwd) : null;
561
+ if (pathProblem) {
562
+ d.err(`ccr: cannot open the panes in ${d.cwd}\n`);
563
+ d.err(` the path ${pathProblem}\n`);
564
+ d.err(" — they will open in Windows Terminal's default directory instead.\n");
565
+ const hint = wtPathHint(d.cwd);
566
+ if (hint) d.err(` ${hint}.\n`);
567
+ }
568
+
390
569
  // 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
391
570
  const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
392
571
  const settingsFile = d.writeSettings(inject.buildSettings(command));
@@ -404,6 +583,8 @@ function run(profile, deps = {}) {
404
583
  sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
405
584
  sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
406
585
  termCols: d.cols,
586
+ title: inst ? inst.title : undefined,
587
+ cwd: paneCwd,
407
588
  });
408
589
  } catch (e) {
409
590
  d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
@@ -425,6 +606,8 @@ function run(profile, deps = {}) {
425
606
  * @property {NodeJS.ProcessEnv} env
426
607
  * @property {string} home
427
608
  * @property {number|undefined} cols
609
+ * @property {string|null} cwd the directory `ccr` was run in — recorded as the
610
+ * tab's launch dir AND passed to wt as the panes' starting directory
428
611
  * @property {string} node
429
612
  * @property {string} ccrJs
430
613
  * @property {(s: string) => void} out
@@ -434,6 +617,10 @@ function run(profile, deps = {}) {
434
617
  * @property {(dir: string) => boolean} existsDir
435
618
  * @property {(dir: string) => string[]} listDir
436
619
  * @property {(dir: string) => void} ensureDir
620
+ * @property {(dir: string, cwd: string) => void} recordLaunchDir
621
+ * @property {(dir: string) => void} clearLaunchDir
622
+ * @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
623
+ * @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
437
624
  * @property {(dir: string) => void} removeExited
438
625
  * @property {(settings: object) => string} writeSettings
439
626
  * @property {(file: string) => void} cleanup
@@ -446,6 +633,10 @@ module.exports = {
446
633
  DEFAULT_SIDEBAR_SIDE,
447
634
  validateProfile,
448
635
  isWtArgSafe,
636
+ isWtPathSafe,
637
+ wtPathProblem,
638
+ wtPathHint,
639
+ WT_PATH_MAX,
449
640
  resolveProfileState,
450
641
  sidebarFraction,
451
642
  sidebarSplitFlag,
package/src/migrate.js ADDED
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/migrate.js — move a 0.3 ccr home to the 0.4 container layout, once,
4
+ // safely (features/instance-migration.feature).
5
+ //
6
+ // There is nothing to relocate but HISTORY: the only persistent content the
7
+ // 0.3 layout ever held was account burn history, misplaced inside profile
8
+ // dirs because the logger wrote to whatever the state dir was. So migration
9
+ // HARVESTS burnlogs to the container's top level, SWEEPS the profile dirs and
10
+ // the loose ephemeral droppings (a dead session's captured status, heartbeat,
11
+ // sentinel), and writes the ".layout" marker LAST — an interrupted migration
12
+ // is indistinguishable from one that has not started, and every move is
13
+ // move-if-present, so the next launch simply completes it.
14
+ //
15
+ // THE GENERAL SAFETY PROPERTY, ruled: if the source is not what migration
16
+ // expects, it STOPS AND CHANGES NOTHING. That is also the entire handling of
17
+ // an entry named "instances" or "profiles" (reserved by the new layout) and
18
+ // of a CCS profile literally named either — declined as real scope ("no other
19
+ // users will have this problem"); the stop makes it fail loudly for free.
20
+ //
21
+ // Runs AT LAUNCH ONLY. `ccr statusline` — invoked headlessly by Claude
22
+ // mid-session — never calls this. Removed at 1.0.0.
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const os = require('node:os');
27
+
28
+ const MARKER = '.layout';
29
+
30
+ // The 0.3 layout's loose per-session droppings — dead state the ephemeral
31
+ // rule says dies. Everything here may appear at the container root (bare
32
+ // launches) or inside a profile dir.
33
+ const DROPPINGS = ['last-status.json', 'launch-cwd', 'exited', 'sidecar-alive', 'slot-owner', 'view-request'];
34
+
35
+ const BURNLOG_RE = /^burnlog-[A-Za-z0-9_-]+\.jsonl$/;
36
+ // 0.4's own session join key (src/session-log.js). An upgrade while a session
37
+ // is open leaves the new statusline ticking against an unmigrated home, so
38
+ // these appear at the root BEFORE any launch migrates it — already at their
39
+ // final location, they are kept, not a surprise.
40
+ const SESSION_RE = /^session-[A-Za-z0-9_-]+\.jsonl$/;
41
+ // Same allow-list the launcher enforces for profile names.
42
+ const PROFILE_DIR_RE = /^[A-Za-z0-9._-]+$/;
43
+ // Names the new layout owns; their presence in an unmigrated home is exactly
44
+ // the surprise the safety property exists for.
45
+ const RESERVED = new Set(['instances', 'profiles']);
46
+
47
+ /**
48
+ * Is a 0.3 session still running in this dir, as far as 0.3 state can say?
49
+ * The published 0.3 recorded no owning pid, so the freshest signal it left is
50
+ * the sidecar heartbeat. Refusing on a live heartbeat is the safe direction:
51
+ * a false "live" delays migration by seconds; a false "dead" would harvest
52
+ * burnlogs out from under a writing session.
53
+ * @param {string} dir
54
+ */
55
+ function legacyLive(dir) {
56
+ try {
57
+ if (fs.existsSync(path.join(dir, 'exited'))) return false;
58
+ return require('./sidecar').sidecarAlive(dir);
59
+ } catch { return false; }
60
+ }
61
+
62
+ /**
63
+ * Move a burnlog to the container root, first-wins on a name collision (the
64
+ * name carries the Claude session id, so a collision is the same session's
65
+ * data twice — the copy already at the root is kept).
66
+ * @param {string} from @param {string} rootDir @param {string} name
67
+ */
68
+ function harvestBurnlog(from, rootDir, name) {
69
+ const dest = path.join(rootDir, name);
70
+ try {
71
+ if (fs.existsSync(dest)) { fs.rmSync(from, { force: true }); return; }
72
+ fs.renameSync(from, dest);
73
+ } catch { /* best effort — a later launch completes it */ }
74
+ }
75
+
76
+ /**
77
+ * Bring <home>/.ccr to the 0.4 layout. Total: never throws.
78
+ *
79
+ * @param {{ home?: string }} [opts]
80
+ * @returns {{ ok: true } | { ok: false, error: string }}
81
+ */
82
+ function ensureLayout(opts = {}) {
83
+ const home = opts.home || os.homedir();
84
+ const root = path.join(home, '.ccr');
85
+ const marker = path.join(root, MARKER);
86
+
87
+ try {
88
+ if (!fs.existsSync(root)) {
89
+ // Fresh install: create the container already migrated.
90
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
91
+ fs.writeFileSync(marker, '1\n', { mode: 0o600 });
92
+ return { ok: true };
93
+ }
94
+ if (fs.existsSync(marker)) {
95
+ sweepLegacyDroppings(root);
96
+ return { ok: true };
97
+ }
98
+
99
+ // ---- Classify EVERYTHING before touching ANYTHING. ----
100
+ /** @type {{ dir: string, name: string }[]} */
101
+ const profileDirs = [];
102
+ /** @type {string[]} */
103
+ const looseBurnlogs = [];
104
+ let rootHasDroppings = false;
105
+ for (const name of fs.readdirSync(root)) {
106
+ const p = path.join(root, name);
107
+ if (RESERVED.has(name)) return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}' (reserved by the new layout); move it aside and relaunch` };
108
+ if (name.startsWith('.')) continue; // dotted container entries are left alone
109
+ if (BURNLOG_RE.test(name)) { looseBurnlogs.push(name); continue; }
110
+ if (SESSION_RE.test(name)) continue; // 0.4 join key — already at its final home
111
+ if (DROPPINGS.includes(name)) { rootHasDroppings = true; continue; }
112
+ let st; try { st = fs.lstatSync(p); } catch { continue; }
113
+ if (st.isDirectory() && PROFILE_DIR_RE.test(name)) { profileDirs.push({ dir: p, name }); continue; }
114
+ return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}'; move it aside and relaunch` };
115
+ }
116
+
117
+ // A live 0.3 session anywhere refuses the whole migration, by name.
118
+ if (legacyLive(root)) return { ok: false, error: 'ccr: cannot migrate ~/.ccr while a session is running there — close it first' };
119
+ for (const { dir, name } of profileDirs) {
120
+ if (legacyLive(dir)) return { ok: false, error: `ccr: cannot migrate ~/.ccr while an instance is running — close '${name}' first` };
121
+ }
122
+
123
+ // ---- Harvest, then sweep, then mark. ----
124
+ for (const { dir } of profileDirs) {
125
+ let names; try { names = fs.readdirSync(dir); } catch { continue; }
126
+ for (const n of names) if (BURNLOG_RE.test(n)) harvestBurnlog(path.join(dir, n), root, n);
127
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* next launch retries */ }
128
+ }
129
+ if (rootHasDroppings) for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
130
+ try { fs.chmodSync(root, 0o700); } catch { /* best effort */ }
131
+ fs.writeFileSync(marker, '1\n', { mode: 0o600 });
132
+ return { ok: true };
133
+ } catch (e) {
134
+ return { ok: false, error: `ccr: migration failed: ${e instanceof Error ? e.message : String(e)}` };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A DOWNGRADE-then-re-upgrade leaves a migrated container plus a 0.3 session's
140
+ * loose droppings at the root — a dead instance's live state, which the
141
+ * ephemeral rule says dies. Its burnlogs already live where the pool is, so
142
+ * the day's history merges for free. Swept only once that session shows no
143
+ * heartbeat (the safe direction, same as everywhere else).
144
+ * @param {string} root
145
+ */
146
+ function sweepLegacyDroppings(root) {
147
+ try {
148
+ if (!fs.existsSync(path.join(root, 'last-status.json'))
149
+ && !fs.existsSync(path.join(root, 'sidecar-alive'))) return;
150
+ if (legacyLive(root)) return;
151
+ for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
152
+ } catch { /* best effort */ }
153
+ }
154
+
155
+ module.exports = { ensureLayout, MARKER };