claude-code-runrate 0.2.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/bin/ccr.js +7 -3
- package/package.json +1 -1
- package/src/launch-win.js +68 -12
- package/src/sidecar.js +90 -7
package/bin/ccr.js
CHANGED
|
@@ -59,6 +59,7 @@ function main(argv) {
|
|
|
59
59
|
json: { type: 'boolean' },
|
|
60
60
|
'state-dir': { type: 'string' },
|
|
61
61
|
hint: { type: 'boolean' },
|
|
62
|
+
'exit-on-end': { type: 'boolean' },
|
|
62
63
|
mary: { type: 'boolean' },
|
|
63
64
|
},
|
|
64
65
|
});
|
|
@@ -80,7 +81,7 @@ function main(argv) {
|
|
|
80
81
|
case 'economy': return cmdEconomy(!!values.json);
|
|
81
82
|
case 'resume': return cmdResume(positionals[1]);
|
|
82
83
|
case 'statusline': return cmdStatusline();
|
|
83
|
-
case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint);
|
|
84
|
+
case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint, !!values['exit-on-end']);
|
|
84
85
|
case 'doctor': return require('../src/doctor').run();
|
|
85
86
|
case 'launch': return cmdLaunch(positionals[1]);
|
|
86
87
|
default: return cmdLaunch(cmd); // anything else → treat as a CCS profile
|
|
@@ -163,14 +164,17 @@ function cmdResume(arg) {
|
|
|
163
164
|
* `--state-dir <dir>` targets a specific session (used by the VS Code split-pane
|
|
164
165
|
* one-liner, which is shell-agnostic). `--hint` reprints the VS Code split
|
|
165
166
|
* instructions + re-copies the one-liner instead of running the panel.
|
|
167
|
+
* `--exit-on-end` closes the panel shortly after the session ends (the Windows
|
|
168
|
+
* Terminal launcher passes it so its `cmd /c` pane sweeps closed like tmux).
|
|
166
169
|
* @param {string | undefined} stateDir
|
|
167
170
|
* @param {boolean} [showHint]
|
|
171
|
+
* @param {boolean} [exitOnEnd]
|
|
168
172
|
* @returns {number | undefined}
|
|
169
173
|
*/
|
|
170
|
-
function cmdSidecar(stateDir, showHint) {
|
|
174
|
+
function cmdSidecar(stateDir, showHint, exitOnEnd) {
|
|
171
175
|
if (stateDir) process.env.CCR_STATE_DIR = stateDir;
|
|
172
176
|
if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
|
|
173
|
-
require('../src/sidecar').run();
|
|
177
|
+
require('../src/sidecar').run({ exitOnEnd: !!exitOnEnd });
|
|
174
178
|
return undefined;
|
|
175
179
|
}
|
|
176
180
|
|
package/package.json
CHANGED
package/src/launch-win.js
CHANGED
|
@@ -129,22 +129,57 @@ function paneCommand(stateDir, body) {
|
|
|
129
129
|
return `set "CCR_STATE_DIR=${stateDir}"&& ${body}`;
|
|
130
130
|
}
|
|
131
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
|
+
|
|
132
156
|
/**
|
|
133
157
|
* Build the argv passed to wt.exe (excluding the wt.exe path itself):
|
|
134
|
-
* new-tab --title Claude cmd /
|
|
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).
|
|
135
170
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* (cleanup-after-window-closes; the file was only needed at Claude startup).
|
|
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.
|
|
141
175
|
*
|
|
142
176
|
* Throws if any interpolated value contains a character that would break (or, in
|
|
143
|
-
* the case of %, hijack) the cmd /
|
|
177
|
+
* the case of %, hijack) the cmd /c payload — see isWtArgSafe. run() catches
|
|
144
178
|
* this and reports a clean error instead of spawning a broken command.
|
|
145
179
|
*
|
|
146
180
|
* @param {{ ccCmd: string, settingsFile: string, stateDir: string,
|
|
147
|
-
* node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string
|
|
181
|
+
* node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
|
|
182
|
+
* termCols?: number }} o
|
|
148
183
|
* @returns {string[]}
|
|
149
184
|
*/
|
|
150
185
|
function buildWtArgs(o) {
|
|
@@ -167,16 +202,31 @@ function buildWtArgs(o) {
|
|
|
167
202
|
const splitFlag = sidebarSplitFlag(o.sidebarSide);
|
|
168
203
|
const exited = path.win32.join(stateDir, 'exited');
|
|
169
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';
|
|
170
214
|
const pane0 = paneCommand(
|
|
171
215
|
stateDir,
|
|
172
|
-
`${ccCmd} --settings "${settingsFile}" & type nul > "${exited}" & del /q "${settingsFile}"`,
|
|
216
|
+
`${ccCmd} --settings "${settingsFile}" & type nul > "${exited}" & del /q "${settingsFile}" & ${SWEEP_LINGER}`,
|
|
173
217
|
);
|
|
174
|
-
|
|
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);
|
|
175
225
|
|
|
176
226
|
return [
|
|
177
|
-
'new-tab', '--title', 'Claude', 'cmd', '/
|
|
227
|
+
'-w', '0', 'new-tab', '--title', 'Claude', 'cmd', '/c', pane0,
|
|
178
228
|
';',
|
|
179
|
-
'split-pane', splitFlag, '-s', frac, 'cmd', '/
|
|
229
|
+
'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
|
|
180
230
|
];
|
|
181
231
|
}
|
|
182
232
|
|
|
@@ -221,6 +271,9 @@ function withDefaults(deps) {
|
|
|
221
271
|
return {
|
|
222
272
|
env,
|
|
223
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,
|
|
224
277
|
node: deps.node || process.execPath,
|
|
225
278
|
ccrJs: deps.ccrJs || path.join(__dirname, '..', 'bin', 'ccr.js'),
|
|
226
279
|
out: deps.out || ((s) => { process.stdout.write(s); }),
|
|
@@ -350,6 +403,7 @@ function run(profile, deps = {}) {
|
|
|
350
403
|
ccrJs: d.ccrJs,
|
|
351
404
|
sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
|
|
352
405
|
sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
|
|
406
|
+
termCols: d.cols,
|
|
353
407
|
});
|
|
354
408
|
} catch (e) {
|
|
355
409
|
d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
@@ -370,6 +424,7 @@ function run(profile, deps = {}) {
|
|
|
370
424
|
* @typedef {object} Deps
|
|
371
425
|
* @property {NodeJS.ProcessEnv} env
|
|
372
426
|
* @property {string} home
|
|
427
|
+
* @property {number|undefined} cols
|
|
373
428
|
* @property {string} node
|
|
374
429
|
* @property {string} ccrJs
|
|
375
430
|
* @property {(s: string) => void} out
|
|
@@ -394,6 +449,7 @@ module.exports = {
|
|
|
394
449
|
resolveProfileState,
|
|
395
450
|
sidebarFraction,
|
|
396
451
|
sidebarSplitFlag,
|
|
452
|
+
sidecarCols,
|
|
397
453
|
buildWtArgs,
|
|
398
454
|
findWindowsTerminal,
|
|
399
455
|
run,
|
package/src/sidecar.js
CHANGED
|
@@ -105,17 +105,100 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
105
105
|
return clamp(out.endsWith('\n') ? out : out + '\n');
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the column budget to clamp the panel to. `process.stdout.columns` is
|
|
110
|
+
* authoritative when present (live resize re-flows on the next frame), but inside
|
|
111
|
+
* the Windows launcher's `cmd /c` conpty pane it is unreliable — often undefined
|
|
112
|
+
* or the FULL window width rather than the narrow split. So the launcher injects
|
|
113
|
+
* the computed pane width as CCR_SIDECAR_COLS; we take the SMALLER of the two,
|
|
114
|
+
* which is safe whichever is wrong: a bogus full-width `columns` can't defeat the
|
|
115
|
+
* hint, and a missing hint (Linux/tmux, standalone `ccr sidecar`) leaves the live
|
|
116
|
+
* value untouched. Returns undefined only when neither is known (no clamp).
|
|
117
|
+
*
|
|
118
|
+
* @returns {number|undefined}
|
|
119
|
+
*/
|
|
120
|
+
function resolveCols() {
|
|
121
|
+
const live = process.stdout.columns;
|
|
122
|
+
const haveLive = typeof live === 'number' && live > 0;
|
|
123
|
+
const hint = parseInt(process.env.CCR_SIDECAR_COLS || '', 10);
|
|
124
|
+
const haveHint = Number.isFinite(hint) && hint > 0;
|
|
125
|
+
if (haveLive && haveHint) return Math.min(live, hint);
|
|
126
|
+
if (haveHint) return hint;
|
|
127
|
+
return haveLive ? live : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
108
130
|
function frame() {
|
|
109
131
|
// Read columns each tick so a live resize re-flows on the next frame.
|
|
110
|
-
draw(composeFrame(STATE_DIR, { now: Date.now(), cols:
|
|
132
|
+
draw(composeFrame(STATE_DIR, { now: Date.now(), cols: resolveCols() }));
|
|
111
133
|
}
|
|
112
134
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The live loop. With `exitOnEnd` (the Windows launcher passes `--exit-on-end`),
|
|
137
|
+
* the sidecar closes its own pane as soon as the `exited` sentinel appears — so a
|
|
138
|
+
* `cmd /c` pane folds away on session end rather than lingering, matching the tmux
|
|
139
|
+
* launcher's kill-session sweep. Without it (Linux/tmux, standalone `ccr sidecar`)
|
|
140
|
+
* the loop runs until signalled, exactly as before.
|
|
141
|
+
*
|
|
142
|
+
* For the fastest, correctly-ordered close, the sentinel is POLLED faster than the
|
|
143
|
+
* render cadence when `exitOnEnd` (a redraw is ~1s; waiting a full second just to
|
|
144
|
+
* NOTICE the exit would dominate the close time). A single interval ticks at
|
|
145
|
+
* `pollMs`; the expensive redraw is throttled to ~1s, while the cheap sentinel
|
|
146
|
+
* check runs every poll. On exit we paint "session ended" once and close after a
|
|
147
|
+
* short `graceMs`. The launcher's pane 0 then lingers slightly longer (see
|
|
148
|
+
* buildWtArgs) so this RIGHT pane closes first and the border sweeps left→right.
|
|
149
|
+
* Side effects are injectable so the end-sweep is unit-testable.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ exitOnEnd?: boolean, stateDir?: string, graceMs?: number,
|
|
152
|
+
* tick?: () => void, sentinelExists?: () => boolean,
|
|
153
|
+
* setIntervalFn?: Function, setTimeoutFn?: Function,
|
|
154
|
+
* clearIntervalFn?: Function, clearTimeoutFn?: Function,
|
|
155
|
+
* exit?: () => void, onSignal?: (sig: string, handler: () => void) => void }} [opts]
|
|
156
|
+
* @returns {() => void} the stop handler (exposed for tests)
|
|
157
|
+
*/
|
|
158
|
+
function run(opts = {}) {
|
|
159
|
+
const stateDir = opts.stateDir || STATE_DIR;
|
|
160
|
+
const exitOnEnd = opts.exitOnEnd != null ? opts.exitOnEnd : (process.env.CCR_SIDECAR_EXIT_ON_END === '1');
|
|
161
|
+
// Tiny grace so the "session ended" frame paints before we close — kept short
|
|
162
|
+
// since this drives the close speed (the launcher tunes pane 0 to outlast it).
|
|
163
|
+
const graceMs = opts.graceMs != null ? opts.graceMs : 200;
|
|
164
|
+
const tick = opts.tick || frame;
|
|
165
|
+
const sentinelExists = opts.sentinelExists || (() => fs.existsSync(path.join(stateDir, 'exited')));
|
|
166
|
+
const setIntervalFn = opts.setIntervalFn || setInterval;
|
|
167
|
+
const setTimeoutFn = opts.setTimeoutFn || setTimeout;
|
|
168
|
+
const clearIntervalFn = opts.clearIntervalFn || clearInterval;
|
|
169
|
+
const clearTimeoutFn = opts.clearTimeoutFn || clearTimeout;
|
|
170
|
+
const exit = opts.exit || (() => process.exit(0));
|
|
171
|
+
const onSignal = opts.onSignal || ((sig, handler) => process.on(sig, handler));
|
|
172
|
+
|
|
173
|
+
// Poll the sentinel fast when we have to detect the end; keep the redraw at ~1s.
|
|
174
|
+
const RENDER_MS = 1000;
|
|
175
|
+
const pollMs = exitOnEnd ? 120 : RENDER_MS;
|
|
176
|
+
|
|
177
|
+
let id = null;
|
|
178
|
+
let endTimer = null;
|
|
179
|
+
let sinceRender = RENDER_MS; // render on the first loop
|
|
180
|
+
const stop = () => {
|
|
181
|
+
if (id != null) clearIntervalFn(id);
|
|
182
|
+
if (endTimer != null) clearTimeoutFn(endTimer);
|
|
183
|
+
exit();
|
|
184
|
+
};
|
|
185
|
+
const checkEnd = () => {
|
|
186
|
+
// Once the session has ended, paint it once then sweep this pane closed.
|
|
187
|
+
if (exitOnEnd && endTimer == null && sentinelExists()) {
|
|
188
|
+
tick();
|
|
189
|
+
endTimer = setTimeoutFn(stop, graceMs);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const loop = () => {
|
|
193
|
+
sinceRender += pollMs;
|
|
194
|
+
if (sinceRender >= RENDER_MS) { sinceRender = 0; tick(); }
|
|
195
|
+
checkEnd();
|
|
196
|
+
};
|
|
197
|
+
loop();
|
|
198
|
+
id = setIntervalFn(loop, pollMs);
|
|
199
|
+
onSignal('SIGINT', stop);
|
|
200
|
+
onSignal('SIGTERM', stop);
|
|
201
|
+
return stop;
|
|
119
202
|
}
|
|
120
203
|
|
|
121
204
|
// `updateFeed` + `composeFrame` are exported for tests (the incremental tail +
|