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.
@@ -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.5.0';
357
406
 
358
407
  // ---------------------------------------------------------------------------
359
408
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -401,6 +450,13 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
401
450
  longTurnTools: 30,
402
451
  failedToolRate: 0.5,
403
452
  failedToolMinCalls: 3,
453
+ failedToolWarnMinErrors: 2,
454
+ failedToolWarnRate: 0.2,
455
+ cacheMissMinShare: 0.05,
456
+ longGenerationMaxTokPerSec: 15,
457
+ duplicateReadAgents: 3,
458
+ duplicateReadWarnChars: 100000,
459
+ groupRepeatsMin: 3, // slow-tool / oversized-result: this many from one tool become one finding
404
460
  };
405
461
 
406
462
  // ---------------------------------------------------------------------------
@@ -410,18 +466,31 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
410
466
  const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
411
467
  const EXEC_TOOLS = new Set(['Bash', 'BashOutput', 'KillShell']);
412
468
  const AGENT_TOOLS = new Set(['Agent', 'Task', 'Workflow']);
413
- const USER_TOOLS = new Set(['AskUserQuestion']);
469
+ const USER_TOOLS = new Set(['AskUserQuestion', 'SendUserMessage', 'SendUserFile', 'ExitPlanMode', 'EnterPlanMode']);
470
+ // Cowork: Artifact publishes a page (a write), Skill loads instructions (a read); Task* are bookkeeping (other).
471
+ const COWORK_READ = new Set(['Skill', 'ListSkills', 'SearchSkills', 'TaskList', 'TaskGet']);
472
+ const COWORK_WRITE = new Set(['Artifact']);
473
+ // Blocking reads of a background task's output: the duration is the task's run time, and re-issuing the
474
+ // same call after a "still running" timeout is polling, not a retry.
475
+ const WAIT_TOOLS = new Set(['TaskOutput', 'AgentOutputTool']);
476
+ const isWait = (c) => WAIT_TOOLS.has(c.name) && !(c.input && c.input.block === false);
477
+ const waitTarget = (c) => { const i = c.input || {}; return String(i.task_id || i.agentId || i.bash_id || ''); };
478
+ const hasRange = (c) => !!(c.input && (c.input.limit != null || c.input.offset != null || c.input.head_limit != null));
479
+ // MCP leaf names are verb_noun or noun_verb ("memory_read", "device_list_dir", "send_message"): match the verb as a word.
480
+ const MCP_READ = /(^|_)(get|list|search|read|find|fetch|query|describe|show|stage|screenshot|context|status|info|check)(_|$)/i;
481
+ const MCP_WRITE = /(^|_)(write|set|put|create|update|delete|remove|commit|send|reply|forward|upload|str_replace|append|label|move|rename|save)(_|$)/i;
414
482
 
415
483
  function toolCategory(name) {
416
484
  if (!name) return 'other';
417
- if (READ_TOOLS.has(name)) return 'read';
418
- if (WRITE_TOOLS.has(name)) return 'write';
485
+ if (READ_TOOLS.has(name) || COWORK_READ.has(name)) return 'read';
486
+ if (WRITE_TOOLS.has(name) || COWORK_WRITE.has(name)) return 'write';
419
487
  if (EXEC_TOOLS.has(name) || /device_bash$/.test(name)) return 'exec';
420
488
  if (AGENT_TOOLS.has(name)) return 'agent';
421
489
  if (USER_TOOLS.has(name)) return 'user';
422
490
  if (name.startsWith('mcp__')) {
423
491
  const leaf = name.split('__').pop() || '';
424
- if (/^(get|list|search|read|find|fetch|query|describe|show)/i.test(leaf)) return 'read';
492
+ if (MCP_READ.test(leaf)) return 'read';
493
+ if (MCP_WRITE.test(leaf)) return 'write';
425
494
  return 'mcp';
426
495
  }
427
496
  return 'other';
@@ -487,6 +556,26 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
487
556
  return !u.input && !u.cacheRead && !u.cacheWrite && !u.output;
488
557
  }
489
558
 
559
+ // A tool call that never ran because the human or a permission rule stopped it. Claude Code
560
+ // records toolDenialKind (user-rejected, permission-rule, automode-blocked, interrupted); older
561
+ // transcripts only have the result text. A shell's own "Permission denied" is a real failure.
562
+ function denialKind(rec, text) {
563
+ if (rec && rec.toolDenialKind) return String(rec.toolDenialKind);
564
+ const t = String(text || '').trim();
565
+ if (/^\[Request interrupted by user/.test(t)) return 'interrupted';
566
+ if (/^The user doesn't want to proceed with this tool use|^User rejected tool use|^Denied by voice/.test(t)) return 'user-rejected';
567
+ if (/^Permission (for this action was|to use \S+ has been) denied/.test(t)) return 'permission-rule';
568
+ return null;
569
+ }
570
+
571
+ // One API response is written as several records (one per content block). Subagent transcripts
572
+ // rewrite output_tokens as the stream progresses, so the first record can be far below the final
573
+ // count; the other fields repeat. Keep the largest value seen for each field.
574
+ function maxUsage(a, b) {
575
+ for (const k of Object.keys(a)) if ((b[k] || 0) > a[k]) a[k] = b[k];
576
+ return a;
577
+ }
578
+
490
579
  // ---------------------------------------------------------------------------
491
580
  // Line parsing
492
581
  // ---------------------------------------------------------------------------
@@ -577,7 +666,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
577
666
  const meta = {
578
667
  sessionId: null, version: null, cwd: null, gitBranch: null, entrypoint: null, title: null,
579
668
  models: [], start: null, end: null, hasTimestamps, files: fileMeta, format: 'claude-code',
580
- reportedCost: null, reportedUsage: null, recordCounts: {},
669
+ reportedCost: null, reportedUsage: null, reportedTurns: null, reportedDurationMs: null, recordCounts: {},
581
670
  };
582
671
 
583
672
  const agents = new Map(); // id -> agent
@@ -587,6 +676,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
587
676
  agents.set(id, {
588
677
  id, type: id === 'main' ? 'main' : 'subagent', description: null, parentToolUseId: null,
589
678
  start: null, end: null, usage: emptyUsage(), requestCount: 0, toolCount: 0, model: null,
679
+ workflowRun: (String(rec._file || '').match(/workflows[\\/](wf_[^\\/]+)[\\/]/) || [])[1] || null,
590
680
  });
591
681
  }
592
682
  const a = agents.get(id);
@@ -637,13 +727,17 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
637
727
  if (!meta.gitBranch && rec.gitBranch) meta.gitBranch = rec.gitBranch;
638
728
  if (!meta.entrypoint && rec.entrypoint) meta.entrypoint = rec.entrypoint;
639
729
  const t = ts(rec);
640
- if (t != null) { if (meta.start == null || t < meta.start) meta.start = t; if (meta.end == null || t > meta.end) meta.end = t; }
730
+ // Only conversation records bound the session: bookkeeping records (frame-link, file-history…)
731
+ // can be written days later and would stretch the wall clock.
732
+ if (t != null && (type === 'user' || type === 'assistant' || type === 'system')) { if (meta.start == null || t < meta.start) meta.start = t; if (meta.end == null || t > meta.end) meta.end = t; }
641
733
 
642
734
  if (type === 'summary') { if (rec.summary && !meta.title) meta.title = rec.summary; events.push({ kind: 'summary', at: t, detail: rec.summary }); continue; }
643
735
  if (type === 'attachment') { attachments.count++; attachments.chars += JSON.stringify(rec.attachment || '').length; continue; }
644
736
  if (type === 'result') {
645
737
  meta.format = 'stream-json';
646
738
  if (typeof rec.total_cost_usd === 'number') meta.reportedCost = rec.total_cost_usd;
739
+ if (typeof rec.num_turns === 'number') meta.reportedTurns = rec.num_turns;
740
+ if (typeof rec.duration_ms === 'number') meta.reportedDurationMs = rec.duration_ms;
647
741
  if (rec.usage) meta.reportedUsage = usageFrom(rec.usage);
648
742
  if (rec.is_error) events.push({ kind: 'api_error', at: t, detail: textOf(rec.result) || 'result is_error' });
649
743
  continue;
@@ -689,6 +783,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
689
783
  if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = JSON.stringify(structured).length;
690
784
  // stream-json puts is_error only in block; Claude Code also stores toolUseResult as an Error string
691
785
  if (!tr.is_error && typeof structured === 'string' && /^Error/i.test(structured)) { call.status = 'error'; call.isError = true; }
786
+ if (call.isError) call.denial = denialKind(rec, text);
692
787
  } else {
693
788
  toolCalls.push(mkOrphanResult(tr, rec, a.id, text, turns.length ? turns[turns.length - 1].index : null));
694
789
  }
@@ -701,6 +796,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
701
796
  let kind = 'human';
702
797
  if (rec.isCompactSummary) kind = 'compact';
703
798
  else if (rec.isMeta) kind = 'meta';
799
+ else if (/^\[Request interrupted by user/.test(text.trim())) kind = 'interrupt';
704
800
  else if (/^<(system-reminder|local-command|command-name|bash-input|bash-stdout)/.test(text.trim())) kind = 'meta';
705
801
  ensureTurn(rec, kind, text);
706
802
  continue;
@@ -723,16 +819,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
723
819
  turn.requestIds.push(req.id);
724
820
  a.requestCount++;
725
821
  if (msg.model) { pushModel(meta, msg.model); if (!a.model) a.model = msg.model; }
726
- const u = usageFrom(msg.usage);
727
- if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; addUsage(turn.usage, u); addUsage(a.usage, u); }
728
822
  } else {
729
823
  if (t != null) { if (req.start == null || t < req.start) req.start = t; if (req.end == null || t > req.end) req.end = t; }
730
- // usage: if the first record lacked it and a later one has it, take it once
731
- if (isEmptyUsage(req.usage)) {
732
- const u = usageFrom(msg.usage);
733
- if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; const turn = turns[req.turnIndex]; addUsage(turn.usage, u); addUsage(a.usage, u); }
734
- }
735
824
  }
825
+ maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
736
826
  req.records++;
737
827
  if (msg.stop_reason) req.stopReason = msg.stop_reason;
738
828
  if (rec.isApiErrorMessage) req.isApiError = true;
@@ -745,7 +835,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
745
835
  id: b.id || (key + ':' + blockIndex), agent: a.id, requestId: req.id, name: b.name || 'unknown', input: b.input || {},
746
836
  start: t, end: null, durationMs: null, status: 'orphan', isError: false, resultText: '', resultChars: 0,
747
837
  resultStructured: null, category: toolCategory(b.name), turnIndex: req.turnIndex, subagentId: null,
748
- inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0,
838
+ inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0, denial: null,
749
839
  };
750
840
  toolCalls.push(call);
751
841
  toolCallById.set(call.id, call);
@@ -767,7 +857,12 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
767
857
  }
768
858
 
769
859
  // Finalise
770
- for (const req of requests) req.blocks.sort((x, y) => x.index - y.index);
860
+ for (const req of requests) {
861
+ req.blocks.sort((x, y) => x.index - y.index);
862
+ req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
863
+ addUsage(turns[req.turnIndex].usage, req.usage);
864
+ addUsage(agents.get(req.agent).usage, req.usage);
865
+ }
771
866
  for (const turn of turns) if (turn.end == null) turn.end = meta.end;
772
867
  // Response timing. Assistant record timestamps are written when a block finishes streaming,
773
868
  // so a request's "start" is really its first completed block. We measure from the moment the
@@ -799,9 +894,14 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
799
894
  const parent = toolCalls.find((c) => c.category === 'agent' && c.agent === 'main' && ((c.resultText && c.resultText.includes(ag.id)) || (c.resultStructured && JSON.stringify(c.resultStructured).includes(ag.id))));
800
895
  if (parent) ag.parentToolUseId = parent.id;
801
896
  }
897
+ // Workflow agents live in subagents/workflows/<runId>/; the Workflow call's result names that runId.
898
+ if (!ag.parentToolUseId && ag.workflowRun) {
899
+ const parent = toolCalls.find((c) => c.name === 'Workflow' && ((c.resultStructured && c.resultStructured.runId === ag.workflowRun) || (c.resultText && c.resultText.includes(ag.workflowRun))));
900
+ if (parent) ag.parentToolUseId = parent.id;
901
+ }
802
902
  if (ag.parentToolUseId) {
803
903
  const parent = toolCallById.get(ag.parentToolUseId);
804
- if (parent) { parent.subagentId = ag.id; if (!ag.description && parent.input && parent.input.description) ag.description = parent.input.description; if (!ag.agentType && parent.input && parent.input.subagent_type) ag.agentType = parent.input.subagent_type; }
904
+ if (parent) { if (!parent.subagentId) parent.subagentId = ag.id; (parent.subagentIds || (parent.subagentIds = [])).push(ag.id); if (!ag.description && parent.input && parent.input.description) ag.description = parent.input.description; if (!ag.agentType && parent.input && parent.input.subagent_type) ag.agentType = parent.input.subagent_type; }
805
905
  }
806
906
  }
807
907
 
@@ -809,12 +909,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
809
909
  const usage = emptyUsage();
810
910
  for (const r of requests) addUsage(usage, r.usage);
811
911
  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;
912
+ // stream-json runs carry no per-record timestamps and no human prompt record, but the result
913
+ // record reports duration_ms and num_turns; use those rather than showing "—" and 0.
914
+ const wallMs = (meta.start != null && meta.end != null) ? meta.end - meta.start : (meta.reportedDurationMs != null ? meta.reportedDurationMs : null);
915
+ const humanTurns = turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length;
813
916
  const totals = {
814
917
  usage, wallMs, humanIdleMs, activeMs: wallMs != null ? Math.max(0, wallMs - humanIdleMs) : null,
815
918
  requests: requests.length, toolCalls: toolCalls.length, toolErrors: toolCalls.filter((c) => c.isError).length,
816
919
  orphans: toolCalls.filter((c) => c.status === 'orphan').length,
817
- turns: turns.filter((tn) => tn.promptKind === 'human' && tn.agent === 'main').length,
920
+ turns: humanTurns || (meta.reportedTurns != null ? meta.reportedTurns : 0),
818
921
  toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
819
922
  modelTimeMs: agents.get('main').modelTimeMs || 0,
820
923
  cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
@@ -852,9 +955,20 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
852
955
  if (!byTurnSig.has(sig)) byTurnSig.set(sig, []);
853
956
  byTurnSig.get(sig).push(c);
854
957
  }
958
+ const callIndex = new Map(calls.map((c, i) => [c, i]));
855
959
  for (const group of byTurnSig.values()) {
856
960
  if (group.length >= o.retryLoopMin) {
857
961
  const errs = group.filter((c) => c.isError).length;
962
+ if (isWait(group[0]) && errs < 2) continue; // polling a running task; failing polls still count
963
+ if (errs < 2) {
964
+ // Re-running a test or taking another screenshot after something changed is observing, not
965
+ // looping: only flag successful repeats that returned the same thing with nothing in between.
966
+ const inGroup = new Set(group);
967
+ const between = calls.slice(callIndex.get(group[0]) + 1, callIndex.get(group[group.length - 1]));
968
+ const changed = between.some((c) => c.agent === group[0].agent && !inGroup.has(c) && c.category !== 'read');
969
+ const same = group.every((c) => c.resultText === group[0].resultText && c.imageBytes === group[0].imageBytes);
970
+ if (changed || !same) continue;
971
+ }
858
972
  push({
859
973
  id: 'retry-loop', severity: errs >= 2 ? 'error' : 'warn',
860
974
  title: `${group[0].name} called ${group.length}× with identical input`,
@@ -864,14 +978,16 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
864
978
  }
865
979
  }
866
980
 
867
- // failed-tool: per tool error rate
981
+ // failed-tool: per tool error rate. Calls the human or a permission rule stopped are not the
982
+ // tool's failure (see permission-denied); one stray error in a long session is not a pattern.
868
983
  const byTool = new Map();
869
- for (const c of calls) { if (c.unmatched) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
984
+ for (const c of calls) { if (c.unmatched || c.denial) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
870
985
  for (const [name, list] of byTool) {
871
986
  const errs = list.filter((c) => c.isError);
872
987
  if (!errs.length) continue;
873
988
  const rate = errs.length / list.length;
874
989
  const bad = list.length >= o.failedToolMinCalls && rate >= o.failedToolRate;
990
+ if (!bad && (errs.length < o.failedToolWarnMinErrors || rate < o.failedToolWarnRate)) continue;
875
991
  push({
876
992
  id: 'failed-tool', severity: bad ? 'error' : 'warn',
877
993
  title: `${name} failed ${errs.length} of ${list.length} calls (${Math.round(rate * 100)}%)`,
@@ -880,6 +996,18 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
880
996
  });
881
997
  }
882
998
 
999
+ // permission-denied: calls that never ran because the human rejected them, a permission rule or
1000
+ // the auto-mode classifier blocked them, or the human interrupted.
1001
+ const denied = calls.filter((c) => c.denial);
1002
+ if (denied.length) {
1003
+ const kinds = {}; for (const c of denied) kinds[c.denial] = (kinds[c.denial] || 0) + 1;
1004
+ push({
1005
+ id: 'permission-denied', severity: 'info', title: `${denied.length} tool call${denied.length > 1 ? 's' : ''} denied or interrupted`,
1006
+ detail: Object.entries(kinds).map(([k, n]) => `${k} ×${n}`).join(', ') + ' — ' + summariseNames(denied),
1007
+ evidence: { toolCallIds: denied.map((c) => c.id) }, metric: denied.length,
1008
+ });
1009
+ }
1010
+
883
1011
  // orphan-tool
884
1012
  const orphans = calls.filter((c) => c.status === 'orphan');
885
1013
  if (orphans.length) push({
@@ -904,18 +1032,52 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
904
1032
  };
905
1033
  for (const c of calls) {
906
1034
  if (c.agent !== ag.id || c.unmatched) continue;
907
- if (c.category === 'read') run.push(c); else if (c.category === 'write' || c.category === 'exec') flush(); // agent/user/mcp calls don't break a run
1035
+ // MCP calls that aren't reads are actions (browser navigate/click, device commands) and end a run;
1036
+ // agent and user calls don't.
1037
+ if (c.category === 'read') run.push(c); else if (c.category === 'write' || c.category === 'exec' || c.category === 'mcp') flush();
908
1038
  }
909
1039
  flush();
910
1040
  }
911
1041
 
912
- // oversized-result
1042
+ // duplicate-subagent-read: the same file read by several agents, each paying for it in its own context
1043
+ const sharedReads = new Set(); // reported here, so oversized-result below skips them
1044
+ const readsByFile = new Map();
913
1045
  for (const c of calls) {
914
- if (c.resultChars >= o.oversizedResultWarn) push({
1046
+ if (c.name !== 'Read' || c.isError || !c.input || !c.input.file_path) continue;
1047
+ const k = String(c.input.file_path).replace(/\\/g, '/').toLowerCase();
1048
+ if (!readsByFile.has(k)) readsByFile.set(k, []);
1049
+ readsByFile.get(k).push(c);
1050
+ }
1051
+ for (const list of readsByFile.values()) {
1052
+ const agentIds = new Set(list.map((c) => c.agent));
1053
+ if (agentIds.size < o.duplicateReadAgents) continue;
1054
+ const chars = list.reduce((s, c) => s + c.resultChars, 0);
1055
+ for (const c of list) sharedReads.add(c);
1056
+ const largest = Math.max(...list.map((c) => c.resultChars));
1057
+ push({
1058
+ id: 'duplicate-subagent-read', severity: largest >= o.oversizedResultError ? 'error' : chars >= o.duplicateReadWarnChars ? 'warn' : 'info',
1059
+ title: `Same file read by ${agentIds.size} agents (${fmtInt(chars)} chars)`, // path only in detail, which --redact blanks
1060
+ detail: `${list.length} reads of ${list[0].input.file_path} across ${agentIds.size} agents; each copy is billed in that agent's context on every later request.`,
1061
+ evidence: { toolCallIds: list.map((c) => c.id) }, metric: chars,
1062
+ });
1063
+ }
1064
+
1065
+ // oversized-result: one line per call, or one per tool when a tool does it repeatedly
1066
+ const bigByTool = new Map();
1067
+ for (const c of calls) if (c.resultChars >= o.oversizedResultWarn && !sharedReads.has(c)) { if (!bigByTool.has(c.name)) bigByTool.set(c.name, []); bigByTool.get(c.name).push(c); }
1068
+ for (const [name, list] of bigByTool) {
1069
+ const largest = Math.max(...list.map((c) => c.resultChars)), total = list.reduce((s, c) => s + c.resultChars, 0);
1070
+ const ranged = list.every(hasRange);
1071
+ const detail = 'Large tool results stay in context for the rest of the session. ' + (ranged ? 'The call already set a range; long lines or a wide window still cost, so narrow it or grep first.' : 'Consider head/limit, grep, or a subagent.');
1072
+ if (list.length >= o.groupRepeatsMin) push({
1073
+ id: 'oversized-result', severity: largest >= o.oversizedResultError ? 'error' : 'warn',
1074
+ title: `${name} returned over ${fmtInt(o.oversizedResultWarn)} chars ${list.length} times (largest ${fmtInt(largest)}, ${fmtInt(total)} in all)`,
1075
+ detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: largest,
1076
+ });
1077
+ else for (const c of list) push({
915
1078
  id: 'oversized-result', severity: c.resultChars >= o.oversizedResultError ? 'error' : 'warn',
916
1079
  title: `${c.name} returned ${fmtInt(c.resultChars)} chars`,
917
- detail: 'Large tool results stay in context for the rest of the session. Consider head/limit, grep, or a subagent.',
918
- evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
1080
+ detail, evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
919
1081
  });
920
1082
  }
921
1083
 
@@ -935,21 +1097,45 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
935
1097
  if (r.contextTokens >= o.contextBloatWarn && !crossed) crossed = r;
936
1098
  if (!peak || r.contextTokens > peak.contextTokens) peak = r;
937
1099
  }
938
- if (crossed) push({
939
- id: 'context-bloat', severity: peak.contextTokens >= o.contextBloatError ? 'error' : 'warn',
940
- title: `Context reached ${fmtInt(peak.contextTokens)} tokens`,
941
- detail: `First crossed ${fmtInt(o.contextBloatWarn)} at request #${reqs.indexOf(crossed) + 1} (turn ${crossed.turnIndex + 1}). Every later request pays for that prefix.`,
942
- evidence: { requestIds: [crossed.id, peak.id], turnIndex: crossed.turnIndex }, metric: peak.contextTokens,
943
- });
1100
+ if (crossed) {
1101
+ // What the large context cost: every request (any agent) sent with more than the threshold.
1102
+ let above = 0, all = 0, n = 0;
1103
+ for (const r of reqs) { const c = requestCost(r.usage, rateFor(r.model, o.rates)); if (c == null) continue; all += c; if (r.contextTokens >= o.contextBloatWarn) { above += c; n++; } }
1104
+ const spend = all > 0 ? ` · ${usd(above)} (${Math.round(above / all * 100)}% of cost) spent above ${fmtInt(o.contextBloatWarn)}` : '';
1105
+ push({
1106
+ id: 'context-bloat', severity: peak.contextTokens >= o.contextBloatError ? 'error' : 'warn',
1107
+ title: `Context reached ${fmtInt(peak.contextTokens)} tokens${spend}`,
1108
+ detail: `First crossed ${fmtInt(o.contextBloatWarn)} at request #${reqs.indexOf(crossed) + 1} (turn ${crossed.turnIndex + 1}); ${n} request${n === 1 ? '' : 's'} ran above it. Every one of them re-reads that prefix.`,
1109
+ evidence: { requestIds: [crossed.id, peak.id], turnIndex: crossed.turnIndex }, metric: peak.contextTokens, cost: all > 0 ? above : null,
1110
+ });
1111
+ }
944
1112
 
945
- // cache-churn
946
- const firstReqOfAgent = new Set();
1113
+ // cache-churn / idle-cache-expiry: a request that re-wrote prefix the previous request of the same
1114
+ // agent had already cached. A big cache write on its own is usually just a new tool result being
1115
+ // cached, which is expected. After a gap longer than the cache lifetime the miss is expiry, not
1116
+ // invalidation; right after a compaction it is an expected rebuild.
1117
+ const prevByAgent = new Map(); const ttlByAgent = new Map();
947
1118
  for (const r of reqs) {
948
- if (!firstReqOfAgent.has(r.agent)) { firstReqOfAgent.add(r.agent); continue; }
949
- if (r.usage.cacheWrite >= o.cacheChurnMin) push({
950
- id: 'cache-churn', severity: 'info', title: `${fmtInt(r.usage.cacheWrite)} tokens re-cached at request #${reqs.indexOf(r) + 1}`,
951
- detail: 'A large cache write mid-session means the cached prefix was invalidated (system prompt, tool list or early messages changed).',
952
- evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.usage.cacheWrite,
1119
+ const prev = prevByAgent.get(r.agent); prevByAgent.set(r.agent, r);
1120
+ if (r.usage.cacheWrite1h > 0) ttlByAgent.set(r.agent, 3600e3); else if (r.usage.cacheWrite5m > 0 && !ttlByAgent.has(r.agent)) ttlByAgent.set(r.agent, 300e3);
1121
+ if (!prev) continue;
1122
+ const cached = prev.usage.cacheRead + prev.usage.cacheWrite;
1123
+ const missed = cached - r.usage.cacheRead;
1124
+ if (missed < o.cacheChurnMin || missed < cached * o.cacheMissMinShare) continue;
1125
+ const sent = r.responseFrom != null ? r.responseFrom : r.start;
1126
+ if (prev.end != null && sent != null && trace.events.some((e) => e.kind === 'compact' && e.at != null && e.at >= prev.end && e.at <= sent)) continue;
1127
+ const idle = prev.end != null && sent != null ? sent - prev.end : null;
1128
+ const ttl = ttlByAgent.get(r.agent) || 300e3;
1129
+ const n = reqs.indexOf(r) + 1;
1130
+ if (idle != null && idle > ttl) push({
1131
+ id: 'idle-cache-expiry', severity: 'info', title: `${fmtInt(missed)} tokens re-cached after ${fmtDur(idle)} idle`,
1132
+ detail: `Request #${n}${r.agent !== 'main' ? ' (subagent)' : ''}: the gap was longer than the ${ttl >= 3600e3 ? '1-hour' : '5-minute'} cache lifetime, so the whole prefix was written again.`,
1133
+ evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
1134
+ });
1135
+ else push({
1136
+ id: 'cache-churn', severity: 'info', title: `${fmtInt(missed)} cached tokens re-written at request #${n}`,
1137
+ detail: `The previous request had ${fmtInt(cached)} tokens cached; this one read only ${fmtInt(r.usage.cacheRead)} back${idle != null ? ` ${fmtDur(idle)} later` : ''}. Something early in the prompt changed (system prompt, tool list, early messages).`,
1138
+ evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
953
1139
  });
954
1140
  }
955
1141
 
@@ -960,14 +1146,40 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
960
1146
  evidence: {}, metric: trace.totals.cacheHitRatio,
961
1147
  });
962
1148
 
963
- // slow-tool
1149
+ // slow-tool: blocking waits roll up per background task, other slow calls per tool when repeated
1150
+ const waits = new Map(), slowByTool = new Map();
964
1151
  for (const c of calls) {
965
- if (c.durationMs != null && c.durationMs >= o.slowToolInfoMs) push({
1152
+ if (isWait(c)) { const k = c.agent + '|' + c.name + '|' + waitTarget(c); if (!waits.has(k)) waits.set(k, []); waits.get(k).push(c); continue; }
1153
+ // Time spent on a question to the human, or on a permission prompt that ended in a denial, is the human's.
1154
+ if (c.category === 'user' || c.denial) continue;
1155
+ if (c.durationMs != null && c.durationMs >= o.slowToolInfoMs) { if (!slowByTool.has(c.name)) slowByTool.set(c.name, []); slowByTool.get(c.name).push(c); }
1156
+ }
1157
+ for (const [name, list] of slowByTool) {
1158
+ const longest = Math.max(...list.map((c) => c.durationMs)), total = list.reduce((s, c) => s + c.durationMs, 0);
1159
+ const detail = list[0].category === 'agent' ? 'Subagent run time.' : 'Long-running tool call.';
1160
+ if (list.length >= o.groupRepeatsMin) push({
1161
+ id: 'slow-tool', severity: longest >= o.slowToolWarnMs ? 'warn' : 'info',
1162
+ title: `${name} took over ${fmtDur(o.slowToolInfoMs)} ${list.length} times (longest ${fmtDur(longest)}, ${fmtDur(total)} in all)`,
1163
+ detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: total,
1164
+ });
1165
+ else for (const c of list) push({
966
1166
  id: 'slow-tool', severity: c.durationMs >= o.slowToolWarnMs ? 'warn' : 'info',
967
- title: `${c.name} took ${fmtDur(c.durationMs)}`, detail: c.category === 'agent' ? 'Subagent run time.' : c.category === 'user' ? 'Waiting on the human.' : 'Long-running tool call.',
1167
+ title: `${c.name} took ${fmtDur(c.durationMs)}`, detail,
968
1168
  evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.durationMs,
969
1169
  });
970
1170
  }
1171
+ // One finding per waited-on task, always info: the time belongs to the background job.
1172
+ for (const group of waits.values()) {
1173
+ const ms = group.reduce((s, c) => s + (c.durationMs || 0), 0);
1174
+ if (ms < o.slowToolInfoMs) continue;
1175
+ const target = waitTarget(group[0]);
1176
+ push({
1177
+ id: 'slow-tool', severity: 'info',
1178
+ title: `${group[0].name} waited ${fmtDur(ms)} on ${target ? 'task ' + target : 'a background task'}${group.length > 1 ? ` (${group.length} polls)` : ''}`,
1179
+ detail: 'Blocking wait on background work: the time is the task\'s run time, not a slow tool.',
1180
+ evidence: { toolCallIds: group.map((c) => c.id), turnIndex: group[0].turnIndex }, metric: ms,
1181
+ });
1182
+ }
971
1183
 
972
1184
  // slow-model: a long response that produced little — API latency, rate limiting or a stall,
973
1185
  // as opposed to a long response that was simply writing a lot (reported separately as info).
@@ -980,9 +1192,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
980
1192
  detail: `Request #${n} (${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s): API latency, rate limiting or a stalled stream.`,
981
1193
  evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
982
1194
  });
983
- else push({
984
- id: 'long-generation', severity: 'info', title: `${fmtDur(r.responseMs)} writing ${fmtInt(r.usage.output)} tokens`,
985
- detail: `Request #${n} at ${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s (${r.blocksSummary}). Big single outputs are normal for file writes; a stall would show as low tok/s.`,
1195
+ // A long response that wrote a lot at a normal rate is just a big output. Only a slow rate is worth a line.
1196
+ else if (r.tokensPerSec != null && r.tokensPerSec < o.longGenerationMaxTokPerSec) push({
1197
+ id: 'long-generation', severity: 'info', title: `${fmtDur(r.responseMs)} writing ${fmtInt(r.usage.output)} tokens (${r.tokensPerSec.toFixed(1)} tok/s)`,
1198
+ detail: `Request #${n} (${r.blocksSummary}) streamed well below the usual rate: a partial stall or a slow API period.`,
986
1199
  evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
987
1200
  });
988
1201
  }
@@ -1014,10 +1227,18 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1014
1227
  detail: 'Most of the spend happened in delegated work.', evidence: {}, metric: subTokens / allTokens,
1015
1228
  });
1016
1229
 
1017
- // long-turn
1018
- for (const tn of trace.turns) if (tn.toolCallIds.length >= o.longTurnTools) push({
1019
- id: 'long-turn', severity: 'info', title: `Turn ${tn.index + 1} made ${tn.toolCallIds.length} tool calls`,
1020
- detail: 'A very long autonomous stretch. Worth a skim for wasted work.', evidence: { turnIndex: tn.index, toolCallIds: tn.toolCallIds.slice() }, metric: tn.toolCallIds.length,
1230
+ // long-turn: main conversation only (a subagent's whole run is one turn by design), numbered like
1231
+ // the viewer's T1, T2… (human prompts).
1232
+ // Meta turns (injected reminders, interrupts) between two prompts belong to the stretch before them.
1233
+ const stretches = [];
1234
+ for (const tn of trace.turns) {
1235
+ if (tn.agent !== 'main') continue;
1236
+ if (tn.promptKind === 'human' || !stretches.length) stretches.push({ first: tn, no: stretches.length + 1, ids: [] });
1237
+ stretches[stretches.length - 1].ids.push(...tn.toolCallIds);
1238
+ }
1239
+ for (const st of stretches) if (st.ids.length >= o.longTurnTools) push({
1240
+ id: 'long-turn', severity: 'info', title: `Turn ${st.no} made ${st.ids.length} tool calls`,
1241
+ detail: 'A very long autonomous stretch. Worth a skim for wasted work.', evidence: { turnIndex: st.first.index, toolCallIds: st.ids }, metric: st.ids.length,
1021
1242
  });
1022
1243
 
1023
1244
  const order = { error: 0, warn: 1, info: 2 };
@@ -1080,6 +1301,210 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1080
1301
  };
1081
1302
  }
1082
1303
 
1304
+ // ---------------------------------------------------------------------------
1305
+ // Advice — one sentence per rule on what to do differently. Mechanical, like the rules.
1306
+ // ---------------------------------------------------------------------------
1307
+ const ADVICE = {
1308
+ '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.',
1309
+ '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.',
1310
+ '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.',
1311
+ '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.',
1312
+ '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.',
1313
+ '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.',
1314
+ 'context-bloat': 'Keep the prompt small: summarise long results into notes, avoid re-reading files already in context, and hand large investigations to subagents.',
1315
+ '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.',
1316
+ 'low-cache-hit': 'Long stable prefixes cache well; frequent tool-list or system-prompt changes and many tiny sessions do not.',
1317
+ '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.',
1318
+ '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.',
1319
+ 'long-generation': 'Large single outputs are normal for file writes; split very large files into parts if a stall (low tok/s) shows up.',
1320
+ 'max-tokens': 'The output was truncated: write large files in sections and keep single responses under the output limit.',
1321
+ 'api-error': 'An API error interrupted the session; check what was lost and re-run the affected step rather than assuming it completed.',
1322
+ 'hook-error': 'A hook failed; read the hook error, fix the hook command or its permissions, then re-run the step it guarded.',
1323
+ '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.',
1324
+ '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.',
1325
+ 'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
1326
+ 'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
1327
+ 'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
1328
+ 'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
1329
+ 'idle-cache-expiry': 'The cache expired while the session sat idle; resume sooner, use the 1-hour cache lifetime where available, or start a fresh session with a summary instead of re-sending the whole history.',
1330
+ };
1331
+
1332
+ // Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
1333
+ function adviceFor(f, trace) {
1334
+ if (f.id === 'slow-tool') {
1335
+ const ids = (f.evidence && f.evidence.toolCallIds) || [];
1336
+ const c = trace.toolCalls.find((x) => x.id === ids[0]);
1337
+ if (c && c.category === 'user') return null; // waiting on the human is not the agent's time
1338
+ 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.';
1339
+ if (c && isWait(c)) return 'Waiting on a long background task: keep doing independent work while it runs, or tell the user it is still going, instead of blocking the turn on it.';
1340
+ }
1341
+ if (f.id === 'oversized-result') {
1342
+ const c = trace.toolCalls.find((x) => x.id === ((f.evidence && f.evidence.toolCallIds) || [])[0]);
1343
+ if (c && hasRange(c)) return 'The read already had a range and still returned a lot: grep for the part you need first, or read a narrower window.';
1344
+ }
1345
+ return ADVICE[f.id] || null;
1346
+ }
1347
+
1348
+ function inputSummary(c) {
1349
+ const i = (c && c.input) || {};
1350
+ if (i.command) return String(i.command);
1351
+ if (i.file_path) return String(i.file_path) + (i.pattern ? ' ' + i.pattern : '');
1352
+ if (i.pattern) return String(i.pattern);
1353
+ if (i.query) return String(i.query);
1354
+ if (i.description) return String(i.description);
1355
+ if (i.url) return String(i.url);
1356
+ if (i.prompt) return String(i.prompt);
1357
+ if (i.path) return String(i.path);
1358
+ const s = JSON.stringify(i);
1359
+ return s === '{}' ? '' : s;
1360
+ }
1361
+
1362
+ // ---------------------------------------------------------------------------
1363
+ // Markdown report — written to be handed back to the agent as well as read by a human.
1364
+ // opts: { redact, advice=true, instruction=true, maxEvidence=6, title }
1365
+ // ---------------------------------------------------------------------------
1366
+ function usd(v) { return v == null ? '—' : v < 0.01 ? '$' + v.toFixed(4) : v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2); }
1367
+ function pctStr(x) { return x == null ? '—' : Math.round(x * 100) + '%'; }
1368
+ function oneLine(s, n) { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
1369
+ function blank(s) { return '«' + String(s == null ? '' : s).length + ' chars»'; }
1370
+
1371
+ function toolTable(trace) {
1372
+ const rows = new Map();
1373
+ 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; }
1374
+ return Array.from(rows.values()).sort((a, b) => b.time - a.time || b.calls - a.calls);
1375
+ }
1376
+
1377
+ // Rules whose detail quotes tool output or model text (everything else is names and numbers).
1378
+ const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read']);
1379
+ function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
1380
+
1381
+ function reportMarkdown(trace, findings, cost, opts) {
1382
+ opts = opts || {};
1383
+ const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
1384
+ const T = trace.totals, m = trace.meta, u = T.usage;
1385
+ const R = (s) => redact ? blank(s) : s;
1386
+ const total = cost ? (cost.reported != null ? cost.reported : cost.total) : null;
1387
+ const turnNo = (idx) => idx != null ? idx + 1 : null; // same numbering as the finding details and the viewer drawer
1388
+ const advised = new Set();
1389
+ const out = [];
1390
+ out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
1391
+ out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
1392
+ 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' : ''})`);
1393
+ 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)' : ''}`, '');
1394
+ if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
1395
+ else {
1396
+ out.push(`## Findings (${findings.length})`, '');
1397
+ findings.forEach((f, i) => {
1398
+ out.push(`### ${i + 1}. ${f.severity.toUpperCase()} \`${f.id}\` — ${f.title}`, '', redact ? redactDetail(f) : f.detail, '');
1399
+ const ev = f.evidence || {};
1400
+ const lines = [];
1401
+ 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)'}\``); }
1402
+ 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) : ''}`); }
1403
+ if (lines.length) { out.push('Evidence:', ''); out.push(...lines.slice(0, maxEv)); if (lines.length > maxEv) out.push(`- … ${lines.length - maxEv} more`); out.push(''); }
1404
+ const adv = opts.advice !== false && !advised.has(f.id) ? adviceFor(f, trace) : null;
1405
+ if (adv) { advised.add(f.id); out.push(`Next time: ${adv}`, ''); }
1406
+ });
1407
+ }
1408
+ const tools = toolTable(trace);
1409
+ 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(''); }
1410
+ 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.', '');
1411
+ out.push('_Generated by Glassbox ' + VERSION + '._', '');
1412
+ return out.join('\n');
1413
+ }
1414
+
1415
+ // ---------------------------------------------------------------------------
1416
+ // Compare two analysed sessions ({trace, findings, cost}).
1417
+ // ---------------------------------------------------------------------------
1418
+ const METRICS = [
1419
+ { key: 'wallMs', label: 'Wall time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.wallMs },
1420
+ { key: 'activeMs', label: 'Active time', fmt: 'dur', dir: 'low', get: (s) => s.trace.totals.activeMs },
1421
+ { key: 'turns', label: 'Turns', fmt: 'int', dir: null, get: (s) => s.trace.totals.turns },
1422
+ { key: 'requests', label: 'API requests', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.requests },
1423
+ { key: 'toolCalls', label: 'Tool calls', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolCalls },
1424
+ { key: 'toolErrors', label: 'Tool errors', fmt: 'int', dir: 'low', get: (s) => s.trace.totals.toolErrors },
1425
+ { key: 'toolTimeMs', label: 'Time in tools', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.toolTimeMs : null },
1426
+ { key: 'modelTimeMs', label: 'Time generating', fmt: 'dur', dir: 'low', get: (s) => s.trace.meta.hasTimestamps ? s.trace.totals.modelTimeMs : null },
1427
+ { 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); } },
1428
+ { 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 },
1429
+ { key: 'cacheHitRatio', label: 'Cache hit', fmt: 'pct', dir: 'high', get: (s) => s.trace.totals.cacheHitRatio },
1430
+ { key: 'output', label: 'Output tokens', fmt: 'tok', dir: 'low', get: (s) => s.trace.requests.length ? s.trace.totals.usage.output : null },
1431
+ { 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; } },
1432
+ { key: 'cost', label: 'Est. cost', fmt: 'usd', dir: 'low', get: (s) => s.cost ? (s.cost.reported != null ? s.cost.reported : s.cost.total) : null },
1433
+ { key: 'findings', label: 'Findings', fmt: 'int', dir: 'low', get: (s) => s.findings.length },
1434
+ { key: 'errorFindings', label: 'Error-level findings', fmt: 'int', dir: 'low', get: (s) => s.findings.filter((f) => f.severity === 'error').length },
1435
+ ];
1436
+ function fmtMetric(v, fmt) {
1437
+ if (v == null) return '—';
1438
+ if (fmt === 'dur') return fmtDur(v);
1439
+ if (fmt === 'pct') return pctStr(v);
1440
+ if (fmt === 'usd') return usd(v);
1441
+ return fmtInt(v);
1442
+ }
1443
+ // "×2.4" when B is bigger, "÷2.4" when smaller; never "×0.00".
1444
+ function fmtRatio(r) {
1445
+ if (r == null || !Number.isFinite(r)) return '—';
1446
+ if (r === 1) return '×1.00';
1447
+ const v = r >= 1 ? r : 1 / r; const t = v >= 100 ? Math.round(v).toString() : v >= 10 ? v.toFixed(1) : v.toFixed(2);
1448
+ return (r >= 1 ? '×' : '÷') + t;
1449
+ }
1450
+ // Change of B relative to A: a signed percentage where that reads well, ×N for big increases,
1451
+ // the absolute delta when A is 0 (no ratio) or B is 0 (no "÷∞").
1452
+ function fmtChange(m) {
1453
+ if (m.delta == null) return '—';
1454
+ const r = m.ratio;
1455
+ if (r == null || !m.a || !m.b) return (m.delta > 0 ? '+' : m.delta < 0 ? '−' : '') + fmtMetric(Math.abs(m.delta), m.fmt);
1456
+ if (r === 1) return '0%';
1457
+ if (r < 1) { const p = (1 - r) * 100; return '−' + (p < 10 || p > 99 ? p.toFixed(1) : Math.round(p)) + '%'; }
1458
+ if (r < 10) { const p = (r - 1) * 100; return '+' + (p < 10 ? p.toFixed(1) : Math.round(p)) + '%'; }
1459
+ return fmtRatio(r);
1460
+ }
1461
+ function compare(A, B) {
1462
+ const metrics = METRICS.map((d) => {
1463
+ const a = d.get(A), b = d.get(B);
1464
+ const ok = a != null && b != null && Number.isFinite(a) && Number.isFinite(b);
1465
+ const delta = ok ? b - a : null;
1466
+ const ratio = ok && a ? b / a : (ok && !a && !b ? 1 : null);
1467
+ let better = null;
1468
+ if (ok && d.dir && a !== b) better = (d.dir === 'low') === (a < b) ? 'a' : 'b';
1469
+ 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) };
1470
+ });
1471
+ const ta = toolTable(A.trace), tb = toolTable(B.trace);
1472
+ const names = new Set([...ta.map((t) => t.name), ...tb.map((t) => t.name)]);
1473
+ const zero = { calls: 0, errors: 0, time: 0 };
1474
+ 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 }; });
1475
+ 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));
1476
+ 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; };
1477
+ const fa = byId(A.findings), fb = byId(B.findings);
1478
+ const slim = (f) => ({ id: f.id, severity: f.severity, title: f.title });
1479
+ const onlyA = [], onlyB = [], both = [];
1480
+ 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)); }
1481
+ for (const [id, list] of fb) if (!fa.has(id)) onlyB.push(...list.map(slim));
1482
+ const pick = (key) => { const m = metrics.find((x) => x.key === key); return m ? m.better : null; };
1483
+ 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'); })();
1484
+ const verdict = { cheaper: pick('cost'), faster: pick('wallMs') || pick('activeMs'), cleaner };
1485
+ 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 } };
1486
+ }
1487
+ function compareMarkdown(c, opts) {
1488
+ opts = opts || {};
1489
+ const la = opts.labelA || 'A', lb = opts.labelB || 'B';
1490
+ const out = [`# Glassbox compare — ${la} vs ${lb}`, ''];
1491
+ out.push(`- ${la}: ${c.a.sessionId || '—'}${c.a.title ? ' · ' + oneLine(c.a.title, 70) : ''}${c.a.models.length ? ' · ' + c.a.models.join(', ') : ''}`);
1492
+ out.push(`- ${lb}: ${c.b.sessionId || '—'}${c.b.title ? ' · ' + oneLine(c.b.title, 70) : ''}${c.b.models.length ? ' · ' + c.b.models.join(', ') : ''}`, '');
1493
+ const v = c.verdict; const who = (x) => x === 'a' ? la : x === 'b' ? lb : 'tie';
1494
+ out.push(`**Verdict** — cheaper: ${who(v.cheaper)} · faster: ${who(v.faster)} · cleaner: ${who(v.cleaner)}`, '');
1495
+ out.push(`| metric | ${la} | ${lb} | change |`, '|---|---|---|---|');
1496
+ 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)' : ''} |`); }
1497
+ out.push('');
1498
+ 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(''); }
1499
+ out.push('## Findings', '');
1500
+ if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) out.push('Neither session was flagged.', '');
1501
+ 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(''); }
1502
+ 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(''); }
1503
+ 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(''); }
1504
+ out.push('_Generated by Glassbox ' + VERSION + '._', '');
1505
+ return out.join('\n');
1506
+ }
1507
+
1083
1508
  // ---------------------------------------------------------------------------
1084
1509
  // Redaction — keep structure, timestamps, usage, tool names; blank every string.
1085
1510
  // ---------------------------------------------------------------------------
@@ -1108,7 +1533,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1108
1533
  }
1109
1534
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1110
1535
 
1111
- return { VERSION, RATES, DEFAULTS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, stableStringify };
1536
+ 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
1537
  });
1113
1538
  </script>
1114
1539
  <script>
@@ -1129,7 +1554,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1129
1554
  const say = (msg) => { const s = $('#status'); s.textContent = ''; setTimeout(() => { s.textContent = msg; }, 30); };
1130
1555
 
1131
1556
  // ------------------------------------------------------------ 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 };
1557
+ 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 };
1558
+ const pickingForCompare = () => $('#cmp-dlg').open;
1133
1559
  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
1560
  const R = (s) => S.share ? '«' + String(s == null ? '' : s).length + ' chars»' : s;
1135
1561
 
@@ -1154,9 +1580,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1154
1580
  if (!usedEntries) for (const f of Array.from(dt.files || [])) out.push({ name: f.name, file: f });
1155
1581
  await loadFiles(out);
1156
1582
  }
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) {
1583
+ 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); }
1584
+ function load(files, isDemo, keepOther) {
1159
1585
  S.files = files; S.isDemo = !!isDemo;
1586
+ if (!keepOther) { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; }
1160
1587
  S.records = files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
1161
1588
  S.trace = C.parseTrace(files);
1162
1589
  S.findings = C.diagnose(S.trace);
@@ -1164,6 +1591,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1164
1591
  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
1592
  $('#main').hidden = false; $('#banner').hidden = !isDemo;
1166
1593
  renderAll();
1594
+ if (S.other) renderCompare();
1167
1595
  const T = S.trace.totals;
1168
1596
  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
1597
  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 +1811,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1383
1811
  function hideTip() { tip.style.display = 'none'; }
1384
1812
  document.addEventListener('mousemove', (e) => { if (tip.style.display === 'block' && !(document.activeElement && document.activeElement.dataset && document.activeElement.dataset.kind)) moveTip(e.clientX, e.clientY); });
1385
1813
  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; }
1814
+ const inputSummary = C.inputSummary;
1387
1815
 
1388
1816
  // ------------------------------------------------------------ burn chart
1389
1817
  function renderBurn() {
@@ -1423,13 +1851,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1423
1851
 
1424
1852
  // ------------------------------------------------------------ findings
1425
1853
  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
- }
1854
+ function reportMd() { return C.reportMarkdown(S.trace, S.findings, S.cost, { redact: S.share }); }
1433
1855
  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
1856
  $('#copy-close').onclick = () => $('#copy-dlg').close();
1435
1857
  function renderFindings() {
@@ -1557,6 +1979,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1557
1979
  function setHash(h) { hashLock = true; try { history.replaceState(null, '', '#' + h); } catch (e) { } setTimeout(() => { hashLock = false; }, 0); }
1558
1980
  function applyHash() {
1559
1981
  const h = location.hash.slice(1); if (!h || !S.trace) return;
1982
+ if (h === 'compare') { if (S.other) $('#cmp-sec').scrollIntoView(); return; }
1560
1983
  const eq = h.indexOf('='); if (eq < 0) return; const k = h.slice(0, eq), v = decodeURIComponent(h.slice(eq + 1)); if (!v) return;
1561
1984
  if (k === 'req') { const r = S.trace.requests[+v - 1]; if (r) select({ kind: 'req', ref: r }); }
1562
1985
  else if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === v); if (c) select({ kind: 'tool', ref: c }); }
@@ -1588,7 +2011,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1588
2011
  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
2012
  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
2013
  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, '/');
2014
+ 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
2015
  async function scanProjects(dir) {
1593
2016
  const sessions = [];
1594
2017
  for await (const [pname, ph] of dir.entries()) {
@@ -1609,8 +2032,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1609
2032
  }
1610
2033
  async function loadSession(s) {
1611
2034
  const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
1612
- 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);
2035
+ // subagents/*.jsonl plus Workflow agents in subagents/workflows/<runId>/ (journal.jsonl is the workflow's log)
2036
+ 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() }); } };
2037
+ try { await walk(await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'), s.id + '/subagents'); } catch (e) { }
2038
+ $('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
1614
2039
  }
1615
2040
  async function showSessions(dir) {
1616
2041
  const dlg = $('#sessions-dlg'); $('#sessions-note').textContent = 'Scanning ' + dir.name + '…'; $('#sessions-tbl').innerHTML = ''; dlg.showModal();
@@ -1628,6 +2053,99 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1628
2053
  }
1629
2054
  $('#sessions-close').onclick = () => $('#sessions-dlg').close();
1630
2055
 
2056
+ // ------------------------------------------------------------ compare
2057
+ function analyseFiles(files) { const trace = C.parseTrace(files); return { files, trace, findings: C.diagnose(trace), cost: C.estimateCost(trace, S.rates) }; }
2058
+ function loadOther(files) {
2059
+ if (!S.trace) { say('Load a session first, then compare it with another.'); return; }
2060
+ S.other = analyseFiles(files);
2061
+ try { $('#cmp-dlg').close(); } catch (e) { }
2062
+ renderCompare();
2063
+ const m = S.cmp.metrics.find((x) => x.key === 'cost');
2064
+ 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' : ''}.`);
2065
+ $('#cmp-sec').scrollIntoView({ behavior: 'smooth', block: 'start' });
2066
+ setHash('compare');
2067
+ }
2068
+ 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; }
2069
+ function renderCompare() {
2070
+ if (!S.other) return;
2071
+ const A = { trace: S.trace, findings: S.findings, cost: S.cost }, B = S.other;
2072
+ const c = S.cmp = C.compare(A, B);
2073
+ const la = S.labels.a, lb = S.labels.b;
2074
+ 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>';
2075
+ $('#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`;
2076
+ const rows = c.metrics.map((m) => {
2077
+ let bar = '<span class="ch">—</span>';
2078
+ if (m.delta != null) {
2079
+ const good = m.better === 'a' ? 'bad' : m.better === 'b' ? 'good' : '';
2080
+ const ch = C.fmtChange(m);
2081
+ const r = m.ratio != null && m.a ? m.ratio : (m.delta === 0 ? 1 : 2);
2082
+ const f = Math.min(1, Math.abs(Math.log2(Math.max(r, 1e-6))) / 3);
2083
+ const left = r < 1, w = (f * 50).toFixed(1);
2084
+ 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>`;
2085
+ }
2086
+ const sr = m.delta == null ? 'not comparable' : m.better ? `${m.better === 'a' ? la : lb} better` : 'same';
2087
+ 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>`;
2088
+ }).join('');
2089
+ 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('');
2090
+ 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>';
2091
+ $('#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>
2092
+ <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>
2093
+ <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>` : ''}
2094
+ <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>`;
2095
+ $('#cmp-sec').hidden = false;
2096
+ }
2097
+ $('#compare').onclick = () => { if (!S.trace) { say('Load a session first.'); return; } $('#cmp-folder').hidden = !FSA; $('#cmp-dlg').showModal(); };
2098
+ $('#cmp-dlg-close').onclick = () => $('#cmp-dlg').close();
2099
+ $('#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); } };
2100
+ $('#sessions-dlg').addEventListener('close', () => { setTimeout(() => { S.pickOther = false; }, 0); });
2101
+ $('#cmp-pick').onclick = () => $('#file-cmp').click();
2102
+ $('#file-cmp').addEventListener('change', (e) => { loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f })), true); e.target.value = ''; });
2103
+ $('#cmp-demo').onclick = () => { if (!DEMO) { say('No demo embedded in this build.'); return; } loadOther(DEMO.map((f) => ({ name: f.name, text: f.text }))); };
2104
+ $('#cmp-close').onclick = () => { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; setHash(''); say('Compare closed.'); $('#compare').focus(); };
2105
+ $('#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.`); };
2106
+ $('#cmp-copy').onclick = () => { if (!S.cmp) return; copyText(C.compareMarkdown(S.cmp, { labelA: S.labels.a, labelB: S.labels.b }), 'Comparison (Markdown)'); };
2107
+
2108
+ // ------------------------------------------------------------ live tail
2109
+ let liveTimer = null;
2110
+ function applyLiveFiles(ev) {
2111
+ const f = S.files.find((x) => x.name === ev.name);
2112
+ if (ev.type === 'append') { if (f) f.text += ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
2113
+ else if (ev.type === 'replace') { if (f) f.text = ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
2114
+ else if (ev.type === 'hello') { S.files = ev.files.map((x) => ({ name: x.name, text: x.text })); }
2115
+ if (liveTimer) return; liveTimer = setTimeout(() => { liveTimer = null; refresh(); }, 150);
2116
+ }
2117
+ // Re-parse the current files in place: selection, filter, search and zoom survive; Follow keeps the right edge on now.
2118
+ function refresh() {
2119
+ const wasWhole = !S.view || (TL.total && S.view.t1 >= TL.total - 1 && S.view.t0 <= 1);
2120
+ const oldSpan = S.view ? S.view.t1 - S.view.t0 : null;
2121
+ const selId = S.sel ? S.sel.ref.id : null, selKind = S.sel ? S.sel.kind : null;
2122
+ S.records = S.files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
2123
+ S.trace = C.parseTrace(S.files); S.findings = C.diagnose(S.trace); S.cost = C.estimateCost(S.trace, S.rates);
2124
+ 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; }
2125
+ if (S.toolFilter && !S.trace.toolCalls.some((c) => c.name === S.toolFilter)) S.toolFilter = null;
2126
+ buildLayout();
2127
+ 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 }; }
2128
+ renderSession(); renderStrip(); renderFit(); renderTimeline(); renderBurn(); renderFindings(); renderTools(); renderTurns();
2129
+ if (S.query) runSearch();
2130
+ if (S.sel && !$('#drawer').hidden) select(S.sel, true);
2131
+ if (S.other) renderCompare();
2132
+ const t = new Date(); $('#live-txt').textContent = 'LIVE · ' + t.toTimeString().slice(0, 8);
2133
+ }
2134
+ function startLive(cfg) {
2135
+ const pill = $('#live'); pill.hidden = false; $('#follow').hidden = false;
2136
+ $('#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(); };
2137
+ const es = new EventSource(cfg.url); S.live = es;
2138
+ es.addEventListener('hello', (e) => { pill.classList.remove('off'); $('#live-txt').textContent = 'LIVE'; applyLiveFiles(Object.assign({ type: 'hello' }, JSON.parse(e.data))); });
2139
+ es.addEventListener('append', (e) => applyLiveFiles(JSON.parse(e.data)));
2140
+ es.addEventListener('replace', (e) => applyLiveFiles(JSON.parse(e.data)));
2141
+ es.onerror = () => { pill.classList.add('off'); $('#live-txt').textContent = 'reconnecting'; };
2142
+ // any manual zoom or pan lets go of Follow
2143
+ const letGo = () => { if (S.follow) { S.follow = false; $('#follow').setAttribute('aria-pressed', 'false'); } };
2144
+ $('#tl').addEventListener('wheel', letGo, { passive: true }); $('#tl').addEventListener('pointerdown', letGo);
2145
+ $('#z-in').addEventListener('click', letGo); $('#z-out').addEventListener('click', letGo); $('#fit').addEventListener('change', letGo);
2146
+ $('#banner').hidden = true;
2147
+ }
2148
+
1631
2149
  // ------------------------------------------------------------ help + inputs
1632
2150
  $('#help').onclick = () => $('#help-dlg').showModal(); $('#help-close').onclick = () => $('#help-dlg').close();
1633
2151
  $('#banner-x').onclick = () => { $('#banner').hidden = true; };
@@ -1637,6 +2155,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1637
2155
  drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); handleDataTransfer(e.dataTransfer); });
1638
2156
  document.addEventListener('dragover', (e) => { e.preventDefault(); });
1639
2157
  document.addEventListener('drop', (e) => { if (!drop.contains(e.target)) { e.preventDefault(); handleDataTransfer(e.dataTransfer); } });
2158
+ $('#cmp-dlg').addEventListener('dragover', (e) => e.preventDefault()); $('#cmp-dlg').addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); handleDataTransfer(e.dataTransfer); });
1640
2159
  $('#file').addEventListener('change', (e) => loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f }))));
1641
2160
  drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#file').click(); } });
1642
2161
 
@@ -1644,8 +2163,14 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1644
2163
  const EMBED = /*__EMBED__*/null;
1645
2164
  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
2165
  $('#demo').onclick = loadDemo;
1647
- if (EMBED && EMBED.length) load(EMBED.map((f) => ({ name: f.name, text: f.text })), false);
2166
+ const LIVE = /*__LIVE__*/null;
2167
+ const embedFiles = EMBED && !Array.isArray(EMBED) ? EMBED.files : EMBED;
2168
+ if (embedFiles && embedFiles.length) {
2169
+ load(embedFiles.map((f) => ({ name: f.name, text: f.text })), false);
2170
+ 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 }))); }
2171
+ }
1648
2172
  else if (DEMO) loadDemo();
2173
+ if (LIVE && LIVE.url && 'EventSource' in window) startLive(LIVE);
1649
2174
  })();
1650
2175
  </script>
1651
2176