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.
- package/CHANGELOG.md +114 -0
- package/README.md +107 -12
- package/bin/ccr.js +233 -26
- package/package.json +8 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +34 -3
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/history-privacy.js +435 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +202 -11
- package/src/migrate.js +155 -0
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +69 -1
- package/src/render/statusline.js +50 -5
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +167 -0
- package/src/sidecar.js +174 -27
- package/src/state-dir.js +61 -1
package/src/launch-vscode.js
CHANGED
|
@@ -18,7 +18,8 @@ const path = require('node:path');
|
|
|
18
18
|
const os = require('node:os');
|
|
19
19
|
const launchWin = require('./launch-win');
|
|
20
20
|
const inject = require('./settings-inject');
|
|
21
|
-
const
|
|
21
|
+
const slots = require('./instance-slot');
|
|
22
|
+
const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* VS Code's "Split Terminal" default keybinding, per platform.
|
|
@@ -33,12 +34,20 @@ function splitKeybinding(platform) {
|
|
|
33
34
|
* The command the user runs in the split pane. Carries the resolved state dir as
|
|
34
35
|
* an explicit arg (shell-agnostic: no per-shell `set`/`$env:`/`export` needed).
|
|
35
36
|
* Prefers the `ccr` binary when on PATH; falls back to node + ccr.js by path.
|
|
37
|
+
*
|
|
38
|
+
* `--keys` rides along because THIS host binds no key. Under tmux the launcher
|
|
39
|
+
* binds F3 and tmux runs `ccr cycle-view`; VS Code offers no equivalent, and its
|
|
40
|
+
* split leaves both panes running a foreground process, so there is not even a
|
|
41
|
+
* shell prompt free to type the command into. `--keys` makes the pasted process
|
|
42
|
+
* the hotkey host and runs the panel as its child — the renderer still reads no
|
|
43
|
+
* input (src/sidecar-keys.js).
|
|
44
|
+
*
|
|
36
45
|
* @param {{ stateDir: string, ccrBin?: string|null, node: string, ccrJs: string, hint?: boolean }} o
|
|
37
46
|
* @returns {string}
|
|
38
47
|
*/
|
|
39
48
|
function sidecarPasteCommand(o) {
|
|
40
49
|
const head = o.ccrBin ? 'ccr' : `"${o.node}" "${o.ccrJs}"`;
|
|
41
|
-
const tail = o.hint ? ' --hint' : ` --state-dir "${o.stateDir}"`;
|
|
50
|
+
const tail = o.hint ? ' --hint' : ` --state-dir "${o.stateDir}" --keys`;
|
|
42
51
|
return `${head} sidecar${tail}`;
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -71,6 +80,10 @@ function buildBanner(o) {
|
|
|
71
80
|
'',
|
|
72
81
|
` ${c('1;92', o.sidecarCmd)}`,
|
|
73
82
|
'',
|
|
83
|
+
// The key is named here because this host binds none of its own: nothing
|
|
84
|
+
// else on screen would tell the user the second view exists.
|
|
85
|
+
c('2', ' Then click that pane and press ') + c('1;97', 'Space') + c('2', ' (or ') + c('1;97', 'F3') + c('2', ') to cycle its views.'),
|
|
86
|
+
'',
|
|
74
87
|
c('2', ` Claude is starting in THIS pane. Lost these steps? Run: ${o.hintCmd}`),
|
|
75
88
|
'',
|
|
76
89
|
].join('\n') + '\n';
|
|
@@ -116,9 +129,10 @@ function buildAttachedNote(o) {
|
|
|
116
129
|
* sidecar, then run Claude in the current pane. Returns Claude's exit code.
|
|
117
130
|
* @param {string} [profile]
|
|
118
131
|
* @param {Partial<Deps>} [deps]
|
|
132
|
+
* @param {{ name?: string|null }} [opts] explicit --name, already validated
|
|
119
133
|
* @returns {number}
|
|
120
134
|
*/
|
|
121
|
-
function run(profile, deps = {}) {
|
|
135
|
+
function run(profile, deps = {}, opts = {}) {
|
|
122
136
|
const d = withDefaults(deps);
|
|
123
137
|
|
|
124
138
|
if (profile !== undefined && !launchWin.validateProfile(profile)) {
|
|
@@ -126,7 +140,20 @@ function run(profile, deps = {}) {
|
|
|
126
140
|
return 1;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
|
|
143
|
+
// Every launch claims a free instance slot first, so a second VS Code window
|
|
144
|
+
// never shares another's state dir (src/instance-slot.js). A slot is only
|
|
145
|
+
// busy while a LIVE session holds it — an attached-but-idle sidebar (the note
|
|
146
|
+
// below) leaves its slot reusable, so relaunching here still lands on the same
|
|
147
|
+
// state dir and that pane picks the new session up, exactly as before.
|
|
148
|
+
const slot = d.allocateSlot({ profile, env: d.env, home: d.home });
|
|
149
|
+
if (slot && 'exhausted' in slot) {
|
|
150
|
+
d.err(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
// The instance's name and profile record — same on every platform. (The
|
|
154
|
+
// editor owns its tab titles, so there is no title surface here.)
|
|
155
|
+
if (slot && !('exhausted' in slot)) d.prepareInstance(slot, { profile, name: opts.name });
|
|
156
|
+
const st = launchWin.resolveProfileState(profile, { env: slots.applySlotEnv(d.env, slot), home: d.home });
|
|
130
157
|
if (st.usesCcs) {
|
|
131
158
|
if (!d.which('ccs')) {
|
|
132
159
|
d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
|
|
@@ -143,6 +170,9 @@ function run(profile, deps = {}) {
|
|
|
143
170
|
}
|
|
144
171
|
|
|
145
172
|
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
173
|
+
// The tab's stable identity for the git pane. Recorded here because only the
|
|
174
|
+
// launcher knows where ccr was started (src/state-dir.js).
|
|
175
|
+
try { d.recordLaunchDir(st.stateDir, process.cwd()); } catch { /* best effort */ }
|
|
146
176
|
d.removeExited(st.stateDir);
|
|
147
177
|
|
|
148
178
|
// statusLine via a per-launch temp settings file (no ~/.claude mutation).
|
|
@@ -157,7 +187,13 @@ function run(profile, deps = {}) {
|
|
|
157
187
|
const ccrBin = d.which('ccr');
|
|
158
188
|
const sidecarCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
|
|
159
189
|
const hintCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
|
|
160
|
-
|
|
190
|
+
// "Already attached?" comes from the allocator when it chose this slot: it
|
|
191
|
+
// inspected the heartbeat BEFORE reserving, and reserving a free slot writes a
|
|
192
|
+
// placeholder heartbeat of our own — so re-reading it here would see our own
|
|
193
|
+
// write and wrongly skip the split banner. Only a launch with no slot (a named
|
|
194
|
+
// profile, an explicit override) asks the heartbeat directly.
|
|
195
|
+
const attached = slot && !('exhausted' in slot) ? slot.attached : d.sidecarAlive(st.stateDir);
|
|
196
|
+
if (attached) {
|
|
161
197
|
d.out(buildAttachedNote({ hintCmd, color: d.color }));
|
|
162
198
|
} else {
|
|
163
199
|
d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
|
|
@@ -175,8 +211,19 @@ function run(profile, deps = {}) {
|
|
|
175
211
|
const parts = st.ccCmd.split(' ');
|
|
176
212
|
const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile], { CCR_STATE_DIR: st.stateDir });
|
|
177
213
|
d.cleanup(settingsFile);
|
|
178
|
-
if (r && r.error) {
|
|
214
|
+
if (r && r.error) {
|
|
215
|
+
// A failed spawn must NOT flip the sidecar to "ended" or delete anything;
|
|
216
|
+
// just hand the reservation back.
|
|
217
|
+
if (slot && !('exhausted' in slot)) d.releaseSlot(slot.stateDir);
|
|
218
|
+
d.err(`ccr: failed to launch Claude: ${r.error.message}\n`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
// Session over: drop the sentinel FIRST (an attached sidebar reads it to show
|
|
222
|
+
// "session ended" and wait for reuse), then retire the instance — ephemeral,
|
|
223
|
+
// so the dir is deleted unless that sidebar is still attached, in which case
|
|
224
|
+
// only the reservation is released and a later sweep collects the dir.
|
|
179
225
|
d.dropExited(st.stateDir);
|
|
226
|
+
if (slot && !('exhausted' in slot)) d.retireInstance(slot.stateDir);
|
|
180
227
|
return r && typeof r.status === 'number' ? r.status : 0;
|
|
181
228
|
}
|
|
182
229
|
|
|
@@ -283,6 +330,13 @@ function withDefaults(deps) {
|
|
|
283
330
|
existsDir: deps.existsDir || ((dir) => { try { return require('node:fs').statSync(dir).isDirectory(); } catch { return false; } }),
|
|
284
331
|
listDir: deps.listDir || ((dir) => { try { return require('node:fs').readdirSync(dir); } catch { return []; } }),
|
|
285
332
|
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
333
|
+
recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
|
|
334
|
+
allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
|
|
335
|
+
releaseSlot: deps.releaseSlot || ((dir) => slots.releaseSlot(dir)),
|
|
336
|
+
retireInstance: deps.retireInstance || ((dir) => slots.retireInstance(dir)),
|
|
337
|
+
prepareInstance: deps.prepareInstance
|
|
338
|
+
|| ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
|
|
339
|
+
require('./instance-name').prepareInstance(s, { ...o, home: deps.home || os.homedir() })),
|
|
286
340
|
removeExited: deps.removeExited || ((dir) => { try { require('node:fs').rmSync(path.join(dir, 'exited'), { force: true }); } catch { /* best effort */ } }),
|
|
287
341
|
dropExited: deps.dropExited || ((dir) => { try { require('node:fs').writeFileSync(path.join(dir, 'exited'), ''); } catch { /* best effort */ } }),
|
|
288
342
|
writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
|
|
@@ -309,6 +363,11 @@ function withDefaults(deps) {
|
|
|
309
363
|
* @property {(dir: string) => boolean} existsDir
|
|
310
364
|
* @property {(dir: string) => string[]} listDir
|
|
311
365
|
* @property {(dir: string) => void} ensureDir
|
|
366
|
+
* @property {(dir: string, cwd: string) => void} recordLaunchDir
|
|
367
|
+
* @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
|
|
368
|
+
* @property {(dir: string) => void} releaseSlot
|
|
369
|
+
* @property {(dir: string) => void} retireInstance
|
|
370
|
+
* @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
|
|
312
371
|
* @property {(dir: string) => void} removeExited
|
|
313
372
|
* @property {(dir: string) => void} dropExited
|
|
314
373
|
* @property {(settings: object) => string} writeSettings
|
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 ||
|
|
89
|
-
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr',
|
|
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',
|
|
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
|
|
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
|
-
|
|
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 };
|