@ra3orblade/swarm 0.12.1 → 0.13.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/dist/swarm-hook.js +9 -5
- package/dist/swarmd.js +127 -59
- package/package.json +1 -1
- package/web/dashboard.css +1 -0
- package/web/dashboard.js +28 -0
- package/web/favicon.ico +0 -0
- package/web/favicon.svg +1 -0
- package/web/icons.js +1 -0
- package/web/index.html +7 -763
- package/web/release-notes.js +1 -1
- package/web/table.js +4 -1
- package/web/viz.js +4 -1
- package/web/app.js +0 -3508
package/web/app.js
DELETED
|
@@ -1,3508 +0,0 @@
|
|
|
1
|
-
const $ = (s) => document.querySelector(s);
|
|
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
|
-
});
|
|
18
|
-
// M8.2b daemon token: `swarm ui` (and the desktop app) open the dashboard with ?token=…; it is kept
|
|
19
|
-
// in sessionStorage, stripped from the URL, and sent on every /v1 request. Loopback without a token
|
|
20
|
-
// still works while `[daemon] auth = "loopback-optional"`.
|
|
21
|
-
const TOKEN = (() => {
|
|
22
|
-
const q = new URLSearchParams(location.search);
|
|
23
|
-
const t = q.get("token");
|
|
24
|
-
if (t) { try { sessionStorage.setItem("swarm.token", t); } catch {} q.delete("token"); history.replaceState(null, "", `${location.pathname}${q.size ? `?${q}` : ""}${location.hash}`); return t; }
|
|
25
|
-
try { return sessionStorage.getItem("swarm.token"); } catch { return null; }
|
|
26
|
-
})();
|
|
27
|
-
if (TOKEN) {
|
|
28
|
-
const rawFetch = window.fetch.bind(window);
|
|
29
|
-
window.fetch = (input, init = {}) => {
|
|
30
|
-
const url = typeof input === "string" ? input : input.url;
|
|
31
|
-
if (!url.startsWith("/v1/")) return rawFetch(input, init);
|
|
32
|
-
const headers = new Headers(init.headers || {});
|
|
33
|
-
headers.set("authorization", `Bearer ${TOKEN}`);
|
|
34
|
-
return rawFetch(input, { ...init, headers });
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
// macOS desktop app signals its overlay title bar via ?chrome=inset (see src-tauri/lib.rs).
|
|
38
|
-
if (new URLSearchParams(location.search).get("chrome") === "inset") {
|
|
39
|
-
document.documentElement.classList.add("chrome-inset");
|
|
40
|
-
// The overlay title bar has no native drag region — drag the window from the header.
|
|
41
|
-
const twin = () => window.__TAURI__?.window?.getCurrentWindow?.();
|
|
42
|
-
const inert = (e) => e.target.closest("a,button,input,select");
|
|
43
|
-
const hdr = document.querySelector("header");
|
|
44
|
-
hdr?.addEventListener("mousedown", (e) => {
|
|
45
|
-
if (e.button === 0 && !inert(e)) twin()?.startDragging?.();
|
|
46
|
-
});
|
|
47
|
-
hdr?.addEventListener("dblclick", (e) => {
|
|
48
|
-
if (!inert(e)) twin()?.toggleMaximize?.();
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
// UI zoom. The browser zooms natively; the desktop webview doesn't, so the app does it itself:
|
|
52
|
-
// ⌘/Ctrl + − 0 here (and the native View menu in src-tauri/lib.rs, which calls swarmZoom).
|
|
53
|
-
const ZOOM_STEPS = [0.7, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
|
|
54
|
-
const isDesktop = () => Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
|
|
55
|
-
let lastZoomAt = 0;
|
|
56
|
-
window.swarmZoom = (dir) => {
|
|
57
|
-
const now = Date.now();
|
|
58
|
-
if (now - lastZoomAt < 80) return; // a native accelerator and the keydown can both fire — once is enough
|
|
59
|
-
lastZoomAt = now;
|
|
60
|
-
const cur = Number(localStorage.getItem("swarm.zoom")) || 1;
|
|
61
|
-
let z = 1;
|
|
62
|
-
if (dir !== 0) {
|
|
63
|
-
const i = ZOOM_STEPS.findIndex((v) => Math.abs(v - cur) < 0.01);
|
|
64
|
-
z = ZOOM_STEPS[Math.max(0, Math.min(ZOOM_STEPS.length - 1, (i < 0 ? 3 : i) + dir))];
|
|
65
|
-
}
|
|
66
|
-
localStorage.setItem("swarm.zoom", String(z));
|
|
67
|
-
document.documentElement.style.setProperty("--ui-zoom", String(z));
|
|
68
|
-
document.documentElement.classList.toggle("zoomed", z !== 1);
|
|
69
|
-
};
|
|
70
|
-
{
|
|
71
|
-
const z = Number(localStorage.getItem("swarm.zoom")) || 1;
|
|
72
|
-
if (z !== 1) { document.documentElement.style.setProperty("--ui-zoom", String(z)); document.documentElement.classList.add("zoomed"); }
|
|
73
|
-
}
|
|
74
|
-
document.addEventListener("keydown", (ev) => {
|
|
75
|
-
if (!isDesktop() || !(ev.metaKey || ev.ctrlKey) || ev.altKey) return;
|
|
76
|
-
const k = ev.key;
|
|
77
|
-
const dir = k === "=" || k === "+" ? 1 : k === "-" || k === "_" ? -1 : k === "0" ? 0 : null;
|
|
78
|
-
if (dir === null) return;
|
|
79
|
-
ev.preventDefault();
|
|
80
|
-
window.swarmZoom(dir);
|
|
81
|
-
});
|
|
82
|
-
// `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
|
|
83
|
-
const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, dispatch: null, questions: [], budget: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, collisions: null, outcomes: null, dirty: true };
|
|
84
|
-
|
|
85
|
-
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
86
|
-
const ago = (iso) => { const d = (Date.now() - new Date(iso)) / 1000; return d < 60 ? `${d | 0}s` : d < 3600 ? `${(d / 60) | 0}m` : d < 86400 ? `${(d / 3600) | 0}h` : `${(d / 86400) | 0}d`; };
|
|
87
|
-
// p2 (zero-pad) is defined in viz.js, which loads first
|
|
88
|
-
const hhmm = (iso) => { const d = new Date(iso); return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`; };
|
|
89
|
-
/** The project's glyph: its emoji icon, or the folder icon, tinted with its color slot. */
|
|
90
|
-
const projGlyph = (p, size = 14) => p?.icon
|
|
91
|
-
? `<span class="pg ${p.color ? `pg-${p.color}` : ""}">${p.icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(p.icon)}" alt="">` : esc(p.icon)}</span>`
|
|
92
|
-
: `<span class="pg ${p?.color ? `pg-${p.color}` : ""}">${ic("folder-simple", size)}</span>`;
|
|
93
|
-
/** Project cell for tables: glyph + name. */
|
|
94
|
-
const projCell = (id) => { const p = state.projects.find((x) => x.id === id); return p ? `${projGlyph(p, 12)} ${esc(p.name)}` : esc(projName(id)); };
|
|
95
|
-
const projName = (id) => state.projects.find((p) => p.id === id)?.name ?? (id === "p_unknown" ? "?" : "(removed)");
|
|
96
|
-
const short = (p) => String(p ?? "").replace(/^\/Users\/[^/]+/, "~");
|
|
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));
|
|
103
|
-
const usd = (n) => (n == null ? '<span class="dim">—</span>' : `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`);
|
|
104
|
-
const model = (m) => (m ? m.replace(/^claude-/, "").replace(/-\d{8}$/, "") : "");
|
|
105
|
-
const sumBy = (arr, f) => arr.reduce((a, x) => a + (f(x) ?? 0), 0);
|
|
106
|
-
const leaseLeft = (iso) => { const d = (new Date(iso) - Date.now()) / 1000; if (d <= 0) return "expired"; return d < 3600 ? `${(d / 60) | 0}m left` : `${(d / 3600).toFixed(1)}h left`; };
|
|
107
|
-
const ic = (name, size = 14, cls = "") => (window.icon ? window.icon(name, size, cls) : "");
|
|
108
|
-
const kindIcon = (s) => ic(s.kind === "subagent" ? "tree-structure" : s.kind === "spawned" ? "play" : "keyboard", 13, "kind");
|
|
109
|
-
// pixel-art illustrations for empty states (crispEdges, theme-green; won't clash with icon packs)
|
|
110
|
-
function pixmap(rows, cell = 6) {
|
|
111
|
-
// Every tone is derived from the accent, so they are guaranteed to separate in either theme.
|
|
112
|
-
// An earlier 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
|
-
// Keep in step with ART_THEME in core/src/art.ts; art.test.ts asserts they match.
|
|
115
|
-
const C = {
|
|
116
|
-
O: "color-mix(in srgb, var(--acc) 40%, black)",
|
|
117
|
-
K: "color-mix(in srgb, var(--acc) 51%, black)",
|
|
118
|
-
D: "color-mix(in srgb, var(--acc) 58%, black)",
|
|
119
|
-
S: "color-mix(in srgb, var(--acc) 71%, black)",
|
|
120
|
-
E: "color-mix(in srgb, var(--acc) 84%, black)",
|
|
121
|
-
M: "var(--acc)",
|
|
122
|
-
L: "color-mix(in srgb, var(--acc) 52%, white)",
|
|
123
|
-
};
|
|
124
|
-
const w = Math.max(...rows.map((r) => r.length)) * cell;
|
|
125
|
-
const h = rows.length * cell;
|
|
126
|
-
let r = "";
|
|
127
|
-
rows.forEach((row, y) => {
|
|
128
|
-
for (let x = 0; x < row.length; x++) {
|
|
129
|
-
const f = C[row[x]];
|
|
130
|
-
if (f) r += `<rect x="${x * cell}" y="${y * cell}" width="${cell}" height="${cell}" fill="${f}"/>`;
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
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>`;
|
|
134
|
-
}
|
|
135
|
-
const PX = {
|
|
136
|
-
// Inline copy of ROBOT in core/src/art.ts — app.js is a plain script and cannot import from
|
|
137
|
-
// core, so art.test.ts asserts the two are byte-identical rather than trusting anyone to
|
|
138
|
-
// remember. Edit the drawing there and re-run `bun tools/icons.ts`, never edit it here.
|
|
139
|
-
idle: () =>
|
|
140
|
-
pixmap(
|
|
141
|
-
[
|
|
142
|
-
" MM MM ",
|
|
143
|
-
" LL LL ",
|
|
144
|
-
" LL LL ",
|
|
145
|
-
" LM MM ",
|
|
146
|
-
" MD MS ",
|
|
147
|
-
" LE LE ",
|
|
148
|
-
" ME ME ",
|
|
149
|
-
" MS MS ",
|
|
150
|
-
" KK KK ",
|
|
151
|
-
" SSDDDSDDDDDDDDDDDDDDDDDDDDDDDDDDDDS ",
|
|
152
|
-
" SSDSDDDDDDDDDDDDDDDDDDDDDDDDDDDDDSS ",
|
|
153
|
-
" SSDOOOOOOOOOOOOOOOOOOOOOOOOOOOOOKSS ",
|
|
154
|
-
" SSOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMOSS ",
|
|
155
|
-
" DDKMMLLLMLLMMMMMMMMMMMMMMMMMMMMMODD ",
|
|
156
|
-
" DDOMMLMMMMMMMMMMMMMMMMMMMMMMMMMMODD ",
|
|
157
|
-
" SSSSODDOMMLMMMMMMMMMMMMMMMMMMMMMMMMMMODDOSSSS ",
|
|
158
|
-
" SSSSODDOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMODDOSSSS ",
|
|
159
|
-
" SMMMMODDOMMLMMMMMMMMMMMMMMMMMMMMMMMMMMODDOSMMMS ",
|
|
160
|
-
" DMSSSODDOMMMMOOOOOOMMMMMMMMMOOOOOOMMMMODDOSSSED ",
|
|
161
|
-
" DSSSSODDOMMMMODDDDDMMMMMMMMMODDDDDMMMMODDOSSSSK ",
|
|
162
|
-
" DSMMSODDOMMMMDDDDDDMMMMMMMMMKDDDDDMMMMODDOSMMSK ",
|
|
163
|
-
" DSSSSODDOMMMMDDDDDDMMMMMMMMMDDDDDDMMMMODDOSSSSK ",
|
|
164
|
-
" DSSSSODDOMMMMDDDDDDMMMMMMMMMDDDDDDMMMMODDODSSSK ",
|
|
165
|
-
" DSSSSODDOMMMMDDDDDDMMMMMMMMMDDDDDDMMMMODDODSSSK ",
|
|
166
|
-
" DSSSKODDOMMMMDDDDDDMMMMMMMMMDDDDDDMMMMODDODSSSD ",
|
|
167
|
-
" OKKKKODDOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMODDOKKKKO ",
|
|
168
|
-
" KKKKODDOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMODDOOKKK ",
|
|
169
|
-
" KKKOODDOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMODDOOKKK ",
|
|
170
|
-
" DDOMMMMDDDDDDDDDDDDDDDDDDDDDMMMMODD ",
|
|
171
|
-
" DDOMMMMDDDDDDDDDDDDDDDDDDDDDMMMMODD ",
|
|
172
|
-
" DDOMMMMDDDDDDDDDDDDDDDDDDDDDMMMMODD ",
|
|
173
|
-
" DDOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMODD ",
|
|
174
|
-
" DDOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMODD ",
|
|
175
|
-
" DDOMMMMMMMMMMMMMMMMMMMMMMMMMMMMMODD ",
|
|
176
|
-
" DDDOOOOOOOOOOOOOOOOOOOOOOOOOOOOOKDD ",
|
|
177
|
-
" DDDDDDDDDDDDDDDDDDDDDDDDDDDDDKDDDDD ",
|
|
178
|
-
" DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD ",
|
|
179
|
-
" OOOOOOOOOOO ",
|
|
180
|
-
" OODDDDDKKOO ",
|
|
181
|
-
" DSMMMMMMSDD ",
|
|
182
|
-
" OOOOOOOOOOO ",
|
|
183
|
-
" KDSMMMMMESSKK ",
|
|
184
|
-
" MMMMMMSSKOOKDSMMMMMSSSDKOODSEMMMMMM ",
|
|
185
|
-
" SSSSDO MMMMMMSSKKOOOOOOOOOOOOOOOOOKSSSMMMMMM ODSSSS ",
|
|
186
|
-
" EEESSSSOOLLLLMMMMMESSSSSSSSSSSSSSSSSSSSMMMMMLLLLOOSSSSMME ",
|
|
187
|
-
" SMMSSSSKOEELLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLEEOOSSSSEMS ",
|
|
188
|
-
" DSSSSSSSOOSEEKSMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMSOEEDOOSSSSSSSK ",
|
|
189
|
-
" DSSSSSSDOOSEOSKEMMMMMMMMMMMMMMMMMMMMMMMMMMMMMEOSDEDOODSSSSSSK ",
|
|
190
|
-
" KSSSSSSKOODSSOSEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEESOEEDOOKSSSSSSK ",
|
|
191
|
-
" DKSSSSKKOODSMEEMLLLLLLLLLLLLLLLLLLLLLLLLLLLLLMSEMSDOOKKSSSSDK ",
|
|
192
|
-
" OKDDKDKKOOKSMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMSKOOKKKKKKKO ",
|
|
193
|
-
" OOOOOKKKOOKSMMMMESSSSSSSSSSSSSMMMMEEEEEESSSSEMMMMSKOOKKKOOOOO ",
|
|
194
|
-
" OOOOOOKKOOKSMMMSSKKKKKKKKKKKKKKMMMDKOOOOOOOODDMMMSKOOKKOOOOOO ",
|
|
195
|
-
" OKSSKOOOOOKSMMMKKMLLLLLLLLLLLMKMMMDDSSSSSSSSDDMMMSKOOOOODSSKO ",
|
|
196
|
-
" SDOKKK KSMMMKDLLLLLLLLLLLLLKMMMKDKKKDDKKKDKMMMSK KKKKDS ",
|
|
197
|
-
" DMLLMKO KSMMMKDLLLLLLLLLLLLLKMMMDDDDDDDDDSKDMMMSK ODMLLMD ",
|
|
198
|
-
" DELLMSD KSMMMKKMLLLLLLLLLLLLKMMMDKKKKKKKKKDDMMMSK DSMLLEO ",
|
|
199
|
-
" KSKKKKDO KSMMMEDDDDKKDDDKKKDKSMMMDDDDDDDDDSDDMMMSK ODDKKKSK ",
|
|
200
|
-
" EMLLMDO KSMMMMLLLLLLLLLLLLLLLMMMDOOOOOOOOODDMMMSK ODMLLME ",
|
|
201
|
-
" KMLLMSD KSMMMMMMMMMMMMMMMMMMMMMMDDSSDDSSSSDDMMMSK SSMLLEK ",
|
|
202
|
-
" KSKKKKSO KSMMMMMOOMMOOMKOEMOOMMMMDOOOOOOOOODDMMMSK OSKKKKSO ",
|
|
203
|
-
" SMLLMDO KSMMMMMKKMMKKMDKEMKKMMMMKDDDDSSSSDDDMMMSK ODMLLMS ",
|
|
204
|
-
" SMMMMSD KSMMMMMEEMMEEMEEMMEEMMMMDSOOOOOOOODDMMSSK DSMMMMD ",
|
|
205
|
-
" OOOOOOO KSSMMMMMMMMMMMMMMMMMMMMMDDDDDDDDDDDDMSSSK OOOOOOO ",
|
|
206
|
-
" KSMLLLMMSKO KSSSSMMMMMMMMMMMMMMMMMMMSOOOOOOOOOOSSSSSK OKSMLLLLMSO ",
|
|
207
|
-
" DSMMMEEESKK KSSKSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSOSSK KDSMMMMMESD ",
|
|
208
|
-
" DSMMMMMESKK KSOOKSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSOOKSK KKSMMMMMMSD ",
|
|
209
|
-
" DSMMMMMESKK OKSKSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDSKO KDSMMMMMMSD ",
|
|
210
|
-
" KDDDDDDDDKK KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKK KKDDDDDDDDO ",
|
|
211
|
-
" OOOOOOOOOOO OOOOOOOOO OOOOOOOOO OOOOOOOOOOO ",
|
|
212
|
-
"DSDO OSSO ODKKKKKKK OKKKKKKKO KEDO ODEK",
|
|
213
|
-
"SMDO OSMD KDSSSSSSK KDSSSSSKO SMDO ODMS",
|
|
214
|
-
"DEKO OOED OOKKKOOO OOOKKOOO SSOO OKMS",
|
|
215
|
-
"DEKO OOED SEMMMMSK OSMMMMED SSKO OKES",
|
|
216
|
-
"DSSO OESK SEMMMMSK OSMMMMED KSSO OSSK",
|
|
217
|
-
"KSMS EEDO OOKKKKKO OOKKKKKO OSEE EMSO",
|
|
218
|
-
" DSS SSK OSEEESDO ODSEEESO KSS SSD ",
|
|
219
|
-
" OOO OO SEMMMMSK OSMMMMED OO OOO ",
|
|
220
|
-
" KSSSSSDO OKSSESSK ",
|
|
221
|
-
" DKKDDDDDDKKDK KDKDDDDDDDKDK ",
|
|
222
|
-
" SSMLLMMMMMMLMSK DSSMMMMMMMLLMSS ",
|
|
223
|
-
" SMLMMMMMMMMLLED SEMMMMMMMMMMLMS ",
|
|
224
|
-
" DMMMMMMMMMMMMMMD SMMMMMMMMMMMMMSK ",
|
|
225
|
-
" ODSSSSSSSSSSSSSSDO ODSSSSSSSSSSSSSSKO ",
|
|
226
|
-
" OOOOOOOOOOOOOOOOO OOOOOOOOOOOOOOOOO ",
|
|
227
|
-
" OKSSSSSSSSSSSSSSK KSSSSSSSSSSSSSSKO ",
|
|
228
|
-
" OKDDDDDDDDDDDDDDK KDDDDDDDDDDDDDDOO ",
|
|
229
|
-
],
|
|
230
|
-
// 1px a cell: the drawing is 73 cells wide now, and at the old 4 it would be a 292px
|
|
231
|
-
// illustration in a box that used to hold 92.
|
|
232
|
-
1,
|
|
233
|
-
),
|
|
234
|
-
folder: () => pixmap([
|
|
235
|
-
" MMMM ",
|
|
236
|
-
"MMMMMMMMMM",
|
|
237
|
-
"MLLLLLLLLM",
|
|
238
|
-
"MLLLLLLLLM",
|
|
239
|
-
"MLLLLLLLLM",
|
|
240
|
-
"MLLLLLLLLM",
|
|
241
|
-
"MMMMMMMMMM",
|
|
242
|
-
]),
|
|
243
|
-
clock: () => pixmap([
|
|
244
|
-
" MMMMM ",
|
|
245
|
-
" M M ",
|
|
246
|
-
"M M M",
|
|
247
|
-
"M M M",
|
|
248
|
-
"M MMM M",
|
|
249
|
-
"M M",
|
|
250
|
-
"M M",
|
|
251
|
-
" M M ",
|
|
252
|
-
" MMMMM ",
|
|
253
|
-
]),
|
|
254
|
-
};
|
|
255
|
-
// static <i data-icon> placeholders in index.html → inline SVG
|
|
256
|
-
for (const el of document.querySelectorAll("i[data-icon]")) el.outerHTML = ic(el.dataset.icon, 15);
|
|
257
|
-
// theme: "system" | "light" | "dark", persisted; CSS handles system via prefers-color-scheme
|
|
258
|
-
const getTheme = () => localStorage.getItem("swarm.theme") ?? "system";
|
|
259
|
-
const setTheme = (t) => { localStorage.setItem("swarm.theme", t); if (t === "system") delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t; };
|
|
260
|
-
setTheme(getTheme());
|
|
261
|
-
/**
|
|
262
|
-
* Copy, and say whether it worked. The desktop shell's webview exposes no async clipboard API, and
|
|
263
|
-
* the old one-liner used `?.` — so there it did nothing at all, silently. Falls back to a hidden
|
|
264
|
-
* textarea, which still works in that webview.
|
|
265
|
-
*/
|
|
266
|
-
async function copy(text) {
|
|
267
|
-
const value = String(text ?? "");
|
|
268
|
-
try {
|
|
269
|
-
if (navigator.clipboard?.writeText) {
|
|
270
|
-
await navigator.clipboard.writeText(value);
|
|
271
|
-
return true;
|
|
272
|
-
}
|
|
273
|
-
} catch {
|
|
274
|
-
// permission denied or no secure context — fall through to the textarea
|
|
275
|
-
}
|
|
276
|
-
try {
|
|
277
|
-
const ta = document.createElement("textarea");
|
|
278
|
-
ta.value = value;
|
|
279
|
-
ta.setAttribute("readonly", "");
|
|
280
|
-
ta.style.cssText = "position:fixed;top:-1000px;left:0;opacity:0";
|
|
281
|
-
document.body.appendChild(ta);
|
|
282
|
-
ta.select();
|
|
283
|
-
ta.setSelectionRange(0, value.length);
|
|
284
|
-
const ok = document.execCommand("copy");
|
|
285
|
-
ta.remove();
|
|
286
|
-
return ok;
|
|
287
|
-
} catch {
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
const tail = (p, n = 16) => { const t = short(p); return t.length > n ? `…${t.slice(-(n - 1))}` : t; };
|
|
292
|
-
const agentLabel = (a) => viz.agentName(a);
|
|
293
|
-
const agentBadge = (a) => (a ? `<span class="badge agent" style="color:${viz.agentColor(a)};background:color-mix(in srgb,${viz.agentColor(a)} 14%,transparent)">${esc(agentLabel(a))}</span>` : "");
|
|
294
|
-
|
|
295
|
-
// One render per animation frame, whatever triggered it (SSE, polls, clicks).
|
|
296
|
-
let raf = 0;
|
|
297
|
-
const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; safeRender(); }); };
|
|
298
|
-
const touch = () => { state.dirty = true; schedule(); };
|
|
299
|
-
// `render()` refuses to paint while a menu is open (it would detach the anchor the menu is
|
|
300
|
-
// positioned against) and defers the frame instead. fancy-menus exposes no close callback, so the
|
|
301
|
-
// deferred paint has to wait for the close — armed from that bail, where the menu is known to be
|
|
302
|
-
// open. Without it a menu action (switch view, ack, release…) only lands on the next 5s poll,
|
|
303
|
-
// which reads as a dead click. One boolean check per frame, only while a menu is open.
|
|
304
|
-
// The menus island re-broadcasts the package's `useIsAnyMenuOpen` as `menus:openchange`.
|
|
305
|
-
window.addEventListener("menus:openchange", (e) => {
|
|
306
|
-
if (e.detail?.open) return;
|
|
307
|
-
// Menu closed: drop the trigger's open state and paint whatever render() deferred.
|
|
308
|
-
for (const b of $$("#viewnav .navgrp.open")) b.classList.remove("open");
|
|
309
|
-
for (const el of $$(".menu-open")) el.classList.remove("menu-open");
|
|
310
|
-
if (state.dirty) schedule();
|
|
311
|
-
});
|
|
312
|
-
// Last snapshot body + last render time: an unchanged snapshot (same seq, same data) skips the render
|
|
313
|
-
// unless the UI changed, or `ago`-style cells are older than 30s.
|
|
314
|
-
let lastSnap = "", lastRenderAt = 0;
|
|
315
|
-
async function refresh() {
|
|
316
|
-
const txt = await (await fetch("/v1/state")).text();
|
|
317
|
-
const same = txt === lastSnap;
|
|
318
|
-
if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
|
|
319
|
-
if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; state.diskVersion = h.disk ?? null; state.hooksInstalled = h.hooksInstalled !== false; maybeUpdateNudge(h); maybeWhatsNew(); }).catch(() => {});
|
|
320
|
-
let prsChanged = false;
|
|
321
|
-
if (state.view === "prs" && !state.session) {
|
|
322
|
-
const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
|
|
323
|
-
prsChanged = JSON.stringify(prs) !== JSON.stringify(state.prs);
|
|
324
|
-
state.prs = prs;
|
|
325
|
-
}
|
|
326
|
-
let attrChanged = false;
|
|
327
|
-
if (state.view === "spend" && state.sel && !state.session) {
|
|
328
|
-
const [a, bd] = await Promise.all([
|
|
329
|
-
fetch(`/v1/attribution?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.attribution),
|
|
330
|
-
fetch(`/v1/budget?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.budget),
|
|
331
|
-
]);
|
|
332
|
-
attrChanged = JSON.stringify(a) !== JSON.stringify(state.attribution) || JSON.stringify(bd) !== JSON.stringify(state.budget);
|
|
333
|
-
state.attribution = a; state.budget = bd;
|
|
334
|
-
} else if (state.view === "spend" && !state.sel) {
|
|
335
|
-
if (state.attribution) attrChanged = true;
|
|
336
|
-
state.attribution = null;
|
|
337
|
-
}
|
|
338
|
-
let runsChanged = false;
|
|
339
|
-
if (state.session) {
|
|
340
|
-
const ms = await fetch(`/v1/messages?session=${encodeURIComponent(state.session)}&limit=50`).then((r) => r.json()).catch(() => state.msgs ?? []);
|
|
341
|
-
if (JSON.stringify(ms) !== JSON.stringify(state.msgs)) { state.msgs = ms; state.dirty = true; }
|
|
342
|
-
}
|
|
343
|
-
const openSpawned = state.session && state.sessions.find((x) => x.id === state.session)?.kind === "spawned";
|
|
344
|
-
if (openSpawned || (state.view === "board" && !state.session) || (state.view === "fleet" && !state.session)) {
|
|
345
|
-
const runs = await fetch("/v1/runs").then((r) => r.json()).catch(() => state.runs ?? []);
|
|
346
|
-
runsChanged = JSON.stringify(runs) !== JSON.stringify(state.runs);
|
|
347
|
-
state.runs = runs;
|
|
348
|
-
}
|
|
349
|
-
let tasksChanged = false;
|
|
350
|
-
if (state.view === "board" && state.sel && !state.session) {
|
|
351
|
-
const [t, g, d, wf] = await Promise.all([
|
|
352
|
-
fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
|
|
353
|
-
fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
|
|
354
|
-
fetch(`/v1/dispatch?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.dispatch),
|
|
355
|
-
fetch(`/v1/workflows?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.workflows),
|
|
356
|
-
]);
|
|
357
|
-
tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch) || JSON.stringify(wf) !== JSON.stringify(state.workflows);
|
|
358
|
-
state.tasks = t; state.gates = g; state.dispatch = d; state.workflows = wf;
|
|
359
|
-
}
|
|
360
|
-
let incChanged = false;
|
|
361
|
-
if (state.view === "incidents" && !state.session) {
|
|
362
|
-
const q = new URLSearchParams({ limit: "500" }); if (state.incFilter === "open") q.set("open", "1");
|
|
363
|
-
const inc = await (await fetch(`/v1/incidents?${q}`)).json().catch(() => state.allIncidents ?? []);
|
|
364
|
-
incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
|
|
365
|
-
state.allIncidents = inc;
|
|
366
|
-
}
|
|
367
|
-
let linChanged = false;
|
|
368
|
-
if (state.view === "graphs" && (state.graphTab ?? "collisions") === "lineage" && !state.session) {
|
|
369
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
370
|
-
const open = (state.lineageOpen ?? []).map((g) => `&expand=${encodeURIComponent(g)}`).join("");
|
|
371
|
-
const lin = (await api(`/v1/graphs/lineage${q || "?"}${open}`)) ?? state.lineage;
|
|
372
|
-
linChanged = JSON.stringify(lin) !== JSON.stringify(state.lineage);
|
|
373
|
-
state.lineage = lin;
|
|
374
|
-
}
|
|
375
|
-
let colChanged = false;
|
|
376
|
-
if (state.view === "graphs" && (state.graphTab ?? "collisions") === "collisions" && !state.session) {
|
|
377
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
378
|
-
const col = (await api(`/v1/graphs/collisions${q}`)) ?? state.collisions;
|
|
379
|
-
colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
|
|
380
|
-
state.collisions = col;
|
|
381
|
-
}
|
|
382
|
-
let resChanged = false;
|
|
383
|
-
if (state.view === "graphs" && state.graphTab === "resources" && !state.session) {
|
|
384
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
385
|
-
const rg = (await api(`/v1/graphs/resources${q}`)) ?? state.resourceGraph;
|
|
386
|
-
resChanged = JSON.stringify(rg) !== JSON.stringify(state.resourceGraph);
|
|
387
|
-
state.resourceGraph = rg;
|
|
388
|
-
}
|
|
389
|
-
let trChanged = false;
|
|
390
|
-
if (state.view === "graphs" && state.graphTab === "tools" && !state.session) {
|
|
391
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
392
|
-
const tr = (await api(`/v1/graphs/transitions${q}`)) ?? state.transitions;
|
|
393
|
-
trChanged = JSON.stringify(tr) !== JSON.stringify(state.transitions);
|
|
394
|
-
state.transitions = tr;
|
|
395
|
-
}
|
|
396
|
-
let waitChanged = false;
|
|
397
|
-
if ((state.view === "fleet" || state.view === "stats") && !state.session) {
|
|
398
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
399
|
-
const w = await fetch(`/v1/waiting${q}`).then((r) => r.json()).catch(() => state.waiting);
|
|
400
|
-
waitChanged = JSON.stringify(w) !== JSON.stringify(state.waiting);
|
|
401
|
-
state.waiting = w;
|
|
402
|
-
}
|
|
403
|
-
let hygChanged = false;
|
|
404
|
-
if (state.view === "hygiene" && !state.session) {
|
|
405
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
406
|
-
const hy = await fetch(`/v1/hygiene${q}`).then((r) => r.json()).catch(() => state.hygiene);
|
|
407
|
-
hygChanged = JSON.stringify(hy) !== JSON.stringify(state.hygiene);
|
|
408
|
-
state.hygiene = hy;
|
|
409
|
-
}
|
|
410
|
-
let reChanged = false;
|
|
411
|
-
if (state.view === "rules" && !state.session) {
|
|
412
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
413
|
-
const re = (await api(`/v1/rules/effect${q}`)) ?? state.ruleEffect;
|
|
414
|
-
reChanged = JSON.stringify(re) !== JSON.stringify(state.ruleEffect);
|
|
415
|
-
state.ruleEffect = re;
|
|
416
|
-
}
|
|
417
|
-
let secChanged = false;
|
|
418
|
-
if (state.view === "security" && !state.session) {
|
|
419
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
420
|
-
const sec = (await api(`/v1/security${q}`)) ?? state.security;
|
|
421
|
-
secChanged = JSON.stringify(sec) !== JSON.stringify(state.security);
|
|
422
|
-
state.security = sec;
|
|
423
|
-
}
|
|
424
|
-
let heatChanged = false;
|
|
425
|
-
if (state.view === "heat" && !state.session) {
|
|
426
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
427
|
-
const h = (await api(`/v1/heat${q}`)) ?? state.heat;
|
|
428
|
-
heatChanged = JSON.stringify(h) !== JSON.stringify(state.heat);
|
|
429
|
-
state.heat = h;
|
|
430
|
-
}
|
|
431
|
-
let ctxChanged = false;
|
|
432
|
-
if (state.view === "context" && !state.session) {
|
|
433
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
434
|
-
const cx = await fetch(`/v1/context${q}`).then((r) => r.json()).catch(() => state.context);
|
|
435
|
-
ctxChanged = JSON.stringify(cx) !== JSON.stringify(state.context);
|
|
436
|
-
state.context = cx;
|
|
437
|
-
}
|
|
438
|
-
let trialsChanged = false;
|
|
439
|
-
if (state.view === "trials" && !state.session) {
|
|
440
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
441
|
-
const tr = await fetch(`/v1/ab${q}`).then((r) => r.json()).then((r) => r.trials ?? []).catch(() => state.trials);
|
|
442
|
-
trialsChanged = JSON.stringify(tr) !== JSON.stringify(state.trials);
|
|
443
|
-
state.trials = tr;
|
|
444
|
-
}
|
|
445
|
-
let provChanged = false;
|
|
446
|
-
if (state.view === "provenance" && !state.session) {
|
|
447
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
448
|
-
const off = state.provOffset ?? 0;
|
|
449
|
-
const pv = await fetch(`/v1/provenance${q ? `${q}&` : "?"}limit=50&offset=${off}`).then((r) => r.json()).catch(() => state.provenance);
|
|
450
|
-
provChanged = JSON.stringify(pv) !== JSON.stringify(state.provenance);
|
|
451
|
-
state.provenance = pv;
|
|
452
|
-
}
|
|
453
|
-
let mcpChanged = false;
|
|
454
|
-
if (state.view === "mcp" && !state.session) {
|
|
455
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
456
|
-
const m = await fetch(`/v1/mcp/health${q}`).then((r) => r.json()).catch(() => state.mcpHealth);
|
|
457
|
-
mcpChanged = JSON.stringify(m) !== JSON.stringify(state.mcpHealth);
|
|
458
|
-
state.mcpHealth = m;
|
|
459
|
-
}
|
|
460
|
-
let ghChanged = false;
|
|
461
|
-
if (state.view === "gates" && !state.session) {
|
|
462
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
463
|
-
const gh = await fetch(`/v1/gates/health${q}`).then((r) => r.json()).catch(() => state.gateHealth);
|
|
464
|
-
ghChanged = JSON.stringify(gh) !== JSON.stringify(state.gateHealth);
|
|
465
|
-
state.gateHealth = gh;
|
|
466
|
-
}
|
|
467
|
-
let outChanged = false;
|
|
468
|
-
if (state.view === "outcomes" && !state.session) {
|
|
469
|
-
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
470
|
-
const o = await fetch(`/v1/outcomes${q}`).then((r) => r.json()).catch(() => state.outcomes);
|
|
471
|
-
outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
|
|
472
|
-
state.outcomes = o;
|
|
473
|
-
}
|
|
474
|
-
if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || trChanged || resChanged || linChanged || outChanged || waitChanged || ghChanged || mcpChanged || ctxChanged || heatChanged || secChanged || reChanged || provChanged || trialsChanged || hygChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
|
|
475
|
-
}
|
|
476
|
-
// M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
|
|
477
|
-
// deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
|
|
478
|
-
const VIEW_DEFS = [
|
|
479
|
-
{ id: "fleet", label: "Fleet", icon: "squares-four", group: "Observe", render: () => renderFleet() },
|
|
480
|
-
{ id: "timeline", label: "Timeline", icon: "clock-counter-clockwise", group: "Observe", render: () => renderTimeline() },
|
|
481
|
-
{ id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
|
|
482
|
-
{ id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
|
|
483
|
-
{ id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
|
|
484
|
-
{ id: "trials", label: "Trials", icon: "robot", group: "Work", render: () => renderTrials(), badge: () => (state.trials ?? []).filter((t) => t.verdict === "undecided").length },
|
|
485
|
-
{ id: "hygiene", label: "Hygiene", icon: "trash", group: "Work", render: () => renderHygiene(), badge: () => state.hygiene?.totals?.issues ?? 0 },
|
|
486
|
-
// not "check": inside a menu a tick reads as "this item is selected" rather than as an icon
|
|
487
|
-
{ id: "outcomes", label: "Outcomes", icon: "git-branch", group: "Insight", render: () => renderOutcomes() },
|
|
488
|
-
{ id: "gates", label: "Gates", icon: "shield", group: "Insight", render: () => renderGateHealth(), badge: () => state.gateHealth?.totals?.flakyGates ?? 0 },
|
|
489
|
-
{ id: "mcp", label: "MCP", icon: "plugs-connected", group: "Insight", render: () => renderMcpHealth() },
|
|
490
|
-
{ id: "context", label: "Context", icon: "brain", group: "Insight", render: () => renderContext() },
|
|
491
|
-
{ id: "heat", label: "Files", icon: "file-text", group: "Insight", render: () => renderHeat(), badge: () => state.heat?.candidates?.length ?? 0 },
|
|
492
|
-
{ id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
|
|
493
|
-
{ id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
|
|
494
|
-
{ id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
|
|
495
|
-
{ id: "security", label: "Security", icon: "shield", group: "Guard", render: () => renderSecurity(), badge: () => state.security?.totals?.secrets ?? 0 },
|
|
496
|
-
{ id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard", render: () => renderProvenance(), badge: () => state.provenance?.totals?.untracked ?? 0 },
|
|
497
|
-
{ id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
|
|
498
|
-
{ id: "rules", label: "Rules", icon: "shield", group: "Guard", render: () => renderRuleEffect(), badge: () => state.ruleEffect?.totals?.unchanged ?? 0 },
|
|
499
|
-
];
|
|
500
|
-
const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
|
|
501
|
-
const VIEWS = VIEW_DEFS.map((v) => v.id);
|
|
502
|
-
let navHtml = ""; // last-rendered nav html; declared before the restore block below calls renderNav()
|
|
503
|
-
// restore last view + project selection (persisted UI state)
|
|
504
|
-
{
|
|
505
|
-
const v = localStorage.getItem("swarm.view");
|
|
506
|
-
if (VIEWS.includes(v)) state.view = v;
|
|
507
|
-
const gt = localStorage.getItem("swarm.graphTab");
|
|
508
|
-
if (["lineage", "collisions", "tools", "resources"].includes(gt)) state.graphTab = gt;
|
|
509
|
-
const sel = localStorage.getItem("swarm.sel");
|
|
510
|
-
if (sel) state.sel = sel;
|
|
511
|
-
// Deep links win over persisted state: ?view=board&project=<id>&session=<id>
|
|
512
|
-
const q = new URLSearchParams(location.search);
|
|
513
|
-
if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
|
|
514
|
-
if (q.has("project")) state.sel = q.get("project") || null;
|
|
515
|
-
// Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
|
|
516
|
-
renderNav();
|
|
517
|
-
}
|
|
518
|
-
// ---------- errors
|
|
519
|
-
// The dashboard is one long-lived page: an exception in a view used to leave the last frame on
|
|
520
|
-
// screen with no sign anything had gone wrong, and a failed poll was swallowed by `.catch(() =>
|
|
521
|
-
// keep the old value)`. Both now surface. `api()` records what failed so a report has something
|
|
522
|
-
// in it, and `render()` is wrapped so a throwing view shows a panel instead of a frozen one.
|
|
523
|
-
const failures = []; // newest first, capped — a report wants the recent ones, not all of them
|
|
524
|
-
const noteFailure = (f) => { failures.unshift({ ...f, at: new Date().toISOString() }); failures.length = Math.min(failures.length, 12); };
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* GET JSON, or null. A non-2xx is a failure worth naming: a 404 on a `/v1/` route almost always
|
|
528
|
-
* means the running daemon is older than the page it is serving, which is a restart, not a bug.
|
|
529
|
-
*/
|
|
530
|
-
async function api(url) {
|
|
531
|
-
try {
|
|
532
|
-
const r = await fetch(url);
|
|
533
|
-
if (!r.ok) { noteFailure({ url, status: r.status, kind: r.status === 404 ? "missing-route" : "http" }); return null; }
|
|
534
|
-
return await r.json();
|
|
535
|
-
} catch (e) {
|
|
536
|
-
noteFailure({ url, status: 0, kind: "network", message: String(e?.message ?? e) });
|
|
537
|
-
return null;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/** Everything a bug report needs and nothing a person would mind pasting into a public issue. */
|
|
542
|
-
function errorReport(err, where) {
|
|
543
|
-
return {
|
|
544
|
-
swarm: state.version ?? "unknown",
|
|
545
|
-
onDisk: state.diskVersion ?? null,
|
|
546
|
-
view: where ?? state.view,
|
|
547
|
-
graphTab: state.graphTab ?? null,
|
|
548
|
-
session: state.session ? "open" : "none", // the id is not ours to put in a public issue
|
|
549
|
-
projectScoped: Boolean(state.sel),
|
|
550
|
-
error: err ? `${err.name ?? "Error"}: ${err.message ?? err}` : null,
|
|
551
|
-
stack: err?.stack ? String(err.stack).split("\n").slice(0, 8).join("\n") : null,
|
|
552
|
-
recentFailedRequests: failures.slice(0, 6),
|
|
553
|
-
userAgent: navigator.userAgent,
|
|
554
|
-
at: new Date().toISOString(),
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/** Chrome's stack starts with the message, WebKit's does not — do not lose it on either. */
|
|
559
|
-
const errText = (rep) =>
|
|
560
|
-
rep.stack && rep.error && rep.stack.startsWith(rep.error.split(":")[0] ?? "")
|
|
561
|
-
? rep.stack
|
|
562
|
-
: [rep.error, rep.stack].filter(Boolean).join("\n");
|
|
563
|
-
|
|
564
|
-
let lastError = null;
|
|
565
|
-
function renderErrorPanel(err, where) {
|
|
566
|
-
lastError = { err, where };
|
|
567
|
-
const skew = failures.find((f) => f.kind === "missing-route");
|
|
568
|
-
const rep = errorReport(err, where);
|
|
569
|
-
$("#main").innerHTML =
|
|
570
|
-
`<h2 class="err-h">${ic("warning", 14, "err-ic")}Something broke <span>${esc(where ?? state.view)}</span></h2>
|
|
571
|
-
<div class="card err-card">
|
|
572
|
-
${err
|
|
573
|
-
? `<p style="margin:0 0 10px">This view hit an error. The rest of the dashboard is still fine — switching views or reloading usually clears it.</p>`
|
|
574
|
-
: `<p style="margin:0 0 10px"><b>The daemon is older than this page.</b> <code>${esc(skew?.url ?? "")}</code> came back 404, which means the dashboard was updated but the running daemon has not restarted yet.</p>
|
|
575
|
-
<button class="btn primary" data-act="restart-daemon">Restart daemon</button>`}
|
|
576
|
-
${err ? `<pre class="err-detail">${esc(errText(rep))}</pre>` : ""}
|
|
577
|
-
${err && skew ? `<p class="dim" style="margin:10px 0 0;font-size:var(--fs-sm)">Also worth knowing: <code>${esc(skew.url)}</code> is 404ing, so this daemon is older than the page. <a href="#" data-act="restart-daemon">Restart it</a>.</p>` : ""}
|
|
578
|
-
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
|
|
579
|
-
<button class="btn" data-act="err-copy">Copy report</button>
|
|
580
|
-
<button class="btn" data-act="err-issue">Open an issue</button>
|
|
581
|
-
<button class="btn" data-act="err-reload">Reload</button>
|
|
582
|
-
</div>
|
|
583
|
-
<p class="dim" style="margin:12px 0 0;font-size:var(--fs-sm)">The report is the version, the view, the error and the last few failed requests — no session contents, no paths, no titles. Copy it first if you want to read it before sending.</p>
|
|
584
|
-
</div>`;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// A view that throws must not take the whole page with it, and must not leave the previous frame
|
|
588
|
-
// up pretending to be current.
|
|
589
|
-
function safeRender() {
|
|
590
|
-
try { render(); }
|
|
591
|
-
catch (e) { try { renderErrorPanel(e, state.view); } catch { /* the panel itself failed; leave the frame */ } }
|
|
592
|
-
}
|
|
593
|
-
addEventListener("error", (e) => noteFailure({ kind: "exception", url: location.hash || "#", status: 0, message: String(e.message ?? e.error ?? e) }));
|
|
594
|
-
addEventListener("unhandledrejection", (e) => noteFailure({ kind: "rejection", url: location.hash || "#", status: 0, message: String(e.reason?.message ?? e.reason ?? e) }));
|
|
595
|
-
|
|
596
|
-
function render() {
|
|
597
|
-
// A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
|
|
598
|
-
// hold the frame while one is open; the next poll or interaction paints it.
|
|
599
|
-
if (window.menus?.isOpen()) { state.dirty = true; return; } // the menus:openchange listener paints on close
|
|
600
|
-
// Live refresh re-renders the whole view; keep focus + caret in a grid filter input alive.
|
|
601
|
-
const af = document.activeElement;
|
|
602
|
-
const keep = af?.dataset?.filter ? { key: af.dataset.filter, tid: af.dataset.tid, pos: af.selectionStart } : null;
|
|
603
|
-
state.dirty = false;
|
|
604
|
-
lastRenderAt = Date.now();
|
|
605
|
-
if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
|
|
606
|
-
renderHeader();
|
|
607
|
-
if (state.session) renderSession();
|
|
608
|
-
else (viewDef(state.view)?.render ?? viewDef("fleet").render)();
|
|
609
|
-
if (keep) {
|
|
610
|
-
const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
|
|
611
|
-
if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
let todayHtml = "";
|
|
615
|
-
function renderHeader() {
|
|
616
|
-
const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
|
|
617
|
-
const html = `Today <b>${usd(today)}</b>`;
|
|
618
|
-
if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
|
|
619
|
-
renderNav();
|
|
620
|
-
}
|
|
621
|
-
// View nav in the header: one button per group (Observe / Work / Insight / Guard); clicking one
|
|
622
|
-
// opens a fancy-menus dropdown of that group's views. Rebuilt only when the html changes (active
|
|
623
|
-
// view, badges) so the 5s poll doesn't churn the DOM — and never while its menu is open.
|
|
624
|
-
function showView(id) {
|
|
625
|
-
state.view = id;
|
|
626
|
-
localStorage.setItem("swarm.view", id);
|
|
627
|
-
state.session = null;
|
|
628
|
-
state.dirty = true;
|
|
629
|
-
refresh();
|
|
630
|
-
}
|
|
631
|
-
function viewGroups() {
|
|
632
|
-
const groups = [];
|
|
633
|
-
for (const v of VIEW_DEFS) {
|
|
634
|
-
const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
|
|
635
|
-
g.views.push(v);
|
|
636
|
-
}
|
|
637
|
-
return groups;
|
|
638
|
-
}
|
|
639
|
-
function renderNav() {
|
|
640
|
-
const html = viewGroups()
|
|
641
|
-
.map((g) => {
|
|
642
|
-
const n = g.views.reduce((a, v) => a + (v.badge?.() ?? 0), 0);
|
|
643
|
-
const on = !state.session && g.views.some((v) => v.id === state.view);
|
|
644
|
-
// The group name alone never says which of its views you are on, so ten destinations hid
|
|
645
|
-
// behind four words. The active group carries the view's own label.
|
|
646
|
-
const cur = on ? g.views.find((v) => v.id === state.view) : null;
|
|
647
|
-
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>`;
|
|
648
|
-
})
|
|
649
|
-
.join("");
|
|
650
|
-
if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
|
|
651
|
-
}
|
|
652
|
-
// Delegated: the group buttons are re-rendered, so the listener lives on the container.
|
|
653
|
-
$("#viewnav").addEventListener("click", (ev) => {
|
|
654
|
-
const btn = ev.target.closest("[data-grp]");
|
|
655
|
-
if (!btn) return;
|
|
656
|
-
const g = viewGroups().find((x) => x.name === btn.dataset.grp);
|
|
657
|
-
if (!g || !window.menus) return;
|
|
658
|
-
btn.classList.add("open"); // cleared by menus:openchange when the menu closes
|
|
659
|
-
window.menus.open(btn, {
|
|
660
|
-
items: g.views.map((v) => {
|
|
661
|
-
const n = v.badge?.() ?? 0;
|
|
662
|
-
return {
|
|
663
|
-
label: v.label,
|
|
664
|
-
icon: v.icon,
|
|
665
|
-
caption: n ? String(n) : undefined,
|
|
666
|
-
pressed: !state.session && state.view === v.id,
|
|
667
|
-
run: () => showView(v.id),
|
|
668
|
-
};
|
|
669
|
-
}),
|
|
670
|
-
});
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
const isLive = (s) => s.state === "active" || s.state === "waiting";
|
|
674
|
-
// One pass over sessions → live count per project (+ "" for all), instead of a filter per sidebar row.
|
|
675
|
-
function liveCounts() {
|
|
676
|
-
const m = new Map();
|
|
677
|
-
for (const s of state.sessions) if (isLive(s)) { m.set(s.projectId, (m.get(s.projectId) ?? 0) + 1); m.set("", (m.get("") ?? 0) + 1); }
|
|
678
|
-
return m;
|
|
679
|
-
}
|
|
680
|
-
// M5.7: 14-day spend sparkline per pinned project; hidden when the fortnight cost is ~zero.
|
|
681
|
-
function spendSpark(pid) {
|
|
682
|
-
const pts = state.spendSparks?.[pid];
|
|
683
|
-
if (!pts || pts.reduce((a, b) => a + b, 0) < 0.5) return "";
|
|
684
|
-
return `<span class="proj-spark" title="last 14 days · $${pts.reduce((a, b) => a + b, 0).toFixed(0)}">${viz.sparkline(pts, "var(--c1)")}</span>`;
|
|
685
|
-
}
|
|
686
|
-
function renderProjects() {
|
|
687
|
-
const lc = liveCounts();
|
|
688
|
-
const live = (pid) => lc.get(pid) ?? 0;
|
|
689
|
-
const pinned = state.projects.filter((p) => !p.discovered);
|
|
690
|
-
const unpinned = state.projects.filter((p) => p.discovered);
|
|
691
|
-
const nameCount = {};
|
|
692
|
-
for (const p of state.projects) nameCount[p.name] = (nameCount[p.name] || 0) + 1;
|
|
693
|
-
const disamb = (p) => {
|
|
694
|
-
if ((nameCount[p.name] || 0) <= 1) return "";
|
|
695
|
-
const parts = String(p.root || "").split("/").filter(Boolean);
|
|
696
|
-
const parent = parts[parts.length - 2];
|
|
697
|
-
return parent ? `<span class="pdir">${esc(parent)}/</span>` : "";
|
|
698
|
-
};
|
|
699
|
-
const row = (p) => {
|
|
700
|
-
const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
|
|
701
|
-
return `<div class="proj ${state.sel === p.id ? "sel" : ""}" data-id="${p.id}" data-ctx="project" data-pid="${p.id}" title="${esc(p.root)}"${p.discovered ? "" : ' draggable="true"'}>
|
|
702
|
-
<span class="st ${live(p.id) ? "live" : ""}"></span>${projGlyph(p)}<span class="nm">${disamb(p)}${esc(p.name)}</span>${spendSpark(p.id)}<small>${live(p.id) || ""}</small>${act}</div>`;
|
|
703
|
-
};
|
|
704
|
-
const liveAll = live("");
|
|
705
|
-
$("#projects").innerHTML =
|
|
706
|
-
`<h4>Projects <span class="h4-act" id="addProj" title="Add project">${ic("plus", 14)}</span></h4>` +
|
|
707
|
-
`<div class="proj ${state.sel === null ? "sel" : ""}" data-id=""><span class="st ${liveAll ? "live" : ""}"></span>${ic("folders", 14)}<span class="nm">All projects</span><small>${liveAll || ""}</small></div>` +
|
|
708
|
-
`<div id="pinned">${pinned.map(row).join("")}</div>` +
|
|
709
|
-
(unpinned.length ? `<h4>Unpinned <span class="faint" style="text-transform:none;letter-spacing:0;font-weight:400">· seen, not pinned</span></h4>${unpinned.map(row).join("")}` : "") +
|
|
710
|
-
(!pinned.length && !unpinned.length ? `<div class="empty" style="padding:16px;font-size:12px">${PX.folder()}No projects yet.<br>Add a folder below, or start Claude in one.</div>` : "");
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// Pinned projects reorder by drag-and-drop (native DnD on the rows; order persists on the daemon).
|
|
714
|
-
let dragPid = null;
|
|
715
|
-
const projectsEl = $("#projects");
|
|
716
|
-
projectsEl.addEventListener("dragstart", (ev) => {
|
|
717
|
-
const r = ev.target.closest?.(".proj[draggable]");
|
|
718
|
-
if (!r) return;
|
|
719
|
-
dragPid = r.dataset.pid;
|
|
720
|
-
ev.dataTransfer.effectAllowed = "move";
|
|
721
|
-
ev.dataTransfer.setData("text/plain", dragPid);
|
|
722
|
-
requestAnimationFrame(() => r.classList.add("dragging")); // after the drag image is captured
|
|
723
|
-
});
|
|
724
|
-
projectsEl.addEventListener("dragover", (ev) => {
|
|
725
|
-
if (!dragPid) return;
|
|
726
|
-
const r = ev.target.closest?.(".proj[draggable]");
|
|
727
|
-
if (!r || r.dataset.pid === dragPid) return;
|
|
728
|
-
ev.preventDefault();
|
|
729
|
-
ev.dataTransfer.dropEffect = "move";
|
|
730
|
-
const box = r.getBoundingClientRect();
|
|
731
|
-
const before = ev.clientY < box.top + box.height / 2;
|
|
732
|
-
const dragged = projectsEl.querySelector(`.proj[data-pid="${dragPid}"]`);
|
|
733
|
-
if (dragged) r.parentNode.insertBefore(dragged, before ? r : r.nextSibling); // live reflow = the drop preview
|
|
734
|
-
});
|
|
735
|
-
projectsEl.addEventListener("drop", (ev) => { if (dragPid) ev.preventDefault(); });
|
|
736
|
-
projectsEl.addEventListener("dragend", () => {
|
|
737
|
-
if (!dragPid) return;
|
|
738
|
-
dragPid = null;
|
|
739
|
-
const ids = [...projectsEl.querySelectorAll("#pinned .proj[draggable]")].map((r) => r.dataset.pid);
|
|
740
|
-
const rank = new Map(ids.map((id, i) => [id, i]));
|
|
741
|
-
for (const p of state.projects) if (rank.has(p.id)) p.order = rank.get(p.id);
|
|
742
|
-
state.projects.sort((a, b) => Number(a.discovered) - Number(b.discovered) || (a.order ?? 1e9) - (b.order ?? 1e9) || a.name.localeCompare(b.name));
|
|
743
|
-
renderProjects();
|
|
744
|
-
fetch("/v1/projects/order", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }) }).then(refresh);
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
// First run: no sessions have ever been seen. Say exactly what to do next, and whether hooks are in.
|
|
748
|
-
function onboarding() {
|
|
749
|
-
const hooksOk = state.hooksInstalled !== false;
|
|
750
|
-
const step = (n, done, html) => `<div class="ob-step ${done ? "done" : ""}"><span class="ob-n">${done ? "✓" : n}</span><div>${html}</div></div>`;
|
|
751
|
-
return `<div class="onboard">${PX.idle()}
|
|
752
|
-
<h3>Swarm is running and watching this machine.</h3>
|
|
753
|
-
<div class="ob-steps">
|
|
754
|
-
${step(1, hooksOk, `<b>Hook into Claude Code</b> — <code>swarm install</code> once${hooksOk ? "" : " <span class='badge warn'>not installed</span>"}. Codex and Grok are picked up automatically, nothing to configure.`)}
|
|
755
|
-
${step(2, false, `<b>Open any agent session</b> — run <code>claude</code> in any repository, in any terminal. No changes to the repo, the agent doesn't know Swarm is there.`)}
|
|
756
|
-
${step(3, false, `<b>Watch it appear here</b> — live status, branch, tokens and cost per session; Board, Timeline and Spend fill up as you work.`)}
|
|
757
|
-
</div>
|
|
758
|
-
<div class="dim">Something off? <code>swarm doctor</code> checks every piece and prints the fix.</div>
|
|
759
|
-
</div>`;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// ---------- fleet
|
|
763
|
-
// Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
|
|
764
|
-
// M9.4: this session is blocked on a person right now — the badge says for how long, and on what.
|
|
765
|
-
const waitFor = (sid) => (state.waiting?.sessions ?? []).find((w) => w.sessionId === sid);
|
|
766
|
-
const WAIT_WHAT = { permission: "a permission prompt", question: "a question it asked", notification: "a notification" };
|
|
767
|
-
function waitBadge(sid) {
|
|
768
|
-
const w = waitFor(sid);
|
|
769
|
-
if (!w?.openSince) return "";
|
|
770
|
-
const what = WAIT_WHAT[w.openKind] ?? "you";
|
|
771
|
-
return ` <span class="badge warn" title="Blocked on ${esc(what)} since ${esc(w.openSince)}${w.openLabel ? ` — ${esc(w.openLabel)}` : ""}">Waiting ${ago(w.openSince)}</span>`;
|
|
772
|
-
}
|
|
773
|
-
const FLEET_COLS = [
|
|
774
|
-
{ key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
|
|
775
|
-
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
|
|
776
|
-
{ 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)}` },
|
|
777
|
-
{ key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
|
|
778
|
-
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
|
|
779
|
-
const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
|
|
780
|
-
if (s.state === "ended") return line ? `<span class="now dim" title="${esc(line)}">${esc(line)}</span>` : '<span class="dim">ended</span>';
|
|
781
|
-
return `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" && line ? line : s.last)}</span>`;
|
|
782
|
-
} },
|
|
783
|
-
{ key: "model", label: "model", width: 84, get: (s) => model(s.model), cell: (s) => `<span class="br">${esc(model(s.model))}${s.models > 1 ? ` <span class="faint">+${s.models - 1}</span>` : ""}</span>` },
|
|
784
|
-
{ key: "trend", label: "trend", width: 84, sortable: false, filterable: false, get: () => null, cell: (s) => viz.sparkline(s.spark.map((p) => p[0]), viz.agentColor(s.agent)) },
|
|
785
|
-
{ key: "out", label: "out", width: 66, num: true, get: (s) => s.tokens.output, cell: (s) => tok(s.tokens.output) },
|
|
786
|
-
{ key: "ctx", label: "ctx", width: 72, num: true, get: (s) => s.tokens.cacheRead + s.tokens.input + s.tokens.cacheWrite, cell: (s) => tok(s.tokens.cacheRead + s.tokens.input + s.tokens.cacheWrite) },
|
|
787
|
-
{ key: "cost", label: "cost", width: 64, num: true, get: (s) => s.costUsd ?? 0, cell: (s) => usd(s.costUsd) },
|
|
788
|
-
{ key: "age", label: "age", width: 56, num: true, get: (s) => new Date(s.lastSeenAt).getTime(), cell: (s) => `<span class="dim">${ago(s.lastSeenAt)}</span>` },
|
|
789
|
-
];
|
|
790
|
-
|
|
791
|
-
function renderFleet() {
|
|
792
|
-
const base = state.sessions.filter((s) => !state.sel || s.projectId === state.sel);
|
|
793
|
-
const agentCount = new Map();
|
|
794
|
-
for (const s of base) agentCount.set(s.agent, (agentCount.get(s.agent) ?? 0) + 1);
|
|
795
|
-
const agents = [...agentCount.keys()].sort();
|
|
796
|
-
const live = [], rest = [];
|
|
797
|
-
for (const s of base) if (!state.agentFilter || s.agent === state.agentFilter) (isLive(s) ? live : rest).push(s);
|
|
798
|
-
const cols = FLEET_COLS.filter((c) => !(c.key === "project" && state.sel));
|
|
799
|
-
// Live and Earlier are separate grids: each keeps its own column order/widths/visibility.
|
|
800
|
-
const table = (list, id) =>
|
|
801
|
-
dataTable({
|
|
802
|
-
id,
|
|
803
|
-
columns: cols,
|
|
804
|
-
rows: list,
|
|
805
|
-
leading: { width: 24, cell: (s) => `<span class="s ${s.state}"></span>` },
|
|
806
|
-
trailing: { width: 34, cell: (s) => `<span class="more" data-menu="session" data-sid="${s.id}" title="Session actions">${ic("dots-three", 15)}</span>` },
|
|
807
|
-
rowAttrs: (s) => `data-s="${s.id}" data-ctx="session" data-sid="${s.id}"`,
|
|
808
|
-
rerender: touch,
|
|
809
|
-
});
|
|
810
|
-
const chips = agents.length > 1
|
|
811
|
-
? `<div class="chips"><span class="chip ${!state.agentFilter ? "on" : ""}" data-agent="">All</span>${agents
|
|
812
|
-
.map((a) => `<span class="chip ${state.agentFilter === a ? "on" : ""}" data-agent="${a}">${esc(agentLabel(a))} <b>${agentCount.get(a)}</b></span>`)
|
|
813
|
-
.join("")}</div>`
|
|
814
|
-
: "";
|
|
815
|
-
$("#main").innerHTML = chips +
|
|
816
|
-
`<h2>Live <span>${live.length} sessions · ${usd(sumBy(live, (s) => s.costUsd))}</span></h2>` +
|
|
817
|
-
(live.length ? table(live, "fleet-live") : state.sessions.length ? `<div class="empty">${PX.idle()}Nothing running.</div>` : onboarding()) +
|
|
818
|
-
(rest.length ? `<h2 class="mt-sec">Earlier <span>${rest.length}</span></h2>${table(rest.slice(0, 30), "fleet-earlier")}` : "") +
|
|
819
|
-
"";
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// ---------- PRs (one queue across GitHub + GitLab)
|
|
823
|
-
function renderPRs() {
|
|
824
|
-
const rows = state.prs ?? [];
|
|
825
|
-
const chk = (c) => c === "pass" ? '<span class="badge ok">Checks ✓</span>'
|
|
826
|
-
: c === "fail" ? '<span class="badge warn">Checks ✗</span>'
|
|
827
|
-
: c === "pending" ? '<span class="badge">Running…</span>' : '<span class="dim">—</span>';
|
|
828
|
-
const rev = (r) => r === "approved" ? '<span class="badge ok">Approved</span>'
|
|
829
|
-
: r === "changes" ? '<span class="badge warn">Changes</span>' : '<span class="dim">—</span>';
|
|
830
|
-
const green = (p) => p.checks !== "fail" && p.mergeable && !p.draft;
|
|
831
|
-
const cols = [
|
|
832
|
-
{ key: "repo", label: "repo", width: 170, get: (p) => p.repo, cell: (p) => `${ic(p.forge === "gitlab" ? "git-merge" : "git-pull-request", 13)} <span class="br">${esc(p.repo.split("/").pop())}</span>` },
|
|
833
|
-
{ key: "title", label: "title", flex: true, get: (p) => p.title, cell: (p) => `<a href="${esc(p.url)}" target="_blank" rel="noopener"><b>#${p.number}</b> ${esc(p.title)}</a>${p.draft ? ' <span class="badge">Draft</span>' : ""}` },
|
|
834
|
-
{ key: "branch", label: "branch", width: 170, get: (p) => p.branch, cell: (p) => `<span class="br">${esc(p.branch)}</span>` },
|
|
835
|
-
{ key: "author", label: "author", width: 110, get: (p) => p.author, cell: (p) => esc(p.author) },
|
|
836
|
-
{ key: "checks", label: "checks", width: 100, get: (p) => p.checks, cell: (p) => chk(p.checks) },
|
|
837
|
-
{ key: "review", label: "review", width: 100, get: (p) => p.review, cell: (p) => rev(p.review) },
|
|
838
|
-
{ key: "age", label: "age", width: 56, num: true, get: (p) => new Date(p.createdAt).getTime(), cell: (p) => `<span class="dim">${ago(p.createdAt)}</span>` },
|
|
839
|
-
];
|
|
840
|
-
$("#main").innerHTML =
|
|
841
|
-
`<h2>Pull requests <span>${rows.length} open · GitHub + GitLab, merged from here</span></h2>` +
|
|
842
|
-
(rows.length
|
|
843
|
-
? dataTable({
|
|
844
|
-
id: "prs",
|
|
845
|
-
columns: cols,
|
|
846
|
-
rows,
|
|
847
|
-
leading: { width: 24, cell: (p) => `<span class="s ${p.checks === "fail" ? "waiting" : p.checks === "pass" ? "active" : "idle"}"></span>` },
|
|
848
|
-
trailing: { width: 34, cell: (p) => more("pr", `data-pid="${esc(p.projectId)}" data-num="${p.number}"`) },
|
|
849
|
-
rowAttrs: (p) => `data-ctx="pr" data-pid="${esc(p.projectId)}" data-num="${p.number}"`,
|
|
850
|
-
rerender: touch,
|
|
851
|
-
})
|
|
852
|
-
: `<div class="empty">${PX.idle()}No open pull requests.<br>Agent branches land here the moment they're pushed.</div>`);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
// ---------- board (coordination: claims, worktrees, incidents)
|
|
856
|
-
// Board representation toggles (cards vs table), persisted per section.
|
|
857
|
-
const boardMode = (k) => localStorage.getItem(`swarm.board.${k}`) ?? "cards";
|
|
858
|
-
const modeSeg = (k, a = "Cards", b = "Table") => `<span class="seg"><a href="#" data-bmode="${k}:cards" class="${boardMode(k) === "cards" ? "on" : ""}">${a}</a><a href="#" data-bmode="${k}:table" class="${boardMode(k) === "table" ? "on" : ""}">${b}</a></span>`;
|
|
859
|
-
|
|
860
|
-
// KPI strip: the board at a glance — what is live, held, dirty, failing, waiting.
|
|
861
|
-
function renderBoardKpis() {
|
|
862
|
-
const inSel = (pid) => !state.sel || pid === state.sel;
|
|
863
|
-
const live = state.sessions.filter((s) => inSel(s.projectId) && (s.state === "active" || s.state === "waiting"));
|
|
864
|
-
const waiting = live.filter((s) => s.state === "waiting").length;
|
|
865
|
-
const claims = (state.claims ?? []).filter((c) => c.state !== "released" && inSel(c.projectId));
|
|
866
|
-
const orphaned = claims.filter((c) => c.state === "orphaned").length;
|
|
867
|
-
const wts = (state.sel ? [state.sel] : state.projects.map((p) => p.id)).flatMap((id) => state.worktrees[id] ?? []);
|
|
868
|
-
const dirty = wts.filter((w) => w.dirty > 0).length, merged = wts.filter((w) => !w.main && w.merged).length;
|
|
869
|
-
// The snapshot carries only the 20 most recent open incidents, so counting that window caps the
|
|
870
|
-
// KPI at 20 while the Guard badge shows the real number. Both now read the same true count.
|
|
871
|
-
const inc = state.sel
|
|
872
|
-
? (state.openIncidentsByProject?.[state.sel] ?? (state.incidents ?? []).filter((i) => inSel(i.projectId) && !i.acked).length)
|
|
873
|
-
: (state.openIncidents ?? (state.incidents ?? []).filter((i) => !i.acked).length);
|
|
874
|
-
const tasks = state.sel && state.tasks?.tasks ? state.tasks.tasks : null;
|
|
875
|
-
const ready = tasks ? tasks.filter((t) => t.ready).length : null;
|
|
876
|
-
const gateFails = tasks ? tasks.filter((t) => (t.gates ?? []).some((g) => g.verdict === "fail")).length : 0;
|
|
877
|
-
if (!live.length && !claims.length && !wts.length && !inc && !tasks) return "";
|
|
878
|
-
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>`;
|
|
879
|
-
return `<div class="kpis kpis-5">${
|
|
880
|
-
kpi("Live", live.length, waiting ? `${waiting} waiting on you` : live.length ? "sessions working" : "no sessions", waiting ? "hot" : "")
|
|
881
|
-
}${kpi("Held", claims.length, orphaned ? `${orphaned} orphaned` : claims.length ? "claims with a lease" : "nothing claimed", orphaned ? "hot" : "")
|
|
882
|
-
}${kpi("Worktrees", wts.length, dirty || merged ? `${dirty ? `${dirty} dirty` : ""}${dirty && merged ? " · " : ""}${merged ? `${merged} merged` : ""}` : "all clean", dirty ? "warm" : "")
|
|
883
|
-
}${tasks ? kpi("Ready", ready, gateFails ? `${gateFails} with failing gates` : `${tasks.filter((t) => t.status !== "done").length} open`, gateFails ? "hot" : "") : kpi("Projects", state.sel ? 1 : state.projects.length, "on the board")
|
|
884
|
-
}${kpi("Incidents", inc, inc ? "need a look" : "all acknowledged", inc ? "hot" : "")}</div>`;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function renderBoard() {
|
|
888
|
-
const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderWorkflowRuns(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
889
|
-
$("#main").innerHTML = parts.length
|
|
890
|
-
? parts.join("").replace(/^(<div class="kpis[^>]*>[\s\S]*?<\/div><\/div>|)(<h2) class="mt-sec"/, "$1$2") // first section needs no top gap
|
|
891
|
-
: `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// Incident columns are shared by the Board section (open only, recent) and the Incidents view (feed).
|
|
895
|
-
function incidentColumns(full) {
|
|
896
|
-
const sess = (id) => state.sessions.find((s) => s.id === id);
|
|
897
|
-
return [
|
|
898
|
-
{ key: "ts", label: "when", width: 76, get: (i) => i.ts, cell: (i) => `<span class="dim" title="${esc(i.ts)}">${ago(i.ts)}</span>` },
|
|
899
|
-
{ key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => projCell(i.projectId) },
|
|
900
|
-
{ key: "session", label: "session", width: 150, get: (i) => sess(i.sessionId)?.title ?? i.sessionId ?? "", cell: (i) => (i.sessionId ? `<a href="#" data-s="${i.sessionId}">${esc(sess(i.sessionId)?.title ?? i.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
|
|
901
|
-
{ key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
|
|
902
|
-
{ key: "action", label: "action", width: 80, get: (i) => i.action, cell: (i) => (i.action === "deny" ? '<span class="badge warn">Denied</span>' : i.action === "orphaned" ? '<span class="badge warn">Orphaned</span>' : i.action === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge acc">Asked</span>') },
|
|
903
|
-
{ key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.command ?? "")}${i.reason ? `\n\n${esc(i.reason)}` : ""}">${esc(cmdGist(i.command ?? ""))}</span>` },
|
|
904
|
-
...(full ? [
|
|
905
|
-
{ key: "reason", label: "reason", width: 260, get: (i) => i.reason ?? "", cell: (i) => `<span class="dim now" title="${esc(i.reason ?? "")}">${esc(i.reason ?? "")}</span>` },
|
|
906
|
-
{ key: "acked", label: "acked", width: 80, get: (i) => i.acked ?? "", cell: (i) => (i.acked ? `<span class="dim" title="${esc(i.acked)}">${ago(i.acked)}</span>` : '<span class="badge warn">Open</span>') },
|
|
907
|
-
] : []),
|
|
908
|
-
].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
|
|
909
|
-
}
|
|
910
|
-
/** The part of a shell command worth reading in a cell: drop a leading `cd <dir> &&` / `;`. */
|
|
911
|
-
const cmdGist = (c) => c.replace(/^\s*cd\s+\S+\s*(&&|;)\s*/, "").replace(/\s+/g, " ").trim() || c;
|
|
912
|
-
const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" || i.action === "orphaned" || i.action === "failed" ? "waiting" : "idle"}"></span>`;
|
|
913
|
-
|
|
914
|
-
function renderIncidents() {
|
|
915
|
-
const rows = (state.incidents ?? []).filter((i) => !state.sel || i.projectId === state.sel);
|
|
916
|
-
if (!rows.length) return "";
|
|
917
|
-
const open = state.sel ? rows.length : (state.openIncidents ?? rows.length);
|
|
918
|
-
return `<h2 class="mt-sec">Incidents <span>${open} open · what the rules stopped · <a href="#" data-view="incidents">all incidents</a></span></h2>` +
|
|
919
|
-
dataTable({
|
|
920
|
-
id: "incidents",
|
|
921
|
-
columns: incidentColumns(false),
|
|
922
|
-
rows,
|
|
923
|
-
leading: { width: 24, cell: incidentDot },
|
|
924
|
-
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
925
|
-
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
926
|
-
rerender: touch,
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// M4.6: rule dry-run — replay this project's history under chosen modes; nothing is recorded.
|
|
931
|
-
const RULE_IDS = ["pattern_kill", "shared_tree", "destructive_git", "protected_ports", "no_foreign_worktree", "claim_required_to_write"];
|
|
932
|
-
const dry = { modes: {}, report: null, busy: false };
|
|
933
|
-
async function openDryRun() {
|
|
934
|
-
if (!state.sel) return alert("Pick a project in the sidebar first — the dry-run replays one project's history.");
|
|
935
|
-
dry.modes = {}; dry.report = null;
|
|
936
|
-
await runDryRun();
|
|
937
|
-
}
|
|
938
|
-
async function runDryRun() {
|
|
939
|
-
dry.busy = true; renderDryRun();
|
|
940
|
-
const q = new URLSearchParams({ project: state.sel, ...dry.modes });
|
|
941
|
-
dry.report = await fetch(`/v1/rules/dryrun?${q}`).then((r) => r.json()).catch((e) => ({ ok: false, error: String(e) }));
|
|
942
|
-
dry.busy = false; renderDryRun();
|
|
943
|
-
}
|
|
944
|
-
function renderDryRun() {
|
|
945
|
-
const r = dry.report;
|
|
946
|
-
const sel = (id) => {
|
|
947
|
-
const cur = dry.modes[id] ?? r?.modes?.[id] ?? "ask";
|
|
948
|
-
return `<label class="dr-rule"><span class="br">${id}</span><select data-drmode="${id}">${["ask", "deny", "off"].map((m) => `<option value="${m}" ${m === cur ? "selected" : ""}>${m}</option>`).join("")}</select>${r ? `<span class="dim">ask <b>${r.byRule[id].ask}</b> · deny <b>${r.byRule[id].deny}</b></span>` : ""}</label>`;
|
|
949
|
-
};
|
|
950
|
-
const flaky = (r?.flaky ?? []).map((f) => `<div class="dr-flaky"><code>${esc(f.display)}</code><div class="dim" style="font-size:var(--fs-sm)">${esc(f.suggestion)} · ${f.sessions} session${f.sessions === 1 ? "" : "s"}</div></div>`).join("");
|
|
951
|
-
const hits = (r?.hits ?? []).slice(-40).reverse().map((h) => `<tr><td class="dim">${hhmm(h.ts)}</td><td><span class="br">${esc(h.rule)}</span></td><td>${h.action}</td><td><code>${esc(h.display)}</code></td><td class="dim">${h.completed ? "ran" : ""}</td></tr>`).join("");
|
|
952
|
-
$("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
|
|
953
|
-
<div class="pk-h">${ic("shield", 15)}<b>Rule dry-run</b><span class="dim" style="margin-left:8px">${esc(projName(state.sel))}</span><span class="grow"></span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
|
|
954
|
-
<div class="pk-b">
|
|
955
|
-
<p class="dim" style="font-size:var(--fs-sm)">Replays this project's recorded tool calls through the rules under the modes below — what <em>would</em> have been asked or denied. Nothing is recorded; change a mode and re-run to try a rule before switching it on in <code>.swarm.toml</code>.</p>
|
|
956
|
-
<div class="dr-rules">${RULE_IDS.map(sel).join("")}</div>
|
|
957
|
-
${dry.busy ? '<p class="dim">replaying…</p>' : r?.error ? `<p class="dim">${esc(r.error)}</p>` : r ? `
|
|
958
|
-
<div class="date">${r.evaluated} of ${r.calls} calls evaluated · ${r.hits.length}${r.hits.length >= 200 ? "+" : ""} hits</div>
|
|
959
|
-
<h4>Flaky signals <span class="dim">rules that keep asking about something that is then allowed anyway</span></h4>
|
|
960
|
-
${flaky || '<p class="dim" style="font-size:var(--fs-sm)">None — every rule that fired stuck.</p>'}
|
|
961
|
-
<h4>Would have fired <span class="dim">newest first, last 40</span></h4>
|
|
962
|
-
${hits ? `<div style="overflow-x:auto"><table class="plain"><tbody>${hits}</tbody></table></div>` : '<p class="dim" style="font-size:var(--fs-sm)">Nothing — these modes are silent on this history.</p>'}` : ""}
|
|
963
|
-
</div>
|
|
964
|
-
<div class="pk-f"><span class="grow"></span><button id="drRun" ${dry.busy ? "disabled" : ""}>Re-run</button><button id="pkCancel">Close</button></div>
|
|
965
|
-
</div>`;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// ---------- incidents view (M2.3): the denied-action feed, with ack
|
|
969
|
-
// M4.3: turn an incident into a .swarm.toml rule + a CLAUDE.md lesson, both copyable.
|
|
970
|
-
function codifyIncident(seq) {
|
|
971
|
-
const i = (state.allIncidents ?? []).find((x) => x.seq === Number(seq));
|
|
972
|
-
if (!i?.suggestion) return;
|
|
973
|
-
const sg = i.suggestion;
|
|
974
|
-
$("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
|
|
975
|
-
<div class="pk-h">${ic("shield", 15)}<b>Codify</b><span class="grow"></span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
|
|
976
|
-
<div class="pk-b">
|
|
977
|
-
<h3>${esc(sg.title)}</h3>
|
|
978
|
-
<div class="date">from a <span class="br">${esc(i.rule)}</span> incident${i.count > 1 ? ` \u00b7 seen ${i.count}\u00d7` : ""}</div>
|
|
979
|
-
${sg.toml ? `<h4>.swarm.toml <a href="#" class="cbtn" data-copy-toml="${seq}">${ic("copy", 12)} copy</a></h4><pre class="snip" id="toml-${seq}">${esc(sg.toml)}</pre>` : ""}
|
|
980
|
-
<h4>CLAUDE.md lesson <a href="#" class="cbtn" data-copy-lesson="${seq}">${ic("copy", 12)} copy</a></h4>
|
|
981
|
-
<pre class="snip" id="lesson-${seq}">- ${esc(sg.lesson)}</pre>
|
|
982
|
-
${sg.toml ? '<p class="dim" style="font-size:var(--fs-sm)">Merge the block into the repo\'s <code>.swarm.toml</code>; the daemon picks it up within ~30s.</p>' : '<p class="dim" style="font-size:var(--fs-sm)">No config rule fits this one \u2014 the lesson is the takeaway.</p>'}
|
|
983
|
-
</div>
|
|
984
|
-
<div class="pk-f"><span class="grow"></span><button id="pkCancel">Close</button></div>
|
|
985
|
-
</div>`;
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function renderIncidentsView() {
|
|
989
|
-
const all = state.allIncidents;
|
|
990
|
-
const rows = (all ?? []).filter((i) => !state.sel || i.projectId === state.sel);
|
|
991
|
-
const open = rows.filter((i) => !i.acked).length;
|
|
992
|
-
const chip = (k, label) => `<span class="chip ${state.incFilter === k ? "on" : ""}" data-inc="${k}">${label}</span>`;
|
|
993
|
-
const byRule = new Map();
|
|
994
|
-
for (const i of rows) byRule.set(i.rule, (byRule.get(i.rule) ?? 0) + 1);
|
|
995
|
-
const rules = [...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([r, n]) => `<span class="br">${esc(r)}</span> <b>${n}</b>`).join(" · ");
|
|
996
|
-
$("#main").innerHTML =
|
|
997
|
-
`<h2>Incidents <span>${all === null ? "loading…" : `${open} open · ${rows.length} shown`} · every ask/deny the rules made${rules ? ` · ${rules}` : ""}</span></h2>` +
|
|
998
|
-
`<div class="chips">${chip("open", "Open")}${chip("all", "All")}${open ? `<span class="chip" data-ackall="1" title="Mark every open incident${state.sel ? " in this project" : ""} as seen">Ack all <b>${open}</b></span>` : ""}${state.sel ? `<span class="chip" id="dryrun" title="Replay this project's history under different rule modes">${ic("shield", 12)} Dry-run rules</span>` : ""}</div>` +
|
|
999
|
-
(rows.length
|
|
1000
|
-
? dataTable({
|
|
1001
|
-
id: "incidents-feed",
|
|
1002
|
-
columns: incidentColumns(true),
|
|
1003
|
-
rows,
|
|
1004
|
-
leading: { width: 24, cell: incidentDot },
|
|
1005
|
-
trailing: { width: 34, cell: (i) => more("incident", `data-seq="${i.seq}"`) },
|
|
1006
|
-
rowAttrs: (i) => `data-ctx="incident" data-seq="${i.seq}"`,
|
|
1007
|
-
rerender: touch,
|
|
1008
|
-
})
|
|
1009
|
-
: `<div class="empty">${PX.idle()}${state.incFilter === "open" ? "No open incidents." : "No incidents yet."}<br>Every <code>ask</code> or <code>deny</code> a rule makes lands here; ack it once you've seen it.</div>`);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
// PROCESSES: what `swarm serve` / `swarm proc` started — pid-tracked, stoppable by pid only.
|
|
1013
|
-
function renderProcesses() {
|
|
1014
|
-
const rows = (state.processes ?? []).filter((r) => !state.sel || r.projectId === state.sel);
|
|
1015
|
-
if (!rows.length) return "";
|
|
1016
|
-
const cols = [
|
|
1017
|
-
{ key: "name", label: "process", width: 150, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
|
|
1018
|
-
{ key: "kind", label: "kind", width: 80, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
|
|
1019
|
-
{ key: "project", label: "project", width: 104, get: (r) => projName(r.projectId), cell: (r) => projCell(r.projectId) },
|
|
1020
|
-
{ key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid, cell: (r) => r.pid },
|
|
1021
|
-
{ key: "port", label: "port", width: 70, num: true, get: (r) => r.port ?? 0, cell: (r) => (r.port != null ? `<a href="http://127.0.0.1:${r.port}/" target="_blank" rel="noopener">:${r.port}</a>` : '<span class="dim">—</span>') },
|
|
1022
|
-
{ key: "owner", label: "owner", width: 110, get: (r) => r.owner, cell: (r) => esc(r.owner) },
|
|
1023
|
-
{ key: "cmd", label: "command", flex: true, get: (r) => r.cmd, cell: (r) => `<span class="now" title="${esc(r.cwd)}">${esc(r.cmd)}</span>` },
|
|
1024
|
-
{ key: "up", label: "up", width: 64, get: (r) => r.startedAt, cell: (r) => `<span class="dim">${ago(r.startedAt)}</span>` },
|
|
1025
|
-
].filter((c) => !(c.key === "project" && state.sel));
|
|
1026
|
-
return `<h2 class="mt-sec">Processes <span>${rows.length} · started through swarm serve / proc</span></h2>` +
|
|
1027
|
-
dataTable({
|
|
1028
|
-
id: "processes",
|
|
1029
|
-
columns: cols,
|
|
1030
|
-
rows,
|
|
1031
|
-
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
1032
|
-
trailing: { width: 34, cell: (r) => more("process", `data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`) },
|
|
1033
|
-
rowAttrs: (r) => `data-ctx="process" data-pid="${r.pid}" data-proj="${esc(r.projectId)}" data-cwd="${esc(r.cwd ?? "")}"`,
|
|
1034
|
-
rerender: touch,
|
|
1035
|
-
});
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
function renderResources() {
|
|
1039
|
-
const rows = (state.resources ?? []).filter((r) => !state.sel || r.projectId === state.sel || r.projectId === null);
|
|
1040
|
-
if (!rows.length) return "";
|
|
1041
|
-
const cols = [
|
|
1042
|
-
{ key: "name", label: "resource", width: 170, get: (r) => r.name, cell: (r) => `<b>${esc(r.name)}</b>` },
|
|
1043
|
-
{ key: "kind", label: "kind", width: 90, get: (r) => r.kind, cell: (r) => `<span class="badge">${esc(r.kind)}</span>` },
|
|
1044
|
-
{ key: "project", label: "project", width: 104, get: (r) => (r.projectId ? projName(r.projectId) : "global"), cell: (r) => (r.projectId ? esc(projName(r.projectId)) : '<span class="dim">global</span>') },
|
|
1045
|
-
{ key: "owner", label: "owner", width: 130, get: (r) => r.owner, cell: (r) => esc(r.owner) },
|
|
1046
|
-
{ key: "pid", label: "pid", width: 76, num: true, get: (r) => r.pid ?? 0, cell: (r) => (r.pid ?? '<span class="dim">—</span>') },
|
|
1047
|
-
{ key: "port", label: "port", width: 76, num: true, get: (r) => r.port ?? 0, cell: (r) => (r.port ?? '<span class="dim">—</span>') },
|
|
1048
|
-
{ key: "held", label: "held", flex: true, get: (r) => r.acquiredAt, cell: (r) => `<span class="dim">${ago(r.acquiredAt)}${r.expiresAt ? ` · lease ${leaseLeft(r.expiresAt)}` : r.pid ? " · pid-tracked" : ""}</span>` },
|
|
1049
|
-
].filter((c) => !(c.key === "project" && state.sel));
|
|
1050
|
-
return `<h2>Resources <span>${rows.length} held · ports auto-protected</span></h2>` +
|
|
1051
|
-
dataTable({
|
|
1052
|
-
id: "resources",
|
|
1053
|
-
columns: cols,
|
|
1054
|
-
rows,
|
|
1055
|
-
leading: { width: 24, cell: () => '<span class="s active"></span>' },
|
|
1056
|
-
trailing: { width: 34, cell: (r) => more("resource", `data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`) },
|
|
1057
|
-
rowAttrs: (r) => `data-ctx="resource" data-name="${esc(r.name)}" data-proj="${esc(r.projectId ?? "")}"`,
|
|
1058
|
-
rerender: touch,
|
|
1059
|
-
});
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// Gate chips: ✓ pass / ✗ fail / — never run, latest run on hover.
|
|
1063
|
-
const gateChips = (gates) => gates.map((g) => {
|
|
1064
|
-
const cls = g.verdict === "pass" ? "ok" : g.verdict === "fail" ? "warn" : "";
|
|
1065
|
-
const mark = g.verdict === "pass" ? "✓" : g.verdict === "fail" ? "✗" : "—";
|
|
1066
|
-
return `<span class="badge ${cls}" title="${esc(g.gate)}: ${g.runs} run${g.runs === 1 ? "" : "s"}, ${g.fails} fail${g.fails === 1 ? "" : "s"}">${esc(g.gate)} ${mark}</span>`;
|
|
1067
|
-
}).join(" ") || '<span class="dim">—</span>';
|
|
1068
|
-
|
|
1069
|
-
// RECENT GATES: verification runs on this project (M2.2). Only with a project selected.
|
|
1070
|
-
function renderGates() {
|
|
1071
|
-
if (!state.sel || !state.gates) return "";
|
|
1072
|
-
const runs = state.gates.runs ?? [];
|
|
1073
|
-
const required = state.gates.required ?? [];
|
|
1074
|
-
if (!runs.length && !required.length) return "";
|
|
1075
|
-
const sess = (id) => state.sessions.find((s) => s.id === id);
|
|
1076
|
-
const cols = [
|
|
1077
|
-
{ key: "ts", label: "when", width: 76, get: (r) => r.createdAt, cell: (r) => `<span class="dim" title="${esc(r.createdAt)}">${ago(r.createdAt)}</span>` },
|
|
1078
|
-
{ key: "task", label: "task", width: 110, get: (r) => r.task, cell: (r) => `<b>${esc(r.task)}</b>` },
|
|
1079
|
-
{ key: "gate", label: "gate", width: 120, get: (r) => r.gate, cell: (r) => `<span class="br">${esc(r.gate)}</span>` },
|
|
1080
|
-
{ key: "verdict", label: "verdict", width: 80, get: (r) => r.verdict, cell: (r) => (r.verdict === "pass" ? '<span class="badge ok">Pass</span>' : '<span class="badge warn">Fail</span>') },
|
|
1081
|
-
{ key: "rubric", label: "rubric", flex: true, get: (r) => r.rubric, cell: (r) => `<span class="now" title="${esc(r.rubric)}">${esc(r.rubric)}</span>` },
|
|
1082
|
-
{ key: "evidence", label: "evidence", width: 220, get: (r) => r.evidence ?? "", cell: (r) => (r.evidence ? `<span class="dim now" title="${esc(r.evidence)}">${esc(r.evidence)}</span>` : '<span class="dim">—</span>') },
|
|
1083
|
-
{ key: "session", label: "session", width: 140, get: (r) => sess(r.sessionId)?.title ?? "", cell: (r) => (r.sessionId ? `<a href="#" data-s="${r.sessionId}">${esc(sess(r.sessionId)?.title ?? r.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
|
|
1084
|
-
];
|
|
1085
|
-
const history = (gate) => {
|
|
1086
|
-
const rs = runs.filter((r) => r.gate === gate).slice(0, 12).reverse();
|
|
1087
|
-
if (!rs.length) return "";
|
|
1088
|
-
return `<span class="gh" title="${esc(gate)} — last ${rs.length} run${rs.length === 1 ? "" : "s"}, oldest first">${esc(gate)} ${rs.map((r) => `<i class="${r.verdict === "pass" ? "ok" : "bad"}" title="${esc(r.rubric)}"></i>`).join("")}</span>`;
|
|
1089
|
-
};
|
|
1090
|
-
const gateNames = [...new Set(runs.map((r) => r.gate))];
|
|
1091
|
-
return `<h2 class="mt-sec">Recent gates <span>${runs.length} run${runs.length === 1 ? "" : "s"}${required.length ? ` · required: ${required.map(esc).join(", ")}` : ""} · latest run per gate decides</span>${gateNames.length ? `<span class="grow"></span><span class="gh-strip">${gateNames.map(history).join("")}</span>` : ""}</h2>` +
|
|
1092
|
-
(runs.length
|
|
1093
|
-
? dataTable({
|
|
1094
|
-
id: "gates",
|
|
1095
|
-
columns: cols,
|
|
1096
|
-
rows: runs.slice(0, 50),
|
|
1097
|
-
leading: { width: 24, cell: (r) => `<span class="s ${r.verdict === "fail" ? "waiting" : "active"}"></span>` },
|
|
1098
|
-
trailing: { width: 12, cell: () => "" },
|
|
1099
|
-
rowAttrs: () => "",
|
|
1100
|
-
rerender: touch,
|
|
1101
|
-
})
|
|
1102
|
-
: `<div class="empty">${PX.idle()}No gate runs yet. <code>swarm gate record <task> ${esc(required[0] ?? "review")} pass --rubric "…"</code></div>`);
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
// TASKS: the project's backlog from `.swarm.toml [tasks] source` (M1.6). Only with a project selected.
|
|
1106
|
-
function renderTasks() {
|
|
1107
|
-
if (!state.sel || !state.tasks?.source) return "";
|
|
1108
|
-
const all = state.tasks.tasks ?? [];
|
|
1109
|
-
const ready = all.filter((t) => t.ready);
|
|
1110
|
-
const hasGates = (state.tasks.required ?? []).length > 0 || all.some((t) => (t.gates ?? []).length);
|
|
1111
|
-
const rows = state.taskFilter === "ready" ? ready : state.taskFilter === "open" ? all.filter((t) => t.status !== "done") : all;
|
|
1112
|
-
const chip = (k, label, n) => `<span class="chip ${state.taskFilter === k ? "on" : ""}" data-task-filter="${k}">${label}${n != null ? ` <b>${n}</b>` : ""}</span>`;
|
|
1113
|
-
const st = (t) => t.claimedBy ? `<span class="badge ok">Held · ${esc(t.claimedBy)}</span>`
|
|
1114
|
-
: t.status === "done" ? '<span class="badge">Done</span>'
|
|
1115
|
-
: t.status === "active" ? '<span class="badge acc">In progress</span>'
|
|
1116
|
-
: t.ready ? '<span class="badge ok">Ready</span>' : '<span class="badge">Blocked</span>';
|
|
1117
|
-
const cols = [
|
|
1118
|
-
{ key: "id", label: "id", width: 70, get: (t) => t.id, cell: (t) => `<b>${esc(t.id)}</b>` },
|
|
1119
|
-
{ key: "title", label: "task", flex: true, get: (t) => t.title, cell: (t) => `<span class="now" title="${esc(t.statusText)}">${esc(t.title)}</span>` },
|
|
1120
|
-
{ key: "milestone", label: "milestone", width: 160, get: (t) => t.milestone ?? "", cell: (t) => `<span class="dim now">${esc((t.milestone ?? "").split(" — ")[0])}</span>` },
|
|
1121
|
-
{ key: "depends", label: "depends", width: 130, get: (t) => t.depends.join(" "), cell: (t) => `<span class="br">${esc(t.depends.join(" ")) || "—"}</span>` },
|
|
1122
|
-
{ key: "state", label: "state", width: 150, get: (t) => (t.claimedBy ? 0 : t.ready ? 1 : t.status === "active" ? 2 : t.status === "done" ? 4 : 3), cell: st },
|
|
1123
|
-
...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
|
|
1124
|
-
];
|
|
1125
|
-
const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
|
|
1126
|
-
const lane = (t) => (t.claimedBy ? "held" : t.status === "done" ? "done" : t.ready ? "ready" : t.status === "active" ? "held" : "blocked");
|
|
1127
|
-
const card = (t) => `<div class="tcard ${lane(t)}" tabindex="0" role="button" data-menu="task" data-ctx="task" data-task="${esc(t.id)}" title="${esc(t.statusText)}">
|
|
1128
|
-
<div class="tc-h"><b>${esc(t.id)}</b>${t.claimedBy ? `<span class="badge ok">${esc(t.claimedBy)}</span>` : ""}${t.depends.length && lane(t) === "blocked" ? `<span class="dim">← ${esc(t.depends.join(" "))}</span>` : ""}</div>
|
|
1129
|
-
<div class="tc-t">${esc(t.title)}</div>
|
|
1130
|
-
${t.milestone ? `<div class="tc-m">${esc(t.milestone.split(" — ")[0])}</div>` : ""}
|
|
1131
|
-
${(t.gates ?? []).some((g) => g.verdict) ? `<div class="tc-g">${gateChips(t.gates)}</div>` : ""}
|
|
1132
|
-
</div>`;
|
|
1133
|
-
const kanban = () => {
|
|
1134
|
-
const lanes = [["ready", "Ready"], ["held", "In progress"], ["blocked", "Blocked"], ["done", "Done"]];
|
|
1135
|
-
const by = Object.fromEntries(lanes.map(([k]) => [k, []]));
|
|
1136
|
-
for (const t of all) by[lane(t)].push(t);
|
|
1137
|
-
by.done.reverse();
|
|
1138
|
-
const CAP = 6;
|
|
1139
|
-
return `<div class="kanban">${lanes.map(([k, label]) => {
|
|
1140
|
-
const list = by[k];
|
|
1141
|
-
const shown = k === "done" ? list.slice(0, CAP) : list;
|
|
1142
|
-
return `<div class="lane ${k}"><div class="lane-h">${label} <span>${list.length}</span></div>${shown.map(card).join("") || '<div class="lane-empty">—</div>'}${list.length > shown.length ? `<div class="lane-more dim">+${list.length - shown.length} more in the table</div>` : ""}</div>`;
|
|
1143
|
-
}).join("")}</div>`;
|
|
1144
|
-
};
|
|
1145
|
-
return `<h2 class="mt-sec">Tasks <span>${ready.length} ready · ${all.length} in ${esc(srcLabel)}${state.tasks.error ? ` · <span class="badge warn" title="${esc(state.tasks.error)}">${ic("warning", 12)} ${esc(state.tasks.error)}</span>` : ""}</span></h2>` +
|
|
1146
|
-
`<div class="chips">${boardMode("tasks") === "cards" ? "" : chip("ready", "Ready", ready.length) + chip("open", "Open", all.filter((t) => t.status !== "done").length) + chip("all", "All", all.length)}${ready.length ? `<span class="chip" id="dispatch" title="Claim a worktree per ready task and spawn a run in each, ${state.dispatch?.config?.max_parallel ?? 2} at a time">${ic("play", 12)} Dispatch</span>` : ""}<span class="grow"></span>${modeSeg("tasks")}</div>` +
|
|
1147
|
-
(all.length && boardMode("tasks") === "cards" ? kanban() : rows.length
|
|
1148
|
-
? dataTable({
|
|
1149
|
-
id: "tasks",
|
|
1150
|
-
columns: cols,
|
|
1151
|
-
rows,
|
|
1152
|
-
leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
|
|
1153
|
-
trailing: { width: 34, cell: (t) => (t.ready || t.claimedBy ? more("task", `data-task="${esc(t.id)}"`) : "") },
|
|
1154
|
-
rowAttrs: (t) => `data-ctx="task" data-task="${esc(t.id)}"`,
|
|
1155
|
-
rerender: touch,
|
|
1156
|
-
})
|
|
1157
|
-
: `<div class="empty">${PX.idle()}${state.taskFilter === "ready" ? "Nothing ready — every open task is blocked or held." : "No tasks."}</div>`);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
function renderClaims() {
|
|
1161
|
-
const rows = (state.claims ?? []).filter((c) => c.state !== "released" && (!state.sel || c.projectId === state.sel));
|
|
1162
|
-
if (!rows.length) return "";
|
|
1163
|
-
const order = { orphaned: 0, expired: 1, held: 2 };
|
|
1164
|
-
rows.sort((a, b) => (order[a.state] ?? 3) - (order[b.state] ?? 3));
|
|
1165
|
-
const badge = (st) => st === "orphaned" ? '<span class="badge warn">Orphaned · holds work</span>' : st === "expired" ? '<span class="badge acc">Expired</span>' : '<span class="badge ok">Held</span>';
|
|
1166
|
-
const orphans = rows.filter((c) => c.state === "orphaned").length;
|
|
1167
|
-
const cols = [
|
|
1168
|
-
{ key: "project", label: "project", width: 104, get: (c) => projName(c.projectId), cell: (c) => projCell(c.projectId) },
|
|
1169
|
-
{ key: "task", label: "task", width: 140, get: (c) => c.task, cell: (c) => `<b>${esc(c.task)}</b>` },
|
|
1170
|
-
{ key: "owner", label: "owner", width: 120, get: (c) => c.owner || "", cell: (c) => esc(c.owner || "—") },
|
|
1171
|
-
{ key: "lease", label: "lease", width: 130, get: (c) => (c.state === "held" ? new Date(c.expiresAt).getTime() : 0), cell: (c) => `<span class="dim">${c.state === "held" ? leaseLeft(c.expiresAt) : "—"}</span>` },
|
|
1172
|
-
{ key: "worktree", label: "worktree", flex: true, get: (c) => c.worktree, cell: (c) => `<span class="now" title="${esc(c.worktree)}">${esc(short(c.worktree))}</span>` },
|
|
1173
|
-
{ key: "state", label: "state", width: 150, get: (c) => c.state, cell: (c) => badge(c.state) },
|
|
1174
|
-
].filter((c) => !(c.key === "project" && state.sel));
|
|
1175
|
-
return `<h2 class="mt-sec">Claims <span>${rows.length}${orphans ? ` · ${orphans} orphaned` : ""}</span></h2>` +
|
|
1176
|
-
dataTable({
|
|
1177
|
-
id: "claims",
|
|
1178
|
-
columns: cols,
|
|
1179
|
-
rows,
|
|
1180
|
-
leading: { width: 24, cell: (c) => `<span class="s ${c.state === "orphaned" ? "waiting" : c.state === "expired" ? "idle" : "active"}"></span>` },
|
|
1181
|
-
trailing: { width: 34, cell: (c) => more("claim", `data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`) },
|
|
1182
|
-
rowAttrs: (c) => `data-ctx="claim" data-pid="${esc(c.projectId)}" data-task="${esc(c.task)}"`,
|
|
1183
|
-
rerender: touch,
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
function renderWorktrees() {
|
|
1188
|
-
const ids = state.sel ? [state.sel] : state.projects.map((p) => p.id);
|
|
1189
|
-
const rows = ids.flatMap((id) => (state.worktrees[id] ?? []).map((w) => ({ ...w, projectId: id })));
|
|
1190
|
-
if (!rows.length) return "";
|
|
1191
|
-
// worktree path → sessions inside it, built once (not per cell, per row)
|
|
1192
|
-
const byPath = new Map(rows.map((w) => [w.path, []]));
|
|
1193
|
-
const paths = [...byPath.keys()];
|
|
1194
|
-
for (const s of state.sessions) {
|
|
1195
|
-
if (s.state === "ended") continue;
|
|
1196
|
-
for (const p of paths) if (s.cwd === p || s.cwd.startsWith(`${p}/`)) byPath.get(p).push(s);
|
|
1197
|
-
}
|
|
1198
|
-
const inside = (w) => byPath.get(w.path);
|
|
1199
|
-
const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
|
|
1200
|
-
const cols = [
|
|
1201
|
-
{ key: "project", label: "project", width: 104, get: (w) => projName(w.projectId), cell: (w) => projCell(w.projectId) },
|
|
1202
|
-
{ key: "branch", label: "branch", width: 240, get: (w) => w.branch ?? "", cell: (w) => `<span class="br">${esc(w.branch ?? "(detached)")}</span>${w.main ? ' <span class="badge">Main tree</span>' : ""}` },
|
|
1203
|
-
{ key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
|
|
1204
|
-
{ key: "path", label: "path", flex: true, get: (w) => w.path, cell: (w) => `<span class="now" title="${esc(w.path)}">${esc(short(w.path))}</span>` },
|
|
1205
|
-
{ key: "state", label: "state", width: 170, get: (w) => w.dirty * 1000 + w.ahead, cell: (w) => `${badge(w.dirty, "Dirty", "warn")}${badge(w.ahead, "Unpushed", "acc")}${w.dirty === 0 && w.ahead <= 0 ? '<span class="badge">Clean</span>' : ""}` },
|
|
1206
|
-
{ key: "drift", label: "drift", width: 120, get: (w) => (w.main ? -1 : w.behind), cell: (w) => (w.main ? "" : w.merged ? '<span class="badge" title="This branch is already in the main checkout\'s branch">Merged</span>' : w.behind > 0 ? `<span class="badge warn" title="Commits on the main checkout\'s branch this worktree lacks">${w.behind} behind</span>` : w.behind === 0 ? '<span class="badge">Up to date</span>' : '<span class="dim">—</span>') },
|
|
1207
|
-
{ key: "sessions", label: "sessions", width: 160, get: (w) => inside(w).length, cell: (w) => inside(w).map((x) => `<a href="#" data-s="${x.id}">${esc(x.title ?? x.id.slice(0, 8))}</a>`).join(", ") || '<span class="dim">—</span>' },
|
|
1208
|
-
].filter((c) => !(c.key === "project" && state.sel));
|
|
1209
|
-
const heldBy = new Map(state.claims ? state.claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]) : []);
|
|
1210
|
-
const gcBtn = state.sel ? ` <a href="#" class="nav" id="wtgc" title="Find worktrees whose branch is merged or whose claim is gone">${ic("trash", 12)} Collect stale</a>` : "";
|
|
1211
|
-
const newBtn = state.sel ? ` <a href="#" class="nav" id="wtnew" title="Create a task-less worktree (spike, review checkout)">${ic("plus", 12)} New worktree</a>` : "";
|
|
1212
|
-
const stateOf = (w) => (inside(w).length ? "live" : w.dirty > 0 ? "dirty" : w.ahead > 0 ? "ahead" : w.merged ? "merged" : "clean");
|
|
1213
|
-
const tile = (w) => `<div class="wt ${stateOf(w)}${w.main ? " main" : ""}${heldBy.has(w.path) ? " held" : ""}" tabindex="0" role="button" data-menu="worktree" data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}" title="${esc(w.path)}">
|
|
1214
|
-
<div class="wt-b"><span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span><span class="br">${esc(w.branch ?? "(detached)")}</span></div>
|
|
1215
|
-
<div class="wt-m">${w.main ? "main tree" : w.merged ? "merged" : w.behind > 0 ? `${w.behind} behind` : w.behind === 0 ? "up to date" : ""}${w.dirty ? ` · <i class="warn">${w.dirty} dirty</i>` : ""}${w.ahead > 0 ? ` · <i class="acc">${w.ahead} unpushed</i>` : ""}${heldBy.has(w.path) ? ` · held: ${esc(heldBy.get(w.path))}` : ""}${inside(w).length ? ` · ${inside(w).map((x) => esc(x.title ?? x.id.slice(0, 8))).join(", ")}` : ""}</div>
|
|
1216
|
-
</div>`;
|
|
1217
|
-
const map = () => {
|
|
1218
|
-
const groups = new Map();
|
|
1219
|
-
for (const w of rows) (groups.get(w.projectId) ?? groups.set(w.projectId, []).get(w.projectId)).push(w);
|
|
1220
|
-
const order = { live: 0, dirty: 1, ahead: 2, clean: 3, merged: 4 };
|
|
1221
|
-
return `<div class="wtmap">${[...groups].map(([pid, list]) => `<div class="wt-group"><div class="wt-proj">${projCell(pid)} <span>${list.length}</span></div><div class="wt-tiles">${list.sort((a, b) => (b.main - a.main) || order[stateOf(a)] - order[stateOf(b)]).map(tile).join("")}</div></div>`).join("")}</div>`;
|
|
1222
|
-
};
|
|
1223
|
-
return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}<span class="grow"></span>${modeSeg("worktrees", "Map", "Table")}</h2>` +
|
|
1224
|
-
(boardMode("worktrees") === "cards" ? map() :
|
|
1225
|
-
dataTable({
|
|
1226
|
-
id: "worktrees",
|
|
1227
|
-
columns: cols,
|
|
1228
|
-
rows,
|
|
1229
|
-
leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
|
|
1230
|
-
trailing: { width: 34, cell: (w) => more("worktree", `data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`) },
|
|
1231
|
-
rowAttrs: (w) => `data-ctx="worktree" data-pid="${esc(w.projectId)}" data-path="${esc(w.path)}"`,
|
|
1232
|
-
rerender: touch,
|
|
1233
|
-
}));
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// ---------- workflows (M7.8)
|
|
1237
|
-
function renderWorkflowRuns() {
|
|
1238
|
-
const w = state.workflows;
|
|
1239
|
-
if (!state.sel || !w?.runs?.length) return "";
|
|
1240
|
-
const chip = (r, i) => {
|
|
1241
|
-
const label = esc(r.steps[i]);
|
|
1242
|
-
if (i < r.step || (r.state === "done" && i <= r.step)) return `<span class="wfs ok" title="${label}">✓ ${label}</span>`;
|
|
1243
|
-
if (i === r.step) return r.state === "running" ? `<span class="wfs run" title="${label}">● ${label}</span>` : r.state === "failed" ? `<span class="wfs bad" title="${label}">✗ ${label}</span>` : `<span class="wfs" title="${label}">◦ ${label}</span>`;
|
|
1244
|
-
return `<span class="wfs" title="${label}">○ ${label}</span>`;
|
|
1245
|
-
};
|
|
1246
|
-
const badge = (r) => r.state === "running" ? '<span class="badge acc">Running</span>' : r.state === "done" ? '<span class="badge ok">Done</span>' : r.state === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge">Stopped</span>';
|
|
1247
|
-
const cols = [
|
|
1248
|
-
{ key: "task", label: "task", width: 90, get: (r) => r.task, cell: (r) => `<b>${esc(r.task)}</b>` },
|
|
1249
|
-
{ key: "workflow", label: "workflow", width: 100, get: (r) => r.workflow, cell: (r) => `<span class="br">${esc(r.workflow)}</span>` },
|
|
1250
|
-
{ key: "steps", label: "steps", flex: true, sortable: false, get: (r) => r.step, cell: (r) => `<span class="wf-steps">${r.steps.map((_, i) => chip(r, i)).join("")}</span>` },
|
|
1251
|
-
{ key: "state", label: "state", width: 90, get: (r) => r.state, cell: badge },
|
|
1252
|
-
{ key: "detail", label: "detail", width: 260, get: (r) => r.detail ?? "", cell: (r) => `<span class="dim now" title="${esc(r.detail ?? "")}">${esc(r.detail ?? "")}</span>` },
|
|
1253
|
-
{ key: "when", label: "updated", width: 76, get: (r) => r.updatedAt, cell: (r) => `<span class="dim">${ago(r.updatedAt)}</span>` },
|
|
1254
|
-
];
|
|
1255
|
-
const running = w.runs.filter((r) => r.state === "running").length;
|
|
1256
|
-
return `<h2 class="mt-sec">Workflows <span>${running ? `${running} running · ` : ""}${Object.keys(w.defs ?? {}).map(esc).join(", ") || "none declared"}</span></h2>` +
|
|
1257
|
-
dataTable({
|
|
1258
|
-
id: "workflows",
|
|
1259
|
-
columns: cols,
|
|
1260
|
-
rows: w.runs.slice(0, 20),
|
|
1261
|
-
leading: { width: 24, cell: (r) => `<span class="s ${r.state === "running" ? "active" : r.state === "failed" ? "waiting" : "ended"}"></span>` },
|
|
1262
|
-
trailing: { width: 60, cell: (r) => (r.state === "running" ? `<a href="#" data-wfstop="${esc(r.task)}">Stop</a>` : "") },
|
|
1263
|
-
rerender: touch,
|
|
1264
|
-
});
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// ---------- dispatch (M7.5)
|
|
1268
|
-
function renderDispatch() {
|
|
1269
|
-
const d = state.dispatch;
|
|
1270
|
-
if (!state.sel || !d?.entries?.length) return "";
|
|
1271
|
-
const rows = d.entries;
|
|
1272
|
-
const oc = (e) => e.state === "queued" ? '<span class="badge">Queued</span>'
|
|
1273
|
-
: e.state === "running" ? '<span class="badge acc">Running</span>'
|
|
1274
|
-
: e.outcome === "done" ? '<span class="badge ok">Done</span>'
|
|
1275
|
-
: e.outcome === "stopped" ? '<span class="badge">Stopped</span>'
|
|
1276
|
-
: `<span class="badge warn">${esc(e.outcome ?? "?")}</span>`;
|
|
1277
|
-
const cols = [
|
|
1278
|
-
{ key: "task", label: "task", width: 90, get: (e) => e.task, cell: (e) => `<b>${esc(e.task)}</b>` },
|
|
1279
|
-
{ key: "title", label: "title", flex: true, get: (e) => e.title, cell: (e) => esc(e.title) },
|
|
1280
|
-
{ key: "state", label: "state", width: 110, get: (e) => (e.state === "running" ? 0 : e.state === "queued" ? 1 : 2), cell: oc },
|
|
1281
|
-
{ key: "cost", label: "cost", width: 70, num: true, get: (e) => e.costUsd ?? -1, cell: (e) => (e.costUsd != null ? usd(e.costUsd) : '<span class="dim">—</span>') },
|
|
1282
|
-
{ key: "detail", label: "detail", width: 360, get: (e) => e.detail ?? "", cell: (e) => `<span class="dim" title="${esc(e.detail ?? "")}">${esc(e.detail ?? "")}</span>` },
|
|
1283
|
-
];
|
|
1284
|
-
const running = rows.filter((e) => e.state === "running").length, queued = rows.filter((e) => e.state === "queued").length;
|
|
1285
|
-
return `<h2 class="mt-sec hrow">Dispatch <span>${running} running · ${queued} queued · cap ${d.config?.max_parallel ?? 2}</span><a href="#" class="nav" id="dispatchClear" title="Drop queued tasks and clear finished rows (running ones keep going)">${ic("trash", 12)} Clear</a></h2>` +
|
|
1286
|
-
dataTable({
|
|
1287
|
-
id: "dispatch",
|
|
1288
|
-
columns: cols,
|
|
1289
|
-
rows,
|
|
1290
|
-
leading: { width: 24, cell: (e) => `<span class="s ${e.state === "running" ? "active" : e.state === "queued" ? "waiting" : e.outcome === "done" ? "ended" : "waiting"}"></span>` },
|
|
1291
|
-
trailing: { width: 90, cell: (e) => (e.sessionId ? `<a href="#" data-s="${esc(e.sessionId)}">session</a>` : "") },
|
|
1292
|
-
rerender: touch,
|
|
1293
|
-
});
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// 0.7.0: the project's [budget] ceiling against what it spent
|
|
1297
|
-
function budgetKpi(kpi) {
|
|
1298
|
-
const b = state.sel ? state.budget : null;
|
|
1299
|
-
if (!b?.status) return state.sel ? kpi("budget", "—", "no [budget] in .swarm.toml") : "";
|
|
1300
|
-
const s = b.status;
|
|
1301
|
-
const pct = Math.round(s.pct * 100);
|
|
1302
|
-
const cls = s.level === "exceeded" ? "warn" : s.level === "warn" ? "acc" : "";
|
|
1303
|
-
return kpi(`${s.kind} budget`, `<span class="${cls}">${pct}%</span>`, `${usd(s.spent)} of ${usd(s.limit)} · past it: ${b.config.on_exceed}`);
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
// ---------- spend
|
|
1307
|
-
function renderSpend() {
|
|
1308
|
-
const sp = state.spend;
|
|
1309
|
-
if (!sp) return;
|
|
1310
|
-
const inSel = (x) => !state.sel || x.projectId === state.sel;
|
|
1311
|
-
const filt = (arr) => (state.sel ? arr.filter((x) => x.key === state.sel) : arr);
|
|
1312
|
-
// last N days, zero-filled, stacked by agent
|
|
1313
|
-
const N = state.spendDays ?? 14;
|
|
1314
|
-
const days = [];
|
|
1315
|
-
for (let i = N - 1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(viz.localDay(d)); }
|
|
1316
|
-
const inRange = sp.daily.filter((d) => inSel(d) && d.day >= days[0]);
|
|
1317
|
-
const today = days.at(-1);
|
|
1318
|
-
// one pass: "day|agent" → cost, plus the headline sums
|
|
1319
|
-
const cell = new Map(), agentSet = new Set(), active = new Set();
|
|
1320
|
-
let total14 = 0, todayCost = 0, todayTurns = 0;
|
|
1321
|
-
for (const d of inRange) {
|
|
1322
|
-
const k = `${d.day}|${d.agent}`, c = d.cost ?? 0;
|
|
1323
|
-
cell.set(k, (cell.get(k) ?? 0) + c);
|
|
1324
|
-
agentSet.add(d.agent);
|
|
1325
|
-
total14 += c;
|
|
1326
|
-
if (c) active.add(d.day);
|
|
1327
|
-
if (d.day === today) { todayCost += c; todayTurns += d.turns ?? 0; }
|
|
1328
|
-
}
|
|
1329
|
-
const agents = [...agentSet].sort(viz.agentSort);
|
|
1330
|
-
const series = Object.fromEntries(agents.map((a) => [a, days.map((day) => cell.get(`${day}|${a}`) ?? 0)]));
|
|
1331
|
-
const activeDays = active.size;
|
|
1332
|
-
const prevDays = activeDays - (active.has(today) ? 1 : 0);
|
|
1333
|
-
const avg = prevDays ? (total14 - todayCost) / prevDays : 0;
|
|
1334
|
-
const rangeChips = `<span class="seg" style="margin-left:auto">${[7, 14, 30, 90].map((n) => `<a href="#" class="${N === n ? "on" : ""}" data-days="${n}">${n}d</a>`).join("")}</span>`;
|
|
1335
|
-
const byAgentToday = state.sel ? null : sp.byAgentToday;
|
|
1336
|
-
const kpi = (l, v, d) => `<div class="kpi"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1337
|
-
// Tables of the same shape share one grid id (sort/widths apply to both today/all-time).
|
|
1338
|
-
const tbl = (rows, label, name, color) =>
|
|
1339
|
-
dataTable({
|
|
1340
|
-
id: `spend-${label}`,
|
|
1341
|
-
columns: [
|
|
1342
|
-
{ key: "key", label, flex: true, get: (r) => name(r.key), cell: (r) => `${color ? `<i class="sw" style="background:${color(r.key)}"></i>` : ""}${esc(name(r.key))}` },
|
|
1343
|
-
{ key: "cost", label: "cost", width: 88, num: true, get: (r) => r.cost ?? 0, cell: (r) => usd(r.cost) },
|
|
1344
|
-
{ key: "input", label: "in+cache", width: 88, num: true, get: (r) => r.input ?? 0, cell: (r) => tok(r.input) },
|
|
1345
|
-
{ key: "output", label: "out", width: 84, num: true, get: (r) => r.output ?? 0, cell: (r) => tok(r.output) },
|
|
1346
|
-
{ key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns ?? 0, cell: (r) => String(r.turns) },
|
|
1347
|
-
],
|
|
1348
|
-
rows: rows.slice().sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0)),
|
|
1349
|
-
trailing: { width: 34, cell: () => "" },
|
|
1350
|
-
rerender: touch,
|
|
1351
|
-
});
|
|
1352
|
-
const hm = sp.hourly.filter(inSel).map((c) => ({ dow: c.dow, hour: c.hour, v: c.cost ?? 0 }));
|
|
1353
|
-
$("#main").innerHTML =
|
|
1354
|
-
`<h2>Spend <span>${state.sel ? esc(projName(state.sel)) : "all projects"}</span>${rangeChips}</h2>
|
|
1355
|
-
<div class="kpis">${kpi("today", usd(todayCost), `${todayTurns} turns`)}${kpi(`${N}-day total`, usd(total14), `${activeDays} active day${activeDays === 1 ? "" : "s"}`)}${kpi("today vs avg", prevDays ? `${todayCost >= avg ? "+" : ""}${(((todayCost - avg) / avg) * 100).toFixed(0)}%` : "—", prevDays ? `vs ${usd(avg)} / active day` : "no earlier days to compare")}${kpi("agents", agents.length, agents.map(agentLabel).join(" · ") || "—")}${budgetKpi(kpi)}</div>
|
|
1356
|
-
<div class="chart-card"><h3>Daily cost · last ${N} days <span>stacked by agent</span></h3>${viz.stackedColumns(days, series)}${agents.length > 1 ? viz.legend(agents) : ""}</div>
|
|
1357
|
-
<div class="cols">
|
|
1358
|
-
<div>
|
|
1359
|
-
<div class="chart-card" style="margin:0"><h3>When the agents work <span>cost by weekday × hour · last 4 weeks · local time</span></h3>${viz.heatmap(hm)}</div>
|
|
1360
|
-
<h2 class="mt-sec">By project · today <span>${usd(sumBy(filt(sp.byProjectToday), (x) => x.cost))}</span></h2>${tbl(filt(sp.byProjectToday), "project", projName)}
|
|
1361
|
-
<h2 class="mt-sec">By project · all time</h2>${tbl(filt(sp.byProjectAll), "project", projName)}
|
|
1362
|
-
</div>
|
|
1363
|
-
<div>
|
|
1364
|
-
${byAgentToday ? `<h2>By agent · today <span>${usd(sumBy(byAgentToday, (x) => x.cost))}</span></h2>${tbl(byAgentToday, "agent", agentLabel, viz.agentColor)}<h2 class="mt-sec">By agent · all time</h2>${tbl(sp.byAgentAll, "agent", agentLabel, viz.agentColor)}<h2 class="mt-sec">By model · today</h2>${tbl(sp.byModelToday, "model", model)}` : `<h2>By model · today</h2>${tbl(sp.byModelToday, "model", model)}`}
|
|
1365
|
-
<h2 class="mt-sec">By model · all time</h2>${tbl(sp.byModelAll, "model", model)}
|
|
1366
|
-
</div>
|
|
1367
|
-
</div>
|
|
1368
|
-
${renderAttribution()}
|
|
1369
|
-
<p class="dim" style="margin-top:var(--gap-sec)">Costs use list prices (static table, refreshed from LiteLLM when online; override in <code>~/.swarm/pricing.json</code>). Cache reads are the bulk of "ctx". Sessions on a subscription plan still show what the tokens would cost at API rates.</p>`;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// M4.2: cost attributed to tasks (via each claim's worktree) + a context re-processing signal.
|
|
1373
|
-
// Only meaningful with a project selected.
|
|
1374
|
-
function renderAttribution() {
|
|
1375
|
-
const a = state.attribution;
|
|
1376
|
-
if (!state.sel || !a) return "";
|
|
1377
|
-
const parts = [];
|
|
1378
|
-
if (a.byTask?.length) {
|
|
1379
|
-
parts.push(`<h2 class="mt-sec">By task <span>${usd(sumBy(a.byTask, (t) => t.cost))} across ${a.byTask.length} task${a.byTask.length === 1 ? "" : "s"} · attributed by worktree</span></h2>` +
|
|
1380
|
-
dataTable({
|
|
1381
|
-
id: "spend-task",
|
|
1382
|
-
columns: [
|
|
1383
|
-
{ key: "task", label: "task", width: 150, get: (t) => t.task, cell: (t) => `<b>${esc(t.task)}</b>` },
|
|
1384
|
-
{ key: "owner", label: "owner", width: 120, get: (t) => t.owner || "", cell: (t) => esc(t.owner || "—") },
|
|
1385
|
-
{ key: "cost", label: "cost", width: 88, num: true, get: (t) => t.cost, cell: (t) => usd(t.cost) },
|
|
1386
|
-
{ key: "output", label: "out", width: 84, num: true, get: (t) => t.output, cell: (t) => tok(t.output) },
|
|
1387
|
-
{ key: "sessions", label: "sessions", width: 84, num: true, get: (t) => t.sessions, cell: (t) => String(t.sessions) },
|
|
1388
|
-
{ key: "turns", label: "turns", width: 64, num: true, get: (t) => t.turns, cell: (t) => String(t.turns) },
|
|
1389
|
-
{ key: "worktree", label: "worktree", flex: true, get: (t) => t.worktree, cell: (t) => `<span class="now dim" title="${esc(t.worktree)}">${esc(short(t.worktree))}</span>` },
|
|
1390
|
-
],
|
|
1391
|
-
rows: a.byTask,
|
|
1392
|
-
trailing: { width: 8, cell: () => "" },
|
|
1393
|
-
rerender: touch,
|
|
1394
|
-
}));
|
|
1395
|
-
}
|
|
1396
|
-
if (a.contextBudget?.length) {
|
|
1397
|
-
parts.push(`<h2 class="mt-sec">Context budget <span>sessions re-processing the most context · a high reuse % is a lot of re-reading</span></h2>` +
|
|
1398
|
-
dataTable({
|
|
1399
|
-
id: "spend-ctx",
|
|
1400
|
-
columns: [
|
|
1401
|
-
{ key: "title", label: "session", flex: true, get: (r) => r.title ?? r.id, cell: (r) => `<a href="#" data-s="${r.id}">${esc(r.title ?? r.id.slice(0, 8))}</a>` },
|
|
1402
|
-
{ key: "reuse", label: "reuse", width: 90, num: true, get: (r) => r.reuse, cell: (r) => `<span class="${r.reuse > 0.9 ? "br" : "dim"}">${(r.reuse * 100).toFixed(0)}%</span>` },
|
|
1403
|
-
{ key: "cacheRead", label: "context re-read", width: 120, num: true, get: (r) => r.cacheRead, cell: (r) => tok(r.cacheRead) },
|
|
1404
|
-
{ key: "cost", label: "cost", width: 88, num: true, get: (r) => r.cost, cell: (r) => usd(r.cost) },
|
|
1405
|
-
{ key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
|
|
1406
|
-
],
|
|
1407
|
-
rows: a.contextBudget,
|
|
1408
|
-
trailing: { width: 8, cell: () => "" },
|
|
1409
|
-
rerender: touch,
|
|
1410
|
-
}));
|
|
1411
|
-
}
|
|
1412
|
-
return parts.join("");
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
// ---------- stats
|
|
1416
|
-
// Heavier than the 5s snapshot, so it has its own endpoint: fetched when the view opens (per project
|
|
1417
|
-
// scope), then refreshed at most every 30s while the view stays open.
|
|
1418
|
-
const statsCache = { key: null, at: 0, data: null, busy: false };
|
|
1419
|
-
async function loadStats() {
|
|
1420
|
-
const key = state.sel ?? "";
|
|
1421
|
-
if (statsCache.busy || (statsCache.key === key && Date.now() - statsCache.at < 30_000)) return;
|
|
1422
|
-
statsCache.busy = true;
|
|
1423
|
-
try {
|
|
1424
|
-
const data = await (await fetch(`/v1/stats${key ? `?project=${encodeURIComponent(key)}` : ""}`)).json();
|
|
1425
|
-
Object.assign(statsCache, { key, at: Date.now(), data });
|
|
1426
|
-
if (state.view === "stats" && !state.session) touch();
|
|
1427
|
-
} finally { statsCache.busy = false; }
|
|
1428
|
-
}
|
|
1429
|
-
const big = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n)));
|
|
1430
|
-
const toolName = (t) => String(t).replace(/^mcp__([^_]+(?:_[^_]+)*)__/, "$1 · ").replace(/^plugin_/, "");
|
|
1431
|
-
const pct = (a, b) => (b ? `${((100 * a) / b).toFixed(0)}%` : "—");
|
|
1432
|
-
const dur = (ms) => (ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : ms < 86400e3 ? `${(ms / 3600e3).toFixed(1)}h` : `${(ms / 86400e3).toFixed(1)}d`);
|
|
1433
|
-
// ---------- search view (M4.5): memory over Swarm's own data — handoffs, incidents, gates, what sessions said
|
|
1434
|
-
const srch = { q: "", kind: "", hits: null, t: 0 };
|
|
1435
|
-
function renderSearch() {
|
|
1436
|
-
const chip = (k, label) => `<span class="chip ${srch.kind === k ? "on" : ""}" data-skind="${k}">${label}</span>`;
|
|
1437
|
-
const mark = (s) => esc(s).replace(/\u0001/g, "<mark>").replace(/\u0002/g, "</mark>");
|
|
1438
|
-
const link = (h) => h.kind === "session" ? `data-s="${esc(h.ref)}"` : h.sessionId ? `data-s="${esc(h.sessionId)}"` : "";
|
|
1439
|
-
const hits = (srch.hits ?? []).map((h) => `<div class="hit"><div class="ht"><span class="badge">${esc(h.kind)}</span><b>${esc(h.title)}</b>${h.task ? `<span class="br">${esc(h.task)}</span>` : ""}<span class="grow"></span>${!state.sel ? `<span class="dim">${esc(projName(h.projectId))} · </span>` : ""}<span class="dim">${ago(h.ts)}</span>${link(h) ? `<a href="#" ${link(h)} title="Open the session">${ic("arrow-right", 12)}</a>` : ""}</div><div class="hs">${mark(h.snippet)}</div></div>`).join("");
|
|
1440
|
-
const had = document.activeElement?.id === "srchQ" ? { pos: document.activeElement.selectionStart } : null;
|
|
1441
|
-
$("#main").innerHTML =
|
|
1442
|
-
`<h2>Search <span>Swarm's own memory${state.sel ? ` · ${esc(projName(state.sel))}` : " · all projects"} — handoffs, incidents, gates, what sessions said. Never your code.</span></h2>` +
|
|
1443
|
-
`<div class="srch"><input id="srchQ" type="search" placeholder="pkill, login form, kind:incident git reset, task:M1.2 …" value="${esc(srch.q)}" autocomplete="off"></div>` +
|
|
1444
|
-
`<div class="chips">${chip("", "All")}${chip("handoff", "Handoffs")}${chip("incident", "Incidents")}${chip("gate", "Gates")}${chip("session", "Sessions")}</div>` +
|
|
1445
|
-
(srch.hits === null ? `<div class="empty">${PX.idle()}Type to search. Words are AND-ed, the last one is a prefix; quote a phrase; <code>kind:</code> and <code>task:</code> filter.</div>`
|
|
1446
|
-
: hits || `<div class="empty">${PX.idle()}Nothing in memory matches <b>${esc(srch.q)}</b>.</div>`);
|
|
1447
|
-
if (had) { const i = $("#srchQ"); i.focus(); i.setSelectionRange(had.pos, had.pos); }
|
|
1448
|
-
}
|
|
1449
|
-
async function runSearch() {
|
|
1450
|
-
if (!srch.q.trim()) { srch.hits = null; return renderSearch(); }
|
|
1451
|
-
const q = new URLSearchParams({ q: srch.q, limit: "50" });
|
|
1452
|
-
if (state.sel) q.set("project", state.sel);
|
|
1453
|
-
if (srch.kind) q.set("kind", srch.kind);
|
|
1454
|
-
const mine = ++srch.t;
|
|
1455
|
-
const j = await fetch(`/v1/memory?${q}`).then((r) => r.json()).catch(() => ({ hits: [] }));
|
|
1456
|
-
if (mine !== srch.t) return;
|
|
1457
|
-
srch.hits = j.hits ?? [];
|
|
1458
|
-
if (state.view === "search" && !state.session) renderSearch();
|
|
1459
|
-
}
|
|
1460
|
-
document.addEventListener("change", async (ev) => {
|
|
1461
|
-
if (ev.target.id !== "psFile" || !ev.target.files?.[0]) return;
|
|
1462
|
-
try { const d = await fileToIconDataUrl(ev.target.files[0]); $("#psImage").value = d; $("#psIcon").value = ""; setIconPreview(d); for (const e of $$(".emoji")) e.classList.remove("on"); }
|
|
1463
|
-
catch (e) { alert(e.message); }
|
|
1464
|
-
});
|
|
1465
|
-
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()); } });
|
|
1466
|
-
document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
|
|
1467
|
-
// M9.4: how much of the fleet's time is spent waiting on a person, and on what. Blocked time is
|
|
1468
|
-
// not idle time — it is the agent standing still with the work half-done, which is why it gets a
|
|
1469
|
-
// number rather than a footnote.
|
|
1470
|
-
function waitingSection() {
|
|
1471
|
-
const w = state.waiting;
|
|
1472
|
-
if (!w?.totals?.episodes) return "";
|
|
1473
|
-
const t = w.totals;
|
|
1474
|
-
const kindRow = (k, label) => {
|
|
1475
|
-
const x = t.byKind[k];
|
|
1476
|
-
return x?.episodes ? `<tr><td>${label}</td><td class="num">${x.episodes}</td><td class="num">${dur(x.blockedMs)}</td></tr>` : "";
|
|
1477
|
-
};
|
|
1478
|
-
const top = w.sessions.slice(0, 8).map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
|
|
1479
|
-
<td>${esc(s.title ?? s.sessionId.slice(0, 8))}${s.openSince ? ` <span class="badge warn">waiting ${ago(s.openSince)}</span>` : ""}</td>
|
|
1480
|
-
<td class="num">${s.episodes}</td><td class="num">${dur(s.blockedMs)}</td><td class="num">${dur(s.longestMs)}</td></tr>`).join("");
|
|
1481
|
-
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>
|
|
1482
|
-
<div class="cols">
|
|
1483
|
-
<div class="chart-card" style="margin:0"><h3>By what blocked them</h3>
|
|
1484
|
-
<table class="mini"><thead><tr><th>kind</th><th class="num">times</th><th class="num">blocked</th></tr></thead>
|
|
1485
|
-
<tbody>${kindRow("permission", "Permission prompt")}${kindRow("question", "Question it asked")}${kindRow("notification", "Notification")}
|
|
1486
|
-
<tr><td><b>Total</b></td><td class="num"><b>${t.episodes}</b></td><td class="num"><b>${dur(t.blockedMs)}</b></td></tr>
|
|
1487
|
-
<tr><td class="dim">median wait</td><td class="num"></td><td class="num dim">${dur(t.medianMs)}</td></tr>
|
|
1488
|
-
<tr><td class="dim">longest wait</td><td class="num"></td><td class="num dim">${dur(t.longestMs)}</td></tr>
|
|
1489
|
-
</tbody></table></div>
|
|
1490
|
-
<div class="chart-card" style="margin:0"><h3>Sessions that waited most</h3>
|
|
1491
|
-
<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>
|
|
1492
|
-
<tbody>${top}</tbody></table></div>
|
|
1493
|
-
</div>`;
|
|
1494
|
-
}
|
|
1495
|
-
function renderStats() {
|
|
1496
|
-
const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
|
|
1497
|
-
const scope = state.sel ? esc(projName(state.sel)) : "all projects";
|
|
1498
|
-
if (!st) { $("#main").innerHTML = `<h2>Stats <span>${scope}</span></h2><div class="empty">${PX.clock()}Crunching numbers…</div>`; return; }
|
|
1499
|
-
const T = st.totals;
|
|
1500
|
-
if (!T.turns) { $("#main").innerHTML = `<h2>Stats <span>${scope}</span></h2><div class="empty">${PX.clock()}No turns recorded yet. Numbers appear once a session is transcribed.</div>`; return; }
|
|
1501
|
-
const N = state.statsDays ?? 90;
|
|
1502
|
-
const days = [];
|
|
1503
|
-
for (let i = N - 1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(viz.localDay(d)); }
|
|
1504
|
-
const byDay = Object.fromEntries(st.daily.map((d) => [d.day, d]));
|
|
1505
|
-
const pick = (k) => days.map((d) => byDay[d]?.[k] ?? 0);
|
|
1506
|
-
const rangeChips = `<span class="seg" style="margin-left:auto">${[30, 90, 365].map((n) => `<a href="#" class="${N === n ? "on" : ""}" data-sdays="${n}">${n}d</a>`).join("")}</span>`;
|
|
1507
|
-
const kpi = (l, v, d) => `<div class="kpi"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1508
|
-
|
|
1509
|
-
// ---- headline numbers
|
|
1510
|
-
const allTok = T.input + T.cacheWrite + T.cacheRead + T.output;
|
|
1511
|
-
const since = T.firstTs ? new Date(T.firstTs) : null;
|
|
1512
|
-
const spanDays = since ? Math.max(1, Math.round((Date.now() - since) / 86400e3)) : 1;
|
|
1513
|
-
const activeDays = st.daily.filter((d) => d.turns).map((d) => d.day);
|
|
1514
|
-
const sk = viz.streaks(activeDays);
|
|
1515
|
-
const costDays = Object.fromEntries(st.daily.map((d) => [d.day, d.cost ?? 0]));
|
|
1516
|
-
const kpis =
|
|
1517
|
-
kpi("all-time spend", usd(T.cost), `since ${since ? since.toISOString().slice(0, 10) : "—"} · ${usd((T.cost ?? 0) / spanDays)}/day`) +
|
|
1518
|
-
kpi("tokens processed", big(allTok), `${big(T.output)} out · ${big(T.cacheRead)} cache read`) +
|
|
1519
|
-
kpi("turns", big(T.turns), `${T.sessions} sessions · ${big(T.toolCalls)} tool calls`) +
|
|
1520
|
-
kpi("streak", `${sk.current}d`, `longest ${sk.longest}d · ${activeDays.length} active day${activeDays.length === 1 ? "" : "s"} this year`);
|
|
1521
|
-
|
|
1522
|
-
// ---- fun equivalents (a token ≈ 0.75 words; a novel ≈ 90k words; War and Peace ≈ 587k words)
|
|
1523
|
-
const words = T.output * 0.75;
|
|
1524
|
-
const novels = words / 90_000;
|
|
1525
|
-
const ctxWords = (T.input + T.cacheRead + T.cacheWrite) * 0.75;
|
|
1526
|
-
const wp = ctxWords / 587_000;
|
|
1527
|
-
const coffees = (T.cost ?? 0) / 5;
|
|
1528
|
-
const fun =
|
|
1529
|
-
kpi("words written", big(words), novels >= 1 ? `≈ ${novels.toFixed(novels < 10 ? 1 : 0)} novels` : `≈ ${(words / 300).toFixed(0)} pages`) +
|
|
1530
|
-
kpi("context re-read", `${big(ctxWords)} words`, wp >= 1 ? `≈ ${wp.toFixed(wp < 10 ? 1 : 0)}× War and Peace` : `≈ ${(ctxWords / 300).toFixed(0)} pages`) +
|
|
1531
|
-
kpi("thinking share", pct(T.thinking, T.output), `${tok(T.thinking)} reasoning tokens · cache hit ${pct(T.cacheRead, T.input + T.cacheRead + T.cacheWrite)}`) +
|
|
1532
|
-
kpi("in coffee", `${coffees >= 100 ? coffees.toFixed(0) : coffees.toFixed(1)} ☕`, `at $5 a cup · ${T.subagents} subagents spawned`);
|
|
1533
|
-
|
|
1534
|
-
// ---- charts
|
|
1535
|
-
const classColor = { output: "var(--acc-5)", input: "var(--acc-3)", cacheWrite: "var(--acc-2)", cacheRead: "var(--acc-1)" };
|
|
1536
|
-
const className = { output: "output", input: "input", cacheWrite: "cache write", cacheRead: "cache read" };
|
|
1537
|
-
const order = ["output", "input", "cacheWrite", "cacheRead"];
|
|
1538
|
-
const tokOpts = { fmt: tok, color: (k) => classColor[k], name: (k) => className[k], sort: (a, b) => order.indexOf(a) - order.indexOf(b) };
|
|
1539
|
-
const tokSeries = Object.fromEntries(order.map((k) => [k, pick(k)]));
|
|
1540
|
-
let acc = 0;
|
|
1541
|
-
const cum = days.map((d) => (acc += byDay[d]?.cost ?? 0));
|
|
1542
|
-
const hours = Array.from({ length: 24 }, (_, h) => String(h).padStart(2, "0"));
|
|
1543
|
-
const hourSeries = { turns: hours.map((_, h) => st.byHour.find((x) => x.hour === h)?.turns ?? 0) };
|
|
1544
|
-
const peakHour = hourSeries.turns.indexOf(Math.max(...hourSeries.turns));
|
|
1545
|
-
const hourOpts = { fmt: (n) => String(Math.round(n)), color: () => "var(--acc)", name: () => "turns", label: (h) => (Number(h) % 3 ? "" : h), sort: () => 0 };
|
|
1546
|
-
const models = st.byModel.filter((m) => m.model).map((m) => ({ label: `${model(m.model)} · ${m.turns} turns`, v: m.output }));
|
|
1547
|
-
const comp = [{ label: "cache read", v: T.cacheRead }, { label: "cache write", v: T.cacheWrite }, { label: "input", v: T.input }, { label: "output", v: T.output }];
|
|
1548
|
-
|
|
1549
|
-
// ---- records
|
|
1550
|
-
const R = st.records;
|
|
1551
|
-
const sessLink = (r) => (r ? `<a href="#" data-s="${r.id}">${esc(r.title || r.id.slice(0, 8))}</a>` : "—");
|
|
1552
|
-
const rec = (l, v, d) => `<div class="rec"><div class="l">${l}</div><div class="v">${v}</div><div class="d">${d}</div></div>`;
|
|
1553
|
-
const wall = R.longestWallSession ? new Date(R.longestWallSession.lastSeenAt) - new Date(R.longestWallSession.startedAt) : 0;
|
|
1554
|
-
const bt = R.biggestTurn;
|
|
1555
|
-
const records =
|
|
1556
|
-
rec("costliest session", usd(R.costliestSession?.cost), sessLink(R.costliestSession)) +
|
|
1557
|
-
rec("most turns in a session", R.longestSession ? String(R.longestSession.turns) : "—", sessLink(R.longestSession)) +
|
|
1558
|
-
rec("longest session", wall > 0 ? dur(wall) : "—", sessLink(R.longestWallSession)) +
|
|
1559
|
-
rec("biggest single turn", bt ? `${tok(bt.output)} out` : "—", bt ? `${esc(model(bt.model))} · <a href="#" data-s="${bt.sessionId}">${esc(bt.title || bt.sessionId.slice(0, 8))}</a>` : "—") +
|
|
1560
|
-
rec("busiest day", R.busiestDay ? usd(R.busiestDay.cost) : "—", R.busiestDay ? `${R.busiestDay.day} · ${R.busiestDay.turns} turns` : "—") +
|
|
1561
|
-
rec("favourite hour", `${hours[peakHour]}:00`, `${hourSeries.turns[peakHour]} turns in that hour, all time`);
|
|
1562
|
-
|
|
1563
|
-
$("#main").innerHTML =
|
|
1564
|
-
`<h2>Stats <span>${scope}</span>${rangeChips}</h2>
|
|
1565
|
-
<div class="kpis">${kpis}</div>
|
|
1566
|
-
<div class="kpis">${fun}</div>
|
|
1567
|
-
<div class="chart-card"><h3>Activity <span>cost per day · last 52 weeks</span></h3>${viz.calendar(costDays)}</div>
|
|
1568
|
-
<div class="chart-card"><h3>Tokens per day <span>last ${N} days · by class</span></h3>${viz.stackedColumns(days, tokSeries, tokOpts)}${viz.legend(order, tokOpts.name, tokOpts.color)}</div>
|
|
1569
|
-
<div class="cols">
|
|
1570
|
-
<div class="chart-card" style="margin:0"><h3>Output tokens per day <span>last ${N} days</span></h3>${viz.stackedColumns(days, { output: pick("output") }, tokOpts)}</div>
|
|
1571
|
-
<div class="chart-card" style="margin:0"><h3>Cumulative spend <span>last ${N} days</span></h3>${viz.line(days, cum)}</div>
|
|
1572
|
-
</div>
|
|
1573
|
-
<div class="cols mt-sec">
|
|
1574
|
-
<div class="chart-card" style="margin:0"><h3>Turns by hour of day <span>all time · local</span></h3>${viz.stackedColumns(hours, hourSeries, hourOpts)}</div>
|
|
1575
|
-
<div class="chart-card" style="margin:0"><h3>Model mix <span>by output tokens · all time</span></h3>${viz.compositionBar(models)}
|
|
1576
|
-
<h3 style="margin-top:14px">Token composition <span>all time</span></h3>${viz.compositionBar(comp)}</div>
|
|
1577
|
-
</div>
|
|
1578
|
-
<div class="cols mt-sec">
|
|
1579
|
-
<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>
|
|
1580
|
-
<div><h2 style="margin-top:0">Records</h2><div class="records">${records}</div></div>
|
|
1581
|
-
</div>
|
|
1582
|
-
${waitingSection()}
|
|
1583
|
-
<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>`;
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
// ---------- timeline
|
|
1587
|
-
let tlDetail = { key: "", data: null, busy: false };
|
|
1588
|
-
async function loadTimelineDetail() {
|
|
1589
|
-
const hours = state.tlHours ?? 12;
|
|
1590
|
-
const key = `${hours}:${state.sel ?? ""}`;
|
|
1591
|
-
if (tlDetail.busy || (tlDetail.key === key && tlDetail.at && Date.now() - tlDetail.at < 15_000)) return;
|
|
1592
|
-
tlDetail.busy = true;
|
|
1593
|
-
try {
|
|
1594
|
-
const q = new URLSearchParams({ hours: String(hours) });
|
|
1595
|
-
if (state.sel) q.set("project", state.sel);
|
|
1596
|
-
const data = await (await fetch(`/v1/timeline?${q}`)).json();
|
|
1597
|
-
tlDetail = { key, at: Date.now(), data, busy: false };
|
|
1598
|
-
if (state.view === "timeline" && !state.session) touch();
|
|
1599
|
-
} finally { tlDetail.busy = false; }
|
|
1600
|
-
}
|
|
1601
|
-
// M9.2: Outcomes — did the agent's work survive? Branch → PR → merged / reverted, with
|
|
1602
|
-
// scorecards per model and per agent. Data from /v1/outcomes (fetched by the poll while open).
|
|
1603
|
-
const outBadge = (o) => ({ merged: '<span class="badge ok">merged</span>', reverted: '<span class="badge bad">reverted</span>', open: '<span class="badge acc">open</span>', "no-pr": '<span class="badge">no PR</span>' })[o] ?? esc(o);
|
|
1604
|
-
const ratePct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
|
|
1605
|
-
const hrs = (x) => (x == null || x < 0 ? "—" : x < 1 ? `${Math.round(x * 60)}m` : x < 48 ? `${x.toFixed(1)}h` : `${(x / 24).toFixed(1)}d`);
|
|
1606
|
-
const scoreCols = (label) => [
|
|
1607
|
-
{ key: "key", label, width: 150, get: (r) => r.key, cell: (r) => `<b>${esc(label === "model" ? model(r.key) : viz.agentName(r.key))}</b>` },
|
|
1608
|
-
{ key: "branches", label: "branches", width: 80, num: true, get: (r) => r.branches, cell: (r) => String(r.branches) },
|
|
1609
|
-
{ key: "merged", label: "merged", width: 72, num: true, get: (r) => r.merged, cell: (r) => String(r.merged) },
|
|
1610
|
-
{ key: "reverted", label: "reverted", width: 78, num: true, get: (r) => r.reverted, cell: (r) => (r.reverted ? `<b style="color:var(--bad)">${r.reverted}</b>` : "0") },
|
|
1611
|
-
{ key: "open", label: "open", width: 60, num: true, get: (r) => r.open, cell: (r) => String(r.open) },
|
|
1612
|
-
{ key: "nopr", label: "no PR", width: 64, num: true, get: (r) => r.noPr, cell: (r) => String(r.noPr) },
|
|
1613
|
-
{ key: "rate", label: "merge rate", width: 92, num: true, get: (r) => r.mergeRate ?? -1, cell: (r) => ratePct(r.mergeRate) },
|
|
1614
|
-
{ key: "lead", label: "median lead", width: 98, num: true, get: (r) => r.medianLeadHours ?? -1, cell: (r) => hrs(r.medianLeadHours) },
|
|
1615
|
-
{ key: "cpm", label: "$ / merge", width: 84, num: true, get: (r) => r.costPerMerge ?? -1, cell: (r) => (r.costPerMerge == null ? "—" : usd(r.costPerMerge)) },
|
|
1616
|
-
];
|
|
1617
|
-
const BRANCH_COLS = [
|
|
1618
|
-
{ key: "branch", label: "branch", width: 190, get: (r) => r.branch, cell: (r) => `<span class="br">${esc(r.branch)}</span>` },
|
|
1619
|
-
{ key: "outcome", label: "outcome", width: 92, cls: "td-badge", get: (r) => r.outcome, cell: (r) => outBadge(r.outcome) },
|
|
1620
|
-
{ key: "pr", label: "PR", flex: true, get: (r) => r.title ?? "", cell: (r) => (r.prNumber ? `<a href="${esc(r.url ?? "#")}" target="_blank" rel="noreferrer">#${r.prNumber}</a> <span class="dim">${esc(r.title ?? "")}</span>` : '<span class="faint">—</span>') },
|
|
1621
|
-
{ key: "model", label: "model", width: 92, get: (r) => model(r.model), cell: (r) => `<span class="br">${esc(model(r.model))}</span>` },
|
|
1622
|
-
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (r) => agentLabel(r.agent), cell: (r) => agentBadge(r.agent) },
|
|
1623
|
-
{ key: "sessions", label: "sessions", width: 76, num: true, get: (r) => r.sessions.length, cell: (r) => String(r.sessions.length) },
|
|
1624
|
-
{ key: "cost", label: "cost", width: 64, num: true, get: (r) => r.costUsd, cell: (r) => usd(r.costUsd) },
|
|
1625
|
-
{ key: "lead", label: "lead", width: 64, num: true, get: (r) => r.leadHours ?? -1, cell: (r) => hrs(r.leadHours) },
|
|
1626
|
-
];
|
|
1627
|
-
function renderOutcomes() {
|
|
1628
|
-
const o = state.outcomes;
|
|
1629
|
-
const head = (sub) => `<h2>Outcomes <span>${sub}</span></h2>`;
|
|
1630
|
-
if (!o) {
|
|
1631
|
-
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}Loading…</div>`;
|
|
1632
|
-
return;
|
|
1633
|
-
}
|
|
1634
|
-
if (!o.branches?.length) {
|
|
1635
|
-
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}No agent branches yet${state.sel ? " in this project" : ""}.<br>Outcomes fill in as sessions work on branches and their PRs merge — or get reverted.</div>`;
|
|
1636
|
-
return;
|
|
1637
|
-
}
|
|
1638
|
-
const n = (k) => o.branches.filter((b) => b.outcome === k).length;
|
|
1639
|
-
const rev = n("reverted");
|
|
1640
|
-
$("#main").innerHTML =
|
|
1641
|
-
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`) +
|
|
1642
|
-
`<h2 class="mt-sec">By model <span>who ships work that survives</span></h2>` +
|
|
1643
|
-
dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel, rerender: touch }) +
|
|
1644
|
-
(o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent, rerender: touch })}` : "") +
|
|
1645
|
-
`<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
|
|
1646
|
-
dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100), rerender: touch });
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
// M9.5: where the context window goes. Character counts are exact (every tool response is stored);
|
|
1650
|
-
// the token figures are a flat 4:1 estimate and say so. Re-reading a file is the waste metric —
|
|
1651
|
-
// the first read is work, every copy after it is the price of having forgotten.
|
|
1652
|
-
// `toolName` puts the server first, so four MCP tools all truncated to "claude-in-c…" and the
|
|
1653
|
-
// half that tells them apart was the half cut off. Lead with the tool, keep a short server hint.
|
|
1654
|
-
function ctxToolLabel(tool) {
|
|
1655
|
-
const m = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(tool);
|
|
1656
|
-
if (!m) return tool;
|
|
1657
|
-
const srv = m[1].replace(/[-_]/g, " ").split(" ").map((w) => w[0]).join("").toLowerCase();
|
|
1658
|
-
return `${m[2]} · ${srv}`;
|
|
1659
|
-
}
|
|
1660
|
-
function renderContext() {
|
|
1661
|
-
const c = state.context;
|
|
1662
|
-
const head = (sub) => `<h2>Context <span>${sub}</span></h2>`;
|
|
1663
|
-
if (!c) { $("#main").innerHTML = head("where the window goes") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1664
|
-
if (!c.totals.sessions) {
|
|
1665
|
-
$("#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>`;
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
const t = c.totals;
|
|
1669
|
-
const chars = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n));
|
|
1670
|
-
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>`;
|
|
1671
|
-
const kpis = `<div class="kpis">${
|
|
1672
|
-
kpi("Returned by tools", `${chars(t.toolChars)}`, `characters · ≈${chars(t.toolTokens)} tokens`)
|
|
1673
|
-
}${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" : "")
|
|
1674
|
-
}${kpi("Cache hit", `${Math.round(t.cacheHit * 100)}%`, "of the window came back free")
|
|
1675
|
-
}${kpi("Sessions", t.sessions, "with tool activity")}</div>`;
|
|
1676
|
-
|
|
1677
|
-
const worst = c.sessions.filter((s) => s.wastedChars > 0).slice(0, 10);
|
|
1678
|
-
const rows = worst.map((s) => `<tr${s.sessionId ? ` data-s="${esc(s.sessionId)}"` : ""}>
|
|
1679
|
-
<td>${esc(s.title ?? s.sessionId.slice(0, 8))}</td>
|
|
1680
|
-
<td class="num">${chars(s.toolChars)}</td>
|
|
1681
|
-
<td class="num"><b>${chars(s.wastedChars)}</b></td>
|
|
1682
|
-
<td class="num">${Math.round(s.wasteShare * 100)}%</td>
|
|
1683
|
-
<td class="clip">${s.worst.slice(0, 2).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>
|
|
1684
|
-
</tr>`).join("");
|
|
1685
|
-
|
|
1686
|
-
$("#main").innerHTML = head(`last 7 days · ${chars(t.toolChars)} characters returned by tools`) + kpis +
|
|
1687
|
-
`<div class="cols">
|
|
1688
|
-
<div class="chart-card" style="margin:0"><h3>What fills the window <span>by tool · characters returned</span></h3>
|
|
1689
|
-
${viz.hbars(c.byTool.map((x) => [ctxToolLabel(x.tool), x.chars, `${chars(x.chars)} · ${x.calls}`]))}</div>
|
|
1690
|
-
<div class="chart-card" style="margin:0"><h3>Re-read waste <span>the same file, read again</span></h3>
|
|
1691
|
-
${worst.length ? `<table class="mini"><colgroup><col style="width:31%"><col style="width:15%"><col style="width:14%"><col style="width:11%"><col style="width:29%"></colgroup><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>
|
|
1692
|
-
</div>
|
|
1693
|
-
<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>`;
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
// M9.18: the same task run by N models side by side. An arm is its own task id, so each has its
|
|
1697
|
-
// own claim and worktree and the ledger's one-holder rule is untouched — see core/abtrial.ts.
|
|
1698
|
-
const VERDICT = { winner: ["ok", "Decided"], undecided: ["acc", "Running"], "all-failed": ["bad", "No winner"] };
|
|
1699
|
-
function renderTrials() {
|
|
1700
|
-
const trials = state.trials;
|
|
1701
|
-
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>`;
|
|
1702
|
-
if (!trials) { $("#main").innerHTML = head("same task, different models") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1703
|
-
if (!trials.length) {
|
|
1704
|
-
$("#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>`;
|
|
1705
|
-
return;
|
|
1706
|
-
}
|
|
1707
|
-
const secs = (v) => (v === null ? '<span class="dim">—</span>' : dur(v));
|
|
1708
|
-
const cols = [
|
|
1709
|
-
{ 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>' : ""}` },
|
|
1710
|
-
{ 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>`) },
|
|
1711
|
-
{ key: "cost", label: "cost", width: 74, num: true, get: (a) => a.costUsd, cell: (a) => usd(a.costUsd) },
|
|
1712
|
-
{ key: "wall", label: "wall", width: 74, num: true, get: (a) => a.wallMs ?? -1, cell: (a) => secs(a.wallMs) },
|
|
1713
|
-
{ key: "turns", label: "turns", width: 64, num: true, get: (a) => a.turns, cell: (a) => a.turns },
|
|
1714
|
-
{ 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>' : ""}` },
|
|
1715
|
-
{ 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>`) },
|
|
1716
|
-
{ 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>') },
|
|
1717
|
-
];
|
|
1718
|
-
const block = (t) => {
|
|
1719
|
-
const v = VERDICT[t.verdict] ?? VERDICT.undecided;
|
|
1720
|
-
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` : ""}` : ""}`;
|
|
1721
|
-
return `<h2 class="mt-sec">${esc(t.task)} <span class="badge ${v[0]}">${v[1]}</span> <span>${sub}</span></h2>` +
|
|
1722
|
-
dataTable({ id: `ab-${t.task}`, columns: cols, rows: t.arms, rerender: touch });
|
|
1723
|
-
};
|
|
1724
|
-
const running = trials.filter((t) => t.verdict === "undecided").length;
|
|
1725
|
-
$("#main").innerHTML = head(`${trials.length} trial${trials.length === 1 ? "" : "s"}${running ? ` · ${running} still running` : ""}`) +
|
|
1726
|
-
trials.map(block).join("") +
|
|
1727
|
-
`<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>`;
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
// M9.14: issue → task → claim → session → branch → PR → merged, as one row per piece of work.
|
|
1731
|
-
// The six link dots are the graph: a filled run that stops is exactly where the trail goes cold.
|
|
1732
|
-
const LINK_ORDER = ["task", "claim", "session", "branch", "pr", "merged"];
|
|
1733
|
-
const BREAK_LABEL = {
|
|
1734
|
-
"no-task": ["bad", "No task", "landed with no task behind it"],
|
|
1735
|
-
unclaimed: ["warn", "Unclaimed", "no claim was ever taken for this task"],
|
|
1736
|
-
"no-session": ["warn", "No session", "claimed, but no session did the work"],
|
|
1737
|
-
"no-branch": ["warn", "No branch", "worked on, but never reached a branch"],
|
|
1738
|
-
"no-pr": ["warn", "No PR", "a branch exists but no pull request"],
|
|
1739
|
-
"open-pr": ["acc", "Open PR", "the pull request has not merged yet"],
|
|
1740
|
-
};
|
|
1741
|
-
// Lead time spans minutes to months, and "889.4h" both overflows a numeric column and means
|
|
1742
|
-
// nothing to a reader. Never wider than 5 characters.
|
|
1743
|
-
function leadTime(h) {
|
|
1744
|
-
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
1745
|
-
if (h < 48) return `${h.toFixed(h < 10 ? 1 : 0)}h`;
|
|
1746
|
-
const d = h / 24;
|
|
1747
|
-
return d < 100 ? `${d.toFixed(d < 10 ? 1 : 0)}d` : `${Math.round(d / 7)}w`;
|
|
1748
|
-
}
|
|
1749
|
-
function renderProvenance() {
|
|
1750
|
-
const p = state.provenance;
|
|
1751
|
-
const head = (sub) => `<h2>Provenance <span>${sub}</span></h2>`;
|
|
1752
|
-
if (!p) { $("#main").innerHTML = head("follow the work back") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1753
|
-
if (!p.chains.length) {
|
|
1754
|
-
$("#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>`;
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
const t = p.totals;
|
|
1758
|
-
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>`;
|
|
1759
|
-
const kpis = `<div class="kpis">${
|
|
1760
|
-
kpi("Traced", `${t.complete}/${t.tasks}`, "reach a merged PR", t.complete ? "" : "warm")
|
|
1761
|
-
}${kpi("Untracked", t.untracked, t.untracked ? "landed with no task" : "all work has a task", t.untracked ? "hot" : "")
|
|
1762
|
-
}${kpi("Unclaimed", t.unclaimed, "tasks nobody claimed", t.unclaimed ? "warm" : "")
|
|
1763
|
-
}${kpi("Traced spend", usd(t.costUsd), "across every chain")}</div>`;
|
|
1764
|
-
|
|
1765
|
-
const track = (c) => `<span class="track" title="${LINK_ORDER.map((k) => `${k}: ${c.links[k] ? "yes" : "no"}`).join(" · ")}">${
|
|
1766
|
-
LINK_ORDER.map((k) => `<i class="${c.links[k] ? "on" : ""}"></i>`).join("")}</span>`;
|
|
1767
|
-
const cols = [
|
|
1768
|
-
{ 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>'}` },
|
|
1769
|
-
{ key: "track", label: "chain", width: 92, sortable: false, filterable: false, get: (c) => c.depth, cell: track },
|
|
1770
|
-
{ 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>'; } },
|
|
1771
|
-
{ 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>` },
|
|
1772
|
-
{ 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>') },
|
|
1773
|
-
{ 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>') },
|
|
1774
|
-
{ 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>') },
|
|
1775
|
-
{ key: "cost", label: "cost", width: 74, num: true, get: (c) => c.costUsd, cell: (c) => usd(c.costUsd) },
|
|
1776
|
-
{ 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)) },
|
|
1777
|
-
];
|
|
1778
|
-
const pg = p.page ?? { limit: p.chains.length, offset: 0, total: p.chains.length };
|
|
1779
|
-
const from = pg.total ? pg.offset + 1 : 0;
|
|
1780
|
-
const to = Math.min(pg.offset + pg.limit, pg.total);
|
|
1781
|
-
const pager = pg.total > pg.limit
|
|
1782
|
-
? `<div class="chips" style="margin-top:10px">
|
|
1783
|
-
<span class="chip ${pg.offset ? "" : "off"}" data-provpage="${Math.max(0, pg.offset - pg.limit)}">${ic("arrow-left", 12)} Newer</span>
|
|
1784
|
-
<span class="dim" style="align-self:center;font-size:var(--fs-sm)">${from}–${to} of ${pg.total}</span>
|
|
1785
|
-
<span class="chip ${to >= pg.total ? "off" : ""}" data-provpage="${pg.offset + pg.limit}">Older ${ic("arrow-right", 12)}</span>
|
|
1786
|
-
</div>`
|
|
1787
|
-
: "";
|
|
1788
|
-
// A cold start has no forge data yet, so PR columns would read as "no PR" for everything.
|
|
1789
|
-
const catching = p.stale
|
|
1790
|
-
? `<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>`
|
|
1791
|
-
: "";
|
|
1792
|
-
$("#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 +
|
|
1793
|
-
dataTable({ id: "provenance", columns: cols, rows: p.chains, rerender: touch }) + pager + catching +
|
|
1794
|
-
`<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>`;
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
// M9.6: which MCP servers the fleet waits on. Latency is hook-to-hook — the wall-clock between
|
|
1798
|
-
// PreToolUse and PostToolUse — so it is what the agent actually waited for, including any time a
|
|
1799
|
-
// call spent behind a permission prompt. That is why the view leads with p50/p95, not max.
|
|
1800
|
-
function renderMcpHealth() {
|
|
1801
|
-
const h = state.mcpHealth;
|
|
1802
|
-
const head = (sub) => `<h2>MCP <span>${sub}</span></h2>`;
|
|
1803
|
-
if (!h) { $("#main").innerHTML = head("server health") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1804
|
-
if (!h.servers.length) {
|
|
1805
|
-
$("#main").innerHTML = head("server health") + `<div class="empty">${PX.idle()}No tool calls in the last 7 days${state.sel ? " in this project" : ""}.</div>`;
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
const t = h.totals;
|
|
1809
|
-
const ms = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : v < 60_000 ? `${(v / 1000).toFixed(1)}s` : dur(v));
|
|
1810
|
-
const cols = [
|
|
1811
|
-
{ 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>'}` },
|
|
1812
|
-
{ key: "calls", label: "calls", width: 74, num: true, get: (s) => s.calls, cell: (s) => s.calls.toLocaleString() },
|
|
1813
|
-
{ key: "sessions", label: "sessions", width: 78, num: true, get: (s) => s.sessions, cell: (s) => s.sessions },
|
|
1814
|
-
{ key: "p50", label: "p50", width: 68, num: true, get: (s) => s.p50Ms ?? -1, cell: (s) => ms(s.p50Ms) },
|
|
1815
|
-
{ key: "p95", label: "p95", width: 68, num: true, get: (s) => s.p95Ms ?? -1, cell: (s) => ms(s.p95Ms) },
|
|
1816
|
-
{ 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>` },
|
|
1817
|
-
{ key: "wait", label: "waited", width: 82, num: true, get: (s) => s.totalMs, cell: (s) => dur(s.totalMs) },
|
|
1818
|
-
{ 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>') },
|
|
1819
|
-
{ 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>') },
|
|
1820
|
-
{ 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(" ") },
|
|
1821
|
-
];
|
|
1822
|
-
const share = t.totalMs ? Math.round((t.mcpMs / t.totalMs) * 100) : 0;
|
|
1823
|
-
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)`;
|
|
1824
|
-
$("#main").innerHTML = head(sub) +
|
|
1825
|
-
dataTable({ id: "mcp-health", columns: cols, rows: h.servers, rerender: touch }) +
|
|
1826
|
-
`<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>`;
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
// M9.7: gate flakiness and cost. A gate that flips on the *same task* told you two different
|
|
1830
|
-
// things about identical work — that is the number worth ranking on, not a raw fail count.
|
|
1831
|
-
function renderGateHealth() {
|
|
1832
|
-
const h = state.gateHealth;
|
|
1833
|
-
const head = (sub) => `<h2>Gates <span>${sub}</span></h2>`;
|
|
1834
|
-
if (!h) { $("#main").innerHTML = head("flakiness and wall-clock") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1835
|
-
if (!h.gates.length) {
|
|
1836
|
-
$("#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>`;
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
const t = h.totals;
|
|
1840
|
-
const secs = (v) => (v === null ? '<span class="dim">—</span>' : v < 1000 ? `${v}ms` : `${(v / 1000).toFixed(1)}s`);
|
|
1841
|
-
// Oldest-first strip, matching Recent gates on the Board.
|
|
1842
|
-
const strip = (g) => {
|
|
1843
|
-
const rs = [...g.history].reverse();
|
|
1844
|
-
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>`;
|
|
1845
|
-
};
|
|
1846
|
-
const cols = [
|
|
1847
|
-
{ 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>' : ""}` },
|
|
1848
|
-
{ key: "history", label: "history", width: 150, sortable: false, filterable: false, get: () => null, cell: strip },
|
|
1849
|
-
{ key: "runs", label: "runs", width: 60, num: true, get: (g) => g.runs, cell: (g) => g.runs },
|
|
1850
|
-
{ key: "pass", label: "pass rate", width: 84, num: true, get: (g) => g.passRate, cell: (g) => `${Math.round(g.passRate * 100)}%` },
|
|
1851
|
-
{ 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>') },
|
|
1852
|
-
{ key: "p50", label: "p50", width: 66, num: true, get: (g) => g.p50Ms ?? -1, cell: (g) => secs(g.p50Ms) },
|
|
1853
|
-
{ key: "p95", label: "p95", width: 66, num: true, get: (g) => g.p95Ms ?? -1, cell: (g) => secs(g.p95Ms) },
|
|
1854
|
-
{ key: "max", label: "slowest", width: 74, num: true, get: (g) => g.maxMs ?? -1, cell: (g) => secs(g.maxMs) },
|
|
1855
|
-
{ key: "total", label: "total", width: 74, num: true, get: (g) => g.totalMs, cell: (g) => (g.timedRuns ? dur(g.totalMs) : '<span class="dim">—</span>') },
|
|
1856
|
-
{ 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>') },
|
|
1857
|
-
];
|
|
1858
|
-
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` : ""}`;
|
|
1859
|
-
$("#main").innerHTML = head(sub) +
|
|
1860
|
-
dataTable({ id: "gate-health", columns: cols, rows: h.gates, rerender: touch }) +
|
|
1861
|
-
`<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>`;
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
// M9.8: machine hygiene — what the fleet left behind. Observation plus the two actions that
|
|
1865
|
-
// already exist (stop a process, remove a worktree); nothing here reclaims anything on its own,
|
|
1866
|
-
// and a worktree with uncommitted or unpushed work is never offered as safe.
|
|
1867
|
-
const ISSUE_BADGE = {
|
|
1868
|
-
dead: ["bad", "Dead"], orphaned: ["bad", "Orphaned"], hungry: ["warn", "Hungry"],
|
|
1869
|
-
stale: ["warn", "Stale"], abandoned: ["warn", "Abandoned"], heavy: ["", "Heavy"],
|
|
1870
|
-
};
|
|
1871
|
-
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`);
|
|
1872
|
-
const issueBadge = (i) => { const b = ISSUE_BADGE[i]; return b ? `<span class="badge ${b[0]}">${b[1]}</span>` : '<span class="dim">ok</span>'; };
|
|
1873
|
-
function renderHygiene() {
|
|
1874
|
-
const h = state.hygiene;
|
|
1875
|
-
const head = (sub) => `<h2>Hygiene <span>${sub}</span></h2>`;
|
|
1876
|
-
if (!h) { $("#main").innerHTML = head("what the fleet left behind") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1877
|
-
const t = h.totals;
|
|
1878
|
-
if (!h.processes.length && !h.worktrees.length) {
|
|
1879
|
-
$("#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>`;
|
|
1880
|
-
return;
|
|
1881
|
-
}
|
|
1882
|
-
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>`;
|
|
1883
|
-
const badge = (n, label, cls) => (n > 0 ? `<span class="badge ${cls}">${n} ${label}</span>` : "");
|
|
1884
|
-
// Disk is sampled in the background, so "0 MB" before the first sweep would be a lie — say so.
|
|
1885
|
-
const sampled = h.worktrees.filter((w) => w.diskKb !== null).length;
|
|
1886
|
-
const diskPending = h.worktrees.length > 0 && sampled === 0;
|
|
1887
|
-
const totalDisk = diskPending ? "measuring…" : mb(t.diskKb);
|
|
1888
|
-
const buildKb = h.worktrees.reduce((n, w) => n + (w.buildKb ?? 0), 0);
|
|
1889
|
-
const clearable = h.worktrees
|
|
1890
|
-
.filter((w) => !w.main && !w.heldByClaim && w.liveSessions === 0)
|
|
1891
|
-
.reduce((n, w) => n + (w.buildKb ?? 0), 0);
|
|
1892
|
-
const kpis = `<div class="kpis">${
|
|
1893
|
-
kpi("Needs a look", t.issues, t.issues ? "processes + worktrees" : "all clean", t.issues ? "hot" : "")
|
|
1894
|
-
}${kpi("Processes", t.processes, t.orphanedProcesses || t.deadProcesses ? `${t.orphanedProcesses} orphaned · ${t.deadProcesses} dead` : "all healthy", t.orphanedProcesses || t.deadProcesses ? "hot" : "")
|
|
1895
|
-
}${kpi("Worktrees", t.worktrees, t.staleWorktrees ? `${t.staleWorktrees} stale` : "none stale", t.staleWorktrees ? "warm" : "")
|
|
1896
|
-
}${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" : "")
|
|
1897
|
-
}${kpi("Build output", diskPending ? '<span class="dim">—</span>' : mb(buildKb), clearable ? `${mb(clearable)} clearable now` : "nothing to clear", clearable ? "warm" : "")}</div>`;
|
|
1898
|
-
|
|
1899
|
-
const pcols = [
|
|
1900
|
-
{ key: "issue", label: "state", width: 96, get: (p) => p.issue ?? "", cell: (p) => issueBadge(p.issue) },
|
|
1901
|
-
{ key: "name", label: "name", width: 130, get: (p) => p.name, cell: (p) => `<b>${esc(p.name)}</b>` },
|
|
1902
|
-
{ key: "kind", label: "kind", width: 64, get: (p) => p.kind, cell: (p) => `<span class="br">${esc(p.kind)}</span>` },
|
|
1903
|
-
{ key: "pid", label: "pid", width: 64, num: true, get: (p) => p.pid, cell: (p) => p.pid },
|
|
1904
|
-
{ key: "port", label: "port", width: 60, num: true, get: (p) => p.port ?? 0, cell: (p) => p.port ?? '<span class="dim">—</span>' },
|
|
1905
|
-
{ 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)}%`) },
|
|
1906
|
-
{ key: "rss", label: "memory", width: 78, num: true, get: (p) => p.rssKb ?? -1, cell: (p) => mb(p.rssKb) },
|
|
1907
|
-
{ 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>') },
|
|
1908
|
-
{ 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>` : "") },
|
|
1909
|
-
];
|
|
1910
|
-
const wcols = [
|
|
1911
|
-
{ key: "issue", label: "state", width: 106, get: (w) => w.issue ?? "", cell: (w) => issueBadge(w.issue) },
|
|
1912
|
-
// 32 worktrees across a dozen repos: a branch name alone does not say which repo it is in.
|
|
1913
|
-
{ key: "project", label: "project", width: 122, get: (w) => projName(w.projectId), cell: (w) => `<span class="clip">${esc(projName(w.projectId))}</span>` },
|
|
1914
|
-
{ 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>' : ""}` },
|
|
1915
|
-
{ key: "disk", label: "disk", width: 78, num: true, get: (w) => w.diskKb ?? -1, cell: (w) => mb(w.diskKb) },
|
|
1916
|
-
{ key: "build", label: "build output", width: 100, num: true, get: (w) => w.buildKb ?? -1, cell: (w) => (w.buildKb === null ? '<span class="dim">—</span>' : `<span title="node_modules, target, dist — a rebuild recreates these">${mb(w.buildKb)}</span>`) },
|
|
1917
|
-
{ 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)) },
|
|
1918
|
-
{ 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>') : ""}` },
|
|
1919
|
-
{ 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>') },
|
|
1920
|
-
{ 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>') },
|
|
1921
|
-
{ key: "act", label: "", width: 196, sortable: false, filterable: false, get: () => null, cell: (w) => {
|
|
1922
|
-
// Two different things: clearing build output keeps the branch, removing the worktree does not.
|
|
1923
|
-
const canClear = !w.main && !w.heldByClaim && w.liveSessions === 0 && (w.buildKb ?? 0) > 0;
|
|
1924
|
-
return `${canClear ? `<a href="#" class="mini-act" data-wtclear="${esc(w.path)}" title="Delete node_modules, target and dist here — a rebuild recreates them; the branch and any uncommitted work are untouched">Clear ${mb(w.buildKb)}</a>` : ""}${w.reclaimable ? `<a href="#" class="mini-act bad" data-wtrm="${esc(w.projectId)}:${esc(w.path)}" title="Remove this worktree">Remove</a>` : ""}`;
|
|
1925
|
-
} },
|
|
1926
|
-
];
|
|
1927
|
-
const sub = t.issues ? `<b class="navcount">${t.issues} need${t.issues === 1 ? "s" : ""} a look</b>` : "nothing to clean up";
|
|
1928
|
-
$("#main").innerHTML = head(sub) + kpis +
|
|
1929
|
-
`<h2 class="mt-sec">Processes <span>${h.processes.length} tracked · started through swarm, never matched by command pattern</span></h2>` +
|
|
1930
|
-
(h.processes.length ? dataTable({ id: "hyg-procs", columns: pcols, rows: h.processes, rerender: touch }) : `<div class="empty">${PX.idle()}No tracked processes.</div>`) +
|
|
1931
|
-
`<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>` +
|
|
1932
|
-
(h.worktrees.length ? dataTable({ id: "hyg-trees", columns: wcols, rows: h.worktrees, rerender: touch }) : `<div class="empty">${PX.idle()}No worktrees.</div>`) +
|
|
1933
|
-
`<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>`;
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
// M9.12: live file-collision graph — which live sessions touch which files, contested files
|
|
1937
|
-
// highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
|
|
1938
|
-
function renderGraphs() {
|
|
1939
|
-
const tab = state.graphTab ?? "collisions";
|
|
1940
|
-
const chip = (k, label, n) => `<span class="chip ${tab === k ? "on" : ""}" data-graphtab="${k}">${label}${n ? ` <b>${n}</b>` : ""}</span>`;
|
|
1941
|
-
const tabs = `<div class="chips">${chip("collisions", "Collisions", state.collisions?.contested ?? 0)}${chip("lineage", "Lineage", state.lineage?.edges?.length ?? 0)}${chip("tools", "Tools", state.transitions?.loops?.length ?? 0)}${chip("resources", "Resources", state.resourceGraph?.totals?.orphaned ?? 0)}</div>`;
|
|
1942
|
-
const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>${tabs}`;
|
|
1943
|
-
if (tab === "lineage") return renderLineage(head);
|
|
1944
|
-
if (tab === "tools") return renderTransitions(head);
|
|
1945
|
-
if (tab === "resources") return renderResourceGraph(head);
|
|
1946
|
-
const g = state.collisions;
|
|
1947
|
-
const title = (s) => s.title ?? s.id.slice(0, 8);
|
|
1948
|
-
if (!g || !g.sessions.length) {
|
|
1949
|
-
$("#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>`;
|
|
1950
|
-
return;
|
|
1951
|
-
}
|
|
1952
|
-
if (!g.files.length) {
|
|
1953
|
-
$("#main").innerHTML = head(`${g.sessions.length} live session${g.sessions.length === 1 ? "" : "s"}`) + `<div class="empty">${PX.idle()}No file touches recorded yet — the graph fills in as agents read and edit.</div>`;
|
|
1954
|
-
return;
|
|
1955
|
-
}
|
|
1956
|
-
const sessions = g.sessions.map((s) => ({ ...s, label: title(s) }));
|
|
1957
|
-
const agents = [...new Set(sessions.map((s) => s.agent))].sort(viz.agentSort);
|
|
1958
|
-
const sub = `${sessions.length} live session${sessions.length === 1 ? "" : "s"} · ${g.files.length} file${g.files.length === 1 ? "" : "s"} · ${g.contested ? `<b class="navcount">${g.contested} contested</b>` : "no collisions"}`;
|
|
1959
|
-
$("#main").innerHTML = head(sub) +
|
|
1960
|
-
`<div class="card" style="padding:14px">${viz.bipartite(sessions, g.files)}</div>
|
|
1961
|
-
<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>`;
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
// M9.13: who started whom, who told whom, who picked up whose work. Every edge is a recorded
|
|
1965
|
-
// relationship — nothing is inferred from timing.
|
|
1966
|
-
const EDGE_LEGEND = [
|
|
1967
|
-
["subagent", "spawned a subagent", "var(--acc)", ""],
|
|
1968
|
-
["dispatch", "dispatched a run", "var(--c3,#5a9e6f)", ""],
|
|
1969
|
-
["message", "sent a message", "var(--warn)", "3 3"],
|
|
1970
|
-
["handoff", "handed the task on", "var(--dim)", "6 3"],
|
|
1971
|
-
];
|
|
1972
|
-
function renderLineage(head) {
|
|
1973
|
-
const g = state.lineage;
|
|
1974
|
-
if (!g) { $("#main").innerHTML = head("session lineage") + `<div class="empty">${PX.clock()}Loading…</div>`; return; }
|
|
1975
|
-
if (!g.nodes.length) {
|
|
1976
|
-
$("#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>`;
|
|
1977
|
-
return;
|
|
1978
|
-
}
|
|
1979
|
-
const key = EDGE_LEGEND.filter(([k]) => g.byKind[k]).map(([k, label, color, dash]) =>
|
|
1980
|
-
`<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("");
|
|
1981
|
-
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>` : ""}`;
|
|
1982
|
-
$("#main").innerHTML = head(sub) +
|
|
1983
|
-
`<div class="card" style="padding:14px;overflow:auto;max-height:72vh">${viz.dag(g)}</div>
|
|
1984
|
-
<div style="margin-top:10px;display:flex;gap:18px;align-items:center;flex-wrap:wrap">${key}
|
|
1985
|
-
<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>`;
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
// M9.15: what an agent reaches for after what. Edge thickness is the weight; a two-tool cycle is
|
|
1989
|
-
// a round trip, which is only worth worrying about when the calls inside it are also failing —
|
|
1990
|
-
// so the loops table describes shape, and the Stuck badge (M9.3) stays the thing that judges.
|
|
1991
|
-
function renderTransitions(head) {
|
|
1992
|
-
const g = state.transitions;
|
|
1993
|
-
if (!g) {
|
|
1994
|
-
// Distinguish "not fetched yet" from "this daemon has no such route": the second never resolves
|
|
1995
|
-
// on its own, and telling someone to wait for it is a lie.
|
|
1996
|
-
const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/transitions"));
|
|
1997
|
-
if (skew) return renderErrorPanel(null, "graphs · tools");
|
|
1998
|
-
$("#main").innerHTML = head("tool transitions") + `<div class="empty">${PX.clock()}Loading…</div>`;
|
|
1999
|
-
return;
|
|
2000
|
-
}
|
|
2001
|
-
if (!g.nodes?.length) {
|
|
2002
|
-
$("#main").innerHTML = head("tool transitions") + `<div class="empty">${PX.idle()}No tool calls recorded${state.sel ? " in this project" : ""} in the last 7 days.<br>The matrix fills in as agents work — it counts what each tool call was followed by.</div>`;
|
|
2003
|
-
return;
|
|
2004
|
-
}
|
|
2005
|
-
const tools = g.nodes.slice(0, 18).map((n) => n.tool);
|
|
2006
|
-
const shown = new Set(tools);
|
|
2007
|
-
const sub = `${g.nodes.length} tool${g.nodes.length === 1 ? "" : "s"} · ${g.transitions.toLocaleString()} transitions · ${g.sessions} session${g.sessions === 1 ? "" : "s"} · last 7 days${g.nodes.length > tools.length ? ` · <span class="dim">${g.nodes.length - tools.length} quieter not shown</span>` : ""}`;
|
|
2008
|
-
const loops = (g.loops ?? []).slice(0, 9);
|
|
2009
|
-
const loopRows = loops.map((l) => `<tr>
|
|
2010
|
-
<td class="clip">${l.tools.map((t) => `<span class="br">${esc(ctxToolLabel(t))}</span>`).join(' <span class="dim">→</span> ')}${l.tools.length === 1 ? ' <span class="dim">itself</span>' : ""}</td>
|
|
2011
|
-
<td class="num"><b>${l.weight.toLocaleString()}</b></td>
|
|
2012
|
-
<td class="num">${l.sessions}</td>
|
|
2013
|
-
</tr>`).join("");
|
|
2014
|
-
$("#main").innerHTML = head(sub) +
|
|
2015
|
-
`<div class="cols">
|
|
2016
|
-
<div class="chart-card" style="margin:0"><h3>What follows what <span>row ran, then column · darker = more often</span></h3>
|
|
2017
|
-
${viz.matrix(tools, g.edges.filter((e) => shown.has(e.from) && shown.has(e.to)), { label: ctxToolLabel })}</div>
|
|
2018
|
-
<div class="chart-card" style="margin:0"><h3>Round trips <span>a tool pair that keeps handing back</span></h3>
|
|
2019
|
-
${loops.length
|
|
2020
|
-
? `<table class="mini"><colgroup><col style="width:52%"><col style="width:26%"><col style="width:22%"></colgroup><thead><tr><th>loop</th><th class="num">round trips</th><th class="num">sessions</th></tr></thead><tbody>${loopRows}</tbody></table>
|
|
2021
|
-
<p class="dim" style="margin:10px 0 0;font-size:var(--fs-sm)">A loop is ordinary work — <code>Read → Edit</code> is what writing code looks like. It only counts as stuck when the calls inside it are <em>failing</em>, which is what the <b>Stuck</b> badge on Fleet judges.</p>`
|
|
2022
|
-
: '<div class="dim">No tool pair hands back to the other — every move is one-way.</div>'}</div>
|
|
2023
|
-
</div>`;
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
// M9.17: claims, ports, leases and processes on one picture with whoever holds them. Orphaned
|
|
2027
|
-
// means the holding session ended (or the lease expired) — the same reading Hygiene uses. There
|
|
2028
|
-
// is no deadlock to find: claims fail closed, so a second claimer is refused rather than queued
|
|
2029
|
-
// and nobody ever blocks. What the rings show is contention — two agents each wanting what the
|
|
2030
|
-
// other has — which is a scheduling problem for a person, not a lock to break.
|
|
2031
|
-
function renderResourceGraph(head) {
|
|
2032
|
-
const g = state.resourceGraph;
|
|
2033
|
-
if (!g) {
|
|
2034
|
-
const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/resources"));
|
|
2035
|
-
if (skew) return renderErrorPanel(null, "graphs · resources");
|
|
2036
|
-
$("#main").innerHTML = head("who holds what") + `<div class="empty">${PX.clock()}Loading…</div>`;
|
|
2037
|
-
return;
|
|
2038
|
-
}
|
|
2039
|
-
if (!g.resources.length) {
|
|
2040
|
-
$("#main").innerHTML = head("who holds what") + `<div class="empty">${PX.idle()}Nothing is held${state.sel ? " in this project" : ""}.<br>Claims, ports, leases and tracked processes appear here with whoever took them.</div>`;
|
|
2041
|
-
return;
|
|
2042
|
-
}
|
|
2043
|
-
const t = g.totals;
|
|
2044
|
-
const sub = `${t.held} held · ${t.orphaned ? `<b class="navcount">${t.orphaned} orphaned</b>` : "none orphaned"}${t.contested ? ` · <b class="navcount">${t.contested} contested</b>` : ""}`;
|
|
2045
|
-
// Same shape the collision graph draws: holders on the left, what they hold on the right.
|
|
2046
|
-
const holders = g.holders.map((h) => ({ id: h.id, label: h.gone ? `${h.id} (gone)` : h.id, agent: "claude-code", files: h.holds, writes: h.holds }));
|
|
2047
|
-
const items = g.resources.map((r) => ({ path: `${r.kind === "claim" ? "" : `${r.kind} `}${r.name}`, readers: r.wanted, writers: r.holder ? [r.holder] : [], contested: r.wanted.length > 0 || r.orphaned }));
|
|
2048
|
-
const rings = g.contention.map((c) => `<li>${c.owners.map((o) => `<span class="br">${esc(o)}</span>`).join(' <span class="dim">wants what</span> ')} <span class="dim">holds — via</span> ${c.resources.map((r) => `<code>${esc(r)}</code>`).join(", ")}</li>`).join("");
|
|
2049
|
-
const orphans = g.resources.filter((r) => r.orphaned);
|
|
2050
|
-
$("#main").innerHTML = head(sub) +
|
|
2051
|
-
(rings ? `<div class="card err-card" style="margin-bottom:12px"><b>${g.contention.length} contention ring${g.contention.length === 1 ? "" : "s"}</b> — each agent wants something the next one holds. Nothing is blocked (claims refuse rather than queue), but they are working against each other.<ul style="margin:8px 0 0;padding-left:18px">${rings}</ul></div>` : "") +
|
|
2052
|
-
`<div class="card" style="padding:14px">${viz.bipartite(holders, items)}</div>
|
|
2053
|
-
<div style="margin-top:10px" class="dim" style="font-size:var(--fs-sm)">solid edge = holds · faint edge = was refused it · <span style="color:var(--bad)">red</span> = orphaned or contested</div>` +
|
|
2054
|
-
(orphans.length
|
|
2055
|
-
? `<div class="chart-card" style="margin-top:14px"><h3>Orphaned <span>the session that took it has ended</span></h3>
|
|
2056
|
-
<ul class="plainlist">${orphans.map((r) => `<li><span class="badge">${esc(r.kind)}</span><b>${esc(r.name)}</b><span class="dim">held by ${esc(r.holder ?? "nobody")}</span></li>`).join("")}</ul></div>`
|
|
2057
|
-
: "");
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
// M9.16: where the fleet's attention actually goes. The candidates list is the point — a file many
|
|
2061
|
-
// separate sessions read, re-read, and hardly ever write is one the fleet keeps re-learning, and
|
|
2062
|
-
// that belongs in CLAUDE.md. A file read *and written* a lot is just where the work is.
|
|
2063
|
-
function renderHeat(head) {
|
|
2064
|
-
const h = state.heat;
|
|
2065
|
-
const title = (sub) => `<h2>Files <span>${sub}</span></h2>`;
|
|
2066
|
-
if (!h) {
|
|
2067
|
-
const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/heat"));
|
|
2068
|
-
if (skew) return renderErrorPanel(null, "files");
|
|
2069
|
-
$("#main").innerHTML = title("file-touch heat") + `<div class="empty">${PX.clock()}Loading…</div>`;
|
|
2070
|
-
return;
|
|
2071
|
-
}
|
|
2072
|
-
if (!h.files.length) {
|
|
2073
|
-
$("#main").innerHTML = title("file-touch heat") + `<div class="empty">${PX.idle()}No file was touched more than once${state.sel ? " in this project" : ""} in the last 14 days.</div>`;
|
|
2074
|
-
return;
|
|
2075
|
-
}
|
|
2076
|
-
const t = h.totals;
|
|
2077
|
-
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>`;
|
|
2078
|
-
const kpis = `<div class="kpis">${kpi("Files touched", t.files.toLocaleString(), `${t.touches.toLocaleString()} touches · last 14 days`)}
|
|
2079
|
-
${kpi("Re-reads", t.rereads.toLocaleString(), t.touches ? `${Math.round((t.rereads / t.touches) * 100)}% of every touch` : "none")}
|
|
2080
|
-
${kpi("Touched once", t.cold.toLocaleString(), "cold — read and never returned to")}
|
|
2081
|
-
${kpi("CLAUDE.md candidates", h.candidates.length, h.candidates.length ? "re-read by several sessions" : "nothing worth writing down", h.candidates.length ? "warm" : "")}</div>`;
|
|
2082
|
-
// Paths here are long and the column is narrow, and neither end can simply be cut: the head is
|
|
2083
|
-
// a home prefix every row shares, and the tail is the filename — which is the only part worth
|
|
2084
|
-
// reading. Three worktrees each have a packages/web/public/app.js, so the name alone is not
|
|
2085
|
-
// enough either. Name first, then just enough of its directory to tell them apart, dimmed and
|
|
2086
|
-
// free to truncate.
|
|
2087
|
-
const nameOf = (p) => short(p).split("/").pop() || short(p);
|
|
2088
|
-
const ctxOf = (p, n = 2) => {
|
|
2089
|
-
const parts = short(p).split("/");
|
|
2090
|
-
parts.pop();
|
|
2091
|
-
return parts.length <= n ? parts.join("/") : `…/${parts.slice(-n).join("/")}`;
|
|
2092
|
-
};
|
|
2093
|
-
/**
|
|
2094
|
-
* Two segments of context is usually enough, but three worktrees each holding a
|
|
2095
|
-
* packages/web/public/app.js all render identically — the list then reads as one file listed
|
|
2096
|
-
* three times. Widen the context only for the rows that actually collide, and only as far as it
|
|
2097
|
-
* takes to tell them apart.
|
|
2098
|
-
*/
|
|
2099
|
-
const labelPaths = (paths) => {
|
|
2100
|
-
const out = new Map();
|
|
2101
|
-
for (const p of paths) {
|
|
2102
|
-
let n = 2;
|
|
2103
|
-
let label = `${nameOf(p)}|${ctxOf(p, n)}`;
|
|
2104
|
-
while (n < 6 && paths.some((q) => q !== p && `${nameOf(q)}|${ctxOf(q, n)}` === label)) {
|
|
2105
|
-
n++;
|
|
2106
|
-
label = `${nameOf(p)}|${ctxOf(p, n)}`;
|
|
2107
|
-
}
|
|
2108
|
-
out.set(p, ctxOf(p, n));
|
|
2109
|
-
}
|
|
2110
|
-
return out;
|
|
2111
|
-
};
|
|
2112
|
-
const pathCell = (p, ctx) =>
|
|
2113
|
-
`<b>${esc(nameOf(p))}</b> <span class="dim">${esc(ctx.get(p) ?? ctxOf(p))}</span>`;
|
|
2114
|
-
|
|
2115
|
-
const shownFiles = h.files.slice(0, 14);
|
|
2116
|
-
const fileCtx = labelPaths(shownFiles.map((f) => f.path));
|
|
2117
|
-
const fileRows = shownFiles.map((f) => `<tr>
|
|
2118
|
-
<td class="clip path" title="${esc(short(f.path))}">${pathCell(f.path, fileCtx)}</td>
|
|
2119
|
-
<td class="num"><b>${f.touches.toLocaleString()}</b></td>
|
|
2120
|
-
<td class="num">${f.sessions}</td>
|
|
2121
|
-
<td class="num">${f.rereads.toLocaleString()}</td>
|
|
2122
|
-
<td class="num">${f.writes.toLocaleString()}</td>
|
|
2123
|
-
</tr>`).join("");
|
|
2124
|
-
const top = h.dirs[0]?.touches || 1;
|
|
2125
|
-
const shownDirs = h.dirs.slice(0, 10);
|
|
2126
|
-
const dirCtx = labelPaths(shownDirs.map((d) => d.dir));
|
|
2127
|
-
const dirRows = shownDirs.map((d) => `<li>
|
|
2128
|
-
<span class="bar" style="--w:${Math.max(2, Math.round((d.touches / top) * 100))}%"></span>
|
|
2129
|
-
<span class="clip path" title="${esc(short(d.dir))}">${pathCell(d.dir, dirCtx)}</span>
|
|
2130
|
-
<b>${d.touches.toLocaleString()}</b>
|
|
2131
|
-
<span class="dim">${d.files} file${d.files === 1 ? "" : "s"} · ${d.sessions} session${d.sessions === 1 ? "" : "s"}</span>
|
|
2132
|
-
</li>`).join("");
|
|
2133
|
-
const shownCand = h.candidates.slice(0, 10);
|
|
2134
|
-
const candCtx = labelPaths(shownCand.map((f) => f.path));
|
|
2135
|
-
const cand = shownCand.map((f) => `<tr>
|
|
2136
|
-
<td class="clip path" title="${esc(short(f.path))}">${pathCell(f.path, candCtx)}</td>
|
|
2137
|
-
<td class="num"><b>${f.rereads.toLocaleString()}</b></td>
|
|
2138
|
-
<td class="num">${f.sessions}</td>
|
|
2139
|
-
</tr>`).join("");
|
|
2140
|
-
$("#main").innerHTML = title(`${t.files.toLocaleString()} files · ${t.touches.toLocaleString()} touches · ${t.sessions} sessions · last 14 days`) + kpis +
|
|
2141
|
-
`<div class="cols">
|
|
2142
|
-
<div class="chart-card" style="margin:0"><h3>Hottest files <span>every touch, across sessions</span></h3>
|
|
2143
|
-
<table class="mini"><colgroup><col style="width:46%"><col style="width:15%"><col style="width:13%"><col style="width:13%"><col style="width:13%"></colgroup>
|
|
2144
|
-
<thead><tr><th>path</th><th class="num">touches</th><th class="num">sessions</th><th class="num">re-reads</th><th class="num">writes</th></tr></thead><tbody>${fileRows}</tbody></table></div>
|
|
2145
|
-
<div style="display:flex;flex-direction:column;gap:var(--gap-sec);min-width:0">
|
|
2146
|
-
<div class="chart-card" style="margin:0"><h3>Worth writing down <span>read again and again, rarely written</span></h3>
|
|
2147
|
-
${cand
|
|
2148
|
-
? `<table class="mini"><colgroup><col style="width:58%"><col style="width:22%"><col style="width:20%"></colgroup><thead><tr><th>path</th><th class="num">re-reads</th><th class="num">sessions</th></tr></thead><tbody>${cand}</tbody></table>
|
|
2149
|
-
<p class="dim" style="margin:10px 0 0;font-size:var(--fs-sm)">Several sessions keep reading these and rarely change them — the conclusion is being re-derived every time. Put it in <code>CLAUDE.md</code> once instead.</p>`
|
|
2150
|
-
: '<div class="dim">Nothing here is worth extracting. Every file several sessions re-read is also one they edit — that is where the work is, not a reference being re-learned.</div>'}</div>
|
|
2151
|
-
<div class="chart-card" style="margin:0"><h3>By directory <span>where the work sits</span></h3>
|
|
2152
|
-
<ul class="heatlist">${dirRows}</ul></div>
|
|
2153
|
-
</div>
|
|
2154
|
-
</div>
|
|
2155
|
-
<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)"><b>Incidents are not correlated here.</b> An incident records the rule, the action and the command — not a path — so tying a rule that fired on a shell command to a file would mean parsing paths out of command strings and guessing.</p>`;
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
// M9.9: what agents reached for. Observation only — nothing here denies anything, and the point of
|
|
2159
|
-
// looking is to learn what your fleet actually does before writing an `ask` rule about it.
|
|
2160
|
-
function renderSecurity() {
|
|
2161
|
-
const r = state.security;
|
|
2162
|
-
const head = (sub) => `<h2>Security <span>${sub}</span></h2>`;
|
|
2163
|
-
if (!r) {
|
|
2164
|
-
const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/security"));
|
|
2165
|
-
if (skew) return renderErrorPanel(null, "security");
|
|
2166
|
-
$("#main").innerHTML = head("what agents reached for") + `<div class="empty">${PX.clock()}Loading…</div>`;
|
|
2167
|
-
return;
|
|
2168
|
-
}
|
|
2169
|
-
const t = r.totals;
|
|
2170
|
-
if (!t.scanned) {
|
|
2171
|
-
$("#main").innerHTML = head("what agents reached for") + `<div class="empty">${PX.idle()}No commands recorded${state.sel ? " in this project" : ""} in the last 14 days.</div>`;
|
|
2172
|
-
return;
|
|
2173
|
-
}
|
|
2174
|
-
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>`;
|
|
2175
|
-
const remote = r.egress.filter((h) => !h.local);
|
|
2176
|
-
const kpis = `<div class="kpis">
|
|
2177
|
-
${kpi("Hosts reached", t.remoteHosts, `${r.egress.length - t.remoteHosts} more were local`)}
|
|
2178
|
-
${kpi("Packages installed", t.installs, `${new Set(r.installs.map((i) => i.ecosystem)).size} ecosystem${new Set(r.installs.map((i) => i.ecosystem)).size === 1 ? "" : "s"}`)}
|
|
2179
|
-
${kpi("Credential files opened", t.secrets, t.secrets ? "by name — contents are never read" : "none", t.secrets ? "hot" : "")}
|
|
2180
|
-
${kpi("Commands scanned", t.scanned.toLocaleString(), "last 14 days")}</div>`;
|
|
2181
|
-
const rows = (list, cells) => list.map((x) => `<tr>${cells(x)}</tr>`).join("");
|
|
2182
|
-
$("#main").innerHTML = head(`${t.scanned.toLocaleString()} commands · last 14 days`) + kpis +
|
|
2183
|
-
`<div class="cols">
|
|
2184
|
-
<div class="chart-card" style="margin:0"><h3>Hosts reached <span>named in a command or a fetch</span></h3>
|
|
2185
|
-
${remote.length
|
|
2186
|
-
? `<table class="mini"><colgroup><col style="width:60%"><col style="width:20%"><col style="width:20%"></colgroup><thead><tr><th>host</th><th class="num">times</th><th class="num">sessions</th></tr></thead><tbody>
|
|
2187
|
-
${rows(remote.slice(0, 14), (h) => `<td class="clip path"><b>${esc(h.host)}</b></td><td class="num">${h.hits}</td><td class="num">${h.sessions}</td>`)}</tbody></table>`
|
|
2188
|
-
: '<div class="dim">Nothing but localhost.</div>'}
|
|
2189
|
-
<p class="dim" style="margin:10px 0 0;font-size:var(--fs-sm)">A host here means an agent <em>named</em> it. Whether bytes left is not something Swarm can see without running the command, so it over-reports rather than under-reports.</p></div>
|
|
2190
|
-
<div style="display:flex;flex-direction:column;gap:var(--gap-sec);min-width:0">
|
|
2191
|
-
<div class="chart-card" style="margin:0"><h3>Credential files <span>opened by name</span></h3>
|
|
2192
|
-
${r.secrets.length
|
|
2193
|
-
? `<ul class="plainlist">${r.secrets.map((sx) => `<li><span class="badge warn">${esc(sx.what)}</span><b>${sx.hits}×</b><span class="dim">${sx.sessions} session${sx.sessions === 1 ? "" : "s"}</span></li>`).join("")}</ul>
|
|
2194
|
-
<p class="dim" style="margin:10px 0 0;font-size:var(--fs-sm)">Swarm reads the <em>path</em>, never the contents — this says something opened the file and nothing about what was in it.</p>`
|
|
2195
|
-
: '<div class="dim">No credential file was opened by name.</div>'}</div>
|
|
2196
|
-
<div class="chart-card" style="margin:0"><h3>Packages installed <span>what the machine will run later</span></h3>
|
|
2197
|
-
${r.installs.length
|
|
2198
|
-
? `<ul class="plainlist">${r.installs.slice(0, 12).map((i) => `<li><span class="badge">${esc(i.ecosystem)}</span><b>${esc(i.pkg)}</b><span class="dim">${i.hits}×</span></li>`).join("")}</ul>`
|
|
2199
|
-
: '<div class="dim">Nothing was installed.</div>'}</div>
|
|
2200
|
-
</div>
|
|
2201
|
-
</div>
|
|
2202
|
-
<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)"><b>This is a lint, not a sandbox.</b> Everything here is matched against the recorded command text, so an obfuscated command will not match and a comment mentioning <code>.env</code> will. It is here to tell you what your fleet does, so you can decide what to write an <code>ask</code> rule about.</p>`;
|
|
2203
|
-
}
|
|
2204
|
-
|
|
2205
|
-
// M9.10: a rule that fires once and never again taught somebody something. A rule that fires forty
|
|
2206
|
-
// times on the same shaped command is friction — the habit needs changing, or the rule does.
|
|
2207
|
-
function renderRuleEffect() {
|
|
2208
|
-
const r = state.ruleEffect;
|
|
2209
|
-
const head = (sub) => `<h2>Rules <span>${sub}</span></h2>`;
|
|
2210
|
-
if (!r) {
|
|
2211
|
-
const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/rules/"));
|
|
2212
|
-
if (skew) return renderErrorPanel(null, "rules");
|
|
2213
|
-
$("#main").innerHTML = head("is a rule teaching anyone anything?") + `<div class="empty">${PX.clock()}Loading…</div>`;
|
|
2214
|
-
return;
|
|
2215
|
-
}
|
|
2216
|
-
if (!r.rules.length) {
|
|
2217
|
-
$("#main").innerHTML = head("is a rule teaching anyone anything?") + `<div class="empty">${PX.idle()}No rule has fired${state.sel ? " in this project" : ""} in the last 30 days.<br>That is the good outcome: rules exist to be learned and then never hit again.</div>`;
|
|
2218
|
-
return;
|
|
2219
|
-
}
|
|
2220
|
-
const t = r.totals;
|
|
2221
|
-
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>`;
|
|
2222
|
-
const TREND = { rising: ["bad", "rising"], falling: ["ok", "falling"], steady: ["", "steady"] };
|
|
2223
|
-
const kpis = `<div class="kpis">
|
|
2224
|
-
${kpi("Incidents", t.incidents, "last 30 days")}
|
|
2225
|
-
${kpi("Rules firing", t.rules, `${t.acked} incident${t.acked === 1 ? "" : "s"} acknowledged`)}
|
|
2226
|
-
${kpi("Not settling", t.unchanged, t.unchanged ? "firing as much as ever, or more" : "every rule is quieting down", t.unchanged ? "hot" : "")}
|
|
2227
|
-
${kpi("Change history", r.noChangeHistory ? "none" : "yes", r.noChangeHistory ? "no before/after yet" : "before/after available")}</div>`;
|
|
2228
|
-
|
|
2229
|
-
const cards = r.rules.map((x) => {
|
|
2230
|
-
const [cls, word] = TREND[x.trend];
|
|
2231
|
-
const spark = viz.sparkline(x.perDay.map((d) => d.n));
|
|
2232
|
-
const worst = x.clusters[0];
|
|
2233
|
-
return `<div class="chart-card" style="margin:0">
|
|
2234
|
-
<h3>${esc(x.rule)} <span><b class="${cls}">${word}</b> · ${x.total} incident${x.total === 1 ? "" : "s"} · ${x.acked} acked</span></h3>
|
|
2235
|
-
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">${spark}
|
|
2236
|
-
<span class="dim" style="font-size:var(--fs-sm)">${ago(x.lastAt)} since the last one</span></div>
|
|
2237
|
-
${worst && x.total > 1
|
|
2238
|
-
? `<p style="margin:0 0 8px;font-size:var(--fs-md)">${Math.round(x.concentration * 100)}% of these are the same shape: <code>${esc(worst.signature)}</code></p>
|
|
2239
|
-
<ul class="clusters">${x.clusters.map((c) => `<li><b title="${esc(c.signature)}">${esc(c.signature)}</b><span class="n">${c.hits}×</span><span title="${esc(c.example)}">${esc(c.example)}</span></li>`).join("")}</ul>`
|
|
2240
|
-
: '<p class="dim" style="margin:0;font-size:var(--fs-md)">Fired once. Whatever it was, it has not come back.</p>'}
|
|
2241
|
-
${x.landed
|
|
2242
|
-
? `<p class="dim" style="margin:10px 0 0;font-size:var(--fs-sm)">Since it landed ${ago(x.landed.at)} ago: <b>${x.landed.afterPerDay.toFixed(1)}/day</b>, against ${x.landed.beforePerDay.toFixed(1)}/day before.</p>`
|
|
2243
|
-
: ""}
|
|
2244
|
-
</div>`;
|
|
2245
|
-
}).join("");
|
|
2246
|
-
|
|
2247
|
-
$("#main").innerHTML = head(`${t.incidents} incidents · ${t.rules} rule${t.rules === 1 ? "" : "s"} · last 30 days`) + kpis +
|
|
2248
|
-
`<div class="cols">${cards}</div>` +
|
|
2249
|
-
(r.noChangeHistory
|
|
2250
|
-
? `<p class="dim" style="margin-top:10px;font-size:var(--fs-sm)"><b>No before-and-after yet.</b> Comparing a rule's rate before and after it landed needs to know when it landed, and nothing recorded that until now — the daemon writes <code>rules.changed</code> from this version on, so the comparison fills in for edits made from here.</p>`
|
|
2251
|
-
: "");
|
|
2252
|
-
}
|
|
2253
|
-
|
|
2254
|
-
function renderTimeline() {
|
|
2255
|
-
loadTimelineDetail();
|
|
2256
|
-
const now = Date.now();
|
|
2257
|
-
const hours = state.tlHours ?? 12;
|
|
2258
|
-
const from = now - hours * 3.6e6, to = now + 0.25 * 3.6e6;
|
|
2259
|
-
const rows = state.sessions.filter((s) => (!state.sel || s.projectId === state.sel) && new Date(s.lastSeenAt).getTime() >= from && s.kind !== "subagent");
|
|
2260
|
-
const agents = [...new Set(rows.map((s) => s.agent))].sort(viz.agentSort);
|
|
2261
|
-
const chip = (h) => `<a href="#" class="nav ${hours === h ? "on" : ""}" data-tl="${h}">${h}h</a>`;
|
|
2262
|
-
$("#main").innerHTML =
|
|
2263
|
-
`<h2>Timeline <span>${rows.length} sessions · last ${hours}h · ${usd(sumBy(rows, (s) => s.costUsd))}</span><span style="margin-left:auto;display:flex;gap:2px">${[3, 6, 12, 24, 72].map(chip).join("")}</span></h2>
|
|
2264
|
-
${rows.length ? viz.timeline(rows, { from, to, projName, now, detail: tlDetail.key === `${hours}:${state.sel ?? ""}` ? tlDetail.data : null }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
|
|
2265
|
-
${agents.length ? `<div style="margin-top:10px">${viz.legend(agents)}</div>` : ""}`;
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
// ---------- session
|
|
2269
|
-
const LOG_CAP = 500;
|
|
2270
|
-
// Re-polling the open session fetches only what is newer than what we hold (events by seq, turns by ts)
|
|
2271
|
-
// and appends, deduping against rows the SSE stream already pushed. A different session starts over.
|
|
2272
|
-
let sessionFetch = null;
|
|
2273
|
-
async function openSession(id) {
|
|
2274
|
-
const same = state.session === id && sessionFetch === id;
|
|
2275
|
-
if (!same) { state.session = id; state.log = []; state.turns = []; rowCache.clear(); logRendered = null; state.dirty = true; }
|
|
2276
|
-
const q = new URLSearchParams();
|
|
2277
|
-
if (same) {
|
|
2278
|
-
let seq = 0, ts = "";
|
|
2279
|
-
for (const e of state.log) if (e.seq > seq) seq = e.seq;
|
|
2280
|
-
for (const t of state.turns) if (t.ts > ts) ts = t.ts;
|
|
2281
|
-
if (seq) q.set("after", String(seq));
|
|
2282
|
-
if (ts) q.set("afterTs", ts);
|
|
2283
|
-
}
|
|
2284
|
-
const qs = q.toString();
|
|
2285
|
-
const d = await (await fetch(`/v1/sessions/${id}/events${qs ? `?${qs}` : ""}`)).json();
|
|
2286
|
-
if (state.session !== id) return; // user moved on while we were fetching
|
|
2287
|
-
sessionFetch = id;
|
|
2288
|
-
let changed = !same;
|
|
2289
|
-
if (same) {
|
|
2290
|
-
const seen = new Set(state.log.map((e) => e.seq));
|
|
2291
|
-
for (const e of d.events) if (!seen.has(e.seq)) { state.log.push(e); changed = true; }
|
|
2292
|
-
const tid = new Set(state.turns.map((t) => t.id));
|
|
2293
|
-
for (const t of d.turns) if (!tid.has(t.id)) { state.turns.push(t); changed = true; }
|
|
2294
|
-
if (d.events.length) state.log.sort((a, b) => a.seq - b.seq); // SSE pushes and the fetch may interleave
|
|
2295
|
-
if (d.turns.length) state.turns.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
2296
|
-
} else { state.log = d.events; state.turns = d.turns; }
|
|
2297
|
-
if (state.log.length > LOG_CAP) state.log.splice(0, state.log.length - LOG_CAP);
|
|
2298
|
-
if (changed) schedule();
|
|
2299
|
-
}
|
|
2300
|
-
// Rendered log rows, keyed per event seq / turn id (+ the mutable turn fields) so only new rows are formatted.
|
|
2301
|
-
const rowCache = new Map();
|
|
2302
|
-
let logRendered = null; // keys of the rows currently in #log, in order — enables append-only updates
|
|
2303
|
-
// The kind column showed raw hook names — "pretooluse", "subagentstop" — which are long, repeat on
|
|
2304
|
-
// every row, and say nothing the row does not: a tool row already begins with the tool's name. Short
|
|
2305
|
-
// labels here buy the transcript back ~70px of width per row; the full name stays in the title.
|
|
2306
|
-
const EV_LABEL = {
|
|
2307
|
-
PreToolUse: "tool", PostToolUse: "result", UserPromptSubmit: "you", Stop: "stop",
|
|
2308
|
-
SubagentStart: "sub →", SubagentStop: "sub ←", Notification: "note",
|
|
2309
|
-
SessionStart: "start", SessionEnd: "end", PreCompact: "compact",
|
|
2310
|
-
assistant: "agent", subagent: "sub",
|
|
2311
|
-
// ledger events reach the transcript too, and their dotted type names are the longest of all
|
|
2312
|
-
"incident.opened": "rule", "question.asked": "asks", "question.answered": "answer",
|
|
2313
|
-
"message.sent": "msg", "gate.recorded": "gate", "session.stuck": "stuck",
|
|
2314
|
-
"permission.requested": "perm?", "permission.resolved": "perm",
|
|
2315
|
-
"claim.acquired": "claim", "claim.released": "release", "pr.opened": "pr",
|
|
2316
|
-
};
|
|
2317
|
-
// Anything unmapped keeps its last dotted segment rather than the whole `a.b` name.
|
|
2318
|
-
const evLabel = (k) => EV_LABEL[k] ?? String(k).split(".").at(-1) ?? String(k);
|
|
2319
|
-
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>`;
|
|
2320
|
-
// Merge the two ts-sorted inputs (events by seq ≈ ts, turns by ts) in one pass → [{key, html}].
|
|
2321
|
-
function sessionStream() {
|
|
2322
|
-
const out = [];
|
|
2323
|
-
const log = state.log, turns = state.turns;
|
|
2324
|
-
let i = 0, j = 0;
|
|
2325
|
-
const pushEv = (e) => {
|
|
2326
|
-
const key = `e${e.seq}`;
|
|
2327
|
-
let html = rowCache.get(key);
|
|
2328
|
-
if (!html) rowCache.set(key, (html = evRow({ ts: e.ts, kind: e.payload?.hook ?? e.type, text: e.payload?.summary ?? "", cls: e.type })));
|
|
2329
|
-
out.push({ key, html });
|
|
2330
|
-
};
|
|
2331
|
-
const pushTurn = (t) => {
|
|
2332
|
-
const key = `t${t.id}:${t.costUsd ?? ""}:${t.output ?? ""}:${t.text.length}`;
|
|
2333
|
-
let html = rowCache.get(key);
|
|
2334
|
-
if (!html) rowCache.set(key, (html = evRow({ ts: t.ts, kind: t.sidechain ? "subagent" : "assistant", text: t.text, cls: "assistant", cost: t.costUsd, out: t.output })));
|
|
2335
|
-
out.push({ key, html });
|
|
2336
|
-
};
|
|
2337
|
-
while (i < log.length || j < turns.length) {
|
|
2338
|
-
if (i < log.length && log[i].payload?.hook === "PostToolUse") { i++; continue; }
|
|
2339
|
-
if (j < turns.length && !turns[j].text) { j++; continue; }
|
|
2340
|
-
if (j >= turns.length || (i < log.length && log[i].ts < turns[j].ts)) pushEv(log[i++]);
|
|
2341
|
-
else pushTurn(turns[j++]);
|
|
2342
|
-
}
|
|
2343
|
-
return out;
|
|
2344
|
-
}
|
|
2345
|
-
// True when `rows` only extends the rows already in #log (same session, same prefix) → append, don't rebuild.
|
|
2346
|
-
const isAppend = (rows) => logRendered && rows.length >= logRendered.length && logRendered.every((k, n) => rows[n].key === k);
|
|
2347
|
-
// M4.1 session replay: step through a session's tool calls, one at a time, with full input/output
|
|
2348
|
-
// (lazy-fetched from /v1/events/:seq). replayState holds the current step; nav by buttons or ←/→.
|
|
2349
|
-
const replay = { steps: [], i: 0, cache: new Map() };
|
|
2350
|
-
// M4.4: resume where this died — the daemon builds the prompt from the handoff + tail; we just confirm.
|
|
2351
|
-
async function resumeDead() {
|
|
2352
|
-
const id = state.session; if (!id) return;
|
|
2353
|
-
const plan = await fetch(`/v1/sessions/${encodeURIComponent(id)}/resume`).then((r) => r.json());
|
|
2354
|
-
if (!plan.ok) return alert(plan.error);
|
|
2355
|
-
if (!confirm(`Resume ${plan.task}${plan.owner ? ` as ${plan.owner}` : ""}?\n\n${plan.prompt.slice(0, 900)}${plan.prompt.length > 900 ? "…" : ""}`)) return;
|
|
2356
|
-
const r = await fetch(`/v1/sessions/${encodeURIComponent(id)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then((x) => x.json());
|
|
2357
|
-
if (!r.ok) return alert(r.error);
|
|
2358
|
-
openSession(r.run.sessionId);
|
|
2359
|
-
}
|
|
2360
|
-
function openReplay() {
|
|
2361
|
-
replay.steps = state.log.filter((e) => e.type === "tool.requested").map((e) => ({ seq: e.seq, tool: e.payload?.tool ?? "tool", summary: e.payload?.summary ?? "" }));
|
|
2362
|
-
replay.i = 0;
|
|
2363
|
-
replay.cache.clear();
|
|
2364
|
-
if (!replay.steps.length) { alert("No tool calls in this session yet."); return; }
|
|
2365
|
-
renderReplay();
|
|
2366
|
-
}
|
|
2367
|
-
async function renderReplay() {
|
|
2368
|
-
const n = replay.steps.length;
|
|
2369
|
-
const step = replay.steps[replay.i];
|
|
2370
|
-
let detail = replay.cache.get(step.seq);
|
|
2371
|
-
if (!detail) {
|
|
2372
|
-
// the request event (full input) and the paired completed event (output), both by seq
|
|
2373
|
-
const req = await fetch(`/v1/events/${step.seq}`).then((r) => r.json()).catch(() => null);
|
|
2374
|
-
const done = state.log.find((e) => e.type === "tool.completed" && e.seq > step.seq && e.payload?.summary === step.summary);
|
|
2375
|
-
const res = done ? await fetch(`/v1/events/${done.seq}`).then((r) => r.json()).catch(() => null) : null;
|
|
2376
|
-
detail = { input: req?.payload?.toolInput ?? null, output: res?.payload?.toolResponse ?? null, ts: req?.ts };
|
|
2377
|
-
replay.cache.set(step.seq, detail);
|
|
2378
|
-
}
|
|
2379
|
-
const j = (v) => (v == null ? "" : typeof v === "string" ? v : JSON.stringify(v, null, 2));
|
|
2380
|
-
$("#picker").innerHTML = `<div class="pk wn rp" role="dialog" aria-modal="true">
|
|
2381
|
-
<div class="pk-h">${ic("play", 15)}<b>Replay</b><span class="dim" style="margin-left:8px">${step.tool}</span><span class="grow"></span><span class="dim" style="font-size:var(--fs-sm)">${replay.i + 1} / ${n}${detail.ts ? ` · ${hhmm(detail.ts)}` : ""}</span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
|
|
2382
|
-
<div class="pk-b">
|
|
2383
|
-
<div class="dim now" style="font-family:var(--mono);font-size:var(--fs-sm);margin-bottom:8px">${esc(step.summary)}</div>
|
|
2384
|
-
<h4>input</h4><pre class="snip">${esc(j(detail.input)) || '<span class="dim">—</span>'}</pre>
|
|
2385
|
-
<h4>output</h4><pre class="snip">${detail.output != null ? esc(j(detail.output)).slice(0, 4000) : '<span class="dim">(no result captured)</span>'}</pre>
|
|
2386
|
-
</div>
|
|
2387
|
-
<div class="pk-f"><button id="rpPrev" ${replay.i === 0 ? "disabled" : ""}>${ic("arrow-left", 12)} Prev</button><span class="grow"></span><input id="rpRange" type="range" min="0" max="${n - 1}" value="${replay.i}" style="flex:1;max-width:280px"><span class="grow"></span><button class="primary" id="rpNext" ${replay.i >= n - 1 ? "disabled" : ""}>Next ${ic("arrow-right", 12)}</button></div>
|
|
2388
|
-
</div>`;
|
|
2389
|
-
}
|
|
2390
|
-
function replayGo(delta) {
|
|
2391
|
-
const n = replay.steps.length;
|
|
2392
|
-
replay.i = Math.max(0, Math.min(n - 1, replay.i + delta));
|
|
2393
|
-
renderReplay();
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
// Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
|
|
2397
|
-
// M7.6: the session's message thread (sent + received) and a compose box. Messages are never an
|
|
2398
|
-
// interrupt: they ride along as context on the agent's next tool call, so the block says so.
|
|
2399
|
-
function messageThread(s) {
|
|
2400
|
-
const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
|
|
2401
|
-
const queued = ms.filter((m) => m.fromSession !== s.id && !m.deliveredAt).length;
|
|
2402
|
-
const ended = s.state === "ended";
|
|
2403
|
-
const row = (m) => {
|
|
2404
|
-
const out = m.fromSession === s.id;
|
|
2405
|
-
return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
|
|
2406
|
-
<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>`;
|
|
2407
|
-
};
|
|
2408
|
-
const hint = ended
|
|
2409
|
-
? `${ic("warning", 12)} Session ended — there is nothing left to deliver to.`
|
|
2410
|
-
: queued
|
|
2411
|
-
? `${ic("clock", 12)} <b>${queued} queued</b> · delivered the next time this agent calls a tool.`
|
|
2412
|
-
: `${ic("comment-text", 12)} Delivered as context on this agent's next tool call — never an interrupt.`;
|
|
2413
|
-
return `<h4>messages${ms.length ? ` <span class="badge">${ms.length}</span>` : ""}</h4>
|
|
2414
|
-
${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : ""}
|
|
2415
|
-
<div class="msg-compose">
|
|
2416
|
-
<input id="msgText" placeholder="Message this agent…" aria-label="Message this agent" autocomplete="off"${ended ? " disabled" : ""}>
|
|
2417
|
-
<button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}" title="Send (Enter)"${ended ? " disabled" : ""}>${ic("arrow-right", 12)}Send</button>
|
|
2418
|
-
</div>
|
|
2419
|
-
<p class="msg-hint">${hint}</p>`;
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
|
-
// The transcript file, as one copyable row: the directory truncates, the file name always shows.
|
|
2423
|
-
function transcriptRow(s) {
|
|
2424
|
-
if (!s.transcriptPath) return "";
|
|
2425
|
-
const p = short(s.transcriptPath);
|
|
2426
|
-
const cut = p.lastIndexOf("/");
|
|
2427
|
-
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>`;
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
// M7.7: questions this session is waiting on a human for
|
|
2431
|
-
function questionCards(s) {
|
|
2432
|
-
const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
|
|
2433
|
-
if (!qs.length) return "";
|
|
2434
|
-
return `<h4>waiting on you</h4>${qs.map((q) => `<div class="perm"><div class="perm-t">${ic("warning", 13)} <b>Question #${q.id}</b>${q.task ? `<span class="dim"> · ${esc(q.task)}</span>` : ""}</div><div class="perm-c">${esc(q.text)}</div><div class="perm-b">${(q.options ?? []).map((o) => `<button class="ok" data-qanswer="${q.id}" data-text="${esc(o)}">${esc(o)}</button>`).join("")}<button data-qanswer="${q.id}">Answer…</button></div></div>`).join("")}`;
|
|
2435
|
-
}
|
|
2436
|
-
async function answerQuestion(id, preset) {
|
|
2437
|
-
const text = preset ?? prompt(`Answer to question #${id}:`);
|
|
2438
|
-
if (!text) return;
|
|
2439
|
-
const r = await fetch(`/v1/questions/${id}/answer`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, by: "dashboard" }) }).then((x) => x.json());
|
|
2440
|
-
if (!r.ok) alert(r.error);
|
|
2441
|
-
return refresh();
|
|
2442
|
-
}
|
|
2443
|
-
function stdinBox(s) {
|
|
2444
|
-
if (s.kind !== "spawned") return "";
|
|
2445
|
-
const run = (state.runs ?? []).find((r) => r.sessionId === s.id);
|
|
2446
|
-
if (!run) return `<div class="stdin"><span class="hint">${ic("play", 12)} spawned by swarm run · no longer live</span></div>`;
|
|
2447
|
-
const perms = (run.pending ?? []).map((pp) => `<div class="perm"><div class="perm-t">${ic("warning", 13)} <b>${esc(pp.tool)}</b> needs approval<span class="dim now" title="${esc(pp.reason)}"> — ${esc(pp.reason)}</span></div><div class="perm-c">${esc(pp.display)}</div><div class="perm-b"><button class="ok" data-perm-allow="${esc(run.id)}:${esc(pp.requestId)}">Allow</button><button class="danger" data-perm-deny="${esc(run.id)}:${esc(pp.requestId)}">Deny</button></div></div>`).join("");
|
|
2448
|
-
return `${perms}<div class="stdin" id="stdin"><input id="stdinText" placeholder="Send a message to this run… (Enter)" autocomplete="off" spellcheck="false"><button id="stdinSend">${ic("arrow-right", 13)} Send</button><button class="danger" data-runstop="${esc(run.id)}">Stop</button><span class="hint">run ${esc(run.id)} · pid ${run.pid}${run.result ? ` · $${run.result.costUsd.toFixed(2)} so far` : ""}</span></div>`;
|
|
2449
|
-
}
|
|
2450
|
-
async function sendStdin() {
|
|
2451
|
-
const s = state.sessions.find((x) => x.id === state.session);
|
|
2452
|
-
const run = (state.runs ?? []).find((r) => r.sessionId === s?.id);
|
|
2453
|
-
const el = $("#stdinText"); const text = el?.value.trim();
|
|
2454
|
-
if (!run || !text) return;
|
|
2455
|
-
el.value = "";
|
|
2456
|
-
const r = await fetch(`/v1/runs/${encodeURIComponent(run.id)}/send`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });
|
|
2457
|
-
if (!r.ok) alert((await r.json()).error);
|
|
2458
|
-
refresh();
|
|
2459
|
-
}
|
|
2460
|
-
document.addEventListener("click", (ev) => {
|
|
2461
|
-
if (ev.target.closest("#stdinSend")) return sendStdin();
|
|
2462
|
-
const cp = ev.target.closest("[data-copy]");
|
|
2463
|
-
if (cp) { ev.preventDefault(); copy(cp.dataset.copy).then((ok) => { cp.classList.add(ok ? "copied" : "copy-failed"); setTimeout(() => cp.classList.remove("copied", "copy-failed"), 1200); }); return; }
|
|
2464
|
-
const qa = ev.target.closest("[data-qanswer]");
|
|
2465
|
-
if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
|
|
2466
|
-
const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
|
|
2467
|
-
const key = a?.dataset.permAllow || d?.dataset.permDeny;
|
|
2468
|
-
if (key) {
|
|
2469
|
-
const [runId, reqId] = key.split(":");
|
|
2470
|
-
return fetch(`/v1/runs/${encodeURIComponent(runId)}/permissions/${encodeURIComponent(reqId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ allow: Boolean(a) }) }).then(refresh);
|
|
2471
|
-
}
|
|
2472
|
-
});
|
|
2473
|
-
document.addEventListener("keydown", (ev) => { if (ev.key === "Enter" && ev.target.id === "stdinText") { ev.preventDefault(); sendStdin(); } });
|
|
2474
|
-
|
|
2475
|
-
function renderSession() {
|
|
2476
|
-
const s = state.sessions.find((x) => x.id === state.session);
|
|
2477
|
-
if (!s) return;
|
|
2478
|
-
const logEl = $("#log");
|
|
2479
|
-
const atBottom = !logEl || logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 40;
|
|
2480
|
-
const prevTop = logEl ? logEl.scrollTop : 0;
|
|
2481
|
-
const rows = sessionStream();
|
|
2482
|
-
const tools = Object.entries(s.toolCounts).sort((a, b) => b[1] - a[1]);
|
|
2483
|
-
const t = s.tokens;
|
|
2484
|
-
const ctx = t.input + t.cacheRead + t.cacheWrite;
|
|
2485
|
-
const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
|
|
2486
|
-
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" };
|
|
2487
|
-
const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
|
|
2488
|
-
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>`;
|
|
2489
|
-
const side = `<div class="stats">
|
|
2490
|
-
${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
|
|
2491
|
-
${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>`)}
|
|
2492
|
-
${stat("started", `${ago(s.startedAt)} ago`)}${stat("last seen", `${ago(s.lastSeenAt)} ago`)}
|
|
2493
|
-
${subTurns.length ? stat("subagent turns", subTurns.length) : ""}
|
|
2494
|
-
</div>
|
|
2495
|
-
<h4>tokens</h4>${viz.compositionBar([{ label: "cache read", v: t.cacheRead }, { label: "cache write", v: t.cacheWrite }, { label: "input", v: t.input }, { label: "thinking", v: t.thinking }, { label: "output", v: t.output }])}
|
|
2496
|
-
${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
|
|
2497
|
-
<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>'}
|
|
2498
|
-
${messageThread(s)}
|
|
2499
|
-
${questionCards(s)}
|
|
2500
|
-
${transcriptRow(s)}`;
|
|
2501
|
-
if (logEl && isAppend(rows)) {
|
|
2502
|
-
// Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
|
|
2503
|
-
// scroll position (and its DOM) untouched.
|
|
2504
|
-
$("#main > h2").outerHTML = head;
|
|
2505
|
-
// The message compose box lives inside .side, and this fast-path runs on every event while the
|
|
2506
|
-
// agent works — carry the draft (and the caret) across the swap instead of wiping what is
|
|
2507
|
-
// being typed.
|
|
2508
|
-
const msg = $("#msgText");
|
|
2509
|
-
const draft = msg?.value ? { v: msg.value, focused: document.activeElement === msg, pos: msg.selectionStart } : null;
|
|
2510
|
-
$("#main .side").innerHTML = side;
|
|
2511
|
-
if (draft) {
|
|
2512
|
-
const el = $("#msgText");
|
|
2513
|
-
if (el) {
|
|
2514
|
-
el.value = draft.v;
|
|
2515
|
-
if (draft.focused) { el.focus(); el.setSelectionRange(draft.pos, draft.pos); }
|
|
2516
|
-
}
|
|
2517
|
-
}
|
|
2518
|
-
const sb = stdinBox(s); const cur = $("#main .stdin");
|
|
2519
|
-
if (cur && cur.outerHTML !== sb && document.activeElement?.id !== "stdinText") cur.outerHTML = sb;
|
|
2520
|
-
else if (!cur && sb) $("#main").insertAdjacentHTML("beforeend", sb);
|
|
2521
|
-
if (rows.length > logRendered.length) logEl.insertAdjacentHTML("beforeend", rows.slice(logRendered.length).map((r) => r.html).join(""));
|
|
2522
|
-
} else {
|
|
2523
|
-
const sb = stdinBox(s);
|
|
2524
|
-
$("#main").innerHTML = `${head}<div class="sess ${sb ? "has-stdin" : ""}"><div id="log">${rows.map((r) => r.html).join("")}</div><aside class="side">${side}</aside></div>${sb}`;
|
|
2525
|
-
}
|
|
2526
|
-
logRendered = rows.map((r) => r.key);
|
|
2527
|
-
// Follow the tail when pinned to the bottom; otherwise keep the reading position —
|
|
2528
|
-
// innerHTML replacement resets scroll to the top on every live update.
|
|
2529
|
-
const nl = $("#log");
|
|
2530
|
-
if (nl) nl.scrollTop = atBottom ? nl.scrollHeight : prevTop;
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
// ---------- menus (fancy-menus island; see src/menus.tsx). Menus are plain data.
|
|
2535
|
-
const pinProject = (id, pinned) => fetch(`/v1/projects/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) }).then(refresh);
|
|
2536
|
-
const removeProject = (id) => fetch(`/v1/projects/${id}`, { method: "DELETE" }).then(refresh);
|
|
2537
|
-
// ---------- row actions (shared by the row menus, right-click, and any remaining links)
|
|
2538
|
-
const post = (url, body) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
|
|
2539
|
-
const act = {
|
|
2540
|
-
async wtOpen(projectId, worktree) { const r = await post("/v1/worktrees/open", { projectId, worktree }); if (!r.ok) alert(r.error); },
|
|
2541
|
-
wtDiff(projectId, worktree) { openDiffDrawer(projectId, worktree); },
|
|
2542
|
-
wtPr(projectId, worktree) { openPrDrawer(projectId, worktree); },
|
|
2543
|
-
async wtRemove(projectId, worktree) {
|
|
2544
|
-
const rm = (force) => post("/v1/worktrees/remove", { projectId, worktree, force });
|
|
2545
|
-
if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
|
|
2546
|
-
const r = await rm(false);
|
|
2547
|
-
if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
|
|
2548
|
-
if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
|
|
2549
|
-
} else if (!r.ok) alert(r.error);
|
|
2550
|
-
state.worktrees[projectId] = null;
|
|
2551
|
-
refresh();
|
|
2552
|
-
},
|
|
2553
|
-
async claimTask(task) {
|
|
2554
|
-
const r = await post("/v1/claims", { projectId: state.sel, task, owner: "dashboard" });
|
|
2555
|
-
if (!r.ok) alert(r.error); else state.tasks = null;
|
|
2556
|
-
refresh();
|
|
2557
|
-
},
|
|
2558
|
-
runTask(task) { openRunDrawer(task); },
|
|
2559
|
-
async gateRun(task) {
|
|
2560
|
-
const r = await post("/v1/gates/run", { projectId: state.sel, task });
|
|
2561
|
-
if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
|
|
2562
|
-
else alert(`${task}: ${r.runs.map((x) => `${x.verdict === "pass" ? "✓" : "✗"} ${x.gate} — ${x.rubric}`).join("\n")}${r.skipped.length ? `\n\nskipped: ${r.skipped.map((x) => `${x.gate} (${x.reason})`).join(", ")}` : ""}`);
|
|
2563
|
-
state.tasks = null;
|
|
2564
|
-
refresh();
|
|
2565
|
-
},
|
|
2566
|
-
async releaseClaim(projectId, task, force) {
|
|
2567
|
-
if (force && !confirm(`Force-release ${task}? This permanently discards its worktree and any uncommitted work.`)) return;
|
|
2568
|
-
const r = await post("/v1/claims/release", { projectId, task, force });
|
|
2569
|
-
if (!r.ok && confirm(`${r.error}\n\nForce-release anyway (discards the work)?`)) await post("/v1/claims/release", { projectId, task, force: true });
|
|
2570
|
-
refresh();
|
|
2571
|
-
},
|
|
2572
|
-
async merge(projectId, number) {
|
|
2573
|
-
if (!confirm(`Squash-merge #${number}?`)) return;
|
|
2574
|
-
const r = await post("/v1/prs/merge", { projectId, number: Number(number) });
|
|
2575
|
-
if (r.ok === false || r.error) alert(r.error);
|
|
2576
|
-
refresh();
|
|
2577
|
-
},
|
|
2578
|
-
async procStop(pid, projectId) {
|
|
2579
|
-
if (!confirm(`Stop pid ${pid}?`)) return;
|
|
2580
|
-
const r = await fetch(`/v1/processes/${pid}?project=${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
|
2581
|
-
if (!r.ok) alert((await r.json()).error);
|
|
2582
|
-
refresh();
|
|
2583
|
-
},
|
|
2584
|
-
resRelease(name, projectId) {
|
|
2585
|
-
const q = new URLSearchParams({ force: "1" }); if (projectId) q.set("project", projectId);
|
|
2586
|
-
return fetch(`/v1/resources/${encodeURIComponent(name)}?${q}`, { method: "DELETE" }).then(refresh);
|
|
2587
|
-
},
|
|
2588
|
-
ack(seq) { return fetch(`/v1/incidents/${seq}/ack`, { method: "POST" }).then(refresh); },
|
|
2589
|
-
codify(seq) { codifyIncident(seq); },
|
|
2590
|
-
};
|
|
2591
|
-
/** Hover kebab that opens the row menu `kind`; `attrs` are the data-* the menu needs. */
|
|
2592
|
-
const more = (kind, attrs, title = "Actions") => `<span class="more" tabindex="0" role="button" data-menu="${kind}" ${attrs} title="${title}">${ic("dots-three", 15)}</span>`;
|
|
2593
|
-
|
|
2594
|
-
function menuSpec(kind, d) {
|
|
2595
|
-
if (kind === "project") {
|
|
2596
|
-
const p = state.projects.find((x) => x.id === d.pid);
|
|
2597
|
-
if (!p) return null;
|
|
2598
|
-
const live = state.sessions.filter((s) => s.projectId === p.id && (s.state === "active" || s.state === "waiting")).length;
|
|
2599
|
-
return { title: p.name, items: [
|
|
2600
|
-
{ label: "Show sessions", icon: "squares-four", caption: live ? `${live} live` : undefined, run: () => { state.sel = p.id; state.view = "fleet"; state.session = null; touch(); } },
|
|
2601
|
-
{ label: "Show in Timeline", icon: "clock-counter-clockwise", run: () => { state.sel = p.id; state.view = "timeline"; state.session = null; touch(); } },
|
|
2602
|
-
{ label: "Spend", icon: "coins", run: () => { state.sel = p.id; state.view = "spend"; state.session = null; touch(); } },
|
|
2603
|
-
{ label: "Stats", icon: "chart-bar", run: () => { state.sel = p.id; state.view = "stats"; state.session = null; touch(); } },
|
|
2604
|
-
{ divider: true },
|
|
2605
|
-
p.discovered ? { label: "Pin project", icon: "push-pin", run: () => pinProject(p.id, true) } : { label: "Unpin project", icon: "push-pin-slash", run: () => pinProject(p.id, false) },
|
|
2606
|
-
{ label: "Settings…", icon: "sliders", caption: "name · icon · color", run: () => openProjectSettings(p.id) },
|
|
2607
|
-
{ label: "Copy path", icon: "copy", caption: tail(p.root, 16), run: () => copy(p.root) },
|
|
2608
|
-
{ divider: true },
|
|
2609
|
-
{ label: "Remove from Swarm", icon: "trash", danger: true, run: () => removeProject(p.id) },
|
|
2610
|
-
] };
|
|
2611
|
-
}
|
|
2612
|
-
if (kind === "session") {
|
|
2613
|
-
const s = state.sessions.find((x) => x.id === d.sid);
|
|
2614
|
-
if (!s) return null;
|
|
2615
|
-
return { title: s.title ?? s.id.slice(0, 8), items: [
|
|
2616
|
-
{ label: "Open session", icon: "terminal-window", run: () => openSession(s.id) },
|
|
2617
|
-
{ label: "Show in Timeline", icon: "clock-counter-clockwise", run: () => { state.sel = s.projectId; state.view = "timeline"; state.session = null; touch(); } },
|
|
2618
|
-
{ divider: true },
|
|
2619
|
-
{ section: "Copy" },
|
|
2620
|
-
{ label: "Session id", icon: "copy", caption: s.id.slice(0, 8), run: () => copy(s.id) },
|
|
2621
|
-
{ label: "Working directory", icon: "folder-simple", caption: tail(s.cwd, 16), run: () => copy(s.cwd) },
|
|
2622
|
-
...(s.transcriptPath ? [{ label: "Transcript path", icon: "file-text", run: () => copy(s.transcriptPath) }] : []),
|
|
2623
|
-
...(s.branch ? [{ label: "Branch", icon: "git-branch", caption: tail(s.branch, 16), run: () => copy(s.branch) }] : []),
|
|
2624
|
-
] };
|
|
2625
|
-
}
|
|
2626
|
-
if (kind === "worktree") {
|
|
2627
|
-
const w = (state.worktrees[d.pid] ?? []).find((x) => x.path === d.path);
|
|
2628
|
-
if (!w) return null;
|
|
2629
|
-
const held = (state.claims ?? []).some((c) => c.state === "held" && c.worktree === w.path);
|
|
2630
|
-
const sess = state.sessions.filter((x) => x.state !== "ended" && (x.cwd === w.path || x.cwd.startsWith(`${w.path}/`)));
|
|
2631
|
-
return { title: w.branch ?? "(detached)", items: [
|
|
2632
|
-
{ label: "Open", icon: "arrow-square-out", caption: "editor", run: () => act.wtOpen(d.pid, w.path) },
|
|
2633
|
-
...(w.main ? [] : [{ label: "Diff", icon: "folders", caption: "vs main", run: () => act.wtDiff(d.pid, w.path) }]),
|
|
2634
|
-
...(w.branch && !w.merged && !w.main ? [{ label: "Open PR", icon: "git-pull-request", run: () => act.wtPr(d.pid, w.path) }] : []),
|
|
2635
|
-
...(sess.length ? [{ divider: true }, { section: "Sessions" }, ...sess.map((x) => ({ label: x.title ?? x.id.slice(0, 8), icon: "terminal-window", run: () => openSession(x.id) }))] : []),
|
|
2636
|
-
{ divider: true },
|
|
2637
|
-
{ label: "Copy path", icon: "copy", caption: tail(w.path, 14), run: () => copy(w.path) },
|
|
2638
|
-
...(w.branch ? [{ label: "Copy branch", icon: "git-branch", caption: tail(w.branch, 14), run: () => copy(w.branch) }] : []),
|
|
2639
|
-
...(w.main || held ? [] : [{ divider: true }, { label: "Remove", icon: "trash", danger: true, caption: w.dirty > 0 ? "dirty" : w.ahead > 0 ? "unpushed" : undefined, run: () => act.wtRemove(d.pid, w.path) }]),
|
|
2640
|
-
] };
|
|
2641
|
-
}
|
|
2642
|
-
if (kind === "task") {
|
|
2643
|
-
const t = (state.tasks?.tasks ?? []).find((x) => x.id === d.task);
|
|
2644
|
-
if (!t) return null;
|
|
2645
|
-
const exec = state.gates?.executable ?? [];
|
|
2646
|
-
return { title: t.id, items: [
|
|
2647
|
-
...(t.ready ? [
|
|
2648
|
-
{ label: "Run", icon: "play", caption: "claim + claude -p", run: () => act.runTask(t.id) },
|
|
2649
|
-
{ label: "Claim", icon: "folders", caption: "fresh worktree", run: () => act.claimTask(t.id) },
|
|
2650
|
-
] : t.claimedBy ? [
|
|
2651
|
-
{ label: "Run in worktree", icon: "play", run: () => act.runTask(t.id) },
|
|
2652
|
-
...(exec.length ? [{ label: "Run gates", icon: "check", caption: exec.join(", "), run: () => act.gateRun(t.id) }] : []),
|
|
2653
|
-
] : [{ label: t.status === "done" ? "Done" : "Blocked", disabled: true }]),
|
|
2654
|
-
{ divider: true },
|
|
2655
|
-
{ label: "Copy id", icon: "copy", caption: t.id, run: () => copy(t.id) },
|
|
2656
|
-
{ label: "Copy title", icon: "file-text", run: () => copy(`${t.id} — ${t.title}`) },
|
|
2657
|
-
] };
|
|
2658
|
-
}
|
|
2659
|
-
if (kind === "claim") {
|
|
2660
|
-
const c = (state.claims ?? []).find((x) => x.projectId === d.pid && x.task === d.task);
|
|
2661
|
-
if (!c) return null;
|
|
2662
|
-
const w = (state.worktrees[c.projectId] ?? []).find((x) => x.path === c.worktree);
|
|
2663
|
-
return { title: c.task, items: [
|
|
2664
|
-
...(w ? [{ label: "Open worktree", icon: "arrow-square-out", run: () => act.wtOpen(c.projectId, c.worktree) }, { label: "Diff", icon: "folders", run: () => act.wtDiff(c.projectId, c.worktree) }] : []),
|
|
2665
|
-
{ label: "Copy path", icon: "copy", caption: tail(c.worktree, 14), run: () => copy(c.worktree) },
|
|
2666
|
-
{ divider: true },
|
|
2667
|
-
c.state === "orphaned"
|
|
2668
|
-
? { label: "Force release", icon: "trash", danger: true, caption: "discards work", run: () => act.releaseClaim(c.projectId, c.task, true) }
|
|
2669
|
-
: { label: "Release claim", icon: "x", run: () => act.releaseClaim(c.projectId, c.task, false) },
|
|
2670
|
-
] };
|
|
2671
|
-
}
|
|
2672
|
-
if (kind === "pr") {
|
|
2673
|
-
const p = (state.prs ?? []).find((x) => String(x.projectId) === d.pid && String(x.number) === d.num);
|
|
2674
|
-
if (!p) return null;
|
|
2675
|
-
const green = p.checks !== "fail" && p.mergeable && !p.draft;
|
|
2676
|
-
return { title: `#${p.number}`, items: [
|
|
2677
|
-
{ label: "Open on " + (p.forge === "gitlab" ? "GitLab" : "GitHub"), icon: "arrow-square-out", run: () => openExternal(p.url) },
|
|
2678
|
-
{ label: "Copy URL", icon: "copy", run: () => copy(p.url) },
|
|
2679
|
-
{ divider: true },
|
|
2680
|
-
{ 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) },
|
|
2681
|
-
] };
|
|
2682
|
-
}
|
|
2683
|
-
if (kind === "process") {
|
|
2684
|
-
return { items: [
|
|
2685
|
-
{ label: "Copy pid", icon: "copy", caption: d.pid, run: () => copy(d.pid) },
|
|
2686
|
-
...(d.cwd ? [{ label: "Copy cwd", icon: "folder-simple", caption: tail(d.cwd, 16), run: () => copy(d.cwd) }] : []),
|
|
2687
|
-
{ divider: true },
|
|
2688
|
-
{ label: "Stop", icon: "stop", danger: true, caption: "SIGTERM → SIGKILL", run: () => act.procStop(d.pid, d.proj) },
|
|
2689
|
-
] };
|
|
2690
|
-
}
|
|
2691
|
-
if (kind === "resource") {
|
|
2692
|
-
return { title: d.name, items: [
|
|
2693
|
-
{ label: "Copy name", icon: "copy", run: () => copy(d.name) },
|
|
2694
|
-
{ divider: true },
|
|
2695
|
-
{ label: "Release", icon: "x", danger: true, caption: "force", run: () => act.resRelease(d.name, d.proj) },
|
|
2696
|
-
] };
|
|
2697
|
-
}
|
|
2698
|
-
if (kind === "incident") {
|
|
2699
|
-
const i = [...(state.incidents ?? []), ...(state.allIncidents ?? [])].find((x) => String(x.seq) === d.seq);
|
|
2700
|
-
if (!i) return null;
|
|
2701
|
-
return { items: [
|
|
2702
|
-
...(i.sessionId ? [{ label: "Open session", icon: "terminal-window", run: () => openSession(i.sessionId) }] : []),
|
|
2703
|
-
...(i.suggestion ? [{ label: "Codify", icon: "shield", caption: "rule / lesson", run: () => act.codify(i.seq) }] : []),
|
|
2704
|
-
{ label: "Copy command", icon: "copy", run: () => copy(i.command ?? "") },
|
|
2705
|
-
...(i.acked ? [] : [{ divider: true }, { label: "Acknowledge", icon: "check", run: () => act.ack(i.seq) }]),
|
|
2706
|
-
] };
|
|
2707
|
-
}
|
|
2708
|
-
if (kind === "settings") {
|
|
2709
|
-
const theme = getTheme();
|
|
2710
|
-
const th = (id, label, icon) => ({ label, icon, pressed: theme === id, run: () => { setTheme(id); $("#settings").blur(); } });
|
|
2711
|
-
return { items: [
|
|
2712
|
-
{ label: "Theme", icon: theme === "dark" ? "moon" : theme === "light" ? "sun" : "monitor", caption: theme, children: [th("system", "System", "monitor"), th("light", "Light", "sun"), th("dark", "Dark", "moon")] },
|
|
2713
|
-
{ divider: true },
|
|
2714
|
-
{ label: "Refresh pricing", icon: "arrows-clockwise", caption: "LiteLLM", run: async () => { const r = await fetch("/v1/pricing/refresh", { method: "POST" }); if (!r.ok) console.warn("pricing refresh failed", r.status); refresh(); } },
|
|
2715
|
-
{ label: "Copy dashboard URL", icon: "copy", run: () => copy(location.origin) },
|
|
2716
|
-
{ divider: true },
|
|
2717
|
-
{ label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
|
|
2718
|
-
{ label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
|
|
2719
|
-
{ label: "Documentation", icon: "book-open", caption: "getswarm", run: () => openExternal("https://getswarm.vercel.app/docs/") },
|
|
2720
|
-
{ label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => openExternal(feedbackUrl()) },
|
|
2721
|
-
] };
|
|
2722
|
-
}
|
|
2723
|
-
return null;
|
|
2724
|
-
}
|
|
2725
|
-
// M4.7 desktop notifications: native notifications (web Notification API — works in the browser and
|
|
2726
|
-
// the desktop app's webview) for the things you'd want to walk away and be pinged about — a spawned
|
|
2727
|
-
// run waiting on a permission, and a claim orphaned with unfinished work. Clicking opens the spot to
|
|
2728
|
-
// act. Off until enabled from the settings menu (which requests OS permission). Quiet while focused.
|
|
2729
|
-
const NOTIFY_KEY = "swarm.notify";
|
|
2730
|
-
const notifyOn = () => { try { return localStorage.getItem(NOTIFY_KEY) === "on"; } catch { return false; } };
|
|
2731
|
-
async function enableNotifications() {
|
|
2732
|
-
if (!("Notification" in window)) { alert("This browser doesn't support notifications."); return; }
|
|
2733
|
-
const perm = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
|
|
2734
|
-
if (perm !== "granted") { alert("Notifications were blocked. Allow them for this site in your browser/OS settings."); return; }
|
|
2735
|
-
try { localStorage.setItem(NOTIFY_KEY, "on"); } catch {}
|
|
2736
|
-
new Notification("Swarm notifications on", { body: "You'll be pinged when a run needs a permission or a claim is orphaned." });
|
|
2737
|
-
}
|
|
2738
|
-
function disableNotifications() { try { localStorage.setItem(NOTIFY_KEY, "off"); } catch {} }
|
|
2739
|
-
let lastNotifyAt = 0;
|
|
2740
|
-
function notifyForEvent(ev) {
|
|
2741
|
-
if (!notifyOn() || !("Notification" in window) || Notification.permission !== "granted") return;
|
|
2742
|
-
if (!document.hidden && ev.type !== "permission.requested" && ev.type !== "question.asked") return; // only prompts that block an agent interrupt while you're looking
|
|
2743
|
-
const now = Date.now();
|
|
2744
|
-
if (now - lastNotifyAt < 1500) return; // don't stack
|
|
2745
|
-
const p = ev.payload || {};
|
|
2746
|
-
let title, body, onClick;
|
|
2747
|
-
if (ev.type === "permission.requested") {
|
|
2748
|
-
title = `Permission needed: ${p.tool ?? "tool"}`;
|
|
2749
|
-
body = `${p.display ?? ""}
|
|
2750
|
-
${p.reason ?? ""}`.slice(0, 180);
|
|
2751
|
-
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
2752
|
-
} else if (ev.type === "question.asked") {
|
|
2753
|
-
title = "An agent has a question";
|
|
2754
|
-
body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
|
|
2755
|
-
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
2756
|
-
} else if (ev.type === "session.stuck") {
|
|
2757
|
-
title = "Session looks stuck";
|
|
2758
|
-
body = (p.reason ?? p.summary ?? "").slice(0, 180);
|
|
2759
|
-
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
2760
|
-
} else if (ev.type === "claim.orphaned") {
|
|
2761
|
-
title = "Claim orphaned";
|
|
2762
|
-
body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
|
|
2763
|
-
onClick = () => { state.view = "board"; state.sel = ev.projectId || state.sel; state.session = null; refresh(); };
|
|
2764
|
-
} else return;
|
|
2765
|
-
lastNotifyAt = now;
|
|
2766
|
-
const n = new Notification(title, { body, tag: `swarm-${ev.type}-${ev.sessionId ?? ev.seq}` });
|
|
2767
|
-
n.onclick = () => { window.focus(); onClick?.(); n.close(); };
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
// What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
|
|
2771
|
-
// The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
|
|
2772
|
-
// itself once after an upgrade (localStorage remembers the last version the user saw).
|
|
2773
|
-
// `strict` matters: the automatic post-upgrade panel must never fall back. Falling back showed
|
|
2774
|
-
// 0.10.0's notes under a "What's New" triggered by upgrading to 0.11.0 — the notes bundle was a
|
|
2775
|
-
// stale cached copy that had no 0.11.0 in it, and the fallback quietly hid that.
|
|
2776
|
-
function releaseNotesFor(version, { strict = false } = {}) {
|
|
2777
|
-
const all = window.RELEASE_NOTES || {};
|
|
2778
|
-
if (version && all[version]) return { version, ...all[version] };
|
|
2779
|
-
if (strict) return null;
|
|
2780
|
-
const latest = Object.keys(all)[0];
|
|
2781
|
-
return latest ? { version: latest, ...all[latest] } : null;
|
|
2782
|
-
}
|
|
2783
|
-
function whatsNew(version) {
|
|
2784
|
-
const n = releaseNotesFor(version || state.version);
|
|
2785
|
-
if (!n) return;
|
|
2786
|
-
try { localStorage.setItem("swarm.seenVersion", n.version); } catch {}
|
|
2787
|
-
$("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
|
|
2788
|
-
<div class="pk-h">${ic("star", 15)}<b>What's New</b><span class="grow"></span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
|
|
2789
|
-
<div class="pk-b"><h3>Swarm ${esc(n.version)}</h3>${n.date ? `<div class="date">${esc(n.date)}</div>` : ""}${n.html}</div>
|
|
2790
|
-
<div class="pk-f"><span class="grow"></span><a href="https://getswarm.vercel.app/changelog" target="_blank" rel="noopener" style="align-self:center;color:var(--dim);font-size:var(--fs-sm)">Full changelog →</a><button id="pkCancel">Close</button></div>
|
|
2791
|
-
</div>`;
|
|
2792
|
-
}
|
|
2793
|
-
window.swarmWhatsNew = (v) => whatsNew(v);
|
|
2794
|
-
// auto-open once per version, but never on the very first run (nothing to compare against)
|
|
2795
|
-
// M-launch: after an update the running daemon is the old build until it restarts. The daemon
|
|
2796
|
-
// reports the version on disk; when it differs, offer a one-click restart, then reload.
|
|
2797
|
-
let updateNudged = false;
|
|
2798
|
-
setInterval(() => { fetch("/v1/health").then((r) => r.json()).then(maybeUpdateNudge).catch(() => {}); }, 300_000);
|
|
2799
|
-
function maybeUpdateNudge(h) {
|
|
2800
|
-
if (!h?.disk || !h.version || h.disk === h.version || updateNudged) return;
|
|
2801
|
-
updateNudged = true;
|
|
2802
|
-
const el = document.createElement("div");
|
|
2803
|
-
el.className = "nudge";
|
|
2804
|
-
el.innerHTML = `${ic("arrows-clockwise", 18, "ic")}<div><b>Swarm ${esc(h.disk)} is installed</b>The daemon is still running ${esc(h.version)} — restart it to switch. Sessions and history are unaffected.
|
|
2805
|
-
<div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
|
|
2806
|
-
document.body.appendChild(el);
|
|
2807
|
-
el.addEventListener("click", async (e) => {
|
|
2808
|
-
// closest(), not e.target.id: the button holds an <svg> icon, so a click on the glyph itself
|
|
2809
|
-
// targets the svg/path and an id check would miss it.
|
|
2810
|
-
const btn = e.target.closest?.("button");
|
|
2811
|
-
if (btn?.id === "updLater") return el.remove();
|
|
2812
|
-
if (btn?.id !== "updRestart") return;
|
|
2813
|
-
btn.textContent = "restarting…";
|
|
2814
|
-
await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
|
|
2815
|
-
const t0 = Date.now();
|
|
2816
|
-
const wait = setInterval(async () => {
|
|
2817
|
-
try {
|
|
2818
|
-
const j = await (await fetch("/v1/health")).json();
|
|
2819
|
-
if (j.version === h.disk) { clearInterval(wait); location.reload(); }
|
|
2820
|
-
} catch {}
|
|
2821
|
-
if (Date.now() - t0 > 30_000) { clearInterval(wait); el.remove(); }
|
|
2822
|
-
}, 800);
|
|
2823
|
-
});
|
|
2824
|
-
}
|
|
2825
|
-
function maybeWhatsNew() {
|
|
2826
|
-
if (!state.version || !window.RELEASE_NOTES) return;
|
|
2827
|
-
let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
|
|
2828
|
-
if (seen === state.version) return;
|
|
2829
|
-
if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
|
|
2830
|
-
if (releaseNotesFor(state.version, { strict: true })) whatsNew(state.version);
|
|
2831
|
-
}
|
|
2832
|
-
|
|
2833
|
-
// Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
|
|
2834
|
-
// nothing leaves the machine; clicking Star just opens the repo in a browser.
|
|
2835
|
-
const STAR = { key: "swarm.star", firstAfterMs: 2 * 86_400_000, everyMs: 30 * 86_400_000 };
|
|
2836
|
-
function starState() { try { return JSON.parse(localStorage.getItem(STAR.key) || "{}"); } catch { return {}; } }
|
|
2837
|
-
function starSave(patch) { try { localStorage.setItem(STAR.key, JSON.stringify({ ...starState(), ...patch })); } catch {} }
|
|
2838
|
-
function maybeStarNudge() {
|
|
2839
|
-
const st = starState();
|
|
2840
|
-
const now = Date.now();
|
|
2841
|
-
if (!st.since) return starSave({ since: now });
|
|
2842
|
-
if (st.done || st.never) return;
|
|
2843
|
-
if (now - st.since < STAR.firstAfterMs) return;
|
|
2844
|
-
if (st.last && now - st.last < STAR.everyMs) return;
|
|
2845
|
-
if (document.querySelector(".nudge")) return;
|
|
2846
|
-
starSave({ last: now });
|
|
2847
|
-
const el = document.createElement("div");
|
|
2848
|
-
el.className = "nudge";
|
|
2849
|
-
el.innerHTML = `${ic("star", 18, "ic")}<div><b>Enjoying Swarm?</b>A star on GitHub helps other people find it — and tells us it's worth the evenings.
|
|
2850
|
-
<div class="row"><button class="pri" data-star="go">${ic("star", 13)} Star on GitHub</button><button data-star="later">Later</button><a href="#" class="dim" data-star="never">Don't ask again</a></div></div>`;
|
|
2851
|
-
document.body.appendChild(el);
|
|
2852
|
-
el.addEventListener("click", (ev) => {
|
|
2853
|
-
const t = ev.target.closest("[data-star]"); if (!t) return;
|
|
2854
|
-
ev.preventDefault();
|
|
2855
|
-
if (t.dataset.star === "go") { starSave({ done: now }); openExternal(REPO_URL); }
|
|
2856
|
-
else if (t.dataset.star === "never") starSave({ never: now });
|
|
2857
|
-
el.remove();
|
|
2858
|
-
});
|
|
2859
|
-
}
|
|
2860
|
-
window.swarmStarNudge = (force) => { if (force) starSave({ since: 1, last: 0, done: 0, never: 0 }); maybeStarNudge(); };
|
|
2861
|
-
setTimeout(maybeStarNudge, 4000);
|
|
2862
|
-
|
|
2863
|
-
// Feedback lands in a GitHub issue form, prefilled with the environment so people don't have to type it.
|
|
2864
|
-
const REPO_URL = "https://github.com/ra3orblade/swarm";
|
|
2865
|
-
function feedbackUrl() {
|
|
2866
|
-
const ua = navigator.userAgent;
|
|
2867
|
-
const os = /Mac/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "unknown OS";
|
|
2868
|
-
const shell = window.__TAURI__ || window.__TAURI_INTERNALS__ ? "desktop" : "browser";
|
|
2869
|
-
const env = `swarm ${state.version || "?"} · ${os} · ${shell}`;
|
|
2870
|
-
const q = new URLSearchParams({ template: "feedback.yml", environment: env });
|
|
2871
|
-
return `${REPO_URL}/issues/new?${q}`;
|
|
2872
|
-
}
|
|
2873
|
-
function openMenu(kind, anchor, d) {
|
|
2874
|
-
const spec = menuSpec(kind, d);
|
|
2875
|
-
if (!spec) return;
|
|
2876
|
-
if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
|
|
2877
|
-
// Once the menu is up the pointer is over *it*, not the row, so a :hover-only kebab vanishes
|
|
2878
|
-
// under its own menu. Mark the row (and the kebab) until menus:openchange reports the close.
|
|
2879
|
-
if (anchor?.closest) {
|
|
2880
|
-
for (const el of [anchor.closest(".proj"), anchor.closest("tr"), anchor.closest(".more")]) el?.classList.add("menu-open");
|
|
2881
|
-
}
|
|
2882
|
-
window.menus.open(anchor, spec);
|
|
2883
|
-
}
|
|
2884
|
-
document.addEventListener("keydown", (e) => {
|
|
2885
|
-
if (e.key === "Enter" && e.target.id === "msgText") { e.preventDefault(); $("#msgSend")?.click(); }
|
|
2886
|
-
});
|
|
2887
|
-
// Enter / Space on a focused card, tile or kebab opens its menu like a click.
|
|
2888
|
-
document.addEventListener("keydown", (ev) => {
|
|
2889
|
-
if (ev.key !== "Enter" && ev.key !== " ") return;
|
|
2890
|
-
const t = ev.target.closest?.("[data-menu]");
|
|
2891
|
-
if (!t || t.tagName === "INPUT") return;
|
|
2892
|
-
ev.preventDefault();
|
|
2893
|
-
openMenu(t.dataset.menu, t, t.dataset);
|
|
2894
|
-
});
|
|
2895
|
-
document.addEventListener("contextmenu", (ev) => {
|
|
2896
|
-
const t = ev.target.closest("[data-ctx]");
|
|
2897
|
-
if (!t) return;
|
|
2898
|
-
ev.preventDefault();
|
|
2899
|
-
openMenu(t.dataset.ctx, { x: ev.clientX, y: ev.clientY }, t.dataset);
|
|
2900
|
-
});
|
|
2901
|
-
|
|
2902
|
-
// ---------- events
|
|
2903
|
-
// Every id / data-attr a branch below matches on MUST be in this selector, or the branch is
|
|
2904
|
-
// unreachable (closest() returns null and the click dies silently) — that is how Replay,
|
|
2905
|
-
// Resume-where-it-died and the dry-run Re-run button all shipped dead.
|
|
2906
|
-
document.addEventListener("click", async (ev) => {
|
|
2907
|
-
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");
|
|
2908
|
-
if (!t) return;
|
|
2909
|
-
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
2910
|
-
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
2911
|
-
if (t.id === "feedback") { ev.preventDefault(); return openExternal(feedbackUrl()); }
|
|
2912
|
-
if (t.dataset.view) { ev.preventDefault(); return showView(t.dataset.view); }
|
|
2913
|
-
if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
|
|
2914
|
-
if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
|
|
2915
|
-
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; }
|
|
2916
|
-
if (t.id === "psAllEmoji") { const all = $("#psEmojiAll"); if (all.hidden) { all.innerHTML = buildEmojiGrid(); all.hidden = false; } else all.hidden = true; return; }
|
|
2917
|
-
if (t.dataset.color !== undefined && t.classList.contains("swatch")) { for (const e of $$(".swatch")) e.classList.toggle("on", e === t); return; }
|
|
2918
|
-
if (t.id === "msgSend") {
|
|
2919
|
-
ev.preventDefault();
|
|
2920
|
-
const text = $("#msgText")?.value.trim();
|
|
2921
|
-
if (!text) return;
|
|
2922
|
-
const r = await post("/v1/messages", { projectId: t.dataset.pid, to: t.dataset.sid, text, from: "dashboard" });
|
|
2923
|
-
if (!r.ok) return alert(r.error);
|
|
2924
|
-
$("#msgText").value = "";
|
|
2925
|
-
state.msgs = null;
|
|
2926
|
-
return refresh();
|
|
2927
|
-
}
|
|
2928
|
-
if (t.id === "psSave") { ev.preventDefault(); return saveProjectSettings(t.dataset.pid); }
|
|
2929
|
-
if (t.dataset.wfstop !== undefined) {
|
|
2930
|
-
ev.preventDefault();
|
|
2931
|
-
if (!confirm(`Stop the workflow on ${t.dataset.wfstop}? A live step's run is stopped too.`)) return;
|
|
2932
|
-
const r = await post("/v1/workflows/stop", { projectId: state.sel, task: t.dataset.wfstop });
|
|
2933
|
-
if (!r.ok) alert(r.error);
|
|
2934
|
-
state.workflows = null;
|
|
2935
|
-
return refresh();
|
|
2936
|
-
}
|
|
2937
|
-
if (t.dataset.bmode) { ev.preventDefault(); const [k, v] = t.dataset.bmode.split(":"); localStorage.setItem(`swarm.board.${k}`, v); return touch(); }
|
|
2938
|
-
if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
|
|
2939
|
-
if (t.dataset.runstop) {
|
|
2940
|
-
ev.preventDefault();
|
|
2941
|
-
if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
|
|
2942
|
-
return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
|
|
2943
|
-
}
|
|
2944
|
-
if (t.dataset.claim) { ev.preventDefault(); return act.claimTask(t.dataset.claim); }
|
|
2945
|
-
const split = (v) => { const i = v.indexOf(":"); return [v.slice(0, i), v.slice(i + 1)]; };
|
|
2946
|
-
if (t.dataset.wtopen) { ev.preventDefault(); return act.wtOpen(...split(t.dataset.wtopen)); }
|
|
2947
|
-
if (t.dataset.wtdiff) { ev.preventDefault(); return act.wtDiff(...split(t.dataset.wtdiff)); }
|
|
2948
|
-
if (t.dataset.wtpr) { ev.preventDefault(); return act.wtPr(...split(t.dataset.wtpr)); }
|
|
2949
|
-
if (t.dataset.dffile !== undefined) { ev.preventDefault(); return loadDiffFile(t.dataset.dffile); }
|
|
2950
|
-
if (t.id === "prGo") { ev.preventDefault(); return submitPr(); }
|
|
2951
|
-
if (t.id === "sessDiff") {
|
|
2952
|
-
ev.preventDefault();
|
|
2953
|
-
const s = state.sessions.find((x) => x.id === state.session);
|
|
2954
|
-
if (!s) return;
|
|
2955
|
-
const w = (state.worktrees[s.projectId] ?? []).find((x) => !x.main && (s.cwd === x.path || s.cwd.startsWith(`${x.path}/`)));
|
|
2956
|
-
return w ? openDiffDrawer(s.projectId, w.path) : null;
|
|
2957
|
-
}
|
|
2958
|
-
if (t.dataset.wtrm) { ev.preventDefault(); return act.wtRemove(...split(t.dataset.wtrm)); }
|
|
2959
|
-
if (t.id === "wtnew") {
|
|
2960
|
-
ev.preventDefault();
|
|
2961
|
-
const name = prompt("Worktree name (folder under ~/.swarm/worktrees/<project>/; branch wt/<name>):");
|
|
2962
|
-
if (!name) return;
|
|
2963
|
-
const r = await fetch("/v1/worktrees", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, name }) }).then((x) => x.json());
|
|
2964
|
-
if (!r.ok) alert(r.error);
|
|
2965
|
-
state.worktrees[state.sel] = null;
|
|
2966
|
-
return refresh();
|
|
2967
|
-
}
|
|
2968
|
-
if (t.id === "wtgc") {
|
|
2969
|
-
ev.preventDefault();
|
|
2970
|
-
const r = await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) }).then((x) => x.json());
|
|
2971
|
-
if (!r.candidates.length) return alert("Nothing to collect — no merged branches or released claims with a worktree left behind.");
|
|
2972
|
-
const lines = r.candidates.map((c) => `${c.removable ? "•" : "✗"} ${c.branch ?? "(detached)"} — ${c.why}${c.blocker ? ` (blocked: ${c.blocker})` : ""}`).join("\n");
|
|
2973
|
-
const n = r.candidates.filter((c) => c.removable).length;
|
|
2974
|
-
if (!n) return alert(`Stale worktrees, none removable without force:\n\n${lines}`);
|
|
2975
|
-
if (!confirm(`Stale worktrees:\n\n${lines}\n\nRemove the ${n} removable one${n === 1 ? "" : "s"}?`)) return;
|
|
2976
|
-
await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, apply: true }) });
|
|
2977
|
-
state.worktrees[state.sel] = null;
|
|
2978
|
-
return refresh();
|
|
2979
|
-
}
|
|
2980
|
-
if (t.id === "dispatch") { ev.preventDefault(); return openDispatchDrawer(); }
|
|
2981
|
-
if (t.id === "dispatchGo") { ev.preventDefault(); return submitDispatch(); }
|
|
2982
|
-
if (t.id === "dispatchClear") {
|
|
2983
|
-
ev.preventDefault();
|
|
2984
|
-
await fetch("/v1/dispatch", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) });
|
|
2985
|
-
state.dispatch = null;
|
|
2986
|
-
return refresh();
|
|
2987
|
-
}
|
|
2988
|
-
if (t.dataset.gaterun) { ev.preventDefault(); return act.gateRun(t.dataset.gaterun); }
|
|
2989
|
-
if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
|
|
2990
|
-
if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
|
|
2991
|
-
if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
|
|
2992
|
-
if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
|
|
2993
|
-
if (t.id === "abNew") {
|
|
2994
|
-
ev.preventDefault();
|
|
2995
|
-
if (!state.sel) return;
|
|
2996
|
-
const task = prompt("Task id to trial (each arm claims task#model, so each gets its own worktree):");
|
|
2997
|
-
if (!task) return;
|
|
2998
|
-
const models = prompt("Models to compare, comma separated:", "opus-5, sonnet-5");
|
|
2999
|
-
const arms = (models ?? "").split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ model: m, label: m }));
|
|
3000
|
-
if (arms.length < 2) { alert("A trial needs at least two models."); return; }
|
|
3001
|
-
const r = await fetch("/v1/ab", {
|
|
3002
|
-
method: "POST",
|
|
3003
|
-
headers: { "content-type": "application/json" },
|
|
3004
|
-
body: JSON.stringify({ projectId: state.sel, task: task.trim(), arms }),
|
|
3005
|
-
}).then((x) => x.json()).catch(() => null);
|
|
3006
|
-
if (!r) return alert("Could not reach the daemon.");
|
|
3007
|
-
if (r?.failed?.length) alert(`Started ${r.started.length}. Could not start: ${r.failed.map((f) => `${f.arm} — ${f.reason}`).join("; ")}`);
|
|
3008
|
-
return refresh();
|
|
3009
|
-
}
|
|
3010
|
-
if (t.dataset.provpage) {
|
|
3011
|
-
ev.preventDefault();
|
|
3012
|
-
if (t.classList.contains("off")) return;
|
|
3013
|
-
state.provOffset = Number(t.dataset.provpage);
|
|
3014
|
-
return refresh();
|
|
3015
|
-
}
|
|
3016
|
-
if (t.dataset.group) {
|
|
3017
|
-
ev.preventDefault();
|
|
3018
|
-
const open = new Set(state.lineageOpen ?? []);
|
|
3019
|
-
open.has(t.dataset.group) ? open.delete(t.dataset.group) : open.add(t.dataset.group);
|
|
3020
|
-
state.lineageOpen = [...open];
|
|
3021
|
-
return refresh();
|
|
3022
|
-
}
|
|
3023
|
-
if (t.dataset.act?.startsWith("err-") || t.dataset.act === "restart-daemon") {
|
|
3024
|
-
ev.preventDefault();
|
|
3025
|
-
const rep = JSON.stringify(errorReport(lastError?.err, lastError?.where), null, 2);
|
|
3026
|
-
if (t.dataset.act === "err-copy") {
|
|
3027
|
-
copy(rep).then((ok) => {
|
|
3028
|
-
t.textContent = ok ? "copied" : "copy blocked — select it below";
|
|
3029
|
-
if (!ok) {
|
|
3030
|
-
// Never claim it copied when it did not: put the report on screen, pre-selected.
|
|
3031
|
-
const ta = document.createElement("textarea");
|
|
3032
|
-
ta.className = "err-detail";
|
|
3033
|
-
ta.readOnly = true;
|
|
3034
|
-
ta.value = rep;
|
|
3035
|
-
ta.style.cssText = "width:100%;min-height:160px;margin-top:10px";
|
|
3036
|
-
t.closest(".err-card")?.appendChild(ta);
|
|
3037
|
-
ta.focus();
|
|
3038
|
-
ta.select();
|
|
3039
|
-
}
|
|
3040
|
-
setTimeout(() => { t.textContent = "Copy report"; }, 2500);
|
|
3041
|
-
});
|
|
3042
|
-
return;
|
|
3043
|
-
}
|
|
3044
|
-
if (t.dataset.act === "err-issue") {
|
|
3045
|
-
// Prefilled, but the person still reads and sends it — nothing leaves the machine on its own.
|
|
3046
|
-
const body = `**What I was doing:**\n\n\n<details><summary>Report</summary>\n\n\`\`\`json\n${rep}\n\`\`\`\n</details>`;
|
|
3047
|
-
const url = `https://github.com/ra3orblade/swarm/issues/new?title=${encodeURIComponent(`Dashboard error in ${lastError?.where ?? state.view}`)}&body=${encodeURIComponent(body)}`;
|
|
3048
|
-
window.open(url, "_blank", "noopener");
|
|
3049
|
-
return;
|
|
3050
|
-
}
|
|
3051
|
-
if (t.dataset.act === "err-reload") { lastError = null; return location.reload(); }
|
|
3052
|
-
t.textContent = "restarting…";
|
|
3053
|
-
fetch("/v1/daemon/restart", { method: "POST" })
|
|
3054
|
-
.catch(() => {})
|
|
3055
|
-
.then(() => setTimeout(() => location.reload(), 1500));
|
|
3056
|
-
return;
|
|
3057
|
-
}
|
|
3058
|
-
if (t.dataset.wtclear) {
|
|
3059
|
-
ev.preventDefault();
|
|
3060
|
-
const path = t.dataset.wtclear;
|
|
3061
|
-
const label = t.textContent;
|
|
3062
|
-
t.textContent = "clearing…";
|
|
3063
|
-
fetch("/v1/hygiene/reclaim", {
|
|
3064
|
-
method: "POST",
|
|
3065
|
-
headers: { "content-type": "application/json" },
|
|
3066
|
-
body: JSON.stringify({ path }),
|
|
3067
|
-
})
|
|
3068
|
-
.then((r) => r.json())
|
|
3069
|
-
.then((r) => {
|
|
3070
|
-
t.textContent = r.ok ? `freed ${mb(r.freedKb)}` : (r.error ?? "failed");
|
|
3071
|
-
setTimeout(() => { t.textContent = label; refresh(); }, 1600);
|
|
3072
|
-
})
|
|
3073
|
-
.catch(() => { t.textContent = label; });
|
|
3074
|
-
return;
|
|
3075
|
-
}
|
|
3076
|
-
if (t.dataset.graphtab) { state.graphTab = t.dataset.graphtab; localStorage.setItem("swarm.graphTab", state.graphTab); return refresh(); }
|
|
3077
|
-
if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
|
|
3078
|
-
if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }
|
|
3079
|
-
if (t.dataset.ackall) {
|
|
3080
|
-
return fetch("/v1/incidents/ack", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project: state.sel || undefined }) }).then(refresh);
|
|
3081
|
-
}
|
|
3082
|
-
if (t.dataset.days) { ev.preventDefault(); state.spendDays = Number(t.dataset.days); return touch(); }
|
|
3083
|
-
if (t.dataset.sdays) { ev.preventDefault(); state.statsDays = Number(t.dataset.sdays); return touch(); }
|
|
3084
|
-
if (t.dataset.release || t.dataset.forcerelease) {
|
|
3085
|
-
ev.preventDefault();
|
|
3086
|
-
const [projectId, task] = (t.dataset.release || t.dataset.forcerelease).split(":");
|
|
3087
|
-
return act.releaseClaim(projectId, task, Boolean(t.dataset.forcerelease));
|
|
3088
|
-
}
|
|
3089
|
-
if (t.dataset.agent !== undefined && t.classList.contains("chip")) { state.agentFilter = t.dataset.agent || null; return touch(); }
|
|
3090
|
-
if (t.dataset.merge !== undefined) { ev.preventDefault(); return act.merge(...t.dataset.merge.split(":")); }
|
|
3091
|
-
if (t.dataset.procstop) { ev.preventDefault(); return act.procStop(t.dataset.procstop, t.dataset.procproj); }
|
|
3092
|
-
if (t.dataset.resrelease !== undefined) { ev.preventDefault(); return act.resRelease(t.dataset.resrelease, t.dataset.resproj); }
|
|
3093
|
-
if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
|
|
3094
|
-
if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
|
|
3095
|
-
if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
|
|
3096
|
-
if (t.dataset.s) { ev.preventDefault(); return openSession(t.dataset.s); }
|
|
3097
|
-
if (t.dataset.id !== undefined) { state.sel = t.dataset.id || null; localStorage.setItem("swarm.sel", state.sel ?? ""); state.session = null; state.tasks = null; state.dirty = true; return refresh(); }
|
|
3098
|
-
});
|
|
3099
|
-
async function addProject(path) {
|
|
3100
|
-
if (!path) return;
|
|
3101
|
-
const r = await fetch("/v1/projects", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
|
|
3102
|
-
if (!r.ok) return alert((await r.json()).error);
|
|
3103
|
-
refresh();
|
|
3104
|
-
}
|
|
3105
|
-
// "+" in the Projects header: menu of ways to add a project
|
|
3106
|
-
document.addEventListener("click", (ev) => {
|
|
3107
|
-
const t = ev.target.closest?.("#addProj");
|
|
3108
|
-
if (!t || !window.menus) return;
|
|
3109
|
-
window.menus.open(t, { items: [
|
|
3110
|
-
{ label: "Browse folders\u2026", icon: "folder-simple", run: () => openPicker() },
|
|
3111
|
-
{ label: "Add by path\u2026", icon: "terminal-window", run: () => openPicker(true) },
|
|
3112
|
-
] });
|
|
3113
|
-
});
|
|
3114
|
-
// collapsible sidebar, persisted
|
|
3115
|
-
const sbApply = () => {
|
|
3116
|
-
const off = localStorage.getItem("swarm.sidebar") === "off";
|
|
3117
|
-
document.body.classList.toggle("nosb", off);
|
|
3118
|
-
const b = $("#sbToggle");
|
|
3119
|
-
if (b) b.innerHTML = ic(off ? "arrow-bar-right" : "arrow-bar-left", 15);
|
|
3120
|
-
};
|
|
3121
|
-
$("#sbToggle")?.addEventListener("click", () => {
|
|
3122
|
-
localStorage.setItem("swarm.sidebar", document.body.classList.contains("nosb") ? "on" : "off");
|
|
3123
|
-
sbApply();
|
|
3124
|
-
});
|
|
3125
|
-
sbApply();
|
|
3126
|
-
|
|
3127
|
-
// ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
|
|
3128
|
-
const pal = { items: [], view: [], q: "", i: 0 };
|
|
3129
|
-
function palBuild() {
|
|
3130
|
-
const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => showView(v.id) }));
|
|
3131
|
-
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(); } });
|
|
3132
|
-
const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
|
|
3133
|
-
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) });
|
|
3134
|
-
return items;
|
|
3135
|
-
}
|
|
3136
|
-
function palFilter() {
|
|
3137
|
-
const q = pal.q.trim().toLowerCase();
|
|
3138
|
-
const rank = (x) => Math.min(...[x.label, x.sub ?? ""].map((t) => { const i = t.toLowerCase().indexOf(q); return i < 0 ? 1e9 : i; }));
|
|
3139
|
-
const out = q
|
|
3140
|
-
? pal.items.map((x) => ({ x, r: rank(x) })).filter((h) => h.r < 1e9).sort((a, b) => a.r - b.r).map((h) => h.x).slice(0, 12)
|
|
3141
|
-
: pal.items.filter((x) => x.grp !== "session" || x.live).slice(0, 16); // idle: every view + project + live sessions
|
|
3142
|
-
if (q) out.push({ icon: "magnifying-glass", label: `Search Swarm for “${pal.q.trim()}”`, grp: "search", run: () => { srch.q = pal.q.trim(); state.view = "search"; localStorage.setItem("swarm.view", "search"); state.session = null; state.dirty = true; runSearch(); refresh(); } });
|
|
3143
|
-
return out;
|
|
3144
|
-
}
|
|
3145
|
-
function palRender() {
|
|
3146
|
-
pal.view = palFilter();
|
|
3147
|
-
if (pal.i >= pal.view.length) pal.i = Math.max(0, pal.view.length - 1);
|
|
3148
|
-
const row = (x, i) => `<div class="pk-row pal-row ${i === pal.i ? "on" : ""}" data-pal="${i}">${ic(x.icon, 14)}<span class="nm">${esc(x.label)}${x.sub ? ` <span class="dim">· ${esc(x.sub)}</span>` : ""}</span><span class="grp">${x.grp}</span></div>`;
|
|
3149
|
-
const el = $("#palList");
|
|
3150
|
-
if (el) el.innerHTML = pal.view.map(row).join("") || '<div class="empty" style="padding:16px">No matches.</div>';
|
|
3151
|
-
}
|
|
3152
|
-
function palRun(i) {
|
|
3153
|
-
const x = pal.view[i];
|
|
3154
|
-
if (!x) return;
|
|
3155
|
-
closePicker();
|
|
3156
|
-
x.run();
|
|
3157
|
-
}
|
|
3158
|
-
function openPalette() {
|
|
3159
|
-
pal.items = palBuild(); pal.q = ""; pal.i = 0;
|
|
3160
|
-
$("#picker").innerHTML = `<div class="pk pal" role="dialog" aria-modal="true">
|
|
3161
|
-
<div class="pk-h">${ic("magnifying-glass", 15)}<input id="palQ" placeholder="Jump to view, project or session…" spellcheck="false" autocomplete="off"></div>
|
|
3162
|
-
<div class="pk-list" id="palList"></div>
|
|
3163
|
-
</div>`;
|
|
3164
|
-
palRender();
|
|
3165
|
-
const inp = $("#palQ");
|
|
3166
|
-
inp.focus();
|
|
3167
|
-
inp.addEventListener("input", () => { pal.q = inp.value; pal.i = 0; palRender(); });
|
|
3168
|
-
inp.addEventListener("keydown", (ev) => {
|
|
3169
|
-
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); pal.i = Math.max(0, Math.min(pal.view.length - 1, pal.i + (ev.key === "ArrowDown" ? 1 : -1))); palRender(); }
|
|
3170
|
-
else if (ev.key === "Enter") { ev.preventDefault(); palRun(pal.i); }
|
|
3171
|
-
});
|
|
3172
|
-
}
|
|
3173
|
-
$("#palBtn")?.addEventListener("click", openPalette);
|
|
3174
|
-
document.addEventListener("keydown", (ev) => {
|
|
3175
|
-
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { ev.preventDefault(); if ($("#palQ")) closePicker(); else openPalette(); }
|
|
3176
|
-
});
|
|
3177
|
-
|
|
3178
|
-
// ---------- folder picker
|
|
3179
|
-
const picker = { path: null };
|
|
3180
|
-
// Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
|
|
3181
|
-
function openRunDrawer(taskId) {
|
|
3182
|
-
const task = (state.tasks?.tasks ?? []).find((t) => t.id === taskId);
|
|
3183
|
-
const title = task ? `${task.id} — ${task.title}` : taskId;
|
|
3184
|
-
const prompt = task
|
|
3185
|
-
? `Task ${task.id}: ${task.title}\n\nWork only inside this worktree. When done: commit, push, then call swarm_handoff with what was done and what remains, and record the required gates with swarm_gate_record.`
|
|
3186
|
-
: "";
|
|
3187
|
-
const last = (() => { try { return JSON.parse(localStorage.getItem("swarm.runOpts") || "{}"); } catch { return {}; } })();
|
|
3188
|
-
const opt = (v, cur) => `<option value="${v}" ${v === cur ? "selected" : ""}>${v || "default"}</option>`;
|
|
3189
|
-
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
3190
|
-
<div class="pk-h">${ic("play", 15)}<b>Run</b><span class="dim now" style="flex:1;margin-left:8px">${esc(title)}</span></div>
|
|
3191
|
-
<div class="pk-b">
|
|
3192
|
-
<label>prompt<textarea id="rnPrompt" spellcheck="false">${esc(prompt)}</textarea></label>
|
|
3193
|
-
<div class="row">
|
|
3194
|
-
<label>permission mode<select id="rnMode">${["acceptEdits", "auto", "plan", "dontAsk", "manual", "bypassPermissions"].map((m) => opt(m, last.mode ?? "acceptEdits")).join("")}</select></label>
|
|
3195
|
-
<label>model<input id="rnModel" placeholder="default" value="${esc(last.model ?? "")}"></label>
|
|
3196
|
-
<label>max turns<input id="rnTurns" type="number" min="1" placeholder="∞" value="${esc(last.turns ?? "")}"></label>
|
|
3197
|
-
</div>
|
|
3198
|
-
<label>profile<select id="rnProfile" title="full: every tool · no-edits: commands but no file edits · read-only: read and search only">${["full", "no-edits", "read-only"].map((m) => opt(m, last.profile ?? "full")).join("")}</select></label>
|
|
3199
|
-
<div class="dim" style="font-size:var(--fs-sm)">Claims <b>${esc(taskId)}</b> (or reuses your held worktree) and spawns <code>claude -p</code> there. The session appears in Fleet; steer it from its page.</div>
|
|
3200
|
-
</div>
|
|
3201
|
-
<div class="pk-f"><span class="grow"></span><button id="rnCancel">Cancel</button><button class="primary" id="rnGo" data-task="${esc(taskId)}">${ic("play", 13)} Run</button></div>
|
|
3202
|
-
</div>`;
|
|
3203
|
-
$("#rnPrompt")?.focus();
|
|
3204
|
-
}
|
|
3205
|
-
async function submitRun(taskId) {
|
|
3206
|
-
const prompt = $("#rnPrompt")?.value.trim();
|
|
3207
|
-
if (!prompt) return alert("A prompt is required.");
|
|
3208
|
-
const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value, profile = $("#rnProfile")?.value;
|
|
3209
|
-
localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns, profile }));
|
|
3210
|
-
closePicker();
|
|
3211
|
-
const r = await fetch("/v1/runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({
|
|
3212
|
-
projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined, profile: profile && profile !== "full" ? profile : undefined,
|
|
3213
|
-
}) }).then((x) => x.json());
|
|
3214
|
-
if (!r.ok) return alert(r.error);
|
|
3215
|
-
state.tasks = null;
|
|
3216
|
-
await refresh();
|
|
3217
|
-
openSession(r.run.sessionId);
|
|
3218
|
-
}
|
|
3219
|
-
|
|
3220
|
-
// ---------- project settings drawer
|
|
3221
|
-
const PROJECT_EMOJI = ["🐝", "🚀", "🧪", "📦", "🛠️", "🌐", "📊", "🤖", "🧠", "🎨", "🔒", "📚", "💬", "🏗️", "🧩", "⚡"];
|
|
3222
|
-
// Every emoji the platform font can draw, by Unicode block — no names, but browseable; the OS picker
|
|
3223
|
-
// (⌃⌘Space on macOS, Win+. on Windows) covers search. Filtered by the font once, lazily.
|
|
3224
|
-
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]];
|
|
3225
|
-
let emojiGrid = null;
|
|
3226
|
-
// Which code points the platform font actually draws in colour is a per-machine answer, so it is
|
|
3227
|
-
// probed once and remembered. Two things made that probe cost ~150ms of blocked main thread:
|
|
3228
|
-
// it called getImageData once per code point (1536 GPU->CPU readbacks), and the blocks overlap,
|
|
3229
|
-
// so 96 code points were probed — and rendered — twice. Now it is one readback per block over a
|
|
3230
|
-
// grid of glyphs, deduped, and the answer is cached across reloads.
|
|
3231
|
-
const EMOJI_CACHE_KEY = "swarm.emoji.v1";
|
|
3232
|
-
function detectEmoji(a, b) {
|
|
3233
|
-
const S = 20, COLS = 32, n = b - a + 1, rows = Math.ceil(n / COLS);
|
|
3234
|
-
const cv = document.createElement("canvas");
|
|
3235
|
-
cv.width = COLS * S; cv.height = rows * S;
|
|
3236
|
-
const c = cv.getContext("2d", { willReadFrequently: true });
|
|
3237
|
-
c.font = `${S - 4}px system-ui`; c.textBaseline = "top";
|
|
3238
|
-
for (let i = 0; i < n; i++) c.fillText(String.fromCodePoint(a + i), (i % COLS) * S, ((i / COLS) | 0) * S);
|
|
3239
|
-
const d = c.getImageData(0, 0, cv.width, cv.height).data, W = cv.width, out = [];
|
|
3240
|
-
// A code point counts as an emoji the platform can draw if its cell paints coloured pixels.
|
|
3241
|
-
for (let i = 0; i < n; i++) {
|
|
3242
|
-
const x0 = (i % COLS) * S, y0 = ((i / COLS) | 0) * S;
|
|
3243
|
-
let ok = false;
|
|
3244
|
-
for (let y = y0; y < y0 + S && !ok; y++)
|
|
3245
|
-
for (let x = x0; x < x0 + S; x++) {
|
|
3246
|
-
const p = (y * W + x) * 4;
|
|
3247
|
-
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; }
|
|
3248
|
-
}
|
|
3249
|
-
if (ok) out.push(String.fromCodePoint(a + i));
|
|
3250
|
-
}
|
|
3251
|
-
return out;
|
|
3252
|
-
}
|
|
3253
|
-
function buildEmojiGrid() {
|
|
3254
|
-
if (emojiGrid) return emojiGrid;
|
|
3255
|
-
// The cache is keyed by the UA (a font change is what would invalidate it) plus the block list.
|
|
3256
|
-
const sig = `${navigator.userAgent}|${EMOJI_BLOCKS.map((x) => x.join(":")).join(",")}`;
|
|
3257
|
-
let blocks = null;
|
|
3258
|
-
try {
|
|
3259
|
-
const hit = JSON.parse(localStorage.getItem(EMOJI_CACHE_KEY) ?? "null");
|
|
3260
|
-
if (hit?.sig === sig) blocks = hit.blocks;
|
|
3261
|
-
} catch { /* corrupt or unavailable cache: probe again */ }
|
|
3262
|
-
if (!blocks) {
|
|
3263
|
-
const seen = new Set();
|
|
3264
|
-
blocks = EMOJI_BLOCKS.map(([, a, b]) => detectEmoji(a, b).filter((e) => !seen.has(e) && seen.add(e)));
|
|
3265
|
-
try { localStorage.setItem(EMOJI_CACHE_KEY, JSON.stringify({ sig, blocks })); } catch { /* private mode / quota */ }
|
|
3266
|
-
}
|
|
3267
|
-
emojiGrid = EMOJI_BLOCKS.map(([name], i) => {
|
|
3268
|
-
const list = blocks[i] ?? [];
|
|
3269
|
-
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>` : "";
|
|
3270
|
-
}).join("");
|
|
3271
|
-
return emojiGrid;
|
|
3272
|
-
}
|
|
3273
|
-
function openProjectSettings(pid) {
|
|
3274
|
-
const p = state.projects.find((x) => x.id === pid);
|
|
3275
|
-
if (!p) return;
|
|
3276
|
-
const slots = ["", "c1", "c2", "c3", "c4", "c5", "c6", "c7"];
|
|
3277
|
-
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
3278
|
-
<div class="pk-h">${ic("sliders", 15)}<b>Project settings</b><span class="dim now" style="flex:1;margin-left:8px">${esc(p.root)}</span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
|
|
3279
|
-
<div class="pk-b">
|
|
3280
|
-
<label>name<input id="psName" value="${esc(p.name)}" maxlength="60" spellcheck="false"></label>
|
|
3281
|
-
<label>icon<div class="icon-row"><span class="pg pg-lg" id="psPreview">${p.icon ? (p.icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(p.icon)}" alt="">` : esc(p.icon)) : ic("folder-simple", 16)}</span><input id="psIcon" value="${esc(p.icon?.startsWith("data:image/") ? "" : (p.icon ?? ""))}" maxlength="4" placeholder="emoji or 1–2 letters · ${navigator.platform.startsWith("Mac") ? "⌃⌘Space" : "Win+."} opens the OS emoji picker" spellcheck="false" autocomplete="off"><label class="btn" title="PNG / JPEG / SVG / WebP — downsized to 64px and stored with the project">${ic("file-text", 13)} Image…<input type="file" id="psFile" accept="image/*" hidden></label></div></label>
|
|
3282
|
-
<input type="hidden" id="psImage" value="${esc(p.icon?.startsWith("data:image/") ? p.icon : "")}">
|
|
3283
|
-
<div class="emoji-row">${PROJECT_EMOJI.map((e) => `<span class="emoji ${p.icon === e ? "on" : ""}" data-emoji="${e}">${e}</span>`).join("")}<span class="emoji ${!p.icon ? "on" : ""}" data-emoji="" title="No icon">${ic("folder-simple", 14)}</span><span class="emoji more-emoji" id="psAllEmoji" title="Browse every emoji">…</span></div>
|
|
3284
|
-
<div class="emoji-all" id="psEmojiAll" hidden></div>
|
|
3285
|
-
<label>color</label>
|
|
3286
|
-
<div class="swatches">${slots.map((c) => `<span class="swatch ${c ? `pg-${c}` : "none"} ${(p.color ?? "") === c ? "on" : ""}" data-color="${c}" title="${c || "none"}"></span>`).join("")}</div>
|
|
3287
|
-
<label class="chk"><input type="checkbox" id="psPinned" ${p.discovered ? "" : "checked"}> pinned — always in the sidebar, drag to reorder</label>
|
|
3288
|
-
</div>
|
|
3289
|
-
<div class="pk-f"><span class="grow"></span><button id="pkCancel">Cancel</button><button class="primary" id="psSave" data-pid="${esc(p.id)}">Save</button></div>
|
|
3290
|
-
</div>`;
|
|
3291
|
-
$("#psName").focus();
|
|
3292
|
-
}
|
|
3293
|
-
/** Downsize an image file to a square 64px PNG data URL (center-cropped). */
|
|
3294
|
-
function fileToIconDataUrl(file) {
|
|
3295
|
-
return new Promise((resolve, reject) => {
|
|
3296
|
-
const url = URL.createObjectURL(file);
|
|
3297
|
-
const img = new Image();
|
|
3298
|
-
img.onload = () => {
|
|
3299
|
-
// square: center-crop the shorter side (cover), never letterbox
|
|
3300
|
-
const S = 64, cv = document.createElement("canvas"); cv.width = S; cv.height = S;
|
|
3301
|
-
const side = Math.min(img.width, img.height), sx = (img.width - side) / 2, sy = (img.height - side) / 2;
|
|
3302
|
-
cv.getContext("2d").drawImage(img, sx, sy, side, side, 0, 0, S, S);
|
|
3303
|
-
URL.revokeObjectURL(url);
|
|
3304
|
-
resolve(cv.toDataURL("image/png"));
|
|
3305
|
-
};
|
|
3306
|
-
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("not an image the browser can decode")); };
|
|
3307
|
-
img.src = url;
|
|
3308
|
-
});
|
|
3309
|
-
}
|
|
3310
|
-
function setIconPreview(icon) {
|
|
3311
|
-
const el = $("#psPreview");
|
|
3312
|
-
if (!el) return;
|
|
3313
|
-
el.innerHTML = icon ? (icon.startsWith("data:image/") ? `<img class="pg-img" src="${esc(icon)}" alt="">` : esc(icon)) : ic("folder-simple", 16);
|
|
3314
|
-
}
|
|
3315
|
-
async function saveProjectSettings(pid) {
|
|
3316
|
-
const body = {
|
|
3317
|
-
name: $("#psName").value.trim() || undefined,
|
|
3318
|
-
icon: $("#psImage").value || $("#psIcon").value.trim(),
|
|
3319
|
-
color: $(".swatch.on")?.dataset.color ?? "",
|
|
3320
|
-
pinned: $("#psPinned").checked,
|
|
3321
|
-
};
|
|
3322
|
-
const r = await fetch(`/v1/projects/${encodeURIComponent(pid)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
3323
|
-
if (!r.ok) return alert((await r.json()).error ?? "could not save");
|
|
3324
|
-
closePicker();
|
|
3325
|
-
state.dirty = true;
|
|
3326
|
-
refresh();
|
|
3327
|
-
}
|
|
3328
|
-
|
|
3329
|
-
// ---------- dispatch drawer (M7.5)
|
|
3330
|
-
function openDispatchDrawer() {
|
|
3331
|
-
const ready = (state.tasks?.tasks ?? []).filter((t) => t.ready);
|
|
3332
|
-
const cfg = state.dispatch?.config ?? {};
|
|
3333
|
-
const last = (() => { try { return JSON.parse(localStorage.getItem("swarm.runOpts") || "{}"); } catch { return {}; } })();
|
|
3334
|
-
const opt = (v, cur) => `<option value="${v}" ${v === cur ? "selected" : ""}>${v || "default"}</option>`;
|
|
3335
|
-
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
3336
|
-
<div class="pk-h">${ic("play", 15)}<b>Dispatch</b><span class="dim now" style="flex:1;margin-left:8px">${ready.length} ready task${ready.length === 1 ? "" : "s"}</span></div>
|
|
3337
|
-
<div class="pk-b">
|
|
3338
|
-
<div class="df-files" style="max-height:30vh">${ready.map((t) => `<label style="display:flex;gap:8px;padding:4px 8px;align-items:center"><input type="checkbox" class="dpTask" value="${esc(t.id)}" checked style="width:auto"><b>${esc(t.id)}</b><span class="pa dim">${esc(t.title)}</span></label>`).join("")}</div>
|
|
3339
|
-
<div class="row">
|
|
3340
|
-
<label>at a time<input id="dpPar" type="number" min="1" max="16" value="${cfg.max_parallel ?? 2}"></label>
|
|
3341
|
-
<label>permission mode<select id="dpMode">${["acceptEdits", "auto", "plan", "dontAsk", "manual", "bypassPermissions"].map((m) => opt(m, cfg.permission_mode ?? last.mode ?? "acceptEdits")).join("")}</select></label>
|
|
3342
|
-
<label>max turns<input id="dpTurns" type="number" min="1" placeholder="∞" value="${esc(cfg.max_turns ?? last.turns ?? "")}"></label>
|
|
3343
|
-
</div>
|
|
3344
|
-
<label>profile<select id="dpProfile">${["full", "no-edits", "read-only"].map((m) => opt(m, cfg.profile ?? "full")).join("")}</select></label>
|
|
3345
|
-
<div class="dim" style="font-size:var(--fs-sm)">Each task gets its own claim + worktree and a <code>claude -p</code> run told to work there, run the gates, hand off and open a PR. The rest queue until a slot frees. Swarm derives the outcome from gates and PRs — a task is never flipped done by an agent.</div>
|
|
3346
|
-
</div>
|
|
3347
|
-
<div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="dispatchGo">${ic("play", 13)} Dispatch</button></div>
|
|
3348
|
-
</div>`;
|
|
3349
|
-
$("#pkClose")?.addEventListener("click", closePicker);
|
|
3350
|
-
}
|
|
3351
|
-
async function submitDispatch() {
|
|
3352
|
-
const tasks = [...document.querySelectorAll(".dpTask:checked")].map((i) => i.value);
|
|
3353
|
-
if (!tasks.length) return alert("Pick at least one task.");
|
|
3354
|
-
const prof = $("#dpProfile")?.value;
|
|
3355
|
-
const body = { projectId: state.sel, tasks, maxParallel: Number($("#dpPar")?.value) || undefined, permissionMode: $("#dpMode")?.value, maxTurns: Number($("#dpTurns")?.value) || undefined, profile: prof && prof !== "full" ? prof : undefined, owner: "dashboard" };
|
|
3356
|
-
closePicker();
|
|
3357
|
-
const r = await fetch("/v1/dispatch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
|
|
3358
|
-
if (!r.ok) return alert(r.error);
|
|
3359
|
-
if (r.rejected?.length) alert(`Not dispatched:\n${r.rejected.map((x) => `${x.id} — ${x.reason}`).join("\n")}`);
|
|
3360
|
-
state.tasks = null; state.dispatch = null;
|
|
3361
|
-
return refresh();
|
|
3362
|
-
}
|
|
3363
|
-
|
|
3364
|
-
// ---------- worktree diff + PR drawers (M7.3)
|
|
3365
|
-
const diffState = { projectId: null, worktree: null, base: null, files: [] };
|
|
3366
|
-
function colorPatch(patch) {
|
|
3367
|
-
return esc(patch).split("\n").map((l) => {
|
|
3368
|
-
const c = l.startsWith("+++") || l.startsWith("---") ? "m" : l.startsWith("@@") ? "h" : l.startsWith("+") ? "a" : l.startsWith("-") ? "d" : l.startsWith("diff ") ? "m" : "";
|
|
3369
|
-
return c ? `<span class="${c}">${l}</span>` : l;
|
|
3370
|
-
}).join("\n");
|
|
3371
|
-
}
|
|
3372
|
-
async function openDiffDrawer(projectId, worktree) {
|
|
3373
|
-
const q = new URLSearchParams({ project: projectId, worktree });
|
|
3374
|
-
const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
|
|
3375
|
-
if (d.error) return alert(d.error);
|
|
3376
|
-
Object.assign(diffState, { projectId, worktree: d.worktree, base: d.base, files: d.files });
|
|
3377
|
-
const files = d.files.map((f) => `<a href="#" data-dffile="${esc(f.path)}"><span class="st">${esc(f.status)}</span><span class="pa" title="${esc(f.path)}">${esc(f.path)}</span>${f.added >= 0 ? `<span class="pl">+${f.added}</span><span class="mi">−${f.deleted}</span>` : '<span class="dim">bin</span>'}</a>`).join("");
|
|
3378
|
-
$("#picker").innerHTML = `<div class="pk wide" role="dialog" aria-modal="true">
|
|
3379
|
-
<div class="pk-h">${ic("folders", 15)}<b>Diff</b><span class="dim now" style="flex:1;margin-left:8px">${esc(short(d.worktree))} · vs ${esc(d.baseRef ?? "HEAD")} · ${d.commits.length} commit${d.commits.length === 1 ? "" : "s"} · ${d.files.length} file${d.files.length === 1 ? "" : "s"}${d.dirty ? ' · <span class="badge warn">dirty</span>' : ""}</span></div>
|
|
3380
|
-
<div class="pk-b">
|
|
3381
|
-
${d.commits.length ? `<div class="dim" style="font-size:var(--fs-sm)">${d.commits.slice(0, 8).map(esc).join("<br>")}${d.commits.length > 8 ? `<br>… ${d.commits.length - 8} more` : ""}</div>` : ""}
|
|
3382
|
-
${d.files.length ? `<div class="df-files">${files}</div><pre class="df-patch" id="dfPatch"><span class="m">select a file — or view everything below</span></pre>` : '<div class="empty">Nothing changed.</div>'}
|
|
3383
|
-
</div>
|
|
3384
|
-
<div class="pk-f">${d.files.length ? `<a href="#" class="nav" data-dffile="">${ic("folders", 12)} Whole diff</a>` : ""}<span class="grow"></span><button id="pkClose">Close</button></div>
|
|
3385
|
-
</div>`;
|
|
3386
|
-
$("#pkClose")?.addEventListener("click", closePicker);
|
|
3387
|
-
}
|
|
3388
|
-
async function loadDiffFile(file) {
|
|
3389
|
-
const q = new URLSearchParams({ project: diffState.projectId, worktree: diffState.worktree });
|
|
3390
|
-
if (file) q.set("file", file); else q.set("patch", "1");
|
|
3391
|
-
for (const a of document.querySelectorAll(".df-files a")) a.classList.toggle("on", a.dataset.dffile === file);
|
|
3392
|
-
const el = $("#dfPatch"); if (el) el.innerHTML = '<span class="m">loading…</span>';
|
|
3393
|
-
const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
|
|
3394
|
-
if (el) el.innerHTML = d.patch ? colorPatch(d.patch) : '<span class="m">(empty)</span>';
|
|
3395
|
-
}
|
|
3396
|
-
async function openPrDrawer(projectId, worktree) {
|
|
3397
|
-
const q = new URLSearchParams({ project: projectId, worktree });
|
|
3398
|
-
const d = await fetch(`/v1/prs/draft?${q}`).then((x) => x.json());
|
|
3399
|
-
if (!d.ok) return alert(d.error);
|
|
3400
|
-
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
3401
|
-
<div class="pk-h">${ic("git-pull-request", 15)}<b>Open PR</b><span class="dim now" style="flex:1;margin-left:8px">${esc(d.task)} · ${esc(d.worktree.branch ?? "")}${d.diff.dirty ? ' · <span class="badge warn">uncommitted changes — commit first</span>' : ""}</span></div>
|
|
3402
|
-
<div class="pk-b">
|
|
3403
|
-
<label>title<input id="prTitle" value="${esc(d.title)}"></label>
|
|
3404
|
-
<label>body<textarea id="prBody" style="min-height:220px">${esc(d.body)}</textarea></label>
|
|
3405
|
-
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" id="prDraft" style="width:auto"> draft</label>
|
|
3406
|
-
<div class="dim" style="font-size:var(--fs-sm)">Pushes <code>${esc(d.worktree.branch ?? "")}</code> to origin and runs <code>gh pr create</code> / <code>glab mr create</code> with your local login. Swarm never commits for you.</div>
|
|
3407
|
-
</div>
|
|
3408
|
-
<div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="prGo" data-project="${esc(projectId)}" data-worktree="${esc(d.worktree.path)}" ${d.diff.dirty ? "disabled" : ""}>${ic("git-pull-request", 13)} Open PR</button></div>
|
|
3409
|
-
</div>`;
|
|
3410
|
-
$("#pkClose")?.addEventListener("click", closePicker);
|
|
3411
|
-
}
|
|
3412
|
-
async function submitPr() {
|
|
3413
|
-
const b = $("#prGo"); if (!b) return;
|
|
3414
|
-
const projectId = b.dataset.project, worktree = b.dataset.worktree;
|
|
3415
|
-
const title = $("#prTitle")?.value.trim(), body = $("#prBody")?.value, draft = $("#prDraft")?.checked;
|
|
3416
|
-
b.disabled = true; b.textContent = "opening…";
|
|
3417
|
-
const r = await fetch("/v1/prs/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, worktree, title, body, draft }) }).then((x) => x.json());
|
|
3418
|
-
if (!r.ok) { b.disabled = false; b.textContent = "Open PR"; return alert(r.error); }
|
|
3419
|
-
closePicker();
|
|
3420
|
-
state.prs = [];
|
|
3421
|
-
await refresh();
|
|
3422
|
-
if (r.url) openExternal(r.url);
|
|
3423
|
-
}
|
|
3424
|
-
|
|
3425
|
-
async function openPicker(focusPath = false) {
|
|
3426
|
-
await pickerGo("");
|
|
3427
|
-
if (focusPath) { const i = $("#pkPath"); if (i) { i.focus(); i.select(); } }
|
|
3428
|
-
}
|
|
3429
|
-
async function pickerGo(path) {
|
|
3430
|
-
let data;
|
|
3431
|
-
try {
|
|
3432
|
-
const r = await fetch(`/v1/fs/ls?path=${encodeURIComponent(path)}`);
|
|
3433
|
-
data = await r.json();
|
|
3434
|
-
if (!r.ok) throw new Error(data.error || "cannot read folder");
|
|
3435
|
-
} catch (e) { return alert(e.message); }
|
|
3436
|
-
picker.path = data.path;
|
|
3437
|
-
const rows = [];
|
|
3438
|
-
if (data.parent) rows.push(`<div class="pk-row up" data-go="${esc(data.parent)}">${ic("arrow-left", 14)}<span class="nm">..</span></div>`);
|
|
3439
|
-
const base = data.path.replace(/\/$/, "");
|
|
3440
|
-
for (const e of data.entries)
|
|
3441
|
-
rows.push(`<div class="pk-row" data-go="${esc(base)}/${esc(e.name)}">${ic(e.repo ? "git-branch" : "folder-simple", 14)}<span class="nm">${esc(e.name)}</span>${e.repo ? '<span class="badge acc">git</span>' : ""}</div>`);
|
|
3442
|
-
$("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
|
|
3443
|
-
<div class="pk-h">${ic("folders", 15)}<input id="pkPath" value="${esc(data.path)}" spellcheck="false" autocomplete="off" title="Type a path and press Enter"></div>
|
|
3444
|
-
<div class="pk-list">${rows.join("") || '<div class="empty" style="padding:20px">No sub-folders.</div>'}</div>
|
|
3445
|
-
<div class="pk-f"><span class="grow"></span><button type="button" id="pkCancel">Cancel</button><button type="button" id="pkAdd" class="primary">Add this folder</button></div>
|
|
3446
|
-
</div>`;
|
|
3447
|
-
}
|
|
3448
|
-
const closePicker = () => { $("#picker").innerHTML = ""; };
|
|
3449
|
-
$("#picker").addEventListener("click", (ev) => {
|
|
3450
|
-
if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
|
|
3451
|
-
const pr = ev.target.closest("[data-pal]");
|
|
3452
|
-
if (pr) return palRun(Number(pr.dataset.pal));
|
|
3453
|
-
const go = ev.target.closest("[data-go]");
|
|
3454
|
-
if (go) return void pickerGo(go.dataset.go);
|
|
3455
|
-
const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
|
|
3456
|
-
if (ctoml) { ev.preventDefault(); copy($(`#toml-${ctoml.dataset.copyToml}`)?.textContent); ctoml.lastChild.textContent = " copied"; return; }
|
|
3457
|
-
if (cles) { ev.preventDefault(); copy($(`#lesson-${cles.dataset.copyLesson}`)?.textContent); cles.lastChild.textContent = " copied"; return; }
|
|
3458
|
-
if (ev.target.closest("#rpPrev")) return replayGo(-1);
|
|
3459
|
-
if (ev.target.closest("#rpNext")) return replayGo(1);
|
|
3460
|
-
if (ev.target.closest("#rnCancel")) return closePicker();
|
|
3461
|
-
const rnGo = ev.target.closest("#rnGo"); if (rnGo) return submitRun(rnGo.dataset.task);
|
|
3462
|
-
if (ev.target.closest("#pkAdd")) { const p = $("#pkPath")?.value.trim() || picker.path; closePicker(); addProject(p); }
|
|
3463
|
-
});
|
|
3464
|
-
$("#picker").addEventListener("input", (ev) => { if (ev.target.id === "rpRange") { replay.i = Number(ev.target.value); renderReplay(); } });
|
|
3465
|
-
$("#picker").addEventListener("change", (ev) => { if (ev.target.dataset?.drmode) { dry.modes[ev.target.dataset.drmode] = ev.target.value; } });
|
|
3466
|
-
$("#picker").addEventListener("keydown", (ev) => {
|
|
3467
|
-
if (ev.key === "Enter" && ev.target.id === "pkPath") { ev.preventDefault(); pickerGo(ev.target.value.trim()); }
|
|
3468
|
-
if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey) && ev.target.id === "rnPrompt") { ev.preventDefault(); submitRun($("#rnGo")?.dataset.task); }
|
|
3469
|
-
if ((ev.key === "ArrowRight" || ev.key === "ArrowLeft") && $(".rp")) { ev.preventDefault(); replayGo(ev.key === "ArrowRight" ? 1 : -1); }
|
|
3470
|
-
});
|
|
3471
|
-
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && $("#picker").innerHTML) closePicker(); });
|
|
3472
|
-
|
|
3473
|
-
// ---------- live
|
|
3474
|
-
// Poll for whatever the stream doesn't carry (turn costs, worktrees, PRs); SSE events coalesce into one
|
|
3475
|
-
// fetch 400 ms later. Both pause while the tab is hidden and resume on the next visibilitychange.
|
|
3476
|
-
const poll = () => (state.session ? openSession(state.session) : refresh());
|
|
3477
|
-
let pending = false;
|
|
3478
|
-
const pollSoon = () => { if (!pending) { pending = true; setTimeout(() => { pending = false; poll(); }, 400); } };
|
|
3479
|
-
let backoff = 1500;
|
|
3480
|
-
function connect() {
|
|
3481
|
-
const es = new EventSource(`/v1/events?since=${state.seq}${TOKEN ? `&token=${TOKEN}` : ""}`);
|
|
3482
|
-
const on = () => { backoff = 1500; $("#daemon .dot").classList.add("on"); };
|
|
3483
|
-
es.addEventListener("open", on);
|
|
3484
|
-
es.addEventListener("ping", on);
|
|
3485
|
-
es.onerror = () => { $("#daemon .dot").classList.remove("on"); es.close(); setTimeout(connect, backoff); backoff = Math.min(30_000, backoff * 2); };
|
|
3486
|
-
const onAny = (e) => {
|
|
3487
|
-
$("#daemon .dot").classList.add("on");
|
|
3488
|
-
const ev = JSON.parse(e.data);
|
|
3489
|
-
// Replayed events (reconnect) only bump the seq; the coalesced poll below picks up the rest.
|
|
3490
|
-
const fresh = ev.seq > state.seq;
|
|
3491
|
-
state.seq = Math.max(state.seq, ev.seq);
|
|
3492
|
-
if (fresh && state.session && ev.sessionId === state.session && !state.log.some((x) => x.seq === ev.seq)) {
|
|
3493
|
-
state.log.push(ev);
|
|
3494
|
-
if (state.log.length > LOG_CAP) state.log.shift();
|
|
3495
|
-
schedule();
|
|
3496
|
-
}
|
|
3497
|
-
if (fresh) notifyForEvent(ev);
|
|
3498
|
-
pollSoon();
|
|
3499
|
-
};
|
|
3500
|
-
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);
|
|
3501
|
-
}
|
|
3502
|
-
refresh().then(() => {
|
|
3503
|
-
const sid = new URLSearchParams(location.search).get("session");
|
|
3504
|
-
if (sid) openSession(sid);
|
|
3505
|
-
connect();
|
|
3506
|
-
});
|
|
3507
|
-
setInterval(() => { if (!document.hidden) poll(); }, 5000);
|
|
3508
|
-
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
|