@ra3orblade/swarm 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -1,5 +1,20 @@
1
1
  const $ = (s) => document.querySelector(s);
2
2
  const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
3
+ // Open a URL in the user's browser. The desktop app's webview has no new-window handler, so
4
+ // `window.open` and target=_blank silently do nothing there — route through Tauri's shell opener
5
+ // when it is present (capability `shell:allow-open`), and fall back to window.open in a browser.
6
+ const openExternal = (url) => {
7
+ const shell = window.__TAURI__?.shell;
8
+ if (shell?.open) shell.open(url).catch(() => window.open(url, "_blank"));
9
+ else window.open(url, "_blank");
10
+ };
11
+ // Every absolute link (PR titles, docs, search hits, dev-server ports) takes the same path.
12
+ document.addEventListener("click", (e) => {
13
+ const a = e.target.closest?.('a[href^="http"]');
14
+ if (!a) return;
15
+ e.preventDefault();
16
+ openExternal(a.href);
17
+ });
3
18
  // M8.2b daemon token: `swarm ui` (and the desktop app) open the dashboard with ?token=…; it is kept
4
19
  // in sessionStorage, stripped from the URL, and sent on every /v1 request. Loopback without a token
5
20
  // still works while `[daemon] auth = "loopback-optional"`.
@@ -79,7 +94,12 @@ const projGlyph = (p, size = 14) => p?.icon
79
94
  const projCell = (id) => { const p = state.projects.find((x) => x.id === id); return p ? `${projGlyph(p, 12)} ${esc(p.name)}` : esc(projName(id)); };
80
95
  const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : "(removed)");
81
96
  const short = (p) => String(p ?? "").replace(/^\/Users\/[^/]+/, "~");
82
- const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
97
+ // Never wider than 5 characters, so a numeric column never has to ellipsize a number: without a
98
+ // billions step a 2.8B context read "2820.0M", and the tenth is noise once the mantissa is 3 digits.
99
+ // At most 3 significant digits, so a numeric column never has to ellipsize a number: without a
100
+ // billions step a 2.8B context read "2820.0M", and the tenth is noise once the mantissa is 3 digits.
101
+ const unit = (n, div, suffix) => `${(n / div).toFixed((n /= div) >= 100 ? 0 : n >= 10 ? 1 : 2)}${suffix}`;
102
+ const tok = (n) => (n >= 1e9 ? unit(n, 1e9, "B") : n >= 1e6 ? unit(n, 1e6, "M") : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
83
103
  const usd = (n) => (n == null ? '<span class="dim">—</span>' : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
84
104
  const model = (m) => (m ? m.replace(/^claude-/, "").replace(/-\d{8}$/, "") : "");
85
105
  const sumBy = (arr, f) => arr.reduce((a, x) => a + (f(x) ?? 0), 0);
@@ -88,7 +108,14 @@ const ic = (name, size = 14, cls = "") => (window.icon ? window.icon(name, size,
88
108
  const kindIcon = (s) => ic(s.kind === "subagent" ? "tree-structure" : s.kind === "spawned" ? "play" : "keyboard", 13, "kind");
89
109
  // pixel-art illustrations for empty states (crispEdges, theme-green; won't clash with icon packs)
90
110
  function pixmap(rows, cell = 6) {
91
- const C = { X: "var(--acc)", g: "var(--c5,#7fb069)", d: "var(--c4,#2f7d4f)" };
111
+ // All three tones are derived from the accent, so they are guaranteed to separate in either
112
+ // theme. The old palette used --c4 for the shade, whose luminance in light mode (0.158) is
113
+ // indistinguishable from --acc's (0.160) — the outline simply vanished into the face.
114
+ const C = {
115
+ X: "var(--acc)",
116
+ g: "color-mix(in srgb, var(--acc) 45%, white)",
117
+ d: "color-mix(in srgb, var(--acc) 58%, black)",
118
+ };
92
119
  const w = Math.max(...rows.map((r) => r.length)) * cell;
93
120
  const h = rows.length * cell;
94
121
  let r = "";
@@ -101,17 +128,43 @@ function pixmap(rows, cell = 6) {
101
128
  return `<svg class="px" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">${r}</svg>`;
102
129
  }
103
130
  const PX = {
104
- idle: () => pixmap([
105
- " X X ",
106
- " X X ",
107
- " XXXXXXXX ",
108
- " XXXXXXXX ",
109
- " X XX X ",
110
- " XXXXXXXX ",
111
- " XX XX ",
112
- " XXXXXXXX ",
113
- " X X ",
114
- ]),
131
+ // The palette carries three tones (accent / light / dark) and this used one, which is why it
132
+ // read as a flat blob. Outline, ear modules and features in `d`; face and body in `X`; antenna
133
+ // tips and the chest light in `g`. The body tapers rather than sitting under the head as a slab.
134
+ idle: () =>
135
+ pixmap(
136
+ [
137
+ " g g ",
138
+ " X X ",
139
+ " X X ",
140
+ " ddddddddddddd ",
141
+ " dXXXXXXXXXXXd ",
142
+ " dggXXXXXXXXXd ",
143
+ " dddXXddXXXddXXddd ",
144
+ " dddXXddXXXddXXddd ",
145
+ " dddXXXXXXXXXXXddd ",
146
+ " dddXXdddddddXXddd ",
147
+ " dXXXXXXXXXXXd ",
148
+ " dXXXXXXXXXXXd ",
149
+ " ddddddddddddd ",
150
+ " XXX ",
151
+ " ddddd ",
152
+ " ddddddddddd ",
153
+ " dXXdggXXXXXXXdXXd ",
154
+ " dXXdXggggXddddXXd ",
155
+ " dXXdXggggXXXXdXXd ",
156
+ " dXXdXXXXXXddddXXd ",
157
+ " dXXdXXXXXXXXXdXXd ",
158
+ " dXXdXdddXXXXXdXXd ",
159
+ " ddddXXXXXXXXXdddd ",
160
+ " ddddddddddd ",
161
+ " XX XX ",
162
+ " XX XX ",
163
+ " XXXX XXXX ",
164
+ " dddd dddd ",
165
+ ],
166
+ 4,
167
+ ),
115
168
  folder: () => pixmap([
116
169
  " XXXX ",
117
170
  "XXXXXXXXXX",
@@ -148,6 +201,19 @@ const agentBadge = (a) => (a ? `<span class="badge agent" style="color:${viz.age
148
201
  let raf = 0;
149
202
  const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; render(); }); };
150
203
  const touch = () => { state.dirty = true; schedule(); };
204
+ // `render()` refuses to paint while a menu is open (it would detach the anchor the menu is
205
+ // positioned against) and defers the frame instead. fancy-menus exposes no close callback, so the
206
+ // deferred paint has to wait for the close — armed from that bail, where the menu is known to be
207
+ // open. Without it a menu action (switch view, ack, release…) only lands on the next 5s poll,
208
+ // which reads as a dead click. One boolean check per frame, only while a menu is open.
209
+ // The menus island re-broadcasts the package's `useIsAnyMenuOpen` as `menus:openchange`.
210
+ window.addEventListener("menus:openchange", (e) => {
211
+ if (e.detail?.open) return;
212
+ // Menu closed: drop the trigger's open state and paint whatever render() deferred.
213
+ for (const b of $$("#viewnav .navgrp.open")) b.classList.remove("open");
214
+ for (const el of $$(".menu-open")) el.classList.remove("menu-open");
215
+ if (state.dirty) schedule();
216
+ });
151
217
  // Last snapshot body + last render time: an unchanged snapshot (same seq, same data) skips the render
152
218
  // unless the UI changed, or `ago`-style cells are older than 30s.
153
219
  let lastSnap = "", lastRenderAt = 0;
@@ -203,13 +269,70 @@ async function refresh() {
203
269
  incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
204
270
  state.allIncidents = inc;
205
271
  }
272
+ let linChanged = false;
273
+ if (state.view === "graphs" && (state.graphTab ?? "collisions") === "lineage" && !state.session) {
274
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
275
+ const open = (state.lineageOpen ?? []).map((g) => `&expand=${encodeURIComponent(g)}`).join("");
276
+ const lin = await fetch(`/v1/graphs/lineage${q || "?"}${open}`).then((r) => r.json()).catch(() => state.lineage);
277
+ linChanged = JSON.stringify(lin) !== JSON.stringify(state.lineage);
278
+ state.lineage = lin;
279
+ }
206
280
  let colChanged = false;
207
- if (state.view === "graphs" && !state.session) {
281
+ if (state.view === "graphs" && (state.graphTab ?? "collisions") !== "lineage" && !state.session) {
208
282
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
209
283
  const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
210
284
  colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
211
285
  state.collisions = col;
212
286
  }
287
+ let waitChanged = false;
288
+ if ((state.view === "fleet" || state.view === "stats") && !state.session) {
289
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
290
+ const w = await fetch(`/v1/waiting${q}`).then((r) => r.json()).catch(() => state.waiting);
291
+ waitChanged = JSON.stringify(w) !== JSON.stringify(state.waiting);
292
+ state.waiting = w;
293
+ }
294
+ let hygChanged = false;
295
+ if (state.view === "hygiene" && !state.session) {
296
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
297
+ const hy = await fetch(`/v1/hygiene${q}`).then((r) => r.json()).catch(() => state.hygiene);
298
+ hygChanged = JSON.stringify(hy) !== JSON.stringify(state.hygiene);
299
+ state.hygiene = hy;
300
+ }
301
+ let ctxChanged = false;
302
+ if (state.view === "context" && !state.session) {
303
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
304
+ const cx = await fetch(`/v1/context${q}`).then((r) => r.json()).catch(() => state.context);
305
+ ctxChanged = JSON.stringify(cx) !== JSON.stringify(state.context);
306
+ state.context = cx;
307
+ }
308
+ let trialsChanged = false;
309
+ if (state.view === "trials" && !state.session) {
310
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
311
+ const tr = await fetch(`/v1/ab${q}`).then((r) => r.json()).then((r) => r.trials ?? []).catch(() => state.trials);
312
+ trialsChanged = JSON.stringify(tr) !== JSON.stringify(state.trials);
313
+ state.trials = tr;
314
+ }
315
+ let provChanged = false;
316
+ if (state.view === "provenance" && !state.session) {
317
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
318
+ const pv = await fetch(`/v1/provenance${q}`).then((r) => r.json()).catch(() => state.provenance);
319
+ provChanged = JSON.stringify(pv) !== JSON.stringify(state.provenance);
320
+ state.provenance = pv;
321
+ }
322
+ let mcpChanged = false;
323
+ if (state.view === "mcp" && !state.session) {
324
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
325
+ const m = await fetch(`/v1/mcp/health${q}`).then((r) => r.json()).catch(() => state.mcpHealth);
326
+ mcpChanged = JSON.stringify(m) !== JSON.stringify(state.mcpHealth);
327
+ state.mcpHealth = m;
328
+ }
329
+ let ghChanged = false;
330
+ if (state.view === "gates" && !state.session) {
331
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
332
+ const gh = await fetch(`/v1/gates/health${q}`).then((r) => r.json()).catch(() => state.gateHealth);
333
+ ghChanged = JSON.stringify(gh) !== JSON.stringify(state.gateHealth);
334
+ state.gateHealth = gh;
335
+ }
213
336
  let outChanged = false;
214
337
  if (state.view === "outcomes" && !state.session) {
215
338
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
@@ -217,7 +340,7 @@ async function refresh() {
217
340
  outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
218
341
  state.outcomes = o;
219
342
  }
220
- if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || outChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
343
+ if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || linChanged || outChanged || waitChanged || ghChanged || mcpChanged || ctxChanged || provChanged || trialsChanged || hygChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
221
344
  }
222
345
  // M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
223
346
  // deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
@@ -227,10 +350,17 @@ const VIEW_DEFS = [
227
350
  { id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
228
351
  { id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
229
352
  { id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
230
- { id: "outcomes", label: "Outcomes", icon: "check", group: "Insight", render: () => renderOutcomes() },
353
+ { id: "trials", label: "Trials", icon: "robot", group: "Work", render: () => renderTrials(), badge: () => (state.trials ?? []).filter((t) => t.verdict === "undecided").length },
354
+ { id: "hygiene", label: "Hygiene", icon: "trash", group: "Work", render: () => renderHygiene(), badge: () => state.hygiene?.totals?.issues ?? 0 },
355
+ // not "check": inside a menu a tick reads as "this item is selected" rather than as an icon
356
+ { id: "outcomes", label: "Outcomes", icon: "git-branch", group: "Insight", render: () => renderOutcomes() },
357
+ { id: "gates", label: "Gates", icon: "shield", group: "Insight", render: () => renderGateHealth(), badge: () => state.gateHealth?.totals?.flakyGates ?? 0 },
358
+ { id: "mcp", label: "MCP", icon: "plugs-connected", group: "Insight", render: () => renderMcpHealth() },
359
+ { id: "context", label: "Context", icon: "brain", group: "Insight", render: () => renderContext() },
231
360
  { id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
232
361
  { id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
233
362
  { id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
363
+ { id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard", render: () => renderProvenance(), badge: () => state.provenance?.totals?.untracked ?? 0 },
234
364
  { id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
235
365
  ];
236
366
  const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
@@ -240,6 +370,8 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
240
370
  {
241
371
  const v = localStorage.getItem("swarm.view");
242
372
  if (VIEWS.includes(v)) state.view = v;
373
+ const gt = localStorage.getItem("swarm.graphTab");
374
+ if (gt === "lineage" || gt === "collisions") state.graphTab = gt;
243
375
  const sel = localStorage.getItem("swarm.sel");
244
376
  if (sel) state.sel = sel;
245
377
  // Deep links win over persisted state: ?view=board&project=<id>&session=<id>
@@ -252,7 +384,7 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
252
384
  function render() {
253
385
  // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
254
386
  // hold the frame while one is open; the next poll or interaction paints it.
255
- if (window.menus?.isOpen()) { state.dirty = true; return; }
387
+ if (window.menus?.isOpen()) { state.dirty = true; return; } // the menus:openchange listener paints on close
256
388
  // Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
257
389
  const af = document.activeElement;
258
390
  const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
@@ -274,21 +406,57 @@ function renderHeader() {
274
406
  if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
275
407
  renderNav();
276
408
  }
277
- // M9.1: grouped view nav in the sidebar, generated from VIEW_DEFS. Rebuilt only when the
278
- // html changes (active view, badges) so the 5s poll doesn't churn the DOM.
279
- function renderNav() {
409
+ // View nav in the header: one button per group (Observe / Work / Insight / Guard); clicking one
410
+ // opens a fancy-menus dropdown of that group's views. Rebuilt only when the html changes (active
411
+ // view, badges) so the 5s poll doesn't churn the DOM — and never while its menu is open.
412
+ function showView(id) {
413
+ state.view = id;
414
+ localStorage.setItem("swarm.view", id);
415
+ state.session = null;
416
+ state.dirty = true;
417
+ refresh();
418
+ }
419
+ function viewGroups() {
280
420
  const groups = [];
281
421
  for (const v of VIEW_DEFS) {
282
422
  const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
283
423
  g.views.push(v);
284
424
  }
285
- const link = (v) => {
286
- const n = v.badge?.() ?? 0;
287
- return `<a href="#" data-view="${v.id}" class="nav ${!state.session && state.view === v.id ? "on" : ""}" title="${v.label}">${ic(v.icon, 14)}<span class="nm">${v.label}</span>${n ? `<b class="navcount">${n > 99 ? "99+" : n}</b>` : ""}</a>`;
288
- };
289
- const html = groups.map((g) => `<h4>${g.name}</h4>${g.views.map(link).join("")}`).join("");
425
+ return groups;
426
+ }
427
+ function renderNav() {
428
+ const html = viewGroups()
429
+ .map((g) => {
430
+ const n = g.views.reduce((a, v) => a + (v.badge?.() ?? 0), 0);
431
+ const on = !state.session && g.views.some((v) => v.id === state.view);
432
+ // The group name alone never says which of its views you are on, so ten destinations hid
433
+ // behind four words. The active group carries the view's own label.
434
+ const cur = on ? g.views.find((v) => v.id === state.view) : null;
435
+ return `<button class="navgrp ${on ? "on" : ""}" data-grp="${g.name}"${on ? ' aria-current="page"' : ""} aria-haspopup="menu">${g.name}${cur ? `<span class="navview">${esc(cur.label)}</span>` : ""}${n ? `<b class="navcount">${n > 99 ? "99+" : n}</b>` : ""}${ic("chevron-down", 12, "chev")}</button>`;
436
+ })
437
+ .join("");
290
438
  if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
291
439
  }
440
+ // Delegated: the group buttons are re-rendered, so the listener lives on the container.
441
+ $("#viewnav").addEventListener("click", (ev) => {
442
+ const btn = ev.target.closest("[data-grp]");
443
+ if (!btn) return;
444
+ const g = viewGroups().find((x) => x.name === btn.dataset.grp);
445
+ if (!g || !window.menus) return;
446
+ btn.classList.add("open"); // cleared by menus:openchange when the menu closes
447
+ window.menus.open(btn, {
448
+ items: g.views.map((v) => {
449
+ const n = v.badge?.() ?? 0;
450
+ return {
451
+ label: v.label,
452
+ icon: v.icon,
453
+ caption: n ? String(n) : undefined,
454
+ pressed: !state.session && state.view === v.id,
455
+ run: () => showView(v.id),
456
+ };
457
+ }),
458
+ });
459
+ });
292
460
 
293
461
  const isLive = (s) => s.state === "active" || s.state === "waiting";
294
462
  // One pass over sessions → live count per project (+ "" for all), instead of a filter per sidebar row.
@@ -381,10 +549,19 @@ function onboarding() {
381
549
 
382
550
  // ---------- fleet
383
551
  // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
552
+ // M9.4: this session is blocked on a person right now — the badge says for how long, and on what.
553
+ const waitFor = (sid) => (state.waiting?.sessions ?? []).find((w) => w.sessionId === sid);
554
+ const WAIT_WHAT = { permission: "a permission prompt", question: "a question it asked", notification: "a notification" };
555
+ function waitBadge(sid) {
556
+ const w = waitFor(sid);
557
+ if (!w?.openSince) return "";
558
+ const what = WAIT_WHAT[w.openKind] ?? "you";
559
+ return ` <span class="badge warn" title="Blocked on ${esc(what)} since ${esc(w.openSince)}${w.openLabel ? ` — ${esc(w.openLabel)}` : ""}">Waiting ${ago(w.openSince)}</span>`;
560
+ }
384
561
  const FLEET_COLS = [
385
562
  { key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
386
563
  { key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
387
- { key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}${s.stuck ? ` <span class="badge bad" title="${esc(s.stuck)} — heuristic, nothing was interrupted; open the session to judge">Stuck</span>` : ""}` },
564
+ { key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}${s.stuck ? ` <span class="badge bad" title="${esc(s.stuck)} — heuristic, nothing was interrupted; open the session to judge">Stuck</span>` : ""}${waitBadge(s.id)}` },
388
565
  { key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
389
566
  { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
390
567
  const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
@@ -477,7 +654,11 @@ function renderBoardKpis() {
477
654
  const orphaned = claims.filter((c) => c.state === "orphaned").length;
478
655
  const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
479
656
  const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
480
- const inc = (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length;
657
+ // The snapshot carries only the 20 most recent open incidents, so counting that window caps the
658
+ // KPI at 20 while the Guard badge shows the real number. Both now read the same true count.
659
+ const inc = state.sel
660
+ ? (state.openIncidentsByProject?.[state.sel] ?? (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length)
661
+ : (state.openIncidents ?? (state.incidents ?? []).filter((i) => !i.acked).length);
481
662
  const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
482
663
  const ready = tasks ? tasks.filter((t) => t.ready).length : null;
483
664
  const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
@@ -1067,6 +1248,34 @@ document.addEventListener("change", async (ev) => {
1067
1248
  });
1068
1249
  document.addEventListener("input", (ev) => { if (ev.target.id === "psIcon") { $("#psImage").value = ""; setIconPreview(ev.target.value.trim()); for (const e of $$(".emoji")) e.classList.toggle("on", e.dataset.emoji === ev.target.value.trim()); } });
1069
1250
  document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
1251
+ // M9.4: how much of the fleet's time is spent waiting on a person, and on what. Blocked time is
1252
+ // not idle time — it is the agent standing still with the work half-done, which is why it gets a
1253
+ // number rather than a footnote.
1254
+ function waitingSection() {
1255
+ const w = state.waiting;
1256
+ if (!w?.totals?.episodes) return "";
1257
+ const t = w.totals;
1258
+ const kindRow = (k, label) => {
1259
+ const x = t.byKind[k];
1260
+ return x?.episodes ? `<tr><td>${label}</td><td class="num">${x.episodes}</td><td class="num">${dur(x.blockedMs)}</td></tr>` : "";
1261
+ };
1262
+ const top = w.sessions.slice(0, 8).map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
1263
+ <td>${esc(s.title ?? s.sessionId.slice(0, 8))}${s.openSince ? ` <span class="badge warn">waiting ${ago(s.openSince)}</span>` : ""}</td>
1264
+ <td class="num">${s.episodes}</td><td class="num">${dur(s.blockedMs)}</td><td class="num">${dur(s.longestMs)}</td></tr>`).join("");
1265
+ return `<h2 class="mt-sec">Waiting on you <span>last 7 days · time agents spent blocked on a person${t.waitingNow ? ` · <b class="navcount">${t.waitingNow} waiting now</b>` : ""}</span></h2>
1266
+ <div class="cols">
1267
+ <div class="chart-card" style="margin:0"><h3>By what blocked them</h3>
1268
+ <table class="mini"><thead><tr><th>kind</th><th class="num">times</th><th class="num">blocked</th></tr></thead>
1269
+ <tbody>${kindRow("permission", "Permission prompt")}${kindRow("question", "Question it asked")}${kindRow("notification", "Notification")}
1270
+ <tr><td><b>Total</b></td><td class="num"><b>${t.episodes}</b></td><td class="num"><b>${dur(t.blockedMs)}</b></td></tr>
1271
+ <tr><td class="dim">median wait</td><td class="num"></td><td class="num dim">${dur(t.medianMs)}</td></tr>
1272
+ <tr><td class="dim">longest wait</td><td class="num"></td><td class="num dim">${dur(t.longestMs)}</td></tr>
1273
+ </tbody></table></div>
1274
+ <div class="chart-card" style="margin:0"><h3>Sessions that waited most</h3>
1275
+ <table class="mini"><thead><tr><th>session</th><th class="num">waits</th><th class="num">blocked</th><th class="num">longest</th></tr></thead>
1276
+ <tbody>${top}</tbody></table></div>
1277
+ </div>`;
1278
+ }
1070
1279
  function renderStats() {
1071
1280
  const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
1072
1281
  const scope = state.sel ? esc(projName(state.sel)) : "all projects";
@@ -1154,6 +1363,7 @@ function renderStats() {
1154
1363
  <div class="chart-card" style="margin:0"><h3>Tool leaderboard <span>calls · all time</span></h3>${st.tools.length ? viz.hbars(st.tools.map(([k, v]) => [toolName(k), v])) : '<div class="dim">no tool calls yet</div>'}</div>
1155
1364
  <div><h2 style="margin-top:0">Records</h2><div class="records">${records}</div></div>
1156
1365
  </div>
1366
+ ${waitingSection()}
1157
1367
  <p class="dim" style="margin-top:var(--gap-sec)">Word counts assume ~0.75 words per token; a novel is 90k words. Costs use list prices, as on Spend. ${pct(T.sidechainTurns, T.turns)} of turns came from subagents.</p>`;
1158
1368
  }
1159
1369
 
@@ -1214,18 +1424,283 @@ function renderOutcomes() {
1214
1424
  $("#main").innerHTML =
1215
1425
  head(`${o.branches.length} branch${o.branches.length === 1 ? "" : "es"} · ${n("merged")} merged · ${rev ? `<b style="color:var(--bad)">${rev} reverted</b>` : "0 reverted"} · ${n("open")} open`) +
1216
1426
  `<h2 class="mt-sec">By model <span>who ships work that survives</span></h2>` +
1217
- dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel }) +
1218
- (o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent })}` : "") +
1427
+ dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel, rerender: touch }) +
1428
+ (o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent, rerender: touch })}` : "") +
1219
1429
  `<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
1220
- dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100) });
1430
+ dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100), rerender: touch });
1431
+ }
1432
+
1433
+ // M9.5: where the context window goes. Character counts are exact (every tool response is stored);
1434
+ // the token figures are a flat 4:1 estimate and say so. Re-reading a file is the waste metric —
1435
+ // the first read is work, every copy after it is the price of having forgotten.
1436
+ // `toolName` puts the server first, so four MCP tools all truncated to "claude-in-c…" and the
1437
+ // half that tells them apart was the half cut off. Lead with the tool, keep a short server hint.
1438
+ function ctxToolLabel(tool) {
1439
+ const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(tool);
1440
+ if (!m) return tool;
1441
+ const srv = m[1].replace(/[-_]/g, " ").split(" ").map((w) => w[0]).join("").toLowerCase();
1442
+ return `${m[2]} · ${srv}`;
1443
+ }
1444
+ function renderContext() {
1445
+ const c = state.context;
1446
+ const head = (sub) => `<h2>Context <span>${sub}</span></h2>`;
1447
+ if (!c) { $("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1448
+ if (!c.totals.sessions) {
1449
+ $("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.idle()}No tool results in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
1450
+ return;
1451
+ }
1452
+ const t = c.totals;
1453
+ const chars = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
1454
+ const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
1455
+ const kpis = `<div class="kpis">${
1456
+ kpi("Returned by tools", `${chars(t.toolChars)}`, `characters · ≈${chars(t.toolTokens)} tokens`)
1457
+ }${kpi("Spent re-reading", chars(t.wastedChars), t.wasteShare ? `${Math.round(t.wasteShare * 100)}% of it · ${t.rereadFiles} file${t.rereadFiles === 1 ? "" : "s"}` : "nothing re-read", t.wasteShare > 0.1 ? "hot" : t.wasteShare > 0.03 ? "warm" : "")
1458
+ }${kpi("Cache hit", `${Math.round(t.cacheHit * 100)}%`, "of the window came back free")
1459
+ }${kpi("Sessions", t.sessions, "with tool activity")}</div>`;
1460
+
1461
+ const worst = c.sessions.filter((s) => s.wastedChars > 0).slice(0, 10);
1462
+ const rows = worst.map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
1463
+ <td>${esc(s.title ?? s.sessionId.slice(0, 8))}</td>
1464
+ <td class="num">${chars(s.toolChars)}</td>
1465
+ <td class="num"><b>${chars(s.wastedChars)}</b></td>
1466
+ <td class="num">${Math.round(s.wasteShare * 100)}%</td>
1467
+ <td>${s.worst.map((w) => `<span class="br" title="${esc(w.path)} — read ${w.reads}× · ${chars(w.wastedChars)} chars re-read">${esc(w.path.split("/").slice(-1)[0])} <b>${w.reads}×</b></span>`).join(" ")}</td>
1468
+ </tr>`).join("");
1469
+
1470
+ $("#main").innerHTML = head(`last 7 days · ${chars(t.toolChars)} characters returned by tools`) + kpis +
1471
+ `<div class="cols">
1472
+ <div class="chart-card" style="margin:0"><h3>What fills the window <span>by tool · characters returned</span></h3>
1473
+ ${viz.hbars(c.byTool.map((x) => [ctxToolLabel(x.tool), x.chars, `${chars(x.chars)} · ${x.calls}`]))}</div>
1474
+ <div class="chart-card" style="margin:0"><h3>Re-read waste <span>the same file, read again</span></h3>
1475
+ ${worst.length ? `<table class="mini"><thead><tr><th>session</th><th class="num">returned</th><th class="num">wasted</th><th class="num">share</th><th>worst files</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="dim">Nothing was read twice — no waste to report.</div>'}</div>
1476
+ </div>
1477
+ <p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Character counts are exact — every tool response is stored. Token figures are a flat 4:1 estimate. <b>MCP tool schemas and the system prompt are not included</b>: Swarm sees tool calls, never the schemas or the prompt preamble, so they are left out rather than guessed at.</p>`;
1478
+ }
1479
+
1480
+ // M9.18: the same task run by N models side by side. An arm is its own task id, so each has its
1481
+ // own claim and worktree and the ledger's one-holder rule is untouched — see core/abtrial.ts.
1482
+ const VERDICT = { winner: ["ok", "Decided"], undecided: ["acc", "Running"], "all-failed": ["bad", "No winner"] };
1483
+ function renderTrials() {
1484
+ const trials = state.trials;
1485
+ const head = (sub) => `<h2>Trials <span>${sub}</span>${state.sel ? `<span class="grow"></span><span class="chip" id="abNew">${ic("plus", 12)} New trial</span>` : ""}</h2>`;
1486
+ if (!trials) { $("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1487
+ if (!trials.length) {
1488
+ $("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.idle()}No trials yet${state.sel ? "" : " — pick a project to start one"}.<br>A trial runs one task on several models at once and compares what each produced: cost, wall time, gates, diff size.</div>`;
1489
+ return;
1490
+ }
1491
+ const secs = (v) => (v === null ? '<span class="dim">—</span>' : dur(v));
1492
+ const cols = [
1493
+ { key: "arm", label: "arm", width: 130, get: (a) => a.label, cell: (a) => `<b>${esc(a.label)}</b>${a.winner ? ' <span class="badge ok">Winner</span>' : ""}` },
1494
+ { key: "state", label: "state", width: 116, get: (a) => a.ineligibleFor ?? "", cell: (a) => (a.eligible ? '<span class="badge ok">Passed</span>' : `<span class="badge ${a.state === "running" ? "acc" : "warn"}" title="This arm cannot win: ${esc(a.ineligibleFor ?? "")}">${esc(a.ineligibleFor ?? "—")}</span>`) },
1495
+ { key: "cost", label: "cost", width: 74, num: true, get: (a) => a.costUsd, cell: (a) => usd(a.costUsd) },
1496
+ { key: "wall", label: "wall", width: 74, num: true, get: (a) => a.wallMs ?? -1, cell: (a) => secs(a.wallMs) },
1497
+ { key: "turns", label: "turns", width: 64, num: true, get: (a) => a.turns, cell: (a) => a.turns },
1498
+ { key: "gates", label: "gates", width: 84, num: true, get: (a) => a.gatesFailed * -1 + a.gatesPassed, cell: (a) => `${a.gatesPassed ? `<span class="badge ok">${a.gatesPassed}</span>` : ""}${a.gatesFailed ? ` <span class="badge bad">${a.gatesFailed}</span>` : ""}${!a.gatesPassed && !a.gatesFailed ? '<span class="dim">none</span>' : ""}` },
1499
+ { key: "diff", label: "diff", width: 108, num: true, get: (a) => a.churn ?? -1, cell: (a) => (a.churn === null ? '<span class="dim">measuring…</span>' : `<span title="${a.filesChanged} file${a.filesChanged === 1 ? "" : "s"} · +${a.insertions} −${a.deletions}">${a.churn} lines</span>`) },
1500
+ { key: "sess", label: "session", flex: true, get: (a) => a.sessionId ?? "", cell: (a) => (a.sessionId ? `<a href="#" data-s="${esc(a.sessionId)}">${esc(a.model ?? a.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
1501
+ ];
1502
+ const block = (t) => {
1503
+ const v = VERDICT[t.verdict] ?? VERDICT.undecided;
1504
+ const sub = `${t.totals.arms} arm${t.totals.arms === 1 ? "" : "s"} · ${t.totals.finished} finished · ${usd(t.totals.costUsd)} spent${t.winner ? ` · <b>${esc(t.winner)}</b> won${t.totals.savedUsd > 0.005 ? `, ${usd(t.totals.savedUsd)} cheaper than the dearest` : ""}` : ""}`;
1505
+ return `<h2 class="mt-sec">${esc(t.task)} <span class="badge ${v[0]}">${v[1]}</span> <span>${sub}</span></h2>` +
1506
+ dataTable({ id: `ab-${t.task}`, columns: cols, rows: t.arms, rerender: touch });
1507
+ };
1508
+ const running = trials.filter((t) => t.verdict === "undecided").length;
1509
+ $("#main").innerHTML = head(`${trials.length} trial${trials.length === 1 ? "" : "s"}${running ? ` · ${running} still running` : ""}`) +
1510
+ trials.map(block).join("") +
1511
+ `<p class="dim" style="margin-top:12px;font-size:var(--fs-sm)">An arm wins only if it finished and passed every gate it ran; among those, the cheapest wins and wall time breaks ties. A cheap arm that failed a gate never wins — the cheap wrong answer is not the answer. Each arm claims <code>task#arm</code>, so it gets its own worktree and the one-holder claim is never bent.</p>`;
1512
+ }
1513
+
1514
+ // M9.14: issue → task → claim → session → branch → PR → merged, as one row per piece of work.
1515
+ // The six link dots are the graph: a filled run that stops is exactly where the trail goes cold.
1516
+ const LINK_ORDER = ["task", "claim", "session", "branch", "pr", "merged"];
1517
+ const BREAK_LABEL = {
1518
+ "no-task": ["bad", "No task", "landed with no task behind it"],
1519
+ unclaimed: ["warn", "Unclaimed", "no claim was ever taken for this task"],
1520
+ "no-session": ["warn", "No session", "claimed, but no session did the work"],
1521
+ "no-branch": ["warn", "No branch", "worked on, but never reached a branch"],
1522
+ "no-pr": ["warn", "No PR", "a branch exists but no pull request"],
1523
+ "open-pr": ["acc", "Open PR", "the pull request has not merged yet"],
1524
+ };
1525
+ // Lead time spans minutes to months, and "889.4h" both overflows a numeric column and means
1526
+ // nothing to a reader. Never wider than 5 characters.
1527
+ function leadTime(h) {
1528
+ if (h < 1) return `${Math.round(h * 60)}m`;
1529
+ if (h < 48) return `${h.toFixed(h < 10 ? 1 : 0)}h`;
1530
+ const d = h / 24;
1531
+ return d < 100 ? `${d.toFixed(d < 10 ? 1 : 0)}d` : `${Math.round(d / 7)}w`;
1532
+ }
1533
+ function renderProvenance() {
1534
+ const p = state.provenance;
1535
+ const head = (sub) => `<h2>Provenance <span>${sub}</span></h2>`;
1536
+ if (!p) { $("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1537
+ if (!p.chains.length) {
1538
+ $("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.idle()}Nothing to trace${state.sel ? " in this project" : ""}.<br>Chains appear once a task source is configured or a branch reaches a pull request.</div>`;
1539
+ return;
1540
+ }
1541
+ const t = p.totals;
1542
+ const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
1543
+ const kpis = `<div class="kpis">${
1544
+ kpi("Traced", `${t.complete}/${t.tasks}`, "reach a merged PR", t.complete ? "" : "warm")
1545
+ }${kpi("Untracked", t.untracked, t.untracked ? "landed with no task" : "all work has a task", t.untracked ? "hot" : "")
1546
+ }${kpi("Unclaimed", t.unclaimed, "tasks nobody claimed", t.unclaimed ? "warm" : "")
1547
+ }${kpi("Traced spend", usd(t.costUsd), "across every chain")}</div>`;
1548
+
1549
+ const track = (c) => `<span class="track" title="${LINK_ORDER.map((k) => `${k}: ${c.links[k] ? "yes" : "no"}`).join(" · ")}">${
1550
+ LINK_ORDER.map((k) => `<i class="${c.links[k] ? "on" : ""}"></i>`).join("")}</span>`;
1551
+ const cols = [
1552
+ { key: "what", label: "task / branch", width: 190, get: (c) => c.task, cell: (c) => `<b title="${esc(c.task)}${c.fromTask ? "" : " — a branch with no task behind it"}">${esc(c.task)}</b>${c.fromTask ? "" : ' <span class="badge">branch</span>'}` },
1553
+ { key: "track", label: "chain", width: 92, sortable: false, filterable: false, get: (c) => c.depth, cell: track },
1554
+ { key: "gap", label: "trail ends at", width: 118, get: (c) => c.brokenAt ?? "", cell: (c) => { const b = BREAK_LABEL[c.brokenAt]; return b ? `<span class="badge ${b[0]}" title="${esc(b[2])}">${b[1]}</span>` : '<span class="badge ok">Merged</span>'; } },
1555
+ { key: "title", label: "what it was", flex: true, get: (c) => c.title, cell: (c) => `<span class="now" title="${esc(c.title)}">${esc(c.title)}</span>` },
1556
+ { key: "who", label: "held by", width: 120, get: (c) => c.holders.join(","), cell: (c) => (c.holders.length ? esc(c.holders.join(", ")) : '<span class="dim">—</span>') },
1557
+ { key: "sess", label: "sessions", width: 78, num: true, get: (c) => c.sessions.length, cell: (c) => (c.sessions.length ? `<a href="#" data-s="${esc(c.sessions[0].id)}" title="${esc(c.sessions.map((s) => s.title ?? s.id).join(" · "))}">${c.sessions.length}</a>` : '<span class="dim">0</span>') },
1558
+ { key: "pr", label: "PR", width: 74, num: true, get: (c) => c.prNumber ?? 0, cell: (c) => (c.prNumber ? `<a href="${esc(c.prUrl ?? "#")}" target="_blank" rel="noopener">#${c.prNumber}</a>` : '<span class="dim">—</span>') },
1559
+ { key: "cost", label: "cost", width: 74, num: true, get: (c) => c.costUsd, cell: (c) => usd(c.costUsd) },
1560
+ { key: "lead", label: "lead", width: 68, num: true, get: (c) => c.leadHours ?? -1, cell: (c) => (c.leadHours === null ? '<span class="dim">—</span>' : leadTime(c.leadHours)) },
1561
+ ];
1562
+ $("#main").innerHTML = head(`${t.tasks} chain${t.tasks === 1 ? "" : "s"} · ${t.untracked ? `<b class="navcount">${t.untracked} untracked</b>` : "every branch has a task"}`) + kpis +
1563
+ dataTable({ id: "provenance", columns: cols, rows: p.chains, rerender: touch }) +
1564
+ `<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">The six dots are task · claim · session · branch · PR · merged — a filled run that stops is where the trail goes cold. Chains are walked from both ends: from tasks forward, and from branches back, so <b>work that landed with no task behind it</b> shows up too. Task rows carry no issue link because the task source records ids and titles, not URLs.</p>`;
1565
+ }
1566
+
1567
+ // M9.6: which MCP servers the fleet waits on. Latency is hook-to-hook — the wall-clock between
1568
+ // PreToolUse and PostToolUse — so it is what the agent actually waited for, including any time a
1569
+ // call spent behind a permission prompt. That is why the view leads with p50/p95, not max.
1570
+ function renderMcpHealth() {
1571
+ const h = state.mcpHealth;
1572
+ const head = (sub) => `<h2>MCP <span>${sub}</span></h2>`;
1573
+ if (!h) { $("#main").innerHTML = head("server health") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1574
+ if (!h.servers.length) {
1575
+ $("#main").innerHTML = head("server health") + `<div class="empty">${PX.idle()}No tool calls in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
1576
+ return;
1577
+ }
1578
+ const t = h.totals;
1579
+ const ms = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : v < 60_000 ? `${(v / 1000).toFixed(1)}s` : dur(v));
1580
+ const cols = [
1581
+ { key: "server", label: "server", width: 170, get: (s) => s.server, cell: (s) => `<b>${esc(s.server)}</b>${s.mcp ? "" : ' <span class="badge">built-in</span>'}` },
1582
+ { key: "calls", label: "calls", width: 74, num: true, get: (s) => s.calls, cell: (s) => s.calls.toLocaleString() },
1583
+ { key: "sessions", label: "sessions", width: 78, num: true, get: (s) => s.sessions, cell: (s) => s.sessions },
1584
+ { key: "p50", label: "p50", width: 68, num: true, get: (s) => s.p50Ms ?? -1, cell: (s) => ms(s.p50Ms) },
1585
+ { key: "p95", label: "p95", width: 68, num: true, get: (s) => s.p95Ms ?? -1, cell: (s) => ms(s.p95Ms) },
1586
+ { key: "max", label: "slowest", width: 78, num: true, get: (s) => s.maxMs ?? -1, cell: (s) => `<span class="dim" title="Includes any time the call spent waiting on a person">${ms(s.maxMs)}</span>` },
1587
+ { key: "wait", label: "waited", width: 82, num: true, get: (s) => s.totalMs, cell: (s) => dur(s.totalMs) },
1588
+ { key: "unans", label: "no reply", width: 78, num: true, get: (s) => s.unanswered, cell: (s) => (s.unanswered ? `<b class="bad">${s.unanswered}</b>` : '<span class="dim">0</span>') },
1589
+ { key: "err", label: "errors", width: 74, num: true, get: (s) => s.errorRate, cell: (s) => (s.errors ? `<b class="bad">${Math.round(s.errorRate * 100)}%</b>` : '<span class="dim">0</span>') },
1590
+ { key: "tools", label: "busiest tools", flex: true, sortable: false, get: () => null, cell: (s) => s.tools.map((x) => `<span class="br" title="${esc(x.tool)} · ${x.calls} calls${x.p50Ms === null ? "" : ` · p50 ${x.p50Ms}ms`}">${esc(x.tool)} <b>${x.calls}</b></span>`).join(" ") },
1591
+ ];
1592
+ const share = t.totalMs ? Math.round((t.mcpMs / t.totalMs) * 100) : 0;
1593
+ const sub = `${t.servers} MCP server${t.servers === 1 ? "" : "s"} · ${t.calls.toLocaleString()} call${t.calls === 1 ? "" : "s"} · last 7 days · ${dur(t.mcpMs)} waiting on MCP (${share}% of all tool time)`;
1594
+ $("#main").innerHTML = head(sub) +
1595
+ dataTable({ id: "mcp-health", columns: cols, rows: h.servers, rerender: touch }) +
1596
+ `<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Latency is measured hook to hook, so it is the wall-clock an agent actually waited — a call held behind a permission prompt carries that wait too, which is why <b>slowest</b> can be hours and p50/p95 are the numbers to read. <b>errors</b> counts only unambiguous failures: a command that merely prints the word "error" is not one.</p>`;
1597
+ }
1598
+
1599
+ // M9.7: gate flakiness and cost. A gate that flips on the *same task* told you two different
1600
+ // things about identical work — that is the number worth ranking on, not a raw fail count.
1601
+ function renderGateHealth() {
1602
+ const h = state.gateHealth;
1603
+ const head = (sub) => `<h2>Gates <span>${sub}</span></h2>`;
1604
+ if (!h) { $("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1605
+ if (!h.gates.length) {
1606
+ $("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.idle()}No gate runs in the last 30 days${state.sel ? " in this project" : ""}.<br>Gates appear here once <code>swarm_gate_run</code> or a workflow's gate step records one.</div>`;
1607
+ return;
1608
+ }
1609
+ const t = h.totals;
1610
+ const secs = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : `${(v / 1000).toFixed(1)}s`);
1611
+ // Oldest-first strip, matching Recent gates on the Board.
1612
+ const strip = (g) => {
1613
+ const rs = [...g.history].reverse();
1614
+ return `<span class="gh" title="last ${rs.length} run${rs.length === 1 ? "" : "s"}, oldest first">${rs.map((r) => `<i class="${r.verdict === "pass" ? "ok" : "bad"}" title="${esc(r.task)} · ${esc(r.at)}${r.durationMs === null ? "" : ` · ${(r.durationMs / 1000).toFixed(1)}s`}"></i>`).join("")}</span>`;
1615
+ };
1616
+ const cols = [
1617
+ { key: "gate", label: "gate", width: 150, get: (g) => g.gate, cell: (g) => `<b>${esc(g.gate)}</b>${g.flaky ? ' <span class="badge bad" title="This gate returned both a pass and a fail on the same task">Flaky</span>' : ""}` },
1618
+ { key: "history", label: "history", width: 150, sortable: false, filterable: false, get: () => null, cell: strip },
1619
+ { key: "runs", label: "runs", width: 60, num: true, get: (g) => g.runs, cell: (g) => g.runs },
1620
+ { key: "pass", label: "pass rate", width: 84, num: true, get: (g) => g.passRate, cell: (g) => `${Math.round(g.passRate * 100)}%` },
1621
+ { key: "flips", label: "flips", width: 64, num: true, get: (g) => g.flips, cell: (g) => (g.flips ? `<b class="bad">${g.flips}</b>` : '<span class="dim">0</span>') },
1622
+ { key: "p50", label: "p50", width: 66, num: true, get: (g) => g.p50Ms ?? -1, cell: (g) => secs(g.p50Ms) },
1623
+ { key: "p95", label: "p95", width: 66, num: true, get: (g) => g.p95Ms ?? -1, cell: (g) => secs(g.p95Ms) },
1624
+ { key: "max", label: "slowest", width: 74, num: true, get: (g) => g.maxMs ?? -1, cell: (g) => secs(g.maxMs) },
1625
+ { key: "total", label: "total", width: 74, num: true, get: (g) => g.totalMs, cell: (g) => (g.timedRuns ? dur(g.totalMs) : '<span class="dim">—</span>') },
1626
+ { key: "last", label: "last", flex: true, get: (g) => g.lastAt ?? "", cell: (g) => (g.lastAt ? `${g.lastVerdict === "pass" ? '<span class="badge ok">Pass</span>' : '<span class="badge warn">Fail</span>'} <span class="dim">${ago(g.lastAt)}</span>` : '<span class="dim">—</span>') },
1627
+ ];
1628
+ const sub = `${t.gates} gate${t.gates === 1 ? "" : "s"} · ${t.runs} run${t.runs === 1 ? "" : "s"} · last 30 days${t.flakyGates ? ` · <b class="navcount">${t.flakyGates} flaky</b>` : " · none flaky"}${t.totalMs ? ` · ${dur(t.totalMs)} of wall-clock` : ""}`;
1629
+ $("#main").innerHTML = head(sub) +
1630
+ dataTable({ id: "gate-health", columns: cols, rows: h.gates, rerender: touch }) +
1631
+ `<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Flaky = the same gate returned both a pass and a fail on one task. A gate that fails on one task and passes on another is doing its job, and is not counted. Durations cover executed gates only — a gate an agent simply recorded has no wall-clock.</p>`;
1632
+ }
1633
+
1634
+ // M9.8: machine hygiene — what the fleet left behind. Observation plus the two actions that
1635
+ // already exist (stop a process, remove a worktree); nothing here reclaims anything on its own,
1636
+ // and a worktree with uncommitted or unpushed work is never offered as safe.
1637
+ const ISSUE_BADGE = {
1638
+ dead: ["bad", "Dead"], orphaned: ["bad", "Orphaned"], hungry: ["warn", "Hungry"],
1639
+ stale: ["warn", "Stale"], abandoned: ["warn", "Abandoned"], heavy: ["", "Heavy"],
1640
+ };
1641
+ const mb = (kb) => (kb === null || kb === undefined ? '<span class="dim">—</span>' : kb >= 1024 * 1024 ? `${(kb / 1024 / 1024).toFixed(1)} GB` : `${Math.round(kb / 1024)} MB`);
1642
+ const issueBadge = (i) => { const b = ISSUE_BADGE[i]; return b ? `<span class="badge ${b[0]}">${b[1]}</span>` : '<span class="dim">ok</span>'; };
1643
+ function renderHygiene() {
1644
+ const h = state.hygiene;
1645
+ const head = (sub) => `<h2>Hygiene <span>${sub}</span></h2>`;
1646
+ if (!h) { $("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1647
+ const t = h.totals;
1648
+ if (!h.processes.length && !h.worktrees.length) {
1649
+ $("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.idle()}Nothing tracked${state.sel ? " in this project" : ""}.<br>Processes started through <code>swarm serve</code> / <code>proc</code> and this machine's worktrees appear here.</div>`;
1650
+ return;
1651
+ }
1652
+ const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
1653
+ const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
1654
+ // Disk is sampled in the background, so "0 MB" before the first sweep would be a lie — say so.
1655
+ const sampled = h.worktrees.filter((w) => w.diskKb !== null).length;
1656
+ const diskPending = h.worktrees.length > 0 && sampled === 0;
1657
+ const totalDisk = diskPending ? "measuring…" : mb(t.diskKb);
1658
+ const kpis = `<div class="kpis">${
1659
+ kpi("Needs a look", t.issues, t.issues ? "processes + worktrees" : "all clean", t.issues ? "hot" : "")
1660
+ }${kpi("Processes", t.processes, t.orphanedProcesses || t.deadProcesses ? `${t.orphanedProcesses} orphaned · ${t.deadProcesses} dead` : "all healthy", t.orphanedProcesses || t.deadProcesses ? "hot" : "")
1661
+ }${kpi("Worktrees", t.worktrees, t.staleWorktrees ? `${t.staleWorktrees} stale` : "none stale", t.staleWorktrees ? "warm" : "")
1662
+ }${kpi("Reclaimable", diskPending ? '<span class="dim">—</span>' : mb(t.reclaimableKb), diskPending ? `measuring ${h.worktrees.length} worktrees…` : `of ${mb(t.diskKb)} on disk`, !diskPending && t.reclaimableKb ? "warm" : "")}</div>`;
1663
+
1664
+ const pcols = [
1665
+ { key: "issue", label: "state", width: 96, get: (p) => p.issue ?? "", cell: (p) => issueBadge(p.issue) },
1666
+ { key: "name", label: "name", width: 130, get: (p) => p.name, cell: (p) => `<b>${esc(p.name)}</b>` },
1667
+ { key: "kind", label: "kind", width: 64, get: (p) => p.kind, cell: (p) => `<span class="br">${esc(p.kind)}</span>` },
1668
+ { key: "pid", label: "pid", width: 64, num: true, get: (p) => p.pid, cell: (p) => p.pid },
1669
+ { key: "port", label: "port", width: 60, num: true, get: (p) => p.port ?? 0, cell: (p) => p.port ?? '<span class="dim">—</span>' },
1670
+ { key: "cpu", label: "cpu", width: 60, num: true, get: (p) => p.cpuPct ?? -1, cell: (p) => (p.cpuPct === null ? '<span class="dim">—</span>' : `${p.cpuPct.toFixed(0)}%`) },
1671
+ { key: "rss", label: "memory", width: 78, num: true, get: (p) => p.rssKb ?? -1, cell: (p) => mb(p.rssKb) },
1672
+ { key: "note", label: "why", flex: true, get: (p) => p.note ?? "", cell: (p) => (p.note ? `<span class="now" title="${esc(p.note)}">${esc(p.note)}</span>` : '<span class="dim">—</span>') },
1673
+ { key: "act", label: "", width: 70, sortable: false, filterable: false, get: () => null, cell: (p) => (p.reclaimable ? `<a href="#" class="mini-act" data-procstop="${esc(String(p.pid))}" data-procproj="${esc(p.projectId)}" title="Stop this process">Stop</a>` : "") },
1674
+ ];
1675
+ const wcols = [
1676
+ { key: "issue", label: "state", width: 106, get: (w) => w.issue ?? "", cell: (w) => issueBadge(w.issue) },
1677
+ { key: "branch", label: "branch", width: 190, get: (w) => w.branch ?? w.path, cell: (w) => `<b>${esc(w.branch ?? "(detached)")}</b>${w.main ? ' <span class="badge">main</span>' : ""}` },
1678
+ { key: "disk", label: "disk", width: 78, num: true, get: (w) => w.diskKb ?? -1, cell: (w) => mb(w.diskKb) },
1679
+ { key: "idle", label: "untouched", width: 88, num: true, get: (w) => w.idleMs ?? -1, cell: (w) => (w.idleMs === null ? '<span class="dim">—</span>' : dur(w.idleMs)) },
1680
+ { key: "state2", label: "work", width: 130, get: (w) => w.dirty * 1000 + w.ahead, cell: (w) => `${badge(w.dirty, "Dirty", "warn")}${badge(w.ahead, "Unpushed", "acc")}${w.dirty === 0 && w.ahead <= 0 ? (w.merged ? '<span class="badge ok">Merged</span>' : '<span class="badge">Clean</span>') : ""}` },
1681
+ { key: "held", label: "in use", width: 110, get: (w) => w.heldByClaim ?? "", cell: (w) => (w.heldByClaim ? `<span class="br" title="Claimed">${esc(w.heldByClaim)}</span>` : w.liveSessions ? `<span class="badge acc">${w.liveSessions} live</span>` : '<span class="dim">—</span>') },
1682
+ { key: "note", label: "why", flex: true, get: (w) => w.note ?? "", cell: (w) => (w.note ? `<span class="now" title="${esc(w.note)}">${esc(w.note)}</span>` : '<span class="dim">—</span>') },
1683
+ { key: "act", label: "", width: 80, sortable: false, filterable: false, get: () => null, cell: (w) => (w.reclaimable ? `<a href="#" class="mini-act bad" data-wtrm="${esc(w.projectId)}:${esc(w.path)}" title="Remove this worktree">Remove</a>` : "") },
1684
+ ];
1685
+ const sub = t.issues ? `<b class="navcount">${t.issues} need${t.issues === 1 ? "s" : ""} a look</b>` : "nothing to clean up";
1686
+ $("#main").innerHTML = head(sub) + kpis +
1687
+ `<h2 class="mt-sec">Processes <span>${h.processes.length} tracked · started through swarm, never matched by command pattern</span></h2>` +
1688
+ (h.processes.length ? dataTable({ id: "hyg-procs", columns: pcols, rows: h.processes, rerender: touch }) : `<div class="empty">${PX.idle()}No tracked processes.</div>`) +
1689
+ `<h2 class="mt-sec">Worktrees <span>${h.worktrees.length} on this machine · ${totalDisk}${diskPending ? "" : " on disk"}${sampled && sampled < h.worktrees.length ? ` · ${sampled}/${h.worktrees.length} measured` : ""}</span></h2>` +
1690
+ (h.worktrees.length ? dataTable({ id: "hyg-trees", columns: wcols, rows: h.worktrees, rerender: touch }) : `<div class="empty">${PX.idle()}No worktrees.</div>`) +
1691
+ `<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Only merged worktrees with nothing uncommitted, nothing unpushed and nobody working in them are offered for removal. Anything unmerged is listed but never called safe. Disk is sampled in the background, so sizes fill in a moment after the view opens.</p>`;
1221
1692
  }
1222
1693
 
1223
1694
  // M9.12: live file-collision graph — which live sessions touch which files, contested files
1224
1695
  // highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
1225
1696
  function renderGraphs() {
1697
+ const tab = state.graphTab ?? "collisions";
1698
+ const chip = (k, label, n) => `<span class="chip ${tab === k ? "on" : ""}" data-graphtab="${k}">${label}${n ? ` <b>${n}</b>` : ""}</span>`;
1699
+ const tabs = `<div class="chips">${chip("collisions", "Collisions", state.collisions?.contested ?? 0)}${chip("lineage", "Lineage", state.lineage?.edges?.length ?? 0)}</div>`;
1700
+ const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>${tabs}`;
1701
+ if (tab === "lineage") return renderLineage(head);
1226
1702
  const g = state.collisions;
1227
1703
  const title = (s) => s.title ?? s.id.slice(0, 8);
1228
- const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>`;
1229
1704
  if (!g || !g.sessions.length) {
1230
1705
  $("#main").innerHTML = head("live file collisions") + `<div class="empty">${PX.idle()}No live sessions${state.sel ? " in this project" : ""}.<br>The collision graph shows who is touching what, the moment two agents run at once.</div>`;
1231
1706
  return;
@@ -1242,6 +1717,30 @@ function renderGraphs() {
1242
1717
  <div style="margin-top:10px;display:flex;gap:16px;align-items:center">${viz.legend(agents)}<span class="dim" style="font-size:var(--fs-sm)">solid edge = writing · faint edge = reading · <span style="color:var(--bad)">red file</span> = two sessions on it, at least one writing</span></div>`;
1243
1718
  }
1244
1719
 
1720
+ // M9.13: who started whom, who told whom, who picked up whose work. Every edge is a recorded
1721
+ // relationship — nothing is inferred from timing.
1722
+ const EDGE_LEGEND = [
1723
+ ["subagent", "spawned a subagent", "var(--acc)", ""],
1724
+ ["dispatch", "dispatched a run", "var(--c3,#5a9e6f)", ""],
1725
+ ["message", "sent a message", "var(--warn)", "3 3"],
1726
+ ["handoff", "handed the task on", "var(--dim)", "6 3"],
1727
+ ];
1728
+ function renderLineage(head) {
1729
+ const g = state.lineage;
1730
+ if (!g) { $("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1731
+ if (!g.nodes.length) {
1732
+ $("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.idle()}No relationships between sessions${state.sel ? " in this project" : ""} in the last 14 days.<br>Edges appear when a session spawns a subagent, dispatches a run, messages another agent, or hands a task on.</div>`;
1733
+ return;
1734
+ }
1735
+ const key = EDGE_LEGEND.filter(([k]) => g.byKind[k]).map(([k, label, color, dash]) =>
1736
+ `<span style="display:inline-flex;align-items:center;gap:6px"><svg width="22" height="8" aria-hidden="true"><line x1="0" y1="4" x2="22" y2="4" stroke="${color}" stroke-width="2"${dash ? ` stroke-dasharray="${dash}"` : ""}/></svg><span class="dim" style="font-size:var(--fs-sm)">${label} <b>${g.byKind[k]}</b></span></span>`).join("");
1737
+ const sub = `${g.nodes.length} session${g.nodes.length === 1 ? "" : "s"} · ${g.edges.length} link${g.edges.length === 1 ? "" : "s"} · ${g.roots} root${g.roots === 1 ? "" : "s"} · last 14 days${g.truncated ? ` · <b class="navcount" title="The best-connected ${g.nodes.length} are drawn; the rest would be an unreadable column">${g.truncated} not drawn</b>` : ""}`;
1738
+ $("#main").innerHTML = head(sub) +
1739
+ `<div class="card" style="padding:14px;overflow:auto;max-height:72vh">${viz.dag(g)}</div>
1740
+ <div style="margin-top:10px;display:flex;gap:18px;align-items:center;flex-wrap:wrap">${key}
1741
+ <span class="dim" style="font-size:var(--fs-sm)">a green pill is a collapsed group — click to open it · ring = outcome · thicker dot = more links · a bowed edge closed a loop</span></div>`;
1742
+ }
1743
+
1245
1744
  function renderTimeline() {
1246
1745
  loadTimelineDetail();
1247
1746
  const now = Date.now();
@@ -1291,7 +1790,23 @@ async function openSession(id) {
1291
1790
  // Rendered log rows, keyed per event seq / turn id (+ the mutable turn fields) so only new rows are formatted.
1292
1791
  const rowCache = new Map();
1293
1792
  let logRendered = null; // keys of the rows currently in #log, in order — enables append-only updates
1294
- const evRow = (i) => `<div class="ev ${i.cls}"><span class="t">${hhmm(i.ts)}</span><span class="k">${esc(i.kind)}</span><span class="m">${esc(i.text)}${i.out ? `<span class="dim"> · ${tok(i.out)} out${i.cost != null ? ` · $${i.cost.toFixed(3)}` : ""}</span>` : ""}</span></div>`;
1793
+ // The kind column showed raw hook names "pretooluse", "subagentstop" which are long, repeat on
1794
+ // every row, and say nothing the row does not: a tool row already begins with the tool's name. Short
1795
+ // labels here buy the transcript back ~70px of width per row; the full name stays in the title.
1796
+ const EV_LABEL = {
1797
+ PreToolUse: "tool", PostToolUse: "result", UserPromptSubmit: "you", Stop: "stop",
1798
+ SubagentStart: "sub →", SubagentStop: "sub ←", Notification: "note",
1799
+ SessionStart: "start", SessionEnd: "end", PreCompact: "compact",
1800
+ assistant: "agent", subagent: "sub",
1801
+ // ledger events reach the transcript too, and their dotted type names are the longest of all
1802
+ "incident.opened": "rule", "question.asked": "asks", "question.answered": "answer",
1803
+ "message.sent": "msg", "gate.recorded": "gate", "session.stuck": "stuck",
1804
+ "permission.requested": "perm?", "permission.resolved": "perm",
1805
+ "claim.acquired": "claim", "claim.released": "release", "pr.opened": "pr",
1806
+ };
1807
+ // Anything unmapped keeps its last dotted segment rather than the whole `a.b` name.
1808
+ const evLabel = (k) => EV_LABEL[k] ?? String(k).split(".").at(-1) ?? String(k);
1809
+ const evRow = (i) => `<div class="ev ${i.cls}"><span class="t">${hhmm(i.ts)}</span><span class="k" title="${esc(i.kind)}">${esc(evLabel(i.kind))}</span><span class="m">${esc(i.text)}${i.out ? `<span class="dim"> · ${tok(i.out)} out${i.cost != null ? ` · $${i.cost.toFixed(3)}` : ""}</span>` : ""}</span></div>`;
1295
1810
  // Merge the two ts-sorted inputs (events by seq ≈ ts, turns by ts) in one pass → [{key, html}].
1296
1811
  function sessionStream() {
1297
1812
  const out = [];
@@ -1369,16 +1884,37 @@ function replayGo(delta) {
1369
1884
  }
1370
1885
 
1371
1886
  // Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
1372
- // M7.6: the session's message thread (sent + received) and a compose box.
1887
+ // M7.6: the session's message thread (sent + received) and a compose box. Messages are never an
1888
+ // interrupt: they ride along as context on the agent's next tool call, so the block says so.
1373
1889
  function messageThread(s) {
1374
1890
  const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
1891
+ const queued = ms.filter((m) => m.fromSession !== s.id && !m.deliveredAt).length;
1892
+ const ended = s.state === "ended";
1375
1893
  const row = (m) => {
1376
1894
  const out = m.fromSession === s.id;
1377
1895
  return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
1378
1896
  <span class="msg-f">${out ? `→ ${esc(m.task ?? m.toKind)}` : esc(m.from ?? "?")}${m.deliveredAt ? "" : ' <i class="dim">·queued</i>'}</span>${esc(m.text)}</div>`;
1379
1897
  };
1380
- return `<h4>messages</h4>${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : '<span class="dim">None yet.</span>'}
1381
- <div class="msg-compose"><input id="msgText" placeholder="Message this agent… (delivered on its next tool call)" autocomplete="off"><button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}">${ic("arrow-right", 13)}</button></div>`;
1898
+ const hint = ended
1899
+ ? `${ic("warning", 12)} Session ended there is nothing left to deliver to.`
1900
+ : queued
1901
+ ? `${ic("clock", 12)} <b>${queued} queued</b> · delivered the next time this agent calls a tool.`
1902
+ : `${ic("comment-text", 12)} Delivered as context on this agent's next tool call — never an interrupt.`;
1903
+ return `<h4>messages${ms.length ? ` <span class="badge">${ms.length}</span>` : ""}</h4>
1904
+ ${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : ""}
1905
+ <div class="msg-compose">
1906
+ <input id="msgText" placeholder="Message this agent…" aria-label="Message this agent" autocomplete="off"${ended ? " disabled" : ""}>
1907
+ <button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}" title="Send (Enter)"${ended ? " disabled" : ""}>${ic("arrow-right", 12)}Send</button>
1908
+ </div>
1909
+ <p class="msg-hint">${hint}</p>`;
1910
+ }
1911
+
1912
+ // The transcript file, as one copyable row: the directory truncates, the file name always shows.
1913
+ function transcriptRow(s) {
1914
+ if (!s.transcriptPath) return "";
1915
+ const p = short(s.transcriptPath);
1916
+ const cut = p.lastIndexOf("/");
1917
+ return `<h4>transcript</h4><button class="pathrow" data-copy="${esc(s.transcriptPath)}" title="Copy path · ${esc(p)}">${ic("file-text", 12)}<span class="dir">${esc(cut < 0 ? "" : p.slice(0, cut + 1))}</span><b>${esc(cut < 0 ? p : p.slice(cut + 1))}</b>${ic("copy", 12, "cp")}</button>`;
1382
1918
  }
1383
1919
 
1384
1920
  // M7.7: questions this session is waiting on a human for
@@ -1413,6 +1949,8 @@ async function sendStdin() {
1413
1949
  }
1414
1950
  document.addEventListener("click", (ev) => {
1415
1951
  if (ev.target.closest("#stdinSend")) return sendStdin();
1952
+ const cp = ev.target.closest("[data-copy]");
1953
+ if (cp) { ev.preventDefault(); copy(cp.dataset.copy); cp.classList.add("copied"); setTimeout(() => cp.classList.remove("copied"), 1000); return; }
1416
1954
  const qa = ev.target.closest("[data-qanswer]");
1417
1955
  if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
1418
1956
  const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
@@ -1435,9 +1973,9 @@ function renderSession() {
1435
1973
  const t = s.tokens;
1436
1974
  const ctx = t.input + t.cacheRead + t.cacheWrite;
1437
1975
  const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
1438
- const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar", context: "rows", started: "clock", "last seen": "eye", "subagent turns": "tree-structure" };
1976
+ const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar", processed: "rows", started: "clock", "last seen": "eye", "subagent turns": "tree-structure" };
1439
1977
  const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
1440
- const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("reload", 13)} Resume where it died</a>` : ""}</h2>`;
1978
+ const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("arrows-clockwise", 13)} Resume where it died</a>` : ""}</h2>`;
1441
1979
  const side = `<div class="stats">
1442
1980
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
1443
1981
  ${stat("output", `${tok(t.output)}${t.thinking ? `<small> · ${tok(t.thinking)} thinking</small>` : ""}`)}${stat("processed", `${tok(ctx)}<small> · ${ctx ? ((100 * t.cacheRead) / ctx).toFixed(0) : 0}% cached</small>`)}
@@ -1449,12 +1987,24 @@ function renderSession() {
1449
1987
  <h4>tools</h4>${tools.length ? viz.hbars(tools.slice(0, 8).map(([k, v]) => [k.replace(/^mcp__[a-z0-9-]+__/i, ""), v])) : '<span class="dim">None yet</span>'}
1450
1988
  ${messageThread(s)}
1451
1989
  ${questionCards(s)}
1452
- ${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
1990
+ ${transcriptRow(s)}`;
1453
1991
  if (logEl && isAppend(rows)) {
1454
1992
  // Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
1455
1993
  // scroll position (and its DOM) untouched.
1456
1994
  $("#main > h2").outerHTML = head;
1995
+ // The message compose box lives inside .side, and this fast-path runs on every event while the
1996
+ // agent works — carry the draft (and the caret) across the swap instead of wiping what is
1997
+ // being typed.
1998
+ const msg = $("#msgText");
1999
+ const draft = msg?.value ? { v: msg.value, focused: document.activeElement === msg, pos: msg.selectionStart } : null;
1457
2000
  $("#main .side").innerHTML = side;
2001
+ if (draft) {
2002
+ const el = $("#msgText");
2003
+ if (el) {
2004
+ el.value = draft.v;
2005
+ if (draft.focused) { el.focus(); el.setSelectionRange(draft.pos, draft.pos); }
2006
+ }
2007
+ }
1458
2008
  const sb = stdinBox(s); const cur = $("#main .stdin");
1459
2009
  if (cur && cur.outerHTML !== sb && document.activeElement?.id !== "stdinText") cur.outerHTML = sb;
1460
2010
  else if (!cur && sb) $("#main").insertAdjacentHTML("beforeend", sb);
@@ -1614,7 +2164,7 @@ function menuSpec(kind, d) {
1614
2164
  if (!p) return null;
1615
2165
  const green = p.checks !== "fail" && p.mergeable && !p.draft;
1616
2166
  return { title: `#${p.number}`, items: [
1617
- { label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => window.open(p.url, "_blank") },
2167
+ { label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => openExternal(p.url) },
1618
2168
  { label: "Copy URL", icon: "copy", run: () => copy(p.url) },
1619
2169
  { divider: true },
1620
2170
  { label: "Squash-merge", icon: "git-pull-request", disabled: !green, caption: green ? (p.forge === "gitlab" ? "glab" : "gh") : p.draft ? "draft" : p.checks === "fail" ? "checks failing" : "not mergeable", run: () => act.merge(p.projectId, p.number) },
@@ -1656,8 +2206,8 @@ function menuSpec(kind, d) {
1656
2206
  { divider: true },
1657
2207
  { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1658
2208
  { label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
1659
- { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
1660
- { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => window.open(feedbackUrl(), "_blank") },
2209
+ { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => openExternal("https://getswarm.vercel.app/docs/") },
2210
+ { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => openExternal(feedbackUrl()) },
1661
2211
  ] };
1662
2212
  }
1663
2213
  return null;
@@ -1710,9 +2260,13 @@ ${p.reason ?? ""}`.slice(0, 180);
1710
2260
  // What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
1711
2261
  // The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
1712
2262
  // itself once after an upgrade (localStorage remembers the last version the user saw).
1713
- function releaseNotesFor(version) {
2263
+ // `strict` matters: the automatic post-upgrade panel must never fall back. Falling back showed
2264
+ // 0.10.0's notes under a "What's New" triggered by upgrading to 0.11.0 — the notes bundle was a
2265
+ // stale cached copy that had no 0.11.0 in it, and the fallback quietly hid that.
2266
+ function releaseNotesFor(version, { strict = false } = {}) {
1714
2267
  const all = window.RELEASE_NOTES || {};
1715
2268
  if (version && all[version]) return { version, ...all[version] };
2269
+ if (strict) return null;
1716
2270
  const latest = Object.keys(all)[0];
1717
2271
  return latest ? { version: latest, ...all[latest] } : null;
1718
2272
  }
@@ -1741,9 +2295,12 @@ function maybeUpdateNudge(h) {
1741
2295
  <div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
1742
2296
  document.body.appendChild(el);
1743
2297
  el.addEventListener("click", async (e) => {
1744
- if (e.target.id === "updLater") return el.remove();
1745
- if (e.target.id !== "updRestart") return;
1746
- e.target.textContent = "restarting…";
2298
+ // closest(), not e.target.id: the button holds an <svg> icon, so a click on the glyph itself
2299
+ // targets the svg/path and an id check would miss it.
2300
+ const btn = e.target.closest?.("button");
2301
+ if (btn?.id === "updLater") return el.remove();
2302
+ if (btn?.id !== "updRestart") return;
2303
+ btn.textContent = "restarting…";
1747
2304
  await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
1748
2305
  const t0 = Date.now();
1749
2306
  const wait = setInterval(async () => {
@@ -1760,7 +2317,7 @@ function maybeWhatsNew() {
1760
2317
  let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
1761
2318
  if (seen === state.version) return;
1762
2319
  if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
1763
- if (releaseNotesFor(state.version)) whatsNew(state.version);
2320
+ if (releaseNotesFor(state.version, { strict: true })) whatsNew(state.version);
1764
2321
  }
1765
2322
 
1766
2323
  // Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
@@ -1785,7 +2342,7 @@ function maybeStarNudge() {
1785
2342
  el.addEventListener("click", (ev) => {
1786
2343
  const t = ev.target.closest("[data-star]"); if (!t) return;
1787
2344
  ev.preventDefault();
1788
- if (t.dataset.star === "go") { starSave({ done: now }); window.open(REPO_URL, "_blank"); }
2345
+ if (t.dataset.star === "go") { starSave({ done: now }); openExternal(REPO_URL); }
1789
2346
  else if (t.dataset.star === "never") starSave({ never: now });
1790
2347
  el.remove();
1791
2348
  });
@@ -1807,6 +2364,11 @@ function openMenu(kind, anchor, d) {
1807
2364
  const spec = menuSpec(kind, d);
1808
2365
  if (!spec) return;
1809
2366
  if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
2367
+ // Once the menu is up the pointer is over *it*, not the row, so a :hover-only kebab vanishes
2368
+ // under its own menu. Mark the row (and the kebab) until menus:openchange reports the close.
2369
+ if (anchor?.closest) {
2370
+ for (const el of [anchor.closest(".proj"), anchor.closest("tr"), anchor.closest(".more")]) el?.classList.add("menu-open");
2371
+ }
1810
2372
  window.menus.open(anchor, spec);
1811
2373
  }
1812
2374
  document.addEventListener("keydown", (e) => {
@@ -1832,12 +2394,12 @@ document.addEventListener("contextmenu", (ev) => {
1832
2394
  // unreachable (closest() returns null and the click dies silently) — that is how Replay,
1833
2395
  // Resume-where-it-died and the dry-run Re-run button all shipped dead.
1834
2396
  document.addEventListener("click", async (ev) => {
1835
- const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#replay,#resumeDead,#drRun,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
2397
+ const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-graphtab],[data-group],#abNew,[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#replay,#resumeDead,#drRun,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
1836
2398
  if (!t) return;
1837
2399
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1838
2400
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
1839
- if (t.id === "feedback") { ev.preventDefault(); return window.open(feedbackUrl(), "_blank"); }
1840
- if (t.dataset.view) { ev.preventDefault(); state.view = t.dataset.view; localStorage.setItem("swarm.view", state.view); state.session = null; state.dirty = true; return refresh(); }
2401
+ if (t.id === "feedback") { ev.preventDefault(); return openExternal(feedbackUrl()); }
2402
+ if (t.dataset.view) { ev.preventDefault(); return showView(t.dataset.view); }
1841
2403
  if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
1842
2404
  if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
1843
2405
  if (t.dataset.emoji !== undefined) { $("#psIcon").value = t.dataset.emoji; $("#psImage").value = ""; setIconPreview(t.dataset.emoji); for (const e of $$(".emoji")) e.classList.toggle("on", e.dataset.emoji === t.dataset.emoji); return; }
@@ -1918,6 +2480,31 @@ document.addEventListener("click", async (ev) => {
1918
2480
  if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1919
2481
  if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
1920
2482
  if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
2483
+ if (t.id === "abNew") {
2484
+ ev.preventDefault();
2485
+ if (!state.sel) return;
2486
+ const task = prompt("Task id to trial (each arm claims task#model, so each gets its own worktree):");
2487
+ if (!task) return;
2488
+ const models = prompt("Models to compare, comma separated:", "opus-5, sonnet-5");
2489
+ const arms = (models ?? "").split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ model: m, label: m }));
2490
+ if (arms.length < 2) { alert("A trial needs at least two models."); return; }
2491
+ const r = await fetch("/v1/ab", {
2492
+ method: "POST",
2493
+ headers: { "content-type": "application/json" },
2494
+ body: JSON.stringify({ projectId: state.sel, task: task.trim(), arms }),
2495
+ }).then((x) => x.json()).catch(() => null);
2496
+ if (!r) return alert("Could not reach the daemon.");
2497
+ if (r?.failed?.length) alert(`Started ${r.started.length}. Could not start: ${r.failed.map((f) => `${f.arm} — ${f.reason}`).join("; ")}`);
2498
+ return refresh();
2499
+ }
2500
+ if (t.dataset.group) {
2501
+ ev.preventDefault();
2502
+ const open = new Set(state.lineageOpen ?? []);
2503
+ open.has(t.dataset.group) ? open.delete(t.dataset.group) : open.add(t.dataset.group);
2504
+ state.lineageOpen = [...open];
2505
+ return refresh();
2506
+ }
2507
+ if (t.dataset.graphtab) { state.graphTab = t.dataset.graphtab; localStorage.setItem("swarm.graphTab", state.graphTab); return refresh(); }
1921
2508
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
1922
2509
  if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
1923
2510
  if (t.dataset.ackall) {
@@ -1971,7 +2558,7 @@ sbApply();
1971
2558
  // ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
1972
2559
  const pal = { items: [], view: [], q: "", i: 0 };
1973
2560
  function palBuild() {
1974
- const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => { state.view = v.id; localStorage.setItem("swarm.view", v.id); state.session = null; state.dirty = true; refresh(); } }));
2561
+ const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => showView(v.id) }));
1975
2562
  for (const p of state.projects) items.push({ icon: "folder-simple", label: p.name, grp: "project", run: () => { state.sel = p.id; localStorage.setItem("swarm.sel", p.id); state.session = null; state.dirty = true; refresh(); } });
1976
2563
  const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
1977
2564
  for (const s of state.sessions) items.push({ icon: "terminal-window", label: s.title || s.id.slice(0, 8), sub: pname(s.projectId), live: isLive(s), grp: "session", run: () => openSession(s.id) });
@@ -2067,21 +2654,49 @@ const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊"
2067
2654
  // (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
2068
2655
  const EMOJI_BLOCKS = [["Smileys & people", 0x1f600, 0x1f64f], ["Gestures & body", 0x1f440, 0x1f4ff], ["Animals & nature", 0x1f400, 0x1f43f], ["Food", 0x1f32d, 0x1f37f], ["Activity & travel", 0x1f680, 0x1f6ff], ["Objects", 0x1f4a0, 0x1f4ff], ["Symbols", 0x1f300, 0x1f32c], ["More", 0x1f900, 0x1f9ff], ["Extended", 0x1fa70, 0x1faff], ["Misc", 0x2600, 0x26ff], ["Dingbats", 0x2700, 0x27bf]];
2069
2656
  let emojiGrid = null;
2070
- function buildEmojiGrid() {
2071
- if (emojiGrid) return emojiGrid;
2072
- // A code point counts as an emoji the platform can draw if it paints colored pixels.
2073
- const S = 20, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
2657
+ // Which code points the platform font actually draws in colour is a per-machine answer, so it is
2658
+ // probed once and remembered. Two things made that probe cost ~150ms of blocked main thread:
2659
+ // it called getImageData once per code point (1536 GPU->CPU readbacks), and the blocks overlap,
2660
+ // so 96 code points were probed and rendered — twice. Now it is one readback per block over a
2661
+ // grid of glyphs, deduped, and the answer is cached across reloads.
2662
+ const EMOJI_CACHE_KEY = "swarm.emoji.v1";
2663
+ function detectEmoji(a, b) {
2664
+ const S = 20, COLS = 32, n = b - a + 1, rows = Math.ceil(n / COLS);
2665
+ const cv = document.createElement("canvas");
2666
+ cv.width = COLS * S; cv.height = rows * S;
2074
2667
  const c = cv.getContext("2d", { willReadFrequently: true });
2075
2668
  c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
2076
- const colored = (ch) => {
2077
- c.clearRect(0, 0, S, S); c.fillText(ch, 0, 0);
2078
- const d = c.getImageData(0, 0, S, S).data;
2079
- for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 40 && (Math.abs(d[i] - d[i + 1]) > 24 || Math.abs(d[i + 1] - d[i + 2]) > 24)) return true;
2080
- return false;
2081
- };
2082
- emojiGrid = EMOJI_BLOCKS.map(([name, a, b]) => {
2083
- const list = [];
2084
- for (let cp = a; cp <= b; cp++) { const ch = String.fromCodePoint(cp); if (colored(ch)) list.push(ch); }
2669
+ for (let i = 0; i < n; i++) c.fillText(String.fromCodePoint(a + i), (i % COLS) * S, ((i / COLS) | 0) * S);
2670
+ const d = c.getImageData(0, 0, cv.width, cv.height).data, W = cv.width, out = [];
2671
+ // A code point counts as an emoji the platform can draw if its cell paints coloured pixels.
2672
+ for (let i = 0; i < n; i++) {
2673
+ const x0 = (i % COLS) * S, y0 = ((i / COLS) | 0) * S;
2674
+ let ok = false;
2675
+ for (let y = y0; y < y0 + S && !ok; y++)
2676
+ for (let x = x0; x < x0 + S; x++) {
2677
+ const p = (y * W + x) * 4;
2678
+ if (d[p + 3] > 40 && (Math.abs(d[p] - d[p + 1]) > 24 || Math.abs(d[p + 1] - d[p + 2]) > 24)) { ok = true; break; }
2679
+ }
2680
+ if (ok) out.push(String.fromCodePoint(a + i));
2681
+ }
2682
+ return out;
2683
+ }
2684
+ function buildEmojiGrid() {
2685
+ if (emojiGrid) return emojiGrid;
2686
+ // The cache is keyed by the UA (a font change is what would invalidate it) plus the block list.
2687
+ const sig = `${navigator.userAgent}|${EMOJI_BLOCKS.map((x) => x.join(":")).join(",")}`;
2688
+ let blocks = null;
2689
+ try {
2690
+ const hit = JSON.parse(localStorage.getItem(EMOJI_CACHE_KEY) ?? "null");
2691
+ if (hit?.sig === sig) blocks = hit.blocks;
2692
+ } catch { /* corrupt or unavailable cache: probe again */ }
2693
+ if (!blocks) {
2694
+ const seen = new Set();
2695
+ blocks = EMOJI_BLOCKS.map(([, a, b]) => detectEmoji(a, b).filter((e) => !seen.has(e) && seen.add(e)));
2696
+ try { localStorage.setItem(EMOJI_CACHE_KEY, JSON.stringify({ sig, blocks })); } catch { /* private mode / quota */ }
2697
+ }
2698
+ emojiGrid = EMOJI_BLOCKS.map(([name], i) => {
2699
+ const list = blocks[i] ?? [];
2085
2700
  return list.length ? `<div class="emoji-sec">${esc(name)}</div><div class="emoji-row">${list.map((e) => `<span class="emoji" data-emoji="${e}">${e}</span>`).join("")}</div>` : "";
2086
2701
  }).join("");
2087
2702
  return emojiGrid;
@@ -2235,7 +2850,7 @@ async function submitPr() {
2235
2850
  closePicker();
2236
2851
  state.prs = [];
2237
2852
  await refresh();
2238
- if (r.url) window.open(r.url, "_blank");
2853
+ if (r.url) openExternal(r.url);
2239
2854
  }
2240
2855
 
2241
2856
  async function openPicker(focusPath = false) {
@@ -2313,7 +2928,7 @@ function connect() {
2313
2928
  if (fresh) notifyForEvent(ev);
2314
2929
  pollSoon();
2315
2930
  };
2316
- for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
2931
+ for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved", "session.stuck"]) es.addEventListener(t, onAny);
2317
2932
  }
2318
2933
  refresh().then(() => {
2319
2934
  const sid = new URLSearchParams(location.search).get("session");