claude-code-runrate 0.2.4 → 0.3.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/README.md +56 -0
- package/bin/ccr.js +16 -0
- package/package.json +1 -1
- package/scripts/launch.sh +87 -11
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +2 -0
- package/src/economy-model.js +3 -0
- package/src/liveness.js +18 -1
- package/src/normalize.js +24 -5
- package/src/pane-blob.js +249 -0
- package/src/pane-config.js +109 -0
- package/src/rate-limits.js +12 -2
- package/src/render/economy.js +10 -2
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +66 -10
- package/src/render/statusline.js +3 -0
- package/src/safe-read.js +66 -0
- package/src/sanitize.js +45 -4
- package/src/sidecar.js +148 -19
- package/src/transcripts.js +31 -15
package/src/render/shared.js
CHANGED
|
@@ -21,12 +21,61 @@ function bar(/** @type {number} */ p, w = 10) {
|
|
|
21
21
|
return '▓'.repeat(f) + '░'.repeat(w - f);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Code-point ranges a terminal renders two columns wide (East Asian Wide and
|
|
25
|
+
// Fullwidth, per UAX #11), condensed to the blocks that actually turn up in a
|
|
26
|
+
// file path, a model name, or a tool argument: CJK, Hangul, Kana, fullwidth
|
|
27
|
+
// forms, and the emoji planes. Not exhaustive — it does not need to be. Every
|
|
28
|
+
// range here converts a "counted 1, occupies 2" error, which overflows the pane
|
|
29
|
+
// and soft-wraps, into a correct count.
|
|
30
|
+
const WIDE_RANGES = [
|
|
31
|
+
[0x1100, 0x115f], // Hangul Jamo
|
|
32
|
+
[0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols/punctuation
|
|
33
|
+
[0x3041, 0x33ff], // Kana, Bopomofo, Hangul Compat Jamo, CJK compat
|
|
34
|
+
[0x3400, 0x4dbf], // CJK Ext A
|
|
35
|
+
[0x4e00, 0x9fff], // CJK Unified
|
|
36
|
+
[0xa000, 0xa4cf], // Yi
|
|
37
|
+
[0xac00, 0xd7a3], // Hangul syllables
|
|
38
|
+
[0xf900, 0xfaff], // CJK compat ideographs
|
|
39
|
+
[0xfe30, 0xfe6f], // CJK compat forms, small form variants
|
|
40
|
+
[0xff00, 0xff60], // Fullwidth forms
|
|
41
|
+
[0xffe0, 0xffe6], // Fullwidth signs
|
|
42
|
+
[0x1f300, 0x1f64f], // Emoji: symbols/pictographs, emoticons
|
|
43
|
+
[0x1f900, 0x1f9ff], // Supplemental symbols/pictographs
|
|
44
|
+
[0x20000, 0x3fffd], // CJK Ext B+ (SIP)
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Terminal columns occupied by one code point: 2 for East Asian Wide/Fullwidth,
|
|
49
|
+
* 0 for combining marks (they stack onto the previous glyph), else 1.
|
|
50
|
+
* @param {number} cp
|
|
51
|
+
* @returns {0|1|2}
|
|
52
|
+
*/
|
|
53
|
+
function charWidth(cp) {
|
|
54
|
+
// Combining diacriticals, and the Hebrew/Arabic/Devanagari combining blocks
|
|
55
|
+
// most likely to appear in fetched text. Zero-width formatting characters are
|
|
56
|
+
// already gone by here (src/sanitize.js strips them at ingestion).
|
|
57
|
+
if ((cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x0483 && cp <= 0x0489)
|
|
58
|
+
|| (cp >= 0x0591 && cp <= 0x05bd) || (cp >= 0x0610 && cp <= 0x061a)
|
|
59
|
+
|| (cp >= 0x064b && cp <= 0x065f) || (cp >= 0x0900 && cp <= 0x0903)
|
|
60
|
+
|| (cp >= 0x1ab0 && cp <= 0x1aff) || (cp >= 0x20d0 && cp <= 0x20f0)
|
|
61
|
+
|| (cp >= 0xfe00 && cp <= 0xfe0f)) return 0; // incl. variation selectors
|
|
62
|
+
for (const [lo, hi] of WIDE_RANGES) if (cp >= lo && cp <= hi) return 2;
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
24
66
|
/**
|
|
25
|
-
* Clamp one line to `cols` visible
|
|
26
|
-
* with zero width
|
|
27
|
-
*
|
|
28
|
-
*
|
|
67
|
+
* Clamp one line to `cols` visible COLUMNS: SGR escapes (`\x1b[…m`) pass through
|
|
68
|
+
* with zero width; every other character counts for the columns a terminal will
|
|
69
|
+
* actually give it (see charWidth). Appends a reset if it had to cut, so a
|
|
70
|
+
* severed colour run doesn't bleed into the cleared tail. Prevents the soft wrap
|
|
71
|
+
* that corrupts the sidecar's cursor-home redraw in a narrow pane. A
|
|
29
72
|
* non-positive `cols` (e.g. a non-TTY where columns is undefined) is a no-op.
|
|
73
|
+
*
|
|
74
|
+
* Iterates by CODE POINT, not by UTF-16 unit: the old per-unit walk counted a
|
|
75
|
+
* CJK glyph as one column (so 8 of them filled a 16-column pane and wrapped —
|
|
76
|
+
* the exact corruption this function exists to prevent) and could cut an astral
|
|
77
|
+
* character in half, emitting a lone surrogate.
|
|
78
|
+
*
|
|
30
79
|
* @param {string} line
|
|
31
80
|
* @param {number} [cols]
|
|
32
81
|
* @returns {string}
|
|
@@ -41,17 +90,24 @@ function clampVisible(line, cols) {
|
|
|
41
90
|
sgr.lastIndex = i;
|
|
42
91
|
const m = sgr.exec(line);
|
|
43
92
|
if (m) { out += m[0]; i = sgr.lastIndex; continue; }
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
93
|
+
const cp = /** @type {number} */ (line.codePointAt(i));
|
|
94
|
+
const ch = String.fromCodePoint(cp);
|
|
95
|
+
const w = charWidth(cp);
|
|
96
|
+
// Cut BEFORE a character that would not fit whole — a wide glyph straddling
|
|
97
|
+
// the last column is what wraps the line.
|
|
98
|
+
if (width + w > cols) return out + '\x1b[0m';
|
|
99
|
+
out += ch;
|
|
100
|
+
width += w;
|
|
101
|
+
i += ch.length;
|
|
48
102
|
}
|
|
49
103
|
return out;
|
|
50
104
|
}
|
|
51
105
|
|
|
52
106
|
function tok(/** @type {number|null} */ n) {
|
|
53
|
-
if (n == null) return '?';
|
|
54
|
-
|
|
107
|
+
if (n == null || !Number.isFinite(n)) return '?';
|
|
108
|
+
// 999_500 rounds to 1000K, which is a unit the scale never uses — promote it
|
|
109
|
+
// to 1.0M rather than printing a fourth digit.
|
|
110
|
+
if (n >= 999500) return (n / 1e6).toFixed(1) + 'M';
|
|
55
111
|
if (n >= 1e3) return Math.round(n / 1e3) + 'K';
|
|
56
112
|
return String(Math.round(n));
|
|
57
113
|
}
|
package/src/render/statusline.js
CHANGED
|
@@ -14,6 +14,9 @@ function renderStatusline(view) {
|
|
|
14
14
|
const parts = [];
|
|
15
15
|
if (view.model) parts.push(view.model);
|
|
16
16
|
|
|
17
|
+
// Annotated because Array.isArray does not narrow an `any`: without this the
|
|
18
|
+
// whole chain below decays to `any` and the row callbacks lose their types.
|
|
19
|
+
/** @type {any[]} */
|
|
17
20
|
const windows = Array.isArray(view.windows) ? view.windows : [];
|
|
18
21
|
if (!windows.length) {
|
|
19
22
|
parts.push('API · no limits');
|
package/src/safe-read.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/safe-read.js — bounded, non-blocking reads of files ccr does not control.
|
|
4
|
+
//
|
|
5
|
+
// docs/PANE-CONTRACT.md states this rule for external pane blobs ("Safe reads":
|
|
6
|
+
// lstat, regular file only, size cap enforced before the read completes). The
|
|
7
|
+
// rule is not blob-specific — it belongs at every point where the sidecar reads
|
|
8
|
+
// a file some other process writes, which includes ccr's OWN inputs:
|
|
9
|
+
// last-status.json and the heartbeat file both live in a directory anything
|
|
10
|
+
// running as the user can write.
|
|
11
|
+
//
|
|
12
|
+
// Two failure modes it closes, both verified against the pre-fix sidecar:
|
|
13
|
+
//
|
|
14
|
+
// A FIFO at the path. `readFileSync` on a fifo BLOCKS until a writer appears.
|
|
15
|
+
// The sidecar's loop is single-threaded and synchronous, so one mkfifo froze
|
|
16
|
+
// the whole panel forever — no render, no heartbeat, no recovery. `lstat`
|
|
17
|
+
// answers "is this a regular file?" without opening anything, so the block
|
|
18
|
+
// never happens.
|
|
19
|
+
//
|
|
20
|
+
// An unbounded file. The reader had no cap at all (the WRITER caps itself at
|
|
21
|
+
// 1 MB, which says nothing about a planted file). A large planted snapshot
|
|
22
|
+
// drove quadratic label padding into a RangeError and blanked the panel.
|
|
23
|
+
//
|
|
24
|
+
// `lstat` also means a SYMLINK is refused rather than followed: this is state,
|
|
25
|
+
// not configuration, and nothing legitimate links it elsewhere.
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
|
|
29
|
+
/** Default cap. Generous for a status snapshot (a real one is ~1-2 KB). */
|
|
30
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read a file as UTF-8 if — and only if — it is a regular file no larger than
|
|
34
|
+
* `maxBytes`. Returns null for every other case (missing, fifo, socket, device,
|
|
35
|
+
* symlink, directory, too large, unreadable). Never throws, never blocks.
|
|
36
|
+
*
|
|
37
|
+
* The size is re-checked from the open descriptor, not just the lstat: the file
|
|
38
|
+
* can be replaced between the two calls, and the fstat describes the bytes we
|
|
39
|
+
* actually hold. The read is capped regardless, so a file that grows after the
|
|
40
|
+
* check still yields at most `maxBytes`.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} file
|
|
43
|
+
* @param {number} [maxBytes]
|
|
44
|
+
* @returns {string|null}
|
|
45
|
+
*/
|
|
46
|
+
function readTextCapped(file, maxBytes = DEFAULT_MAX_BYTES) {
|
|
47
|
+
let st;
|
|
48
|
+
try { st = fs.lstatSync(file); } catch { return null; }
|
|
49
|
+
if (!st.isFile() || st.size > maxBytes) return null;
|
|
50
|
+
|
|
51
|
+
let fd;
|
|
52
|
+
try { fd = fs.openSync(file, 'r'); } catch { return null; }
|
|
53
|
+
try {
|
|
54
|
+
const fst = fs.fstatSync(fd);
|
|
55
|
+
if (!fst.isFile() || fst.size > maxBytes) return null;
|
|
56
|
+
const buf = Buffer.alloc(Math.min(fst.size, maxBytes));
|
|
57
|
+
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
58
|
+
return buf.subarray(0, read).toString('utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
} finally {
|
|
62
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { readTextCapped, DEFAULT_MAX_BYTES };
|
package/src/sanitize.js
CHANGED
|
@@ -17,15 +17,56 @@
|
|
|
17
17
|
// alone is NOT sufficient — it escapes C0 but leaves DEL/C1 bytes raw — which is
|
|
18
18
|
// exactly why we sanitize at ingestion rather than rely on the serializer.)
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
|
|
20
|
+
// The stripped set, as code-point ranges. Spelled numerically and assembled at
|
|
21
|
+
// runtime rather than written as a literal character class: every character in
|
|
22
|
+
// here is invisible or display-altering, so a literal class would be unreadable
|
|
23
|
+
// in a diff — and could hide an added character in plain sight, in the very code
|
|
24
|
+
// meant to remove such characters.
|
|
25
|
+
const CONTROL_RANGES = [
|
|
26
|
+
[0x0000, 0x001f], // C0 controls — ESC, newline, tab
|
|
27
|
+
[0x007f, 0x009f], // DEL, then C1 controls: includes the 8-bit CSI (0x9b) and
|
|
28
|
+
// OSC (0x9d) introducers, not just their ESC-prefixed forms
|
|
29
|
+
[0x200b, 0x200f], // zero-width space/joiners + LRM/RLM — invisible, so two
|
|
30
|
+
// different byte strings can render identically
|
|
31
|
+
[0x2028, 0x2029], // line/paragraph separators — a line break by another name
|
|
32
|
+
[0x202a, 0x202e], // bidi embeddings and overrides
|
|
33
|
+
[0x2066, 0x2069], // bidi isolates — these two ranges reorder the glyphs a
|
|
34
|
+
// reader sees relative to the bytes actually present:
|
|
35
|
+
// "Trojan Source" (CVE-2021-42574) aimed at a status pane.
|
|
36
|
+
// Legitimate RTL text needs neither; scripts carry their
|
|
37
|
+
// own direction.
|
|
38
|
+
[0xfeff, 0xfeff], // zero-width no-break space (BOM) — invisible when not leading
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const CONTROL_RE = new RegExp(
|
|
42
|
+
'[' + CONTROL_RANGES.map(([lo, hi]) =>
|
|
43
|
+
(lo === hi ? String.fromCodePoint(lo) : String.fromCodePoint(lo) + '-' + String.fromCodePoint(hi))
|
|
44
|
+
).join('') + ']',
|
|
45
|
+
'g',
|
|
46
|
+
);
|
|
22
47
|
|
|
23
48
|
/**
|
|
49
|
+
* Strip control characters, COERCING any non-nullish input to a string first.
|
|
50
|
+
*
|
|
51
|
+
* The coercion is the security-relevant half. Every renderer downstream
|
|
52
|
+
* concatenates or `String()`s whatever it is handed, so returning a non-string
|
|
53
|
+
* unchanged does not keep it out of the terminal — it only skips the strip, and
|
|
54
|
+
* the escape bytes land on screen anyway once something stringifies them. A
|
|
55
|
+
* JSON file chooses its own value *types*, so "this field is a string" is never
|
|
56
|
+
* a safe assumption: `{"display_name": ["…"]}` parses just as well as a bare
|
|
57
|
+
* string, and an array of one string stringifies straight back to that string.
|
|
58
|
+
* Coerce once, here, at the choke point, rather than trusting a dozen call
|
|
59
|
+
* sites to remember.
|
|
60
|
+
*
|
|
61
|
+
* `null`/`undefined` still pass through, because callers use them as "absent"
|
|
62
|
+
* (`x || null`, `x != null`) and "null"/"undefined" are not display text.
|
|
63
|
+
*
|
|
24
64
|
* @param {any} s
|
|
25
|
-
* @returns {any}
|
|
65
|
+
* @returns {any} a control-char-free string, or null/undefined unchanged
|
|
26
66
|
*/
|
|
27
67
|
function stripControl(s) {
|
|
28
|
-
|
|
68
|
+
if (s == null) return s;
|
|
69
|
+
return String(s).replace(CONTROL_RE, '');
|
|
29
70
|
}
|
|
30
71
|
|
|
31
72
|
module.exports = { stripControl };
|
package/src/sidecar.js
CHANGED
|
@@ -15,6 +15,12 @@ const { renderFeed } = require('./render/feed');
|
|
|
15
15
|
const { clampVisible } = require('./render/shared');
|
|
16
16
|
const { liveness } = require('./liveness');
|
|
17
17
|
const { currentTranscriptPath, readNewLines, parseEvents } = require('./transcripts');
|
|
18
|
+
const { readTextCapped } = require('./safe-read');
|
|
19
|
+
const { stripControl } = require('./sanitize');
|
|
20
|
+
const { loadPaneConfig } = require('./pane-config');
|
|
21
|
+
const { loadPaneBlob } = require('./pane-blob');
|
|
22
|
+
const { renderPane } = require('./render/pane');
|
|
23
|
+
const { readViewRequests } = require('./cycle-view');
|
|
18
24
|
|
|
19
25
|
const STATE_DIR = process.env.CCR_STATE_DIR || path.join(os.homedir(), '.ccr');
|
|
20
26
|
|
|
@@ -45,18 +51,34 @@ function parseNonce(s) {
|
|
|
45
51
|
* claimed over (a garbage file must not wedge the panel), and any fs error
|
|
46
52
|
* claims rather than kills the loop — the heartbeat is strictly best-effort.
|
|
47
53
|
* @param {string} stateDir @param {string} nonce
|
|
54
|
+
* @param {{ now?: number, freshMs?: number }} [opts] injectable clock, for tests
|
|
48
55
|
* @returns {'claimed' | 'yielded'}
|
|
49
56
|
*/
|
|
50
|
-
function heartbeatTick(stateDir, nonce) {
|
|
57
|
+
function heartbeatTick(stateDir, nonce, opts = {}) {
|
|
51
58
|
const file = path.join(stateDir, HEARTBEAT_FILE);
|
|
52
59
|
const mine = parseNonce(nonce);
|
|
60
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
61
|
+
const freshMs = opts.freshMs != null ? opts.freshMs : HEARTBEAT_FRESH_MS;
|
|
53
62
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
const cur = readTextCapped(file, 256) || '';
|
|
64
|
+
// Yield only to a nonce that is BOTH newer and still being beaten. Nonce
|
|
65
|
+
// order alone is a wall-clock comparison against a file that outlives its
|
|
66
|
+
// writer: a hard-killed sidecar leaves its nonce behind, and after any
|
|
67
|
+
// backwards clock step (NTP correction, VM restore) every sidecar launched
|
|
68
|
+
// since reads that dead nonce as "newer" and stands down — so the pane ends
|
|
69
|
+
// up with no live sidecar at all, repeatably, until the clock catches up.
|
|
70
|
+
// Mtime is what distinguishes a live rival from a corpse; sidecarAlive()
|
|
71
|
+
// has always used it, and the takeover decision needs it just as much.
|
|
72
|
+
const fresh = (() => {
|
|
73
|
+
try { return now - fs.lstatSync(file).mtimeMs <= freshMs; } catch { return false; }
|
|
74
|
+
})();
|
|
75
|
+
const other = fresh && cur && cur.trim() !== nonce ? parseNonce(cur) : null;
|
|
57
76
|
if (other && mine && (other.start > mine.start || (other.start === mine.start && other.pid > mine.pid))) {
|
|
58
77
|
return 'yielded';
|
|
59
78
|
}
|
|
79
|
+
// Never write THROUGH a symlink planted at this path — that turns a
|
|
80
|
+
// heartbeat into an arbitrary-file write of "<pid>:<ms>".
|
|
81
|
+
try { if (fs.lstatSync(file).isSymbolicLink()) fs.rmSync(file, { force: true }); } catch { /* absent */ }
|
|
60
82
|
fs.writeFileSync(file, nonce);
|
|
61
83
|
} catch { /* best-effort */ }
|
|
62
84
|
return 'claimed';
|
|
@@ -70,7 +92,7 @@ function heartbeatTick(stateDir, nonce) {
|
|
|
70
92
|
function clearHeartbeat(stateDir, nonce) {
|
|
71
93
|
const file = path.join(stateDir, HEARTBEAT_FILE);
|
|
72
94
|
try {
|
|
73
|
-
if (
|
|
95
|
+
if ((readTextCapped(file, 256) || '').trim() === nonce) fs.rmSync(file, { force: true });
|
|
74
96
|
} catch { /* already gone / unreadable — nothing to clear */ }
|
|
75
97
|
}
|
|
76
98
|
|
|
@@ -95,15 +117,30 @@ function sidecarAlive(stateDir, opts = {}) {
|
|
|
95
117
|
// offset) and roll up tool/skill events + per-session stats. Reset on session
|
|
96
118
|
// switch. Best-effort — must never break the economy panel.
|
|
97
119
|
const FEED_CAP = 200;
|
|
98
|
-
const feedState = { path: /** @type {string|null} */ (null), offset: 0, events: /** @type {any[]} */ ([]), tools: /** @type {Record<string,number>} */ (
|
|
120
|
+
const feedState = { path: /** @type {string|null} */ (null), offset: 0, events: /** @type {any[]} */ ([]), tools: /** @type {Record<string,number>} */ (Object.create(null)), commands: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }, files: new Set() };
|
|
121
|
+
|
|
122
|
+
/** Zero the rolling totals — on a session switch, and on a tail restart. */
|
|
123
|
+
function resetFeedState(/** @type {string|null} */ tpath) {
|
|
124
|
+
feedState.path = tpath; feedState.offset = 0; feedState.events = [];
|
|
125
|
+
// Null-prototype: these keys are tool NAMES from the transcript, i.e. attacker
|
|
126
|
+
// -influenceable. On a plain object a tool called "constructor" reads back the
|
|
127
|
+
// inherited function (the feed header rendered its native source), and one
|
|
128
|
+
// called "__proto__" silently vanishes into a prototype write instead of
|
|
129
|
+
// counting. With no prototype there is nothing to inherit or to set.
|
|
130
|
+
feedState.tools = Object.create(null);
|
|
131
|
+
feedState.commands = 0;
|
|
132
|
+
feedState.tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
133
|
+
feedState.files = new Set();
|
|
134
|
+
}
|
|
99
135
|
|
|
100
136
|
/** @param {string} tpath @returns {any} feed view for renderFeed */
|
|
101
137
|
function updateFeed(tpath) {
|
|
102
|
-
if (feedState.path !== tpath)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
138
|
+
if (feedState.path !== tpath) resetFeedState(tpath); // new session → start clean
|
|
139
|
+
const { offset, lines, restarted } = readNewLines(tpath, feedState.offset);
|
|
140
|
+
// The tail went back to 0 because the file shrank, so the lines below are ones
|
|
141
|
+
// we have already counted. Resetting the offset without resetting the totals
|
|
142
|
+
// double-counts every tool, file, and token for the rest of the session.
|
|
143
|
+
if (restarted) resetFeedState(tpath);
|
|
107
144
|
feedState.offset = offset;
|
|
108
145
|
if (lines.length) {
|
|
109
146
|
const p = parseEvents(lines);
|
|
@@ -140,8 +177,15 @@ function draw(/** @type {string} */ s) {
|
|
|
140
177
|
* is clamped to it so a wide row can't soft-wrap and corrupt the cursor-home
|
|
141
178
|
* redraw in a narrow cmd/PowerShell/split pane. Omit it (non-TTY) for no clamp.
|
|
142
179
|
*
|
|
180
|
+
* `view` selects which whole-pane view to draw: 0 is ccr's own economy view and
|
|
181
|
+
* 1..N are the configured external panes, in config order. It is taken modulo
|
|
182
|
+
* the number of views, so an index that outlives a shrinking config wraps rather
|
|
183
|
+
* than showing nothing. External panes are FULL-HEIGHT views, never stacked
|
|
184
|
+
* beside the economy panel — one pane, one subject.
|
|
185
|
+
*
|
|
143
186
|
* @param {string} stateDir
|
|
144
|
-
* @param {{ now?: number, cols?: number
|
|
187
|
+
* @param {{ now?: number, cols?: number, rows?: number, view?: number,
|
|
188
|
+
* panes?: Array<{path: string, source: string}> }} [opts]
|
|
145
189
|
* @returns {string}
|
|
146
190
|
*/
|
|
147
191
|
function composeFrame(stateDir, opts = {}) {
|
|
@@ -155,8 +199,38 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
155
199
|
const exited = path.join(stateDir, 'exited');
|
|
156
200
|
|
|
157
201
|
if (fs.existsSync(exited)) return clamp(bold('ccr') + ' ' + dim('session ended') + '\n');
|
|
158
|
-
|
|
159
|
-
|
|
202
|
+
|
|
203
|
+
// External panes. Config is re-read per tick so adding a pane needs no
|
|
204
|
+
// relaunch, and it is best-effort: a broken config costs the panes, never the
|
|
205
|
+
// panel (loadPaneConfig is total — see src/pane-config.js).
|
|
206
|
+
const panes = opts.panes || loadPaneConfig().panes;
|
|
207
|
+
const viewCount = 1 + panes.length;
|
|
208
|
+
const view = ((Math.trunc(opts.view || 0) % viewCount) + viewCount) % viewCount;
|
|
209
|
+
if (view > 0) {
|
|
210
|
+
const pane = panes[view - 1];
|
|
211
|
+
// Guarded like the economy branch below: "a malformed file must cost a pane
|
|
212
|
+
// state, never the sidecar" is the contract's rule, and leaving it to rest
|
|
213
|
+
// on the renderer never throwing would make it one careless edit from false.
|
|
214
|
+
try {
|
|
215
|
+
const res = loadPaneBlob(pane.path, { now });
|
|
216
|
+
return clamp(renderPane(res, {
|
|
217
|
+
source: pane.source,
|
|
218
|
+
position: `${view + 1}/${viewCount}`,
|
|
219
|
+
width: typeof cols === 'number' && cols > 0 ? cols : 48,
|
|
220
|
+
// Rows the body may use: the pane's height less the chrome (title,
|
|
221
|
+
// basis, blank) and a line of breathing room. Without this the overflow
|
|
222
|
+
// collapse in renderPane is unreachable, and a long blob silently
|
|
223
|
+
// scrolls the pane — which is exactly what obligation 8 forbids.
|
|
224
|
+
maxRows: Math.max(1, (opts.rows || resolveRows() || 24) - 4),
|
|
225
|
+
}) + '\n');
|
|
226
|
+
} catch (e) {
|
|
227
|
+
const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
|
|
228
|
+
return clamp(dim('ccr · pane render error: ' + msg.slice(0, 120)) + '\n');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Capped, regular-files-only read: a fifo here would block this synchronous
|
|
232
|
+
// loop forever and an unbounded file can blank the panel (see src/safe-read.js).
|
|
233
|
+
const raw = readTextCapped(snapshot) || '';
|
|
160
234
|
if (!raw.trim()) return clamp(dim('ccr · waiting for the first status tick…') + '\n');
|
|
161
235
|
let state;
|
|
162
236
|
try { state = JSON.parse(raw); } catch { return clamp(dim('ccr · status unreadable') + '\n'); }
|
|
@@ -168,8 +242,15 @@ function composeFrame(stateDir, opts = {}) {
|
|
|
168
242
|
// strictly guarded so a different account is never mixed in.
|
|
169
243
|
const reconciled = { ...state, rate_limits: freshenAccountLimits(state.rate_limits, stateDir) };
|
|
170
244
|
out = renderEconomy(normalizeStatus(reconciled), { tick: Math.floor(now / 1000) % 2 === 0 });
|
|
245
|
+
// ccr's own view is position 1 of the cycle. Shown only when there is a
|
|
246
|
+
// cycle to be in — a lone economy view has no position worth naming.
|
|
247
|
+
if (viewCount > 1) out = out.replace(/\n/, dim(` 1/${viewCount}`) + '\n');
|
|
171
248
|
} catch (e) {
|
|
172
|
-
|
|
249
|
+
// Sanitize and bound the message: it is the one error surface that prints
|
|
250
|
+
// text ccr did not author, and an exception string can quote the input that
|
|
251
|
+
// caused it. Everything else here is a named state naming a path only.
|
|
252
|
+
const msg = stripControl(e && e instanceof Error ? e.message : String(e)) || 'unknown';
|
|
253
|
+
out = dim('ccr render error: ' + msg.slice(0, 120));
|
|
173
254
|
}
|
|
174
255
|
// Live tool/skills feed below the panel — best-effort; never break the panel.
|
|
175
256
|
// Its inner width tracks the pane so args truncate cleanly (the clamp below is
|
|
@@ -217,11 +298,47 @@ function resolveCols() {
|
|
|
217
298
|
return haveLive ? live : undefined;
|
|
218
299
|
}
|
|
219
300
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
301
|
+
/** The pane's height, for the row budget. Unknown (non-TTY) → undefined. */
|
|
302
|
+
function resolveRows() {
|
|
303
|
+
const live = process.stdout.rows;
|
|
304
|
+
return typeof live === 'number' && live > 0 ? live : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Which whole-pane view is showing. composeFrame takes it modulo the view
|
|
308
|
+
// count, so it never needs clamping here.
|
|
309
|
+
let currentView = 0;
|
|
310
|
+
// Advance-requests already applied. The host's key writes a counter (see
|
|
311
|
+
// src/cycle-view.js) and we consume the DIFFERENCE, so a burst of presses
|
|
312
|
+
// advances by the number pressed and none is lost between ticks.
|
|
313
|
+
/** @type {number|null} */
|
|
314
|
+
let seenRequests = null;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One tick: consume any pending advance-requests, then paint.
|
|
318
|
+
* The seams exist so a test can observe what reaches composeFrame — without
|
|
319
|
+
* them, "the view index actually reaches the frame" is unobservable, and both
|
|
320
|
+
* halves of the cycling wiring can be broken with the suite still green.
|
|
321
|
+
* @param {{ stateDir?: string, compose?: Function, paint?: Function }} [deps]
|
|
322
|
+
*/
|
|
323
|
+
function frame(deps = {}) {
|
|
324
|
+
const stateDir = deps.stateDir || STATE_DIR;
|
|
325
|
+
const compose = deps.compose || composeFrame;
|
|
326
|
+
const paint = deps.paint || draw;
|
|
327
|
+
const requests = readViewRequests(stateDir);
|
|
328
|
+
if (seenRequests == null) seenRequests = requests; // adopt on first tick
|
|
329
|
+
else if (requests !== seenRequests) {
|
|
330
|
+
currentView += Math.max(0, requests - seenRequests);
|
|
331
|
+
seenRequests = requests;
|
|
332
|
+
}
|
|
333
|
+
// Read columns and rows each tick so a live resize re-flows on the next frame.
|
|
334
|
+
paint(compose(stateDir, {
|
|
335
|
+
now: Date.now(), cols: resolveCols(), rows: resolveRows(), view: currentView,
|
|
336
|
+
}));
|
|
223
337
|
}
|
|
224
338
|
|
|
339
|
+
/** Test seam: reset the cycling state between scenarios (module-level by design). */
|
|
340
|
+
function __resetViewState() { currentView = 0; seenRequests = null; }
|
|
341
|
+
|
|
225
342
|
/**
|
|
226
343
|
* The live loop. With `exitOnEnd` (the Windows launcher passes `--exit-on-end`),
|
|
227
344
|
* the sidecar closes its own pane as soon as the `exited` sentinel appears — so a
|
|
@@ -274,7 +391,9 @@ function run(opts = {}) {
|
|
|
274
391
|
const RENDER_MS = 1000;
|
|
275
392
|
const pollMs = exitOnEnd ? 120 : RENDER_MS;
|
|
276
393
|
|
|
394
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
277
395
|
let id = null;
|
|
396
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
278
397
|
let endTimer = null;
|
|
279
398
|
let sinceRender = RENDER_MS; // render on the first loop
|
|
280
399
|
const teardown = (/** @type {boolean} */ clearHb) => {
|
|
@@ -304,6 +423,13 @@ function run(opts = {}) {
|
|
|
304
423
|
};
|
|
305
424
|
loop();
|
|
306
425
|
id = setIntervalFn(loop, pollMs);
|
|
426
|
+
// SIGUSR1 also advances the view, for anyone who can already signal this
|
|
427
|
+
// process (`kill -USR1 $(pgrep -f 'ccr.js sidecar')`). The KEY binding does
|
|
428
|
+
// not use it: routing a keypress through a pid read out of a writable file
|
|
429
|
+
// turned a cosmetic hotkey into an arbitrary-kill primitive, so the host
|
|
430
|
+
// writes a request file instead (see src/cycle-view.js). Cycling still never
|
|
431
|
+
// reaches stdin, which is the invariant that matters.
|
|
432
|
+
onSignal('SIGUSR1', () => { currentView += 1; tick(); });
|
|
307
433
|
onSignal('SIGINT', stop);
|
|
308
434
|
onSignal('SIGTERM', stop);
|
|
309
435
|
return stop;
|
|
@@ -313,4 +439,7 @@ function run(opts = {}) {
|
|
|
313
439
|
// session-switch reset and the ended/waiting/render states are the subtle
|
|
314
440
|
// parts); the live loop uses `run`. The heartbeat trio is exported for tests
|
|
315
441
|
// and for the VS Code launcher's `sidecarAlive` check.
|
|
316
|
-
module.exports = {
|
|
442
|
+
module.exports = {
|
|
443
|
+
run, updateFeed, composeFrame, heartbeatTick, clearHeartbeat, sidecarAlive,
|
|
444
|
+
frame, __resetViewState,
|
|
445
|
+
};
|
package/src/transcripts.js
CHANGED
|
@@ -87,7 +87,11 @@ function parseEvents(input) {
|
|
|
87
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
88
|
/** @type {{ ts: number|null, kind: 'tool'|'cmd', tool: string, arg: string }[]} */
|
|
89
89
|
const events = [];
|
|
90
|
-
|
|
90
|
+
// Null-prototype: keys are tool NAMES straight out of the transcript. On a
|
|
91
|
+
// plain object, `tools['constructor'] || 0` reads the inherited Object
|
|
92
|
+
// constructor (which then rendered as its native source in the feed header),
|
|
93
|
+
// and `tools['__proto__'] = n` performs a prototype write instead of counting.
|
|
94
|
+
const tools = /** @type {Record<string, number>} */ (Object.create(null));
|
|
91
95
|
const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
92
96
|
const files = new Set();
|
|
93
97
|
const models = new Set();
|
|
@@ -111,7 +115,7 @@ function parseEvents(input) {
|
|
|
111
115
|
if (meta.sessionId == null && o.sessionId) meta.sessionId = stripControl(String(o.sessionId));
|
|
112
116
|
if (meta.cwd == null && o.cwd) meta.cwd = stripControl(String(o.cwd));
|
|
113
117
|
if (meta.gitBranch == null && o.gitBranch) meta.gitBranch = stripControl(String(o.gitBranch));
|
|
114
|
-
if (meta.version == null && o.version) meta.version = o.version;
|
|
118
|
+
if (meta.version == null && o.version) meta.version = stripControl(o.version);
|
|
115
119
|
const ts = o.timestamp ? Date.parse(o.timestamp) : NaN;
|
|
116
120
|
const tsOk = Number.isFinite(ts) ? ts : null;
|
|
117
121
|
if (tsOk != null) {
|
|
@@ -137,7 +141,10 @@ function parseEvents(input) {
|
|
|
137
141
|
if (o.type === 'assistant') {
|
|
138
142
|
assistantTurns++;
|
|
139
143
|
const msg = o.message || {};
|
|
140
|
-
|
|
144
|
+
// Sanitize here, not at the renderer: parseEvents is the documented choke
|
|
145
|
+
// point "so every renderer is covered", and lastModel/models are consumed
|
|
146
|
+
// by src/render/resume.js outside the sidecar's graph.
|
|
147
|
+
if (msg.model) { const m = stripControl(msg.model); models.add(m); lastModel = m; }
|
|
141
148
|
const u = msg.usage;
|
|
142
149
|
if (u) {
|
|
143
150
|
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 };
|
|
@@ -223,36 +230,45 @@ const MAX_READ = 4 * 1024 * 1024; // bound one tick's allocation; large backlogs
|
|
|
223
230
|
* transcript is append-only; if it shrank (rotation/truncation) we restart at 0.
|
|
224
231
|
* Reads at most `maxRead` bytes per call (bounded allocation), so a very large
|
|
225
232
|
* transcript is consumed over several ticks rather than in one giant buffer.
|
|
226
|
-
* Returns the new byte offset (advanced only past whole lines)
|
|
233
|
+
* Returns the new byte offset (advanced only past whole lines), and `restarted`
|
|
234
|
+
* — true when the file had shrunk and the tail went back to 0. Callers that
|
|
235
|
+
* ACCUMULATE across calls must reset their totals when they see it, or they
|
|
236
|
+
* re-add everything the restart is about to replay.
|
|
227
237
|
* @param {string} file
|
|
228
238
|
* @param {number} [fromOffset]
|
|
229
239
|
* @param {number} [maxRead]
|
|
230
|
-
* @returns {{ offset: number, lines: string[] }}
|
|
240
|
+
* @returns {{ offset: number, lines: string[], restarted: boolean }}
|
|
231
241
|
*/
|
|
232
242
|
function readNewLines(file, fromOffset = 0, maxRead = MAX_READ) {
|
|
233
|
-
let st; try { st = fs.statSync(file); } catch { return { offset: fromOffset, lines: [] }; }
|
|
243
|
+
let st; try { st = fs.statSync(file); } catch { return { offset: fromOffset, lines: [], restarted: false }; }
|
|
234
244
|
let start = fromOffset;
|
|
235
|
-
|
|
245
|
+
const restarted = st.size < start; // truncated/rotated → restart
|
|
246
|
+
if (restarted) start = 0;
|
|
236
247
|
let len = st.size - start;
|
|
237
|
-
if (len <= 0) return { offset: st.size, lines: [] };
|
|
248
|
+
if (len <= 0) return { offset: st.size, lines: [], restarted };
|
|
238
249
|
const capped = len > maxRead; // more data than one window holds
|
|
239
250
|
if (capped) len = maxRead;
|
|
240
251
|
const fd = fs.openSync(file, 'r');
|
|
241
252
|
try {
|
|
242
253
|
const buf = Buffer.alloc(len);
|
|
243
|
-
fs.readSync(fd, buf, 0, len, start);
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
const read = fs.readSync(fd, buf, 0, len, start);
|
|
255
|
+
// Find the line break in the BYTES, and advance by bytes. Decoding first and
|
|
256
|
+
// re-encoding the kept prefix (what this used to do) is not a round trip:
|
|
257
|
+
// every invalid UTF-8 byte decodes to U+FFFD and re-encodes to THREE bytes,
|
|
258
|
+
// so the offset overshot the file — after which `st.size < start` reads as a
|
|
259
|
+
// truncation, the tail restarts at 0, and the whole transcript is re-ingested
|
|
260
|
+
// every tick, forever, with the stats inflating each time. One stray binary
|
|
261
|
+
// byte in a tool result was enough. Byte arithmetic has no such failure.
|
|
262
|
+
const lastNl = buf.lastIndexOf(0x0a, read - 1);
|
|
246
263
|
if (lastNl < 0) {
|
|
247
264
|
// No complete line in this window. If capped, the current line is longer
|
|
248
265
|
// than the cap — skip past the window to guarantee forward progress (the
|
|
249
266
|
// resulting partial line fails JSON.parse and is tolerated). Otherwise the
|
|
250
267
|
// last line just isn't finished yet; wait for more.
|
|
251
|
-
return capped ? { offset: start + len, lines: [] } : { offset: start, lines: [] };
|
|
268
|
+
return capped ? { offset: start + len, lines: [], restarted } : { offset: start, lines: [], restarted };
|
|
252
269
|
}
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
return { offset: consumed, lines: whole.split('\n').filter(Boolean) };
|
|
270
|
+
const text = buf.subarray(0, lastNl).toString('utf8');
|
|
271
|
+
return { offset: start + lastNl + 1, lines: text.split('\n').filter(Boolean), restarted };
|
|
256
272
|
} finally {
|
|
257
273
|
fs.closeSync(fd);
|
|
258
274
|
}
|