@ra3orblade/swarm 0.11.3 → 0.12.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/web/app.js CHANGED
@@ -258,14 +258,43 @@ for (const el of document.querySelectorAll("i[data-icon]")) el.outerHTML = ic(el
258
258
  const getTheme = () => localStorage.getItem("swarm.theme") ?? "system";
259
259
  const setTheme = (t) => { localStorage.setItem("swarm.theme", t); if (t === "system") delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t; };
260
260
  setTheme(getTheme());
261
- const copy = (text) => navigator.clipboard?.writeText(String(text ?? ""));
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
+ }
262
291
  const tail = (p, n = 16) => { const t = short(p); return t.length > n ? `…${t.slice(-(n - 1))}` : t; };
263
292
  const agentLabel = (a) => viz.agentName(a);
264
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>` : "");
265
294
 
266
295
  // One render per animation frame, whatever triggered it (SSE, polls, clicks).
267
296
  let raf = 0;
268
- const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; render(); }); };
297
+ const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; safeRender(); }); };
269
298
  const touch = () => { state.dirty = true; schedule(); };
270
299
  // `render()` refuses to paint while a menu is open (it would detach the anchor the menu is
271
300
  // positioned against) and defers the frame instead. fancy-menus exposes no close callback, so the
@@ -287,7 +316,7 @@ async function refresh() {
287
316
  const txt = await (await fetch("/v1/state")).text();
288
317
  const same = txt === lastSnap;
289
318
  if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
290
- 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(() => {});
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(() => {});
291
320
  let prsChanged = false;
292
321
  if (state.view === "prs" && !state.session) {
293
322
  const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
@@ -339,17 +368,31 @@ async function refresh() {
339
368
  if (state.view === "graphs" && (state.graphTab ?? "collisions") === "lineage" && !state.session) {
340
369
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
341
370
  const open = (state.lineageOpen ?? []).map((g) => `&expand=${encodeURIComponent(g)}`).join("");
342
- const lin = await fetch(`/v1/graphs/lineage${q || "?"}${open}`).then((r) => r.json()).catch(() => state.lineage);
371
+ const lin = (await api(`/v1/graphs/lineage${q || "?"}${open}`)) ?? state.lineage;
343
372
  linChanged = JSON.stringify(lin) !== JSON.stringify(state.lineage);
344
373
  state.lineage = lin;
345
374
  }
346
375
  let colChanged = false;
347
- if (state.view === "graphs" && (state.graphTab ?? "collisions") !== "lineage" && !state.session) {
376
+ if (state.view === "graphs" && (state.graphTab ?? "collisions") === "collisions" && !state.session) {
348
377
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
349
- const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
378
+ const col = (await api(`/v1/graphs/collisions${q}`)) ?? state.collisions;
350
379
  colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
351
380
  state.collisions = col;
352
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
+ }
353
396
  let waitChanged = false;
354
397
  if ((state.view === "fleet" || state.view === "stats") && !state.session) {
355
398
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
@@ -364,6 +407,27 @@ async function refresh() {
364
407
  hygChanged = JSON.stringify(hy) !== JSON.stringify(state.hygiene);
365
408
  state.hygiene = hy;
366
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
+ }
367
431
  let ctxChanged = false;
368
432
  if (state.view === "context" && !state.session) {
369
433
  const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
@@ -407,7 +471,7 @@ async function refresh() {
407
471
  outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
408
472
  state.outcomes = o;
409
473
  }
410
- 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();
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();
411
475
  }
412
476
  // M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
413
477
  // deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
@@ -424,11 +488,14 @@ const VIEW_DEFS = [
424
488
  { id: "gates", label: "Gates", icon: "shield", group: "Insight", render: () => renderGateHealth(), badge: () => state.gateHealth?.totals?.flakyGates ?? 0 },
425
489
  { id: "mcp", label: "MCP", icon: "plugs-connected", group: "Insight", render: () => renderMcpHealth() },
426
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 },
427
492
  { id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
428
493
  { id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
429
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 },
430
496
  { id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard", render: () => renderProvenance(), badge: () => state.provenance?.totals?.untracked ?? 0 },
431
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 },
432
499
  ];
433
500
  const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
434
501
  const VIEWS = VIEW_DEFS.map((v) => v.id);
@@ -438,7 +505,7 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
438
505
  const v = localStorage.getItem("swarm.view");
439
506
  if (VIEWS.includes(v)) state.view = v;
440
507
  const gt = localStorage.getItem("swarm.graphTab");
441
- if (gt === "lineage" || gt === "collisions") state.graphTab = gt;
508
+ if (["lineage", "collisions", "tools", "resources"].includes(gt)) state.graphTab = gt;
442
509
  const sel = localStorage.getItem("swarm.sel");
443
510
  if (sel) state.sel = sel;
444
511
  // Deep links win over persisted state: ?view=board&project=<id>&session=<id>
@@ -448,6 +515,84 @@ let navHtml = ""; // last-rendered nav html; declared before the restore block b
448
515
  // Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
449
516
  renderNav();
450
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
+
451
596
  function render() {
452
597
  // A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
453
598
  // hold the frame while one is open; the next poll or interaction paints it.
@@ -1210,12 +1355,16 @@ function renderSpend() {
1210
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>
1211
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>
1212
1357
  <div class="cols">
1213
- <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>
1214
- <div>${byAgentToday ? `<h2>By agent · today <span>${usd(sumBy(byAgentToday, (x) => x.cost))}</span></h2>${tbl(byAgentToday, "agent", agentLabel, viz.agentColor)}<h2 class="mt-sec">By agent · all time</h2>${tbl(sp.byAgentAll, "agent", agentLabel, viz.agentColor)}` : `<h2>By model · today</h2>${tbl(sp.byModelToday, "model", model)}`}</div>
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>
1215
1367
  </div>
1216
- <div class="cols mt-sec"><div><h2>By project · today <span>${usd(sumBy(filt(sp.byProjectToday), (x) => x.cost))}</span></h2>${tbl(filt(sp.byProjectToday), "project", projName)}
1217
- <h2 class="mt-sec">By project · all time</h2>${tbl(filt(sp.byProjectAll), "project", projName)}</div>
1218
- <div>${byAgentToday ? `<h2>By model · today</h2>${tbl(sp.byModelToday, "model", model)}` : ""}<h2 style="${byAgentToday ? "margin-top:18px" : ""}">By model · all time</h2>${tbl(sp.byModelAll, "model", model)}</div></div>
1219
1368
  ${renderAttribution()}
1220
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>`;
1221
1370
  }
@@ -1531,7 +1680,7 @@ function renderContext() {
1531
1680
  <td class="num">${chars(s.toolChars)}</td>
1532
1681
  <td class="num"><b>${chars(s.wastedChars)}</b></td>
1533
1682
  <td class="num">${Math.round(s.wasteShare * 100)}%</td>
1534
- <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>
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>
1535
1684
  </tr>`).join("");
1536
1685
 
1537
1686
  $("#main").innerHTML = head(`last 7 days · ${chars(t.toolChars)} characters returned by tools`) + kpis +
@@ -1539,7 +1688,7 @@ function renderContext() {
1539
1688
  <div class="chart-card" style="margin:0"><h3>What fills the window <span>by tool · characters returned</span></h3>
1540
1689
  ${viz.hbars(c.byTool.map((x) => [ctxToolLabel(x.tool), x.chars, `${chars(x.chars)} · ${x.calls}`]))}</div>
1541
1690
  <div class="chart-card" style="margin:0"><h3>Re-read waste <span>the same file, read again</span></h3>
1542
- ${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>
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>
1543
1692
  </div>
1544
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>`;
1545
1694
  }
@@ -1736,11 +1885,16 @@ function renderHygiene() {
1736
1885
  const sampled = h.worktrees.filter((w) => w.diskKb !== null).length;
1737
1886
  const diskPending = h.worktrees.length > 0 && sampled === 0;
1738
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);
1739
1892
  const kpis = `<div class="kpis">${
1740
1893
  kpi("Needs a look", t.issues, t.issues ? "processes + worktrees" : "all clean", t.issues ? "hot" : "")
1741
1894
  }${kpi("Processes", t.processes, t.orphanedProcesses || t.deadProcesses ? `${t.orphanedProcesses} orphaned · ${t.deadProcesses} dead` : "all healthy", t.orphanedProcesses || t.deadProcesses ? "hot" : "")
1742
1895
  }${kpi("Worktrees", t.worktrees, t.staleWorktrees ? `${t.staleWorktrees} stale` : "none stale", t.staleWorktrees ? "warm" : "")
1743
- }${kpi("Reclaimable", diskPending ? '<span class="dim">—</span>' : mb(t.reclaimableKb), diskPending ? `measuring ${h.worktrees.length} worktrees…` : `of ${mb(t.diskKb)} on disk`, !diskPending && t.reclaimableKb ? "warm" : "")}</div>`;
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>`;
1744
1898
 
1745
1899
  const pcols = [
1746
1900
  { key: "issue", label: "state", width: 96, get: (p) => p.issue ?? "", cell: (p) => issueBadge(p.issue) },
@@ -1755,13 +1909,20 @@ function renderHygiene() {
1755
1909
  ];
1756
1910
  const wcols = [
1757
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>` },
1758
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>' : ""}` },
1759
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>`) },
1760
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)) },
1761
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>') : ""}` },
1762
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>') },
1763
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>') },
1764
- { key: "act", label: "", width: 80, sortable: false, filterable: false, get: () => null, cell: (w) => (w.reclaimable ? `<a href="#" class="mini-act bad" data-wtrm="${esc(w.projectId)}:${esc(w.path)}" title="Remove this worktree">Remove</a>` : "") },
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
+ } },
1765
1926
  ];
1766
1927
  const sub = t.issues ? `<b class="navcount">${t.issues} need${t.issues === 1 ? "s" : ""} a look</b>` : "nothing to clean up";
1767
1928
  $("#main").innerHTML = head(sub) + kpis +
@@ -1777,9 +1938,11 @@ function renderHygiene() {
1777
1938
  function renderGraphs() {
1778
1939
  const tab = state.graphTab ?? "collisions";
1779
1940
  const chip = (k, label, n) => `<span class="chip ${tab === k ? "on" : ""}" data-graphtab="${k}">${label}${n ? ` <b>${n}</b>` : ""}</span>`;
1780
- const tabs = `<div class="chips">${chip("collisions", "Collisions", state.collisions?.contested ?? 0)}${chip("lineage", "Lineage", state.lineage?.edges?.length ?? 0)}</div>`;
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>`;
1781
1942
  const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>${tabs}`;
1782
1943
  if (tab === "lineage") return renderLineage(head);
1944
+ if (tab === "tools") return renderTransitions(head);
1945
+ if (tab === "resources") return renderResourceGraph(head);
1783
1946
  const g = state.collisions;
1784
1947
  const title = (s) => s.title ?? s.id.slice(0, 8);
1785
1948
  if (!g || !g.sessions.length) {
@@ -1822,6 +1985,272 @@ function renderLineage(head) {
1822
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>`;
1823
1986
  }
1824
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
+
1825
2254
  function renderTimeline() {
1826
2255
  loadTimelineDetail();
1827
2256
  const now = Date.now();
@@ -2031,7 +2460,7 @@ async function sendStdin() {
2031
2460
  document.addEventListener("click", (ev) => {
2032
2461
  if (ev.target.closest("#stdinSend")) return sendStdin();
2033
2462
  const cp = ev.target.closest("[data-copy]");
2034
- if (cp) { ev.preventDefault(); copy(cp.dataset.copy); cp.classList.add("copied"); setTimeout(() => cp.classList.remove("copied"), 1000); return; }
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; }
2035
2464
  const qa = ev.target.closest("[data-qanswer]");
2036
2465
  if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
2037
2466
  const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
@@ -2591,6 +3020,59 @@ document.addEventListener("click", async (ev) => {
2591
3020
  state.lineageOpen = [...open];
2592
3021
  return refresh();
2593
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
+ }
2594
3076
  if (t.dataset.graphtab) { state.graphTab = t.dataset.graphtab; localStorage.setItem("swarm.graphTab", state.graphTab); return refresh(); }
2595
3077
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
2596
3078
  if (t.dataset.ack) { ev.preventDefault(); ev.stopPropagation(); return act.ack(t.dataset.ack); }