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
package/src/launch-win.js
CHANGED
|
@@ -82,11 +82,15 @@ function resolveProfileState(profile, opts = {}) {
|
|
|
82
82
|
const env = opts.env || process.env;
|
|
83
83
|
const home = opts.home || os.homedir();
|
|
84
84
|
|
|
85
|
+
// The stateDir fallbacks are reachable only when called with neither a slot's
|
|
86
|
+
// env nor the user's own override — never from the launchers, which refuse an
|
|
87
|
+
// exhausted allocation before this. They point at slot 1's member dir so no
|
|
88
|
+
// path here can ever name the container itself as a state dir.
|
|
85
89
|
if (profile) {
|
|
86
90
|
return {
|
|
87
91
|
ccCmd: `ccs ${profile}`,
|
|
88
|
-
session: env.CCR_SESSION ||
|
|
89
|
-
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr',
|
|
92
|
+
session: env.CCR_SESSION || 'ccr',
|
|
93
|
+
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
|
|
90
94
|
instanceDir: path.join(home, '.ccs', 'instances', profile),
|
|
91
95
|
usesCcs: true,
|
|
92
96
|
};
|
|
@@ -94,7 +98,7 @@ function resolveProfileState(profile, opts = {}) {
|
|
|
94
98
|
return {
|
|
95
99
|
ccCmd: env.CC_BIN || 'claude',
|
|
96
100
|
session: env.CCR_SESSION || 'ccr',
|
|
97
|
-
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr'),
|
|
101
|
+
stateDir: env.CCR_STATE_DIR || path.join(home, '.ccr', 'instances', '1'),
|
|
98
102
|
instanceDir: null,
|
|
99
103
|
usesCcs: false,
|
|
100
104
|
};
|
|
@@ -179,7 +183,7 @@ function sidecarCols(termCols, fracNum, splitFlag) {
|
|
|
179
183
|
*
|
|
180
184
|
* @param {{ ccCmd: string, settingsFile: string, stateDir: string,
|
|
181
185
|
* node: string, ccrJs: string, sidebarPct?: number, sidebarSide?: string,
|
|
182
|
-
* termCols?: number }} o
|
|
186
|
+
* termCols?: number, title?: string }} o
|
|
183
187
|
* @returns {string[]}
|
|
184
188
|
*/
|
|
185
189
|
function buildWtArgs(o) {
|
|
@@ -200,6 +204,9 @@ function buildWtArgs(o) {
|
|
|
200
204
|
}
|
|
201
205
|
const frac = sidebarFraction(o.sidebarPct);
|
|
202
206
|
const splitFlag = sidebarSplitFlag(o.sidebarSide);
|
|
207
|
+
// The tab's ADDRESS ("[profile / ]name") when the instance has one — both
|
|
208
|
+
// halves allow-listed, so it is wt-arg-safe by construction.
|
|
209
|
+
const title = o.title || 'Claude';
|
|
203
210
|
const exited = path.win32.join(stateDir, 'exited');
|
|
204
211
|
|
|
205
212
|
// After Claude exits we drop the sentinel + clean the settings file, then idle
|
|
@@ -224,7 +231,7 @@ function buildWtArgs(o) {
|
|
|
224
231
|
const pane1 = paneCommand(stateDir, sidecarBody);
|
|
225
232
|
|
|
226
233
|
return [
|
|
227
|
-
'-w', '0', 'new-tab', '--title',
|
|
234
|
+
'-w', '0', 'new-tab', '--title', title, 'cmd', '/c', pane0,
|
|
228
235
|
';',
|
|
229
236
|
'split-pane', splitFlag, '-s', frac, 'cmd', '/c', pane1,
|
|
230
237
|
];
|
|
@@ -255,7 +262,8 @@ function defaultWhere(name) {
|
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
const inject = require('./settings-inject');
|
|
258
|
-
const
|
|
265
|
+
const slots = require('./instance-slot');
|
|
266
|
+
const { ensureSecureDir, recordLaunchDir } = require('./state-dir');
|
|
259
267
|
|
|
260
268
|
/**
|
|
261
269
|
* Fill in real-environment implementations for anything the caller didn't
|
|
@@ -283,6 +291,11 @@ function withDefaults(deps) {
|
|
|
283
291
|
existsDir: deps.existsDir || defaultExistsDir,
|
|
284
292
|
listDir: deps.listDir || defaultListDir,
|
|
285
293
|
ensureDir: deps.ensureDir || ensureSecureDir,
|
|
294
|
+
recordLaunchDir: deps.recordLaunchDir || recordLaunchDir,
|
|
295
|
+
allocateSlot: deps.allocateSlot || ((o) => slots.allocateSlot(o)),
|
|
296
|
+
prepareInstance: deps.prepareInstance
|
|
297
|
+
|| ((/** @type {{slot:number,stateDir:string}} */ s, /** @type {any} */ o) =>
|
|
298
|
+
require('./instance-name').prepareInstance(s, { ...o, home: deps.home || os.homedir() })),
|
|
286
299
|
removeExited: deps.removeExited || defaultRemoveExited,
|
|
287
300
|
writeSettings: deps.writeSettings || ((s) => inject.writeSettingsFile(s)),
|
|
288
301
|
cleanup: deps.cleanup || ((f) => inject.cleanupSettingsFile(f)),
|
|
@@ -347,9 +360,10 @@ function fallbackNoWt(d) {
|
|
|
347
360
|
*
|
|
348
361
|
* @param {string} [profile]
|
|
349
362
|
* @param {Partial<Deps>} [deps]
|
|
363
|
+
* @param {{ name?: string|null }} [opts] explicit --name, already validated
|
|
350
364
|
* @returns {number}
|
|
351
365
|
*/
|
|
352
|
-
function run(profile, deps = {}) {
|
|
366
|
+
function run(profile, deps = {}, opts = {}) {
|
|
353
367
|
const d = withDefaults(deps);
|
|
354
368
|
|
|
355
369
|
// 1. Validate the profile (it lands in paths and a spawned command).
|
|
@@ -362,8 +376,18 @@ function run(profile, deps = {}) {
|
|
|
362
376
|
const wt = d.findWt();
|
|
363
377
|
if (!wt) return fallbackNoWt(d);
|
|
364
378
|
|
|
365
|
-
// 3. Resolve profile state + required binaries.
|
|
366
|
-
|
|
379
|
+
// 3. Resolve profile state + required binaries. Every launch first claims a
|
|
380
|
+
// free instance slot (src/instance-slot.js) so a second window never shares
|
|
381
|
+
// another's state dir; only an explicit override skips it.
|
|
382
|
+
const slot = d.allocateSlot({ profile, env: d.env, home: d.home });
|
|
383
|
+
if (slot && 'exhausted' in slot) {
|
|
384
|
+
d.err(`ccr: every slot is in use (${slots.MAX_SLOTS} live instances) — close one first\n`);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
// The instance's name, profile record and title — same on every platform.
|
|
388
|
+
const inst = slot && !('exhausted' in slot)
|
|
389
|
+
? d.prepareInstance(slot, { profile, name: opts.name }) : null;
|
|
390
|
+
const st = resolveProfileState(profile, { env: slots.applySlotEnv(d.env, slot), home: d.home });
|
|
367
391
|
if (st.usesCcs) {
|
|
368
392
|
if (!d.which('ccs')) {
|
|
369
393
|
d.err("ccr: 'ccs' not found on PATH — pass a profile only if CCS is installed.\n");
|
|
@@ -385,6 +409,9 @@ function run(profile, deps = {}) {
|
|
|
385
409
|
|
|
386
410
|
// 4. Prepare the per-profile state dir; clear a stale sentinel.
|
|
387
411
|
try { d.ensureDir(st.stateDir); } catch { /* best effort */ }
|
|
412
|
+
// The tab's stable identity for the git pane. Recorded here because only the
|
|
413
|
+
// launcher knows where ccr was started (src/state-dir.js).
|
|
414
|
+
try { d.recordLaunchDir(st.stateDir, process.cwd()); } catch { /* best effort */ }
|
|
388
415
|
d.removeExited(st.stateDir);
|
|
389
416
|
|
|
390
417
|
// 5. Inject statusLine via a temp settings FILE (avoids CLI JSON quoting).
|
|
@@ -404,6 +431,7 @@ function run(profile, deps = {}) {
|
|
|
404
431
|
sidebarPct: Number.isFinite(pct) ? pct : DEFAULT_SIDEBAR_PCT,
|
|
405
432
|
sidebarSide: d.env.CCR_SIDEBAR_SIDE || DEFAULT_SIDEBAR_SIDE,
|
|
406
433
|
termCols: d.cols,
|
|
434
|
+
title: inst ? inst.title : undefined,
|
|
407
435
|
});
|
|
408
436
|
} catch (e) {
|
|
409
437
|
d.err(`ccr: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
@@ -434,6 +462,9 @@ function run(profile, deps = {}) {
|
|
|
434
462
|
* @property {(dir: string) => boolean} existsDir
|
|
435
463
|
* @property {(dir: string) => string[]} listDir
|
|
436
464
|
* @property {(dir: string) => void} ensureDir
|
|
465
|
+
* @property {(dir: string, cwd: string) => void} recordLaunchDir
|
|
466
|
+
* @property {(o: {profile?: string, env: NodeJS.ProcessEnv, home: string}) => ({slot: number, session: string, stateDir: string, attached: boolean}|{exhausted: true}|null)} allocateSlot
|
|
467
|
+
* @property {(slot: {slot: number, stateDir: string}, o: {profile?: string, name?: string|null}) => {name: string, title: string}} prepareInstance
|
|
437
468
|
* @property {(dir: string) => void} removeExited
|
|
438
469
|
* @property {(settings: object) => string} writeSettings
|
|
439
470
|
* @property {(file: string) => void} cleanup
|
package/src/liveness.js
CHANGED
|
@@ -20,6 +20,23 @@ function envStaleMs() {
|
|
|
20
20
|
return Number.isFinite(v) && v > 0 ? v : null;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Snapshot age for the freshness note. Minutes roll into hours and hours into
|
|
25
|
+
* days: an overnight-idle pane used to read "updated 1500m ago", which is
|
|
26
|
+
* accurate and unreadable.
|
|
27
|
+
* @param {number} ageMs
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function fmtAge(ageMs) {
|
|
31
|
+
const m = Math.floor(ageMs / 60000);
|
|
32
|
+
if (m < 60) return `${m}m`;
|
|
33
|
+
const h = Math.floor(m / 60);
|
|
34
|
+
if (h < 24) { const r = m % 60; return r ? `${h}h${String(r).padStart(2, '0')}m` : `${h}h`; }
|
|
35
|
+
const d = Math.floor(h / 24);
|
|
36
|
+
const rh = h % 24;
|
|
37
|
+
return rh ? `${d}d${rh}h` : `${d}d`;
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
/**
|
|
24
41
|
* @param {{ exited?: boolean, ageMs?: number, staleMs?: number }} input
|
|
25
42
|
* @returns {{ mode: 'ended' | 'live', marker: string | null }}
|
|
@@ -32,7 +49,7 @@ function liveness(input) {
|
|
|
32
49
|
|
|
33
50
|
const ageMs = input.ageMs ?? 0;
|
|
34
51
|
const staleMs = input.staleMs ?? envStaleMs() ?? DEFAULT_STALE_MS;
|
|
35
|
-
const marker = ageMs >= staleMs ? `updated ${
|
|
52
|
+
const marker = ageMs >= staleMs ? `updated ${fmtAge(ageMs)} ago` : null;
|
|
36
53
|
return { mode: 'live', marker };
|
|
37
54
|
}
|
|
38
55
|
|
package/src/migrate.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/migrate.js — move a 0.3 ccr home to the 0.4 container layout, once,
|
|
4
|
+
// safely (features/instance-migration.feature).
|
|
5
|
+
//
|
|
6
|
+
// There is nothing to relocate but HISTORY: the only persistent content the
|
|
7
|
+
// 0.3 layout ever held was account burn history, misplaced inside profile
|
|
8
|
+
// dirs because the logger wrote to whatever the state dir was. So migration
|
|
9
|
+
// HARVESTS burnlogs to the container's top level, SWEEPS the profile dirs and
|
|
10
|
+
// the loose ephemeral droppings (a dead session's captured status, heartbeat,
|
|
11
|
+
// sentinel), and writes the ".layout" marker LAST — an interrupted migration
|
|
12
|
+
// is indistinguishable from one that has not started, and every move is
|
|
13
|
+
// move-if-present, so the next launch simply completes it.
|
|
14
|
+
//
|
|
15
|
+
// THE GENERAL SAFETY PROPERTY, ruled: if the source is not what migration
|
|
16
|
+
// expects, it STOPS AND CHANGES NOTHING. That is also the entire handling of
|
|
17
|
+
// an entry named "instances" or "profiles" (reserved by the new layout) and
|
|
18
|
+
// of a CCS profile literally named either — declined as real scope ("no other
|
|
19
|
+
// users will have this problem"); the stop makes it fail loudly for free.
|
|
20
|
+
//
|
|
21
|
+
// Runs AT LAUNCH ONLY. `ccr statusline` — invoked headlessly by Claude
|
|
22
|
+
// mid-session — never calls this. Removed at 1.0.0.
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
|
|
28
|
+
const MARKER = '.layout';
|
|
29
|
+
|
|
30
|
+
// The 0.3 layout's loose per-session droppings — dead state the ephemeral
|
|
31
|
+
// rule says dies. Everything here may appear at the container root (bare
|
|
32
|
+
// launches) or inside a profile dir.
|
|
33
|
+
const DROPPINGS = ['last-status.json', 'launch-cwd', 'exited', 'sidecar-alive', 'slot-owner', 'view-request'];
|
|
34
|
+
|
|
35
|
+
const BURNLOG_RE = /^burnlog-[A-Za-z0-9_-]+\.jsonl$/;
|
|
36
|
+
// 0.4's own session join key (src/session-log.js). An upgrade while a session
|
|
37
|
+
// is open leaves the new statusline ticking against an unmigrated home, so
|
|
38
|
+
// these appear at the root BEFORE any launch migrates it — already at their
|
|
39
|
+
// final location, they are kept, not a surprise.
|
|
40
|
+
const SESSION_RE = /^session-[A-Za-z0-9_-]+\.jsonl$/;
|
|
41
|
+
// Same allow-list the launcher enforces for profile names.
|
|
42
|
+
const PROFILE_DIR_RE = /^[A-Za-z0-9._-]+$/;
|
|
43
|
+
// Names the new layout owns; their presence in an unmigrated home is exactly
|
|
44
|
+
// the surprise the safety property exists for.
|
|
45
|
+
const RESERVED = new Set(['instances', 'profiles']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is a 0.3 session still running in this dir, as far as 0.3 state can say?
|
|
49
|
+
* The published 0.3 recorded no owning pid, so the freshest signal it left is
|
|
50
|
+
* the sidecar heartbeat. Refusing on a live heartbeat is the safe direction:
|
|
51
|
+
* a false "live" delays migration by seconds; a false "dead" would harvest
|
|
52
|
+
* burnlogs out from under a writing session.
|
|
53
|
+
* @param {string} dir
|
|
54
|
+
*/
|
|
55
|
+
function legacyLive(dir) {
|
|
56
|
+
try {
|
|
57
|
+
if (fs.existsSync(path.join(dir, 'exited'))) return false;
|
|
58
|
+
return require('./sidecar').sidecarAlive(dir);
|
|
59
|
+
} catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Move a burnlog to the container root, first-wins on a name collision (the
|
|
64
|
+
* name carries the Claude session id, so a collision is the same session's
|
|
65
|
+
* data twice — the copy already at the root is kept).
|
|
66
|
+
* @param {string} from @param {string} rootDir @param {string} name
|
|
67
|
+
*/
|
|
68
|
+
function harvestBurnlog(from, rootDir, name) {
|
|
69
|
+
const dest = path.join(rootDir, name);
|
|
70
|
+
try {
|
|
71
|
+
if (fs.existsSync(dest)) { fs.rmSync(from, { force: true }); return; }
|
|
72
|
+
fs.renameSync(from, dest);
|
|
73
|
+
} catch { /* best effort — a later launch completes it */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Bring <home>/.ccr to the 0.4 layout. Total: never throws.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ home?: string }} [opts]
|
|
80
|
+
* @returns {{ ok: true } | { ok: false, error: string }}
|
|
81
|
+
*/
|
|
82
|
+
function ensureLayout(opts = {}) {
|
|
83
|
+
const home = opts.home || os.homedir();
|
|
84
|
+
const root = path.join(home, '.ccr');
|
|
85
|
+
const marker = path.join(root, MARKER);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
if (!fs.existsSync(root)) {
|
|
89
|
+
// Fresh install: create the container already migrated.
|
|
90
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
91
|
+
fs.writeFileSync(marker, '1\n', { mode: 0o600 });
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
if (fs.existsSync(marker)) {
|
|
95
|
+
sweepLegacyDroppings(root);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- Classify EVERYTHING before touching ANYTHING. ----
|
|
100
|
+
/** @type {{ dir: string, name: string }[]} */
|
|
101
|
+
const profileDirs = [];
|
|
102
|
+
/** @type {string[]} */
|
|
103
|
+
const looseBurnlogs = [];
|
|
104
|
+
let rootHasDroppings = false;
|
|
105
|
+
for (const name of fs.readdirSync(root)) {
|
|
106
|
+
const p = path.join(root, name);
|
|
107
|
+
if (RESERVED.has(name)) return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}' (reserved by the new layout); move it aside and relaunch` };
|
|
108
|
+
if (name.startsWith('.')) continue; // dotted container entries are left alone
|
|
109
|
+
if (BURNLOG_RE.test(name)) { looseBurnlogs.push(name); continue; }
|
|
110
|
+
if (SESSION_RE.test(name)) continue; // 0.4 join key — already at its final home
|
|
111
|
+
if (DROPPINGS.includes(name)) { rootHasDroppings = true; continue; }
|
|
112
|
+
let st; try { st = fs.lstatSync(p); } catch { continue; }
|
|
113
|
+
if (st.isDirectory() && PROFILE_DIR_RE.test(name)) { profileDirs.push({ dir: p, name }); continue; }
|
|
114
|
+
return { ok: false, error: `ccr: cannot migrate ~/.ccr — unexpected entry '${name}'; move it aside and relaunch` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A live 0.3 session anywhere refuses the whole migration, by name.
|
|
118
|
+
if (legacyLive(root)) return { ok: false, error: 'ccr: cannot migrate ~/.ccr while a session is running there — close it first' };
|
|
119
|
+
for (const { dir, name } of profileDirs) {
|
|
120
|
+
if (legacyLive(dir)) return { ok: false, error: `ccr: cannot migrate ~/.ccr while an instance is running — close '${name}' first` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Harvest, then sweep, then mark. ----
|
|
124
|
+
for (const { dir } of profileDirs) {
|
|
125
|
+
let names; try { names = fs.readdirSync(dir); } catch { continue; }
|
|
126
|
+
for (const n of names) if (BURNLOG_RE.test(n)) harvestBurnlog(path.join(dir, n), root, n);
|
|
127
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* next launch retries */ }
|
|
128
|
+
}
|
|
129
|
+
if (rootHasDroppings) for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
|
|
130
|
+
try { fs.chmodSync(root, 0o700); } catch { /* best effort */ }
|
|
131
|
+
fs.writeFileSync(marker, '1\n', { mode: 0o600 });
|
|
132
|
+
return { ok: true };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { ok: false, error: `ccr: migration failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A DOWNGRADE-then-re-upgrade leaves a migrated container plus a 0.3 session's
|
|
140
|
+
* loose droppings at the root — a dead instance's live state, which the
|
|
141
|
+
* ephemeral rule says dies. Its burnlogs already live where the pool is, so
|
|
142
|
+
* the day's history merges for free. Swept only once that session shows no
|
|
143
|
+
* heartbeat (the safe direction, same as everywhere else).
|
|
144
|
+
* @param {string} root
|
|
145
|
+
*/
|
|
146
|
+
function sweepLegacyDroppings(root) {
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(path.join(root, 'last-status.json'))
|
|
149
|
+
&& !fs.existsSync(path.join(root, 'sidecar-alive'))) return;
|
|
150
|
+
if (legacyLive(root)) return;
|
|
151
|
+
for (const n of DROPPINGS) { try { fs.rmSync(path.join(root, n), { force: true }); } catch { /* best effort */ } }
|
|
152
|
+
} catch { /* best effort */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { ensureLayout, MARKER };
|
package/src/normalize.js
CHANGED
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
const { discoverWindows } = require('./rate-limits');
|
|
8
8
|
const { stripControl } = require('./sanitize');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A finite number, or null. The snapshot is a JSON file on disk: it chooses its
|
|
12
|
+
* own value types, and a `!= null` check accepts the string "1.5" as happily as
|
|
13
|
+
* 1.5. Downstream does arithmetic and calls `.toFixed()`, so one wrong type is
|
|
14
|
+
* a TypeError inside the draw loop — which the sidecar catches, but only by
|
|
15
|
+
* replacing the whole economy panel with an error line, every tick, until the
|
|
16
|
+
* file changes. Type-check at ingestion; a bad field costs itself and nothing
|
|
17
|
+
* else. (NaN/Infinity are excluded too — they render as "NaN%" meters.)
|
|
18
|
+
* @param {any} v
|
|
19
|
+
* @returns {number|null}
|
|
20
|
+
*/
|
|
21
|
+
function num(v) {
|
|
22
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/**
|
|
11
26
|
* @param {any} state CC status-line JSON
|
|
12
27
|
* @param {number} [nowSec] override for testing
|
|
@@ -15,16 +30,20 @@ const { stripControl } = require('./sanitize');
|
|
|
15
30
|
function normalizeStatus(state, nowSec) {
|
|
16
31
|
const rl = (state && state.rate_limits) || {};
|
|
17
32
|
const cw = (state && state.context_window) || {};
|
|
33
|
+
const cost = (state && state.cost) || {};
|
|
34
|
+
const durationMs = num(cost.total_duration_ms);
|
|
18
35
|
return {
|
|
19
36
|
model: stripControl((state && state.model && state.model.display_name) || null),
|
|
20
|
-
|
|
37
|
+
// Must be positive: it is a divisor for the ctx meter, and `?? ` (unlike the
|
|
38
|
+
// `||` this replaced) would let a literal 0 through to divide by zero.
|
|
39
|
+
windowSize: (num(cw.context_window_size) || 0) > 0 ? cw.context_window_size : 200000,
|
|
21
40
|
windows: discoverWindows(rl, nowSec),
|
|
22
|
-
contextTokens: cw.total_input_tokens
|
|
23
|
-
?? (cw.current_usage && cw.current_usage.cache_read_input_tokens)
|
|
41
|
+
contextTokens: num(cw.total_input_tokens)
|
|
42
|
+
?? num(cw.current_usage && cw.current_usage.cache_read_input_tokens),
|
|
24
43
|
cachedPct: null,
|
|
25
44
|
baselineTok: 14000,
|
|
26
|
-
costUsd:
|
|
27
|
-
durationMin:
|
|
45
|
+
costUsd: num(cost.total_cost_usd),
|
|
46
|
+
durationMin: durationMs != null ? durationMs / 60000 : null,
|
|
28
47
|
branch: null,
|
|
29
48
|
};
|
|
30
49
|
}
|
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
|
+
};
|