glassbox-trace 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -26
- package/dist/glassbox.html +592 -67
- package/package.json +57 -54
- package/src/cli.mjs +217 -31
- package/src/tail.mjs +141 -0
- package/src/trace-core.js +426 -50
- package/src/viewer.html +166 -17
package/src/viewer.html
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<title>Glassbox</title>
|
|
2
|
-
<meta name="description" content="
|
|
2
|
+
<meta name="description" content="Glassbox tells you what went wrong in a Claude Code or Agent SDK session. Drop a session .jsonl; get a timeline, token and cost burn, auto-diagnostics, session compare and live tail. Nothing leaves your browser.">
|
|
3
3
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2712%27 fill=%27%23161C24%27/%3E%3Crect x=%2712%27 y=%2728%27 width=%2740%27 height=%278%27 rx=%272%27 fill=%27%23FF8A4D%27/%3E%3Crect x=%2712%27 y=%2716%27 width=%2724%27 height=%278%27 rx=%272%27 fill=%27%237DB0F0%27/%3E%3Crect x=%2724%27 y=%2740%27 width=%2728%27 height=%278%27 rx=%272%27 fill=%27%234FD0BB%27/%3E%3C/svg%3E">
|
|
4
4
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
5
5
|
<style>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
--err:#C4342A;--warn:#8A5F00;--info:#2A63B8;--ok:#177A6B;
|
|
13
13
|
--err-soft:rgba(196,52,42,.12);--warn-soft:rgba(138,95,0,.14);--info-soft:rgba(42,99,184,.12);
|
|
14
14
|
--focus:#B8420A;
|
|
15
|
+
--who-a:#2A63B8;--who-b:#5B3FB8;
|
|
15
16
|
--shadow:0 10px 30px rgba(22,28,36,.14);
|
|
16
17
|
--sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
17
18
|
--cond:"IBM Plex Sans Condensed","Arial Narrow",var(--sans);
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
--err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
|
|
29
30
|
--err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
|
|
30
31
|
--focus:#FF8A4D;
|
|
32
|
+
--who-a:#7DB0F0;--who-b:#B39CF5;
|
|
31
33
|
--shadow:0 10px 30px rgba(0,0,0,.5);
|
|
32
34
|
}
|
|
33
35
|
}
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
--err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
|
|
41
43
|
--err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
|
|
42
44
|
--focus:#FF8A4D;
|
|
45
|
+
--who-a:#7DB0F0;--who-b:#B39CF5;
|
|
43
46
|
--shadow:0 10px 30px rgba(0,0,0,.5);
|
|
44
47
|
}
|
|
45
48
|
:root[data-density="compact"]{--fs:12.5px;--pad:8px;--rowh:15px}
|
|
@@ -76,6 +79,33 @@ a{color:var(--accent)}
|
|
|
76
79
|
.banner p{margin:0;flex:1}
|
|
77
80
|
.banner code{font-family:var(--mono);font-size:12.5px}
|
|
78
81
|
|
|
82
|
+
/* live pill + compare */
|
|
83
|
+
.live{display:inline-flex;align-items:center;gap:6px;font:500 11px/1 var(--cond);letter-spacing:.08em;text-transform:uppercase;color:var(--ok);border:1px solid var(--ok);border-radius:999px;padding:4px 9px;margin-left:8px}
|
|
84
|
+
.live i{width:7px;height:7px;border-radius:50%;background:var(--ok);animation:pulse 1.6s ease-in-out infinite}
|
|
85
|
+
.live.off{color:var(--warn);border-color:var(--warn)}.live.off i{background:var(--warn);animation:none}
|
|
86
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
|
|
87
|
+
@media (prefers-reduced-motion:reduce){.live i{animation:none}}
|
|
88
|
+
.cmp{padding:var(--pad)}
|
|
89
|
+
.cmp .verdict{display:flex;gap:18px;flex-wrap:wrap;font-size:13px;margin:0 0 10px;color:var(--muted)}
|
|
90
|
+
.cmp .verdict b{color:var(--ink)}
|
|
91
|
+
.cmp .who{display:inline-block;padding:1px 7px;border-radius:999px;font:500 11px var(--cond);letter-spacing:.06em;text-transform:uppercase;background:var(--panel-2);border:1px solid var(--line);color:var(--ink-2)}
|
|
92
|
+
.cmp .who.a{border-color:var(--who-a);color:var(--who-a)}.cmp .who.b{border-color:var(--who-b);color:var(--who-b)}
|
|
93
|
+
.cmp .grid{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:16px}
|
|
94
|
+
@media (max-width:900px){.cmp .grid{grid-template-columns:1fr}}
|
|
95
|
+
.cmp table{font-size:13px}
|
|
96
|
+
.cmp td.d{min-width:140px}
|
|
97
|
+
.cmp .dbar{display:flex;align-items:center;gap:8px}
|
|
98
|
+
.cmp .dbar .track{position:relative;flex:1;height:8px;background:var(--panel-2);border-radius:4px;overflow:hidden;border:1px solid var(--line-2)}
|
|
99
|
+
.cmp .dbar .fill{position:absolute;top:0;bottom:0;background:var(--faint)}
|
|
100
|
+
.cmp .dbar .fill.good{background:var(--ok)}.cmp .dbar .fill.bad{background:var(--err)}
|
|
101
|
+
.cmp .dbar .ch{font-family:var(--mono);font-size:12px;min-width:56px;text-align:right;color:var(--muted)}
|
|
102
|
+
.cmp .dbar .ch.good{color:var(--ok)}.cmp .dbar .ch.bad{color:var(--err)}
|
|
103
|
+
.cmp h3{font:600 11px var(--cond);letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:14px 0 6px}
|
|
104
|
+
.cmp .fl{margin:0;padding:0;list-style:none;font-size:13px}
|
|
105
|
+
.cmp .fl li{padding:4px 0;border-top:1px solid var(--line-2)}
|
|
106
|
+
.cmp .fl .id{font:11px var(--mono);color:var(--faint);margin-left:6px}
|
|
107
|
+
.cmp .empty{padding:8px 0}
|
|
108
|
+
|
|
79
109
|
/* session line */
|
|
80
110
|
.session{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline;padding:10px 0 0;color:var(--muted);font-size:13px}
|
|
81
111
|
.session .title{color:var(--ink);font-weight:500;font-size:15px}
|
|
@@ -228,7 +258,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
228
258
|
|
|
229
259
|
<div class="app">
|
|
230
260
|
<header class="mast">
|
|
231
|
-
<div class="brand"><h1>Glass<span>box</span></h1><span class="tag">
|
|
261
|
+
<div class="brand"><h1>Glass<span>box</span></h1><span class="tag">what went wrong in your Claude Code session</span><span class="live" id="live" hidden role="status" aria-live="polite"><i aria-hidden="true"></i><span id="live-txt">LIVE</span></span></div>
|
|
232
262
|
<label class="drop" id="drop" tabindex="0" aria-label="Drop a session .jsonl file or folder here, or press Enter to choose files">
|
|
233
263
|
<input type="file" id="file" multiple accept=".jsonl,.json,.txt" class="sr-only">
|
|
234
264
|
<span class="hint"><b>Drop a session .jsonl</b> (or the whole session folder) — parsed in your browser, never uploaded.</span>
|
|
@@ -238,6 +268,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
238
268
|
<button class="btn" id="open-folder" hidden title="Browse ~/.claude/projects and pick a session">Open folder…</button>
|
|
239
269
|
<button class="btn" id="recent" hidden title="Sessions from the folder you opened before">Recent</button>
|
|
240
270
|
<button class="btn" id="demo">Load demo</button>
|
|
271
|
+
<button class="btn" id="compare" title="Load a second session and compare it with this one">Compare…</button>
|
|
272
|
+
<input type="file" id="file-cmp" multiple accept=".jsonl,.json,.txt" class="sr-only" aria-label="Choose the session to compare with">
|
|
241
273
|
<button class="btn" id="share" aria-pressed="false" title="Blank every prompt, input and result so the trace can be shared">Share mode</button>
|
|
242
274
|
<button class="btn" id="export" disabled title="Download a structure-only copy of the transcript">Export redacted</button>
|
|
243
275
|
<button class="btn" id="rates" title="Edit the per-model rate card">Rates</button>
|
|
@@ -254,9 +286,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
254
286
|
<div id="problems" class="problems" hidden role="alert"></div>
|
|
255
287
|
<dl class="strip" id="strip"></dl>
|
|
256
288
|
|
|
289
|
+
<section class="sec" id="cmp-sec" hidden aria-labelledby="h-cmp">
|
|
290
|
+
<div class="sec-h"><h2 id="h-cmp">Compare</h2><div class="tools"><span class="aux" id="cmp-aux"></span><button class="btn sm" id="cmp-swap" title="Put the other session on the timeline">Swap</button><button class="btn sm" id="cmp-copy">Copy as Markdown</button><button class="btn sm" id="cmp-close" aria-label="Close compare">Close</button></div></div>
|
|
291
|
+
<div class="panel cmp" id="cmp"></div>
|
|
292
|
+
</section>
|
|
293
|
+
|
|
257
294
|
<section class="sec" aria-labelledby="h-tl">
|
|
258
295
|
<div class="sec-h"><h2 id="h-tl">Timeline</h2>
|
|
259
296
|
<div class="tools">
|
|
297
|
+
<button class="btn" id="follow" hidden aria-pressed="true" title="Keep the right edge of the timeline on now">Follow</button>
|
|
260
298
|
<div class="search"><label class="sr-only" for="q">Search prompts, tool inputs and results</label><input id="q" type="search" placeholder="Search…"><span class="n" id="q-n" aria-live="polite"></span></div>
|
|
261
299
|
<label class="sr-only" for="fit">Fit to turn</label><select id="fit" class="btn"></select>
|
|
262
300
|
<button class="btn" id="z-out" aria-label="Zoom out">−</button><button class="btn" id="z-in" aria-label="Zoom in">+</button><button class="btn" id="z-reset" aria-label="Reset zoom">⟲</button>
|
|
@@ -315,6 +353,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
315
353
|
<div class="db"><p class="note" style="margin:0 0 8px">The clipboard isn't available here — the text is selected below, press <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>C</kbd>.</p><label class="sr-only" for="copy-text">Text to copy</label><textarea id="copy-text" style="width:100%;height:240px;font:12px/1.45 var(--mono);background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:8px"></textarea></div>
|
|
316
354
|
</dialog>
|
|
317
355
|
|
|
356
|
+
<dialog id="cmp-dlg" aria-labelledby="cmp-h">
|
|
357
|
+
<div class="dh"><h3 id="cmp-h">Compare with another session</h3><button class="x" id="cmp-dlg-close" aria-label="Close">×</button></div>
|
|
358
|
+
<div class="db" style="font-size:13.5px;line-height:1.55">
|
|
359
|
+
<p style="margin:0 0 12px">Same task, two sessions: Glassbox lines up time, tokens, cost, tool use and findings side by side. The session already loaded stays on the timeline as <b>A</b>; the one you pick becomes <b>B</b>.</p>
|
|
360
|
+
<div class="controls"><button class="btn primary" id="cmp-pick">Choose files…</button><button class="btn" id="cmp-folder" hidden>From folder…</button><button class="btn" id="cmp-demo">Use the demo</button></div>
|
|
361
|
+
<p class="note" style="margin:12px 0 0">Or drop the second session's <code class="mono">.jsonl</code> anywhere on the page while this dialog is open. From the CLI: <code class="mono">glassbox compare A B --out compare.html</code>.</p>
|
|
362
|
+
</div>
|
|
363
|
+
</dialog>
|
|
364
|
+
|
|
318
365
|
<dialog id="help-dlg" aria-labelledby="help-h">
|
|
319
366
|
<div class="dh"><h3 id="help-h">How to read Glassbox</h3><button class="x" id="help-close" aria-label="Close">×</button></div>
|
|
320
367
|
<div class="db" style="font-size:13.5px;line-height:1.55" tabindex="0">
|
|
@@ -322,6 +369,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
322
369
|
<p><b>Context & cost.</b> One bar per API request: the size of the prompt the model saw, split into cached reads (light), cache writes (mid) and uncached input (dark). The line is cumulative estimated cost.</p>
|
|
323
370
|
<p><b>Findings.</b> Mechanical rules, no AI: identical calls repeated in a turn, tool error rates, results over 20k characters, context over 120k tokens, mid-session cache invalidation, responses that took over a minute and produced little, truncated outputs, hook and API errors, compaction events. Select one to highlight its evidence on the timeline; copy one or all as Markdown to paste into an issue or back into the agent.</p>
|
|
324
371
|
<p><b>Tokens.</b> Assistant messages are stored one record per content block with the usage repeated on each; Glassbox counts usage once per API request. Thinking tokens are part of output tokens, shown as a share, never double counted. Cost is an estimate from the rate card unless the file carries a reported <code class="mono">total_cost_usd</code>.</p>
|
|
372
|
+
<p><b>Compare.</b> Load a second session (<b>Compare…</b>) and Glassbox lines the two up: every metric with a change bar, tool use side by side, and which findings appear in only one of them. <b>Swap</b> puts the other session on the timeline.</p>
|
|
373
|
+
<p><b>Live.</b> <code class="mono">glassbox watch</code> serves this page from your own machine and streams the transcript as Claude Code writes it. <b>Follow</b> keeps the right edge on now; pan or zoom and it lets go.</p>
|
|
325
374
|
<p><b>Share mode.</b> Blanks every prompt, tool input, result and assistant text in the view; <b>Export redacted</b> downloads a structure-only <code class="mono">.jsonl</code> that keeps timestamps, usage and tool names — safe to post publicly.</p>
|
|
326
375
|
<p><b>Keyboard.</b></p>
|
|
327
376
|
<div class="keys">
|
|
@@ -354,7 +403,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
354
403
|
const say = (msg) => { const s = $('#status'); s.textContent = ''; setTimeout(() => { s.textContent = msg; }, 30); };
|
|
355
404
|
|
|
356
405
|
// ------------------------------------------------------------ state
|
|
357
|
-
const S = { files: [], records: [], trace: null, findings: [], cost: null, rates: loadRates(), share: false, sel: null, hl: new Set(), toolFilter: null, view: null, query: '', matches: new Set(), focusId: null, lastFocus: null, isDemo: false };
|
|
406
|
+
const S = { files: [], records: [], trace: null, findings: [], cost: null, rates: loadRates(), share: false, sel: null, hl: new Set(), toolFilter: null, view: null, query: '', matches: new Set(), focusId: null, lastFocus: null, isDemo: false, other: null, labels: { a: 'A', b: 'B' }, cmp: null, follow: true, live: null };
|
|
407
|
+
const pickingForCompare = () => $('#cmp-dlg').open;
|
|
358
408
|
function loadRates() { try { const r = JSON.parse(store.get('rates') || 'null'); if (r && typeof r === 'object') return r; } catch (e) { } return JSON.parse(JSON.stringify(C.RATES)); }
|
|
359
409
|
const R = (s) => S.share ? '«' + String(s == null ? '' : s).length + ' chars»' : s;
|
|
360
410
|
|
|
@@ -379,9 +429,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
379
429
|
if (!usedEntries) for (const f of Array.from(dt.files || [])) out.push({ name: f.name, file: f });
|
|
380
430
|
await loadFiles(out);
|
|
381
431
|
}
|
|
382
|
-
async function loadFiles(list) { const files = []; for (const { name, file } of list) files.push({ name: (file.webkitRelativePath || name), text: await file.text() }); if (files.length)
|
|
383
|
-
function load(files, isDemo) {
|
|
432
|
+
async function loadFiles(list, forCompare) { const files = []; for (const { name, file } of list) files.push({ name: (file.webkitRelativePath || name), text: await file.text() }); if (!files.length) { say('No .jsonl files found in what was dropped.'); return; } if (forCompare || pickingForCompare()) loadOther(files); else load(files, false); }
|
|
433
|
+
function load(files, isDemo, keepOther) {
|
|
384
434
|
S.files = files; S.isDemo = !!isDemo;
|
|
435
|
+
if (!keepOther) { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; }
|
|
385
436
|
S.records = files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
|
|
386
437
|
S.trace = C.parseTrace(files);
|
|
387
438
|
S.findings = C.diagnose(S.trace);
|
|
@@ -389,6 +440,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
389
440
|
S.sel = null; S.hl = new Set(); S.toolFilter = null; S.view = null; S.query = ''; S.matches = new Set(); S.focusId = null; $('#q').value = ''; $('#q-n').textContent = '';
|
|
390
441
|
$('#main').hidden = false; $('#banner').hidden = !isDemo;
|
|
391
442
|
renderAll();
|
|
443
|
+
if (S.other) renderCompare();
|
|
392
444
|
const T = S.trace.totals;
|
|
393
445
|
if (!S.trace.requests.length && !S.trace.toolCalls.length) { const p = $('#problems'); p.hidden = false; p.textContent = 'No API requests or tool calls were found — this does not look like a Claude Code / Agent SDK transcript. Expected a .jsonl with "type":"assistant" and "type":"user" records.'; }
|
|
394
446
|
say(`Loaded ${isDemo ? 'the demo session' : files.length + ' file' + (files.length > 1 ? 's' : '')}: ${T.turns} turns, ${T.toolCalls} tool calls, ${S.findings.length} findings${S.trace.problems.length ? ', ' + S.trace.problems.length + ' unreadable lines skipped' : ''}.`);
|
|
@@ -608,7 +660,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
608
660
|
function hideTip() { tip.style.display = 'none'; }
|
|
609
661
|
document.addEventListener('mousemove', (e) => { if (tip.style.display === 'block' && !(document.activeElement && document.activeElement.dataset && document.activeElement.dataset.kind)) moveTip(e.clientX, e.clientY); });
|
|
610
662
|
function cum(i) { let s = 0; for (let k = 0; k <= i; k++) s += S.cost.byRequest[k] || 0; return s; }
|
|
611
|
-
|
|
663
|
+
const inputSummary = C.inputSummary;
|
|
612
664
|
|
|
613
665
|
// ------------------------------------------------------------ burn chart
|
|
614
666
|
function renderBurn() {
|
|
@@ -648,13 +700,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
648
700
|
|
|
649
701
|
// ------------------------------------------------------------ findings
|
|
650
702
|
function findingMd(f) { return `- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}\n ${f.detail}`; }
|
|
651
|
-
function reportMd() {
|
|
652
|
-
const t = S.trace, T = t.totals, m = t.meta;
|
|
653
|
-
const head = `# Glassbox report — ${R(m.title || 'session')}\n\n` + `- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'} · started: ${m.start ? new Date(m.start).toISOString() : '—'}\n` + `- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed)\n` + `- tokens: ${fmtInt(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} context served (${pct(T.cacheHitRatio)} cached), ${fmtInt(T.usage.output)} output (${fmtInt(T.usage.thinking)} thinking) · est. cost ${S.cost.total != null ? fmtUSD(S.cost.total) : '—'}\n\n`;
|
|
654
|
-
const fs = S.findings.length ? `## Findings (${S.findings.length})\n\n` + S.findings.map(findingMd).join('\n') + '\n' : '## Findings\n\nNothing flagged.\n';
|
|
655
|
-
const tools = '\n## Tools\n\n| tool | calls | errors | total time |\n|---|---|---|---|\n' + toolRows().map((r) => `| ${r.name} | ${r.calls} | ${r.errors} | ${fmtDur(r.time)} |`).join('\n') + '\n';
|
|
656
|
-
return head + fs + tools + '\n_Generated by Glassbox._\n';
|
|
657
|
-
}
|
|
703
|
+
function reportMd() { return C.reportMarkdown(S.trace, S.findings, S.cost, { redact: S.share }); }
|
|
658
704
|
async function copyText(text, what) { try { await navigator.clipboard.writeText(text); say(what + ' copied to clipboard.'); return true; } catch (e) { const dlg = $('#copy-dlg'); $('#copy-text').value = text; dlg.showModal(); $('#copy-text').select(); say('Clipboard blocked — text shown for manual copy.'); return false; } }
|
|
659
705
|
$('#copy-close').onclick = () => $('#copy-dlg').close();
|
|
660
706
|
function renderFindings() {
|
|
@@ -782,6 +828,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
782
828
|
function setHash(h) { hashLock = true; try { history.replaceState(null, '', '#' + h); } catch (e) { } setTimeout(() => { hashLock = false; }, 0); }
|
|
783
829
|
function applyHash() {
|
|
784
830
|
const h = location.hash.slice(1); if (!h || !S.trace) return;
|
|
831
|
+
if (h === 'compare') { if (S.other) $('#cmp-sec').scrollIntoView(); return; }
|
|
785
832
|
const eq = h.indexOf('='); if (eq < 0) return; const k = h.slice(0, eq), v = decodeURIComponent(h.slice(eq + 1)); if (!v) return;
|
|
786
833
|
if (k === 'req') { const r = S.trace.requests[+v - 1]; if (r) select({ kind: 'req', ref: r }); }
|
|
787
834
|
else if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === v); if (c) select({ kind: 'tool', ref: c }); }
|
|
@@ -813,7 +860,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
813
860
|
const idb = { open() { return new Promise((res, rej) => { const r = indexedDB.open('glassbox', 1); r.onupgradeneeded = () => r.result.createObjectStore('kv'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); },
|
|
814
861
|
async get(k) { try { const db = await this.open(); return await new Promise((res) => { const t = db.transaction('kv').objectStore('kv').get(k); t.onsuccess = () => res(t.result); t.onerror = () => res(null); }); } catch (e) { return null; } },
|
|
815
862
|
async set(k, v) { try { const db = await this.open(); await new Promise((res) => { const t = db.transaction('kv', 'readwrite').objectStore('kv').put(v, k); t.onsuccess = res; t.onerror = res; }); } catch (e) { } } };
|
|
816
|
-
const decodeProject = (n) => n.replace(/^-/, '/').replace(/-/g, '/');
|
|
863
|
+
const decodeProject = (n) => { const m = n.match(/^([A-Za-z])--(.*)$/); if (m) return m[1] + ':/' + m[2].replace(/-/g, '/'); return n.replace(/^-/, '/').replace(/-/g, '/'); };
|
|
817
864
|
async function scanProjects(dir) {
|
|
818
865
|
const sessions = [];
|
|
819
866
|
for await (const [pname, ph] of dir.entries()) {
|
|
@@ -834,8 +881,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
834
881
|
}
|
|
835
882
|
async function loadSession(s) {
|
|
836
883
|
const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
|
|
837
|
-
|
|
838
|
-
|
|
884
|
+
// subagents/*.jsonl plus Workflow agents in subagents/workflows/<runId>/ (journal.jsonl is the workflow's log)
|
|
885
|
+
const walk = async (dir, rel) => { for await (const [n, h] of dir.entries()) { if (h.kind === 'directory') await walk(h, rel + '/' + n); else if (/\.(jsonl|json)$/.test(n) && n !== 'journal.jsonl') files.push({ name: rel + '/' + n, text: await (await h.getFile()).text() }); } };
|
|
886
|
+
try { await walk(await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'), s.id + '/subagents'); } catch (e) { }
|
|
887
|
+
$('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
|
|
839
888
|
}
|
|
840
889
|
async function showSessions(dir) {
|
|
841
890
|
const dlg = $('#sessions-dlg'); $('#sessions-note').textContent = 'Scanning ' + dir.name + '…'; $('#sessions-tbl').innerHTML = ''; dlg.showModal();
|
|
@@ -853,6 +902,99 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
853
902
|
}
|
|
854
903
|
$('#sessions-close').onclick = () => $('#sessions-dlg').close();
|
|
855
904
|
|
|
905
|
+
// ------------------------------------------------------------ compare
|
|
906
|
+
function analyseFiles(files) { const trace = C.parseTrace(files); return { files, trace, findings: C.diagnose(trace), cost: C.estimateCost(trace, S.rates) }; }
|
|
907
|
+
function loadOther(files) {
|
|
908
|
+
if (!S.trace) { say('Load a session first, then compare it with another.'); return; }
|
|
909
|
+
S.other = analyseFiles(files);
|
|
910
|
+
try { $('#cmp-dlg').close(); } catch (e) { }
|
|
911
|
+
renderCompare();
|
|
912
|
+
const m = S.cmp.metrics.find((x) => x.key === 'cost');
|
|
913
|
+
say(`Comparing: ${S.labels.a} vs ${S.labels.b}. ${S.other.trace.totals.toolCalls} tool calls in ${S.labels.b}${m && m.better ? ', ' + (m.better === 'a' ? S.labels.a : S.labels.b) + ' is cheaper' : ''}.`);
|
|
914
|
+
$('#cmp-sec').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
915
|
+
setHash('compare');
|
|
916
|
+
}
|
|
917
|
+
function sessionLabel(res, fallback) { const m = res.trace.meta; return m.title ? m.title.slice(0, 40) : m.sessionId ? m.sessionId.slice(0, 8) : fallback; }
|
|
918
|
+
function renderCompare() {
|
|
919
|
+
if (!S.other) return;
|
|
920
|
+
const A = { trace: S.trace, findings: S.findings, cost: S.cost }, B = S.other;
|
|
921
|
+
const c = S.cmp = C.compare(A, B);
|
|
922
|
+
const la = S.labels.a, lb = S.labels.b;
|
|
923
|
+
const who = (x) => x === 'a' ? `<span class="who a">${esc(la)}</span>` : x === 'b' ? `<span class="who b">${esc(lb)}</span>` : '<span class="who">tie</span>';
|
|
924
|
+
$('#cmp-aux').innerHTML = `<span class="who a">${esc(la)}</span> ${esc(R(sessionLabel(A, 'this session')))} · <span class="who b">${esc(lb)}</span> ${esc(R(sessionLabel(B, 'other session')))} — ${esc(la)} is on the timeline`;
|
|
925
|
+
const rows = c.metrics.map((m) => {
|
|
926
|
+
let bar = '<span class="ch">—</span>';
|
|
927
|
+
if (m.delta != null) {
|
|
928
|
+
const good = m.better === 'a' ? 'bad' : m.better === 'b' ? 'good' : '';
|
|
929
|
+
const ch = C.fmtChange(m);
|
|
930
|
+
const r = m.ratio != null && m.a ? m.ratio : (m.delta === 0 ? 1 : 2);
|
|
931
|
+
const f = Math.min(1, Math.abs(Math.log2(Math.max(r, 1e-6))) / 3);
|
|
932
|
+
const left = r < 1, w = (f * 50).toFixed(1);
|
|
933
|
+
bar = `<div class="dbar" aria-hidden="true"><div class="track"><div class="fill ${good}" style="left:${left ? 50 - w : 50}%;width:${w}%"></div></div><span class="ch ${good}">${ch}</span></div>`;
|
|
934
|
+
}
|
|
935
|
+
const sr = m.delta == null ? 'not comparable' : m.better ? `${m.better === 'a' ? la : lb} better` : 'same';
|
|
936
|
+
return `<tr><td>${esc(m.label)}</td><td class="n">${m.aText}</td><td class="n">${m.bText}</td><td class="d">${bar}<span class="sr-only">${esc(sr)}</span></td></tr>`;
|
|
937
|
+
}).join('');
|
|
938
|
+
const tools = c.tools.slice(0, 14).map((t) => `<tr><td>${esc(t.name)}</td><td class="n">${t.a.calls}${t.a.errors ? ` <span class="errpill">${t.a.errors}</span>` : ''}</td><td class="n">${t.b.calls}${t.b.errors ? ` <span class="errpill">${t.b.errors}</span>` : ''}</td><td class="n">${t.deltaCalls > 0 ? '+' : ''}${t.deltaCalls}</td></tr>`).join('');
|
|
939
|
+
const fl = (list) => list.length ? `<ul class="fl">${list.map((f) => `<li><span class="sev ${f.severity}"></span> ${esc(f.title)}<span class="id">${f.id}</span></li>`).join('')}</ul>` : '<div class="empty">none</div>';
|
|
940
|
+
$('#cmp').innerHTML = `<div class="verdict"><span>cheaper ${who(c.verdict.cheaper)}</span><span>faster ${who(c.verdict.faster)}</span><span>cleaner ${who(c.verdict.cleaner)}</span></div>
|
|
941
|
+
<div class="grid"><div><table><caption class="sr-only">Metrics side by side</caption><thead><tr><th scope="col">Metric</th><th class="n" scope="col">${esc(la)}</th><th class="n" scope="col">${esc(lb)}</th><th scope="col">Change (${esc(lb)} vs ${esc(la)})</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
942
|
+
<div><h3 style="margin-top:0">Tools</h3><table><caption class="sr-only">Tool calls per session</caption><thead><tr><th scope="col">Tool</th><th class="n" scope="col">${esc(la)}</th><th class="n" scope="col">${esc(lb)}</th><th class="n" scope="col">Δ</th></tr></thead><tbody>${tools || '<tr><td colspan="4" class="empty">no tool calls</td></tr>'}</tbody></table>${c.tools.length > 14 ? `<p class="note">… ${c.tools.length - 14} more tools</p>` : ''}
|
|
943
|
+
<h3>Only in ${esc(la)}</h3>${fl(c.findings.onlyA)}<h3>Only in ${esc(lb)}</h3>${fl(c.findings.onlyB)}${c.findings.both.length ? `<h3>In both</h3><ul class="fl">${c.findings.both.map((f) => `<li><span class="id" style="margin:0">${f.id}</span> — ${esc(la)}: ${f.a.length}, ${esc(lb)}: ${f.b.length}</li>`).join('')}</ul>` : ''}</div></div>`;
|
|
944
|
+
$('#cmp-sec').hidden = false;
|
|
945
|
+
}
|
|
946
|
+
$('#compare').onclick = () => { if (!S.trace) { say('Load a session first.'); return; } $('#cmp-folder').hidden = !FSA; $('#cmp-dlg').showModal(); };
|
|
947
|
+
$('#cmp-dlg-close').onclick = () => $('#cmp-dlg').close();
|
|
948
|
+
$('#cmp-folder').onclick = async () => { $('#cmp-dlg').close(); S.pickOther = true; let h = await idb.get('projects'); try { if (h) { let p = await h.queryPermission({ mode: 'read' }); if (p !== 'granted') p = await h.requestPermission({ mode: 'read' }); if (p !== 'granted') h = null; } if (!h) { h = await window.showDirectoryPicker({ id: 'claude-projects', mode: 'read' }); await idb.set('projects', h); $('#recent').hidden = false; } await showSessions(h); } catch (e) { S.pickOther = false; if (e && e.name !== 'AbortError') say('Could not open the folder: ' + e.message); } };
|
|
949
|
+
$('#sessions-dlg').addEventListener('close', () => { setTimeout(() => { S.pickOther = false; }, 0); });
|
|
950
|
+
$('#cmp-pick').onclick = () => $('#file-cmp').click();
|
|
951
|
+
$('#file-cmp').addEventListener('change', (e) => { loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f })), true); e.target.value = ''; });
|
|
952
|
+
$('#cmp-demo').onclick = () => { if (!DEMO) { say('No demo embedded in this build.'); return; } loadOther(DEMO.map((f) => ({ name: f.name, text: f.text }))); };
|
|
953
|
+
$('#cmp-close').onclick = () => { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; setHash(''); say('Compare closed.'); $('#compare').focus(); };
|
|
954
|
+
$('#cmp-swap').onclick = () => { if (!S.other) return; const cur = { files: S.files, isDemo: S.isDemo }; const nxt = S.other; S.labels = { a: S.labels.b, b: S.labels.a }; S.other = analyseFiles(cur.files); load(nxt.files, false, true); say(`${S.labels.a} is now on the timeline.`); };
|
|
955
|
+
$('#cmp-copy').onclick = () => { if (!S.cmp) return; copyText(C.compareMarkdown(S.cmp, { labelA: S.labels.a, labelB: S.labels.b }), 'Comparison (Markdown)'); };
|
|
956
|
+
|
|
957
|
+
// ------------------------------------------------------------ live tail
|
|
958
|
+
let liveTimer = null;
|
|
959
|
+
function applyLiveFiles(ev) {
|
|
960
|
+
const f = S.files.find((x) => x.name === ev.name);
|
|
961
|
+
if (ev.type === 'append') { if (f) f.text += ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
|
|
962
|
+
else if (ev.type === 'replace') { if (f) f.text = ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
|
|
963
|
+
else if (ev.type === 'hello') { S.files = ev.files.map((x) => ({ name: x.name, text: x.text })); }
|
|
964
|
+
if (liveTimer) return; liveTimer = setTimeout(() => { liveTimer = null; refresh(); }, 150);
|
|
965
|
+
}
|
|
966
|
+
// Re-parse the current files in place: selection, filter, search and zoom survive; Follow keeps the right edge on now.
|
|
967
|
+
function refresh() {
|
|
968
|
+
const wasWhole = !S.view || (TL.total && S.view.t1 >= TL.total - 1 && S.view.t0 <= 1);
|
|
969
|
+
const oldSpan = S.view ? S.view.t1 - S.view.t0 : null;
|
|
970
|
+
const selId = S.sel ? S.sel.ref.id : null, selKind = S.sel ? S.sel.kind : null;
|
|
971
|
+
S.records = S.files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
|
|
972
|
+
S.trace = C.parseTrace(S.files); S.findings = C.diagnose(S.trace); S.cost = C.estimateCost(S.trace, S.rates);
|
|
973
|
+
if (selId) { const ref = selKind === 'tool' ? S.trace.toolCalls.find((c) => c.id === selId) : S.trace.requests.find((r) => r.id === selId); S.sel = ref ? { kind: selKind, ref } : null; }
|
|
974
|
+
if (S.toolFilter && !S.trace.toolCalls.some((c) => c.name === S.toolFilter)) S.toolFilter = null;
|
|
975
|
+
buildLayout();
|
|
976
|
+
if (S.follow) { if (wasWhole || oldSpan == null || oldSpan >= TL.total) S.view = null; else S.view = { t0: Math.max(0, TL.total - oldSpan), t1: TL.total }; }
|
|
977
|
+
renderSession(); renderStrip(); renderFit(); renderTimeline(); renderBurn(); renderFindings(); renderTools(); renderTurns();
|
|
978
|
+
if (S.query) runSearch();
|
|
979
|
+
if (S.sel && !$('#drawer').hidden) select(S.sel, true);
|
|
980
|
+
if (S.other) renderCompare();
|
|
981
|
+
const t = new Date(); $('#live-txt').textContent = 'LIVE · ' + t.toTimeString().slice(0, 8);
|
|
982
|
+
}
|
|
983
|
+
function startLive(cfg) {
|
|
984
|
+
const pill = $('#live'); pill.hidden = false; $('#follow').hidden = false;
|
|
985
|
+
$('#follow').onclick = () => { S.follow = !S.follow; $('#follow').setAttribute('aria-pressed', String(S.follow)); say(S.follow ? 'Following: the timeline keeps up with the session.' : 'Follow off: the view stays where it is.'); if (S.follow) refresh(); };
|
|
986
|
+
const es = new EventSource(cfg.url); S.live = es;
|
|
987
|
+
es.addEventListener('hello', (e) => { pill.classList.remove('off'); $('#live-txt').textContent = 'LIVE'; applyLiveFiles(Object.assign({ type: 'hello' }, JSON.parse(e.data))); });
|
|
988
|
+
es.addEventListener('append', (e) => applyLiveFiles(JSON.parse(e.data)));
|
|
989
|
+
es.addEventListener('replace', (e) => applyLiveFiles(JSON.parse(e.data)));
|
|
990
|
+
es.onerror = () => { pill.classList.add('off'); $('#live-txt').textContent = 'reconnecting'; };
|
|
991
|
+
// any manual zoom or pan lets go of Follow
|
|
992
|
+
const letGo = () => { if (S.follow) { S.follow = false; $('#follow').setAttribute('aria-pressed', 'false'); } };
|
|
993
|
+
$('#tl').addEventListener('wheel', letGo, { passive: true }); $('#tl').addEventListener('pointerdown', letGo);
|
|
994
|
+
$('#z-in').addEventListener('click', letGo); $('#z-out').addEventListener('click', letGo); $('#fit').addEventListener('change', letGo);
|
|
995
|
+
$('#banner').hidden = true;
|
|
996
|
+
}
|
|
997
|
+
|
|
856
998
|
// ------------------------------------------------------------ help + inputs
|
|
857
999
|
$('#help').onclick = () => $('#help-dlg').showModal(); $('#help-close').onclick = () => $('#help-dlg').close();
|
|
858
1000
|
$('#banner-x').onclick = () => { $('#banner').hidden = true; };
|
|
@@ -862,6 +1004,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
862
1004
|
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); handleDataTransfer(e.dataTransfer); });
|
|
863
1005
|
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
864
1006
|
document.addEventListener('drop', (e) => { if (!drop.contains(e.target)) { e.preventDefault(); handleDataTransfer(e.dataTransfer); } });
|
|
1007
|
+
$('#cmp-dlg').addEventListener('dragover', (e) => e.preventDefault()); $('#cmp-dlg').addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); handleDataTransfer(e.dataTransfer); });
|
|
865
1008
|
$('#file').addEventListener('change', (e) => loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f }))));
|
|
866
1009
|
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#file').click(); } });
|
|
867
1010
|
|
|
@@ -869,7 +1012,13 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
869
1012
|
const EMBED = /*__EMBED__*/null;
|
|
870
1013
|
function loadDemo(e) { if (e) e.preventDefault(); if (!DEMO) { say('No demo embedded in this build.'); return; } load(DEMO.map((f) => ({ name: f.name, text: f.text })), true); }
|
|
871
1014
|
$('#demo').onclick = loadDemo;
|
|
872
|
-
|
|
1015
|
+
const LIVE = /*__LIVE__*/null;
|
|
1016
|
+
const embedFiles = EMBED && !Array.isArray(EMBED) ? EMBED.files : EMBED;
|
|
1017
|
+
if (embedFiles && embedFiles.length) {
|
|
1018
|
+
load(embedFiles.map((f) => ({ name: f.name, text: f.text })), false);
|
|
1019
|
+
if (EMBED && !Array.isArray(EMBED) && EMBED.compare && EMBED.compare.length) { if (EMBED.labels) S.labels = EMBED.labels; loadOther(EMBED.compare.map((f) => ({ name: f.name, text: f.text }))); }
|
|
1020
|
+
}
|
|
873
1021
|
else if (DEMO) loadDemo();
|
|
1022
|
+
if (LIVE && LIVE.url && 'EventSource' in window) startLive(LIVE);
|
|
874
1023
|
})();
|
|
875
1024
|
</script>
|