claude-code-runrate 0.1.0 → 0.2.1
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 +35 -5
- package/bin/ccr.js +35 -13
- package/package.json +11 -4
- package/src/doctor.js +33 -23
- package/src/launch-vscode.js +288 -0
- package/src/launch-win.js +457 -0
- package/src/render/economy.js +5 -1
- package/src/render/shared.js +29 -1
- package/src/settings-inject.js +77 -0
- package/src/sidecar.js +133 -20
|
@@ -0,0 +1,457 @@
|
|
|
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
|
+
* Compute the column budget for the sidecar pane so it can clamp every line and
|
|
134
|
+
* never soft-wrap. `process.stdout.columns` inside the `cmd /c` conpty pane is
|
|
135
|
+
* unreliable (often undefined or the FULL window width, not the narrow split),
|
|
136
|
+
* so the launcher — whose own stdout DOES report the live terminal width —
|
|
137
|
+
* computes the pane width here and injects it as CCR_SIDECAR_COLS.
|
|
138
|
+
*
|
|
139
|
+
* A vertical split (-V, sidebar on the right) gets `frac` of the width, minus one
|
|
140
|
+
* column for the pane divider. A horizontal split (-H, sidebar on the bottom)
|
|
141
|
+
* keeps the full width. Returns null when the terminal width is unknown (non-TTY
|
|
142
|
+
* launch) — the sidecar then falls back to whatever it can detect.
|
|
143
|
+
*
|
|
144
|
+
* @param {number|undefined} termCols the launcher's own terminal width
|
|
145
|
+
* @param {number} fracNum the sidebar fraction (e.g. 0.34)
|
|
146
|
+
* @param {'-V'|'-H'} splitFlag
|
|
147
|
+
* @returns {number|null}
|
|
148
|
+
*/
|
|
149
|
+
function sidecarCols(termCols, fracNum, splitFlag) {
|
|
150
|
+
const t = Number(termCols);
|
|
151
|
+
if (!Number.isFinite(t) || t <= 0) return null;
|
|
152
|
+
if (splitFlag === '-H') return Math.max(20, Math.floor(t)); // bottom split keeps full width
|
|
153
|
+
return Math.max(20, Math.floor(t * fracNum) - 1); // right split: fraction, less the divider
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the argv passed to wt.exe (excluding the wt.exe path itself):
|
|
158
|
+
* -w 0 new-tab --title Claude cmd /c "<pane0>" ; split-pane -H -s <frac> cmd /c "<pane1>"
|
|
159
|
+
*
|
|
160
|
+
* `-w 0` targets the CURRENT Windows Terminal window (open a tab in the window
|
|
161
|
+
* you ran `ccr` from) instead of spawning a separate window. The ";" pane
|
|
162
|
+
* separator is its own argv token (wt re-parses it); per-pane env is injected via
|
|
163
|
+
* `cmd /c set ...` rather than wt global env.
|
|
164
|
+
*
|
|
165
|
+
* Teardown mirrors the tmux launcher's sweep (launch.sh: `… ; touch exited; …;
|
|
166
|
+
* kill-session`): both panes run under `cmd /c`, so each closes when its command
|
|
167
|
+
* ends. On Claude exit pane 0 drops the `exited` sentinel and deletes the temp
|
|
168
|
+
* settings file; the sidecar runs with `--exit-on-end` so it detects the exit
|
|
169
|
+
* within ~120ms and closes pane 1 after a ~200ms grace (see sidecar.run).
|
|
170
|
+
*
|
|
171
|
+
* Close ORDER drives the sweep: pane 0 then lingers (SWEEP_LINGER ≈ 1s) past the
|
|
172
|
+
* sidecar's ~320ms close so the RIGHT pane collapses FIRST and Claude expands
|
|
173
|
+
* rightward to fill it — the sidebar border sweeps left→right and the tab folds,
|
|
174
|
+
* instead of pane 0 vanishing first and the sidecar ballooning right→left.
|
|
175
|
+
*
|
|
176
|
+
* Throws if any interpolated value contains a character that would break (or, in
|
|
177
|
+
* the case of %, hijack) the cmd /c payload — see isWtArgSafe. run() catches
|
|
178
|
+
* this and reports a clean error instead of spawning a broken command.
|
|
179
|
+
*
|
|
180
|
+
* @param {{ ccCmd: string, settingsFile: string, stateDir: string,
|
|
181
|
+
* node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
|
|
182
|
+
* termCols?: number }} o
|
|
183
|
+
* @returns {string[]}
|
|
184
|
+
*/
|
|
185
|
+
function buildWtArgs(o) {
|
|
186
|
+
const { ccCmd, settingsFile, stateDir, node, ccrJs } = o;
|
|
187
|
+
for (const [label, value] of [
|
|
188
|
+
['profile/state dir', stateDir],
|
|
189
|
+
['settings file path', settingsFile],
|
|
190
|
+
['claude command', ccCmd],
|
|
191
|
+
['node path', node],
|
|
192
|
+
['ccr.js path', ccrJs],
|
|
193
|
+
]) {
|
|
194
|
+
if (!isWtArgSafe(value)) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`cannot launch: ${label} contains an unsupported character (" or %) for the ` +
|
|
197
|
+
`Windows Terminal launcher: ${value}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const frac = sidebarFraction(o.sidebarPct);
|
|
202
|
+
const splitFlag = sidebarSplitFlag(o.sidebarSide);
|
|
203
|
+
const exited = path.win32.join(stateDir, 'exited');
|
|
204
|
+
|
|
205
|
+
// After Claude exits we drop the sentinel + clean the settings file, then idle
|
|
206
|
+
// ~1s so pane 1 (which now detects the exit within ~120ms and closes after a
|
|
207
|
+
// ~200ms grace) folds FIRST and the border sweeps left→right. `ping -n 2`
|
|
208
|
+
// loopback is a reliable ~1s — its fixed inter-ping gap can't undershoot the
|
|
209
|
+
// sidecar's close the way `timeout /t 1` (0–1s, second-aligned) could, and it
|
|
210
|
+
// has none of `timeout`'s stdin quirks. ~1s is the floor: cmd.exe's wait
|
|
211
|
+
// primitives have 1-second granularity, so a tighter robust delay isn't
|
|
212
|
+
// available without fragile tricks. Silent under `>nul`.
|
|
213
|
+
const SWEEP_LINGER = 'ping -n 2 127.0.0.1 >nul';
|
|
214
|
+
const pane0 = paneCommand(
|
|
215
|
+
stateDir,
|
|
216
|
+
`${ccCmd} --settings "${settingsFile}" & type nul > "${exited}" & del /q "${settingsFile}" & ${SWEEP_LINGER}`,
|
|
217
|
+
);
|
|
218
|
+
// Inject the computed pane width so the sidecar clamps cleanly even when its own
|
|
219
|
+
// process.stdout.columns is unreliable inside the cmd /c conpty pane.
|
|
220
|
+
const cols = sidecarCols(o.termCols, Number(frac), splitFlag);
|
|
221
|
+
const sidecarBody =
|
|
222
|
+
(cols != null ? `set "CCR_SIDECAR_COLS=${cols}"&& ` : '') +
|
|
223
|
+
`"${node}" "${ccrJs}" sidecar --exit-on-end`;
|
|
224
|
+
const pane1 = paneCommand(stateDir, sidecarBody);
|
|
225
|
+
|
|
226
|
+
return [
|
|
227
|
+
'-w', '0', 'new-tab', '--title', 'Claude', 'cmd', '/c', pane0,
|
|
228
|
+
';',
|
|
229
|
+
'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve wt.exe via `where`, or return null if absent. The lookup is injected
|
|
235
|
+
* so this stays unit-testable; the default shells out to `where`.
|
|
236
|
+
*
|
|
237
|
+
* @param {{ runWhere?: (name: string) => (string|null) }} [opts]
|
|
238
|
+
* @returns {string|null}
|
|
239
|
+
*/
|
|
240
|
+
function findWindowsTerminal(opts = {}) {
|
|
241
|
+
const lookup = opts.runWhere || defaultWhere;
|
|
242
|
+
return lookup('wt') || lookup('wt.exe') || null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} name
|
|
247
|
+
* @returns {string|null} first match path, or null
|
|
248
|
+
*/
|
|
249
|
+
function defaultWhere(name) {
|
|
250
|
+
const { spawnSync } = require('node:child_process');
|
|
251
|
+
const r = spawnSync('where', [name], { encoding: 'utf8' });
|
|
252
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
253
|
+
const first = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
|
|
254
|
+
return first || null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const inject = require('./settings-inject');
|
|
258
|
+
const { ensureSecureDir } = require('./state-dir');
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Fill in real-environment implementations for anything the caller didn't
|
|
262
|
+
* inject. Every external effect (PATH lookups, fs, spawn, output) flows through
|
|
263
|
+
* here so run() can be unit-tested with pure stand-ins.
|
|
264
|
+
*
|
|
265
|
+
* @param {Partial<Deps>} deps
|
|
266
|
+
* @returns {Deps}
|
|
267
|
+
*/
|
|
268
|
+
function withDefaults(deps) {
|
|
269
|
+
const env = deps.env || process.env;
|
|
270
|
+
const home = deps.home || os.homedir();
|
|
271
|
+
return {
|
|
272
|
+
env,
|
|
273
|
+
home,
|
|
274
|
+
// The launcher's own stdout reports the live terminal width — the sidecar's
|
|
275
|
+
// does not, inside its cmd /c pane (see sidecarCols). undefined on non-TTY.
|
|
276
|
+
cols: deps.cols != null ? deps.cols : process.stdout.columns,
|
|
277
|
+
node: deps.node || process.execPath,
|
|
278
|
+
ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
|
|
279
|
+
out: deps.out || ((s) => { process.stdout.write(s); }),
|
|
280
|
+
err: deps.err || ((s) => { process.stderr.write(s); }),
|
|
281
|
+
findWt: deps.findWt || (() => findWindowsTerminal()),
|
|
282
|
+
which: deps.which || defaultWhere,
|
|
283
|
+
existsDir: deps.existsDir || defaultExistsDir,
|
|
284
|
+
listDir: deps.listDir || defaultListDir,
|
|
285
|
+
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
286
|
+
removeExited: deps.removeExited || defaultRemoveExited,
|
|
287
|
+
writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
|
|
288
|
+
cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
|
|
289
|
+
spawnWt: deps.spawnWt || defaultSpawnWt,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** @param {string} dir @returns {boolean} */
|
|
294
|
+
function defaultExistsDir(dir) {
|
|
295
|
+
try {
|
|
296
|
+
return require('node:fs').statSync(dir).isDirectory();
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @param {string} dir @returns {string[]} */
|
|
303
|
+
function defaultListDir(dir) {
|
|
304
|
+
try {
|
|
305
|
+
return require('node:fs').readdirSync(dir);
|
|
306
|
+
} catch {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** @param {string} stateDir @returns {void} */
|
|
312
|
+
function defaultRemoveExited(stateDir) {
|
|
313
|
+
try {
|
|
314
|
+
require('node:fs').rmSync(path.join(stateDir, 'exited'), { force: true });
|
|
315
|
+
} catch {
|
|
316
|
+
// best effort
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** @param {string} wt @param {string[]} args @returns {{status:number|null, error?:Error}} */
|
|
321
|
+
function defaultSpawnWt(wt, args) {
|
|
322
|
+
return require('node:child_process').spawnSync(wt, args, { stdio: 'ignore' });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The graceful no-Windows-Terminal fallback: keep the working native CLI usable
|
|
327
|
+
* instead of dead-ending. Returns 1 (no crash, no stack trace). SPEC §6 / §8.7.
|
|
328
|
+
*
|
|
329
|
+
* @param {{ err: (s: string) => void }} d
|
|
330
|
+
* @returns {number}
|
|
331
|
+
*/
|
|
332
|
+
function fallbackNoWt(d) {
|
|
333
|
+
d.err(
|
|
334
|
+
'ccr: Windows Terminal (wt.exe) not found — it hosts the live sidecar.\n' +
|
|
335
|
+
' Install it: winget install Microsoft.WindowsTerminal\n' +
|
|
336
|
+
' Until then these native commands work without it:\n' +
|
|
337
|
+
' ccr economy one-off economy panel\n' +
|
|
338
|
+
" ccr statusline wire into Claude Code's statusLine\n" +
|
|
339
|
+
' ccr doctor check your setup\n');
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `ccr [profile]` on native Windows: split a Windows Terminal window into
|
|
345
|
+
* Claude Code + ccr sidecar. Implements SPEC §4.2 steps 1–7. Returns an exit
|
|
346
|
+
* code. All side effects are injectable for testing (see withDefaults).
|
|
347
|
+
*
|
|
348
|
+
* @param {string} [profile]
|
|
349
|
+
* @param {Partial<Deps>} [deps]
|
|
350
|
+
* @returns {number}
|
|
351
|
+
*/
|
|
352
|
+
function run(profile, deps = {}) {
|
|
353
|
+
const d = withDefaults(deps);
|
|
354
|
+
|
|
355
|
+
// 1. Validate the profile (it lands in paths and a spawned command).
|
|
356
|
+
if (profile !== undefined && !validateProfile(profile)) {
|
|
357
|
+
d.err(`ccr: invalid profile name '${profile}' (allowed: letters, digits, . _ -)\n`);
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 2. Require Windows Terminal, else fall back gracefully.
|
|
362
|
+
const wt = d.findWt();
|
|
363
|
+
if (!wt) return fallbackNoWt(d);
|
|
364
|
+
|
|
365
|
+
// 3. Resolve profile state + required binaries.
|
|
366
|
+
const st = resolveProfileState(profile, { env: d.env, home: d.home });
|
|
367
|
+
if (st.usesCcs) {
|
|
368
|
+
if (!d.which('ccs')) {
|
|
369
|
+
d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
if (st.instanceDir && !d.existsDir(st.instanceDir)) {
|
|
373
|
+
d.err(`ccr: CCS profile '${profile}' not found (${st.instanceDir}).\n`);
|
|
374
|
+
const avail = d.listDir(path.join(d.home, '.ccs', 'instances')).join(' ');
|
|
375
|
+
d.err(` available: ${avail}\n`);
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
const bin = st.ccCmd.split(' ')[0];
|
|
380
|
+
if (!d.which(bin)) {
|
|
381
|
+
d.err(`ccr: '${bin}' not found on PATH.\n`);
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 4. Prepare the per-profile state dir; clear a stale sentinel.
|
|
387
|
+
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
388
|
+
d.removeExited(st.stateDir);
|
|
389
|
+
|
|
390
|
+
// 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
|
|
391
|
+
const command = inject.buildStatusLineCommandInline({ node: d.node, ccrJs: d.ccrJs });
|
|
392
|
+
const settingsFile = d.writeSettings(inject.buildSettings(command));
|
|
393
|
+
|
|
394
|
+
// 6. Build + spawn the wt.exe command (CCR_STATE_DIR injected per-pane).
|
|
395
|
+
const pct = parseInt(String(d.env.CCR_SIDEBAR_PCT), 10);
|
|
396
|
+
let args;
|
|
397
|
+
try {
|
|
398
|
+
args = buildWtArgs({
|
|
399
|
+
ccCmd: st.ccCmd,
|
|
400
|
+
settingsFile,
|
|
401
|
+
stateDir: st.stateDir,
|
|
402
|
+
node: d.node,
|
|
403
|
+
ccrJs: d.ccrJs,
|
|
404
|
+
sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
|
|
405
|
+
sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
|
|
406
|
+
termCols: d.cols,
|
|
407
|
+
});
|
|
408
|
+
} catch (e) {
|
|
409
|
+
d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
410
|
+
d.cleanup(settingsFile); // window never opened → nothing else will clean up
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
const r = d.spawnWt(wt, args);
|
|
414
|
+
if (r.error) {
|
|
415
|
+
d.err(`ccr: failed to launch Windows Terminal: ${r.error.message}\n`);
|
|
416
|
+
d.cleanup(settingsFile); // window never opened → pane 0 can't clean up
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
// 7. On success the settings file is cleaned up by pane 0 when Claude exits.
|
|
420
|
+
return typeof r.status === 'number' ? r.status : 0;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @typedef {object} Deps
|
|
425
|
+
* @property {NodeJS.ProcessEnv} env
|
|
426
|
+
* @property {string} home
|
|
427
|
+
* @property {number|undefined} cols
|
|
428
|
+
* @property {string} node
|
|
429
|
+
* @property {string} ccrJs
|
|
430
|
+
* @property {(s: string) => void} out
|
|
431
|
+
* @property {(s: string) => void} err
|
|
432
|
+
* @property {() => (string|null)} findWt
|
|
433
|
+
* @property {(name: string) => (string|null)} which
|
|
434
|
+
* @property {(dir: string) => boolean} existsDir
|
|
435
|
+
* @property {(dir: string) => string[]} listDir
|
|
436
|
+
* @property {(dir: string) => void} ensureDir
|
|
437
|
+
* @property {(dir: string) => void} removeExited
|
|
438
|
+
* @property {(settings: object) => string} writeSettings
|
|
439
|
+
* @property {(file: string) => void} cleanup
|
|
440
|
+
* @property {(wt: string, args: string[]) => {status: number|null, error?: Error}} spawnWt
|
|
441
|
+
*/
|
|
442
|
+
|
|
443
|
+
module.exports = {
|
|
444
|
+
PROFILE_RE,
|
|
445
|
+
DEFAULT_SIDEBAR_PCT,
|
|
446
|
+
DEFAULT_SIDEBAR_SIDE,
|
|
447
|
+
validateProfile,
|
|
448
|
+
isWtArgSafe,
|
|
449
|
+
resolveProfileState,
|
|
450
|
+
sidebarFraction,
|
|
451
|
+
sidebarSplitFlag,
|
|
452
|
+
sidecarCols,
|
|
453
|
+
buildWtArgs,
|
|
454
|
+
findWindowsTerminal,
|
|
455
|
+
run,
|
|
456
|
+
fallbackNoWt,
|
|
457
|
+
};
|
package/src/render/economy.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/render/shared.js
CHANGED
|
@@ -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
|
+
};
|