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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/render/resume.js — renders the resume-cost advisor. Pure function of
|
|
4
|
+
// advisor rows (see src/resume.js); selection stays with `claude --resume`.
|
|
5
|
+
|
|
6
|
+
const { dim, bold, green, yellow, red, cyan, tok } = require('./shared');
|
|
7
|
+
|
|
8
|
+
/** @param {number} m minutes → coarse age */
|
|
9
|
+
function fmtAge(m) {
|
|
10
|
+
if (m >= 1440) return Math.round(m / 1440) + 'd';
|
|
11
|
+
if (m >= 60) return Math.round(m / 60) + 'h';
|
|
12
|
+
return Math.max(0, m) + 'm';
|
|
13
|
+
}
|
|
14
|
+
/** @param {string} s @param {number} n */
|
|
15
|
+
function trunc(s, n) { s = String(s); return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + '…'; }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ sessionId: string, title: string, branch: string|null, ageMin: number, ctx: number, winPct: number, cold: boolean }[]} rows
|
|
19
|
+
* @param {{ scope?: 'cwd'|'all' }} [opts]
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function renderResume(rows, opts = {}) {
|
|
23
|
+
const scopeLabel = opts.scope === 'all' ? 'all projects' : 'this project';
|
|
24
|
+
if (!rows.length) {
|
|
25
|
+
let s = ' ' + dim(`no resumable sessions in ${scopeLabel}.`);
|
|
26
|
+
if (opts.scope !== 'all') s += '\n ' + dim('try ') + cyan('ccr resume all');
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const out = [bold('recent sessions') + dim(' · ' + scopeLabel), ''];
|
|
31
|
+
const cols = ['age'.padStart(5), 'ctx'.padStart(5), 'win%'.padStart(5), 'cache'.padEnd(4), 'title'];
|
|
32
|
+
out.push(' ' + dim(cols.join(' ')));
|
|
33
|
+
|
|
34
|
+
for (const r of rows) {
|
|
35
|
+
const age = fmtAge(r.ageMin).padStart(5);
|
|
36
|
+
const ctx = tok(r.ctx).padStart(5);
|
|
37
|
+
const winS = (r.winPct + '%').padStart(5);
|
|
38
|
+
const winCol = r.winPct >= 80 ? red : r.winPct >= 50 ? yellow : green;
|
|
39
|
+
const cache = (r.cold ? yellow : green)((r.cold ? 'cold' : 'warm').padEnd(4));
|
|
40
|
+
const title = trunc(r.title, 40);
|
|
41
|
+
const branch = r.branch ? dim(' ' + r.branch) : '';
|
|
42
|
+
const nearClear = r.winPct >= 80 ? red(' ⚠ near /clear') : '';
|
|
43
|
+
out.push(' ' + dim(age) + ' ' + ctx + ' ' + winCol(winS) + ' ' + cache + ' ' + title + branch + nearClear);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
out.push('');
|
|
47
|
+
out.push(' ' + dim('cold = first turn re-pays full context · select with ') + cyan('claude --resume'));
|
|
48
|
+
return out.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { renderResume, fmtAge };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/render/shared.js — ANSI + formatting helpers shared by renderers.
|
|
4
|
+
|
|
5
|
+
const e = (/** @type {string} */ c, /** @type {string} */ s) => `\x1b[${c}m${s}\x1b[0m`;
|
|
6
|
+
const dim = (/** @type {string} */ s) => e('2', s);
|
|
7
|
+
const bold = (/** @type {string} */ s) => e('1', s);
|
|
8
|
+
const green = (/** @type {string} */ s) => e('32', s);
|
|
9
|
+
const red = (/** @type {string} */ s) => e('31', s);
|
|
10
|
+
const yellow = (/** @type {string} */ s) => e('33', s);
|
|
11
|
+
const cyan = (/** @type {string} */ s) => e('36', s);
|
|
12
|
+
|
|
13
|
+
// Imminent flash: inverse video on the "on" tick, solid red on the "off" tick.
|
|
14
|
+
// Width is preserved (no padding) so rows don't shift between frames.
|
|
15
|
+
const flash = (/** @type {boolean} */ tick, /** @type {string} */ s) => (tick ? e('7;1;31', s) : e('1;31', s));
|
|
16
|
+
|
|
17
|
+
const pctColor = (/** @type {number} */ p) => (p >= 75 ? red : p >= 60 ? yellow : green);
|
|
18
|
+
|
|
19
|
+
function bar(/** @type {number} */ p, w = 10) {
|
|
20
|
+
const f = Math.max(0, Math.min(w, Math.round((p / 100) * w)));
|
|
21
|
+
return '▓'.repeat(f) + '░'.repeat(w - f);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tok(/** @type {number|null} */ n) {
|
|
25
|
+
if (n == null) return '?';
|
|
26
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
27
|
+
if (n >= 1e3) return Math.round(n / 1e3) + 'K';
|
|
28
|
+
return String(Math.round(n));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fmtMins(/** @type {number|null} */ m) {
|
|
32
|
+
if (m == null || !isFinite(m)) return '?';
|
|
33
|
+
m = Math.max(0, Math.round(m));
|
|
34
|
+
if (m >= 1440) { const d = Math.floor(m / 1440), hh = Math.floor((m % 1440) / 60); return hh ? `${d}d${hh}h` : `${d}d`; }
|
|
35
|
+
const h = Math.floor(m / 60), r = m % 60;
|
|
36
|
+
if (h >= 1) return r ? `${h}h${String(r).padStart(2, '0')}m` : `${h}h`;
|
|
37
|
+
return `${m}m`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtReset(/** @type {number|null} */ min) {
|
|
41
|
+
if (min == null) return '';
|
|
42
|
+
min = Math.round(min); // round to whole minutes FIRST so 239.97 → 240 → 4h, not 3h60m
|
|
43
|
+
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
|
|
44
|
+
if (d > 0) return `${d}d${h > 0 ? h + 'h' : ''}`;
|
|
45
|
+
if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`;
|
|
46
|
+
return `${m}m`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, tok, fmtMins, fmtReset };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/render/statusline.js — compact one-line economy summary for CC's status bar.
|
|
4
|
+
// Plain text (no ANSI) so it renders cleanly wherever the status line appears.
|
|
5
|
+
|
|
6
|
+
const { windowEstimate, binding } = require('../burn');
|
|
7
|
+
const { fmtMins } = require('./shared');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {any} view normalized economy data
|
|
11
|
+
* @returns {string} one line, e.g. "Sonnet 4.6 · weekly · Sonnet ~5h · ctx 15% · $2.50"
|
|
12
|
+
*/
|
|
13
|
+
function renderStatusline(view) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
if (view.model) parts.push(view.model);
|
|
16
|
+
|
|
17
|
+
const windows = Array.isArray(view.windows) ? view.windows : [];
|
|
18
|
+
if (!windows.length) {
|
|
19
|
+
parts.push('API · no limits');
|
|
20
|
+
} else {
|
|
21
|
+
const rows = windows.map((/** @type {any} */ wd) => ({
|
|
22
|
+
key: wd.key,
|
|
23
|
+
label: wd.label || wd.key,
|
|
24
|
+
est: windowEstimate({ usedPct: wd.usedPct, rate: wd.rate, minutesToReset: wd.minutesToReset, windowMinutes: wd.windowMinutes }),
|
|
25
|
+
reset: wd.minutesToReset,
|
|
26
|
+
}));
|
|
27
|
+
const live = rows
|
|
28
|
+
.filter((r) => r.est.minutesLeft != null && r.reset != null && r.est.minutesLeft < r.reset)
|
|
29
|
+
.map((r) => ({ key: r.key, est: r.est, reset: r.reset }));
|
|
30
|
+
const b = binding(live);
|
|
31
|
+
if (b && b.minutesLeft != null) {
|
|
32
|
+
const row = rows.find((r) => r.key === b.window);
|
|
33
|
+
const imminent = b.minutesLeft <= 30 ? '⚠ ' : '';
|
|
34
|
+
parts.push(`${imminent}${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
|
|
35
|
+
} else {
|
|
36
|
+
parts.push('within limits');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (view.contextTokens != null && view.windowSize) {
|
|
41
|
+
parts.push(`ctx ${Math.round((view.contextTokens / view.windowSize) * 100)}%`);
|
|
42
|
+
}
|
|
43
|
+
if (view.costUsd != null) parts.push('$' + view.costUsd.toFixed(2));
|
|
44
|
+
|
|
45
|
+
return parts.join(' · ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { renderStatusline };
|
package/src/resume.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/resume.js — the resume-cost ADVISOR (not a picker). Claude Code's own
|
|
4
|
+
// `--resume` picker is good; ccr only adds the economics it can't show, then
|
|
5
|
+
// hands selection back to `claude --resume`.
|
|
6
|
+
//
|
|
7
|
+
// For each recent session: how heavy is it to bring back (last turn's input-side
|
|
8
|
+
// tokens ≈ the context re-fed), as a share of the model's context window, and
|
|
9
|
+
// whether its cache is cold (stale ⇒ the first turn re-pays full cache-creation).
|
|
10
|
+
// We deliberately do NOT express cost as a % of the rate-limit window — ccr
|
|
11
|
+
// doesn't know its absolute token cap, so that would be fabricated.
|
|
12
|
+
|
|
13
|
+
const { listSessionFiles, readSession } = require('./transcripts');
|
|
14
|
+
const { inferWindow } = require('./burn');
|
|
15
|
+
|
|
16
|
+
const COLD_MS = 5 * 60 * 1000; // cache TTL: older than this ⇒ cold
|
|
17
|
+
const SCAN_CAP = 80; // bound work: parse at most this many files
|
|
18
|
+
|
|
19
|
+
/** Context re-fed on resume ≈ the last assistant turn's input side. @param {any} parsed */
|
|
20
|
+
function contextTokens(parsed) {
|
|
21
|
+
const t = parsed && parsed.stats && parsed.stats.lastTurn;
|
|
22
|
+
return t ? (t.input || 0) + (t.cacheRead || 0) + (t.cacheCreate || 0) : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build one advisor row from a parsed transcript + its file metadata. Pure.
|
|
27
|
+
* @param {any} parsed result of parseEvents/readSession
|
|
28
|
+
* @param {{ sessionId: string, mtimeMs: number }} file
|
|
29
|
+
* @param {number} now epoch ms
|
|
30
|
+
* @returns {{ sessionId: string, title: string, branch: string|null, cwd: string|null, ageMin: number, ctx: number, winPct: number, cold: boolean }}
|
|
31
|
+
*/
|
|
32
|
+
function buildRow(parsed, file, now) {
|
|
33
|
+
const ctx = contextTokens(parsed);
|
|
34
|
+
const win = inferWindow({ model: parsed.stats.lastModel || undefined });
|
|
35
|
+
const ageMs = Math.max(0, now - file.mtimeMs);
|
|
36
|
+
return {
|
|
37
|
+
sessionId: file.sessionId,
|
|
38
|
+
title: parsed.title || parsed.lastPrompt || '(untitled)',
|
|
39
|
+
branch: parsed.meta.gitBranch,
|
|
40
|
+
cwd: parsed.meta.cwd,
|
|
41
|
+
ageMin: Math.round(ageMs / 60000),
|
|
42
|
+
ctx,
|
|
43
|
+
winPct: win ? Math.round((ctx / win) * 100) : 0,
|
|
44
|
+
cold: ageMs > COLD_MS,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gather recent sessions as advisor rows, newest first.
|
|
50
|
+
* @param {{ limit?: number, scope?: 'cwd'|'all', cwd?: string, now?: number, files?: any[] }} [opts]
|
|
51
|
+
* @returns {ReturnType<typeof buildRow>[]}
|
|
52
|
+
*/
|
|
53
|
+
function gather(opts = {}) {
|
|
54
|
+
const limit = opts.limit || 12;
|
|
55
|
+
const scope = opts.scope || 'cwd';
|
|
56
|
+
const cwd = opts.cwd || process.cwd();
|
|
57
|
+
const now = opts.now || Date.now();
|
|
58
|
+
const files = opts.files || listSessionFiles();
|
|
59
|
+
/** @type {ReturnType<typeof buildRow>[]} */
|
|
60
|
+
const rows = [];
|
|
61
|
+
let scanned = 0;
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
if (rows.length >= limit || scanned >= SCAN_CAP) break;
|
|
64
|
+
scanned++;
|
|
65
|
+
const parsed = readSession(f.path);
|
|
66
|
+
if (!parsed) continue;
|
|
67
|
+
if (scope === 'cwd' && parsed.meta.cwd && parsed.meta.cwd !== cwd) continue;
|
|
68
|
+
if (!contextTokens(parsed) && !parsed.title) continue; // skip empty/aborted
|
|
69
|
+
rows.push(buildRow(parsed, f, now));
|
|
70
|
+
}
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { buildRow, gather, contextTokens, COLD_MS, SCAN_CAP };
|
package/src/sanitize.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/sanitize.js — strip terminal control characters from externally-sourced
|
|
4
|
+
// text before it is rendered.
|
|
5
|
+
//
|
|
6
|
+
// Transcript titles/prompts/tool-args and status-JSON fields (model name,
|
|
7
|
+
// rate-limit labels) can contain arbitrary bytes — web content the assistant
|
|
8
|
+
// fetched, pasted data, a planted snapshot. Emitting raw ANSI/control sequences
|
|
9
|
+
// to a terminal enables output spoofing (and worse on some terminals). These are
|
|
10
|
+
// all single-line display fields, so we drop every C0/C1 control + DEL
|
|
11
|
+
// (including ESC, newline, tab). Applied at the ingestion choke points
|
|
12
|
+
// (parseEvents, normalizeStatus, discoverWindows) so every renderer is covered.
|
|
13
|
+
//
|
|
14
|
+
// `ccr economy --json` needs no extra escaping layer: its string fields (model,
|
|
15
|
+
// rate-limit labels) come from the SAME sanitized ingestion (normalizeStatus /
|
|
16
|
+
// discoverWindows), so they are already control-char-free. (Note JSON.stringify
|
|
17
|
+
// alone is NOT sufficient — it escapes C0 but leaves DEL/C1 bytes raw — which is
|
|
18
|
+
// exactly why we sanitize at ingestion rather than rely on the serializer.)
|
|
19
|
+
|
|
20
|
+
// C0 controls (00-1F, incl. ESC/newline/tab), DEL (7F), and C1 controls (80-9F).
|
|
21
|
+
const CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {any} s
|
|
25
|
+
* @returns {any} the string with control chars removed; non-strings pass through
|
|
26
|
+
*/
|
|
27
|
+
function stripControl(s) {
|
|
28
|
+
return typeof s === 'string' ? s.replace(CONTROL_RE, '') : s;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { stripControl };
|
package/src/sidecar.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/sidecar.js — the live economy panel that runs in the tmux sidebar.
|
|
4
|
+
// Reads the per-session snapshot that `ccr statusline` writes (CCR_STATE_DIR),
|
|
5
|
+
// re-renders the economy screen every second (so the imminent band flashes),
|
|
6
|
+
// and shows a clean ended/waiting state. Pure Node, zero dependencies.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const { normalizeStatus } = require('./normalize');
|
|
12
|
+
const { renderEconomy } = require('./render/economy');
|
|
13
|
+
const { renderFeed } = require('./render/feed');
|
|
14
|
+
const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcripts');
|
|
15
|
+
|
|
16
|
+
const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
|
|
17
|
+
const SNAPSHOT = path.join(STATE_DIR, 'last-status.json');
|
|
18
|
+
const EXITED = path.join(STATE_DIR, 'exited');
|
|
19
|
+
|
|
20
|
+
// Live feed accumulator: tail the current transcript incrementally (by byte
|
|
21
|
+
// offset) and roll up tool/skill events + per-session stats. Reset on session
|
|
22
|
+
// switch. Best-effort — must never break the economy panel.
|
|
23
|
+
const FEED_CAP = 200;
|
|
24
|
+
const feedState = { path: /** @type {string|null} */ (null), offset: 0, events: /** @type {any[]} */ ([]), tools: /** @type {Record<string,number>} */ ({}), commands: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }, files: new Set() };
|
|
25
|
+
|
|
26
|
+
/** @param {string} tpath @returns {any} feed view for renderFeed */
|
|
27
|
+
function updateFeed(tpath) {
|
|
28
|
+
if (feedState.path !== tpath) { // new session → start clean
|
|
29
|
+
feedState.path = tpath; feedState.offset = 0; feedState.events = []; feedState.tools = {};
|
|
30
|
+
feedState.commands = 0; feedState.tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }; feedState.files = new Set();
|
|
31
|
+
}
|
|
32
|
+
const { offset, lines } = readNewLines(tpath, feedState.offset);
|
|
33
|
+
feedState.offset = offset;
|
|
34
|
+
if (lines.length) {
|
|
35
|
+
const p = parseEvents(lines);
|
|
36
|
+
for (const e of p.events) feedState.events.push(e);
|
|
37
|
+
if (feedState.events.length > FEED_CAP) feedState.events.splice(0, feedState.events.length - FEED_CAP);
|
|
38
|
+
for (const k of Object.keys(p.stats.tools)) feedState.tools[k] = (feedState.tools[k] || 0) + p.stats.tools[k];
|
|
39
|
+
feedState.commands += p.stats.commands;
|
|
40
|
+
feedState.tokens.input += p.stats.tokens.input;
|
|
41
|
+
feedState.tokens.output += p.stats.tokens.output;
|
|
42
|
+
feedState.tokens.cacheRead += p.stats.tokens.cacheRead;
|
|
43
|
+
feedState.tokens.cacheCreate += p.stats.tokens.cacheCreate;
|
|
44
|
+
for (const f of p.stats.files) feedState.files.add(f);
|
|
45
|
+
}
|
|
46
|
+
return { events: feedState.events, tools: feedState.tools, commands: feedState.commands, tokens: feedState.tokens, files: [...feedState.files] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const dim = (/** @type {string} */ s) => `\x1b[2m${s}\x1b[0m`;
|
|
50
|
+
const bold = (/** @type {string} */ s) => `\x1b[1m${s}\x1b[0m`;
|
|
51
|
+
|
|
52
|
+
let prev = '';
|
|
53
|
+
function draw(/** @type {string} */ s) {
|
|
54
|
+
if (s === prev) return;
|
|
55
|
+
prev = s;
|
|
56
|
+
// Cursor home, clear-to-EOL per line, then clear below — flicker-free.
|
|
57
|
+
process.stdout.write('\x1b[H' + s.replace(/\n/g, '\x1b[K\n') + '\x1b[J');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function frame() {
|
|
61
|
+
if (fs.existsSync(EXITED)) { draw(bold('ccr') + ' ' + dim('session ended') + '\n'); return; }
|
|
62
|
+
let raw = '';
|
|
63
|
+
try { raw = fs.readFileSync(SNAPSHOT, 'utf8'); } catch { /* none yet */ }
|
|
64
|
+
if (!raw.trim()) { draw(dim('ccr · waiting for the first status tick…') + '\n'); return; }
|
|
65
|
+
let state;
|
|
66
|
+
try { state = JSON.parse(raw); } catch { draw(dim('ccr · status unreadable') + '\n'); return; }
|
|
67
|
+
let out;
|
|
68
|
+
try {
|
|
69
|
+
out = renderEconomy(normalizeStatus(state), { tick: Math.floor(Date.now() / 1000) % 2 === 0 });
|
|
70
|
+
} catch (e) {
|
|
71
|
+
out = dim('ccr render error: ' + (e && e instanceof Error ? e.message : String(e)));
|
|
72
|
+
}
|
|
73
|
+
// Live tool/skills feed below the panel — best-effort; never break the panel.
|
|
74
|
+
try {
|
|
75
|
+
const tpath = currentTranscriptPath(state);
|
|
76
|
+
if (tpath) {
|
|
77
|
+
const feedStr = renderFeed(updateFeed(tpath), { max: 6 });
|
|
78
|
+
if (feedStr) out += '\n\n' + feedStr;
|
|
79
|
+
}
|
|
80
|
+
} catch { /* feed is optional */ }
|
|
81
|
+
draw(out.endsWith('\n') ? out : out + '\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function run() {
|
|
85
|
+
frame();
|
|
86
|
+
const id = setInterval(frame, 1000);
|
|
87
|
+
const stop = () => { clearInterval(id); process.exit(0); };
|
|
88
|
+
process.on('SIGINT', stop);
|
|
89
|
+
process.on('SIGTERM', stop);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `updateFeed` is exported for tests (the incremental tail + session-switch
|
|
93
|
+
// reset is the subtle part); the live loop uses `run`.
|
|
94
|
+
module.exports = { run, updateFeed };
|
package/src/state-dir.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/state-dir.js — ccr keeps its local state under the user's home (~/.ccr),
|
|
4
|
+
// never in world-shared /tmp. Captured status includes the transcript path,
|
|
5
|
+
// cost, and usage %, so the directory is created owner-only (0700) to keep other
|
|
6
|
+
// local users from reading it. Best-effort: state I/O must never break the
|
|
7
|
+
// status line, so callers wrap this in try/catch.
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create (or tighten to owner-only) a state directory.
|
|
13
|
+
* @param {string} dir
|
|
14
|
+
*/
|
|
15
|
+
function ensureSecureDir(dir) {
|
|
16
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
17
|
+
// mkdir's mode only applies to dirs it creates; tighten a pre-existing one.
|
|
18
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { ensureSecureDir };
|
package/src/theme.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/theme.js — UI lexicon and theme gating.
|
|
4
|
+
//
|
|
5
|
+
// Default ("plain") is accessible: "the wall" works as the everyday "hit the
|
|
6
|
+
// wall" idiom for hitting your limit. The "mary" theme (Proud Mary — CCR) swaps
|
|
7
|
+
// in the full classic-rock vocabulary. It auto-enables on the CCR debut-album
|
|
8
|
+
// anniversary, and can be forced any day via the innocuous env switch
|
|
9
|
+
// CCR_ENABLE_MARY_INTERFACE (a "subtle startup switch" that gives nothing away).
|
|
10
|
+
|
|
11
|
+
const THEMES = {
|
|
12
|
+
plain: { wall: 'the wall', within: 'within limits', imminent: 'limit imminent', looming: 'next limit', clearKey: 'F2·clear' },
|
|
13
|
+
mary: { wall: 'the wall', within: 'comfortably numb', imminent: 'bad moon rising', looming: 'up around the bend', clearKey: 'F2·wipe out' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Creedence Clearwater Revival — self-titled debut LP, released July 5, 1968.
|
|
17
|
+
const CCR_DEBUT = { month: 7, day: 5 };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Date} [now]
|
|
21
|
+
* @param {Record<string,string|undefined>} [env]
|
|
22
|
+
* @returns {'plain'|'mary'}
|
|
23
|
+
*/
|
|
24
|
+
function resolveTheme(now, env) {
|
|
25
|
+
const e = env || process.env;
|
|
26
|
+
if (e.CCR_ENABLE_MARY_INTERFACE) return 'mary';
|
|
27
|
+
const d = now || new Date();
|
|
28
|
+
if (d.getMonth() + 1 === CCR_DEBUT.month && d.getDate() === CCR_DEBUT.day) return 'mary';
|
|
29
|
+
return 'plain';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} [name] */
|
|
33
|
+
function lexicon(name) {
|
|
34
|
+
return THEMES[name === 'mary' ? 'mary' : 'plain'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { THEMES, CCR_DEBUT, resolveTheme, lexicon };
|