claude-code-runrate 0.1.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/LICENSE +21 -0
- package/README.md +116 -0
- package/bin/ccr.js +183 -0
- package/package.json +41 -0
- package/scripts/launch.sh +87 -0
- package/sidecar/ccr-statusline +9 -0
- package/sidecar/ccr.tmux.conf +16 -0
- package/src/burn.js +183 -0
- package/src/doctor.js +115 -0
- package/src/economy-model.js +122 -0
- package/src/instrument.js +67 -0
- package/src/liveness.js +39 -0
- package/src/normalize.js +32 -0
- package/src/rate-limits.js +89 -0
- package/src/render/economy.js +112 -0
- package/src/render/feed.js +64 -0
- package/src/render/resume.js +51 -0
- package/src/render/shared.js +49 -0
- package/src/render/statusline.js +48 -0
- package/src/resume.js +74 -0
- package/src/sanitize.js +31 -0
- package/src/sidecar.js +94 -0
- package/src/state-dir.js +21 -0
- package/src/theme.js +37 -0
- package/src/transcripts.js +270 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bing Ho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# ccr — Claude Code run-rate
|
|
2
|
+
|
|
3
|
+
> Know how fast you're burning your Claude Code subscription window — and whether
|
|
4
|
+
> clearing context now is worth it — without doing token math in your head.
|
|
5
|
+
|
|
6
|
+
**A solo side project, shared as-is** — built to scratch my own itch and
|
|
7
|
+
daily-driven. See [Status & support](#status--support) for what to expect, and
|
|
8
|
+
the acceptance criteria in [`features/`](features/) for exactly how the
|
|
9
|
+
burn-rate and clear-ROI math behaves.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
ccr reads Claude Code's own status-line JSON and your session transcripts and
|
|
16
|
+
shows you the **economy** of a session:
|
|
17
|
+
|
|
18
|
+
- **Burn rate** on your 5-hour (and weekly) rate-limit window — `%/min` and, more
|
|
19
|
+
usefully, **how long until you hit the wall**.
|
|
20
|
+
- **Clear ROI** — in plain language, how many more minutes clearing context now
|
|
21
|
+
would buy you.
|
|
22
|
+
- **Live tool/skills feed** — in the sidebar, a rolling view of recent tool and
|
|
23
|
+
slash-command calls plus per-session stats (files touched, tokens generated).
|
|
24
|
+
- **Resume advisor** (`ccr resume`) — recent sessions ranked by what they'd cost
|
|
25
|
+
to bring back (context size, share of the window, cold/warm cache), then it
|
|
26
|
+
hands selection to `claude --resume`.
|
|
27
|
+
|
|
28
|
+
For scripting and external tools (status bars, menu-bar widgets), `ccr economy
|
|
29
|
+
--json` emits a stable, versioned model — see
|
|
30
|
+
[`docs/JSON-CONTRACT.md`](docs/JSON-CONTRACT.md).
|
|
31
|
+
|
|
32
|
+
It is **read-only**: it never modifies `~/.claude/settings.json`, your
|
|
33
|
+
credentials, or any config. It targets **subscription** plans (Pro / Max); on
|
|
34
|
+
API/pay-as-you-go it degrades gracefully and just shows Claude Code's own cost.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# zero-install, on-demand report (any OS):
|
|
40
|
+
npx claude-code-runrate economy
|
|
41
|
+
|
|
42
|
+
# or install the `ccr` command for the live statusline / sidebar:
|
|
43
|
+
npm i -g claude-code-runrate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Surfaces
|
|
47
|
+
|
|
48
|
+
| Surface | What | Linux | macOS | Windows |
|
|
49
|
+
|---|---|:--:|:--:|:--:|
|
|
50
|
+
| `ccr economy` (CLI report) | Full panel on demand | ✅ | ✅ | ✅ |
|
|
51
|
+
| `ccr economy --json` | Stable machine-readable model (scripting) | ✅ | ✅ | ✅ |
|
|
52
|
+
| `ccr resume` | Recent sessions ranked by cost to resume | ✅ | ✅ | ✅ |
|
|
53
|
+
| `ccr statusline` | One-line summary wired into CC's `statusLine` | ✅ | ✅ | ✅ |
|
|
54
|
+
| `ccr sidecar` | Live tmux dashboard + tool/skills feed | ✅ | ✅ | ⛔ (tmux; use WSL) |
|
|
55
|
+
|
|
56
|
+
The CLI and statusline are pure Node — they run on native Windows. The rich live
|
|
57
|
+
sidebar needs `tmux`, so on Windows use WSL.
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- **Node ≥ 18.3** — that's it for the core. **Zero runtime dependencies.**
|
|
62
|
+
- `tmux` — only for the optional `ccr sidecar`.
|
|
63
|
+
|
|
64
|
+
## Wiring the statusline into Claude Code
|
|
65
|
+
|
|
66
|
+
In `~/.claude/settings.json`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "statusLine": { "type": "command", "command": "ccr statusline" } }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
(Install the binary with `npm i -g claude-code-runrate` rather than using `npx` here — Claude
|
|
73
|
+
Code calls the status line frequently, and a resolved binary avoids per-tick
|
|
74
|
+
latency.)
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
This project is built **BDD-first**: the Gherkin in [`features/`](features/) is
|
|
79
|
+
the source of truth, executed by a hand-rolled zero-dependency harness on top of
|
|
80
|
+
Node's built-in test runner.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm test # node --test — harness self-tests + feature scenarios
|
|
84
|
+
npm run typecheck # tsc --noEmit over @ts-check'd JS (needs: npm i first)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **No runtime dependencies**, ever — it's what lets `npx claude-code-runrate` install
|
|
88
|
+
instantly on every OS, including native Windows.
|
|
89
|
+
- **Dev-only** tooling (`typescript`, `@types/node`) exists solely for
|
|
90
|
+
`npm run typecheck`; it is never installed for consumers, so the runtime
|
|
91
|
+
promise is untouched.
|
|
92
|
+
- All source uses `// @ts-check` + JSDoc — type-checked, but shipped as plain JS
|
|
93
|
+
that runs straight from source (no build step, no `dist/`).
|
|
94
|
+
|
|
95
|
+
## Status & support
|
|
96
|
+
|
|
97
|
+
ccr is a **solo side project**. I built it to answer one question for myself —
|
|
98
|
+
*am I about to hit my Claude Code limit, and is clearing context worth it?* — and
|
|
99
|
+
I'm sharing it in case it's useful to you too. I use it daily, but please treat it
|
|
100
|
+
as **best-effort and as-is**:
|
|
101
|
+
|
|
102
|
+
- **Issues** — I read them all. Triage is usually within a week or two, faster for
|
|
103
|
+
anything that breaks the core (the economy/statusline math, or a platform that
|
|
104
|
+
won't run). A minimal repro and your OS + Node version help a lot.
|
|
105
|
+
- **Fixes** — core bugs get priority; nice-to-haves may sit for a while.
|
|
106
|
+
- **Pull requests** — welcome, especially small, focused ones with a test. I review
|
|
107
|
+
on the same best-effort cadence. For anything large, please open an issue first so
|
|
108
|
+
we don't both sink time into something I'd want shaped differently.
|
|
109
|
+
- **No SLA and no roadmap promises.** Things land when I have the itch or a good PR
|
|
110
|
+
shows up. If ccr stops being maintained I'll say so plainly at the top of this file.
|
|
111
|
+
|
|
112
|
+
If it saved you some token math, that's the whole goal. 🎸
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
[MIT](LICENSE) © 2026 Bing Ho
|
package/bin/ccr.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
'use strict';
|
|
4
|
+
// bin/ccr.js — ccr CLI + launcher. Pure Node, zero runtime deps.
|
|
5
|
+
//
|
|
6
|
+
// ccr launch `claude` + economy sidebar (tmux)
|
|
7
|
+
// ccr <profile> launch CCS profile + sidebar (e.g. `ccr c1`)
|
|
8
|
+
// ccr economy print the economy panel from the latest captured status
|
|
9
|
+
// ccr economy --json emit the machine-readable economy model (see docs/JSON-CONTRACT.md)
|
|
10
|
+
// ccr statusline emit one-line status (wired via --settings at launch)
|
|
11
|
+
// ccr sidecar run the live economy panel (used inside the tmux session)
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const { parseArgs } = require('node:util');
|
|
17
|
+
const pkg = require('../package.json');
|
|
18
|
+
|
|
19
|
+
// Per-session state dir (the launcher sets CCR_STATE_DIR per profile); default
|
|
20
|
+
// ~/.ccr for ad-hoc use. Always under the user's home — never world-shared /tmp.
|
|
21
|
+
const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
|
|
22
|
+
const SNAPSHOT = path.join(STATE_DIR, 'last-status.json');
|
|
23
|
+
const MAX_SNAPSHOT_BYTES = 1_000_000; // a status JSON is a few KB; cap to bound parse/disk
|
|
24
|
+
|
|
25
|
+
const HELP = `ccr — Claude Code run-rate (v${pkg.version})
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
ccr [profile] Launch Claude (or a CCS profile) with the live economy sidebar
|
|
29
|
+
ccr economy Print the economy panel from the latest captured status
|
|
30
|
+
ccr economy --json Emit the machine-readable economy model (stable contract)
|
|
31
|
+
ccr resume Recent sessions ranked by cost to resume (advisor)
|
|
32
|
+
ccr statusline Emit one-line status (wired automatically at launch)
|
|
33
|
+
ccr sidecar Run the live economy panel (used inside the tmux session)
|
|
34
|
+
ccr doctor Check your local setup (node, tmux, CCS, capture status)
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
ccr plain \`claude\` + sidebar
|
|
38
|
+
ccr c1 CCS profile c1 + sidebar
|
|
39
|
+
ccr economy one-off panel
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
-v, --version Show version`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string[]} argv
|
|
47
|
+
* @returns {number | undefined} exit code; undefined keeps the process alive (sidecar)
|
|
48
|
+
*/
|
|
49
|
+
function main(argv) {
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = parseArgs({
|
|
53
|
+
args: argv,
|
|
54
|
+
allowPositionals: true,
|
|
55
|
+
options: {
|
|
56
|
+
help: { type: 'boolean', short: 'h' },
|
|
57
|
+
version: { type: 'boolean', short: 'v' },
|
|
58
|
+
json: { type: 'boolean' },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
process.stderr.write(`ccr: ${err instanceof Error ? err.message : String(err)}\n\n${HELP}\n`);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { values, positionals } = parsed;
|
|
67
|
+
if (values.version) { process.stdout.write(`${pkg.version}\n`); return 0; }
|
|
68
|
+
if (values.help) { process.stdout.write(`${HELP}\n`); return 0; }
|
|
69
|
+
|
|
70
|
+
const cmd = positionals[0];
|
|
71
|
+
if (!cmd) return cmdLaunch(undefined); // bare `ccr` → launch
|
|
72
|
+
switch (cmd) {
|
|
73
|
+
case 'economy': return cmdEconomy(!!values.json);
|
|
74
|
+
case 'resume': return cmdResume(positionals[1]);
|
|
75
|
+
case 'statusline': return cmdStatusline();
|
|
76
|
+
case 'sidecar': return cmdSidecar();
|
|
77
|
+
case 'doctor': return require('../src/doctor').run();
|
|
78
|
+
case 'launch': return cmdLaunch(positionals[1]);
|
|
79
|
+
default: return cmdLaunch(cmd); // anything else → treat as a CCS profile
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readStdin() {
|
|
84
|
+
try { return process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `ccr statusline` — reads status JSON on stdin, persists the snapshot, logs a
|
|
89
|
+
* meter sample, prints a compact line. MUST never throw.
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
function cmdStatusline() {
|
|
93
|
+
const raw = readStdin();
|
|
94
|
+
let line = 'ccr';
|
|
95
|
+
// Bound input: a real status JSON is a few KB. Refuse to parse/persist an
|
|
96
|
+
// absurdly large blob (keeps the status line cheap and the snapshot small).
|
|
97
|
+
if (raw.length > MAX_SNAPSHOT_BYTES) { process.stdout.write(line + '\n'); return 0; }
|
|
98
|
+
try {
|
|
99
|
+
const state = JSON.parse(raw);
|
|
100
|
+
const { normalizeStatus } = require('../src/normalize');
|
|
101
|
+
const { renderStatusline } = require('../src/render/statusline');
|
|
102
|
+
const { logMeterSample } = require('../src/instrument');
|
|
103
|
+
const { ensureSecureDir } = require('../src/state-dir');
|
|
104
|
+
line = renderStatusline(normalizeStatus(state));
|
|
105
|
+
try { ensureSecureDir(STATE_DIR); fs.writeFileSync(SNAPSHOT, raw, { mode: 0o600 }); } catch { /* ignore */ }
|
|
106
|
+
try { logMeterSample(state, { dir: STATE_DIR }); } catch { /* ignore */ }
|
|
107
|
+
} catch { /* keep fallback line */ }
|
|
108
|
+
process.stdout.write(line + '\n');
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `ccr economy [--json]` — render the full panel, or with `--json` emit the
|
|
114
|
+
* stable machine-readable economy model (the integration contract). Reads stdin,
|
|
115
|
+
* else the captured snapshot.
|
|
116
|
+
* @param {boolean} [json]
|
|
117
|
+
* @returns {number}
|
|
118
|
+
*/
|
|
119
|
+
function cmdEconomy(json) {
|
|
120
|
+
let raw = readStdin();
|
|
121
|
+
if (!raw.trim()) { try { raw = fs.readFileSync(SNAPSHOT, 'utf8'); } catch { /* none yet */ } }
|
|
122
|
+
let state = null;
|
|
123
|
+
if (raw.trim()) { try { state = JSON.parse(raw); } catch { /* bad json */ } }
|
|
124
|
+
if (!state) {
|
|
125
|
+
process.stderr.write('ccr economy: no status captured yet. Run `ccr` (or `ccr <profile>`) to launch + capture.\n');
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
const { normalizeStatus } = require('../src/normalize');
|
|
129
|
+
if (json) {
|
|
130
|
+
const { computeEconomy } = require('../src/economy-model');
|
|
131
|
+
process.stdout.write(JSON.stringify(computeEconomy(normalizeStatus(state)), null, 2) + '\n');
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
const { renderEconomy } = require('../src/render/economy');
|
|
135
|
+
process.stdout.write(renderEconomy(normalizeStatus(state)) + '\n');
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `ccr resume [all]` — advisor: recent sessions ranked by cost to resume. Default
|
|
141
|
+
* scope is the current project; `all` widens to every project. Read-only; you pick
|
|
142
|
+
* with `claude --resume`.
|
|
143
|
+
* @param {string | undefined} arg
|
|
144
|
+
* @returns {number}
|
|
145
|
+
*/
|
|
146
|
+
function cmdResume(arg) {
|
|
147
|
+
const scope = (arg === 'all' || arg === '--all') ? 'all' : 'cwd';
|
|
148
|
+
const { gather } = require('../src/resume');
|
|
149
|
+
const { renderResume } = require('../src/render/resume');
|
|
150
|
+
process.stdout.write(renderResume(gather({ scope, cwd: process.cwd() }), { scope }) + '\n');
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `ccr sidecar` — live economy panel; keeps the process alive (no exit code). */
|
|
155
|
+
function cmdSidecar() {
|
|
156
|
+
require('../src/sidecar').run();
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `ccr [profile]` — launch the tmux session (claude/ccs + sidebar) via launch.sh.
|
|
162
|
+
* @param {string | undefined} profile
|
|
163
|
+
* @returns {number}
|
|
164
|
+
*/
|
|
165
|
+
function cmdLaunch(profile) {
|
|
166
|
+
if (process.platform === 'win32') {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
'ccr: the live sidebar needs tmux + bash, which native Windows lacks.\n' +
|
|
169
|
+
' Use WSL for the sidebar, or run these directly (they work natively):\n' +
|
|
170
|
+
' ccr economy one-off economy panel\n' +
|
|
171
|
+
" ccr statusline wire into Claude Code's statusLine\n" +
|
|
172
|
+
' ccr doctor check your setup\n');
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
const { spawnSync } = require('node:child_process');
|
|
176
|
+
const launcher = path.join(__dirname, '..', 'scripts', 'launch.sh');
|
|
177
|
+
const r = spawnSync('bash', profile ? [launcher, profile] : [launcher], { stdio: 'inherit' });
|
|
178
|
+
if (r.error) { process.stderr.write(`ccr: launch failed: ${r.error.message}\n`); return 1; }
|
|
179
|
+
return typeof r.status === 'number' ? r.status : 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const code = main(process.argv.slice(2));
|
|
183
|
+
if (typeof code === 'number') process.exit(code);
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-code-runrate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bing Ho <reps-attic-riot@duck.com>",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/bingh0/ccr.git" },
|
|
8
|
+
"bugs": { "url": "https://github.com/bingh0/ccr/issues" },
|
|
9
|
+
"homepage": "https://github.com/bingh0/ccr#readme",
|
|
10
|
+
"type": "commonjs",
|
|
11
|
+
"bin": {
|
|
12
|
+
"ccr": "bin/ccr.js"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.3"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src",
|
|
20
|
+
"scripts/launch.sh",
|
|
21
|
+
"sidecar",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test",
|
|
27
|
+
"typecheck": "tsc --noEmit -p jsconfig.json"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"claude-code",
|
|
31
|
+
"burn-rate",
|
|
32
|
+
"rate-limit",
|
|
33
|
+
"economy",
|
|
34
|
+
"cli"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^24.0.0",
|
|
39
|
+
"typescript": "^5.7.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/launch.sh — bring up a tmux session with:
|
|
3
|
+
# pane 0 — claude (or `ccs <profile>`) with statusLine injected via --settings
|
|
4
|
+
# pane 1 — the live economy sidebar (ccr sidecar)
|
|
5
|
+
#
|
|
6
|
+
# Usage (normally via the `ccr` CLI):
|
|
7
|
+
# ccr → plain `claude` + sidebar
|
|
8
|
+
# ccr c1 → CCS profile c1 + sidebar
|
|
9
|
+
#
|
|
10
|
+
# No config files are modified: statusLine is passed per-launch with --settings,
|
|
11
|
+
# so CCS symlinks, shared settings, and credentials are untouched. Per-profile
|
|
12
|
+
# state dirs keep concurrent profiles from colliding.
|
|
13
|
+
#
|
|
14
|
+
# Env overrides: CC_BIN, CCR_SESSION, CCR_STATE_DIR, CCR_SIDEBAR_PCT (default 34).
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
|
19
|
+
PROFILE="${1:-}"
|
|
20
|
+
|
|
21
|
+
# Validate the profile name: it goes into filesystem paths and the launched
|
|
22
|
+
# command, so allow only a safe identifier (letters, digits, . _ -).
|
|
23
|
+
if [ -n "$PROFILE" ] && ! printf '%s' "$PROFILE" | grep -qE '^[A-Za-z0-9._-]+$'; then
|
|
24
|
+
echo "ccr: invalid profile name '$PROFILE' (allowed: letters, digits, . _ -)" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# State lives under the user's home, never world-shared /tmp; create it
|
|
29
|
+
# owner-only so other local users can't read captured status.
|
|
30
|
+
umask 077
|
|
31
|
+
|
|
32
|
+
# Belt-and-suspenders: CC executes the statusLine command directly, so it must
|
|
33
|
+
# be executable even if git/npm didn't preserve the bit.
|
|
34
|
+
chmod +x "$REPO/sidecar/ccr-statusline" 2>/dev/null || true
|
|
35
|
+
|
|
36
|
+
# Prefer the newest nvm-installed node; `sort -V` is a GNU-ism, so suppress its
|
|
37
|
+
# error on BSD/macOS and fall back to PATH node below.
|
|
38
|
+
NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V 2>/dev/null | tail -1)"
|
|
39
|
+
[ -x "$NODE" ] || NODE="$(command -v node || true)"
|
|
40
|
+
[ -n "$NODE" ] || { echo "ccr: node not found" >&2; exit 1; }
|
|
41
|
+
command -v tmux >/dev/null 2>&1 || { echo "ccr: tmux not found (required for the sidebar)" >&2; exit 1; }
|
|
42
|
+
|
|
43
|
+
if [ -n "$PROFILE" ]; then
|
|
44
|
+
command -v ccs >/dev/null 2>&1 || { echo "ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed." >&2; exit 1; }
|
|
45
|
+
if [ ! -d "$HOME/.ccs/instances/$PROFILE" ]; then
|
|
46
|
+
echo "ccr: CCS profile '$PROFILE' not found ($HOME/.ccs/instances/$PROFILE)." >&2
|
|
47
|
+
echo " available: $(ls -1 "$HOME/.ccs/instances" 2>/dev/null | tr '\n' ' ')" >&2
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
CC_CMD="ccs $PROFILE"
|
|
51
|
+
SESSION="${CCR_SESSION:-ccr-$PROFILE}"
|
|
52
|
+
STATE="${CCR_STATE_DIR:-$HOME/.ccr/$PROFILE}"
|
|
53
|
+
else
|
|
54
|
+
CC_CMD="${CC_BIN:-claude}"
|
|
55
|
+
SESSION="${CCR_SESSION:-ccr}"
|
|
56
|
+
STATE="${CCR_STATE_DIR:-$HOME/.ccr}"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
mkdir -p "$STATE"
|
|
60
|
+
chmod 700 "$HOME/.ccr" "$STATE" 2>/dev/null || true
|
|
61
|
+
rm -f "$STATE/exited"
|
|
62
|
+
|
|
63
|
+
SETTINGS='{"statusLine":{"type":"command","command":"'"$REPO/sidecar/ccr-statusline"'"}}'
|
|
64
|
+
|
|
65
|
+
# Portable across GNU and BSD/macOS mktemp (the `-t PREFIX` form differs between
|
|
66
|
+
# them); also lands in the per-user $TMPDIR on macOS rather than shared /tmp.
|
|
67
|
+
RUN_CONF="$(mktemp "${TMPDIR:-/tmp}/ccr-tmux.XXXXXX")"
|
|
68
|
+
trap 'rm -f "$RUN_CONF"' EXIT
|
|
69
|
+
cp "$REPO/sidecar/ccr.tmux.conf" "$RUN_CONF"
|
|
70
|
+
|
|
71
|
+
# Clean re-launch.
|
|
72
|
+
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
|
73
|
+
|
|
74
|
+
ENV_PREAMBLE="export CCR_STATE_DIR='$STATE'"
|
|
75
|
+
|
|
76
|
+
# Pane 0: claude/ccs with --settings. On exit, drop the sentinel then close.
|
|
77
|
+
tmux new-session -d -s "$SESSION" \
|
|
78
|
+
"$ENV_PREAMBLE; $CC_CMD --settings '$SETTINGS'; touch '$STATE/exited'; sleep 2; tmux kill-session -t '$SESSION' 2>/dev/null"
|
|
79
|
+
tmux set-environment -t "$SESSION" CCR_STATE_DIR "$STATE"
|
|
80
|
+
|
|
81
|
+
# Pane 1: the live economy sidebar.
|
|
82
|
+
tmux split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" \
|
|
83
|
+
"$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '"
|
|
84
|
+
|
|
85
|
+
tmux select-pane -t "$SESSION:0.0"
|
|
86
|
+
tmux source-file -t "$SESSION" "$RUN_CONF"
|
|
87
|
+
tmux attach -t "$SESSION"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# sidecar/ccr-statusline — the statusLine command injected via `claude --settings`
|
|
3
|
+
# at launch. A single no-space path so it embeds cleanly in the settings JSON.
|
|
4
|
+
# Resolves node itself (survives nvm upgrades) and runs `ccr statusline`, which
|
|
5
|
+
# reads CCR_STATE_DIR from the environment the launcher exports.
|
|
6
|
+
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
7
|
+
NODE="$(ls -d "$HOME"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1)"
|
|
8
|
+
[ -x "$NODE" ] || NODE="$(command -v node)"
|
|
9
|
+
exec "$NODE" "$DIR/bin/ccr.js" statusline
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# sidecar/ccr.tmux.conf — applies ONLY to ccr's own tmux session, never your
|
|
2
|
+
# global tmux config. Minimal bindings — just F2 → /clear.
|
|
3
|
+
|
|
4
|
+
# F2 — send /clear to the Claude pane (reset context before compaction hits).
|
|
5
|
+
bind-key -n F2 send-keys -t .0 '/clear' Enter
|
|
6
|
+
|
|
7
|
+
set -g mouse on
|
|
8
|
+
set -g status off
|
|
9
|
+
|
|
10
|
+
# Generous scrollback + OSC52 clipboard so copy works across long output.
|
|
11
|
+
# (When Claude Code grabs the mouse: prefix + [ enters copy-mode, then wheel/PageUp.)
|
|
12
|
+
set -g history-limit 50000
|
|
13
|
+
set -g set-clipboard on
|
|
14
|
+
|
|
15
|
+
# prefix + P — dump the whole pane scrollback to a file, regardless of length.
|
|
16
|
+
bind-key P command-prompt -p 'save pane to:' 'capture-pane -S - ; save-buffer %1'
|
package/src/burn.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/burn.js
|
|
4
|
+
// Burn-rate and clear-ROI math, scoped to subscription plans.
|
|
5
|
+
//
|
|
6
|
+
// Key design points:
|
|
7
|
+
// - Rate is derived from the plan's own percentage meter (du/dt), so it is
|
|
8
|
+
// correct on Pro and Max without knowing the absolute token cap.
|
|
9
|
+
// - Rate is scoped to the active model — a prior model's samples must not
|
|
10
|
+
// pollute it.
|
|
11
|
+
// - BOTH the 5h and weekly windows are modelled; the binding (smaller) horizon
|
|
12
|
+
// is what "time to limit" reports. On Max the weekly window often binds.
|
|
13
|
+
// - clear-ROI is bounded: floored projected burn + capped at the nearest reset.
|
|
14
|
+
|
|
15
|
+
const FIVE_HOUR_MIN = 5 * 60; // 300
|
|
16
|
+
const SEVEN_DAY_MIN = 7 * 24 * 60; // 10080
|
|
17
|
+
const WINDOW_TIERS = [200000, 400000, 512000, 1000000];
|
|
18
|
+
|
|
19
|
+
// Pricing-ratio fallback for w(C) when no empirical calibration exists.
|
|
20
|
+
const READ_WEIGHT = 0.1;
|
|
21
|
+
const K_TAIL = 3000;
|
|
22
|
+
|
|
23
|
+
/** @param {string} [model] */
|
|
24
|
+
function modelWindowGuess(model) {
|
|
25
|
+
if (!model) return 0;
|
|
26
|
+
const m = model.toLowerCase();
|
|
27
|
+
if (m.includes('opus-4-8')) return 1000000;
|
|
28
|
+
if (m.includes('opus-4-6') || m.includes('opus-4-7')) return 200000;
|
|
29
|
+
if (m.includes('haiku')) return 200000;
|
|
30
|
+
return 0; // unknown → fall back to the observed-tier lower bound
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {number} maxCtx */
|
|
34
|
+
function inferWindowFromMax(maxCtx) {
|
|
35
|
+
for (const t of WINDOW_TIERS) if (maxCtx <= t) return t;
|
|
36
|
+
return 1000000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Effective window for normalizing context to "% of window". Prefer the live
|
|
41
|
+
* reported size when present; otherwise best-effort from model name + observed max.
|
|
42
|
+
* @param {{ model?: string, maxCtx?: number, reportedWindowSize?: number }} input
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
function inferWindow(input) {
|
|
46
|
+
if (input.reportedWindowSize) return input.reportedWindowSize;
|
|
47
|
+
return Math.max(modelWindowGuess(input.model), inferWindowFromMax(input.maxCtx || 0)) || 200000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Accept a reset value as epoch seconds (number or numeric string) OR an ISO
|
|
52
|
+
* string, returning epoch seconds. Robust to CC changing the field's format.
|
|
53
|
+
* @param {number | string | null | undefined} value
|
|
54
|
+
* @returns {number | null}
|
|
55
|
+
*/
|
|
56
|
+
function parseResetsAt(value) {
|
|
57
|
+
if (value == null) return null;
|
|
58
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
59
|
+
const s = String(value).trim();
|
|
60
|
+
if (/^\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
61
|
+
const ms = Date.parse(s);
|
|
62
|
+
return Number.isNaN(ms) ? null : ms / 1000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Model-scoped burn rate from a sample buffer. Uses only the contiguous tail of
|
|
67
|
+
* samples on the active model (since the last model switch).
|
|
68
|
+
* @param {{ t: number, u: number, m?: string }[]} samples t in minutes, u = used %
|
|
69
|
+
* @param {string} activeModel
|
|
70
|
+
* @returns {{ rate: number | null, switched: boolean, count: number }}
|
|
71
|
+
*/
|
|
72
|
+
function rateFromSamples(samples, activeModel) {
|
|
73
|
+
let start = 0;
|
|
74
|
+
for (let i = samples.length - 1; i >= 0; i--) {
|
|
75
|
+
if ((samples[i].m || '') !== activeModel) { start = i + 1; break; }
|
|
76
|
+
}
|
|
77
|
+
const tail = samples.slice(start);
|
|
78
|
+
let rate = null;
|
|
79
|
+
if (tail.length >= 2) {
|
|
80
|
+
const dt = tail[tail.length - 1].t - tail[0].t;
|
|
81
|
+
const du = tail[tail.length - 1].u - tail[0].u;
|
|
82
|
+
if (dt > 0 && du >= 0) rate = du / dt;
|
|
83
|
+
}
|
|
84
|
+
return { rate, switched: tail.length < samples.length, count: tail.length };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Estimate one window's burn + minutes-left. If no live `rate` is given, derive
|
|
89
|
+
* the average burn over the window so far: used% / elapsed, where elapsed is the
|
|
90
|
+
* window length minus the time remaining until it resets.
|
|
91
|
+
* @param {{ usedPct: number, minutesToReset?: number, windowMinutes?: number, rate?: number }} w
|
|
92
|
+
* @returns {{ usedPct: number, rate: number | null, minutesLeft: number | null, minutesToReset: number | null }}
|
|
93
|
+
*/
|
|
94
|
+
function windowEstimate(w) {
|
|
95
|
+
let rate = w.rate ?? null;
|
|
96
|
+
if (rate == null && w.minutesToReset != null && w.windowMinutes != null) {
|
|
97
|
+
const elapsed = w.windowMinutes - w.minutesToReset;
|
|
98
|
+
if (elapsed > 0) rate = w.usedPct / elapsed;
|
|
99
|
+
}
|
|
100
|
+
const minutesLeft = (rate != null && rate > 0) ? (100 - w.usedPct) / rate : null;
|
|
101
|
+
return { usedPct: w.usedPct, rate, minutesLeft, minutesToReset: w.minutesToReset ?? null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Pick the binding window — the one you'll hit first (smallest minutes-left) —
|
|
106
|
+
* from an arbitrary set of buckets (5h, weekly, model-scoped, monthly, …).
|
|
107
|
+
* Tolerates any plan shape without blanking; returns null for no windows.
|
|
108
|
+
* @param {{ key: string, est: any, reset?: number|null }[]} windows
|
|
109
|
+
* @returns {{ window: string, minutesLeft: number | null, all: any[] } | null}
|
|
110
|
+
*/
|
|
111
|
+
function binding(windows) {
|
|
112
|
+
if (!Array.isArray(windows) || !windows.length) return null;
|
|
113
|
+
/** @param {any} w */
|
|
114
|
+
const ml = (w) => {
|
|
115
|
+
const m = w && w.est && w.est.minutesLeft;
|
|
116
|
+
return (m == null || !isFinite(m)) ? Infinity : m;
|
|
117
|
+
};
|
|
118
|
+
const sorted = [...windows].sort((a, b) => ml(a) - ml(b));
|
|
119
|
+
const top = sorted[0];
|
|
120
|
+
return { window: top.key, minutesLeft: top.est ? top.est.minutesLeft : null, all: windows };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Minutes of budget a /clear would buy. Floors the projected post-clear burn and
|
|
125
|
+
* caps the gain at the nearest reset horizon, so it can never explode.
|
|
126
|
+
* @param {{ rate: number | null, usedPct: number, contextC: number, baselineB: number, calib?: {a:number,b:number} | null, resetMinutes?: number }} o
|
|
127
|
+
* @returns {{ boughtMinutes: number, projectedBurn: number | null }}
|
|
128
|
+
*/
|
|
129
|
+
function clearROI(o) {
|
|
130
|
+
if (o.rate == null || !(o.contextC > o.baselineB * 1.2)) {
|
|
131
|
+
return { boughtMinutes: 0, projectedBurn: o.rate };
|
|
132
|
+
}
|
|
133
|
+
let ratio;
|
|
134
|
+
if (o.calib) {
|
|
135
|
+
const w = (x) => Math.max(o.calib.a * x + o.calib.b, 1e-9);
|
|
136
|
+
ratio = w(o.baselineB) / w(o.contextC);
|
|
137
|
+
} else {
|
|
138
|
+
const w = (x) => READ_WEIGHT * x + K_TAIL;
|
|
139
|
+
ratio = w(o.baselineB) / w(o.contextC);
|
|
140
|
+
}
|
|
141
|
+
// A clear can't shed the output/write tail or the retained baseline, so burn
|
|
142
|
+
// can't realistically drop below ~5% of current (or an absolute 0.01%/min).
|
|
143
|
+
const rNew = Math.max(o.rate * ratio, o.rate * 0.05, 0.01);
|
|
144
|
+
const remaining = 100 - o.usedPct;
|
|
145
|
+
const horizon = (o.resetMinutes != null && isFinite(o.resetMinutes)) ? o.resetMinutes : Infinity;
|
|
146
|
+
const lifeOld = Math.min(remaining / o.rate, horizon);
|
|
147
|
+
const lifeNew = Math.min(remaining / rNew, horizon);
|
|
148
|
+
return { boughtMinutes: Math.max(lifeNew - lifeOld, 0), projectedBurn: rNew };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Interpretable burn-rate estimate: an EWMA of recent per-interval rates, scoped
|
|
153
|
+
* to the active model. "A running average of your recent burn, weighting the last
|
|
154
|
+
* few minutes most." Chosen over Kalman/window after backtesting real transcripts
|
|
155
|
+
* (scripts/backtest-burn.js): trend overfits, raw-instantaneous jitters ~5x more,
|
|
156
|
+
* and a recency-weighted average is as accurate while far steadier — and any
|
|
157
|
+
* reviewer can verify the calculation. Forecast the sustained rate, not the tick.
|
|
158
|
+
* @param {{ t: number, u: number, m?: string }[]} samples t in minutes, u = used %
|
|
159
|
+
* @param {string} activeModel
|
|
160
|
+
* @param {number} [alpha] smoothing in (0,1]; higher = more responsive. Default 0.3.
|
|
161
|
+
* @returns {{ rate: number | null, samples: number, switched: boolean }}
|
|
162
|
+
*/
|
|
163
|
+
function smoothedRate(samples, activeModel, alpha = 0.3) {
|
|
164
|
+
let start = 0;
|
|
165
|
+
for (let i = samples.length - 1; i >= 0; i--) {
|
|
166
|
+
if ((samples[i].m || '') !== activeModel) { start = i + 1; break; }
|
|
167
|
+
}
|
|
168
|
+
const tail = samples.slice(start);
|
|
169
|
+
let ewma = NaN; // NaN = "no rate yet" (avoids null typing)
|
|
170
|
+
for (let i = 1; i < tail.length; i++) {
|
|
171
|
+
const dt = tail[i].t - tail[i - 1].t;
|
|
172
|
+
const du = tail[i].u - tail[i - 1].u;
|
|
173
|
+
if (dt <= 0 || du < 0) continue; // skip idle gaps / meter resets
|
|
174
|
+
const r = du / dt;
|
|
175
|
+
ewma = isNaN(ewma) ? r : alpha * r + (1 - alpha) * ewma;
|
|
176
|
+
}
|
|
177
|
+
return { rate: isNaN(ewma) ? null : ewma, samples: tail.length, switched: tail.length < samples.length };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
FIVE_HOUR_MIN, SEVEN_DAY_MIN,
|
|
182
|
+
inferWindow, parseResetsAt, rateFromSamples, smoothedRate, windowEstimate, binding, clearROI,
|
|
183
|
+
};
|