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/pane-blob.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/pane-blob.js — THE verifier. Between a blob file's bytes and any renderer
|
|
4
|
+
// sits exactly this function, and it returns either a validated v1 blob or one
|
|
5
|
+
// named failure. No renderer ever sees unvalidated input.
|
|
6
|
+
// Contract: docs/PANE-CONTRACT.md § The verifier.
|
|
7
|
+
//
|
|
8
|
+
// Three properties this file is built around, all of them load-bearing:
|
|
9
|
+
//
|
|
10
|
+
// TOTAL. Nothing here throws. The sidebar is ONE pane: an exception raised
|
|
11
|
+
// into the draw loop would take the burn-rate display down with it, so a
|
|
12
|
+
// malformed blob must cost a pane state and never the sidecar.
|
|
13
|
+
//
|
|
14
|
+
// WHITELIST-CONSTRUCT. Every returned object is built fresh from the fields
|
|
15
|
+
// v1 names. The parsed input is never spread, never Object.assign'd, never
|
|
16
|
+
// merged. That is the prototype-pollution path, and it is the one
|
|
17
|
+
// injection-style attack a JSON consumer in Node gets handed for free.
|
|
18
|
+
//
|
|
19
|
+
// TYPES CHECKED, NOT COERCED. A JSON file chooses its own value types, so
|
|
20
|
+
// `typeof` is the only thing that makes "this field is a string" true. (The
|
|
21
|
+
// sidecar learned this the hard way elsewhere: an array where a string was
|
|
22
|
+
// expected put raw terminal escapes on screen. See src/sanitize.js.)
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const { stripControl } = require('./sanitize');
|
|
26
|
+
|
|
27
|
+
const BLOB_VERSION = 1;
|
|
28
|
+
const MAX_BLOB_BYTES = 256 * 1024;
|
|
29
|
+
const MAX_ROWS = 256;
|
|
30
|
+
const MAX_FIELD_CHARS = 512;
|
|
31
|
+
const MAX_SPARK = 32;
|
|
32
|
+
|
|
33
|
+
/** The closed row-status enum. Anything else renders as `dark` (never green). */
|
|
34
|
+
const ROW_STATUSES = new Set(['ok', 'warn', 'alert', 'dark', 'off']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A display string: sanitized, then truncated. Order matters — validation
|
|
38
|
+
* checks shape, never bytes, so the strip is unconditional and comes after.
|
|
39
|
+
* @param {string} s
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
const display = (s) => {
|
|
43
|
+
const clean = String(stripControl(s) ?? '');
|
|
44
|
+
return clean.length > MAX_FIELD_CHARS ? clean.slice(0, MAX_FIELD_CHARS) : clean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isStr = (/** @type {any} */ v) => typeof v === 'string';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the blob file safely enough to name WHY it could not be read.
|
|
51
|
+
*
|
|
52
|
+
* This does not use readTextCapped: that collapses every failure to null, and
|
|
53
|
+
* the contract requires "cannot-read" (a chmod mistake, a fifo, a symlink) to
|
|
54
|
+
* be visibly distinct from "waiting" (the producer simply hasn't run yet).
|
|
55
|
+
* Conflating them would make a permissions bug look like patience.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} file
|
|
58
|
+
* @returns {{ ok: true, text: string, mtimeMs: number }
|
|
59
|
+
* | { ok: false, state: 'waiting' }
|
|
60
|
+
* | { ok: false, state: 'cannot-read', reason: string }
|
|
61
|
+
* | { ok: false, state: 'oversized' }}
|
|
62
|
+
*/
|
|
63
|
+
function readBlobFile(file) {
|
|
64
|
+
let st;
|
|
65
|
+
try {
|
|
66
|
+
// lstat, not stat: a symlink must be REFUSED rather than followed, and a
|
|
67
|
+
// fifo must be identified without opening it (opening one blocks forever).
|
|
68
|
+
st = fs.lstatSync(file);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
const code = e && /** @type {any} */ (e).code;
|
|
71
|
+
if (code === 'ENOENT') return { ok: false, state: 'waiting' };
|
|
72
|
+
return { ok: false, state: 'cannot-read', reason: code === 'EACCES' ? 'permission' : 'unavailable' };
|
|
73
|
+
}
|
|
74
|
+
if (st.isSymbolicLink()) return { ok: false, state: 'cannot-read', reason: 'symlink' };
|
|
75
|
+
if (st.isDirectory()) return { ok: false, state: 'cannot-read', reason: 'directory' };
|
|
76
|
+
if (!st.isFile()) return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
|
|
77
|
+
if (st.size > MAX_BLOB_BYTES) return { ok: false, state: 'oversized' };
|
|
78
|
+
|
|
79
|
+
// The lstat above is ADVISORY, not a guarantee: the path can be replaced
|
|
80
|
+
// between the check and the open, and a producer that writes atomically is
|
|
81
|
+
// renaming over this path constantly, so a swap looks like normal operation.
|
|
82
|
+
// The open itself must therefore be safe on its own terms:
|
|
83
|
+
// O_NOFOLLOW — refuse a symlink at open time, so "symlinks are refused"
|
|
84
|
+
// is enforced by the kernel rather than by a stale stat.
|
|
85
|
+
// O_NONBLOCK — a FIFO opened for reading blocks until a writer appears,
|
|
86
|
+
// which in this single-threaded draw loop means FOREVER: no
|
|
87
|
+
// render, no heartbeat, no recovery. With O_NONBLOCK the open
|
|
88
|
+
// returns immediately (ENXIO) instead of hanging.
|
|
89
|
+
// Both flags are absent on Windows; `|| 0` degrades to the old behaviour
|
|
90
|
+
// there, where neither fifos nor symlinks-without-privilege are a concern.
|
|
91
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
92
|
+
const O_NONBLOCK = fs.constants.O_NONBLOCK || 0;
|
|
93
|
+
let fd;
|
|
94
|
+
try {
|
|
95
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
const code = e && /** @type {any} */ (e).code;
|
|
98
|
+
if (code === 'ELOOP') return { ok: false, state: 'cannot-read', reason: 'symlink' };
|
|
99
|
+
if (code === 'ENXIO' || code === 'EWOULDBLOCK' || code === 'EAGAIN') {
|
|
100
|
+
return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
|
|
101
|
+
}
|
|
102
|
+
if (code === 'ENOENT') return { ok: false, state: 'waiting' };
|
|
103
|
+
return { ok: false, state: 'cannot-read', reason: code === 'EACCES' ? 'permission' : 'unavailable' };
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
// Re-stat from the descriptor: the file may have been replaced since the
|
|
107
|
+
// lstat, and this describes the bytes actually held open.
|
|
108
|
+
const fst = fs.fstatSync(fd);
|
|
109
|
+
if (!fst.isFile()) return { ok: false, state: 'cannot-read', reason: 'not a regular file' };
|
|
110
|
+
if (fst.size > MAX_BLOB_BYTES) return { ok: false, state: 'oversized' };
|
|
111
|
+
const buf = Buffer.alloc(Math.min(fst.size, MAX_BLOB_BYTES));
|
|
112
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
113
|
+
return { ok: true, text: buf.subarray(0, n).toString('utf8'), mtimeMs: fst.mtimeMs };
|
|
114
|
+
} catch {
|
|
115
|
+
return { ok: false, state: 'cannot-read', reason: 'unavailable' };
|
|
116
|
+
} finally {
|
|
117
|
+
try { fs.closeSync(fd); } catch { /* already closed */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validate one row. Returns null when the row itself is malformed (which makes
|
|
123
|
+
* the whole blob invalid — a row without a value is a shape violation, not a
|
|
124
|
+
* decoration). An UNRECOGNIZED status is not a violation: it renders as `dark`,
|
|
125
|
+
* because a producer naming a state ccr doesn't know must never come out green.
|
|
126
|
+
* @param {any} r
|
|
127
|
+
* @returns {{ label: string, value: string, status: string, detail: string|null, spark: number[]|null }|null}
|
|
128
|
+
*/
|
|
129
|
+
function validateRow(r) {
|
|
130
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
|
|
131
|
+
if (!isStr(r.label) || !isStr(r.value) || !isStr(r.status)) return null;
|
|
132
|
+
|
|
133
|
+
// Spark is DECORATION, so it degrades locally: a non-conforming spark drops
|
|
134
|
+
// the sparkline and keeps the row. Ruled 2026-08-02 — a decoration never
|
|
135
|
+
// costs more than itself, the same principle as clamping an overlong field.
|
|
136
|
+
/** @type {number[]|null} */
|
|
137
|
+
let spark = null;
|
|
138
|
+
if (Array.isArray(r.spark) && r.spark.length && r.spark.length <= MAX_SPARK
|
|
139
|
+
&& r.spark.every((/** @type {any} */ n) => typeof n === 'number' && Number.isFinite(n))) {
|
|
140
|
+
spark = r.spark.slice();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
label: display(r.label),
|
|
145
|
+
value: display(r.value),
|
|
146
|
+
status: ROW_STATUSES.has(r.status) ? r.status : 'dark',
|
|
147
|
+
detail: isStr(r.detail) ? display(r.detail) : null,
|
|
148
|
+
spark,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Turn a parsed blob into a validated v1 blob, or name the single failure.
|
|
154
|
+
* Split out from the file read so it is directly testable and so the render
|
|
155
|
+
* path can never reach a shape that skipped it.
|
|
156
|
+
* @param {any} input
|
|
157
|
+
* @returns {{ state: 'ok', blob: any } | { state: 'invalid' }
|
|
158
|
+
* | { state: 'unsupported', version: number|null } | { state: 'oversized-rows' }}
|
|
159
|
+
*/
|
|
160
|
+
function validateBlob(input) {
|
|
161
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { state: 'invalid' };
|
|
162
|
+
|
|
163
|
+
// Version first: an unrecognized `v` is its own named state, so it must be
|
|
164
|
+
// decided before any other field can call the blob "invalid".
|
|
165
|
+
if (!Number.isInteger(input.v)) return { state: 'invalid' };
|
|
166
|
+
if (input.v !== BLOB_VERSION) return { state: 'unsupported', version: input.v };
|
|
167
|
+
|
|
168
|
+
if (!isStr(input.tool) || !isStr(input.title) || !isStr(input.status)) return { state: 'invalid' };
|
|
169
|
+
if (input.status !== 'ok' && input.status !== 'broken') return { state: 'invalid' };
|
|
170
|
+
|
|
171
|
+
const basis = input.basis;
|
|
172
|
+
if (!basis || typeof basis !== 'object' || Array.isArray(basis)) return { state: 'invalid' };
|
|
173
|
+
if (!isStr(basis.label) || !isStr(basis.at)) return { state: 'invalid' };
|
|
174
|
+
|
|
175
|
+
// A broken blob must carry a non-empty message. Without one it would render
|
|
176
|
+
// as a failure with nothing to say, which is indistinguishable from a bug in
|
|
177
|
+
// ccr — so it is invalid rather than a silent half-render.
|
|
178
|
+
const broken = input.status === 'broken';
|
|
179
|
+
if (broken && !(isStr(input.message) && input.message.trim())) return { state: 'invalid' };
|
|
180
|
+
|
|
181
|
+
if (!Array.isArray(input.rows)) return { state: 'invalid' };
|
|
182
|
+
if (input.rows.length > MAX_ROWS) return { state: 'oversized-rows' };
|
|
183
|
+
|
|
184
|
+
/** @type {any[]} */
|
|
185
|
+
const rows = [];
|
|
186
|
+
// A broken blob's rows are IGNORED per the contract — not validated, not
|
|
187
|
+
// rendered. Validating them anyway would let a stray row turn a producer's
|
|
188
|
+
// honest failure report into "invalid", burying the message it exists to show.
|
|
189
|
+
if (!broken) {
|
|
190
|
+
for (const r of input.rows) {
|
|
191
|
+
const row = validateRow(r);
|
|
192
|
+
if (!row) return { state: 'invalid' };
|
|
193
|
+
rows.push(row);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
state: 'ok',
|
|
199
|
+
blob: {
|
|
200
|
+
v: BLOB_VERSION,
|
|
201
|
+
tool: display(input.tool),
|
|
202
|
+
title: display(input.title),
|
|
203
|
+
status: input.status,
|
|
204
|
+
basis: { label: display(basis.label), at: display(basis.at) },
|
|
205
|
+
message: isStr(input.message) ? display(input.message) : null,
|
|
206
|
+
rows,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The whole pipeline for one configured pane, once per tick.
|
|
213
|
+
* @param {string} file absolute path from config
|
|
214
|
+
* @param {{ now?: number }} [opts]
|
|
215
|
+
* @returns {{ state: string, blob?: any, version?: number|null, reason?: string, ageMs?: number }}
|
|
216
|
+
*/
|
|
217
|
+
function loadPaneBlob(file, opts = {}) {
|
|
218
|
+
const read = readBlobFile(file);
|
|
219
|
+
if (!read.ok) {
|
|
220
|
+
// `strict` is off in jsconfig.json, and without strictNullChecks TypeScript
|
|
221
|
+
// will not narrow this union by its boolean `ok` discriminant — the whole
|
|
222
|
+
// union survives into this branch. So name the failure shape once here
|
|
223
|
+
// instead of re-testing it at runtime: the runtime predicate stays `ok`,
|
|
224
|
+
// which is the property readBlobFile actually guarantees, and this replaces
|
|
225
|
+
// the `any` cast that was already covering the same gap for `reason`.
|
|
226
|
+
const fail = /** @type {{ ok: false, state: string, reason?: string }} */ (read);
|
|
227
|
+
return fail.state === 'cannot-read'
|
|
228
|
+
? { state: 'cannot-read', reason: fail.reason }
|
|
229
|
+
: { state: fail.state };
|
|
230
|
+
}
|
|
231
|
+
if (!read.text.trim()) return { state: 'waiting' };
|
|
232
|
+
|
|
233
|
+
let parsed;
|
|
234
|
+
try { parsed = JSON.parse(read.text); } catch { return { state: 'unreadable' }; }
|
|
235
|
+
|
|
236
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
237
|
+
const ageMs = Math.max(0, now - read.mtimeMs);
|
|
238
|
+
|
|
239
|
+
const v = validateBlob(parsed);
|
|
240
|
+
if (v.state === 'ok') return { state: 'ok', blob: v.blob, ageMs };
|
|
241
|
+
if (v.state === 'unsupported') return { state: 'unsupported', version: v.version, ageMs };
|
|
242
|
+
if (v.state === 'oversized-rows') return { state: 'oversized', ageMs };
|
|
243
|
+
return { state: 'invalid', ageMs };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
loadPaneBlob, validateBlob, readBlobFile,
|
|
248
|
+
BLOB_VERSION, MAX_BLOB_BYTES, MAX_ROWS, MAX_FIELD_CHARS, MAX_SPARK, ROW_STATUSES,
|
|
249
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/pane-config.js — where the list of pane blob paths comes from.
|
|
4
|
+
//
|
|
5
|
+
// RULED 2026-08-02 (the question left open since session e2994e0d):
|
|
6
|
+
//
|
|
7
|
+
// Location: $XDG_CONFIG_HOME/ccr/config.json, defaulting to
|
|
8
|
+
// ~/.config/ccr/config.json. Overridable by CCR_CONFIG for tests and for
|
|
9
|
+
// users who keep dotfiles elsewhere.
|
|
10
|
+
//
|
|
11
|
+
// NOT ccr's state dir (~/.ccr): that holds state ccr writes, and mixing
|
|
12
|
+
// user-authored configuration into a directory the program rewrites invites
|
|
13
|
+
// exactly one accident — clobbering it. NOT repo-local, ever: a config file
|
|
14
|
+
// discovered by walking up from the working directory would let anyone who
|
|
15
|
+
// can land a PR add a pane path to a teammate's sidecar. That is the same
|
|
16
|
+
// reasoning that removed configurable prompt files (see the contract's
|
|
17
|
+
// ruling log); config is the user's, and only the user's.
|
|
18
|
+
//
|
|
19
|
+
// Format: JSON. ccr already parses JSON at every ingestion point, so this
|
|
20
|
+
// adds no new parser and no new attack surface, and the verifier discipline
|
|
21
|
+
// (whitelist-construct, types checked not coerced, total function) applies
|
|
22
|
+
// here unchanged. A bespoke line format would need all of that written again.
|
|
23
|
+
//
|
|
24
|
+
// Shape (v1):
|
|
25
|
+
// { "panes": [ { "path": "~/code/app/.gherkin-trace/sidecar.json" } ] }
|
|
26
|
+
//
|
|
27
|
+
// Entries are OBJECTS rather than bare strings so a later optional key is an
|
|
28
|
+
// additive change rather than a format break. Order is significant (it is the
|
|
29
|
+
// cycle order). Two entries naming the same path are two panes, per the
|
|
30
|
+
// contract — this never de-duplicates.
|
|
31
|
+
//
|
|
32
|
+
// Config is trusted more than a blob (the user wrote it) but is still parsed
|
|
33
|
+
// defensively: a malformed config yields NO panes rather than throwing into
|
|
34
|
+
// the draw loop. The sidecar's own panel must survive a typo in a config file.
|
|
35
|
+
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const os = require('node:os');
|
|
38
|
+
const { readTextCapped } = require('./safe-read');
|
|
39
|
+
|
|
40
|
+
/** Config is small; this is a sanity bound, not a policy. */
|
|
41
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The config file path, without touching the filesystem.
|
|
45
|
+
* @param {Record<string, string|undefined>} [env]
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function configPath(env) {
|
|
49
|
+
const e = env || process.env;
|
|
50
|
+
if (e.CCR_CONFIG) return e.CCR_CONFIG;
|
|
51
|
+
const xdg = e.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
52
|
+
return path.join(xdg, 'ccr', 'config.json');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Expand a leading `~`, then resolve relative paths against `baseDir` — the
|
|
57
|
+
* config file's own directory, per the contract ("a relative path resolves
|
|
58
|
+
* against the config file's directory"). Resolving against the CWD instead
|
|
59
|
+
* would make a pane's identity depend on where the sidecar happened to start.
|
|
60
|
+
* @param {string} p
|
|
61
|
+
* @param {string} baseDir
|
|
62
|
+
* @param {string} home
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function resolvePanePath(p, baseDir, home) {
|
|
66
|
+
let out = p;
|
|
67
|
+
if (out === '~') out = home;
|
|
68
|
+
else if (out.startsWith('~/')) out = path.join(home, out.slice(2));
|
|
69
|
+
return path.resolve(baseDir, out);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load the configured pane list. Never throws: a missing, unreadable, or
|
|
74
|
+
* malformed config is "no panes configured", which renders as the plain
|
|
75
|
+
* economy sidebar exactly as before this feature existed.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
|
|
78
|
+
* @returns {{ panes: Array<{ path: string, source: string }>, configPath: string }}
|
|
79
|
+
* `path` is absolute and ready to read; `source` is the string the user wrote
|
|
80
|
+
* (what error states name, so the message matches their config, not ours).
|
|
81
|
+
*/
|
|
82
|
+
function loadPaneConfig(opts = {}) {
|
|
83
|
+
const env = opts.env || process.env;
|
|
84
|
+
const home = opts.home || os.homedir();
|
|
85
|
+
const file = configPath(env);
|
|
86
|
+
const empty = { panes: [], configPath: file };
|
|
87
|
+
|
|
88
|
+
const raw = readTextCapped(file, MAX_CONFIG_BYTES);
|
|
89
|
+
if (raw == null || !raw.trim()) return empty;
|
|
90
|
+
|
|
91
|
+
let parsed;
|
|
92
|
+
try { parsed = JSON.parse(raw); } catch { return empty; }
|
|
93
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.panes)) return empty;
|
|
94
|
+
|
|
95
|
+
const baseDir = path.dirname(file);
|
|
96
|
+
/** @type {Array<{ path: string, source: string }>} */
|
|
97
|
+
const panes = [];
|
|
98
|
+
for (const entry of parsed.panes) {
|
|
99
|
+
// Whitelist-construct: read the one field v1 names, off a fresh object.
|
|
100
|
+
// Never spread the entry — same rule the blob verifier follows.
|
|
101
|
+
if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') continue;
|
|
102
|
+
const source = entry.path;
|
|
103
|
+
if (!source.trim()) continue;
|
|
104
|
+
panes.push({ path: resolvePanePath(source, baseDir, home), source });
|
|
105
|
+
}
|
|
106
|
+
return { panes, configPath: file };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { loadPaneConfig, configPath, resolvePanePath, MAX_CONFIG_BYTES };
|
package/src/rate-limits.js
CHANGED
|
@@ -16,10 +16,20 @@ const { stripControl } = require('./sanitize');
|
|
|
16
16
|
const FIVE = 300, WEEK = 10080, MONTH = 43200;
|
|
17
17
|
|
|
18
18
|
// Known keys get exact labels/windows; everything else falls back to heuristics.
|
|
19
|
-
|
|
19
|
+
//
|
|
20
|
+
// NULL PROTOTYPE, deliberately: bucket names arrive from the status JSON, so a
|
|
21
|
+
// bucket named `toString`, `constructor`, or `__proto__` must MISS this table
|
|
22
|
+
// and fall through to the heuristics below. A plain object literal inherits
|
|
23
|
+
// those names from Object.prototype and hands back a function, whose `.label`
|
|
24
|
+
// is undefined — so labelFor would return undefined while its own contract
|
|
25
|
+
// promises a string. That only stayed harmless because every consumer happens
|
|
26
|
+
// to write `wd.label || wd.key`; a consumer trusting the declared type would
|
|
27
|
+
// break. Structural fix rather than a guard at each lookup.
|
|
28
|
+
/** @type {Record<string, { label: string, windowMinutes: number }>} */
|
|
29
|
+
const KNOWN = Object.assign(Object.create(null), {
|
|
20
30
|
five_hour: { label: '5h', windowMinutes: FIVE },
|
|
21
31
|
seven_day: { label: 'weekly', windowMinutes: WEEK },
|
|
22
|
-
};
|
|
32
|
+
});
|
|
23
33
|
|
|
24
34
|
/** @param {string} key → 'Sonnet' | 'Opus' | 'Haiku' | null */
|
|
25
35
|
function modelScope(key) {
|
package/src/render/economy.js
CHANGED
|
@@ -28,7 +28,9 @@ function wallRow(/** @type {any} */ row, /** @type {any} */ L, /** @type {boolea
|
|
|
28
28
|
const dotColor = row.resetsFirst ? green : bandColor[b];
|
|
29
29
|
const dot = (row.binding && b === 'imminent') ? flash(tick, '●') : dotColor('●');
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// Truncate as well as pad: labelW is capped, so a longer label must be cut to
|
|
32
|
+
// the column rather than pushing every sibling row out of alignment.
|
|
33
|
+
const labelTxt = (row.label.length > labelW ? row.label.slice(0, labelW - 1) + '…' : row.label).padEnd(labelW);
|
|
32
34
|
const label = row.binding ? bold(bandColor[b](labelTxt)) : dim(labelTxt);
|
|
33
35
|
// Time-to-exhaust carries no word: the sibling "resets …" is self-labelling,
|
|
34
36
|
// so a bare "~8h43m" reads unambiguously as remaining budget.
|
|
@@ -59,7 +61,13 @@ function renderEconomy(view, opts = {}) {
|
|
|
59
61
|
const out = [bold('economy') + dim(' ' + (view.model || '')), ''];
|
|
60
62
|
|
|
61
63
|
const { rows, next } = classifyWindows(view);
|
|
62
|
-
|
|
64
|
+
// Cap the label column. `labelW` multiplies: every row pads to it, so cost is
|
|
65
|
+
// rows × longest-label, and BOTH come from the snapshot's rate_limits keys. A
|
|
66
|
+
// planted file with many buckets and one very long key built a string large
|
|
67
|
+
// enough to throw RangeError and blank the panel — an amplifier, not a leak,
|
|
68
|
+
// but it costs the whole display. 18 columns fits every real bucket name.
|
|
69
|
+
const LABEL_MAX = 18;
|
|
70
|
+
const labelW = Math.min(LABEL_MAX, Math.max(8, ...rows.map((/** @type {any} */ r) => r.label.length)));
|
|
63
71
|
|
|
64
72
|
// HERO
|
|
65
73
|
if (!rows.length) {
|
|
@@ -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 };
|