claude-code-runrate 0.2.4 → 0.4.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 +134 -2
- package/bin/ccr.js +235 -22
- package/package.json +4 -2
- package/scripts/launch.sh +121 -16
- package/sidecar/ccr.tmux.conf +12 -4
- package/src/account-limits.js +21 -13
- package/src/burn.js +7 -3
- package/src/cycle-view.js +78 -0
- package/src/doctor.js +15 -2
- package/src/economy-model.js +3 -0
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +40 -9
- package/src/liveness.js +18 -1
- package/src/migrate.js +155 -0
- 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/git-pane.js +345 -0
- package/src/render/pane.js +186 -0
- package/src/render/shared.js +115 -11
- package/src/render/statusline.js +45 -4
- package/src/safe-read.js +82 -0
- package/src/sanitize.js +45 -4
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +154 -0
- package/src/sidecar.js +277 -23
- package/src/state-dir.js +44 -1
- package/src/transcripts.js +31 -15
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/render/pane.js — draw one external tool pane from a VALIDATED blob.
|
|
4
|
+
// Contract: docs/PANE-CONTRACT.md § ccr (consumer) obligations.
|
|
5
|
+
//
|
|
6
|
+
// This renderer only ever sees output from src/pane-blob.js, so it does no
|
|
7
|
+
// validation and no sanitizing of its own: every string reaching it has already
|
|
8
|
+
// been stripped and truncated at the choke point. It does clamp to the cell,
|
|
9
|
+
// which is layout, not safety.
|
|
10
|
+
//
|
|
11
|
+
// The honesty rules live here, and they are the reason the pane is worth
|
|
12
|
+
// looking at: `dark` renders as visibly not-green, `off` renders as present-but
|
|
13
|
+
// -disabled, a broken blob shows its producer's message instead of stale rows,
|
|
14
|
+
// hidden rows collapse into a line that inherits the WORST status they carried,
|
|
15
|
+
// and every pane — healthy or not — carries its tool, its basis, and its age.
|
|
16
|
+
|
|
17
|
+
const { dim, bold, green, red, yellow, cyan, clampVisible } = require('./shared');
|
|
18
|
+
const { stripControl } = require('../sanitize');
|
|
19
|
+
|
|
20
|
+
/** Per-status marker. `dark` must never be green and never blank; `off` is dim. */
|
|
21
|
+
const MARKERS = {
|
|
22
|
+
ok: () => green('●'),
|
|
23
|
+
warn: () => yellow('●'),
|
|
24
|
+
alert: () => red('●'),
|
|
25
|
+
dark: () => cyan('◌'), // hollow: "cannot tell", visibly not a light
|
|
26
|
+
off: () => dim('·'), // present, deliberately disabled
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Worst-first precedence for the overflow line (contract § in-pane overflow). */
|
|
30
|
+
const SEVERITY = ['alert', 'dark', 'warn', 'ok', 'off'];
|
|
31
|
+
|
|
32
|
+
const SPARK_GLYPHS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* File write age on the contract's unit ladder: Xs / Xm / Xh / Xd.
|
|
36
|
+
* @param {number} ms
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function writeAge(ms) {
|
|
40
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
41
|
+
if (s < 60) return `${s}s`;
|
|
42
|
+
const m = Math.floor(s / 60);
|
|
43
|
+
if (m < 60) return `${m}m`;
|
|
44
|
+
const h = Math.floor(m / 60);
|
|
45
|
+
if (h < 24) return `${h}h`;
|
|
46
|
+
return `${Math.floor(h / 24)}d`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a row's spark to its OWN min-max (never a cross-row or cross-run
|
|
51
|
+
* scale — that would make one row's shape depend on another's data).
|
|
52
|
+
* @param {number[]} spark
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function sparkline(spark) {
|
|
56
|
+
const lo = Math.min(...spark);
|
|
57
|
+
const hi = Math.max(...spark);
|
|
58
|
+
const span = hi - lo;
|
|
59
|
+
return spark.map((n) => {
|
|
60
|
+
// A flat series has no shape to show; put it on the floor rather than
|
|
61
|
+
// inventing a peak by dividing by zero.
|
|
62
|
+
const idx = span === 0 ? 0 : Math.round(((n - lo) / span) * (SPARK_GLYPHS.length - 1));
|
|
63
|
+
return SPARK_GLYPHS[Math.max(0, Math.min(SPARK_GLYPHS.length - 1, idx))];
|
|
64
|
+
}).join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {string} status */
|
|
68
|
+
const marker = (status) => (MARKERS[/** @type {keyof MARKERS} */ (status)] || MARKERS.dark)();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The chrome every pane carries, in every state. `tool` and `basis` are what
|
|
72
|
+
* make a claim attributable; the age is what makes it dateable. A pane missing
|
|
73
|
+
* any of them manufactures currency, which is why they are not optional even on
|
|
74
|
+
* the error states.
|
|
75
|
+
* @param {{ title?: string, tool?: string, basis?: any, ageMs?: number, position?: string }} p
|
|
76
|
+
* @returns {string[]}
|
|
77
|
+
*/
|
|
78
|
+
function chromeLines(p) {
|
|
79
|
+
const head = bold(p.title || 'pane') + (p.tool ? dim(' ' + p.tool) : '')
|
|
80
|
+
+ (p.position ? dim(' ' + p.position) : '');
|
|
81
|
+
const lines = [head];
|
|
82
|
+
const bits = [];
|
|
83
|
+
// basis.at is OPAQUE — displayed verbatim, never parsed. Currency comes from
|
|
84
|
+
// the file's mtime below, not from anything the producer wrote.
|
|
85
|
+
if (p.basis && p.basis.label) bits.push(p.basis.label);
|
|
86
|
+
if (p.basis && p.basis.at) bits.push(p.basis.at);
|
|
87
|
+
if (p.ageMs != null) bits.push(`blob written ${writeAge(p.ageMs)} ago`);
|
|
88
|
+
if (bits.length) lines.push(' ' + dim(bits.join(' · ')));
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Render one row: marker, label, value, then optional spark and detail.
|
|
94
|
+
* @param {any} row
|
|
95
|
+
* @param {number} labelW
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function rowLine(row, labelW) {
|
|
99
|
+
// Cut by CODE POINT: slicing UTF-16 units splits an astral character in half
|
|
100
|
+
// and emits a lone surrogate — the same bug clampVisible documents fixing,
|
|
101
|
+
// and this text is untrusted blob content.
|
|
102
|
+
const cps = [...row.label];
|
|
103
|
+
const label = cps.length > labelW ? cps.slice(0, labelW - 1).join('') + '…' : row.label;
|
|
104
|
+
const body = ' ' + marker(row.status) + ' ' + label.padEnd(labelW) + ' '
|
|
105
|
+
+ (row.status === 'off' ? dim(row.value) : row.value);
|
|
106
|
+
const spark = row.spark ? ' ' + cyan(sparkline(row.spark)) : '';
|
|
107
|
+
const detail = row.detail ? dim(' ' + row.detail) : '';
|
|
108
|
+
return body + spark + detail;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render a validated pane blob, or a named non-healthy state, as a whole pane.
|
|
113
|
+
*
|
|
114
|
+
* Error states name the configured PATH and nothing else — never file bytes,
|
|
115
|
+
* never a parser message, because a parser message quotes the input that caused
|
|
116
|
+
* it and the input is exactly what must not reach the terminal.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ state: string, blob?: any, version?: number|null, reason?: string, ageMs?: number }} res
|
|
119
|
+
* the verifier's result for this pane
|
|
120
|
+
* @param {{ source: string, position?: string, width?: number, maxRows?: number }} opts
|
|
121
|
+
* `source` is the path AS THE USER WROTE IT in config — error states quote
|
|
122
|
+
* their config, not ccr's resolution of it.
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
function renderPane(res, opts) {
|
|
126
|
+
const width = opts.width && opts.width > 0 ? opts.width : 48;
|
|
127
|
+
const src = opts.source;
|
|
128
|
+
/** @type {string[]} */
|
|
129
|
+
let lines;
|
|
130
|
+
|
|
131
|
+
if (res.state === 'ok' && res.blob) {
|
|
132
|
+
const b = res.blob;
|
|
133
|
+
lines = chromeLines({ title: b.title, tool: b.tool, basis: b.basis, ageMs: res.ageMs, position: opts.position });
|
|
134
|
+
lines.push('');
|
|
135
|
+
|
|
136
|
+
if (b.status === 'broken') {
|
|
137
|
+
// Confession, not stale health: the message, prominently, with the chrome.
|
|
138
|
+
// Rows are ignored — showing them would present data the producer has just
|
|
139
|
+
// told us it could not stand behind.
|
|
140
|
+
lines.push(' ' + red(bold('broken')));
|
|
141
|
+
lines.push(' ' + (b.message || ''));
|
|
142
|
+
} else if (!b.rows.length) {
|
|
143
|
+
lines.push(' ' + dim('no rows reported'));
|
|
144
|
+
} else {
|
|
145
|
+
const labelW = Math.min(20, Math.max(6, ...b.rows.map((/** @type {any} */ r) => r.label.length)));
|
|
146
|
+
// Reserve a line for the overflow notice only when one is actually needed.
|
|
147
|
+
const budget = opts.maxRows && opts.maxRows > 0 ? opts.maxRows : b.rows.length;
|
|
148
|
+
const overflow = b.rows.length > budget;
|
|
149
|
+
const shownCount = overflow ? Math.max(0, budget - 1) : b.rows.length;
|
|
150
|
+
for (const r of b.rows.slice(0, shownCount)) lines.push(rowLine(r, labelW));
|
|
151
|
+
if (overflow) {
|
|
152
|
+
const hidden = b.rows.slice(shownCount);
|
|
153
|
+
// The collapsed line inherits the WORST hidden status, so a hidden
|
|
154
|
+
// `dark` row still reads as darkness rather than vanishing into a count.
|
|
155
|
+
const worst = SEVERITY.find((s) => hidden.some((/** @type {any} */ r) => r.status === s)) || 'off';
|
|
156
|
+
lines.push(' ' + marker(worst) + ' ' + dim(`+${hidden.length} more`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
/** @type {Record<string, string>} */
|
|
161
|
+
const named = {
|
|
162
|
+
waiting: 'waiting for first blob',
|
|
163
|
+
unreadable: 'blob unreadable',
|
|
164
|
+
invalid: 'blob invalid',
|
|
165
|
+
oversized: 'blob oversized',
|
|
166
|
+
'cannot-read': `cannot read blob${res.reason ? ' (' + res.reason + ')' : ''}`,
|
|
167
|
+
unsupported: `unsupported blob version ${res.version == null ? '?' : res.version}`,
|
|
168
|
+
};
|
|
169
|
+
// Age chrome belongs on the error states too. A pane stuck on `invalid` for
|
|
170
|
+
// three days must not look like one that broke a second ago — that is the
|
|
171
|
+
// "manufactures currency" failure obligation 3 names, and dropping the age
|
|
172
|
+
// here contradicted this file's own docstring. `waiting`/`cannot-read` have
|
|
173
|
+
// no readable file and so legitimately have no age.
|
|
174
|
+
lines = chromeLines({ title: 'pane', position: opts.position, ageMs: res.ageMs });
|
|
175
|
+
lines.push('');
|
|
176
|
+
lines.push(' ' + (res.state === 'waiting' ? dim(named.waiting) : yellow(named[res.state] || 'blob unavailable')));
|
|
177
|
+
// The configured path is USER-authored, not blob-authored — but it is still
|
|
178
|
+
// text from a file on disk, and a config carrying an escape sequence would
|
|
179
|
+
// otherwise put it straight on the terminal from six different states.
|
|
180
|
+
lines.push(' ' + dim(String(stripControl(src) ?? '')));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return lines.map((l) => clampVisible(l, width)).join('\n');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { renderPane, writeAge, sparkline, SEVERITY, MARKERS };
|
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
|
}
|
|
@@ -84,4 +140,52 @@ function fmtReset(/** @type {number|null} */ min) {
|
|
|
84
140
|
return `${m}m`;
|
|
85
141
|
}
|
|
86
142
|
|
|
87
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Terminal columns a PLAIN string occupies — the same accounting `clampVisible`
|
|
145
|
+
* does, exposed for the callers that must budget space before they build a line
|
|
146
|
+
* rather than clamp one afterwards. No SGR handling: the strings measured here
|
|
147
|
+
* are display text before any colour is applied.
|
|
148
|
+
* @param {string} s
|
|
149
|
+
* @returns {number}
|
|
150
|
+
*/
|
|
151
|
+
function visibleWidth(s) {
|
|
152
|
+
let w = 0;
|
|
153
|
+
for (const ch of s) w += charWidth(/** @type {number} */ (ch.codePointAt(0)));
|
|
154
|
+
return w;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fit plain text into `cols` columns, marking the cut with an ellipsis so a
|
|
159
|
+
* shortened value never reads as a complete one. Cutting is by code point and
|
|
160
|
+
* by COLUMN (a wide glyph costs two), and the ellipsis is inside the budget —
|
|
161
|
+
* the result is never wider than `cols`.
|
|
162
|
+
*
|
|
163
|
+
* Distinct from `clampVisible`, which is the hard safety net applied to a
|
|
164
|
+
* finished line: this one is composition, so the caller can lay out around a
|
|
165
|
+
* value it knows will fit. Returns '' for a non-positive budget.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} s
|
|
168
|
+
* @param {number} cols
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
function ellipsize(s, cols) {
|
|
172
|
+
if (!(typeof cols === 'number' && cols > 0)) return '';
|
|
173
|
+
if (visibleWidth(s) <= cols) return s;
|
|
174
|
+
// One column is spent on the ellipsis, so the text gets cols-1. At cols === 1
|
|
175
|
+
// that leaves nothing, and the ellipsis alone is the honest answer.
|
|
176
|
+
const budget = cols - 1;
|
|
177
|
+
let out = '';
|
|
178
|
+
let w = 0;
|
|
179
|
+
for (const ch of s) {
|
|
180
|
+
const cw = charWidth(/** @type {number} */ (ch.codePointAt(0)));
|
|
181
|
+
if (w + cw > budget) break;
|
|
182
|
+
out += ch;
|
|
183
|
+
w += cw;
|
|
184
|
+
}
|
|
185
|
+
return out + '…';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
e, dim, bold, green, red, yellow, cyan, flash, pctColor, bar, clampVisible, tok, fmtMins, fmtReset,
|
|
190
|
+
charWidth, visibleWidth, ellipsize,
|
|
191
|
+
};
|
package/src/render/statusline.js
CHANGED
|
@@ -6,14 +6,50 @@
|
|
|
6
6
|
const { windowEstimate, binding } = require('../burn');
|
|
7
7
|
const { fmtMins } = require('./shared');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic middle ellipsis: the same input shortens the same way at
|
|
11
|
+
* every glance — the anti-marquee rule. (Animation was rejected outright:
|
|
12
|
+
* Claude re-renders this line per turn, not on a clock, so anything animated
|
|
13
|
+
* freezes exactly when the user is idle and orienting.)
|
|
14
|
+
* @param {string} s @param {number} max
|
|
15
|
+
*/
|
|
16
|
+
function midEllipsis(s, max) {
|
|
17
|
+
if (s.length <= max) return s;
|
|
18
|
+
const head = Math.ceil((max - 1) / 2);
|
|
19
|
+
const tail = max - 1 - head;
|
|
20
|
+
return s.slice(0, head) + '…' + (tail > 0 ? s.slice(-tail) : '');
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
/**
|
|
10
24
|
* @param {any} view normalized economy data
|
|
11
|
-
* @
|
|
25
|
+
* @param {{ name?: string|null, location?: string|null, cols?: number }} [identity]
|
|
26
|
+
* The instance identity, shown FIRST so terminal end-truncation eats meters,
|
|
27
|
+
* never orientation. The location half is LIVE (follows a mid-session cd)
|
|
28
|
+
* and appears only when it differs from the name — "notes @ notes" says
|
|
29
|
+
* nothing twice. `cols` bounds the identity: the location stays whole, the
|
|
30
|
+
* name takes the ellipsis.
|
|
31
|
+
* @returns {string} one line, e.g. "a-is-awesome @ ccr · Opus 4.8 · 5h ~2h · ctx 15% · $2.50"
|
|
12
32
|
*/
|
|
13
|
-
function renderStatusline(view) {
|
|
33
|
+
function renderStatusline(view, identity = {}) {
|
|
14
34
|
const parts = [];
|
|
35
|
+
const name = identity.name || null;
|
|
36
|
+
const loc = identity.location || null;
|
|
37
|
+
if (name) {
|
|
38
|
+
const withLoc = loc && loc !== name;
|
|
39
|
+
let shownName = name;
|
|
40
|
+
if (identity.cols && withLoc) {
|
|
41
|
+
const budget = identity.cols - (' @ '.length + (loc ? loc.length : 0));
|
|
42
|
+
if (name.length > budget) shownName = midEllipsis(name, Math.max(5, budget));
|
|
43
|
+
} else if (identity.cols && name.length > identity.cols) {
|
|
44
|
+
shownName = midEllipsis(name, Math.max(5, identity.cols));
|
|
45
|
+
}
|
|
46
|
+
parts.push(withLoc ? `${shownName} @ ${loc}` : shownName);
|
|
47
|
+
}
|
|
15
48
|
if (view.model) parts.push(view.model);
|
|
16
49
|
|
|
50
|
+
// Annotated because Array.isArray does not narrow an `any`: without this the
|
|
51
|
+
// whole chain below decays to `any` and the row callbacks lose their types.
|
|
52
|
+
/** @type {any[]} */
|
|
17
53
|
const windows = Array.isArray(view.windows) ? view.windows : [];
|
|
18
54
|
if (!windows.length) {
|
|
19
55
|
parts.push('API · no limits');
|
|
@@ -30,8 +66,13 @@ function renderStatusline(view) {
|
|
|
30
66
|
const b = binding(live);
|
|
31
67
|
if (b && b.minutesLeft != null) {
|
|
32
68
|
const row = rows.find((r) => r.key === b.window);
|
|
33
|
-
|
|
34
|
-
|
|
69
|
+
if (b.minutesLeft <= 30) {
|
|
70
|
+
// "About to hit the wall" outranks orientation for the next thing the
|
|
71
|
+
// user types: the warning jumps ahead of everything, identity included.
|
|
72
|
+
parts.unshift(`⚠ ${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
|
|
73
|
+
} else {
|
|
74
|
+
parts.push(`${row ? row.label : b.window} ~${fmtMins(b.minutesLeft)}`);
|
|
75
|
+
}
|
|
35
76
|
} else {
|
|
36
77
|
parts.push('within limits');
|
|
37
78
|
}
|
package/src/safe-read.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
const buf = readBytesCapped(file, maxBytes);
|
|
48
|
+
return buf === null ? null : buf.toString('utf8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The binary form of the same rule, for files that are not text: the git pane
|
|
53
|
+
* reads `.git/index`, object files and packfiles, none of which survive a
|
|
54
|
+
* UTF-8 round trip. Identical guards — lstat first (regular file only, so a
|
|
55
|
+
* fifo never blocks and a symlink is never followed), size re-checked from the
|
|
56
|
+
* open descriptor, capped read, never throws.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} file
|
|
59
|
+
* @param {number} [maxBytes]
|
|
60
|
+
* @returns {Buffer|null}
|
|
61
|
+
*/
|
|
62
|
+
function readBytesCapped(file, maxBytes = DEFAULT_MAX_BYTES) {
|
|
63
|
+
let st;
|
|
64
|
+
try { st = fs.lstatSync(file); } catch { return null; }
|
|
65
|
+
if (!st.isFile() || st.size > maxBytes) return null;
|
|
66
|
+
|
|
67
|
+
let fd;
|
|
68
|
+
try { fd = fs.openSync(file, 'r'); } catch { return null; }
|
|
69
|
+
try {
|
|
70
|
+
const fst = fs.fstatSync(fd);
|
|
71
|
+
if (!fst.isFile() || fst.size > maxBytes) return null;
|
|
72
|
+
const buf = Buffer.alloc(Math.min(fst.size, maxBytes));
|
|
73
|
+
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
74
|
+
return buf.subarray(0, read);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
} finally {
|
|
78
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { readTextCapped, readBytesCapped, 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 };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/session-log.js — the forensic join key and history retention
|
|
4
|
+
// (features/instance-persistence.feature).
|
|
5
|
+
//
|
|
6
|
+
// One file per session, `session-<sid>.jsonl`, at the container's top level
|
|
7
|
+
// beside `burnlog-<sid>.jsonl` — same key, same lifecycle. TWO-PHASE, ruled
|
|
8
|
+
// on the owner's words ("even partial information allows forensic
|
|
9
|
+
// reconstruction of what happened"): the open record is written the moment
|
|
10
|
+
// the session id first exists — deaths are exactly when writes cannot be
|
|
11
|
+
// trusted to happen — and finalized by whoever sees the death: the exiting
|
|
12
|
+
// process (`ended`) if polite, the sweep (`swept`, stamped with the last
|
|
13
|
+
// heartbeat's mtime — the honest "ended around here") if not. A `swept`
|
|
14
|
+
// marker is itself forensic signal: this session died badly.
|
|
15
|
+
//
|
|
16
|
+
// The join key gets its OWN file, never a line inside the burnlog: the
|
|
17
|
+
// burnlog's size cap halves that file by DROPPING THE HEAD
|
|
18
|
+
// (src/instrument.js capFile), which would silently destroy a head-of-file
|
|
19
|
+
// key at 2MB.
|
|
20
|
+
//
|
|
21
|
+
// RETENTION, ruled shape-independent: content survives 30 full days after
|
|
22
|
+
// its session ends and is gone at 31, counted from last write — and with
|
|
23
|
+
// per-session files, last write IS death (the finalize marker), so file-age
|
|
24
|
+
// pruning needs no date parsing.
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const RETAIN_DAYS = 31;
|
|
30
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** @param {string} sid */
|
|
33
|
+
const clean = (sid) => String(sid || '').replace(/[^A-Za-z0-9_-]/g, '');
|
|
34
|
+
|
|
35
|
+
/** @param {string} home @param {string} sid */
|
|
36
|
+
function logFile(home, sid) {
|
|
37
|
+
return path.join(home, '.ccr', `session-${clean(sid)}.jsonl`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Phase one: the open record, written once, at the first status capture.
|
|
42
|
+
* @param {string} home
|
|
43
|
+
* @param {string} sid
|
|
44
|
+
* @param {{ name?: string|null, profile?: string|null, launch_cwd?: string|null, now?: number }} fields
|
|
45
|
+
*/
|
|
46
|
+
function openEntry(home, sid, fields = {}) {
|
|
47
|
+
if (!clean(sid)) return;
|
|
48
|
+
const file = logFile(home, sid);
|
|
49
|
+
try {
|
|
50
|
+
if (fs.existsSync(file)) return;
|
|
51
|
+
const rec = {
|
|
52
|
+
session_id: clean(sid),
|
|
53
|
+
name: fields.name || null,
|
|
54
|
+
profile: fields.profile || null,
|
|
55
|
+
launch_cwd: fields.launch_cwd || null,
|
|
56
|
+
started: fields.now != null ? fields.now : Date.now(),
|
|
57
|
+
};
|
|
58
|
+
fs.writeFileSync(file, JSON.stringify(rec) + '\n', { mode: 0o600 });
|
|
59
|
+
} catch { /* best effort — forensics must never break the status line */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Phase two: whoever sees the death appends the marker.
|
|
64
|
+
* @param {string} home
|
|
65
|
+
* @param {string} sid
|
|
66
|
+
* @param {{ ended?: number, swept?: number }} marker
|
|
67
|
+
*/
|
|
68
|
+
function finalize(home, sid, marker) {
|
|
69
|
+
if (!clean(sid)) return;
|
|
70
|
+
const file = logFile(home, sid);
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.existsSync(file)) return; // died before the first tick — nothing to finalize
|
|
73
|
+
fs.appendFileSync(file, JSON.stringify(marker) + '\n');
|
|
74
|
+
} catch { /* best effort */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Finalize on behalf of an instance dir about to be deleted: the dir's own
|
|
79
|
+
* captured status is what still knows the session id.
|
|
80
|
+
* @param {string} home
|
|
81
|
+
* @param {string} dir the instance's state dir
|
|
82
|
+
* @param {'ended'|'swept'} how
|
|
83
|
+
* @param {number} [at]
|
|
84
|
+
*/
|
|
85
|
+
function finalizeFromDir(home, dir, how, at) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = fs.readFileSync(path.join(dir, 'last-status.json'), 'utf8');
|
|
88
|
+
const sid = JSON.parse(raw).session_id;
|
|
89
|
+
if (!sid) return;
|
|
90
|
+
finalize(home, sid, { [how]: at != null ? at : Date.now() });
|
|
91
|
+
} catch { /* no capture — the accepted gap: nothing to join, nothing to debug */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The 31-day boundary: history is kept through 30 full days after its
|
|
96
|
+
* session's end and gone at 31 — burnlogs and session logs alike, whole
|
|
97
|
+
* files, by last-write mtime.
|
|
98
|
+
* @param {string} home
|
|
99
|
+
* @param {{ now?: number }} [opts]
|
|
100
|
+
*/
|
|
101
|
+
function pruneHistory(home, opts = {}) {
|
|
102
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
103
|
+
const root = path.join(home, '.ccr');
|
|
104
|
+
let names; try { names = fs.readdirSync(root); } catch { return; }
|
|
105
|
+
for (const n of names) {
|
|
106
|
+
if (!/^(burnlog|session)-[A-Za-z0-9_-]+\.jsonl$/.test(n)) continue;
|
|
107
|
+
const p = path.join(root, n);
|
|
108
|
+
try {
|
|
109
|
+
const st = fs.lstatSync(p);
|
|
110
|
+
if (!st.isFile()) continue;
|
|
111
|
+
if (now - st.mtimeMs >= RETAIN_DAYS * DAY_MS) fs.rmSync(p, { force: true });
|
|
112
|
+
} catch { /* best effort */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { openEntry, finalize, finalizeFromDir, pruneHistory, logFile, RETAIN_DAYS };
|