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/src/doctor.js ADDED
@@ -0,0 +1,115 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/doctor.js — `ccr doctor`: check the local setup and capture status.
4
+ // Pure Node; the few external checks use `command -v`. Diagnoses the common
5
+ // "nothing happens" causes (ccr not linked, tmux/ccs missing, no capture yet).
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const os = require('node:os');
10
+ const { spawnSync } = require('node:child_process');
11
+ const { stripControl } = require('./sanitize');
12
+
13
+ const ok = (/** @type {string} */ s) => `\x1b[32m✓\x1b[0m ${s}`;
14
+ const bad = (/** @type {string} */ s) => `\x1b[31m✗\x1b[0m ${s}`;
15
+ const warn = (/** @type {string} */ s) => `\x1b[33m⚠\x1b[0m ${s}`;
16
+ const dim = (/** @type {string} */ s) => `\x1b[2m${s}\x1b[0m`;
17
+ const bold = (/** @type {string} */ s) => `\x1b[1m${s}\x1b[0m`;
18
+
19
+ /** @param {string} cmd → resolved path or null */
20
+ function has(cmd) {
21
+ // Only ever called with literal tool names; refuse anything that isn't a bare
22
+ // command word so this can never become a shell-injection sink.
23
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(cmd)) return null;
24
+ try {
25
+ // Native Windows has no `sh`; `where` is the built-in PATH lookup there.
26
+ const r = process.platform === 'win32'
27
+ ? spawnSync('where', [cmd], { encoding: 'utf8' })
28
+ : spawnSync('sh', ['-c', `command -v ${cmd}`], { encoding: 'utf8' });
29
+ if (r.status !== 0) return null;
30
+ return r.stdout.trim().split(/\r?\n/)[0] || null; // `where` may list several
31
+ } catch { return null; }
32
+ }
33
+ function isExec(/** @type {string} */ f) {
34
+ try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
35
+ }
36
+
37
+ /** @returns {number} exit code (0 = healthy) */
38
+ function run() {
39
+ const REPO = path.join(__dirname, '..');
40
+ const isWin = process.platform === 'win32';
41
+ const out = [bold('ccr doctor'), ''];
42
+ let problems = 0;
43
+
44
+ const [maj, min] = process.versions.node.split('.').map(Number);
45
+ const nodeOk = maj > 18 || (maj === 18 && min >= 3);
46
+ out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 18.3`));
47
+ if (!nodeOk) problems++;
48
+
49
+ const ccr = has('ccr');
50
+ out.push(ccr ? ok(`ccr on PATH (${stripControl(ccr)})`) : warn('ccr not on PATH — run `npm link` in the repo'));
51
+ if (!ccr) problems++;
52
+
53
+ if (isWin) {
54
+ // The live sidebar (tmux + bash) is WSL-only on native Windows — by design,
55
+ // not a problem. Say so plainly so a Windows user isn't told to "fix" it.
56
+ out.push(dim('· live sidebar (`ccr`) needs tmux + bash — for that, use WSL2.'));
57
+ out.push(dim(' The CLI (economy / statusline / resume / doctor) runs natively here.'));
58
+ } else {
59
+ const tmux = has('tmux');
60
+ out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for `ccr [profile]` sidebar (use WSL on Windows)'));
61
+ if (!tmux) problems++;
62
+ out.push(has('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
63
+
64
+ const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
65
+ out.push(isExec(sl) ? ok('sidecar/ccr-statusline is executable') : warn('sidecar/ccr-statusline not executable (the launcher self-heals this)'));
66
+ }
67
+
68
+ const ccs = has('ccs');
69
+ if (ccs) {
70
+ let profiles = [];
71
+ try { profiles = fs.readdirSync(path.join(os.homedir(), '.ccs', 'instances')).filter((p) => !p.startsWith('.')); } catch { /* none */ }
72
+ // Profile + path come from the filesystem; sanitize before display.
73
+ out.push(ok(`ccs (${stripControl(ccs)}) · profiles: ${profiles.map(stripControl).join(', ') || '(none)'}`));
74
+ } else {
75
+ out.push(dim('· ccs not installed (optional — only for `ccr <profile>`)'));
76
+ }
77
+
78
+ // newest captured snapshot across ~/.ccr and its per-profile subdirs (state
79
+ // lives under the user's home now, never world-shared /tmp).
80
+ const ccrDir = path.join(os.homedir(), '.ccr');
81
+ const dirs = [ccrDir];
82
+ try {
83
+ for (const d of fs.readdirSync(ccrDir)) {
84
+ const sub = path.join(ccrDir, d);
85
+ try { if (fs.statSync(sub).isDirectory()) dirs.push(sub); } catch { /* ignore */ }
86
+ }
87
+ } catch { /* none */ }
88
+ let newest = null;
89
+ for (const d of dirs) {
90
+ try { const m = fs.statSync(path.join(d, 'last-status.json')).mtimeMs; if (!newest || m > newest.m) newest = { d, m }; } catch { /* none */ }
91
+ }
92
+ if (newest) {
93
+ const ageMin = Math.round((Date.now() - newest.m) / 60000);
94
+ let keys = [];
95
+ try { keys = Object.keys(JSON.parse(fs.readFileSync(path.join(newest.d, 'last-status.json'), 'utf8')).rate_limits || {}); } catch { /* ignore */ }
96
+ // Defense-in-depth: sanitize the dir + bucket keys before display even
97
+ // though state now lives under the user's own home.
98
+ out.push(ok(`status captured ${ageMin}m ago (${stripControl(newest.d)})`));
99
+ out.push(dim(` buckets: ${keys.map(stripControl).join(', ') || '(none — API session?)'}`));
100
+ } else {
101
+ out.push(warn(isWin
102
+ ? 'no status captured yet — wire `ccr statusline` into Claude Code (settings.json) to start capturing'
103
+ : 'no status captured yet — launch with `ccr` (or `ccr <profile>`) to start capturing'));
104
+ }
105
+
106
+ out.push('');
107
+ const allGood = isWin
108
+ ? 'all good — `ccr economy` for the panel, `ccr statusline` to wire into CC'
109
+ : 'all good — `ccr` to launch, `ccr economy` for the panel';
110
+ out.push(problems ? warn(`${problems} thing(s) to address above`) : ok(allGood));
111
+ process.stdout.write(out.join('\n') + '\n');
112
+ return problems ? 1 : 0;
113
+ }
114
+
115
+ module.exports = { run };
@@ -0,0 +1,122 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/economy-model.js — the computed economy model.
4
+ //
5
+ // This is the SINGLE SOURCE OF TRUTH for window classification, the binding
6
+ // window, the status band, and clear-ROI. Both consumers read from it:
7
+ // - the text panel (src/render/economy.js) renders it
8
+ // - `ccr economy --json` serialises it (the stable machine-readable contract)
9
+ // so the panel and the JSON can never disagree about which window binds.
10
+ //
11
+ // Pure function of a normalized `view` (see src/normalize.js). All numbers are
12
+ // raw (unrounded) — consumers format. Units: pct = percent, rate = %/min,
13
+ // minutes* = minutes, tokens = count, costUsd = US dollars.
14
+
15
+ const { windowEstimate, clearROI, binding } = require('./burn');
16
+
17
+ // Bump on any BREAKING change (renamed/removed field, changed semantics).
18
+ // Additive changes (new fields) do NOT bump it — consumers must ignore unknowns.
19
+ const SCHEMA_VERSION = 1;
20
+
21
+ const IMMINENT_MIN = 30; // status band: red / flashing in the panel
22
+ const WARN_MIN = 120; // status band: yellow
23
+
24
+ /** @param {number|null} min minutes-to-exhaust → at-a-glance status band */
25
+ function band(min) {
26
+ if (min == null) return 'ok';
27
+ if (min <= IMMINENT_MIN) return 'imminent';
28
+ if (min <= WARN_MIN) return 'warn';
29
+ return 'ok';
30
+ }
31
+
32
+ /**
33
+ * Classify each rate-limit window: burn estimate, whether it binds (would
34
+ * exhaust BEFORE it resets), and which one you hit first. Tolerates any plan
35
+ * shape (5h, weekly, model-scoped, monthly, …); returns `next = null` for an
36
+ * API session with no reported windows.
37
+ * @param {any} view
38
+ * @returns {{ rows: any[], next: any }}
39
+ */
40
+ function classifyWindows(view) {
41
+ const windows = Array.isArray(view.windows) ? view.windows : [];
42
+ const rows = windows.map((/** @type {any} */ wd) => {
43
+ const est = windowEstimate({ usedPct: wd.usedPct, rate: wd.rate, minutesToReset: wd.minutesToReset, windowMinutes: wd.windowMinutes });
44
+ const ml = est.minutesLeft;
45
+ return {
46
+ key: wd.key,
47
+ label: wd.label || wd.key,
48
+ est,
49
+ reset: wd.minutesToReset,
50
+ binding: false,
51
+ live: ml != null && wd.minutesToReset != null && ml < wd.minutesToReset,
52
+ resetsFirst: ml != null && wd.minutesToReset != null && ml >= wd.minutesToReset,
53
+ };
54
+ });
55
+ const live = rows.filter((r) => r.live).map((r) => ({ key: r.key, est: r.est, reset: r.reset }));
56
+ const b = binding(live);
57
+ const next = b ? rows.find((r) => r.key === b.window) : null;
58
+ if (next) next.binding = true;
59
+ return { rows, next };
60
+ }
61
+
62
+ /**
63
+ * Build the full economy model — the contract behind `ccr economy --json`.
64
+ * @param {any} view normalized economy data (see normalizeStatus)
65
+ * @returns {{
66
+ * schemaVersion: number, model: string|null,
67
+ * context: { tokens: number|null, windowSize: number|null, pct: number|null, cachedPct: number|null },
68
+ * windows: Array<{ key:string, label:string, usedPct:number, rate:number|null, minutesLeft:number|null, minutesToReset:number|null, band:string, binding:boolean, resetsBeforeHit:boolean }>,
69
+ * binding: { key:string, label:string, minutesLeft:number|null, band:string }|null,
70
+ * clear: { worthwhile:boolean, boughtMinutes:number, contextTokens:number|null, baselineTokens:number },
71
+ * session: { costUsd:number|null, durationMin:number|null, branch:string|null }
72
+ * }}
73
+ */
74
+ function computeEconomy(view) {
75
+ const { rows, next } = classifyWindows(view);
76
+ const baselineTokens = view.baselineTok || 14000;
77
+ const ctxTokens = view.contextTokens ?? null;
78
+ const windowSize = view.windowSize ?? null;
79
+
80
+ const windows = rows.map((r) => ({
81
+ key: r.key,
82
+ label: r.label,
83
+ usedPct: r.est.usedPct,
84
+ rate: r.est.rate,
85
+ minutesLeft: r.est.minutesLeft,
86
+ minutesToReset: r.reset,
87
+ band: band(r.est.minutesLeft),
88
+ binding: r.binding,
89
+ resetsBeforeHit: r.resetsFirst,
90
+ }));
91
+
92
+ // Mirrors the panel's clear gate: only meaningful when a window binds, a rate
93
+ // is known, and context sits >20% above the post-clear baseline.
94
+ let clear = { worthwhile: false, boughtMinutes: 0, contextTokens: ctxTokens, baselineTokens };
95
+ if (next && next.est.rate != null && ctxTokens != null && ctxTokens > baselineTokens * 1.2) {
96
+ const roi = clearROI({ rate: next.est.rate, usedPct: next.est.usedPct, contextC: ctxTokens, baselineB: baselineTokens, calib: null, resetMinutes: next.reset });
97
+ clear = { worthwhile: roi.boughtMinutes > 0, boughtMinutes: roi.boughtMinutes, contextTokens: ctxTokens, baselineTokens };
98
+ }
99
+
100
+ return {
101
+ schemaVersion: SCHEMA_VERSION,
102
+ model: view.model ?? null,
103
+ context: {
104
+ tokens: ctxTokens,
105
+ windowSize,
106
+ pct: (ctxTokens != null && windowSize) ? (ctxTokens / windowSize) * 100 : null,
107
+ cachedPct: view.cachedPct ?? null,
108
+ },
109
+ windows,
110
+ binding: next
111
+ ? { key: next.key, label: next.label, minutesLeft: next.est.minutesLeft, band: band(next.est.minutesLeft) }
112
+ : null,
113
+ clear,
114
+ session: {
115
+ costUsd: view.costUsd ?? null,
116
+ durationMin: view.durationMin ?? null,
117
+ branch: view.branch ?? null,
118
+ },
119
+ };
120
+ }
121
+
122
+ module.exports = { computeEconomy, classifyWindows, band, SCHEMA_VERSION };
@@ -0,0 +1,67 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/instrument.js — minimal LOCAL meter-sample logging for OFFLINE analysis.
4
+ //
5
+ // Data collection, NOT machine learning. The transcripts don't store the meter
6
+ // %, so this is the only way to backtest the estimator on the real target (feed
7
+ // scripts/backtest-burn.js). It captures EVERY rate-limit bucket the plan
8
+ // exposes verbatim — so we learn each tier's real schema (5h, weekly, a
9
+ // model-scoped "Sonnet only" weekly, a monthly one) rather than assuming two.
10
+ // Append-only, size-capped, under ~/.ccr, never transmitted; a no-op when
11
+ // CCR_NO_INSTRUMENT is set.
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+ const os = require('node:os');
16
+ const { ensureSecureDir } = require('./state-dir');
17
+
18
+ const CAP_BYTES = 2_000_000; // halve the log when it exceeds this
19
+
20
+ function capFile(/** @type {string} */ file) {
21
+ try {
22
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
23
+ fs.writeFileSync(file, lines.slice(-Math.floor(lines.length / 2)).join('\n') + '\n', { mode: 0o600 });
24
+ } catch { /* ignore */ }
25
+ }
26
+
27
+ /**
28
+ * Append one sample if this is a subscription session (≥1 rate-limit bucket).
29
+ * Never throws — logging must not break the status line.
30
+ * @param {any} state CC status-line JSON
31
+ * @param {{ dir?: string, now?: number }} [opts]
32
+ * @returns {boolean} whether a sample was written
33
+ */
34
+ function logMeterSample(state, opts = {}) {
35
+ if (process.env.CCR_NO_INSTRUMENT) return false;
36
+ const rl = (state && state.rate_limits) || {};
37
+ /** @type {Record<string, { used: number, resets_at: any }>} */
38
+ const limits = {};
39
+ for (const k of Object.keys(rl)) {
40
+ const r = rl[k];
41
+ if (r && typeof r === 'object' && r.used_percentage != null) {
42
+ limits[k] = { used: r.used_percentage, resets_at: r.resets_at ?? null };
43
+ }
44
+ }
45
+ if (!Object.keys(limits).length) return false; // API session — nothing to log
46
+
47
+ const dir = opts.dir || path.join(os.homedir(), '.ccr');
48
+ const sid = String(state.session_id || 'default').replace(/[^A-Za-z0-9_-]/g, '');
49
+ const file = path.join(dir, `burnlog-${sid}.jsonl`);
50
+ const cw = state.context_window || {};
51
+ const rec = {
52
+ t: opts.now != null ? opts.now : Date.now(),
53
+ model: (state.model && state.model.id) || null,
54
+ ctx: cw.total_input_tokens ?? (cw.current_usage && cw.current_usage.cache_read_input_tokens) ?? null,
55
+ limits,
56
+ };
57
+ try {
58
+ ensureSecureDir(dir);
59
+ try { if (fs.statSync(file).size > CAP_BYTES) capFile(file); } catch { /* new file */ }
60
+ fs.appendFileSync(file, JSON.stringify(rec) + '\n', { mode: 0o600 });
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ module.exports = { logMeterSample };
@@ -0,0 +1,39 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/liveness.js
4
+ // Decide how the sidecar should present session liveness.
5
+ //
6
+ // Core principle: status-line emission cadence is NOT a liveness signal. Claude
7
+ // Code does not tick during a single long operation, so an old snapshot must
8
+ // NEVER blank the dashboard. Staleness is a quiet annotation; only the explicit
9
+ // exit sentinel means the session ended.
10
+ //
11
+ // Pure function of (exited, ageMs, staleMs) — no process probing (pstree/tmux),
12
+ // which is exactly what makes naive liveness heuristics over-eager to timeout.
13
+
14
+ /** Default age before a dim freshness note appears. Annotation only, never a wipe. */
15
+ const DEFAULT_STALE_MS = 120000;
16
+
17
+ /** @returns {number | null} */
18
+ function envStaleMs() {
19
+ const v = Number(process.env.CCR_STALE_MS);
20
+ return Number.isFinite(v) && v > 0 ? v : null;
21
+ }
22
+
23
+ /**
24
+ * @param {{ exited?: boolean, ageMs?: number, staleMs?: number }} input
25
+ * @returns {{ mode: 'ended' | 'live', marker: string | null }}
26
+ * mode 'live' → render the dashboard (optionally with a freshness marker)
27
+ * mode 'ended' → render the session-ended screen (sentinel-confirmed only)
28
+ */
29
+ function liveness(input) {
30
+ const exited = !!input.exited;
31
+ if (exited) return { mode: 'ended', marker: null };
32
+
33
+ const ageMs = input.ageMs ?? 0;
34
+ const staleMs = input.staleMs ?? envStaleMs() ?? DEFAULT_STALE_MS;
35
+ const marker = ageMs >= staleMs ? `updated ${Math.floor(ageMs / 60000)}m ago` : null;
36
+ return { mode: 'live', marker };
37
+ }
38
+
39
+ module.exports = { liveness, DEFAULT_STALE_MS };
@@ -0,0 +1,32 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/normalize.js — map Claude Code's status-line JSON to the renderer's view.
4
+ // Rate-limit buckets are DISCOVERED (not hardcoded) so any plan's bucket set —
5
+ // grandfathered Pro, current Pro, Max, model-scoped "Sonnet only" — is handled.
6
+
7
+ const { discoverWindows } = require('./rate-limits');
8
+ const { stripControl } = require('./sanitize');
9
+
10
+ /**
11
+ * @param {any} state CC status-line JSON
12
+ * @param {number} [nowSec] override for testing
13
+ * @returns {any} view consumed by renderEconomy
14
+ */
15
+ function normalizeStatus(state, nowSec) {
16
+ const rl = (state && state.rate_limits) || {};
17
+ const cw = (state && state.context_window) || {};
18
+ return {
19
+ model: stripControl((state && state.model && state.model.display_name) || null),
20
+ windowSize: cw.context_window_size || 200000,
21
+ windows: discoverWindows(rl, nowSec),
22
+ contextTokens: cw.total_input_tokens
23
+ ?? (cw.current_usage && cw.current_usage.cache_read_input_tokens) ?? null,
24
+ cachedPct: null,
25
+ baselineTok: 14000,
26
+ costUsd: state && state.cost && state.cost.total_cost_usd != null ? state.cost.total_cost_usd : null,
27
+ durationMin: state && state.cost && state.cost.total_duration_ms != null ? state.cost.total_duration_ms / 60000 : null,
28
+ branch: null,
29
+ };
30
+ }
31
+
32
+ module.exports = { normalizeStatus };
@@ -0,0 +1,89 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/rate-limits.js — discover whatever rate-limit buckets a plan exposes.
4
+ //
5
+ // Subscription tiers differ: a grandfathered Pro account, a current Pro account,
6
+ // and Max all report DIFFERENT buckets under `rate_limits` (5h, weekly, a
7
+ // separate model-scoped "Sonnet only" weekly, possibly a monthly one). Rather
8
+ // than hardcode key names, we read every bucket present and derive a label +
9
+ // window length heuristically, so any current or future bucket is handled
10
+ // gracefully. Unknown buckets still render (used % + reset); they just can't
11
+ // project a time-to-limit until we know (or learn) their window length.
12
+
13
+ const { parseResetsAt } = require('./burn');
14
+ const { stripControl } = require('./sanitize');
15
+
16
+ const FIVE = 300, WEEK = 10080, MONTH = 43200;
17
+
18
+ // Known keys get exact labels/windows; everything else falls back to heuristics.
19
+ const KNOWN = {
20
+ five_hour: { label: '5h', windowMinutes: FIVE },
21
+ seven_day: { label: 'weekly', windowMinutes: WEEK },
22
+ };
23
+
24
+ /** @param {string} key → 'Sonnet' | 'Opus' | 'Haiku' | null */
25
+ function modelScope(key) {
26
+ if (/sonnet/i.test(key)) return 'Sonnet';
27
+ if (/opus/i.test(key)) return 'Opus';
28
+ if (/haiku/i.test(key)) return 'Haiku';
29
+ return null;
30
+ }
31
+
32
+ /** @param {string} key → window length in minutes, or null if not inferable */
33
+ function inferWindowMinutes(key) {
34
+ const h = key.match(/(\d+)\s*_?\s*hour/i); if (h) return Number(h[1]) * 60;
35
+ if (/month/i.test(key)) return MONTH;
36
+ if (/week|seven[_-]?day|7[_-]?day/i.test(key)) return WEEK;
37
+ const d = key.match(/(\d+)\s*_?\s*day/i); if (d) return Number(d[1]) * 1440;
38
+ if (/hour/i.test(key)) return FIVE; // bare "hour" → the 5h window
39
+ if (/day/i.test(key)) return 1440;
40
+ return null;
41
+ }
42
+
43
+ /** @param {string} key → human label (scope appended when present) */
44
+ function labelFor(key) {
45
+ let base;
46
+ if (KNOWN[key]) base = KNOWN[key].label;
47
+ else if (/week|seven[_-]?day|7[_-]?day/i.test(key)) base = 'weekly';
48
+ else if (/month/i.test(key)) base = 'monthly';
49
+ else {
50
+ const h = key.match(/(\d+)\s*_?\s*hour/i);
51
+ const d = key.match(/(\d+)\s*_?\s*day/i);
52
+ base = h ? `${h[1]}h` : d ? `${d[1]}d` : key.replace(/_/g, ' ');
53
+ }
54
+ const scope = modelScope(key);
55
+ // Unknown keys flow into the label verbatim (`key.replace(...)`), so sanitize
56
+ // before it reaches the terminal.
57
+ return stripControl(scope ? `${base} · ${scope}` : base);
58
+ }
59
+
60
+ /**
61
+ * @param {any} rateLimits the status JSON `rate_limits` object
62
+ * @param {number} [nowSec]
63
+ * @returns {{ key:string, label:string, usedPct:number, minutesToReset:number|null, windowMinutes:number|null, modelScope:string|null }[]}
64
+ */
65
+ function discoverWindows(rateLimits, nowSec) {
66
+ /** @type {any[]} */
67
+ const out = [];
68
+ if (!rateLimits || typeof rateLimits !== 'object') return out;
69
+ const now = nowSec != null ? nowSec : Date.now() / 1000;
70
+ for (const key of Object.keys(rateLimits)) {
71
+ const r = rateLimits[key];
72
+ if (!r || typeof r !== 'object' || r.used_percentage == null) continue;
73
+ const at = r.resets_at != null ? parseResetsAt(r.resets_at) : null;
74
+ out.push({
75
+ key,
76
+ label: labelFor(key),
77
+ usedPct: r.used_percentage,
78
+ minutesToReset: at != null ? Math.max(0, (at - now) / 60) : null,
79
+ windowMinutes: KNOWN[key] ? KNOWN[key].windowMinutes : inferWindowMinutes(key),
80
+ modelScope: modelScope(key),
81
+ });
82
+ }
83
+ // Shortest window first (5h → weekly → monthly); buckets with no inferable
84
+ // window sort last.
85
+ out.sort((a, b) => (a.windowMinutes || 1e9) - (b.windowMinutes || 1e9));
86
+ return out;
87
+ }
88
+
89
+ module.exports = { discoverWindows, labelFor, inferWindowMinutes, modelScope };
@@ -0,0 +1,112 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/render/economy.js — the economy screen.
4
+ //
5
+ // Shows BOTH the 5h and weekly walls, marks which one you'll hit first ("the
6
+ // wall" — only if it exhausts before it resets), states the clear decision in
7
+ // plain language, and degrades gracefully on API sessions. Pure function of a
8
+ // normalized `view`; colour/flash via opts.tick; vocabulary via opts.theme.
9
+
10
+ const { clearROI } = require('../burn');
11
+ const { classifyWindows, band } = require('../economy-model');
12
+ const { resolveTheme, lexicon } = require('../theme');
13
+ const { dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins, fmtReset } = require('./shared');
14
+
15
+ const bandColor = { imminent: red, warn: yellow, ok: cyan };
16
+
17
+ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolean} */ tick, /** @type {number} */ labelW) {
18
+ const used = Math.round(row.est.usedPct);
19
+ const ml = row.est.minutesLeft;
20
+ const b = band(ml);
21
+ // Per-row colour dot: green when the window resets before you'd hit it,
22
+ // otherwise graded by how soon it would exhaust (cyan→yellow→red, flash when
23
+ // imminent on the binding window). The at-a-glance status signal.
24
+ const dotColor = row.resetsFirst ? green : bandColor[b];
25
+ const dot = (row.binding && b === 'imminent') ? flash(tick, '●') : dotColor('●');
26
+
27
+ const labelTxt = row.label.padEnd(labelW);
28
+ const label = row.binding ? bold(bandColor[b](labelTxt)) : dim(labelTxt);
29
+ // Time-to-exhaust carries no word: the sibling "resets …" is self-labelling,
30
+ // so a bare "~8h43m" reads unambiguously as remaining budget.
31
+ const leftTxt = (ml != null ? '~' + fmtMins(ml) : '—').padEnd(7);
32
+ const left = row.binding ? bold(leftTxt) : dim(leftTxt);
33
+ const resets = row.reset != null ? dim('resets ' + fmtReset(row.reset)) : '';
34
+ const meter = pctColor(used)(bar(used)) + ' ' + String(used).padStart(2) + '% used';
35
+ const main = ' ' + dot + ' ' + label + ' ' + left + ' ' + meter + ' ' + resets;
36
+
37
+ // The binding window's "wall" call-out drops to its own indented line below —
38
+ // so a long marker never trails off the narrow sidebar edge and wraps.
39
+ if (row.binding) {
40
+ const mark = '↑ ' + L.wall;
41
+ return main + '\n ' + (b === 'imminent' ? flash(tick, mark) : bandColor[b](mark));
42
+ }
43
+ return main;
44
+ }
45
+
46
+ /**
47
+ * @param {any} view normalized economy data
48
+ * @param {{ theme?: 'plain'|'mary', now?: Date, tick?: boolean, env?: any }} [opts]
49
+ * @returns {string}
50
+ */
51
+ function renderEconomy(view, opts = {}) {
52
+ const themeName = opts.theme || resolveTheme(opts.now, opts.env);
53
+ const L = lexicon(themeName);
54
+ const tick = !!opts.tick;
55
+ const out = [bold('economy') + dim(' ' + (view.model || '')), ''];
56
+
57
+ const { rows, next } = classifyWindows(view);
58
+ const labelW = Math.max(8, ...rows.map((/** @type {any} */ r) => r.label.length));
59
+
60
+ // HERO
61
+ if (!rows.length) {
62
+ out.push(' ' + dim('window limits are subscription-only — none reported (API session)'));
63
+ } else if (next) {
64
+ const b = band(next.est.minutesLeft);
65
+ const t = '~' + fmtMins(next.est.minutesLeft);
66
+ if (b === 'imminent') {
67
+ out.push(' ' + flash(tick, '▲ ' + L.imminent) + dim(' · ') + bold(next.label) + dim(' in ') + flash(tick, t));
68
+ } else {
69
+ const col = bandColor[b];
70
+ out.push(' ' + dim(L.looming) + ' ' + bold(col(next.label)) + dim(' in ') + bold(col(t)));
71
+ }
72
+ } else {
73
+ out.push(' ' + green(L.within) + dim(' · each window resets before you reach it'));
74
+ }
75
+ out.push('');
76
+
77
+ for (const r of rows) out.push(wallRow(r, L, tick, labelW));
78
+ if (rows.length) out.push('');
79
+
80
+ // CLEAR — plain language, framed against the binding wall, only when it looms.
81
+ const B = view.baselineTok || 14000;
82
+ if (next && next.est.rate != null) {
83
+ if (view.contextTokens > B * 1.2) {
84
+ const roi = clearROI({ rate: next.est.rate, usedPct: next.est.usedPct, contextC: view.contextTokens, baselineB: B, calib: null, resetMinutes: next.reset });
85
+ out.push(' ' + bold('clear now') + ' → ' + green('+' + fmtMins(roi.boughtMinutes)) + ' before ' + cyan(next.label) + dim(` (${tok(view.contextTokens)} → ${tok(B)})`));
86
+ } else {
87
+ out.push(' ' + dim(`context near baseline (${tok(view.contextTokens)}) — little to gain from clearing`));
88
+ }
89
+ out.push('');
90
+ } else if (rows.length && view.contextTokens > B * 1.2) {
91
+ out.push(' ' + dim(`no limit pressure · clearing ${tok(view.contextTokens)}→${tok(B)} would only trim cost`));
92
+ out.push('');
93
+ }
94
+
95
+ // CONTEXT + footer
96
+ if (view.contextTokens != null) {
97
+ const cp = Math.round((view.contextTokens / view.windowSize) * 100);
98
+ const cached = view.cachedPct != null ? dim(` cached ${view.cachedPct}%`) : '';
99
+ out.push(' ' + 'ctx'.padEnd(labelW) + ' ' + pctColor(cp)(bar(cp)) + ' ' + String(cp).padStart(2) + '%' + dim(` ${tok(view.contextTokens)}/${tok(view.windowSize)}`) + cached);
100
+ }
101
+ if (view.rolling) out.push(' ' + dim(`last ${view.rolling.sessions} sessions · clears ${view.rolling.clears} · median clear @ ${Math.round(view.rolling.medClearPct * 100)}%`));
102
+ const foot = [];
103
+ if (view.costUsd != null) foot.push('$' + view.costUsd.toFixed(2));
104
+ if (view.durationMin != null) foot.push(fmtMins(view.durationMin));
105
+ if (view.branch) foot.push(view.branch);
106
+ foot.push(L.clearKey);
107
+ out.push(' ' + dim(foot.join(' · ')));
108
+
109
+ return out.join('\n');
110
+ }
111
+
112
+ module.exports = { renderEconomy };
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/render/feed.js — the live tool/skills feed shown under the economy panel in
4
+ // the sidecar. Pure function of an accumulated feed object (events + rolling
5
+ // stats); the sidecar does the incremental transcript tail and hands it here.
6
+
7
+ const { dim, bold, cyan, tok } = require('./shared');
8
+
9
+ /** @param {string} s @param {number} n */
10
+ function trunc(s, n) {
11
+ s = String(s);
12
+ return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + '…';
13
+ }
14
+
15
+ /**
16
+ * @typedef {{ ts: number|null, kind: 'tool'|'cmd', tool: string, arg: string }} FeedEvent
17
+ * @typedef {{ events: FeedEvent[], tools: Record<string, number>, commands: number,
18
+ * tokens: { input: number, output: number, cacheRead: number, cacheCreate: number },
19
+ * files: string[] }} Feed
20
+ */
21
+
22
+ /**
23
+ * Render the feed block: a tool-count header, an optional rolling-stats line, and
24
+ * the last N events. Returns '' when there's nothing to show (so the sidecar can
25
+ * omit it cleanly).
26
+ * @param {Feed} feed
27
+ * @param {{ max?: number, width?: number }} [opts]
28
+ * @returns {string}
29
+ */
30
+ function renderFeed(feed, opts = {}) {
31
+ if (!feed) return '';
32
+ const max = opts.max || 5;
33
+ const width = opts.width || 48;
34
+ const events = Array.isArray(feed.events) ? feed.events : [];
35
+ const tools = feed.tools || {};
36
+ const counts = Object.keys(tools).sort((a, b) => tools[b] - tools[a]);
37
+ // Rolling per-session stats: files touched + work generated (output tokens).
38
+ const nFiles = Array.isArray(feed.files) ? feed.files.length : 0;
39
+ const out = feed.tokens && feed.tokens.output;
40
+ // Nothing to show only when there are neither tool/command/event rows NOR any
41
+ // rolling stats — stats alone are still worth rendering.
42
+ if (!counts.length && !feed.commands && !events.length && !nFiles && !out) return '';
43
+
44
+ const parts = counts.map((k) => `${k} ${tools[k]}`);
45
+ if (feed.commands) parts.push(`cmd ${feed.commands}`);
46
+ const lines = [' ' + bold('feed') + dim(parts.length ? ' · ' + trunc(parts.join(' · '), width - 8) : ' · (no tool calls yet)')];
47
+
48
+ const stat = [];
49
+ if (nFiles) stat.push(`${nFiles} file${nFiles === 1 ? '' : 's'}`);
50
+ if (out) stat.push(`${tok(out)} generated`);
51
+ if (stat.length) lines.push(' ' + dim(' ' + stat.join(' · ')));
52
+
53
+ for (const e of events.slice(-max)) {
54
+ if (e.kind === 'cmd') {
55
+ lines.push(' ' + cyan('⌘ ' + trunc(e.tool, width - 4)));
56
+ } else {
57
+ const arg = e.arg ? ' ' + dim(trunc(e.arg, width - 12)) : '';
58
+ lines.push(' ' + dim('↳ ') + e.tool.padEnd(8) + arg);
59
+ }
60
+ }
61
+ return lines.join('\n');
62
+ }
63
+
64
+ module.exports = { renderFeed, trunc };