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 +114 -0
- package/README.md +107 -12
- package/bin/ccr.js +233 -26
- package/package.json +8 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +34 -3
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/history-privacy.js +435 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +202 -11
- package/src/migrate.js +155 -0
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +69 -1
- package/src/render/statusline.js +50 -5
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +167 -0
- package/src/sidecar.js +174 -27
- package/src/state-dir.js +61 -1
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
|
|
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.
|
|
90
|
-
|
|
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
|
|
118
|
-
|
|
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
|
|
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 →
|
|
151
|
-
pane → back to economy; the `
|
|
152
|
-
|
|
153
|
-
|
|
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 pane — the 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
|
|
181
|
-
npm run typecheck
|
|
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
|
|
38
|
-
ccr c1
|
|
39
|
-
ccr
|
|
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
|
|
43
|
-
-v, --version
|
|
44
|
-
--
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|