claude-code-runrate 0.2.4 → 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 +134 -2
- package/bin/ccr.js +235 -22
- package/package.json +4 -2
- package/scripts/launch.sh +121 -16
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/account-limits.js +21 -13
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +15 -2
- package/src/economy-model.js +3 -0
- 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/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 +40 -9
- package/src/liveness.js +18 -1
- package/src/migrate.js +155 -0
- package/src/normalize.js +24 -5
- package/src/pane-blob.js +249 -0
- package/src/pane-config.js +109 -0
- package/src/rate-limits.js +12 -2
- package/src/render/economy.js +10 -2
- package/src/render/git-pane.js +345 -0
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +115 -11
- package/src/render/statusline.js +45 -4
- package/src/safe-read.js +82 -0
- package/src/sanitize.js +45 -4
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +277 -23
- package/src/state-dir.js +44 -1
- package/src/transcripts.js +31 -15
package/README.md
CHANGED
|
@@ -24,6 +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.
|
|
32
|
+
- **External tool panes** — the sidebar can render read-only status panes from
|
|
33
|
+
other tools via a strict JSON contract; **F3** cycles between the economy
|
|
34
|
+
panel, the built-in git pane, and each configured pane (see
|
|
35
|
+
[External tool panes](#external-tool-panes-sidebar)).
|
|
27
36
|
|
|
28
37
|
For scripting and external tools (status bars, menu-bar widgets), `ccr economy
|
|
29
38
|
--json` emits a stable, versioned model — see
|
|
@@ -83,8 +92,8 @@ The split is a **one-time** setup per VS Code window: an attached sidecar picks
|
|
|
83
92
|
each new `ccr` session up automatically, so relaunching prints a short note
|
|
84
93
|
instead of the banner. And if you do paste the one-liner into a second pane, the
|
|
85
94
|
older pane stands down by itself — there is never more than one live sidebar per
|
|
86
|
-
session.
|
|
87
|
-
|
|
95
|
+
session. Instances stay independent — see
|
|
96
|
+
[Running more than one](#running-more-than-one) below.
|
|
88
97
|
|
|
89
98
|
On **Windows** this is the default inside VS Code (Windows Terminal otherwise
|
|
90
99
|
opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
|
|
@@ -95,6 +104,56 @@ opens a separate window, so the in-editor split is nicer). On **Linux/macOS**,
|
|
|
95
104
|
> CLI has no "run command" verb) — out of scope for the zero-dependency core. The
|
|
96
105
|
> clipboard + `--hint` reduce it to split-and-paste.
|
|
97
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
|
+
|
|
98
157
|
## Wiring the statusline into Claude Code
|
|
99
158
|
|
|
100
159
|
In `~/.claude/settings.json`:
|
|
@@ -107,6 +166,72 @@ In `~/.claude/settings.json`:
|
|
|
107
166
|
Code calls the status line frequently, and a resolved binary avoids per-tick
|
|
108
167
|
latency.)
|
|
109
168
|
|
|
169
|
+
## External tool panes (sidebar)
|
|
170
|
+
|
|
171
|
+
The live sidebar can host **read-only panes from other tools**. A tool writes a
|
|
172
|
+
small JSON blob beside its own artifacts; you list that file's path in ccr's
|
|
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).
|
|
185
|
+
|
|
186
|
+
Config lives at `~/.config/ccr/config.json` (`$XDG_CONFIG_HOME` respected,
|
|
187
|
+
`CCR_CONFIG` overrides) — deliberately *not* in ccr's state dir, and never
|
|
188
|
+
read from a repository:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{ "panes": [ { "path": "/home/you/project/.your-tool/sidecar.json" } ] }
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A pane is a full-height view carrying the producing tool's own rows — here
|
|
195
|
+
`gherkin-trace`, whose blob ships as the golden example:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
trace gherkin-trace 3/3
|
|
199
|
+
refresh · 2026-08-01 14:10 · blob written 0s
|
|
200
|
+
|
|
201
|
+
● attention 3 1 breach, 2 orphans
|
|
202
|
+
● reviewed 8
|
|
203
|
+
◌ heat withheld no natural break
|
|
204
|
+
◌ binding dark no run manifest
|
|
205
|
+
● fence clean ▁▅▂█
|
|
206
|
+
● exceptions 0
|
|
207
|
+
· experimental off
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- ccr **reads the file, validates it, renders it** — that is the entire
|
|
211
|
+
integration. No subprocess, no plugin code, no schema knowledge of the
|
|
212
|
+
producing tool: a pane is data all the way down, and every blob string is
|
|
213
|
+
stripped of control bytes before it touches your terminal.
|
|
214
|
+
- **Producers never know ccr exists.** You wire the join by hand, exactly like
|
|
215
|
+
Claude Code's own `statusLine` — neither side takes a dependency on the other.
|
|
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 pane — the 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.
|
|
223
|
+
- **The producer is trusted for content, never for behaviour.** ccr executes
|
|
224
|
+
nothing from a blob and draws no value it has not validated, so a hostile file
|
|
225
|
+
cannot crash the panel or escape into your terminal — but what a pane *says*
|
|
226
|
+
is the producing tool's word, not ccr's. Point it only at files you would read
|
|
227
|
+
yourself.
|
|
228
|
+
- A malformed config yields no panes and a malformed blob renders as a **named
|
|
229
|
+
error state** — never a crash, never a misrender.
|
|
230
|
+
- The blob format is specified in
|
|
231
|
+
[`docs/PANE-CONTRACT.md`](docs/PANE-CONTRACT.md), with a golden example at
|
|
232
|
+
[`docs/pane-blob.golden.json`](docs/pane-blob.golden.json). Anything that
|
|
233
|
+
writes a conforming blob is a producer — there is no registry.
|
|
234
|
+
|
|
110
235
|
## Development
|
|
111
236
|
|
|
112
237
|
This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
|
|
@@ -120,6 +245,13 @@ canonical source; `test/gherkin.js` is a vendored copy). See
|
|
|
120
245
|
[`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the deliberate limits,
|
|
121
246
|
and the API.
|
|
122
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
|
+
|
|
123
255
|
```bash
|
|
124
256
|
npm test # node --test — harness self-tests + feature scenarios
|
|
125
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
|
|
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,19 +86,63 @@ 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 '
|
|
87
|
-
|
|
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
|
|
88
123
|
}
|
|
89
124
|
}
|
|
90
125
|
|
|
126
|
+
/**
|
|
127
|
+
* `ccr cycle-view` — show the running sidecar's next view. Bound to a key by
|
|
128
|
+
* the launcher; the sidecar itself reads no input (see src/cycle-view.js).
|
|
129
|
+
* Always exits 0: a keypress that finds no live sidecar is a no-op, not an
|
|
130
|
+
* error worth painting over the user's terminal.
|
|
131
|
+
* @param {string|undefined} stateDirFlag
|
|
132
|
+
* @param {string|null} [target] -i name, resolved through the chain
|
|
133
|
+
* @returns {number}
|
|
134
|
+
*/
|
|
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
|
+
}
|
|
142
|
+
require('../src/cycle-view').cycleView(stateDir);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
function readStdin() {
|
|
92
147
|
try { return process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
93
148
|
}
|
|
@@ -109,9 +164,32 @@ function cmdStatusline() {
|
|
|
109
164
|
const { renderStatusline } = require('../src/render/statusline');
|
|
110
165
|
const { logMeterSample } = require('../src/instrument');
|
|
111
166
|
const { ensureSecureDir } = require('../src/state-dir');
|
|
112
|
-
|
|
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);
|
|
113
177
|
try { ensureSecureDir(STATE_DIR); fs.writeFileSync(SNAPSHOT, raw, { mode: 0o600 }); } catch { /* ignore */ }
|
|
114
|
-
|
|
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 */ }
|
|
115
193
|
} catch { /* keep fallback line */ }
|
|
116
194
|
process.stdout.write(line + '\n');
|
|
117
195
|
return 0;
|
|
@@ -122,11 +200,29 @@ function cmdStatusline() {
|
|
|
122
200
|
* stable machine-readable economy model (the integration contract). Reads stdin,
|
|
123
201
|
* else the captured snapshot.
|
|
124
202
|
* @param {boolean} [json]
|
|
203
|
+
* @param {string|null} [target] -i name, resolved through the chain
|
|
125
204
|
* @returns {number}
|
|
126
205
|
*/
|
|
127
|
-
function cmdEconomy(json) {
|
|
206
|
+
function cmdEconomy(json, target = null) {
|
|
128
207
|
let raw = readStdin();
|
|
129
|
-
|
|
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
|
+
}
|
|
130
226
|
let state = null;
|
|
131
227
|
if (raw.trim()) { try { state = JSON.parse(raw); } catch { /* bad json */ } }
|
|
132
228
|
if (!state) {
|
|
@@ -135,12 +231,57 @@ function cmdEconomy(json) {
|
|
|
135
231
|
}
|
|
136
232
|
const { normalizeStatus } = require('../src/normalize');
|
|
137
233
|
if (json) {
|
|
234
|
+
// The machine contract (docs/JSON-CONTRACT.md) is unchanged: no heading.
|
|
138
235
|
const { computeEconomy } = require('../src/economy-model');
|
|
139
236
|
process.stdout.write(JSON.stringify(computeEconomy(normalizeStatus(state)), null, 2) + '\n');
|
|
140
237
|
return 0;
|
|
141
238
|
}
|
|
142
239
|
const { renderEconomy } = require('../src/render/economy');
|
|
143
|
-
|
|
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');
|
|
144
285
|
return 0;
|
|
145
286
|
}
|
|
146
287
|
|
|
@@ -166,15 +307,44 @@ function cmdResume(arg) {
|
|
|
166
307
|
* instructions + re-copies the one-liner instead of running the panel.
|
|
167
308
|
* `--exit-on-end` closes the panel shortly after the session ends (the Windows
|
|
168
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
|
+
*
|
|
169
317
|
* @param {string | undefined} stateDir
|
|
170
318
|
* @param {boolean} [showHint]
|
|
171
319
|
* @param {boolean} [exitOnEnd]
|
|
320
|
+
* @param {boolean} [useKeys]
|
|
321
|
+
* @param {number} [view]
|
|
322
|
+
* @param {string|null} [target] -i name, resolved through the chain
|
|
172
323
|
* @returns {number | undefined}
|
|
173
324
|
*/
|
|
174
|
-
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
|
+
}
|
|
175
332
|
if (stateDir) process.env.CCR_STATE_DIR = stateDir;
|
|
176
333
|
if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
|
|
177
|
-
|
|
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 });
|
|
178
348
|
return undefined;
|
|
179
349
|
}
|
|
180
350
|
|
|
@@ -184,19 +354,62 @@ function cmdSidecar(stateDir, showHint, exitOnEnd) {
|
|
|
184
354
|
* already works there); on native Windows we drive Windows Terminal; otherwise
|
|
185
355
|
* the tmux launcher (scripts/launch.sh).
|
|
186
356
|
* @param {string | undefined} profile
|
|
357
|
+
* @param {string} [name] explicit instance name (--name); validated here
|
|
187
358
|
* @returns {number}
|
|
188
359
|
*/
|
|
189
|
-
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 */ }
|
|
190
382
|
const inVscode = process.env.TERM_PROGRAM === 'vscode';
|
|
191
383
|
if (inVscode && (process.platform === 'win32' || process.env.CCR_VSCODE === '1')) {
|
|
192
|
-
return require('../src/launch-vscode').run(profile);
|
|
384
|
+
return require('../src/launch-vscode').run(profile, undefined, { name });
|
|
193
385
|
}
|
|
194
386
|
if (process.platform === 'win32') {
|
|
195
|
-
return require('../src/launch-win').run(profile);
|
|
387
|
+
return require('../src/launch-win').run(profile, undefined, { name });
|
|
196
388
|
}
|
|
197
389
|
const { spawnSync } = require('node:child_process');
|
|
198
390
|
const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
|
|
199
|
-
|
|
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);
|
|
200
413
|
if (r.error) { process.stderr.write(`ccr: launch failed: ${r.error.message}\n`); return 1; }
|
|
201
414
|
return typeof r.status === 'number' ? r.status : 1;
|
|
202
415
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-runrate",
|
|
3
|
-
"version": "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",
|