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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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>",
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 /k "<pane0>" ; split-pane -H -s <frac> cmd /k "<pane1>"
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
- * 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).
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 /k payload — see isWtArgSafe. run() catches
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 }} o
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
- const pane1 = paneCommand(stateDir, `"${node}" "${ccrJs}" sidecar`);
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', '/k', pane0,
227
+ '-w', '0', 'new-tab', '--title', 'Claude', 'cmd', '/c', pane0,
178
228
  ';',
179
- 'split-pane', splitFlag, '-s', frac, 'cmd', '/k', pane1,
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: process.stdout.columns }));
132
+ draw(composeFrame(STATE_DIR, { now: Date.now(), cols: resolveCols() }));
111
133
  }
112
134
 
113
- function run() {
114
- frame();
115
- const id = setInterval(frame, 1000);
116
- const stop = () => { clearInterval(id); process.exit(0); };
117
- process.on('SIGINT', stop);
118
- process.on('SIGTERM', stop);
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 +