@ra3orblade/swarm 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,71 @@ 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 off = state.provOffset ?? 0;
319
+ const pv = await fetch(`/v1/provenance${q ? `${q}&` : "?"}limit=50&offset=${off}`).then((r) => r.json()).catch(() => state.provenance);
320
+ provChanged = JSON.stringify(pv) !== JSON.stringify(state.provenance);
321
+ state.provenance = pv;
322
+ }
323
+ let mcpChanged = false;
324
+ if (state.view === "mcp" && !state.session) {
325
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
326
+ const m = await fetch(`/v1/mcp/health${q}`).then((r) => r.json()).catch(() => state.mcpHealth);
327
+ mcpChanged = JSON.stringify(m) !== JSON.stringify(state.mcpHealth);
328
+ state.mcpHealth = m;
329
+ }
330
+ let ghChanged = false;
331
+ if (state.view === "gates" && !state.session) {
332
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
333
+ const gh = await fetch(`/v1/gates/health${q}`).then((r) => r.json()).catch(() => state.gateHealth);
334
+ ghChanged = JSON.stringify(gh) !== JSON.stringify(state.gateHealth);
335
+ state.gateHealth = gh;
336
+ }
213
337
  let outChanged = false;
214
338
  if (state.view === "outcomes" && !state.session) {
215
339
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
@@ -217,7 +341,7 @@ async function refresh() {
217
341
  outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
218
342
  state.outcomes = o;
219
343
  }
220
- if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || outChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
344
+ 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
345
  }
222
346
  // M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
223
347
  // deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
@@ -227,10 +351,17 @@ const VIEW_DEFS = [
227
351
  { id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
228
352
  { id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
229
353
  { id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
230
- { id: "outcomes", label: "Outcomes", icon: "check", group: "Insight", render: () => renderOutcomes() },
354
+ { id: "trials", label: "Trials", icon: "robot", group: "Work", render: () => renderTrials(), badge: () => (state.trials ?? []).filter((t) => t.verdict === "undecided").length },
355
+ { id: "hygiene", label: "Hygiene", icon: "trash", group: "Work", render: () => renderHygiene(), badge: () => state.hygiene?.totals?.issues ?? 0 },
356
+ // not "check": inside a menu a tick reads as "this item is selected" rather than as an icon
357
+ { id: "outcomes", label: "Outcomes", icon: "git-branch", group: "Insight", render: () => renderOutcomes() },
358
+ { id: "gates", label: "Gates", icon: "shield", group: "Insight", render: () => renderGateHealth(), badge: () => state.gateHealth?.totals?.flakyGates ?? 0 },
359
+ { id: "mcp", label: "MCP", icon: "plugs-connected", group: "Insight", render: () => renderMcpHealth() },
360
+ { id: "context", label: "Context", icon: "brain", group: "Insight", render: () => renderContext() },
231
361
  { id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
232
362
  { id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
233
363
  { id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
364
+ { id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard", render: () => renderProvenance(), badge: () => state.provenance?.totals?.untracked ?? 0 },
234
365
  { id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
235
366
  ];
236
367
  const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
@@ -240,6 +371,8 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
240
371
  {
241
372
  const v = localStorage.getItem("swarm.view");
242
373
  if (VIEWS.includes(v)) state.view = v;
374
+ const gt = localStorage.getItem("swarm.graphTab");
375
+ if (gt === "lineage" || gt === "collisions") state.graphTab = gt;
243
376
  const sel = localStorage.getItem("swarm.sel");
244
377
  if (sel) state.sel = sel;
245
378
  // Deep links win over persisted state: ?view=board&project=<id>&session=<id>
@@ -252,7 +385,7 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
252
385
  function render() {
253
386
  // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
254
387
  // hold the frame while one is open; the next poll or interaction paints it.
255
- if (window.menus?.isOpen()) { state.dirty = true; return; }
388
+ if (window.menus?.isOpen()) { state.dirty = true; return; } // the menus:openchange listener paints on close
256
389
  // Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
257
390
  const af = document.activeElement;
258
391
  const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
@@ -274,21 +407,57 @@ function renderHeader() {
274
407
  if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
275
408
  renderNav();
276
409
  }
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() {
410
+ // View nav in the header: one button per group (Observe / Work / Insight / Guard); clicking one
411
+ // opens a fancy-menus dropdown of that group's views. Rebuilt only when the html changes (active
412
+ // view, badges) so the 5s poll doesn't churn the DOM — and never while its menu is open.
413
+ function showView(id) {
414
+ state.view = id;
415
+ localStorage.setItem("swarm.view", id);
416
+ state.session = null;
417
+ state.dirty = true;
418
+ refresh();
419
+ }
420
+ function viewGroups() {
280
421
  const groups = [];
281
422
  for (const v of VIEW_DEFS) {
282
423
  const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
283
424
  g.views.push(v);
284
425
  }
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("");
426
+ return groups;
427
+ }
428
+ function renderNav() {
429
+ const html = viewGroups()
430
+ .map((g) => {
431
+ const n = g.views.reduce((a, v) => a + (v.badge?.() ?? 0), 0);
432
+ const on = !state.session && g.views.some((v) => v.id === state.view);
433
+ // The group name alone never says which of its views you are on, so ten destinations hid
434
+ // behind four words. The active group carries the view's own label.
435
+ const cur = on ? g.views.find((v) => v.id === state.view) : null;
436
+ 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>`;
437
+ })
438
+ .join("");
290
439
  if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
291
440
  }
441
+ // Delegated: the group buttons are re-rendered, so the listener lives on the container.
442
+ $("#viewnav").addEventListener("click", (ev) => {
443
+ const btn = ev.target.closest("[data-grp]");
444
+ if (!btn) return;
445
+ const g = viewGroups().find((x) => x.name === btn.dataset.grp);
446
+ if (!g || !window.menus) return;
447
+ btn.classList.add("open"); // cleared by menus:openchange when the menu closes
448
+ window.menus.open(btn, {
449
+ items: g.views.map((v) => {
450
+ const n = v.badge?.() ?? 0;
451
+ return {
452
+ label: v.label,
453
+ icon: v.icon,
454
+ caption: n ? String(n) : undefined,
455
+ pressed: !state.session && state.view === v.id,
456
+ run: () => showView(v.id),
457
+ };
458
+ }),
459
+ });
460
+ });
292
461
 
293
462
  const isLive = (s) => s.state === "active" || s.state === "waiting";
294
463
  // One pass over sessions → live count per project (+ "" for all), instead of a filter per sidebar row.
@@ -381,10 +550,19 @@ function onboarding() {
381
550
 
382
551
  // ---------- fleet
383
552
  // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
553
+ // M9.4: this session is blocked on a person right now — the badge says for how long, and on what.
554
+ const waitFor = (sid) => (state.waiting?.sessions ?? []).find((w) => w.sessionId === sid);
555
+ const WAIT_WHAT = { permission: "a permission prompt", question: "a question it asked", notification: "a notification" };
556
+ function waitBadge(sid) {
557
+ const w = waitFor(sid);
558
+ if (!w?.openSince) return "";
559
+ const what = WAIT_WHAT[w.openKind] ?? "you";
560
+ return ` <span class="badge warn" title="Blocked on ${esc(what)} since ${esc(w.openSince)}${w.openLabel ? ` — ${esc(w.openLabel)}` : ""}">Waiting ${ago(w.openSince)}</span>`;
561
+ }
384
562
  const FLEET_COLS = [
385
563
  { key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
386
564
  { 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>` : ""}` },
565
+ { 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
566
  { key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
389
567
  { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
390
568
  const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
@@ -477,7 +655,11 @@ function renderBoardKpis() {
477
655
  const orphaned = claims.filter((c) => c.state === "orphaned").length;
478
656
  const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
479
657
  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;
658
+ // The snapshot carries only the 20 most recent open incidents, so counting that window caps the
659
+ // KPI at 20 while the Guard badge shows the real number. Both now read the same true count.
660
+ const inc = state.sel
661
+ ? (state.openIncidentsByProject?.[state.sel] ?? (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length)
662
+ : (state.openIncidents ?? (state.incidents ?? []).filter((i) => !i.acked).length);
481
663
  const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
482
664
  const ready = tasks ? tasks.filter((t) => t.ready).length : null;
483
665
  const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
@@ -1067,6 +1249,34 @@ document.addEventListener("change", async (ev) => {
1067
1249
  });
1068
1250
  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
1251
  document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
1252
+ // M9.4: how much of the fleet's time is spent waiting on a person, and on what. Blocked time is
1253
+ // not idle time — it is the agent standing still with the work half-done, which is why it gets a
1254
+ // number rather than a footnote.
1255
+ function waitingSection() {
1256
+ const w = state.waiting;
1257
+ if (!w?.totals?.episodes) return "";
1258
+ const t = w.totals;
1259
+ const kindRow = (k, label) => {
1260
+ const x = t.byKind[k];
1261
+ return x?.episodes ? `<tr><td>${label}</td><td class="num">${x.episodes}</td><td class="num">${dur(x.blockedMs)}</td></tr>` : "";
1262
+ };
1263
+ const top = w.sessions.slice(0, 8).map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
1264
+ <td>${esc(s.title ?? s.sessionId.slice(0, 8))}${s.openSince ? ` <span class="badge warn">waiting ${ago(s.openSince)}</span>` : ""}</td>
1265
+ <td class="num">${s.episodes}</td><td class="num">${dur(s.blockedMs)}</td><td class="num">${dur(s.longestMs)}</td></tr>`).join("");
1266
+ 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>
1267
+ <div class="cols">
1268
+ <div class="chart-card" style="margin:0"><h3>By what blocked them</h3>
1269
+ <table class="mini"><thead><tr><th>kind</th><th class="num">times</th><th class="num">blocked</th></tr></thead>
1270
+ <tbody>${kindRow("permission", "Permission prompt")}${kindRow("question", "Question it asked")}${kindRow("notification", "Notification")}
1271
+ <tr><td><b>Total</b></td><td class="num"><b>${t.episodes}</b></td><td class="num"><b>${dur(t.blockedMs)}</b></td></tr>
1272
+ <tr><td class="dim">median wait</td><td class="num"></td><td class="num dim">${dur(t.medianMs)}</td></tr>
1273
+ <tr><td class="dim">longest wait</td><td class="num"></td><td class="num dim">${dur(t.longestMs)}</td></tr>
1274
+ </tbody></table></div>
1275
+ <div class="chart-card" style="margin:0"><h3>Sessions that waited most</h3>
1276
+ <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>
1277
+ <tbody>${top}</tbody></table></div>
1278
+ </div>`;
1279
+ }
1070
1280
  function renderStats() {
1071
1281
  const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
1072
1282
  const scope = state.sel ? esc(projName(state.sel)) : "all projects";
@@ -1154,6 +1364,7 @@ function renderStats() {
1154
1364
  <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
1365
  <div><h2 style="margin-top:0">Records</h2><div class="records">${records}</div></div>
1156
1366
  </div>
1367
+ ${waitingSection()}
1157
1368
  <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
1369
  }
1159
1370
 
@@ -1214,18 +1425,297 @@ function renderOutcomes() {
1214
1425
  $("#main").innerHTML =
1215
1426
  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
1427
  `<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 })}` : "") +
1428
+ dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel, rerender: touch }) +
1429
+ (o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent, rerender: touch })}` : "") +
1219
1430
  `<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
1220
- dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100) });
1431
+ dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100), rerender: touch });
1432
+ }
1433
+
1434
+ // M9.5: where the context window goes. Character counts are exact (every tool response is stored);
1435
+ // the token figures are a flat 4:1 estimate and say so. Re-reading a file is the waste metric —
1436
+ // the first read is work, every copy after it is the price of having forgotten.
1437
+ // `toolName` puts the server first, so four MCP tools all truncated to "claude-in-c…" and the
1438
+ // half that tells them apart was the half cut off. Lead with the tool, keep a short server hint.
1439
+ function ctxToolLabel(tool) {
1440
+ const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(tool);
1441
+ if (!m) return tool;
1442
+ const srv = m[1].replace(/[-_]/g, " ").split(" ").map((w) => w[0]).join("").toLowerCase();
1443
+ return `${m[2]} · ${srv}`;
1444
+ }
1445
+ function renderContext() {
1446
+ const c = state.context;
1447
+ const head = (sub) => `<h2>Context <span>${sub}</span></h2>`;
1448
+ if (!c) { $("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1449
+ if (!c.totals.sessions) {
1450
+ $("#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>`;
1451
+ return;
1452
+ }
1453
+ const t = c.totals;
1454
+ const chars = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
1455
+ 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>`;
1456
+ const kpis = `<div class="kpis">${
1457
+ kpi("Returned by tools", `${chars(t.toolChars)}`, `characters · ≈${chars(t.toolTokens)} tokens`)
1458
+ }${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" : "")
1459
+ }${kpi("Cache hit", `${Math.round(t.cacheHit * 100)}%`, "of the window came back free")
1460
+ }${kpi("Sessions", t.sessions, "with tool activity")}</div>`;
1461
+
1462
+ const worst = c.sessions.filter((s) => s.wastedChars > 0).slice(0, 10);
1463
+ const rows = worst.map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
1464
+ <td>${esc(s.title ?? s.sessionId.slice(0, 8))}</td>
1465
+ <td class="num">${chars(s.toolChars)}</td>
1466
+ <td class="num"><b>${chars(s.wastedChars)}</b></td>
1467
+ <td class="num">${Math.round(s.wasteShare * 100)}%</td>
1468
+ <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>
1469
+ </tr>`).join("");
1470
+
1471
+ $("#main").innerHTML = head(`last 7 days · ${chars(t.toolChars)} characters returned by tools`) + kpis +
1472
+ `<div class="cols">
1473
+ <div class="chart-card" style="margin:0"><h3>What fills the window <span>by tool · characters returned</span></h3>
1474
+ ${viz.hbars(c.byTool.map((x) => [ctxToolLabel(x.tool), x.chars, `${chars(x.chars)} · ${x.calls}`]))}</div>
1475
+ <div class="chart-card" style="margin:0"><h3>Re-read waste <span>the same file, read again</span></h3>
1476
+ ${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>
1477
+ </div>
1478
+ <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>`;
1479
+ }
1480
+
1481
+ // M9.18: the same task run by N models side by side. An arm is its own task id, so each has its
1482
+ // own claim and worktree and the ledger's one-holder rule is untouched — see core/abtrial.ts.
1483
+ const VERDICT = { winner: ["ok", "Decided"], undecided: ["acc", "Running"], "all-failed": ["bad", "No winner"] };
1484
+ function renderTrials() {
1485
+ const trials = state.trials;
1486
+ 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>`;
1487
+ if (!trials) { $("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1488
+ if (!trials.length) {
1489
+ $("#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>`;
1490
+ return;
1491
+ }
1492
+ const secs = (v) => (v === null ? '<span class="dim">—</span>' : dur(v));
1493
+ const cols = [
1494
+ { 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>' : ""}` },
1495
+ { 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>`) },
1496
+ { key: "cost", label: "cost", width: 74, num: true, get: (a) => a.costUsd, cell: (a) => usd(a.costUsd) },
1497
+ { key: "wall", label: "wall", width: 74, num: true, get: (a) => a.wallMs ?? -1, cell: (a) => secs(a.wallMs) },
1498
+ { key: "turns", label: "turns", width: 64, num: true, get: (a) => a.turns, cell: (a) => a.turns },
1499
+ { 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>' : ""}` },
1500
+ { 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>`) },
1501
+ { 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>') },
1502
+ ];
1503
+ const block = (t) => {
1504
+ const v = VERDICT[t.verdict] ?? VERDICT.undecided;
1505
+ 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` : ""}` : ""}`;
1506
+ return `<h2 class="mt-sec">${esc(t.task)} <span class="badge ${v[0]}">${v[1]}</span> <span>${sub}</span></h2>` +
1507
+ dataTable({ id: `ab-${t.task}`, columns: cols, rows: t.arms, rerender: touch });
1508
+ };
1509
+ const running = trials.filter((t) => t.verdict === "undecided").length;
1510
+ $("#main").innerHTML = head(`${trials.length} trial${trials.length === 1 ? "" : "s"}${running ? ` · ${running} still running` : ""}`) +
1511
+ trials.map(block).join("") +
1512
+ `<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>`;
1513
+ }
1514
+
1515
+ // M9.14: issue → task → claim → session → branch → PR → merged, as one row per piece of work.
1516
+ // The six link dots are the graph: a filled run that stops is exactly where the trail goes cold.
1517
+ const LINK_ORDER = ["task", "claim", "session", "branch", "pr", "merged"];
1518
+ const BREAK_LABEL = {
1519
+ "no-task": ["bad", "No task", "landed with no task behind it"],
1520
+ unclaimed: ["warn", "Unclaimed", "no claim was ever taken for this task"],
1521
+ "no-session": ["warn", "No session", "claimed, but no session did the work"],
1522
+ "no-branch": ["warn", "No branch", "worked on, but never reached a branch"],
1523
+ "no-pr": ["warn", "No PR", "a branch exists but no pull request"],
1524
+ "open-pr": ["acc", "Open PR", "the pull request has not merged yet"],
1525
+ };
1526
+ // Lead time spans minutes to months, and "889.4h" both overflows a numeric column and means
1527
+ // nothing to a reader. Never wider than 5 characters.
1528
+ function leadTime(h) {
1529
+ if (h < 1) return `${Math.round(h * 60)}m`;
1530
+ if (h < 48) return `${h.toFixed(h < 10 ? 1 : 0)}h`;
1531
+ const d = h / 24;
1532
+ return d < 100 ? `${d.toFixed(d < 10 ? 1 : 0)}d` : `${Math.round(d / 7)}w`;
1533
+ }
1534
+ function renderProvenance() {
1535
+ const p = state.provenance;
1536
+ const head = (sub) => `<h2>Provenance <span>${sub}</span></h2>`;
1537
+ if (!p) { $("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1538
+ if (!p.chains.length) {
1539
+ $("#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>`;
1540
+ return;
1541
+ }
1542
+ const t = p.totals;
1543
+ 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>`;
1544
+ const kpis = `<div class="kpis">${
1545
+ kpi("Traced", `${t.complete}/${t.tasks}`, "reach a merged PR", t.complete ? "" : "warm")
1546
+ }${kpi("Untracked", t.untracked, t.untracked ? "landed with no task" : "all work has a task", t.untracked ? "hot" : "")
1547
+ }${kpi("Unclaimed", t.unclaimed, "tasks nobody claimed", t.unclaimed ? "warm" : "")
1548
+ }${kpi("Traced spend", usd(t.costUsd), "across every chain")}</div>`;
1549
+
1550
+ const track = (c) => `<span class="track" title="${LINK_ORDER.map((k) => `${k}: ${c.links[k] ? "yes" : "no"}`).join(" · ")}">${
1551
+ LINK_ORDER.map((k) => `<i class="${c.links[k] ? "on" : ""}"></i>`).join("")}</span>`;
1552
+ const cols = [
1553
+ { 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>'}` },
1554
+ { key: "track", label: "chain", width: 92, sortable: false, filterable: false, get: (c) => c.depth, cell: track },
1555
+ { 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>'; } },
1556
+ { 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>` },
1557
+ { 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>') },
1558
+ { 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>') },
1559
+ { 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>') },
1560
+ { key: "cost", label: "cost", width: 74, num: true, get: (c) => c.costUsd, cell: (c) => usd(c.costUsd) },
1561
+ { 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)) },
1562
+ ];
1563
+ const pg = p.page ?? { limit: p.chains.length, offset: 0, total: p.chains.length };
1564
+ const from = pg.total ? pg.offset + 1 : 0;
1565
+ const to = Math.min(pg.offset + pg.limit, pg.total);
1566
+ const pager = pg.total > pg.limit
1567
+ ? `<div class="chips" style="margin-top:10px">
1568
+ <span class="chip ${pg.offset ? "" : "off"}" data-provpage="${Math.max(0, pg.offset - pg.limit)}">${ic("arrow-left", 12)} Newer</span>
1569
+ <span class="dim" style="align-self:center;font-size:var(--fs-sm)">${from}–${to} of ${pg.total}</span>
1570
+ <span class="chip ${to >= pg.total ? "off" : ""}" data-provpage="${pg.offset + pg.limit}">Older ${ic("arrow-right", 12)}</span>
1571
+ </div>`
1572
+ : "";
1573
+ // A cold start has no forge data yet, so PR columns would read as "no PR" for everything.
1574
+ const catching = p.stale
1575
+ ? `<p class="dim" style="margin-top:8px;font-size:var(--fs-sm)">${ic("arrows-clockwise", 12)} Pull request state is still loading from the forge — it fills in on the next refresh.</p>`
1576
+ : "";
1577
+ $("#main").innerHTML = head(`${pg.total} chain${pg.total === 1 ? "" : "s"} · ${t.untracked ? `<b class="navcount">${t.untracked} untracked</b>` : "every branch has a task"}`) + kpis +
1578
+ dataTable({ id: "provenance", columns: cols, rows: p.chains, rerender: touch }) + pager + catching +
1579
+ `<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>`;
1580
+ }
1581
+
1582
+ // M9.6: which MCP servers the fleet waits on. Latency is hook-to-hook — the wall-clock between
1583
+ // PreToolUse and PostToolUse — so it is what the agent actually waited for, including any time a
1584
+ // call spent behind a permission prompt. That is why the view leads with p50/p95, not max.
1585
+ function renderMcpHealth() {
1586
+ const h = state.mcpHealth;
1587
+ const head = (sub) => `<h2>MCP <span>${sub}</span></h2>`;
1588
+ if (!h) { $("#main").innerHTML = head("server health") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1589
+ if (!h.servers.length) {
1590
+ $("#main").innerHTML = head("server health") + `<div class="empty">${PX.idle()}No tool calls in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
1591
+ return;
1592
+ }
1593
+ const t = h.totals;
1594
+ const ms = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : v < 60_000 ? `${(v / 1000).toFixed(1)}s` : dur(v));
1595
+ const cols = [
1596
+ { 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>'}` },
1597
+ { key: "calls", label: "calls", width: 74, num: true, get: (s) => s.calls, cell: (s) => s.calls.toLocaleString() },
1598
+ { key: "sessions", label: "sessions", width: 78, num: true, get: (s) => s.sessions, cell: (s) => s.sessions },
1599
+ { key: "p50", label: "p50", width: 68, num: true, get: (s) => s.p50Ms ?? -1, cell: (s) => ms(s.p50Ms) },
1600
+ { key: "p95", label: "p95", width: 68, num: true, get: (s) => s.p95Ms ?? -1, cell: (s) => ms(s.p95Ms) },
1601
+ { 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>` },
1602
+ { key: "wait", label: "waited", width: 82, num: true, get: (s) => s.totalMs, cell: (s) => dur(s.totalMs) },
1603
+ { 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>') },
1604
+ { 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>') },
1605
+ { 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(" ") },
1606
+ ];
1607
+ const share = t.totalMs ? Math.round((t.mcpMs / t.totalMs) * 100) : 0;
1608
+ 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)`;
1609
+ $("#main").innerHTML = head(sub) +
1610
+ dataTable({ id: "mcp-health", columns: cols, rows: h.servers, rerender: touch }) +
1611
+ `<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>`;
1612
+ }
1613
+
1614
+ // M9.7: gate flakiness and cost. A gate that flips on the *same task* told you two different
1615
+ // things about identical work — that is the number worth ranking on, not a raw fail count.
1616
+ function renderGateHealth() {
1617
+ const h = state.gateHealth;
1618
+ const head = (sub) => `<h2>Gates <span>${sub}</span></h2>`;
1619
+ if (!h) { $("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1620
+ if (!h.gates.length) {
1621
+ $("#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>`;
1622
+ return;
1623
+ }
1624
+ const t = h.totals;
1625
+ const secs = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : `${(v / 1000).toFixed(1)}s`);
1626
+ // Oldest-first strip, matching Recent gates on the Board.
1627
+ const strip = (g) => {
1628
+ const rs = [...g.history].reverse();
1629
+ 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>`;
1630
+ };
1631
+ const cols = [
1632
+ { 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>' : ""}` },
1633
+ { key: "history", label: "history", width: 150, sortable: false, filterable: false, get: () => null, cell: strip },
1634
+ { key: "runs", label: "runs", width: 60, num: true, get: (g) => g.runs, cell: (g) => g.runs },
1635
+ { key: "pass", label: "pass rate", width: 84, num: true, get: (g) => g.passRate, cell: (g) => `${Math.round(g.passRate * 100)}%` },
1636
+ { 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>') },
1637
+ { key: "p50", label: "p50", width: 66, num: true, get: (g) => g.p50Ms ?? -1, cell: (g) => secs(g.p50Ms) },
1638
+ { key: "p95", label: "p95", width: 66, num: true, get: (g) => g.p95Ms ?? -1, cell: (g) => secs(g.p95Ms) },
1639
+ { key: "max", label: "slowest", width: 74, num: true, get: (g) => g.maxMs ?? -1, cell: (g) => secs(g.maxMs) },
1640
+ { key: "total", label: "total", width: 74, num: true, get: (g) => g.totalMs, cell: (g) => (g.timedRuns ? dur(g.totalMs) : '<span class="dim">—</span>') },
1641
+ { 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>') },
1642
+ ];
1643
+ 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` : ""}`;
1644
+ $("#main").innerHTML = head(sub) +
1645
+ dataTable({ id: "gate-health", columns: cols, rows: h.gates, rerender: touch }) +
1646
+ `<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>`;
1647
+ }
1648
+
1649
+ // M9.8: machine hygiene — what the fleet left behind. Observation plus the two actions that
1650
+ // already exist (stop a process, remove a worktree); nothing here reclaims anything on its own,
1651
+ // and a worktree with uncommitted or unpushed work is never offered as safe.
1652
+ const ISSUE_BADGE = {
1653
+ dead: ["bad", "Dead"], orphaned: ["bad", "Orphaned"], hungry: ["warn", "Hungry"],
1654
+ stale: ["warn", "Stale"], abandoned: ["warn", "Abandoned"], heavy: ["", "Heavy"],
1655
+ };
1656
+ 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`);
1657
+ const issueBadge = (i) => { const b = ISSUE_BADGE[i]; return b ? `<span class="badge ${b[0]}">${b[1]}</span>` : '<span class="dim">ok</span>'; };
1658
+ function renderHygiene() {
1659
+ const h = state.hygiene;
1660
+ const head = (sub) => `<h2>Hygiene <span>${sub}</span></h2>`;
1661
+ if (!h) { $("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1662
+ const t = h.totals;
1663
+ if (!h.processes.length && !h.worktrees.length) {
1664
+ $("#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>`;
1665
+ return;
1666
+ }
1667
+ 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>`;
1668
+ const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
1669
+ // Disk is sampled in the background, so "0 MB" before the first sweep would be a lie — say so.
1670
+ const sampled = h.worktrees.filter((w) => w.diskKb !== null).length;
1671
+ const diskPending = h.worktrees.length > 0 && sampled === 0;
1672
+ const totalDisk = diskPending ? "measuring…" : mb(t.diskKb);
1673
+ const kpis = `<div class="kpis">${
1674
+ kpi("Needs a look", t.issues, t.issues ? "processes + worktrees" : "all clean", t.issues ? "hot" : "")
1675
+ }${kpi("Processes", t.processes, t.orphanedProcesses || t.deadProcesses ? `${t.orphanedProcesses} orphaned · ${t.deadProcesses} dead` : "all healthy", t.orphanedProcesses || t.deadProcesses ? "hot" : "")
1676
+ }${kpi("Worktrees", t.worktrees, t.staleWorktrees ? `${t.staleWorktrees} stale` : "none stale", t.staleWorktrees ? "warm" : "")
1677
+ }${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>`;
1678
+
1679
+ const pcols = [
1680
+ { key: "issue", label: "state", width: 96, get: (p) => p.issue ?? "", cell: (p) => issueBadge(p.issue) },
1681
+ { key: "name", label: "name", width: 130, get: (p) => p.name, cell: (p) => `<b>${esc(p.name)}</b>` },
1682
+ { key: "kind", label: "kind", width: 64, get: (p) => p.kind, cell: (p) => `<span class="br">${esc(p.kind)}</span>` },
1683
+ { key: "pid", label: "pid", width: 64, num: true, get: (p) => p.pid, cell: (p) => p.pid },
1684
+ { key: "port", label: "port", width: 60, num: true, get: (p) => p.port ?? 0, cell: (p) => p.port ?? '<span class="dim">—</span>' },
1685
+ { 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)}%`) },
1686
+ { key: "rss", label: "memory", width: 78, num: true, get: (p) => p.rssKb ?? -1, cell: (p) => mb(p.rssKb) },
1687
+ { 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>') },
1688
+ { 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>` : "") },
1689
+ ];
1690
+ const wcols = [
1691
+ { key: "issue", label: "state", width: 106, get: (w) => w.issue ?? "", cell: (w) => issueBadge(w.issue) },
1692
+ { 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>' : ""}` },
1693
+ { key: "disk", label: "disk", width: 78, num: true, get: (w) => w.diskKb ?? -1, cell: (w) => mb(w.diskKb) },
1694
+ { 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)) },
1695
+ { 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>') : ""}` },
1696
+ { 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>') },
1697
+ { 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>') },
1698
+ { 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>` : "") },
1699
+ ];
1700
+ const sub = t.issues ? `<b class="navcount">${t.issues} need${t.issues === 1 ? "s" : ""} a look</b>` : "nothing to clean up";
1701
+ $("#main").innerHTML = head(sub) + kpis +
1702
+ `<h2 class="mt-sec">Processes <span>${h.processes.length} tracked · started through swarm, never matched by command pattern</span></h2>` +
1703
+ (h.processes.length ? dataTable({ id: "hyg-procs", columns: pcols, rows: h.processes, rerender: touch }) : `<div class="empty">${PX.idle()}No tracked processes.</div>`) +
1704
+ `<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>` +
1705
+ (h.worktrees.length ? dataTable({ id: "hyg-trees", columns: wcols, rows: h.worktrees, rerender: touch }) : `<div class="empty">${PX.idle()}No worktrees.</div>`) +
1706
+ `<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
1707
  }
1222
1708
 
1223
1709
  // M9.12: live file-collision graph — which live sessions touch which files, contested files
1224
1710
  // highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
1225
1711
  function renderGraphs() {
1712
+ const tab = state.graphTab ?? "collisions";
1713
+ const chip = (k, label, n) => `<span class="chip ${tab === k ? "on" : ""}" data-graphtab="${k}">${label}${n ? ` <b>${n}</b>` : ""}</span>`;
1714
+ const tabs = `<div class="chips">${chip("collisions", "Collisions", state.collisions?.contested ?? 0)}${chip("lineage", "Lineage", state.lineage?.edges?.length ?? 0)}</div>`;
1715
+ const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>${tabs}`;
1716
+ if (tab === "lineage") return renderLineage(head);
1226
1717
  const g = state.collisions;
1227
1718
  const title = (s) => s.title ?? s.id.slice(0, 8);
1228
- const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>`;
1229
1719
  if (!g || !g.sessions.length) {
1230
1720
  $("#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
1721
  return;
@@ -1242,6 +1732,30 @@ function renderGraphs() {
1242
1732
  <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
1733
  }
1244
1734
 
1735
+ // M9.13: who started whom, who told whom, who picked up whose work. Every edge is a recorded
1736
+ // relationship — nothing is inferred from timing.
1737
+ const EDGE_LEGEND = [
1738
+ ["subagent", "spawned a subagent", "var(--acc)", ""],
1739
+ ["dispatch", "dispatched a run", "var(--c3,#5a9e6f)", ""],
1740
+ ["message", "sent a message", "var(--warn)", "3 3"],
1741
+ ["handoff", "handed the task on", "var(--dim)", "6 3"],
1742
+ ];
1743
+ function renderLineage(head) {
1744
+ const g = state.lineage;
1745
+ if (!g) { $("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
1746
+ if (!g.nodes.length) {
1747
+ $("#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>`;
1748
+ return;
1749
+ }
1750
+ const key = EDGE_LEGEND.filter(([k]) => g.byKind[k]).map(([k, label, color, dash]) =>
1751
+ `<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("");
1752
+ 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>` : ""}`;
1753
+ $("#main").innerHTML = head(sub) +
1754
+ `<div class="card" style="padding:14px;overflow:auto;max-height:72vh">${viz.dag(g)}</div>
1755
+ <div style="margin-top:10px;display:flex;gap:18px;align-items:center;flex-wrap:wrap">${key}
1756
+ <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>`;
1757
+ }
1758
+
1245
1759
  function renderTimeline() {
1246
1760
  loadTimelineDetail();
1247
1761
  const now = Date.now();
@@ -1291,7 +1805,23 @@ async function openSession(id) {
1291
1805
  // Rendered log rows, keyed per event seq / turn id (+ the mutable turn fields) so only new rows are formatted.
1292
1806
  const rowCache = new Map();
1293
1807
  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>`;
1808
+ // The kind column showed raw hook names "pretooluse", "subagentstop" which are long, repeat on
1809
+ // every row, and say nothing the row does not: a tool row already begins with the tool's name. Short
1810
+ // labels here buy the transcript back ~70px of width per row; the full name stays in the title.
1811
+ const EV_LABEL = {
1812
+ PreToolUse: "tool", PostToolUse: "result", UserPromptSubmit: "you", Stop: "stop",
1813
+ SubagentStart: "sub →", SubagentStop: "sub ←", Notification: "note",
1814
+ SessionStart: "start", SessionEnd: "end", PreCompact: "compact",
1815
+ assistant: "agent", subagent: "sub",
1816
+ // ledger events reach the transcript too, and their dotted type names are the longest of all
1817
+ "incident.opened": "rule", "question.asked": "asks", "question.answered": "answer",
1818
+ "message.sent": "msg", "gate.recorded": "gate", "session.stuck": "stuck",
1819
+ "permission.requested": "perm?", "permission.resolved": "perm",
1820
+ "claim.acquired": "claim", "claim.released": "release", "pr.opened": "pr",
1821
+ };
1822
+ // Anything unmapped keeps its last dotted segment rather than the whole `a.b` name.
1823
+ const evLabel = (k) => EV_LABEL[k] ?? String(k).split(".").at(-1) ?? String(k);
1824
+ 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
1825
  // Merge the two ts-sorted inputs (events by seq ≈ ts, turns by ts) in one pass → [{key, html}].
1296
1826
  function sessionStream() {
1297
1827
  const out = [];
@@ -1369,16 +1899,37 @@ function replayGo(delta) {
1369
1899
  }
1370
1900
 
1371
1901
  // 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.
1902
+ // M7.6: the session's message thread (sent + received) and a compose box. Messages are never an
1903
+ // interrupt: they ride along as context on the agent's next tool call, so the block says so.
1373
1904
  function messageThread(s) {
1374
1905
  const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
1906
+ const queued = ms.filter((m) => m.fromSession !== s.id && !m.deliveredAt).length;
1907
+ const ended = s.state === "ended";
1375
1908
  const row = (m) => {
1376
1909
  const out = m.fromSession === s.id;
1377
1910
  return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
1378
1911
  <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
1912
  };
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>`;
1913
+ const hint = ended
1914
+ ? `${ic("warning", 12)} Session ended there is nothing left to deliver to.`
1915
+ : queued
1916
+ ? `${ic("clock", 12)} <b>${queued} queued</b> · delivered the next time this agent calls a tool.`
1917
+ : `${ic("comment-text", 12)} Delivered as context on this agent's next tool call — never an interrupt.`;
1918
+ return `<h4>messages${ms.length ? ` <span class="badge">${ms.length}</span>` : ""}</h4>
1919
+ ${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : ""}
1920
+ <div class="msg-compose">
1921
+ <input id="msgText" placeholder="Message this agent…" aria-label="Message this agent" autocomplete="off"${ended ? " disabled" : ""}>
1922
+ <button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}" title="Send (Enter)"${ended ? " disabled" : ""}>${ic("arrow-right", 12)}Send</button>
1923
+ </div>
1924
+ <p class="msg-hint">${hint}</p>`;
1925
+ }
1926
+
1927
+ // The transcript file, as one copyable row: the directory truncates, the file name always shows.
1928
+ function transcriptRow(s) {
1929
+ if (!s.transcriptPath) return "";
1930
+ const p = short(s.transcriptPath);
1931
+ const cut = p.lastIndexOf("/");
1932
+ 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
1933
  }
1383
1934
 
1384
1935
  // M7.7: questions this session is waiting on a human for
@@ -1413,6 +1964,8 @@ async function sendStdin() {
1413
1964
  }
1414
1965
  document.addEventListener("click", (ev) => {
1415
1966
  if (ev.target.closest("#stdinSend")) return sendStdin();
1967
+ const cp = ev.target.closest("[data-copy]");
1968
+ if (cp) { ev.preventDefault(); copy(cp.dataset.copy); cp.classList.add("copied"); setTimeout(() => cp.classList.remove("copied"), 1000); return; }
1416
1969
  const qa = ev.target.closest("[data-qanswer]");
1417
1970
  if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
1418
1971
  const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
@@ -1435,9 +1988,9 @@ function renderSession() {
1435
1988
  const t = s.tokens;
1436
1989
  const ctx = t.input + t.cacheRead + t.cacheWrite;
1437
1990
  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" };
1991
+ 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
1992
  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>`;
1993
+ 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
1994
  const side = `<div class="stats">
1442
1995
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
1443
1996
  ${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 +2002,24 @@ function renderSession() {
1449
2002
  <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
2003
  ${messageThread(s)}
1451
2004
  ${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>` : ""}`;
2005
+ ${transcriptRow(s)}`;
1453
2006
  if (logEl && isAppend(rows)) {
1454
2007
  // Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
1455
2008
  // scroll position (and its DOM) untouched.
1456
2009
  $("#main > h2").outerHTML = head;
2010
+ // The message compose box lives inside .side, and this fast-path runs on every event while the
2011
+ // agent works — carry the draft (and the caret) across the swap instead of wiping what is
2012
+ // being typed.
2013
+ const msg = $("#msgText");
2014
+ const draft = msg?.value ? { v: msg.value, focused: document.activeElement === msg, pos: msg.selectionStart } : null;
1457
2015
  $("#main .side").innerHTML = side;
2016
+ if (draft) {
2017
+ const el = $("#msgText");
2018
+ if (el) {
2019
+ el.value = draft.v;
2020
+ if (draft.focused) { el.focus(); el.setSelectionRange(draft.pos, draft.pos); }
2021
+ }
2022
+ }
1458
2023
  const sb = stdinBox(s); const cur = $("#main .stdin");
1459
2024
  if (cur && cur.outerHTML !== sb && document.activeElement?.id !== "stdinText") cur.outerHTML = sb;
1460
2025
  else if (!cur && sb) $("#main").insertAdjacentHTML("beforeend", sb);
@@ -1614,7 +2179,7 @@ function menuSpec(kind, d) {
1614
2179
  if (!p) return null;
1615
2180
  const green = p.checks !== "fail" && p.mergeable && !p.draft;
1616
2181
  return { title: `#${p.number}`, items: [
1617
- { label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => window.open(p.url, "_blank") },
2182
+ { label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => openExternal(p.url) },
1618
2183
  { label: "Copy URL", icon: "copy", run: () => copy(p.url) },
1619
2184
  { divider: true },
1620
2185
  { 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 +2221,8 @@ function menuSpec(kind, d) {
1656
2221
  { divider: true },
1657
2222
  { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1658
2223
  { 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") },
2224
+ { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => openExternal("https://getswarm.vercel.app/docs/") },
2225
+ { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => openExternal(feedbackUrl()) },
1661
2226
  ] };
1662
2227
  }
1663
2228
  return null;
@@ -1710,9 +2275,13 @@ ${p.reason ?? ""}`.slice(0, 180);
1710
2275
  // What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
1711
2276
  // The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
1712
2277
  // itself once after an upgrade (localStorage remembers the last version the user saw).
1713
- function releaseNotesFor(version) {
2278
+ // `strict` matters: the automatic post-upgrade panel must never fall back. Falling back showed
2279
+ // 0.10.0's notes under a "What's New" triggered by upgrading to 0.11.0 — the notes bundle was a
2280
+ // stale cached copy that had no 0.11.0 in it, and the fallback quietly hid that.
2281
+ function releaseNotesFor(version, { strict = false } = {}) {
1714
2282
  const all = window.RELEASE_NOTES || {};
1715
2283
  if (version && all[version]) return { version, ...all[version] };
2284
+ if (strict) return null;
1716
2285
  const latest = Object.keys(all)[0];
1717
2286
  return latest ? { version: latest, ...all[latest] } : null;
1718
2287
  }
@@ -1741,9 +2310,12 @@ function maybeUpdateNudge(h) {
1741
2310
  <div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
1742
2311
  document.body.appendChild(el);
1743
2312
  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…";
2313
+ // closest(), not e.target.id: the button holds an <svg> icon, so a click on the glyph itself
2314
+ // targets the svg/path and an id check would miss it.
2315
+ const btn = e.target.closest?.("button");
2316
+ if (btn?.id === "updLater") return el.remove();
2317
+ if (btn?.id !== "updRestart") return;
2318
+ btn.textContent = "restarting…";
1747
2319
  await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
1748
2320
  const t0 = Date.now();
1749
2321
  const wait = setInterval(async () => {
@@ -1760,7 +2332,7 @@ function maybeWhatsNew() {
1760
2332
  let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
1761
2333
  if (seen === state.version) return;
1762
2334
  if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
1763
- if (releaseNotesFor(state.version)) whatsNew(state.version);
2335
+ if (releaseNotesFor(state.version, { strict: true })) whatsNew(state.version);
1764
2336
  }
1765
2337
 
1766
2338
  // Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
@@ -1785,7 +2357,7 @@ function maybeStarNudge() {
1785
2357
  el.addEventListener("click", (ev) => {
1786
2358
  const t = ev.target.closest("[data-star]"); if (!t) return;
1787
2359
  ev.preventDefault();
1788
- if (t.dataset.star === "go") { starSave({ done: now }); window.open(REPO_URL, "_blank"); }
2360
+ if (t.dataset.star === "go") { starSave({ done: now }); openExternal(REPO_URL); }
1789
2361
  else if (t.dataset.star === "never") starSave({ never: now });
1790
2362
  el.remove();
1791
2363
  });
@@ -1807,6 +2379,11 @@ function openMenu(kind, anchor, d) {
1807
2379
  const spec = menuSpec(kind, d);
1808
2380
  if (!spec) return;
1809
2381
  if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
2382
+ // Once the menu is up the pointer is over *it*, not the row, so a :hover-only kebab vanishes
2383
+ // under its own menu. Mark the row (and the kebab) until menus:openchange reports the close.
2384
+ if (anchor?.closest) {
2385
+ for (const el of [anchor.closest(".proj"), anchor.closest("tr"), anchor.closest(".more")]) el?.classList.add("menu-open");
2386
+ }
1810
2387
  window.menus.open(anchor, spec);
1811
2388
  }
1812
2389
  document.addEventListener("keydown", (e) => {
@@ -1832,12 +2409,12 @@ document.addEventListener("contextmenu", (ev) => {
1832
2409
  // unreachable (closest() returns null and the click dies silently) — that is how Replay,
1833
2410
  // Resume-where-it-died and the dry-run Re-run button all shipped dead.
1834
2411
  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");
2412
+ 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],[data-provpage],#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
2413
  if (!t) return;
1837
2414
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1838
2415
  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(); }
2416
+ if (t.id === "feedback") { ev.preventDefault(); return openExternal(feedbackUrl()); }
2417
+ if (t.dataset.view) { ev.preventDefault(); return showView(t.dataset.view); }
1841
2418
  if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
1842
2419
  if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
1843
2420
  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 +2495,37 @@ document.addEventListener("click", async (ev) => {
1918
2495
  if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1919
2496
  if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
1920
2497
  if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
2498
+ if (t.id === "abNew") {
2499
+ ev.preventDefault();
2500
+ if (!state.sel) return;
2501
+ const task = prompt("Task id to trial (each arm claims task#model, so each gets its own worktree):");
2502
+ if (!task) return;
2503
+ const models = prompt("Models to compare, comma separated:", "opus-5, sonnet-5");
2504
+ const arms = (models ?? "").split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ model: m, label: m }));
2505
+ if (arms.length < 2) { alert("A trial needs at least two models."); return; }
2506
+ const r = await fetch("/v1/ab", {
2507
+ method: "POST",
2508
+ headers: { "content-type": "application/json" },
2509
+ body: JSON.stringify({ projectId: state.sel, task: task.trim(), arms }),
2510
+ }).then((x) => x.json()).catch(() => null);
2511
+ if (!r) return alert("Could not reach the daemon.");
2512
+ if (r?.failed?.length) alert(`Started ${r.started.length}. Could not start: ${r.failed.map((f) => `${f.arm} — ${f.reason}`).join("; ")}`);
2513
+ return refresh();
2514
+ }
2515
+ if (t.dataset.provpage) {
2516
+ ev.preventDefault();
2517
+ if (t.classList.contains("off")) return;
2518
+ state.provOffset = Number(t.dataset.provpage);
2519
+ return refresh();
2520
+ }
2521
+ if (t.dataset.group) {
2522
+ ev.preventDefault();
2523
+ const open = new Set(state.lineageOpen ?? []);
2524
+ open.has(t.dataset.group) ? open.delete(t.dataset.group) : open.add(t.dataset.group);
2525
+ state.lineageOpen = [...open];
2526
+ return refresh();
2527
+ }
2528
+ if (t.dataset.graphtab) { state.graphTab = t.dataset.graphtab; localStorage.setItem("swarm.graphTab", state.graphTab); return refresh(); }
1921
2529
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
1922
2530
  if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
1923
2531
  if (t.dataset.ackall) {
@@ -1971,7 +2579,7 @@ sbApply();
1971
2579
  // ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
1972
2580
  const pal = { items: [], view: [], q: "", i: 0 };
1973
2581
  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(); } }));
2582
+ const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => showView(v.id) }));
1975
2583
  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
2584
  const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
1977
2585
  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 +2675,49 @@ const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊"
2067
2675
  // (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
2068
2676
  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
2677
  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;
2678
+ // Which code points the platform font actually draws in colour is a per-machine answer, so it is
2679
+ // probed once and remembered. Two things made that probe cost ~150ms of blocked main thread:
2680
+ // it called getImageData once per code point (1536 GPU->CPU readbacks), and the blocks overlap,
2681
+ // so 96 code points were probed and rendered — twice. Now it is one readback per block over a
2682
+ // grid of glyphs, deduped, and the answer is cached across reloads.
2683
+ const EMOJI_CACHE_KEY = "swarm.emoji.v1";
2684
+ function detectEmoji(a, b) {
2685
+ const S = 20, COLS = 32, n = b - a + 1, rows = Math.ceil(n / COLS);
2686
+ const cv = document.createElement("canvas");
2687
+ cv.width = COLS * S; cv.height = rows * S;
2074
2688
  const c = cv.getContext("2d", { willReadFrequently: true });
2075
2689
  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); }
2690
+ for (let i = 0; i < n; i++) c.fillText(String.fromCodePoint(a + i), (i % COLS) * S, ((i / COLS) | 0) * S);
2691
+ const d = c.getImageData(0, 0, cv.width, cv.height).data, W = cv.width, out = [];
2692
+ // A code point counts as an emoji the platform can draw if its cell paints coloured pixels.
2693
+ for (let i = 0; i < n; i++) {
2694
+ const x0 = (i % COLS) * S, y0 = ((i / COLS) | 0) * S;
2695
+ let ok = false;
2696
+ for (let y = y0; y < y0 + S && !ok; y++)
2697
+ for (let x = x0; x < x0 + S; x++) {
2698
+ const p = (y * W + x) * 4;
2699
+ 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; }
2700
+ }
2701
+ if (ok) out.push(String.fromCodePoint(a + i));
2702
+ }
2703
+ return out;
2704
+ }
2705
+ function buildEmojiGrid() {
2706
+ if (emojiGrid) return emojiGrid;
2707
+ // The cache is keyed by the UA (a font change is what would invalidate it) plus the block list.
2708
+ const sig = `${navigator.userAgent}|${EMOJI_BLOCKS.map((x) => x.join(":")).join(",")}`;
2709
+ let blocks = null;
2710
+ try {
2711
+ const hit = JSON.parse(localStorage.getItem(EMOJI_CACHE_KEY) ?? "null");
2712
+ if (hit?.sig === sig) blocks = hit.blocks;
2713
+ } catch { /* corrupt or unavailable cache: probe again */ }
2714
+ if (!blocks) {
2715
+ const seen = new Set();
2716
+ blocks = EMOJI_BLOCKS.map(([, a, b]) => detectEmoji(a, b).filter((e) => !seen.has(e) && seen.add(e)));
2717
+ try { localStorage.setItem(EMOJI_CACHE_KEY, JSON.stringify({ sig, blocks })); } catch { /* private mode / quota */ }
2718
+ }
2719
+ emojiGrid = EMOJI_BLOCKS.map(([name], i) => {
2720
+ const list = blocks[i] ?? [];
2085
2721
  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
2722
  }).join("");
2087
2723
  return emojiGrid;
@@ -2235,7 +2871,7 @@ async function submitPr() {
2235
2871
  closePicker();
2236
2872
  state.prs = [];
2237
2873
  await refresh();
2238
- if (r.url) window.open(r.url, "_blank");
2874
+ if (r.url) openExternal(r.url);
2239
2875
  }
2240
2876
 
2241
2877
  async function openPicker(focusPath = false) {
@@ -2313,7 +2949,7 @@ function connect() {
2313
2949
  if (fresh) notifyForEvent(ev);
2314
2950
  pollSoon();
2315
2951
  };
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);
2952
+ 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
2953
  }
2318
2954
  refresh().then(() => {
2319
2955
  const sid = new URLSearchParams(location.search).get("session");