glassbox-trace 0.3.0 → 0.4.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "glassbox-trace",
3
- "version": "0.3.0",
4
- "description": "The flight recorder viewer for Claude Code / Agent SDK sessions: drop a session .jsonl on one HTML file and get a timeline, token and cost burn, and auto-diagnostics. Includes a CLI and a Claude Code Stop hook.",
3
+ "version": "0.4.1",
4
+ "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
5
  "main": "src/trace-core.js",
6
6
  "scripts": {
7
7
  "test": "node --test test/*.test.mjs",
@@ -29,6 +29,9 @@
29
29
  },
30
30
  "keywords": [
31
31
  "claude-code",
32
+ "claude-code-log",
33
+ "compare",
34
+ "live-tail",
32
35
  "claude",
33
36
  "agent",
34
37
  "agent-sdk",
package/src/cli.mjs CHANGED
@@ -4,12 +4,16 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { createRequire } from 'node:module';
7
+ import { fileURLToPath } from 'node:url';
7
8
  const require = createRequire(import.meta.url);
8
9
  const core = require('./trace-core.js');
9
- const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
10
+ // fileURLToPath, not URL.pathname: on Windows the latter gives "/C:/…" which path.resolve turns into "\C:\…".
11
+ export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
12
 
11
13
  export const claudeHome = (env = process.env) => env.GLASSBOX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.claude');
12
- export const decodeProject = (n) => n.replace(/^-/, '/').replace(/-/g, '/');
14
+ // Claude Code encodes the project path with '-' for every separator: "/home/aldo/x" → "-home-aldo-x",
15
+ // and on Windows "C:\Users\aldo" → "C--Users-aldo".
16
+ export const decodeProject = (n) => { const m = n.match(/^([A-Za-z])--(.*)$/); if (m) return m[1] + ':/' + m[2].replace(/-/g, '/'); return n.replace(/^-/, '/').replace(/-/g, '/'); };
13
17
 
14
18
  export function findSessions(opts = {}) {
15
19
  const home = opts.home || claudeHome();
@@ -41,7 +45,7 @@ export function sessionTitle(file) {
41
45
 
42
46
  // Resolve a user-supplied target: a path to a .jsonl, or a session-id prefix.
43
47
  export function resolveTarget(target, opts = {}) {
44
- if (!target) { const s = findSessions({ home: opts.home, last: 1 })[0]; if (!s) throw new Error('No sessions found under ' + path.join(opts.home || claudeHome(), 'projects')); return s; }
48
+ if (!target) { const s = findSessions({ home: opts.home, last: 1 })[0]; if (!s) throw new Error(noSessionsMessage(opts.home)); return s; }
45
49
  if (fs.existsSync(target) && fs.statSync(target).isFile()) { const file = path.resolve(target); return { id: path.basename(file).replace(/\.jsonl$/, ''), file, project: null, projectDir: path.dirname(file), size: fs.statSync(file).size, mtime: fs.statSync(file).mtimeMs }; }
46
50
  const all = findSessions({ home: opts.home });
47
51
  const hits = all.filter((s) => s.id.startsWith(target));
@@ -50,6 +54,11 @@ export function resolveTarget(target, opts = {}) {
50
54
  throw new Error(`No session or file matches "${target}"`);
51
55
  }
52
56
 
57
+ export function noSessionsMessage(home) {
58
+ const dir = path.join(home || claudeHome(), 'projects');
59
+ return `No sessions found under ${dir}\n Run a Claude Code session first — every session writes ${path.join(dir, '<project>', '<session-id>.jsonl')}.\n Or point at a file: glassbox open <file.jsonl> (also check / compare / watch)\n Or another home: GLASSBOX_HOME=/path/to/.claude glassbox (or --home)`;
60
+ }
61
+
53
62
  // Session file + its subagent transcripts, as the {name, text} list the core expects.
54
63
  export function loadSessionFiles(s) {
55
64
  const files = [{ name: s.id + '.jsonl', text: fs.readFileSync(s.file, 'utf8') }];
@@ -66,7 +75,8 @@ export function embed(files, opts = {}) {
66
75
  if (!fs.existsSync(template)) throw new Error('dist/glassbox.html not found — run `npm run build` first');
67
76
  const html = fs.readFileSync(template, 'utf8');
68
77
  if (!html.includes('/*__EMBED__*/null')) throw new Error('template has no embed marker');
69
- return html.replace('/*__EMBED__*/null', () => safe(JSON.stringify(files)));
78
+ const payload = Array.isArray(files) ? files : { files: files.files, compare: files.compare || null, labels: files.labels || null };
79
+ return html.replace('/*__EMBED__*/null', () => safe(JSON.stringify(payload)));
70
80
  }
71
81
 
72
82
  export function openInBrowser(file, env = process.env) {
@@ -86,8 +96,9 @@ export function checkReport({ trace, findings, cost }, opts = {}) {
86
96
  const T = trace.totals, m = trace.meta;
87
97
  const fmt = core.fmtDur, fi = core.fmtInt;
88
98
  const usd = (v) => v == null ? '—' : '$' + (v < 1 ? v.toFixed(3) : v.toFixed(2));
89
- const summary = { session: m.sessionId, title: m.title || null, model: m.models, wallMs: T.wallMs, activeMs: T.activeMs, turns: T.turns, requests: T.requests, toolCalls: T.toolCalls, toolErrors: T.toolErrors, orphans: T.orphans, usage: T.usage, cacheHitRatio: T.cacheHitRatio, cost: cost.reported != null ? cost.reported : cost.total, costSource: cost.source };
90
- const json = { summary, findings: findings.map((f) => ({ id: f.id, severity: f.severity, title: f.title, detail: f.detail, metric: f.metric })), failOn, failed: failing.length > 0 };
99
+ const redact = !!opts.redact;
100
+ const summary = { session: m.sessionId, title: m.title ? (redact ? '«' + m.title.length + ' chars»' : m.title) : null, model: m.models, wallMs: T.wallMs, activeMs: T.activeMs, turns: T.turns, requests: T.requests, toolCalls: T.toolCalls, toolErrors: T.toolErrors, orphans: T.orphans, usage: T.usage, cacheHitRatio: T.cacheHitRatio, cost: cost.reported != null ? cost.reported : cost.total, costSource: cost.source };
101
+ const json = { glassbox: core.VERSION, schema: 1, redacted: redact, summary, findings: findings.map((f) => ({ id: f.id, severity: f.severity, title: f.title, detail: redact ? core.redactDetail(f) : f.detail, metric: f.metric, evidence: f.evidence })), failOn, failed: failing.length > 0 };
91
102
  const lines = [];
92
103
  lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
93
104
  lines.push(` wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
@@ -95,9 +106,10 @@ export function checkReport({ trace, findings, cost }, opts = {}) {
95
106
  lines.push('');
96
107
  if (!findings.length) lines.push(' no findings');
97
108
  for (const f of findings) lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.id.padEnd(16)} ${f.title}`);
109
+ if (redact) lines.push('', ' (redacted: prompt text, tool inputs and result text blanked)');
98
110
  lines.push('');
99
111
  lines.push(failing.length ? ` FAIL: ${failing.length} finding${failing.length > 1 ? 's' : ''} at or above "${failOn}"` : ` OK: nothing at or above "${failOn}"`);
100
- const md = ['# Glassbox report', '', `- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}`, `- wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed)`, `- context served ${fi(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} · output ${fi(T.usage.output)} · est. cost ${usd(summary.cost)}`, '', `## Findings (${findings.length})`, '', ...(findings.length ? findings.map((f) => `- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}\n ${f.detail}`) : ['Nothing flagged.']), ''].join('\n');
112
+ const md = core.reportMarkdown(trace, findings, cost, { redact, maxEvidence: opts.maxEvidence });
101
113
  return { text: lines.join('\n'), json, markdown: md, failed: failing.length > 0 };
102
114
  }
103
115
 
@@ -130,7 +142,7 @@ export function hookResponse(input, opts = {}) {
130
142
  // Feedback loop: only on Stop, only once (stop_hook_active guards against loops), only when something is worth saying.
131
143
  if (feedback && input.hook_event_name === 'Stop' && !input.stop_hook_active && top.length) {
132
144
  out.decision = 'block';
133
- out.reason = `Glassbox read this session's flight recorder. ${head}\n\nFindings at or above "${failOn}":\n` + top.map((f) => `- ${f.severity.toUpperCase()} \`${f.id}\` — ${f.title}. ${f.detail}`).join('\n') + `\n\nIn one or two sentences, tell the user what you would do differently next session (no need to redo work), then stop.`;
145
+ out.reason = `Glassbox read this session's flight recorder (${head}).\n\n` + core.reportMarkdown(res.trace, top, null, { maxEvidence: 3, instruction: false, title: 'this session' }).replace(/^# [^\n]*\n\n/, '').replace(/\n## Tools[\s\S]*?(?=\n_Generated)/, '\n').replace(/\n_Generated[^\n]*\n?$/, '') + `\nIn one or two sentences, tell the user what you would do differently next session (no need to redo work), then stop.`;
134
146
  }
135
147
  return out;
136
148
  }
@@ -175,21 +187,27 @@ export function parseArgs(argv) {
175
187
  const args = { _: [], flags: {} };
176
188
  for (let i = 0; i < argv.length; i++) {
177
189
  const a = argv[i];
178
- if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
190
+ if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
179
191
  else args._.push(a);
180
192
  }
181
193
  return args;
182
194
  }
183
195
 
184
- export const HELP = `glassbox — the flight recorder viewer for Claude Code sessions
196
+ export const HELP = `glassbox — other tools show you what happened in a Claude Code session; Glassbox tells you what went wrong
185
197
 
186
198
  glassbox open the newest session in your browser
187
199
  glassbox list [--last N] [--grep TEXT] [--project PATH]
188
200
  list sessions, newest first
189
201
  glassbox open [ID|FILE] [--out FILE.html] [--no-open]
190
202
  build a self-contained HTML for a session (id prefix or .jsonl path)
191
- glassbox check [ID|FILE] [--fail-on error|warn|info] [--json] [--markdown]
192
- print findings; exit 1 when any finding is at/above --fail-on (default: error)
203
+ glassbox check [ID|FILE] [--fail-on error|warn|info] [--format text|json|md] [--redact]
204
+ print findings; exit 1 when any finding is at/above --fail-on (default: error), 2 on a usage error
205
+ --format md is written to be pasted back to the agent: evidence + advice per finding
206
+ --redact blanks prompt text, tool inputs and result text so the report can be shared
207
+ glassbox compare A B [--format text|json|md] [--out FILE.html] [--label-a NAME --label-b NAME]
208
+ same task, two sessions: what changed in time, tokens, cost, tools and findings
209
+ glassbox watch [ID|FILE] [--port N] [--no-open]
210
+ live tail: serves the viewer on 127.0.0.1 and pushes the transcript as it grows
193
211
  glassbox hook install [--feedback] [--fail-on warn]
194
212
  add a Claude Code Stop hook so every session ends with a Glassbox summary;
195
213
  --feedback also hands the findings back to the agent once, so it can learn from them
@@ -202,6 +220,29 @@ export const HELP = `glassbox — the flight recorder viewer for Claude Code ses
202
220
 
203
221
  Nothing leaves your machine. Subagent transcripts next to the session are included automatically.`;
204
222
 
223
+ export function outputFormat(flags) {
224
+ const f = flags.format ? String(flags.format).toLowerCase() : (flags.json ? 'json' : flags.markdown ? 'md' : 'text');
225
+ if (f === 'markdown') return 'md';
226
+ if (!['text', 'json', 'md'].includes(f)) throw new Error(`--format must be text, json or md (got "${f}")`);
227
+ return f;
228
+ }
229
+
230
+ export function compareReport(A, B, opts = {}) {
231
+ const c = core.compare(A, B);
232
+ const la = opts.labelA || (A.trace.meta.sessionId || 'A').slice(0, 8), lb = opts.labelB || (B.trace.meta.sessionId || 'B').slice(0, 8);
233
+ const who = (x) => x === 'a' ? la : x === 'b' ? lb : 'tie';
234
+ const lines = [`Glassbox compare · ${la} vs ${lb}`, ` cheaper: ${who(c.verdict.cheaper)} · faster: ${who(c.verdict.faster)} · cleaner: ${who(c.verdict.cleaner)}`, ''];
235
+ const w = Math.max(la.length, lb.length, 10);
236
+ lines.push(' ' + 'metric'.padEnd(22) + la.padStart(w) + ' ' + lb.padStart(w) + ' change');
237
+ for (const m of c.metrics) { const ch = core.fmtChange(m); lines.push(' ' + m.label.padEnd(22) + m.aText.padStart(w) + ' ' + m.bText.padStart(w) + ' ' + ch + (m.better ? ' (' + who(m.better) + ')' : '')); }
238
+ lines.push('');
239
+ const fl = (list, label) => { if (!list.length) return; lines.push(` only in ${label}:`); for (const f of list) lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.id.padEnd(16)} ${f.title}`); };
240
+ fl(c.findings.onlyA, la); fl(c.findings.onlyB, lb);
241
+ if (c.findings.both.length) lines.push(` in both: ${c.findings.both.map((f) => f.id).join(', ')}`);
242
+ if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) lines.push(' no findings in either session');
243
+ return { text: lines.join('\n'), json: c, markdown: core.compareMarkdown(c, { labelA: la, labelB: lb }), compare: c };
244
+ }
245
+
205
246
  export async function main(argv, io = {}) {
206
247
  const out = io.stdout || ((s) => process.stdout.write(s + '\n'));
207
248
  const err = io.stderr || ((s) => process.stderr.write(s + '\n'));
@@ -213,16 +254,33 @@ export async function main(argv, io = {}) {
213
254
  try {
214
255
  if (cmd === 'list') {
215
256
  const list = findSessions({ home, last: args.flags.last ? +args.flags.last : 20, grep: args.flags.grep, project: args.flags.project });
216
- if (!list.length) { out('No sessions found under ' + path.join(home || claudeHome(), 'projects')); return 0; }
257
+ if (!list.length) { out(noSessionsMessage(home)); return 0; }
217
258
  for (const s of list) out(`${s.id.slice(0, 8)} ${new Date(s.mtime).toISOString().slice(0, 16).replace('T', ' ')} ${String(Math.round(s.size / 1024)).padStart(6)} KB ${s.project.padEnd(28).slice(0, 28)} ${sessionTitle(s.file)}`);
218
259
  return 0;
219
260
  }
220
261
  if (cmd === 'check') {
221
262
  const s = resolveTarget(args._[1], { home });
222
- const res = checkReport(analyse(loadSessionFiles(s)), { failOn: args.flags['fail-on'] });
223
- if (args.flags.json) out(JSON.stringify(res.json, null, 2)); else if (args.flags.markdown) out(res.markdown); else out(res.text);
263
+ const res = checkReport(analyse(loadSessionFiles(s)), { failOn: args.flags['fail-on'], redact: !!args.flags.redact });
264
+ const fmtOut = outputFormat(args.flags);
265
+ if (fmtOut === 'json') out(JSON.stringify(res.json, null, 2)); else if (fmtOut === 'md') out(res.markdown); else out(res.text);
224
266
  return res.failed ? 1 : 0;
225
267
  }
268
+ if (cmd === 'compare') {
269
+ if (!args._[1] || !args._[2]) throw new Error('compare needs two sessions: glassbox compare A B');
270
+ const sa = resolveTarget(args._[1], { home }), sb = resolveTarget(args._[2], { home });
271
+ const fa = loadSessionFiles(sa), fb = loadSessionFiles(sb);
272
+ const rep = compareReport(analyse(fa), analyse(fb), { labelA: args.flags['label-a'], labelB: args.flags['label-b'] });
273
+ if (args.flags.out) {
274
+ const html = embed({ files: fa, compare: fb }, { template: io.template });
275
+ const outFile = path.resolve(args.flags.out); fs.writeFileSync(outFile, html);
276
+ out(`${outFile} (${fa.length + fb.length} files, ${Math.round(html.length / 1024)} KB)`);
277
+ if (!args.flags['no-open'] && !io.noOpen) openInBrowser(outFile);
278
+ return 0;
279
+ }
280
+ const fmtOut = outputFormat(args.flags);
281
+ if (fmtOut === 'json') out(JSON.stringify(rep.json, null, 2)); else if (fmtOut === 'md') out(rep.markdown); else out(rep.text);
282
+ return 0;
283
+ }
226
284
  if (cmd === 'open') {
227
285
  const s = resolveTarget(args._[1], { home });
228
286
  const files = loadSessionFiles(s);
@@ -233,6 +291,16 @@ export async function main(argv, io = {}) {
233
291
  if (!args.flags['no-open'] && !io.noOpen) openInBrowser(outFile);
234
292
  return 0;
235
293
  }
294
+ if (cmd === 'watch' || (cmd === 'open' && args.flags.watch)) {
295
+ const { serveLive } = await import('./tail.mjs');
296
+ const s = resolveTarget(args._[1], { home });
297
+ const live = await serveLive(s, { port: args.flags.port ? +args.flags.port : 0, embed: (files) => embed(files, { template: io.template }) });
298
+ out(`Glassbox live · ${s.id.slice(0, 8)} · ${live.url}\n following ${s.file}\n Ctrl+C to stop`);
299
+ if (!args.flags['no-open'] && !io.noOpen) openInBrowser(live.url);
300
+ if (io.onLive) { io.onLive(live); return 0; }
301
+ await new Promise((resolve) => { const stop = () => { live.close().then(resolve); }; process.once('SIGINT', stop); process.once('SIGTERM', stop); });
302
+ return 0;
303
+ }
236
304
  if (cmd === 'hook') {
237
305
  const sub = args._[1];
238
306
  if (sub === 'install') { const r = installHook({ home, feedback: !!args.flags.feedback, failOn: args.flags['fail-on'], command: args.flags.command, events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed Stop hook in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}. Restart Claude Code to pick it up.`); return 0; }
package/src/tail.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // Glassbox live tail — follow a session transcript (and its subagents) as it grows, and serve
2
+ // the viewer with Server-Sent Events on 127.0.0.1. No dependencies. Nothing leaves the machine.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import http from 'node:http';
6
+
7
+ // Follow one session's files by byte offset. Emits:
8
+ // { type: 'snapshot', files: [{name, text}] } once, at start()
9
+ // { type: 'append', name, text } new complete lines
10
+ // { type: 'replace', name, text } file shrank or was rewritten → whole file again
11
+ // A trailing partial line (no newline yet) is held back until it completes.
12
+ export class Tailer {
13
+ constructor(session, opts = {}) {
14
+ this.session = session;
15
+ this.pollMs = opts.pollMs || 1000;
16
+ this.files = new Map(); // name -> { path, offset, pending }
17
+ this.listeners = [];
18
+ this.timer = null;
19
+ this.watchers = [];
20
+ }
21
+ on(fn) { this.listeners.push(fn); return this; }
22
+ emit(ev) { for (const fn of this.listeners) fn(ev); }
23
+
24
+ // All files that belong to the session right now: main + subagents dir.
25
+ discover() {
26
+ const s = this.session; const out = [{ name: s.id + '.jsonl', path: s.file }];
27
+ const sub = path.join(s.projectDir, s.id, 'subagents');
28
+ if (fs.existsSync(sub)) for (const f of fs.readdirSync(sub).sort()) if (/\.(jsonl|json)$/.test(f)) out.push({ name: `${s.id}/subagents/${f}`, path: path.join(sub, f) });
29
+ return out;
30
+ }
31
+
32
+ // Read everything new. Returns the events it emitted (also emits them).
33
+ tick() {
34
+ const events = [];
35
+ for (const { name, path: p } of this.discover()) {
36
+ let st; try { st = fs.statSync(p); } catch (e) { continue; }
37
+ let f = this.files.get(name);
38
+ if (!f) { f = { path: p, offset: 0, pending: Buffer.alloc(0) }; this.files.set(name, f); }
39
+ if (st.size < f.offset) { // truncated or rewritten
40
+ f.offset = 0; f.pending = Buffer.alloc(0);
41
+ const text = this.readFrom(f, st.size, true);
42
+ events.push({ type: 'replace', name, text });
43
+ continue;
44
+ }
45
+ if (st.size === f.offset) continue;
46
+ const text = this.readFrom(f, st.size, false);
47
+ if (text) events.push({ type: 'append', name, text });
48
+ }
49
+ for (const ev of events) this.emit(ev);
50
+ return events;
51
+ }
52
+
53
+ // Read [offset, size) from the file; split at the last newline byte and keep the tail pending
54
+ // as bytes, so a multi-byte UTF-8 character cut across two appends is reassembled intact.
55
+ readFrom(f, size, whole) {
56
+ const len = size - f.offset; if (len <= 0) return '';
57
+ const fd = fs.openSync(f.path, 'r');
58
+ let chunk;
59
+ try { const buf = Buffer.alloc(len); const n = fs.readSync(fd, buf, 0, len, f.offset); chunk = buf.subarray(0, n); } finally { fs.closeSync(fd); }
60
+ f.offset = size;
61
+ const all = f.pending && f.pending.length ? Buffer.concat([f.pending, chunk]) : chunk;
62
+ const cut = all.lastIndexOf(0x0a);
63
+ if (cut < 0) { if (whole) { f.pending = Buffer.alloc(0); return all.toString('utf8'); } f.pending = Buffer.from(all); return ''; }
64
+ f.pending = Buffer.from(all.subarray(cut + 1));
65
+ return all.subarray(0, cut + 1).toString('utf8');
66
+ }
67
+
68
+ // Full current content of every file (complete lines only), as the viewer expects them.
69
+ snapshot() {
70
+ const files = [];
71
+ for (const { name, path: p } of this.discover()) {
72
+ let buf; try { buf = fs.readFileSync(p); } catch (e) { continue; }
73
+ const cut = buf.lastIndexOf(0x0a);
74
+ this.files.set(name, { path: p, offset: buf.length, pending: Buffer.from(cut < 0 ? buf : buf.subarray(cut + 1)) });
75
+ files.push({ name, text: cut < 0 ? '' : buf.subarray(0, cut + 1).toString('utf8') });
76
+ }
77
+ return files;
78
+ }
79
+
80
+ // Everything emitted so far, per file (complete lines only): [0, offset − pending). Drains first so
81
+ // a page that loads from this and then subscribes to /events cannot miss or duplicate a line.
82
+ current() {
83
+ this.tick();
84
+ const out = [];
85
+ for (const [name, f] of this.files) {
86
+ const len = f.offset - f.pending.length; let text = '';
87
+ if (len > 0) { try { const fd = fs.openSync(f.path, 'r'); try { const buf = Buffer.alloc(len); const n = fs.readSync(fd, buf, 0, len, 0); text = buf.toString('utf8', 0, n); } finally { fs.closeSync(fd); } } catch (e) { } }
88
+ out.push({ name, text });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ start() {
94
+ const files = this.snapshot();
95
+ this.emit({ type: 'snapshot', files });
96
+ const kick = () => { try { this.tick(); } catch (e) { /* transient read errors: next tick */ } };
97
+ // fs.watch where it works (fast), plus a poll (fs.watch misses events on some filesystems).
98
+ const dirs = new Set([path.dirname(this.session.file), path.join(this.session.projectDir, this.session.id, 'subagents')]);
99
+ for (const d of dirs) { try { if (fs.existsSync(d)) { const w = fs.watch(d, { persistent: false }, () => setTimeout(kick, 30)); w.on('error', () => { }); this.watchers.push(w); } } catch (e) { /* fall back to polling */ } }
100
+ this.timer = setInterval(kick, this.pollMs); if (this.timer.unref) this.timer.unref();
101
+ return files;
102
+ }
103
+ stop() { if (this.timer) clearInterval(this.timer); this.timer = null; for (const w of this.watchers) { try { w.close(); } catch (e) { } } this.watchers = []; }
104
+ }
105
+
106
+ // Serve the viewer for one session on loopback and stream tail events to it.
107
+ // opts: { port (0 = random), template (dist/glassbox.html path), embed (fn files→html), pollMs }
108
+ export function serveLive(session, opts = {}) {
109
+ const tailer = new Tailer(session, { pollMs: opts.pollMs });
110
+ const clients = new Set();
111
+ const send = (res, type, data) => { try { res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); } catch (e) { clients.delete(res); } };
112
+ tailer.on((ev) => { if (ev.type === 'snapshot') return; for (const res of clients) send(res, ev.type, ev); });
113
+ const server = http.createServer((req, res) => {
114
+ const url = (req.url || '/').split('?')[0];
115
+ if (url === '/' || url === '/index.html') {
116
+ let html = opts.embed(tailer.current());
117
+ html = html.replace('/*__LIVE__*/null', () => JSON.stringify({ url: '/events', session: session.id, file: session.file }));
118
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
119
+ res.end(html); return;
120
+ }
121
+ if (url === '/events') {
122
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' });
123
+ res.write(': glassbox live\n\n');
124
+ clients.add(res);
125
+ send(res, 'hello', { session: session.id, file: session.file, files: tailer.current() });
126
+ req.on('close', () => clients.delete(res));
127
+ return;
128
+ }
129
+ if (url === '/health') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true, session: session.id, clients: clients.size, files: Array.from(tailer.files.keys()) })); return; }
130
+ res.writeHead(404); res.end('not found');
131
+ });
132
+ const ping = setInterval(() => { for (const res of clients) send(res, 'ping', { t: Date.now() }); }, 15000); if (ping.unref) ping.unref();
133
+ return new Promise((resolve, reject) => {
134
+ server.on('error', reject);
135
+ server.listen(opts.port || 0, '127.0.0.1', () => {
136
+ tailer.start();
137
+ const { port } = server.address();
138
+ resolve({ url: `http://127.0.0.1:${port}/`, port, tailer, server, clients, close: () => new Promise((r) => { clearInterval(ping); tailer.stop(); for (const c of clients) { try { c.end(); } catch (e) { } } clients.clear(); server.close(() => r()); }) });
139
+ });
140
+ });
141
+ }