claude-code-runrate 0.3.0 → 0.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,114 @@
1
+ # Changelog
2
+
3
+ Every release also has a [GitHub release](https://github.com/bingh0/ccr/releases)
4
+ and a release commit, and those carry the full notes — what was measured, what
5
+ was rejected, and why. This file is the index: enough to decide whether a
6
+ version is worth upgrading to, and where to read the rest.
7
+
8
+ Dates are npm publish dates, because that is when a version reached anyone.
9
+
10
+ ## 0.5.0 — 2026-08-22
11
+
12
+ The Windows release. The things that could not be reasoned about were measured
13
+ on a real Windows 11 machine, because none of them are reachable from CI: every
14
+ Windows test injects the launcher, so the suite proves the argv `ccr` builds and
15
+ nothing about what Windows Terminal does when handed it.
16
+
17
+ - **The panes open where `ccr` was run.** `wt.exe` does not inherit the working
18
+ directory the way `tmux new-session` does, so every pane opened in
19
+ `%USERPROFILE%` while the launch record claimed otherwise — and the sidecar
20
+ prefers that record, so the git pane described the project in full confidence
21
+ for a terminal that was not in it. One source now feeds both.
22
+ - **Measured limits, and they are real.** Past 256 characters `wt` opens *no
23
+ tab at all*; a UNC path opens a tab and silently lands in `%SystemRoot%`; a
24
+ semicolon costs the whole tab; a backtick does not, so refusing it was wrong
25
+ and no longer happens. Each unusable path degrades — the panes open in the
26
+ default directory and stderr names which of the four reasons applied.
27
+ - **The sidecar pane has a cycle key** on Windows Terminal. F3 or Space. F3 has
28
+ three encodings and on Windows which one arrives depends on the *Node*
29
+ version, so `ccr` answers all three rather than pinning a Node floor for a
30
+ keystroke.
31
+ - **A pane config that says what is wrong.** A UTF-8 BOM is stripped; UTF-16 —
32
+ what PowerShell 5.1 writes for `>` and `Out-File` — is named as an encoding
33
+ problem instead of reported as bad JSON; `~\` expands as well as `~/`; and a
34
+ broken config says so on the panel and in `ccr doctor`, which reports pane
35
+ paths as *resolved*.
36
+ - **`ccr sidecar` resolves the instance from the working directory**, which the
37
+ feature files and the README had both described for a while.
38
+ - **The economy screen earns a decimal** where the next tenth is a decision: at
39
+ or past 95% used, one truncated digit, so `floor(shown)` still matches
40
+ `/usage`. A snapshot that stopped refreshing dims rather than blanks.
41
+
42
+ ## 0.4.0 — 2026-08-07
43
+
44
+ [The instance layer and the git pane.](https://github.com/bingh0/ccr/releases/tag/v0.4.0)
45
+
46
+ - **Instances.** Every launch is a named, slotted instance under one container
47
+ home; the name is derived from the project or given with `-i`, and travels
48
+ into the window title, status line and sidebar.
49
+ - **A git pane** on F3: which repository the tab is in, the working tree, and
50
+ recent history in lanes. Read-only — it never writes to the repository.
51
+ - The three launchers (tmux, Windows Terminal, VS Code) take naming, titles and
52
+ retention from one shared path rather than the tmux path owning them.
53
+
54
+ ## 0.3.0 — 2026-08-04
55
+
56
+ [External tool panes in the sidebar.](https://github.com/bingh0/ccr/releases/tag/v0.3.0)
57
+
58
+ - **Panes from other tools.** The sidebar hosts full-height read-only panes
59
+ rendered from small JSON blobs another tool writes beside its own artifacts.
60
+ You list the path in `~/.config/ccr/config.json`; F3 cycles.
61
+ - A rate-limit correctness fix, and `strict` type checking across the codebase.
62
+
63
+ ## 0.2.4 — 2026-07-12
64
+
65
+ [VS Code sidecars stop piling up; profile state-dir fix.](https://github.com/bingh0/ccr/releases/tag/v0.2.4)
66
+
67
+ - One live sidecar per session: relaunching inside VS Code no longer
68
+ accumulates identical panes.
69
+ - Profile sessions write their snapshots to the right state dir.
70
+
71
+ ## 0.2.3 — 2026-07-03
72
+
73
+ [Sidecars agree on 5h/weekly across profiles.](https://github.com/bingh0/ccr/releases/tag/v0.2.3)
74
+
75
+ - The 5h and weekly walls are one account-wide resource, but each profile only
76
+ captures its own; two sidecars on the same account could disagree. They
77
+ reconcile now.
78
+
79
+ ## 0.2.2 — 2026-07-02
80
+
81
+ [Aligned meter bars + a staleness marker.](https://github.com/bingh0/ccr/releases/tag/v0.2.2)
82
+
83
+ - A barely-used window projects an enormous time-to-exhaust, which overflowed
84
+ the fixed time column and shoved that row's bar out of line. Long horizons
85
+ are capped, so the bars stay aligned and the figure reads more honestly.
86
+ - A stale snapshot is annotated (`· updated Nm ago`) rather than silently
87
+ frozen. Never a wipe — Claude Code legitimately stops ticking during a long
88
+ operation.
89
+
90
+ ## 0.2.1 — 2026-06-26
91
+
92
+ [Windows Terminal teardown and sidecar width fixes.](https://github.com/bingh0/ccr/releases/tag/v0.2.1)
93
+
94
+ - ConPTY makes a split pane's reported width unreliable, so the panel
95
+ soft-wrapped. The launcher injects the computed width and the sidecar clamps
96
+ to the smaller of the two.
97
+ - On exit the sidecar pane collapses first and the border sweeps left to right,
98
+ fixing an ordering bug where Claude's pane could close first.
99
+
100
+ ## 0.2.0 — 2026-06-26
101
+
102
+ [Native Windows and VS Code live sidecars.](https://github.com/bingh0/ccr/releases/tag/v0.2.0)
103
+
104
+ - **Native Windows** — the live sidecar hosted in Windows Terminal split panes,
105
+ no tmux, bash or WSL, with a graceful fallback when `wt.exe` is absent.
106
+ - **VS Code integrated terminal on any OS** — split-pane sidecar via a
107
+ clipboard one-liner.
108
+ - `statusLine` injected per launch through a temp settings file, so `~/.claude`
109
+ is never mutated.
110
+
111
+ ## 0.1.0 — 2026-06-24
112
+
113
+ First publish: the CLI and status line — `economy`, `resume`, `statusline` —
114
+ with the live sidebar on tmux. Native Windows arrived in 0.2.0.
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** at the host. **Windows
177
+ Terminal** binds no key of its own, and neither do VS Code and its forks (Cursor,
178
+ Positron, Antigravity) — so on those the sidecar pane carries its own key: click
179
+ the pane and press **Space** or **F3**. The renderer still reads no input — the
180
+ key 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
@@ -125,11 +191,24 @@ read from a repository:
125
191
  { "panes": [ { "path": "/home/you/project/.your-tool/sidecar.json" } ] }
126
192
  ```
127
193
 
194
+ A leading `~` expands, with either separator (`~/tools/blob.json`,
195
+ `~\tools\blob.json`), and a relative path resolves against the config file's own
196
+ directory. **On Windows, save the file as UTF-8** — `>` and `Out-File` in Windows
197
+ PowerShell 5.1 write UTF-16, which is not JSON as far as any parser is concerned:
198
+
199
+ ```powershell
200
+ Set-Content -Encoding utf8 $env:USERPROFILE\.config\ccr\config.json $json
201
+ ```
202
+
203
+ `ccr doctor` reports what it read out of the config — the paths as **resolved**,
204
+ not as written — and names the problem when there is one, so a pane that never
205
+ appears is a question you can answer rather than a silence.
206
+
128
207
  A pane is a full-height view carrying the producing tool's own rows — here
129
208
  `gherkin-trace`, whose blob ships as the golden example:
130
209
 
131
210
  ```
132
- trace gherkin-trace 2/2
211
+ trace gherkin-trace 3/3
133
212
  refresh · 2026-08-01 14:10 · blob written 0s
134
213
 
135
214
  ● attention 3 1 breach, 2 orphans
@@ -147,10 +226,13 @@ trace gherkin-trace 2/2
147
226
  stripped of control bytes before it touches your terminal.
148
227
  - **Producers never know ccr exists.** You wire the join by hand, exactly like
149
228
  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.
229
+ - **Config order is cycle order.** F3 goes economy panel → git pane → first pane
230
+ → second pane → back to economy; the `3/3` above is that position, for the one
231
+ configured pane in the example. The position marker appears only once you have
232
+ configured a panethe two built-in views identify themselves, so numbering
233
+ them buys nothing. Entries are never de-duplicated, so listing one path twice
234
+ gives you two panes. The config is re-read every tick — adding a pane takes
235
+ effect without relaunching.
154
236
  - **The producer is trusted for content, never for behaviour.** ccr executes
155
237
  nothing from a blob and draws no value it has not validated, so a hostile file
156
238
  cannot crash the panel or escape into your terminal — but what a pane *says*
@@ -176,11 +258,24 @@ canonical source; `test/gherkin.js` is a vendored copy). See
176
258
  [`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the deliberate limits,
177
259
  and the API.
178
260
 
261
+ The *practice* is documented separately in [`docs/BDD.md`](docs/BDD.md): what
262
+ these scenarios (271 of them, at 0.4.0) took from conventional BDD, where they departed — refusals
263
+ as a quarter of the specification, tags that are gates rather than filters,
264
+ comments that carry rulings — and why. It also places ccr in the lineage the
265
+ method has followed since, and is honest about which point on that line this
266
+ repository represents.
267
+
179
268
  ```bash
180
- npm test # node --test — harness self-tests + feature scenarios
181
- npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
269
+ npm test # node --test — harness self-tests + feature scenarios
270
+ npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
271
+ npm run install-hooks # copy .githooks/ into this clone (run once)
182
272
  ```
183
273
 
274
+ `install-hooks` installs a fail-closed `pre-push` guard for the public remote:
275
+ only `main`, fast-forward only, and a scan of the commits the push would make
276
+ public. It is worth running in any clone you push from — a push is the moment
277
+ history stops being private, and it is the last moment the check can help.
278
+
184
279
  - **No runtime dependencies**, ever — it's what lets `npx claude-code-runrate` install
185
280
  instantly on every OS, including native Windows.
186
281
  - **Dev-only** tooling (`typescript`, `@types/node`) exists solely for
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,54 @@ 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
+ // Resolve whether or not -i was typed, which is what `ccr cycle-view` has
327
+ // always done. Consulting the chain ONLY for -i meant a bare `ccr sidecar`
328
+ // skipped every other link in it — including the one that matters most here,
329
+ // "the live instance whose launch directory contains the cwd" — and landed on
330
+ // the CONTAINER (~/.ccr) instead. The container holds no session, so the pane
331
+ // sat empty next to a running instance, and the fix looked like naming the
332
+ // instance exactly right when the whole point of the chain is not having to.
333
+ //
334
+ // --hint is left out: it prints the VS Code split instructions, which are
335
+ // worth printing before any instance exists, so requiring a live one to
336
+ // explain how to start one would be backwards.
337
+ if (!stateDir && !showHint) {
338
+ const res = require('../src/instance-resolve').resolveInstance({ target, command: 'sidecar' });
339
+ if (!res.ok) { process.stderr.write(res.error + '\n'); return 1; }
340
+ stateDir = res.stateDir;
341
+ }
191
342
  if (stateDir) process.env.CCR_STATE_DIR = stateDir;
192
343
  if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
193
- require('../src/sidecar').run({ exitOnEnd: !!exitOnEnd });
344
+ if (useKeys) {
345
+ require('../src/sidecar-keys').runWithKeys({
346
+ stateDir: process.env.CCR_STATE_DIR || STATE_DIR,
347
+ // This very file, so the child is the same ccr the user invoked — not
348
+ // whatever `ccr` happens to resolve to on the child's PATH.
349
+ ccrJs: __filename,
350
+ argv: [
351
+ ...(exitOnEnd ? ['--exit-on-end'] : []),
352
+ ...(view != null ? ['--view', String(view)] : []),
353
+ ],
354
+ });
355
+ return undefined;
356
+ }
357
+ require('../src/sidecar').run({ exitOnEnd: !!exitOnEnd, view });
194
358
  return undefined;
195
359
  }
196
360
 
@@ -200,19 +364,62 @@ function cmdSidecar(stateDir, showHint, exitOnEnd) {
200
364
  * already works there); on native Windows we drive Windows Terminal; otherwise
201
365
  * the tmux launcher (scripts/launch.sh).
202
366
  * @param {string | undefined} profile
367
+ * @param {string} [name] explicit instance name (--name); validated here
203
368
  * @returns {number}
204
369
  */
205
- function cmdLaunch(profile) {
370
+ function cmdLaunch(profile, name) {
371
+ // An explicit name is REJECTED, never repaired: the user typed it and a
372
+ // human is right there — matching the launcher's profile-name message shape
373
+ // (features/instance-naming.feature).
374
+ const naming = require('../src/instance-name');
375
+ if (name != null && !naming.NAME_RE.test(name)) {
376
+ process.stderr.write(`ccr: invalid instance name '${name}' (allowed: letters, digits, . _ -)\n`);
377
+ return 1;
378
+ }
379
+ // Migration runs AT LAUNCH ONLY — never from `ccr statusline`, which Claude
380
+ // invokes headlessly mid-session (src/migrate.js). A refused migration is a
381
+ // refused launch: proceeding would write new-layout state into an old home.
382
+ const mig = require('../src/migrate').ensureLayout();
383
+ if (!mig.ok) { process.stderr.write(mig.error + '\n'); return 1; }
384
+ // Shared across ALL launchers (a platform must never ship half-lit): the
385
+ // explicit-name collision refusal, and the retention boundary.
386
+ const live = naming.liveNames({ home: os.homedir() });
387
+ if (name != null && live.has(name)) {
388
+ process.stderr.write(`ccr: instance name '${name}' is already live — pick another\n`);
389
+ return 1;
390
+ }
391
+ try { require('../src/session-log').pruneHistory(os.homedir()); } catch { /* best effort */ }
206
392
  const inVscode = process.env.TERM_PROGRAM === 'vscode';
207
393
  if (inVscode && (process.platform === 'win32' || process.env.CCR_VSCODE === '1')) {
208
- return require('../src/launch-vscode').run(profile);
394
+ return require('../src/launch-vscode').run(profile, undefined, { name });
209
395
  }
210
396
  if (process.platform === 'win32') {
211
- return require('../src/launch-win').run(profile);
397
+ return require('../src/launch-win').run(profile, undefined, { name });
212
398
  }
213
399
  const { spawnSync } = require('node:child_process');
214
400
  const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
215
- const r = spawnSync('bash', profile ? [launcher, profile] : [launcher], { stdio: 'inherit' });
401
+ // Every launch gets a free instance slot bare or profiled so a second
402
+ // terminal never collides with the first (src/instance-slot.js). The
403
+ // launcher needs no knowledge of slots: it already derives session, socket
404
+ // and state dir from these two vars.
405
+ const slots = require('../src/instance-slot');
406
+ const slot = slots.allocateSlot({ profile });
407
+ if (slot && 'exhausted' in slot) {
408
+ process.stderr.write(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
409
+ return 1;
410
+ }
411
+ // Name + profile record + title, the same way every launcher does it.
412
+ const inst = slot && !('exhausted' in slot)
413
+ ? naming.prepareInstance(slot, { profile, name }) : null;
414
+ let env = slots.applySlotEnv(process.env, slot);
415
+ // The tab's ADDRESS, composed once here and never retitled mid-session.
416
+ if (inst) env = { ...env, CCR_TITLE: inst.title };
417
+ // This call blocks for the session's whole lifetime (launch.sh ends in `tmux
418
+ // attach`), which is exactly why this process is what owns the slot.
419
+ const r = spawnSync('bash', profile ? [launcher, profile] : [launcher], { stdio: 'inherit', env });
420
+ // Ephemeral instances: a polite exit deletes the instance dir, unless an
421
+ // attached sidebar is still reading it (then the dir waits for reuse).
422
+ if (slot && !('exhausted' in slot)) slots.retireInstance(slot.stateDir);
216
423
  if (r.error) { process.stderr.write(`ccr: launch failed: ${r.error.message}\n`); return 1; }
217
424
  return typeof r.status === 'number' ? r.status : 1;
218
425
  }