@ra3orblade/swarm 0.9.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"`.
@@ -65,7 +80,7 @@ document.addEventListener("keydown", (ev) => {
65
80
  window.swarmZoom(dir);
66
81
  });
67
82
  // `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
68
- const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
83
+ const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, collisions: null, outcomes: null, dirty: true };
69
84
 
70
85
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
71
86
  const ago = (iso) => { const d = (Date.now() - new Date(iso)) / 1000; return d < 60 ? `${d | 0}s` : d < 3600 ? `${(d / 60) | 0}m` : d < 86400 ? `${(d / 3600) | 0}h` : `${(d / 86400) | 0}d`; };
@@ -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,109 @@ async function refresh() {
203
269
  incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
204
270
  state.allIncidents = inc;
205
271
  }
206
- if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
207
- }
208
- const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats", "search"];
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
+ }
280
+ let colChanged = false;
281
+ if (state.view === "graphs" && (state.graphTab ?? "collisions") !== "lineage" && !state.session) {
282
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
283
+ const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
284
+ colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
285
+ state.collisions = col;
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
+ }
336
+ let outChanged = false;
337
+ if (state.view === "outcomes" && !state.session) {
338
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
339
+ const o = await fetch(`/v1/outcomes${q}`).then((r) => r.json()).catch(() => state.outcomes);
340
+ outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
341
+ state.outcomes = o;
342
+ }
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();
344
+ }
345
+ // M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
346
+ // deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
347
+ const VIEW_DEFS = [
348
+ { id: "fleet", label: "Fleet", icon: "squares-four", group: "Observe", render: () => renderFleet() },
349
+ { id: "timeline", label: "Timeline", icon: "clock-counter-clockwise", group: "Observe", render: () => renderTimeline() },
350
+ { id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
351
+ { id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
352
+ { id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
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() },
360
+ { id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
361
+ { id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
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 },
364
+ { id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
365
+ ];
366
+ const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
367
+ const VIEWS = VIEW_DEFS.map((v) => v.id);
368
+ let navHtml = ""; // last-rendered nav html; declared before the restore block below calls renderNav()
209
369
  // restore last view + project selection (persisted UI state)
210
370
  {
211
371
  const v = localStorage.getItem("swarm.view");
212
372
  if (VIEWS.includes(v)) state.view = v;
373
+ const gt = localStorage.getItem("swarm.graphTab");
374
+ if (gt === "lineage" || gt === "collisions") state.graphTab = gt;
213
375
  const sel = localStorage.getItem("swarm.sel");
214
376
  if (sel) state.sel = sel;
215
377
  // Deep links win over persisted state: ?view=board&project=<id>&session=<id>
@@ -217,12 +379,12 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
217
379
  if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
218
380
  if (q.has("project")) state.sel = q.get("project") || null;
219
381
  // Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
220
- for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
382
+ renderNav();
221
383
  }
222
384
  function render() {
223
385
  // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
224
386
  // hold the frame while one is open; the next poll or interaction paints it.
225
- if (window.menus?.isOpen()) { state.dirty = true; return; }
387
+ if (window.menus?.isOpen()) { state.dirty = true; return; } // the menus:openchange listener paints on close
226
388
  // Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
227
389
  const af = document.activeElement;
228
390
  const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
@@ -231,14 +393,7 @@ function render() {
231
393
  if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
232
394
  renderHeader();
233
395
  if (state.session) renderSession();
234
- else if (state.view === "spend") renderSpend();
235
- else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
236
- else if (state.view === "search") renderSearch();
237
- else if (state.view === "timeline") renderTimeline();
238
- else if (state.view === "board") renderBoard();
239
- else if (state.view === "incidents") renderIncidentsView();
240
- else if (state.view === "prs") renderPRs();
241
- else renderFleet();
396
+ else (viewDef(state.view)?.render ?? viewDef("fleet").render)();
242
397
  if (keep) {
243
398
  const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
244
399
  if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
@@ -249,10 +404,59 @@ function renderHeader() {
249
404
  const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
250
405
  const html = `Today <b>${usd(today)}</b>`;
251
406
  if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
252
- const ic_ = $("#incCount"); const n = state.openIncidents ?? 0;
253
- if (ic_) { ic_.hidden = !n; ic_.textContent = n > 99 ? "99+" : String(n); }
254
- for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", !state.session && a.dataset.view === state.view);
407
+ renderNav();
408
+ }
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();
255
418
  }
419
+ function viewGroups() {
420
+ const groups = [];
421
+ for (const v of VIEW_DEFS) {
422
+ const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
423
+ g.views.push(v);
424
+ }
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("");
438
+ if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
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
+ });
256
460
 
257
461
  const isLive = (s) => s.state === "active" || s.state === "waiting";
258
462
  // One pass over sessions → live count per project (+ "" for all), instead of a filter per sidebar row.
@@ -345,10 +549,19 @@ function onboarding() {
345
549
 
346
550
  // ---------- fleet
347
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
+ }
348
561
  const FLEET_COLS = [
349
562
  { key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
350
563
  { key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
351
- { 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>' : ""}` },
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)}` },
352
565
  { key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
353
566
  { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
354
567
  const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
@@ -441,7 +654,11 @@ function renderBoardKpis() {
441
654
  const orphaned = claims.filter((c) => c.state === "orphaned").length;
442
655
  const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
443
656
  const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
444
- 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);
445
662
  const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
446
663
  const ready = tasks ? tasks.filter((t) => t.ready).length : null;
447
664
  const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
@@ -1031,6 +1248,34 @@ document.addEventListener("change", async (ev) => {
1031
1248
  });
1032
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()); } });
1033
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
+ }
1034
1279
  function renderStats() {
1035
1280
  const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
1036
1281
  const scope = state.sel ? esc(projName(state.sel)) : "all projects";
@@ -1118,6 +1363,7 @@ function renderStats() {
1118
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>
1119
1364
  <div><h2 style="margin-top:0">Records</h2><div class="records">${records}</div></div>
1120
1365
  </div>
1366
+ ${waitingSection()}
1121
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>`;
1122
1368
  }
1123
1369
 
@@ -1136,6 +1382,365 @@ async function loadTimelineDetail() {
1136
1382
  if (state.view === "timeline" && !state.session) touch();
1137
1383
  } finally { tlDetail.busy = false; }
1138
1384
  }
1385
+ // M9.2: Outcomes — did the agent's work survive? Branch → PR → merged / reverted, with
1386
+ // scorecards per model and per agent. Data from /v1/outcomes (fetched by the poll while open).
1387
+ const outBadge = (o) => ({ merged: '<span class="badge ok">merged</span>', reverted: '<span class="badge bad">reverted</span>', open: '<span class="badge acc">open</span>', "no-pr": '<span class="badge">no PR</span>' })[o] ?? esc(o);
1388
+ const ratePct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
1389
+ const hrs = (x) => (x == null || x < 0 ? "—" : x < 1 ? `${Math.round(x * 60)}m` : x < 48 ? `${x.toFixed(1)}h` : `${(x / 24).toFixed(1)}d`);
1390
+ const scoreCols = (label) => [
1391
+ { key: "key", label, width: 150, get: (r) => r.key, cell: (r) => `<b>${esc(label === "model" ? model(r.key) : viz.agentName(r.key))}</b>` },
1392
+ { key: "branches", label: "branches", width: 80, num: true, get: (r) => r.branches, cell: (r) => String(r.branches) },
1393
+ { key: "merged", label: "merged", width: 72, num: true, get: (r) => r.merged, cell: (r) => String(r.merged) },
1394
+ { key: "reverted", label: "reverted", width: 78, num: true, get: (r) => r.reverted, cell: (r) => (r.reverted ? `<b style="color:var(--bad)">${r.reverted}</b>` : "0") },
1395
+ { key: "open", label: "open", width: 60, num: true, get: (r) => r.open, cell: (r) => String(r.open) },
1396
+ { key: "nopr", label: "no PR", width: 64, num: true, get: (r) => r.noPr, cell: (r) => String(r.noPr) },
1397
+ { key: "rate", label: "merge rate", width: 92, num: true, get: (r) => r.mergeRate ?? -1, cell: (r) => ratePct(r.mergeRate) },
1398
+ { key: "lead", label: "median lead", width: 98, num: true, get: (r) => r.medianLeadHours ?? -1, cell: (r) => hrs(r.medianLeadHours) },
1399
+ { key: "cpm", label: "$ / merge", width: 84, num: true, get: (r) => r.costPerMerge ?? -1, cell: (r) => (r.costPerMerge == null ? "—" : usd(r.costPerMerge)) },
1400
+ ];
1401
+ const BRANCH_COLS = [
1402
+ { key: "branch", label: "branch", width: 190, get: (r) => r.branch, cell: (r) => `<span class="br">${esc(r.branch)}</span>` },
1403
+ { key: "outcome", label: "outcome", width: 92, cls: "td-badge", get: (r) => r.outcome, cell: (r) => outBadge(r.outcome) },
1404
+ { key: "pr", label: "PR", flex: true, get: (r) => r.title ?? "", cell: (r) => (r.prNumber ? `<a href="${esc(r.url ?? "#")}" target="_blank" rel="noreferrer">#${r.prNumber}</a> <span class="dim">${esc(r.title ?? "")}</span>` : '<span class="faint">—</span>') },
1405
+ { key: "model", label: "model", width: 92, get: (r) => model(r.model), cell: (r) => `<span class="br">${esc(model(r.model))}</span>` },
1406
+ { key: "agent", label: "agent", width: 78, cls: "td-badge", get: (r) => agentLabel(r.agent), cell: (r) => agentBadge(r.agent) },
1407
+ { key: "sessions", label: "sessions", width: 76, num: true, get: (r) => r.sessions.length, cell: (r) => String(r.sessions.length) },
1408
+ { key: "cost", label: "cost", width: 64, num: true, get: (r) => r.costUsd, cell: (r) => usd(r.costUsd) },
1409
+ { key: "lead", label: "lead", width: 64, num: true, get: (r) => r.leadHours ?? -1, cell: (r) => hrs(r.leadHours) },
1410
+ ];
1411
+ function renderOutcomes() {
1412
+ const o = state.outcomes;
1413
+ const head = (sub) => `<h2>Outcomes <span>${sub}</span></h2>`;
1414
+ if (!o) {
1415
+ $("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}Loading…</div>`;
1416
+ return;
1417
+ }
1418
+ if (!o.branches?.length) {
1419
+ $("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}No agent branches yet${state.sel ? " in this project" : ""}.<br>Outcomes fill in as sessions work on branches and their PRs merge — or get reverted.</div>`;
1420
+ return;
1421
+ }
1422
+ const n = (k) => o.branches.filter((b) => b.outcome === k).length;
1423
+ const rev = n("reverted");
1424
+ $("#main").innerHTML =
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`) +
1426
+ `<h2 class="mt-sec">By model <span>who ships work that survives</span></h2>` +
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 })}` : "") +
1429
+ `<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
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>`;
1692
+ }
1693
+
1694
+ // M9.12: live file-collision graph — which live sessions touch which files, contested files
1695
+ // highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
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);
1702
+ const g = state.collisions;
1703
+ const title = (s) => s.title ?? s.id.slice(0, 8);
1704
+ if (!g || !g.sessions.length) {
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>`;
1706
+ return;
1707
+ }
1708
+ if (!g.files.length) {
1709
+ $("#main").innerHTML = head(`${g.sessions.length} live session${g.sessions.length === 1 ? "" : "s"}`) + `<div class="empty">${PX.idle()}No file touches recorded yet — the graph fills in as agents read and edit.</div>`;
1710
+ return;
1711
+ }
1712
+ const sessions = g.sessions.map((s) => ({ ...s, label: title(s) }));
1713
+ const agents = [...new Set(sessions.map((s) => s.agent))].sort(viz.agentSort);
1714
+ const sub = `${sessions.length} live session${sessions.length === 1 ? "" : "s"} · ${g.files.length} file${g.files.length === 1 ? "" : "s"} · ${g.contested ? `<b class="navcount">${g.contested} contested</b>` : "no collisions"}`;
1715
+ $("#main").innerHTML = head(sub) +
1716
+ `<div class="card" style="padding:14px">${viz.bipartite(sessions, g.files)}</div>
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>`;
1718
+ }
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
+
1139
1744
  function renderTimeline() {
1140
1745
  loadTimelineDetail();
1141
1746
  const now = Date.now();
@@ -1185,7 +1790,23 @@ async function openSession(id) {
1185
1790
  // Rendered log rows, keyed per event seq / turn id (+ the mutable turn fields) so only new rows are formatted.
1186
1791
  const rowCache = new Map();
1187
1792
  let logRendered = null; // keys of the rows currently in #log, in order — enables append-only updates
1188
- 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>`;
1189
1810
  // Merge the two ts-sorted inputs (events by seq ≈ ts, turns by ts) in one pass → [{key, html}].
1190
1811
  function sessionStream() {
1191
1812
  const out = [];
@@ -1263,16 +1884,37 @@ function replayGo(delta) {
1263
1884
  }
1264
1885
 
1265
1886
  // Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
1266
- // 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.
1267
1889
  function messageThread(s) {
1268
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";
1269
1893
  const row = (m) => {
1270
1894
  const out = m.fromSession === s.id;
1271
1895
  return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
1272
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>`;
1273
1897
  };
1274
- return `<h4>messages</h4>${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : '<span class="dim">None yet.</span>'}
1275
- <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>`;
1276
1918
  }
1277
1919
 
1278
1920
  // M7.7: questions this session is waiting on a human for
@@ -1307,6 +1949,8 @@ async function sendStdin() {
1307
1949
  }
1308
1950
  document.addEventListener("click", (ev) => {
1309
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; }
1310
1954
  const qa = ev.target.closest("[data-qanswer]");
1311
1955
  if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
1312
1956
  const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
@@ -1329,9 +1973,9 @@ function renderSession() {
1329
1973
  const t = s.tokens;
1330
1974
  const ctx = t.input + t.cacheRead + t.cacheWrite;
1331
1975
  const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
1332
- 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" };
1333
1977
  const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
1334
- 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>`;
1335
1979
  const side = `<div class="stats">
1336
1980
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
1337
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>`)}
@@ -1343,12 +1987,24 @@ function renderSession() {
1343
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>'}
1344
1988
  ${messageThread(s)}
1345
1989
  ${questionCards(s)}
1346
- ${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)}`;
1347
1991
  if (logEl && isAppend(rows)) {
1348
1992
  // Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
1349
1993
  // scroll position (and its DOM) untouched.
1350
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;
1351
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
+ }
1352
2008
  const sb = stdinBox(s); const cur = $("#main .stdin");
1353
2009
  if (cur && cur.outerHTML !== sb && document.activeElement?.id !== "stdinText") cur.outerHTML = sb;
1354
2010
  else if (!cur && sb) $("#main").insertAdjacentHTML("beforeend", sb);
@@ -1508,7 +2164,7 @@ function menuSpec(kind, d) {
1508
2164
  if (!p) return null;
1509
2165
  const green = p.checks !== "fail" && p.mergeable && !p.draft;
1510
2166
  return { title: `#${p.number}`, items: [
1511
- { 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) },
1512
2168
  { label: "Copy URL", icon: "copy", run: () => copy(p.url) },
1513
2169
  { divider: true },
1514
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) },
@@ -1550,8 +2206,8 @@ function menuSpec(kind, d) {
1550
2206
  { divider: true },
1551
2207
  { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1552
2208
  { label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
1553
- { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
1554
- { 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()) },
1555
2211
  ] };
1556
2212
  }
1557
2213
  return null;
@@ -1587,6 +2243,10 @@ ${p.reason ?? ""}`.slice(0, 180);
1587
2243
  title = "An agent has a question";
1588
2244
  body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
1589
2245
  onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
2246
+ } else if (ev.type === "session.stuck") {
2247
+ title = "Session looks stuck";
2248
+ body = (p.reason ?? p.summary ?? "").slice(0, 180);
2249
+ onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1590
2250
  } else if (ev.type === "claim.orphaned") {
1591
2251
  title = "Claim orphaned";
1592
2252
  body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
@@ -1600,9 +2260,13 @@ ${p.reason ?? ""}`.slice(0, 180);
1600
2260
  // What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
1601
2261
  // The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
1602
2262
  // itself once after an upgrade (localStorage remembers the last version the user saw).
1603
- 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 } = {}) {
1604
2267
  const all = window.RELEASE_NOTES || {};
1605
2268
  if (version && all[version]) return { version, ...all[version] };
2269
+ if (strict) return null;
1606
2270
  const latest = Object.keys(all)[0];
1607
2271
  return latest ? { version: latest, ...all[latest] } : null;
1608
2272
  }
@@ -1631,9 +2295,12 @@ function maybeUpdateNudge(h) {
1631
2295
  <div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
1632
2296
  document.body.appendChild(el);
1633
2297
  el.addEventListener("click", async (e) => {
1634
- if (e.target.id === "updLater") return el.remove();
1635
- if (e.target.id !== "updRestart") return;
1636
- 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…";
1637
2304
  await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
1638
2305
  const t0 = Date.now();
1639
2306
  const wait = setInterval(async () => {
@@ -1650,7 +2317,7 @@ function maybeWhatsNew() {
1650
2317
  let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
1651
2318
  if (seen === state.version) return;
1652
2319
  if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
1653
- if (releaseNotesFor(state.version)) whatsNew(state.version);
2320
+ if (releaseNotesFor(state.version, { strict: true })) whatsNew(state.version);
1654
2321
  }
1655
2322
 
1656
2323
  // Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
@@ -1675,7 +2342,7 @@ function maybeStarNudge() {
1675
2342
  el.addEventListener("click", (ev) => {
1676
2343
  const t = ev.target.closest("[data-star]"); if (!t) return;
1677
2344
  ev.preventDefault();
1678
- 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); }
1679
2346
  else if (t.dataset.star === "never") starSave({ never: now });
1680
2347
  el.remove();
1681
2348
  });
@@ -1697,6 +2364,11 @@ function openMenu(kind, anchor, d) {
1697
2364
  const spec = menuSpec(kind, d);
1698
2365
  if (!spec) return;
1699
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
+ }
1700
2372
  window.menus.open(anchor, spec);
1701
2373
  }
1702
2374
  document.addEventListener("keydown", (e) => {
@@ -1718,13 +2390,16 @@ document.addEventListener("contextmenu", (ev) => {
1718
2390
  });
1719
2391
 
1720
2392
  // ---------- events
2393
+ // Every id / data-attr a branch below matches on MUST be in this selector, or the branch is
2394
+ // unreachable (closest() returns null and the click dies silently) — that is how Replay,
2395
+ // Resume-where-it-died and the dry-run Re-run button all shipped dead.
1721
2396
  document.addEventListener("click", async (ev) => {
1722
- 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,#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");
1723
2398
  if (!t) return;
1724
2399
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1725
2400
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
1726
- if (t.id === "feedback") { ev.preventDefault(); return window.open(feedbackUrl(), "_blank"); }
1727
- 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); }
1728
2403
  if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
1729
2404
  if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
1730
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; }
@@ -1805,6 +2480,31 @@ document.addEventListener("click", async (ev) => {
1805
2480
  if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1806
2481
  if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
1807
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(); }
1808
2508
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
1809
2509
  if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
1810
2510
  if (t.dataset.ackall) {
@@ -1855,6 +2555,57 @@ $("#sbToggle")?.addEventListener("click", () => {
1855
2555
  });
1856
2556
  sbApply();
1857
2557
 
2558
+ // ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
2559
+ const pal = { items: [], view: [], q: "", i: 0 };
2560
+ function palBuild() {
2561
+ const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => showView(v.id) }));
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(); } });
2563
+ const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
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) });
2565
+ return items;
2566
+ }
2567
+ function palFilter() {
2568
+ const q = pal.q.trim().toLowerCase();
2569
+ const rank = (x) => Math.min(...[x.label, x.sub ?? ""].map((t) => { const i = t.toLowerCase().indexOf(q); return i < 0 ? 1e9 : i; }));
2570
+ const out = q
2571
+ ? pal.items.map((x) => ({ x, r: rank(x) })).filter((h) => h.r < 1e9).sort((a, b) => a.r - b.r).map((h) => h.x).slice(0, 12)
2572
+ : pal.items.filter((x) => x.grp !== "session" || x.live).slice(0, 16); // idle: every view + project + live sessions
2573
+ if (q) out.push({ icon: "magnifying-glass", label: `Search Swarm for “${pal.q.trim()}”`, grp: "search", run: () => { srch.q = pal.q.trim(); state.view = "search"; localStorage.setItem("swarm.view", "search"); state.session = null; state.dirty = true; runSearch(); refresh(); } });
2574
+ return out;
2575
+ }
2576
+ function palRender() {
2577
+ pal.view = palFilter();
2578
+ if (pal.i >= pal.view.length) pal.i = Math.max(0, pal.view.length - 1);
2579
+ const row = (x, i) => `<div class="pk-row pal-row ${i === pal.i ? "on" : ""}" data-pal="${i}">${ic(x.icon, 14)}<span class="nm">${esc(x.label)}${x.sub ? ` <span class="dim">· ${esc(x.sub)}</span>` : ""}</span><span class="grp">${x.grp}</span></div>`;
2580
+ const el = $("#palList");
2581
+ if (el) el.innerHTML = pal.view.map(row).join("") || '<div class="empty" style="padding:16px">No matches.</div>';
2582
+ }
2583
+ function palRun(i) {
2584
+ const x = pal.view[i];
2585
+ if (!x) return;
2586
+ closePicker();
2587
+ x.run();
2588
+ }
2589
+ function openPalette() {
2590
+ pal.items = palBuild(); pal.q = ""; pal.i = 0;
2591
+ $("#picker").innerHTML = `<div class="pk pal" role="dialog" aria-modal="true">
2592
+ <div class="pk-h">${ic("magnifying-glass", 15)}<input id="palQ" placeholder="Jump to view, project or session…" spellcheck="false" autocomplete="off"></div>
2593
+ <div class="pk-list" id="palList"></div>
2594
+ </div>`;
2595
+ palRender();
2596
+ const inp = $("#palQ");
2597
+ inp.focus();
2598
+ inp.addEventListener("input", () => { pal.q = inp.value; pal.i = 0; palRender(); });
2599
+ inp.addEventListener("keydown", (ev) => {
2600
+ if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); pal.i = Math.max(0, Math.min(pal.view.length - 1, pal.i + (ev.key === "ArrowDown" ? 1 : -1))); palRender(); }
2601
+ else if (ev.key === "Enter") { ev.preventDefault(); palRun(pal.i); }
2602
+ });
2603
+ }
2604
+ $("#palBtn")?.addEventListener("click", openPalette);
2605
+ document.addEventListener("keydown", (ev) => {
2606
+ if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { ev.preventDefault(); if ($("#palQ")) closePicker(); else openPalette(); }
2607
+ });
2608
+
1858
2609
  // ---------- folder picker
1859
2610
  const picker = { path: null };
1860
2611
  // Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
@@ -1903,21 +2654,49 @@ const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊"
1903
2654
  // (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
1904
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]];
1905
2656
  let emojiGrid = null;
1906
- function buildEmojiGrid() {
1907
- if (emojiGrid) return emojiGrid;
1908
- // A code point counts as an emoji the platform can draw if it paints colored pixels.
1909
- 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;
1910
2667
  const c = cv.getContext("2d", { willReadFrequently: true });
1911
2668
  c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
1912
- const colored = (ch) => {
1913
- c.clearRect(0, 0, S, S); c.fillText(ch, 0, 0);
1914
- const d = c.getImageData(0, 0, S, S).data;
1915
- 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;
1916
- return false;
1917
- };
1918
- emojiGrid = EMOJI_BLOCKS.map(([name, a, b]) => {
1919
- const list = [];
1920
- 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] ?? [];
1921
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>` : "";
1922
2701
  }).join("");
1923
2702
  return emojiGrid;
@@ -2071,7 +2850,7 @@ async function submitPr() {
2071
2850
  closePicker();
2072
2851
  state.prs = [];
2073
2852
  await refresh();
2074
- if (r.url) window.open(r.url, "_blank");
2853
+ if (r.url) openExternal(r.url);
2075
2854
  }
2076
2855
 
2077
2856
  async function openPicker(focusPath = false) {
@@ -2100,6 +2879,8 @@ async function pickerGo(path) {
2100
2879
  const closePicker = () => { $("#picker").innerHTML = ""; };
2101
2880
  $("#picker").addEventListener("click", (ev) => {
2102
2881
  if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
2882
+ const pr = ev.target.closest("[data-pal]");
2883
+ if (pr) return palRun(Number(pr.dataset.pal));
2103
2884
  const go = ev.target.closest("[data-go]");
2104
2885
  if (go) return void pickerGo(go.dataset.go);
2105
2886
  const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
@@ -2147,7 +2928,7 @@ function connect() {
2147
2928
  if (fresh) notifyForEvent(ev);
2148
2929
  pollSoon();
2149
2930
  };
2150
- 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);
2151
2932
  }
2152
2933
  refresh().then(() => {
2153
2934
  const sid = new URLSearchParams(location.search).get("session");