claude-code-runrate 0.2.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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>",
@@ -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/sidecar.js CHANGED
@@ -9,6 +9,7 @@ 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');
@@ -88,7 +89,12 @@ function composeFrame(stateDir, opts = {}) {
88
89
  try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
89
90
  let out;
90
91
  try {
91
- out = renderEconomy(normalizeStatus(state), { tick: Math.floor(now / 1000) % 2 === 0 });
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 });
92
98
  } catch (e) {
93
99
  out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
94
100
  }