@ra3orblade/swarm 0.4.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 ADDED
@@ -0,0 +1,1103 @@
1
+ const $ = (s) => document.querySelector(s);
2
+ // macOS desktop app signals its overlay title bar via ?chrome=inset (see src-tauri/lib.rs).
3
+ if (new URLSearchParams(location.search).get("chrome") === "inset") {
4
+ document.documentElement.classList.add("chrome-inset");
5
+ // The overlay title bar has no native drag region — drag the window from the header.
6
+ const twin = () => window.__TAURI__?.window?.getCurrentWindow?.();
7
+ const inert = (e) => e.target.closest("a,button,input,select");
8
+ const hdr = document.querySelector("header");
9
+ hdr?.addEventListener("mousedown", (e) => {
10
+ if (e.button === 0 && !inert(e)) twin()?.startDragging?.();
11
+ });
12
+ hdr?.addEventListener("dblclick", (e) => {
13
+ if (!inert(e)) twin()?.toggleMaximize?.();
14
+ });
15
+ }
16
+ // UI zoom. The browser zooms natively; the desktop webview doesn't, so the app does it itself:
17
+ // ⌘/Ctrl + − 0 here (and the native View menu in src-tauri/lib.rs, which calls swarmZoom).
18
+ const ZOOM_STEPS = [0.7, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
19
+ const isDesktop = () => Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
20
+ let lastZoomAt = 0;
21
+ window.swarmZoom = (dir) => {
22
+ const now = Date.now();
23
+ if (now - lastZoomAt < 80) return; // a native accelerator and the keydown can both fire — once is enough
24
+ lastZoomAt = now;
25
+ const cur = Number(localStorage.getItem("swarm.zoom")) || 1;
26
+ let z = 1;
27
+ if (dir !== 0) {
28
+ const i = ZOOM_STEPS.findIndex((v) => Math.abs(v - cur) < 0.01);
29
+ z = ZOOM_STEPS[Math.max(0, Math.min(ZOOM_STEPS.length - 1, (i < 0 ? 3 : i) + dir))];
30
+ }
31
+ localStorage.setItem("swarm.zoom", String(z));
32
+ document.documentElement.style.setProperty("--ui-zoom", String(z));
33
+ document.documentElement.classList.toggle("zoomed", z !== 1);
34
+ };
35
+ {
36
+ const z = Number(localStorage.getItem("swarm.zoom")) || 1;
37
+ if (z !== 1) { document.documentElement.style.setProperty("--ui-zoom", String(z)); document.documentElement.classList.add("zoomed"); }
38
+ }
39
+ document.addEventListener("keydown", (ev) => {
40
+ if (!isDesktop() || !(ev.metaKey || ev.ctrlKey) || ev.altKey) return;
41
+ const k = ev.key;
42
+ const dir = k === "=" || k === "+" ? 1 : k === "-" || k === "_" ? -1 : k === "0" ? 0 : null;
43
+ if (dir === null) return;
44
+ ev.preventDefault();
45
+ window.swarmZoom(dir);
46
+ });
47
+ // `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, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
49
+
50
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
51
+ 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
+ // p2 (zero-pad) is defined in viz.js, which loads first
53
+ 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);
55
+ const short = (p) => String(p ?? "").replace(/^\/Users\/[^/]+/, "~");
56
+ const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
57
+ const usd = (n) => (n == null ? '<span class="dim">—</span>' : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
58
+ const model = (m) => (m ? m.replace(/^claude-/, "").replace(/-\d{8}$/, "") : "");
59
+ const sumBy = (arr, f) => arr.reduce((a, x) => a + (f(x) ?? 0), 0);
60
+ const leaseLeft = (iso) => { const d = (new Date(iso) - Date.now()) / 1000; if (d <= 0) return "expired"; return d < 3600 ? `${(d / 60) | 0}m left` : `${(d / 3600).toFixed(1)}h left`; };
61
+ const ic = (name, size = 14, cls = "") => (window.icon ? window.icon(name, size, cls) : "");
62
+ const kindIcon = (s) => ic(s.kind === "subagent" ? "tree-structure" : s.kind === "spawned" ? "play" : "keyboard", 13, "kind");
63
+ // pixel-art illustrations for empty states (crispEdges, theme-green; won't clash with icon packs)
64
+ function pixmap(rows, cell = 6) {
65
+ const C = { X: "var(--acc)", g: "var(--c5,#7fb069)", d: "var(--c4,#2f7d4f)" };
66
+ const w = Math.max(...rows.map((r) => r.length)) * cell;
67
+ const h = rows.length * cell;
68
+ let r = "";
69
+ rows.forEach((row, y) => {
70
+ for (let x = 0; x < row.length; x++) {
71
+ const f = C[row[x]];
72
+ if (f) r += `<rect x="${x * cell}" y="${y * cell}" width="${cell}" height="${cell}" fill="${f}"/>`;
73
+ }
74
+ });
75
+ return `<svg class="px" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">${r}</svg>`;
76
+ }
77
+ const PX = {
78
+ idle: () => pixmap([
79
+ " X X ",
80
+ " X X ",
81
+ " XXXXXXXX ",
82
+ " XXXXXXXX ",
83
+ " X XX X ",
84
+ " XXXXXXXX ",
85
+ " XX XX ",
86
+ " XXXXXXXX ",
87
+ " X X ",
88
+ ]),
89
+ folder: () => pixmap([
90
+ " XXXX ",
91
+ "XXXXXXXXXX",
92
+ "XggggggggX",
93
+ "XggggggggX",
94
+ "XggggggggX",
95
+ "XggggggggX",
96
+ "XXXXXXXXXX",
97
+ ]),
98
+ clock: () => pixmap([
99
+ " XXXXX ",
100
+ " X X ",
101
+ "X X X",
102
+ "X X X",
103
+ "X XXX X",
104
+ "X X",
105
+ "X X",
106
+ " X X ",
107
+ " XXXXX ",
108
+ ]),
109
+ };
110
+ // static <i data-icon> placeholders in index.html → inline SVG
111
+ for (const el of document.querySelectorAll("i[data-icon]")) el.outerHTML = ic(el.dataset.icon, 15);
112
+ // theme: "system" | "light" | "dark", persisted; CSS handles system via prefers-color-scheme
113
+ const getTheme = () => localStorage.getItem("swarm.theme") ?? "system";
114
+ const setTheme = (t) => { localStorage.setItem("swarm.theme", t); if (t === "system") delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t; };
115
+ setTheme(getTheme());
116
+ 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; };
118
+ const agentLabel = (a) => viz.agentName(a);
119
+ 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
+
121
+ // One render per animation frame, whatever triggered it (SSE, polls, clicks).
122
+ let raf = 0;
123
+ const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; render(); }); };
124
+ const touch = () => { state.dirty = true; schedule(); };
125
+ // Last snapshot body + last render time: an unchanged snapshot (same seq, same data) skips the render
126
+ // unless the UI changed, or `ago`-style cells are older than 30s.
127
+ let lastSnap = "", lastRenderAt = 0;
128
+ async function refresh() {
129
+ const txt = await (await fetch("/v1/state")).text();
130
+ const same = txt === lastSnap;
131
+ if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
132
+ if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; }).catch(() => {});
133
+ let prsChanged = false;
134
+ if (state.view === "prs" && !state.session) {
135
+ const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
136
+ prsChanged = JSON.stringify(prs) !== JSON.stringify(state.prs);
137
+ state.prs = prs;
138
+ }
139
+ let tasksChanged = false;
140
+ if (state.view === "board" && state.sel && !state.session) {
141
+ const t = await (await fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`)).json().catch(() => state.tasks);
142
+ tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks);
143
+ state.tasks = t;
144
+ }
145
+ let incChanged = false;
146
+ if (state.view === "incidents" && !state.session) {
147
+ const q = new URLSearchParams({ limit: "500" }); if (state.incFilter === "open") q.set("open", "1");
148
+ const inc = await (await fetch(`/v1/incidents?${q}`)).json().catch(() => state.allIncidents ?? []);
149
+ incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
150
+ state.allIncidents = inc;
151
+ }
152
+ if (!same || prsChanged || incChanged || tasksChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
153
+ }
154
+ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats"];
155
+ // restore last view + project selection (persisted UI state)
156
+ {
157
+ const v = localStorage.getItem("swarm.view");
158
+ if (VIEWS.includes(v)) state.view = v;
159
+ const sel = localStorage.getItem("swarm.sel");
160
+ if (sel) state.sel = sel;
161
+ // Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
162
+ for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
163
+ }
164
+ function render() {
165
+ // Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
166
+ const af = document.activeElement;
167
+ const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
168
+ state.dirty = false;
169
+ lastRenderAt = Date.now();
170
+ if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
171
+ renderHeader();
172
+ if (state.session) renderSession();
173
+ else if (state.view === "spend") renderSpend();
174
+ else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
175
+ else if (state.view === "timeline") renderTimeline();
176
+ else if (state.view === "board") renderBoard();
177
+ else if (state.view === "incidents") renderIncidentsView();
178
+ else if (state.view === "prs") renderPRs();
179
+ else renderFleet();
180
+ if (keep) {
181
+ const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
182
+ if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
183
+ }
184
+ }
185
+ let todayHtml = "";
186
+ function renderHeader() {
187
+ const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
188
+ const html = `Today <b>${usd(today)}</b>`;
189
+ if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
190
+ const ic_ = $("#incCount"); const n = state.openIncidents ?? 0;
191
+ if (ic_) { ic_.hidden = !n; ic_.textContent = n > 99 ? "99+" : String(n); }
192
+ for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", !state.session && a.dataset.view === state.view);
193
+ }
194
+
195
+ const isLive = (s) => s.state === "active" || s.state === "waiting";
196
+ // One pass over sessions → live count per project (+ "" for all), instead of a filter per sidebar row.
197
+ function liveCounts() {
198
+ const m = new Map();
199
+ 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); }
200
+ return m;
201
+ }
202
+ function renderProjects() {
203
+ const lc = liveCounts();
204
+ const live = (pid) => lc.get(pid) ?? 0;
205
+ const pinned = state.projects.filter((p) => !p.discovered);
206
+ const unpinned = state.projects.filter((p) => p.discovered);
207
+ const nameCount = {};
208
+ for (const p of state.projects) nameCount[p.name] = (nameCount[p.name] || 0) + 1;
209
+ const disamb = (p) => {
210
+ if ((nameCount[p.name] || 0) <= 1) return "";
211
+ const parts = String(p.root || "").split("/").filter(Boolean);
212
+ const parent = parts[parts.length - 2];
213
+ return parent ? `<span class="pdir">${esc(parent)}/</span>` : "";
214
+ };
215
+ const row = (p) => {
216
+ const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
217
+ 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"'}>
218
+ <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>`;
219
+ };
220
+ const liveAll = live("");
221
+ $("#projects").innerHTML =
222
+ `<h4>Projects <span class="h4-act" id="addProj" title="Add project">${ic("plus", 14)}</span></h4>` +
223
+ `<div class="proj ${state.sel === null ? "sel" : ""}" data-id=""><span class="st ${liveAll ? "live" : ""}"></span>${ic("folders", 14)}<span class="nm">All projects</span><small>${liveAll || ""}</small></div>` +
224
+ `<div id="pinned">${pinned.map(row).join("")}</div>` +
225
+ (unpinned.length ? `<h4>Unpinned <span class="faint" style="text-transform:none;letter-spacing:0;font-weight:400">· seen, not pinned</span></h4>${unpinned.map(row).join("")}` : "") +
226
+ (!pinned.length && !unpinned.length ? `<div class="empty" style="padding:16px;font-size:12px">${PX.folder()}No projects yet.<br>Add a folder below, or start Claude in one.</div>` : "");
227
+ }
228
+
229
+ // Pinned projects reorder by drag-and-drop (native DnD on the rows; order persists on the daemon).
230
+ let dragPid = null;
231
+ const projectsEl = $("#projects");
232
+ projectsEl.addEventListener("dragstart", (ev) => {
233
+ const r = ev.target.closest?.(".proj[draggable]");
234
+ if (!r) return;
235
+ dragPid = r.dataset.pid;
236
+ ev.dataTransfer.effectAllowed = "move";
237
+ ev.dataTransfer.setData("text/plain", dragPid);
238
+ requestAnimationFrame(() => r.classList.add("dragging")); // after the drag image is captured
239
+ });
240
+ projectsEl.addEventListener("dragover", (ev) => {
241
+ if (!dragPid) return;
242
+ const r = ev.target.closest?.(".proj[draggable]");
243
+ if (!r || r.dataset.pid === dragPid) return;
244
+ ev.preventDefault();
245
+ ev.dataTransfer.dropEffect = "move";
246
+ const box = r.getBoundingClientRect();
247
+ const before = ev.clientY < box.top + box.height / 2;
248
+ const dragged = projectsEl.querySelector(`.proj[data-pid="${dragPid}"]`);
249
+ if (dragged) r.parentNode.insertBefore(dragged, before ? r : r.nextSibling); // live reflow = the drop preview
250
+ });
251
+ projectsEl.addEventListener("drop", (ev) => { if (dragPid) ev.preventDefault(); });
252
+ projectsEl.addEventListener("dragend", () => {
253
+ if (!dragPid) return;
254
+ dragPid = null;
255
+ const ids = [...projectsEl.querySelectorAll("#pinned .proj[draggable]")].map((r) => r.dataset.pid);
256
+ const rank = new Map(ids.map((id, i) => [id, i]));
257
+ for (const p of state.projects) if (rank.has(p.id)) p.order = rank.get(p.id);
258
+ state.projects.sort((a, b) => Number(a.discovered) - Number(b.discovered) || (a.order ?? 1e9) - (b.order ?? 1e9) || a.name.localeCompare(b.name));
259
+ renderProjects();
260
+ fetch("/v1/projects/order", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }) }).then(refresh);
261
+ });
262
+
263
+ // ---------- fleet
264
+ // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
265
+ const FLEET_COLS = [
266
+ { key: "project", label: "project", width: 104, get: (s) => projName(s.projectId), cell: (s) => esc(projName(s.projectId)) },
267
+ { key: "agent", label: "agent", width: 76, get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
268
+ { 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>` : ""}` },
269
+ { key: "branch", label: "branch", width: 134, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
270
+ { 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>` },
271
+ { 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>` },
272
+ { 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)) },
273
+ { key: "out", label: "out", width: 66, num: true, get: (s) => s.tokens.output, cell: (s) => tok(s.tokens.output) },
274
+ { 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) },
275
+ { key: "cost", label: "cost", width: 64, num: true, get: (s) => s.costUsd ?? 0, cell: (s) => usd(s.costUsd) },
276
+ { key: "age", label: "age", width: 56, num: true, get: (s) => new Date(s.lastSeenAt).getTime(), cell: (s) => `<span class="dim">${ago(s.lastSeenAt)}</span>` },
277
+ ];
278
+
279
+ function renderFleet() {
280
+ const base = state.sessions.filter((s) => !state.sel || s.projectId === state.sel);
281
+ const agentCount = new Map();
282
+ for (const s of base) agentCount.set(s.agent, (agentCount.get(s.agent) ?? 0) + 1);
283
+ const agents = [...agentCount.keys()].sort();
284
+ const live = [], rest = [];
285
+ for (const s of base) if (!state.agentFilter || s.agent === state.agentFilter) (isLive(s) ? live : rest).push(s);
286
+ const cols = FLEET_COLS.filter((c) => !(c.key === "project" && state.sel));
287
+ // Live and Earlier are separate grids: each keeps its own column order/widths/visibility.
288
+ const table = (list, id) =>
289
+ dataTable({
290
+ id,
291
+ columns: cols,
292
+ rows: list,
293
+ leading: { width: 24, cell: (s) => `<span class="s ${s.state}"></span>` },
294
+ trailing: { width: 34, cell: (s) => `<span class="more" data-menu="session" data-sid="${s.id}" title="Session actions">${ic("dots-three", 15)}</span>` },
295
+ rowAttrs: (s) => `data-s="${s.id}" data-ctx="session" data-sid="${s.id}"`,
296
+ rerender: touch,
297
+ });
298
+ const chips = agents.length > 1
299
+ ? `<div class="chips"><span class="chip ${!state.agentFilter ? "on" : ""}" data-agent="">All</span>${agents
300
+ .map((a) => `<span class="chip ${state.agentFilter === a ? "on" : ""}" data-agent="${a}">${esc(agentLabel(a))} <b>${agentCount.get(a)}</b></span>`)
301
+ .join("")}</div>`
302
+ : "";
303
+ $("#main").innerHTML = chips +
304
+ `<h2>Live <span>${live.length} sessions · ${usd(sumBy(live, (s) => s.costUsd))}</span></h2>` +
305
+ (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>`) +
306
+ (rest.length ? `<h2 class="mt-sec">Earlier <span>${rest.length}</span></h2>${table(rest.slice(0, 30), "fleet-earlier")}` : "") +
307
+ "";
308
+ }
309
+
310
+ // ---------- PRs (one queue across GitHub + GitLab)
311
+ function renderPRs() {
312
+ const rows = state.prs ?? [];
313
+ const chk = (c) => c === "pass" ? '<span class="badge ok">Checks ✓</span>'
314
+ : c === "fail" ? '<span class="badge warn">Checks ✗</span>'
315
+ : c === "pending" ? '<span class="badge">Running…</span>' : '<span class="dim">—</span>';
316
+ const rev = (r) => r === "approved" ? '<span class="badge ok">Approved</span>'
317
+ : r === "changes" ? '<span class="badge warn">Changes</span>' : '<span class="dim">—</span>';
318
+ const green = (p) => p.checks !== "fail" && p.mergeable && !p.draft;
319
+ const cols = [
320
+ { key: "repo", label: "repo", width: 170, get: (p) => p.repo, cell: (p) => `${ic(p.forge === "gitlab" ? "git-merge" : "git-pull-request", 13)} <span class="br">${esc(p.repo.split("/").pop())}</span>` },
321
+ { key: "title", label: "title", flex: true, get: (p) => p.title, cell: (p) => `<a href="${esc(p.url)}" target="_blank" rel="noopener"><b>#${p.number}</b> ${esc(p.title)}</a>${p.draft ? ' <span class="badge">Draft</span>' : ""}` },
322
+ { key: "branch", label: "branch", width: 170, get: (p) => p.branch, cell: (p) => `<span class="br">${esc(p.branch)}</span>` },
323
+ { key: "author", label: "author", width: 110, get: (p) => p.author, cell: (p) => esc(p.author) },
324
+ { key: "checks", label: "checks", width: 100, get: (p) => p.checks, cell: (p) => chk(p.checks) },
325
+ { key: "review", label: "review", width: 100, get: (p) => p.review, cell: (p) => rev(p.review) },
326
+ { key: "age", label: "age", width: 56, num: true, get: (p) => new Date(p.createdAt).getTime(), cell: (p) => `<span class="dim">${ago(p.createdAt)}</span>` },
327
+ ];
328
+ $("#main").innerHTML =
329
+ `<h2>Pull requests <span>${rows.length} open · GitHub + GitLab, merged from here</span></h2>` +
330
+ (rows.length
331
+ ? dataTable({
332
+ id: "prs",
333
+ columns: cols,
334
+ rows,
335
+ leading: { width: 24, cell: (p) => `<span class="s ${p.checks === "fail" ? "waiting" : p.checks === "pass" ? "active" : "idle"}"></span>` },
336
+ 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>` : "") },
337
+ rowAttrs: () => "",
338
+ rerender: touch,
339
+ })
340
+ : `<div class="empty">${PX.idle()}No open pull requests.<br>Agent branches land here the moment they're pushed.</div>`);
341
+ }
342
+
343
+ // ---------- board (coordination: claims, worktrees, incidents)
344
+ function renderBoard() {
345
+ const parts = [renderTasks(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
346
+ $("#main").innerHTML = parts.length
347
+ ? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
348
+ : `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
349
+ }
350
+
351
+ // Incident columns are shared by the Board section (open only, recent) and the Incidents view (feed).
352
+ function incidentColumns(full) {
353
+ const sess = (id) => state.sessions.find((s) => s.id === id);
354
+ return [
355
+ { key: "ts", label: "when", width: 76, get: (i) => i.ts, cell: (i) => `<span class="dim" title="${esc(i.ts)}">${ago(i.ts)}</span>` },
356
+ { key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => esc(projName(i.projectId)) },
357
+ { 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>') },
358
+ { key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
359
+ { key: "action", label: "action", width: 80, get: (i) => i.action, cell: (i) => (i.action === "deny" ? '<span class="badge warn">Denied</span>' : '<span class="badge acc">Asked</span>') },
360
+ { key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.reason ?? "")}">${esc(i.command ?? "")}</span>` },
361
+ ...(full ? [
362
+ { key: "reason", label: "reason", width: 260, get: (i) => i.reason ?? "", cell: (i) => `<span class="dim now" title="${esc(i.reason ?? "")}">${esc(i.reason ?? "")}</span>` },
363
+ { 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>') },
364
+ ] : []),
365
+ ].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
366
+ }
367
+ const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" ? "waiting" : "idle"}"></span>`;
368
+ const ackLink = (i) => (i.acked ? "" : `<a href="#" data-ack="${i.seq}" title="Mark as seen">Ack</a>`);
369
+
370
+ function renderIncidents() {
371
+ const rows = (state.incidents ?? []).filter((i) => !state.sel || i.projectId === state.sel);
372
+ if (!rows.length) return "";
373
+ const open = state.sel ? rows.length : (state.openIncidents ?? rows.length);
374
+ return `<h2 class="mt-sec">Incidents <span>${open} open · what the rules stopped · <a href="#" data-view="incidents">all incidents</a></span></h2>` +
375
+ dataTable({
376
+ id: "incidents",
377
+ columns: incidentColumns(false),
378
+ rows,
379
+ leading: { width: 24, cell: incidentDot },
380
+ trailing: { width: 44, cell: ackLink },
381
+ rowAttrs: (i) => (i.sessionId ? `data-s="${i.sessionId}"` : ""),
382
+ rerender: touch,
383
+ });
384
+ }
385
+
386
+ // ---------- incidents view (M2.3): the denied-action feed, with ack
387
+ function renderIncidentsView() {
388
+ const all = state.allIncidents;
389
+ const rows = (all ?? []).filter((i) => !state.sel || i.projectId === state.sel);
390
+ const open = rows.filter((i) => !i.acked).length;
391
+ const chip = (k, label) => `<span class="chip ${state.incFilter === k ? "on" : ""}" data-inc="${k}">${label}</span>`;
392
+ const byRule = new Map();
393
+ for (const i of rows) byRule.set(i.rule, (byRule.get(i.rule) ?? 0) + 1);
394
+ const rules = [...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([r, n]) => `<span class="br">${esc(r)}</span> <b>${n}</b>`).join(" · ");
395
+ $("#main").innerHTML =
396
+ `<h2>Incidents <span>${all === null ? "loading…" : `${open} open · ${rows.length} shown`} · every ask/deny the rules made${rules ? ` · ${rules}` : ""}</span></h2>` +
397
+ `<div class="chips">${chip("open", "Open")}${chip("all", "All")}${open ? `<span class="chip" data-ackall="1" title="Mark every open incident${state.sel ? " in this project" : ""} as seen">Ack all <b>${open}</b></span>` : ""}</div>` +
398
+ (rows.length
399
+ ? dataTable({
400
+ id: "incidents-feed",
401
+ columns: incidentColumns(true),
402
+ rows,
403
+ leading: { width: 24, cell: incidentDot },
404
+ trailing: { width: 44, cell: ackLink },
405
+ rowAttrs: () => "",
406
+ rerender: touch,
407
+ })
408
+ : `<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>`);
409
+ }
410
+
411
+ // PROCESSES: what `swarm serve` / `swarm proc` started — pid-tracked, stoppable by pid only.
412
+ function renderProcesses() {
413
+ const rows = (state.processes ?? []).filter((r) => !state.sel || r.projectId === state.sel);
414
+ if (!rows.length) return "";
415
+ const cols = [
416
+ { key: "name", label: "process", width: 150, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
417
+ { key: "kind", label: "kind", width: 80, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
418
+ { key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => esc(projName(r.projectId)) },
419
+ { key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid, cell: (r) => r.pid },
420
+ { 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>') },
421
+ { key: "owner", label: "owner", width: 110, get: (r) => r.owner, cell: (r) => esc(r.owner) },
422
+ { key: "cmd", label: "command", flex: true, get: (r) => r.cmd, cell: (r) => `<span class="now" title="${esc(r.cwd)}">${esc(r.cmd)}</span>` },
423
+ { key: "up", label: "up", width: 64, get: (r) => r.startedAt, cell: (r) => `<span class="dim">${ago(r.startedAt)}</span>` },
424
+ ].filter((c) => !(c.key === "project" && state.sel));
425
+ return `<h2 class="mt-sec">Processes <span>${rows.length} · started through swarm serve / proc</span></h2>` +
426
+ dataTable({
427
+ id: "processes",
428
+ columns: cols,
429
+ rows,
430
+ leading: { width: 24, cell: () => '<span class="s active"></span>' },
431
+ 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>` },
432
+ rowAttrs: () => "",
433
+ rerender: touch,
434
+ });
435
+ }
436
+
437
+ function renderResources() {
438
+ const rows = (state.resources ?? []).filter((r) => !state.sel || r.projectId === state.sel || r.projectId === null);
439
+ if (!rows.length) return "";
440
+ const cols = [
441
+ { key: "name", label: "resource", width: 170, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
442
+ { key: "kind", label: "kind", width: 90, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
443
+ { key: "project", label: "project", width: 104, get: (r) => (r.projectId ? projName(r.projectId) : "global"), cell: (r) => (r.projectId ? esc(projName(r.projectId)) : '<span class="dim">global</span>') },
444
+ { key: "owner", label: "owner", width: 130, get: (r) => r.owner, cell: (r) => esc(r.owner) },
445
+ { key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid ?? 0, cell: (r) => (r.pid ?? '<span class="dim">—</span>') },
446
+ { key: "port", label: "port", width: 76, num: true, get: (r) => r.port ?? 0, cell: (r) => (r.port ?? '<span class="dim">—</span>') },
447
+ { key: "held", label: "held", flex: true, get: (r) => r.acquiredAt, cell: (r) => `<span class="dim">${ago(r.acquiredAt)}${r.expiresAt ? ` · lease ${leaseLeft(r.expiresAt)}` : r.pid ? " · pid-tracked" : ""}</span>` },
448
+ ].filter((c) => !(c.key === "project" && state.sel));
449
+ return `<h2>Resources <span>${rows.length} held · ports auto-protected</span></h2>` +
450
+ dataTable({
451
+ id: "resources",
452
+ columns: cols,
453
+ rows,
454
+ leading: { width: 24, cell: () => '<span class="s active"></span>' },
455
+ trailing: { width: 90, cell: (r) => `<a href="#" data-resrelease="${esc(r.name)}" data-resproj="${esc(r.projectId ?? "")}">Release</a>` },
456
+ rerender: touch,
457
+ });
458
+ }
459
+
460
+ // TASKS: the project's backlog from `.swarm.toml [tasks] source` (M1.6). Only with a project selected.
461
+ function renderTasks() {
462
+ if (!state.sel || !state.tasks?.source) return "";
463
+ const all = state.tasks.tasks ?? [];
464
+ const ready = all.filter((t) => t.ready);
465
+ const rows = state.taskFilter === "ready" ? ready : state.taskFilter === "open" ? all.filter((t) => t.status !== "done") : all;
466
+ const chip = (k, label, n) => `<span class="chip ${state.taskFilter === k ? "on" : ""}" data-task-filter="${k}">${label}${n != null ? ` <b>${n}</b>` : ""}</span>`;
467
+ const st = (t) => t.claimedBy ? `<span class="badge ok">Held · ${esc(t.claimedBy)}</span>`
468
+ : t.status === "done" ? '<span class="badge">Done</span>'
469
+ : t.status === "active" ? '<span class="badge acc">In progress</span>'
470
+ : t.ready ? '<span class="badge ok">Ready</span>' : '<span class="badge">Blocked</span>';
471
+ const cols = [
472
+ { key: "id", label: "id", width: 70, get: (t) => t.id, cell: (t) => `<b>${esc(t.id)}</b>` },
473
+ { key: "title", label: "task", flex: true, get: (t) => t.title, cell: (t) => `<span class="now" title="${esc(t.statusText)}">${esc(t.title)}</span>` },
474
+ { key: "milestone", label: "milestone", width: 160, get: (t) => t.milestone ?? "", cell: (t) => `<span class="dim now">${esc((t.milestone ?? "").split(" — ")[0])}</span>` },
475
+ { key: "depends", label: "depends", width: 130, get: (t) => t.depends.join(" "), cell: (t) => `<span class="br">${esc(t.depends.join(" ")) || "—"}</span>` },
476
+ { key: "state", label: "state", width: 150, get: (t) => (t.claimedBy ? 0 : t.ready ? 1 : t.status === "active" ? 2 : t.status === "done" ? 4 : 3), cell: st },
477
+ ];
478
+ return `<h2 class="mt-sec">Tasks <span>${ready.length} ready · ${all.length} in ${esc(state.tasks.source)}</span></h2>` +
479
+ `<div class="chips">${chip("ready", "Ready", ready.length)}${chip("open", "Open", all.filter((t) => t.status !== "done").length)}${chip("all", "All", all.length)}</div>` +
480
+ (rows.length
481
+ ? dataTable({
482
+ id: "tasks",
483
+ columns: cols,
484
+ rows,
485
+ leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
486
+ trailing: { width: 70, cell: (t) => (t.ready ? `<a href="#" data-claim="${esc(t.id)}" title="Claim into a fresh worktree">Claim</a>` : "") },
487
+ rowAttrs: () => "",
488
+ rerender: touch,
489
+ })
490
+ : `<div class="empty">${PX.idle()}${state.taskFilter === "ready" ? "Nothing ready — every open task is blocked or held." : "No tasks."}</div>`);
491
+ }
492
+
493
+ function renderClaims() {
494
+ const rows = (state.claims ?? []).filter((c) => c.state !== "released" && (!state.sel || c.projectId === state.sel));
495
+ if (!rows.length) return "";
496
+ const order = { orphaned: 0, expired: 1, held: 2 };
497
+ rows.sort((a, b) => (order[a.state] ?? 3) - (order[b.state] ?? 3));
498
+ 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>';
499
+ const orphans = rows.filter((c) => c.state === "orphaned").length;
500
+ const cols = [
501
+ { key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => esc(projName(c.projectId)) },
502
+ { key: "task", label: "task", width: 140, get: (c) => c.task, cell: (c) => `<b>${esc(c.task)}</b>` },
503
+ { key: "owner", label: "owner", width: 120, get: (c) => c.owner || "", cell: (c) => esc(c.owner || "—") },
504
+ { 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>` },
505
+ { key: "worktree", label: "worktree", flex: true, get: (c) => c.worktree, cell: (c) => `<span class="now" title="${esc(c.worktree)}">${esc(short(c.worktree))}</span>` },
506
+ { key: "state", label: "state", width: 150, get: (c) => c.state, cell: (c) => badge(c.state) },
507
+ ].filter((c) => !(c.key === "project" && state.sel));
508
+ return `<h2 class="mt-sec">Claims <span>${rows.length}${orphans ? ` · ${orphans} orphaned` : ""}</span></h2>` +
509
+ dataTable({
510
+ id: "claims",
511
+ columns: cols,
512
+ rows,
513
+ leading: { width: 24, cell: (c) => `<span class="s ${c.state === "orphaned" ? "waiting" : c.state === "expired" ? "idle" : "active"}"></span>` },
514
+ trailing: { width: 120, cell: (c) => {
515
+ const key = `${c.projectId}:${c.task}`;
516
+ return c.state === "orphaned"
517
+ ? `<a href="#" data-forcerelease="${key}" title="Discards the worktree AND its uncommitted work">Force release</a>`
518
+ : `<a href="#" data-release="${key}">Release</a>`;
519
+ } },
520
+ rerender: touch,
521
+ });
522
+ }
523
+
524
+ function renderWorktrees() {
525
+ const ids = state.sel ? [state.sel] : state.projects.map((p) => p.id);
526
+ const rows = ids.flatMap((id) => (state.worktrees[id] ?? []).map((w) => ({ ...w, projectId: id })));
527
+ if (!rows.length) return "";
528
+ // worktree path → sessions inside it, built once (not per cell, per row)
529
+ const byPath = new Map(rows.map((w) => [w.path, []]));
530
+ const paths = [...byPath.keys()];
531
+ for (const s of state.sessions) {
532
+ if (s.state === "ended") continue;
533
+ for (const p of paths) if (s.cwd === p || s.cwd.startsWith(`${p}/`)) byPath.get(p).push(s);
534
+ }
535
+ const inside = (w) => byPath.get(w.path);
536
+ const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
537
+ const cols = [
538
+ { key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => esc(projName(w.projectId)) },
539
+ { 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>' : ""}` },
540
+ { key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
541
+ { key: "path", label: "path", flex: true, get: (w) => w.path, cell: (w) => `<span class="now" title="${esc(w.path)}">${esc(short(w.path))}</span>` },
542
+ { 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>' : ""}` },
543
+ { 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>' },
544
+ ].filter((c) => !(c.key === "project" && state.sel));
545
+ return `<h2 class="mt-sec">Worktrees <span>${rows.length}</span></h2>` +
546
+ dataTable({
547
+ id: "worktrees",
548
+ columns: cols,
549
+ rows,
550
+ leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
551
+ trailing: { width: 34, cell: () => "" },
552
+ rerender: touch,
553
+ });
554
+ }
555
+
556
+ // ---------- spend
557
+ function renderSpend() {
558
+ const sp = state.spend;
559
+ if (!sp) return;
560
+ const inSel = (x) => !state.sel || x.projectId === state.sel;
561
+ const filt = (arr) => (state.sel ? arr.filter((x) => x.key === state.sel) : arr);
562
+ // last N days, zero-filled, stacked by agent
563
+ const N = state.spendDays ?? 14;
564
+ const days = [];
565
+ for (let i = N - 1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(viz.localDay(d)); }
566
+ const inRange = sp.daily.filter((d) => inSel(d) && d.day >= days[0]);
567
+ const today = days.at(-1);
568
+ // one pass: "day|agent" → cost, plus the headline sums
569
+ const cell = new Map(), agentSet = new Set(), active = new Set();
570
+ let total14 = 0, todayCost = 0, todayTurns = 0;
571
+ for (const d of inRange) {
572
+ const k = `${d.day}|${d.agent}`, c = d.cost ?? 0;
573
+ cell.set(k, (cell.get(k) ?? 0) + c);
574
+ agentSet.add(d.agent);
575
+ total14 += c;
576
+ if (c) active.add(d.day);
577
+ if (d.day === today) { todayCost += c; todayTurns += d.turns ?? 0; }
578
+ }
579
+ const agents = [...agentSet].sort(viz.agentSort);
580
+ const series = Object.fromEntries(agents.map((a) => [a, days.map((day) => cell.get(`${day}|${a}`) ?? 0)]));
581
+ const activeDays = active.size;
582
+ const prevDays = activeDays - (active.has(today) ? 1 : 0);
583
+ const avg = prevDays ? (total14 - todayCost) / prevDays : 0;
584
+ const rangeChips = `<span class="seg" style="margin-left:auto">${[7, 14, 30, 90].map((n) => `<a href="#" class="${N === n ? "on" : ""}" data-days="${n}">${n}d</a>`).join("")}</span>`;
585
+ const byAgentToday = state.sel ? null : sp.byAgentToday;
586
+ const kpi = (l, v, d) => `<div class="kpi"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
587
+ // Tables of the same shape share one grid id (sort/widths apply to both today/all-time).
588
+ const tbl = (rows, label, name, color) =>
589
+ dataTable({
590
+ id: `spend-${label}`,
591
+ columns: [
592
+ { key: "key", label, flex: true, get: (r) => name(r.key), cell: (r) => `${color ? `<i class="sw" style="background:${color(r.key)}"></i>` : ""}${esc(name(r.key))}` },
593
+ { key: "cost", label: "cost", width: 88, num: true, get: (r) => r.cost ?? 0, cell: (r) => usd(r.cost) },
594
+ { key: "input", label: "in+cache", width: 88, num: true, get: (r) => r.input ?? 0, cell: (r) => tok(r.input) },
595
+ { key: "output", label: "out", width: 84, num: true, get: (r) => r.output ?? 0, cell: (r) => tok(r.output) },
596
+ { key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns ?? 0, cell: (r) => String(r.turns) },
597
+ ],
598
+ rows: rows.slice().sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0)),
599
+ trailing: { width: 34, cell: () => "" },
600
+ rerender: touch,
601
+ });
602
+ const hm = sp.hourly.filter(inSel).map((c) => ({ dow: c.dow, hour: c.hour, v: c.cost ?? 0 }));
603
+ $("#main").innerHTML =
604
+ `<h2>Spend <span>${state.sel ? esc(projName(state.sel)) : "all projects"}</span>${rangeChips}</h2>
605
+ <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>
606
+ <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>
607
+ <div class="cols">
608
+ <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>
609
+ <div>${byAgentToday ? `<h2>By agent · today <span>${usd(sumBy(byAgentToday, (x) => x.cost))}</span></h2>${tbl(byAgentToday, "agent", agentLabel, viz.agentColor)}<h2 class="mt-sec">By agent · all time</h2>${tbl(sp.byAgentAll, "agent", agentLabel, viz.agentColor)}` : `<h2>By model · today</h2>${tbl(sp.byModelToday, "model", model)}`}</div>
610
+ </div>
611
+ <div class="cols mt-sec"><div><h2>By project · today <span>${usd(sumBy(filt(sp.byProjectToday), (x) => x.cost))}</span></h2>${tbl(filt(sp.byProjectToday), "project", projName)}
612
+ <h2 class="mt-sec">By project · all time</h2>${tbl(filt(sp.byProjectAll), "project", projName)}</div>
613
+ <div>${byAgentToday ? `<h2>By model · today</h2>${tbl(sp.byModelToday, "model", model)}` : ""}<h2 style="${byAgentToday ? "margin-top:18px" : ""}">By model · all time</h2>${tbl(sp.byModelAll, "model", model)}</div></div>
614
+ <p class="dim" style="margin-top:var(--gap-sec)">Costs use list prices (static table, refreshed from LiteLLM when online; override in <code>~/.swarm/pricing.json</code>). Cache reads are the bulk of "ctx". Sessions on a subscription plan still show what the tokens would cost at API rates.</p>`;
615
+ }
616
+
617
+ // ---------- stats
618
+ // Heavier than the 5s snapshot, so it has its own endpoint: fetched when the view opens (per project
619
+ // scope), then refreshed at most every 30s while the view stays open.
620
+ const statsCache = { key: null, at: 0, data: null, busy: false };
621
+ async function loadStats() {
622
+ const key = state.sel ?? "";
623
+ if (statsCache.busy || (statsCache.key === key && Date.now() - statsCache.at < 30_000)) return;
624
+ statsCache.busy = true;
625
+ try {
626
+ const data = await (await fetch(`/v1/stats${key ? `?project=${encodeURIComponent(key)}` : ""}`)).json();
627
+ Object.assign(statsCache, { key, at: Date.now(), data });
628
+ if (state.view === "stats" && !state.session) touch();
629
+ } finally { statsCache.busy = false; }
630
+ }
631
+ const big = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n)));
632
+ const toolName = (t) => String(t).replace(/^mcp__([^_]+(?:_[^_]+)*)__/, "$1 · ").replace(/^plugin_/, "");
633
+ const pct = (a, b) => (b ? `${((100 * a) / b).toFixed(0)}%` : "—");
634
+ const dur = (ms) => (ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : ms < 86400e3 ? `${(ms / 3600e3).toFixed(1)}h` : `${(ms / 86400e3).toFixed(1)}d`);
635
+ function renderStats() {
636
+ const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
637
+ const scope = state.sel ? esc(projName(state.sel)) : "all projects";
638
+ if (!st) { $("#main").innerHTML = `<h2>Stats <span>${scope}</span></h2><div class="empty">${PX.clock()}Crunching numbers…</div>`; return; }
639
+ const T = st.totals;
640
+ if (!T.turns) { $("#main").innerHTML = `<h2>Stats <span>${scope}</span></h2><div class="empty">${PX.clock()}No turns recorded yet. Numbers appear once a session is transcribed.</div>`; return; }
641
+ const N = state.statsDays ?? 90;
642
+ const days = [];
643
+ for (let i = N - 1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(viz.localDay(d)); }
644
+ const byDay = Object.fromEntries(st.daily.map((d) => [d.day, d]));
645
+ const pick = (k) => days.map((d) => byDay[d]?.[k] ?? 0);
646
+ const rangeChips = `<span class="seg" style="margin-left:auto">${[30, 90, 365].map((n) => `<a href="#" class="${N === n ? "on" : ""}" data-sdays="${n}">${n}d</a>`).join("")}</span>`;
647
+ const kpi = (l, v, d) => `<div class="kpi"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
648
+
649
+ // ---- headline numbers
650
+ const allTok = T.input + T.cacheWrite + T.cacheRead + T.output;
651
+ const since = T.firstTs ? new Date(T.firstTs) : null;
652
+ const spanDays = since ? Math.max(1, Math.round((Date.now() - since) / 86400e3)) : 1;
653
+ const activeDays = st.daily.filter((d) => d.turns).map((d) => d.day);
654
+ const sk = viz.streaks(activeDays);
655
+ const costDays = Object.fromEntries(st.daily.map((d) => [d.day, d.cost ?? 0]));
656
+ const kpis =
657
+ kpi("all-time spend", usd(T.cost), `since ${since ? since.toISOString().slice(0, 10) : "—"} · ${usd((T.cost ?? 0) / spanDays)}/day`) +
658
+ kpi("tokens processed", big(allTok), `${big(T.output)} out · ${big(T.cacheRead)} cache read`) +
659
+ kpi("turns", big(T.turns), `${T.sessions} sessions · ${big(T.toolCalls)} tool calls`) +
660
+ kpi("streak", `${sk.current}d`, `longest ${sk.longest}d · ${activeDays.length} active day${activeDays.length === 1 ? "" : "s"} this year`);
661
+
662
+ // ---- fun equivalents (a token ≈ 0.75 words; a novel ≈ 90k words; War and Peace ≈ 587k words)
663
+ const words = T.output * 0.75;
664
+ const novels = words / 90_000;
665
+ const ctxWords = (T.input + T.cacheRead + T.cacheWrite) * 0.75;
666
+ const wp = ctxWords / 587_000;
667
+ const coffees = (T.cost ?? 0) / 5;
668
+ const fun =
669
+ kpi("words written", big(words), novels >= 1 ? `≈ ${novels.toFixed(novels < 10 ? 1 : 0)} novels` : `≈ ${(words / 300).toFixed(0)} pages`) +
670
+ kpi("context re-read", `${big(ctxWords)} words`, wp >= 1 ? `≈ ${wp.toFixed(wp < 10 ? 1 : 0)}× War and Peace` : `≈ ${(ctxWords / 300).toFixed(0)} pages`) +
671
+ kpi("thinking share", pct(T.thinking, T.output), `${tok(T.thinking)} reasoning tokens · cache hit ${pct(T.cacheRead, T.input + T.cacheRead + T.cacheWrite)}`) +
672
+ kpi("in coffee", `${coffees >= 100 ? coffees.toFixed(0) : coffees.toFixed(1)} ☕`, `at $5 a cup · ${T.subagents} subagents spawned`);
673
+
674
+ // ---- charts
675
+ const classColor = { output: "var(--acc-5)", input: "var(--acc-3)", cacheWrite: "var(--acc-2)", cacheRead: "var(--acc-1)" };
676
+ const className = { output: "output", input: "input", cacheWrite: "cache write", cacheRead: "cache read" };
677
+ const order = ["output", "input", "cacheWrite", "cacheRead"];
678
+ const tokOpts = { fmt: tok, color: (k) => classColor[k], name: (k) => className[k], sort: (a, b) => order.indexOf(a) - order.indexOf(b) };
679
+ const tokSeries = Object.fromEntries(order.map((k) => [k, pick(k)]));
680
+ let acc = 0;
681
+ const cum = days.map((d) => (acc += byDay[d]?.cost ?? 0));
682
+ const hours = Array.from({ length: 24 }, (_, h) => String(h).padStart(2, "0"));
683
+ const hourSeries = { turns: hours.map((_, h) => st.byHour.find((x) => x.hour === h)?.turns ?? 0) };
684
+ const peakHour = hourSeries.turns.indexOf(Math.max(...hourSeries.turns));
685
+ const hourOpts = { fmt: (n) => String(Math.round(n)), color: () => "var(--acc)", name: () => "turns", label: (h) => (Number(h) % 3 ? "" : h), sort: () => 0 };
686
+ const models = st.byModel.filter((m) => m.model).map((m) => ({ label: `${model(m.model)} · ${m.turns} turns`, v: m.output }));
687
+ const comp = [{ label: "cache read", v: T.cacheRead }, { label: "cache write", v: T.cacheWrite }, { label: "input", v: T.input }, { label: "output", v: T.output }];
688
+
689
+ // ---- records
690
+ const R = st.records;
691
+ const sessLink = (r) => (r ? `<a href="#" data-s="${r.id}">${esc(r.title || r.id.slice(0, 8))}</a>` : "—");
692
+ const rec = (l, v, d) => `<div class="rec"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
693
+ const wall = R.longestWallSession ? new Date(R.longestWallSession.lastSeenAt) - new Date(R.longestWallSession.startedAt) : 0;
694
+ const bt = R.biggestTurn;
695
+ const records =
696
+ rec("costliest session", usd(R.costliestSession?.cost), sessLink(R.costliestSession)) +
697
+ rec("most turns in a session", R.longestSession ? String(R.longestSession.turns) : "—", sessLink(R.longestSession)) +
698
+ rec("longest session", wall > 0 ? dur(wall) : "—", sessLink(R.longestWallSession)) +
699
+ rec("biggest single turn", bt ? `${tok(bt.output)} out` : "—", bt ? `${esc(model(bt.model))} · <a href="#" data-s="${bt.sessionId}">${esc(bt.title || bt.sessionId.slice(0, 8))}</a>` : "—") +
700
+ rec("busiest day", R.busiestDay ? usd(R.busiestDay.cost) : "—", R.busiestDay ? `${R.busiestDay.day} · ${R.busiestDay.turns} turns` : "—") +
701
+ rec("favourite hour", `${hours[peakHour]}:00`, `${hourSeries.turns[peakHour]} turns in that hour, all time`);
702
+
703
+ $("#main").innerHTML =
704
+ `<h2>Stats <span>${scope}</span>${rangeChips}</h2>
705
+ <div class="kpis">${kpis}</div>
706
+ <div class="kpis">${fun}</div>
707
+ <div class="chart-card"><h3>Activity <span>cost per day · last 52 weeks</span></h3>${viz.calendar(costDays)}</div>
708
+ <div class="chart-card"><h3>Tokens per day <span>last ${N} days · by class</span></h3>${viz.stackedColumns(days, tokSeries, tokOpts)}${viz.legend(order, tokOpts.name, tokOpts.color)}</div>
709
+ <div class="cols">
710
+ <div class="chart-card" style="margin:0"><h3>Output tokens per day <span>last ${N} days</span></h3>${viz.stackedColumns(days, { output: pick("output") }, tokOpts)}</div>
711
+ <div class="chart-card" style="margin:0"><h3>Cumulative spend <span>last ${N} days</span></h3>${viz.line(days, cum)}</div>
712
+ </div>
713
+ <div class="cols mt-sec">
714
+ <div class="chart-card" style="margin:0"><h3>Turns by hour of day <span>all time · local</span></h3>${viz.stackedColumns(hours, hourSeries, hourOpts)}</div>
715
+ <div class="chart-card" style="margin:0"><h3>Model mix <span>by output tokens · all time</span></h3>${viz.compositionBar(models)}
716
+ <h3 style="margin-top:14px">Token composition <span>all time</span></h3>${viz.compositionBar(comp)}</div>
717
+ </div>
718
+ <div class="cols mt-sec">
719
+ <div class="chart-card" style="margin:0"><h3>Tool leaderboard <span>calls · all time</span></h3>${st.tools.length ? viz.hbars(st.tools.map(([k, v]) => [toolName(k), v])) : '<div class="dim">no tool calls yet</div>'}</div>
720
+ <div><h2 style="margin-top:0">Records</h2><div class="records">${records}</div></div>
721
+ </div>
722
+ <p class="dim" style="margin-top:var(--gap-sec)">Word counts assume ~0.75 words per token; a novel is 90k words. Costs use list prices, as on Spend. ${pct(T.sidechainTurns, T.turns)} of turns came from subagents.</p>`;
723
+ }
724
+
725
+ // ---------- timeline
726
+ function renderTimeline() {
727
+ const now = Date.now();
728
+ const hours = state.tlHours ?? 12;
729
+ const from = now - hours * 3.6e6, to = now + 0.25 * 3.6e6;
730
+ const rows = state.sessions.filter((s) => (!state.sel || s.projectId === state.sel) && new Date(s.lastSeenAt).getTime() >= from && s.kind !== "subagent");
731
+ const agents = [...new Set(rows.map((s) => s.agent))].sort(viz.agentSort);
732
+ const chip = (h) => `<a href="#" class="nav ${hours === h ? "on" : ""}" data-tl="${h}">${h}h</a>`;
733
+ $("#main").innerHTML =
734
+ `<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>
735
+ ${rows.length ? viz.timeline(rows, { from, to, projName, now }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
736
+ ${agents.length ? `<div style="margin-top:10px">${viz.legend(agents)}</div>` : ""}`;
737
+ }
738
+
739
+ // ---------- session
740
+ const LOG_CAP = 500;
741
+ // Re-polling the open session fetches only what is newer than what we hold (events by seq, turns by ts)
742
+ // and appends, deduping against rows the SSE stream already pushed. A different session starts over.
743
+ let sessionFetch = null;
744
+ async function openSession(id) {
745
+ const same = state.session === id && sessionFetch === id;
746
+ if (!same) { state.session = id; state.log = []; state.turns = []; rowCache.clear(); logRendered = null; state.dirty = true; }
747
+ const q = new URLSearchParams();
748
+ if (same) {
749
+ let seq = 0, ts = "";
750
+ for (const e of state.log) if (e.seq > seq) seq = e.seq;
751
+ for (const t of state.turns) if (t.ts > ts) ts = t.ts;
752
+ if (seq) q.set("after", String(seq));
753
+ if (ts) q.set("afterTs", ts);
754
+ }
755
+ const qs = q.toString();
756
+ const d = await (await fetch(`/v1/sessions/${id}/events${qs ? `?${qs}` : ""}`)).json();
757
+ if (state.session !== id) return; // user moved on while we were fetching
758
+ sessionFetch = id;
759
+ let changed = !same;
760
+ if (same) {
761
+ const seen = new Set(state.log.map((e) => e.seq));
762
+ for (const e of d.events) if (!seen.has(e.seq)) { state.log.push(e); changed = true; }
763
+ const tid = new Set(state.turns.map((t) => t.id));
764
+ for (const t of d.turns) if (!tid.has(t.id)) { state.turns.push(t); changed = true; }
765
+ if (d.events.length) state.log.sort((a, b) => a.seq - b.seq); // SSE pushes and the fetch may interleave
766
+ if (d.turns.length) state.turns.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
767
+ } else { state.log = d.events; state.turns = d.turns; }
768
+ if (state.log.length > LOG_CAP) state.log.splice(0, state.log.length - LOG_CAP);
769
+ if (changed) schedule();
770
+ }
771
+ // Rendered log rows, keyed per event seq / turn id (+ the mutable turn fields) so only new rows are formatted.
772
+ const rowCache = new Map();
773
+ let logRendered = null; // keys of the rows currently in #log, in order — enables append-only updates
774
+ const evRow = (i) => `<div class="ev ${i.cls}"><span class="t">${hhmm(i.ts)}</span><span class="k">${esc(i.kind)}</span><span class="m">${esc(i.text)}${i.out ? `<span class="dim"> · ${tok(i.out)} out${i.cost != null ? ` · $${i.cost.toFixed(3)}` : ""}</span>` : ""}</span></div>`;
775
+ // Merge the two ts-sorted inputs (events by seq ≈ ts, turns by ts) in one pass → [{key, html}].
776
+ function sessionStream() {
777
+ const out = [];
778
+ const log = state.log, turns = state.turns;
779
+ let i = 0, j = 0;
780
+ const pushEv = (e) => {
781
+ const key = `e${e.seq}`;
782
+ let html = rowCache.get(key);
783
+ if (!html) rowCache.set(key, (html = evRow({ ts: e.ts, kind: e.payload?.hook ?? e.type, text: e.payload?.summary ?? "", cls: e.type })));
784
+ out.push({ key, html });
785
+ };
786
+ const pushTurn = (t) => {
787
+ const key = `t${t.id}:${t.costUsd ?? ""}:${t.output ?? ""}:${t.text.length}`;
788
+ let html = rowCache.get(key);
789
+ if (!html) rowCache.set(key, (html = evRow({ ts: t.ts, kind: t.sidechain ? "subagent" : "assistant", text: t.text, cls: "assistant", cost: t.costUsd, out: t.output })));
790
+ out.push({ key, html });
791
+ };
792
+ while (i < log.length || j < turns.length) {
793
+ if (i < log.length && log[i].payload?.hook === "PostToolUse") { i++; continue; }
794
+ if (j < turns.length && !turns[j].text) { j++; continue; }
795
+ if (j >= turns.length || (i < log.length && log[i].ts < turns[j].ts)) pushEv(log[i++]);
796
+ else pushTurn(turns[j++]);
797
+ }
798
+ return out;
799
+ }
800
+ // True when `rows` only extends the rows already in #log (same session, same prefix) → append, don't rebuild.
801
+ const isAppend = (rows) => logRendered && rows.length >= logRendered.length && logRendered.every((k, n) => rows[n].key === k);
802
+ function renderSession() {
803
+ const s = state.sessions.find((x) => x.id === state.session);
804
+ if (!s) return;
805
+ const logEl = $("#log");
806
+ const atBottom = !logEl || logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 40;
807
+ const prevTop = logEl ? logEl.scrollTop : 0;
808
+ const rows = sessionStream();
809
+ const tools = Object.entries(s.toolCounts).sort((a, b) => b[1] - a[1]);
810
+ const t = s.tokens;
811
+ const ctx = t.input + t.cacheRead + t.cacheWrite;
812
+ const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
813
+ 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" };
814
+ const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
815
+ 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></h2>`;
816
+ const side = `<div class="stats">
817
+ ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
818
+ ${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>`)}
819
+ ${stat("started", `${ago(s.startedAt)} ago`)}${stat("last seen", `${ago(s.lastSeenAt)} ago`)}
820
+ ${subTurns.length ? stat("subagent turns", subTurns.length) : ""}
821
+ </div>
822
+ <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 }])}
823
+ ${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
824
+ <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>'}
825
+ ${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
826
+ if (logEl && isAppend(rows)) {
827
+ // Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
828
+ // scroll position (and its DOM) untouched.
829
+ $("#main > h2").outerHTML = head;
830
+ $("#main .side").innerHTML = side;
831
+ if (rows.length > logRendered.length) logEl.insertAdjacentHTML("beforeend", rows.slice(logRendered.length).map((r) => r.html).join(""));
832
+ } else {
833
+ $("#main").innerHTML = `${head}<div class="sess"><div id="log">${rows.map((r) => r.html).join("")}</div><aside class="side">${side}</aside></div>`;
834
+ }
835
+ logRendered = rows.map((r) => r.key);
836
+ // Follow the tail when pinned to the bottom; otherwise keep the reading position —
837
+ // innerHTML replacement resets scroll to the top on every live update.
838
+ const nl = $("#log");
839
+ if (nl) nl.scrollTop = atBottom ? nl.scrollHeight : prevTop;
840
+ }
841
+
842
+
843
+ // ---------- menus (fancy-menus island; see src/menus.tsx). Menus are plain data.
844
+ const pinProject = (id, pinned) => fetch(`/v1/projects/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) }).then(refresh);
845
+ const removeProject = (id) => fetch(`/v1/projects/${id}`, { method: "DELETE" }).then(refresh);
846
+ function menuSpec(kind, d) {
847
+ if (kind === "project") {
848
+ const p = state.projects.find((x) => x.id === d.pid);
849
+ if (!p) return null;
850
+ const live = state.sessions.filter((s) => s.projectId === p.id && (s.state === "active" || s.state === "waiting")).length;
851
+ return { title: p.name, items: [
852
+ { label: "Show sessions", icon: "squares-four", caption: live ? `${live} live` : undefined, run: () => { state.sel = p.id; state.view = "fleet"; state.session = null; touch(); } },
853
+ { label: "Show in Timeline", icon: "clock-counter-clockwise", run: () => { state.sel = p.id; state.view = "timeline"; state.session = null; touch(); } },
854
+ { label: "Spend", icon: "coins", run: () => { state.sel = p.id; state.view = "spend"; state.session = null; touch(); } },
855
+ { label: "Stats", icon: "chart-bar", run: () => { state.sel = p.id; state.view = "stats"; state.session = null; touch(); } },
856
+ { divider: true },
857
+ 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) },
858
+ { label: "Copy path", icon: "copy", caption: tail(p.root), run: () => copy(p.root) },
859
+ { divider: true },
860
+ { label: "Remove from Swarm", icon: "trash", danger: true, run: () => removeProject(p.id) },
861
+ ] };
862
+ }
863
+ if (kind === "session") {
864
+ const s = state.sessions.find((x) => x.id === d.sid);
865
+ if (!s) return null;
866
+ return { title: s.title ?? s.id.slice(0, 8), items: [
867
+ { label: "Open session", icon: "terminal-window", run: () => openSession(s.id) },
868
+ { label: "Show in Timeline", icon: "clock-counter-clockwise", run: () => { state.sel = s.projectId; state.view = "timeline"; state.session = null; touch(); } },
869
+ { divider: true },
870
+ { section: "Copy" },
871
+ { label: "Session id", icon: "copy", caption: s.id.slice(0, 8), run: () => copy(s.id) },
872
+ { label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 18), run: () => copy(s.cwd) },
873
+ ...(s.transcriptPath ? [{ label: "Transcript path", icon: "file-text", run: () => copy(s.transcriptPath) }] : []),
874
+ ...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 18), run: () => copy(s.branch) }] : []),
875
+ ] };
876
+ }
877
+ if (kind === "settings") {
878
+ const theme = getTheme();
879
+ const th = (id, label, icon) => ({ label, icon, pressed: theme === id, run: () => { setTheme(id); $("#settings").blur(); } });
880
+ return { items: [
881
+ { label: "Theme", icon: theme === "dark" ? "moon" : theme === "light" ? "sun" : "monitor", caption: theme, children: [th("system", "System", "monitor"), th("light", "Light", "sun"), th("dark", "Dark", "moon")] },
882
+ { divider: true },
883
+ { 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(); } },
884
+ { label: "Copy dashboard URL", icon: "copy", run: () => copy(location.origin) },
885
+ { divider: true },
886
+ { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
887
+ { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => window.open(feedbackUrl(), "_blank") },
888
+ ] };
889
+ }
890
+ return null;
891
+ }
892
+ // Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
893
+ // nothing leaves the machine; clicking Star just opens the repo in a browser.
894
+ const STAR = { key: "swarm.star", firstAfterMs: 2 * 86_400_000, everyMs: 30 * 86_400_000 };
895
+ function starState() { try { return JSON.parse(localStorage.getItem(STAR.key) || "{}"); } catch { return {}; } }
896
+ function starSave(patch) { try { localStorage.setItem(STAR.key, JSON.stringify({ ...starState(), ...patch })); } catch {} }
897
+ function maybeStarNudge() {
898
+ const st = starState();
899
+ const now = Date.now();
900
+ if (!st.since) return starSave({ since: now });
901
+ if (st.done || st.never) return;
902
+ if (now - st.since < STAR.firstAfterMs) return;
903
+ if (st.last && now - st.last < STAR.everyMs) return;
904
+ if (document.querySelector(".nudge")) return;
905
+ starSave({ last: now });
906
+ const el = document.createElement("div");
907
+ el.className = "nudge";
908
+ el.innerHTML = `${ic("star", 18, "ic")}<div><b>Enjoying Swarm?</b>A star on GitHub helps other people find it — and tells us it's worth the evenings.
909
+ <div class="row"><button class="pri" data-star="go">${ic("star", 13)} Star on GitHub</button><button data-star="later">Later</button><a href="#" class="dim" data-star="never">Don't ask again</a></div></div>`;
910
+ document.body.appendChild(el);
911
+ el.addEventListener("click", (ev) => {
912
+ const t = ev.target.closest("[data-star]"); if (!t) return;
913
+ ev.preventDefault();
914
+ if (t.dataset.star === "go") { starSave({ done: now }); window.open(REPO_URL, "_blank"); }
915
+ else if (t.dataset.star === "never") starSave({ never: now });
916
+ el.remove();
917
+ });
918
+ }
919
+ window.swarmStarNudge = (force) => { if (force) starSave({ since: 1, last: 0, done: 0, never: 0 }); maybeStarNudge(); };
920
+ setTimeout(maybeStarNudge, 4000);
921
+
922
+ // Feedback lands in a GitHub issue form, prefilled with the environment so people don't have to type it.
923
+ const REPO_URL = "https://github.com/ra3orblade/swarm";
924
+ function feedbackUrl() {
925
+ const ua = navigator.userAgent;
926
+ const os = /Mac/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "unknown OS";
927
+ const shell = window.__TAURI__ || window.__TAURI_INTERNALS__ ? "desktop" : "browser";
928
+ const env = `swarm ${state.version || "?"} · ${os} · ${shell}`;
929
+ const q = new URLSearchParams({ template: "feedback.yml", environment: env });
930
+ return `${REPO_URL}/issues/new?${q}`;
931
+ }
932
+ function openMenu(kind, anchor, d) {
933
+ const spec = menuSpec(kind, d);
934
+ if (!spec) return;
935
+ if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
936
+ window.menus.open(anchor, spec);
937
+ }
938
+ document.addEventListener("contextmenu", (ev) => {
939
+ const t = ev.target.closest("[data-ctx]");
940
+ if (!t) return;
941
+ ev.preventDefault();
942
+ openMenu(t.dataset.ctx, { x: ev.clientX, y: ev.clientY }, t.dataset);
943
+ });
944
+
945
+ // ---------- events
946
+ document.addEventListener("click", async (ev) => {
947
+ 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]");
948
+ if (!t) return;
949
+ if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
950
+ if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
951
+ if (t.id === "feedback") { ev.preventDefault(); return window.open(feedbackUrl(), "_blank"); }
952
+ 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(); }
953
+ if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
954
+ if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
955
+ if (t.dataset.claim) {
956
+ ev.preventDefault();
957
+ 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());
958
+ if (!r.ok) alert(r.error); else state.tasks = null;
959
+ return refresh();
960
+ }
961
+ if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
962
+ if (t.dataset.ack) {
963
+ ev.preventDefault(); ev.stopPropagation();
964
+ return fetch(`/v1/incidents/${t.dataset.ack}/ack`, { method: "POST" }).then(refresh);
965
+ }
966
+ if (t.dataset.ackall) {
967
+ return fetch("/v1/incidents/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: state.sel || undefined }) }).then(refresh);
968
+ }
969
+ if (t.dataset.days) { ev.preventDefault(); state.spendDays = Number(t.dataset.days); return touch(); }
970
+ if (t.dataset.sdays) { ev.preventDefault(); state.statsDays = Number(t.dataset.sdays); return touch(); }
971
+ if (t.dataset.release || t.dataset.forcerelease) {
972
+ ev.preventDefault();
973
+ const force = Boolean(t.dataset.forcerelease);
974
+ const [projectId, task] = (t.dataset.release || t.dataset.forcerelease).split(":");
975
+ if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
976
+ const r = await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force }) }).then((x) => x.json());
977
+ if (!r.ok) {
978
+ if (confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) {
979
+ await fetch("/v1/claims/release", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, task, force: true }) });
980
+ }
981
+ }
982
+ return refresh();
983
+ }
984
+ if (t.dataset.agent !== undefined && t.classList.contains("chip")) { state.agentFilter = t.dataset.agent || null; return touch(); }
985
+ if (t.dataset.merge !== undefined) {
986
+ ev.preventDefault();
987
+ const [projectId, number] = t.dataset.merge.split(":");
988
+ if (!confirm(`Squash-merge #${number}?`)) return;
989
+ return fetch("/v1/prs/merge", {
990
+ method: "POST", headers: { "content-type": "application/json" },
991
+ body: JSON.stringify({ projectId, number: Number(number) }),
992
+ }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
993
+ }
994
+ if (t.dataset.procstop) {
995
+ ev.preventDefault();
996
+ if (!confirm(`Stop pid ${t.dataset.procstop}?`)) return;
997
+ 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(); });
998
+ }
999
+ if (t.dataset.resrelease !== undefined) {
1000
+ ev.preventDefault();
1001
+ const q = new URLSearchParams({ force: "1" }); if (t.dataset.resproj) q.set("project", t.dataset.resproj);
1002
+ return fetch(`/v1/resources/${encodeURIComponent(t.dataset.resrelease)}?${q}`, { method: "DELETE" }).then(refresh);
1003
+ }
1004
+ if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
1005
+ if (t.dataset.s) { ev.preventDefault(); return openSession(t.dataset.s); }
1006
+ if (t.dataset.id !== undefined) { state.sel = t.dataset.id || null; localStorage.setItem("swarm.sel", state.sel ?? ""); state.session = null; state.tasks = null; state.dirty = true; return refresh(); }
1007
+ });
1008
+ async function addProject(path) {
1009
+ if (!path) return;
1010
+ const r = await fetch("/v1/projects", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
1011
+ if (!r.ok) return alert((await r.json()).error);
1012
+ refresh();
1013
+ }
1014
+ // "+" in the Projects header: menu of ways to add a project
1015
+ document.addEventListener("click", (ev) => {
1016
+ const t = ev.target.closest?.("#addProj");
1017
+ if (!t || !window.menus) return;
1018
+ window.menus.open(t, { items: [
1019
+ { label: "Browse folders\u2026", icon: "folder-simple", run: () => openPicker() },
1020
+ { label: "Add by path\u2026", icon: "terminal-window", run: () => openPicker(true) },
1021
+ ] });
1022
+ });
1023
+ // collapsible sidebar, persisted
1024
+ const sbApply = () => {
1025
+ const off = localStorage.getItem("swarm.sidebar") === "off";
1026
+ document.body.classList.toggle("nosb", off);
1027
+ const b = $("#sbToggle");
1028
+ if (b) b.innerHTML = ic(off ? "arrow-bar-right" : "arrow-bar-left", 15);
1029
+ };
1030
+ $("#sbToggle")?.addEventListener("click", () => {
1031
+ localStorage.setItem("swarm.sidebar", document.body.classList.contains("nosb") ? "on" : "off");
1032
+ sbApply();
1033
+ });
1034
+ sbApply();
1035
+
1036
+ // ---------- folder picker
1037
+ const picker = { path: null };
1038
+ async function openPicker(focusPath = false) {
1039
+ await pickerGo("");
1040
+ if (focusPath) { const i = $("#pkPath"); if (i) { i.focus(); i.select(); } }
1041
+ }
1042
+ async function pickerGo(path) {
1043
+ let data;
1044
+ try {
1045
+ const r = await fetch(`/v1/fs/ls?path=${encodeURIComponent(path)}`);
1046
+ data = await r.json();
1047
+ if (!r.ok) throw new Error(data.error || "cannot read folder");
1048
+ } catch (e) { return alert(e.message); }
1049
+ picker.path = data.path;
1050
+ const rows = [];
1051
+ if (data.parent) rows.push(`<div class="pk-row up" data-go="${esc(data.parent)}">${ic("arrow-left", 14)}<span class="nm">..</span></div>`);
1052
+ const base = data.path.replace(/\/$/, "");
1053
+ for (const e of data.entries)
1054
+ rows.push(`<div class="pk-row" data-go="${esc(base)}/${esc(e.name)}">${ic(e.repo ? "git-branch" : "folder-simple", 14)}<span class="nm">${esc(e.name)}</span>${e.repo ? '<span class="badge acc">git</span>' : ""}</div>`);
1055
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1056
+ <div class="pk-h">${ic("folders", 15)}<input id="pkPath" value="${esc(data.path)}" spellcheck="false" autocomplete="off" title="Type a path and press Enter"></div>
1057
+ <div class="pk-list">${rows.join("") || '<div class="empty" style="padding:20px">No sub-folders.</div>'}</div>
1058
+ <div class="pk-f"><span class="grow"></span><button type="button" id="pkCancel">Cancel</button><button type="button" id="pkAdd" class="primary">Add this folder</button></div>
1059
+ </div>`;
1060
+ }
1061
+ const closePicker = () => { $("#picker").innerHTML = ""; };
1062
+ $("#picker").addEventListener("click", (ev) => {
1063
+ if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
1064
+ const go = ev.target.closest("[data-go]");
1065
+ if (go) return void pickerGo(go.dataset.go);
1066
+ if (ev.target.closest("#pkAdd")) { const p = $("#pkPath")?.value.trim() || picker.path; closePicker(); addProject(p); }
1067
+ });
1068
+ $("#picker").addEventListener("keydown", (ev) => {
1069
+ if (ev.key === "Enter" && ev.target.id === "pkPath") { ev.preventDefault(); pickerGo(ev.target.value.trim()); }
1070
+ });
1071
+ document.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && $("#picker").innerHTML) closePicker(); });
1072
+
1073
+ // ---------- live
1074
+ // Poll for whatever the stream doesn't carry (turn costs, worktrees, PRs); SSE events coalesce into one
1075
+ // fetch 400 ms later. Both pause while the tab is hidden and resume on the next visibilitychange.
1076
+ const poll = () => (state.session ? openSession(state.session) : refresh());
1077
+ let pending = false;
1078
+ const pollSoon = () => { if (!pending) { pending = true; setTimeout(() => { pending = false; poll(); }, 400); } };
1079
+ let backoff = 1500;
1080
+ function connect() {
1081
+ const es = new EventSource(`/v1/events?since=${state.seq}`);
1082
+ const on = () => { backoff = 1500; $("#daemon .dot").classList.add("on"); };
1083
+ es.addEventListener("open", on);
1084
+ es.addEventListener("ping", on);
1085
+ es.onerror = () => { $("#daemon .dot").classList.remove("on"); es.close(); setTimeout(connect, backoff); backoff = Math.min(30_000, backoff * 2); };
1086
+ const onAny = (e) => {
1087
+ $("#daemon .dot").classList.add("on");
1088
+ const ev = JSON.parse(e.data);
1089
+ // Replayed events (reconnect) only bump the seq; the coalesced poll below picks up the rest.
1090
+ const fresh = ev.seq > state.seq;
1091
+ state.seq = Math.max(state.seq, ev.seq);
1092
+ if (fresh && state.session && ev.sessionId === state.session && !state.log.some((x) => x.seq === ev.seq)) {
1093
+ state.log.push(ev);
1094
+ if (state.log.length > LOG_CAP) state.log.shift();
1095
+ schedule();
1096
+ }
1097
+ pollSoon();
1098
+ };
1099
+ 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"]) es.addEventListener(t, onAny);
1100
+ }
1101
+ refresh().then(connect);
1102
+ setInterval(() => { if (!document.hidden) poll(); }, 5000);
1103
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });