glassbox-trace 0.11.0 → 0.12.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/src/memory.mjs CHANGED
@@ -17,6 +17,7 @@ import { maskSecrets } from './fence.mjs';
17
17
  import { segments, actionRe, isShell, projectSessions } from './adhere.mjs';
18
18
  import { TEST_CMD, runFailed } from './claims.mjs';
19
19
  import { loadSessionFiles } from './cli.mjs';
20
+ import { autoMemoryDir } from './memory-audit.mjs';
20
21
  const require = createRequire(import.meta.url);
21
22
  const core = require('./trace-core.js');
22
23
 
@@ -47,14 +48,25 @@ export const cleanCmd = (seg) => String(seg).replace(/^timeout\s+(?:-\S+\s+)*\d+
47
48
 
48
49
  // What a shell command does, in a word or two: the first segment that isn't navigation, its program and subcommand
49
50
  // ("npm test", "npm run e2e", "git push", "python -m pytest").
51
+ // A word in a lesson key must look like a program or a subcommand, never like a password or a token: a key is
52
+ // written into the agent's own memory (0.12 review: `echo <pw> | sudo -S apt-get …` became `Bash echo <pw>`).
53
+ // Lowercase words joined by : . _ - are names (build:es2015, test:e2e2, build-v2, python3.11, webpack5); mixed case
54
+ // with digits, or anything long, is not kept.
55
+ const NAME_RE = /^[a-z][a-z0-9]*(?:[:._-][a-z0-9]+)*$/;
56
+ const secretish = (w) => w.length > 40 || (!NAME_RE.test(w) && (w.length > 24 || (w.length >= 8 && /\d/.test(w) && /[A-Za-z]/.test(w)) || (/[A-Z]/.test(w) && /[a-z]/.test(w) && /\d/.test(w))));
50
57
  export function commandHead(cmd) {
51
- const seg = cleanCmd(segments(cmd).find((s) => !/^(?:cd|pushd|popd|export|set|source|\.)(?:\s|$)/.test(s)) || '');
52
- const toks = seg.split(/\s+/).filter(Boolean); if (!toks.length) return '';
58
+ // Not navigation, and not a segment that only feeds data to the next one (echo / printf / yes / cat <<).
59
+ const seg = cleanCmd(segments(cmd).find((s) => !/^(?:cd|pushd|popd|export|set|source|\.|echo|printf|yes|true|false|:)(?:\s|$)/.test(s) && !/^cat\s+<</.test(s)) || '');
60
+ let toks = seg.split(/\s+/).filter(Boolean);
61
+ while (toks.length && /^-/.test(toks[0])) toks = toks.slice(/^-[ugpCDhr]$/.test(toks[0]) ? 2 : 1); // what's left of `sudo -u bob …`
62
+ if (!toks.length) return '';
53
63
  const prog = toks[0].replace(/^.*[\\/]/, '');
54
- if (/^python3?$/.test(prog) && toks[1] === '-m' && toks[2]) return `${prog} -m ${toks[2]}`;
64
+ if (!/^[\w.+-]+$/.test(prog) || secretish(prog)) return '';
65
+ if (/^python(?:3(?:\.\d+)?)?$/.test(prog) && toks[1] === '-m' && toks[2]) return `${prog} -m ${toks[2]}`;
55
66
  let i = 1; if (prog === 'git') while (i < toks.length && /^-/.test(toks[i])) i += /^-[Cc]$/.test(toks[i]) ? 2 : 1;
56
67
  let sub = toks[i] && /^[A-Za-z][\w:.-]*$/.test(toks[i]) ? toks[i] : toks[1] && /^--[a-z][\w-]*$/.test(toks[1]) ? toks[1] : '';
57
- if (/^(?:run|exec|x|dlx)$/.test(sub) && toks[i + 1] && /^[\w:.-]+$/.test(toks[i + 1])) sub += ' ' + toks[i + 1];
68
+ if (sub && secretish(sub)) sub = '';
69
+ if (/^(?:run|exec|x|dlx)$/.test(sub) && toks[i + 1] && /^[\w:.-]+$/.test(toks[i + 1]) && !secretish(toks[i + 1])) sub += ' ' + toks[i + 1];
58
70
  return sub ? `${prog} ${sub}` : prog;
59
71
  }
60
72
 
@@ -298,25 +310,158 @@ export function forget(cwd, what) {
298
310
  return keys;
299
311
  }
300
312
 
301
- export function clearMemory(cwd) {
313
+ export function clearMemory(cwd, opts = {}) {
302
314
  const removed = [];
303
315
  for (const f of [MEMORY_FILE, NOTES_FILE]) { const p = path.join(cwd, f); if (fs.existsSync(p)) { fs.unlinkSync(p); removed.push(p); } }
316
+ // The lessons block, wherever it was written (auto memory may have been switched on or off since).
317
+ for (const file of lessonsFiles(cwd, opts)) { if (writeLessonsBlock(file, null).changed) removed.push(`${file} (Glassbox lessons block)`); }
304
318
  return removed;
305
319
  }
306
320
 
321
+ // ---------------------------------------------------------------------------------------------
322
+ // One memory (0.12, DESIGN.md §21). The lessons the agent should act on go into the memory it already reads — the
323
+ // auto memory MEMORY.md when auto memory is on and its folder exists, else <project>/CLAUDE.local.md; never the
324
+ // shared CLAUDE.md — as one block Glassbox owns and rewrites at every Stop. The block is a projection of the ledger
325
+ // (active lessons, worst first, the advice once per rule), so it can't drift from it; text outside the markers is
326
+ // never touched. The end marker carries the keys, so the next session's first Stop knows what it was told.
327
+ // ---------------------------------------------------------------------------------------------
328
+ export const BLOCK_START = '<!-- glassbox:lessons:start';
329
+ export const BLOCK_END = '<!-- glassbox:lessons:end';
330
+ export const BLOCK_MAX = 1800;
331
+ export function lessonsTarget(cwd, opts = {}) {
332
+ const am = autoMemoryDir({ project: cwd, home: opts.home, userHome: opts.userHome });
333
+ if (am.enabled && am.dir) return { file: path.join(am.dir, 'MEMORY.md'), kind: 'auto-memory' };
334
+ return { file: path.join(path.resolve(cwd), 'CLAUDE.local.md'), kind: 'local' };
335
+ }
336
+ function lessonsFiles(cwd, opts) {
337
+ const am = autoMemoryDir({ project: cwd, home: opts.home, userHome: opts.userHome });
338
+ return [am.dir ? path.join(am.dir, 'MEMORY.md') : null, path.join(path.resolve(cwd), 'CLAUDE.local.md')].filter((f) => f && fs.existsSync(f));
339
+ }
340
+ export function lessonsBlock(led, opts = {}) {
341
+ const o = Object.assign({}, MEMORY_DEFAULTS, opts), n = led.sessions.length;
342
+ let shown = lessonStats(led, o).filter((l) => l.active).slice(0, o.lessons);
343
+ const build = (list) => {
344
+ const advised = new Set();
345
+ const lines = list.map((l) => {
346
+ const adv = !advised.has(l.rule) && core.ADVICE[l.rule]; advised.add(l.rule);
347
+ const howOften = n === 1 ? 'in the only session so far' : `in ${l.sessions} of the last ${n} sessions`;
348
+ // No marker can be forged from a key, title or path: an HTML comment opener is broken with a zero-width non-joiner.
349
+ return '- ' + clip(`${maskSecrets(l.key)} — ${howOften}: ${maskSecrets(l.title)}.${adv ? ` Next time: ${adv}` : ''}`, 320).replace(/<!--/g, '<\u200c!--');
350
+ });
351
+ return [`${BLOCK_START} (rewritten by the Glassbox Stop hook from this project's transcripts; edit outside these markers; \`glassbox memory forget <lesson>\` drops one) -->`,
352
+ '## Glassbox: what keeps going wrong in this project', '', ...lines,
353
+ `${BLOCK_END} keys=${list.map((l) => encodeURIComponent(l.key)).join(',')} -->`, ''].join('\n');
354
+ };
355
+ if (!shown.length) return null;
356
+ let text = build(shown);
357
+ while (text.length > BLOCK_MAX && shown.length > 1) { shown = shown.slice(0, -1); text = build(shown); }
358
+ return { text, keys: shown.map((l) => l.key) };
359
+ }
360
+ export function blockKeys(text) {
361
+ const t = String(text || ''); const b = findBlocks(t.charCodeAt(0) === 0xfeff ? t.slice(1) : t).blocks[0];
362
+ return b ? b.keys.split(',').filter(Boolean).map((k) => { try { return decodeURIComponent(k); } catch (e) { return k; } }) : [];
363
+ }
364
+ // Put `body` (a block, or null to remove it) into the file. Markers count only as whole lines outside ``` / ~~~
365
+ // fences, so a marker quoted in prose, inline code or a fenced example is never taken for the block (0.12 review);
366
+ // a start line with no end line after it replaces only itself. In place when a block is there (any extra complete
367
+ // blocks are dropped); under a leading `# ` title in an auto memory index, so it sits inside the 200-line cut; at the
368
+ // end of anything else. Line endings and a BOM follow the file. Written through a symlink, with the file's mode, via
369
+ // a temp file unique to this process; a file we can't write is left alone. Concurrent writers each start from the
370
+ // whole file, so the last rename wins without losing the user's lines.
371
+ const START_LINE = /^<!-- glassbox:lessons:start\b.*-->[ \t]*\r?$/;
372
+ const END_LINE = /^<!-- glassbox:lessons:end keys=(\S*) -->[ \t]*\r?$/;
373
+ // CommonMark fences: an opener is 3+ backticks or tildes (backtick openers can't have a backtick in the info
374
+ // string); the closer is the same character, at least as long, with nothing after it. A fence still open at the end
375
+ // of the file runs to the end in Markdown, but anything after it may be ours, so it is scanned again with that
376
+ // opener read as text — otherwise every Stop would append another block inside it (0.12 review, round 2).
377
+ function findBlocks(text, ignore = new Set()) {
378
+ const blocks = []; let fence = null, open = null, at = 0, idx = -1;
379
+ for (const line of text.split('\n')) {
380
+ idx++; const start = at, end = at + line.length + 1; at = end;
381
+ const l = line.replace(/\r$/, '');
382
+ if (fence) {
383
+ const c = l.match(/^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/);
384
+ if (c && c[1][0] === fence.ch && c[1].length >= fence.len) fence = null;
385
+ continue;
386
+ }
387
+ const o = l.match(/^[ \t]{0,3}(`{3,}|~{3,})(.*)$/);
388
+ if (o && !ignore.has(idx) && !(o[1][0] === '`' && o[2].includes('`'))) { fence = { ch: o[1][0], len: o[1].length, idx }; continue; }
389
+ if (START_LINE.test(line)) { if (!open) open = { start, lineEnd: Math.min(end, text.length) }; continue; }
390
+ const m = line.match(END_LINE);
391
+ if (m && open) { blocks.push({ start: open.start, end: Math.min(end, text.length), keys: m[1] }); open = null; }
392
+ }
393
+ if (fence) return findBlocks(text, new Set([...ignore, fence.idx])); // each pass ignores one more opener: it ends
394
+ return { blocks, broken: open };
395
+ }
396
+ export function writeLessonsBlock(file, body, kind) {
397
+ let real = file;
398
+ try { real = fs.realpathSync(file); } catch (e) { let link = false; try { link = fs.lstatSync(file).isSymbolicLink(); } catch (e2) { } if (link) return { file, changed: false, reason: 'a link to nothing' }; }
399
+ const exists = fs.existsSync(real);
400
+ // Read-only by its mode bits (root passes an access() check on a 0444 file) or by access(): someone froze it; leave it.
401
+ if (exists) { let ok = (fs.statSync(real).mode & 0o222) !== 0; try { fs.accessSync(real, fs.constants.W_OK); } catch (e) { ok = false; } if (!ok) return { file, changed: false, reason: 'not writable' }; }
402
+ const raw = exists ? fs.readFileSync(real, 'utf8') : '';
403
+ const bom = raw.charCodeAt(0) === 0xfeff ? '' : '';
404
+ const text = bom ? raw.slice(1) : raw;
405
+ const eol = /\r\n/.test(text) ? '\r\n' : '\n';
406
+ const blk = body ? body.replace(/\r?\n/g, eol) : '';
407
+ const { blocks, broken } = findBlocks(text);
408
+ let out;
409
+ if (blocks.length) {
410
+ out = ''; let pos = 0;
411
+ blocks.forEach((b, i) => { out += text.slice(pos, b.start) + (i === 0 ? blk : ''); pos = b.end; });
412
+ let rest = text.slice(pos);
413
+ // Removed: take back the blank line that was put beside it, so the file reads as it did before the block.
414
+ if (!blk && rest.startsWith(eol) && (out === '' || out.endsWith(eol + eol))) rest = rest.slice(eol.length);
415
+ else if (!blk && rest === '' && out.endsWith(eol + eol)) out = out.slice(0, -eol.length);
416
+ out += rest;
417
+ } else if (broken) out = text.slice(0, broken.start) + blk + text.slice(broken.lineEnd);
418
+ else if (!blk) return { file, changed: false };
419
+ else if (kind === 'auto-memory') {
420
+ const m = /^# [^\r\n]*\r?\n(?:\r?\n)?/.exec(text); const at = m ? m[0].length : 0;
421
+ out = text.slice(0, at) + blk + (text.slice(at) ? eol : '') + text.slice(at);
422
+ } else out = text + (text && !text.endsWith('\n') ? eol : '') + (text.trim() ? eol : '') + blk;
423
+ out = bom + out;
424
+ if (out === raw) return { file, changed: false };
425
+ if (!exists && !out) return { file, changed: false };
426
+ fs.mkdirSync(path.dirname(real), { recursive: true });
427
+ const mode = exists ? fs.statSync(real).mode & 0o777 : 0o644;
428
+ const tmp = `${real}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.glassbox~`;
429
+ try { fs.writeFileSync(tmp, out, { flag: 'wx', mode }); fs.chmodSync(tmp, mode); fs.renameSync(tmp, real); }
430
+ catch (e) { try { fs.unlinkSync(tmp); } catch (e2) { } throw e; }
431
+ return { file, changed: true };
432
+ }
433
+ // The Stop hook's part: what this session started with (first Stop only), then the block for the next one.
434
+ export function syncLessonsBlock(cwd, opts = {}) {
435
+ if (!memoryPath(cwd)) return null;
436
+ const t = lessonsTarget(cwd, opts);
437
+ const led = loadLedger(cwd);
438
+ const blk = lessonsBlock(led, opts);
439
+ return Object.assign(writeLessonsBlock(t.file, blk ? blk.text : null, t.kind), { kind: t.kind, keys: blk ? blk.keys : [] });
440
+ }
441
+ export function toldFromBlock(cwd, sessionId, opts = {}) {
442
+ if (!sessionId || !memoryPath(cwd)) return false;
443
+ const led = loadLedger(cwd);
444
+ if (led.sessions.some((x) => x.id === sessionId) || led.pending[sessionId]) return false;
445
+ const t = lessonsTarget(cwd, opts);
446
+ const keys = fs.existsSync(t.file) ? blockKeys(fs.readFileSync(t.file, 'utf8')) : [];
447
+ return noteTold(cwd, sessionId, keys);
448
+ }
449
+
307
450
  // ---------------------------------------------------------------------------------------------
308
451
  // glassbox memory: the note the next session will get, and the lessons behind it.
309
452
  // ---------------------------------------------------------------------------------------------
310
453
  export function memoryReport(opts = {}) {
311
454
  const project = path.resolve(opts.project || process.cwd());
312
455
  const led = loadLedger(project), o = Object.assign({}, MEMORY_DEFAULTS, opts);
456
+ const target = lessonsTarget(project, opts); const blk = lessonsBlock(led, o);
313
457
  const lessons = lessonStats(led, o);
314
458
  const note = memoryNote(project, Object.assign({}, o, { ledger: led }));
315
459
  const told = lessons.reduce((s, l) => s + l.told, 0), recurred = lessons.reduce((s, l) => s + l.recurred, 0);
316
460
  return { glassbox: core.VERSION, kind: 'memory', schema: 1, project, file: fs.existsSync(path.join(project, MEMORY_FILE)) ? path.join(project, MEMORY_FILE) : null, updated: led.updated,
317
461
  sessions: led.sessions.length, window: o.window, retireAfter: o.retireAfter,
318
462
  summary: { lessons: lessons.length, active: lessons.filter((l) => l.active).length, retired: lessons.filter((l) => !l.active).length, told, recurred },
319
- lessons, recent: led.sessions.slice().reverse().slice(0, o.recent).map((d) => ({ id: d.id, start: d.start, line: digestLine(d) })), note: note ? note.text : null };
463
+ lessons, recent: led.sessions.slice().reverse().slice(0, o.recent).map((d) => ({ id: d.id, start: d.start, line: digestLine(d) })), note: note ? note.text : null,
464
+ block: { file: target.file, kind: target.kind, text: blk ? blk.text : null } };
320
465
  }
321
466
  export function memoryText(rep) {
322
467
  const s = rep.summary;
@@ -327,6 +472,11 @@ export function memoryText(rep) {
327
472
  L.push('');
328
473
  for (const l of rep.lessons) L.push(` ${(l.active ? 'ACTIVE' : 'RETIRED').padEnd(8)} ${l.key} · ${l.sessions} session${l.sessions === 1 ? '' : 's'}, last ${String(l.lastSeen || '').slice(0, 10)}${l.told ? ` · told ${l.told}, came back ${l.recurred}` : ''}${l.quiet ? ` · quiet ${l.quiet}` : ''}`);
329
474
  }
475
+ if (rep.block) {
476
+ L.push('', rep.block.text ? ` The agent's own memory carries these lessons, in ${rep.block.file} (${rep.block.kind === 'auto-memory' ? 'auto memory' : 'CLAUDE.local.md'}):` : ` No lessons to carry: the block in ${rep.block.file} is removed at the next Stop.`);
477
+ if (rep.block.text) { L.push(''); for (const line of rep.block.text.replace(/\n$/, '').split('\n')) L.push(' │ ' + line); }
478
+ L.push('', ' (With --memory glassbox the next session gets this note from the SessionStart hook instead:)');
479
+ }
330
480
  L.push('', rep.note ? ' The next session in this project starts with this note:' : ' The next session in this project starts with no note.');
331
481
  if (rep.note) { L.push(''); for (const line of rep.note.replace(/\n$/, '').split('\n')) L.push(' │ ' + line); }
332
482
  L.push('', ' glassbox memory forget <key|rule> drops a lesson · glassbox memory clear deletes the memory · glassbox recall <words> searches it');
@@ -371,7 +521,8 @@ export function recall(opts = {}) {
371
521
  for (const s of list) {
372
522
  const files = loadSessionFiles(s);
373
523
  const words = terms.flatMap((t) => t.split(/\s+/)).filter(rawSafe);
374
- if (words.length) { const raw = files.map((f) => f.text).join('\n').toLowerCase(); if (!words.every((w) => raw.includes(w))) continue; }
524
+ // The raw-text shortcut needs the text; a streamed (big) transcript has records instead and is parsed (0.12).
525
+ if (words.length && files.every((f) => typeof f.text === 'string')) { const raw = files.map((f) => f.text).join('\n').toLowerCase(); if (!words.every((w) => raw.includes(w))) continue; }
375
526
  const d = digestOf(core.parseTrace(files), { cwd: project, id: s.id }); parsed++;
376
527
  const prev = byId.get(s.id);
377
528
  byId.set(s.id, Object.assign(d, { hit: prev ? prev.hit : [], cost: prev ? prev.cost : null, source: prev ? 'both' : 'transcript' }));
package/src/plan.mjs ADDED
@@ -0,0 +1,253 @@
1
+ // glassbox plan — what a Pro/Max plan is buying, from the transcripts on this machine.
2
+ //
3
+ // Glassbox prices every request at API list price. A plan subscriber doesn't pay that; what they want to know is
4
+ // what the plan carried and where the heavy stretches were. `plan` reads the plan tier from ~/.claude.json (two
5
+ // fields, nothing else), buckets every request by its own timestamp into billing months, the last 7 days and
6
+ // rebuilt 5-hour windows, and finds limit hits. It cannot see the plan's limits: Claude Code gives the % used only
7
+ // to the status line, never to a transcript, and the limits count claude.ai and other machines too (DESIGN.md §22).
8
+ // Nothing leaves the machine.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createRequire } from 'node:module';
12
+ import { findSessions, loadSessionFiles, sessionTitle, claudeHome } from './cli.mjs';
13
+ import { readTextFile } from './textfile.mjs';
14
+ import { readSnapshots } from './statusline.mjs';
15
+ const require = createRequire(import.meta.url);
16
+ const core = require('./trace-core.js');
17
+
18
+ const H = 3600e3, D = 24 * H, WINDOW = 5 * H;
19
+
20
+ // Rate-limit tiers as ~/.claude.json names them → plan and its monthly price in USD (claude.com/pricing).
21
+ export const PLANS = {
22
+ pro: { label: 'Pro', price: 20 },
23
+ max_5x: { label: 'Max 5x', price: 100 },
24
+ max_20x: { label: 'Max 20x', price: 200 },
25
+ };
26
+ export const tierPlan = (tier) => { const m = String(tier || '').toLowerCase().match(/(max_20x|max_5x|pro)\b/); return m ? m[1] : null; };
27
+
28
+ // Where Claude Code keeps .claude.json: inside the home when the home is a CLAUDE_CONFIG_DIR, else next to it.
29
+ // The first one that has a signed-in account wins (an old copy without one can sit inside ~/.claude).
30
+ export function readPlan(home) {
31
+ home = home || claudeHome();
32
+ let first = null;
33
+ for (const f of [path.join(home, '.claude.json'), path.join(path.dirname(home), '.claude.json')]) {
34
+ if (!fs.existsSync(f)) continue;
35
+ first = first || f;
36
+ let acct; try { acct = (JSON.parse(readTextFile(f)) || {}).oauthAccount; } catch (e) { continue; }
37
+ if (!acct) continue;
38
+ const tier = typeof acct.organizationRateLimitTier === 'string' ? acct.organizationRateLimitTier : typeof acct.userRateLimitTier === 'string' ? acct.userRateLimitTier : null;
39
+ return { file: f, plan: tierPlan(tier), tier, since: acct.subscriptionCreatedAt || null };
40
+ }
41
+ return { file: first, plan: null };
42
+ }
43
+
44
+ // Billing months run from the subscription's day and time of day (UTC); a 31st falls on a short month's last day.
45
+ const monthStart = (y, m, day, tod) => { const last = new Date(Date.UTC(y, m + 1, 0)).getUTCDate(); return Date.UTC(y, m, Math.min(day, last)) + tod; };
46
+ export function billingPeriods(anchor, now, count = 2) {
47
+ const a = new Date(anchor), day = a.getUTCDate(), tod = anchor - Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), a.getUTCDate());
48
+ const n = new Date(now); let y = n.getUTCFullYear(), m = n.getUTCMonth();
49
+ if (monthStart(y, m, day, tod) > now) m--;
50
+ const out = [];
51
+ for (let i = 0; i < count; i++) { const from = monthStart(y, m - i, day, tod), to = monthStart(y, m - i + 1, day, tod); if (to <= anchor) break; out.push({ from: Math.max(from, anchor), to }); }
52
+ return out;
53
+ }
54
+
55
+ // A 5-hour window opens at the first request after the previous one closed (the way ccusage `blocks` rebuilds them).
56
+ // Usage from claude.ai or another machine can open a window Glassbox never sees, so these are estimates.
57
+ export function rebuildWindows(reqs) {
58
+ const out = [];
59
+ for (const r of reqs) { const w = out[out.length - 1]; if (!w || r.t >= w.start + WINDOW) out.push({ start: r.t, end: r.t + WINDOW, reqs: [r] }); else w.reqs.push(r); }
60
+ return out;
61
+ }
62
+
63
+ // A limit hit is the synthetic assistant record Claude Code writes in place of a reply.
64
+ const LIMIT_TEXT = /usage limit|hit your limit|limit reached|limit will reset|resets? (?:at|in)\b/i;
65
+ export function limitKind(r) {
66
+ if (!r.isApiError) return null;
67
+ const text = r.blocks.filter((b) => b.type === 'text').map((b) => b.text).join(' ');
68
+ if (LIMIT_TEXT.test(text) || r.apiError === 'billing_error') return 'plan';
69
+ if (r.apiError === 'rate_limit') return 'rate';
70
+ return null;
71
+ }
72
+
73
+ const family = (m) => String(m || 'unknown').replace(/-\d{8}$/, '');
74
+ function bucket(reqs, from, to) {
75
+ const list = reqs.filter((r) => r.t >= from && r.t < to);
76
+ const byModel = {}, bySession = {};
77
+ for (const r of list) { byModel[family(r.model)] = (byModel[family(r.model)] || 0) + r.cost; bySession[r.session] = (bySession[r.session] || 0) + r.cost; }
78
+ const list$ = list.reduce((a, r) => a + r.cost, 0);
79
+ return { list: list$, requests: list.length, byModel, bySession };
80
+ }
81
+
82
+ export function plan(opts = {}) {
83
+ const home = opts.home || claudeHome();
84
+ const now = opts.now != null ? opts.now : Date.now();
85
+ const since = opts.since != null ? opts.since : 7 * D;
86
+ const found = readPlan(home);
87
+ const key = opts.plan !== undefined ? (opts.plan === 'none' ? null : tierPlan(String(opts.plan).replace(/-/g, '_'))) : found.plan;
88
+ if (opts.plan !== undefined && opts.plan !== 'none' && !key) throw new Error(`--plan must be pro, max-5x, max-20x or none (got "${opts.plan}")`);
89
+ const info = key ? PLANS[key] : null;
90
+ const price = opts.planPrice != null ? opts.planPrice : info ? info.price : null;
91
+
92
+ // The billing anchor: --bill-day (00:00 UTC on that day), else the subscription's own start.
93
+ // A January a year back carries any day 1–31, and billingPeriods only reads its day and time of day.
94
+ let anchor = null;
95
+ if (opts.billDay != null) {
96
+ if (!Number.isInteger(opts.billDay) || opts.billDay < 1 || opts.billDay > 31) throw new Error(`--bill-day must be a day of the month, 1–31 (got "${opts.billDay}")`);
97
+ anchor = Date.UTC(new Date(now).getUTCFullYear() - 1, 0, opts.billDay);
98
+ } else if (found.since && key) anchor = Date.parse(found.since);
99
+ const periods = info && anchor != null && Number.isFinite(anchor) ? billingPeriods(anchor, now, 2) : [];
100
+ const from = Math.min(now - since, now - 7 * D, ...periods.map((p) => p.from));
101
+
102
+ // Every request, once: a resumed session repeats earlier records under the same requestId.
103
+ const seen = new Set(), reqs = [], hits = [], files = {}; const unpriced = new Set(); let unpricedN = 0, sessions = 0;
104
+ for (const s of findSessions({ home }).filter((x) => x.mtime >= from)) {
105
+ let trace; try { trace = core.parseTrace(loadSessionFiles(s)); } catch (e) { continue; }
106
+ sessions++; files[s.id] = s.file;
107
+ for (const r of trace.requests) {
108
+ if (r.start == null || r.start < from || seen.has(r.id)) continue;
109
+ seen.add(r.id);
110
+ const kind = limitKind(r);
111
+ if (kind) { if (r.start >= now - since) hits.push({ t: r.start, kind, session: s.id, code: r.apiError || null }); continue; }
112
+ if (r.isApiError || r.model === '<synthetic>') continue; // written by Claude Code, not an API reply
113
+ const cost = core.requestCost(r.usage, core.rateFor(r.model, opts.rates));
114
+ if (cost == null) { unpricedN++; if (r.model) unpriced.add(r.model); continue; }
115
+ reqs.push({ t: r.start, cost, model: r.model, session: s.id });
116
+ }
117
+ }
118
+ reqs.sort((a, b) => a.t - b.t); hits.sort((a, b) => a.t - b.t);
119
+
120
+ const title = (id) => { if (opts.redact) return null; const t = sessionTitle(files[id]); return /^\((no prompt found|unreadable)\)$/.test(t) ? null : t; };
121
+ const topOf = (bySession, total) => { const e = Object.entries(bySession).sort((a, b) => b[1] - a[1])[0]; return e ? { session: e[0], list: e[1], share: total ? e[1] / total : 0, title: title(e[0]) } : null; };
122
+ const models = (byModel, total) => Object.entries(byModel).sort((a, b) => b[1] - a[1]).map(([model, list]) => ({ model, list, share: total ? list / total : 0 }));
123
+
124
+ // Rebuilt from everything read (so the first window in range isn't cut at its edge), kept if it ends in range.
125
+ const windows = key ? rebuildWindows(reqs).filter((w) => w.end > now - since).map((w) => {
126
+ const b = bucket(w.reqs, w.start, w.end);
127
+ return { start: w.start, end: w.end, open: now < w.end, list: b.list, requests: b.requests, top: topOf(b.bySession, b.list) };
128
+ }) : [];
129
+ const week = bucket(reqs, now - 7 * D, now + 1);
130
+ const rateCard = core.rateCardCheck(reqs.map((r) => r.model).concat([...unpriced]), opts.rates || undefined, { latest: reqs.length ? reqs[reqs.length - 1].t : null, custom: !!(opts.rates && opts.rates._custom) });
131
+ const live = opts.live ? liveFrom(opts.snapshots || readSnapshots(home), reqs, { now, since, title, top: opts.top || 5 }) : null;
132
+ return {
133
+ glassbox: core.VERSION, schema: 1, kind: 'plan', generated: now, redacted: !!opts.redact,
134
+ scope: 'Claude Code transcripts on this machine; claude.ai, the apps and other machines are not included',
135
+ plan: { key, label: info ? info.label : null, price, priceFrom: opts.planPrice != null ? 'flag' : info ? 'default' : null, tier: found.tier || null, file: found.file, override: opts.plan !== undefined },
136
+ sessions, requests: reqs.length, since,
137
+ periods: periods.map((p) => { const b = bucket(reqs, p.from, p.to); const elapsed = Math.min(now, p.to) - p.from;
138
+ return { from: p.from, to: p.to, current: now < p.to, days: Math.round((p.to - p.from) / D), daysElapsed: Math.max(1, Math.ceil(elapsed / D)), list: b.list, requests: b.requests, multiple: price ? b.list / price : null, models: models(b.byModel, b.list) }; }),
139
+ last7d: { from: now - 7 * D, list: week.list, requests: week.requests, models: models(week.byModel, week.list) },
140
+ windows: { count: windows.length, current: windows.find((w) => w.open) || null, top: windows.slice().sort((a, b) => b.list - a.list).slice(0, opts.top || 5) },
141
+ limitHits: hits,
142
+ unpriced: { requests: unpricedN, models: [...unpriced] },
143
+ rateCard, live, pricing: core.pricingNote(reqs.map((r) => r.model), opts.rates || undefined),
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // --live: the status line's snapshots (src/statusline.mjs) against the local requests
149
+ // ---------------------------------------------------------------------------
150
+ // A window is known exactly from its resets_at: the 5-hour one started 5 h before it, the week 7 days before.
151
+ // resets_at is whole seconds and can wobble by one between readings, so readings are grouped to the minute.
152
+ // A session's share is what the % did between its own first and last reading in each window: other sessions,
153
+ // claude.ai and other machines in that time count too, so it is "while it ran", not "because of it".
154
+ export function liveFrom(snaps, reqs, opts) {
155
+ const { now, since } = opts;
156
+ const recent = snaps.filter((s) => s.t >= now - Math.max(since, 7 * D));
157
+ const groups = (kind, len) => {
158
+ const by = new Map();
159
+ for (const s of recent) { const w = s[kind]; if (!w) continue; const k = Math.round(w.resets / 60e3); const g = by.get(k) || { start: w.resets - len, end: w.resets, peak: 0, readings: 0, first: s.t, last: s.t }; g.peak = Math.max(g.peak, w.pct); g.readings++; g.last = s.t; by.set(k, g); }
160
+ return [...by.values()].sort((a, b) => b.start - a.start).map((g) => { const b = bucket(reqs, g.start, Math.min(g.end, now)); return Object.assign(g, { open: now < g.end, list: b.list, requests: b.requests }); });
161
+ };
162
+ const deltas = (list, kind) => { const by = new Map(); for (const s of list) { const w = s[kind]; if (!w) continue; const k = Math.round(w.resets / 60e3); const g = by.get(k) || { lo: w.pct, hi: w.pct }; g.hi = w.pct; by.set(k, g); } let d = 0; for (const g of by.values()) d += Math.max(0, g.hi - g.lo); return d; };
163
+ const bySession = new Map(); for (const s of recent) { if (!s.session) continue; if (!bySession.has(s.session)) bySession.set(s.session, []); bySession.get(s.session).push(s); }
164
+ const sessions = [...bySession.entries()].filter(([, l]) => l.length >= 2).map(([id, l]) => {
165
+ const from = l[0].t, to = l[l.length - 1].t, b = bucket(reqs.filter((r) => r.session === id), from, to + 1);
166
+ return { session: id, title: opts.title(id), readings: l.length, from, to, week: deltas(l, 'week'), five: deltas(l, 'five'), list: b.list, requests: b.requests };
167
+ }).sort((a, b) => b.week - a.week || b.five - a.five).slice(0, opts.top);
168
+ const latest = recent[recent.length - 1] || null;
169
+ const fresh = (w) => w && w.resets > now ? w : null;
170
+ return {
171
+ snapshots: recent.length, newest: latest ? latest.t : null,
172
+ now: latest ? { t: latest.t, five: fresh(latest.five), week: fresh(latest.week) } : null,
173
+ fiveHour: groups('five', 5 * H).slice(0, opts.top), weeks: groups('week', 7 * D).slice(0, 2), sessions,
174
+ };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Rendering
179
+ // ---------------------------------------------------------------------------
180
+ const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
181
+ const p2 = (n) => String(n).padStart(2, '0');
182
+ const day = (t) => { const d = new Date(t); return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`; };
183
+ const hm = (t) => { const d = new Date(t); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; };
184
+ const usd = (v) => '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
185
+ const pct = (v) => v > 0 && v < 0.005 ? '<1%' : Math.round(v * 100) + '%';
186
+ const mult = (v) => v.toFixed(1) + '×';
187
+ const short = (m) => m.replace(/^claude-/, '').replace(/-(\d+)-(\d+)$/, ' $1.$2').replace(/-(\d+)$/, ' $1').replace(/^./, (c) => c.toUpperCase());
188
+ const modelLine = (ms) => ms.slice(0, 3).map((m) => `${short(m.model)} ${pct(m.share)}`).join(' · ');
189
+ const sinceText = (ms) => ms % D === 0 ? `${ms / D}d` : ms % H === 0 ? `${ms / H}h` : `${Math.round(ms / 60e3)}m`;
190
+ const NOTE = ['List price is what the same tokens would cost on the API. It isn\'t money saved, and it isn\'t your share',
191
+ 'of the plan\'s limits: Claude Code shows those only to the status line, and they count claude.ai and other machines too.'];
192
+
193
+ function headline(rep) {
194
+ const p = rep.plan;
195
+ if (!p.key) return `Glassbox plan · no Claude plan found${p.file ? ` in ${p.file}` : ''} (API key, Bedrock or Vertex?) · list price is what you pay`;
196
+ return `Glassbox plan · ${p.label}${p.override ? '' : ' (from .claude.json)'} · ${p.price != null ? usd(p.price).replace(/\.00$/, '') + '/month' + (p.priceFrom === 'default' ? ' (USD list; --plan-price for yours)' : '') : 'no price'} · Claude Code on this machine only`;
197
+ }
198
+ const periodLabel = (p) => p.current ? `billing month from ${day(p.from)}` : `${day(p.from)} – ${day(p.to)}`;
199
+ const windowRow = (w, redact) => `${day(w.start)} ${hm(w.start)}–${hm(w.end)} ${usd(w.list).padStart(10)} ${String(w.requests).padStart(5)} req${w.top ? ` top ${w.top.session.slice(0, 8)} ${pct(w.top.share)}${!redact && w.top.title ? ` "${w.top.title.slice(0, 50)}"` : ''}` : ''}`;
200
+
201
+ export function planText(rep) {
202
+ const L = [headline(rep), ''];
203
+ for (const p of rep.periods) L.push(` ${periodLabel(p).padEnd(32)} ${usd(p.list).padStart(11)} list${p.multiple != null ? ` vs ${usd(rep.plan.price).replace(/\.00$/, '')} plan ${mult(p.multiple)}` : ''}${p.current ? ` (day ${p.daysElapsed} of ${p.days})` : ''}`);
204
+ L.push(` ${'last 7 days'.padEnd(32)} ${usd(rep.last7d.list).padStart(11)} list ${modelLine(rep.last7d.models)}`);
205
+ if (rep.plan.key) {
206
+ L.push('', ` 5-hour windows, last ${sinceText(rep.since)}: ${rep.windows.count} (rebuilt from request times; usage elsewhere can shift them)`);
207
+ if (rep.windows.current) L.push(` now ${windowRow(rep.windows.current, rep.redacted)}`);
208
+ for (const w of rep.windows.top) if (!rep.windows.current || w.start !== rep.windows.current.start) L.push(` ${windowRow(w, rep.redacted)}`);
209
+ L.push('', rep.limitHits.length ? ` limit hits: ${rep.limitHits.length}` : ` limit hits: none in the last ${sinceText(rep.since)}`);
210
+ for (const h of rep.limitHits.slice(-10)) L.push(` ${day(h.t)} ${hm(h.t)} ${h.kind === 'plan' ? 'plan limit' : 'rate limited'} ${h.session.slice(0, 8)}`);
211
+ }
212
+ if (rep.live) L.push('', ...liveLines(rep.live, rep.redacted, rep.generated).map((s) => ' ' + s));
213
+ if (rep.unpriced.requests) L.push('', ` ${rep.unpriced.requests} request${rep.unpriced.requests === 1 ? '' : 's'} not priced (no rate for ${rep.unpriced.models.join(', ') || 'an unnamed model'}; --rates FILE adds one)`);
214
+ if (rep.pricing) L.push('', ' ' + rep.pricing);
215
+ const rl = core.rateCardLine(rep.rateCard); if (rl) L.push('', ' ' + rl);
216
+ L.push('', ...(rep.live && rep.live.snapshots ? NOTE_LIVE : NOTE).map((s) => ' ' + s));
217
+ return L.join('\n');
218
+ }
219
+
220
+ const NOTE_LIVE = ['List price is what the same tokens would cost on the API, not money saved. The % readings are the plan\'s own,',
221
+ 'from the status line; they count claude.ai and other machines too, which the local list price does not.'];
222
+ const ago = (ms) => ms < 60e3 ? 'just now' : ms < H ? `${Math.round(ms / 60e3)}m ago` : ms < D ? `${Math.round(ms / H)}h ago` : `${Math.round(ms / D)}d ago`;
223
+ const span = (a, b) => `${day(a)} ${hm(a)} – ${day(b)} ${hm(b)}`;
224
+ function liveLines(lv, redact, now) {
225
+ if (!lv.snapshots) return ['From the status line: no readings yet. Run `glassbox statusline install`, then use Claude Code for a while.'];
226
+ const L = [`From the status line: ${lv.snapshots} reading${lv.snapshots === 1 ? '' : 's'}, newest ${ago(now - lv.newest)}`];
227
+ const n = lv.now;
228
+ L.push(` latest ${n.five ? `5h ${pct(n.five.pct / 100)} (resets ${hm(n.five.resets)})` : '5h — (reset since)'} · ${n.week ? `week ${pct(n.week.pct / 100)} (resets ${day(n.week.resets)} ${hm(n.week.resets)})` : 'week — (reset since)'}`);
229
+ if (lv.fiveHour.length) { L.push(' 5-hour windows (exact)'); for (const w of lv.fiveHour) L.push(` ${day(w.start)} ${hm(w.start)}–${hm(w.end)} peak ${pct(w.peak / 100).padStart(4)} ${usd(w.list).padStart(10)} local${w.open ? ' (open)' : ''}`); }
230
+ if (lv.weeks.length) { L.push(' weeks'); for (const w of lv.weeks) L.push(` ${span(w.start, w.end)} peak ${pct(w.peak / 100).padStart(4)} ${usd(w.list).padStart(10)} local${w.open ? ' (open)' : ''}`); }
231
+ if (lv.sessions.length) {
232
+ L.push(' sessions, by how much the week moved while each ran (other usage in that time counts too)');
233
+ for (const s of lv.sessions) L.push(` ${s.session.slice(0, 8)} week +${pct(s.week / 100)} 5h +${pct(s.five / 100)} ${usd(s.list).padStart(9)} local${!redact && s.title ? ` "${s.title.slice(0, 50)}"` : ''}`);
234
+ }
235
+ return L;
236
+ }
237
+
238
+ export function planMarkdown(rep) {
239
+ const L = [`## ${headline(rep)}`, '', '| Period | List price | Plan | Multiple | Top models |', '|---|---:|---:|---:|---|'];
240
+ for (const p of rep.periods) L.push(`| ${periodLabel(p)}${p.current ? ` (day ${p.daysElapsed} of ${p.days})` : ''} | ${usd(p.list)} | ${rep.plan.price != null ? usd(rep.plan.price) : '—'} | ${p.multiple != null ? mult(p.multiple) : '—'} | ${modelLine(p.models)} |`);
241
+ L.push(`| last 7 days | ${usd(rep.last7d.list)} | | | ${modelLine(rep.last7d.models)} |`);
242
+ if (rep.plan.key) {
243
+ L.push('', `### 5-hour windows, last ${sinceText(rep.since)}: ${rep.windows.count}`, '', '*Rebuilt from request times; usage elsewhere can shift them.*', '', '| Window | List price | Requests | Top session |', '|---|---:|---:|---|');
244
+ const rows = rep.windows.current ? [rep.windows.current, ...rep.windows.top.filter((w) => w.start !== rep.windows.current.start)] : rep.windows.top;
245
+ for (const w of rows) L.push(`| ${w.open ? 'now: ' : ''}${day(w.start)} ${hm(w.start)}–${hm(w.end)} | ${usd(w.list)} | ${w.requests} | ${w.top ? `\`${w.top.session.slice(0, 8)}\` ${pct(w.top.share)}${!rep.redacted && w.top.title ? ` ${w.top.title.slice(0, 50).replace(/\|/g, '\\|')}` : ''}` : ''} |`);
246
+ L.push('', `**Limit hits:** ${rep.limitHits.length ? rep.limitHits.map((h) => `${day(h.t)} ${hm(h.t)} (${h.kind === 'plan' ? 'plan limit' : 'rate limited'}, \`${h.session.slice(0, 8)}\`)`).join(', ') : `none in the last ${sinceText(rep.since)}`}`);
247
+ }
248
+ if (rep.live) L.push('', '### Live', '', '```', ...liveLines(rep.live, rep.redacted, rep.generated), '```');
249
+ if (rep.unpriced.requests) L.push('', `${rep.unpriced.requests} request(s) not priced: no rate for ${rep.unpriced.models.join(', ') || 'an unnamed model'}.`);
250
+ const rl = core.rateCardLine(rep.rateCard); if (rl) L.push('', `> ${rl}`);
251
+ L.push('', (rep.live && rep.live.snapshots ? NOTE_LIVE : NOTE).join(' '), '');
252
+ return L.join('\n');
253
+ }
@@ -0,0 +1,154 @@
1
+ // glassbox statusline — the plan's real limits in Claude Code's status bar, and a log of them for `plan --live`.
2
+ //
3
+ // Claude Code gives a status-line command what it never writes to a transcript: `rate_limits.five_hour` and
4
+ // `rate_limits.seven_day`, each { used_percentage 0–100, resets_at epoch seconds }, for subscribers, after the first
5
+ // API response. This prints them in one line and appends a snapshot to <claude home>/glassbox/limits.jsonl when
6
+ // they change, so `plan --live` can show exact windows and what each session took (DESIGN.md §23). It runs on
7
+ // every status refresh, so it parses no transcript and never fails loudly: whatever goes wrong, it prints a line
8
+ // and exits 0. Nothing leaves the machine.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { claudeHome, settingsPath } from './cli.mjs';
13
+ import { readTextFile } from './textfile.mjs';
14
+
15
+ const M = 60e3, H = 60 * M;
16
+ export const LIMITS_CAP = 4 * 1024 * 1024; // then limits.jsonl becomes limits.1.jsonl (one older file kept)
17
+ const REPEAT_MS = 10 * M; // an unchanged reading is logged again after this long
18
+ export const limitsFile = (home) => path.join(home || claudeHome(), 'glassbox', 'limits.jsonl');
19
+ export const previousFile = (home) => path.join(home || claudeHome(), 'glassbox', 'statusline-previous.json');
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Snapshots
23
+ // ---------------------------------------------------------------------------
24
+ const toMs = (v) => typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : typeof v === 'string' ? Date.parse(v) : NaN;
25
+ const win = (w) => w && typeof w.used_percentage === 'number' && Number.isFinite(toMs(w.resets_at)) ? { pct: w.used_percentage, resets: toMs(w.resets_at) } : null;
26
+
27
+ export function snapshotFrom(input, now = Date.now()) {
28
+ const rl = input && input.rate_limits;
29
+ if (!rl) return null;
30
+ const five = win(rl.five_hour), week = win(rl.seven_day);
31
+ if (!five && !week) return null;
32
+ return { t: now, session: input.session_id || null, model: (input.model && input.model.id) || null, five, week };
33
+ }
34
+
35
+ const same = (a, b) => (!a && !b) || (a && b && a.pct === b.pct && a.resets === b.resets);
36
+ // The last few KB of the log, parsed newest first (a partial first line is skipped).
37
+ function tail(file, bytes = 65536) {
38
+ let fd; try { fd = fs.openSync(file, 'r'); const size = fs.fstatSync(fd).size; const n = Math.min(size, bytes); const buf = Buffer.alloc(n); fs.readSync(fd, buf, 0, n, size - n);
39
+ const lines = buf.toString('utf8').split('\n'); if (n < size) lines.shift();
40
+ const out = []; for (let i = lines.length - 1; i >= 0; i--) { if (!lines[i].trim()) continue; try { out.push(JSON.parse(lines[i])); } catch (e) { } } return out;
41
+ } catch (e) { return []; } finally { if (fd != null) fs.closeSync(fd); }
42
+ }
43
+
44
+ // Append when this session's reading changed, or REPEAT_MS after its last line. Returns whether it wrote.
45
+ export function logSnapshot(home, snap) {
46
+ if (!snap) return false;
47
+ const file = limitsFile(home);
48
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
49
+ const last = tail(file).find((s) => s.session === snap.session);
50
+ if (last && same(last.five, snap.five) && same(last.week, snap.week) && snap.t - last.t < REPEAT_MS) return false;
51
+ try { if (fs.statSync(file).size > LIMITS_CAP) fs.renameSync(file, file.replace(/\.jsonl$/, '.1.jsonl')); } catch (e) { }
52
+ fs.appendFileSync(file, JSON.stringify(snap) + '\n', { mode: 0o600 });
53
+ return true;
54
+ }
55
+
56
+ export function readSnapshots(home) {
57
+ const file = limitsFile(home), out = [];
58
+ for (const f of [file.replace(/\.jsonl$/, '.1.jsonl'), file]) {
59
+ if (!fs.existsSync(f)) continue;
60
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) { if (!line.trim()) continue; try { const s = JSON.parse(line); if (typeof s.t === 'number') out.push(s); } catch (e) { } }
61
+ }
62
+ return out.sort((a, b) => a.t - b.t);
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // The line
67
+ // ---------------------------------------------------------------------------
68
+ const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
69
+ const p2 = (n) => String(n).padStart(2, '0');
70
+ const hm = (t) => { const d = new Date(t); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; };
71
+ const left = (ms) => { const m = Math.max(0, Math.round(ms / M)); return m >= 60 ? `${Math.floor(m / 60)}h${p2(m % 60)}m` : `${m}m`; };
72
+ const pctText = (v) => (Math.round(v * 10) / 10) + '%';
73
+
74
+ export function statusText(input, opts = {}) {
75
+ const now = opts.now != null ? opts.now : Date.now();
76
+ const color = opts.color !== false;
77
+ const paint = (pct, s) => !color ? s : `\x1b[${pct >= 80 ? 31 : pct >= 50 ? 33 : 32}m${s}\x1b[0m`;
78
+ const dim = (s) => color ? `\x1b[2m${s}\x1b[0m` : s;
79
+ const parts = [];
80
+ const model = input && input.model && (input.model.display_name || input.model.id);
81
+ const ctx = input && input.context_window && typeof input.context_window.used_percentage === 'number' ? input.context_window.used_percentage : null;
82
+ if (model) parts.push(model + (ctx != null ? dim(` · ctx ${Math.round(ctx)}%`) : ''));
83
+ if (input && input.rate_limits_available === false) parts.push(dim('no plan limits (API key, Bedrock or Vertex)'));
84
+ else {
85
+ const snap = snapshotFrom(input, now);
86
+ const five = snap && snap.five, week = snap && snap.week;
87
+ parts.push(five ? `5h ${paint(five.pct, pctText(five.pct))}${dim(' · ' + left(five.resets - now) + ' left')}` : dim('5h —'));
88
+ parts.push(week ? `week ${paint(week.pct, pctText(week.pct))}${dim(' · resets ' + (week.resets - now > 24 * H ? DAYS[new Date(week.resets).getDay()] + ' ' : '') + hm(week.resets))}` : dim('week —'));
89
+ }
90
+ return parts.join(dim(' │ '));
91
+ }
92
+
93
+ // A status line the user had before `statusline install` keeps running, in front of ours. Process start can take
94
+ // seconds on a busy Windows machine, so when it runs out of time its last good line is shown instead of nothing.
95
+ function previousLine(home, raw, timeout = 2000) {
96
+ const f = previousFile(home);
97
+ if (!fs.existsSync(f)) return null;
98
+ let prev; try { prev = JSON.parse(readTextFile(f)).statusLine; } catch (e) { return null; }
99
+ if (!prev || prev.type !== 'command' || typeof prev.command !== 'string') return null;
100
+ const cache = f.replace(/\.json$/, '.last');
101
+ const r = spawnSync(prev.command, { shell: true, input: raw, encoding: 'utf8', timeout, windowsHide: true });
102
+ const line = String(r.stdout || '').split('\n')[0].trimEnd();
103
+ if (line && !r.error) { try { fs.writeFileSync(cache, line); } catch (e) { } return line; }
104
+ try { return fs.readFileSync(cache, 'utf8') || null; } catch (e) { return null; }
105
+ }
106
+
107
+ // What `glassbox statusline` does on each refresh: log, then print.
108
+ export function statusline(input, opts = {}) {
109
+ const home = opts.home || claudeHome();
110
+ const now = opts.now != null ? opts.now : Date.now();
111
+ try { logSnapshot(home, snapshotFrom(input, now)); } catch (e) { } // a full disk must not blank the status bar
112
+ const ours = statusText(input, { now, color: opts.color });
113
+ let theirs = null; try { theirs = previousLine(home, opts.raw != null ? opts.raw : JSON.stringify(input || {}), opts.previousTimeout); } catch (e) { }
114
+ return theirs ? `${theirs} │ ${ours}` : ours;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // install / uninstall
119
+ // ---------------------------------------------------------------------------
120
+ export const STATUS_RE = /\bglassbox(?:-trace)?(?:\.mjs)?["']?\s+statusline\b/;
121
+ const ours = (sl) => sl && typeof sl.command === 'string' && STATUS_RE.test(sl.command);
122
+
123
+ export function installStatusLine(opts = {}) {
124
+ const home = opts.home || claudeHome();
125
+ const file = settingsPath(home);
126
+ let settings = {};
127
+ if (fs.existsSync(file)) { try { settings = JSON.parse(readTextFile(file)); } catch (e) { throw new Error(`${file} is not valid JSON — fix it first (nothing was changed)`); } fs.copyFileSync(file, file + '.glassbox-backup'); }
128
+ let kept = null;
129
+ if (settings.statusLine && !ours(settings.statusLine)) {
130
+ const prev = previousFile(home); fs.mkdirSync(path.dirname(prev), { recursive: true, mode: 0o700 });
131
+ fs.writeFileSync(prev, JSON.stringify({ savedAt: new Date().toISOString(), statusLine: settings.statusLine }, null, 2) + '\n');
132
+ kept = settings.statusLine.command || '(not a command)';
133
+ }
134
+ const command = `${opts.command} statusline${opts.noColor ? ' --no-color' : ''}`;
135
+ settings.statusLine = { type: 'command', command, padding: 0 };
136
+ fs.mkdirSync(path.dirname(file), { recursive: true });
137
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
138
+ return { file, command, kept };
139
+ }
140
+
141
+ export function uninstallStatusLine(opts = {}) {
142
+ const home = opts.home || claudeHome();
143
+ const file = settingsPath(home), prev = previousFile(home);
144
+ if (!fs.existsSync(file)) return { file, removed: false, restored: null };
145
+ const settings = JSON.parse(readTextFile(file));
146
+ if (!ours(settings.statusLine)) return { file, removed: false, restored: null };
147
+ fs.copyFileSync(file, file + '.glassbox-backup');
148
+ let restored = null;
149
+ if (fs.existsSync(prev)) { try { restored = JSON.parse(readTextFile(prev)).statusLine || null; } catch (e) { } fs.unlinkSync(prev); }
150
+ try { fs.unlinkSync(prev.replace(/\.json$/, '.last')); } catch (e) { }
151
+ if (restored) settings.statusLine = restored; else delete settings.statusLine;
152
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
153
+ return { file, removed: true, restored: restored ? restored.command || '(restored)' : null };
154
+ }