claude-code-runrate 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -10
- package/bin/ccr.js +223 -26
- package/package.json +4 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +13 -2
- 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/migrate.js +155 -0
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +49 -1
- package/src/render/statusline.js +42 -4
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +145 -20
- package/src/state-dir.js +44 -1
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**. VS Code and its forks
|
|
177
|
+
(Cursor, Positron, Antigravity) bind no key and leave both split panes running a
|
|
178
|
+
foreground process, so the pasted sidecar one-liner carries `--keys`: click that
|
|
179
|
+
pane and press **Space** (or **F3**). The renderer still reads no input — the key
|
|
180
|
+
lives in a separate parent process that runs the panel as a child, the same
|
|
181
|
+
separation tmux enforces. Anywhere else, `ccr cycle-view -i <name>` (or
|
|
182
|
+
`--state-dir <dir>`); a bare `ccr cycle-view` resolves the instance from your
|
|
183
|
+
working directory, like `ccr economy` does.
|
|
184
|
+
`ccr sidecar --view <n>` opens on a chosen view (0 economy, 1 git, 2+ panes).
|
|
119
185
|
|
|
120
186
|
Config lives at `~/.config/ccr/config.json` (`$XDG_CONFIG_HOME` respected,
|
|
121
187
|
`CCR_CONFIG` overrides) — deliberately *not* in ccr's state dir, and never
|
|
@@ -129,7 +195,7 @@ A pane is a full-height view carrying the producing tool's own rows — here
|
|
|
129
195
|
`gherkin-trace`, whose blob ships as the golden example:
|
|
130
196
|
|
|
131
197
|
```
|
|
132
|
-
trace gherkin-trace
|
|
198
|
+
trace gherkin-trace 3/3
|
|
133
199
|
refresh · 2026-08-01 14:10 · blob written 0s
|
|
134
200
|
|
|
135
201
|
● attention 3 1 breach, 2 orphans
|
|
@@ -147,10 +213,13 @@ trace gherkin-trace 2/2
|
|
|
147
213
|
stripped of control bytes before it touches your terminal.
|
|
148
214
|
- **Producers never know ccr exists.** You wire the join by hand, exactly like
|
|
149
215
|
Claude Code's own `statusLine` — neither side takes a dependency on the other.
|
|
150
|
-
- **Config order is cycle order.** F3 goes economy panel →
|
|
151
|
-
pane → back to economy; the `
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
154
223
|
- **The producer is trusted for content, never for behaviour.** ccr executes
|
|
155
224
|
nothing from a blob and draws no value it has not validated, so a hostile file
|
|
156
225
|
cannot crash the panel or escape into your terminal — but what a pane *says*
|
|
@@ -176,6 +245,13 @@ canonical source; `test/gherkin.js` is a vendored copy). See
|
|
|
176
245
|
[`docs/GHERKIN.md`](docs/GHERKIN.md) for the grammar, the deliberate limits,
|
|
177
246
|
and the API.
|
|
178
247
|
|
|
248
|
+
The *practice* is documented separately in [`docs/BDD.md`](docs/BDD.md): what
|
|
249
|
+
these scenarios (271 of them, at 0.4.0) took from conventional BDD, where they departed — refusals
|
|
250
|
+
as a quarter of the specification, tags that are gates rather than filters,
|
|
251
|
+
comments that carry rulings — and why. It also places ccr in the lineage the
|
|
252
|
+
method has followed since, and is honest about which point on that line this
|
|
253
|
+
repository represents.
|
|
254
|
+
|
|
179
255
|
```bash
|
|
180
256
|
npm test # node --test — harness self-tests + feature scenarios
|
|
181
257
|
npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
|
package/bin/ccr.js
CHANGED
|
@@ -34,14 +34,21 @@ Usage:
|
|
|
34
34
|
ccr doctor Check your local setup (node, tmux, CCS, capture status)
|
|
35
35
|
|
|
36
36
|
Examples:
|
|
37
|
-
ccr
|
|
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,44 @@ function cmdResume(arg) {
|
|
|
182
307
|
* instructions + re-copies the one-liner instead of running the panel.
|
|
183
308
|
* `--exit-on-end` closes the panel shortly after the session ends (the Windows
|
|
184
309
|
* Terminal launcher passes it so its `cmd /c` pane sweeps closed like tmux).
|
|
310
|
+
* `--view <n>` opens on a chosen view (0 economy, 1 git, 2…N configured panes).
|
|
311
|
+
*
|
|
312
|
+
* `--keys` makes THIS process the hotkey host and runs the panel as a child, for
|
|
313
|
+
* terminals that bind no key of their own (VS Code and its forks). The renderer
|
|
314
|
+
* still never reads input — see the header of src/sidecar-keys.js for why that
|
|
315
|
+
* separation is the whole point, and why it is a child rather than a listener.
|
|
316
|
+
*
|
|
185
317
|
* @param {string | undefined} stateDir
|
|
186
318
|
* @param {boolean} [showHint]
|
|
187
319
|
* @param {boolean} [exitOnEnd]
|
|
320
|
+
* @param {boolean} [useKeys]
|
|
321
|
+
* @param {number} [view]
|
|
322
|
+
* @param {string|null} [target] -i name, resolved through the chain
|
|
188
323
|
* @returns {number | undefined}
|
|
189
324
|
*/
|
|
190
|
-
function cmdSidecar(stateDir, showHint, exitOnEnd) {
|
|
325
|
+
function cmdSidecar(stateDir, showHint, exitOnEnd, useKeys, view, target = null) {
|
|
326
|
+
if (!stateDir && target != null) {
|
|
327
|
+
// -i: attach to a live instance by name, through the resolution chain.
|
|
328
|
+
const res = require('../src/instance-resolve').resolveInstance({ target, command: 'sidecar' });
|
|
329
|
+
if (!res.ok) { process.stderr.write(res.error + '\n'); return 1; }
|
|
330
|
+
stateDir = res.stateDir;
|
|
331
|
+
}
|
|
191
332
|
if (stateDir) process.env.CCR_STATE_DIR = stateDir;
|
|
192
333
|
if (showHint) return require('../src/launch-vscode').hint(process.env.CCR_STATE_DIR || STATE_DIR);
|
|
193
|
-
|
|
334
|
+
if (useKeys) {
|
|
335
|
+
require('../src/sidecar-keys').runWithKeys({
|
|
336
|
+
stateDir: process.env.CCR_STATE_DIR || STATE_DIR,
|
|
337
|
+
// This very file, so the child is the same ccr the user invoked — not
|
|
338
|
+
// whatever `ccr` happens to resolve to on the child's PATH.
|
|
339
|
+
ccrJs: __filename,
|
|
340
|
+
argv: [
|
|
341
|
+
...(exitOnEnd ? ['--exit-on-end'] : []),
|
|
342
|
+
...(view != null ? ['--view', String(view)] : []),
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
require('../src/sidecar').run({ exitOnEnd: !!exitOnEnd, view });
|
|
194
348
|
return undefined;
|
|
195
349
|
}
|
|
196
350
|
|
|
@@ -200,19 +354,62 @@ function cmdSidecar(stateDir, showHint, exitOnEnd) {
|
|
|
200
354
|
* already works there); on native Windows we drive Windows Terminal; otherwise
|
|
201
355
|
* the tmux launcher (scripts/launch.sh).
|
|
202
356
|
* @param {string | undefined} profile
|
|
357
|
+
* @param {string} [name] explicit instance name (--name); validated here
|
|
203
358
|
* @returns {number}
|
|
204
359
|
*/
|
|
205
|
-
function cmdLaunch(profile) {
|
|
360
|
+
function cmdLaunch(profile, name) {
|
|
361
|
+
// An explicit name is REJECTED, never repaired: the user typed it and a
|
|
362
|
+
// human is right there — matching the launcher's profile-name message shape
|
|
363
|
+
// (features/instance-naming.feature).
|
|
364
|
+
const naming = require('../src/instance-name');
|
|
365
|
+
if (name != null && !naming.NAME_RE.test(name)) {
|
|
366
|
+
process.stderr.write(`ccr: invalid instance name '${name}' (allowed: letters, digits, . _ -)\n`);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
// Migration runs AT LAUNCH ONLY — never from `ccr statusline`, which Claude
|
|
370
|
+
// invokes headlessly mid-session (src/migrate.js). A refused migration is a
|
|
371
|
+
// refused launch: proceeding would write new-layout state into an old home.
|
|
372
|
+
const mig = require('../src/migrate').ensureLayout();
|
|
373
|
+
if (!mig.ok) { process.stderr.write(mig.error + '\n'); return 1; }
|
|
374
|
+
// Shared across ALL launchers (a platform must never ship half-lit): the
|
|
375
|
+
// explicit-name collision refusal, and the retention boundary.
|
|
376
|
+
const live = naming.liveNames({ home: os.homedir() });
|
|
377
|
+
if (name != null && live.has(name)) {
|
|
378
|
+
process.stderr.write(`ccr: instance name '${name}' is already live — pick another\n`);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
try { require('../src/session-log').pruneHistory(os.homedir()); } catch { /* best effort */ }
|
|
206
382
|
const inVscode = process.env.TERM_PROGRAM === 'vscode';
|
|
207
383
|
if (inVscode && (process.platform === 'win32' || process.env.CCR_VSCODE === '1')) {
|
|
208
|
-
return require('../src/launch-vscode').run(profile);
|
|
384
|
+
return require('../src/launch-vscode').run(profile, undefined, { name });
|
|
209
385
|
}
|
|
210
386
|
if (process.platform === 'win32') {
|
|
211
|
-
return require('../src/launch-win').run(profile);
|
|
387
|
+
return require('../src/launch-win').run(profile, undefined, { name });
|
|
212
388
|
}
|
|
213
389
|
const { spawnSync } = require('node:child_process');
|
|
214
390
|
const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
|
|
215
|
-
|
|
391
|
+
// Every launch gets a free instance slot — bare or profiled — so a second
|
|
392
|
+
// terminal never collides with the first (src/instance-slot.js). The
|
|
393
|
+
// launcher needs no knowledge of slots: it already derives session, socket
|
|
394
|
+
// and state dir from these two vars.
|
|
395
|
+
const slots = require('../src/instance-slot');
|
|
396
|
+
const slot = slots.allocateSlot({ profile });
|
|
397
|
+
if (slot && 'exhausted' in slot) {
|
|
398
|
+
process.stderr.write(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
|
|
399
|
+
return 1;
|
|
400
|
+
}
|
|
401
|
+
// Name + profile record + title, the same way every launcher does it.
|
|
402
|
+
const inst = slot && !('exhausted' in slot)
|
|
403
|
+
? naming.prepareInstance(slot, { profile, name }) : null;
|
|
404
|
+
let env = slots.applySlotEnv(process.env, slot);
|
|
405
|
+
// The tab's ADDRESS, composed once here and never retitled mid-session.
|
|
406
|
+
if (inst) env = { ...env, CCR_TITLE: inst.title };
|
|
407
|
+
// This call blocks for the session's whole lifetime (launch.sh ends in `tmux
|
|
408
|
+
// attach`), which is exactly why this process is what owns the slot.
|
|
409
|
+
const r = spawnSync('bash', profile ? [launcher, profile] : [launcher], { stdio: 'inherit', env });
|
|
410
|
+
// Ephemeral instances: a polite exit deletes the instance dir, unless an
|
|
411
|
+
// attached sidebar is still reading it (then the dir waits for reuse).
|
|
412
|
+
if (slot && !('exhausted' in slot)) slots.retireInstance(slot.stateDir);
|
|
216
413
|
if (r.error) { process.stderr.write(`ccr: launch failed: ${r.error.message}\n`); return 1; }
|
|
217
414
|
return typeof r.status === 'number' ? r.status : 1;
|
|
218
415
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-runrate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bing Ho <reps-attic-riot@duck.com>",
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"test": "node --test",
|
|
32
32
|
"typecheck": "tsc --noEmit -p jsconfig.json",
|
|
33
|
-
"lint": "oxlint"
|
|
33
|
+
"lint": "oxlint",
|
|
34
|
+
"release-gate": "node scripts/release-gate.js",
|
|
35
|
+
"prepublishOnly": "npm run release-gate && npm run lint && npm run typecheck && npm test"
|
|
34
36
|
},
|
|
35
37
|
"keywords": [
|
|
36
38
|
"claude-code",
|
package/scripts/launch.sh
CHANGED
|
@@ -42,7 +42,14 @@ chmod +x "$REPO/sidecar/ccr-statusline" 2>/dev/null || true
|
|
|
42
42
|
|
|
43
43
|
# Prefer the newest nvm-installed node; `sort -V` is a GNU-ism, so suppress its
|
|
44
44
|
# error on BSD/macOS and fall back to PATH node below.
|
|
45
|
-
|
|
45
|
+
#
|
|
46
|
+
# The `|| true` is load-bearing, not defensive habit: with `set -e` and
|
|
47
|
+
# `pipefail`, `ls` failing on a machine with no ~/.nvm makes the whole pipeline
|
|
48
|
+
# non-zero, and a failing command substitution in an assignment aborts the
|
|
49
|
+
# script. That killed the launcher outright — exit 2, no message, no sidebar —
|
|
50
|
+
# for exactly the user who has plain Claude Code and no nvm, who then never
|
|
51
|
+
# reaches the PATH fallback two lines below. Reproduced 2026-08-04.
|
|
52
|
+
NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1 || true)"
|
|
46
53
|
[ -x "$NODE" ] || NODE="$(command -v node || true)"
|
|
47
54
|
[ -n "$NODE" ] || { echo "ccr: node not found" >&2; exit 1; }
|
|
48
55
|
command -v tmux >/dev/null 2>&1 || { echo "ccr: tmux not found (required for the sidebar)" >&2; exit 1; }
|
|
@@ -55,13 +62,15 @@ if [ -n "$PROFILE" ]; then
|
|
|
55
62
|
exit 1
|
|
56
63
|
fi
|
|
57
64
|
CC_CMD="ccs $PROFILE"
|
|
58
|
-
SESSION="${CCR_SESSION:-ccr-$PROFILE}"
|
|
59
|
-
STATE="${CCR_STATE_DIR:-$HOME/.ccr/$PROFILE}"
|
|
60
65
|
else
|
|
61
66
|
CC_CMD="${CC_BIN:-claude}"
|
|
62
|
-
SESSION="${CCR_SESSION:-ccr}"
|
|
63
|
-
STATE="${CCR_STATE_DIR:-$HOME/.ccr}"
|
|
64
67
|
fi
|
|
68
|
+
# The launcher normally arrives with CCR_SESSION/CCR_STATE_DIR already set by
|
|
69
|
+
# the slot allocator (bin/ccr.js — every launch slots, profiled or bare). The
|
|
70
|
+
# fallbacks cover only a direct invocation of this script, and they point at
|
|
71
|
+
# slot 1's member dir: ~/.ccr itself is a container now, never a state dir.
|
|
72
|
+
SESSION="${CCR_SESSION:-ccr}"
|
|
73
|
+
STATE="${CCR_STATE_DIR:-$HOME/.ccr/instances/1}"
|
|
65
74
|
|
|
66
75
|
# Every instance gets its OWN tmux server, on a socket named after the session
|
|
67
76
|
# (-L puts it under /tmp/tmux-$UID/). On a shared server, one server death —
|
|
@@ -76,6 +85,17 @@ SOCKET="$SESSION"
|
|
|
76
85
|
mkdir -p "$STATE"
|
|
77
86
|
chmod 700 "$HOME/.ccr" "$STATE" 2>/dev/null || true
|
|
78
87
|
rm -f "$STATE/exited"
|
|
88
|
+
# The directory ccr was launched in — the tab's stable identity for the git
|
|
89
|
+
# pane (src/state-dir.js: recordLaunchDir does the same for the other two
|
|
90
|
+
# launchers). Best-effort: a tab that cannot record it falls back to naming
|
|
91
|
+
# only the repo the session is in.
|
|
92
|
+
# The rm is load-bearing, not tidiness: `>` on a planted FIFO blocks until a
|
|
93
|
+
# reader appears and hangs the launcher here, before Claude is ever spawned, and
|
|
94
|
+
# on a planted symlink it overwrites the link's target. Removing first turns both
|
|
95
|
+
# into an ordinary create. src/state-dir.js does the same for the other two
|
|
96
|
+
# launchers, and both sibling writers in <stateDir> already guard this way.
|
|
97
|
+
rm -f "$STATE/launch-cwd" 2>/dev/null || true
|
|
98
|
+
printf '%s\n' "$PWD" > "$STATE/launch-cwd" 2>/dev/null || true
|
|
79
99
|
|
|
80
100
|
SETTINGS='{"statusLine":{"type":"command","command":"'"$REPO/sidecar/ccr-statusline"'"}}'
|
|
81
101
|
|
|
@@ -105,6 +125,15 @@ CLAUDE_PANE="$(tmux -L "$SOCKET" new-session -d -P -F '#{pane_id}' -s "$SESSION"
|
|
|
105
125
|
"$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE_Q/exited'; sleep 2; tmux -L '$SOCKET_Q' kill-session -t '$SESSION_Q' 2>/dev/null")"
|
|
106
126
|
tmux -L "$SOCKET" set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
|
|
107
127
|
|
|
128
|
+
# The tab's ADDRESS: composed once by the launcher (CCR_TITLE = "[profile / ]name",
|
|
129
|
+
# both halves allow-listed), never retitled mid-session. set-titles-string is a
|
|
130
|
+
# literal — deliberately NOT a tmux format that would follow the session; the
|
|
131
|
+
# pane and the status line are the surfaces honest about movement.
|
|
132
|
+
if [ -n "${CCR_TITLE:-}" ]; then
|
|
133
|
+
tmux -L "$SOCKET" set-option -t "$SESSION" set-titles on
|
|
134
|
+
tmux -L "$SOCKET" set-option -t "$SESSION" set-titles-string "$CCR_TITLE"
|
|
135
|
+
fi
|
|
136
|
+
|
|
108
137
|
# Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
|
|
109
138
|
SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
|
|
110
139
|
"$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
|
package/src/account-limits.js
CHANGED
|
@@ -33,7 +33,8 @@ const { parseResetsAt } = require('./burn');
|
|
|
33
33
|
const { modelScope } = require('./rate-limits');
|
|
34
34
|
|
|
35
35
|
const MAX_SNAPSHOT_BYTES = 1_000_000; // a status JSON is a few KB; bound parse/disk
|
|
36
|
-
const MAX_PROFILES = 32; // sanity cap on how many siblings we
|
|
36
|
+
const MAX_PROFILES = 32; // sanity cap on how many siblings we merge
|
|
37
|
+
const MAX_SCAN_ENTRIES = 512; // sanity cap on how many dir entries we inspect
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Canonical reset instant for fingerprinting/matching — tolerant of CC reporting
|
|
@@ -127,12 +128,14 @@ function readSiblingRateLimits(file) {
|
|
|
127
128
|
* reconcile the local meters against them. Best-effort — returns `localRl` on any
|
|
128
129
|
* problem so it can wrap the render path without a guard at the call site.
|
|
129
130
|
*
|
|
130
|
-
* Engages ONLY for the launcher's
|
|
131
|
-
*
|
|
132
|
-
*
|
|
131
|
+
* Engages ONLY for the launcher's layout: instances under `~/.ccr/instances/`
|
|
132
|
+
* (the 0.4.0 container/member split — src/instance-slot.js). A custom
|
|
133
|
+
* CCR_STATE_DIR elsewhere has no sibling set to trust, so it behaves as
|
|
134
|
+
* before (no merge). There is no slot-1 special case any more: slot 1 is an
|
|
135
|
+
* ordinary member of instances/, which is exactly why the layout changed.
|
|
133
136
|
*
|
|
134
137
|
* @param {any} localRl the local snapshot's `rate_limits`
|
|
135
|
-
* @param {string} stateDir the local
|
|
138
|
+
* @param {string} stateDir the local instance's state dir (CCR_STATE_DIR)
|
|
136
139
|
* @param {{ home?: string }} [opts]
|
|
137
140
|
* @returns {any}
|
|
138
141
|
*/
|
|
@@ -140,21 +143,26 @@ function freshenAccountLimits(localRl, stateDir, opts = {}) {
|
|
|
140
143
|
try {
|
|
141
144
|
if (!localRl || typeof localRl !== 'object') return localRl;
|
|
142
145
|
const home = opts.home || os.homedir();
|
|
143
|
-
const root = path.
|
|
144
|
-
|
|
146
|
+
const root = path.resolve(path.join(home, '.ccr', 'instances'));
|
|
147
|
+
const self = path.resolve(stateDir);
|
|
148
|
+
if (path.dirname(self) !== root) return localRl; // not the launcher's layout
|
|
145
149
|
const selfFile = path.resolve(path.join(stateDir, 'last-status.json'));
|
|
146
150
|
|
|
147
151
|
/** @type {any[]} */
|
|
148
152
|
const siblings = [];
|
|
153
|
+
// Bound the WALK, not just the harvest: MAX_PROFILES alone counts collected
|
|
154
|
+
// siblings, so entries that yield nothing — slot dirs with no snapshot yet,
|
|
155
|
+
// junk — would all be stat'd on every tick. This runs inside the sidecar's
|
|
156
|
+
// ~1s draw loop, so the scan stays capped by entries seen even though
|
|
157
|
+
// instances/ holds only instance dirs under the 0.4.0 layout.
|
|
158
|
+
let seen = 0;
|
|
149
159
|
for (const name of fs.readdirSync(root)) {
|
|
150
|
-
if (siblings.length >= MAX_PROFILES) break;
|
|
160
|
+
if (siblings.length >= MAX_PROFILES || ++seen > MAX_SCAN_ENTRIES) break;
|
|
151
161
|
const p = path.join(root, name);
|
|
152
162
|
let st; try { st = fs.statSync(p); } catch { continue; }
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
: (name === 'last-status.json' ? p : null);
|
|
157
|
-
if (!file || path.resolve(file) === selfFile) continue;
|
|
163
|
+
if (!st.isDirectory()) continue;
|
|
164
|
+
const file = path.join(p, 'last-status.json');
|
|
165
|
+
if (path.resolve(file) === selfFile) continue;
|
|
158
166
|
const rl = readSiblingRateLimits(file);
|
|
159
167
|
if (rl) siblings.push(rl);
|
|
160
168
|
}
|