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,270 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/transcripts.js — read-only reader for Claude Code session transcripts
|
|
4
|
+
// (`~/.claude/projects/<slug>/<sessionId>.jsonl`). The shared spine for the
|
|
5
|
+
// tool/skills feed and the resume list.
|
|
6
|
+
//
|
|
7
|
+
// NEVER writes to ~/.claude. Tolerant of malformed lines (skipped) and schema
|
|
8
|
+
// drift (CC stamps `version`; we degrade, never throw). Pure parsing lives in
|
|
9
|
+
// `parseEvents` (operates on lines/strings) so it's testable without the FS; the
|
|
10
|
+
// file/dir helpers are thin wrappers.
|
|
11
|
+
//
|
|
12
|
+
// Skills/slash-commands: in current CC they appear as USER messages carrying a
|
|
13
|
+
// `<command-name>…</command-name>` tag (verified — zero `Skill` tool_use blocks
|
|
14
|
+
// in real transcripts). We detect that tag first, and ALSO accept a `Skill` /
|
|
15
|
+
// `SlashCommand` tool_use for forward/back compatibility.
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const { stripControl } = require('./sanitize');
|
|
21
|
+
|
|
22
|
+
/** Rough input-token-equivalent weights — the meter-drain proxy (see backtest-burn.js). */
|
|
23
|
+
const DRAIN_WEIGHTS = { output: 5, cacheCreate: 1.25, input: 1, cacheRead: 0.1 };
|
|
24
|
+
|
|
25
|
+
const projectsDir = () => path.join(os.homedir(), '.claude', 'projects');
|
|
26
|
+
|
|
27
|
+
/** @param {string} [p] */
|
|
28
|
+
function basename(p) { return String(p || '').split(/[\\/]/).filter(Boolean).pop() || ''; }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One-line summary of a tool call's arguments, per tool.
|
|
32
|
+
* @param {string} name
|
|
33
|
+
* @param {any} input
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function summarizeArg(name, input) {
|
|
37
|
+
const i = input || {};
|
|
38
|
+
switch (name) {
|
|
39
|
+
case 'Read': case 'Edit': case 'Write': case 'NotebookEdit':
|
|
40
|
+
return basename(i.file_path || i.notebook_path);
|
|
41
|
+
case 'Bash':
|
|
42
|
+
return i.description || String(i.command || '').split('\n')[0].slice(0, 48);
|
|
43
|
+
case 'Grep': case 'Glob':
|
|
44
|
+
return String(i.pattern || '');
|
|
45
|
+
case 'Task': case 'Agent':
|
|
46
|
+
return i.subagent_type || i.description || '';
|
|
47
|
+
case 'Skill':
|
|
48
|
+
return i.skill || i.command || '';
|
|
49
|
+
case 'SlashCommand':
|
|
50
|
+
return i.command || '';
|
|
51
|
+
case 'WebFetch':
|
|
52
|
+
return i.url || '';
|
|
53
|
+
case 'WebSearch':
|
|
54
|
+
return i.query || '';
|
|
55
|
+
default:
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Flatten a message's content to plain text (content may be a string or block array). */
|
|
61
|
+
function messageText(/** @type {any} */ msg) {
|
|
62
|
+
const c = msg && msg.content;
|
|
63
|
+
if (typeof c === 'string') return c;
|
|
64
|
+
if (Array.isArray(c)) return c.map((b) => (b && b.type === 'text' ? b.text : (typeof b === 'string' ? b : ''))).join('');
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Linear: a single greedy class with a literal terminator not in the class — no
|
|
69
|
+
// overlapping quantifiers, so no catastrophic backtracking. (The old
|
|
70
|
+
// `\s*([^<]+?)\s*` form was super-quadratic on a long unclosed tag; the `\s*`
|
|
71
|
+
// was redundant anyway since we .trim() the capture.)
|
|
72
|
+
const CMD_RE = /<command-name>([^<]+)<\/command-name>/;
|
|
73
|
+
const CMD_SCAN_MAX = 64 * 1024; // only scan a bounded prefix for the tag
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse transcript lines into events, rolling stats, and session metadata.
|
|
77
|
+
* @param {string | string[]} input raw JSONL text or an array of raw lines
|
|
78
|
+
* @returns {{
|
|
79
|
+
* meta: { sessionId: string|null, cwd: string|null, gitBranch: string|null, version: string|null, startTs: number|null, lastTs: number|null },
|
|
80
|
+
* events: { ts: number|null, kind: 'tool'|'cmd', tool: string, arg: string }[],
|
|
81
|
+
* title: string|null, lastPrompt: string|null,
|
|
82
|
+
* stats: { tools: Record<string, number>, commands: number, tokens: { input: number, output: number, cacheRead: number, cacheCreate: number }, weighted: number, files: string[], models: string[], userPrompts: number, assistantTurns: number, lastTurn: { input: number, output: number, cacheRead: number, cacheCreate: number }|null, lastModel: string|null }
|
|
83
|
+
* }}
|
|
84
|
+
*/
|
|
85
|
+
function parseEvents(input) {
|
|
86
|
+
const lines = Array.isArray(input) ? input : String(input).split('\n');
|
|
87
|
+
const meta = { sessionId: /** @type {string|null} */(null), cwd: /** @type {string|null} */(null), gitBranch: /** @type {string|null} */(null), version: /** @type {string|null} */(null), startTs: /** @type {number|null} */(null), lastTs: /** @type {number|null} */(null) };
|
|
88
|
+
/** @type {{ ts: number|null, kind: 'tool'|'cmd', tool: string, arg: string }[]} */
|
|
89
|
+
const events = [];
|
|
90
|
+
const tools = /** @type {Record<string, number>} */ ({});
|
|
91
|
+
const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
92
|
+
const files = new Set();
|
|
93
|
+
const models = new Set();
|
|
94
|
+
let commands = 0, userPrompts = 0, assistantTurns = 0;
|
|
95
|
+
let title = /** @type {string|null} */ (null);
|
|
96
|
+
let lastPrompt = /** @type {string|null} */ (null);
|
|
97
|
+
// The freshest assistant turn's usage + model — what a resume re-feeds (its
|
|
98
|
+
// input side is ~the context size), as opposed to the cumulative totals.
|
|
99
|
+
let lastTurn = /** @type {{ input: number, output: number, cacheRead: number, cacheCreate: number }|null} */ (null);
|
|
100
|
+
let lastModel = /** @type {string|null} */ (null);
|
|
101
|
+
|
|
102
|
+
for (const ln of lines) {
|
|
103
|
+
if (!ln || !ln.trim()) continue;
|
|
104
|
+
let o; try { o = JSON.parse(ln); } catch { continue; } // tolerate malformed lines
|
|
105
|
+
if (!o || typeof o !== 'object') continue;
|
|
106
|
+
|
|
107
|
+
// Metadata: first non-null wins for identity; ts tracks the span.
|
|
108
|
+
// Sanitize every externally-sourced field (titles, prompts, args, identity)
|
|
109
|
+
// as it's captured — these are displayed in the terminal, so control chars
|
|
110
|
+
// would otherwise be a terminal-escape-injection vector.
|
|
111
|
+
if (meta.sessionId == null && o.sessionId) meta.sessionId = stripControl(String(o.sessionId));
|
|
112
|
+
if (meta.cwd == null && o.cwd) meta.cwd = stripControl(String(o.cwd));
|
|
113
|
+
if (meta.gitBranch == null && o.gitBranch) meta.gitBranch = stripControl(String(o.gitBranch));
|
|
114
|
+
if (meta.version == null && o.version) meta.version = o.version;
|
|
115
|
+
const ts = o.timestamp ? Date.parse(o.timestamp) : NaN;
|
|
116
|
+
const tsOk = Number.isFinite(ts) ? ts : null;
|
|
117
|
+
if (tsOk != null) {
|
|
118
|
+
if (meta.startTs == null || tsOk < meta.startTs) meta.startTs = tsOk;
|
|
119
|
+
if (meta.lastTs == null || tsOk > meta.lastTs) meta.lastTs = tsOk;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (o.type === 'ai-title' && o.aiTitle) { title = stripControl(String(o.aiTitle)); continue; } // last wins
|
|
123
|
+
if (o.type === 'last-prompt' && o.lastPrompt != null) { lastPrompt = stripControl(String(o.lastPrompt)); continue; } // last wins
|
|
124
|
+
|
|
125
|
+
if (o.type === 'user') {
|
|
126
|
+
const text = messageText(o.message);
|
|
127
|
+
const m = CMD_RE.exec(text.length > CMD_SCAN_MAX ? text.slice(0, CMD_SCAN_MAX) : text);
|
|
128
|
+
// Command name goes in `tool` (the renderer's convention — same as the
|
|
129
|
+
// Skill/SlashCommand tool_use path below); `arg` stays empty.
|
|
130
|
+
if (m) { commands++; events.push({ ts: tsOk, kind: 'cmd', tool: stripControl(m[1].trim()), arg: '' }); continue; }
|
|
131
|
+
// A genuine prompt: has text and isn't a tool_result echo or meta line.
|
|
132
|
+
const isToolResult = Array.isArray(o.message && o.message.content) && o.message.content.some((/** @type {any} */ b) => b && b.type === 'tool_result');
|
|
133
|
+
if (text.trim() && !o.isMeta && !isToolResult) userPrompts++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (o.type === 'assistant') {
|
|
138
|
+
assistantTurns++;
|
|
139
|
+
const msg = o.message || {};
|
|
140
|
+
if (msg.model) { models.add(msg.model); lastModel = msg.model; }
|
|
141
|
+
const u = msg.usage;
|
|
142
|
+
if (u) {
|
|
143
|
+
const turn = { input: u.input_tokens || 0, output: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheCreate: u.cache_creation_input_tokens || 0 };
|
|
144
|
+
tokens.input += turn.input;
|
|
145
|
+
tokens.output += turn.output;
|
|
146
|
+
tokens.cacheRead += turn.cacheRead;
|
|
147
|
+
tokens.cacheCreate += turn.cacheCreate;
|
|
148
|
+
lastTurn = turn;
|
|
149
|
+
}
|
|
150
|
+
const c = msg.content;
|
|
151
|
+
if (Array.isArray(c)) for (const b of c) {
|
|
152
|
+
if (!b || b.type !== 'tool_use') continue;
|
|
153
|
+
const name = String(b.name || '');
|
|
154
|
+
const cleanName = stripControl(name); // the displayed name (header count + event)
|
|
155
|
+
const kind = /** @type {'tool'|'cmd'} */ (name === 'Skill' || name === 'SlashCommand' ? 'cmd' : 'tool');
|
|
156
|
+
if (kind === 'cmd') commands++; else tools[cleanName] = (tools[cleanName] || 0) + 1;
|
|
157
|
+
const arg = stripControl(summarizeArg(name, b.input));
|
|
158
|
+
if ((name === 'Read' || name === 'Edit' || name === 'Write' || name === 'NotebookEdit') && arg) files.add(arg);
|
|
159
|
+
events.push({ ts: tsOk, kind, tool: kind === 'cmd' ? (name === 'Skill' || name === 'SlashCommand' ? arg : cleanName) : cleanName, arg: kind === 'cmd' ? '' : arg });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const weighted = tokens.output * DRAIN_WEIGHTS.output + tokens.cacheCreate * DRAIN_WEIGHTS.cacheCreate
|
|
165
|
+
+ tokens.input * DRAIN_WEIGHTS.input + tokens.cacheRead * DRAIN_WEIGHTS.cacheRead;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
meta, events, title, lastPrompt,
|
|
169
|
+
stats: { tools, commands, tokens, weighted, files: [...files], models: [...models], userPrompts, assistantTurns, lastTurn, lastModel },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The current session's transcript path from the captured statusline JSON,
|
|
175
|
+
* CONFINED to `~/.claude/projects`. The snapshot can be attacker-influenced (e.g.
|
|
176
|
+
* a planted /tmp state file), so we never hand the sidecar a path that escapes
|
|
177
|
+
* the projects tree or isn't a `.jsonl` — preventing arbitrary-file reads.
|
|
178
|
+
* Confinement is lexical (path.resolve, not realpath): a symlink planted *inside*
|
|
179
|
+
* the projects tree is out of scope, as that already requires write access to
|
|
180
|
+
* the user's home directory.
|
|
181
|
+
* @param {any} snapshot
|
|
182
|
+
* @param {string} [base] projects dir (injectable for tests)
|
|
183
|
+
* @returns {string|null} the resolved in-tree path, or null if rejected
|
|
184
|
+
*/
|
|
185
|
+
function currentTranscriptPath(snapshot, base = projectsDir()) {
|
|
186
|
+
const p = snapshot && snapshot.transcript_path;
|
|
187
|
+
if (typeof p !== 'string' || !p || !p.endsWith('.jsonl')) return null;
|
|
188
|
+
const resolved = path.resolve(p);
|
|
189
|
+
const root = path.resolve(base);
|
|
190
|
+
return resolved.startsWith(root + path.sep) ? resolved : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Enumerate transcript files across all projects, newest first (by mtime). Cheap:
|
|
195
|
+
* stat only — callers parse just the top N.
|
|
196
|
+
* @param {string} [base]
|
|
197
|
+
* @returns {{ path: string, sessionId: string, mtimeMs: number, size: number }[]}
|
|
198
|
+
*/
|
|
199
|
+
function listSessionFiles(base = projectsDir()) {
|
|
200
|
+
/** @type {{ path: string, sessionId: string, mtimeMs: number, size: number }[]} */
|
|
201
|
+
const out = [];
|
|
202
|
+
let dirs; try { dirs = fs.readdirSync(base); } catch { return out; }
|
|
203
|
+
for (const d of dirs) {
|
|
204
|
+
const dir = path.join(base, d);
|
|
205
|
+
let st; try { st = fs.statSync(dir); } catch { continue; }
|
|
206
|
+
if (!st.isDirectory()) continue;
|
|
207
|
+
let names; try { names = fs.readdirSync(dir); } catch { continue; }
|
|
208
|
+
for (const f of names) {
|
|
209
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
210
|
+
const fp = path.join(dir, f);
|
|
211
|
+
let s; try { s = fs.statSync(fp); } catch { continue; }
|
|
212
|
+
out.push({ path: fp, sessionId: f.replace(/\.jsonl$/, ''), mtimeMs: s.mtimeMs, size: s.size });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const MAX_READ = 4 * 1024 * 1024; // bound one tick's allocation; large backlogs catch up over ticks
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read complete new lines appended since `fromOffset` (for the live tail). The
|
|
223
|
+
* transcript is append-only; if it shrank (rotation/truncation) we restart at 0.
|
|
224
|
+
* Reads at most `maxRead` bytes per call (bounded allocation), so a very large
|
|
225
|
+
* transcript is consumed over several ticks rather than in one giant buffer.
|
|
226
|
+
* Returns the new byte offset (advanced only past whole lines).
|
|
227
|
+
* @param {string} file
|
|
228
|
+
* @param {number} [fromOffset]
|
|
229
|
+
* @param {number} [maxRead]
|
|
230
|
+
* @returns {{ offset: number, lines: string[] }}
|
|
231
|
+
*/
|
|
232
|
+
function readNewLines(file, fromOffset = 0, maxRead = MAX_READ) {
|
|
233
|
+
let st; try { st = fs.statSync(file); } catch { return { offset: fromOffset, lines: [] }; }
|
|
234
|
+
let start = fromOffset;
|
|
235
|
+
if (st.size < start) start = 0; // truncated/rotated → restart
|
|
236
|
+
let len = st.size - start;
|
|
237
|
+
if (len <= 0) return { offset: st.size, lines: [] };
|
|
238
|
+
const capped = len > maxRead; // more data than one window holds
|
|
239
|
+
if (capped) len = maxRead;
|
|
240
|
+
const fd = fs.openSync(file, 'r');
|
|
241
|
+
try {
|
|
242
|
+
const buf = Buffer.alloc(len);
|
|
243
|
+
fs.readSync(fd, buf, 0, len, start);
|
|
244
|
+
const text = buf.toString('utf8');
|
|
245
|
+
const lastNl = text.lastIndexOf('\n');
|
|
246
|
+
if (lastNl < 0) {
|
|
247
|
+
// No complete line in this window. If capped, the current line is longer
|
|
248
|
+
// than the cap — skip past the window to guarantee forward progress (the
|
|
249
|
+
// resulting partial line fails JSON.parse and is tolerated). Otherwise the
|
|
250
|
+
// last line just isn't finished yet; wait for more.
|
|
251
|
+
return capped ? { offset: start + len, lines: [] } : { offset: start, lines: [] };
|
|
252
|
+
}
|
|
253
|
+
const whole = text.slice(0, lastNl);
|
|
254
|
+
const consumed = start + Buffer.byteLength(whole, 'utf8') + 1; // +1 for the newline
|
|
255
|
+
return { offset: consumed, lines: whole.split('\n').filter(Boolean) };
|
|
256
|
+
} finally {
|
|
257
|
+
fs.closeSync(fd);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Parse a whole transcript file in one shot. @param {string} file */
|
|
262
|
+
function readSession(file) {
|
|
263
|
+
let txt; try { txt = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
264
|
+
return parseEvents(txt);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
DRAIN_WEIGHTS, parseEvents, summarizeArg, currentTranscriptPath,
|
|
269
|
+
listSessionFiles, readNewLines, readSession, projectsDir,
|
|
270
|
+
};
|