@ra3orblade/swarm 0.6.0 → 0.8.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");
@@ -45,13 +65,19 @@ document.addEventListener("keydown", (ev) => {
45
65
  window.swarmZoom(dir);
46
66
  });
47
67
  // `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
48
- const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
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 };
49
69
 
50
70
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
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
 
@@ -138,9 +164,12 @@ async function refresh() {
138
164
  }
139
165
  let attrChanged = false;
140
166
  if (state.view === "spend" && state.sel && !state.session) {
141
- const a = await fetch(`/v1/attribution?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.attribution);
142
- attrChanged = JSON.stringify(a) !== JSON.stringify(state.attribution);
143
- state.attribution = a;
167
+ const [a, bd] = await Promise.all([
168
+ fetch(`/v1/attribution?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.attribution),
169
+ fetch(`/v1/budget?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.budget),
170
+ ]);
171
+ attrChanged = JSON.stringify(a) !== JSON.stringify(state.attribution) || JSON.stringify(bd) !== JSON.stringify(state.budget);
172
+ state.attribution = a; state.budget = bd;
144
173
  } else if (state.view === "spend" && !state.sel) {
145
174
  if (state.attribution) attrChanged = true;
146
175
  state.attribution = null;
@@ -154,12 +183,13 @@ async function refresh() {
154
183
  }
155
184
  let tasksChanged = false;
156
185
  if (state.view === "board" && state.sel && !state.session) {
157
- const [t, g] = await Promise.all([
186
+ const [t, g, d] = await Promise.all([
158
187
  fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
159
188
  fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
189
+ fetch(`/v1/dispatch?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.dispatch),
160
190
  ]);
161
- tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates);
162
- state.tasks = t; state.gates = g;
191
+ tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch);
192
+ state.tasks = t; state.gates = g; state.dispatch = d;
163
193
  }
164
194
  let incChanged = false;
165
195
  if (state.view === "incidents" && !state.session) {
@@ -185,6 +215,9 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
185
215
  for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
186
216
  }
187
217
  function render() {
218
+ // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
219
+ // hold the frame while one is open; the next poll or interaction paints it.
220
+ if (window.menus?.isOpen()) { state.dirty = true; return; }
188
221
  // Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
189
222
  const af = document.activeElement;
190
223
  const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
@@ -239,7 +272,7 @@ function renderProjects() {
239
272
  const row = (p) => {
240
273
  const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
241
274
  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"'}>
242
- <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>`;
275
+ <span class="st ${live(p.id) ? "live" : ""}"></span>${projGlyph(p)}<span class="nm">${disamb(p)}${esc(p.name)}</span><small>${live(p.id) || ""}</small>${act}</div>`;
243
276
  };
244
277
  const liveAll = live("");
245
278
  $("#projects").innerHTML =
@@ -287,13 +320,17 @@ projectsEl.addEventListener("dragend", () => {
287
320
  // ---------- fleet
288
321
  // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
289
322
  const FLEET_COLS = [
290
- { key: "project", label: "project", width: 104, get: (s) => projName(s.projectId), cell: (s) => esc(projName(s.projectId)) },
291
- { key: "agent", label: "agent", width: 76, get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
292
- { 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>` : ""}` },
293
- { key: "branch", label: "branch", width: 134, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
294
- { 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>` },
295
- { 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>` },
296
- { 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)) },
323
+ { key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
324
+ { key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
325
+ { 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>' : ""}` },
326
+ { key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
327
+ { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
328
+ const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
329
+ if (s.state === "ended") return line ? `<span class="now dim" title="${esc(line)}">${esc(line)}</span>` : '<span class="dim">ended</span>';
330
+ return `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" && line ? line : s.last)}</span>`;
331
+ } },
332
+ { 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>` },
333
+ { 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)) },
297
334
  { key: "out", label: "out", width: 66, num: true, get: (s) => s.tokens.output, cell: (s) => tok(s.tokens.output) },
298
335
  { 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) },
299
336
  { key: "cost", label: "cost", width: 64, num: true, get: (s) => s.costUsd ?? 0, cell: (s) => usd(s.costUsd) },
@@ -357,18 +394,45 @@ function renderPRs() {
357
394
  columns: cols,
358
395
  rows,
359
396
  leading: { width: 24, cell: (p) => `<span class="s ${p.checks === "fail" ? "waiting" : p.checks === "pass" ? "active" : "idle"}"></span>` },
360
- 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>` : "") },
361
- rowAttrs: () => "",
397
+ trailing: { width: 34, cell: (p) => more("pr", `data-pid="${esc(p.projectId)}" data-num="${p.number}"`) },
398
+ rowAttrs: (p) => `data-ctx="pr" data-pid="${esc(p.projectId)}" data-num="${p.number}"`,
362
399
  rerender: touch,
363
400
  })
364
401
  : `<div class="empty">${PX.idle()}No open pull requests.<br>Agent branches land here the moment they're pushed.</div>`);
365
402
  }
366
403
 
367
404
  // ---------- board (coordination: claims, worktrees, incidents)
405
+ // Board representation toggles (cards vs table), persisted per section.
406
+ const boardMode = (k) => localStorage.getItem(`swarm.board.${k}`) ?? "cards";
407
+ 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>`;
408
+
409
+ // KPI strip: the board at a glance — what is live, held, dirty, failing, waiting.
410
+ function renderBoardKpis() {
411
+ const inSel = (pid) => !state.sel || pid === state.sel;
412
+ const live = state.sessions.filter((s) => inSel(s.projectId) && (s.state === "active" || s.state === "waiting"));
413
+ const waiting = live.filter((s) => s.state === "waiting").length;
414
+ const claims = (state.claims ?? []).filter((c) => c.state !== "released" && inSel(c.projectId));
415
+ const orphaned = claims.filter((c) => c.state === "orphaned").length;
416
+ const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
417
+ const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
418
+ const inc = (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length;
419
+ const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
420
+ const ready = tasks ? tasks.filter((t) => t.ready).length : null;
421
+ const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
422
+ if (!live.length && !claims.length && !wts.length && !inc && !tasks) return "";
423
+ 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>`;
424
+ return `<div class="kpis kpis-5">${
425
+ kpi("Live", live.length, waiting ? `${waiting} waiting on you` : live.length ? "sessions working" : "no sessions", waiting ? "hot" : "")
426
+ }${kpi("Held", claims.length, orphaned ? `${orphaned} orphaned` : claims.length ? "claims with a lease" : "nothing claimed", orphaned ? "hot" : "")
427
+ }${kpi("Worktrees", wts.length, dirty || merged ? `${dirty ? `${dirty} dirty` : ""}${dirty && merged ? " · " : ""}${merged ? `${merged} merged` : ""}` : "all clean", dirty ? "warm" : "")
428
+ }${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")
429
+ }${kpi("Incidents", inc, inc ? "need a look" : "all acknowledged", inc ? "hot" : "")}</div>`;
430
+ }
431
+
368
432
  function renderBoard() {
369
- const parts = [renderTasks(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
433
+ const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
370
434
  $("#main").innerHTML = parts.length
371
- ? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
435
+ ? parts.join("").replace(/^(<div class="kpis[^>]*>[\s\S]*?<\/div><\/div>|)(<h2) class="mt-sec"/, "$1$2") // first section needs no top gap
372
436
  : `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
373
437
  }
374
438
 
@@ -377,19 +441,20 @@ function incidentColumns(full) {
377
441
  const sess = (id) => state.sessions.find((s) => s.id === id);
378
442
  return [
379
443
  { key: "ts", label: "when", width: 76, get: (i) => i.ts, cell: (i) => `<span class="dim" title="${esc(i.ts)}">${ago(i.ts)}</span>` },
380
- { key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => esc(projName(i.projectId)) },
444
+ { key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => projCell(i.projectId) },
381
445
  { 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>') },
382
446
  { key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
383
447
  { 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>') },
384
- { key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.reason ?? "")}">${esc(i.command ?? "")}</span>` },
448
+ { 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>` },
385
449
  ...(full ? [
386
450
  { key: "reason", label: "reason", width: 260, get: (i) => i.reason ?? "", cell: (i) => `<span class="dim now" title="${esc(i.reason ?? "")}">${esc(i.reason ?? "")}</span>` },
387
451
  { 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>') },
388
452
  ] : []),
389
453
  ].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
390
454
  }
455
+ /** The part of a shell command worth reading in a cell: drop a leading `cd <dir> &&` / `;`. */
456
+ const cmdGist = (c) => c.replace(/^\s*cd\s+\S+\s*(&&|;)\s*/, "").replace(/\s+/g, " ").trim() || c;
391
457
  const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" || i.action === "orphaned" || i.action === "failed" ? "waiting" : "idle"}"></span>`;
392
- const ackLink = (i) => (i.acked ? "" : `<a href="#" data-ack="${i.seq}" title="Mark as seen">Ack</a>`);
393
458
 
394
459
  function renderIncidents() {
395
460
  const rows = (state.incidents ?? []).filter((i) => !state.sel || i.projectId === state.sel);
@@ -401,8 +466,8 @@ function renderIncidents() {
401
466
  columns: incidentColumns(false),
402
467
  rows,
403
468
  leading: { width: 24, cell: incidentDot },
404
- trailing: { width: 44, cell: ackLink },
405
- rowAttrs: (i) => (i.sessionId ? `data-s="${i.sessionId}"` : ""),
469
+ trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
470
+ rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
406
471
  rerender: touch,
407
472
  });
408
473
  }
@@ -482,8 +547,8 @@ function renderIncidentsView() {
482
547
  columns: incidentColumns(true),
483
548
  rows,
484
549
  leading: { width: 24, cell: incidentDot },
485
- 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)}` },
486
- rowAttrs: () => "",
550
+ trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
551
+ rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
487
552
  rerender: touch,
488
553
  })
489
554
  : `<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>`);
@@ -496,7 +561,7 @@ function renderProcesses() {
496
561
  const cols = [
497
562
  { key: "name", label: "process", width: 150, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
498
563
  { key: "kind", label: "kind", width: 80, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
499
- { key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => esc(projName(r.projectId)) },
564
+ { key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => projCell(r.projectId) },
500
565
  { key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid, cell: (r) => r.pid },
501
566
  { 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>') },
502
567
  { key: "owner", label: "owner", width: 110, get: (r) => r.owner, cell: (r) => esc(r.owner) },
@@ -509,8 +574,8 @@ function renderProcesses() {
509
574
  columns: cols,
510
575
  rows,
511
576
  leading: { width: 24, cell: () => '<span class="s active"></span>' },
512
- 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>` },
513
- rowAttrs: () => "",
577
+ trailing: { width: 34, cell: (r) => more("process", `data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`) },
578
+ rowAttrs: (r) => `data-ctx="process" data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`,
514
579
  rerender: touch,
515
580
  });
516
581
  }
@@ -533,7 +598,8 @@ function renderResources() {
533
598
  columns: cols,
534
599
  rows,
535
600
  leading: { width: 24, cell: () => '<span class="s active"></span>' },
536
- trailing: { width: 90, cell: (r) => `<a href="#" data-resrelease="${esc(r.name)}" data-resproj="${esc(r.projectId ?? "")}">Release</a>` },
601
+ trailing: { width: 34, cell: (r) => more("resource", `data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`) },
602
+ rowAttrs: (r) => `data-ctx="resource" data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`,
537
603
  rerender: touch,
538
604
  });
539
605
  }
@@ -596,16 +662,35 @@ function renderTasks() {
596
662
  ...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
597
663
  ];
598
664
  const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
665
+ const lane = (t) => (t.claimedBy ? "held" : t.status === "done" ? "done" : t.ready ? "ready" : t.status === "active" ? "held" : "blocked");
666
+ 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)}">
667
+ <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>
668
+ <div class="tc-t">${esc(t.title)}</div>
669
+ ${t.milestone ? `<div class="tc-m">${esc(t.milestone.split(" — ")[0])}</div>` : ""}
670
+ ${(t.gates ?? []).some((g) => g.verdict) ? `<div class="tc-g">${gateChips(t.gates)}</div>` : ""}
671
+ </div>`;
672
+ const kanban = () => {
673
+ const lanes = [["ready", "Ready"], ["held", "In progress"], ["blocked", "Blocked"], ["done", "Done"]];
674
+ const by = Object.fromEntries(lanes.map(([k]) => [k, []]));
675
+ for (const t of all) by[lane(t)].push(t);
676
+ by.done.reverse();
677
+ const CAP = 6;
678
+ return `<div class="kanban">${lanes.map(([k, label]) => {
679
+ const list = by[k];
680
+ const shown = k === "done" ? list.slice(0, CAP) : list;
681
+ 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>`;
682
+ }).join("")}</div>`;
683
+ };
599
684
  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>` +
600
- `<div class="chips">${chip("ready", "Ready", ready.length)}${chip("open", "Open", all.filter((t) => t.status !== "done").length)}${chip("all", "All", all.length)}</div>` +
601
- (rows.length
685
+ `<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>` +
686
+ (all.length && boardMode("tasks") === "cards" ? kanban() : rows.length
602
687
  ? dataTable({
603
688
  id: "tasks",
604
689
  columns: cols,
605
690
  rows,
606
691
  leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
607
- trailing: { width: 120, 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>` : "") },
608
- rowAttrs: () => "",
692
+ trailing: { width: 34, cell: (t) => (t.ready || t.claimedBy ? more("task", `data-task="${esc(t.id)}"`) : "") },
693
+ rowAttrs: (t) => `data-ctx="task" data-task="${esc(t.id)}"`,
609
694
  rerender: touch,
610
695
  })
611
696
  : `<div class="empty">${PX.idle()}${state.taskFilter === "ready" ? "Nothing ready — every open task is blocked or held." : "No tasks."}</div>`);
@@ -619,7 +704,7 @@ function renderClaims() {
619
704
  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>';
620
705
  const orphans = rows.filter((c) => c.state === "orphaned").length;
621
706
  const cols = [
622
- { key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => esc(projName(c.projectId)) },
707
+ { key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => projCell(c.projectId) },
623
708
  { key: "task", label: "task", width: 140, get: (c) => c.task, cell: (c) => `<b>${esc(c.task)}</b>` },
624
709
  { key: "owner", label: "owner", width: 120, get: (c) => c.owner || "", cell: (c) => esc(c.owner || "—") },
625
710
  { 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>` },
@@ -632,12 +717,8 @@ function renderClaims() {
632
717
  columns: cols,
633
718
  rows,
634
719
  leading: { width: 24, cell: (c) => `<span class="s ${c.state === "orphaned" ? "waiting" : c.state === "expired" ? "idle" : "active"}"></span>` },
635
- trailing: { width: 120, cell: (c) => {
636
- const key = `${c.projectId}:${c.task}`;
637
- return c.state === "orphaned"
638
- ? `<a href="#" data-forcerelease="${key}" title="Discards the worktree AND its uncommitted work">Force release</a>`
639
- : `<a href="#" data-release="${key}">Release</a>`;
640
- } },
720
+ trailing: { width: 34, cell: (c) => more("claim", `data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`) },
721
+ rowAttrs: (c) => `data-ctx="claim" data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`,
641
722
  rerender: touch,
642
723
  });
643
724
  }
@@ -656,24 +737,80 @@ function renderWorktrees() {
656
737
  const inside = (w) => byPath.get(w.path);
657
738
  const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
658
739
  const cols = [
659
- { key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => esc(projName(w.projectId)) },
740
+ { key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => projCell(w.projectId) },
660
741
  { 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>' : ""}` },
661
742
  { key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
662
743
  { key: "path", label: "path", flex: true, get: (w) => w.path, cell: (w) => `<span class="now" title="${esc(w.path)}">${esc(short(w.path))}</span>` },
663
744
  { key: "state", label: "state", width: 170, 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 ? '<span class="badge">Clean</span>' : ""}` },
745
+ { key: "drift", label: "drift", width: 120, get: (w) => (w.main ? -1 : w.behind), cell: (w) => (w.main ? "" : w.merged ? '<span class="badge" title="This branch is already in the main checkout\'s branch">Merged</span>' : w.behind > 0 ? `<span class="badge warn" title="Commits on the main checkout\'s branch this worktree lacks">${w.behind} behind</span>` : w.behind === 0 ? '<span class="badge">Up to date</span>' : '<span class="dim">—</span>') },
664
746
  { 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>' },
665
747
  ].filter((c) => !(c.key === "project" && state.sel));
666
- return `<h2 class="mt-sec">Worktrees <span>${rows.length}</span></h2>` +
748
+ const heldBy = new Map(state.claims ? state.claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]) : []);
749
+ 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>` : "";
750
+ 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>` : "";
751
+ const stateOf = (w) => (inside(w).length ? "live" : w.dirty > 0 ? "dirty" : w.ahead > 0 ? "ahead" : w.merged ? "merged" : "clean");
752
+ 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)}">
753
+ <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>
754
+ <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>
755
+ </div>`;
756
+ const map = () => {
757
+ const groups = new Map();
758
+ for (const w of rows) (groups.get(w.projectId) ?? groups.set(w.projectId, []).get(w.projectId)).push(w);
759
+ const order = { live: 0, dirty: 1, ahead: 2, clean: 3, merged: 4 };
760
+ 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>`;
761
+ };
762
+ return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}<span class="grow"></span>${modeSeg("worktrees", "Map", "Table")}</h2>` +
763
+ (boardMode("worktrees") === "cards" ? map() :
667
764
  dataTable({
668
765
  id: "worktrees",
669
766
  columns: cols,
670
767
  rows,
671
768
  leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
672
- trailing: { width: 34, cell: () => "" },
769
+ trailing: { width: 34, cell: (w) => more("worktree", `data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`) },
770
+ rowAttrs: (w) => `data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`,
771
+ rerender: touch,
772
+ }));
773
+ }
774
+
775
+ // ---------- dispatch (M7.5)
776
+ function renderDispatch() {
777
+ const d = state.dispatch;
778
+ if (!state.sel || !d?.entries?.length) return "";
779
+ const rows = d.entries;
780
+ const oc = (e) => e.state === "queued" ? '<span class="badge">Queued</span>'
781
+ : e.state === "running" ? '<span class="badge acc">Running</span>'
782
+ : e.outcome === "done" ? '<span class="badge ok">Done</span>'
783
+ : e.outcome === "stopped" ? '<span class="badge">Stopped</span>'
784
+ : `<span class="badge warn">${esc(e.outcome ?? "?")}</span>`;
785
+ const cols = [
786
+ { key: "task", label: "task", width: 90, get: (e) => e.task, cell: (e) => `<b>${esc(e.task)}</b>` },
787
+ { key: "title", label: "title", flex: true, get: (e) => e.title, cell: (e) => esc(e.title) },
788
+ { key: "state", label: "state", width: 110, get: (e) => (e.state === "running" ? 0 : e.state === "queued" ? 1 : 2), cell: oc },
789
+ { key: "cost", label: "cost", width: 70, num: true, get: (e) => e.costUsd ?? -1, cell: (e) => (e.costUsd != null ? usd(e.costUsd) : '<span class="dim">—</span>') },
790
+ { key: "detail", label: "detail", width: 360, get: (e) => e.detail ?? "", cell: (e) => `<span class="dim" title="${esc(e.detail ?? "")}">${esc(e.detail ?? "")}</span>` },
791
+ ];
792
+ const running = rows.filter((e) => e.state === "running").length, queued = rows.filter((e) => e.state === "queued").length;
793
+ return `<h2 class="mt-sec hrow">Dispatch <span>${running} running · ${queued} queued · cap ${d.config?.max_parallel ?? 2}</span><a href="#" class="nav" id="dispatchClear" title="Drop queued tasks and clear finished rows (running ones keep going)">${ic("trash", 12)} Clear</a></h2>` +
794
+ dataTable({
795
+ id: "dispatch",
796
+ columns: cols,
797
+ rows,
798
+ leading: { width: 24, cell: (e) => `<span class="s ${e.state === "running" ? "active" : e.state === "queued" ? "waiting" : e.outcome === "done" ? "ended" : "waiting"}"></span>` },
799
+ trailing: { width: 90, cell: (e) => (e.sessionId ? `<a href="#" data-s="${esc(e.sessionId)}">session</a>` : "") },
673
800
  rerender: touch,
674
801
  });
675
802
  }
676
803
 
804
+ // 0.7.0: the project's [budget] ceiling against what it spent
805
+ function budgetKpi(kpi) {
806
+ const b = state.sel ? state.budget : null;
807
+ if (!b?.status) return state.sel ? kpi("budget", "—", "no [budget] in .swarm.toml") : "";
808
+ const s = b.status;
809
+ const pct = Math.round(s.pct * 100);
810
+ const cls = s.level === "exceeded" ? "warn" : s.level === "warn" ? "acc" : "";
811
+ return kpi(`${s.kind} budget`, `<span class="${cls}">${pct}%</span>`, `${usd(s.spent)} of ${usd(s.limit)} · past it: ${b.config.on_exceed}`);
812
+ }
813
+
677
814
  // ---------- spend
678
815
  function renderSpend() {
679
816
  const sp = state.spend;
@@ -723,7 +860,7 @@ function renderSpend() {
723
860
  const hm = sp.hourly.filter(inSel).map((c) => ({ dow: c.dow, hour: c.hour, v: c.cost ?? 0 }));
724
861
  $("#main").innerHTML =
725
862
  `<h2>Spend <span>${state.sel ? esc(projName(state.sel)) : "all projects"}</span>${rangeChips}</h2>
726
- <div class="kpis">${kpi("today", usd(todayCost), `${todayTurns} turns`)}${kpi(`${N}-day total`, usd(total14), `${activeDays} active day${activeDays === 1 ? "" : "s"}`)}${kpi("today vs avg", prevDays ? `${todayCost >= avg ? "+" : ""}${(((todayCost - avg) / avg) * 100).toFixed(0)}%` : "—", prevDays ? `vs ${usd(avg)} / active day` : "no earlier days to compare")}${kpi("agents", agents.length, agents.map(agentLabel).join(" · ") || "—")}</div>
863
+ <div class="kpis">${kpi("today", usd(todayCost), `${todayTurns} turns`)}${kpi(`${N}-day total`, usd(total14), `${activeDays} active day${activeDays === 1 ? "" : "s"}`)}${kpi("today vs avg", prevDays ? `${todayCost >= avg ? "+" : ""}${(((todayCost - avg) / avg) * 100).toFixed(0)}%` : "—", prevDays ? `vs ${usd(avg)} / active day` : "no earlier days to compare")}${kpi("agents", agents.length, agents.map(agentLabel).join(" · ") || "—")}${budgetKpi(kpi)}</div>
727
864
  <div class="chart-card"><h3>Daily cost · last ${N} days <span>stacked by agent</span></h3>${viz.stackedColumns(days, series)}${agents.length > 1 ? viz.legend(agents) : ""}</div>
728
865
  <div class="cols">
729
866
  <div class="chart-card" style="margin:0"><h3>When the agents work <span>cost by weekday × hour · last 4 weeks · local time</span></h3>${viz.heatmap(hm)}</div>
@@ -756,7 +893,6 @@ function renderAttribution() {
756
893
  { 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>` },
757
894
  ],
758
895
  rows: a.byTask,
759
- leading: { width: 20, cell: () => "" },
760
896
  trailing: { width: 8, cell: () => "" },
761
897
  rerender: touch,
762
898
  }));
@@ -773,7 +909,6 @@ function renderAttribution() {
773
909
  { key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
774
910
  ],
775
911
  rows: a.contextBudget,
776
- leading: { width: 20, cell: () => "" },
777
912
  trailing: { width: 8, cell: () => "" },
778
913
  rerender: touch,
779
914
  }));
@@ -826,6 +961,12 @@ async function runSearch() {
826
961
  srch.hits = j.hits ?? [];
827
962
  if (state.view === "search" && !state.session) renderSearch();
828
963
  }
964
+ document.addEventListener("change", async (ev) => {
965
+ if (ev.target.id !== "psFile" || !ev.target.files?.[0]) return;
966
+ 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"); }
967
+ catch (e) { alert(e.message); }
968
+ });
969
+ 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()); } });
829
970
  document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
830
971
  function renderStats() {
831
972
  const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
@@ -1044,6 +1185,19 @@ function replayGo(delta) {
1044
1185
  }
1045
1186
 
1046
1187
  // Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
1188
+ // M7.7: questions this session is waiting on a human for
1189
+ function questionCards(s) {
1190
+ const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
1191
+ if (!qs.length) return "";
1192
+ return `<h4>waiting on you</h4>${qs.map((q) => `<div class="perm"><div class="perm-t">${ic("warning", 13)} <b>Question #${q.id}</b>${q.task ? `<span class="dim"> · ${esc(q.task)}</span>` : ""}</div><div class="perm-c">${esc(q.text)}</div><div class="perm-b">${(q.options ?? []).map((o) => `<button class="ok" data-qanswer="${q.id}" data-text="${esc(o)}">${esc(o)}</button>`).join("")}<button data-qanswer="${q.id}">Answer…</button></div></div>`).join("")}`;
1193
+ }
1194
+ async function answerQuestion(id, preset) {
1195
+ const text = preset ?? prompt(`Answer to question #${id}:`);
1196
+ if (!text) return;
1197
+ const r = await fetch(`/v1/questions/${id}/answer`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, by: "dashboard" }) }).then((x) => x.json());
1198
+ if (!r.ok) alert(r.error);
1199
+ return refresh();
1200
+ }
1047
1201
  function stdinBox(s) {
1048
1202
  if (s.kind !== "spawned") return "";
1049
1203
  const run = (state.runs ?? []).find((r) => r.sessionId === s.id);
@@ -1063,6 +1217,8 @@ async function sendStdin() {
1063
1217
  }
1064
1218
  document.addEventListener("click", (ev) => {
1065
1219
  if (ev.target.closest("#stdinSend")) return sendStdin();
1220
+ const qa = ev.target.closest("[data-qanswer]");
1221
+ if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
1066
1222
  const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
1067
1223
  const key = a?.dataset.permAllow || d?.dataset.permDeny;
1068
1224
  if (key) {
@@ -1085,16 +1241,17 @@ function renderSession() {
1085
1241
  const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
1086
1242
  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" };
1087
1243
  const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
1088
- 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>${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>`;
1244
+ 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>`;
1089
1245
  const side = `<div class="stats">
1090
1246
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
1091
- ${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>`)}
1247
+ ${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>`)}
1092
1248
  ${stat("started", `${ago(s.startedAt)} ago`)}${stat("last seen", `${ago(s.lastSeenAt)} ago`)}
1093
1249
  ${subTurns.length ? stat("subagent turns", subTurns.length) : ""}
1094
1250
  </div>
1095
1251
  <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 }])}
1096
1252
  ${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
1097
1253
  <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>'}
1254
+ ${questionCards(s)}
1098
1255
  ${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
1099
1256
  if (logEl && isAppend(rows)) {
1100
1257
  // Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
@@ -1120,6 +1277,63 @@ function renderSession() {
1120
1277
  // ---------- menus (fancy-menus island; see src/menus.tsx). Menus are plain data.
1121
1278
  const pinProject = (id, pinned) => fetch(`/v1/projects/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) }).then(refresh);
1122
1279
  const removeProject = (id) => fetch(`/v1/projects/${id}`, { method: "DELETE" }).then(refresh);
1280
+ // ---------- row actions (shared by the row menus, right-click, and any remaining links)
1281
+ const post = (url, body) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
1282
+ const act = {
1283
+ async wtOpen(projectId, worktree) { const r = await post("/v1/worktrees/open", { projectId, worktree }); if (!r.ok) alert(r.error); },
1284
+ wtDiff(projectId, worktree) { openDiffDrawer(projectId, worktree); },
1285
+ wtPr(projectId, worktree) { openPrDrawer(projectId, worktree); },
1286
+ async wtRemove(projectId, worktree) {
1287
+ const rm = (force) => post("/v1/worktrees/remove", { projectId, worktree, force });
1288
+ if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
1289
+ const r = await rm(false);
1290
+ if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
1291
+ if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
1292
+ } else if (!r.ok) alert(r.error);
1293
+ state.worktrees[projectId] = null;
1294
+ refresh();
1295
+ },
1296
+ async claimTask(task) {
1297
+ const r = await post("/v1/claims", { projectId: state.sel, task, owner: "dashboard" });
1298
+ if (!r.ok) alert(r.error); else state.tasks = null;
1299
+ refresh();
1300
+ },
1301
+ runTask(task) { openRunDrawer(task); },
1302
+ async gateRun(task) {
1303
+ const r = await post("/v1/gates/run", { projectId: state.sel, task });
1304
+ if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
1305
+ 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(", ")}` : ""}`);
1306
+ state.tasks = null;
1307
+ refresh();
1308
+ },
1309
+ async releaseClaim(projectId, task, force) {
1310
+ if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
1311
+ const r = await post("/v1/claims/release", { projectId, task, force });
1312
+ if (!r.ok && confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) await post("/v1/claims/release", { projectId, task, force: true });
1313
+ refresh();
1314
+ },
1315
+ async merge(projectId, number) {
1316
+ if (!confirm(`Squash-merge #${number}?`)) return;
1317
+ const r = await post("/v1/prs/merge", { projectId, number: Number(number) });
1318
+ if (r.ok === false || r.error) alert(r.error);
1319
+ refresh();
1320
+ },
1321
+ async procStop(pid, projectId) {
1322
+ if (!confirm(`Stop pid ${pid}?`)) return;
1323
+ const r = await fetch(`/v1/processes/${pid}?project=${encodeURIComponent(projectId)}`, { method: "DELETE" });
1324
+ if (!r.ok) alert((await r.json()).error);
1325
+ refresh();
1326
+ },
1327
+ resRelease(name, projectId) {
1328
+ const q = new URLSearchParams({ force: "1" }); if (projectId) q.set("project", projectId);
1329
+ return fetch(`/v1/resources/${encodeURIComponent(name)}?${q}`, { method: "DELETE" }).then(refresh);
1330
+ },
1331
+ ack(seq) { return fetch(`/v1/incidents/${seq}/ack`, { method: "POST" }).then(refresh); },
1332
+ codify(seq) { codifyIncident(seq); },
1333
+ };
1334
+ /** Hover kebab that opens the row menu `kind`; `attrs` are the data-* the menu needs. */
1335
+ const more = (kind, attrs, title = "Actions") => `<span class="more" tabindex="0" role="button" data-menu="${kind}" ${attrs} title="${title}">${ic("dots-three", 15)}</span>`;
1336
+
1123
1337
  function menuSpec(kind, d) {
1124
1338
  if (kind === "project") {
1125
1339
  const p = state.projects.find((x) => x.id === d.pid);
@@ -1132,7 +1346,8 @@ function menuSpec(kind, d) {
1132
1346
  { label: "Stats", icon: "chart-bar", run: () => { state.sel = p.id; state.view = "stats"; state.session = null; touch(); } },
1133
1347
  { divider: true },
1134
1348
  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) },
1135
- { label: "Copy path", icon: "copy", caption: tail(p.root), run: () => copy(p.root) },
1349
+ { label: "Settings…", icon: "sliders", caption: "name · icon · color", run: () => openProjectSettings(p.id) },
1350
+ { label: "Copy path", icon: "copy", caption: tail(p.root, 16), run: () => copy(p.root) },
1136
1351
  { divider: true },
1137
1352
  { label: "Remove from Swarm", icon: "trash", danger: true, run: () => removeProject(p.id) },
1138
1353
  ] };
@@ -1146,9 +1361,91 @@ function menuSpec(kind, d) {
1146
1361
  { divider: true },
1147
1362
  { section: "Copy" },
1148
1363
  { label: "Session id", icon: "copy", caption: s.id.slice(0, 8), run: () => copy(s.id) },
1149
- { label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 18), run: () => copy(s.cwd) },
1364
+ { label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 16), run: () => copy(s.cwd) },
1150
1365
  ...(s.transcriptPath ? [{ label: "Transcript path", icon: "file-text", run: () => copy(s.transcriptPath) }] : []),
1151
- ...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 18), run: () => copy(s.branch) }] : []),
1366
+ ...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 16), run: () => copy(s.branch) }] : []),
1367
+ ] };
1368
+ }
1369
+ if (kind === "worktree") {
1370
+ const w = (state.worktrees[d.pid] ?? []).find((x) => x.path === d.path);
1371
+ if (!w) return null;
1372
+ const held = (state.claims ?? []).some((c) => c.state === "held" && c.worktree === w.path);
1373
+ const sess = state.sessions.filter((x) => x.state !== "ended" && (x.cwd === w.path || x.cwd.startsWith(`${w.path}/`)));
1374
+ return { title: w.branch ?? "(detached)", items: [
1375
+ { label: "Open", icon: "arrow-square-out", caption: "editor", run: () => act.wtOpen(d.pid, w.path) },
1376
+ ...(w.main ? [] : [{ label: "Diff", icon: "folders", caption: "vs main", run: () => act.wtDiff(d.pid, w.path) }]),
1377
+ ...(w.branch && !w.merged && !w.main ? [{ label: "Open PR", icon: "git-pull-request", run: () => act.wtPr(d.pid, w.path) }] : []),
1378
+ ...(sess.length ? [{ divider: true }, { section: "Sessions" }, ...sess.map((x) => ({ label: x.title ?? x.id.slice(0, 8), icon: "terminal-window", run: () => openSession(x.id) }))] : []),
1379
+ { divider: true },
1380
+ { label: "Copy path", icon: "copy", caption: tail(w.path, 14), run: () => copy(w.path) },
1381
+ ...(w.branch ? [{ label: "Copy branch", icon: "git-branch", caption: tail(w.branch, 14), run: () => copy(w.branch) }] : []),
1382
+ ...(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) }]),
1383
+ ] };
1384
+ }
1385
+ if (kind === "task") {
1386
+ const t = (state.tasks?.tasks ?? []).find((x) => x.id === d.task);
1387
+ if (!t) return null;
1388
+ const exec = state.gates?.executable ?? [];
1389
+ return { title: t.id, items: [
1390
+ ...(t.ready ? [
1391
+ { label: "Run", icon: "play", caption: "claim + claude -p", run: () => act.runTask(t.id) },
1392
+ { label: "Claim", icon: "folders", caption: "fresh worktree", run: () => act.claimTask(t.id) },
1393
+ ] : t.claimedBy ? [
1394
+ { label: "Run in worktree", icon: "play", run: () => act.runTask(t.id) },
1395
+ ...(exec.length ? [{ label: "Run gates", icon: "check", caption: exec.join(", "), run: () => act.gateRun(t.id) }] : []),
1396
+ ] : [{ label: t.status === "done" ? "Done" : "Blocked", disabled: true }]),
1397
+ { divider: true },
1398
+ { label: "Copy id", icon: "copy", caption: t.id, run: () => copy(t.id) },
1399
+ { label: "Copy title", icon: "file-text", run: () => copy(`${t.id} — ${t.title}`) },
1400
+ ] };
1401
+ }
1402
+ if (kind === "claim") {
1403
+ const c = (state.claims ?? []).find((x) => x.projectId === d.pid && x.task === d.task);
1404
+ if (!c) return null;
1405
+ const w = (state.worktrees[c.projectId] ?? []).find((x) => x.path === c.worktree);
1406
+ return { title: c.task, items: [
1407
+ ...(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) }] : []),
1408
+ { label: "Copy path", icon: "copy", caption: tail(c.worktree, 14), run: () => copy(c.worktree) },
1409
+ { divider: true },
1410
+ c.state === "orphaned"
1411
+ ? { label: "Force release", icon: "trash", danger: true, caption: "discards work", run: () => act.releaseClaim(c.projectId, c.task, true) }
1412
+ : { label: "Release claim", icon: "x", run: () => act.releaseClaim(c.projectId, c.task, false) },
1413
+ ] };
1414
+ }
1415
+ if (kind === "pr") {
1416
+ const p = (state.prs ?? []).find((x) => String(x.projectId) === d.pid && String(x.number) === d.num);
1417
+ if (!p) return null;
1418
+ const green = p.checks !== "fail" && p.mergeable && !p.draft;
1419
+ return { title: `#${p.number}`, items: [
1420
+ { label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => window.open(p.url, "_blank") },
1421
+ { label: "Copy URL", icon: "copy", run: () => copy(p.url) },
1422
+ { divider: true },
1423
+ { 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) },
1424
+ ] };
1425
+ }
1426
+ if (kind === "process") {
1427
+ return { items: [
1428
+ { label: "Copy pid", icon: "copy", caption: d.pid, run: () => copy(d.pid) },
1429
+ ...(d.cwd ? [{ label: "Copy cwd", icon: "folder-simple", caption: tail(d.cwd, 16), run: () => copy(d.cwd) }] : []),
1430
+ { divider: true },
1431
+ { label: "Stop", icon: "stop", danger: true, caption: "SIGTERM → SIGKILL", run: () => act.procStop(d.pid, d.proj) },
1432
+ ] };
1433
+ }
1434
+ if (kind === "resource") {
1435
+ return { title: d.name, items: [
1436
+ { label: "Copy name", icon: "copy", run: () => copy(d.name) },
1437
+ { divider: true },
1438
+ { label: "Release", icon: "x", danger: true, caption: "force", run: () => act.resRelease(d.name, d.proj) },
1439
+ ] };
1440
+ }
1441
+ if (kind === "incident") {
1442
+ const i = [...(state.incidents ?? []), ...(state.allIncidents ?? [])].find((x) => String(x.seq) === d.seq);
1443
+ if (!i) return null;
1444
+ return { items: [
1445
+ ...(i.sessionId ? [{ label: "Open session", icon: "terminal-window", run: () => openSession(i.sessionId) }] : []),
1446
+ ...(i.suggestion ? [{ label: "Codify", icon: "shield", caption: "rule / lesson", run: () => act.codify(i.seq) }] : []),
1447
+ { label: "Copy command", icon: "copy", run: () => copy(i.command ?? "") },
1448
+ ...(i.acked ? [] : [{ divider: true }, { label: "Acknowledge", icon: "check", run: () => act.ack(i.seq) }]),
1152
1449
  ] };
1153
1450
  }
1154
1451
  if (kind === "settings") {
@@ -1160,7 +1457,7 @@ function menuSpec(kind, d) {
1160
1457
  { label: "Refresh pricing", icon: "arrows-clockwise", caption: "LiteLLM", run: async () => { const r = await fetch("/v1/pricing/refresh", { method: "POST" }); if (!r.ok) console.warn("pricing refresh failed", r.status); refresh(); } },
1161
1458
  { label: "Copy dashboard URL", icon: "copy", run: () => copy(location.origin) },
1162
1459
  { divider: true },
1163
- { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "permission prompts, orphans", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1460
+ { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1164
1461
  { label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
1165
1462
  { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
1166
1463
  { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => window.open(feedbackUrl(), "_blank") },
@@ -1185,7 +1482,7 @@ function disableNotifications() { try { localStorage.setItem(NOTIFY_KEY, "off");
1185
1482
  let lastNotifyAt = 0;
1186
1483
  function notifyForEvent(ev) {
1187
1484
  if (!notifyOn() || !("Notification" in window) || Notification.permission !== "granted") return;
1188
- if (!document.hidden && ev.type !== "permission.requested") return; // only permission prompts interrupt while you're looking
1485
+ if (!document.hidden && ev.type !== "permission.requested" && ev.type !== "question.asked") return; // only prompts that block an agent interrupt while you're looking
1189
1486
  const now = Date.now();
1190
1487
  if (now - lastNotifyAt < 1500) return; // don't stack
1191
1488
  const p = ev.payload || {};
@@ -1195,6 +1492,10 @@ function notifyForEvent(ev) {
1195
1492
  body = `${p.display ?? ""}
1196
1493
  ${p.reason ?? ""}`.slice(0, 180);
1197
1494
  onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1495
+ } else if (ev.type === "question.asked") {
1496
+ title = "An agent has a question";
1497
+ body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
1498
+ onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1198
1499
  } else if (ev.type === "claim.orphaned") {
1199
1500
  title = "Claim orphaned";
1200
1501
  body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
@@ -1280,6 +1581,14 @@ function openMenu(kind, anchor, d) {
1280
1581
  if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
1281
1582
  window.menus.open(anchor, spec);
1282
1583
  }
1584
+ // Enter / Space on a focused card, tile or kebab opens its menu like a click.
1585
+ document.addEventListener("keydown", (ev) => {
1586
+ if (ev.key !== "Enter" && ev.key !== " ") return;
1587
+ const t = ev.target.closest?.("[data-menu]");
1588
+ if (!t || t.tagName === "INPUT") return;
1589
+ ev.preventDefault();
1590
+ openMenu(t.dataset.menu, t, t.dataset);
1591
+ });
1283
1592
  document.addEventListener("contextmenu", (ev) => {
1284
1593
  const t = ev.target.closest("[data-ctx]");
1285
1594
  if (!t) return;
@@ -1289,7 +1598,7 @@ document.addEventListener("contextmenu", (ev) => {
1289
1598
 
1290
1599
  // ---------- events
1291
1600
  document.addEventListener("click", async (ev) => {
1292
- 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]");
1601
+ 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-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#dispatch,#dispatchGo,#dispatchClear");
1293
1602
  if (!t) return;
1294
1603
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1295
1604
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
@@ -1297,27 +1606,68 @@ document.addEventListener("click", async (ev) => {
1297
1606
  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(); }
1298
1607
  if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
1299
1608
  if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
1609
+ 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; }
1610
+ if (t.id === "psAllEmoji") { const all = $("#psEmojiAll"); if (all.hidden) { all.innerHTML = buildEmojiGrid(); all.hidden = false; } else all.hidden = true; return; }
1611
+ if (t.dataset.color !== undefined && t.classList.contains("swatch")) { for (const e of $$(".swatch")) e.classList.toggle("on", e === t); return; }
1612
+ if (t.id === "psSave") { ev.preventDefault(); return saveProjectSettings(t.dataset.pid); }
1613
+ if (t.dataset.bmode) { ev.preventDefault(); const [k, v] = t.dataset.bmode.split(":"); localStorage.setItem(`swarm.board.${k}`, v); return touch(); }
1300
1614
  if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
1301
1615
  if (t.dataset.runstop) {
1302
1616
  ev.preventDefault();
1303
1617
  if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
1304
1618
  return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
1305
1619
  }
1306
- if (t.dataset.claim) {
1620
+ if (t.dataset.claim) { ev.preventDefault(); return act.claimTask(t.dataset.claim); }
1621
+ const split = (v) => { const i = v.indexOf(":"); return [v.slice(0, i), v.slice(i + 1)]; };
1622
+ if (t.dataset.wtopen) { ev.preventDefault(); return act.wtOpen(...split(t.dataset.wtopen)); }
1623
+ if (t.dataset.wtdiff) { ev.preventDefault(); return act.wtDiff(...split(t.dataset.wtdiff)); }
1624
+ if (t.dataset.wtpr) { ev.preventDefault(); return act.wtPr(...split(t.dataset.wtpr)); }
1625
+ if (t.dataset.dffile !== undefined) { ev.preventDefault(); return loadDiffFile(t.dataset.dffile); }
1626
+ if (t.id === "prGo") { ev.preventDefault(); return submitPr(); }
1627
+ if (t.id === "sessDiff") {
1307
1628
  ev.preventDefault();
1308
- 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());
1309
- if (!r.ok) alert(r.error); else state.tasks = null;
1629
+ const s = state.sessions.find((x) => x.id === state.session);
1630
+ if (!s) return;
1631
+ const w = (state.worktrees[s.projectId] ?? []).find((x) => !x.main && (s.cwd === x.path || s.cwd.startsWith(`${x.path}/`)));
1632
+ return w ? openDiffDrawer(s.projectId, w.path) : null;
1633
+ }
1634
+ if (t.dataset.wtrm) { ev.preventDefault(); return act.wtRemove(...split(t.dataset.wtrm)); }
1635
+ if (t.id === "wtnew") {
1636
+ ev.preventDefault();
1637
+ const name = prompt("Worktree name (folder under ~/.swarm/worktrees/<project>/; branch wt/<name>):");
1638
+ if (!name) return;
1639
+ const r = await fetch("/v1/worktrees", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, name }) }).then((x) => x.json());
1640
+ if (!r.ok) alert(r.error);
1641
+ state.worktrees[state.sel] = null;
1642
+ return refresh();
1643
+ }
1644
+ if (t.id === "wtgc") {
1645
+ ev.preventDefault();
1646
+ const r = await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) }).then((x) => x.json());
1647
+ if (!r.candidates.length) return alert("Nothing to collect — no merged branches or released claims with a worktree left behind.");
1648
+ const lines = r.candidates.map((c) => `${c.removable ? "•" : "✗"} ${c.branch ?? "(detached)"} — ${c.why}${c.blocker ? ` (blocked: ${c.blocker})` : ""}`).join("\n");
1649
+ const n = r.candidates.filter((c) => c.removable).length;
1650
+ if (!n) return alert(`Stale worktrees, none removable without force:\n\n${lines}`);
1651
+ if (!confirm(`Stale worktrees:\n\n${lines}\n\nRemove the ${n} removable one${n === 1 ? "" : "s"}?`)) return;
1652
+ await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, apply: true }) });
1653
+ state.worktrees[state.sel] = null;
1310
1654
  return refresh();
1311
1655
  }
1656
+ if (t.id === "dispatch") { ev.preventDefault(); return openDispatchDrawer(); }
1657
+ if (t.id === "dispatchGo") { ev.preventDefault(); return submitDispatch(); }
1658
+ if (t.id === "dispatchClear") {
1659
+ ev.preventDefault();
1660
+ await fetch("/v1/dispatch", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) });
1661
+ state.dispatch = null;
1662
+ return refresh();
1663
+ }
1664
+ if (t.dataset.gaterun) { ev.preventDefault(); return act.gateRun(t.dataset.gaterun); }
1312
1665
  if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
1313
1666
  if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1314
1667
  if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
1315
1668
  if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
1316
1669
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
1317
- if (t.dataset.ack) {
1318
- ev.preventDefault(); ev.stopPropagation();
1319
- return fetch(`/v1/incidents/${t.dataset.ack}/ack`, { method: "POST" }).then(refresh);
1320
- }
1670
+ if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
1321
1671
  if (t.dataset.ackall) {
1322
1672
  return fetch("/v1/incidents/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: state.sel || undefined }) }).then(refresh);
1323
1673
  }
@@ -1325,37 +1675,13 @@ document.addEventListener("click", async (ev) => {
1325
1675
  if (t.dataset.sdays) { ev.preventDefault(); state.statsDays = Number(t.dataset.sdays); return touch(); }
1326
1676
  if (t.dataset.release || t.dataset.forcerelease) {
1327
1677
  ev.preventDefault();
1328
- const force = Boolean(t.dataset.forcerelease);
1329
1678
  const [projectId, task] = (t.dataset.release || t.dataset.forcerelease).split(":");
1330
- if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
1331
- const r = await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force }) }).then((x) => x.json());
1332
- if (!r.ok) {
1333
- if (confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) {
1334
- await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force: true }) });
1335
- }
1336
- }
1337
- return refresh();
1679
+ return act.releaseClaim(projectId, task, Boolean(t.dataset.forcerelease));
1338
1680
  }
1339
1681
  if (t.dataset.agent !== undefined && t.classList.contains("chip")) { state.agentFilter = t.dataset.agent || null; return touch(); }
1340
- if (t.dataset.merge !== undefined) {
1341
- ev.preventDefault();
1342
- const [projectId, number] = t.dataset.merge.split(":");
1343
- if (!confirm(`Squash-merge #${number}?`)) return;
1344
- return fetch("/v1/prs/merge", {
1345
- method: "POST", headers: { "content-type": "application/json" },
1346
- body: JSON.stringify({ projectId, number: Number(number) }),
1347
- }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
1348
- }
1349
- if (t.dataset.procstop) {
1350
- ev.preventDefault();
1351
- if (!confirm(`Stop pid ${t.dataset.procstop}?`)) return;
1352
- 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(); });
1353
- }
1354
- if (t.dataset.resrelease !== undefined) {
1355
- ev.preventDefault();
1356
- const q = new URLSearchParams({ force: "1" }); if (t.dataset.resproj) q.set("project", t.dataset.resproj);
1357
- return fetch(`/v1/resources/${encodeURIComponent(t.dataset.resrelease)}?${q}`, { method: "DELETE" }).then(refresh);
1358
- }
1682
+ if (t.dataset.merge !== undefined) { ev.preventDefault(); return act.merge(...t.dataset.merge.split(":")); }
1683
+ if (t.dataset.procstop) { ev.preventDefault(); return act.procStop(t.dataset.procstop, t.dataset.procproj); }
1684
+ if (t.dataset.resrelease !== undefined) { ev.preventDefault(); return act.resRelease(t.dataset.resrelease, t.dataset.resproj); }
1359
1685
  if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
1360
1686
  if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
1361
1687
  if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
@@ -1410,6 +1736,7 @@ function openRunDrawer(taskId) {
1410
1736
  <label>model<input id="rnModel" placeholder="default" value="${esc(last.model ?? "")}"></label>
1411
1737
  <label>max turns<input id="rnTurns" type="number" min="1" placeholder="∞" value="${esc(last.turns ?? "")}"></label>
1412
1738
  </div>
1739
+ <label>profile<select id="rnProfile" title="full: every tool · no-edits: commands but no file edits · read-only: read and search only">${["full", "no-edits", "read-only"].map((m) => opt(m, last.profile ?? "full")).join("")}</select></label>
1413
1740
  <div class="dim" style="font-size:var(--fs-sm)">Claims <b>${esc(taskId)}</b> (or reuses your held worktree) and spawns <code>claude -p</code> there. The session appears in Fleet; steer it from its page.</div>
1414
1741
  </div>
1415
1742
  <div class="pk-f"><span class="grow"></span><button id="rnCancel">Cancel</button><button class="primary" id="rnGo" data-task="${esc(taskId)}">${ic("play", 13)} Run</button></div>
@@ -1419,11 +1746,11 @@ function openRunDrawer(taskId) {
1419
1746
  async function submitRun(taskId) {
1420
1747
  const prompt = $("#rnPrompt")?.value.trim();
1421
1748
  if (!prompt) return alert("A prompt is required.");
1422
- const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value;
1423
- localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns }));
1749
+ const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value, profile = $("#rnProfile")?.value;
1750
+ localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns, profile }));
1424
1751
  closePicker();
1425
1752
  const r = await fetch("/v1/runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({
1426
- projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined,
1753
+ projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined, profile: profile && profile !== "full" ? profile : undefined,
1427
1754
  }) }).then((x) => x.json());
1428
1755
  if (!r.ok) return alert(r.error);
1429
1756
  state.tasks = null;
@@ -1431,6 +1758,183 @@ async function submitRun(taskId) {
1431
1758
  openSession(r.run.sessionId);
1432
1759
  }
1433
1760
 
1761
+ // ---------- project settings drawer
1762
+ const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊", "🤖", "🧠", "🎨", "🔒", "📚", "💬", "🏗️", "🧩", "⚡"];
1763
+ // Every emoji the platform font can draw, by Unicode block — no names, but browseable; the OS picker
1764
+ // (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
1765
+ 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]];
1766
+ let emojiGrid = null;
1767
+ function buildEmojiGrid() {
1768
+ if (emojiGrid) return emojiGrid;
1769
+ // A code point counts as an emoji the platform can draw if it paints colored pixels.
1770
+ const S = 20, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
1771
+ const c = cv.getContext("2d", { willReadFrequently: true });
1772
+ c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
1773
+ const colored = (ch) => {
1774
+ c.clearRect(0, 0, S, S); c.fillText(ch, 0, 0);
1775
+ const d = c.getImageData(0, 0, S, S).data;
1776
+ 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;
1777
+ return false;
1778
+ };
1779
+ emojiGrid = EMOJI_BLOCKS.map(([name, a, b]) => {
1780
+ const list = [];
1781
+ for (let cp = a; cp <= b; cp++) { const ch = String.fromCodePoint(cp); if (colored(ch)) list.push(ch); }
1782
+ 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>` : "";
1783
+ }).join("");
1784
+ return emojiGrid;
1785
+ }
1786
+ function openProjectSettings(pid) {
1787
+ const p = state.projects.find((x) => x.id === pid);
1788
+ if (!p) return;
1789
+ const slots = ["", "c1", "c2", "c3", "c4", "c5", "c6", "c7"];
1790
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1791
+ <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>
1792
+ <div class="pk-b">
1793
+ <label>name<input id="psName" value="${esc(p.name)}" maxlength="60" spellcheck="false"></label>
1794
+ <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>
1795
+ <input type="hidden" id="psImage" value="${esc(p.icon?.startsWith("data:image/") ? p.icon : "")}">
1796
+ <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>
1797
+ <div class="emoji-all" id="psEmojiAll" hidden></div>
1798
+ <label>color</label>
1799
+ <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>
1800
+ <label class="chk"><input type="checkbox" id="psPinned" ${p.discovered ? "" : "checked"}> pinned — always in the sidebar, drag to reorder</label>
1801
+ </div>
1802
+ <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>
1803
+ </div>`;
1804
+ $("#psName").focus();
1805
+ }
1806
+ /** Downsize an image file to a square 64px PNG data URL (center-cropped). */
1807
+ function fileToIconDataUrl(file) {
1808
+ return new Promise((resolve, reject) => {
1809
+ const url = URL.createObjectURL(file);
1810
+ const img = new Image();
1811
+ img.onload = () => {
1812
+ // square: center-crop the shorter side (cover), never letterbox
1813
+ const S = 64, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
1814
+ const side = Math.min(img.width, img.height), sx = (img.width - side) / 2, sy = (img.height - side) / 2;
1815
+ cv.getContext("2d").drawImage(img, sx, sy, side, side, 0, 0, S, S);
1816
+ URL.revokeObjectURL(url);
1817
+ resolve(cv.toDataURL("image/png"));
1818
+ };
1819
+ img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("not an image the browser can decode")); };
1820
+ img.src = url;
1821
+ });
1822
+ }
1823
+ function setIconPreview(icon) {
1824
+ const el = $("#psPreview");
1825
+ if (!el) return;
1826
+ el.innerHTML = icon ? (icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(icon)}" alt="">` : esc(icon)) : ic("folder-simple", 16);
1827
+ }
1828
+ async function saveProjectSettings(pid) {
1829
+ const body = {
1830
+ name: $("#psName").value.trim() || undefined,
1831
+ icon: $("#psImage").value || $("#psIcon").value.trim(),
1832
+ color: $(".swatch.on")?.dataset.color ?? "",
1833
+ pinned: $("#psPinned").checked,
1834
+ };
1835
+ const r = await fetch(`/v1/projects/${encodeURIComponent(pid)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1836
+ if (!r.ok) return alert((await r.json()).error ?? "could not save");
1837
+ closePicker();
1838
+ state.dirty = true;
1839
+ refresh();
1840
+ }
1841
+
1842
+ // ---------- dispatch drawer (M7.5)
1843
+ function openDispatchDrawer() {
1844
+ const ready = (state.tasks?.tasks ?? []).filter((t) => t.ready);
1845
+ const cfg = state.dispatch?.config ?? {};
1846
+ const last = (() => { try { return JSON.parse(localStorage.getItem("swarm.runOpts") || "{}"); } catch { return {}; } })();
1847
+ const opt = (v, cur) => `<option value="${v}" ${v === cur ? "selected" : ""}>${v || "default"}</option>`;
1848
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1849
+ <div class="pk-h">${ic("play", 15)}<b>Dispatch</b><span class="dim now" style="flex:1;margin-left:8px">${ready.length} ready task${ready.length === 1 ? "" : "s"}</span></div>
1850
+ <div class="pk-b">
1851
+ <div class="df-files" style="max-height:30vh">${ready.map((t) => `<label style="display:flex;gap:8px;padding:4px 8px;align-items:center"><input type="checkbox" class="dpTask" value="${esc(t.id)}" checked style="width:auto"><b>${esc(t.id)}</b><span class="pa dim">${esc(t.title)}</span></label>`).join("")}</div>
1852
+ <div class="row">
1853
+ <label>at a time<input id="dpPar" type="number" min="1" max="16" value="${cfg.max_parallel ?? 2}"></label>
1854
+ <label>permission mode<select id="dpMode">${["acceptEdits", "auto", "plan", "dontAsk", "manual", "bypassPermissions"].map((m) => opt(m, cfg.permission_mode ?? last.mode ?? "acceptEdits")).join("")}</select></label>
1855
+ <label>max turns<input id="dpTurns" type="number" min="1" placeholder="∞" value="${esc(cfg.max_turns ?? last.turns ?? "")}"></label>
1856
+ </div>
1857
+ <label>profile<select id="dpProfile">${["full", "no-edits", "read-only"].map((m) => opt(m, cfg.profile ?? "full")).join("")}</select></label>
1858
+ <div class="dim" style="font-size:var(--fs-sm)">Each task gets its own claim + worktree and a <code>claude -p</code> run told to work there, run the gates, hand off and open a PR. The rest queue until a slot frees. Swarm derives the outcome from gates and PRs — a task is never flipped done by an agent.</div>
1859
+ </div>
1860
+ <div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="dispatchGo">${ic("play", 13)} Dispatch</button></div>
1861
+ </div>`;
1862
+ $("#pkClose")?.addEventListener("click", closePicker);
1863
+ }
1864
+ async function submitDispatch() {
1865
+ const tasks = [...document.querySelectorAll(".dpTask:checked")].map((i) => i.value);
1866
+ if (!tasks.length) return alert("Pick at least one task.");
1867
+ const prof = $("#dpProfile")?.value;
1868
+ const body = { projectId: state.sel, tasks, maxParallel: Number($("#dpPar")?.value) || undefined, permissionMode: $("#dpMode")?.value, maxTurns: Number($("#dpTurns")?.value) || undefined, profile: prof && prof !== "full" ? prof : undefined, owner: "dashboard" };
1869
+ closePicker();
1870
+ const r = await fetch("/v1/dispatch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
1871
+ if (!r.ok) return alert(r.error);
1872
+ if (r.rejected?.length) alert(`Not dispatched:\n${r.rejected.map((x) => `${x.id} — ${x.reason}`).join("\n")}`);
1873
+ state.tasks = null; state.dispatch = null;
1874
+ return refresh();
1875
+ }
1876
+
1877
+ // ---------- worktree diff + PR drawers (M7.3)
1878
+ const diffState = { projectId: null, worktree: null, base: null, files: [] };
1879
+ function colorPatch(patch) {
1880
+ return esc(patch).split("\n").map((l) => {
1881
+ const c = l.startsWith("+++") || l.startsWith("---") ? "m" : l.startsWith("@@") ? "h" : l.startsWith("+") ? "a" : l.startsWith("-") ? "d" : l.startsWith("diff ") ? "m" : "";
1882
+ return c ? `<span class="${c}">${l}</span>` : l;
1883
+ }).join("\n");
1884
+ }
1885
+ async function openDiffDrawer(projectId, worktree) {
1886
+ const q = new URLSearchParams({ project: projectId, worktree });
1887
+ const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
1888
+ if (d.error) return alert(d.error);
1889
+ Object.assign(diffState, { projectId, worktree: d.worktree, base: d.base, files: d.files });
1890
+ const files = d.files.map((f) => `<a href="#" data-dffile="${esc(f.path)}"><span class="st">${esc(f.status)}</span><span class="pa" title="${esc(f.path)}">${esc(f.path)}</span>${f.added >= 0 ? `<span class="pl">+${f.added}</span><span class="mi">−${f.deleted}</span>` : '<span class="dim">bin</span>'}</a>`).join("");
1891
+ $("#picker").innerHTML = `<div class="pk wide" role="dialog" aria-modal="true">
1892
+ <div class="pk-h">${ic("folders", 15)}<b>Diff</b><span class="dim now" style="flex:1;margin-left:8px">${esc(short(d.worktree))} · vs ${esc(d.baseRef ?? "HEAD")} · ${d.commits.length} commit${d.commits.length === 1 ? "" : "s"} · ${d.files.length} file${d.files.length === 1 ? "" : "s"}${d.dirty ? ' · <span class="badge warn">dirty</span>' : ""}</span></div>
1893
+ <div class="pk-b">
1894
+ ${d.commits.length ? `<div class="dim" style="font-size:var(--fs-sm)">${d.commits.slice(0, 8).map(esc).join("<br>")}${d.commits.length > 8 ? `<br>… ${d.commits.length - 8} more` : ""}</div>` : ""}
1895
+ ${d.files.length ? `<div class="df-files">${files}</div><pre class="df-patch" id="dfPatch"><span class="m">select a file — or view everything below</span></pre>` : '<div class="empty">Nothing changed.</div>'}
1896
+ </div>
1897
+ <div class="pk-f">${d.files.length ? `<a href="#" class="nav" data-dffile="">${ic("folders", 12)} Whole diff</a>` : ""}<span class="grow"></span><button id="pkClose">Close</button></div>
1898
+ </div>`;
1899
+ $("#pkClose")?.addEventListener("click", closePicker);
1900
+ }
1901
+ async function loadDiffFile(file) {
1902
+ const q = new URLSearchParams({ project: diffState.projectId, worktree: diffState.worktree });
1903
+ if (file) q.set("file", file); else q.set("patch", "1");
1904
+ for (const a of document.querySelectorAll(".df-files a")) a.classList.toggle("on", a.dataset.dffile === file);
1905
+ const el = $("#dfPatch"); if (el) el.innerHTML = '<span class="m">loading…</span>';
1906
+ const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
1907
+ if (el) el.innerHTML = d.patch ? colorPatch(d.patch) : '<span class="m">(empty)</span>';
1908
+ }
1909
+ async function openPrDrawer(projectId, worktree) {
1910
+ const q = new URLSearchParams({ project: projectId, worktree });
1911
+ const d = await fetch(`/v1/prs/draft?${q}`).then((x) => x.json());
1912
+ if (!d.ok) return alert(d.error);
1913
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1914
+ <div class="pk-h">${ic("git-pull-request", 15)}<b>Open PR</b><span class="dim now" style="flex:1;margin-left:8px">${esc(d.task)} · ${esc(d.worktree.branch ?? "")}${d.diff.dirty ? ' · <span class="badge warn">uncommitted changes — commit first</span>' : ""}</span></div>
1915
+ <div class="pk-b">
1916
+ <label>title<input id="prTitle" value="${esc(d.title)}"></label>
1917
+ <label>body<textarea id="prBody" style="min-height:220px">${esc(d.body)}</textarea></label>
1918
+ <label style="display:flex;gap:8px;align-items:center"><input type="checkbox" id="prDraft" style="width:auto"> draft</label>
1919
+ <div class="dim" style="font-size:var(--fs-sm)">Pushes <code>${esc(d.worktree.branch ?? "")}</code> to origin and runs <code>gh pr create</code> / <code>glab mr create</code> with your local login. Swarm never commits for you.</div>
1920
+ </div>
1921
+ <div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="prGo" data-project="${esc(projectId)}" data-worktree="${esc(d.worktree.path)}" ${d.diff.dirty ? "disabled" : ""}>${ic("git-pull-request", 13)} Open PR</button></div>
1922
+ </div>`;
1923
+ $("#pkClose")?.addEventListener("click", closePicker);
1924
+ }
1925
+ async function submitPr() {
1926
+ const b = $("#prGo"); if (!b) return;
1927
+ const projectId = b.dataset.project, worktree = b.dataset.worktree;
1928
+ const title = $("#prTitle")?.value.trim(), body = $("#prBody")?.value, draft = $("#prDraft")?.checked;
1929
+ b.disabled = true; b.textContent = "opening…";
1930
+ const r = await fetch("/v1/prs/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, worktree, title, body, draft }) }).then((x) => x.json());
1931
+ if (!r.ok) { b.disabled = false; b.textContent = "Open PR"; return alert(r.error); }
1932
+ closePicker();
1933
+ state.prs = [];
1934
+ await refresh();
1935
+ if (r.url) window.open(r.url, "_blank");
1936
+ }
1937
+
1434
1938
  async function openPicker(focusPath = false) {
1435
1939
  await pickerGo("");
1436
1940
  if (focusPath) { const i = $("#pkPath"); if (i) { i.focus(); i.select(); } }
@@ -1485,7 +1989,7 @@ let pending = false;
1485
1989
  const pollSoon = () => { if (!pending) { pending = true; setTimeout(() => { pending = false; poll(); }, 400); } };
1486
1990
  let backoff = 1500;
1487
1991
  function connect() {
1488
- const es = new EventSource(`/v1/events?since=${state.seq}`);
1992
+ const es = new EventSource(`/v1/events?since=${state.seq}${TOKEN ? `&token=${TOKEN}` : ""}`);
1489
1993
  const on = () => { backoff = 1500; $("#daemon .dot").classList.add("on"); };
1490
1994
  es.addEventListener("open", on);
1491
1995
  es.addEventListener("ping", on);
@@ -1504,7 +2008,7 @@ function connect() {
1504
2008
  if (fresh) notifyForEvent(ev);
1505
2009
  pollSoon();
1506
2010
  };
1507
- 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", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
2011
+ 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);
1508
2012
  }
1509
2013
  refresh().then(() => {
1510
2014
  const sid = new URLSearchParams(location.search).get("session");