@ra3orblade/swarm 0.7.0 → 0.9.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,4 +1,24 @@
1
1
  const $ = (s) => document.querySelector(s);
2
+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
3
+ // M8.2b daemon token: `swarm ui` (and the desktop app) open the dashboard with ?token=…; it is kept
4
+ // in sessionStorage, stripped from the URL, and sent on every /v1 request. Loopback without a token
5
+ // still works while `[daemon] auth = "loopback-optional"`.
6
+ const TOKEN = (() => {
7
+ const q = new URLSearchParams(location.search);
8
+ const t = q.get("token");
9
+ if (t) { try { sessionStorage.setItem("swarm.token", t); } catch {} q.delete("token"); history.replaceState(null, "", `${location.pathname}${q.size ? `?${q}` : ""}${location.hash}`); return t; }
10
+ try { return sessionStorage.getItem("swarm.token"); } catch { return null; }
11
+ })();
12
+ if (TOKEN) {
13
+ const rawFetch = window.fetch.bind(window);
14
+ window.fetch = (input, init = {}) => {
15
+ const url = typeof input === "string" ? input : input.url;
16
+ if (!url.startsWith("/v1/")) return rawFetch(input, init);
17
+ const headers = new Headers(init.headers || {});
18
+ headers.set("authorization", `Bearer ${TOKEN}`);
19
+ return rawFetch(input, { ...init, headers });
20
+ };
21
+ }
2
22
  // macOS desktop app signals its overlay title bar via ?chrome=inset (see src-tauri/lib.rs).
3
23
  if (new URLSearchParams(location.search).get("chrome") === "inset") {
4
24
  document.documentElement.classList.add("chrome-inset");
@@ -51,7 +71,13 @@ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<
51
71
  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`; };
52
72
  // p2 (zero-pad) is defined in viz.js, which loads first
53
73
  const hhmm = (iso) => { const d = new Date(iso); return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`; };
54
- const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : id);
74
+ /** The project's glyph: its emoji icon, or the folder icon, tinted with its color slot. */
75
+ const projGlyph = (p, size = 14) => p?.icon
76
+ ? `<span class="pg ${p.color ? `pg-${p.color}` : ""}">${p.icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(p.icon)}" alt="">` : esc(p.icon)}</span>`
77
+ : `<span class="pg ${p?.color ? `pg-${p.color}` : ""}">${ic("folder-simple", size)}</span>`;
78
+ /** Project cell for tables: glyph + name. */
79
+ const projCell = (id) => { const p = state.projects.find((x) => x.id === id); return p ? `${projGlyph(p, 12)} ${esc(p.name)}` : esc(projName(id)); };
80
+ const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : "(removed)");
55
81
  const short = (p) => String(p ?? "").replace(/^\/Users\/[^/]+/, "~");
56
82
  const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
57
83
  const usd = (n) => (n == null ? '<span class="dim">—</span>' : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
@@ -114,7 +140,7 @@ const getTheme = () => localStorage.getItem("swarm.theme") ?? "system";
114
140
  const setTheme = (t) => { localStorage.setItem("swarm.theme", t); if (t === "system") delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t; };
115
141
  setTheme(getTheme());
116
142
  const copy = (text) => navigator.clipboard?.writeText(String(text ?? ""));
117
- const tail = (p, n = 24) => { const t = short(p); return t.length > n ? `…${t.slice(-(n - 1))}` : t; };
143
+ const tail = (p, n = 16) => { const t = short(p); return t.length > n ? `…${t.slice(-(n - 1))}` : t; };
118
144
  const agentLabel = (a) => viz.agentName(a);
119
145
  const agentBadge = (a) => (a ? `<span class="badge agent" style="color:${viz.agentColor(a)};background:color-mix(in srgb,${viz.agentColor(a)} 14%,transparent)">${esc(agentLabel(a))}</span>` : "");
120
146
 
@@ -129,7 +155,7 @@ async function refresh() {
129
155
  const txt = await (await fetch("/v1/state")).text();
130
156
  const same = txt === lastSnap;
131
157
  if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
132
- if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; maybeWhatsNew(); }).catch(() => {});
158
+ if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; state.hooksInstalled = h.hooksInstalled !== false; maybeUpdateNudge(h); maybeWhatsNew(); }).catch(() => {});
133
159
  let prsChanged = false;
134
160
  if (state.view === "prs" && !state.session) {
135
161
  const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
@@ -149,6 +175,10 @@ async function refresh() {
149
175
  state.attribution = null;
150
176
  }
151
177
  let runsChanged = false;
178
+ if (state.session) {
179
+ const ms = await fetch(`/v1/messages?session=${encodeURIComponent(state.session)}&limit=50`).then((r) => r.json()).catch(() => state.msgs ?? []);
180
+ if (JSON.stringify(ms) !== JSON.stringify(state.msgs)) { state.msgs = ms; state.dirty = true; }
181
+ }
152
182
  const openSpawned = state.session && state.sessions.find((x) => x.id === state.session)?.kind === "spawned";
153
183
  if (openSpawned || (state.view === "board" && !state.session) || (state.view === "fleet" && !state.session)) {
154
184
  const runs = await fetch("/v1/runs").then((r) => r.json()).catch(() => state.runs ?? []);
@@ -157,13 +187,14 @@ async function refresh() {
157
187
  }
158
188
  let tasksChanged = false;
159
189
  if (state.view === "board" && state.sel && !state.session) {
160
- const [t, g, d] = await Promise.all([
190
+ const [t, g, d, wf] = await Promise.all([
161
191
  fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
162
192
  fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
163
193
  fetch(`/v1/dispatch?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.dispatch),
194
+ fetch(`/v1/workflows?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.workflows),
164
195
  ]);
165
- tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch);
166
- state.tasks = t; state.gates = g; state.dispatch = d;
196
+ tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch) || JSON.stringify(wf) !== JSON.stringify(state.workflows);
197
+ state.tasks = t; state.gates = g; state.dispatch = d; state.workflows = wf;
167
198
  }
168
199
  let incChanged = false;
169
200
  if (state.view === "incidents" && !state.session) {
@@ -189,6 +220,9 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
189
220
  for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
190
221
  }
191
222
  function render() {
223
+ // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
224
+ // hold the frame while one is open; the next poll or interaction paints it.
225
+ if (window.menus?.isOpen()) { state.dirty = true; return; }
192
226
  // Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
193
227
  const af = document.activeElement;
194
228
  const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
@@ -227,6 +261,12 @@ function liveCounts() {
227
261
  for (const s of state.sessions) if (isLive(s)) { m.set(s.projectId, (m.get(s.projectId) ?? 0) + 1); m.set("", (m.get("") ?? 0) + 1); }
228
262
  return m;
229
263
  }
264
+ // M5.7: 14-day spend sparkline per pinned project; hidden when the fortnight cost is ~zero.
265
+ function spendSpark(pid) {
266
+ const pts = state.spendSparks?.[pid];
267
+ if (!pts || pts.reduce((a, b) => a + b, 0) < 0.5) return "";
268
+ return `<span class="proj-spark" title="last 14 days · $${pts.reduce((a, b) => a + b, 0).toFixed(0)}">${viz.sparkline(pts, "var(--c1)")}</span>`;
269
+ }
230
270
  function renderProjects() {
231
271
  const lc = liveCounts();
232
272
  const live = (pid) => lc.get(pid) ?? 0;
@@ -243,7 +283,7 @@ function renderProjects() {
243
283
  const row = (p) => {
244
284
  const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
245
285
  return `<div class="proj ${state.sel === p.id ? "sel" : ""}" data-id="${p.id}" data-ctx="project" data-pid="${p.id}" title="${esc(p.root)}"${p.discovered ? "" : ' draggable="true"'}>
246
- <span class="st ${live(p.id) ? "live" : ""}"></span>${ic("folder-simple", 14)}<span class="nm">${disamb(p)}${esc(p.name)}</span><small>${live(p.id) || ""}</small>${act}</div>`;
286
+ <span class="st ${live(p.id) ? "live" : ""}"></span>${projGlyph(p)}<span class="nm">${disamb(p)}${esc(p.name)}</span>${spendSpark(p.id)}<small>${live(p.id) || ""}</small>${act}</div>`;
247
287
  };
248
288
  const liveAll = live("");
249
289
  $("#projects").innerHTML =
@@ -288,16 +328,35 @@ projectsEl.addEventListener("dragend", () => {
288
328
  fetch("/v1/projects/order", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }) }).then(refresh);
289
329
  });
290
330
 
331
+ // First run: no sessions have ever been seen. Say exactly what to do next, and whether hooks are in.
332
+ function onboarding() {
333
+ const hooksOk = state.hooksInstalled !== false;
334
+ const step = (n, done, html) => `<div class="ob-step ${done ? "done" : ""}"><span class="ob-n">${done ? "✓" : n}</span><div>${html}</div></div>`;
335
+ return `<div class="onboard">${PX.idle()}
336
+ <h3>Swarm is running and watching this machine.</h3>
337
+ <div class="ob-steps">
338
+ ${step(1, hooksOk, `<b>Hook into Claude Code</b> — <code>swarm install</code> once${hooksOk ? "" : " <span class='badge warn'>not installed</span>"}. Codex and Grok are picked up automatically, nothing to configure.`)}
339
+ ${step(2, false, `<b>Open any agent session</b> — run <code>claude</code> in any repository, in any terminal. No changes to the repo, the agent doesn't know Swarm is there.`)}
340
+ ${step(3, false, `<b>Watch it appear here</b> — live status, branch, tokens and cost per session; Board, Timeline and Spend fill up as you work.`)}
341
+ </div>
342
+ <div class="dim">Something off? <code>swarm doctor</code> checks every piece and prints the fix.</div>
343
+ </div>`;
344
+ }
345
+
291
346
  // ---------- fleet
292
347
  // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
293
348
  const FLEET_COLS = [
294
- { key: "project", label: "project", width: 104, get: (s) => projName(s.projectId), cell: (s) => esc(projName(s.projectId)) },
295
- { key: "agent", label: "agent", width: 84, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
296
- { key: "session", label: "session", width: 236, 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>' : ""}` },
297
- { key: "branch", label: "branch", width: 134, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
298
- { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" ? (s.lastText ? s.lastText.split("\n")[0] : s.last) : s.last)}</span>` },
299
- { key: "model", label: "model", width: 96, get: (s) => model(s.model), cell: (s) => `<span class="br">${esc(model(s.model))}${s.models > 1 ? ` <span class="faint">+${s.models - 1}</span>` : ""}</span>` },
300
- { key: "trend", label: "trend", width: 100, sortable: false, filterable: false, get: () => null, cell: (s) => viz.sparkline(s.spark.map((p) => p[0]), viz.agentColor(s.agent)) },
349
+ { key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
350
+ { 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>' : ""}` },
352
+ { key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
353
+ { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
354
+ const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
355
+ if (s.state === "ended") return line ? `<span class="now dim" title="${esc(line)}">${esc(line)}</span>` : '<span class="dim">ended</span>';
356
+ return `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" && line ? line : s.last)}</span>`;
357
+ } },
358
+ { key: "model", label: "model", width: 84, get: (s) => model(s.model), cell: (s) => `<span class="br">${esc(model(s.model))}${s.models > 1 ? ` <span class="faint">+${s.models - 1}</span>` : ""}</span>` },
359
+ { key: "trend", label: "trend", width: 84, sortable: false, filterable: false, get: () => null, cell: (s) => viz.sparkline(s.spark.map((p) => p[0]), viz.agentColor(s.agent)) },
301
360
  { key: "out", label: "out", width: 66, num: true, get: (s) => s.tokens.output, cell: (s) => tok(s.tokens.output) },
302
361
  { key: "ctx", label: "ctx", width: 72, num: true, get: (s) => s.tokens.cacheRead + s.tokens.input + s.tokens.cacheWrite, cell: (s) => tok(s.tokens.cacheRead + s.tokens.input + s.tokens.cacheWrite) },
303
362
  { key: "cost", label: "cost", width: 64, num: true, get: (s) => s.costUsd ?? 0, cell: (s) => usd(s.costUsd) },
@@ -330,7 +389,7 @@ function renderFleet() {
330
389
  : "";
331
390
  $("#main").innerHTML = chips +
332
391
  `<h2>Live <span>${live.length} sessions · ${usd(sumBy(live, (s) => s.costUsd))}</span></h2>` +
333
- (live.length ? table(live, "fleet-live") : `<div class="empty">${PX.idle()}Nothing running.${state.sessions.length ? "" : "<br><br>Run <kbd>swarm install</kbd> once, then start <kbd>claude</kbd> in any folder — it will appear here."}</div>`) +
392
+ (live.length ? table(live, "fleet-live") : state.sessions.length ? `<div class="empty">${PX.idle()}Nothing running.</div>` : onboarding()) +
334
393
  (rest.length ? `<h2 class="mt-sec">Earlier <span>${rest.length}</span></h2>${table(rest.slice(0, 30), "fleet-earlier")}` : "") +
335
394
  "";
336
395
  }
@@ -361,18 +420,45 @@ function renderPRs() {
361
420
  columns: cols,
362
421
  rows,
363
422
  leading: { width: 24, cell: (p) => `<span class="s ${p.checks === "fail" ? "waiting" : p.checks === "pass" ? "active" : "idle"}"></span>` },
364
- trailing: { width: 96, cell: (p) => (green(p) ? `<a href="#" data-merge="${p.projectId}:${p.number}" title="Squash-merge via ${p.forge === "gitlab" ? "glab" : "gh"}">Merge</a>` : "") },
365
- rowAttrs: () => "",
423
+ trailing: { width: 34, cell: (p) => more("pr", `data-pid="${esc(p.projectId)}" data-num="${p.number}"`) },
424
+ rowAttrs: (p) => `data-ctx="pr" data-pid="${esc(p.projectId)}" data-num="${p.number}"`,
366
425
  rerender: touch,
367
426
  })
368
427
  : `<div class="empty">${PX.idle()}No open pull requests.<br>Agent branches land here the moment they're pushed.</div>`);
369
428
  }
370
429
 
371
430
  // ---------- board (coordination: claims, worktrees, incidents)
431
+ // Board representation toggles (cards vs table), persisted per section.
432
+ const boardMode = (k) => localStorage.getItem(`swarm.board.${k}`) ?? "cards";
433
+ const modeSeg = (k, a = "Cards", b = "Table") => `<span class="seg"><a href="#" data-bmode="${k}:cards" class="${boardMode(k) === "cards" ? "on" : ""}">${a}</a><a href="#" data-bmode="${k}:table" class="${boardMode(k) === "table" ? "on" : ""}">${b}</a></span>`;
434
+
435
+ // KPI strip: the board at a glance — what is live, held, dirty, failing, waiting.
436
+ function renderBoardKpis() {
437
+ const inSel = (pid) => !state.sel || pid === state.sel;
438
+ const live = state.sessions.filter((s) => inSel(s.projectId) && (s.state === "active" || s.state === "waiting"));
439
+ const waiting = live.filter((s) => s.state === "waiting").length;
440
+ const claims = (state.claims ?? []).filter((c) => c.state !== "released" && inSel(c.projectId));
441
+ const orphaned = claims.filter((c) => c.state === "orphaned").length;
442
+ const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
443
+ 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;
445
+ const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
446
+ const ready = tasks ? tasks.filter((t) => t.ready).length : null;
447
+ const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
448
+ if (!live.length && !claims.length && !wts.length && !inc && !tasks) return "";
449
+ 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>`;
450
+ return `<div class="kpis kpis-5">${
451
+ kpi("Live", live.length, waiting ? `${waiting} waiting on you` : live.length ? "sessions working" : "no sessions", waiting ? "hot" : "")
452
+ }${kpi("Held", claims.length, orphaned ? `${orphaned} orphaned` : claims.length ? "claims with a lease" : "nothing claimed", orphaned ? "hot" : "")
453
+ }${kpi("Worktrees", wts.length, dirty || merged ? `${dirty ? `${dirty} dirty` : ""}${dirty && merged ? " · " : ""}${merged ? `${merged} merged` : ""}` : "all clean", dirty ? "warm" : "")
454
+ }${tasks ? kpi("Ready", ready, gateFails ? `${gateFails} with failing gates` : `${tasks.filter((t) => t.status !== "done").length} open`, gateFails ? "hot" : "") : kpi("Projects", state.sel ? 1 : state.projects.length, "on the board")
455
+ }${kpi("Incidents", inc, inc ? "need a look" : "all acknowledged", inc ? "hot" : "")}</div>`;
456
+ }
457
+
372
458
  function renderBoard() {
373
- const parts = [renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
459
+ const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderWorkflowRuns(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
374
460
  $("#main").innerHTML = parts.length
375
- ? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
461
+ ? parts.join("").replace(/^(<div class="kpis[^>]*>[\s\S]*?<\/div><\/div>|)(<h2) class="mt-sec"/, "$1$2") // first section needs no top gap
376
462
  : `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
377
463
  }
378
464
 
@@ -381,19 +467,20 @@ function incidentColumns(full) {
381
467
  const sess = (id) => state.sessions.find((s) => s.id === id);
382
468
  return [
383
469
  { key: "ts", label: "when", width: 76, get: (i) => i.ts, cell: (i) => `<span class="dim" title="${esc(i.ts)}">${ago(i.ts)}</span>` },
384
- { key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => esc(projName(i.projectId)) },
470
+ { key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => projCell(i.projectId) },
385
471
  { key: "session", label: "session", width: 150, get: (i) => sess(i.sessionId)?.title ?? i.sessionId ?? "", cell: (i) => (i.sessionId ? `<a href="#" data-s="${i.sessionId}">${esc(sess(i.sessionId)?.title ?? i.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
386
472
  { key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
387
473
  { key: "action", label: "action", width: 80, get: (i) => i.action, cell: (i) => (i.action === "deny" ? '<span class="badge warn">Denied</span>' : i.action === "orphaned" ? '<span class="badge warn">Orphaned</span>' : i.action === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge acc">Asked</span>') },
388
- { key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.reason ?? "")}">${esc(i.command ?? "")}</span>` },
474
+ { key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.command ?? "")}${i.reason ? `\n\n${esc(i.reason)}` : ""}">${esc(cmdGist(i.command ?? ""))}</span>` },
389
475
  ...(full ? [
390
476
  { key: "reason", label: "reason", width: 260, get: (i) => i.reason ?? "", cell: (i) => `<span class="dim now" title="${esc(i.reason ?? "")}">${esc(i.reason ?? "")}</span>` },
391
477
  { key: "acked", label: "acked", width: 80, get: (i) => i.acked ?? "", cell: (i) => (i.acked ? `<span class="dim" title="${esc(i.acked)}">${ago(i.acked)}</span>` : '<span class="badge warn">Open</span>') },
392
478
  ] : []),
393
479
  ].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
394
480
  }
481
+ /** The part of a shell command worth reading in a cell: drop a leading `cd <dir> &&` / `;`. */
482
+ const cmdGist = (c) => c.replace(/^\s*cd\s+\S+\s*(&&|;)\s*/, "").replace(/\s+/g, " ").trim() || c;
395
483
  const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" || i.action === "orphaned" || i.action === "failed" ? "waiting" : "idle"}"></span>`;
396
- const ackLink = (i) => (i.acked ? "" : `<a href="#" data-ack="${i.seq}" title="Mark as seen">Ack</a>`);
397
484
 
398
485
  function renderIncidents() {
399
486
  const rows = (state.incidents ?? []).filter((i) => !state.sel || i.projectId === state.sel);
@@ -405,8 +492,8 @@ function renderIncidents() {
405
492
  columns: incidentColumns(false),
406
493
  rows,
407
494
  leading: { width: 24, cell: incidentDot },
408
- trailing: { width: 44, cell: ackLink },
409
- rowAttrs: (i) => (i.sessionId ? `data-s="${i.sessionId}"` : ""),
495
+ trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
496
+ rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
410
497
  rerender: touch,
411
498
  });
412
499
  }
@@ -486,8 +573,8 @@ function renderIncidentsView() {
486
573
  columns: incidentColumns(true),
487
574
  rows,
488
575
  leading: { width: 24, cell: incidentDot },
489
- trailing: { width: 120, cell: (i) => `${i.suggestion ? `<a href="#" data-codify="${i.seq}" title="Turn this into a rule / lesson">${ic("shield", 12)} Codify</a> ` : ""}${ackLink(i)}` },
490
- rowAttrs: () => "",
576
+ trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
577
+ rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
491
578
  rerender: touch,
492
579
  })
493
580
  : `<div class="empty">${PX.idle()}${state.incFilter === "open" ? "No open incidents." : "No incidents yet."}<br>Every <code>ask</code> or <code>deny</code> a rule makes lands here; ack it once you've seen it.</div>`);
@@ -500,7 +587,7 @@ function renderProcesses() {
500
587
  const cols = [
501
588
  { key: "name", label: "process", width: 150, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
502
589
  { key: "kind", label: "kind", width: 80, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
503
- { key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => esc(projName(r.projectId)) },
590
+ { key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => projCell(r.projectId) },
504
591
  { key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid, cell: (r) => r.pid },
505
592
  { key: "port", label: "port", width: 70, num: true, get: (r) => r.port ?? 0, cell: (r) => (r.port != null ? `<a href="http://127.0.0.1:${r.port}/" target="_blank" rel="noopener">:${r.port}</a>` : '<span class="dim">—</span>') },
506
593
  { key: "owner", label: "owner", width: 110, get: (r) => r.owner, cell: (r) => esc(r.owner) },
@@ -513,8 +600,8 @@ function renderProcesses() {
513
600
  columns: cols,
514
601
  rows,
515
602
  leading: { width: 24, cell: () => '<span class="s active"></span>' },
516
- trailing: { width: 60, cell: (r) => `<a href="#" data-procstop="${r.pid}" data-procproj="${esc(r.projectId)}" title="SIGTERM, then SIGKILL after 3 s">Stop</a>` },
517
- rowAttrs: () => "",
603
+ trailing: { width: 34, cell: (r) => more("process", `data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`) },
604
+ rowAttrs: (r) => `data-ctx="process" data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`,
518
605
  rerender: touch,
519
606
  });
520
607
  }
@@ -537,7 +624,8 @@ function renderResources() {
537
624
  columns: cols,
538
625
  rows,
539
626
  leading: { width: 24, cell: () => '<span class="s active"></span>' },
540
- trailing: { width: 90, cell: (r) => `<a href="#" data-resrelease="${esc(r.name)}" data-resproj="${esc(r.projectId ?? "")}">Release</a>` },
627
+ trailing: { width: 34, cell: (r) => more("resource", `data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`) },
628
+ rowAttrs: (r) => `data-ctx="resource" data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`,
541
629
  rerender: touch,
542
630
  });
543
631
  }
@@ -565,7 +653,13 @@ function renderGates() {
565
653
  { key: "evidence", label: "evidence", width: 220, get: (r) => r.evidence ?? "", cell: (r) => (r.evidence ? `<span class="dim now" title="${esc(r.evidence)}">${esc(r.evidence)}</span>` : '<span class="dim">—</span>') },
566
654
  { key: "session", label: "session", width: 140, get: (r) => sess(r.sessionId)?.title ?? "", cell: (r) => (r.sessionId ? `<a href="#" data-s="${r.sessionId}">${esc(sess(r.sessionId)?.title ?? r.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
567
655
  ];
568
- return `<h2 class="mt-sec">Recent gates <span>${runs.length} run${runs.length === 1 ? "" : "s"}${required.length ? ` · required: ${required.map(esc).join(", ")}` : ""} · latest run per gate decides</span></h2>` +
656
+ const history = (gate) => {
657
+ const rs = runs.filter((r) => r.gate === gate).slice(0, 12).reverse();
658
+ if (!rs.length) return "";
659
+ return `<span class="gh" title="${esc(gate)} — last ${rs.length} run${rs.length === 1 ? "" : "s"}, oldest first">${esc(gate)} ${rs.map((r) => `<i class="${r.verdict === "pass" ? "ok" : "bad"}" title="${esc(r.rubric)}"></i>`).join("")}</span>`;
660
+ };
661
+ const gateNames = [...new Set(runs.map((r) => r.gate))];
662
+ return `<h2 class="mt-sec">Recent gates <span>${runs.length} run${runs.length === 1 ? "" : "s"}${required.length ? ` · required: ${required.map(esc).join(", ")}` : ""} · latest run per gate decides</span>${gateNames.length ? `<span class="grow"></span><span class="gh-strip">${gateNames.map(history).join("")}</span>` : ""}</h2>` +
569
663
  (runs.length
570
664
  ? dataTable({
571
665
  id: "gates",
@@ -600,16 +694,35 @@ function renderTasks() {
600
694
  ...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
601
695
  ];
602
696
  const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
697
+ const lane = (t) => (t.claimedBy ? "held" : t.status === "done" ? "done" : t.ready ? "ready" : t.status === "active" ? "held" : "blocked");
698
+ const card = (t) => `<div class="tcard ${lane(t)}" tabindex="0" role="button" data-menu="task" data-ctx="task" data-task="${esc(t.id)}" title="${esc(t.statusText)}">
699
+ <div class="tc-h"><b>${esc(t.id)}</b>${t.claimedBy ? `<span class="badge ok">${esc(t.claimedBy)}</span>` : ""}${t.depends.length && lane(t) === "blocked" ? `<span class="dim">← ${esc(t.depends.join(" "))}</span>` : ""}</div>
700
+ <div class="tc-t">${esc(t.title)}</div>
701
+ ${t.milestone ? `<div class="tc-m">${esc(t.milestone.split(" — ")[0])}</div>` : ""}
702
+ ${(t.gates ?? []).some((g) => g.verdict) ? `<div class="tc-g">${gateChips(t.gates)}</div>` : ""}
703
+ </div>`;
704
+ const kanban = () => {
705
+ const lanes = [["ready", "Ready"], ["held", "In progress"], ["blocked", "Blocked"], ["done", "Done"]];
706
+ const by = Object.fromEntries(lanes.map(([k]) => [k, []]));
707
+ for (const t of all) by[lane(t)].push(t);
708
+ by.done.reverse();
709
+ const CAP = 6;
710
+ return `<div class="kanban">${lanes.map(([k, label]) => {
711
+ const list = by[k];
712
+ const shown = k === "done" ? list.slice(0, CAP) : list;
713
+ return `<div class="lane ${k}"><div class="lane-h">${label} <span>${list.length}</span></div>${shown.map(card).join("") || '<div class="lane-empty">—</div>'}${list.length > shown.length ? `<div class="lane-more dim">+${list.length - shown.length} more in the table</div>` : ""}</div>`;
714
+ }).join("")}</div>`;
715
+ };
603
716
  return `<h2 class="mt-sec">Tasks <span>${ready.length} ready · ${all.length} in ${esc(srcLabel)}${state.tasks.error ? ` · <span class="badge warn" title="${esc(state.tasks.error)}">${ic("warning", 12)} ${esc(state.tasks.error)}</span>` : ""}</span></h2>` +
604
- `<div class="chips">${chip("ready", "Ready", ready.length)}${chip("open", "Open", all.filter((t) => t.status !== "done").length)}${chip("all", "All", all.length)}${ready.length ? `<span class="chip" id="dispatch" title="Claim a worktree per ready task and spawn a run in each, ${state.dispatch?.config?.max_parallel ?? 2} at a time">${ic("play", 12)} Dispatch</span>` : ""}</div>` +
605
- (rows.length
717
+ `<div class="chips">${boardMode("tasks") === "cards" ? "" : chip("ready", "Ready", ready.length) + chip("open", "Open", all.filter((t) => t.status !== "done").length) + chip("all", "All", all.length)}${ready.length ? `<span class="chip" id="dispatch" title="Claim a worktree per ready task and spawn a run in each, ${state.dispatch?.config?.max_parallel ?? 2} at a time">${ic("play", 12)} Dispatch</span>` : ""}<span class="grow"></span>${modeSeg("tasks")}</div>` +
718
+ (all.length && boardMode("tasks") === "cards" ? kanban() : rows.length
606
719
  ? dataTable({
607
720
  id: "tasks",
608
721
  columns: cols,
609
722
  rows,
610
723
  leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
611
- trailing: { width: 170, cell: (t) => (t.ready ? `<a href="#" data-run="${esc(t.id)}" title="Claim and spawn claude -p in a worktree">${ic("play", 12)} Run</a> · <a href="#" data-claim="${esc(t.id)}" title="Claim into a fresh worktree">Claim</a>` : t.claimedBy ? `<a href="#" data-run="${esc(t.id)}" title="Spawn claude -p in the held worktree">${ic("play", 12)} Run</a>${(state.gates?.executable ?? []).length ? ` · <a href="#" data-gaterun="${esc(t.id)}" title="Execute the repo's [gates.<name>] cmd gates in this task's worktree: ${esc((state.gates.executable ?? []).join(", "))}">${ic("check", 12)} Gates</a>` : ""}` : "") },
612
- rowAttrs: () => "",
724
+ trailing: { width: 34, cell: (t) => (t.ready || t.claimedBy ? more("task", `data-task="${esc(t.id)}"`) : "") },
725
+ rowAttrs: (t) => `data-ctx="task" data-task="${esc(t.id)}"`,
613
726
  rerender: touch,
614
727
  })
615
728
  : `<div class="empty">${PX.idle()}${state.taskFilter === "ready" ? "Nothing ready — every open task is blocked or held." : "No tasks."}</div>`);
@@ -623,7 +736,7 @@ function renderClaims() {
623
736
  const badge = (st) => st === "orphaned" ? '<span class="badge warn">Orphaned · holds work</span>' : st === "expired" ? '<span class="badge acc">Expired</span>' : '<span class="badge ok">Held</span>';
624
737
  const orphans = rows.filter((c) => c.state === "orphaned").length;
625
738
  const cols = [
626
- { key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => esc(projName(c.projectId)) },
739
+ { key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => projCell(c.projectId) },
627
740
  { key: "task", label: "task", width: 140, get: (c) => c.task, cell: (c) => `<b>${esc(c.task)}</b>` },
628
741
  { key: "owner", label: "owner", width: 120, get: (c) => c.owner || "", cell: (c) => esc(c.owner || "—") },
629
742
  { key: "lease", label: "lease", width: 130, get: (c) => (c.state === "held" ? new Date(c.expiresAt).getTime() : 0), cell: (c) => `<span class="dim">${c.state === "held" ? leaseLeft(c.expiresAt) : "—"}</span>` },
@@ -636,12 +749,8 @@ function renderClaims() {
636
749
  columns: cols,
637
750
  rows,
638
751
  leading: { width: 24, cell: (c) => `<span class="s ${c.state === "orphaned" ? "waiting" : c.state === "expired" ? "idle" : "active"}"></span>` },
639
- trailing: { width: 120, cell: (c) => {
640
- const key = `${c.projectId}:${c.task}`;
641
- return c.state === "orphaned"
642
- ? `<a href="#" data-forcerelease="${key}" title="Discards the worktree AND its uncommitted work">Force release</a>`
643
- : `<a href="#" data-release="${key}">Release</a>`;
644
- } },
752
+ trailing: { width: 34, cell: (c) => more("claim", `data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`) },
753
+ rowAttrs: (c) => `data-ctx="claim" data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`,
645
754
  rerender: touch,
646
755
  });
647
756
  }
@@ -660,7 +769,7 @@ function renderWorktrees() {
660
769
  const inside = (w) => byPath.get(w.path);
661
770
  const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
662
771
  const cols = [
663
- { key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => esc(projName(w.projectId)) },
772
+ { key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => projCell(w.projectId) },
664
773
  { key: "branch", label: "branch", width: 240, get: (w) => w.branch ?? "", cell: (w) => `<span class="br">${esc(w.branch ?? "(detached)")}</span>${w.main ? ' <span class="badge">Main tree</span>' : ""}` },
665
774
  { key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
666
775
  { key: "path", label: "path", flex: true, get: (w) => w.path, cell: (w) => `<span class="now" title="${esc(w.path)}">${esc(short(w.path))}</span>` },
@@ -669,24 +778,59 @@ function renderWorktrees() {
669
778
  { key: "sessions", label: "sessions", width: 160, get: (w) => inside(w).length, cell: (w) => inside(w).map((x) => `<a href="#" data-s="${x.id}">${esc(x.title ?? x.id.slice(0, 8))}</a>`).join(", ") || '<span class="dim">—</span>' },
670
779
  ].filter((c) => !(c.key === "project" && state.sel));
671
780
  const heldBy = new Map(state.claims ? state.claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]) : []);
672
- const actions = (w) => {
673
- const key = `${w.projectId}:${w.path}`;
674
- const open = `<a href="#" data-wtopen="${esc(key)}" title="Open this worktree (editor / file manager; [worktree] open in .swarm.toml)">${ic("arrow-square-out", 12)} Open</a>`;
675
- if (w.main) return open;
676
- const diff = ` · <a href="#" data-wtdiff="${esc(key)}" title="What this worktree changed vs the main checkout's branch">${ic("folders", 12)} Diff</a>`;
677
- const pr = w.branch && !w.merged ? ` · <a href="#" data-wtpr="${esc(key)}" title="Push the branch and open a PR prefilled from the task, handoff, gates and files">${ic("git-pull-request", 12)} PR</a>` : "";
678
- if (heldBy.has(w.path)) return `${open}${diff}${pr}`;
679
- return `${open}${diff}${pr} · <a href="#" data-wtrm="${esc(key)}" title="${w.dirty > 0 || w.ahead > 0 ? "Refuses while dirty / unpushed (you can force)" : "git worktree remove"}">${ic("trash", 12)} Remove</a>`;
680
- };
681
781
  const gcBtn = state.sel ? ` <a href="#" class="nav" id="wtgc" title="Find worktrees whose branch is merged or whose claim is gone">${ic("trash", 12)} Collect stale</a>` : "";
682
782
  const newBtn = state.sel ? ` <a href="#" class="nav" id="wtnew" title="Create a task-less worktree (spike, review checkout)">${ic("plus", 12)} New worktree</a>` : "";
683
- return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}</h2>` +
783
+ const stateOf = (w) => (inside(w).length ? "live" : w.dirty > 0 ? "dirty" : w.ahead > 0 ? "ahead" : w.merged ? "merged" : "clean");
784
+ const tile = (w) => `<div class="wt ${stateOf(w)}${w.main ? " main" : ""}${heldBy.has(w.path) ? " held" : ""}" tabindex="0" role="button" data-menu="worktree" data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}" title="${esc(w.path)}">
785
+ <div class="wt-b"><span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span><span class="br">${esc(w.branch ?? "(detached)")}</span></div>
786
+ <div class="wt-m">${w.main ? "main tree" : w.merged ? "merged" : w.behind > 0 ? `${w.behind} behind` : w.behind === 0 ? "up to date" : ""}${w.dirty ? ` · <i class="warn">${w.dirty} dirty</i>` : ""}${w.ahead > 0 ? ` · <i class="acc">${w.ahead} unpushed</i>` : ""}${heldBy.has(w.path) ? ` · held: ${esc(heldBy.get(w.path))}` : ""}${inside(w).length ? ` · ${inside(w).map((x) => esc(x.title ?? x.id.slice(0, 8))).join(", ")}` : ""}</div>
787
+ </div>`;
788
+ const map = () => {
789
+ const groups = new Map();
790
+ for (const w of rows) (groups.get(w.projectId) ?? groups.set(w.projectId, []).get(w.projectId)).push(w);
791
+ const order = { live: 0, dirty: 1, ahead: 2, clean: 3, merged: 4 };
792
+ return `<div class="wtmap">${[...groups].map(([pid, list]) => `<div class="wt-group"><div class="wt-proj">${projCell(pid)} <span>${list.length}</span></div><div class="wt-tiles">${list.sort((a, b) => (b.main - a.main) || order[stateOf(a)] - order[stateOf(b)]).map(tile).join("")}</div></div>`).join("")}</div>`;
793
+ };
794
+ return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}<span class="grow"></span>${modeSeg("worktrees", "Map", "Table")}</h2>` +
795
+ (boardMode("worktrees") === "cards" ? map() :
684
796
  dataTable({
685
797
  id: "worktrees",
686
798
  columns: cols,
687
799
  rows,
688
800
  leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
689
- trailing: { width: 230, cell: actions },
801
+ trailing: { width: 34, cell: (w) => more("worktree", `data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`) },
802
+ rowAttrs: (w) => `data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`,
803
+ rerender: touch,
804
+ }));
805
+ }
806
+
807
+ // ---------- workflows (M7.8)
808
+ function renderWorkflowRuns() {
809
+ const w = state.workflows;
810
+ if (!state.sel || !w?.runs?.length) return "";
811
+ const chip = (r, i) => {
812
+ const label = esc(r.steps[i]);
813
+ if (i < r.step || (r.state === "done" && i <= r.step)) return `<span class="wfs ok" title="${label}">✓ ${label}</span>`;
814
+ if (i === r.step) return r.state === "running" ? `<span class="wfs run" title="${label}">● ${label}</span>` : r.state === "failed" ? `<span class="wfs bad" title="${label}">✗ ${label}</span>` : `<span class="wfs" title="${label}">◦ ${label}</span>`;
815
+ return `<span class="wfs" title="${label}">○ ${label}</span>`;
816
+ };
817
+ const badge = (r) => r.state === "running" ? '<span class="badge acc">Running</span>' : r.state === "done" ? '<span class="badge ok">Done</span>' : r.state === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge">Stopped</span>';
818
+ const cols = [
819
+ { key: "task", label: "task", width: 90, get: (r) => r.task, cell: (r) => `<b>${esc(r.task)}</b>` },
820
+ { key: "workflow", label: "workflow", width: 100, get: (r) => r.workflow, cell: (r) => `<span class="br">${esc(r.workflow)}</span>` },
821
+ { key: "steps", label: "steps", flex: true, sortable: false, get: (r) => r.step, cell: (r) => `<span class="wf-steps">${r.steps.map((_, i) => chip(r, i)).join("")}</span>` },
822
+ { key: "state", label: "state", width: 90, get: (r) => r.state, cell: badge },
823
+ { key: "detail", label: "detail", width: 260, get: (r) => r.detail ?? "", cell: (r) => `<span class="dim now" title="${esc(r.detail ?? "")}">${esc(r.detail ?? "")}</span>` },
824
+ { key: "when", label: "updated", width: 76, get: (r) => r.updatedAt, cell: (r) => `<span class="dim">${ago(r.updatedAt)}</span>` },
825
+ ];
826
+ const running = w.runs.filter((r) => r.state === "running").length;
827
+ return `<h2 class="mt-sec">Workflows <span>${running ? `${running} running · ` : ""}${Object.keys(w.defs ?? {}).map(esc).join(", ") || "none declared"}</span></h2>` +
828
+ dataTable({
829
+ id: "workflows",
830
+ columns: cols,
831
+ rows: w.runs.slice(0, 20),
832
+ leading: { width: 24, cell: (r) => `<span class="s ${r.state === "running" ? "active" : r.state === "failed" ? "waiting" : "ended"}"></span>` },
833
+ trailing: { width: 60, cell: (r) => (r.state === "running" ? `<a href="#" data-wfstop="${esc(r.task)}">Stop</a>` : "") },
690
834
  rerender: touch,
691
835
  });
692
836
  }
@@ -812,7 +956,6 @@ function renderAttribution() {
812
956
  { key: "worktree", label: "worktree", flex: true, get: (t) => t.worktree, cell: (t) => `<span class="now dim" title="${esc(t.worktree)}">${esc(short(t.worktree))}</span>` },
813
957
  ],
814
958
  rows: a.byTask,
815
- leading: { width: 20, cell: () => "" },
816
959
  trailing: { width: 8, cell: () => "" },
817
960
  rerender: touch,
818
961
  }));
@@ -829,7 +972,6 @@ function renderAttribution() {
829
972
  { key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
830
973
  ],
831
974
  rows: a.contextBudget,
832
- leading: { width: 20, cell: () => "" },
833
975
  trailing: { width: 8, cell: () => "" },
834
976
  rerender: touch,
835
977
  }));
@@ -882,6 +1024,12 @@ async function runSearch() {
882
1024
  srch.hits = j.hits ?? [];
883
1025
  if (state.view === "search" && !state.session) renderSearch();
884
1026
  }
1027
+ document.addEventListener("change", async (ev) => {
1028
+ if (ev.target.id !== "psFile" || !ev.target.files?.[0]) return;
1029
+ try { const d = await fileToIconDataUrl(ev.target.files[0]); $("#psImage").value = d; $("#psIcon").value = ""; setIconPreview(d); for (const e of $$(".emoji")) e.classList.remove("on"); }
1030
+ catch (e) { alert(e.message); }
1031
+ });
1032
+ 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()); } });
885
1033
  document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
886
1034
  function renderStats() {
887
1035
  const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
@@ -974,7 +1122,22 @@ function renderStats() {
974
1122
  }
975
1123
 
976
1124
  // ---------- timeline
1125
+ let tlDetail = { key: "", data: null, busy: false };
1126
+ async function loadTimelineDetail() {
1127
+ const hours = state.tlHours ?? 12;
1128
+ const key = `${hours}:${state.sel ?? ""}`;
1129
+ if (tlDetail.busy || (tlDetail.key === key && tlDetail.at && Date.now() - tlDetail.at < 15_000)) return;
1130
+ tlDetail.busy = true;
1131
+ try {
1132
+ const q = new URLSearchParams({ hours: String(hours) });
1133
+ if (state.sel) q.set("project", state.sel);
1134
+ const data = await (await fetch(`/v1/timeline?${q}`)).json();
1135
+ tlDetail = { key, at: Date.now(), data, busy: false };
1136
+ if (state.view === "timeline" && !state.session) touch();
1137
+ } finally { tlDetail.busy = false; }
1138
+ }
977
1139
  function renderTimeline() {
1140
+ loadTimelineDetail();
978
1141
  const now = Date.now();
979
1142
  const hours = state.tlHours ?? 12;
980
1143
  const from = now - hours * 3.6e6, to = now + 0.25 * 3.6e6;
@@ -983,7 +1146,7 @@ function renderTimeline() {
983
1146
  const chip = (h) => `<a href="#" class="nav ${hours === h ? "on" : ""}" data-tl="${h}">${h}h</a>`;
984
1147
  $("#main").innerHTML =
985
1148
  `<h2>Timeline <span>${rows.length} sessions · last ${hours}h · ${usd(sumBy(rows, (s) => s.costUsd))}</span><span style="margin-left:auto;display:flex;gap:2px">${[3, 6, 12, 24, 72].map(chip).join("")}</span></h2>
986
- ${rows.length ? viz.timeline(rows, { from, to, projName, now }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
1149
+ ${rows.length ? viz.timeline(rows, { from, to, projName, now, detail: tlDetail.key === `${hours}:${state.sel ?? ""}` ? tlDetail.data : null }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
987
1150
  ${agents.length ? `<div style="margin-top:10px">${viz.legend(agents)}</div>` : ""}`;
988
1151
  }
989
1152
 
@@ -1100,6 +1263,18 @@ function replayGo(delta) {
1100
1263
  }
1101
1264
 
1102
1265
  // 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.
1267
+ function messageThread(s) {
1268
+ const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
1269
+ const row = (m) => {
1270
+ const out = m.fromSession === s.id;
1271
+ return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
1272
+ <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
+ };
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>`;
1276
+ }
1277
+
1103
1278
  // M7.7: questions this session is waiting on a human for
1104
1279
  function questionCards(s) {
1105
1280
  const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
@@ -1159,13 +1334,14 @@ function renderSession() {
1159
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>`;
1160
1335
  const side = `<div class="stats">
1161
1336
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
1162
- ${stat("output", `${tok(t.output)}${t.thinking ? `<small> · ${tok(t.thinking)} thinking</small>` : ""}`)}${stat("context", `${tok(ctx)}<small> · ${ctx ? ((100 * t.cacheRead) / ctx).toFixed(0) : 0}% cached</small>`)}
1337
+ ${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>`)}
1163
1338
  ${stat("started", `${ago(s.startedAt)} ago`)}${stat("last seen", `${ago(s.lastSeenAt)} ago`)}
1164
1339
  ${subTurns.length ? stat("subagent turns", subTurns.length) : ""}
1165
1340
  </div>
1166
1341
  <h4>tokens</h4>${viz.compositionBar([{ label: "cache read", v: t.cacheRead }, { label: "cache write", v: t.cacheWrite }, { label: "input", v: t.input }, { label: "thinking", v: t.thinking }, { label: "output", v: t.output }])}
1167
1342
  ${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
1168
1343
  <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
+ ${messageThread(s)}
1169
1345
  ${questionCards(s)}
1170
1346
  ${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
1171
1347
  if (logEl && isAppend(rows)) {
@@ -1192,6 +1368,63 @@ function renderSession() {
1192
1368
  // ---------- menus (fancy-menus island; see src/menus.tsx). Menus are plain data.
1193
1369
  const pinProject = (id, pinned) => fetch(`/v1/projects/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) }).then(refresh);
1194
1370
  const removeProject = (id) => fetch(`/v1/projects/${id}`, { method: "DELETE" }).then(refresh);
1371
+ // ---------- row actions (shared by the row menus, right-click, and any remaining links)
1372
+ const post = (url, body) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
1373
+ const act = {
1374
+ async wtOpen(projectId, worktree) { const r = await post("/v1/worktrees/open", { projectId, worktree }); if (!r.ok) alert(r.error); },
1375
+ wtDiff(projectId, worktree) { openDiffDrawer(projectId, worktree); },
1376
+ wtPr(projectId, worktree) { openPrDrawer(projectId, worktree); },
1377
+ async wtRemove(projectId, worktree) {
1378
+ const rm = (force) => post("/v1/worktrees/remove", { projectId, worktree, force });
1379
+ if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
1380
+ const r = await rm(false);
1381
+ if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
1382
+ if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
1383
+ } else if (!r.ok) alert(r.error);
1384
+ state.worktrees[projectId] = null;
1385
+ refresh();
1386
+ },
1387
+ async claimTask(task) {
1388
+ const r = await post("/v1/claims", { projectId: state.sel, task, owner: "dashboard" });
1389
+ if (!r.ok) alert(r.error); else state.tasks = null;
1390
+ refresh();
1391
+ },
1392
+ runTask(task) { openRunDrawer(task); },
1393
+ async gateRun(task) {
1394
+ const r = await post("/v1/gates/run", { projectId: state.sel, task });
1395
+ if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
1396
+ else alert(`${task}: ${r.runs.map((x) => `${x.verdict === "pass" ? "✓" : "✗"} ${x.gate} — ${x.rubric}`).join("\n")}${r.skipped.length ? `\n\nskipped: ${r.skipped.map((x) => `${x.gate} (${x.reason})`).join(", ")}` : ""}`);
1397
+ state.tasks = null;
1398
+ refresh();
1399
+ },
1400
+ async releaseClaim(projectId, task, force) {
1401
+ if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
1402
+ const r = await post("/v1/claims/release", { projectId, task, force });
1403
+ if (!r.ok && confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) await post("/v1/claims/release", { projectId, task, force: true });
1404
+ refresh();
1405
+ },
1406
+ async merge(projectId, number) {
1407
+ if (!confirm(`Squash-merge #${number}?`)) return;
1408
+ const r = await post("/v1/prs/merge", { projectId, number: Number(number) });
1409
+ if (r.ok === false || r.error) alert(r.error);
1410
+ refresh();
1411
+ },
1412
+ async procStop(pid, projectId) {
1413
+ if (!confirm(`Stop pid ${pid}?`)) return;
1414
+ const r = await fetch(`/v1/processes/${pid}?project=${encodeURIComponent(projectId)}`, { method: "DELETE" });
1415
+ if (!r.ok) alert((await r.json()).error);
1416
+ refresh();
1417
+ },
1418
+ resRelease(name, projectId) {
1419
+ const q = new URLSearchParams({ force: "1" }); if (projectId) q.set("project", projectId);
1420
+ return fetch(`/v1/resources/${encodeURIComponent(name)}?${q}`, { method: "DELETE" }).then(refresh);
1421
+ },
1422
+ ack(seq) { return fetch(`/v1/incidents/${seq}/ack`, { method: "POST" }).then(refresh); },
1423
+ codify(seq) { codifyIncident(seq); },
1424
+ };
1425
+ /** Hover kebab that opens the row menu `kind`; `attrs` are the data-* the menu needs. */
1426
+ const more = (kind, attrs, title = "Actions") => `<span class="more" tabindex="0" role="button" data-menu="${kind}" ${attrs} title="${title}">${ic("dots-three", 15)}</span>`;
1427
+
1195
1428
  function menuSpec(kind, d) {
1196
1429
  if (kind === "project") {
1197
1430
  const p = state.projects.find((x) => x.id === d.pid);
@@ -1204,7 +1437,8 @@ function menuSpec(kind, d) {
1204
1437
  { label: "Stats", icon: "chart-bar", run: () => { state.sel = p.id; state.view = "stats"; state.session = null; touch(); } },
1205
1438
  { divider: true },
1206
1439
  p.discovered ? { label: "Pin project", icon: "push-pin", run: () => pinProject(p.id, true) } : { label: "Unpin project", icon: "push-pin-slash", run: () => pinProject(p.id, false) },
1207
- { label: "Copy path", icon: "copy", caption: tail(p.root), run: () => copy(p.root) },
1440
+ { label: "Settings…", icon: "sliders", caption: "name · icon · color", run: () => openProjectSettings(p.id) },
1441
+ { label: "Copy path", icon: "copy", caption: tail(p.root, 16), run: () => copy(p.root) },
1208
1442
  { divider: true },
1209
1443
  { label: "Remove from Swarm", icon: "trash", danger: true, run: () => removeProject(p.id) },
1210
1444
  ] };
@@ -1218,9 +1452,91 @@ function menuSpec(kind, d) {
1218
1452
  { divider: true },
1219
1453
  { section: "Copy" },
1220
1454
  { label: "Session id", icon: "copy", caption: s.id.slice(0, 8), run: () => copy(s.id) },
1221
- { label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 18), run: () => copy(s.cwd) },
1455
+ { label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 16), run: () => copy(s.cwd) },
1222
1456
  ...(s.transcriptPath ? [{ label: "Transcript path", icon: "file-text", run: () => copy(s.transcriptPath) }] : []),
1223
- ...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 18), run: () => copy(s.branch) }] : []),
1457
+ ...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 16), run: () => copy(s.branch) }] : []),
1458
+ ] };
1459
+ }
1460
+ if (kind === "worktree") {
1461
+ const w = (state.worktrees[d.pid] ?? []).find((x) => x.path === d.path);
1462
+ if (!w) return null;
1463
+ const held = (state.claims ?? []).some((c) => c.state === "held" && c.worktree === w.path);
1464
+ const sess = state.sessions.filter((x) => x.state !== "ended" && (x.cwd === w.path || x.cwd.startsWith(`${w.path}/`)));
1465
+ return { title: w.branch ?? "(detached)", items: [
1466
+ { label: "Open", icon: "arrow-square-out", caption: "editor", run: () => act.wtOpen(d.pid, w.path) },
1467
+ ...(w.main ? [] : [{ label: "Diff", icon: "folders", caption: "vs main", run: () => act.wtDiff(d.pid, w.path) }]),
1468
+ ...(w.branch && !w.merged && !w.main ? [{ label: "Open PR", icon: "git-pull-request", run: () => act.wtPr(d.pid, w.path) }] : []),
1469
+ ...(sess.length ? [{ divider: true }, { section: "Sessions" }, ...sess.map((x) => ({ label: x.title ?? x.id.slice(0, 8), icon: "terminal-window", run: () => openSession(x.id) }))] : []),
1470
+ { divider: true },
1471
+ { label: "Copy path", icon: "copy", caption: tail(w.path, 14), run: () => copy(w.path) },
1472
+ ...(w.branch ? [{ label: "Copy branch", icon: "git-branch", caption: tail(w.branch, 14), run: () => copy(w.branch) }] : []),
1473
+ ...(w.main || held ? [] : [{ divider: true }, { label: "Remove", icon: "trash", danger: true, caption: w.dirty > 0 ? "dirty" : w.ahead > 0 ? "unpushed" : undefined, run: () => act.wtRemove(d.pid, w.path) }]),
1474
+ ] };
1475
+ }
1476
+ if (kind === "task") {
1477
+ const t = (state.tasks?.tasks ?? []).find((x) => x.id === d.task);
1478
+ if (!t) return null;
1479
+ const exec = state.gates?.executable ?? [];
1480
+ return { title: t.id, items: [
1481
+ ...(t.ready ? [
1482
+ { label: "Run", icon: "play", caption: "claim + claude -p", run: () => act.runTask(t.id) },
1483
+ { label: "Claim", icon: "folders", caption: "fresh worktree", run: () => act.claimTask(t.id) },
1484
+ ] : t.claimedBy ? [
1485
+ { label: "Run in worktree", icon: "play", run: () => act.runTask(t.id) },
1486
+ ...(exec.length ? [{ label: "Run gates", icon: "check", caption: exec.join(", "), run: () => act.gateRun(t.id) }] : []),
1487
+ ] : [{ label: t.status === "done" ? "Done" : "Blocked", disabled: true }]),
1488
+ { divider: true },
1489
+ { label: "Copy id", icon: "copy", caption: t.id, run: () => copy(t.id) },
1490
+ { label: "Copy title", icon: "file-text", run: () => copy(`${t.id} — ${t.title}`) },
1491
+ ] };
1492
+ }
1493
+ if (kind === "claim") {
1494
+ const c = (state.claims ?? []).find((x) => x.projectId === d.pid && x.task === d.task);
1495
+ if (!c) return null;
1496
+ const w = (state.worktrees[c.projectId] ?? []).find((x) => x.path === c.worktree);
1497
+ return { title: c.task, items: [
1498
+ ...(w ? [{ label: "Open worktree", icon: "arrow-square-out", run: () => act.wtOpen(c.projectId, c.worktree) }, { label: "Diff", icon: "folders", run: () => act.wtDiff(c.projectId, c.worktree) }] : []),
1499
+ { label: "Copy path", icon: "copy", caption: tail(c.worktree, 14), run: () => copy(c.worktree) },
1500
+ { divider: true },
1501
+ c.state === "orphaned"
1502
+ ? { label: "Force release", icon: "trash", danger: true, caption: "discards work", run: () => act.releaseClaim(c.projectId, c.task, true) }
1503
+ : { label: "Release claim", icon: "x", run: () => act.releaseClaim(c.projectId, c.task, false) },
1504
+ ] };
1505
+ }
1506
+ if (kind === "pr") {
1507
+ const p = (state.prs ?? []).find((x) => String(x.projectId) === d.pid && String(x.number) === d.num);
1508
+ if (!p) return null;
1509
+ const green = p.checks !== "fail" && p.mergeable && !p.draft;
1510
+ return { title: `#${p.number}`, items: [
1511
+ { label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => window.open(p.url, "_blank") },
1512
+ { label: "Copy URL", icon: "copy", run: () => copy(p.url) },
1513
+ { divider: true },
1514
+ { 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) },
1515
+ ] };
1516
+ }
1517
+ if (kind === "process") {
1518
+ return { items: [
1519
+ { label: "Copy pid", icon: "copy", caption: d.pid, run: () => copy(d.pid) },
1520
+ ...(d.cwd ? [{ label: "Copy cwd", icon: "folder-simple", caption: tail(d.cwd, 16), run: () => copy(d.cwd) }] : []),
1521
+ { divider: true },
1522
+ { label: "Stop", icon: "stop", danger: true, caption: "SIGTERM → SIGKILL", run: () => act.procStop(d.pid, d.proj) },
1523
+ ] };
1524
+ }
1525
+ if (kind === "resource") {
1526
+ return { title: d.name, items: [
1527
+ { label: "Copy name", icon: "copy", run: () => copy(d.name) },
1528
+ { divider: true },
1529
+ { label: "Release", icon: "x", danger: true, caption: "force", run: () => act.resRelease(d.name, d.proj) },
1530
+ ] };
1531
+ }
1532
+ if (kind === "incident") {
1533
+ const i = [...(state.incidents ?? []), ...(state.allIncidents ?? [])].find((x) => String(x.seq) === d.seq);
1534
+ if (!i) return null;
1535
+ return { items: [
1536
+ ...(i.sessionId ? [{ label: "Open session", icon: "terminal-window", run: () => openSession(i.sessionId) }] : []),
1537
+ ...(i.suggestion ? [{ label: "Codify", icon: "shield", caption: "rule / lesson", run: () => act.codify(i.seq) }] : []),
1538
+ { label: "Copy command", icon: "copy", run: () => copy(i.command ?? "") },
1539
+ ...(i.acked ? [] : [{ divider: true }, { label: "Acknowledge", icon: "check", run: () => act.ack(i.seq) }]),
1224
1540
  ] };
1225
1541
  }
1226
1542
  if (kind === "settings") {
@@ -1302,6 +1618,33 @@ function whatsNew(version) {
1302
1618
  }
1303
1619
  window.swarmWhatsNew = (v) => whatsNew(v);
1304
1620
  // auto-open once per version, but never on the very first run (nothing to compare against)
1621
+ // M-launch: after an update the running daemon is the old build until it restarts. The daemon
1622
+ // reports the version on disk; when it differs, offer a one-click restart, then reload.
1623
+ let updateNudged = false;
1624
+ setInterval(() => { fetch("/v1/health").then((r) => r.json()).then(maybeUpdateNudge).catch(() => {}); }, 300_000);
1625
+ function maybeUpdateNudge(h) {
1626
+ if (!h?.disk || !h.version || h.disk === h.version || updateNudged) return;
1627
+ updateNudged = true;
1628
+ const el = document.createElement("div");
1629
+ el.className = "nudge";
1630
+ el.innerHTML = `${ic("arrows-clockwise", 18, "ic")}<div><b>Swarm ${esc(h.disk)} is installed</b>The daemon is still running ${esc(h.version)} — restart it to switch. Sessions and history are unaffected.
1631
+ <div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
1632
+ document.body.appendChild(el);
1633
+ 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…";
1637
+ await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
1638
+ const t0 = Date.now();
1639
+ const wait = setInterval(async () => {
1640
+ try {
1641
+ const j = await (await fetch("/v1/health")).json();
1642
+ if (j.version === h.disk) { clearInterval(wait); location.reload(); }
1643
+ } catch {}
1644
+ if (Date.now() - t0 > 30_000) { clearInterval(wait); el.remove(); }
1645
+ }, 800);
1646
+ });
1647
+ }
1305
1648
  function maybeWhatsNew() {
1306
1649
  if (!state.version || !window.RELEASE_NOTES) return;
1307
1650
  let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
@@ -1356,6 +1699,17 @@ function openMenu(kind, anchor, d) {
1356
1699
  if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
1357
1700
  window.menus.open(anchor, spec);
1358
1701
  }
1702
+ document.addEventListener("keydown", (e) => {
1703
+ if (e.key === "Enter" && e.target.id === "msgText") { e.preventDefault(); $("#msgSend")?.click(); }
1704
+ });
1705
+ // Enter / Space on a focused card, tile or kebab opens its menu like a click.
1706
+ document.addEventListener("keydown", (ev) => {
1707
+ if (ev.key !== "Enter" && ev.key !== " ") return;
1708
+ const t = ev.target.closest?.("[data-menu]");
1709
+ if (!t || t.tagName === "INPUT") return;
1710
+ ev.preventDefault();
1711
+ openMenu(t.dataset.menu, t, t.dataset);
1712
+ });
1359
1713
  document.addEventListener("contextmenu", (ev) => {
1360
1714
  const t = ev.target.closest("[data-ctx]");
1361
1715
  if (!t) return;
@@ -1365,7 +1719,7 @@ document.addEventListener("contextmenu", (ev) => {
1365
1719
 
1366
1720
  // ---------- events
1367
1721
  document.addEventListener("click", async (ev) => {
1368
- 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],#dispatch,#dispatchGo,#dispatchClear");
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");
1369
1723
  if (!t) return;
1370
1724
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1371
1725
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
@@ -1373,27 +1727,40 @@ document.addEventListener("click", async (ev) => {
1373
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(); }
1374
1728
  if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
1375
1729
  if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
1376
- if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
1377
- if (t.dataset.runstop) {
1730
+ 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; }
1731
+ if (t.id === "psAllEmoji") { const all = $("#psEmojiAll"); if (all.hidden) { all.innerHTML = buildEmojiGrid(); all.hidden = false; } else all.hidden = true; return; }
1732
+ if (t.dataset.color !== undefined && t.classList.contains("swatch")) { for (const e of $$(".swatch")) e.classList.toggle("on", e === t); return; }
1733
+ if (t.id === "msgSend") {
1378
1734
  ev.preventDefault();
1379
- if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
1380
- return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
1735
+ const text = $("#msgText")?.value.trim();
1736
+ if (!text) return;
1737
+ const r = await post("/v1/messages", { projectId: t.dataset.pid, to: t.dataset.sid, text, from: "dashboard" });
1738
+ if (!r.ok) return alert(r.error);
1739
+ $("#msgText").value = "";
1740
+ state.msgs = null;
1741
+ return refresh();
1381
1742
  }
1382
- if (t.dataset.claim) {
1743
+ if (t.id === "psSave") { ev.preventDefault(); return saveProjectSettings(t.dataset.pid); }
1744
+ if (t.dataset.wfstop !== undefined) {
1383
1745
  ev.preventDefault();
1384
- const r = await fetch("/v1/claims", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, task: t.dataset.claim, owner: "dashboard" }) }).then((x) => x.json());
1385
- if (!r.ok) alert(r.error); else state.tasks = null;
1746
+ if (!confirm(`Stop the workflow on ${t.dataset.wfstop}? A live step's run is stopped too.`)) return;
1747
+ const r = await post("/v1/workflows/stop", { projectId: state.sel, task: t.dataset.wfstop });
1748
+ if (!r.ok) alert(r.error);
1749
+ state.workflows = null;
1386
1750
  return refresh();
1387
1751
  }
1388
- if (t.dataset.wtopen) {
1752
+ if (t.dataset.bmode) { ev.preventDefault(); const [k, v] = t.dataset.bmode.split(":"); localStorage.setItem(`swarm.board.${k}`, v); return touch(); }
1753
+ if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
1754
+ if (t.dataset.runstop) {
1389
1755
  ev.preventDefault();
1390
- const i = t.dataset.wtopen.indexOf(":");
1391
- const r = await fetch("/v1/worktrees/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: t.dataset.wtopen.slice(0, i), worktree: t.dataset.wtopen.slice(i + 1) }) }).then((x) => x.json());
1392
- if (!r.ok) alert(r.error);
1393
- return;
1756
+ if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
1757
+ return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
1394
1758
  }
1395
- if (t.dataset.wtdiff) { ev.preventDefault(); const i = t.dataset.wtdiff.indexOf(":"); return openDiffDrawer(t.dataset.wtdiff.slice(0, i), t.dataset.wtdiff.slice(i + 1)); }
1396
- if (t.dataset.wtpr) { ev.preventDefault(); const i = t.dataset.wtpr.indexOf(":"); return openPrDrawer(t.dataset.wtpr.slice(0, i), t.dataset.wtpr.slice(i + 1)); }
1759
+ if (t.dataset.claim) { ev.preventDefault(); return act.claimTask(t.dataset.claim); }
1760
+ const split = (v) => { const i = v.indexOf(":"); return [v.slice(0, i), v.slice(i + 1)]; };
1761
+ if (t.dataset.wtopen) { ev.preventDefault(); return act.wtOpen(...split(t.dataset.wtopen)); }
1762
+ if (t.dataset.wtdiff) { ev.preventDefault(); return act.wtDiff(...split(t.dataset.wtdiff)); }
1763
+ if (t.dataset.wtpr) { ev.preventDefault(); return act.wtPr(...split(t.dataset.wtpr)); }
1397
1764
  if (t.dataset.dffile !== undefined) { ev.preventDefault(); return loadDiffFile(t.dataset.dffile); }
1398
1765
  if (t.id === "prGo") { ev.preventDefault(); return submitPr(); }
1399
1766
  if (t.id === "sessDiff") {
@@ -1403,19 +1770,7 @@ document.addEventListener("click", async (ev) => {
1403
1770
  const w = (state.worktrees[s.projectId] ?? []).find((x) => !x.main && (s.cwd === x.path || s.cwd.startsWith(`${x.path}/`)));
1404
1771
  return w ? openDiffDrawer(s.projectId, w.path) : null;
1405
1772
  }
1406
- if (t.dataset.wtrm) {
1407
- ev.preventDefault();
1408
- const i = t.dataset.wtrm.indexOf(":");
1409
- const projectId = t.dataset.wtrm.slice(0, i), worktree = t.dataset.wtrm.slice(i + 1);
1410
- const rm = (force) => fetch("/v1/worktrees/remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, worktree, force }) }).then((x) => x.json());
1411
- if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
1412
- const r = await rm(false);
1413
- if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
1414
- if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
1415
- } else if (!r.ok) alert(r.error);
1416
- state.worktrees[projectId] = null;
1417
- return refresh();
1418
- }
1773
+ if (t.dataset.wtrm) { ev.preventDefault(); return act.wtRemove(...split(t.dataset.wtrm)); }
1419
1774
  if (t.id === "wtnew") {
1420
1775
  ev.preventDefault();
1421
1776
  const name = prompt("Worktree name (folder under ~/.swarm/worktrees/<project>/; branch wt/<name>):");
@@ -1445,25 +1800,13 @@ document.addEventListener("click", async (ev) => {
1445
1800
  state.dispatch = null;
1446
1801
  return refresh();
1447
1802
  }
1448
- if (t.dataset.gaterun) {
1449
- ev.preventDefault();
1450
- const task = t.dataset.gaterun;
1451
- t.textContent = "running…";
1452
- const r = await fetch("/v1/gates/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, task }) }).then((x) => x.json());
1453
- if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
1454
- else alert(`${task}: ${r.runs.map((x) => `${x.verdict === "pass" ? "✓" : "✗"} ${x.gate} — ${x.rubric}`).join("\n")}${r.skipped.length ? `\n\nskipped: ${r.skipped.map((x) => `${x.gate} (${x.reason})`).join(", ")}` : ""}`);
1455
- state.tasks = null;
1456
- return refresh();
1457
- }
1803
+ if (t.dataset.gaterun) { ev.preventDefault(); return act.gateRun(t.dataset.gaterun); }
1458
1804
  if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
1459
1805
  if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1460
1806
  if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
1461
1807
  if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
1462
1808
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
1463
- if (t.dataset.ack) {
1464
- ev.preventDefault(); ev.stopPropagation();
1465
- return fetch(`/v1/incidents/${t.dataset.ack}/ack`, { method: "POST" }).then(refresh);
1466
- }
1809
+ if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
1467
1810
  if (t.dataset.ackall) {
1468
1811
  return fetch("/v1/incidents/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: state.sel || undefined }) }).then(refresh);
1469
1812
  }
@@ -1471,37 +1814,13 @@ document.addEventListener("click", async (ev) => {
1471
1814
  if (t.dataset.sdays) { ev.preventDefault(); state.statsDays = Number(t.dataset.sdays); return touch(); }
1472
1815
  if (t.dataset.release || t.dataset.forcerelease) {
1473
1816
  ev.preventDefault();
1474
- const force = Boolean(t.dataset.forcerelease);
1475
1817
  const [projectId, task] = (t.dataset.release || t.dataset.forcerelease).split(":");
1476
- if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
1477
- const r = await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force }) }).then((x) => x.json());
1478
- if (!r.ok) {
1479
- if (confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) {
1480
- await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force: true }) });
1481
- }
1482
- }
1483
- return refresh();
1818
+ return act.releaseClaim(projectId, task, Boolean(t.dataset.forcerelease));
1484
1819
  }
1485
1820
  if (t.dataset.agent !== undefined && t.classList.contains("chip")) { state.agentFilter = t.dataset.agent || null; return touch(); }
1486
- if (t.dataset.merge !== undefined) {
1487
- ev.preventDefault();
1488
- const [projectId, number] = t.dataset.merge.split(":");
1489
- if (!confirm(`Squash-merge #${number}?`)) return;
1490
- return fetch("/v1/prs/merge", {
1491
- method: "POST", headers: { "content-type": "application/json" },
1492
- body: JSON.stringify({ projectId, number: Number(number) }),
1493
- }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
1494
- }
1495
- if (t.dataset.procstop) {
1496
- ev.preventDefault();
1497
- if (!confirm(`Stop pid ${t.dataset.procstop}?`)) return;
1498
- return fetch(`/v1/processes/${t.dataset.procstop}?project=${encodeURIComponent(t.dataset.procproj)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
1499
- }
1500
- if (t.dataset.resrelease !== undefined) {
1501
- ev.preventDefault();
1502
- const q = new URLSearchParams({ force: "1" }); if (t.dataset.resproj) q.set("project", t.dataset.resproj);
1503
- return fetch(`/v1/resources/${encodeURIComponent(t.dataset.resrelease)}?${q}`, { method: "DELETE" }).then(refresh);
1504
- }
1821
+ if (t.dataset.merge !== undefined) { ev.preventDefault(); return act.merge(...t.dataset.merge.split(":")); }
1822
+ if (t.dataset.procstop) { ev.preventDefault(); return act.procStop(t.dataset.procstop, t.dataset.procproj); }
1823
+ if (t.dataset.resrelease !== undefined) { ev.preventDefault(); return act.resRelease(t.dataset.resrelease, t.dataset.resproj); }
1505
1824
  if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
1506
1825
  if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
1507
1826
  if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
@@ -1578,6 +1897,87 @@ async function submitRun(taskId) {
1578
1897
  openSession(r.run.sessionId);
1579
1898
  }
1580
1899
 
1900
+ // ---------- project settings drawer
1901
+ const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊", "🤖", "🧠", "🎨", "🔒", "📚", "💬", "🏗️", "🧩", "⚡"];
1902
+ // Every emoji the platform font can draw, by Unicode block — no names, but browseable; the OS picker
1903
+ // (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
1904
+ 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
+ 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;
1910
+ const c = cv.getContext("2d", { willReadFrequently: true });
1911
+ 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); }
1921
+ 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
+ }).join("");
1923
+ return emojiGrid;
1924
+ }
1925
+ function openProjectSettings(pid) {
1926
+ const p = state.projects.find((x) => x.id === pid);
1927
+ if (!p) return;
1928
+ const slots = ["", "c1", "c2", "c3", "c4", "c5", "c6", "c7"];
1929
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1930
+ <div class="pk-h">${ic("sliders", 15)}<b>Project settings</b><span class="dim now" style="flex:1;margin-left:8px">${esc(p.root)}</span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
1931
+ <div class="pk-b">
1932
+ <label>name<input id="psName" value="${esc(p.name)}" maxlength="60" spellcheck="false"></label>
1933
+ <label>icon<div class="icon-row"><span class="pg pg-lg" id="psPreview">${p.icon ? (p.icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(p.icon)}" alt="">` : esc(p.icon)) : ic("folder-simple", 16)}</span><input id="psIcon" value="${esc(p.icon?.startsWith("data:image/") ? "" : (p.icon ?? ""))}" maxlength="4" placeholder="emoji or 1–2 letters · ${navigator.platform.startsWith("Mac") ? "⌃⌘Space" : "Win+."} opens the OS emoji picker" spellcheck="false" autocomplete="off"><label class="btn" title="PNG / JPEG / SVG / WebP — downsized to 64px and stored with the project">${ic("file-text", 13)} Image…<input type="file" id="psFile" accept="image/*" hidden></label></div></label>
1934
+ <input type="hidden" id="psImage" value="${esc(p.icon?.startsWith("data:image/") ? p.icon : "")}">
1935
+ <div class="emoji-row">${PROJECT_EMOJI.map((e) => `<span class="emoji ${p.icon === e ? "on" : ""}" data-emoji="${e}">${e}</span>`).join("")}<span class="emoji ${!p.icon ? "on" : ""}" data-emoji="" title="No icon">${ic("folder-simple", 14)}</span><span class="emoji more-emoji" id="psAllEmoji" title="Browse every emoji">…</span></div>
1936
+ <div class="emoji-all" id="psEmojiAll" hidden></div>
1937
+ <label>color</label>
1938
+ <div class="swatches">${slots.map((c) => `<span class="swatch ${c ? `pg-${c}` : "none"} ${(p.color ?? "") === c ? "on" : ""}" data-color="${c}" title="${c || "none"}"></span>`).join("")}</div>
1939
+ <label class="chk"><input type="checkbox" id="psPinned" ${p.discovered ? "" : "checked"}> pinned — always in the sidebar, drag to reorder</label>
1940
+ </div>
1941
+ <div class="pk-f"><span class="grow"></span><button id="pkCancel">Cancel</button><button class="primary" id="psSave" data-pid="${esc(p.id)}">Save</button></div>
1942
+ </div>`;
1943
+ $("#psName").focus();
1944
+ }
1945
+ /** Downsize an image file to a square 64px PNG data URL (center-cropped). */
1946
+ function fileToIconDataUrl(file) {
1947
+ return new Promise((resolve, reject) => {
1948
+ const url = URL.createObjectURL(file);
1949
+ const img = new Image();
1950
+ img.onload = () => {
1951
+ // square: center-crop the shorter side (cover), never letterbox
1952
+ const S = 64, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
1953
+ const side = Math.min(img.width, img.height), sx = (img.width - side) / 2, sy = (img.height - side) / 2;
1954
+ cv.getContext("2d").drawImage(img, sx, sy, side, side, 0, 0, S, S);
1955
+ URL.revokeObjectURL(url);
1956
+ resolve(cv.toDataURL("image/png"));
1957
+ };
1958
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("not an image the browser can decode")); };
1959
+ img.src = url;
1960
+ });
1961
+ }
1962
+ function setIconPreview(icon) {
1963
+ const el = $("#psPreview");
1964
+ if (!el) return;
1965
+ el.innerHTML = icon ? (icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(icon)}" alt="">` : esc(icon)) : ic("folder-simple", 16);
1966
+ }
1967
+ async function saveProjectSettings(pid) {
1968
+ const body = {
1969
+ name: $("#psName").value.trim() || undefined,
1970
+ icon: $("#psImage").value || $("#psIcon").value.trim(),
1971
+ color: $(".swatch.on")?.dataset.color ?? "",
1972
+ pinned: $("#psPinned").checked,
1973
+ };
1974
+ const r = await fetch(`/v1/projects/${encodeURIComponent(pid)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1975
+ if (!r.ok) return alert((await r.json()).error ?? "could not save");
1976
+ closePicker();
1977
+ state.dirty = true;
1978
+ refresh();
1979
+ }
1980
+
1581
1981
  // ---------- dispatch drawer (M7.5)
1582
1982
  function openDispatchDrawer() {
1583
1983
  const ready = (state.tasks?.tasks ?? []).filter((t) => t.ready);
@@ -1728,7 +2128,7 @@ let pending = false;
1728
2128
  const pollSoon = () => { if (!pending) { pending = true; setTimeout(() => { pending = false; poll(); }, 400); } };
1729
2129
  let backoff = 1500;
1730
2130
  function connect() {
1731
- const es = new EventSource(`/v1/events?since=${state.seq}`);
2131
+ const es = new EventSource(`/v1/events?since=${state.seq}${TOKEN ? `&token=${TOKEN}` : ""}`);
1732
2132
  const on = () => { backoff = 1500; $("#daemon .dot").classList.add("on"); };
1733
2133
  es.addEventListener("open", on);
1734
2134
  es.addEventListener("ping", on);
@@ -1747,7 +2147,7 @@ function connect() {
1747
2147
  if (fresh) notifyForEvent(ev);
1748
2148
  pollSoon();
1749
2149
  };
1750
- 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", "dispatch.queued", "dispatch.started", "dispatch.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
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);
1751
2151
  }
1752
2152
  refresh().then(() => {
1753
2153
  const sid = new URLSearchParams(location.search).get("session");