@ra3orblade/swarm 0.8.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ra3orblade/swarm",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Local-first control plane for AI-agent development: watch every Claude Code / Codex / Grok session on your machine, ledger tasks and worktrees, enforce rules as hook denials.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/web/app.js CHANGED
@@ -65,7 +65,7 @@ document.addEventListener("keydown", (ev) => {
65
65
  window.swarmZoom(dir);
66
66
  });
67
67
  // `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
68
- const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
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, collisions: null, outcomes: null, dirty: true };
69
69
 
70
70
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
71
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`; };
@@ -155,7 +155,7 @@ async function refresh() {
155
155
  const txt = await (await fetch("/v1/state")).text();
156
156
  const same = txt === lastSnap;
157
157
  if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
158
- 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(() => {});
159
159
  let prsChanged = false;
160
160
  if (state.view === "prs" && !state.session) {
161
161
  const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
@@ -175,6 +175,10 @@ async function refresh() {
175
175
  state.attribution = null;
176
176
  }
177
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
+ }
178
182
  const openSpawned = state.session && state.sessions.find((x) => x.id === state.session)?.kind === "spawned";
179
183
  if (openSpawned || (state.view === "board" && !state.session) || (state.view === "fleet" && !state.session)) {
180
184
  const runs = await fetch("/v1/runs").then((r) => r.json()).catch(() => state.runs ?? []);
@@ -183,13 +187,14 @@ async function refresh() {
183
187
  }
184
188
  let tasksChanged = false;
185
189
  if (state.view === "board" && state.sel && !state.session) {
186
- const [t, g, d] = await Promise.all([
190
+ const [t, g, d, wf] = await Promise.all([
187
191
  fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
188
192
  fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
189
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),
190
195
  ]);
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;
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;
193
198
  }
194
199
  let incChanged = false;
195
200
  if (state.view === "incidents" && !state.session) {
@@ -198,9 +203,39 @@ async function refresh() {
198
203
  incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
199
204
  state.allIncidents = inc;
200
205
  }
201
- if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
202
- }
203
- const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats", "search"];
206
+ let colChanged = false;
207
+ if (state.view === "graphs" && !state.session) {
208
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
209
+ const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
210
+ colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
211
+ state.collisions = col;
212
+ }
213
+ let outChanged = false;
214
+ if (state.view === "outcomes" && !state.session) {
215
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
216
+ const o = await fetch(`/v1/outcomes${q}`).then((r) => r.json()).catch(() => state.outcomes);
217
+ outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
218
+ state.outcomes = o;
219
+ }
220
+ if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || outChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
221
+ }
222
+ // M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
223
+ // deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
224
+ const VIEW_DEFS = [
225
+ { id: "fleet", label: "Fleet", icon: "squares-four", group: "Observe", render: () => renderFleet() },
226
+ { id: "timeline", label: "Timeline", icon: "clock-counter-clockwise", group: "Observe", render: () => renderTimeline() },
227
+ { id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
228
+ { id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
229
+ { id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
230
+ { id: "outcomes", label: "Outcomes", icon: "check", group: "Insight", render: () => renderOutcomes() },
231
+ { id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
232
+ { id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
233
+ { id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
234
+ { id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
235
+ ];
236
+ const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
237
+ const VIEWS = VIEW_DEFS.map((v) => v.id);
238
+ let navHtml = ""; // last-rendered nav html; declared before the restore block below calls renderNav()
204
239
  // restore last view + project selection (persisted UI state)
205
240
  {
206
241
  const v = localStorage.getItem("swarm.view");
@@ -212,7 +247,7 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
212
247
  if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
213
248
  if (q.has("project")) state.sel = q.get("project") || null;
214
249
  // Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
215
- for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
250
+ renderNav();
216
251
  }
217
252
  function render() {
218
253
  // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
@@ -226,14 +261,7 @@ function render() {
226
261
  if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
227
262
  renderHeader();
228
263
  if (state.session) renderSession();
229
- else if (state.view === "spend") renderSpend();
230
- else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
231
- else if (state.view === "search") renderSearch();
232
- else if (state.view === "timeline") renderTimeline();
233
- else if (state.view === "board") renderBoard();
234
- else if (state.view === "incidents") renderIncidentsView();
235
- else if (state.view === "prs") renderPRs();
236
- else renderFleet();
264
+ else (viewDef(state.view)?.render ?? viewDef("fleet").render)();
237
265
  if (keep) {
238
266
  const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
239
267
  if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
@@ -244,9 +272,22 @@ function renderHeader() {
244
272
  const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
245
273
  const html = `Today <b>${usd(today)}</b>`;
246
274
  if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
247
- const ic_ = $("#incCount"); const n = state.openIncidents ?? 0;
248
- if (ic_) { ic_.hidden = !n; ic_.textContent = n > 99 ? "99+" : String(n); }
249
- for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", !state.session && a.dataset.view === state.view);
275
+ renderNav();
276
+ }
277
+ // M9.1: grouped view nav in the sidebar, generated from VIEW_DEFS. Rebuilt only when the
278
+ // html changes (active view, badges) so the 5s poll doesn't churn the DOM.
279
+ function renderNav() {
280
+ const groups = [];
281
+ for (const v of VIEW_DEFS) {
282
+ const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
283
+ g.views.push(v);
284
+ }
285
+ const link = (v) => {
286
+ const n = v.badge?.() ?? 0;
287
+ return `<a href="#" data-view="${v.id}" class="nav ${!state.session && state.view === v.id ? "on" : ""}" title="${v.label}">${ic(v.icon, 14)}<span class="nm">${v.label}</span>${n ? `<b class="navcount">${n > 99 ? "99+" : n}</b>` : ""}</a>`;
288
+ };
289
+ const html = groups.map((g) => `<h4>${g.name}</h4>${g.views.map(link).join("")}`).join("");
290
+ if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
250
291
  }
251
292
 
252
293
  const isLive = (s) => s.state === "active" || s.state === "waiting";
@@ -256,6 +297,12 @@ function liveCounts() {
256
297
  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); }
257
298
  return m;
258
299
  }
300
+ // M5.7: 14-day spend sparkline per pinned project; hidden when the fortnight cost is ~zero.
301
+ function spendSpark(pid) {
302
+ const pts = state.spendSparks?.[pid];
303
+ if (!pts || pts.reduce((a, b) => a + b, 0) < 0.5) return "";
304
+ return `<span class="proj-spark" title="last 14 days · $${pts.reduce((a, b) => a + b, 0).toFixed(0)}">${viz.sparkline(pts, "var(--c1)")}</span>`;
305
+ }
259
306
  function renderProjects() {
260
307
  const lc = liveCounts();
261
308
  const live = (pid) => lc.get(pid) ?? 0;
@@ -272,7 +319,7 @@ function renderProjects() {
272
319
  const row = (p) => {
273
320
  const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
274
321
  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"'}>
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>`;
322
+ <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>`;
276
323
  };
277
324
  const liveAll = live("");
278
325
  $("#projects").innerHTML =
@@ -317,12 +364,27 @@ projectsEl.addEventListener("dragend", () => {
317
364
  fetch("/v1/projects/order", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }) }).then(refresh);
318
365
  });
319
366
 
367
+ // First run: no sessions have ever been seen. Say exactly what to do next, and whether hooks are in.
368
+ function onboarding() {
369
+ const hooksOk = state.hooksInstalled !== false;
370
+ const step = (n, done, html) => `<div class="ob-step ${done ? "done" : ""}"><span class="ob-n">${done ? "✓" : n}</span><div>${html}</div></div>`;
371
+ return `<div class="onboard">${PX.idle()}
372
+ <h3>Swarm is running and watching this machine.</h3>
373
+ <div class="ob-steps">
374
+ ${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.`)}
375
+ ${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.`)}
376
+ ${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.`)}
377
+ </div>
378
+ <div class="dim">Something off? <code>swarm doctor</code> checks every piece and prints the fix.</div>
379
+ </div>`;
380
+ }
381
+
320
382
  // ---------- fleet
321
383
  // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
322
384
  const FLEET_COLS = [
323
385
  { key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
324
386
  { 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>' : ""}` },
387
+ { key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}${s.stuck ? ` <span class="badge bad" title="${esc(s.stuck)} — heuristic, nothing was interrupted; open the session to judge">Stuck</span>` : ""}` },
326
388
  { key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
327
389
  { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
328
390
  const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
@@ -363,7 +425,7 @@ function renderFleet() {
363
425
  : "";
364
426
  $("#main").innerHTML = chips +
365
427
  `<h2>Live <span>${live.length} sessions · ${usd(sumBy(live, (s) => s.costUsd))}</span></h2>` +
366
- (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>`) +
428
+ (live.length ? table(live, "fleet-live") : state.sessions.length ? `<div class="empty">${PX.idle()}Nothing running.</div>` : onboarding()) +
367
429
  (rest.length ? `<h2 class="mt-sec">Earlier <span>${rest.length}</span></h2>${table(rest.slice(0, 30), "fleet-earlier")}` : "") +
368
430
  "";
369
431
  }
@@ -430,7 +492,7 @@ function renderBoardKpis() {
430
492
  }
431
493
 
432
494
  function renderBoard() {
433
- const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
495
+ const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderWorkflowRuns(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
434
496
  $("#main").innerHTML = parts.length
435
497
  ? parts.join("").replace(/^(<div class="kpis[^>]*>[\s\S]*?<\/div><\/div>|)(<h2) class="mt-sec"/, "$1$2") // first section needs no top gap
436
498
  : `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
@@ -627,7 +689,13 @@ function renderGates() {
627
689
  { 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>') },
628
690
  { 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>') },
629
691
  ];
630
- 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>` +
692
+ const history = (gate) => {
693
+ const rs = runs.filter((r) => r.gate === gate).slice(0, 12).reverse();
694
+ if (!rs.length) return "";
695
+ 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>`;
696
+ };
697
+ const gateNames = [...new Set(runs.map((r) => r.gate))];
698
+ 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>` +
631
699
  (runs.length
632
700
  ? dataTable({
633
701
  id: "gates",
@@ -772,6 +840,37 @@ function renderWorktrees() {
772
840
  }));
773
841
  }
774
842
 
843
+ // ---------- workflows (M7.8)
844
+ function renderWorkflowRuns() {
845
+ const w = state.workflows;
846
+ if (!state.sel || !w?.runs?.length) return "";
847
+ const chip = (r, i) => {
848
+ const label = esc(r.steps[i]);
849
+ if (i < r.step || (r.state === "done" && i <= r.step)) return `<span class="wfs ok" title="${label}">✓ ${label}</span>`;
850
+ 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>`;
851
+ return `<span class="wfs" title="${label}">○ ${label}</span>`;
852
+ };
853
+ 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>';
854
+ const cols = [
855
+ { key: "task", label: "task", width: 90, get: (r) => r.task, cell: (r) => `<b>${esc(r.task)}</b>` },
856
+ { key: "workflow", label: "workflow", width: 100, get: (r) => r.workflow, cell: (r) => `<span class="br">${esc(r.workflow)}</span>` },
857
+ { 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>` },
858
+ { key: "state", label: "state", width: 90, get: (r) => r.state, cell: badge },
859
+ { key: "detail", label: "detail", width: 260, get: (r) => r.detail ?? "", cell: (r) => `<span class="dim now" title="${esc(r.detail ?? "")}">${esc(r.detail ?? "")}</span>` },
860
+ { key: "when", label: "updated", width: 76, get: (r) => r.updatedAt, cell: (r) => `<span class="dim">${ago(r.updatedAt)}</span>` },
861
+ ];
862
+ const running = w.runs.filter((r) => r.state === "running").length;
863
+ return `<h2 class="mt-sec">Workflows <span>${running ? `${running} running · ` : ""}${Object.keys(w.defs ?? {}).map(esc).join(", ") || "none declared"}</span></h2>` +
864
+ dataTable({
865
+ id: "workflows",
866
+ columns: cols,
867
+ rows: w.runs.slice(0, 20),
868
+ leading: { width: 24, cell: (r) => `<span class="s ${r.state === "running" ? "active" : r.state === "failed" ? "waiting" : "ended"}"></span>` },
869
+ trailing: { width: 60, cell: (r) => (r.state === "running" ? `<a href="#" data-wfstop="${esc(r.task)}">Stop</a>` : "") },
870
+ rerender: touch,
871
+ });
872
+ }
873
+
775
874
  // ---------- dispatch (M7.5)
776
875
  function renderDispatch() {
777
876
  const d = state.dispatch;
@@ -1059,7 +1158,92 @@ function renderStats() {
1059
1158
  }
1060
1159
 
1061
1160
  // ---------- timeline
1161
+ let tlDetail = { key: "", data: null, busy: false };
1162
+ async function loadTimelineDetail() {
1163
+ const hours = state.tlHours ?? 12;
1164
+ const key = `${hours}:${state.sel ?? ""}`;
1165
+ if (tlDetail.busy || (tlDetail.key === key && tlDetail.at && Date.now() - tlDetail.at < 15_000)) return;
1166
+ tlDetail.busy = true;
1167
+ try {
1168
+ const q = new URLSearchParams({ hours: String(hours) });
1169
+ if (state.sel) q.set("project", state.sel);
1170
+ const data = await (await fetch(`/v1/timeline?${q}`)).json();
1171
+ tlDetail = { key, at: Date.now(), data, busy: false };
1172
+ if (state.view === "timeline" && !state.session) touch();
1173
+ } finally { tlDetail.busy = false; }
1174
+ }
1175
+ // M9.2: Outcomes — did the agent's work survive? Branch → PR → merged / reverted, with
1176
+ // scorecards per model and per agent. Data from /v1/outcomes (fetched by the poll while open).
1177
+ const outBadge = (o) => ({ merged: '<span class="badge ok">merged</span>', reverted: '<span class="badge bad">reverted</span>', open: '<span class="badge acc">open</span>', "no-pr": '<span class="badge">no PR</span>' })[o] ?? esc(o);
1178
+ const ratePct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
1179
+ const hrs = (x) => (x == null || x < 0 ? "—" : x < 1 ? `${Math.round(x * 60)}m` : x < 48 ? `${x.toFixed(1)}h` : `${(x / 24).toFixed(1)}d`);
1180
+ const scoreCols = (label) => [
1181
+ { key: "key", label, width: 150, get: (r) => r.key, cell: (r) => `<b>${esc(label === "model" ? model(r.key) : viz.agentName(r.key))}</b>` },
1182
+ { key: "branches", label: "branches", width: 80, num: true, get: (r) => r.branches, cell: (r) => String(r.branches) },
1183
+ { key: "merged", label: "merged", width: 72, num: true, get: (r) => r.merged, cell: (r) => String(r.merged) },
1184
+ { key: "reverted", label: "reverted", width: 78, num: true, get: (r) => r.reverted, cell: (r) => (r.reverted ? `<b style="color:var(--bad)">${r.reverted}</b>` : "0") },
1185
+ { key: "open", label: "open", width: 60, num: true, get: (r) => r.open, cell: (r) => String(r.open) },
1186
+ { key: "nopr", label: "no PR", width: 64, num: true, get: (r) => r.noPr, cell: (r) => String(r.noPr) },
1187
+ { key: "rate", label: "merge rate", width: 92, num: true, get: (r) => r.mergeRate ?? -1, cell: (r) => ratePct(r.mergeRate) },
1188
+ { key: "lead", label: "median lead", width: 98, num: true, get: (r) => r.medianLeadHours ?? -1, cell: (r) => hrs(r.medianLeadHours) },
1189
+ { key: "cpm", label: "$ / merge", width: 84, num: true, get: (r) => r.costPerMerge ?? -1, cell: (r) => (r.costPerMerge == null ? "—" : usd(r.costPerMerge)) },
1190
+ ];
1191
+ const BRANCH_COLS = [
1192
+ { key: "branch", label: "branch", width: 190, get: (r) => r.branch, cell: (r) => `<span class="br">${esc(r.branch)}</span>` },
1193
+ { key: "outcome", label: "outcome", width: 92, cls: "td-badge", get: (r) => r.outcome, cell: (r) => outBadge(r.outcome) },
1194
+ { key: "pr", label: "PR", flex: true, get: (r) => r.title ?? "", cell: (r) => (r.prNumber ? `<a href="${esc(r.url ?? "#")}" target="_blank" rel="noreferrer">#${r.prNumber}</a> <span class="dim">${esc(r.title ?? "")}</span>` : '<span class="faint">—</span>') },
1195
+ { key: "model", label: "model", width: 92, get: (r) => model(r.model), cell: (r) => `<span class="br">${esc(model(r.model))}</span>` },
1196
+ { key: "agent", label: "agent", width: 78, cls: "td-badge", get: (r) => agentLabel(r.agent), cell: (r) => agentBadge(r.agent) },
1197
+ { key: "sessions", label: "sessions", width: 76, num: true, get: (r) => r.sessions.length, cell: (r) => String(r.sessions.length) },
1198
+ { key: "cost", label: "cost", width: 64, num: true, get: (r) => r.costUsd, cell: (r) => usd(r.costUsd) },
1199
+ { key: "lead", label: "lead", width: 64, num: true, get: (r) => r.leadHours ?? -1, cell: (r) => hrs(r.leadHours) },
1200
+ ];
1201
+ function renderOutcomes() {
1202
+ const o = state.outcomes;
1203
+ const head = (sub) => `<h2>Outcomes <span>${sub}</span></h2>`;
1204
+ if (!o) {
1205
+ $("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}Loading…</div>`;
1206
+ return;
1207
+ }
1208
+ if (!o.branches?.length) {
1209
+ $("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}No agent branches yet${state.sel ? " in this project" : ""}.<br>Outcomes fill in as sessions work on branches and their PRs merge — or get reverted.</div>`;
1210
+ return;
1211
+ }
1212
+ const n = (k) => o.branches.filter((b) => b.outcome === k).length;
1213
+ const rev = n("reverted");
1214
+ $("#main").innerHTML =
1215
+ head(`${o.branches.length} branch${o.branches.length === 1 ? "" : "es"} · ${n("merged")} merged · ${rev ? `<b style="color:var(--bad)">${rev} reverted</b>` : "0 reverted"} · ${n("open")} open`) +
1216
+ `<h2 class="mt-sec">By model <span>who ships work that survives</span></h2>` +
1217
+ dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel }) +
1218
+ (o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent })}` : "") +
1219
+ `<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
1220
+ dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100) });
1221
+ }
1222
+
1223
+ // M9.12: live file-collision graph — which live sessions touch which files, contested files
1224
+ // highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
1225
+ function renderGraphs() {
1226
+ const g = state.collisions;
1227
+ const title = (s) => s.title ?? s.id.slice(0, 8);
1228
+ const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>`;
1229
+ if (!g || !g.sessions.length) {
1230
+ $("#main").innerHTML = head("live file collisions") + `<div class="empty">${PX.idle()}No live sessions${state.sel ? " in this project" : ""}.<br>The collision graph shows who is touching what, the moment two agents run at once.</div>`;
1231
+ return;
1232
+ }
1233
+ if (!g.files.length) {
1234
+ $("#main").innerHTML = head(`${g.sessions.length} live session${g.sessions.length === 1 ? "" : "s"}`) + `<div class="empty">${PX.idle()}No file touches recorded yet — the graph fills in as agents read and edit.</div>`;
1235
+ return;
1236
+ }
1237
+ const sessions = g.sessions.map((s) => ({ ...s, label: title(s) }));
1238
+ const agents = [...new Set(sessions.map((s) => s.agent))].sort(viz.agentSort);
1239
+ const sub = `${sessions.length} live session${sessions.length === 1 ? "" : "s"} · ${g.files.length} file${g.files.length === 1 ? "" : "s"} · ${g.contested ? `<b class="navcount">${g.contested} contested</b>` : "no collisions"}`;
1240
+ $("#main").innerHTML = head(sub) +
1241
+ `<div class="card" style="padding:14px">${viz.bipartite(sessions, g.files)}</div>
1242
+ <div style="margin-top:10px;display:flex;gap:16px;align-items:center">${viz.legend(agents)}<span class="dim" style="font-size:var(--fs-sm)">solid edge = writing · faint edge = reading · <span style="color:var(--bad)">red file</span> = two sessions on it, at least one writing</span></div>`;
1243
+ }
1244
+
1062
1245
  function renderTimeline() {
1246
+ loadTimelineDetail();
1063
1247
  const now = Date.now();
1064
1248
  const hours = state.tlHours ?? 12;
1065
1249
  const from = now - hours * 3.6e6, to = now + 0.25 * 3.6e6;
@@ -1068,7 +1252,7 @@ function renderTimeline() {
1068
1252
  const chip = (h) => `<a href="#" class="nav ${hours === h ? "on" : ""}" data-tl="${h}">${h}h</a>`;
1069
1253
  $("#main").innerHTML =
1070
1254
  `<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>
1071
- ${rows.length ? viz.timeline(rows, { from, to, projName, now }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
1255
+ ${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>`}
1072
1256
  ${agents.length ? `<div style="margin-top:10px">${viz.legend(agents)}</div>` : ""}`;
1073
1257
  }
1074
1258
 
@@ -1185,6 +1369,18 @@ function replayGo(delta) {
1185
1369
  }
1186
1370
 
1187
1371
  // Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
1372
+ // M7.6: the session's message thread (sent + received) and a compose box.
1373
+ function messageThread(s) {
1374
+ const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
1375
+ const row = (m) => {
1376
+ const out = m.fromSession === s.id;
1377
+ return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
1378
+ <span class="msg-f">${out ? `→ ${esc(m.task ?? m.toKind)}` : esc(m.from ?? "?")}${m.deliveredAt ? "" : ' <i class="dim">·queued</i>'}</span>${esc(m.text)}</div>`;
1379
+ };
1380
+ return `<h4>messages</h4>${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : '<span class="dim">None yet.</span>'}
1381
+ <div class="msg-compose"><input id="msgText" placeholder="Message this agent… (delivered on its next tool call)" autocomplete="off"><button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}">${ic("arrow-right", 13)}</button></div>`;
1382
+ }
1383
+
1188
1384
  // M7.7: questions this session is waiting on a human for
1189
1385
  function questionCards(s) {
1190
1386
  const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
@@ -1251,6 +1447,7 @@ function renderSession() {
1251
1447
  <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 }])}
1252
1448
  ${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
1253
1449
  <h4>tools</h4>${tools.length ? viz.hbars(tools.slice(0, 8).map(([k, v]) => [k.replace(/^mcp__[a-z0-9-]+__/i, ""), v])) : '<span class="dim">None yet</span>'}
1450
+ ${messageThread(s)}
1254
1451
  ${questionCards(s)}
1255
1452
  ${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
1256
1453
  if (logEl && isAppend(rows)) {
@@ -1496,6 +1693,10 @@ ${p.reason ?? ""}`.slice(0, 180);
1496
1693
  title = "An agent has a question";
1497
1694
  body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
1498
1695
  onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1696
+ } else if (ev.type === "session.stuck") {
1697
+ title = "Session looks stuck";
1698
+ body = (p.reason ?? p.summary ?? "").slice(0, 180);
1699
+ onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1499
1700
  } else if (ev.type === "claim.orphaned") {
1500
1701
  title = "Claim orphaned";
1501
1702
  body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
@@ -1527,6 +1728,33 @@ function whatsNew(version) {
1527
1728
  }
1528
1729
  window.swarmWhatsNew = (v) => whatsNew(v);
1529
1730
  // auto-open once per version, but never on the very first run (nothing to compare against)
1731
+ // M-launch: after an update the running daemon is the old build until it restarts. The daemon
1732
+ // reports the version on disk; when it differs, offer a one-click restart, then reload.
1733
+ let updateNudged = false;
1734
+ setInterval(() => { fetch("/v1/health").then((r) => r.json()).then(maybeUpdateNudge).catch(() => {}); }, 300_000);
1735
+ function maybeUpdateNudge(h) {
1736
+ if (!h?.disk || !h.version || h.disk === h.version || updateNudged) return;
1737
+ updateNudged = true;
1738
+ const el = document.createElement("div");
1739
+ el.className = "nudge";
1740
+ 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.
1741
+ <div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
1742
+ document.body.appendChild(el);
1743
+ el.addEventListener("click", async (e) => {
1744
+ if (e.target.id === "updLater") return el.remove();
1745
+ if (e.target.id !== "updRestart") return;
1746
+ e.target.textContent = "restarting…";
1747
+ await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
1748
+ const t0 = Date.now();
1749
+ const wait = setInterval(async () => {
1750
+ try {
1751
+ const j = await (await fetch("/v1/health")).json();
1752
+ if (j.version === h.disk) { clearInterval(wait); location.reload(); }
1753
+ } catch {}
1754
+ if (Date.now() - t0 > 30_000) { clearInterval(wait); el.remove(); }
1755
+ }, 800);
1756
+ });
1757
+ }
1530
1758
  function maybeWhatsNew() {
1531
1759
  if (!state.version || !window.RELEASE_NOTES) return;
1532
1760
  let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
@@ -1581,6 +1809,9 @@ function openMenu(kind, anchor, d) {
1581
1809
  if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
1582
1810
  window.menus.open(anchor, spec);
1583
1811
  }
1812
+ document.addEventListener("keydown", (e) => {
1813
+ if (e.key === "Enter" && e.target.id === "msgText") { e.preventDefault(); $("#msgSend")?.click(); }
1814
+ });
1584
1815
  // Enter / Space on a focused card, tile or kebab opens its menu like a click.
1585
1816
  document.addEventListener("keydown", (ev) => {
1586
1817
  if (ev.key !== "Enter" && ev.key !== " ") return;
@@ -1597,8 +1828,11 @@ document.addEventListener("contextmenu", (ev) => {
1597
1828
  });
1598
1829
 
1599
1830
  // ---------- events
1831
+ // Every id / data-attr a branch below matches on MUST be in this selector, or the branch is
1832
+ // unreachable (closest() returns null and the click dies silently) — that is how Replay,
1833
+ // Resume-where-it-died and the dry-run Re-run button all shipped dead.
1600
1834
  document.addEventListener("click", async (ev) => {
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");
1835
+ const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#replay,#resumeDead,#drRun,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
1602
1836
  if (!t) return;
1603
1837
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1604
1838
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
@@ -1609,7 +1843,25 @@ document.addEventListener("click", async (ev) => {
1609
1843
  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
1844
  if (t.id === "psAllEmoji") { const all = $("#psEmojiAll"); if (all.hidden) { all.innerHTML = buildEmojiGrid(); all.hidden = false; } else all.hidden = true; return; }
1611
1845
  if (t.dataset.color !== undefined && t.classList.contains("swatch")) { for (const e of $$(".swatch")) e.classList.toggle("on", e === t); return; }
1846
+ if (t.id === "msgSend") {
1847
+ ev.preventDefault();
1848
+ const text = $("#msgText")?.value.trim();
1849
+ if (!text) return;
1850
+ const r = await post("/v1/messages", { projectId: t.dataset.pid, to: t.dataset.sid, text, from: "dashboard" });
1851
+ if (!r.ok) return alert(r.error);
1852
+ $("#msgText").value = "";
1853
+ state.msgs = null;
1854
+ return refresh();
1855
+ }
1612
1856
  if (t.id === "psSave") { ev.preventDefault(); return saveProjectSettings(t.dataset.pid); }
1857
+ if (t.dataset.wfstop !== undefined) {
1858
+ ev.preventDefault();
1859
+ if (!confirm(`Stop the workflow on ${t.dataset.wfstop}? A live step's run is stopped too.`)) return;
1860
+ const r = await post("/v1/workflows/stop", { projectId: state.sel, task: t.dataset.wfstop });
1861
+ if (!r.ok) alert(r.error);
1862
+ state.workflows = null;
1863
+ return refresh();
1864
+ }
1613
1865
  if (t.dataset.bmode) { ev.preventDefault(); const [k, v] = t.dataset.bmode.split(":"); localStorage.setItem(`swarm.board.${k}`, v); return touch(); }
1614
1866
  if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
1615
1867
  if (t.dataset.runstop) {
@@ -1716,6 +1968,57 @@ $("#sbToggle")?.addEventListener("click", () => {
1716
1968
  });
1717
1969
  sbApply();
1718
1970
 
1971
+ // ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
1972
+ const pal = { items: [], view: [], q: "", i: 0 };
1973
+ function palBuild() {
1974
+ const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => { state.view = v.id; localStorage.setItem("swarm.view", v.id); state.session = null; state.dirty = true; refresh(); } }));
1975
+ for (const p of state.projects) items.push({ icon: "folder-simple", label: p.name, grp: "project", run: () => { state.sel = p.id; localStorage.setItem("swarm.sel", p.id); state.session = null; state.dirty = true; refresh(); } });
1976
+ const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
1977
+ for (const s of state.sessions) items.push({ icon: "terminal-window", label: s.title || s.id.slice(0, 8), sub: pname(s.projectId), live: isLive(s), grp: "session", run: () => openSession(s.id) });
1978
+ return items;
1979
+ }
1980
+ function palFilter() {
1981
+ const q = pal.q.trim().toLowerCase();
1982
+ const rank = (x) => Math.min(...[x.label, x.sub ?? ""].map((t) => { const i = t.toLowerCase().indexOf(q); return i < 0 ? 1e9 : i; }));
1983
+ const out = q
1984
+ ? pal.items.map((x) => ({ x, r: rank(x) })).filter((h) => h.r < 1e9).sort((a, b) => a.r - b.r).map((h) => h.x).slice(0, 12)
1985
+ : pal.items.filter((x) => x.grp !== "session" || x.live).slice(0, 16); // idle: every view + project + live sessions
1986
+ if (q) out.push({ icon: "magnifying-glass", label: `Search Swarm for “${pal.q.trim()}”`, grp: "search", run: () => { srch.q = pal.q.trim(); state.view = "search"; localStorage.setItem("swarm.view", "search"); state.session = null; state.dirty = true; runSearch(); refresh(); } });
1987
+ return out;
1988
+ }
1989
+ function palRender() {
1990
+ pal.view = palFilter();
1991
+ if (pal.i >= pal.view.length) pal.i = Math.max(0, pal.view.length - 1);
1992
+ const row = (x, i) => `<div class="pk-row pal-row ${i === pal.i ? "on" : ""}" data-pal="${i}">${ic(x.icon, 14)}<span class="nm">${esc(x.label)}${x.sub ? ` <span class="dim">· ${esc(x.sub)}</span>` : ""}</span><span class="grp">${x.grp}</span></div>`;
1993
+ const el = $("#palList");
1994
+ if (el) el.innerHTML = pal.view.map(row).join("") || '<div class="empty" style="padding:16px">No matches.</div>';
1995
+ }
1996
+ function palRun(i) {
1997
+ const x = pal.view[i];
1998
+ if (!x) return;
1999
+ closePicker();
2000
+ x.run();
2001
+ }
2002
+ function openPalette() {
2003
+ pal.items = palBuild(); pal.q = ""; pal.i = 0;
2004
+ $("#picker").innerHTML = `<div class="pk pal" role="dialog" aria-modal="true">
2005
+ <div class="pk-h">${ic("magnifying-glass", 15)}<input id="palQ" placeholder="Jump to view, project or session…" spellcheck="false" autocomplete="off"></div>
2006
+ <div class="pk-list" id="palList"></div>
2007
+ </div>`;
2008
+ palRender();
2009
+ const inp = $("#palQ");
2010
+ inp.focus();
2011
+ inp.addEventListener("input", () => { pal.q = inp.value; pal.i = 0; palRender(); });
2012
+ inp.addEventListener("keydown", (ev) => {
2013
+ if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); pal.i = Math.max(0, Math.min(pal.view.length - 1, pal.i + (ev.key === "ArrowDown" ? 1 : -1))); palRender(); }
2014
+ else if (ev.key === "Enter") { ev.preventDefault(); palRun(pal.i); }
2015
+ });
2016
+ }
2017
+ $("#palBtn")?.addEventListener("click", openPalette);
2018
+ document.addEventListener("keydown", (ev) => {
2019
+ if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { ev.preventDefault(); if ($("#palQ")) closePicker(); else openPalette(); }
2020
+ });
2021
+
1719
2022
  // ---------- folder picker
1720
2023
  const picker = { path: null };
1721
2024
  // Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
@@ -1961,6 +2264,8 @@ async function pickerGo(path) {
1961
2264
  const closePicker = () => { $("#picker").innerHTML = ""; };
1962
2265
  $("#picker").addEventListener("click", (ev) => {
1963
2266
  if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
2267
+ const pr = ev.target.closest("[data-pal]");
2268
+ if (pr) return palRun(Number(pr.dataset.pal));
1964
2269
  const go = ev.target.closest("[data-go]");
1965
2270
  if (go) return void pickerGo(go.dataset.go);
1966
2271
  const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
@@ -2008,7 +2313,7 @@ function connect() {
2008
2313
  if (fresh) notifyForEvent(ev);
2009
2314
  pollSoon();
2010
2315
  };
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);
2316
+ for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
2012
2317
  }
2013
2318
  refresh().then(() => {
2014
2319
  const sid = new URLSearchParams(location.search).get("session");