@ra3orblade/swarm 0.10.0 → 0.11.1
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/README.md +31 -16
- package/dist/swarm-hook.js +39 -0
- package/dist/swarm.js +39 -1
- package/dist/swarmd.js +1652 -117
- package/package.json +1 -1
- package/web/app.js +702 -66
- package/web/icons.js +2 -2
- package/web/index.html +101 -28
- package/web/menus.js +7 -7
- package/web/release-notes.js +1 -1
- package/web/table.js +1 -1
- package/web/viz.js +76 -2
package/web/app.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
const $ = (s) => document.querySelector(s);
|
|
2
2
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
|
3
|
+
// Open a URL in the user's browser. The desktop app's webview has no new-window handler, so
|
|
4
|
+
// `window.open` and target=_blank silently do nothing there — route through Tauri's shell opener
|
|
5
|
+
// when it is present (capability `shell:allow-open`), and fall back to window.open in a browser.
|
|
6
|
+
const openExternal = (url) => {
|
|
7
|
+
const shell = window.__TAURI__?.shell;
|
|
8
|
+
if (shell?.open) shell.open(url).catch(() => window.open(url, "_blank"));
|
|
9
|
+
else window.open(url, "_blank");
|
|
10
|
+
};
|
|
11
|
+
// Every absolute link (PR titles, docs, search hits, dev-server ports) takes the same path.
|
|
12
|
+
document.addEventListener("click", (e) => {
|
|
13
|
+
const a = e.target.closest?.('a[href^="http"]');
|
|
14
|
+
if (!a) return;
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
openExternal(a.href);
|
|
17
|
+
});
|
|
3
18
|
// M8.2b daemon token: `swarm ui` (and the desktop app) open the dashboard with ?token=…; it is kept
|
|
4
19
|
// in sessionStorage, stripped from the URL, and sent on every /v1 request. Loopback without a token
|
|
5
20
|
// still works while `[daemon] auth = "loopback-optional"`.
|
|
@@ -79,7 +94,12 @@ const projGlyph = (p, size = 14) => p?.icon
|
|
|
79
94
|
const projCell = (id) => { const p = state.projects.find((x) => x.id === id); return p ? `${projGlyph(p, 12)} ${esc(p.name)}` : esc(projName(id)); };
|
|
80
95
|
const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : "(removed)");
|
|
81
96
|
const short = (p) => String(p ?? "").replace(/^\/Users\/[^/]+/, "~");
|
|
82
|
-
|
|
97
|
+
// Never wider than 5 characters, so a numeric column never has to ellipsize a number: without a
|
|
98
|
+
// billions step a 2.8B context read "2820.0M", and the tenth is noise once the mantissa is 3 digits.
|
|
99
|
+
// At most 3 significant digits, so a numeric column never has to ellipsize a number: without a
|
|
100
|
+
// billions step a 2.8B context read "2820.0M", and the tenth is noise once the mantissa is 3 digits.
|
|
101
|
+
const unit = (n, div, suffix) => `${(n / div).toFixed((n /= div) >= 100 ? 0 : n >= 10 ? 1 : 2)}${suffix}`;
|
|
102
|
+
const tok = (n) => (n >= 1e9 ? unit(n, 1e9, "B") : n >= 1e6 ? unit(n, 1e6, "M") : n >= 1e3 ? `${(n / 1e3).toFixed(0)}k` : String(n | 0));
|
|
83
103
|
const usd = (n) => (n == null ? '<span class="dim">—</span>' : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
|
|
84
104
|
const model = (m) => (m ? m.replace(/^claude-/, "").replace(/-\d{8}$/, "") : "");
|
|
85
105
|
const sumBy = (arr, f) => arr.reduce((a, x) => a + (f(x) ?? 0), 0);
|
|
@@ -88,7 +108,14 @@ const ic = (name, size = 14, cls = "") => (window.icon ? window.icon(name, size,
|
|
|
88
108
|
const kindIcon = (s) => ic(s.kind === "subagent" ? "tree-structure" : s.kind === "spawned" ? "play" : "keyboard", 13, "kind");
|
|
89
109
|
// pixel-art illustrations for empty states (crispEdges, theme-green; won't clash with icon packs)
|
|
90
110
|
function pixmap(rows, cell = 6) {
|
|
91
|
-
|
|
111
|
+
// All three tones are derived from the accent, so they are guaranteed to separate in either
|
|
112
|
+
// theme. The old palette used --c4 for the shade, whose luminance in light mode (0.158) is
|
|
113
|
+
// indistinguishable from --acc's (0.160) — the outline simply vanished into the face.
|
|
114
|
+
const C = {
|
|
115
|
+
X: "var(--acc)",
|
|
116
|
+
g: "color-mix(in srgb, var(--acc) 45%, white)",
|
|
117
|
+
d: "color-mix(in srgb, var(--acc) 58%, black)",
|
|
118
|
+
};
|
|
92
119
|
const w = Math.max(...rows.map((r) => r.length)) * cell;
|
|
93
120
|
const h = rows.length * cell;
|
|
94
121
|
let r = "";
|
|
@@ -101,17 +128,43 @@ function pixmap(rows, cell = 6) {
|
|
|
101
128
|
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>`;
|
|
102
129
|
}
|
|
103
130
|
const PX = {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
// The palette carries three tones (accent / light / dark) and this used one, which is why it
|
|
132
|
+
// read as a flat blob. Outline, ear modules and features in `d`; face and body in `X`; antenna
|
|
133
|
+
// tips and the chest light in `g`. The body tapers rather than sitting under the head as a slab.
|
|
134
|
+
idle: () =>
|
|
135
|
+
pixmap(
|
|
136
|
+
[
|
|
137
|
+
" g g ",
|
|
138
|
+
" X X ",
|
|
139
|
+
" X X ",
|
|
140
|
+
" ddddddddddddd ",
|
|
141
|
+
" dXXXXXXXXXXXd ",
|
|
142
|
+
" dggXXXXXXXXXd ",
|
|
143
|
+
" dddXXddXXXddXXddd ",
|
|
144
|
+
" dddXXddXXXddXXddd ",
|
|
145
|
+
" dddXXXXXXXXXXXddd ",
|
|
146
|
+
" dddXXdddddddXXddd ",
|
|
147
|
+
" dXXXXXXXXXXXd ",
|
|
148
|
+
" dXXXXXXXXXXXd ",
|
|
149
|
+
" ddddddddddddd ",
|
|
150
|
+
" XXX ",
|
|
151
|
+
" ddddd ",
|
|
152
|
+
" ddddddddddd ",
|
|
153
|
+
" dXXdggXXXXXXXdXXd ",
|
|
154
|
+
" dXXdXggggXddddXXd ",
|
|
155
|
+
" dXXdXggggXXXXdXXd ",
|
|
156
|
+
" dXXdXXXXXXddddXXd ",
|
|
157
|
+
" dXXdXXXXXXXXXdXXd ",
|
|
158
|
+
" dXXdXdddXXXXXdXXd ",
|
|
159
|
+
" ddddXXXXXXXXXdddd ",
|
|
160
|
+
" ddddddddddd ",
|
|
161
|
+
" XX XX ",
|
|
162
|
+
" XX XX ",
|
|
163
|
+
" XXXX XXXX ",
|
|
164
|
+
" dddd dddd ",
|
|
165
|
+
],
|
|
166
|
+
4,
|
|
167
|
+
),
|
|
115
168
|
folder: () => pixmap([
|
|
116
169
|
" XXXX ",
|
|
117
170
|
"XXXXXXXXXX",
|
|
@@ -148,6 +201,19 @@ const agentBadge = (a) => (a ? `<span class="badge agent" style="color:${viz.age
|
|
|
148
201
|
let raf = 0;
|
|
149
202
|
const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; render(); }); };
|
|
150
203
|
const touch = () => { state.dirty = true; schedule(); };
|
|
204
|
+
// `render()` refuses to paint while a menu is open (it would detach the anchor the menu is
|
|
205
|
+
// positioned against) and defers the frame instead. fancy-menus exposes no close callback, so the
|
|
206
|
+
// deferred paint has to wait for the close — armed from that bail, where the menu is known to be
|
|
207
|
+
// open. Without it a menu action (switch view, ack, release…) only lands on the next 5s poll,
|
|
208
|
+
// which reads as a dead click. One boolean check per frame, only while a menu is open.
|
|
209
|
+
// The menus island re-broadcasts the package's `useIsAnyMenuOpen` as `menus:openchange`.
|
|
210
|
+
window.addEventListener("menus:openchange", (e) => {
|
|
211
|
+
if (e.detail?.open) return;
|
|
212
|
+
// Menu closed: drop the trigger's open state and paint whatever render() deferred.
|
|
213
|
+
for (const b of $$("#viewnav .navgrp.open")) b.classList.remove("open");
|
|
214
|
+
for (const el of $$(".menu-open")) el.classList.remove("menu-open");
|
|
215
|
+
if (state.dirty) schedule();
|
|
216
|
+
});
|
|
151
217
|
// Last snapshot body + last render time: an unchanged snapshot (same seq, same data) skips the render
|
|
152
218
|
// unless the UI changed, or `ago`-style cells are older than 30s.
|
|
153
219
|
let lastSnap = "", lastRenderAt = 0;
|
|
@@ -203,13 +269,71 @@ async function refresh() {
|
|
|
203
269
|
incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
|
|
204
270
|
state.allIncidents = inc;
|
|
205
271
|
}
|
|
272
|
+
let linChanged = false;
|
|
273
|
+
if (state.view === "graphs" && (state.graphTab ?? "collisions") === "lineage" && !state.session) {
|
|
274
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
275
|
+
const open = (state.lineageOpen ?? []).map((g) => `&expand=${encodeURIComponent(g)}`).join("");
|
|
276
|
+
const lin = await fetch(`/v1/graphs/lineage${q || "?"}${open}`).then((r) => r.json()).catch(() => state.lineage);
|
|
277
|
+
linChanged = JSON.stringify(lin) !== JSON.stringify(state.lineage);
|
|
278
|
+
state.lineage = lin;
|
|
279
|
+
}
|
|
206
280
|
let colChanged = false;
|
|
207
|
-
if (state.view === "graphs" && !state.session) {
|
|
281
|
+
if (state.view === "graphs" && (state.graphTab ?? "collisions") !== "lineage" && !state.session) {
|
|
208
282
|
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
209
283
|
const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
|
|
210
284
|
colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
|
|
211
285
|
state.collisions = col;
|
|
212
286
|
}
|
|
287
|
+
let waitChanged = false;
|
|
288
|
+
if ((state.view === "fleet" || state.view === "stats") && !state.session) {
|
|
289
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
290
|
+
const w = await fetch(`/v1/waiting${q}`).then((r) => r.json()).catch(() => state.waiting);
|
|
291
|
+
waitChanged = JSON.stringify(w) !== JSON.stringify(state.waiting);
|
|
292
|
+
state.waiting = w;
|
|
293
|
+
}
|
|
294
|
+
let hygChanged = false;
|
|
295
|
+
if (state.view === "hygiene" && !state.session) {
|
|
296
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
297
|
+
const hy = await fetch(`/v1/hygiene${q}`).then((r) => r.json()).catch(() => state.hygiene);
|
|
298
|
+
hygChanged = JSON.stringify(hy) !== JSON.stringify(state.hygiene);
|
|
299
|
+
state.hygiene = hy;
|
|
300
|
+
}
|
|
301
|
+
let ctxChanged = false;
|
|
302
|
+
if (state.view === "context" && !state.session) {
|
|
303
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
304
|
+
const cx = await fetch(`/v1/context${q}`).then((r) => r.json()).catch(() => state.context);
|
|
305
|
+
ctxChanged = JSON.stringify(cx) !== JSON.stringify(state.context);
|
|
306
|
+
state.context = cx;
|
|
307
|
+
}
|
|
308
|
+
let trialsChanged = false;
|
|
309
|
+
if (state.view === "trials" && !state.session) {
|
|
310
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
311
|
+
const tr = await fetch(`/v1/ab${q}`).then((r) => r.json()).then((r) => r.trials ?? []).catch(() => state.trials);
|
|
312
|
+
trialsChanged = JSON.stringify(tr) !== JSON.stringify(state.trials);
|
|
313
|
+
state.trials = tr;
|
|
314
|
+
}
|
|
315
|
+
let provChanged = false;
|
|
316
|
+
if (state.view === "provenance" && !state.session) {
|
|
317
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
318
|
+
const off = state.provOffset ?? 0;
|
|
319
|
+
const pv = await fetch(`/v1/provenance${q ? `${q}&` : "?"}limit=50&offset=${off}`).then((r) => r.json()).catch(() => state.provenance);
|
|
320
|
+
provChanged = JSON.stringify(pv) !== JSON.stringify(state.provenance);
|
|
321
|
+
state.provenance = pv;
|
|
322
|
+
}
|
|
323
|
+
let mcpChanged = false;
|
|
324
|
+
if (state.view === "mcp" && !state.session) {
|
|
325
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
326
|
+
const m = await fetch(`/v1/mcp/health${q}`).then((r) => r.json()).catch(() => state.mcpHealth);
|
|
327
|
+
mcpChanged = JSON.stringify(m) !== JSON.stringify(state.mcpHealth);
|
|
328
|
+
state.mcpHealth = m;
|
|
329
|
+
}
|
|
330
|
+
let ghChanged = false;
|
|
331
|
+
if (state.view === "gates" && !state.session) {
|
|
332
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
333
|
+
const gh = await fetch(`/v1/gates/health${q}`).then((r) => r.json()).catch(() => state.gateHealth);
|
|
334
|
+
ghChanged = JSON.stringify(gh) !== JSON.stringify(state.gateHealth);
|
|
335
|
+
state.gateHealth = gh;
|
|
336
|
+
}
|
|
213
337
|
let outChanged = false;
|
|
214
338
|
if (state.view === "outcomes" && !state.session) {
|
|
215
339
|
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
@@ -217,7 +341,7 @@ async function refresh() {
|
|
|
217
341
|
outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
|
|
218
342
|
state.outcomes = o;
|
|
219
343
|
}
|
|
220
|
-
if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || outChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
|
|
344
|
+
if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || linChanged || outChanged || waitChanged || ghChanged || mcpChanged || ctxChanged || provChanged || trialsChanged || hygChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
|
|
221
345
|
}
|
|
222
346
|
// M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
|
|
223
347
|
// deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
|
|
@@ -227,10 +351,17 @@ const VIEW_DEFS = [
|
|
|
227
351
|
{ id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
|
|
228
352
|
{ id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
|
|
229
353
|
{ id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
|
|
230
|
-
{ id: "
|
|
354
|
+
{ id: "trials", label: "Trials", icon: "robot", group: "Work", render: () => renderTrials(), badge: () => (state.trials ?? []).filter((t) => t.verdict === "undecided").length },
|
|
355
|
+
{ id: "hygiene", label: "Hygiene", icon: "trash", group: "Work", render: () => renderHygiene(), badge: () => state.hygiene?.totals?.issues ?? 0 },
|
|
356
|
+
// not "check": inside a menu a tick reads as "this item is selected" rather than as an icon
|
|
357
|
+
{ id: "outcomes", label: "Outcomes", icon: "git-branch", group: "Insight", render: () => renderOutcomes() },
|
|
358
|
+
{ id: "gates", label: "Gates", icon: "shield", group: "Insight", render: () => renderGateHealth(), badge: () => state.gateHealth?.totals?.flakyGates ?? 0 },
|
|
359
|
+
{ id: "mcp", label: "MCP", icon: "plugs-connected", group: "Insight", render: () => renderMcpHealth() },
|
|
360
|
+
{ id: "context", label: "Context", icon: "brain", group: "Insight", render: () => renderContext() },
|
|
231
361
|
{ id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
|
|
232
362
|
{ id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
|
|
233
363
|
{ id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
|
|
364
|
+
{ id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard", render: () => renderProvenance(), badge: () => state.provenance?.totals?.untracked ?? 0 },
|
|
234
365
|
{ id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
|
|
235
366
|
];
|
|
236
367
|
const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
|
|
@@ -240,6 +371,8 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
|
|
|
240
371
|
{
|
|
241
372
|
const v = localStorage.getItem("swarm.view");
|
|
242
373
|
if (VIEWS.includes(v)) state.view = v;
|
|
374
|
+
const gt = localStorage.getItem("swarm.graphTab");
|
|
375
|
+
if (gt === "lineage" || gt === "collisions") state.graphTab = gt;
|
|
243
376
|
const sel = localStorage.getItem("swarm.sel");
|
|
244
377
|
if (sel) state.sel = sel;
|
|
245
378
|
// Deep links win over persisted state: ?view=board&project=<id>&session=<id>
|
|
@@ -252,7 +385,7 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
|
|
|
252
385
|
function render() {
|
|
253
386
|
// A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
|
|
254
387
|
// hold the frame while one is open; the next poll or interaction paints it.
|
|
255
|
-
if (window.menus?.isOpen()) { state.dirty = true; return; }
|
|
388
|
+
if (window.menus?.isOpen()) { state.dirty = true; return; } // the menus:openchange listener paints on close
|
|
256
389
|
// Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
|
|
257
390
|
const af = document.activeElement;
|
|
258
391
|
const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
|
|
@@ -274,21 +407,57 @@ function renderHeader() {
|
|
|
274
407
|
if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
|
|
275
408
|
renderNav();
|
|
276
409
|
}
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
410
|
+
// View nav in the header: one button per group (Observe / Work / Insight / Guard); clicking one
|
|
411
|
+
// opens a fancy-menus dropdown of that group's views. Rebuilt only when the html changes (active
|
|
412
|
+
// view, badges) so the 5s poll doesn't churn the DOM — and never while its menu is open.
|
|
413
|
+
function showView(id) {
|
|
414
|
+
state.view = id;
|
|
415
|
+
localStorage.setItem("swarm.view", id);
|
|
416
|
+
state.session = null;
|
|
417
|
+
state.dirty = true;
|
|
418
|
+
refresh();
|
|
419
|
+
}
|
|
420
|
+
function viewGroups() {
|
|
280
421
|
const groups = [];
|
|
281
422
|
for (const v of VIEW_DEFS) {
|
|
282
423
|
const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
|
|
283
424
|
g.views.push(v);
|
|
284
425
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
426
|
+
return groups;
|
|
427
|
+
}
|
|
428
|
+
function renderNav() {
|
|
429
|
+
const html = viewGroups()
|
|
430
|
+
.map((g) => {
|
|
431
|
+
const n = g.views.reduce((a, v) => a + (v.badge?.() ?? 0), 0);
|
|
432
|
+
const on = !state.session && g.views.some((v) => v.id === state.view);
|
|
433
|
+
// The group name alone never says which of its views you are on, so ten destinations hid
|
|
434
|
+
// behind four words. The active group carries the view's own label.
|
|
435
|
+
const cur = on ? g.views.find((v) => v.id === state.view) : null;
|
|
436
|
+
return `<button class="navgrp ${on ? "on" : ""}" data-grp="${g.name}"${on ? ' aria-current="page"' : ""} aria-haspopup="menu">${g.name}${cur ? `<span class="navview">${esc(cur.label)}</span>` : ""}${n ? `<b class="navcount">${n > 99 ? "99+" : n}</b>` : ""}${ic("chevron-down", 12, "chev")}</button>`;
|
|
437
|
+
})
|
|
438
|
+
.join("");
|
|
290
439
|
if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
|
|
291
440
|
}
|
|
441
|
+
// Delegated: the group buttons are re-rendered, so the listener lives on the container.
|
|
442
|
+
$("#viewnav").addEventListener("click", (ev) => {
|
|
443
|
+
const btn = ev.target.closest("[data-grp]");
|
|
444
|
+
if (!btn) return;
|
|
445
|
+
const g = viewGroups().find((x) => x.name === btn.dataset.grp);
|
|
446
|
+
if (!g || !window.menus) return;
|
|
447
|
+
btn.classList.add("open"); // cleared by menus:openchange when the menu closes
|
|
448
|
+
window.menus.open(btn, {
|
|
449
|
+
items: g.views.map((v) => {
|
|
450
|
+
const n = v.badge?.() ?? 0;
|
|
451
|
+
return {
|
|
452
|
+
label: v.label,
|
|
453
|
+
icon: v.icon,
|
|
454
|
+
caption: n ? String(n) : undefined,
|
|
455
|
+
pressed: !state.session && state.view === v.id,
|
|
456
|
+
run: () => showView(v.id),
|
|
457
|
+
};
|
|
458
|
+
}),
|
|
459
|
+
});
|
|
460
|
+
});
|
|
292
461
|
|
|
293
462
|
const isLive = (s) => s.state === "active" || s.state === "waiting";
|
|
294
463
|
// One pass over sessions → live count per project (+ "" for all), instead of a filter per sidebar row.
|
|
@@ -381,10 +550,19 @@ function onboarding() {
|
|
|
381
550
|
|
|
382
551
|
// ---------- fleet
|
|
383
552
|
// Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
|
|
553
|
+
// M9.4: this session is blocked on a person right now — the badge says for how long, and on what.
|
|
554
|
+
const waitFor = (sid) => (state.waiting?.sessions ?? []).find((w) => w.sessionId === sid);
|
|
555
|
+
const WAIT_WHAT = { permission: "a permission prompt", question: "a question it asked", notification: "a notification" };
|
|
556
|
+
function waitBadge(sid) {
|
|
557
|
+
const w = waitFor(sid);
|
|
558
|
+
if (!w?.openSince) return "";
|
|
559
|
+
const what = WAIT_WHAT[w.openKind] ?? "you";
|
|
560
|
+
return ` <span class="badge warn" title="Blocked on ${esc(what)} since ${esc(w.openSince)}${w.openLabel ? ` — ${esc(w.openLabel)}` : ""}">Waiting ${ago(w.openSince)}</span>`;
|
|
561
|
+
}
|
|
384
562
|
const FLEET_COLS = [
|
|
385
563
|
{ key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
|
|
386
564
|
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
|
|
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>` : ""}` },
|
|
565
|
+
{ 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>` : ""}${waitBadge(s.id)}` },
|
|
388
566
|
{ key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
|
|
389
567
|
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
|
|
390
568
|
const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
|
|
@@ -477,7 +655,11 @@ function renderBoardKpis() {
|
|
|
477
655
|
const orphaned = claims.filter((c) => c.state === "orphaned").length;
|
|
478
656
|
const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
|
|
479
657
|
const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
|
|
480
|
-
|
|
658
|
+
// The snapshot carries only the 20 most recent open incidents, so counting that window caps the
|
|
659
|
+
// KPI at 20 while the Guard badge shows the real number. Both now read the same true count.
|
|
660
|
+
const inc = state.sel
|
|
661
|
+
? (state.openIncidentsByProject?.[state.sel] ?? (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length)
|
|
662
|
+
: (state.openIncidents ?? (state.incidents ?? []).filter((i) => !i.acked).length);
|
|
481
663
|
const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
|
|
482
664
|
const ready = tasks ? tasks.filter((t) => t.ready).length : null;
|
|
483
665
|
const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
|
|
@@ -1067,6 +1249,34 @@ document.addEventListener("change", async (ev) => {
|
|
|
1067
1249
|
});
|
|
1068
1250
|
document.addEventListener("input", (ev) => { if (ev.target.id === "psIcon") { $("#psImage").value = ""; setIconPreview(ev.target.value.trim()); for (const e of $$(".emoji")) e.classList.toggle("on", e.dataset.emoji === ev.target.value.trim()); } });
|
|
1069
1251
|
document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
|
|
1252
|
+
// M9.4: how much of the fleet's time is spent waiting on a person, and on what. Blocked time is
|
|
1253
|
+
// not idle time — it is the agent standing still with the work half-done, which is why it gets a
|
|
1254
|
+
// number rather than a footnote.
|
|
1255
|
+
function waitingSection() {
|
|
1256
|
+
const w = state.waiting;
|
|
1257
|
+
if (!w?.totals?.episodes) return "";
|
|
1258
|
+
const t = w.totals;
|
|
1259
|
+
const kindRow = (k, label) => {
|
|
1260
|
+
const x = t.byKind[k];
|
|
1261
|
+
return x?.episodes ? `<tr><td>${label}</td><td class="num">${x.episodes}</td><td class="num">${dur(x.blockedMs)}</td></tr>` : "";
|
|
1262
|
+
};
|
|
1263
|
+
const top = w.sessions.slice(0, 8).map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
|
|
1264
|
+
<td>${esc(s.title ?? s.sessionId.slice(0, 8))}${s.openSince ? ` <span class="badge warn">waiting ${ago(s.openSince)}</span>` : ""}</td>
|
|
1265
|
+
<td class="num">${s.episodes}</td><td class="num">${dur(s.blockedMs)}</td><td class="num">${dur(s.longestMs)}</td></tr>`).join("");
|
|
1266
|
+
return `<h2 class="mt-sec">Waiting on you <span>last 7 days · time agents spent blocked on a person${t.waitingNow ? ` · <b class="navcount">${t.waitingNow} waiting now</b>` : ""}</span></h2>
|
|
1267
|
+
<div class="cols">
|
|
1268
|
+
<div class="chart-card" style="margin:0"><h3>By what blocked them</h3>
|
|
1269
|
+
<table class="mini"><thead><tr><th>kind</th><th class="num">times</th><th class="num">blocked</th></tr></thead>
|
|
1270
|
+
<tbody>${kindRow("permission", "Permission prompt")}${kindRow("question", "Question it asked")}${kindRow("notification", "Notification")}
|
|
1271
|
+
<tr><td><b>Total</b></td><td class="num"><b>${t.episodes}</b></td><td class="num"><b>${dur(t.blockedMs)}</b></td></tr>
|
|
1272
|
+
<tr><td class="dim">median wait</td><td class="num"></td><td class="num dim">${dur(t.medianMs)}</td></tr>
|
|
1273
|
+
<tr><td class="dim">longest wait</td><td class="num"></td><td class="num dim">${dur(t.longestMs)}</td></tr>
|
|
1274
|
+
</tbody></table></div>
|
|
1275
|
+
<div class="chart-card" style="margin:0"><h3>Sessions that waited most</h3>
|
|
1276
|
+
<table class="mini"><thead><tr><th>session</th><th class="num">waits</th><th class="num">blocked</th><th class="num">longest</th></tr></thead>
|
|
1277
|
+
<tbody>${top}</tbody></table></div>
|
|
1278
|
+
</div>`;
|
|
1279
|
+
}
|
|
1070
1280
|
function renderStats() {
|
|
1071
1281
|
const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
|
|
1072
1282
|
const scope = state.sel ? esc(projName(state.sel)) : "all projects";
|
|
@@ -1154,6 +1364,7 @@ function renderStats() {
|
|
|
1154
1364
|
<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>
|
|
1155
1365
|
<div><h2 style="margin-top:0">Records</h2><div class="records">${records}</div></div>
|
|
1156
1366
|
</div>
|
|
1367
|
+
${waitingSection()}
|
|
1157
1368
|
<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>`;
|
|
1158
1369
|
}
|
|
1159
1370
|
|
|
@@ -1214,18 +1425,297 @@ function renderOutcomes() {
|
|
|
1214
1425
|
$("#main").innerHTML =
|
|
1215
1426
|
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
1427
|
`<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 })}` : "") +
|
|
1428
|
+
dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel, rerender: touch }) +
|
|
1429
|
+
(o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent, rerender: touch })}` : "") +
|
|
1219
1430
|
`<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
|
|
1220
|
-
dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100) });
|
|
1431
|
+
dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100), rerender: touch });
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// M9.5: where the context window goes. Character counts are exact (every tool response is stored);
|
|
1435
|
+
// the token figures are a flat 4:1 estimate and say so. Re-reading a file is the waste metric —
|
|
1436
|
+
// the first read is work, every copy after it is the price of having forgotten.
|
|
1437
|
+
// `toolName` puts the server first, so four MCP tools all truncated to "claude-in-c…" and the
|
|
1438
|
+
// half that tells them apart was the half cut off. Lead with the tool, keep a short server hint.
|
|
1439
|
+
function ctxToolLabel(tool) {
|
|
1440
|
+
const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(tool);
|
|
1441
|
+
if (!m) return tool;
|
|
1442
|
+
const srv = m[1].replace(/[-_]/g, " ").split(" ").map((w) => w[0]).join("").toLowerCase();
|
|
1443
|
+
return `${m[2]} · ${srv}`;
|
|
1444
|
+
}
|
|
1445
|
+
function renderContext() {
|
|
1446
|
+
const c = state.context;
|
|
1447
|
+
const head = (sub) => `<h2>Context <span>${sub}</span></h2>`;
|
|
1448
|
+
if (!c) { $("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1449
|
+
if (!c.totals.sessions) {
|
|
1450
|
+
$("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.idle()}No tool results in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
const t = c.totals;
|
|
1454
|
+
const chars = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
|
|
1455
|
+
const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1456
|
+
const kpis = `<div class="kpis">${
|
|
1457
|
+
kpi("Returned by tools", `${chars(t.toolChars)}`, `characters · ≈${chars(t.toolTokens)} tokens`)
|
|
1458
|
+
}${kpi("Spent re-reading", chars(t.wastedChars), t.wasteShare ? `${Math.round(t.wasteShare * 100)}% of it · ${t.rereadFiles} file${t.rereadFiles === 1 ? "" : "s"}` : "nothing re-read", t.wasteShare > 0.1 ? "hot" : t.wasteShare > 0.03 ? "warm" : "")
|
|
1459
|
+
}${kpi("Cache hit", `${Math.round(t.cacheHit * 100)}%`, "of the window came back free")
|
|
1460
|
+
}${kpi("Sessions", t.sessions, "with tool activity")}</div>`;
|
|
1461
|
+
|
|
1462
|
+
const worst = c.sessions.filter((s) => s.wastedChars > 0).slice(0, 10);
|
|
1463
|
+
const rows = worst.map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
|
|
1464
|
+
<td>${esc(s.title ?? s.sessionId.slice(0, 8))}</td>
|
|
1465
|
+
<td class="num">${chars(s.toolChars)}</td>
|
|
1466
|
+
<td class="num"><b>${chars(s.wastedChars)}</b></td>
|
|
1467
|
+
<td class="num">${Math.round(s.wasteShare * 100)}%</td>
|
|
1468
|
+
<td>${s.worst.map((w) => `<span class="br" title="${esc(w.path)} — read ${w.reads}× · ${chars(w.wastedChars)} chars re-read">${esc(w.path.split("/").slice(-1)[0])} <b>${w.reads}×</b></span>`).join(" ")}</td>
|
|
1469
|
+
</tr>`).join("");
|
|
1470
|
+
|
|
1471
|
+
$("#main").innerHTML = head(`last 7 days · ${chars(t.toolChars)} characters returned by tools`) + kpis +
|
|
1472
|
+
`<div class="cols">
|
|
1473
|
+
<div class="chart-card" style="margin:0"><h3>What fills the window <span>by tool · characters returned</span></h3>
|
|
1474
|
+
${viz.hbars(c.byTool.map((x) => [ctxToolLabel(x.tool), x.chars, `${chars(x.chars)} · ${x.calls}`]))}</div>
|
|
1475
|
+
<div class="chart-card" style="margin:0"><h3>Re-read waste <span>the same file, read again</span></h3>
|
|
1476
|
+
${worst.length ? `<table class="mini"><thead><tr><th>session</th><th class="num">returned</th><th class="num">wasted</th><th class="num">share</th><th>worst files</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="dim">Nothing was read twice — no waste to report.</div>'}</div>
|
|
1477
|
+
</div>
|
|
1478
|
+
<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Character counts are exact — every tool response is stored. Token figures are a flat 4:1 estimate. <b>MCP tool schemas and the system prompt are not included</b>: Swarm sees tool calls, never the schemas or the prompt preamble, so they are left out rather than guessed at.</p>`;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// M9.18: the same task run by N models side by side. An arm is its own task id, so each has its
|
|
1482
|
+
// own claim and worktree and the ledger's one-holder rule is untouched — see core/abtrial.ts.
|
|
1483
|
+
const VERDICT = { winner: ["ok", "Decided"], undecided: ["acc", "Running"], "all-failed": ["bad", "No winner"] };
|
|
1484
|
+
function renderTrials() {
|
|
1485
|
+
const trials = state.trials;
|
|
1486
|
+
const head = (sub) => `<h2>Trials <span>${sub}</span>${state.sel ? `<span class="grow"></span><span class="chip" id="abNew">${ic("plus", 12)} New trial</span>` : ""}</h2>`;
|
|
1487
|
+
if (!trials) { $("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1488
|
+
if (!trials.length) {
|
|
1489
|
+
$("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.idle()}No trials yet${state.sel ? "" : " — pick a project to start one"}.<br>A trial runs one task on several models at once and compares what each produced: cost, wall time, gates, diff size.</div>`;
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const secs = (v) => (v === null ? '<span class="dim">—</span>' : dur(v));
|
|
1493
|
+
const cols = [
|
|
1494
|
+
{ key: "arm", label: "arm", width: 130, get: (a) => a.label, cell: (a) => `<b>${esc(a.label)}</b>${a.winner ? ' <span class="badge ok">Winner</span>' : ""}` },
|
|
1495
|
+
{ key: "state", label: "state", width: 116, get: (a) => a.ineligibleFor ?? "", cell: (a) => (a.eligible ? '<span class="badge ok">Passed</span>' : `<span class="badge ${a.state === "running" ? "acc" : "warn"}" title="This arm cannot win: ${esc(a.ineligibleFor ?? "")}">${esc(a.ineligibleFor ?? "—")}</span>`) },
|
|
1496
|
+
{ key: "cost", label: "cost", width: 74, num: true, get: (a) => a.costUsd, cell: (a) => usd(a.costUsd) },
|
|
1497
|
+
{ key: "wall", label: "wall", width: 74, num: true, get: (a) => a.wallMs ?? -1, cell: (a) => secs(a.wallMs) },
|
|
1498
|
+
{ key: "turns", label: "turns", width: 64, num: true, get: (a) => a.turns, cell: (a) => a.turns },
|
|
1499
|
+
{ key: "gates", label: "gates", width: 84, num: true, get: (a) => a.gatesFailed * -1 + a.gatesPassed, cell: (a) => `${a.gatesPassed ? `<span class="badge ok">${a.gatesPassed}</span>` : ""}${a.gatesFailed ? ` <span class="badge bad">${a.gatesFailed}</span>` : ""}${!a.gatesPassed && !a.gatesFailed ? '<span class="dim">none</span>' : ""}` },
|
|
1500
|
+
{ key: "diff", label: "diff", width: 108, num: true, get: (a) => a.churn ?? -1, cell: (a) => (a.churn === null ? '<span class="dim">measuring…</span>' : `<span title="${a.filesChanged} file${a.filesChanged === 1 ? "" : "s"} · +${a.insertions} −${a.deletions}">${a.churn} lines</span>`) },
|
|
1501
|
+
{ key: "sess", label: "session", flex: true, get: (a) => a.sessionId ?? "", cell: (a) => (a.sessionId ? `<a href="#" data-s="${esc(a.sessionId)}">${esc(a.model ?? a.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
|
|
1502
|
+
];
|
|
1503
|
+
const block = (t) => {
|
|
1504
|
+
const v = VERDICT[t.verdict] ?? VERDICT.undecided;
|
|
1505
|
+
const sub = `${t.totals.arms} arm${t.totals.arms === 1 ? "" : "s"} · ${t.totals.finished} finished · ${usd(t.totals.costUsd)} spent${t.winner ? ` · <b>${esc(t.winner)}</b> won${t.totals.savedUsd > 0.005 ? `, ${usd(t.totals.savedUsd)} cheaper than the dearest` : ""}` : ""}`;
|
|
1506
|
+
return `<h2 class="mt-sec">${esc(t.task)} <span class="badge ${v[0]}">${v[1]}</span> <span>${sub}</span></h2>` +
|
|
1507
|
+
dataTable({ id: `ab-${t.task}`, columns: cols, rows: t.arms, rerender: touch });
|
|
1508
|
+
};
|
|
1509
|
+
const running = trials.filter((t) => t.verdict === "undecided").length;
|
|
1510
|
+
$("#main").innerHTML = head(`${trials.length} trial${trials.length === 1 ? "" : "s"}${running ? ` · ${running} still running` : ""}`) +
|
|
1511
|
+
trials.map(block).join("") +
|
|
1512
|
+
`<p class="dim" style="margin-top:12px;font-size:var(--fs-sm)">An arm wins only if it finished and passed every gate it ran; among those, the cheapest wins and wall time breaks ties. A cheap arm that failed a gate never wins — the cheap wrong answer is not the answer. Each arm claims <code>task#arm</code>, so it gets its own worktree and the one-holder claim is never bent.</p>`;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// M9.14: issue → task → claim → session → branch → PR → merged, as one row per piece of work.
|
|
1516
|
+
// The six link dots are the graph: a filled run that stops is exactly where the trail goes cold.
|
|
1517
|
+
const LINK_ORDER = ["task", "claim", "session", "branch", "pr", "merged"];
|
|
1518
|
+
const BREAK_LABEL = {
|
|
1519
|
+
"no-task": ["bad", "No task", "landed with no task behind it"],
|
|
1520
|
+
unclaimed: ["warn", "Unclaimed", "no claim was ever taken for this task"],
|
|
1521
|
+
"no-session": ["warn", "No session", "claimed, but no session did the work"],
|
|
1522
|
+
"no-branch": ["warn", "No branch", "worked on, but never reached a branch"],
|
|
1523
|
+
"no-pr": ["warn", "No PR", "a branch exists but no pull request"],
|
|
1524
|
+
"open-pr": ["acc", "Open PR", "the pull request has not merged yet"],
|
|
1525
|
+
};
|
|
1526
|
+
// Lead time spans minutes to months, and "889.4h" both overflows a numeric column and means
|
|
1527
|
+
// nothing to a reader. Never wider than 5 characters.
|
|
1528
|
+
function leadTime(h) {
|
|
1529
|
+
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
1530
|
+
if (h < 48) return `${h.toFixed(h < 10 ? 1 : 0)}h`;
|
|
1531
|
+
const d = h / 24;
|
|
1532
|
+
return d < 100 ? `${d.toFixed(d < 10 ? 1 : 0)}d` : `${Math.round(d / 7)}w`;
|
|
1533
|
+
}
|
|
1534
|
+
function renderProvenance() {
|
|
1535
|
+
const p = state.provenance;
|
|
1536
|
+
const head = (sub) => `<h2>Provenance <span>${sub}</span></h2>`;
|
|
1537
|
+
if (!p) { $("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1538
|
+
if (!p.chains.length) {
|
|
1539
|
+
$("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.idle()}Nothing to trace${state.sel ? " in this project" : ""}.<br>Chains appear once a task source is configured or a branch reaches a pull request.</div>`;
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
const t = p.totals;
|
|
1543
|
+
const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1544
|
+
const kpis = `<div class="kpis">${
|
|
1545
|
+
kpi("Traced", `${t.complete}/${t.tasks}`, "reach a merged PR", t.complete ? "" : "warm")
|
|
1546
|
+
}${kpi("Untracked", t.untracked, t.untracked ? "landed with no task" : "all work has a task", t.untracked ? "hot" : "")
|
|
1547
|
+
}${kpi("Unclaimed", t.unclaimed, "tasks nobody claimed", t.unclaimed ? "warm" : "")
|
|
1548
|
+
}${kpi("Traced spend", usd(t.costUsd), "across every chain")}</div>`;
|
|
1549
|
+
|
|
1550
|
+
const track = (c) => `<span class="track" title="${LINK_ORDER.map((k) => `${k}: ${c.links[k] ? "yes" : "no"}`).join(" · ")}">${
|
|
1551
|
+
LINK_ORDER.map((k) => `<i class="${c.links[k] ? "on" : ""}"></i>`).join("")}</span>`;
|
|
1552
|
+
const cols = [
|
|
1553
|
+
{ key: "what", label: "task / branch", width: 190, get: (c) => c.task, cell: (c) => `<b title="${esc(c.task)}${c.fromTask ? "" : " — a branch with no task behind it"}">${esc(c.task)}</b>${c.fromTask ? "" : ' <span class="badge">branch</span>'}` },
|
|
1554
|
+
{ key: "track", label: "chain", width: 92, sortable: false, filterable: false, get: (c) => c.depth, cell: track },
|
|
1555
|
+
{ key: "gap", label: "trail ends at", width: 118, get: (c) => c.brokenAt ?? "", cell: (c) => { const b = BREAK_LABEL[c.brokenAt]; return b ? `<span class="badge ${b[0]}" title="${esc(b[2])}">${b[1]}</span>` : '<span class="badge ok">Merged</span>'; } },
|
|
1556
|
+
{ key: "title", label: "what it was", flex: true, get: (c) => c.title, cell: (c) => `<span class="now" title="${esc(c.title)}">${esc(c.title)}</span>` },
|
|
1557
|
+
{ key: "who", label: "held by", width: 120, get: (c) => c.holders.join(","), cell: (c) => (c.holders.length ? esc(c.holders.join(", ")) : '<span class="dim">—</span>') },
|
|
1558
|
+
{ key: "sess", label: "sessions", width: 78, num: true, get: (c) => c.sessions.length, cell: (c) => (c.sessions.length ? `<a href="#" data-s="${esc(c.sessions[0].id)}" title="${esc(c.sessions.map((s) => s.title ?? s.id).join(" · "))}">${c.sessions.length}</a>` : '<span class="dim">0</span>') },
|
|
1559
|
+
{ key: "pr", label: "PR", width: 74, num: true, get: (c) => c.prNumber ?? 0, cell: (c) => (c.prNumber ? `<a href="${esc(c.prUrl ?? "#")}" target="_blank" rel="noopener">#${c.prNumber}</a>` : '<span class="dim">—</span>') },
|
|
1560
|
+
{ key: "cost", label: "cost", width: 74, num: true, get: (c) => c.costUsd, cell: (c) => usd(c.costUsd) },
|
|
1561
|
+
{ key: "lead", label: "lead", width: 68, num: true, get: (c) => c.leadHours ?? -1, cell: (c) => (c.leadHours === null ? '<span class="dim">—</span>' : leadTime(c.leadHours)) },
|
|
1562
|
+
];
|
|
1563
|
+
const pg = p.page ?? { limit: p.chains.length, offset: 0, total: p.chains.length };
|
|
1564
|
+
const from = pg.total ? pg.offset + 1 : 0;
|
|
1565
|
+
const to = Math.min(pg.offset + pg.limit, pg.total);
|
|
1566
|
+
const pager = pg.total > pg.limit
|
|
1567
|
+
? `<div class="chips" style="margin-top:10px">
|
|
1568
|
+
<span class="chip ${pg.offset ? "" : "off"}" data-provpage="${Math.max(0, pg.offset - pg.limit)}">${ic("arrow-left", 12)} Newer</span>
|
|
1569
|
+
<span class="dim" style="align-self:center;font-size:var(--fs-sm)">${from}–${to} of ${pg.total}</span>
|
|
1570
|
+
<span class="chip ${to >= pg.total ? "off" : ""}" data-provpage="${pg.offset + pg.limit}">Older ${ic("arrow-right", 12)}</span>
|
|
1571
|
+
</div>`
|
|
1572
|
+
: "";
|
|
1573
|
+
// A cold start has no forge data yet, so PR columns would read as "no PR" for everything.
|
|
1574
|
+
const catching = p.stale
|
|
1575
|
+
? `<p class="dim" style="margin-top:8px;font-size:var(--fs-sm)">${ic("arrows-clockwise", 12)} Pull request state is still loading from the forge — it fills in on the next refresh.</p>`
|
|
1576
|
+
: "";
|
|
1577
|
+
$("#main").innerHTML = head(`${pg.total} chain${pg.total === 1 ? "" : "s"} · ${t.untracked ? `<b class="navcount">${t.untracked} untracked</b>` : "every branch has a task"}`) + kpis +
|
|
1578
|
+
dataTable({ id: "provenance", columns: cols, rows: p.chains, rerender: touch }) + pager + catching +
|
|
1579
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">The six dots are task · claim · session · branch · PR · merged — a filled run that stops is where the trail goes cold. Chains are walked from both ends: from tasks forward, and from branches back, so <b>work that landed with no task behind it</b> shows up too. Task rows carry no issue link because the task source records ids and titles, not URLs.</p>`;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// M9.6: which MCP servers the fleet waits on. Latency is hook-to-hook — the wall-clock between
|
|
1583
|
+
// PreToolUse and PostToolUse — so it is what the agent actually waited for, including any time a
|
|
1584
|
+
// call spent behind a permission prompt. That is why the view leads with p50/p95, not max.
|
|
1585
|
+
function renderMcpHealth() {
|
|
1586
|
+
const h = state.mcpHealth;
|
|
1587
|
+
const head = (sub) => `<h2>MCP <span>${sub}</span></h2>`;
|
|
1588
|
+
if (!h) { $("#main").innerHTML = head("server health") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1589
|
+
if (!h.servers.length) {
|
|
1590
|
+
$("#main").innerHTML = head("server health") + `<div class="empty">${PX.idle()}No tool calls in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const t = h.totals;
|
|
1594
|
+
const ms = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : v < 60_000 ? `${(v / 1000).toFixed(1)}s` : dur(v));
|
|
1595
|
+
const cols = [
|
|
1596
|
+
{ key: "server", label: "server", width: 170, get: (s) => s.server, cell: (s) => `<b>${esc(s.server)}</b>${s.mcp ? "" : ' <span class="badge">built-in</span>'}` },
|
|
1597
|
+
{ key: "calls", label: "calls", width: 74, num: true, get: (s) => s.calls, cell: (s) => s.calls.toLocaleString() },
|
|
1598
|
+
{ key: "sessions", label: "sessions", width: 78, num: true, get: (s) => s.sessions, cell: (s) => s.sessions },
|
|
1599
|
+
{ key: "p50", label: "p50", width: 68, num: true, get: (s) => s.p50Ms ?? -1, cell: (s) => ms(s.p50Ms) },
|
|
1600
|
+
{ key: "p95", label: "p95", width: 68, num: true, get: (s) => s.p95Ms ?? -1, cell: (s) => ms(s.p95Ms) },
|
|
1601
|
+
{ key: "max", label: "slowest", width: 78, num: true, get: (s) => s.maxMs ?? -1, cell: (s) => `<span class="dim" title="Includes any time the call spent waiting on a person">${ms(s.maxMs)}</span>` },
|
|
1602
|
+
{ key: "wait", label: "waited", width: 82, num: true, get: (s) => s.totalMs, cell: (s) => dur(s.totalMs) },
|
|
1603
|
+
{ key: "unans", label: "no reply", width: 78, num: true, get: (s) => s.unanswered, cell: (s) => (s.unanswered ? `<b class="bad">${s.unanswered}</b>` : '<span class="dim">0</span>') },
|
|
1604
|
+
{ key: "err", label: "errors", width: 74, num: true, get: (s) => s.errorRate, cell: (s) => (s.errors ? `<b class="bad">${Math.round(s.errorRate * 100)}%</b>` : '<span class="dim">0</span>') },
|
|
1605
|
+
{ key: "tools", label: "busiest tools", flex: true, sortable: false, get: () => null, cell: (s) => s.tools.map((x) => `<span class="br" title="${esc(x.tool)} · ${x.calls} calls${x.p50Ms === null ? "" : ` · p50 ${x.p50Ms}ms`}">${esc(x.tool)} <b>${x.calls}</b></span>`).join(" ") },
|
|
1606
|
+
];
|
|
1607
|
+
const share = t.totalMs ? Math.round((t.mcpMs / t.totalMs) * 100) : 0;
|
|
1608
|
+
const sub = `${t.servers} MCP server${t.servers === 1 ? "" : "s"} · ${t.calls.toLocaleString()} call${t.calls === 1 ? "" : "s"} · last 7 days · ${dur(t.mcpMs)} waiting on MCP (${share}% of all tool time)`;
|
|
1609
|
+
$("#main").innerHTML = head(sub) +
|
|
1610
|
+
dataTable({ id: "mcp-health", columns: cols, rows: h.servers, rerender: touch }) +
|
|
1611
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Latency is measured hook to hook, so it is the wall-clock an agent actually waited — a call held behind a permission prompt carries that wait too, which is why <b>slowest</b> can be hours and p50/p95 are the numbers to read. <b>errors</b> counts only unambiguous failures: a command that merely prints the word "error" is not one.</p>`;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// M9.7: gate flakiness and cost. A gate that flips on the *same task* told you two different
|
|
1615
|
+
// things about identical work — that is the number worth ranking on, not a raw fail count.
|
|
1616
|
+
function renderGateHealth() {
|
|
1617
|
+
const h = state.gateHealth;
|
|
1618
|
+
const head = (sub) => `<h2>Gates <span>${sub}</span></h2>`;
|
|
1619
|
+
if (!h) { $("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1620
|
+
if (!h.gates.length) {
|
|
1621
|
+
$("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.idle()}No gate runs in the last 30 days${state.sel ? " in this project" : ""}.<br>Gates appear here once <code>swarm_gate_run</code> or a workflow's gate step records one.</div>`;
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const t = h.totals;
|
|
1625
|
+
const secs = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : `${(v / 1000).toFixed(1)}s`);
|
|
1626
|
+
// Oldest-first strip, matching Recent gates on the Board.
|
|
1627
|
+
const strip = (g) => {
|
|
1628
|
+
const rs = [...g.history].reverse();
|
|
1629
|
+
return `<span class="gh" title="last ${rs.length} run${rs.length === 1 ? "" : "s"}, oldest first">${rs.map((r) => `<i class="${r.verdict === "pass" ? "ok" : "bad"}" title="${esc(r.task)} · ${esc(r.at)}${r.durationMs === null ? "" : ` · ${(r.durationMs / 1000).toFixed(1)}s`}"></i>`).join("")}</span>`;
|
|
1630
|
+
};
|
|
1631
|
+
const cols = [
|
|
1632
|
+
{ key: "gate", label: "gate", width: 150, get: (g) => g.gate, cell: (g) => `<b>${esc(g.gate)}</b>${g.flaky ? ' <span class="badge bad" title="This gate returned both a pass and a fail on the same task">Flaky</span>' : ""}` },
|
|
1633
|
+
{ key: "history", label: "history", width: 150, sortable: false, filterable: false, get: () => null, cell: strip },
|
|
1634
|
+
{ key: "runs", label: "runs", width: 60, num: true, get: (g) => g.runs, cell: (g) => g.runs },
|
|
1635
|
+
{ key: "pass", label: "pass rate", width: 84, num: true, get: (g) => g.passRate, cell: (g) => `${Math.round(g.passRate * 100)}%` },
|
|
1636
|
+
{ key: "flips", label: "flips", width: 64, num: true, get: (g) => g.flips, cell: (g) => (g.flips ? `<b class="bad">${g.flips}</b>` : '<span class="dim">0</span>') },
|
|
1637
|
+
{ key: "p50", label: "p50", width: 66, num: true, get: (g) => g.p50Ms ?? -1, cell: (g) => secs(g.p50Ms) },
|
|
1638
|
+
{ key: "p95", label: "p95", width: 66, num: true, get: (g) => g.p95Ms ?? -1, cell: (g) => secs(g.p95Ms) },
|
|
1639
|
+
{ key: "max", label: "slowest", width: 74, num: true, get: (g) => g.maxMs ?? -1, cell: (g) => secs(g.maxMs) },
|
|
1640
|
+
{ key: "total", label: "total", width: 74, num: true, get: (g) => g.totalMs, cell: (g) => (g.timedRuns ? dur(g.totalMs) : '<span class="dim">—</span>') },
|
|
1641
|
+
{ key: "last", label: "last", flex: true, get: (g) => g.lastAt ?? "", cell: (g) => (g.lastAt ? `${g.lastVerdict === "pass" ? '<span class="badge ok">Pass</span>' : '<span class="badge warn">Fail</span>'} <span class="dim">${ago(g.lastAt)}</span>` : '<span class="dim">—</span>') },
|
|
1642
|
+
];
|
|
1643
|
+
const sub = `${t.gates} gate${t.gates === 1 ? "" : "s"} · ${t.runs} run${t.runs === 1 ? "" : "s"} · last 30 days${t.flakyGates ? ` · <b class="navcount">${t.flakyGates} flaky</b>` : " · none flaky"}${t.totalMs ? ` · ${dur(t.totalMs)} of wall-clock` : ""}`;
|
|
1644
|
+
$("#main").innerHTML = head(sub) +
|
|
1645
|
+
dataTable({ id: "gate-health", columns: cols, rows: h.gates, rerender: touch }) +
|
|
1646
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Flaky = the same gate returned both a pass and a fail on one task. A gate that fails on one task and passes on another is doing its job, and is not counted. Durations cover executed gates only — a gate an agent simply recorded has no wall-clock.</p>`;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// M9.8: machine hygiene — what the fleet left behind. Observation plus the two actions that
|
|
1650
|
+
// already exist (stop a process, remove a worktree); nothing here reclaims anything on its own,
|
|
1651
|
+
// and a worktree with uncommitted or unpushed work is never offered as safe.
|
|
1652
|
+
const ISSUE_BADGE = {
|
|
1653
|
+
dead: ["bad", "Dead"], orphaned: ["bad", "Orphaned"], hungry: ["warn", "Hungry"],
|
|
1654
|
+
stale: ["warn", "Stale"], abandoned: ["warn", "Abandoned"], heavy: ["", "Heavy"],
|
|
1655
|
+
};
|
|
1656
|
+
const mb = (kb) => (kb === null || kb === undefined ? '<span class="dim">—</span>' : kb >= 1024 * 1024 ? `${(kb / 1024 / 1024).toFixed(1)} GB` : `${Math.round(kb / 1024)} MB`);
|
|
1657
|
+
const issueBadge = (i) => { const b = ISSUE_BADGE[i]; return b ? `<span class="badge ${b[0]}">${b[1]}</span>` : '<span class="dim">ok</span>'; };
|
|
1658
|
+
function renderHygiene() {
|
|
1659
|
+
const h = state.hygiene;
|
|
1660
|
+
const head = (sub) => `<h2>Hygiene <span>${sub}</span></h2>`;
|
|
1661
|
+
if (!h) { $("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1662
|
+
const t = h.totals;
|
|
1663
|
+
if (!h.processes.length && !h.worktrees.length) {
|
|
1664
|
+
$("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.idle()}Nothing tracked${state.sel ? " in this project" : ""}.<br>Processes started through <code>swarm serve</code> / <code>proc</code> and this machine's worktrees appear here.</div>`;
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const kpi = (l, v, d, cls = "") => `<div class="kpi ${cls}"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1668
|
+
const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
|
|
1669
|
+
// Disk is sampled in the background, so "0 MB" before the first sweep would be a lie — say so.
|
|
1670
|
+
const sampled = h.worktrees.filter((w) => w.diskKb !== null).length;
|
|
1671
|
+
const diskPending = h.worktrees.length > 0 && sampled === 0;
|
|
1672
|
+
const totalDisk = diskPending ? "measuring…" : mb(t.diskKb);
|
|
1673
|
+
const kpis = `<div class="kpis">${
|
|
1674
|
+
kpi("Needs a look", t.issues, t.issues ? "processes + worktrees" : "all clean", t.issues ? "hot" : "")
|
|
1675
|
+
}${kpi("Processes", t.processes, t.orphanedProcesses || t.deadProcesses ? `${t.orphanedProcesses} orphaned · ${t.deadProcesses} dead` : "all healthy", t.orphanedProcesses || t.deadProcesses ? "hot" : "")
|
|
1676
|
+
}${kpi("Worktrees", t.worktrees, t.staleWorktrees ? `${t.staleWorktrees} stale` : "none stale", t.staleWorktrees ? "warm" : "")
|
|
1677
|
+
}${kpi("Reclaimable", diskPending ? '<span class="dim">—</span>' : mb(t.reclaimableKb), diskPending ? `measuring ${h.worktrees.length} worktrees…` : `of ${mb(t.diskKb)} on disk`, !diskPending && t.reclaimableKb ? "warm" : "")}</div>`;
|
|
1678
|
+
|
|
1679
|
+
const pcols = [
|
|
1680
|
+
{ key: "issue", label: "state", width: 96, get: (p) => p.issue ?? "", cell: (p) => issueBadge(p.issue) },
|
|
1681
|
+
{ key: "name", label: "name", width: 130, get: (p) => p.name, cell: (p) => `<b>${esc(p.name)}</b>` },
|
|
1682
|
+
{ key: "kind", label: "kind", width: 64, get: (p) => p.kind, cell: (p) => `<span class="br">${esc(p.kind)}</span>` },
|
|
1683
|
+
{ key: "pid", label: "pid", width: 64, num: true, get: (p) => p.pid, cell: (p) => p.pid },
|
|
1684
|
+
{ key: "port", label: "port", width: 60, num: true, get: (p) => p.port ?? 0, cell: (p) => p.port ?? '<span class="dim">—</span>' },
|
|
1685
|
+
{ key: "cpu", label: "cpu", width: 60, num: true, get: (p) => p.cpuPct ?? -1, cell: (p) => (p.cpuPct === null ? '<span class="dim">—</span>' : `${p.cpuPct.toFixed(0)}%`) },
|
|
1686
|
+
{ key: "rss", label: "memory", width: 78, num: true, get: (p) => p.rssKb ?? -1, cell: (p) => mb(p.rssKb) },
|
|
1687
|
+
{ key: "note", label: "why", flex: true, get: (p) => p.note ?? "", cell: (p) => (p.note ? `<span class="now" title="${esc(p.note)}">${esc(p.note)}</span>` : '<span class="dim">—</span>') },
|
|
1688
|
+
{ key: "act", label: "", width: 70, sortable: false, filterable: false, get: () => null, cell: (p) => (p.reclaimable ? `<a href="#" class="mini-act" data-procstop="${esc(String(p.pid))}" data-procproj="${esc(p.projectId)}" title="Stop this process">Stop</a>` : "") },
|
|
1689
|
+
];
|
|
1690
|
+
const wcols = [
|
|
1691
|
+
{ key: "issue", label: "state", width: 106, get: (w) => w.issue ?? "", cell: (w) => issueBadge(w.issue) },
|
|
1692
|
+
{ key: "branch", label: "branch", width: 190, get: (w) => w.branch ?? w.path, cell: (w) => `<b>${esc(w.branch ?? "(detached)")}</b>${w.main ? ' <span class="badge">main</span>' : ""}` },
|
|
1693
|
+
{ key: "disk", label: "disk", width: 78, num: true, get: (w) => w.diskKb ?? -1, cell: (w) => mb(w.diskKb) },
|
|
1694
|
+
{ key: "idle", label: "untouched", width: 88, num: true, get: (w) => w.idleMs ?? -1, cell: (w) => (w.idleMs === null ? '<span class="dim">—</span>' : dur(w.idleMs)) },
|
|
1695
|
+
{ key: "state2", label: "work", width: 130, 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 ? (w.merged ? '<span class="badge ok">Merged</span>' : '<span class="badge">Clean</span>') : ""}` },
|
|
1696
|
+
{ key: "held", label: "in use", width: 110, get: (w) => w.heldByClaim ?? "", cell: (w) => (w.heldByClaim ? `<span class="br" title="Claimed">${esc(w.heldByClaim)}</span>` : w.liveSessions ? `<span class="badge acc">${w.liveSessions} live</span>` : '<span class="dim">—</span>') },
|
|
1697
|
+
{ key: "note", label: "why", flex: true, get: (w) => w.note ?? "", cell: (w) => (w.note ? `<span class="now" title="${esc(w.note)}">${esc(w.note)}</span>` : '<span class="dim">—</span>') },
|
|
1698
|
+
{ key: "act", label: "", width: 80, sortable: false, filterable: false, get: () => null, cell: (w) => (w.reclaimable ? `<a href="#" class="mini-act bad" data-wtrm="${esc(w.projectId)}:${esc(w.path)}" title="Remove this worktree">Remove</a>` : "") },
|
|
1699
|
+
];
|
|
1700
|
+
const sub = t.issues ? `<b class="navcount">${t.issues} need${t.issues === 1 ? "s" : ""} a look</b>` : "nothing to clean up";
|
|
1701
|
+
$("#main").innerHTML = head(sub) + kpis +
|
|
1702
|
+
`<h2 class="mt-sec">Processes <span>${h.processes.length} tracked · started through swarm, never matched by command pattern</span></h2>` +
|
|
1703
|
+
(h.processes.length ? dataTable({ id: "hyg-procs", columns: pcols, rows: h.processes, rerender: touch }) : `<div class="empty">${PX.idle()}No tracked processes.</div>`) +
|
|
1704
|
+
`<h2 class="mt-sec">Worktrees <span>${h.worktrees.length} on this machine · ${totalDisk}${diskPending ? "" : " on disk"}${sampled && sampled < h.worktrees.length ? ` · ${sampled}/${h.worktrees.length} measured` : ""}</span></h2>` +
|
|
1705
|
+
(h.worktrees.length ? dataTable({ id: "hyg-trees", columns: wcols, rows: h.worktrees, rerender: touch }) : `<div class="empty">${PX.idle()}No worktrees.</div>`) +
|
|
1706
|
+
`<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Only merged worktrees with nothing uncommitted, nothing unpushed and nobody working in them are offered for removal. Anything unmerged is listed but never called safe. Disk is sampled in the background, so sizes fill in a moment after the view opens.</p>`;
|
|
1221
1707
|
}
|
|
1222
1708
|
|
|
1223
1709
|
// M9.12: live file-collision graph — which live sessions touch which files, contested files
|
|
1224
1710
|
// highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
|
|
1225
1711
|
function renderGraphs() {
|
|
1712
|
+
const tab = state.graphTab ?? "collisions";
|
|
1713
|
+
const chip = (k, label, n) => `<span class="chip ${tab === k ? "on" : ""}" data-graphtab="${k}">${label}${n ? ` <b>${n}</b>` : ""}</span>`;
|
|
1714
|
+
const tabs = `<div class="chips">${chip("collisions", "Collisions", state.collisions?.contested ?? 0)}${chip("lineage", "Lineage", state.lineage?.edges?.length ?? 0)}</div>`;
|
|
1715
|
+
const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>${tabs}`;
|
|
1716
|
+
if (tab === "lineage") return renderLineage(head);
|
|
1226
1717
|
const g = state.collisions;
|
|
1227
1718
|
const title = (s) => s.title ?? s.id.slice(0, 8);
|
|
1228
|
-
const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>`;
|
|
1229
1719
|
if (!g || !g.sessions.length) {
|
|
1230
1720
|
$("#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
1721
|
return;
|
|
@@ -1242,6 +1732,30 @@ function renderGraphs() {
|
|
|
1242
1732
|
<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
1733
|
}
|
|
1244
1734
|
|
|
1735
|
+
// M9.13: who started whom, who told whom, who picked up whose work. Every edge is a recorded
|
|
1736
|
+
// relationship — nothing is inferred from timing.
|
|
1737
|
+
const EDGE_LEGEND = [
|
|
1738
|
+
["subagent", "spawned a subagent", "var(--acc)", ""],
|
|
1739
|
+
["dispatch", "dispatched a run", "var(--c3,#5a9e6f)", ""],
|
|
1740
|
+
["message", "sent a message", "var(--warn)", "3 3"],
|
|
1741
|
+
["handoff", "handed the task on", "var(--dim)", "6 3"],
|
|
1742
|
+
];
|
|
1743
|
+
function renderLineage(head) {
|
|
1744
|
+
const g = state.lineage;
|
|
1745
|
+
if (!g) { $("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1746
|
+
if (!g.nodes.length) {
|
|
1747
|
+
$("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.idle()}No relationships between sessions${state.sel ? " in this project" : ""} in the last 14 days.<br>Edges appear when a session spawns a subagent, dispatches a run, messages another agent, or hands a task on.</div>`;
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
const key = EDGE_LEGEND.filter(([k]) => g.byKind[k]).map(([k, label, color, dash]) =>
|
|
1751
|
+
`<span style="display:inline-flex;align-items:center;gap:6px"><svg width="22" height="8" aria-hidden="true"><line x1="0" y1="4" x2="22" y2="4" stroke="${color}" stroke-width="2"${dash ? ` stroke-dasharray="${dash}"` : ""}/></svg><span class="dim" style="font-size:var(--fs-sm)">${label} <b>${g.byKind[k]}</b></span></span>`).join("");
|
|
1752
|
+
const sub = `${g.nodes.length} session${g.nodes.length === 1 ? "" : "s"} · ${g.edges.length} link${g.edges.length === 1 ? "" : "s"} · ${g.roots} root${g.roots === 1 ? "" : "s"} · last 14 days${g.truncated ? ` · <b class="navcount" title="The best-connected ${g.nodes.length} are drawn; the rest would be an unreadable column">${g.truncated} not drawn</b>` : ""}`;
|
|
1753
|
+
$("#main").innerHTML = head(sub) +
|
|
1754
|
+
`<div class="card" style="padding:14px;overflow:auto;max-height:72vh">${viz.dag(g)}</div>
|
|
1755
|
+
<div style="margin-top:10px;display:flex;gap:18px;align-items:center;flex-wrap:wrap">${key}
|
|
1756
|
+
<span class="dim" style="font-size:var(--fs-sm)">a green pill is a collapsed group — click to open it · ring = outcome · thicker dot = more links · a bowed edge closed a loop</span></div>`;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1245
1759
|
function renderTimeline() {
|
|
1246
1760
|
loadTimelineDetail();
|
|
1247
1761
|
const now = Date.now();
|
|
@@ -1291,7 +1805,23 @@ async function openSession(id) {
|
|
|
1291
1805
|
// Rendered log rows, keyed per event seq / turn id (+ the mutable turn fields) so only new rows are formatted.
|
|
1292
1806
|
const rowCache = new Map();
|
|
1293
1807
|
let logRendered = null; // keys of the rows currently in #log, in order — enables append-only updates
|
|
1294
|
-
|
|
1808
|
+
// The kind column showed raw hook names — "pretooluse", "subagentstop" — which are long, repeat on
|
|
1809
|
+
// every row, and say nothing the row does not: a tool row already begins with the tool's name. Short
|
|
1810
|
+
// labels here buy the transcript back ~70px of width per row; the full name stays in the title.
|
|
1811
|
+
const EV_LABEL = {
|
|
1812
|
+
PreToolUse: "tool", PostToolUse: "result", UserPromptSubmit: "you", Stop: "stop",
|
|
1813
|
+
SubagentStart: "sub →", SubagentStop: "sub ←", Notification: "note",
|
|
1814
|
+
SessionStart: "start", SessionEnd: "end", PreCompact: "compact",
|
|
1815
|
+
assistant: "agent", subagent: "sub",
|
|
1816
|
+
// ledger events reach the transcript too, and their dotted type names are the longest of all
|
|
1817
|
+
"incident.opened": "rule", "question.asked": "asks", "question.answered": "answer",
|
|
1818
|
+
"message.sent": "msg", "gate.recorded": "gate", "session.stuck": "stuck",
|
|
1819
|
+
"permission.requested": "perm?", "permission.resolved": "perm",
|
|
1820
|
+
"claim.acquired": "claim", "claim.released": "release", "pr.opened": "pr",
|
|
1821
|
+
};
|
|
1822
|
+
// Anything unmapped keeps its last dotted segment rather than the whole `a.b` name.
|
|
1823
|
+
const evLabel = (k) => EV_LABEL[k] ?? String(k).split(".").at(-1) ?? String(k);
|
|
1824
|
+
const evRow = (i) => `<div class="ev ${i.cls}"><span class="t">${hhmm(i.ts)}</span><span class="k" title="${esc(i.kind)}">${esc(evLabel(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>`;
|
|
1295
1825
|
// Merge the two ts-sorted inputs (events by seq ≈ ts, turns by ts) in one pass → [{key, html}].
|
|
1296
1826
|
function sessionStream() {
|
|
1297
1827
|
const out = [];
|
|
@@ -1369,16 +1899,37 @@ function replayGo(delta) {
|
|
|
1369
1899
|
}
|
|
1370
1900
|
|
|
1371
1901
|
// Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
|
|
1372
|
-
// M7.6: the session's message thread (sent + received) and a compose box.
|
|
1902
|
+
// M7.6: the session's message thread (sent + received) and a compose box. Messages are never an
|
|
1903
|
+
// interrupt: they ride along as context on the agent's next tool call, so the block says so.
|
|
1373
1904
|
function messageThread(s) {
|
|
1374
1905
|
const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
|
|
1906
|
+
const queued = ms.filter((m) => m.fromSession !== s.id && !m.deliveredAt).length;
|
|
1907
|
+
const ended = s.state === "ended";
|
|
1375
1908
|
const row = (m) => {
|
|
1376
1909
|
const out = m.fromSession === s.id;
|
|
1377
1910
|
return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
|
|
1378
1911
|
<span class="msg-f">${out ? `→ ${esc(m.task ?? m.toKind)}` : esc(m.from ?? "?")}${m.deliveredAt ? "" : ' <i class="dim">·queued</i>'}</span>${esc(m.text)}</div>`;
|
|
1379
1912
|
};
|
|
1380
|
-
|
|
1381
|
-
|
|
1913
|
+
const hint = ended
|
|
1914
|
+
? `${ic("warning", 12)} Session ended — there is nothing left to deliver to.`
|
|
1915
|
+
: queued
|
|
1916
|
+
? `${ic("clock", 12)} <b>${queued} queued</b> · delivered the next time this agent calls a tool.`
|
|
1917
|
+
: `${ic("comment-text", 12)} Delivered as context on this agent's next tool call — never an interrupt.`;
|
|
1918
|
+
return `<h4>messages${ms.length ? ` <span class="badge">${ms.length}</span>` : ""}</h4>
|
|
1919
|
+
${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : ""}
|
|
1920
|
+
<div class="msg-compose">
|
|
1921
|
+
<input id="msgText" placeholder="Message this agent…" aria-label="Message this agent" autocomplete="off"${ended ? " disabled" : ""}>
|
|
1922
|
+
<button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}" title="Send (Enter)"${ended ? " disabled" : ""}>${ic("arrow-right", 12)}Send</button>
|
|
1923
|
+
</div>
|
|
1924
|
+
<p class="msg-hint">${hint}</p>`;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// The transcript file, as one copyable row: the directory truncates, the file name always shows.
|
|
1928
|
+
function transcriptRow(s) {
|
|
1929
|
+
if (!s.transcriptPath) return "";
|
|
1930
|
+
const p = short(s.transcriptPath);
|
|
1931
|
+
const cut = p.lastIndexOf("/");
|
|
1932
|
+
return `<h4>transcript</h4><button class="pathrow" data-copy="${esc(s.transcriptPath)}" title="Copy path · ${esc(p)}">${ic("file-text", 12)}<span class="dir">${esc(cut < 0 ? "" : p.slice(0, cut + 1))}</span><b>${esc(cut < 0 ? p : p.slice(cut + 1))}</b>${ic("copy", 12, "cp")}</button>`;
|
|
1382
1933
|
}
|
|
1383
1934
|
|
|
1384
1935
|
// M7.7: questions this session is waiting on a human for
|
|
@@ -1413,6 +1964,8 @@ async function sendStdin() {
|
|
|
1413
1964
|
}
|
|
1414
1965
|
document.addEventListener("click", (ev) => {
|
|
1415
1966
|
if (ev.target.closest("#stdinSend")) return sendStdin();
|
|
1967
|
+
const cp = ev.target.closest("[data-copy]");
|
|
1968
|
+
if (cp) { ev.preventDefault(); copy(cp.dataset.copy); cp.classList.add("copied"); setTimeout(() => cp.classList.remove("copied"), 1000); return; }
|
|
1416
1969
|
const qa = ev.target.closest("[data-qanswer]");
|
|
1417
1970
|
if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
|
|
1418
1971
|
const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
|
|
@@ -1435,9 +1988,9 @@ function renderSession() {
|
|
|
1435
1988
|
const t = s.tokens;
|
|
1436
1989
|
const ctx = t.input + t.cacheRead + t.cacheWrite;
|
|
1437
1990
|
const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
|
|
1438
|
-
const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar",
|
|
1991
|
+
const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar", processed: "rows", started: "clock", "last seen": "eye", "subagent turns": "tree-structure" };
|
|
1439
1992
|
const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
|
|
1440
|
-
const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("
|
|
1993
|
+
const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("arrows-clockwise", 13)} Resume where it died</a>` : ""}</h2>`;
|
|
1441
1994
|
const side = `<div class="stats">
|
|
1442
1995
|
${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
|
|
1443
1996
|
${stat("output", `${tok(t.output)}${t.thinking ? `<small> · ${tok(t.thinking)} thinking</small>` : ""}`)}${stat("processed", `${tok(ctx)}<small> · ${ctx ? ((100 * t.cacheRead) / ctx).toFixed(0) : 0}% cached</small>`)}
|
|
@@ -1449,12 +2002,24 @@ function renderSession() {
|
|
|
1449
2002
|
<h4>tools</h4>${tools.length ? viz.hbars(tools.slice(0, 8).map(([k, v]) => [k.replace(/^mcp__[a-z0-9-]+__/i, ""), v])) : '<span class="dim">None yet</span>'}
|
|
1450
2003
|
${messageThread(s)}
|
|
1451
2004
|
${questionCards(s)}
|
|
1452
|
-
${
|
|
2005
|
+
${transcriptRow(s)}`;
|
|
1453
2006
|
if (logEl && isAppend(rows)) {
|
|
1454
2007
|
// Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
|
|
1455
2008
|
// scroll position (and its DOM) untouched.
|
|
1456
2009
|
$("#main > h2").outerHTML = head;
|
|
2010
|
+
// The message compose box lives inside .side, and this fast-path runs on every event while the
|
|
2011
|
+
// agent works — carry the draft (and the caret) across the swap instead of wiping what is
|
|
2012
|
+
// being typed.
|
|
2013
|
+
const msg = $("#msgText");
|
|
2014
|
+
const draft = msg?.value ? { v: msg.value, focused: document.activeElement === msg, pos: msg.selectionStart } : null;
|
|
1457
2015
|
$("#main .side").innerHTML = side;
|
|
2016
|
+
if (draft) {
|
|
2017
|
+
const el = $("#msgText");
|
|
2018
|
+
if (el) {
|
|
2019
|
+
el.value = draft.v;
|
|
2020
|
+
if (draft.focused) { el.focus(); el.setSelectionRange(draft.pos, draft.pos); }
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
1458
2023
|
const sb = stdinBox(s); const cur = $("#main .stdin");
|
|
1459
2024
|
if (cur && cur.outerHTML !== sb && document.activeElement?.id !== "stdinText") cur.outerHTML = sb;
|
|
1460
2025
|
else if (!cur && sb) $("#main").insertAdjacentHTML("beforeend", sb);
|
|
@@ -1614,7 +2179,7 @@ function menuSpec(kind, d) {
|
|
|
1614
2179
|
if (!p) return null;
|
|
1615
2180
|
const green = p.checks !== "fail" && p.mergeable && !p.draft;
|
|
1616
2181
|
return { title: `#${p.number}`, items: [
|
|
1617
|
-
{ label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () =>
|
|
2182
|
+
{ label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => openExternal(p.url) },
|
|
1618
2183
|
{ label: "Copy URL", icon: "copy", run: () => copy(p.url) },
|
|
1619
2184
|
{ divider: true },
|
|
1620
2185
|
{ label: "Squash-merge", icon: "git-pull-request", disabled: !green, caption: green ? (p.forge === "gitlab" ? "glab" : "gh") : p.draft ? "draft" : p.checks === "fail" ? "checks failing" : "not mergeable", run: () => act.merge(p.projectId, p.number) },
|
|
@@ -1656,8 +2221,8 @@ function menuSpec(kind, d) {
|
|
|
1656
2221
|
{ divider: true },
|
|
1657
2222
|
{ label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
|
|
1658
2223
|
{ label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
|
|
1659
|
-
{ label: "Documentation", icon: "book-open", caption: "getswarm", run: () =>
|
|
1660
|
-
{ label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () =>
|
|
2224
|
+
{ label: "Documentation", icon: "book-open", caption: "getswarm", run: () => openExternal("https://getswarm.vercel.app/docs/") },
|
|
2225
|
+
{ label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => openExternal(feedbackUrl()) },
|
|
1661
2226
|
] };
|
|
1662
2227
|
}
|
|
1663
2228
|
return null;
|
|
@@ -1710,9 +2275,13 @@ ${p.reason ?? ""}`.slice(0, 180);
|
|
|
1710
2275
|
// What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
|
|
1711
2276
|
// The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
|
|
1712
2277
|
// itself once after an upgrade (localStorage remembers the last version the user saw).
|
|
1713
|
-
|
|
2278
|
+
// `strict` matters: the automatic post-upgrade panel must never fall back. Falling back showed
|
|
2279
|
+
// 0.10.0's notes under a "What's New" triggered by upgrading to 0.11.0 — the notes bundle was a
|
|
2280
|
+
// stale cached copy that had no 0.11.0 in it, and the fallback quietly hid that.
|
|
2281
|
+
function releaseNotesFor(version, { strict = false } = {}) {
|
|
1714
2282
|
const all = window.RELEASE_NOTES || {};
|
|
1715
2283
|
if (version && all[version]) return { version, ...all[version] };
|
|
2284
|
+
if (strict) return null;
|
|
1716
2285
|
const latest = Object.keys(all)[0];
|
|
1717
2286
|
return latest ? { version: latest, ...all[latest] } : null;
|
|
1718
2287
|
}
|
|
@@ -1741,9 +2310,12 @@ function maybeUpdateNudge(h) {
|
|
|
1741
2310
|
<div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
|
|
1742
2311
|
document.body.appendChild(el);
|
|
1743
2312
|
el.addEventListener("click", async (e) => {
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
e.target.
|
|
2313
|
+
// closest(), not e.target.id: the button holds an <svg> icon, so a click on the glyph itself
|
|
2314
|
+
// targets the svg/path and an id check would miss it.
|
|
2315
|
+
const btn = e.target.closest?.("button");
|
|
2316
|
+
if (btn?.id === "updLater") return el.remove();
|
|
2317
|
+
if (btn?.id !== "updRestart") return;
|
|
2318
|
+
btn.textContent = "restarting…";
|
|
1747
2319
|
await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
|
|
1748
2320
|
const t0 = Date.now();
|
|
1749
2321
|
const wait = setInterval(async () => {
|
|
@@ -1760,7 +2332,7 @@ function maybeWhatsNew() {
|
|
|
1760
2332
|
let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
|
|
1761
2333
|
if (seen === state.version) return;
|
|
1762
2334
|
if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
|
|
1763
|
-
if (releaseNotesFor(state.version)) whatsNew(state.version);
|
|
2335
|
+
if (releaseNotesFor(state.version, { strict: true })) whatsNew(state.version);
|
|
1764
2336
|
}
|
|
1765
2337
|
|
|
1766
2338
|
// Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
|
|
@@ -1785,7 +2357,7 @@ function maybeStarNudge() {
|
|
|
1785
2357
|
el.addEventListener("click", (ev) => {
|
|
1786
2358
|
const t = ev.target.closest("[data-star]"); if (!t) return;
|
|
1787
2359
|
ev.preventDefault();
|
|
1788
|
-
if (t.dataset.star === "go") { starSave({ done: now });
|
|
2360
|
+
if (t.dataset.star === "go") { starSave({ done: now }); openExternal(REPO_URL); }
|
|
1789
2361
|
else if (t.dataset.star === "never") starSave({ never: now });
|
|
1790
2362
|
el.remove();
|
|
1791
2363
|
});
|
|
@@ -1807,6 +2379,11 @@ function openMenu(kind, anchor, d) {
|
|
|
1807
2379
|
const spec = menuSpec(kind, d);
|
|
1808
2380
|
if (!spec) return;
|
|
1809
2381
|
if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
|
|
2382
|
+
// Once the menu is up the pointer is over *it*, not the row, so a :hover-only kebab vanishes
|
|
2383
|
+
// under its own menu. Mark the row (and the kebab) until menus:openchange reports the close.
|
|
2384
|
+
if (anchor?.closest) {
|
|
2385
|
+
for (const el of [anchor.closest(".proj"), anchor.closest("tr"), anchor.closest(".more")]) el?.classList.add("menu-open");
|
|
2386
|
+
}
|
|
1810
2387
|
window.menus.open(anchor, spec);
|
|
1811
2388
|
}
|
|
1812
2389
|
document.addEventListener("keydown", (e) => {
|
|
@@ -1832,12 +2409,12 @@ document.addEventListener("contextmenu", (ev) => {
|
|
|
1832
2409
|
// unreachable (closest() returns null and the click dies silently) — that is how Replay,
|
|
1833
2410
|
// Resume-where-it-died and the dry-run Re-run button all shipped dead.
|
|
1834
2411
|
document.addEventListener("click", async (ev) => {
|
|
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");
|
|
2412
|
+
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-graphtab],[data-group],[data-provpage],#abNew,[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");
|
|
1836
2413
|
if (!t) return;
|
|
1837
2414
|
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
1838
2415
|
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
1839
|
-
if (t.id === "feedback") { ev.preventDefault(); return
|
|
1840
|
-
if (t.dataset.view) { ev.preventDefault();
|
|
2416
|
+
if (t.id === "feedback") { ev.preventDefault(); return openExternal(feedbackUrl()); }
|
|
2417
|
+
if (t.dataset.view) { ev.preventDefault(); return showView(t.dataset.view); }
|
|
1841
2418
|
if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
|
|
1842
2419
|
if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
|
|
1843
2420
|
if (t.dataset.emoji !== undefined) { $("#psIcon").value = t.dataset.emoji; $("#psImage").value = ""; setIconPreview(t.dataset.emoji); for (const e of $$(".emoji")) e.classList.toggle("on", e.dataset.emoji === t.dataset.emoji); return; }
|
|
@@ -1918,6 +2495,37 @@ document.addEventListener("click", async (ev) => {
|
|
|
1918
2495
|
if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
|
|
1919
2496
|
if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
|
|
1920
2497
|
if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
|
|
2498
|
+
if (t.id === "abNew") {
|
|
2499
|
+
ev.preventDefault();
|
|
2500
|
+
if (!state.sel) return;
|
|
2501
|
+
const task = prompt("Task id to trial (each arm claims task#model, so each gets its own worktree):");
|
|
2502
|
+
if (!task) return;
|
|
2503
|
+
const models = prompt("Models to compare, comma separated:", "opus-5, sonnet-5");
|
|
2504
|
+
const arms = (models ?? "").split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ model: m, label: m }));
|
|
2505
|
+
if (arms.length < 2) { alert("A trial needs at least two models."); return; }
|
|
2506
|
+
const r = await fetch("/v1/ab", {
|
|
2507
|
+
method: "POST",
|
|
2508
|
+
headers: { "content-type": "application/json" },
|
|
2509
|
+
body: JSON.stringify({ projectId: state.sel, task: task.trim(), arms }),
|
|
2510
|
+
}).then((x) => x.json()).catch(() => null);
|
|
2511
|
+
if (!r) return alert("Could not reach the daemon.");
|
|
2512
|
+
if (r?.failed?.length) alert(`Started ${r.started.length}. Could not start: ${r.failed.map((f) => `${f.arm} — ${f.reason}`).join("; ")}`);
|
|
2513
|
+
return refresh();
|
|
2514
|
+
}
|
|
2515
|
+
if (t.dataset.provpage) {
|
|
2516
|
+
ev.preventDefault();
|
|
2517
|
+
if (t.classList.contains("off")) return;
|
|
2518
|
+
state.provOffset = Number(t.dataset.provpage);
|
|
2519
|
+
return refresh();
|
|
2520
|
+
}
|
|
2521
|
+
if (t.dataset.group) {
|
|
2522
|
+
ev.preventDefault();
|
|
2523
|
+
const open = new Set(state.lineageOpen ?? []);
|
|
2524
|
+
open.has(t.dataset.group) ? open.delete(t.dataset.group) : open.add(t.dataset.group);
|
|
2525
|
+
state.lineageOpen = [...open];
|
|
2526
|
+
return refresh();
|
|
2527
|
+
}
|
|
2528
|
+
if (t.dataset.graphtab) { state.graphTab = t.dataset.graphtab; localStorage.setItem("swarm.graphTab", state.graphTab); return refresh(); }
|
|
1921
2529
|
if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
|
|
1922
2530
|
if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
|
|
1923
2531
|
if (t.dataset.ackall) {
|
|
@@ -1971,7 +2579,7 @@ sbApply();
|
|
|
1971
2579
|
// ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
|
|
1972
2580
|
const pal = { items: [], view: [], q: "", i: 0 };
|
|
1973
2581
|
function palBuild() {
|
|
1974
|
-
const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () =>
|
|
2582
|
+
const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => showView(v.id) }));
|
|
1975
2583
|
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
2584
|
const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
|
|
1977
2585
|
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) });
|
|
@@ -2067,21 +2675,49 @@ const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊"
|
|
|
2067
2675
|
// (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
|
|
2068
2676
|
const EMOJI_BLOCKS = [["Smileys & people", 0x1f600, 0x1f64f], ["Gestures & body", 0x1f440, 0x1f4ff], ["Animals & nature", 0x1f400, 0x1f43f], ["Food", 0x1f32d, 0x1f37f], ["Activity & travel", 0x1f680, 0x1f6ff], ["Objects", 0x1f4a0, 0x1f4ff], ["Symbols", 0x1f300, 0x1f32c], ["More", 0x1f900, 0x1f9ff], ["Extended", 0x1fa70, 0x1faff], ["Misc", 0x2600, 0x26ff], ["Dingbats", 0x2700, 0x27bf]];
|
|
2069
2677
|
let emojiGrid = null;
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2678
|
+
// Which code points the platform font actually draws in colour is a per-machine answer, so it is
|
|
2679
|
+
// probed once and remembered. Two things made that probe cost ~150ms of blocked main thread:
|
|
2680
|
+
// it called getImageData once per code point (1536 GPU->CPU readbacks), and the blocks overlap,
|
|
2681
|
+
// so 96 code points were probed — and rendered — twice. Now it is one readback per block over a
|
|
2682
|
+
// grid of glyphs, deduped, and the answer is cached across reloads.
|
|
2683
|
+
const EMOJI_CACHE_KEY = "swarm.emoji.v1";
|
|
2684
|
+
function detectEmoji(a, b) {
|
|
2685
|
+
const S = 20, COLS = 32, n = b - a + 1, rows = Math.ceil(n / COLS);
|
|
2686
|
+
const cv = document.createElement("canvas");
|
|
2687
|
+
cv.width = COLS * S; cv.height = rows * S;
|
|
2074
2688
|
const c = cv.getContext("2d", { willReadFrequently: true });
|
|
2075
2689
|
c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2690
|
+
for (let i = 0; i < n; i++) c.fillText(String.fromCodePoint(a + i), (i % COLS) * S, ((i / COLS) | 0) * S);
|
|
2691
|
+
const d = c.getImageData(0, 0, cv.width, cv.height).data, W = cv.width, out = [];
|
|
2692
|
+
// A code point counts as an emoji the platform can draw if its cell paints coloured pixels.
|
|
2693
|
+
for (let i = 0; i < n; i++) {
|
|
2694
|
+
const x0 = (i % COLS) * S, y0 = ((i / COLS) | 0) * S;
|
|
2695
|
+
let ok = false;
|
|
2696
|
+
for (let y = y0; y < y0 + S && !ok; y++)
|
|
2697
|
+
for (let x = x0; x < x0 + S; x++) {
|
|
2698
|
+
const p = (y * W + x) * 4;
|
|
2699
|
+
if (d[p + 3] > 40 && (Math.abs(d[p] - d[p + 1]) > 24 || Math.abs(d[p + 1] - d[p + 2]) > 24)) { ok = true; break; }
|
|
2700
|
+
}
|
|
2701
|
+
if (ok) out.push(String.fromCodePoint(a + i));
|
|
2702
|
+
}
|
|
2703
|
+
return out;
|
|
2704
|
+
}
|
|
2705
|
+
function buildEmojiGrid() {
|
|
2706
|
+
if (emojiGrid) return emojiGrid;
|
|
2707
|
+
// The cache is keyed by the UA (a font change is what would invalidate it) plus the block list.
|
|
2708
|
+
const sig = `${navigator.userAgent}|${EMOJI_BLOCKS.map((x) => x.join(":")).join(",")}`;
|
|
2709
|
+
let blocks = null;
|
|
2710
|
+
try {
|
|
2711
|
+
const hit = JSON.parse(localStorage.getItem(EMOJI_CACHE_KEY) ?? "null");
|
|
2712
|
+
if (hit?.sig === sig) blocks = hit.blocks;
|
|
2713
|
+
} catch { /* corrupt or unavailable cache: probe again */ }
|
|
2714
|
+
if (!blocks) {
|
|
2715
|
+
const seen = new Set();
|
|
2716
|
+
blocks = EMOJI_BLOCKS.map(([, a, b]) => detectEmoji(a, b).filter((e) => !seen.has(e) && seen.add(e)));
|
|
2717
|
+
try { localStorage.setItem(EMOJI_CACHE_KEY, JSON.stringify({ sig, blocks })); } catch { /* private mode / quota */ }
|
|
2718
|
+
}
|
|
2719
|
+
emojiGrid = EMOJI_BLOCKS.map(([name], i) => {
|
|
2720
|
+
const list = blocks[i] ?? [];
|
|
2085
2721
|
return list.length ? `<div class="emoji-sec">${esc(name)}</div><div class="emoji-row">${list.map((e) => `<span class="emoji" data-emoji="${e}">${e}</span>`).join("")}</div>` : "";
|
|
2086
2722
|
}).join("");
|
|
2087
2723
|
return emojiGrid;
|
|
@@ -2235,7 +2871,7 @@ async function submitPr() {
|
|
|
2235
2871
|
closePicker();
|
|
2236
2872
|
state.prs = [];
|
|
2237
2873
|
await refresh();
|
|
2238
|
-
if (r.url)
|
|
2874
|
+
if (r.url) openExternal(r.url);
|
|
2239
2875
|
}
|
|
2240
2876
|
|
|
2241
2877
|
async function openPicker(focusPath = false) {
|
|
@@ -2313,7 +2949,7 @@ function connect() {
|
|
|
2313
2949
|
if (fresh) notifyForEvent(ev);
|
|
2314
2950
|
pollSoon();
|
|
2315
2951
|
};
|
|
2316
|
-
for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
|
|
2952
|
+
for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved", "session.stuck"]) es.addEventListener(t, onAny);
|
|
2317
2953
|
}
|
|
2318
2954
|
refresh().then(() => {
|
|
2319
2955
|
const sid = new URLSearchParams(location.search).get("session");
|