@ra3orblade/swarm 0.9.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.9.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`; };
@@ -203,9 +203,39 @@ async function refresh() {
203
203
  incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
204
204
  state.allIncidents = inc;
205
205
  }
206
- if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
207
- }
208
- const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats", "search"];
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()
209
239
  // restore last view + project selection (persisted UI state)
210
240
  {
211
241
  const v = localStorage.getItem("swarm.view");
@@ -217,7 +247,7 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
217
247
  if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
218
248
  if (q.has("project")) state.sel = q.get("project") || null;
219
249
  // Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
220
- for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
250
+ renderNav();
221
251
  }
222
252
  function render() {
223
253
  // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
@@ -231,14 +261,7 @@ function render() {
231
261
  if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
232
262
  renderHeader();
233
263
  if (state.session) renderSession();
234
- else if (state.view === "spend") renderSpend();
235
- else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
236
- else if (state.view === "search") renderSearch();
237
- else if (state.view === "timeline") renderTimeline();
238
- else if (state.view === "board") renderBoard();
239
- else if (state.view === "incidents") renderIncidentsView();
240
- else if (state.view === "prs") renderPRs();
241
- else renderFleet();
264
+ else (viewDef(state.view)?.render ?? viewDef("fleet").render)();
242
265
  if (keep) {
243
266
  const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
244
267
  if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
@@ -249,9 +272,22 @@ function renderHeader() {
249
272
  const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
250
273
  const html = `Today <b>${usd(today)}</b>`;
251
274
  if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
252
- const ic_ = $("#incCount"); const n = state.openIncidents ?? 0;
253
- if (ic_) { ic_.hidden = !n; ic_.textContent = n > 99 ? "99+" : String(n); }
254
- for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", !state.session && a.dataset.view === state.view);
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; }
255
291
  }
256
292
 
257
293
  const isLive = (s) => s.state === "active" || s.state === "waiting";
@@ -348,7 +384,7 @@ function onboarding() {
348
384
  const FLEET_COLS = [
349
385
  { key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
350
386
  { key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
351
- { key: "session", label: "session", width: 210, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}` },
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>` : ""}` },
352
388
  { key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
353
389
  { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
354
390
  const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
@@ -1136,6 +1172,76 @@ async function loadTimelineDetail() {
1136
1172
  if (state.view === "timeline" && !state.session) touch();
1137
1173
  } finally { tlDetail.busy = false; }
1138
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
+
1139
1245
  function renderTimeline() {
1140
1246
  loadTimelineDetail();
1141
1247
  const now = Date.now();
@@ -1587,6 +1693,10 @@ ${p.reason ?? ""}`.slice(0, 180);
1587
1693
  title = "An agent has a question";
1588
1694
  body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
1589
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); };
1590
1700
  } else if (ev.type === "claim.orphaned") {
1591
1701
  title = "Claim orphaned";
1592
1702
  body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
@@ -1718,8 +1828,11 @@ document.addEventListener("contextmenu", (ev) => {
1718
1828
  });
1719
1829
 
1720
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.
1721
1834
  document.addEventListener("click", async (ev) => {
1722
- const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
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");
1723
1836
  if (!t) return;
1724
1837
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1725
1838
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
@@ -1855,6 +1968,57 @@ $("#sbToggle")?.addEventListener("click", () => {
1855
1968
  });
1856
1969
  sbApply();
1857
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
+
1858
2022
  // ---------- folder picker
1859
2023
  const picker = { path: null };
1860
2024
  // Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
@@ -2100,6 +2264,8 @@ async function pickerGo(path) {
2100
2264
  const closePicker = () => { $("#picker").innerHTML = ""; };
2101
2265
  $("#picker").addEventListener("click", (ev) => {
2102
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));
2103
2269
  const go = ev.target.closest("[data-go]");
2104
2270
  if (go) return void pickerGo(go.dataset.go);
2105
2271
  const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
package/web/index.html CHANGED
@@ -88,9 +88,11 @@
88
88
  kbd{font:var(--fs-mono) var(--mono);background:var(--panel-2);border:1px solid var(--line);border-radius:var(--r-xs);padding:1px 5px;color:var(--fg-2)}
89
89
  code{font:var(--fs-md) var(--mono);color:var(--fg-2)}
90
90
 
91
- /* collapsed sidebar */
92
- body.nosb{grid-template-columns:0 1fr}
93
- body.nosb>aside{display:none}
91
+ /* collapsed sidebar = icon rail (M9.1): views stay one click away; projects via ⌘K */
92
+ body.nosb{grid-template-columns:46px 1fr}
93
+ body.nosb>aside{padding:12px 5px;overflow:hidden}
94
+ body.nosb #viewnav a.nav{justify-content:center;padding:7px 0}
95
+ body.nosb #viewnav .nm,body.nosb #viewnav h4,body.nosb #viewnav .navcount,body.nosb #projects{display:none}
94
96
  body>aside{grid-column:1;grid-row:2}
95
97
  body>main{grid-column:2;grid-row:2}
96
98
  #projects h4{display:flex;align-items:center;gap:5px}
@@ -124,6 +126,13 @@
124
126
 
125
127
  /* sidebar */
126
128
  body>aside{border-right:1px solid var(--line);background:var(--panel);padding:12px 10px;overflow:auto;display:flex;flex-direction:column}
129
+ /* view nav (M9.1): grouped views in the sidebar, generated from VIEW_DEFS; header holds only chrome */
130
+ #viewnav{display:flex;flex-direction:column;gap:1px;flex:none;margin-bottom:4px}
131
+ #viewnav a.nav{display:flex;align-items:center;gap:9px;color:var(--dim);padding:5px 8px;border-radius:var(--r-sm);font-weight:500;transition:color var(--t-fast),background var(--t-fast)}
132
+ #viewnav a.nav:hover{color:var(--fg-2);background:var(--panel-2)}
133
+ #viewnav a.nav.on{color:var(--fg);background:var(--panel-2)}
134
+ #viewnav a.nav .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
135
+ #viewnav .navcount{margin-left:auto}
127
136
  body>aside h4{margin:14px 8px 6px;color:var(--faint);font-size:var(--fs-xs);font-weight:600;text-transform:uppercase;letter-spacing:.09em}
128
137
  body>aside h4:first-child{margin-top:2px}
129
138
  .proj{padding:var(--pad-row);border-radius:var(--r-sm);cursor:pointer;display:flex;align-items:center;gap:9px;
@@ -153,6 +162,11 @@
153
162
  .dr-rule select{background:var(--panel-2);color:var(--fg);border:1px solid var(--line);border-radius:var(--r-sm);padding:2px 4px}
154
163
  .dr-flaky{padding:6px 8px;border:1px solid var(--line);border-radius:var(--r-sm);margin:6px 0;background:var(--panel-2)}
155
164
  table.plain{width:100%;border-collapse:collapse;font-size:var(--fs-sm)} table.plain td{padding:3px 6px;border-top:1px solid var(--line);vertical-align:top}
165
+ /* ⌘K palette (M9.1): top-anchored .pk variant; rows reuse .pk-row */
166
+ .pk.pal{align-self:start;margin-top:9vh}
167
+ .pal .pk-list{min-height:120px;max-height:min(440px,62vh)}
168
+ .pal .pk-row.on{background:var(--acc-soft);color:var(--fg)}
169
+ .pal .pk-row .grp{margin-left:auto;color:var(--faint);font-size:var(--fs-xs);text-transform:uppercase;letter-spacing:.07em}
156
170
  #picker:empty{display:none}
157
171
  #picker{position:fixed;inset:0;background:var(--overlay);display:grid;place-items:center;z-index:50}
158
172
  .pk{width:min(560px,92vw);max-height:78vh;display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--shadow-pop);overflow:hidden}
@@ -298,6 +312,7 @@
298
312
  .badge.warn{color:var(--warn);background:var(--warn-soft)}
299
313
  .badge.acc{color:var(--acc);background:var(--acc-soft)}
300
314
  .badge.ok{color:var(--ok);background:var(--ok-soft)}
315
+ .badge.bad{color:var(--bad);background:var(--bad-soft)}
301
316
  .badge.agent{color:var(--violet);background:color-mix(in srgb,var(--violet) 14%,transparent);font-weight:600}
302
317
 
303
318
  /* empty */
@@ -426,7 +441,7 @@
426
441
  /* icons (Phosphor regular, inline) */
427
442
  .ph{display:inline-block;vertical-align:middle;flex:none}
428
443
  header .nav{display:inline-flex;align-items:center;gap:6px}
429
- header .navcount{font:600 var(--fs-xs) var(--mono);color:var(--warn);background:var(--warn-soft);border-radius:var(--r-pill);padding:1px 6px;line-height:1.4}
444
+ .navcount{font:600 var(--fs-xs) var(--mono);color:var(--warn);background:var(--warn-soft);border-radius:var(--r-pill);padding:1px 6px;line-height:1.4}
430
445
  header .nav .ph{opacity:.75}
431
446
  header .nav.on .ph{opacity:1;color:var(--acc)}
432
447
  .icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border-radius:var(--r-sm);
@@ -578,21 +593,15 @@
578
593
  <header>
579
594
  <button class="icon-btn" id="sbToggle" title="Toggle sidebar" aria-label="Toggle sidebar"><i data-icon="arrow-bar-left"></i></button>
580
595
  <span class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 11" shape-rendering="crispEdges"><rect x="2" y="1" width="1" height="1" fill="#a3e635"/><rect x="6" y="1" width="1" height="1" fill="#7cc02f"/><rect x="7" y="1" width="1" height="1" fill="#7cc02f"/><rect x="8" y="1" width="1" height="1" fill="#7cc02f"/><rect x="9" y="1" width="1" height="1" fill="#4f7d24"/><rect x="4" y="3" width="1" height="1" fill="#a3e635"/><rect x="7" y="3" width="1" height="1" fill="#7cc02f"/><rect x="8" y="3" width="1" height="1" fill="#7cc02f"/><rect x="9" y="3" width="1" height="1" fill="#7cc02f"/><rect x="10" y="3" width="1" height="1" fill="#7cc02f"/><rect x="11" y="3" width="1" height="1" fill="#7cc02f"/><rect x="12" y="3" width="1" height="1" fill="#4f7d24"/><rect x="1" y="5" width="1" height="1" fill="#a3e635"/><rect x="5" y="5" width="1" height="1" fill="#a3e635"/><rect x="6" y="5" width="1" height="1" fill="#a3e635"/><rect x="7" y="5" width="1" height="1" fill="#a3e635"/><rect x="8" y="5" width="1" height="1" fill="#a3e635"/><rect x="9" y="5" width="1" height="1" fill="#a3e635"/><rect x="10" y="5" width="1" height="1" fill="#a3e635"/><rect x="11" y="5" width="1" height="1" fill="#a3e635"/><rect x="12" y="5" width="1" height="1" fill="#a3e635"/><rect x="13" y="5" width="1" height="1" fill="#4f7d24"/><rect x="4" y="7" width="1" height="1" fill="#a3e635"/><rect x="7" y="7" width="1" height="1" fill="#7cc02f"/><rect x="8" y="7" width="1" height="1" fill="#7cc02f"/><rect x="9" y="7" width="1" height="1" fill="#7cc02f"/><rect x="10" y="7" width="1" height="1" fill="#7cc02f"/><rect x="11" y="7" width="1" height="1" fill="#4f7d24"/><rect x="2" y="9" width="1" height="1" fill="#a3e635"/><rect x="6" y="9" width="1" height="1" fill="#7cc02f"/><rect x="7" y="9" width="1" height="1" fill="#7cc02f"/><rect x="8" y="9" width="1" height="1" fill="#7cc02f"/><rect x="9" y="9" width="1" height="1" fill="#4f7d24"/></svg>Swarm</span>
581
- <a href="#" data-view="fleet" class="nav on"><i data-icon="squares-four"></i>Fleet</a>
582
- <a href="#" data-view="board" class="nav"><i data-icon="stack"></i>Board</a>
583
- <a href="#" data-view="incidents" class="nav"><i data-icon="warning"></i>Incidents<b id="incCount" class="navcount" hidden></b></a>
584
- <a href="#" data-view="prs" class="nav"><i data-icon="git-pull-request"></i>PRs</a>
585
- <a href="#" data-view="timeline" class="nav"><i data-icon="clock-counter-clockwise"></i>Timeline</a>
586
- <a href="#" data-view="spend" class="nav"><i data-icon="coins"></i>Spend</a>
587
- <a href="#" data-view="stats" class="nav"><i data-icon="chart-bar"></i>Stats</a>
588
- <a href="#" data-view="search" class="nav"><i data-icon="magnifying-glass"></i>Search</a>
589
596
  <span class="sp"></span>
590
597
  <span id="today"></span>
591
598
  <span id="daemon"><span class="dot"></span><span class="lbl">Daemon</span></span>
599
+ <button class="icon-btn" id="palBtn" title="Jump to view, project or session (⌘K)" aria-label="Open command palette"><i data-icon="magnifying-glass"></i></button>
592
600
  <button class="icon-btn" id="feedback" title="Send feedback (opens a GitHub issue)" aria-label="Send feedback"><i data-icon="comment-text"></i></button>
593
601
  <button class="icon-btn" id="settings" title="Settings" aria-label="Settings"><i data-icon="sliders"></i></button>
594
602
  </header>
595
603
  <aside>
604
+ <nav id="viewnav"></nav>
596
605
  <div id="projects"></div>
597
606
  </aside>
598
607
  <main id="main"></main>