claude-code-runrate 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,15 +51,42 @@ npm i -g claude-code-runrate
51
51
  | `ccr economy --json` | Stable machine-readable model (scripting) | ✅ | ✅ | ✅ |
52
52
  | `ccr resume` | Recent sessions ranked by cost to resume | ✅ | ✅ | ✅ |
53
53
  | `ccr statusline` | One-line summary wired into CC's `statusLine` | ✅ | ✅ | ✅ |
54
- | `ccr sidecar` | Live tmux dashboard + tool/skills feed | ✅ | ✅ | (tmux; use WSL) |
54
+ | `ccr sidecar` | Live dashboard + tool/skills feed | ✅ tmux | ✅ tmux | Windows Terminal / VS Code |
55
55
 
56
- The CLI and statusline are pure Node — they run on native Windows. The rich live
57
- sidebar needs `tmux`, so on Windows use WSL.
56
+ The CLI and statusline are pure Node — they run on native Windows. The live
57
+ sidebar runs on **Windows Terminal** (`ccr` splits it automatically) or inside
58
+ **VS Code's integrated terminal** on any OS (see [VS Code](#vs-code-split-terminal)).
59
+ On Linux/macOS the default host is `tmux`. No WSL required.
58
60
 
59
61
  ## Requirements
60
62
 
61
63
  - **Node ≥ 18.3** — that's it for the core. **Zero runtime dependencies.**
62
- - `tmux` — only for the optional `ccr sidecar`.
64
+ - For the optional live `ccr sidecar`: `tmux` on Linux/macOS, or **Windows
65
+ Terminal** / **VS Code's integrated terminal** on Windows (no WSL).
66
+
67
+ ## VS Code (split terminal)
68
+
69
+ `ccr` detects VS Code's integrated terminal (`TERM_PROGRAM=vscode`) and wires the
70
+ live sidebar into a **split pane** — no separate window, no WSL. A shell can't
71
+ trigger the split itself, so `ccr` does everything around it:
72
+
73
+ 1. Run `ccr` (or `ccr <profile>`). Claude starts in the **current** pane and a
74
+ bright banner shows the steps. The sidecar command is **copied to your
75
+ clipboard** automatically (via an OSC 52 escape — works over SSH/remote too).
76
+ 2. **Split the terminal** — `Ctrl+Shift+5` (Windows/Linux) or `Cmd+\` (macOS).
77
+ 3. **Paste** into the new pane and press Enter — the live sidebar runs there.
78
+
79
+ Lost the banner once Claude takes the screen? Run `ccr sidecar --hint` to reprint
80
+ the steps and re-copy the command.
81
+
82
+ On **Windows** this is the default inside VS Code (Windows Terminal otherwise
83
+ opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
84
+ `ccr` defaults to `tmux` (which works inside the VS Code terminal too); set
85
+ `CCR_VSCODE=1` to use the split-terminal flow there instead.
86
+
87
+ > Automating the split keystroke itself would need a VS Code extension (the `code`
88
+ > CLI has no "run command" verb) — out of scope for the zero-dependency core. The
89
+ > clipboard + `--hint` reduce it to split-and-paste.
63
90
 
64
91
  ## Wiring the statusline into Claude Code
65
92
 
@@ -77,7 +104,10 @@ latency.)
77
104
 
78
105
  This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
79
106
  the source of truth, executed by a hand-rolled zero-dependency harness on top of
80
- Node's built-in test runner.
107
+ Node's built-in test runner — a ~250-line Gherkin parser + runner that supports
108
+ the practical core of the grammar and rejects everything else loudly rather than
109
+ mis-parsing it. See [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the
110
+ deliberate limits, and the API.
81
111
 
82
112
  ```bash
83
113
  npm test # node --test — harness self-tests + feature scenarios
package/bin/ccr.js CHANGED
@@ -40,7 +40,8 @@ Examples:
40
40
 
41
41
  Options:
42
42
  -h, --help Show this help
43
- -v, --version Show version`;
43
+ -v, --version Show version
44
+ --mary Enable the mary interface`;
44
45
 
45
46
  /**
46
47
  * @param {string[]} argv
@@ -56,6 +57,9 @@ function main(argv) {
56
57
  help: { type: 'boolean', short: 'h' },
57
58
  version: { type: 'boolean', short: 'v' },
58
59
  json: { type: 'boolean' },
60
+ 'state-dir': { type: 'string' },
61
+ hint: { type: 'boolean' },
62
+ mary: { type: 'boolean' },
59
63
  },
60
64
  });
61
65
  } catch (err) {
@@ -66,6 +70,9 @@ function main(argv) {
66
70
  const { values, positionals } = parsed;
67
71
  if (values.version) { process.stdout.write(`${pkg.version}\n`); return 0; }
68
72
  if (values.help) { process.stdout.write(`${HELP}\n`); return 0; }
73
+ // The mary interface is an env toggle under the hood; surface it as a flag so
74
+ // in-process panels (economy/statusline/resume) pick it up for this run.
75
+ if (values.mary) process.env.CCR_ENABLE_MARY_INTERFACE = '1';
69
76
 
70
77
  const cmd = positionals[0];
71
78
  if (!cmd) return cmdLaunch(undefined); // bare `ccr` → launch
@@ -73,7 +80,7 @@ function main(argv) {
73
80
  case 'economy': return cmdEconomy(!!values.json);
74
81
  case 'resume': return cmdResume(positionals[1]);
75
82
  case 'statusline': return cmdStatusline();
76
- case 'sidecar': return cmdSidecar();
83
+ case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint);
77
84
  case 'doctor': return require('../src/doctor').run();
78
85
  case 'launch': return cmdLaunch(positionals[1]);
79
86
  default: return cmdLaunch(cmd); // anything else → treat as a CCS profile
@@ -151,26 +158,37 @@ function cmdResume(arg) {
151
158
  return 0;
152
159
  }
153
160
 
154
- /** `ccr sidecar` — live economy panel; keeps the process alive (no exit code). */
155
- function cmdSidecar() {
161
+ /**
162
+ * `ccr sidecar` — live economy panel; keeps the process alive (no exit code).
163
+ * `--state-dir <dir>` targets a specific session (used by the VS Code split-pane
164
+ * one-liner, which is shell-agnostic). `--hint` reprints the VS Code split
165
+ * instructions + re-copies the one-liner instead of running the panel.
166
+ * @param {string | undefined} stateDir
167
+ * @param {boolean} [showHint]
168
+ * @returns {number | undefined}
169
+ */
170
+ function cmdSidecar(stateDir, showHint) {
171
+ if (stateDir) process.env.CCR_STATE_DIR = stateDir;
172
+ if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
156
173
  require('../src/sidecar').run();
157
174
  return undefined;
158
175
  }
159
176
 
160
177
  /**
161
- * `ccr [profile]` — launch the tmux session (claude/ccs + sidebar) via launch.sh.
178
+ * `ccr [profile]` — launch the live sidecar. Inside VS Code's integrated terminal
179
+ * we split it in place (Windows always; other OSes via CCR_VSCODE=1, since tmux
180
+ * already works there); on native Windows we drive Windows Terminal; otherwise
181
+ * the tmux launcher (scripts/launch.sh).
162
182
  * @param {string | undefined} profile
163
183
  * @returns {number}
164
184
  */
165
185
  function cmdLaunch(profile) {
186
+ const inVscode = process.env.TERM_PROGRAM === 'vscode';
187
+ if (inVscode && (process.platform === 'win32' || process.env.CCR_VSCODE === '1')) {
188
+ return require('../src/launch-vscode').run(profile);
189
+ }
166
190
  if (process.platform === 'win32') {
167
- process.stderr.write(
168
- 'ccr: the live sidebar needs tmux + bash, which native Windows lacks.\n' +
169
- ' Use WSL for the sidebar, or run these directly (they work natively):\n' +
170
- ' ccr economy one-off economy panel\n' +
171
- " ccr statusline wire into Claude Code's statusLine\n" +
172
- ' ccr doctor check your setup\n');
173
- return 1;
191
+ return require('../src/launch-win').run(profile);
174
192
  }
175
193
  const { spawnSync } = require('node:child_process');
176
194
  const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Bing Ho <reps-attic-riot@duck.com>",
7
- "repository": { "type": "git", "url": "git+https://github.com/bingh0/ccr.git" },
8
- "bugs": { "url": "https://github.com/bingh0/ccr/issues" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bingh0/ccr.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/bingh0/ccr/issues"
13
+ },
9
14
  "homepage": "https://github.com/bingh0/ccr#readme",
10
15
  "type": "commonjs",
11
16
  "bin": {
@@ -24,7 +29,8 @@
24
29
  ],
25
30
  "scripts": {
26
31
  "test": "node --test",
27
- "typecheck": "tsc --noEmit -p jsconfig.json"
32
+ "typecheck": "tsc --noEmit -p jsconfig.json",
33
+ "lint": "oxlint"
28
34
  },
29
35
  "keywords": [
30
36
  "claude-code",
@@ -36,6 +42,7 @@
36
42
  "dependencies": {},
37
43
  "devDependencies": {
38
44
  "@types/node": "^24.0.0",
45
+ "oxlint": "^1.71.0",
39
46
  "typescript": "^5.7.0"
40
47
  }
41
48
  }
package/src/doctor.js CHANGED
@@ -34,10 +34,19 @@ function isExec(/** @type {string} */ f) {
34
34
  try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
35
35
  }
36
36
 
37
- /** @returns {number} exit code (0 = healthy) */
38
- function run() {
39
- const REPO = path.join(__dirname, '..');
40
- const isWin = process.platform === 'win32';
37
+ /**
38
+ * @param {{ platform?: string, has?: (cmd: string) => (string|null),
39
+ * homedir?: string, repo?: string, write?: (s: string) => void }} [opts]
40
+ * side effects are injectable for testing; defaults hit the real environment
41
+ * @returns {number} exit code (0 = healthy)
42
+ */
43
+ function run(opts = {}) {
44
+ const platform = opts.platform || process.platform;
45
+ const hasFn = opts.has || has;
46
+ const homedir = opts.homedir || os.homedir();
47
+ const REPO = opts.repo || path.join(__dirname, '..');
48
+ const write = opts.write || ((s) => { process.stdout.write(s); });
49
+ const isWin = platform === 'win32';
41
50
  const out = [bold('ccr doctor'), ''];
42
51
  let problems = 0;
43
52
 
@@ -46,29 +55,35 @@ function run() {
46
55
  out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 18.3`));
47
56
  if (!nodeOk) problems++;
48
57
 
49
- const ccr = has('ccr');
58
+ const ccr = hasFn('ccr');
50
59
  out.push(ccr ? ok(`ccr on PATH (${stripControl(ccr)})`) : warn('ccr not on PATH — run `npm link` in the repo'));
51
60
  if (!ccr) problems++;
52
61
 
53
62
  if (isWin) {
54
- // The live sidebar (tmux + bash) is WSL-only on native Windows by design,
55
- // not a problem. Say so plainly so a Windows user isn't told to "fix" it.
56
- out.push(dim('· live sidebar (`ccr`) needs tmux + bash — for that, use WSL2.'));
57
- out.push(dim(' The CLI (economy / statusline / resume / doctor) runs natively here.'));
63
+ // Native Windows hosts the sidecar in Windows Terminalno tmux/bash/WSL.
64
+ const wt = hasFn('wt');
65
+ if (wt) {
66
+ out.push(ok(`Windows Terminal (sidecar host) (${stripControl(wt)})`));
67
+ } else {
68
+ out.push(warn('Windows Terminal not found — the sidecar needs it (winget install Microsoft.WindowsTerminal); the CLI still works'));
69
+ problems++;
70
+ }
71
+ // statusLine is injected inline (node + bin/ccr.js by path) via the per-launch
72
+ // temp settings file, so there's no shipped shim asset to check on Windows.
58
73
  } else {
59
- const tmux = has('tmux');
60
- out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for `ccr [profile]` sidebar (use WSL on Windows)'));
74
+ const tmux = hasFn('tmux');
75
+ out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for the `ccr [profile]` sidebar'));
61
76
  if (!tmux) problems++;
62
- out.push(has('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
77
+ out.push(hasFn('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
63
78
 
64
79
  const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
65
80
  out.push(isExec(sl) ? ok('sidecar/ccr-statusline is executable') : warn('sidecar/ccr-statusline not executable (the launcher self-heals this)'));
66
81
  }
67
82
 
68
- const ccs = has('ccs');
83
+ const ccs = hasFn('ccs');
69
84
  if (ccs) {
70
85
  let profiles = [];
71
- try { profiles = fs.readdirSync(path.join(os.homedir(), '.ccs', 'instances')).filter((p) => !p.startsWith('.')); } catch { /* none */ }
86
+ try { profiles = fs.readdirSync(path.join(homedir, '.ccs', 'instances')).filter((p) => !p.startsWith('.')); } catch { /* none */ }
72
87
  // Profile + path come from the filesystem; sanitize before display.
73
88
  out.push(ok(`ccs (${stripControl(ccs)}) · profiles: ${profiles.map(stripControl).join(', ') || '(none)'}`));
74
89
  } else {
@@ -77,7 +92,7 @@ function run() {
77
92
 
78
93
  // newest captured snapshot across ~/.ccr and its per-profile subdirs (state
79
94
  // lives under the user's home now, never world-shared /tmp).
80
- const ccrDir = path.join(os.homedir(), '.ccr');
95
+ const ccrDir = path.join(homedir, '.ccr');
81
96
  const dirs = [ccrDir];
82
97
  try {
83
98
  for (const d of fs.readdirSync(ccrDir)) {
@@ -98,17 +113,12 @@ function run() {
98
113
  out.push(ok(`status captured ${ageMin}m ago (${stripControl(newest.d)})`));
99
114
  out.push(dim(` buckets: ${keys.map(stripControl).join(', ') || '(none — API session?)'}`));
100
115
  } else {
101
- out.push(warn(isWin
102
- ? 'no status captured yet — wire `ccr statusline` into Claude Code (settings.json) to start capturing'
103
- : 'no status captured yet — launch with `ccr` (or `ccr <profile>`) to start capturing'));
116
+ out.push(warn('no status captured yet — launch with `ccr` (or `ccr <profile>`) to start capturing'));
104
117
  }
105
118
 
106
119
  out.push('');
107
- const allGood = isWin
108
- ? 'all good — `ccr economy` for the panel, `ccr statusline` to wire into CC'
109
- : 'all good — `ccr` to launch, `ccr economy` for the panel';
110
- out.push(problems ? warn(`${problems} thing(s) to address above`) : ok(allGood));
111
- process.stdout.write(out.join('\n') + '\n');
120
+ out.push(problems ? warn(`${problems} thing(s) to address above`) : ok('all good — `ccr` to launch, `ccr economy` for the panel'));
121
+ write(out.join('\n') + '\n');
112
122
  return problems ? 1 : 0;
113
123
  }
114
124
 
@@ -0,0 +1,288 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/launch-vscode.js — split-terminal launcher for VS Code's integrated
4
+ // terminal (any OS). Unlike the wt.exe path (a separate window) or tmux (a real
5
+ // multiplexer), a shell process CANNOT split the VS Code terminal itself — the
6
+ // `code` CLI exposes no "run command" verb. So we automate everything AROUND the
7
+ // split: do all the state/statusLine wiring, run Claude in the CURRENT pane, and
8
+ // hand the user a prominent, clipboard-copied one-liner for the new pane.
9
+ //
10
+ // run(profile) → banner + clipboard + Claude in this pane; sidecar one-liner
11
+ // for the split pane (Ctrl+Shift+5 / Cmd+\).
12
+ // hint(dir) → reprint that banner + re-copy the one-liner (no Claude).
13
+ //
14
+ // The zero-dependency / no-config-mutation contracts are preserved exactly as in
15
+ // launch-win: statusLine is injected via a per-launch temp settings file.
16
+
17
+ const path = require('node:path');
18
+ const os = require('node:os');
19
+ const launchWin = require('./launch-win');
20
+ const inject = require('./settings-inject');
21
+ const { ensureSecureDir } = require('./state-dir');
22
+
23
+ /**
24
+ * VS Code's "Split Terminal" default keybinding, per platform.
25
+ * @param {string} [platform]
26
+ * @returns {string}
27
+ */
28
+ function splitKeybinding(platform) {
29
+ return (platform || process.platform) === 'darwin' ? 'Cmd+\\' : 'Ctrl+Shift+5';
30
+ }
31
+
32
+ /**
33
+ * The command the user runs in the split pane. Carries the resolved state dir as
34
+ * an explicit arg (shell-agnostic: no per-shell `set`/`$env:`/`export` needed).
35
+ * Prefers the `ccr` binary when on PATH; falls back to node + ccr.js by path.
36
+ * @param {{ stateDir: string, ccrBin?: string|null, node: string, ccrJs: string, hint?: boolean }} o
37
+ * @returns {string}
38
+ */
39
+ function sidecarPasteCommand(o) {
40
+ const head = o.ccrBin ? 'ccr' : `"${o.node}" "${o.ccrJs}"`;
41
+ const tail = o.hint ? ' --hint' : ` --state-dir "${o.stateDir}"`;
42
+ return `${head} sidecar${tail}`;
43
+ }
44
+
45
+ /**
46
+ * OSC 52 clipboard-set escape — zero-dep, works over SSH/remote/dev-containers,
47
+ * and honored by VS Code's terminal. Setting the clipboard needs no native tool.
48
+ * @param {string} text
49
+ * @returns {string}
50
+ */
51
+ function osc52(text) {
52
+ return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
53
+ }
54
+
55
+ /**
56
+ * The prominent, scrollback-findable banner. A bright reverse-video header plus a
57
+ * blinking ▶/◀ marker is the "animated cue" — terminals honoring blink animate
58
+ * it (live AND when scrolled to); elsewhere it degrades to a bold static banner.
59
+ * @param {{ sidecarCmd: string, splitKey: string, hintCmd: string, color: boolean }} o
60
+ * @returns {string}
61
+ */
62
+ function buildBanner(o) {
63
+ const c = o.color ? (/** @type {string} */ code, /** @type {string} */ s) => `\x1b[${code}m${s}\x1b[0m` : (/** @type {string} */ _code, /** @type {string} */ s) => s;
64
+ const blink = o.color ? (/** @type {string} */ s) => `\x1b[5m${s}\x1b[25m` : (/** @type {string} */ s) => s;
65
+ return [
66
+ '',
67
+ c('1;30;103', ` ${blink('▶')} ccr · live sidecar — split your VS Code terminal ${blink('◀')} `),
68
+ '',
69
+ ` ${c('1;96', '1.')} Split this terminal: ${c('1;97', o.splitKey)}`,
70
+ ` ${c('1;96', '2.')} In the new pane, run ${c('2', '(already on your clipboard — just paste)')}:`,
71
+ '',
72
+ ` ${c('1;92', o.sidecarCmd)}`,
73
+ '',
74
+ c('2', ` Claude is starting in THIS pane. Lost these steps? Run: ${o.hintCmd}`),
75
+ '',
76
+ ].join('\n') + '\n';
77
+ }
78
+
79
+ /**
80
+ * Best-effort copy: OSC 52 first (covers VS Code + remote), then a native tool
81
+ * for terminals with OSC 52 disabled. Never throws; silent if nothing is found.
82
+ * @param {string} text
83
+ * @param {{ platform: string, out: (s: string) => void, spawnCopy: (cmd: string, args: string[], input: string) => { status: number|null, error?: Error } }} d
84
+ */
85
+ function copyToClipboard(text, d) {
86
+ try { d.out(osc52(text)); } catch { /* terminal may not support OSC 52 */ }
87
+ const p = d.platform;
88
+ const attempts = p === 'win32' ? [['clip', []]]
89
+ : p === 'darwin' ? [['pbcopy', []]]
90
+ : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']]];
91
+ for (const [cmd, args] of attempts) {
92
+ try {
93
+ const r = d.spawnCopy(String(cmd), /** @type {string[]} */ (args), text);
94
+ if (r && !r.error && (r.status === 0 || r.status == null)) return;
95
+ } catch { /* try the next tool */ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * `ccr [profile]` inside a VS Code integrated terminal: wire the split-view
101
+ * sidecar, then run Claude in the current pane. Returns Claude's exit code.
102
+ * @param {string} [profile]
103
+ * @param {Partial<Deps>} [deps]
104
+ * @returns {number}
105
+ */
106
+ function run(profile, deps = {}) {
107
+ const d = withDefaults(deps);
108
+
109
+ if (profile !== undefined && !launchWin.validateProfile(profile)) {
110
+ d.err(`ccr: invalid profile name '${profile}' (allowed: letters, digits, . _ -)\n`);
111
+ return 1;
112
+ }
113
+
114
+ const st = launchWin.resolveProfileState(profile, { env: d.env, home: d.home });
115
+ if (st.usesCcs) {
116
+ if (!d.which('ccs')) {
117
+ d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
118
+ return 1;
119
+ }
120
+ if (st.instanceDir && !d.existsDir(st.instanceDir)) {
121
+ d.err(`ccr: CCS profile '${profile}' not found (${st.instanceDir}).\n`);
122
+ d.err(` available: ${d.listDir(path.join(d.home, '.ccs', 'instances')).join(' ')}\n`);
123
+ return 1;
124
+ }
125
+ } else {
126
+ const bin = st.ccCmd.split(' ')[0];
127
+ if (!d.which(bin)) { d.err(`ccr: '${bin}' not found on PATH.\n`); return 1; }
128
+ }
129
+
130
+ try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
131
+ d.removeExited(st.stateDir);
132
+
133
+ // statusLine via a per-launch temp settings file (no ~/.claude mutation).
134
+ const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
135
+ const settingsFile = d.writeSettings(inject.buildSettings(command));
136
+
137
+ // Show the split instructions + copy the sidecar one-liner BEFORE Claude takes
138
+ // over the pane (the clipboard + hint make it recoverable once it scrolls off).
139
+ const ccrBin = d.which('ccr');
140
+ const sidecarCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
141
+ const hintCmd = sidecarPasteCommand({ stateDir: st.stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
142
+ d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
143
+ copyToClipboard(sidecarCmd, d);
144
+
145
+ // Run Claude in the current pane (blocks until exit). The temp settings file is
146
+ // always removed; the "session ended" sentinel is only dropped if Claude
147
+ // actually ran — a failed spawn must NOT flip the sidecar to "ended".
148
+ const parts = st.ccCmd.split(' ');
149
+ const r = d.spawnClaude(parts[0], [...parts.slice(1), '--settings', settingsFile]);
150
+ d.cleanup(settingsFile);
151
+ if (r && r.error) { d.err(`ccr: failed to launch Claude: ${r.error.message}\n`); return 1; }
152
+ d.dropExited(st.stateDir);
153
+ return r && typeof r.status === 'number' ? r.status : 0;
154
+ }
155
+
156
+ /**
157
+ * Reprint the split instructions and re-copy the sidecar one-liner for an
158
+ * already-resolved state dir. Backs `ccr sidecar --hint`. Never launches Claude.
159
+ * @param {string} stateDir
160
+ * @param {Partial<Deps>} [deps]
161
+ * @returns {number}
162
+ */
163
+ function hint(stateDir, deps = {}) {
164
+ const d = withDefaults(deps);
165
+ const ccrBin = d.which('ccr');
166
+ const sidecarCmd = sidecarPasteCommand({ stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs });
167
+ const hintCmd = sidecarPasteCommand({ stateDir, ccrBin, node: d.node, ccrJs: d.ccrJs, hint: true });
168
+ d.out(buildBanner({ sidecarCmd, splitKey: splitKeybinding(d.platform), hintCmd, color: d.color }));
169
+ copyToClipboard(sidecarCmd, d);
170
+ return 0;
171
+ }
172
+
173
+ /**
174
+ * Build the spawn invocation for launching Claude in the CURRENT pane.
175
+ *
176
+ * On POSIX this is a direct exec of `bin`. On Windows the resolved binary is
177
+ * typically `claude.cmd` / `ccs.cmd`, which Node's spawnSync refuses to run by
178
+ * bare name without a shell (it would ENOENT) — so we resolve the real path via
179
+ * `which` and run it through cmd.exe with our own quoting, which keeps a temp
180
+ * path containing spaces working. We reject the two characters cmd quoting
181
+ * cannot neutralize — `"` (ends the quote) and `%` (cmd expansion), plus CR/LF —
182
+ * with a clear error rather than spawn a broken or hijackable line. This mirrors
183
+ * the WT_UNSAFE policy in launch-win.js; trust boundary is the user's own env.
184
+ *
185
+ * Returns either a spawn descriptor or, on a rejected character, an `{ error }`
186
+ * that run() surfaces exactly like a spawn failure.
187
+ *
188
+ * @param {string} bin
189
+ * @param {string[]} args
190
+ * @param {{ platform: string, which: (name: string) => (string|null) }} o
191
+ * @returns {{ command: string, args: string[]|null, shell: boolean } | { error: Error }}
192
+ */
193
+ function buildClaudeSpawn(bin, args, o) {
194
+ if (o.platform !== 'win32') {
195
+ return { command: bin, args, shell: false };
196
+ }
197
+ const resolved = o.which(bin) || bin;
198
+ const all = [resolved, ...args.map(String)];
199
+ const bad = all.find((p) => /["%\r\n]/.test(p));
200
+ if (bad !== undefined) {
201
+ return { error: new Error(
202
+ `argument contains an unsupported character (", %, or newline) for the Windows shell: ${JSON.stringify(bad)}`,
203
+ ) };
204
+ }
205
+ // cmd.exe strips the outer quote pair Node adds around the /c payload, leaving
206
+ // each value individually quoted — spaces are safe, `"`/`%` are pre-rejected.
207
+ return { command: all.map((p) => `"${p}"`).join(' '), args: null, shell: true };
208
+ }
209
+
210
+ /**
211
+ * Real-environment Claude launcher: resolve+route through cmd.exe on Windows
212
+ * (see buildClaudeSpawn), direct exec elsewhere. Stays injectable so tests drive
213
+ * run() without spawning — but the Windows resolution itself is unit-tested via
214
+ * buildClaudeSpawn so this seam can't silently regress to a bare-name spawn.
215
+ *
216
+ * @param {string} bin
217
+ * @param {string[]} args
218
+ * @returns {{ status: number|null, error?: Error }}
219
+ */
220
+ function defaultSpawnClaude(bin, args) {
221
+ const built = buildClaudeSpawn(bin, args, { platform: process.platform, which: defaultWhich });
222
+ if ('error' in built) return { status: null, error: built.error };
223
+ const { spawnSync } = require('node:child_process');
224
+ return built.shell
225
+ ? spawnSync(built.command, { stdio: 'inherit', shell: true })
226
+ : spawnSync(built.command, built.args || [], { stdio: 'inherit' });
227
+ }
228
+
229
+ /** @param {string} name @returns {string|null} */
230
+ function defaultWhich(name) {
231
+ const { spawnSync } = require('node:child_process');
232
+ const finder = process.platform === 'win32' ? 'where' : 'which';
233
+ const r = spawnSync(finder, [name], { encoding: 'utf8' });
234
+ if (r.status !== 0 || !r.stdout) return null;
235
+ return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
236
+ }
237
+
238
+ /**
239
+ * @param {Partial<Deps>} deps
240
+ * @returns {Deps}
241
+ */
242
+ function withDefaults(deps) {
243
+ const env = deps.env || process.env;
244
+ return {
245
+ env,
246
+ home: deps.home || os.homedir(),
247
+ node: deps.node || process.execPath,
248
+ ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
249
+ platform: deps.platform || process.platform,
250
+ color: deps.color != null ? deps.color : (!!process.stdout.isTTY && !env.NO_COLOR),
251
+ out: deps.out || ((s) => { process.stdout.write(s); }),
252
+ err: deps.err || ((s) => { process.stderr.write(s); }),
253
+ which: deps.which || defaultWhich,
254
+ existsDir: deps.existsDir || ((dir) => { try { return require('node:fs').statSync(dir).isDirectory(); } catch { return false; } }),
255
+ listDir: deps.listDir || ((dir) => { try { return require('node:fs').readdirSync(dir); } catch { return []; } }),
256
+ ensureDir: deps.ensureDir || ensureSecureDir,
257
+ removeExited: deps.removeExited || ((dir) => { try { require('node:fs').rmSync(path.join(dir, 'exited'), { force: true }); } catch { /* best effort */ } }),
258
+ dropExited: deps.dropExited || ((dir) => { try { require('node:fs').writeFileSync(path.join(dir, 'exited'), ''); } catch { /* best effort */ } }),
259
+ writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
260
+ cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
261
+ spawnClaude: deps.spawnClaude || defaultSpawnClaude,
262
+ spawnCopy: deps.spawnCopy || ((cmd, args, input) => require('node:child_process').spawnSync(cmd, args, { input, stdio: ['pipe', 'ignore', 'ignore'] })),
263
+ };
264
+ }
265
+
266
+ /**
267
+ * @typedef {object} Deps
268
+ * @property {NodeJS.ProcessEnv} env
269
+ * @property {string} home
270
+ * @property {string} node
271
+ * @property {string} ccrJs
272
+ * @property {string} platform
273
+ * @property {boolean} color
274
+ * @property {(s: string) => void} out
275
+ * @property {(s: string) => void} err
276
+ * @property {(name: string) => (string|null)} which
277
+ * @property {(dir: string) => boolean} existsDir
278
+ * @property {(dir: string) => string[]} listDir
279
+ * @property {(dir: string) => void} ensureDir
280
+ * @property {(dir: string) => void} removeExited
281
+ * @property {(dir: string) => void} dropExited
282
+ * @property {(settings: object) => string} writeSettings
283
+ * @property {(file: string) => void} cleanup
284
+ * @property {(bin: string, args: string[]) => {status: number|null, error?: Error}} spawnClaude
285
+ * @property {(cmd: string, args: string[], input: string) => {status: number|null, error?: Error}} spawnCopy
286
+ */
287
+
288
+ module.exports = { splitKeybinding, sidecarPasteCommand, osc52, buildBanner, copyToClipboard, buildClaudeSpawn, run, hint };
@@ -0,0 +1,401 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ // src/launch-win.js — native-Windows launcher for `ccr` / `ccr <profile>`.
5
+ //
6
+ // Replaces the bash+tmux launch layer (scripts/launch.sh) on win32 by driving
7
+ // Windows Terminal (wt.exe) split panes: pane 0 runs Claude Code, pane 1 runs
8
+ // `ccr sidecar`, with CCR_STATE_DIR injected into both. See SPEC §4.2/§5.2.
9
+ //
10
+ // The pure, side-effect-free helpers (profile validation, state resolution,
11
+ // wt.exe argv construction, the injected wt.exe lookup) are unit-tested without
12
+ // spawning anything; run()/fallbackNoWt() drive the real side effects through
13
+ // the injectable Deps in withDefaults().
14
+
15
+ const path = require('node:path');
16
+ const os = require('node:os');
17
+
18
+ // Profile names land in filesystem paths and a spawned command, so allow only a
19
+ // safe identifier — identical to the allow-list in scripts/launch.sh.
20
+ const PROFILE_RE = /^[A-Za-z0-9._-]+$/;
21
+
22
+ // Characters we cannot safely embed in the per-pane `cmd /k` payload, even with
23
+ // every path wrapped in double quotes:
24
+ // " ends the quoted string;
25
+ // % triggers cmd.exe variable expansion (it fires even inside quotes);
26
+ // \r \n break the command line.
27
+ // By contrast & | < > ^ ( ) ARE literal inside the double quotes we wrap every
28
+ // value in, so they need no special handling. The trust boundary here is the
29
+ // user's own env/paths (self-injection, not RCE), so the goal is to turn a
30
+ // silently-broken — or, with %, a maliciously-expandable — command into a clear,
31
+ // actionable error rather than to defend against an attacker.
32
+ const WT_UNSAFE_RE = /["%\r\n]/;
33
+
34
+ /**
35
+ * @param {string} value
36
+ * @returns {boolean} true if `value` can be safely interpolated into the cmd /k payload
37
+ */
38
+ function isWtArgSafe(value) {
39
+ return !WT_UNSAFE_RE.test(String(value));
40
+ }
41
+
42
+ // Upstream default split: the sidecar gets ~34% of the width.
43
+ const DEFAULT_SIDEBAR_PCT = 34;
44
+
45
+ // Where the sidecar pane sits relative to Claude. 'right' is a vertical split
46
+ // (wt split-pane -V, the default — matches the width-based sizing above);
47
+ // 'bottom' is a horizontal split (-H). Set via CCR_SIDEBAR_SIDE.
48
+ const DEFAULT_SIDEBAR_SIDE = 'right';
49
+
50
+ /**
51
+ * Map a sidebar side ('right' | 'bottom') to the wt.exe split-pane flag.
52
+ * 'right' → '-V' (new pane to the right), 'bottom' → '-H' (new pane below).
53
+ * Anything unrecognized falls back to the default side.
54
+ *
55
+ * @param {string} [side]
56
+ * @returns {'-V'|'-H'}
57
+ */
58
+ function sidebarSplitFlag(side) {
59
+ const s = String(side || DEFAULT_SIDEBAR_SIDE).toLowerCase();
60
+ return s === 'bottom' ? '-H' : '-V';
61
+ }
62
+
63
+ /**
64
+ * @param {unknown} profile
65
+ * @returns {boolean} true if the profile name is a safe identifier
66
+ */
67
+ function validateProfile(profile) {
68
+ return typeof profile === 'string' && PROFILE_RE.test(profile);
69
+ }
70
+
71
+ /**
72
+ * Resolve the Claude command, tmux-equivalent session name, state dir, and (for
73
+ * profiles) the expected CCS instance dir. Mirrors scripts/launch.sh, honoring
74
+ * the CC_BIN / CCR_SESSION / CCR_STATE_DIR overrides.
75
+ *
76
+ * @param {string} [profile] CCS profile name, or undefined for plain `claude`
77
+ * @param {{ env?: NodeJS.ProcessEnv, home?: string }} [opts]
78
+ * @returns {{ ccCmd: string, session: string, stateDir: string,
79
+ * instanceDir: string|null, usesCcs: boolean }}
80
+ */
81
+ function resolveProfileState(profile, opts = {}) {
82
+ const env = opts.env || process.env;
83
+ const home = opts.home || os.homedir();
84
+
85
+ if (profile) {
86
+ return {
87
+ ccCmd: `ccs ${profile}`,
88
+ session: env.CCR_SESSION || `ccr-${profile}`,
89
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', profile),
90
+ instanceDir: path.join(home, '.ccs', 'instances', profile),
91
+ usesCcs: true,
92
+ };
93
+ }
94
+ return {
95
+ ccCmd: env.CC_BIN || 'claude',
96
+ session: env.CCR_SESSION || 'ccr',
97
+ stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
98
+ instanceDir: null,
99
+ usesCcs: false,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Convert a width percentage (e.g. 34) into the fraction string wt.exe's
105
+ * `split-pane -s` expects (e.g. "0.34"). Clamps to a sane 5..95 range.
106
+ *
107
+ * @param {number} [pct]
108
+ * @returns {string}
109
+ */
110
+ function sidebarFraction(pct) {
111
+ let p = Number(pct);
112
+ if (!Number.isFinite(p)) p = DEFAULT_SIDEBAR_PCT;
113
+ p = Math.min(95, Math.max(5, Math.round(p)));
114
+ // Strip any float noise: 34 -> "0.34", 50 -> "0.5".
115
+ return String(Math.round(p) / 100);
116
+ }
117
+
118
+ /**
119
+ * Build the per-pane cmd.exe payload. Env is injected with `set "VAR=val"` so a
120
+ * path with spaces (or & | < >, which are literal inside the quotes) is fine and
121
+ * no trailing space is captured. Callers MUST pre-validate `stateDir` with
122
+ * isWtArgSafe — `"` and `%` are NOT made safe by these quotes (see WT_UNSAFE_RE).
123
+ *
124
+ * @param {string} stateDir
125
+ * @param {string} body the command(s) to run after the env is set
126
+ * @returns {string}
127
+ */
128
+ function paneCommand(stateDir, body) {
129
+ return `set "CCR_STATE_DIR=${stateDir}"&& ${body}`;
130
+ }
131
+
132
+ /**
133
+ * Build the argv passed to wt.exe (excluding the wt.exe path itself):
134
+ * new-tab --title Claude cmd /k "<pane0>" ; split-pane -H -s <frac> cmd /k "<pane1>"
135
+ *
136
+ * The ";" pane separator is its own argv token (wt re-parses it). Per-pane env
137
+ * is injected via `cmd /k set ...` rather than wt global env. After Claude
138
+ * exits, pane 0 (unconditional `&`) drops the `exited` sentinel (so the sidecar
139
+ * can show a clean "session ended" state) and deletes the temp settings file
140
+ * (cleanup-after-window-closes; the file was only needed at Claude startup).
141
+ *
142
+ * Throws if any interpolated value contains a character that would break (or, in
143
+ * the case of %, hijack) the cmd /k payload — see isWtArgSafe. run() catches
144
+ * this and reports a clean error instead of spawning a broken command.
145
+ *
146
+ * @param {{ ccCmd: string, settingsFile: string, stateDir: string,
147
+ * node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string }} o
148
+ * @returns {string[]}
149
+ */
150
+ function buildWtArgs(o) {
151
+ const { ccCmd, settingsFile, stateDir, node, ccrJs } = o;
152
+ for (const [label, value] of [
153
+ ['profile/state dir', stateDir],
154
+ ['settings file path', settingsFile],
155
+ ['claude command', ccCmd],
156
+ ['node path', node],
157
+ ['ccr.js path', ccrJs],
158
+ ]) {
159
+ if (!isWtArgSafe(value)) {
160
+ throw new Error(
161
+ `cannot launch: ${label} contains an unsupported character (" or %) for the ` +
162
+ `Windows Terminal launcher: ${value}`,
163
+ );
164
+ }
165
+ }
166
+ const frac = sidebarFraction(o.sidebarPct);
167
+ const splitFlag = sidebarSplitFlag(o.sidebarSide);
168
+ const exited = path.win32.join(stateDir, 'exited');
169
+
170
+ const pane0 = paneCommand(
171
+ stateDir,
172
+ `${ccCmd} --settings "${settingsFile}" & type nul > "${exited}" & del /q "${settingsFile}"`,
173
+ );
174
+ const pane1 = paneCommand(stateDir, `"${node}" "${ccrJs}" sidecar`);
175
+
176
+ return [
177
+ 'new-tab', '--title', 'Claude', 'cmd', '/k', pane0,
178
+ ';',
179
+ 'split-pane', splitFlag, '-s', frac, 'cmd', '/k', pane1,
180
+ ];
181
+ }
182
+
183
+ /**
184
+ * Resolve wt.exe via `where`, or return null if absent. The lookup is injected
185
+ * so this stays unit-testable; the default shells out to `where`.
186
+ *
187
+ * @param {{ runWhere?: (name: string) => (string|null) }} [opts]
188
+ * @returns {string|null}
189
+ */
190
+ function findWindowsTerminal(opts = {}) {
191
+ const lookup = opts.runWhere || defaultWhere;
192
+ return lookup('wt') || lookup('wt.exe') || null;
193
+ }
194
+
195
+ /**
196
+ * @param {string} name
197
+ * @returns {string|null} first match path, or null
198
+ */
199
+ function defaultWhere(name) {
200
+ const { spawnSync } = require('node:child_process');
201
+ const r = spawnSync('where', [name], { encoding: 'utf8' });
202
+ if (r.status !== 0 || !r.stdout) return null;
203
+ const first = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
204
+ return first || null;
205
+ }
206
+
207
+ const inject = require('./settings-inject');
208
+ const { ensureSecureDir } = require('./state-dir');
209
+
210
+ /**
211
+ * Fill in real-environment implementations for anything the caller didn't
212
+ * inject. Every external effect (PATH lookups, fs, spawn, output) flows through
213
+ * here so run() can be unit-tested with pure stand-ins.
214
+ *
215
+ * @param {Partial<Deps>} deps
216
+ * @returns {Deps}
217
+ */
218
+ function withDefaults(deps) {
219
+ const env = deps.env || process.env;
220
+ const home = deps.home || os.homedir();
221
+ return {
222
+ env,
223
+ home,
224
+ node: deps.node || process.execPath,
225
+ ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
226
+ out: deps.out || ((s) => { process.stdout.write(s); }),
227
+ err: deps.err || ((s) => { process.stderr.write(s); }),
228
+ findWt: deps.findWt || (() => findWindowsTerminal()),
229
+ which: deps.which || defaultWhere,
230
+ existsDir: deps.existsDir || defaultExistsDir,
231
+ listDir: deps.listDir || defaultListDir,
232
+ ensureDir: deps.ensureDir || ensureSecureDir,
233
+ removeExited: deps.removeExited || defaultRemoveExited,
234
+ writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
235
+ cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
236
+ spawnWt: deps.spawnWt || defaultSpawnWt,
237
+ };
238
+ }
239
+
240
+ /** @param {string} dir @returns {boolean} */
241
+ function defaultExistsDir(dir) {
242
+ try {
243
+ return require('node:fs').statSync(dir).isDirectory();
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /** @param {string} dir @returns {string[]} */
250
+ function defaultListDir(dir) {
251
+ try {
252
+ return require('node:fs').readdirSync(dir);
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
258
+ /** @param {string} stateDir @returns {void} */
259
+ function defaultRemoveExited(stateDir) {
260
+ try {
261
+ require('node:fs').rmSync(path.join(stateDir, 'exited'), { force: true });
262
+ } catch {
263
+ // best effort
264
+ }
265
+ }
266
+
267
+ /** @param {string} wt @param {string[]} args @returns {{status:number|null, error?:Error}} */
268
+ function defaultSpawnWt(wt, args) {
269
+ return require('node:child_process').spawnSync(wt, args, { stdio: 'ignore' });
270
+ }
271
+
272
+ /**
273
+ * The graceful no-Windows-Terminal fallback: keep the working native CLI usable
274
+ * instead of dead-ending. Returns 1 (no crash, no stack trace). SPEC §6 / §8.7.
275
+ *
276
+ * @param {{ err: (s: string) => void }} d
277
+ * @returns {number}
278
+ */
279
+ function fallbackNoWt(d) {
280
+ d.err(
281
+ 'ccr: Windows Terminal (wt.exe) not found — it hosts the live sidecar.\n' +
282
+ ' Install it: winget install Microsoft.WindowsTerminal\n' +
283
+ ' Until then these native commands work without it:\n' +
284
+ ' ccr economy one-off economy panel\n' +
285
+ " ccr statusline wire into Claude Code's statusLine\n" +
286
+ ' ccr doctor check your setup\n');
287
+ return 1;
288
+ }
289
+
290
+ /**
291
+ * `ccr [profile]` on native Windows: split a Windows Terminal window into
292
+ * Claude Code + ccr sidecar. Implements SPEC §4.2 steps 1–7. Returns an exit
293
+ * code. All side effects are injectable for testing (see withDefaults).
294
+ *
295
+ * @param {string} [profile]
296
+ * @param {Partial<Deps>} [deps]
297
+ * @returns {number}
298
+ */
299
+ function run(profile, deps = {}) {
300
+ const d = withDefaults(deps);
301
+
302
+ // 1. Validate the profile (it lands in paths and a spawned command).
303
+ if (profile !== undefined && !validateProfile(profile)) {
304
+ d.err(`ccr: invalid profile name '${profile}' (allowed: letters, digits, . _ -)\n`);
305
+ return 1;
306
+ }
307
+
308
+ // 2. Require Windows Terminal, else fall back gracefully.
309
+ const wt = d.findWt();
310
+ if (!wt) return fallbackNoWt(d);
311
+
312
+ // 3. Resolve profile state + required binaries.
313
+ const st = resolveProfileState(profile, { env: d.env, home: d.home });
314
+ if (st.usesCcs) {
315
+ if (!d.which('ccs')) {
316
+ d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
317
+ return 1;
318
+ }
319
+ if (st.instanceDir && !d.existsDir(st.instanceDir)) {
320
+ d.err(`ccr: CCS profile '${profile}' not found (${st.instanceDir}).\n`);
321
+ const avail = d.listDir(path.join(d.home, '.ccs', 'instances')).join(' ');
322
+ d.err(` available: ${avail}\n`);
323
+ return 1;
324
+ }
325
+ } else {
326
+ const bin = st.ccCmd.split(' ')[0];
327
+ if (!d.which(bin)) {
328
+ d.err(`ccr: '${bin}' not found on PATH.\n`);
329
+ return 1;
330
+ }
331
+ }
332
+
333
+ // 4. Prepare the per-profile state dir; clear a stale sentinel.
334
+ try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
335
+ d.removeExited(st.stateDir);
336
+
337
+ // 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
338
+ const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
339
+ const settingsFile = d.writeSettings(inject.buildSettings(command));
340
+
341
+ // 6. Build + spawn the wt.exe command (CCR_STATE_DIR injected per-pane).
342
+ const pct = parseInt(String(d.env.CCR_SIDEBAR_PCT), 10);
343
+ let args;
344
+ try {
345
+ args = buildWtArgs({
346
+ ccCmd: st.ccCmd,
347
+ settingsFile,
348
+ stateDir: st.stateDir,
349
+ node: d.node,
350
+ ccrJs: d.ccrJs,
351
+ sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
352
+ sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
353
+ });
354
+ } catch (e) {
355
+ d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
356
+ d.cleanup(settingsFile); // window never opened → nothing else will clean up
357
+ return 1;
358
+ }
359
+ const r = d.spawnWt(wt, args);
360
+ if (r.error) {
361
+ d.err(`ccr: failed to launch Windows Terminal: ${r.error.message}\n`);
362
+ d.cleanup(settingsFile); // window never opened → pane 0 can't clean up
363
+ return 1;
364
+ }
365
+ // 7. On success the settings file is cleaned up by pane 0 when Claude exits.
366
+ return typeof r.status === 'number' ? r.status : 0;
367
+ }
368
+
369
+ /**
370
+ * @typedef {object} Deps
371
+ * @property {NodeJS.ProcessEnv} env
372
+ * @property {string} home
373
+ * @property {string} node
374
+ * @property {string} ccrJs
375
+ * @property {(s: string) => void} out
376
+ * @property {(s: string) => void} err
377
+ * @property {() => (string|null)} findWt
378
+ * @property {(name: string) => (string|null)} which
379
+ * @property {(dir: string) => boolean} existsDir
380
+ * @property {(dir: string) => string[]} listDir
381
+ * @property {(dir: string) => void} ensureDir
382
+ * @property {(dir: string) => void} removeExited
383
+ * @property {(settings: object) => string} writeSettings
384
+ * @property {(file: string) => void} cleanup
385
+ * @property {(wt: string, args: string[]) => {status: number|null, error?: Error}} spawnWt
386
+ */
387
+
388
+ module.exports = {
389
+ PROFILE_RE,
390
+ DEFAULT_SIDEBAR_PCT,
391
+ DEFAULT_SIDEBAR_SIDE,
392
+ validateProfile,
393
+ isWtArgSafe,
394
+ resolveProfileState,
395
+ sidebarFraction,
396
+ sidebarSplitFlag,
397
+ buildWtArgs,
398
+ findWindowsTerminal,
399
+ run,
400
+ fallbackNoWt,
401
+ };
@@ -15,7 +15,11 @@ const { dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins,
15
15
  const bandColor = { imminent: red, warn: yellow, ok: cyan };
16
16
 
17
17
  function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW) {
18
- const used = Math.round(row.est.usedPct);
18
+ // Truncate, don't round: Claude's own surfaces (`/usage`, claude.ai usage)
19
+ // floor the fractional `used_percentage` (e.g. 41.6 → "41%"). Math.round here
20
+ // read ~1pt high on values past the half-point. Display only — the burn/ROI
21
+ // math below still uses the raw fractional `row.est.usedPct`.
22
+ const used = Math.floor(row.est.usedPct);
19
23
  const ml = row.est.minutesLeft;
20
24
  const b = band(ml);
21
25
  // Per-row colour dot: green when the window resets before you'd hit it,
@@ -21,6 +21,34 @@ function bar(/** @type {number} */ p, w = 10) {
21
21
  return '▓'.repeat(f) + '░'.repeat(w - f);
22
22
  }
23
23
 
24
+ /**
25
+ * Clamp one line to `cols` visible columns: SGR escapes (`\x1b[…m`) pass through
26
+ * with zero width, printable chars count as 1. Appends a reset if it had to cut,
27
+ * so a severed colour run doesn't bleed into the cleared tail. Prevents the soft
28
+ * wrap that corrupts the sidecar's cursor-home redraw in a narrow pane. A
29
+ * non-positive `cols` (e.g. a non-TTY where columns is undefined) is a no-op.
30
+ * @param {string} line
31
+ * @param {number} [cols]
32
+ * @returns {string}
33
+ */
34
+ function clampVisible(line, cols) {
35
+ if (!(typeof cols === 'number' && cols > 0)) return line;
36
+ const sgr = /\x1b\[[0-9;]*m/y;
37
+ let out = '';
38
+ let width = 0;
39
+ let i = 0;
40
+ while (i < line.length) {
41
+ sgr.lastIndex = i;
42
+ const m = sgr.exec(line);
43
+ if (m) { out += m[0]; i = sgr.lastIndex; continue; }
44
+ if (width >= cols) return out + '\x1b[0m';
45
+ out += line[i];
46
+ width += 1;
47
+ i += 1;
48
+ }
49
+ return out;
50
+ }
51
+
24
52
  function tok(/** @type {number|null} */ n) {
25
53
  if (n == null) return '?';
26
54
  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
@@ -46,4 +74,4 @@ function fmtReset(/** @type {number|null} */ min) {
46
74
  return `${m}m`;
47
75
  }
48
76
 
49
- module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins, fmtReset };
77
+ module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset };
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ // src/settings-inject.js — per-launch statusLine injection (SPEC §4.2 step 5, §5.3).
5
+ //
6
+ // Claude Code must call `ccr statusline` on every status tick WITHOUT mutating
7
+ // any file under ~/.claude. We achieve that exactly like upstream launch.sh:
8
+ // write a throwaway settings object to a temp file and pass it to
9
+ // `claude --settings <file>`. A FILE (not inline --settings '{...}') sidesteps
10
+ // the Windows command-line JSON-quoting minefield.
11
+ //
12
+ // The statusLine `command` value is the inline form: node + bin/ccr.js resolved
13
+ // by absolute path. Because it lives inside the JSON settings file (never on a
14
+ // shell line), no shell-quoting is involved and no separate shim file is needed.
15
+
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+ const crypto = require('node:crypto');
20
+
21
+ /**
22
+ * Inline statusLine command: resolve node + ccr.js by path. Quoting is safe
23
+ * because this value lives inside the JSON settings FILE, not on a shell line.
24
+ *
25
+ * @param {{ node: string, ccrJs: string }} o
26
+ * @returns {string}
27
+ */
28
+ function buildStatusLineCommandInline(o) {
29
+ return `"${o.node}" "${o.ccrJs}" statusline`;
30
+ }
31
+
32
+ /**
33
+ * The settings object Claude Code consumes via --settings.
34
+ *
35
+ * @param {string} command the statusLine command value
36
+ * @returns {{ statusLine: { type: 'command', command: string } }}
37
+ */
38
+ function buildSettings(command) {
39
+ return { statusLine: { type: 'command', command } };
40
+ }
41
+
42
+ /**
43
+ * Write the settings object to a uniquely-named temp file and return its path.
44
+ * The file lives under the temp dir only — never under ~/.claude.
45
+ *
46
+ * @param {object} settings
47
+ * @param {{ tmpDir?: string, rand?: string }} [opts]
48
+ * @returns {string} absolute path to the written settings file
49
+ */
50
+ function writeSettingsFile(settings, opts = {}) {
51
+ const dir = opts.tmpDir || os.tmpdir();
52
+ const rand = opts.rand || crypto.randomBytes(4).toString('hex');
53
+ const file = path.join(dir, `ccr-settings-${rand}.json`);
54
+ fs.writeFileSync(file, JSON.stringify(settings), { encoding: 'utf8' });
55
+ return file;
56
+ }
57
+
58
+ /**
59
+ * Best-effort removal of the temp settings file. Never throws.
60
+ *
61
+ * @param {string} file
62
+ * @returns {void}
63
+ */
64
+ function cleanupSettingsFile(file) {
65
+ try {
66
+ fs.rmSync(file, { force: true });
67
+ } catch {
68
+ // best-effort: a leftover temp file is harmless.
69
+ }
70
+ }
71
+
72
+ module.exports = {
73
+ buildStatusLineCommandInline,
74
+ buildSettings,
75
+ writeSettingsFile,
76
+ cleanupSettingsFile,
77
+ };
package/src/sidecar.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/sidecar.js — the live economy panel that runs in the tmux sidebar.
4
4
  // Reads the per-session snapshot that `ccr statusline` writes (CCR_STATE_DIR),
5
5
  // re-renders the economy screen every second (so the imminent band flashes),
6
- // and shows a clean ended/​waiting state. Pure Node, zero dependencies.
6
+ // and shows a clean ended/waiting state. Pure Node, zero dependencies.
7
7
 
8
8
  const fs = require('node:fs');
9
9
  const path = require('node:path');
@@ -11,11 +11,10 @@ const os = require('node:os');
11
11
  const { normalizeStatus } = require('./normalize');
12
12
  const { renderEconomy } = require('./render/economy');
13
13
  const { renderFeed } = require('./render/feed');
14
+ const { clampVisible } = require('./render/shared');
14
15
  const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcripts');
15
16
 
16
17
  const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
17
- const SNAPSHOT = path.join(STATE_DIR, 'last-status.json');
18
- const EXITED = path.join(STATE_DIR, 'exited');
19
18
 
20
19
  // Live feed accumulator: tail the current transcript incrementally (by byte
21
20
  // offset) and roll up tool/skill events + per-session stats. Reset on session
@@ -57,28 +56,58 @@ function draw(/** @type {string} */ s) {
57
56
  process.stdout.write('\x1b[H' + s.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
58
57
  }
59
58
 
60
- function frame() {
61
- if (fs.existsSync(EXITED)) { draw(bold('ccr') + ' ' + dim('session ended') + '\n'); return; }
59
+ /**
60
+ * Compose the screen for one tick — the ended / waiting / unreadable / live
61
+ * states — and return it as a string (no I/O to stdout). Pure enough to test:
62
+ * the only inputs are the state dir on disk, `now`, and the pane width `cols`.
63
+ *
64
+ * `cols` is the pane's visible column count (process.stdout.columns); every line
65
+ * is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
66
+ * redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
67
+ *
68
+ * @param {string} stateDir
69
+ * @param {{ now?: number, cols?: number }} [opts]
70
+ * @returns {string}
71
+ */
72
+ function composeFrame(stateDir, opts = {}) {
73
+ const now = opts.now != null ? opts.now : Date.now();
74
+ const cols = opts.cols;
75
+ const clamp = (/** @type {string} */ s) =>
76
+ (typeof cols === 'number' && cols > 0
77
+ ? s.split('\n').map((l) => clampVisible(l, cols)).join('\n')
78
+ : s);
79
+ const snapshot = path.join(stateDir, 'last-status.json');
80
+ const exited = path.join(stateDir, 'exited');
81
+
82
+ if (fs.existsSync(exited)) return clamp(bold('ccr') + ' ' + dim('session ended') + '\n');
62
83
  let raw = '';
63
- try { raw = fs.readFileSync(SNAPSHOT, 'utf8'); } catch { /* none yet */ }
64
- if (!raw.trim()) { draw(dim('ccr · waiting for the first status tick…') + '\n'); return; }
84
+ try { raw = fs.readFileSync(snapshot, 'utf8'); } catch { /* none yet */ }
85
+ if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
65
86
  let state;
66
- try { state = JSON.parse(raw); } catch { draw(dim('ccr · status unreadable') + '\n'); return; }
87
+ try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
67
88
  let out;
68
89
  try {
69
- out = renderEconomy(normalizeStatus(state), { tick: Math.floor(Date.now() / 1000) % 2 === 0 });
90
+ out = renderEconomy(normalizeStatus(state), { tick: Math.floor(now / 1000) % 2 === 0 });
70
91
  } catch (e) {
71
92
  out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
72
93
  }
73
94
  // Live tool/skills feed below the panel — best-effort; never break the panel.
95
+ // Its inner width tracks the pane so args truncate cleanly (the clamp below is
96
+ // the hard safety net regardless).
74
97
  try {
75
98
  const tpath = currentTranscriptPath(state);
76
99
  if (tpath) {
77
- const feedStr = renderFeed(updateFeed(tpath), { max: 6 });
100
+ const feedWidth = typeof cols === 'number' && cols > 0 ? Math.max(20, Math.min(48, cols - 2)) : 48;
101
+ const feedStr = renderFeed(updateFeed(tpath), { max: 6, width: feedWidth });
78
102
  if (feedStr) out += '\n\n' + feedStr;
79
103
  }
80
104
  } catch { /* feed is optional */ }
81
- draw(out.endsWith('\n') ? out : out + '\n');
105
+ return clamp(out.endsWith('\n') ? out : out + '\n');
106
+ }
107
+
108
+ function frame() {
109
+ // Read columns each tick so a live resize re-flows on the next frame.
110
+ draw(composeFrame(STATE_DIR, { now: Date.now(), cols: process.stdout.columns }));
82
111
  }
83
112
 
84
113
  function run() {
@@ -89,6 +118,7 @@ function run() {
89
118
  process.on('SIGTERM', stop);
90
119
  }
91
120
 
92
- // `updateFeed` is exported for tests (the incremental tail + session-switch
93
- // reset is the subtle part); the live loop uses `run`.
94
- module.exports = { run, updateFeed };
121
+ // `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
122
+ // session-switch reset and the ended/waiting/render states are the subtle
123
+ // parts); the live loop uses `run`.
124
+ module.exports = { run, updateFeed, composeFrame };