@ra3orblade/swarm 0.4.1 → 0.6.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
@@ -45,7 +45,7 @@ document.addEventListener("keydown", (ev) => {
45
45
  window.swarmZoom(dir);
46
46
  });
47
47
  // `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
48
- const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
48
+ const state = { projects: [], sessions: [], worktrees: {}, processes: [], spend: null, incidents: [], allIncidents: null, incFilter: "open", tasks: null, gates: null, runs: [], attribution: null, taskFilter: "ready", resources: [], prs: [], seq: 0, sel: null, session: null, log: [], turns: [], view: "fleet", agentFilter: null, dirty: true };
49
49
 
50
50
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
51
51
  const ago = (iso) => { const d = (Date.now() - new Date(iso)) / 1000; return d < 60 ? `${d | 0}s` : d < 3600 ? `${(d / 60) | 0}m` : d < 86400 ? `${(d / 3600) | 0}h` : `${(d / 86400) | 0}d`; };
@@ -129,18 +129,37 @@ async function refresh() {
129
129
  const txt = await (await fetch("/v1/state")).text();
130
130
  const same = txt === lastSnap;
131
131
  if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
132
- if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; }).catch(() => {});
132
+ if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; maybeWhatsNew(); }).catch(() => {});
133
133
  let prsChanged = false;
134
134
  if (state.view === "prs" && !state.session) {
135
135
  const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
136
136
  prsChanged = JSON.stringify(prs) !== JSON.stringify(state.prs);
137
137
  state.prs = prs;
138
138
  }
139
+ let attrChanged = false;
140
+ if (state.view === "spend" && state.sel && !state.session) {
141
+ const a = await fetch(`/v1/attribution?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.attribution);
142
+ attrChanged = JSON.stringify(a) !== JSON.stringify(state.attribution);
143
+ state.attribution = a;
144
+ } else if (state.view === "spend" && !state.sel) {
145
+ if (state.attribution) attrChanged = true;
146
+ state.attribution = null;
147
+ }
148
+ let runsChanged = false;
149
+ const openSpawned = state.session && state.sessions.find((x) => x.id === state.session)?.kind === "spawned";
150
+ if (openSpawned || (state.view === "board" && !state.session) || (state.view === "fleet" && !state.session)) {
151
+ const runs = await fetch("/v1/runs").then((r) => r.json()).catch(() => state.runs ?? []);
152
+ runsChanged = JSON.stringify(runs) !== JSON.stringify(state.runs);
153
+ state.runs = runs;
154
+ }
139
155
  let tasksChanged = false;
140
156
  if (state.view === "board" && state.sel && !state.session) {
141
- const t = await (await fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`)).json().catch(() => state.tasks);
142
- tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks);
143
- state.tasks = t;
157
+ const [t, g] = await Promise.all([
158
+ fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
159
+ fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
160
+ ]);
161
+ tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates);
162
+ state.tasks = t; state.gates = g;
144
163
  }
145
164
  let incChanged = false;
146
165
  if (state.view === "incidents" && !state.session) {
@@ -149,15 +168,19 @@ async function refresh() {
149
168
  incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
150
169
  state.allIncidents = inc;
151
170
  }
152
- if (!same || prsChanged || incChanged || tasksChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
171
+ if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
153
172
  }
154
- const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats"];
173
+ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats", "search"];
155
174
  // restore last view + project selection (persisted UI state)
156
175
  {
157
176
  const v = localStorage.getItem("swarm.view");
158
177
  if (VIEWS.includes(v)) state.view = v;
159
178
  const sel = localStorage.getItem("swarm.sel");
160
179
  if (sel) state.sel = sel;
180
+ // Deep links win over persisted state: ?view=board&project=<id>&session=<id>
181
+ const q = new URLSearchParams(location.search);
182
+ if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
183
+ if (q.has("project")) state.sel = q.get("project") || null;
161
184
  // Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
162
185
  for (const a of document.querySelectorAll("header a[data-view]")) a.classList.toggle("on", a.dataset.view === state.view);
163
186
  }
@@ -172,6 +195,7 @@ function render() {
172
195
  if (state.session) renderSession();
173
196
  else if (state.view === "spend") renderSpend();
174
197
  else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
198
+ else if (state.view === "search") renderSearch();
175
199
  else if (state.view === "timeline") renderTimeline();
176
200
  else if (state.view === "board") renderBoard();
177
201
  else if (state.view === "incidents") renderIncidentsView();
@@ -342,7 +366,7 @@ function renderPRs() {
342
366
 
343
367
  // ---------- board (coordination: claims, worktrees, incidents)
344
368
  function renderBoard() {
345
- const parts = [renderTasks(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
369
+ const parts = [renderTasks(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
346
370
  $("#main").innerHTML = parts.length
347
371
  ? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
348
372
  : `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
@@ -356,7 +380,7 @@ function incidentColumns(full) {
356
380
  { key: "project", label: "project", width: 104, get: (i) => projName(i.projectId), cell: (i) => esc(projName(i.projectId)) },
357
381
  { key: "session", label: "session", width: 150, get: (i) => sess(i.sessionId)?.title ?? i.sessionId ?? "", cell: (i) => (i.sessionId ? `<a href="#" data-s="${i.sessionId}">${esc(sess(i.sessionId)?.title ?? i.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
358
382
  { key: "rule", label: "rule", width: 150, get: (i) => i.rule, cell: (i) => `<span class="br">${esc(i.rule ?? "")}</span>` },
359
- { key: "action", label: "action", width: 80, get: (i) => i.action, cell: (i) => (i.action === "deny" ? '<span class="badge warn">Denied</span>' : '<span class="badge acc">Asked</span>') },
383
+ { key: "action", label: "action", width: 80, get: (i) => i.action, cell: (i) => (i.action === "deny" ? '<span class="badge warn">Denied</span>' : i.action === "orphaned" ? '<span class="badge warn">Orphaned</span>' : i.action === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge acc">Asked</span>') },
360
384
  { key: "command", label: "command", flex: true, get: (i) => i.command, cell: (i) => `<span class="now" title="${esc(i.reason ?? "")}">${esc(i.command ?? "")}</span>` },
361
385
  ...(full ? [
362
386
  { key: "reason", label: "reason", width: 260, get: (i) => i.reason ?? "", cell: (i) => `<span class="dim now" title="${esc(i.reason ?? "")}">${esc(i.reason ?? "")}</span>` },
@@ -364,7 +388,7 @@ function incidentColumns(full) {
364
388
  ] : []),
365
389
  ].filter((c) => !(c.key === "project" && state.sel) && !(c.key === "session" && !full));
366
390
  }
367
- const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" ? "waiting" : "idle"}"></span>`;
391
+ const incidentDot = (i) => `<span class="s ${i.acked ? "ended" : i.action === "deny" || i.action === "orphaned" || i.action === "failed" ? "waiting" : "idle"}"></span>`;
368
392
  const ackLink = (i) => (i.acked ? "" : `<a href="#" data-ack="${i.seq}" title="Mark as seen">Ack</a>`);
369
393
 
370
394
  function renderIncidents() {
@@ -383,7 +407,64 @@ function renderIncidents() {
383
407
  });
384
408
  }
385
409
 
410
+ // M4.6: rule dry-run — replay this project's history under chosen modes; nothing is recorded.
411
+ const RULE_IDS = ["pattern_kill", "shared_tree", "destructive_git", "protected_ports", "no_foreign_worktree", "claim_required_to_write"];
412
+ const dry = { modes: {}, report: null, busy: false };
413
+ async function openDryRun() {
414
+ if (!state.sel) return alert("Pick a project in the sidebar first — the dry-run replays one project's history.");
415
+ dry.modes = {}; dry.report = null;
416
+ await runDryRun();
417
+ }
418
+ async function runDryRun() {
419
+ dry.busy = true; renderDryRun();
420
+ const q = new URLSearchParams({ project: state.sel, ...dry.modes });
421
+ dry.report = await fetch(`/v1/rules/dryrun?${q}`).then((r) => r.json()).catch((e) => ({ ok: false, error: String(e) }));
422
+ dry.busy = false; renderDryRun();
423
+ }
424
+ function renderDryRun() {
425
+ const r = dry.report;
426
+ const sel = (id) => {
427
+ const cur = dry.modes[id] ?? r?.modes?.[id] ?? "ask";
428
+ return `<label class="dr-rule"><span class="br">${id}</span><select data-drmode="${id}">${["ask", "deny", "off"].map((m) => `<option value="${m}" ${m === cur ? "selected" : ""}>${m}</option>`).join("")}</select>${r ? `<span class="dim">ask <b>${r.byRule[id].ask}</b> · deny <b>${r.byRule[id].deny}</b></span>` : ""}</label>`;
429
+ };
430
+ const flaky = (r?.flaky ?? []).map((f) => `<div class="dr-flaky"><code>${esc(f.display)}</code><div class="dim" style="font-size:var(--fs-sm)">${esc(f.suggestion)} · ${f.sessions} session${f.sessions === 1 ? "" : "s"}</div></div>`).join("");
431
+ const hits = (r?.hits ?? []).slice(-40).reverse().map((h) => `<tr><td class="dim">${hhmm(h.ts)}</td><td><span class="br">${esc(h.rule)}</span></td><td>${h.action}</td><td><code>${esc(h.display)}</code></td><td class="dim">${h.completed ? "ran" : ""}</td></tr>`).join("");
432
+ $("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
433
+ <div class="pk-h">${ic("shield", 15)}<b>Rule dry-run</b><span class="dim" style="margin-left:8px">${esc(projName(state.sel))}</span><span class="grow"></span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
434
+ <div class="pk-b">
435
+ <p class="dim" style="font-size:var(--fs-sm)">Replays this project's recorded tool calls through the rules under the modes below — what <em>would</em> have been asked or denied. Nothing is recorded; change a mode and re-run to try a rule before switching it on in <code>.swarm.toml</code>.</p>
436
+ <div class="dr-rules">${RULE_IDS.map(sel).join("")}</div>
437
+ ${dry.busy ? '<p class="dim">replaying…</p>' : r?.error ? `<p class="dim">${esc(r.error)}</p>` : r ? `
438
+ <div class="date">${r.evaluated} of ${r.calls} calls evaluated · ${r.hits.length}${r.hits.length >= 200 ? "+" : ""} hits</div>
439
+ <h4>Flaky signals <span class="dim">rules that keep asking about something that is then allowed anyway</span></h4>
440
+ ${flaky || '<p class="dim" style="font-size:var(--fs-sm)">None — every rule that fired stuck.</p>'}
441
+ <h4>Would have fired <span class="dim">newest first, last 40</span></h4>
442
+ ${hits ? `<div style="overflow-x:auto"><table class="plain"><tbody>${hits}</tbody></table></div>` : '<p class="dim" style="font-size:var(--fs-sm)">Nothing — these modes are silent on this history.</p>'}` : ""}
443
+ </div>
444
+ <div class="pk-f"><span class="grow"></span><button id="drRun" ${dry.busy ? "disabled" : ""}>Re-run</button><button id="pkCancel">Close</button></div>
445
+ </div>`;
446
+ }
447
+
386
448
  // ---------- incidents view (M2.3): the denied-action feed, with ack
449
+ // M4.3: turn an incident into a .swarm.toml rule + a CLAUDE.md lesson, both copyable.
450
+ function codifyIncident(seq) {
451
+ const i = (state.allIncidents ?? []).find((x) => x.seq === Number(seq));
452
+ if (!i?.suggestion) return;
453
+ const sg = i.suggestion;
454
+ $("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
455
+ <div class="pk-h">${ic("shield", 15)}<b>Codify</b><span class="grow"></span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
456
+ <div class="pk-b">
457
+ <h3>${esc(sg.title)}</h3>
458
+ <div class="date">from a <span class="br">${esc(i.rule)}</span> incident${i.count > 1 ? ` \u00b7 seen ${i.count}\u00d7` : ""}</div>
459
+ ${sg.toml ? `<h4>.swarm.toml <a href="#" class="cbtn" data-copy-toml="${seq}">${ic("copy", 12)} copy</a></h4><pre class="snip" id="toml-${seq}">${esc(sg.toml)}</pre>` : ""}
460
+ <h4>CLAUDE.md lesson <a href="#" class="cbtn" data-copy-lesson="${seq}">${ic("copy", 12)} copy</a></h4>
461
+ <pre class="snip" id="lesson-${seq}">- ${esc(sg.lesson)}</pre>
462
+ ${sg.toml ? '<p class="dim" style="font-size:var(--fs-sm)">Merge the block into the repo\'s <code>.swarm.toml</code>; the daemon picks it up within ~30s.</p>' : '<p class="dim" style="font-size:var(--fs-sm)">No config rule fits this one \u2014 the lesson is the takeaway.</p>'}
463
+ </div>
464
+ <div class="pk-f"><span class="grow"></span><button id="pkCancel">Close</button></div>
465
+ </div>`;
466
+ }
467
+
387
468
  function renderIncidentsView() {
388
469
  const all = state.allIncidents;
389
470
  const rows = (all ?? []).filter((i) => !state.sel || i.projectId === state.sel);
@@ -394,14 +475,14 @@ function renderIncidentsView() {
394
475
  const rules = [...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([r, n]) => `<span class="br">${esc(r)}</span> <b>${n}</b>`).join(" · ");
395
476
  $("#main").innerHTML =
396
477
  `<h2>Incidents <span>${all === null ? "loading…" : `${open} open · ${rows.length} shown`} · every ask/deny the rules made${rules ? ` · ${rules}` : ""}</span></h2>` +
397
- `<div class="chips">${chip("open", "Open")}${chip("all", "All")}${open ? `<span class="chip" data-ackall="1" title="Mark every open incident${state.sel ? " in this project" : ""} as seen">Ack all <b>${open}</b></span>` : ""}</div>` +
478
+ `<div class="chips">${chip("open", "Open")}${chip("all", "All")}${open ? `<span class="chip" data-ackall="1" title="Mark every open incident${state.sel ? " in this project" : ""} as seen">Ack all <b>${open}</b></span>` : ""}${state.sel ? `<span class="chip" id="dryrun" title="Replay this project's history under different rule modes">${ic("shield", 12)} Dry-run rules</span>` : ""}</div>` +
398
479
  (rows.length
399
480
  ? dataTable({
400
481
  id: "incidents-feed",
401
482
  columns: incidentColumns(true),
402
483
  rows,
403
484
  leading: { width: 24, cell: incidentDot },
404
- trailing: { width: 44, cell: ackLink },
485
+ trailing: { width: 120, cell: (i) => `${i.suggestion ? `<a href="#" data-codify="${i.seq}" title="Turn this into a rule / lesson">${ic("shield", 12)} Codify</a> ` : ""}${ackLink(i)}` },
405
486
  rowAttrs: () => "",
406
487
  rerender: touch,
407
488
  })
@@ -457,11 +538,49 @@ function renderResources() {
457
538
  });
458
539
  }
459
540
 
541
+ // Gate chips: ✓ pass / ✗ fail / — never run, latest run on hover.
542
+ const gateChips = (gates) => gates.map((g) => {
543
+ const cls = g.verdict === "pass" ? "ok" : g.verdict === "fail" ? "warn" : "";
544
+ const mark = g.verdict === "pass" ? "✓" : g.verdict === "fail" ? "✗" : "—";
545
+ return `<span class="badge ${cls}" title="${esc(g.gate)}: ${g.runs} run${g.runs === 1 ? "" : "s"}, ${g.fails} fail${g.fails === 1 ? "" : "s"}">${esc(g.gate)} ${mark}</span>`;
546
+ }).join(" ") || '<span class="dim">—</span>';
547
+
548
+ // RECENT GATES: verification runs on this project (M2.2). Only with a project selected.
549
+ function renderGates() {
550
+ if (!state.sel || !state.gates) return "";
551
+ const runs = state.gates.runs ?? [];
552
+ const required = state.gates.required ?? [];
553
+ if (!runs.length && !required.length) return "";
554
+ const sess = (id) => state.sessions.find((s) => s.id === id);
555
+ const cols = [
556
+ { key: "ts", label: "when", width: 76, get: (r) => r.createdAt, cell: (r) => `<span class="dim" title="${esc(r.createdAt)}">${ago(r.createdAt)}</span>` },
557
+ { key: "task", label: "task", width: 110, get: (r) => r.task, cell: (r) => `<b>${esc(r.task)}</b>` },
558
+ { key: "gate", label: "gate", width: 120, get: (r) => r.gate, cell: (r) => `<span class="br">${esc(r.gate)}</span>` },
559
+ { key: "verdict", label: "verdict", width: 80, get: (r) => r.verdict, cell: (r) => (r.verdict === "pass" ? '<span class="badge ok">Pass</span>' : '<span class="badge warn">Fail</span>') },
560
+ { key: "rubric", label: "rubric", flex: true, get: (r) => r.rubric, cell: (r) => `<span class="now" title="${esc(r.rubric)}">${esc(r.rubric)}</span>` },
561
+ { key: "evidence", label: "evidence", width: 220, get: (r) => r.evidence ?? "", cell: (r) => (r.evidence ? `<span class="dim now" title="${esc(r.evidence)}">${esc(r.evidence)}</span>` : '<span class="dim">—</span>') },
562
+ { key: "session", label: "session", width: 140, get: (r) => sess(r.sessionId)?.title ?? "", cell: (r) => (r.sessionId ? `<a href="#" data-s="${r.sessionId}">${esc(sess(r.sessionId)?.title ?? r.sessionId.slice(0, 8))}</a>` : '<span class="dim">—</span>') },
563
+ ];
564
+ return `<h2 class="mt-sec">Recent gates <span>${runs.length} run${runs.length === 1 ? "" : "s"}${required.length ? ` · required: ${required.map(esc).join(", ")}` : ""} · latest run per gate decides</span></h2>` +
565
+ (runs.length
566
+ ? dataTable({
567
+ id: "gates",
568
+ columns: cols,
569
+ rows: runs.slice(0, 50),
570
+ leading: { width: 24, cell: (r) => `<span class="s ${r.verdict === "fail" ? "waiting" : "active"}"></span>` },
571
+ trailing: { width: 12, cell: () => "" },
572
+ rowAttrs: () => "",
573
+ rerender: touch,
574
+ })
575
+ : `<div class="empty">${PX.idle()}No gate runs yet. <code>swarm gate record &lt;task&gt; ${esc(required[0] ?? "review")} pass --rubric "…"</code></div>`);
576
+ }
577
+
460
578
  // TASKS: the project's backlog from `.swarm.toml [tasks] source` (M1.6). Only with a project selected.
461
579
  function renderTasks() {
462
580
  if (!state.sel || !state.tasks?.source) return "";
463
581
  const all = state.tasks.tasks ?? [];
464
582
  const ready = all.filter((t) => t.ready);
583
+ const hasGates = (state.tasks.required ?? []).length > 0 || all.some((t) => (t.gates ?? []).length);
465
584
  const rows = state.taskFilter === "ready" ? ready : state.taskFilter === "open" ? all.filter((t) => t.status !== "done") : all;
466
585
  const chip = (k, label, n) => `<span class="chip ${state.taskFilter === k ? "on" : ""}" data-task-filter="${k}">${label}${n != null ? ` <b>${n}</b>` : ""}</span>`;
467
586
  const st = (t) => t.claimedBy ? `<span class="badge ok">Held · ${esc(t.claimedBy)}</span>`
@@ -474,8 +593,10 @@ function renderTasks() {
474
593
  { key: "milestone", label: "milestone", width: 160, get: (t) => t.milestone ?? "", cell: (t) => `<span class="dim now">${esc((t.milestone ?? "").split(" — ")[0])}</span>` },
475
594
  { key: "depends", label: "depends", width: 130, get: (t) => t.depends.join(" "), cell: (t) => `<span class="br">${esc(t.depends.join(" ")) || "—"}</span>` },
476
595
  { key: "state", label: "state", width: 150, get: (t) => (t.claimedBy ? 0 : t.ready ? 1 : t.status === "active" ? 2 : t.status === "done" ? 4 : 3), cell: st },
596
+ ...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
477
597
  ];
478
- return `<h2 class="mt-sec">Tasks <span>${ready.length} ready · ${all.length} in ${esc(state.tasks.source)}</span></h2>` +
598
+ const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
599
+ return `<h2 class="mt-sec">Tasks <span>${ready.length} ready · ${all.length} in ${esc(srcLabel)}${state.tasks.error ? ` · <span class="badge warn" title="${esc(state.tasks.error)}">${ic("warning", 12)} ${esc(state.tasks.error)}</span>` : ""}</span></h2>` +
479
600
  `<div class="chips">${chip("ready", "Ready", ready.length)}${chip("open", "Open", all.filter((t) => t.status !== "done").length)}${chip("all", "All", all.length)}</div>` +
480
601
  (rows.length
481
602
  ? dataTable({
@@ -483,7 +604,7 @@ function renderTasks() {
483
604
  columns: cols,
484
605
  rows,
485
606
  leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
486
- trailing: { width: 70, cell: (t) => (t.ready ? `<a href="#" data-claim="${esc(t.id)}" title="Claim into a fresh worktree">Claim</a>` : "") },
607
+ trailing: { width: 120, cell: (t) => (t.ready ? `<a href="#" data-run="${esc(t.id)}" title="Claim and spawn claude -p in a worktree">${ic("play", 12)} Run</a> · <a href="#" data-claim="${esc(t.id)}" title="Claim into a fresh worktree">Claim</a>` : t.claimedBy ? `<a href="#" data-run="${esc(t.id)}" title="Spawn claude -p in the held worktree">${ic("play", 12)} Run</a>` : "") },
487
608
  rowAttrs: () => "",
488
609
  rerender: touch,
489
610
  })
@@ -611,9 +732,55 @@ function renderSpend() {
611
732
  <div class="cols mt-sec"><div><h2>By project · today <span>${usd(sumBy(filt(sp.byProjectToday), (x) => x.cost))}</span></h2>${tbl(filt(sp.byProjectToday), "project", projName)}
612
733
  <h2 class="mt-sec">By project · all time</h2>${tbl(filt(sp.byProjectAll), "project", projName)}</div>
613
734
  <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>
735
+ ${renderAttribution()}
614
736
  <p class="dim" style="margin-top:var(--gap-sec)">Costs use list prices (static table, refreshed from LiteLLM when online; override in <code>~/.swarm/pricing.json</code>). Cache reads are the bulk of "ctx". Sessions on a subscription plan still show what the tokens would cost at API rates.</p>`;
615
737
  }
616
738
 
739
+ // M4.2: cost attributed to tasks (via each claim's worktree) + a context re-processing signal.
740
+ // Only meaningful with a project selected.
741
+ function renderAttribution() {
742
+ const a = state.attribution;
743
+ if (!state.sel || !a) return "";
744
+ const parts = [];
745
+ if (a.byTask?.length) {
746
+ parts.push(`<h2 class="mt-sec">By task <span>${usd(sumBy(a.byTask, (t) => t.cost))} across ${a.byTask.length} task${a.byTask.length === 1 ? "" : "s"} · attributed by worktree</span></h2>` +
747
+ dataTable({
748
+ id: "spend-task",
749
+ columns: [
750
+ { key: "task", label: "task", width: 150, get: (t) => t.task, cell: (t) => `<b>${esc(t.task)}</b>` },
751
+ { key: "owner", label: "owner", width: 120, get: (t) => t.owner || "", cell: (t) => esc(t.owner || "—") },
752
+ { key: "cost", label: "cost", width: 88, num: true, get: (t) => t.cost, cell: (t) => usd(t.cost) },
753
+ { key: "output", label: "out", width: 84, num: true, get: (t) => t.output, cell: (t) => tok(t.output) },
754
+ { key: "sessions", label: "sessions", width: 84, num: true, get: (t) => t.sessions, cell: (t) => String(t.sessions) },
755
+ { key: "turns", label: "turns", width: 64, num: true, get: (t) => t.turns, cell: (t) => String(t.turns) },
756
+ { key: "worktree", label: "worktree", flex: true, get: (t) => t.worktree, cell: (t) => `<span class="now dim" title="${esc(t.worktree)}">${esc(short(t.worktree))}</span>` },
757
+ ],
758
+ rows: a.byTask,
759
+ leading: { width: 20, cell: () => "" },
760
+ trailing: { width: 8, cell: () => "" },
761
+ rerender: touch,
762
+ }));
763
+ }
764
+ if (a.contextBudget?.length) {
765
+ parts.push(`<h2 class="mt-sec">Context budget <span>sessions re-processing the most context · a high reuse % is a lot of re-reading</span></h2>` +
766
+ dataTable({
767
+ id: "spend-ctx",
768
+ columns: [
769
+ { key: "title", label: "session", flex: true, get: (r) => r.title ?? r.id, cell: (r) => `<a href="#" data-s="${r.id}">${esc(r.title ?? r.id.slice(0, 8))}</a>` },
770
+ { key: "reuse", label: "reuse", width: 90, num: true, get: (r) => r.reuse, cell: (r) => `<span class="${r.reuse > 0.9 ? "br" : "dim"}">${(r.reuse * 100).toFixed(0)}%</span>` },
771
+ { key: "cacheRead", label: "context re-read", width: 120, num: true, get: (r) => r.cacheRead, cell: (r) => tok(r.cacheRead) },
772
+ { key: "cost", label: "cost", width: 88, num: true, get: (r) => r.cost, cell: (r) => usd(r.cost) },
773
+ { key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
774
+ ],
775
+ rows: a.contextBudget,
776
+ leading: { width: 20, cell: () => "" },
777
+ trailing: { width: 8, cell: () => "" },
778
+ rerender: touch,
779
+ }));
780
+ }
781
+ return parts.join("");
782
+ }
783
+
617
784
  // ---------- stats
618
785
  // Heavier than the 5s snapshot, so it has its own endpoint: fetched when the view opens (per project
619
786
  // scope), then refreshed at most every 30s while the view stays open.
@@ -632,6 +799,34 @@ const big = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1
632
799
  const toolName = (t) => String(t).replace(/^mcp__([^_]+(?:_[^_]+)*)__/, "$1 · ").replace(/^plugin_/, "");
633
800
  const pct = (a, b) => (b ? `${((100 * a) / b).toFixed(0)}%` : "—");
634
801
  const dur = (ms) => (ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : ms < 86400e3 ? `${(ms / 3600e3).toFixed(1)}h` : `${(ms / 86400e3).toFixed(1)}d`);
802
+ // ---------- search view (M4.5): memory over Swarm's own data — handoffs, incidents, gates, what sessions said
803
+ const srch = { q: "", kind: "", hits: null, t: 0 };
804
+ function renderSearch() {
805
+ const chip = (k, label) => `<span class="chip ${srch.kind === k ? "on" : ""}" data-skind="${k}">${label}</span>`;
806
+ const mark = (s) => esc(s).replace(/\u0001/g, "<mark>").replace(/\u0002/g, "</mark>");
807
+ const link = (h) => h.kind === "session" ? `data-s="${esc(h.ref)}"` : h.sessionId ? `data-s="${esc(h.sessionId)}"` : "";
808
+ const hits = (srch.hits ?? []).map((h) => `<div class="hit"><div class="ht"><span class="badge">${esc(h.kind)}</span><b>${esc(h.title)}</b>${h.task ? `<span class="br">${esc(h.task)}</span>` : ""}<span class="grow"></span>${!state.sel ? `<span class="dim">${esc(projName(h.projectId))} · </span>` : ""}<span class="dim">${ago(h.ts)}</span>${link(h) ? `<a href="#" ${link(h)} title="Open the session">${ic("arrow-right", 12)}</a>` : ""}</div><div class="hs">${mark(h.snippet)}</div></div>`).join("");
809
+ const had = document.activeElement?.id === "srchQ" ? { pos: document.activeElement.selectionStart } : null;
810
+ $("#main").innerHTML =
811
+ `<h2>Search <span>Swarm's own memory${state.sel ? ` · ${esc(projName(state.sel))}` : " · all projects"} — handoffs, incidents, gates, what sessions said. Never your code.</span></h2>` +
812
+ `<div class="srch"><input id="srchQ" type="search" placeholder="pkill, login form, kind:incident git reset, task:M1.2 …" value="${esc(srch.q)}" autocomplete="off"></div>` +
813
+ `<div class="chips">${chip("", "All")}${chip("handoff", "Handoffs")}${chip("incident", "Incidents")}${chip("gate", "Gates")}${chip("session", "Sessions")}</div>` +
814
+ (srch.hits === null ? `<div class="empty">${PX.idle()}Type to search. Words are AND-ed, the last one is a prefix; quote a phrase; <code>kind:</code> and <code>task:</code> filter.</div>`
815
+ : hits || `<div class="empty">${PX.idle()}Nothing in memory matches <b>${esc(srch.q)}</b>.</div>`);
816
+ if (had) { const i = $("#srchQ"); i.focus(); i.setSelectionRange(had.pos, had.pos); }
817
+ }
818
+ async function runSearch() {
819
+ if (!srch.q.trim()) { srch.hits = null; return renderSearch(); }
820
+ const q = new URLSearchParams({ q: srch.q, limit: "50" });
821
+ if (state.sel) q.set("project", state.sel);
822
+ if (srch.kind) q.set("kind", srch.kind);
823
+ const mine = ++srch.t;
824
+ const j = await fetch(`/v1/memory?${q}`).then((r) => r.json()).catch(() => ({ hits: [] }));
825
+ if (mine !== srch.t) return;
826
+ srch.hits = j.hits ?? [];
827
+ if (state.view === "search" && !state.session) renderSearch();
828
+ }
829
+ document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
635
830
  function renderStats() {
636
831
  const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
637
832
  const scope = state.sel ? esc(projName(state.sel)) : "all projects";
@@ -799,6 +994,84 @@ function sessionStream() {
799
994
  }
800
995
  // True when `rows` only extends the rows already in #log (same session, same prefix) → append, don't rebuild.
801
996
  const isAppend = (rows) => logRendered && rows.length >= logRendered.length && logRendered.every((k, n) => rows[n].key === k);
997
+ // M4.1 session replay: step through a session's tool calls, one at a time, with full input/output
998
+ // (lazy-fetched from /v1/events/:seq). replayState holds the current step; nav by buttons or ←/→.
999
+ const replay = { steps: [], i: 0, cache: new Map() };
1000
+ // M4.4: resume where this died — the daemon builds the prompt from the handoff + tail; we just confirm.
1001
+ async function resumeDead() {
1002
+ const id = state.session; if (!id) return;
1003
+ const plan = await fetch(`/v1/sessions/${encodeURIComponent(id)}/resume`).then((r) => r.json());
1004
+ if (!plan.ok) return alert(plan.error);
1005
+ if (!confirm(`Resume ${plan.task}${plan.owner ? ` as ${plan.owner}` : ""}?\n\n${plan.prompt.slice(0, 900)}${plan.prompt.length > 900 ? "…" : ""}`)) return;
1006
+ const r = await fetch(`/v1/sessions/${encodeURIComponent(id)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then((x) => x.json());
1007
+ if (!r.ok) return alert(r.error);
1008
+ openSession(r.run.sessionId);
1009
+ }
1010
+ function openReplay() {
1011
+ replay.steps = state.log.filter((e) => e.type === "tool.requested").map((e) => ({ seq: e.seq, tool: e.payload?.tool ?? "tool", summary: e.payload?.summary ?? "" }));
1012
+ replay.i = 0;
1013
+ replay.cache.clear();
1014
+ if (!replay.steps.length) { alert("No tool calls in this session yet."); return; }
1015
+ renderReplay();
1016
+ }
1017
+ async function renderReplay() {
1018
+ const n = replay.steps.length;
1019
+ const step = replay.steps[replay.i];
1020
+ let detail = replay.cache.get(step.seq);
1021
+ if (!detail) {
1022
+ // the request event (full input) and the paired completed event (output), both by seq
1023
+ const req = await fetch(`/v1/events/${step.seq}`).then((r) => r.json()).catch(() => null);
1024
+ const done = state.log.find((e) => e.type === "tool.completed" && e.seq > step.seq && e.payload?.summary === step.summary);
1025
+ const res = done ? await fetch(`/v1/events/${done.seq}`).then((r) => r.json()).catch(() => null) : null;
1026
+ detail = { input: req?.payload?.toolInput ?? null, output: res?.payload?.toolResponse ?? null, ts: req?.ts };
1027
+ replay.cache.set(step.seq, detail);
1028
+ }
1029
+ const j = (v) => (v == null ? "" : typeof v === "string" ? v : JSON.stringify(v, null, 2));
1030
+ $("#picker").innerHTML = `<div class="pk wn rp" role="dialog" aria-modal="true">
1031
+ <div class="pk-h">${ic("play", 15)}<b>Replay</b><span class="dim" style="margin-left:8px">${step.tool}</span><span class="grow"></span><span class="dim" style="font-size:var(--fs-sm)">${replay.i + 1} / ${n}${detail.ts ? ` · ${hhmm(detail.ts)}` : ""}</span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
1032
+ <div class="pk-b">
1033
+ <div class="dim now" style="font-family:var(--mono);font-size:var(--fs-sm);margin-bottom:8px">${esc(step.summary)}</div>
1034
+ <h4>input</h4><pre class="snip">${esc(j(detail.input)) || '<span class="dim">—</span>'}</pre>
1035
+ <h4>output</h4><pre class="snip">${detail.output != null ? esc(j(detail.output)).slice(0, 4000) : '<span class="dim">(no result captured)</span>'}</pre>
1036
+ </div>
1037
+ <div class="pk-f"><button id="rpPrev" ${replay.i === 0 ? "disabled" : ""}>${ic("arrow-left", 12)} Prev</button><span class="grow"></span><input id="rpRange" type="range" min="0" max="${n - 1}" value="${replay.i}" style="flex:1;max-width:280px"><span class="grow"></span><button class="primary" id="rpNext" ${replay.i >= n - 1 ? "disabled" : ""}>Next ${ic("arrow-right", 12)}</button></div>
1038
+ </div>`;
1039
+ }
1040
+ function replayGo(delta) {
1041
+ const n = replay.steps.length;
1042
+ replay.i = Math.max(0, Math.min(n - 1, replay.i + delta));
1043
+ renderReplay();
1044
+ }
1045
+
1046
+ // Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
1047
+ function stdinBox(s) {
1048
+ if (s.kind !== "spawned") return "";
1049
+ const run = (state.runs ?? []).find((r) => r.sessionId === s.id);
1050
+ if (!run) return `<div class="stdin"><span class="hint">${ic("play", 12)} spawned by swarm run · no longer live</span></div>`;
1051
+ const perms = (run.pending ?? []).map((pp) => `<div class="perm"><div class="perm-t">${ic("warning", 13)} <b>${esc(pp.tool)}</b> needs approval<span class="dim now" title="${esc(pp.reason)}"> — ${esc(pp.reason)}</span></div><div class="perm-c">${esc(pp.display)}</div><div class="perm-b"><button class="ok" data-perm-allow="${esc(run.id)}:${esc(pp.requestId)}">Allow</button><button class="danger" data-perm-deny="${esc(run.id)}:${esc(pp.requestId)}">Deny</button></div></div>`).join("");
1052
+ return `${perms}<div class="stdin" id="stdin"><input id="stdinText" placeholder="Send a message to this run… (Enter)" autocomplete="off" spellcheck="false"><button id="stdinSend">${ic("arrow-right", 13)} Send</button><button class="danger" data-runstop="${esc(run.id)}">Stop</button><span class="hint">run ${esc(run.id)} · pid ${run.pid}${run.result ? ` · $${run.result.costUsd.toFixed(2)} so far` : ""}</span></div>`;
1053
+ }
1054
+ async function sendStdin() {
1055
+ const s = state.sessions.find((x) => x.id === state.session);
1056
+ const run = (state.runs ?? []).find((r) => r.sessionId === s?.id);
1057
+ const el = $("#stdinText"); const text = el?.value.trim();
1058
+ if (!run || !text) return;
1059
+ el.value = "";
1060
+ const r = await fetch(`/v1/runs/${encodeURIComponent(run.id)}/send`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });
1061
+ if (!r.ok) alert((await r.json()).error);
1062
+ refresh();
1063
+ }
1064
+ document.addEventListener("click", (ev) => {
1065
+ if (ev.target.closest("#stdinSend")) return sendStdin();
1066
+ const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
1067
+ const key = a?.dataset.permAllow || d?.dataset.permDeny;
1068
+ if (key) {
1069
+ const [runId, reqId] = key.split(":");
1070
+ return fetch(`/v1/runs/${encodeURIComponent(runId)}/permissions/${encodeURIComponent(reqId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ allow: Boolean(a) }) }).then(refresh);
1071
+ }
1072
+ });
1073
+ document.addEventListener("keydown", (ev) => { if (ev.key === "Enter" && ev.target.id === "stdinText") { ev.preventDefault(); sendStdin(); } });
1074
+
802
1075
  function renderSession() {
803
1076
  const s = state.sessions.find((x) => x.id === state.session);
804
1077
  if (!s) return;
@@ -812,7 +1085,7 @@ function renderSession() {
812
1085
  const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
813
1086
  const STAT_ICON = { cost: "coin", model: "robot", turns: "arrows-clockwise", "tool calls": "wrench", output: "chart-bar", context: "rows", started: "clock", "last seen": "eye", "subagent turns": "tree-structure" };
814
1087
  const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
815
- const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span></h2>`;
1088
+ const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("reload", 13)} Resume where it died</a>` : ""}</h2>`;
816
1089
  const side = `<div class="stats">
817
1090
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
818
1091
  ${stat("output", `${tok(t.output)}${t.thinking ? `<small> · ${tok(t.thinking)} thinking</small>` : ""}`)}${stat("context", `${tok(ctx)}<small> · ${ctx ? ((100 * t.cacheRead) / ctx).toFixed(0) : 0}% cached</small>`)}
@@ -828,9 +1101,13 @@ function renderSession() {
828
1101
  // scroll position (and its DOM) untouched.
829
1102
  $("#main > h2").outerHTML = head;
830
1103
  $("#main .side").innerHTML = side;
1104
+ const sb = stdinBox(s); const cur = $("#main .stdin");
1105
+ if (cur && cur.outerHTML !== sb && document.activeElement?.id !== "stdinText") cur.outerHTML = sb;
1106
+ else if (!cur && sb) $("#main").insertAdjacentHTML("beforeend", sb);
831
1107
  if (rows.length > logRendered.length) logEl.insertAdjacentHTML("beforeend", rows.slice(logRendered.length).map((r) => r.html).join(""));
832
1108
  } else {
833
- $("#main").innerHTML = `${head}<div class="sess"><div id="log">${rows.map((r) => r.html).join("")}</div><aside class="side">${side}</aside></div>`;
1109
+ const sb = stdinBox(s);
1110
+ $("#main").innerHTML = `${head}<div class="sess ${sb ? "has-stdin" : ""}"><div id="log">${rows.map((r) => r.html).join("")}</div><aside class="side">${side}</aside></div>${sb}`;
834
1111
  }
835
1112
  logRendered = rows.map((r) => r.key);
836
1113
  // Follow the tail when pinned to the bottom; otherwise keep the reading position —
@@ -883,12 +1160,80 @@ function menuSpec(kind, d) {
883
1160
  { label: "Refresh pricing", icon: "arrows-clockwise", caption: "LiteLLM", run: async () => { const r = await fetch("/v1/pricing/refresh", { method: "POST" }); if (!r.ok) console.warn("pricing refresh failed", r.status); refresh(); } },
884
1161
  { label: "Copy dashboard URL", icon: "copy", run: () => copy(location.origin) },
885
1162
  { divider: true },
1163
+ { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "permission prompts, orphans", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1164
+ { label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
886
1165
  { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
887
1166
  { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => window.open(feedbackUrl(), "_blank") },
888
1167
  ] };
889
1168
  }
890
1169
  return null;
891
1170
  }
1171
+ // M4.7 desktop notifications: native notifications (web Notification API — works in the browser and
1172
+ // the desktop app's webview) for the things you'd want to walk away and be pinged about — a spawned
1173
+ // run waiting on a permission, and a claim orphaned with unfinished work. Clicking opens the spot to
1174
+ // act. Off until enabled from the settings menu (which requests OS permission). Quiet while focused.
1175
+ const NOTIFY_KEY = "swarm.notify";
1176
+ const notifyOn = () => { try { return localStorage.getItem(NOTIFY_KEY) === "on"; } catch { return false; } };
1177
+ async function enableNotifications() {
1178
+ if (!("Notification" in window)) { alert("This browser doesn't support notifications."); return; }
1179
+ const perm = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
1180
+ if (perm !== "granted") { alert("Notifications were blocked. Allow them for this site in your browser/OS settings."); return; }
1181
+ try { localStorage.setItem(NOTIFY_KEY, "on"); } catch {}
1182
+ new Notification("Swarm notifications on", { body: "You'll be pinged when a run needs a permission or a claim is orphaned." });
1183
+ }
1184
+ function disableNotifications() { try { localStorage.setItem(NOTIFY_KEY, "off"); } catch {} }
1185
+ let lastNotifyAt = 0;
1186
+ function notifyForEvent(ev) {
1187
+ if (!notifyOn() || !("Notification" in window) || Notification.permission !== "granted") return;
1188
+ if (!document.hidden && ev.type !== "permission.requested") return; // only permission prompts interrupt while you're looking
1189
+ const now = Date.now();
1190
+ if (now - lastNotifyAt < 1500) return; // don't stack
1191
+ const p = ev.payload || {};
1192
+ let title, body, onClick;
1193
+ if (ev.type === "permission.requested") {
1194
+ title = `Permission needed: ${p.tool ?? "tool"}`;
1195
+ body = `${p.display ?? ""}
1196
+ ${p.reason ?? ""}`.slice(0, 180);
1197
+ onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1198
+ } else if (ev.type === "claim.orphaned") {
1199
+ title = "Claim orphaned";
1200
+ body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
1201
+ onClick = () => { state.view = "board"; state.sel = ev.projectId || state.sel; state.session = null; refresh(); };
1202
+ } else return;
1203
+ lastNotifyAt = now;
1204
+ const n = new Notification(title, { body, tag: `swarm-${ev.type}-${ev.sessionId ?? ev.seq}` });
1205
+ n.onclick = () => { window.focus(); onClick?.(); n.close(); };
1206
+ }
1207
+
1208
+ // What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
1209
+ // The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
1210
+ // itself once after an upgrade (localStorage remembers the last version the user saw).
1211
+ function releaseNotesFor(version) {
1212
+ const all = window.RELEASE_NOTES || {};
1213
+ if (version && all[version]) return { version, ...all[version] };
1214
+ const latest = Object.keys(all)[0];
1215
+ return latest ? { version: latest, ...all[latest] } : null;
1216
+ }
1217
+ function whatsNew(version) {
1218
+ const n = releaseNotesFor(version || state.version);
1219
+ if (!n) return;
1220
+ try { localStorage.setItem("swarm.seenVersion", n.version); } catch {}
1221
+ $("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
1222
+ <div class="pk-h">${ic("star", 15)}<b>What's New</b><span class="grow"></span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
1223
+ <div class="pk-b"><h3>Swarm ${esc(n.version)}</h3>${n.date ? `<div class="date">${esc(n.date)}</div>` : ""}${n.html}</div>
1224
+ <div class="pk-f"><span class="grow"></span><a href="https://getswarm.vercel.app/changelog" target="_blank" rel="noopener" style="align-self:center;color:var(--dim);font-size:var(--fs-sm)">Full changelog →</a><button id="pkCancel">Close</button></div>
1225
+ </div>`;
1226
+ }
1227
+ window.swarmWhatsNew = (v) => whatsNew(v);
1228
+ // auto-open once per version, but never on the very first run (nothing to compare against)
1229
+ function maybeWhatsNew() {
1230
+ if (!state.version || !window.RELEASE_NOTES) return;
1231
+ let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
1232
+ if (seen === state.version) return;
1233
+ if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
1234
+ if (releaseNotesFor(state.version)) whatsNew(state.version);
1235
+ }
1236
+
892
1237
  // Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
893
1238
  // nothing leaves the machine; clicking Star just opens the repo in a browser.
894
1239
  const STAR = { key: "swarm.star", firstAfterMs: 2 * 86_400_000, everyMs: 30 * 86_400_000 };
@@ -944,7 +1289,7 @@ document.addEventListener("contextmenu", (ev) => {
944
1289
 
945
1290
  // ---------- events
946
1291
  document.addEventListener("click", async (ev) => {
947
- const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop]");
1292
+ const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop]");
948
1293
  if (!t) return;
949
1294
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
950
1295
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
@@ -952,12 +1297,22 @@ document.addEventListener("click", async (ev) => {
952
1297
  if (t.dataset.view) { ev.preventDefault(); state.view = t.dataset.view; localStorage.setItem("swarm.view", state.view); state.session = null; state.dirty = true; return refresh(); }
953
1298
  if (t.dataset.tl) { ev.preventDefault(); state.tlHours = Number(t.dataset.tl); return touch(); }
954
1299
  if (t.dataset.taskFilter) { state.taskFilter = t.dataset.taskFilter; return touch(); }
1300
+ if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
1301
+ if (t.dataset.runstop) {
1302
+ ev.preventDefault();
1303
+ if (!confirm("Stop this run? Its stdin is closed, then the process is signalled by pid.")) return;
1304
+ return fetch(`/v1/runs/${encodeURIComponent(t.dataset.runstop)}`, { method: "DELETE" }).then(async (r) => { if (!r.ok) alert((await r.json()).error); return refresh(); });
1305
+ }
955
1306
  if (t.dataset.claim) {
956
1307
  ev.preventDefault();
957
1308
  const r = await fetch("/v1/claims", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, task: t.dataset.claim, owner: "dashboard" }) }).then((x) => x.json());
958
1309
  if (!r.ok) alert(r.error); else state.tasks = null;
959
1310
  return refresh();
960
1311
  }
1312
+ if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
1313
+ if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1314
+ if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
1315
+ if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
961
1316
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
962
1317
  if (t.dataset.ack) {
963
1318
  ev.preventDefault(); ev.stopPropagation();
@@ -1002,6 +1357,8 @@ document.addEventListener("click", async (ev) => {
1002
1357
  return fetch(`/v1/resources/${encodeURIComponent(t.dataset.resrelease)}?${q}`, { method: "DELETE" }).then(refresh);
1003
1358
  }
1004
1359
  if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
1360
+ if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
1361
+ if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
1005
1362
  if (t.dataset.s) { ev.preventDefault(); return openSession(t.dataset.s); }
1006
1363
  if (t.dataset.id !== undefined) { state.sel = t.dataset.id || null; localStorage.setItem("swarm.sel", state.sel ?? ""); state.session = null; state.tasks = null; state.dirty = true; return refresh(); }
1007
1364
  });
@@ -1035,6 +1392,45 @@ sbApply();
1035
1392
 
1036
1393
  // ---------- folder picker
1037
1394
  const picker = { path: null };
1395
+ // Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
1396
+ function openRunDrawer(taskId) {
1397
+ const task = (state.tasks?.tasks ?? []).find((t) => t.id === taskId);
1398
+ const title = task ? `${task.id} — ${task.title}` : taskId;
1399
+ const prompt = task
1400
+ ? `Task ${task.id}: ${task.title}\n\nWork only inside this worktree. When done: commit, push, then call swarm_handoff with what was done and what remains, and record the required gates with swarm_gate_record.`
1401
+ : "";
1402
+ const last = (() => { try { return JSON.parse(localStorage.getItem("swarm.runOpts") || "{}"); } catch { return {}; } })();
1403
+ const opt = (v, cur) => `<option value="${v}" ${v === cur ? "selected" : ""}>${v || "default"}</option>`;
1404
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1405
+ <div class="pk-h">${ic("play", 15)}<b>Run</b><span class="dim now" style="flex:1;margin-left:8px">${esc(title)}</span></div>
1406
+ <div class="pk-b">
1407
+ <label>prompt<textarea id="rnPrompt" spellcheck="false">${esc(prompt)}</textarea></label>
1408
+ <div class="row">
1409
+ <label>permission mode<select id="rnMode">${["acceptEdits", "auto", "plan", "dontAsk", "manual", "bypassPermissions"].map((m) => opt(m, last.mode ?? "acceptEdits")).join("")}</select></label>
1410
+ <label>model<input id="rnModel" placeholder="default" value="${esc(last.model ?? "")}"></label>
1411
+ <label>max turns<input id="rnTurns" type="number" min="1" placeholder="∞" value="${esc(last.turns ?? "")}"></label>
1412
+ </div>
1413
+ <div class="dim" style="font-size:var(--fs-sm)">Claims <b>${esc(taskId)}</b> (or reuses your held worktree) and spawns <code>claude -p</code> there. The session appears in Fleet; steer it from its page.</div>
1414
+ </div>
1415
+ <div class="pk-f"><span class="grow"></span><button id="rnCancel">Cancel</button><button class="primary" id="rnGo" data-task="${esc(taskId)}">${ic("play", 13)} Run</button></div>
1416
+ </div>`;
1417
+ $("#rnPrompt")?.focus();
1418
+ }
1419
+ async function submitRun(taskId) {
1420
+ const prompt = $("#rnPrompt")?.value.trim();
1421
+ if (!prompt) return alert("A prompt is required.");
1422
+ const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value;
1423
+ localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns }));
1424
+ closePicker();
1425
+ const r = await fetch("/v1/runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({
1426
+ projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined,
1427
+ }) }).then((x) => x.json());
1428
+ if (!r.ok) return alert(r.error);
1429
+ state.tasks = null;
1430
+ await refresh();
1431
+ openSession(r.run.sessionId);
1432
+ }
1433
+
1038
1434
  async function openPicker(focusPath = false) {
1039
1435
  await pickerGo("");
1040
1436
  if (focusPath) { const i = $("#pkPath"); if (i) { i.focus(); i.select(); } }
@@ -1063,10 +1459,21 @@ $("#picker").addEventListener("click", (ev) => {
1063
1459
  if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
1064
1460
  const go = ev.target.closest("[data-go]");
1065
1461
  if (go) return void pickerGo(go.dataset.go);
1462
+ const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
1463
+ if (ctoml) { ev.preventDefault(); copy($(`#toml-${ctoml.dataset.copyToml}`)?.textContent); ctoml.lastChild.textContent = " copied"; return; }
1464
+ if (cles) { ev.preventDefault(); copy($(`#lesson-${cles.dataset.copyLesson}`)?.textContent); cles.lastChild.textContent = " copied"; return; }
1465
+ if (ev.target.closest("#rpPrev")) return replayGo(-1);
1466
+ if (ev.target.closest("#rpNext")) return replayGo(1);
1467
+ if (ev.target.closest("#rnCancel")) return closePicker();
1468
+ const rnGo = ev.target.closest("#rnGo"); if (rnGo) return submitRun(rnGo.dataset.task);
1066
1469
  if (ev.target.closest("#pkAdd")) { const p = $("#pkPath")?.value.trim() || picker.path; closePicker(); addProject(p); }
1067
1470
  });
1471
+ $("#picker").addEventListener("input", (ev) => { if (ev.target.id === "rpRange") { replay.i = Number(ev.target.value); renderReplay(); } });
1472
+ $("#picker").addEventListener("change", (ev) => { if (ev.target.dataset?.drmode) { dry.modes[ev.target.dataset.drmode] = ev.target.value; } });
1068
1473
  $("#picker").addEventListener("keydown", (ev) => {
1069
1474
  if (ev.key === "Enter" && ev.target.id === "pkPath") { ev.preventDefault(); pickerGo(ev.target.value.trim()); }
1475
+ if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey) && ev.target.id === "rnPrompt") { ev.preventDefault(); submitRun($("#rnGo")?.dataset.task); }
1476
+ if ((ev.key === "ArrowRight" || ev.key === "ArrowLeft") && $(".rp")) { ev.preventDefault(); replayGo(ev.key === "ArrowRight" ? 1 : -1); }
1070
1477
  });
1071
1478
  document.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && $("#picker").innerHTML) closePicker(); });
1072
1479
 
@@ -1094,10 +1501,15 @@ function connect() {
1094
1501
  if (state.log.length > LOG_CAP) state.log.shift();
1095
1502
  schedule();
1096
1503
  }
1504
+ if (fresh) notifyForEvent(ev);
1097
1505
  pollSoon();
1098
1506
  };
1099
- for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited"]) es.addEventListener(t, onAny);
1507
+ for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
1100
1508
  }
1101
- refresh().then(connect);
1509
+ refresh().then(() => {
1510
+ const sid = new URLSearchParams(location.search).get("session");
1511
+ if (sid) openSession(sid);
1512
+ connect();
1513
+ });
1102
1514
  setInterval(() => { if (!document.hidden) poll(); }, 5000);
1103
1515
  document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });