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/README.md +32 -13
- package/dist/glassbox.html +380 -25
- package/package.json +5 -2
- package/src/cli.mjs +83 -15
- package/src/tail.mjs +141 -0
- package/src/trace-core.js +217 -9
- package/src/viewer.html +163 -16
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glassbox-trace",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
90
|
-
const
|
|
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 =
|
|
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
|
|
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 —
|
|
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] [--
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|