@ra3orblade/swarm 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -108,13 +108,18 @@ const ic = (name, size = 14, cls = "") => (window.icon ? window.icon(name, size,
108
108
  const kindIcon = (s) => ic(s.kind === "subagent" ? "tree-structure" : s.kind === "spawned" ? "play" : "keyboard", 13, "kind");
109
109
  // pixel-art illustrations for empty states (crispEdges, theme-green; won't clash with icon packs)
110
110
  function pixmap(rows, cell = 6) {
111
- // All three tones are derived from the accent, so they are guaranteed to separate in either
112
- // theme. The old palette used --c4 for the shade, whose luminance in light mode (0.158) is
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
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.
114
115
  const C = {
115
- X: "var(--acc)",
116
- g: "color-mix(in srgb, var(--acc) 45%, white)",
117
- d: "color-mix(in srgb, var(--acc) 58%, black)",
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)",
118
123
  };
119
124
  const w = Math.max(...rows.map((r) => r.length)) * cell;
120
125
  const h = rows.length * cell;
@@ -128,62 +133,123 @@ function pixmap(rows, cell = 6) {
128
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>`;
129
134
  }
130
135
  const PX = {
131
- // The palette carries three tones (accent / light / dark) and this used one, which is why it
132
- // read as a flat blob. Outline, ear modules and features in `d`; face and body in `X`; antenna
133
- // tips and the chest light in `g`. The body tapers rather than sitting under the head as a slab.
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.
134
139
  idle: () =>
135
140
  pixmap(
136
141
  [
137
- " g g ",
138
- " X X ",
139
- " X X ",
140
- " ddddddddddddd ",
141
- " dXXXXXXXXXXXd ",
142
- " dggXXXXXXXXXd ",
143
- " dddXXddXXXddXXddd ",
144
- " dddXXddXXXddXXddd ",
145
- " dddXXXXXXXXXXXddd ",
146
- " dddXXdddddddXXddd ",
147
- " dXXXXXXXXXXXd ",
148
- " dXXXXXXXXXXXd ",
149
- " ddddddddddddd ",
150
- " XXX ",
151
- " ddddd ",
152
- " ddddddddddd ",
153
- " dXXdggXXXXXXXdXXd ",
154
- " dXXdXggggXddddXXd ",
155
- " dXXdXggggXXXXdXXd ",
156
- " dXXdXXXXXXddddXXd ",
157
- " dXXdXXXXXXXXXdXXd ",
158
- " dXXdXdddXXXXXdXXd ",
159
- " ddddXXXXXXXXXdddd ",
160
- " ddddddddddd ",
161
- " XX XX ",
162
- " XX XX ",
163
- " XXXX XXXX ",
164
- " dddd dddd ",
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 ",
165
229
  ],
166
- 4,
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,
167
233
  ),
168
234
  folder: () => pixmap([
169
- " XXXX ",
170
- "XXXXXXXXXX",
171
- "XggggggggX",
172
- "XggggggggX",
173
- "XggggggggX",
174
- "XggggggggX",
175
- "XXXXXXXXXX",
235
+ " MMMM ",
236
+ "MMMMMMMMMM",
237
+ "MLLLLLLLLM",
238
+ "MLLLLLLLLM",
239
+ "MLLLLLLLLM",
240
+ "MLLLLLLLLM",
241
+ "MMMMMMMMMM",
176
242
  ]),
177
243
  clock: () => pixmap([
178
- " XXXXX ",
179
- " X X ",
180
- "X X X",
181
- "X X X",
182
- "X XXX X",
183
- "X X",
184
- "X X",
185
- " X X ",
186
- " XXXXX ",
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 ",
187
253
  ]),
188
254
  };
189
255
  // static <i data-icon> placeholders in index.html → inline SVG
@@ -199,7 +265,7 @@ const agentBadge = (a) => (a ? `<span class="badge agent" style="color:${viz.age
199
265
 
200
266
  // One render per animation frame, whatever triggered it (SSE, polls, clicks).
201
267
  let raf = 0;
202
- const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; render(); }); };
268
+ const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; safeRender(); }); };
203
269
  const touch = () => { state.dirty = true; schedule(); };
204
270
  // `render()` refuses to paint while a menu is open (it would detach the anchor the menu is
205
271
  // positioned against) and defers the frame instead. fancy-menus exposes no close callback, so the
@@ -221,7 +287,7 @@ async function refresh() {
221
287
  const txt = await (await fetch("/v1/state")).text();
222
288
  const same = txt === lastSnap;
223
289
  if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
224
- if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; state.hooksInstalled = h.hooksInstalled !== false; maybeUpdateNudge(h); maybeWhatsNew(); }).catch(() => {});
290
+ 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(() => {});
225
291
  let prsChanged = false;
226
292
  if (state.view === "prs" && !state.session) {
227
293
  const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
@@ -273,17 +339,31 @@ async function refresh() {
273
339
  if (state.view === "graphs" && (state.graphTab ?? "collisions") === "lineage" && !state.session) {
274
340
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
275
341
  const open = (state.lineageOpen ?? []).map((g) => `&expand=${encodeURIComponent(g)}`).join("");
276
- const lin = await fetch(`/v1/graphs/lineage${q || "?"}${open}`).then((r) => r.json()).catch(() => state.lineage);
342
+ const lin = (await api(`/v1/graphs/lineage${q || "?"}${open}`)) ?? state.lineage;
277
343
  linChanged = JSON.stringify(lin) !== JSON.stringify(state.lineage);
278
344
  state.lineage = lin;
279
345
  }
280
346
  let colChanged = false;
281
- if (state.view === "graphs" && (state.graphTab ?? "collisions") !== "lineage" && !state.session) {
347
+ if (state.view === "graphs" && (state.graphTab ?? "collisions") === "collisions" && !state.session) {
282
348
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
283
- const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
349
+ const col = (await api(`/v1/graphs/collisions${q}`)) ?? state.collisions;
284
350
  colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
285
351
  state.collisions = col;
286
352
  }
353
+ let resChanged = false;
354
+ if (state.view === "graphs" && state.graphTab === "resources" && !state.session) {
355
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
356
+ const rg = (await api(`/v1/graphs/resources${q}`)) ?? state.resourceGraph;
357
+ resChanged = JSON.stringify(rg) !== JSON.stringify(state.resourceGraph);
358
+ state.resourceGraph = rg;
359
+ }
360
+ let trChanged = false;
361
+ if (state.view === "graphs" && state.graphTab === "tools" && !state.session) {
362
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
363
+ const tr = (await api(`/v1/graphs/transitions${q}`)) ?? state.transitions;
364
+ trChanged = JSON.stringify(tr) !== JSON.stringify(state.transitions);
365
+ state.transitions = tr;
366
+ }
287
367
  let waitChanged = false;
288
368
  if ((state.view === "fleet" || state.view === "stats") && !state.session) {
289
369
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
@@ -298,6 +378,27 @@ async function refresh() {
298
378
  hygChanged = JSON.stringify(hy) !== JSON.stringify(state.hygiene);
299
379
  state.hygiene = hy;
300
380
  }
381
+ let reChanged = false;
382
+ if (state.view === "rules" && !state.session) {
383
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
384
+ const re = (await api(`/v1/rules/effect${q}`)) ?? state.ruleEffect;
385
+ reChanged = JSON.stringify(re) !== JSON.stringify(state.ruleEffect);
386
+ state.ruleEffect = re;
387
+ }
388
+ let secChanged = false;
389
+ if (state.view === "security" && !state.session) {
390
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
391
+ const sec = (await api(`/v1/security${q}`)) ?? state.security;
392
+ secChanged = JSON.stringify(sec) !== JSON.stringify(state.security);
393
+ state.security = sec;
394
+ }
395
+ let heatChanged = false;
396
+ if (state.view === "heat" && !state.session) {
397
+ const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
398
+ const h = (await api(`/v1/heat${q}`)) ?? state.heat;
399
+ heatChanged = JSON.stringify(h) !== JSON.stringify(state.heat);
400
+ state.heat = h;
401
+ }
301
402
  let ctxChanged = false;
302
403
  if (state.view === "context" && !state.session) {
303
404
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
@@ -341,7 +442,7 @@ async function refresh() {
341
442
  outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
342
443
  state.outcomes = o;
343
444
  }
344
- if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || linChanged || outChanged || waitChanged || ghChanged || mcpChanged || ctxChanged || provChanged || trialsChanged || hygChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
445
+ 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();
345
446
  }
346
447
  // M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
347
448
  // deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
@@ -358,11 +459,14 @@ const VIEW_DEFS = [
358
459
  { id: "gates", label: "Gates", icon: "shield", group: "Insight", render: () => renderGateHealth(), badge: () => state.gateHealth?.totals?.flakyGates ?? 0 },
359
460
  { id: "mcp", label: "MCP", icon: "plugs-connected", group: "Insight", render: () => renderMcpHealth() },
360
461
  { id: "context", label: "Context", icon: "brain", group: "Insight", render: () => renderContext() },
462
+ { id: "heat", label: "Files", icon: "file-text", group: "Insight", render: () => renderHeat(), badge: () => state.heat?.candidates?.length ?? 0 },
361
463
  { id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
362
464
  { id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
363
465
  { id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
466
+ { id: "security", label: "Security", icon: "shield", group: "Guard", render: () => renderSecurity(), badge: () => state.security?.totals?.secrets ?? 0 },
364
467
  { id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard", render: () => renderProvenance(), badge: () => state.provenance?.totals?.untracked ?? 0 },
365
468
  { id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
469
+ { id: "rules", label: "Rules", icon: "shield", group: "Guard", render: () => renderRuleEffect(), badge: () => state.ruleEffect?.totals?.unchanged ?? 0 },
366
470
  ];
367
471
  const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
368
472
  const VIEWS = VIEW_DEFS.map((v) => v.id);
@@ -372,7 +476,7 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
372
476
  const v = localStorage.getItem("swarm.view");
373
477
  if (VIEWS.includes(v)) state.view = v;
374
478
  const gt = localStorage.getItem("swarm.graphTab");
375
- if (gt === "lineage" || gt === "collisions") state.graphTab = gt;
479
+ if (["lineage", "collisions", "tools", "resources"].includes(gt)) state.graphTab = gt;
376
480
  const sel = localStorage.getItem("swarm.sel");
377
481
  if (sel) state.sel = sel;
378
482
  // Deep links win over persisted state: ?view=board&project=<id>&session=<id>
@@ -382,6 +486,78 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
382
486
  // Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
383
487
  renderNav();
384
488
  }
489
+ // ---------- errors
490
+ // The dashboard is one long-lived page: an exception in a view used to leave the last frame on
491
+ // screen with no sign anything had gone wrong, and a failed poll was swallowed by `.catch(() =>
492
+ // keep the old value)`. Both now surface. `api()` records what failed so a report has something
493
+ // in it, and `render()` is wrapped so a throwing view shows a panel instead of a frozen one.
494
+ const failures = []; // newest first, capped — a report wants the recent ones, not all of them
495
+ const noteFailure = (f) => { failures.unshift({ ...f, at: new Date().toISOString() }); failures.length = Math.min(failures.length, 12); };
496
+
497
+ /**
498
+ * GET JSON, or null. A non-2xx is a failure worth naming: a 404 on a `/v1/` route almost always
499
+ * means the running daemon is older than the page it is serving, which is a restart, not a bug.
500
+ */
501
+ async function api(url) {
502
+ try {
503
+ const r = await fetch(url);
504
+ if (!r.ok) { noteFailure({ url, status: r.status, kind: r.status === 404 ? "missing-route" : "http" }); return null; }
505
+ return await r.json();
506
+ } catch (e) {
507
+ noteFailure({ url, status: 0, kind: "network", message: String(e?.message ?? e) });
508
+ return null;
509
+ }
510
+ }
511
+
512
+ /** Everything a bug report needs and nothing a person would mind pasting into a public issue. */
513
+ function errorReport(err, where) {
514
+ return {
515
+ swarm: state.version ?? "unknown",
516
+ onDisk: state.diskVersion ?? null,
517
+ view: where ?? state.view,
518
+ graphTab: state.graphTab ?? null,
519
+ session: state.session ? "open" : "none", // the id is not ours to put in a public issue
520
+ projectScoped: Boolean(state.sel),
521
+ error: err ? `${err.name ?? "Error"}: ${err.message ?? err}` : null,
522
+ stack: err?.stack ? String(err.stack).split("\n").slice(0, 8).join("\n") : null,
523
+ recentFailedRequests: failures.slice(0, 6),
524
+ userAgent: navigator.userAgent,
525
+ at: new Date().toISOString(),
526
+ };
527
+ }
528
+
529
+ let lastError = null;
530
+ function renderErrorPanel(err, where) {
531
+ lastError = { err, where };
532
+ const skew = failures.find((f) => f.kind === "missing-route");
533
+ const rep = errorReport(err, where);
534
+ $("#main").innerHTML =
535
+ `<h2 class="err-h">${ic("warning", 14, "err-ic")}Something broke <span>${esc(where ?? state.view)}</span></h2>
536
+ <div class="card err-card">
537
+ ${err
538
+ ? `<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>`
539
+ : `<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>
540
+ <button class="btn primary" data-act="restart-daemon">Restart daemon</button>`}
541
+ ${err ? `<pre class="err-detail">${esc(rep.stack || rep.error)}</pre>` : ""}
542
+ ${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>` : ""}
543
+ <div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
544
+ <button class="btn" data-act="err-copy">Copy report</button>
545
+ <button class="btn" data-act="err-issue">Open an issue</button>
546
+ <button class="btn" data-act="err-reload">Reload</button>
547
+ </div>
548
+ <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>
549
+ </div>`;
550
+ }
551
+
552
+ // A view that throws must not take the whole page with it, and must not leave the previous frame
553
+ // up pretending to be current.
554
+ function safeRender() {
555
+ try { render(); }
556
+ catch (e) { try { renderErrorPanel(e, state.view); } catch { /* the panel itself failed; leave the frame */ } }
557
+ }
558
+ addEventListener("error", (e) => noteFailure({ kind: "exception", url: location.hash || "#", status: 0, message: String(e.message ?? e.error ?? e) }));
559
+ addEventListener("unhandledrejection", (e) => noteFailure({ kind: "rejection", url: location.hash || "#", status: 0, message: String(e.reason?.message ?? e.reason ?? e) }));
560
+
385
561
  function render() {
386
562
  // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
387
563
  // hold the frame while one is open; the next poll or interaction paints it.
@@ -1465,7 +1641,7 @@ function renderContext() {
1465
1641
  <td class="num">${chars(s.toolChars)}</td>
1466
1642
  <td class="num"><b>${chars(s.wastedChars)}</b></td>
1467
1643
  <td class="num">${Math.round(s.wasteShare * 100)}%</td>
1468
- <td>${s.worst.map((w) => `<span class="br" title="${esc(w.path)} — read ${w.reads}× · ${chars(w.wastedChars)} chars re-read">${esc(w.path.split("/").slice(-1)[0])} <b>${w.reads}×</b></span>`).join(" ")}</td>
1644
+ <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>
1469
1645
  </tr>`).join("");
1470
1646
 
1471
1647
  $("#main").innerHTML = head(`last 7 days · ${chars(t.toolChars)} characters returned by tools`) + kpis +
@@ -1473,7 +1649,7 @@ function renderContext() {
1473
1649
  <div class="chart-card" style="margin:0"><h3>What fills the window <span>by tool · characters returned</span></h3>
1474
1650
  ${viz.hbars(c.byTool.map((x) => [ctxToolLabel(x.tool), x.chars, `${chars(x.chars)} · ${x.calls}`]))}</div>
1475
1651
  <div class="chart-card" style="margin:0"><h3>Re-read waste <span>the same file, read again</span></h3>
1476
- ${worst.length ? `<table class="mini"><thead><tr><th>session</th><th class="num">returned</th><th class="num">wasted</th><th class="num">share</th><th>worst files</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="dim">Nothing was read twice — no waste to report.</div>'}</div>
1652
+ ${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>
1477
1653
  </div>
1478
1654
  <p class="dim" style="margin-top:10px;font-size:var(--fs-sm)">Character counts are exact — every tool response is stored. Token figures are a flat 4:1 estimate. <b>MCP tool schemas and the system prompt are not included</b>: Swarm sees tool calls, never the schemas or the prompt preamble, so they are left out rather than guessed at.</p>`;
1479
1655
  }
@@ -1711,9 +1887,11 @@ function renderHygiene() {
1711
1887
  function renderGraphs() {
1712
1888
  const tab = state.graphTab ?? "collisions";
1713
1889
  const chip = (k, label, n) => `<span class="chip ${tab === k ? "on" : ""}" data-graphtab="${k}">${label}${n ? ` <b>${n}</b>` : ""}</span>`;
1714
- const tabs = `<div class="chips">${chip("collisions", "Collisions", state.collisions?.contested ?? 0)}${chip("lineage", "Lineage", state.lineage?.edges?.length ?? 0)}</div>`;
1890
+ 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>`;
1715
1891
  const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>${tabs}`;
1716
1892
  if (tab === "lineage") return renderLineage(head);
1893
+ if (tab === "tools") return renderTransitions(head);
1894
+ if (tab === "resources") return renderResources(head);
1717
1895
  const g = state.collisions;
1718
1896
  const title = (s) => s.title ?? s.id.slice(0, 8);
1719
1897
  if (!g || !g.sessions.length) {
@@ -1756,6 +1934,272 @@ function renderLineage(head) {
1756
1934
  <span class="dim" style="font-size:var(--fs-sm)">a green pill is a collapsed group — click to open it · ring = outcome · thicker dot = more links · a bowed edge closed a loop</span></div>`;
1757
1935
  }
1758
1936
 
1937
+ // M9.15: what an agent reaches for after what. Edge thickness is the weight; a two-tool cycle is
1938
+ // a round trip, which is only worth worrying about when the calls inside it are also failing —
1939
+ // so the loops table describes shape, and the Stuck badge (M9.3) stays the thing that judges.
1940
+ function renderTransitions(head) {
1941
+ const g = state.transitions;
1942
+ if (!g) {
1943
+ // Distinguish "not fetched yet" from "this daemon has no such route": the second never resolves
1944
+ // on its own, and telling someone to wait for it is a lie.
1945
+ const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/transitions"));
1946
+ if (skew) return renderErrorPanel(null, "graphs · tools");
1947
+ $("#main").innerHTML = head("tool transitions") + `<div class="empty">${PX.clock()}Loading…</div>`;
1948
+ return;
1949
+ }
1950
+ if (!g.nodes?.length) {
1951
+ $("#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>`;
1952
+ return;
1953
+ }
1954
+ const tools = g.nodes.slice(0, 18).map((n) => n.tool);
1955
+ const shown = new Set(tools);
1956
+ 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>` : ""}`;
1957
+ const loops = (g.loops ?? []).slice(0, 9);
1958
+ const loopRows = loops.map((l) => `<tr>
1959
+ <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>
1960
+ <td class="num"><b>${l.weight.toLocaleString()}</b></td>
1961
+ <td class="num">${l.sessions}</td>
1962
+ </tr>`).join("");
1963
+ $("#main").innerHTML = head(sub) +
1964
+ `<div class="cols">
1965
+ <div class="chart-card" style="margin:0"><h3>What follows what <span>row ran, then column · darker = more often</span></h3>
1966
+ ${viz.matrix(tools, g.edges.filter((e) => shown.has(e.from) && shown.has(e.to)), { label: ctxToolLabel })}</div>
1967
+ <div class="chart-card" style="margin:0"><h3>Round trips <span>a tool pair that keeps handing back</span></h3>
1968
+ ${loops.length
1969
+ ? `<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>
1970
+ <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>`
1971
+ : '<div class="dim">No tool pair hands back to the other — every move is one-way.</div>'}</div>
1972
+ </div>`;
1973
+ }
1974
+
1975
+ // M9.17: claims, ports, leases and processes on one picture with whoever holds them. Orphaned
1976
+ // means the holding session ended (or the lease expired) — the same reading Hygiene uses. There
1977
+ // is no deadlock to find: claims fail closed, so a second claimer is refused rather than queued
1978
+ // and nobody ever blocks. What the rings show is contention — two agents each wanting what the
1979
+ // other has — which is a scheduling problem for a person, not a lock to break.
1980
+ function renderResources(head) {
1981
+ const g = state.resourceGraph;
1982
+ if (!g) {
1983
+ const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/resources"));
1984
+ if (skew) return renderErrorPanel(null, "graphs · resources");
1985
+ $("#main").innerHTML = head("who holds what") + `<div class="empty">${PX.clock()}Loading…</div>`;
1986
+ return;
1987
+ }
1988
+ if (!g.resources.length) {
1989
+ $("#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>`;
1990
+ return;
1991
+ }
1992
+ const t = g.totals;
1993
+ 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>` : ""}`;
1994
+ // Same shape the collision graph draws: holders on the left, what they hold on the right.
1995
+ 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 }));
1996
+ 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 }));
1997
+ 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("");
1998
+ const orphans = g.resources.filter((r) => r.orphaned);
1999
+ $("#main").innerHTML = head(sub) +
2000
+ (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>` : "") +
2001
+ `<div class="card" style="padding:14px">${viz.bipartite(holders, items)}</div>
2002
+ <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>` +
2003
+ (orphans.length
2004
+ ? `<div class="chart-card" style="margin-top:14px"><h3>Orphaned <span>the session that took it has ended</span></h3>
2005
+ <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>`
2006
+ : "");
2007
+ }
2008
+
2009
+ // M9.16: where the fleet's attention actually goes. The candidates list is the point — a file many
2010
+ // separate sessions read, re-read, and hardly ever write is one the fleet keeps re-learning, and
2011
+ // that belongs in CLAUDE.md. A file read *and written* a lot is just where the work is.
2012
+ function renderHeat(head) {
2013
+ const h = state.heat;
2014
+ const title = (sub) => `<h2>Files <span>${sub}</span></h2>`;
2015
+ if (!h) {
2016
+ const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/heat"));
2017
+ if (skew) return renderErrorPanel(null, "files");
2018
+ $("#main").innerHTML = title("file-touch heat") + `<div class="empty">${PX.clock()}Loading…</div>`;
2019
+ return;
2020
+ }
2021
+ if (!h.files.length) {
2022
+ $("#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>`;
2023
+ return;
2024
+ }
2025
+ const t = h.totals;
2026
+ 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>`;
2027
+ const kpis = `<div class="kpis">${kpi("Files touched", t.files.toLocaleString(), `${t.touches.toLocaleString()} touches · last 14 days`)}
2028
+ ${kpi("Re-reads", t.rereads.toLocaleString(), t.touches ? `${Math.round((t.rereads / t.touches) * 100)}% of every touch` : "none")}
2029
+ ${kpi("Touched once", t.cold.toLocaleString(), "cold — read and never returned to")}
2030
+ ${kpi("CLAUDE.md candidates", h.candidates.length, h.candidates.length ? "re-read by several sessions" : "nothing worth writing down", h.candidates.length ? "warm" : "")}</div>`;
2031
+ // Paths here are long and the column is narrow, and neither end can simply be cut: the head is
2032
+ // a home prefix every row shares, and the tail is the filename — which is the only part worth
2033
+ // reading. Three worktrees each have a packages/web/public/app.js, so the name alone is not
2034
+ // enough either. Name first, then just enough of its directory to tell them apart, dimmed and
2035
+ // free to truncate.
2036
+ const nameOf = (p) => short(p).split("/").pop() || short(p);
2037
+ const ctxOf = (p, n = 2) => {
2038
+ const parts = short(p).split("/");
2039
+ parts.pop();
2040
+ return parts.length <= n ? parts.join("/") : `…/${parts.slice(-n).join("/")}`;
2041
+ };
2042
+ /**
2043
+ * Two segments of context is usually enough, but three worktrees each holding a
2044
+ * packages/web/public/app.js all render identically — the list then reads as one file listed
2045
+ * three times. Widen the context only for the rows that actually collide, and only as far as it
2046
+ * takes to tell them apart.
2047
+ */
2048
+ const labelPaths = (paths) => {
2049
+ const out = new Map();
2050
+ for (const p of paths) {
2051
+ let n = 2;
2052
+ let label = `${nameOf(p)}|${ctxOf(p, n)}`;
2053
+ while (n < 6 && paths.some((q) => q !== p && `${nameOf(q)}|${ctxOf(q, n)}` === label)) {
2054
+ n++;
2055
+ label = `${nameOf(p)}|${ctxOf(p, n)}`;
2056
+ }
2057
+ out.set(p, ctxOf(p, n));
2058
+ }
2059
+ return out;
2060
+ };
2061
+ const pathCell = (p, ctx) =>
2062
+ `<b>${esc(nameOf(p))}</b> <span class="dim">${esc(ctx.get(p) ?? ctxOf(p))}</span>`;
2063
+
2064
+ const shownFiles = h.files.slice(0, 14);
2065
+ const fileCtx = labelPaths(shownFiles.map((f) => f.path));
2066
+ const fileRows = shownFiles.map((f) => `<tr>
2067
+ <td class="clip path" title="${esc(short(f.path))}">${pathCell(f.path, fileCtx)}</td>
2068
+ <td class="num"><b>${f.touches.toLocaleString()}</b></td>
2069
+ <td class="num">${f.sessions}</td>
2070
+ <td class="num">${f.rereads.toLocaleString()}</td>
2071
+ <td class="num">${f.writes.toLocaleString()}</td>
2072
+ </tr>`).join("");
2073
+ const top = h.dirs[0]?.touches || 1;
2074
+ const shownDirs = h.dirs.slice(0, 10);
2075
+ const dirCtx = labelPaths(shownDirs.map((d) => d.dir));
2076
+ const dirRows = shownDirs.map((d) => `<li>
2077
+ <span class="bar" style="--w:${Math.max(2, Math.round((d.touches / top) * 100))}%"></span>
2078
+ <span class="clip path" title="${esc(short(d.dir))}">${pathCell(d.dir, dirCtx)}</span>
2079
+ <b>${d.touches.toLocaleString()}</b>
2080
+ <span class="dim">${d.files} file${d.files === 1 ? "" : "s"} · ${d.sessions} session${d.sessions === 1 ? "" : "s"}</span>
2081
+ </li>`).join("");
2082
+ const shownCand = h.candidates.slice(0, 10);
2083
+ const candCtx = labelPaths(shownCand.map((f) => f.path));
2084
+ const cand = shownCand.map((f) => `<tr>
2085
+ <td class="clip path" title="${esc(short(f.path))}">${pathCell(f.path, candCtx)}</td>
2086
+ <td class="num"><b>${f.rereads.toLocaleString()}</b></td>
2087
+ <td class="num">${f.sessions}</td>
2088
+ </tr>`).join("");
2089
+ $("#main").innerHTML = title(`${t.files.toLocaleString()} files · ${t.touches.toLocaleString()} touches · ${t.sessions} sessions · last 14 days`) + kpis +
2090
+ `<div class="cols">
2091
+ <div class="chart-card" style="margin:0"><h3>Hottest files <span>every touch, across sessions</span></h3>
2092
+ <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>
2093
+ <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>
2094
+ <div style="display:flex;flex-direction:column;gap:var(--gap-sec);min-width:0">
2095
+ <div class="chart-card" style="margin:0"><h3>Worth writing down <span>read again and again, rarely written</span></h3>
2096
+ ${cand
2097
+ ? `<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>
2098
+ <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>`
2099
+ : '<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>
2100
+ <div class="chart-card" style="margin:0"><h3>By directory <span>where the work sits</span></h3>
2101
+ <ul class="heatlist">${dirRows}</ul></div>
2102
+ </div>
2103
+ </div>
2104
+ <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>`;
2105
+ }
2106
+
2107
+ // M9.9: what agents reached for. Observation only — nothing here denies anything, and the point of
2108
+ // looking is to learn what your fleet actually does before writing an `ask` rule about it.
2109
+ function renderSecurity() {
2110
+ const r = state.security;
2111
+ const head = (sub) => `<h2>Security <span>${sub}</span></h2>`;
2112
+ if (!r) {
2113
+ const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/security"));
2114
+ if (skew) return renderErrorPanel(null, "security");
2115
+ $("#main").innerHTML = head("what agents reached for") + `<div class="empty">${PX.clock()}Loading…</div>`;
2116
+ return;
2117
+ }
2118
+ const t = r.totals;
2119
+ if (!t.scanned) {
2120
+ $("#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>`;
2121
+ return;
2122
+ }
2123
+ 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>`;
2124
+ const remote = r.egress.filter((h) => !h.local);
2125
+ const kpis = `<div class="kpis">
2126
+ ${kpi("Hosts reached", t.remoteHosts, `${r.egress.length - t.remoteHosts} more were local`)}
2127
+ ${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"}`)}
2128
+ ${kpi("Credential files opened", t.secrets, t.secrets ? "by name — contents are never read" : "none", t.secrets ? "hot" : "")}
2129
+ ${kpi("Commands scanned", t.scanned.toLocaleString(), "last 14 days")}</div>`;
2130
+ const rows = (list, cells) => list.map((x) => `<tr>${cells(x)}</tr>`).join("");
2131
+ $("#main").innerHTML = head(`${t.scanned.toLocaleString()} commands · last 14 days`) + kpis +
2132
+ `<div class="cols">
2133
+ <div class="chart-card" style="margin:0"><h3>Hosts reached <span>named in a command or a fetch</span></h3>
2134
+ ${remote.length
2135
+ ? `<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>
2136
+ ${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>`
2137
+ : '<div class="dim">Nothing but localhost.</div>'}
2138
+ <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>
2139
+ <div style="display:flex;flex-direction:column;gap:var(--gap-sec);min-width:0">
2140
+ <div class="chart-card" style="margin:0"><h3>Credential files <span>opened by name</span></h3>
2141
+ ${r.secrets.length
2142
+ ? `<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>
2143
+ <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>`
2144
+ : '<div class="dim">No credential file was opened by name.</div>'}</div>
2145
+ <div class="chart-card" style="margin:0"><h3>Packages installed <span>what the machine will run later</span></h3>
2146
+ ${r.installs.length
2147
+ ? `<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>`
2148
+ : '<div class="dim">Nothing was installed.</div>'}</div>
2149
+ </div>
2150
+ </div>
2151
+ <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>`;
2152
+ }
2153
+
2154
+ // M9.10: a rule that fires once and never again taught somebody something. A rule that fires forty
2155
+ // times on the same shaped command is friction — the habit needs changing, or the rule does.
2156
+ function renderRuleEffect() {
2157
+ const r = state.ruleEffect;
2158
+ const head = (sub) => `<h2>Rules <span>${sub}</span></h2>`;
2159
+ if (!r) {
2160
+ const skew = failures.find((f) => f.kind === "missing-route" && f.url.includes("/rules/"));
2161
+ if (skew) return renderErrorPanel(null, "rules");
2162
+ $("#main").innerHTML = head("is a rule teaching anyone anything?") + `<div class="empty">${PX.clock()}Loading…</div>`;
2163
+ return;
2164
+ }
2165
+ if (!r.rules.length) {
2166
+ $("#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>`;
2167
+ return;
2168
+ }
2169
+ const t = r.totals;
2170
+ 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>`;
2171
+ const TREND = { rising: ["bad", "rising"], falling: ["ok", "falling"], steady: ["", "steady"] };
2172
+ const kpis = `<div class="kpis">
2173
+ ${kpi("Incidents", t.incidents, "last 30 days")}
2174
+ ${kpi("Rules firing", t.rules, `${t.acked} incident${t.acked === 1 ? "" : "s"} acknowledged`)}
2175
+ ${kpi("Not settling", t.unchanged, t.unchanged ? "firing as much as ever, or more" : "every rule is quieting down", t.unchanged ? "hot" : "")}
2176
+ ${kpi("Change history", r.noChangeHistory ? "none" : "yes", r.noChangeHistory ? "no before/after yet" : "before/after available")}</div>`;
2177
+
2178
+ const cards = r.rules.map((x) => {
2179
+ const [cls, word] = TREND[x.trend];
2180
+ const spark = viz.sparkline(x.perDay.map((d) => d.n));
2181
+ const worst = x.clusters[0];
2182
+ return `<div class="chart-card" style="margin:0">
2183
+ <h3>${esc(x.rule)} <span><b class="${cls}">${word}</b> · ${x.total} incident${x.total === 1 ? "" : "s"} · ${x.acked} acked</span></h3>
2184
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">${spark}
2185
+ <span class="dim" style="font-size:var(--fs-sm)">${ago(x.lastAt)} since the last one</span></div>
2186
+ ${worst && x.total > 1
2187
+ ? `<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>
2188
+ <ul class="plainlist">${x.clusters.map((c) => `<li><b>${esc(c.signature)}</b><span class="dim">${c.hits}×</span><span class="clip dim" title="${esc(c.example)}">${esc(c.example.slice(0, 70))}</span></li>`).join("")}</ul>`
2189
+ : '<p class="dim" style="margin:0;font-size:var(--fs-md)">Fired once. Whatever it was, it has not come back.</p>'}
2190
+ ${x.landed
2191
+ ? `<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>`
2192
+ : ""}
2193
+ </div>`;
2194
+ }).join("");
2195
+
2196
+ $("#main").innerHTML = head(`${t.incidents} incidents · ${t.rules} rule${t.rules === 1 ? "" : "s"} · last 30 days`) + kpis +
2197
+ `<div class="cols">${cards}</div>` +
2198
+ (r.noChangeHistory
2199
+ ? `<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>`
2200
+ : "");
2201
+ }
2202
+
1759
2203
  function renderTimeline() {
1760
2204
  loadTimelineDetail();
1761
2205
  const now = Date.now();
@@ -2525,6 +2969,24 @@ document.addEventListener("click", async (ev) => {
2525
2969
  state.lineageOpen = [...open];
2526
2970
  return refresh();
2527
2971
  }
2972
+ if (t.dataset.act?.startsWith("err-") || t.dataset.act === "restart-daemon") {
2973
+ ev.preventDefault();
2974
+ const rep = JSON.stringify(errorReport(lastError?.err, lastError?.where), null, 2);
2975
+ if (t.dataset.act === "err-copy") { copy(rep); t.textContent = "copied"; setTimeout(() => { t.textContent = "Copy report"; }, 1400); return; }
2976
+ if (t.dataset.act === "err-issue") {
2977
+ // Prefilled, but the person still reads and sends it — nothing leaves the machine on its own.
2978
+ const body = `**What I was doing:**\n\n\n<details><summary>Report</summary>\n\n\`\`\`json\n${rep}\n\`\`\`\n</details>`;
2979
+ const url = `https://github.com/ra3orblade/swarm/issues/new?title=${encodeURIComponent(`Dashboard error in ${lastError?.where ?? state.view}`)}&body=${encodeURIComponent(body)}`;
2980
+ window.open(url, "_blank", "noopener");
2981
+ return;
2982
+ }
2983
+ if (t.dataset.act === "err-reload") { lastError = null; return location.reload(); }
2984
+ t.textContent = "restarting…";
2985
+ fetch("/v1/daemon/restart", { method: "POST" })
2986
+ .catch(() => {})
2987
+ .then(() => setTimeout(() => location.reload(), 1500));
2988
+ return;
2989
+ }
2528
2990
  if (t.dataset.graphtab) { state.graphTab = t.dataset.graphtab; localStorage.setItem("swarm.graphTab", state.graphTab); return refresh(); }
2529
2991
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
2530
2992
  if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }