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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Glassbox</title>
7
- <meta name="description" content="The flight recorder viewer for Claude Code and Agent SDK sessions. Drop a session .jsonl; get a timeline, token and cost burn, and auto-diagnostics. Nothing leaves your browser.">
7
+ <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.">
8
8
  <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">
9
9
  <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">
10
10
  <style>
@@ -17,6 +17,7 @@
17
17
  --err:#C4342A;--warn:#8A5F00;--info:#2A63B8;--ok:#177A6B;
18
18
  --err-soft:rgba(196,52,42,.12);--warn-soft:rgba(138,95,0,.14);--info-soft:rgba(42,99,184,.12);
19
19
  --focus:#B8420A;
20
+ --who-a:#2A63B8;--who-b:#5B3FB8;
20
21
  --shadow:0 10px 30px rgba(22,28,36,.14);
21
22
  --sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
22
23
  --cond:"IBM Plex Sans Condensed","Arial Narrow",var(--sans);
@@ -33,6 +34,7 @@
33
34
  --err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
34
35
  --err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
35
36
  --focus:#FF8A4D;
37
+ --who-a:#7DB0F0;--who-b:#B39CF5;
36
38
  --shadow:0 10px 30px rgba(0,0,0,.5);
37
39
  }
38
40
  }
@@ -45,6 +47,7 @@
45
47
  --err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
46
48
  --err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
47
49
  --focus:#FF8A4D;
50
+ --who-a:#7DB0F0;--who-b:#B39CF5;
48
51
  --shadow:0 10px 30px rgba(0,0,0,.5);
49
52
  }
50
53
  :root[data-density="compact"]{--fs:12.5px;--pad:8px;--rowh:15px}
@@ -81,6 +84,33 @@ a{color:var(--accent)}
81
84
  .banner p{margin:0;flex:1}
82
85
  .banner code{font-family:var(--mono);font-size:12.5px}
83
86
 
87
+ /* live pill + compare */
88
+ .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}
89
+ .live i{width:7px;height:7px;border-radius:50%;background:var(--ok);animation:pulse 1.6s ease-in-out infinite}
90
+ .live.off{color:var(--warn);border-color:var(--warn)}.live.off i{background:var(--warn);animation:none}
91
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
92
+ @media (prefers-reduced-motion:reduce){.live i{animation:none}}
93
+ .cmp{padding:var(--pad)}
94
+ .cmp .verdict{display:flex;gap:18px;flex-wrap:wrap;font-size:13px;margin:0 0 10px;color:var(--muted)}
95
+ .cmp .verdict b{color:var(--ink)}
96
+ .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)}
97
+ .cmp .who.a{border-color:var(--who-a);color:var(--who-a)}.cmp .who.b{border-color:var(--who-b);color:var(--who-b)}
98
+ .cmp .grid{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:16px}
99
+ @media (max-width:900px){.cmp .grid{grid-template-columns:1fr}}
100
+ .cmp table{font-size:13px}
101
+ .cmp td.d{min-width:140px}
102
+ .cmp .dbar{display:flex;align-items:center;gap:8px}
103
+ .cmp .dbar .track{position:relative;flex:1;height:8px;background:var(--panel-2);border-radius:4px;overflow:hidden;border:1px solid var(--line-2)}
104
+ .cmp .dbar .fill{position:absolute;top:0;bottom:0;background:var(--faint)}
105
+ .cmp .dbar .fill.good{background:var(--ok)}.cmp .dbar .fill.bad{background:var(--err)}
106
+ .cmp .dbar .ch{font-family:var(--mono);font-size:12px;min-width:56px;text-align:right;color:var(--muted)}
107
+ .cmp .dbar .ch.good{color:var(--ok)}.cmp .dbar .ch.bad{color:var(--err)}
108
+ .cmp h3{font:600 11px var(--cond);letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:14px 0 6px}
109
+ .cmp .fl{margin:0;padding:0;list-style:none;font-size:13px}
110
+ .cmp .fl li{padding:4px 0;border-top:1px solid var(--line-2)}
111
+ .cmp .fl .id{font:11px var(--mono);color:var(--faint);margin-left:6px}
112
+ .cmp .empty{padding:8px 0}
113
+
84
114
  /* session line */
85
115
  .session{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline;padding:10px 0 0;color:var(--muted);font-size:13px}
86
116
  .session .title{color:var(--ink);font-weight:500;font-size:15px}
@@ -235,7 +265,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
235
265
  <body>
236
266
  <div class="app">
237
267
  <header class="mast">
238
- <div class="brand"><h1>Glass<span>box</span></h1><span class="tag">flight recorder for Claude Code sessions</span></div>
268
+ <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>
239
269
  <label class="drop" id="drop" tabindex="0" aria-label="Drop a session .jsonl file or folder here, or press Enter to choose files">
240
270
  <input type="file" id="file" multiple accept=".jsonl,.json,.txt" class="sr-only">
241
271
  <span class="hint"><b>Drop a session .jsonl</b> (or the whole session folder) — parsed in your browser, never uploaded.</span>
@@ -245,6 +275,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
245
275
  <button class="btn" id="open-folder" hidden title="Browse ~/.claude/projects and pick a session">Open folder…</button>
246
276
  <button class="btn" id="recent" hidden title="Sessions from the folder you opened before">Recent</button>
247
277
  <button class="btn" id="demo">Load demo</button>
278
+ <button class="btn" id="compare" title="Load a second session and compare it with this one">Compare…</button>
279
+ <input type="file" id="file-cmp" multiple accept=".jsonl,.json,.txt" class="sr-only" aria-label="Choose the session to compare with">
248
280
  <button class="btn" id="share" aria-pressed="false" title="Blank every prompt, input and result so the trace can be shared">Share mode</button>
249
281
  <button class="btn" id="export" disabled title="Download a structure-only copy of the transcript">Export redacted</button>
250
282
  <button class="btn" id="rates" title="Edit the per-model rate card">Rates</button>
@@ -261,9 +293,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
261
293
  <div id="problems" class="problems" hidden role="alert"></div>
262
294
  <dl class="strip" id="strip"></dl>
263
295
 
296
+ <section class="sec" id="cmp-sec" hidden aria-labelledby="h-cmp">
297
+ <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>
298
+ <div class="panel cmp" id="cmp"></div>
299
+ </section>
300
+
264
301
  <section class="sec" aria-labelledby="h-tl">
265
302
  <div class="sec-h"><h2 id="h-tl">Timeline</h2>
266
303
  <div class="tools">
304
+ <button class="btn" id="follow" hidden aria-pressed="true" title="Keep the right edge of the timeline on now">Follow</button>
267
305
  <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>
268
306
  <label class="sr-only" for="fit">Fit to turn</label><select id="fit" class="btn"></select>
269
307
  <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>
@@ -322,6 +360,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
322
360
  <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>
323
361
  </dialog>
324
362
 
363
+ <dialog id="cmp-dlg" aria-labelledby="cmp-h">
364
+ <div class="dh"><h3 id="cmp-h">Compare with another session</h3><button class="x" id="cmp-dlg-close" aria-label="Close">×</button></div>
365
+ <div class="db" style="font-size:13.5px;line-height:1.55">
366
+ <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>
367
+ <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>
368
+ <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>
369
+ </div>
370
+ </dialog>
371
+
325
372
  <dialog id="help-dlg" aria-labelledby="help-h">
326
373
  <div class="dh"><h3 id="help-h">How to read Glassbox</h3><button class="x" id="help-close" aria-label="Close">×</button></div>
327
374
  <div class="db" style="font-size:13.5px;line-height:1.55" tabindex="0">
@@ -329,6 +376,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
329
376
  <p><b>Context &amp; 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>
330
377
  <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>
331
378
  <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>
379
+ <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>
380
+ <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>
332
381
  <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>
333
382
  <p><b>Keyboard.</b></p>
334
383
  <div class="keys">
@@ -353,7 +402,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
353
402
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
354
403
  'use strict';
355
404
 
356
- const VERSION = '0.3.0';
405
+ const VERSION = '0.4.1';
357
406
 
358
407
  // ---------------------------------------------------------------------------
359
408
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -410,18 +459,25 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
410
459
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
411
460
  const EXEC_TOOLS = new Set(['Bash', 'BashOutput', 'KillShell']);
412
461
  const AGENT_TOOLS = new Set(['Agent', 'Task', 'Workflow']);
413
- const USER_TOOLS = new Set(['AskUserQuestion']);
462
+ const USER_TOOLS = new Set(['AskUserQuestion', 'SendUserMessage', 'SendUserFile', 'ExitPlanMode', 'EnterPlanMode']);
463
+ // Cowork: Artifact publishes a page (a write), Skill loads instructions (a read); Task* are bookkeeping (other).
464
+ const COWORK_READ = new Set(['Skill', 'ListSkills', 'SearchSkills', 'TaskList', 'TaskGet']);
465
+ const COWORK_WRITE = new Set(['Artifact']);
466
+ // MCP leaf names are verb_noun or noun_verb ("memory_read", "device_list_dir", "send_message"): match the verb as a word.
467
+ const MCP_READ = /(^|_)(get|list|search|read|find|fetch|query|describe|show|stage|screenshot|context|status|info|check)(_|$)/i;
468
+ const MCP_WRITE = /(^|_)(write|set|put|create|update|delete|remove|commit|send|reply|forward|upload|str_replace|append|label|move|rename|save)(_|$)/i;
414
469
 
415
470
  function toolCategory(name) {
416
471
  if (!name) return 'other';
417
- if (READ_TOOLS.has(name)) return 'read';
418
- if (WRITE_TOOLS.has(name)) return 'write';
472
+ if (READ_TOOLS.has(name) || COWORK_READ.has(name)) return 'read';
473
+ if (WRITE_TOOLS.has(name) || COWORK_WRITE.has(name)) return 'write';
419
474
  if (EXEC_TOOLS.has(name) || /device_bash$/.test(name)) return 'exec';
420
475
  if (AGENT_TOOLS.has(name)) return 'agent';
421
476
  if (USER_TOOLS.has(name)) return 'user';
422
477
  if (name.startsWith('mcp__')) {
423
478
  const leaf = name.split('__').pop() || '';
424
- if (/^(get|list|search|read|find|fetch|query|describe|show)/i.test(leaf)) return 'read';
479
+ if (MCP_READ.test(leaf)) return 'read';
480
+ if (MCP_WRITE.test(leaf)) return 'write';
425
481
  return 'mcp';
426
482
  }
427
483
  return 'other';
@@ -577,7 +633,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
577
633
  const meta = {
578
634
  sessionId: null, version: null, cwd: null, gitBranch: null, entrypoint: null, title: null,
579
635
  models: [], start: null, end: null, hasTimestamps, files: fileMeta, format: 'claude-code',
580
- reportedCost: null, reportedUsage: null, recordCounts: {},
636
+ reportedCost: null, reportedUsage: null, reportedTurns: null, reportedDurationMs: null, recordCounts: {},
581
637
  };
582
638
 
583
639
  const agents = new Map(); // id -> agent
@@ -644,6 +700,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
644
700
  if (type === 'result') {
645
701
  meta.format = 'stream-json';
646
702
  if (typeof rec.total_cost_usd === 'number') meta.reportedCost = rec.total_cost_usd;
703
+ if (typeof rec.num_turns === 'number') meta.reportedTurns = rec.num_turns;
704
+ if (typeof rec.duration_ms === 'number') meta.reportedDurationMs = rec.duration_ms;
647
705
  if (rec.usage) meta.reportedUsage = usageFrom(rec.usage);
648
706
  if (rec.is_error) events.push({ kind: 'api_error', at: t, detail: textOf(rec.result) || 'result is_error' });
649
707
  continue;
@@ -809,12 +867,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
809
867
  const usage = emptyUsage();
810
868
  for (const r of requests) addUsage(usage, r.usage);
811
869
  const humanIdleMs = turns.reduce((s, tn) => s + (tn.idleBeforeMs || 0), 0);
812
- const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : null;
870
+ // stream-json runs carry no per-record timestamps and no human prompt record, but the result
871
+ // record reports duration_ms and num_turns; use those rather than showing "—" and 0.
872
+ const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : (meta.reportedDurationMs != null ? meta.reportedDurationMs : null);
873
+ const humanTurns = turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length;
813
874
  const totals = {
814
875
  usage, wallMs, humanIdleMs, activeMs: wallMs != null ? Math.max(0, wallMs - humanIdleMs) : null,
815
876
  requests: requests.length, toolCalls: toolCalls.length, toolErrors: toolCalls.filter((c) => c.isError).length,
816
877
  orphans: toolCalls.filter((c) => c.status === 'orphan').length,
817
- turns: turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length,
878
+ turns: humanTurns || (meta.reportedTurns != null ? meta.reportedTurns : 0),
818
879
  toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
819
880
  modelTimeMs: agents.get('main').modelTimeMs || 0,
820
881
  cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
@@ -1080,6 +1141,202 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1080
1141
  };
1081
1142
  }
1082
1143
 
1144
+ // ---------------------------------------------------------------------------
1145
+ // Advice — one sentence per rule on what to do differently. Mechanical, like the rules.
1146
+ // ---------------------------------------------------------------------------
1147
+ const ADVICE = {
1148
+ 'retry-loop': 'When a call fails, change something before calling again: read the error, fix the input or the environment, or ask the user — never repeat an identical call.',
1149
+ 'failed-tool': 'Check the first error message of the failing tool before retrying; if the tool itself is broken, switch tools or tell the user instead of pushing on.',
1150
+ 'orphan-tool': 'A call with no result means the session ended mid-call; if you resume, re-run it or confirm its side effects before building on it.',
1151
+ 'exploration-run': 'Batch exploration: use one Grep or Glob with a wider pattern, or delegate a broad search to a subagent, then commit to an edit or command sooner.',
1152
+ 'oversized-result': 'Ask tools for less: head/limit/offset on reads, a tighter grep pattern, a file listing instead of a dump, or a subagent that returns a summary.',
1153
+ 'image-heavy': 'Take fewer screenshots and crop or downscale them; read text with a page-text tool where one exists, since every image is re-billed on each later request.',
1154
+ 'context-bloat': 'Keep the prompt small: summarise long results into notes, avoid re-reading files already in context, and hand large investigations to subagents.',
1155
+ 'cache-churn': 'Avoid changing anything at the top of the prompt mid-session (system prompt, tool list, early messages), because it invalidates the cached prefix and re-bills it.',
1156
+ 'low-cache-hit': 'Long stable prefixes cache well; frequent tool-list or system-prompt changes and many tiny sessions do not.',
1157
+ 'slow-tool': 'For calls that take minutes, run them in the background, tighten the command, or set a timeout, instead of blocking the whole turn.',
1158
+ 'slow-model': 'A slow, small response is usually API latency or a stall; nothing to fix in the session itself, but note it when reporting session time.',
1159
+ 'long-generation': 'Large single outputs are normal for file writes; split very large files into parts if a stall (low tok/s) shows up.',
1160
+ 'max-tokens': 'The output was truncated: write large files in sections and keep single responses under the output limit.',
1161
+ 'api-error': 'An API error interrupted the session; check what was lost and re-run the affected step rather than assuming it completed.',
1162
+ 'hook-error': 'A hook failed; read the hook error, fix the hook command or its permissions, then re-run the step it guarded.',
1163
+ 'compaction': 'Context was compacted; state that matters later (decisions, file paths, test status) should be written to a file or note before it is summarised away.',
1164
+ 'thinking-heavy': 'Most output tokens were thinking; fine for hard problems, but for routine tool loops a lower effort setting is cheaper and just as good.',
1165
+ 'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
1166
+ 'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
1167
+ };
1168
+
1169
+ // Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
1170
+ function adviceFor(f, trace) {
1171
+ if (f.id === 'slow-tool') {
1172
+ const ids = (f.evidence && f.evidence.toolCallIds) || [];
1173
+ const c = trace.toolCalls.find((x) => x.id === ids[0]);
1174
+ if (c && c.category === 'user') return null; // waiting on the human is not the agent's time
1175
+ if (c && c.category === 'agent') return 'Subagent run time: give it a narrower brief and ask for a short structured return so the parent is not blocked for as long.';
1176
+ }
1177
+ return ADVICE[f.id] || null;
1178
+ }
1179
+
1180
+ function inputSummary(c) {
1181
+ const i = (c && c.input) || {};
1182
+ if (i.command) return String(i.command);
1183
+ if (i.file_path) return String(i.file_path) + (i.pattern ? ' ' + i.pattern : '');
1184
+ if (i.pattern) return String(i.pattern);
1185
+ if (i.query) return String(i.query);
1186
+ if (i.description) return String(i.description);
1187
+ if (i.url) return String(i.url);
1188
+ if (i.prompt) return String(i.prompt);
1189
+ if (i.path) return String(i.path);
1190
+ const s = JSON.stringify(i);
1191
+ return s === '{}' ? '' : s;
1192
+ }
1193
+
1194
+ // ---------------------------------------------------------------------------
1195
+ // Markdown report — written to be handed back to the agent as well as read by a human.
1196
+ // opts: { redact, advice=true, instruction=true, maxEvidence=6, title }
1197
+ // ---------------------------------------------------------------------------
1198
+ function usd(v) { return v == null ? '—' : v < 0.01 ? '$' + v.toFixed(4) : v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2); }
1199
+ function pctStr(x) { return x == null ? '—' : Math.round(x * 100) + '%'; }
1200
+ function oneLine(s, n) { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
1201
+ function blank(s) { return '«' + String(s == null ? '' : s).length + ' chars»'; }
1202
+
1203
+ function toolTable(trace) {
1204
+ const rows = new Map();
1205
+ for (const c of trace.toolCalls) { if (c.unmatched) continue; if (!rows.has(c.name)) rows.set(c.name, { name: c.name, calls: 0, errors: 0, time: 0 }); const r = rows.get(c.name); r.calls++; if (c.isError) r.errors++; r.time += c.durationMs || 0; }
1206
+ return Array.from(rows.values()).sort((a, b) => b.time - a.time || b.calls - a.calls);
1207
+ }
1208
+
1209
+ // Rules whose detail quotes tool output or model text (everything else is names and numbers).
1210
+ const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error']);
1211
+ function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
1212
+
1213
+ function reportMarkdown(trace, findings, cost, opts) {
1214
+ opts = opts || {};
1215
+ const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
1216
+ const T = trace.totals, m = trace.meta, u = T.usage;
1217
+ const R = (s) => redact ? blank(s) : s;
1218
+ const total = cost ? (cost.reported != null ? cost.reported : cost.total) : null;
1219
+ const turnNo = (idx) => idx != null ? idx + 1 : null; // same numbering as the finding details and the viewer drawer
1220
+ const advised = new Set();
1221
+ const out = [];
1222
+ out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
1223
+ out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
1224
+ out.push(`- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turn${T.turns === 1 ? '' : 's'} · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
1225
+ out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}`, '');
1226
+ if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
1227
+ else {
1228
+ out.push(`## Findings (${findings.length})`, '');
1229
+ findings.forEach((f, i) => {
1230
+ out.push(`### ${i + 1}. ${f.severity.toUpperCase()} \`${f.id}\` — ${f.title}`, '', redact ? redactDetail(f) : f.detail, '');
1231
+ const ev = f.evidence || {};
1232
+ const lines = [];
1233
+ for (const id of ev.toolCallIds || []) { const c = trace.toolCalls.find((x) => x.id === id); if (!c) continue; const tn = turnNo(c.turnIndex); lines.push(`- ${c.name}${c.isError ? ' (error)' : ''}${c.durationMs != null ? ' · ' + fmtDur(c.durationMs) : ''}${tn != null ? ' · turn ' + tn : ''}${c.agent !== 'main' ? ' · subagent' : ''}: \`${oneLine(R(inputSummary(c)), 100).replace(/`/g, "'") || '(no input)'}\``); }
1234
+ for (const id of ev.requestIds || []) { const r = trace.requests.find((x) => x.id === id); if (!r) continue; const n = trace.requests.indexOf(r) + 1; const tn = turnNo(r.turnIndex); lines.push(`- request #${n}${tn != null ? ' · turn ' + tn : ''}: context ${fmtInt(r.contextTokens)} · output ${fmtInt(r.usage.output)}${r.responseMs != null ? ' · ' + fmtDur(r.responseMs) : ''}`); }
1235
+ if (lines.length) { out.push('Evidence:', ''); out.push(...lines.slice(0, maxEv)); if (lines.length > maxEv) out.push(`- … ${lines.length - maxEv} more`); out.push(''); }
1236
+ const adv = opts.advice !== false && !advised.has(f.id) ? adviceFor(f, trace) : null;
1237
+ if (adv) { advised.add(f.id); out.push(`Next time: ${adv}`, ''); }
1238
+ });
1239
+ }
1240
+ const tools = toolTable(trace);
1241
+ if (tools.length) { out.push('## Tools', '', '| tool | calls | errors | total time |', '|---|---|---|---|'); for (const r of tools) out.push(`| ${r.name} | ${r.calls} | ${r.errors} | ${fmtDur(r.time)} |`); out.push(''); }
1242
+ if (opts.instruction !== false) out.push('## What to do with this', '', 'This is a mechanical review of the session transcript, generated by Glassbox (no AI involved). If you are the agent that ran this session: acknowledge the findings that apply, say in one or two sentences what you will do differently next session, and do not redo any work. If you are a human: the evidence lines are permalinks into the Glassbox viewer for this session.', '');
1243
+ out.push('_Generated by Glassbox ' + VERSION + '._', '');
1244
+ return out.join('\n');
1245
+ }
1246
+
1247
+ // ---------------------------------------------------------------------------
1248
+ // Compare two analysed sessions ({trace, findings, cost}).
1249
+ // ---------------------------------------------------------------------------
1250
+ const METRICS = [
1251
+ { key: 'wallMs', label: 'Wall time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.wallMs },
1252
+ { key: 'activeMs', label: 'Active time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.activeMs },
1253
+ { key: 'turns', label: 'Turns', fmt: 'int', dir: null, get: (s) => s.trace.totals.turns },
1254
+ { key: 'requests', label: 'API requests', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.requests },
1255
+ { key: 'toolCalls', label: 'Tool calls', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolCalls },
1256
+ { key: 'toolErrors', label: 'Tool errors', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolErrors },
1257
+ { key: 'toolTimeMs', label: 'Time in tools', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.toolTimeMs : null },
1258
+ { key: 'modelTimeMs', label: 'Time generating', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.modelTimeMs : null },
1259
+ { key: 'contextServed', label: 'Context served', fmt: 'tok', dir: 'low', get: (s) => { const u = s.trace.totals.usage; const v = u.input + u.cacheRead + u.cacheWrite; return v || (s.trace.requests.length ? 0 : null); } },
1260
+ { key: 'peakContext', label: 'Peak prompt', fmt: 'tok', dir: 'low', get: (s) => s.trace.requests.length ? Math.max(0, ...s.trace.requests.map((r) => r.contextTokens)) : null },
1261
+ { key: 'cacheHitRatio', label: 'Cache hit', fmt: 'pct', dir: 'high', get: (s) => s.trace.totals.cacheHitRatio },
1262
+ { key: 'output', label: 'Output tokens', fmt: 'tok', dir: 'low', get: (s) => s.trace.requests.length ? s.trace.totals.usage.output : null },
1263
+ { key: 'thinkingShare', label: 'Thinking share', fmt: 'pct', dir: null, get: (s) => { const u = s.trace.totals.usage; return u.output ? u.thinking / u.output : null; } },
1264
+ { key: 'cost', label: 'Est. cost', fmt: 'usd', dir: 'low', get: (s) => s.cost ? (s.cost.reported != null ? s.cost.reported : s.cost.total) : null },
1265
+ { key: 'findings', label: 'Findings', fmt: 'int', dir: 'low', get: (s) => s.findings.length },
1266
+ { key: 'errorFindings', label: 'Error-level findings', fmt: 'int', dir: 'low', get: (s) => s.findings.filter((f) => f.severity === 'error').length },
1267
+ ];
1268
+ function fmtMetric(v, fmt) {
1269
+ if (v == null) return '—';
1270
+ if (fmt === 'dur') return fmtDur(v);
1271
+ if (fmt === 'pct') return pctStr(v);
1272
+ if (fmt === 'usd') return usd(v);
1273
+ return fmtInt(v);
1274
+ }
1275
+ // "×2.4" when B is bigger, "÷2.4" when smaller; never "×0.00".
1276
+ function fmtRatio(r) {
1277
+ if (r == null || !Number.isFinite(r)) return '—';
1278
+ if (r === 1) return '×1.00';
1279
+ const v = r >= 1 ? r : 1 / r; const t = v >= 100 ? Math.round(v).toString() : v >= 10 ? v.toFixed(1) : v.toFixed(2);
1280
+ return (r >= 1 ? '×' : '÷') + t;
1281
+ }
1282
+ // Change of B relative to A: a signed percentage where that reads well, ×N for big increases,
1283
+ // the absolute delta when A is 0 (no ratio) or B is 0 (no "÷∞").
1284
+ function fmtChange(m) {
1285
+ if (m.delta == null) return '—';
1286
+ const r = m.ratio;
1287
+ if (r == null || !m.a || !m.b) return (m.delta > 0 ? '+' : m.delta < 0 ? '−' : '') + fmtMetric(Math.abs(m.delta), m.fmt);
1288
+ if (r === 1) return '0%';
1289
+ if (r < 1) { const p = (1 - r) * 100; return '−' + (p < 10 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%'; }
1290
+ if (r < 10) { const p = (r - 1) * 100; return '+' + (p < 10 ? p.toFixed(1) : Math.round(p)) + '%'; }
1291
+ return fmtRatio(r);
1292
+ }
1293
+ function compare(A, B) {
1294
+ const metrics = METRICS.map((d) => {
1295
+ const a = d.get(A), b = d.get(B);
1296
+ const ok = a != null && b != null && Number.isFinite(a) && Number.isFinite(b);
1297
+ const delta = ok ? b - a : null;
1298
+ const ratio = ok && a ? b / a : (ok && !a && !b ? 1 : null);
1299
+ let better = null;
1300
+ if (ok && d.dir && a !== b) better = (d.dir === 'low') === (a < b) ? 'a' : 'b';
1301
+ return { key: d.key, label: d.label, fmt: d.fmt, dir: d.dir, a, b, delta, ratio, better, aText: fmtMetric(a, d.fmt), bText: fmtMetric(b, d.fmt) };
1302
+ });
1303
+ const ta = toolTable(A.trace), tb = toolTable(B.trace);
1304
+ const names = new Set([...ta.map((t) => t.name), ...tb.map((t) => t.name)]);
1305
+ const zero = { calls: 0, errors: 0, time: 0 };
1306
+ const tools = Array.from(names).map((name) => { const a = ta.find((t) => t.name === name) || zero, b = tb.find((t) => t.name === name) || zero; return { name, a: { calls: a.calls, errors: a.errors, time: a.time }, b: { calls: b.calls, errors: b.errors, time: b.time }, deltaCalls: b.calls - a.calls, deltaTime: b.time - a.time }; });
1307
+ tools.sort((x, y) => Math.abs(y.deltaCalls) - Math.abs(x.deltaCalls) || (y.a.calls + y.b.calls) - (x.a.calls + x.b.calls) || x.name.localeCompare(y.name));
1308
+ const byId = (fs) => { const m = new Map(); for (const f of fs) { if (!m.has(f.id)) m.set(f.id, []); m.get(f.id).push(f); } return m; };
1309
+ const fa = byId(A.findings), fb = byId(B.findings);
1310
+ const slim = (f) => ({ id: f.id, severity: f.severity, title: f.title });
1311
+ const onlyA = [], onlyB = [], both = [];
1312
+ for (const [id, list] of fa) { if (fb.has(id)) both.push({ id, a: list.map(slim), b: fb.get(id).map(slim) }); else onlyA.push(...list.map(slim)); }
1313
+ for (const [id, list] of fb) if (!fa.has(id)) onlyB.push(...list.map(slim));
1314
+ const pick = (key) => { const m = metrics.find((x) => x.key === key); return m ? m.better : null; };
1315
+ const cleaner = (() => { const ea = A.findings.filter((f) => f.severity === 'error').length, eb = B.findings.filter((f) => f.severity === 'error').length; if (ea !== eb) return ea < eb ? 'a' : 'b'; const wa = A.findings.length, wb = B.findings.length; return wa === wb ? null : (wa < wb ? 'a' : 'b'); })();
1316
+ const verdict = { cheaper: pick('cost'), faster: pick('wallMs') || pick('activeMs'), cleaner };
1317
+ return { metrics, tools, findings: { onlyA, onlyB, both }, verdict, a: { sessionId: A.trace.meta.sessionId, title: A.trace.meta.title, models: A.trace.meta.models }, b: { sessionId: B.trace.meta.sessionId, title: B.trace.meta.title, models: B.trace.meta.models } };
1318
+ }
1319
+ function compareMarkdown(c, opts) {
1320
+ opts = opts || {};
1321
+ const la = opts.labelA || 'A', lb = opts.labelB || 'B';
1322
+ const out = [`# Glassbox compare — ${la} vs ${lb}`, ''];
1323
+ out.push(`- ${la}: ${c.a.sessionId || '—'}${c.a.title ? ' · ' + oneLine(c.a.title, 70) : ''}${c.a.models.length ? ' · ' + c.a.models.join(', ') : ''}`);
1324
+ out.push(`- ${lb}: ${c.b.sessionId || '—'}${c.b.title ? ' · ' + oneLine(c.b.title, 70) : ''}${c.b.models.length ? ' · ' + c.b.models.join(', ') : ''}`, '');
1325
+ const v = c.verdict; const who = (x) => x === 'a' ? la : x === 'b' ? lb : 'tie';
1326
+ out.push(`**Verdict** — cheaper: ${who(v.cheaper)} · faster: ${who(v.faster)} · cleaner: ${who(v.cleaner)}`, '');
1327
+ out.push(`| metric | ${la} | ${lb} | change |`, '|---|---|---|---|');
1328
+ for (const m of c.metrics) { const ch = fmtChange(m); out.push(`| ${m.label} | ${m.aText} | ${m.bText} | ${ch}${m.better ? ' (' + who(m.better) + ' better)' : ''} |`); }
1329
+ out.push('');
1330
+ if (c.tools.length) { out.push('## Tools', '', `| tool | ${la} calls (errors) | ${lb} calls (errors) | Δ calls |`, '|---|---|---|---|'); for (const t of c.tools) out.push(`| ${t.name} | ${t.a.calls} (${t.a.errors}) | ${t.b.calls} (${t.b.errors}) | ${t.deltaCalls > 0 ? '+' : ''}${t.deltaCalls} |`); out.push(''); }
1331
+ out.push('## Findings', '');
1332
+ if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) out.push('Neither session was flagged.', '');
1333
+ if (c.findings.onlyA.length) { out.push(`Only in ${la}:`, ''); for (const f of c.findings.onlyA) out.push(`- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}`); out.push(''); }
1334
+ if (c.findings.onlyB.length) { out.push(`Only in ${lb}:`, ''); for (const f of c.findings.onlyB) out.push(`- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}`); out.push(''); }
1335
+ if (c.findings.both.length) { out.push('In both:', ''); for (const f of c.findings.both) out.push(`- \`${f.id}\` — ${la}: ${f.a.length}, ${lb}: ${f.b.length}`); out.push(''); }
1336
+ out.push('_Generated by Glassbox ' + VERSION + '._', '');
1337
+ return out.join('\n');
1338
+ }
1339
+
1083
1340
  // ---------------------------------------------------------------------------
1084
1341
  // Redaction — keep structure, timestamps, usage, tool names; blank every string.
1085
1342
  // ---------------------------------------------------------------------------
@@ -1108,7 +1365,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1108
1365
  }
1109
1366
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1110
1367
 
1111
- return { VERSION, RATES, DEFAULTS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, stableStringify };
1368
+ return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable };
1112
1369
  });
1113
1370
  </script>
1114
1371
  <script>
@@ -1129,7 +1386,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1129
1386
  const say = (msg) => { const s = $('#status'); s.textContent = ''; setTimeout(() => { s.textContent = msg; }, 30); };
1130
1387
 
1131
1388
  // ------------------------------------------------------------ state
1132
- 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 };
1389
+ 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 };
1390
+ const pickingForCompare = () => $('#cmp-dlg').open;
1133
1391
  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)); }
1134
1392
  const R = (s) => S.share ? '«' + String(s == null ? '' : s).length + ' chars»' : s;
1135
1393
 
@@ -1154,9 +1412,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1154
1412
  if (!usedEntries) for (const f of Array.from(dt.files || [])) out.push({ name: f.name, file: f });
1155
1413
  await loadFiles(out);
1156
1414
  }
1157
- 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) load(files, false); else say('No .jsonl files found in what was dropped.'); }
1158
- function load(files, isDemo) {
1415
+ 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); }
1416
+ function load(files, isDemo, keepOther) {
1159
1417
  S.files = files; S.isDemo = !!isDemo;
1418
+ if (!keepOther) { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; }
1160
1419
  S.records = files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
1161
1420
  S.trace = C.parseTrace(files);
1162
1421
  S.findings = C.diagnose(S.trace);
@@ -1164,6 +1423,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1164
1423
  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 = '';
1165
1424
  $('#main').hidden = false; $('#banner').hidden = !isDemo;
1166
1425
  renderAll();
1426
+ if (S.other) renderCompare();
1167
1427
  const T = S.trace.totals;
1168
1428
  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.'; }
1169
1429
  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' : ''}.`);
@@ -1383,7 +1643,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1383
1643
  function hideTip() { tip.style.display = 'none'; }
1384
1644
  document.addEventListener('mousemove', (e) => { if (tip.style.display === 'block' && !(document.activeElement && document.activeElement.dataset && document.activeElement.dataset.kind)) moveTip(e.clientX, e.clientY); });
1385
1645
  function cum(i) { let s = 0; for (let k = 0; k <= i; k++) s += S.cost.byRequest[k] || 0; return s; }
1386
- function inputSummary(c) { const i = c.input || {}; if (i.command) return i.command; if (i.file_path) return i.file_path + (i.pattern ? ' ' + i.pattern : ''); if (i.pattern) return i.pattern; if (i.query) return i.query; if (i.description) return i.description; if (i.url) return i.url; if (i.prompt) return i.prompt; if (i.path) return i.path; const s = JSON.stringify(i); return s === '{}' ? '' : s; }
1646
+ const inputSummary = C.inputSummary;
1387
1647
 
1388
1648
  // ------------------------------------------------------------ burn chart
1389
1649
  function renderBurn() {
@@ -1423,13 +1683,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1423
1683
 
1424
1684
  // ------------------------------------------------------------ findings
1425
1685
  function findingMd(f) { return `- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}\n ${f.detail}`; }
1426
- function reportMd() {
1427
- const t = S.trace, T = t.totals, m = t.meta;
1428
- 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`;
1429
- const fs = S.findings.length ? `## Findings (${S.findings.length})\n\n` + S.findings.map(findingMd).join('\n') + '\n' : '## Findings\n\nNothing flagged.\n';
1430
- 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';
1431
- return head + fs + tools + '\n_Generated by Glassbox._\n';
1432
- }
1686
+ function reportMd() { return C.reportMarkdown(S.trace, S.findings, S.cost, { redact: S.share }); }
1433
1687
  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; } }
1434
1688
  $('#copy-close').onclick = () => $('#copy-dlg').close();
1435
1689
  function renderFindings() {
@@ -1557,6 +1811,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1557
1811
  function setHash(h) { hashLock = true; try { history.replaceState(null, '', '#' + h); } catch (e) { } setTimeout(() => { hashLock = false; }, 0); }
1558
1812
  function applyHash() {
1559
1813
  const h = location.hash.slice(1); if (!h || !S.trace) return;
1814
+ if (h === 'compare') { if (S.other) $('#cmp-sec').scrollIntoView(); return; }
1560
1815
  const eq = h.indexOf('='); if (eq < 0) return; const k = h.slice(0, eq), v = decodeURIComponent(h.slice(eq + 1)); if (!v) return;
1561
1816
  if (k === 'req') { const r = S.trace.requests[+v - 1]; if (r) select({ kind: 'req', ref: r }); }
1562
1817
  else if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === v); if (c) select({ kind: 'tool', ref: c }); }
@@ -1588,7 +1843,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1588
1843
  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); }); },
1589
1844
  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; } },
1590
1845
  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) { } } };
1591
- const decodeProject = (n) => n.replace(/^-/, '/').replace(/-/g, '/');
1846
+ const decodeProject = (n) => { const m = n.match(/^([A-Za-z])--(.*)$/); if (m) return m[1] + ':/' + m[2].replace(/-/g, '/'); return n.replace(/^-/, '/').replace(/-/g, '/'); };
1592
1847
  async function scanProjects(dir) {
1593
1848
  const sessions = [];
1594
1849
  for await (const [pname, ph] of dir.entries()) {
@@ -1610,7 +1865,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1610
1865
  async function loadSession(s) {
1611
1866
  const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
1612
1867
  try { const sdir = await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'); for await (const [n, h] of sdir.entries()) if (h.kind === 'file' && /\.(jsonl|json)$/.test(n)) files.push({ name: s.id + '/subagents/' + n, text: await (await h.getFile()).text() }); } catch (e) { }
1613
- $('#sessions-dlg').close(); load(files, false);
1868
+ $('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
1614
1869
  }
1615
1870
  async function showSessions(dir) {
1616
1871
  const dlg = $('#sessions-dlg'); $('#sessions-note').textContent = 'Scanning ' + dir.name + '…'; $('#sessions-tbl').innerHTML = ''; dlg.showModal();
@@ -1628,6 +1883,99 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1628
1883
  }
1629
1884
  $('#sessions-close').onclick = () => $('#sessions-dlg').close();
1630
1885
 
1886
+ // ------------------------------------------------------------ compare
1887
+ function analyseFiles(files) { const trace = C.parseTrace(files); return { files, trace, findings: C.diagnose(trace), cost: C.estimateCost(trace, S.rates) }; }
1888
+ function loadOther(files) {
1889
+ if (!S.trace) { say('Load a session first, then compare it with another.'); return; }
1890
+ S.other = analyseFiles(files);
1891
+ try { $('#cmp-dlg').close(); } catch (e) { }
1892
+ renderCompare();
1893
+ const m = S.cmp.metrics.find((x) => x.key === 'cost');
1894
+ 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' : ''}.`);
1895
+ $('#cmp-sec').scrollIntoView({ behavior: 'smooth', block: 'start' });
1896
+ setHash('compare');
1897
+ }
1898
+ 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; }
1899
+ function renderCompare() {
1900
+ if (!S.other) return;
1901
+ const A = { trace: S.trace, findings: S.findings, cost: S.cost }, B = S.other;
1902
+ const c = S.cmp = C.compare(A, B);
1903
+ const la = S.labels.a, lb = S.labels.b;
1904
+ 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>';
1905
+ $('#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`;
1906
+ const rows = c.metrics.map((m) => {
1907
+ let bar = '<span class="ch">—</span>';
1908
+ if (m.delta != null) {
1909
+ const good = m.better === 'a' ? 'bad' : m.better === 'b' ? 'good' : '';
1910
+ const ch = C.fmtChange(m);
1911
+ const r = m.ratio != null && m.a ? m.ratio : (m.delta === 0 ? 1 : 2);
1912
+ const f = Math.min(1, Math.abs(Math.log2(Math.max(r, 1e-6))) / 3);
1913
+ const left = r < 1, w = (f * 50).toFixed(1);
1914
+ 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>`;
1915
+ }
1916
+ const sr = m.delta == null ? 'not comparable' : m.better ? `${m.better === 'a' ? la : lb} better` : 'same';
1917
+ 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>`;
1918
+ }).join('');
1919
+ 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('');
1920
+ 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>';
1921
+ $('#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>
1922
+ <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>
1923
+ <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>` : ''}
1924
+ <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>`;
1925
+ $('#cmp-sec').hidden = false;
1926
+ }
1927
+ $('#compare').onclick = () => { if (!S.trace) { say('Load a session first.'); return; } $('#cmp-folder').hidden = !FSA; $('#cmp-dlg').showModal(); };
1928
+ $('#cmp-dlg-close').onclick = () => $('#cmp-dlg').close();
1929
+ $('#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); } };
1930
+ $('#sessions-dlg').addEventListener('close', () => { setTimeout(() => { S.pickOther = false; }, 0); });
1931
+ $('#cmp-pick').onclick = () => $('#file-cmp').click();
1932
+ $('#file-cmp').addEventListener('change', (e) => { loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f })), true); e.target.value = ''; });
1933
+ $('#cmp-demo').onclick = () => { if (!DEMO) { say('No demo embedded in this build.'); return; } loadOther(DEMO.map((f) => ({ name: f.name, text: f.text }))); };
1934
+ $('#cmp-close').onclick = () => { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; setHash(''); say('Compare closed.'); $('#compare').focus(); };
1935
+ $('#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.`); };
1936
+ $('#cmp-copy').onclick = () => { if (!S.cmp) return; copyText(C.compareMarkdown(S.cmp, { labelA: S.labels.a, labelB: S.labels.b }), 'Comparison (Markdown)'); };
1937
+
1938
+ // ------------------------------------------------------------ live tail
1939
+ let liveTimer = null;
1940
+ function applyLiveFiles(ev) {
1941
+ const f = S.files.find((x) => x.name === ev.name);
1942
+ if (ev.type === 'append') { if (f) f.text += ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
1943
+ else if (ev.type === 'replace') { if (f) f.text = ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
1944
+ else if (ev.type === 'hello') { S.files = ev.files.map((x) => ({ name: x.name, text: x.text })); }
1945
+ if (liveTimer) return; liveTimer = setTimeout(() => { liveTimer = null; refresh(); }, 150);
1946
+ }
1947
+ // Re-parse the current files in place: selection, filter, search and zoom survive; Follow keeps the right edge on now.
1948
+ function refresh() {
1949
+ const wasWhole = !S.view || (TL.total && S.view.t1 >= TL.total - 1 && S.view.t0 <= 1);
1950
+ const oldSpan = S.view ? S.view.t1 - S.view.t0 : null;
1951
+ const selId = S.sel ? S.sel.ref.id : null, selKind = S.sel ? S.sel.kind : null;
1952
+ S.records = S.files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
1953
+ S.trace = C.parseTrace(S.files); S.findings = C.diagnose(S.trace); S.cost = C.estimateCost(S.trace, S.rates);
1954
+ 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; }
1955
+ if (S.toolFilter && !S.trace.toolCalls.some((c) => c.name === S.toolFilter)) S.toolFilter = null;
1956
+ buildLayout();
1957
+ 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 }; }
1958
+ renderSession(); renderStrip(); renderFit(); renderTimeline(); renderBurn(); renderFindings(); renderTools(); renderTurns();
1959
+ if (S.query) runSearch();
1960
+ if (S.sel && !$('#drawer').hidden) select(S.sel, true);
1961
+ if (S.other) renderCompare();
1962
+ const t = new Date(); $('#live-txt').textContent = 'LIVE · ' + t.toTimeString().slice(0, 8);
1963
+ }
1964
+ function startLive(cfg) {
1965
+ const pill = $('#live'); pill.hidden = false; $('#follow').hidden = false;
1966
+ $('#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(); };
1967
+ const es = new EventSource(cfg.url); S.live = es;
1968
+ es.addEventListener('hello', (e) => { pill.classList.remove('off'); $('#live-txt').textContent = 'LIVE'; applyLiveFiles(Object.assign({ type: 'hello' }, JSON.parse(e.data))); });
1969
+ es.addEventListener('append', (e) => applyLiveFiles(JSON.parse(e.data)));
1970
+ es.addEventListener('replace', (e) => applyLiveFiles(JSON.parse(e.data)));
1971
+ es.onerror = () => { pill.classList.add('off'); $('#live-txt').textContent = 'reconnecting'; };
1972
+ // any manual zoom or pan lets go of Follow
1973
+ const letGo = () => { if (S.follow) { S.follow = false; $('#follow').setAttribute('aria-pressed', 'false'); } };
1974
+ $('#tl').addEventListener('wheel', letGo, { passive: true }); $('#tl').addEventListener('pointerdown', letGo);
1975
+ $('#z-in').addEventListener('click', letGo); $('#z-out').addEventListener('click', letGo); $('#fit').addEventListener('change', letGo);
1976
+ $('#banner').hidden = true;
1977
+ }
1978
+
1631
1979
  // ------------------------------------------------------------ help + inputs
1632
1980
  $('#help').onclick = () => $('#help-dlg').showModal(); $('#help-close').onclick = () => $('#help-dlg').close();
1633
1981
  $('#banner-x').onclick = () => { $('#banner').hidden = true; };
@@ -1637,6 +1985,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1637
1985
  drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); handleDataTransfer(e.dataTransfer); });
1638
1986
  document.addEventListener('dragover', (e) => { e.preventDefault(); });
1639
1987
  document.addEventListener('drop', (e) => { if (!drop.contains(e.target)) { e.preventDefault(); handleDataTransfer(e.dataTransfer); } });
1988
+ $('#cmp-dlg').addEventListener('dragover', (e) => e.preventDefault()); $('#cmp-dlg').addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); handleDataTransfer(e.dataTransfer); });
1640
1989
  $('#file').addEventListener('change', (e) => loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f }))));
1641
1990
  drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#file').click(); } });
1642
1991
 
@@ -1644,8 +1993,14 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1644
1993
  const EMBED = /*__EMBED__*/null;
1645
1994
  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); }
1646
1995
  $('#demo').onclick = loadDemo;
1647
- if (EMBED && EMBED.length) load(EMBED.map((f) => ({ name: f.name, text: f.text })), false);
1996
+ const LIVE = /*__LIVE__*/null;
1997
+ const embedFiles = EMBED && !Array.isArray(EMBED) ? EMBED.files : EMBED;
1998
+ if (embedFiles && embedFiles.length) {
1999
+ load(embedFiles.map((f) => ({ name: f.name, text: f.text })), false);
2000
+ 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 }))); }
2001
+ }
1648
2002
  else if (DEMO) loadDemo();
2003
+ if (LIVE && LIVE.url && 'EventSource' in window) startLive(LIVE);
1649
2004
  })();
1650
2005
  </script>
1651
2006