claude-code-runrate 0.2.1 → 0.2.3
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/package.json +1 -1
- package/src/account-limits.js +165 -0
- package/src/render/shared.js +11 -1
- package/src/sidecar.js +18 -1
package/package.json
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/account-limits.js — reconcile the ACCOUNT-WIDE rate-limit meters across the
|
|
4
|
+
// live ccr profiles (cq/cw/ce/cr, …) so their sidecars agree.
|
|
5
|
+
//
|
|
6
|
+
// THE PROBLEM. The 5h and weekly walls are one shared account resource, but each
|
|
7
|
+
// ccr profile only captures them when ITS OWN Claude session renders the status
|
|
8
|
+
// line. Claude Code re-emits the status line per turn, not on a clock, so an idle
|
|
9
|
+
// profile keeps showing the numbers from its last turn. Two sidecars open
|
|
10
|
+
// side-by-side therefore disagree purely by capture time — the busy one is ahead,
|
|
11
|
+
// the idle one lags. (The model in use is irrelevant: 5h/weekly are not
|
|
12
|
+
// model-scoped.) We fix this by raising each meter the LOCAL profile already knows
|
|
13
|
+
// to the freshest value seen across sibling profiles.
|
|
14
|
+
//
|
|
15
|
+
// THE GUARD — never mix accounts. The snapshot carries no account/org id, so we
|
|
16
|
+
// cannot ask "same account?" directly. Instead we trust bucket IDENTITY: the
|
|
17
|
+
// account-wide windows (5h, weekly — the buckets with no model scope) reset on a
|
|
18
|
+
// per-account schedule, so at any instant every session on one account reports the
|
|
19
|
+
// same resets_at for them. We build an "account fingerprint" from exactly those
|
|
20
|
+
// buckets (key + reset instant) and merge a sibling ONLY when its fingerprint is
|
|
21
|
+
// byte-for-byte the local one. A different account would have to collide on every
|
|
22
|
+
// one of those independent reset timestamps at once (5h AND weekly) — negligible.
|
|
23
|
+
// A sibling from an already-rolled window has a different reset instant, so it is
|
|
24
|
+
// distrusted too (its used% is stale, not fresher). We never import a bucket the
|
|
25
|
+
// local snapshot lacks and never adopt a sibling's resets_at — we only ever raise
|
|
26
|
+
// the used% of a bucket the local profile is already showing. So a profile logged
|
|
27
|
+
// into its own account is never contaminated by another.
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const os = require('node:os');
|
|
32
|
+
const { parseResetsAt } = require('./burn');
|
|
33
|
+
const { modelScope } = require('./rate-limits');
|
|
34
|
+
|
|
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 scan
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Canonical reset instant for fingerprinting/matching — tolerant of CC reporting
|
|
40
|
+
* resets_at as epoch seconds or an ISO string. `null` when absent/unparseable.
|
|
41
|
+
* @param {any} bucket
|
|
42
|
+
* @returns {number | null}
|
|
43
|
+
*/
|
|
44
|
+
function resetInstant(bucket) {
|
|
45
|
+
return bucket && bucket.resets_at != null ? parseResetsAt(bucket.resets_at) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The same-account signal: a stable string built from the ACCOUNT-WIDE buckets
|
|
50
|
+
* only (no model scope), each as `key@reset`. Buckets missing a used% or a reset
|
|
51
|
+
* are excluded — they can't anchor trust. Returns `null` when there is nothing to
|
|
52
|
+
* anchor on (no usable account-wide bucket), which callers treat as "don't merge".
|
|
53
|
+
* @param {any} rateLimits
|
|
54
|
+
* @returns {string | null}
|
|
55
|
+
*/
|
|
56
|
+
function accountFingerprint(rateLimits) {
|
|
57
|
+
if (!rateLimits || typeof rateLimits !== 'object') return null;
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const key of Object.keys(rateLimits)) {
|
|
60
|
+
if (modelScope(key)) continue; // model-scoped ≠ account-wide anchor
|
|
61
|
+
const r = rateLimits[key];
|
|
62
|
+
if (!r || typeof r !== 'object' || r.used_percentage == null) continue;
|
|
63
|
+
const at = resetInstant(r);
|
|
64
|
+
if (at == null) continue;
|
|
65
|
+
parts.push(`${key}@${at}`);
|
|
66
|
+
}
|
|
67
|
+
return parts.length ? parts.sort().join('|') : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Raise each of the local profile's meters to the freshest value seen across
|
|
72
|
+
* sibling profiles ON THE SAME ACCOUNT. Pure: no I/O. Returns the local rate_limits
|
|
73
|
+
* unchanged (same reference) when there is nothing trustworthy to merge.
|
|
74
|
+
*
|
|
75
|
+
* @param {any} localRl the local snapshot's `rate_limits`
|
|
76
|
+
* @param {any[]} siblingRls other profiles' `rate_limits` objects (account-untrusted)
|
|
77
|
+
* @returns {any} a shallow clone with used_percentage bumped where warranted, or `localRl`
|
|
78
|
+
*/
|
|
79
|
+
function mergeAccountLimits(localRl, siblingRls) {
|
|
80
|
+
const fp = accountFingerprint(localRl);
|
|
81
|
+
if (!fp) return localRl; // nothing to anchor trust on
|
|
82
|
+
const trusted = (siblingRls || []).filter((rl) => accountFingerprint(rl) === fp);
|
|
83
|
+
if (!trusted.length) return localRl;
|
|
84
|
+
|
|
85
|
+
let changed = false;
|
|
86
|
+
/** @type {any} */
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const key of Object.keys(localRl)) {
|
|
89
|
+
const local = localRl[key];
|
|
90
|
+
out[key] = local;
|
|
91
|
+
if (!local || typeof local !== 'object' || local.used_percentage == null) continue;
|
|
92
|
+
let best = Number(local.used_percentage);
|
|
93
|
+
if (!Number.isFinite(best)) continue;
|
|
94
|
+
const at = resetInstant(local);
|
|
95
|
+
for (const rl of trusted) {
|
|
96
|
+
const s = rl[key];
|
|
97
|
+
if (!s || typeof s !== 'object') continue;
|
|
98
|
+
// Same window only — a sibling whose bucket reset at a different instant is
|
|
99
|
+
// from a rolled (or foreign) window; its used% does not describe this one.
|
|
100
|
+
if (resetInstant(s) !== at) continue;
|
|
101
|
+
const v = Number(s.used_percentage);
|
|
102
|
+
if (Number.isFinite(v) && v > best) best = v;
|
|
103
|
+
}
|
|
104
|
+
if (best !== local.used_percentage) { out[key] = { ...local, used_percentage: best }; changed = true; }
|
|
105
|
+
}
|
|
106
|
+
return changed ? out : localRl;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read a sibling snapshot's `rate_limits`, best-effort. Bounded read; any error
|
|
111
|
+
* (missing, oversized, unparseable) yields `null` so a bad sibling is simply
|
|
112
|
+
* skipped rather than breaking the panel.
|
|
113
|
+
* @param {string} file
|
|
114
|
+
* @returns {any | null}
|
|
115
|
+
*/
|
|
116
|
+
function readSiblingRateLimits(file) {
|
|
117
|
+
try {
|
|
118
|
+
const st = fs.statSync(file);
|
|
119
|
+
if (!st.isFile() || st.size > MAX_SNAPSHOT_BYTES) return null;
|
|
120
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
121
|
+
return (j && j.rate_limits) || null;
|
|
122
|
+
} catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Disk wrapper: gather sibling profiles' rate limits from the ccr profile root and
|
|
127
|
+
* reconcile the local meters against them. Best-effort — returns `localRl` on any
|
|
128
|
+
* problem so it can wrap the render path without a guard at the call site.
|
|
129
|
+
*
|
|
130
|
+
* Engages ONLY for the launcher's profile layout (`~/.ccr/<profile>`): the state
|
|
131
|
+
* dir's parent must be `~/.ccr`. For ad-hoc `~/.ccr` or a custom CCR_STATE_DIR we
|
|
132
|
+
* have no sibling set to trust, so we behave exactly as before (no merge).
|
|
133
|
+
*
|
|
134
|
+
* @param {any} localRl the local snapshot's `rate_limits`
|
|
135
|
+
* @param {string} stateDir the local profile's state dir (CCR_STATE_DIR)
|
|
136
|
+
* @param {{ home?: string }} [opts]
|
|
137
|
+
* @returns {any}
|
|
138
|
+
*/
|
|
139
|
+
function freshenAccountLimits(localRl, stateDir, opts = {}) {
|
|
140
|
+
try {
|
|
141
|
+
if (!localRl || typeof localRl !== 'object') return localRl;
|
|
142
|
+
const home = opts.home || os.homedir();
|
|
143
|
+
const root = path.dirname(path.resolve(stateDir));
|
|
144
|
+
if (root !== path.resolve(path.join(home, '.ccr'))) return localRl; // not a profile layout
|
|
145
|
+
const selfFile = path.resolve(path.join(stateDir, 'last-status.json'));
|
|
146
|
+
|
|
147
|
+
/** @type {any[]} */
|
|
148
|
+
const siblings = [];
|
|
149
|
+
for (const name of fs.readdirSync(root)) {
|
|
150
|
+
if (siblings.length >= MAX_PROFILES) break;
|
|
151
|
+
const p = path.join(root, name);
|
|
152
|
+
let st; try { st = fs.statSync(p); } catch { continue; }
|
|
153
|
+
// A sibling profile dir (~/.ccr/<name>/last-status.json) or the ad-hoc
|
|
154
|
+
// ~/.ccr/last-status.json file itself.
|
|
155
|
+
const file = st.isDirectory() ? path.join(p, 'last-status.json')
|
|
156
|
+
: (name === 'last-status.json' ? p : null);
|
|
157
|
+
if (!file || path.resolve(file) === selfFile) continue;
|
|
158
|
+
const rl = readSiblingRateLimits(file);
|
|
159
|
+
if (rl) siblings.push(rl);
|
|
160
|
+
}
|
|
161
|
+
return mergeAccountLimits(localRl, siblings);
|
|
162
|
+
} catch { return localRl; }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { accountFingerprint, mergeAccountLimits, freshenAccountLimits };
|
package/src/render/shared.js
CHANGED
|
@@ -59,7 +59,17 @@ function tok(/** @type {number|null} */ n) {
|
|
|
59
59
|
function fmtMins(/** @type {number|null} */ m) {
|
|
60
60
|
if (m == null || !isFinite(m)) return '?';
|
|
61
61
|
m = Math.max(0, Math.round(m));
|
|
62
|
-
if (m >= 1440) {
|
|
62
|
+
if (m >= 1440) {
|
|
63
|
+
const d = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60);
|
|
64
|
+
// A 100+ day horizon is a near-zero-burn artifact (100−used%)/rate with a
|
|
65
|
+
// tiny rate). Its hours are noise, and a wide value like "1000d5h" would
|
|
66
|
+
// overflow the sidebar's fixed 7-col time column and shove the meter bar out
|
|
67
|
+
// of vertical line with the sibling row (the 5h/weekly bars must align). Cap
|
|
68
|
+
// it so the string never exceeds 6 visible columns — compact and honest.
|
|
69
|
+
if (d >= 1000) return '>999d';
|
|
70
|
+
if (d >= 100) return `${d}d`;
|
|
71
|
+
return hh ? `${d}d${hh}h` : `${d}d`;
|
|
72
|
+
}
|
|
63
73
|
const h = Math.floor(m / 60), r = m % 60;
|
|
64
74
|
if (h >= 1) return r ? `${h}h${String(r).padStart(2, '0')}m` : `${h}h`;
|
|
65
75
|
return `${m}m`;
|
package/src/sidecar.js
CHANGED
|
@@ -9,9 +9,11 @@ const fs = require('node:fs');
|
|
|
9
9
|
const path = require('node:path');
|
|
10
10
|
const os = require('node:os');
|
|
11
11
|
const { normalizeStatus } = require('./normalize');
|
|
12
|
+
const { freshenAccountLimits } = require('./account-limits');
|
|
12
13
|
const { renderEconomy } = require('./render/economy');
|
|
13
14
|
const { renderFeed } = require('./render/feed');
|
|
14
15
|
const { clampVisible } = require('./render/shared');
|
|
16
|
+
const { liveness } = require('./liveness');
|
|
15
17
|
const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcripts');
|
|
16
18
|
|
|
17
19
|
const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
|
|
@@ -87,7 +89,12 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
87
89
|
try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
|
|
88
90
|
let out;
|
|
89
91
|
try {
|
|
90
|
-
|
|
92
|
+
// 5h/weekly are ACCOUNT-WIDE but captured per-profile, so an idle sibling's
|
|
93
|
+
// panel lags a busy one. Reconcile the meters against sibling profiles on the
|
|
94
|
+
// SAME account (see src/account-limits.js) before rendering — best-effort, and
|
|
95
|
+
// strictly guarded so a different account is never mixed in.
|
|
96
|
+
const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir) };
|
|
97
|
+
out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0 });
|
|
91
98
|
} catch (e) {
|
|
92
99
|
out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
|
|
93
100
|
}
|
|
@@ -102,6 +109,16 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
102
109
|
if (feedStr) out += '\n\n' + feedStr;
|
|
103
110
|
}
|
|
104
111
|
} catch { /* feed is optional */ }
|
|
112
|
+
// Staleness annotation (never a wipe): Claude Code does not emit the status line
|
|
113
|
+
// during a single long operation, so the snapshot legitimately ages. Surface a
|
|
114
|
+
// quiet "updated Nm ago" so a stale panel reads as stale rather than broken —
|
|
115
|
+
// otherwise a long agent run (or a CC statusLine that stopped firing) looks like
|
|
116
|
+
// the sidecar just froze. See src/liveness.js + features/liveness.feature.
|
|
117
|
+
try {
|
|
118
|
+
const ageMs = now - fs.statSync(snapshot).mtimeMs;
|
|
119
|
+
const mark = liveness({ exited: false, ageMs }).marker;
|
|
120
|
+
if (mark) out += (out.endsWith('\n') ? '' : '\n') + ' ' + dim('· ' + mark);
|
|
121
|
+
} catch { /* snapshot mtime unknown → no marker */ }
|
|
105
122
|
return clamp(out.endsWith('\n') ? out : out + '\n');
|
|
106
123
|
}
|
|
107
124
|
|