claude-code-runrate 0.3.0 → 0.4.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
@@ -24,9 +24,15 @@ shows you the **economy** of a session:
24
24
  - **Resume advisor** (`ccr resume`) — recent sessions ranked by what they'd cost
25
25
  to bring back (context size, share of the window, cold/warm cache), then it
26
26
  hands selection to `claude --resume`.
27
+ - **Git pane** — press **F3** in the sidebar for the repo each tab is in: the
28
+ repo and branch, what's staged / modified / untracked / conflicted, and
29
+ recent commits drawn in lanes like an IDE git graph. Read from `.git`
30
+ directly (packfiles included) — no `git` binary is ever run, and the pane
31
+ can't write or execute anything, structurally.
27
32
  - **External tool panes** — the sidebar can render read-only status panes from
28
33
  other tools via a strict JSON contract; **F3** cycles between the economy
29
- panel and each configured pane (see [External tool panes](#external-tool-panes-sidebar)).
34
+ panel, the built-in git pane, and each configured pane (see
35
+ [External tool panes](#external-tool-panes-sidebar)).
30
36
 
31
37
  For scripting and external tools (status bars, menu-bar widgets), `ccr economy
32
38
  --json` emits a stable, versioned model — see
@@ -86,8 +92,8 @@ The split is a **one-time** setup per VS Code window: an attached sidecar picks
86
92
  each new `ccr` session up automatically, so relaunching prints a short note
87
93
  instead of the banner. And if you do paste the one-liner into a second pane, the
88
94
  older pane stands down by itself — there is never more than one live sidebar per
89
- session. Profiles stay independent: a personal `ccr` and a work `ccr <profile>`
90
- run side by side, each with its own state dir and its own sidebar.
95
+ session. Instances stay independent see
96
+ [Running more than one](#running-more-than-one) below.
91
97
 
92
98
  On **Windows** this is the default inside VS Code (Windows Terminal otherwise
93
99
  opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
@@ -98,6 +104,56 @@ opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
98
104
  > CLI has no "run command" verb) — out of scope for the zero-dependency core. The
99
105
  > clipboard + `--hint` reduce it to split-and-paste.
100
106
 
107
+ ## Running more than one
108
+
109
+ Open a second terminal and run `ccr` again. That's the whole procedure — nothing
110
+ to name, no flag. Each instance gets its own state dir, its own sidebar, and its
111
+ own tmux session and socket, so starting, clearing or quitting one never touches
112
+ the other. This holds for CCS profiles too: two `ccr cq` at once are two
113
+ independent instances of the same account.
114
+
115
+ **Instances are ephemeral.** State lives under `~/.ccr/instances/<n>` for the
116
+ session's lifetime and is deleted when it ends. Slots are **reused** — quit the
117
+ second instance and the next `ccr` takes slot 2 back rather than counting upward
118
+ forever. What outlives an instance lives at the top of `~/.ccr`: account burn
119
+ history, and a small per-session join log (`session-<id>.jsonl` — which
120
+ instance, profile and directory a Claude session ran as, for when you're
121
+ reconstructing what happened) — both pruned 31 days after their session ends.
122
+ Up to 32 instances can be live at once; a 33rd launch refuses rather than
123
+ sharing state. Account-wide meters (the 5h and weekly walls) are reconciled
124
+ across live instances, so two sidebars agree even when one has been idle.
125
+
126
+ **Every instance has a name.** By default it's the repository (or directory)
127
+ you launched from — a second instance from the same repo becomes `gitrepo2` —
128
+ or pick one with `ccr --name side-project`. Characters outside `A-Za-z0-9._-`
129
+ become `-`; an explicit `--name` is rejected rather than repaired, and refused
130
+ if that name is already live. The name is how you see and address an instance:
131
+
132
+ - the **terminal tab title** is `name` (or `profile / name`), set once at
133
+ launch and never changed mid-session — it is the tab's address;
134
+ - the **status line** leads with `name @ location`, and the location is live —
135
+ it follows a mid-session `cd`, and is dropped when it would only repeat the
136
+ name;
137
+ - the **sidebar** heads every view with the name;
138
+ - **`-i <name>`** targets a live instance from anywhere: `ccr economy -i
139
+ side-project`, `ccr sidecar -i …`, `ccr cycle-view -i …`. Without `-i`,
140
+ those three commands resolve the instance from your working directory (the
141
+ live instance whose launch directory contains it) — and when that's
142
+ ambiguous they list the candidates instead of guessing. Every panel is
143
+ headed by the name it resolved to.
144
+
145
+ Setting `CCR_STATE_DIR` (and/or `CCR_SESSION`) still pins an instance wherever
146
+ you want it, and always wins over the automatic choice.
147
+
148
+ **Upgrading from 0.3:** the first launch migrates `~/.ccr` once — burn history
149
+ moves to the top of the container and old per-profile state dirs are swept
150
+ (they held nothing else that outlives a session). Migration refuses while any
151
+ old session is still running, and names what to close.
152
+
153
+ > `tmux ls` won't list ccr's sessions — each runs on its own socket. Use
154
+ > `tmux -L ccr ls` (or `-L ccr-2` for slot 2 — profile launches ride slot
155
+ > sockets too now).
156
+
101
157
  ## Wiring the statusline into Claude Code
102
158
 
103
159
  In `~/.claude/settings.json`:
@@ -114,8 +170,18 @@ latency.)
114
170
 
115
171
  The live sidebar can host **read-only panes from other tools**. A tool writes a
116
172
  small JSON blob beside its own artifacts; you list that file's path in ccr's
117
- config; the sidebar cycles between the economy panel and each configured pane
118
- (**F3** under tmux — the launcher binds it; `ccr cycle-view` on any host).
173
+ config; the sidebar cycles between the economy panel, the built-in git pane, and
174
+ each configured pane.
175
+
176
+ **Cycling views.** Under tmux the launcher binds **F3**. VS Code and its forks
177
+ (Cursor, Positron, Antigravity) bind no key and leave both split panes running a
178
+ foreground process, so the pasted sidecar one-liner carries `--keys`: click that
179
+ pane and press **Space** (or **F3**). The renderer still reads no input — the key
180
+ lives in a separate parent process that runs the panel as a child, the same
181
+ separation tmux enforces. Anywhere else, `ccr cycle-view -i <name>` (or
182
+ `--state-dir <dir>`); a bare `ccr cycle-view` resolves the instance from your
183
+ working directory, like `ccr economy` does.
184
+ `ccr sidecar --view <n>` opens on a chosen view (0 economy, 1 git, 2+ panes).
119
185
 
120
186
  Config lives at `~/.config/ccr/config.json` (`$XDG_CONFIG_HOME` respected,
121
187
  `CCR_CONFIG` overrides) — deliberately *not* in ccr's state dir, and never
@@ -129,7 +195,7 @@ A pane is a full-height view carrying the producing tool's own rows — here
129
195
  `gherkin-trace`, whose blob ships as the golden example:
130
196
 
131
197
  ```
132
- trace gherkin-trace 2/2
198
+ trace gherkin-trace 3/3
133
199
  refresh · 2026-08-01 14:10 · blob written 0s
134
200
 
135
201
  ● attention 3 1 breach, 2 orphans
@@ -147,10 +213,13 @@ trace gherkin-trace 2/2
147
213
  stripped of control bytes before it touches your terminal.
148
214
  - **Producers never know ccr exists.** You wire the join by hand, exactly like
149
215
  Claude Code's own `statusLine` — neither side takes a dependency on the other.
150
- - **Config order is cycle order.** F3 goes economy panel → first pane → second
151
- pane → back to economy; the `2/2` above is that position. Entries are never
152
- de-duplicated, so listing one path twice gives you two panes. The config is
153
- re-read every tickadding a pane takes effect without relaunching.
216
+ - **Config order is cycle order.** F3 goes economy panel → git pane → first pane
217
+ → second pane → back to economy; the `3/3` above is that position, for the one
218
+ configured pane in the example. The position marker appears only once you have
219
+ configured a panethe two built-in views identify themselves, so numbering
220
+ them buys nothing. Entries are never de-duplicated, so listing one path twice
221
+ gives you two panes. The config is re-read every tick — adding a pane takes
222
+ effect without relaunching.
154
223
  - **The producer is trusted for content, never for behaviour.** ccr executes
155
224
  nothing from a blob and draws no value it has not validated, so a hostile file
156
225
  cannot crash the panel or escape into your terminal — but what a pane *says*
@@ -176,6 +245,13 @@ canonical source; `test/gherkin.js` is a vendored copy). See
176
245
  [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the deliberate limits,
177
246
  and the API.
178
247
 
248
+ The *practice* is documented separately in [`docs/BDD.md`](docs/BDD.md): what
249
+ these scenarios (271 of them, at 0.4.0) took from conventional BDD, where they departed — refusals
250
+ as a quarter of the specification, tags that are gates rather than filters,
251
+ comments that carry rulings — and why. It also places ccr in the lineage the
252
+ method has followed since, and is honest about which point on that line this
253
+ repository represents.
254
+
179
255
  ```bash
180
256
  npm test # node --test — harness self-tests + feature scenarios
181
257
  npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
package/bin/ccr.js CHANGED
@@ -34,14 +34,21 @@ Usage:
34
34
  ccr doctor Check your local setup (node, tmux, CCS, capture status)
35
35
 
36
36
  Examples:
37
- ccr plain \`claude\` + sidebar
38
- ccr c1 CCS profile c1 + sidebar
39
- ccr economy one-off panel
37
+ ccr plain \`claude\` + sidebar
38
+ ccr c1 CCS profile c1 + sidebar
39
+ ccr --name side-project named instance (default name: the repo/dir)
40
+ ccr economy -i side-project panel for a live instance, by name
40
41
 
41
42
  Options:
42
- -h, --help Show this help
43
- -v, --version Show version
44
- --mary Enable the mary interface`;
43
+ -h, --help Show this help
44
+ -v, --version Show version
45
+ --name <name> Name this instance (letters, digits, . _ -)
46
+ -i <name> Target a live instance (economy, sidecar, cycle-view)
47
+ --mary Enable the mary interface
48
+
49
+ Sidecar options:
50
+ --view <n> Open on a view: 0 economy, 1 git, 2+ configured panes
51
+ --keys Cycle views with F3 or Space, for terminals that bind no key`;
45
52
 
46
53
  /**
47
54
  * @param {string[]} argv
@@ -60,6 +67,10 @@ function main(argv) {
60
67
  'state-dir': { type: 'string' },
61
68
  hint: { type: 'boolean' },
62
69
  'exit-on-end': { type: 'boolean' },
70
+ name: { type: 'string' },
71
+ instance: { type: 'string', short: 'i' },
72
+ keys: { type: 'boolean' },
73
+ view: { type: 'string' },
63
74
  mary: { type: 'boolean' },
64
75
  },
65
76
  });
@@ -75,17 +86,40 @@ function main(argv) {
75
86
  // in-process panels (economy/statusline/resume) pick it up for this run.
76
87
  if (values.mary) process.env.CCR_ENABLE_MARY_INTERFACE = '1';
77
88
 
89
+ // `--view` names a view index, so a non-integer is a typo worth naming rather
90
+ // than a value to coerce: NaN would silently open the economy panel and look
91
+ // like the flag was ignored.
92
+ let view;
93
+ if (values.view != null) {
94
+ view = Number(values.view);
95
+ if (!Number.isInteger(view) || view < 0) {
96
+ process.stderr.write(`ccr: --view takes a view index (0 = economy, 1 = git), got "${values.view}"\n`);
97
+ return 2;
98
+ }
99
+ }
100
+
78
101
  const cmd = positionals[0];
79
- if (!cmd) return cmdLaunch(undefined); // bare `ccr` launch
102
+ // -i is a THREE-command flag: honored where a command must answer "which
103
+ // live instance?", refused LOUDLY everywhere else — the option parser is
104
+ // global, so a silently swallowed -i would let `ccr resume -i x` look
105
+ // targeted while targeting nothing (features/instance-resolution.feature).
106
+ const iTarget = values.instance != null ? values.instance : null;
107
+ if (iTarget != null && !['economy', 'sidecar', 'cycle-view'].includes(cmd || '')) {
108
+ const known = ['economy', 'resume', 'statusline', 'sidecar', 'doctor', 'cycle-view', 'launch'];
109
+ const cname = cmd && known.includes(cmd) ? cmd : 'launch';
110
+ process.stderr.write(`ccr: -i applies to economy, sidecar, cycle-view — ${cname} does not target an instance\n`);
111
+ return 2;
112
+ }
113
+ if (!cmd) return cmdLaunch(undefined, values.name); // bare `ccr` → launch
80
114
  switch (cmd) {
81
- case 'economy': return cmdEconomy(!!values.json);
115
+ case 'economy': return cmdEconomy(!!values.json, iTarget);
82
116
  case 'resume': return cmdResume(positionals[1]);
83
117
  case 'statusline': return cmdStatusline();
84
- case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint, !!values['exit-on-end']);
118
+ case 'sidecar': return cmdSidecar(values['state-dir'], !!values.hint, !!values['exit-on-end'], !!values.keys, view, iTarget);
85
119
  case 'doctor': return require('../src/doctor').run();
86
- case 'cycle-view': return cmdCycleView(values['state-dir']);
87
- case 'launch': return cmdLaunch(positionals[1]);
88
- default: return cmdLaunch(cmd); // anything else → treat as a CCS profile
120
+ case 'cycle-view': return cmdCycleView(values['state-dir'], iTarget);
121
+ case 'launch': return cmdLaunch(positionals[1], values.name);
122
+ default: return cmdLaunch(cmd, values.name); // anything else → treat as a CCS profile
89
123
  }
90
124
  }
91
125
 
@@ -95,11 +129,16 @@ function main(argv) {
95
129
  * Always exits 0: a keypress that finds no live sidecar is a no-op, not an
96
130
  * error worth painting over the user's terminal.
97
131
  * @param {string|undefined} stateDirFlag
132
+ * @param {string|null} [target] -i name, resolved through the chain
98
133
  * @returns {number}
99
134
  */
100
- function cmdCycleView(stateDirFlag) {
101
- const stateDir = stateDirFlag || process.env.CCR_STATE_DIR
102
- || require('node:path').join(require('node:os').homedir(), '.ccr');
135
+ function cmdCycleView(stateDirFlag, target = null) {
136
+ let stateDir = stateDirFlag || null;
137
+ if (!stateDir) {
138
+ const res = require('../src/instance-resolve').resolveInstance({ target, command: 'cycle-view' });
139
+ if (!res.ok) { process.stderr.write(res.error + '\n'); return 1; }
140
+ stateDir = res.stateDir;
141
+ }
103
142
  require('../src/cycle-view').cycleView(stateDir);
104
143
  return 0;
105
144
  }
@@ -125,9 +164,32 @@ function cmdStatusline() {
125
164
  const { renderStatusline } = require('../src/render/statusline');
126
165
  const { logMeterSample } = require('../src/instrument');
127
166
  const { ensureSecureDir } = require('../src/state-dir');
128
- line = renderStatusline(normalizeStatus(state));
167
+ // The identity half: the name this instance chose at launch, and the LIVE
168
+ // location from the status tick's own cwd (features/instance-identity.feature).
169
+ /** @type {{ name?: string|null, location?: string|null }} */
170
+ let identity = {};
171
+ try {
172
+ const naming = require('../src/instance-name');
173
+ const name = fs.readFileSync(path.join(STATE_DIR, naming.NAME_FILE), 'utf8').trim();
174
+ if (name) identity = { name, location: naming.locationFrom(state.cwd) };
175
+ } catch { /* unnamed (explicit override dirs) — no identity segment */ }
176
+ line = renderStatusline(normalizeStatus(state), identity);
129
177
  try { ensureSecureDir(STATE_DIR); fs.writeFileSync(SNAPSHOT, raw, { mode: 0o600 }); } catch { /* ignore */ }
130
- try { logMeterSample(state, { dir: STATE_DIR }); } catch { /* ignore */ }
178
+ // Burn history is ACCOUNT-scoped and slots are reused, so it lives at the
179
+ // container's top level, never inside an instance dir it would die with.
180
+ try { logMeterSample(state, { dir: path.join(os.homedir(), '.ccr') }); } catch { /* ignore */ }
181
+ // The two-phase join key opens the moment the session id first exists
182
+ // (src/session-log.js) — deaths are when writes can't be trusted.
183
+ try {
184
+ if (state.session_id) {
185
+ const readLocal = (/** @type {string} */ f) => {
186
+ try { return fs.readFileSync(path.join(STATE_DIR, f), 'utf8').trim() || null; } catch { return null; }
187
+ };
188
+ require('../src/session-log').openEntry(os.homedir(), state.session_id, {
189
+ name: readLocal('name'), profile: readLocal('profile'), launch_cwd: readLocal('launch-cwd'),
190
+ });
191
+ }
192
+ } catch { /* ignore */ }
131
193
  } catch { /* keep fallback line */ }
132
194
  process.stdout.write(line + '\n');
133
195
  return 0;
@@ -138,11 +200,29 @@ function cmdStatusline() {
138
200
  * stable machine-readable economy model (the integration contract). Reads stdin,
139
201
  * else the captured snapshot.
140
202
  * @param {boolean} [json]
203
+ * @param {string|null} [target] -i name, resolved through the chain
141
204
  * @returns {number}
142
205
  */
143
- function cmdEconomy(json) {
206
+ function cmdEconomy(json, target = null) {
144
207
  let raw = readStdin();
145
- if (!raw.trim()) { try { raw = fs.readFileSync(SNAPSHOT, 'utf8'); } catch { /* none yet */ } }
208
+ /** @type {string|null} */
209
+ let heading = null;
210
+ if (!raw.trim()) {
211
+ // Which instance? The resolution chain answers, and the panel is headed
212
+ // with the name it resolved to — the safeguard against reading the right
213
+ // panel about the wrong instance.
214
+ const res = require('../src/instance-resolve').resolveInstance({ target, command: 'economy' });
215
+ if (!res.ok) {
216
+ // Zero instances live is not an error: the account's meters and burn
217
+ // history are container-level and still print — only the per-instance
218
+ // panel is gone (features/instance-persistence.feature).
219
+ if ('none' in res && res.none && !json) return printAccountPanel();
220
+ process.stderr.write(res.error + '\n');
221
+ return 1;
222
+ }
223
+ heading = res.name;
224
+ try { raw = fs.readFileSync(path.join(res.stateDir, 'last-status.json'), 'utf8'); } catch { /* none yet */ }
225
+ }
146
226
  let state = null;
147
227
  if (raw.trim()) { try { state = JSON.parse(raw); } catch { /* bad json */ } }
148
228
  if (!state) {
@@ -151,12 +231,57 @@ function cmdEconomy(json) {
151
231
  }
152
232
  const { normalizeStatus } = require('../src/normalize');
153
233
  if (json) {
234
+ // The machine contract (docs/JSON-CONTRACT.md) is unchanged: no heading.
154
235
  const { computeEconomy } = require('../src/economy-model');
155
236
  process.stdout.write(JSON.stringify(computeEconomy(normalizeStatus(state)), null, 2) + '\n');
156
237
  return 0;
157
238
  }
158
239
  const { renderEconomy } = require('../src/render/economy');
159
- process.stdout.write(renderEconomy(normalizeStatus(state)) + '\n');
240
+ const panel = renderEconomy(normalizeStatus(state));
241
+ process.stdout.write((heading ? heading + '\n' : '') + panel + '\n');
242
+ return 0;
243
+ }
244
+
245
+ /**
246
+ * The zero-instance account view: meters from the newest burn history sample,
247
+ * plus how much history the container retains. No per-instance panel — there
248
+ * is no instance.
249
+ * @returns {number}
250
+ */
251
+ function printAccountPanel() {
252
+ const root = path.join(os.homedir(), '.ccr');
253
+ /** @type {{file: string, m: number} | null} */
254
+ let newest = null;
255
+ let sessions = 0;
256
+ try {
257
+ for (const n of fs.readdirSync(root)) {
258
+ if (/^session-[A-Za-z0-9_-]+\.jsonl$/.test(n)) { sessions += 1; continue; }
259
+ if (!/^burnlog-[A-Za-z0-9_-]+\.jsonl$/.test(n)) continue;
260
+ try {
261
+ const m = fs.statSync(path.join(root, n)).mtimeMs;
262
+ if (!newest || m > newest.m) newest = { file: path.join(root, n), m };
263
+ } catch { /* skip */ }
264
+ }
265
+ } catch { /* empty container */ }
266
+ const out = ['account (no live instance)'];
267
+ if (newest) {
268
+ try {
269
+ const lines = fs.readFileSync(newest.file, 'utf8').trim().split('\n');
270
+ const last = JSON.parse(lines[lines.length - 1]);
271
+ /** @type {any} */
272
+ const rl = {};
273
+ for (const [k, v] of Object.entries(last.limits || {})) {
274
+ rl[k] = { used_percentage: /** @type {any} */ (v).used, resets_at: /** @type {any} */ (v).resets_at };
275
+ }
276
+ const { normalizeStatus } = require('../src/normalize');
277
+ const { renderEconomy } = require('../src/render/economy');
278
+ out.push(renderEconomy(normalizeStatus({ rate_limits: rl })));
279
+ } catch { out.push('meters unreadable'); }
280
+ } else {
281
+ out.push('no burn history captured yet');
282
+ }
283
+ out.push(`history: ${sessions} session${sessions === 1 ? '' : 's'} retained`);
284
+ process.stdout.write(out.join('\n') + '\n');
160
285
  return 0;
161
286
  }
162
287
 
@@ -182,15 +307,44 @@ function cmdResume(arg) {
182
307
  * instructions + re-copies the one-liner instead of running the panel.
183
308
  * `--exit-on-end` closes the panel shortly after the session ends (the Windows
184
309
  * Terminal launcher passes it so its `cmd /c` pane sweeps closed like tmux).
310
+ * `--view <n>` opens on a chosen view (0 economy, 1 git, 2…N configured panes).
311
+ *
312
+ * `--keys` makes THIS process the hotkey host and runs the panel as a child, for
313
+ * terminals that bind no key of their own (VS Code and its forks). The renderer
314
+ * still never reads input — see the header of src/sidecar-keys.js for why that
315
+ * separation is the whole point, and why it is a child rather than a listener.
316
+ *
185
317
  * @param {string | undefined} stateDir
186
318
  * @param {boolean} [showHint]
187
319
  * @param {boolean} [exitOnEnd]
320
+ * @param {boolean} [useKeys]
321
+ * @param {number} [view]
322
+ * @param {string|null} [target] -i name, resolved through the chain
188
323
  * @returns {number | undefined}
189
324
  */
190
- function cmdSidecar(stateDir, showHint, exitOnEnd) {
325
+ function cmdSidecar(stateDir, showHint, exitOnEnd, useKeys, view, target = null) {
326
+ if (!stateDir && target != null) {
327
+ // -i: attach to a live instance by name, through the resolution chain.
328
+ const res = require('../src/instance-resolve').resolveInstance({ target, command: 'sidecar' });
329
+ if (!res.ok) { process.stderr.write(res.error + '\n'); return 1; }
330
+ stateDir = res.stateDir;
331
+ }
191
332
  if (stateDir) process.env.CCR_STATE_DIR = stateDir;
192
333
  if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
193
- require('../src/sidecar').run({ exitOnEnd: !!exitOnEnd });
334
+ if (useKeys) {
335
+ require('../src/sidecar-keys').runWithKeys({
336
+ stateDir: process.env.CCR_STATE_DIR || STATE_DIR,
337
+ // This very file, so the child is the same ccr the user invoked — not
338
+ // whatever `ccr` happens to resolve to on the child's PATH.
339
+ ccrJs: __filename,
340
+ argv: [
341
+ ...(exitOnEnd ? ['--exit-on-end'] : []),
342
+ ...(view != null ? ['--view', String(view)] : []),
343
+ ],
344
+ });
345
+ return undefined;
346
+ }
347
+ require('../src/sidecar').run({ exitOnEnd: !!exitOnEnd, view });
194
348
  return undefined;
195
349
  }
196
350
 
@@ -200,19 +354,62 @@ function cmdSidecar(stateDir, showHint, exitOnEnd) {
200
354
  * already works there); on native Windows we drive Windows Terminal; otherwise
201
355
  * the tmux launcher (scripts/launch.sh).
202
356
  * @param {string | undefined} profile
357
+ * @param {string} [name] explicit instance name (--name); validated here
203
358
  * @returns {number}
204
359
  */
205
- function cmdLaunch(profile) {
360
+ function cmdLaunch(profile, name) {
361
+ // An explicit name is REJECTED, never repaired: the user typed it and a
362
+ // human is right there — matching the launcher's profile-name message shape
363
+ // (features/instance-naming.feature).
364
+ const naming = require('../src/instance-name');
365
+ if (name != null && !naming.NAME_RE.test(name)) {
366
+ process.stderr.write(`ccr: invalid instance name '${name}' (allowed: letters, digits, . _ -)\n`);
367
+ return 1;
368
+ }
369
+ // Migration runs AT LAUNCH ONLY — never from `ccr statusline`, which Claude
370
+ // invokes headlessly mid-session (src/migrate.js). A refused migration is a
371
+ // refused launch: proceeding would write new-layout state into an old home.
372
+ const mig = require('../src/migrate').ensureLayout();
373
+ if (!mig.ok) { process.stderr.write(mig.error + '\n'); return 1; }
374
+ // Shared across ALL launchers (a platform must never ship half-lit): the
375
+ // explicit-name collision refusal, and the retention boundary.
376
+ const live = naming.liveNames({ home: os.homedir() });
377
+ if (name != null && live.has(name)) {
378
+ process.stderr.write(`ccr: instance name '${name}' is already live — pick another\n`);
379
+ return 1;
380
+ }
381
+ try { require('../src/session-log').pruneHistory(os.homedir()); } catch { /* best effort */ }
206
382
  const inVscode = process.env.TERM_PROGRAM === 'vscode';
207
383
  if (inVscode && (process.platform === 'win32' || process.env.CCR_VSCODE === '1')) {
208
- return require('../src/launch-vscode').run(profile);
384
+ return require('../src/launch-vscode').run(profile, undefined, { name });
209
385
  }
210
386
  if (process.platform === 'win32') {
211
- return require('../src/launch-win').run(profile);
387
+ return require('../src/launch-win').run(profile, undefined, { name });
212
388
  }
213
389
  const { spawnSync } = require('node:child_process');
214
390
  const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
215
- const r = spawnSync('bash', profile ? [launcher, profile] : [launcher], { stdio: 'inherit' });
391
+ // Every launch gets a free instance slot bare or profiled so a second
392
+ // terminal never collides with the first (src/instance-slot.js). The
393
+ // launcher needs no knowledge of slots: it already derives session, socket
394
+ // and state dir from these two vars.
395
+ const slots = require('../src/instance-slot');
396
+ const slot = slots.allocateSlot({ profile });
397
+ if (slot && 'exhausted' in slot) {
398
+ process.stderr.write(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
399
+ return 1;
400
+ }
401
+ // Name + profile record + title, the same way every launcher does it.
402
+ const inst = slot && !('exhausted' in slot)
403
+ ? naming.prepareInstance(slot, { profile, name }) : null;
404
+ let env = slots.applySlotEnv(process.env, slot);
405
+ // The tab's ADDRESS, composed once here and never retitled mid-session.
406
+ if (inst) env = { ...env, CCR_TITLE: inst.title };
407
+ // This call blocks for the session's whole lifetime (launch.sh ends in `tmux
408
+ // attach`), which is exactly why this process is what owns the slot.
409
+ const r = spawnSync('bash', profile ? [launcher, profile] : [launcher], { stdio: 'inherit', env });
410
+ // Ephemeral instances: a polite exit deletes the instance dir, unless an
411
+ // attached sidebar is still reading it (then the dir waits for reuse).
412
+ if (slot && !('exhausted' in slot)) slots.retireInstance(slot.stateDir);
216
413
  if (r.error) { process.stderr.write(`ccr: launch failed: ${r.error.message}\n`); return 1; }
217
414
  return typeof r.status === 'number' ? r.status : 1;
218
415
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.3.0",
3
+ "version": "0.4.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>",
@@ -30,7 +30,9 @@
30
30
  "scripts": {
31
31
  "test": "node --test",
32
32
  "typecheck": "tsc --noEmit -p jsconfig.json",
33
- "lint": "oxlint"
33
+ "lint": "oxlint",
34
+ "release-gate": "node scripts/release-gate.js",
35
+ "prepublishOnly": "npm run release-gate && npm run lint && npm run typecheck && npm test"
34
36
  },
35
37
  "keywords": [
36
38
  "claude-code",
package/scripts/launch.sh CHANGED
@@ -42,7 +42,14 @@ chmod +x "$REPO/sidecar/ccr-statusline" 2>/dev/null || true
42
42
 
43
43
  # Prefer the newest nvm-installed node; `sort -V` is a GNU-ism, so suppress its
44
44
  # error on BSD/macOS and fall back to PATH node below.
45
- NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1)"
45
+ #
46
+ # The `|| true` is load-bearing, not defensive habit: with `set -e` and
47
+ # `pipefail`, `ls` failing on a machine with no ~/.nvm makes the whole pipeline
48
+ # non-zero, and a failing command substitution in an assignment aborts the
49
+ # script. That killed the launcher outright — exit 2, no message, no sidebar —
50
+ # for exactly the user who has plain Claude Code and no nvm, who then never
51
+ # reaches the PATH fallback two lines below. Reproduced 2026-08-04.
52
+ NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1 || true)"
46
53
  [ -x "$NODE" ] || NODE="$(command -v node || true)"
47
54
  [ -n "$NODE" ] || { echo "ccr: node not found" >&2; exit 1; }
48
55
  command -v tmux >/dev/null 2>&1 || { echo "ccr: tmux not found (required for the sidebar)" >&2; exit 1; }
@@ -55,13 +62,15 @@ if [ -n "$PROFILE" ]; then
55
62
  exit 1
56
63
  fi
57
64
  CC_CMD="ccs $PROFILE"
58
- SESSION="${CCR_SESSION:-ccr-$PROFILE}"
59
- STATE="${CCR_STATE_DIR:-$HOME/.ccr/$PROFILE}"
60
65
  else
61
66
  CC_CMD="${CC_BIN:-claude}"
62
- SESSION="${CCR_SESSION:-ccr}"
63
- STATE="${CCR_STATE_DIR:-$HOME/.ccr}"
64
67
  fi
68
+ # The launcher normally arrives with CCR_SESSION/CCR_STATE_DIR already set by
69
+ # the slot allocator (bin/ccr.js — every launch slots, profiled or bare). The
70
+ # fallbacks cover only a direct invocation of this script, and they point at
71
+ # slot 1's member dir: ~/.ccr itself is a container now, never a state dir.
72
+ SESSION="${CCR_SESSION:-ccr}"
73
+ STATE="${CCR_STATE_DIR:-$HOME/.ccr/instances/1}"
65
74
 
66
75
  # Every instance gets its OWN tmux server, on a socket named after the session
67
76
  # (-L puts it under /tmp/tmux-$UID/). On a shared server, one server death —
@@ -76,6 +85,17 @@ SOCKET="$SESSION"
76
85
  mkdir -p "$STATE"
77
86
  chmod 700 "$HOME/.ccr" "$STATE" 2>/dev/null || true
78
87
  rm -f "$STATE/exited"
88
+ # The directory ccr was launched in — the tab's stable identity for the git
89
+ # pane (src/state-dir.js: recordLaunchDir does the same for the other two
90
+ # launchers). Best-effort: a tab that cannot record it falls back to naming
91
+ # only the repo the session is in.
92
+ # The rm is load-bearing, not tidiness: `>` on a planted FIFO blocks until a
93
+ # reader appears and hangs the launcher here, before Claude is ever spawned, and
94
+ # on a planted symlink it overwrites the link's target. Removing first turns both
95
+ # into an ordinary create. src/state-dir.js does the same for the other two
96
+ # launchers, and both sibling writers in <stateDir> already guard this way.
97
+ rm -f "$STATE/launch-cwd" 2>/dev/null || true
98
+ printf '%s\n' "$PWD" > "$STATE/launch-cwd" 2>/dev/null || true
79
99
 
80
100
  SETTINGS='{"statusLine":{"type":"command","command":"'"$REPO/sidecar/ccr-statusline"'"}}'
81
101
 
@@ -105,6 +125,15 @@ CLAUDE_PANE="$(tmux -L "$SOCKET" new-session -d -P -F '#{pane_id}' -s "$SESSION"
105
125
  "$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE_Q/exited'; sleep 2; tmux -L '$SOCKET_Q' kill-session -t '$SESSION_Q' 2>/dev/null")"
106
126
  tmux -L "$SOCKET" set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
107
127
 
128
+ # The tab's ADDRESS: composed once by the launcher (CCR_TITLE = "[profile / ]name",
129
+ # both halves allow-listed), never retitled mid-session. set-titles-string is a
130
+ # literal — deliberately NOT a tmux format that would follow the session; the
131
+ # pane and the status line are the surfaces honest about movement.
132
+ if [ -n "${CCR_TITLE:-}" ]; then
133
+ tmux -L "$SOCKET" set-option -t "$SESSION" set-titles on
134
+ tmux -L "$SOCKET" set-option -t "$SESSION" set-titles-string "$CCR_TITLE"
135
+ fi
136
+
108
137
  # Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
109
138
  SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
110
139
  "$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
@@ -33,7 +33,8 @@ const { parseResetsAt } = require('./burn');
33
33
  const { modelScope } = require('./rate-limits');
34
34
 
35
35
  const MAX_SNAPSHOT_BYTES = 1_000_000; // a status JSON is a few KB; bound parse/disk
36
- const MAX_PROFILES = 32; // sanity cap on how many siblings we scan
36
+ const MAX_PROFILES = 32; // sanity cap on how many siblings we merge
37
+ const MAX_SCAN_ENTRIES = 512; // sanity cap on how many dir entries we inspect
37
38
 
38
39
  /**
39
40
  * Canonical reset instant for fingerprinting/matching — tolerant of CC reporting
@@ -127,12 +128,14 @@ function readSiblingRateLimits(file) {
127
128
  * reconcile the local meters against them. Best-effort — returns `localRl` on any
128
129
  * problem so it can wrap the render path without a guard at the call site.
129
130
  *
130
- * Engages ONLY for the launcher's profile layout (`~/.ccr/<profile>`): the state
131
- * dir's parent must be `~/.ccr`. For ad-hoc `~/.ccr` or a custom CCR_STATE_DIR we
132
- * have no sibling set to trust, so we behave exactly as before (no merge).
131
+ * Engages ONLY for the launcher's layout: instances under `~/.ccr/instances/`
132
+ * (the 0.4.0 container/member split src/instance-slot.js). A custom
133
+ * CCR_STATE_DIR elsewhere has no sibling set to trust, so it behaves as
134
+ * before (no merge). There is no slot-1 special case any more: slot 1 is an
135
+ * ordinary member of instances/, which is exactly why the layout changed.
133
136
  *
134
137
  * @param {any} localRl the local snapshot's `rate_limits`
135
- * @param {string} stateDir the local profile's state dir (CCR_STATE_DIR)
138
+ * @param {string} stateDir the local instance's state dir (CCR_STATE_DIR)
136
139
  * @param {{ home?: string }} [opts]
137
140
  * @returns {any}
138
141
  */
@@ -140,21 +143,26 @@ function freshenAccountLimits(localRl, stateDir, opts = {}) {
140
143
  try {
141
144
  if (!localRl || typeof localRl !== 'object') return localRl;
142
145
  const home = opts.home || os.homedir();
143
- const root = path.dirname(path.resolve(stateDir));
144
- if (root !== path.resolve(path.join(home, '.ccr'))) return localRl; // not a profile layout
146
+ const root = path.resolve(path.join(home, '.ccr', 'instances'));
147
+ const self = path.resolve(stateDir);
148
+ if (path.dirname(self) !== root) return localRl; // not the launcher's layout
145
149
  const selfFile = path.resolve(path.join(stateDir, 'last-status.json'));
146
150
 
147
151
  /** @type {any[]} */
148
152
  const siblings = [];
153
+ // Bound the WALK, not just the harvest: MAX_PROFILES alone counts collected
154
+ // siblings, so entries that yield nothing — slot dirs with no snapshot yet,
155
+ // junk — would all be stat'd on every tick. This runs inside the sidecar's
156
+ // ~1s draw loop, so the scan stays capped by entries seen even though
157
+ // instances/ holds only instance dirs under the 0.4.0 layout.
158
+ let seen = 0;
149
159
  for (const name of fs.readdirSync(root)) {
150
- if (siblings.length >= MAX_PROFILES) break;
160
+ if (siblings.length >= MAX_PROFILES || ++seen > MAX_SCAN_ENTRIES) break;
151
161
  const p = path.join(root, name);
152
162
  let st; try { st = fs.statSync(p); } catch { continue; }
153
- // A sibling profile dir (~/.ccr/<name>/last-status.json) or the ad-hoc
154
- // ~/.ccr/last-status.json file itself.
155
- const file = st.isDirectory() ? path.join(p, 'last-status.json')
156
- : (name === 'last-status.json' ? p : null);
157
- if (!file || path.resolve(file) === selfFile) continue;
163
+ if (!st.isDirectory()) continue;
164
+ const file = path.join(p, 'last-status.json');
165
+ if (path.resolve(file) === selfFile) continue;
158
166
  const rl = readSiblingRateLimits(file);
159
167
  if (rl) siblings.push(rl);
160
168
  }