@ra3orblade/swarm 0.5.0 → 0.7.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, gates: null, runs: [], 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, dispatch: null, questions: [], budget: 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,13 +129,25 @@ 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, bd] = await Promise.all([
142
+ fetch(`/v1/attribution?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.attribution),
143
+ fetch(`/v1/budget?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.budget),
144
+ ]);
145
+ attrChanged = JSON.stringify(a) !== JSON.stringify(state.attribution) || JSON.stringify(bd) !== JSON.stringify(state.budget);
146
+ state.attribution = a; state.budget = bd;
147
+ } else if (state.view === "spend" && !state.sel) {
148
+ if (state.attribution) attrChanged = true;
149
+ state.attribution = null;
150
+ }
139
151
  let runsChanged = false;
140
152
  const openSpawned = state.session && state.sessions.find((x) => x.id === state.session)?.kind === "spawned";
141
153
  if (openSpawned || (state.view === "board" && !state.session) || (state.view === "fleet" && !state.session)) {
@@ -145,12 +157,13 @@ async function refresh() {
145
157
  }
146
158
  let tasksChanged = false;
147
159
  if (state.view === "board" && state.sel && !state.session) {
148
- const [t, g] = await Promise.all([
160
+ const [t, g, d] = await Promise.all([
149
161
  fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
150
162
  fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
163
+ fetch(`/v1/dispatch?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.dispatch),
151
164
  ]);
152
- tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates);
153
- state.tasks = t; state.gates = g;
165
+ tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch);
166
+ state.tasks = t; state.gates = g; state.dispatch = d;
154
167
  }
155
168
  let incChanged = false;
156
169
  if (state.view === "incidents" && !state.session) {
@@ -159,9 +172,9 @@ async function refresh() {
159
172
  incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
160
173
  state.allIncidents = inc;
161
174
  }
162
- if (!same || prsChanged || incChanged || tasksChanged || runsChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
175
+ if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
163
176
  }
164
- const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats"];
177
+ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats", "search"];
165
178
  // restore last view + project selection (persisted UI state)
166
179
  {
167
180
  const v = localStorage.getItem("swarm.view");
@@ -186,6 +199,7 @@ function render() {
186
199
  if (state.session) renderSession();
187
200
  else if (state.view === "spend") renderSpend();
188
201
  else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
202
+ else if (state.view === "search") renderSearch();
189
203
  else if (state.view === "timeline") renderTimeline();
190
204
  else if (state.view === "board") renderBoard();
191
205
  else if (state.view === "incidents") renderIncidentsView();
@@ -278,8 +292,8 @@ projectsEl.addEventListener("dragend", () => {
278
292
  // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
279
293
  const FLEET_COLS = [
280
294
  { key: "project", label: "project", width: 104, get: (s) => projName(s.projectId), cell: (s) => esc(projName(s.projectId)) },
281
- { key: "agent", label: "agent", width: 76, get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
282
- { key: "session", label: "session", width: 236, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}` },
295
+ { key: "agent", label: "agent", width: 84, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
296
+ { key: "session", label: "session", width: 236, get: (s) => s.title ?? s.id, cell: (s) => `${kindIcon(s)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b>${s.subagents ? ` <span class="badge acc">${s.subagents} Sub</span>` : ""}${(state.questions ?? []).some((q) => q.sessionId === s.id) ? ' <span class="badge warn" title="This agent asked a question only a human can answer — open the session">Asking</span>' : ""}` },
283
297
  { key: "branch", label: "branch", width: 134, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
284
298
  { key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => `<span class="now" title="${esc(s.last)}">${esc(s.state === "waiting" ? (s.lastText ? s.lastText.split("\n")[0] : s.last) : s.last)}</span>` },
285
299
  { key: "model", label: "model", width: 96, get: (s) => model(s.model), cell: (s) => `<span class="br">${esc(model(s.model))}${s.models > 1 ? ` <span class="faint">+${s.models - 1}</span>` : ""}</span>` },
@@ -356,7 +370,7 @@ function renderPRs() {
356
370
 
357
371
  // ---------- board (coordination: claims, worktrees, incidents)
358
372
  function renderBoard() {
359
- const parts = [renderTasks(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
373
+ const parts = [renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
360
374
  $("#main").innerHTML = parts.length
361
375
  ? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
362
376
  : `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
@@ -397,7 +411,64 @@ function renderIncidents() {
397
411
  });
398
412
  }
399
413
 
414
+ // M4.6: rule dry-run — replay this project's history under chosen modes; nothing is recorded.
415
+ const RULE_IDS = ["pattern_kill", "shared_tree", "destructive_git", "protected_ports", "no_foreign_worktree", "claim_required_to_write"];
416
+ const dry = { modes: {}, report: null, busy: false };
417
+ async function openDryRun() {
418
+ if (!state.sel) return alert("Pick a project in the sidebar first — the dry-run replays one project's history.");
419
+ dry.modes = {}; dry.report = null;
420
+ await runDryRun();
421
+ }
422
+ async function runDryRun() {
423
+ dry.busy = true; renderDryRun();
424
+ const q = new URLSearchParams({ project: state.sel, ...dry.modes });
425
+ dry.report = await fetch(`/v1/rules/dryrun?${q}`).then((r) => r.json()).catch((e) => ({ ok: false, error: String(e) }));
426
+ dry.busy = false; renderDryRun();
427
+ }
428
+ function renderDryRun() {
429
+ const r = dry.report;
430
+ const sel = (id) => {
431
+ const cur = dry.modes[id] ?? r?.modes?.[id] ?? "ask";
432
+ 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>`;
433
+ };
434
+ 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("");
435
+ 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("");
436
+ $("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
437
+ <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>
438
+ <div class="pk-b">
439
+ <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>
440
+ <div class="dr-rules">${RULE_IDS.map(sel).join("")}</div>
441
+ ${dry.busy ? '<p class="dim">replaying…</p>' : r?.error ? `<p class="dim">${esc(r.error)}</p>` : r ? `
442
+ <div class="date">${r.evaluated} of ${r.calls} calls evaluated · ${r.hits.length}${r.hits.length >= 200 ? "+" : ""} hits</div>
443
+ <h4>Flaky signals <span class="dim">rules that keep asking about something that is then allowed anyway</span></h4>
444
+ ${flaky || '<p class="dim" style="font-size:var(--fs-sm)">None — every rule that fired stuck.</p>'}
445
+ <h4>Would have fired <span class="dim">newest first, last 40</span></h4>
446
+ ${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>'}` : ""}
447
+ </div>
448
+ <div class="pk-f"><span class="grow"></span><button id="drRun" ${dry.busy ? "disabled" : ""}>Re-run</button><button id="pkCancel">Close</button></div>
449
+ </div>`;
450
+ }
451
+
400
452
  // ---------- incidents view (M2.3): the denied-action feed, with ack
453
+ // M4.3: turn an incident into a .swarm.toml rule + a CLAUDE.md lesson, both copyable.
454
+ function codifyIncident(seq) {
455
+ const i = (state.allIncidents ?? []).find((x) => x.seq === Number(seq));
456
+ if (!i?.suggestion) return;
457
+ const sg = i.suggestion;
458
+ $("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
459
+ <div class="pk-h">${ic("shield", 15)}<b>Codify</b><span class="grow"></span><button id="pkCancel" title="Close">${ic("x", 14)}</button></div>
460
+ <div class="pk-b">
461
+ <h3>${esc(sg.title)}</h3>
462
+ <div class="date">from a <span class="br">${esc(i.rule)}</span> incident${i.count > 1 ? ` \u00b7 seen ${i.count}\u00d7` : ""}</div>
463
+ ${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>` : ""}
464
+ <h4>CLAUDE.md lesson <a href="#" class="cbtn" data-copy-lesson="${seq}">${ic("copy", 12)} copy</a></h4>
465
+ <pre class="snip" id="lesson-${seq}">- ${esc(sg.lesson)}</pre>
466
+ ${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>'}
467
+ </div>
468
+ <div class="pk-f"><span class="grow"></span><button id="pkCancel">Close</button></div>
469
+ </div>`;
470
+ }
471
+
401
472
  function renderIncidentsView() {
402
473
  const all = state.allIncidents;
403
474
  const rows = (all ?? []).filter((i) => !state.sel || i.projectId === state.sel);
@@ -408,14 +479,14 @@ function renderIncidentsView() {
408
479
  const rules = [...byRule.entries()].sort((a, b) => b[1] - a[1]).map(([r, n]) => `<span class="br">${esc(r)}</span> <b>${n}</b>`).join(" · ");
409
480
  $("#main").innerHTML =
410
481
  `<h2>Incidents <span>${all === null ? "loading…" : `${open} open · ${rows.length} shown`} · every ask/deny the rules made${rules ? ` · ${rules}` : ""}</span></h2>` +
411
- `<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>` +
482
+ `<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>` +
412
483
  (rows.length
413
484
  ? dataTable({
414
485
  id: "incidents-feed",
415
486
  columns: incidentColumns(true),
416
487
  rows,
417
488
  leading: { width: 24, cell: incidentDot },
418
- trailing: { width: 44, cell: ackLink },
489
+ 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)}` },
419
490
  rowAttrs: () => "",
420
491
  rerender: touch,
421
492
  })
@@ -528,15 +599,16 @@ function renderTasks() {
528
599
  { 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 },
529
600
  ...(hasGates ? [{ key: "gates", label: "gates", width: 170, get: (t) => (t.gates ?? []).filter((g) => g.verdict === "pass").length, cell: (t) => gateChips(t.gates ?? []) }] : []),
530
601
  ];
531
- return `<h2 class="mt-sec">Tasks <span>${ready.length} ready · ${all.length} in ${esc(state.tasks.source)}</span></h2>` +
532
- `<div class="chips">${chip("ready", "Ready", ready.length)}${chip("open", "Open", all.filter((t) => t.status !== "done").length)}${chip("all", "All", all.length)}</div>` +
602
+ const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
603
+ 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>` +
604
+ `<div class="chips">${chip("ready", "Ready", ready.length)}${chip("open", "Open", all.filter((t) => t.status !== "done").length)}${chip("all", "All", all.length)}${ready.length ? `<span class="chip" id="dispatch" title="Claim a worktree per ready task and spawn a run in each, ${state.dispatch?.config?.max_parallel ?? 2} at a time">${ic("play", 12)} Dispatch</span>` : ""}</div>` +
533
605
  (rows.length
534
606
  ? dataTable({
535
607
  id: "tasks",
536
608
  columns: cols,
537
609
  rows,
538
610
  leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
539
- 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>` : "") },
611
+ trailing: { width: 170, 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>${(state.gates?.executable ?? []).length ? ` · <a href="#" data-gaterun="${esc(t.id)}" title="Execute the repo's [gates.<name>] cmd gates in this task's worktree: ${esc((state.gates.executable ?? []).join(", "))}">${ic("check", 12)} Gates</a>` : ""}` : "") },
540
612
  rowAttrs: () => "",
541
613
  rerender: touch,
542
614
  })
@@ -593,19 +665,71 @@ function renderWorktrees() {
593
665
  { key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
594
666
  { key: "path", label: "path", flex: true, get: (w) => w.path, cell: (w) => `<span class="now" title="${esc(w.path)}">${esc(short(w.path))}</span>` },
595
667
  { key: "state", label: "state", width: 170, get: (w) => w.dirty * 1000 + w.ahead, cell: (w) => `${badge(w.dirty, "Dirty", "warn")}${badge(w.ahead, "Unpushed", "acc")}${w.dirty === 0 && w.ahead <= 0 ? '<span class="badge">Clean</span>' : ""}` },
668
+ { key: "drift", label: "drift", width: 120, get: (w) => (w.main ? -1 : w.behind), cell: (w) => (w.main ? "" : w.merged ? '<span class="badge" title="This branch is already in the main checkout\'s branch">Merged</span>' : w.behind > 0 ? `<span class="badge warn" title="Commits on the main checkout\'s branch this worktree lacks">${w.behind} behind</span>` : w.behind === 0 ? '<span class="badge">Up to date</span>' : '<span class="dim">—</span>') },
596
669
  { key: "sessions", label: "sessions", width: 160, get: (w) => inside(w).length, cell: (w) => inside(w).map((x) => `<a href="#" data-s="${x.id}">${esc(x.title ?? x.id.slice(0, 8))}</a>`).join(", ") || '<span class="dim">—</span>' },
597
670
  ].filter((c) => !(c.key === "project" && state.sel));
598
- return `<h2 class="mt-sec">Worktrees <span>${rows.length}</span></h2>` +
671
+ const heldBy = new Map(state.claims ? state.claims.filter((c) => c.state === "held").map((c) => [c.worktree, c.task]) : []);
672
+ const actions = (w) => {
673
+ const key = `${w.projectId}:${w.path}`;
674
+ const open = `<a href="#" data-wtopen="${esc(key)}" title="Open this worktree (editor / file manager; [worktree] open in .swarm.toml)">${ic("arrow-square-out", 12)} Open</a>`;
675
+ if (w.main) return open;
676
+ const diff = ` · <a href="#" data-wtdiff="${esc(key)}" title="What this worktree changed vs the main checkout's branch">${ic("folders", 12)} Diff</a>`;
677
+ const pr = w.branch && !w.merged ? ` · <a href="#" data-wtpr="${esc(key)}" title="Push the branch and open a PR prefilled from the task, handoff, gates and files">${ic("git-pull-request", 12)} PR</a>` : "";
678
+ if (heldBy.has(w.path)) return `${open}${diff}${pr}`;
679
+ return `${open}${diff}${pr} · <a href="#" data-wtrm="${esc(key)}" title="${w.dirty > 0 || w.ahead > 0 ? "Refuses while dirty / unpushed (you can force)" : "git worktree remove"}">${ic("trash", 12)} Remove</a>`;
680
+ };
681
+ const gcBtn = state.sel ? ` <a href="#" class="nav" id="wtgc" title="Find worktrees whose branch is merged or whose claim is gone">${ic("trash", 12)} Collect stale</a>` : "";
682
+ const newBtn = state.sel ? ` <a href="#" class="nav" id="wtnew" title="Create a task-less worktree (spike, review checkout)">${ic("plus", 12)} New worktree</a>` : "";
683
+ return `<h2 class="mt-sec hrow">Worktrees <span>${rows.length}</span>${newBtn}${gcBtn}</h2>` +
599
684
  dataTable({
600
685
  id: "worktrees",
601
686
  columns: cols,
602
687
  rows,
603
688
  leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
604
- trailing: { width: 34, cell: () => "" },
689
+ trailing: { width: 230, cell: actions },
690
+ rerender: touch,
691
+ });
692
+ }
693
+
694
+ // ---------- dispatch (M7.5)
695
+ function renderDispatch() {
696
+ const d = state.dispatch;
697
+ if (!state.sel || !d?.entries?.length) return "";
698
+ const rows = d.entries;
699
+ const oc = (e) => e.state === "queued" ? '<span class="badge">Queued</span>'
700
+ : e.state === "running" ? '<span class="badge acc">Running</span>'
701
+ : e.outcome === "done" ? '<span class="badge ok">Done</span>'
702
+ : e.outcome === "stopped" ? '<span class="badge">Stopped</span>'
703
+ : `<span class="badge warn">${esc(e.outcome ?? "?")}</span>`;
704
+ const cols = [
705
+ { key: "task", label: "task", width: 90, get: (e) => e.task, cell: (e) => `<b>${esc(e.task)}</b>` },
706
+ { key: "title", label: "title", flex: true, get: (e) => e.title, cell: (e) => esc(e.title) },
707
+ { key: "state", label: "state", width: 110, get: (e) => (e.state === "running" ? 0 : e.state === "queued" ? 1 : 2), cell: oc },
708
+ { key: "cost", label: "cost", width: 70, num: true, get: (e) => e.costUsd ?? -1, cell: (e) => (e.costUsd != null ? usd(e.costUsd) : '<span class="dim">—</span>') },
709
+ { key: "detail", label: "detail", width: 360, get: (e) => e.detail ?? "", cell: (e) => `<span class="dim" title="${esc(e.detail ?? "")}">${esc(e.detail ?? "")}</span>` },
710
+ ];
711
+ const running = rows.filter((e) => e.state === "running").length, queued = rows.filter((e) => e.state === "queued").length;
712
+ return `<h2 class="mt-sec hrow">Dispatch <span>${running} running · ${queued} queued · cap ${d.config?.max_parallel ?? 2}</span><a href="#" class="nav" id="dispatchClear" title="Drop queued tasks and clear finished rows (running ones keep going)">${ic("trash", 12)} Clear</a></h2>` +
713
+ dataTable({
714
+ id: "dispatch",
715
+ columns: cols,
716
+ rows,
717
+ leading: { width: 24, cell: (e) => `<span class="s ${e.state === "running" ? "active" : e.state === "queued" ? "waiting" : e.outcome === "done" ? "ended" : "waiting"}"></span>` },
718
+ trailing: { width: 90, cell: (e) => (e.sessionId ? `<a href="#" data-s="${esc(e.sessionId)}">session</a>` : "") },
605
719
  rerender: touch,
606
720
  });
607
721
  }
608
722
 
723
+ // 0.7.0: the project's [budget] ceiling against what it spent
724
+ function budgetKpi(kpi) {
725
+ const b = state.sel ? state.budget : null;
726
+ if (!b?.status) return state.sel ? kpi("budget", "—", "no [budget] in .swarm.toml") : "";
727
+ const s = b.status;
728
+ const pct = Math.round(s.pct * 100);
729
+ const cls = s.level === "exceeded" ? "warn" : s.level === "warn" ? "acc" : "";
730
+ return kpi(`${s.kind} budget`, `<span class="${cls}">${pct}%</span>`, `${usd(s.spent)} of ${usd(s.limit)} · past it: ${b.config.on_exceed}`);
731
+ }
732
+
609
733
  // ---------- spend
610
734
  function renderSpend() {
611
735
  const sp = state.spend;
@@ -655,7 +779,7 @@ function renderSpend() {
655
779
  const hm = sp.hourly.filter(inSel).map((c) => ({ dow: c.dow, hour: c.hour, v: c.cost ?? 0 }));
656
780
  $("#main").innerHTML =
657
781
  `<h2>Spend <span>${state.sel ? esc(projName(state.sel)) : "all projects"}</span>${rangeChips}</h2>
658
- <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(" · ") || "—")}</div>
782
+ <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>
659
783
  <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>
660
784
  <div class="cols">
661
785
  <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>
@@ -664,9 +788,55 @@ function renderSpend() {
664
788
  <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)}
665
789
  <h2 class="mt-sec">By project · all time</h2>${tbl(filt(sp.byProjectAll), "project", projName)}</div>
666
790
  <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>
791
+ ${renderAttribution()}
667
792
  <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>`;
668
793
  }
669
794
 
795
+ // M4.2: cost attributed to tasks (via each claim's worktree) + a context re-processing signal.
796
+ // Only meaningful with a project selected.
797
+ function renderAttribution() {
798
+ const a = state.attribution;
799
+ if (!state.sel || !a) return "";
800
+ const parts = [];
801
+ if (a.byTask?.length) {
802
+ 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>` +
803
+ dataTable({
804
+ id: "spend-task",
805
+ columns: [
806
+ { key: "task", label: "task", width: 150, get: (t) => t.task, cell: (t) => `<b>${esc(t.task)}</b>` },
807
+ { key: "owner", label: "owner", width: 120, get: (t) => t.owner || "", cell: (t) => esc(t.owner || "—") },
808
+ { key: "cost", label: "cost", width: 88, num: true, get: (t) => t.cost, cell: (t) => usd(t.cost) },
809
+ { key: "output", label: "out", width: 84, num: true, get: (t) => t.output, cell: (t) => tok(t.output) },
810
+ { key: "sessions", label: "sessions", width: 84, num: true, get: (t) => t.sessions, cell: (t) => String(t.sessions) },
811
+ { key: "turns", label: "turns", width: 64, num: true, get: (t) => t.turns, cell: (t) => String(t.turns) },
812
+ { 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>` },
813
+ ],
814
+ rows: a.byTask,
815
+ leading: { width: 20, cell: () => "" },
816
+ trailing: { width: 8, cell: () => "" },
817
+ rerender: touch,
818
+ }));
819
+ }
820
+ if (a.contextBudget?.length) {
821
+ 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>` +
822
+ dataTable({
823
+ id: "spend-ctx",
824
+ columns: [
825
+ { 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>` },
826
+ { 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>` },
827
+ { key: "cacheRead", label: "context re-read", width: 120, num: true, get: (r) => r.cacheRead, cell: (r) => tok(r.cacheRead) },
828
+ { key: "cost", label: "cost", width: 88, num: true, get: (r) => r.cost, cell: (r) => usd(r.cost) },
829
+ { key: "turns", label: "turns", width: 64, num: true, get: (r) => r.turns, cell: (r) => String(r.turns) },
830
+ ],
831
+ rows: a.contextBudget,
832
+ leading: { width: 20, cell: () => "" },
833
+ trailing: { width: 8, cell: () => "" },
834
+ rerender: touch,
835
+ }));
836
+ }
837
+ return parts.join("");
838
+ }
839
+
670
840
  // ---------- stats
671
841
  // Heavier than the 5s snapshot, so it has its own endpoint: fetched when the view opens (per project
672
842
  // scope), then refreshed at most every 30s while the view stays open.
@@ -685,6 +855,34 @@ const big = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1
685
855
  const toolName = (t) => String(t).replace(/^mcp__([^_]+(?:_[^_]+)*)__/, "$1 · ").replace(/^plugin_/, "");
686
856
  const pct = (a, b) => (b ? `${((100 * a) / b).toFixed(0)}%` : "—");
687
857
  const dur = (ms) => (ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : ms < 86400e3 ? `${(ms / 3600e3).toFixed(1)}h` : `${(ms / 86400e3).toFixed(1)}d`);
858
+ // ---------- search view (M4.5): memory over Swarm's own data — handoffs, incidents, gates, what sessions said
859
+ const srch = { q: "", kind: "", hits: null, t: 0 };
860
+ function renderSearch() {
861
+ const chip = (k, label) => `<span class="chip ${srch.kind === k ? "on" : ""}" data-skind="${k}">${label}</span>`;
862
+ const mark = (s) => esc(s).replace(/\u0001/g, "<mark>").replace(/\u0002/g, "</mark>");
863
+ const link = (h) => h.kind === "session" ? `data-s="${esc(h.ref)}"` : h.sessionId ? `data-s="${esc(h.sessionId)}"` : "";
864
+ 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("");
865
+ const had = document.activeElement?.id === "srchQ" ? { pos: document.activeElement.selectionStart } : null;
866
+ $("#main").innerHTML =
867
+ `<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>` +
868
+ `<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>` +
869
+ `<div class="chips">${chip("", "All")}${chip("handoff", "Handoffs")}${chip("incident", "Incidents")}${chip("gate", "Gates")}${chip("session", "Sessions")}</div>` +
870
+ (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>`
871
+ : hits || `<div class="empty">${PX.idle()}Nothing in memory matches <b>${esc(srch.q)}</b>.</div>`);
872
+ if (had) { const i = $("#srchQ"); i.focus(); i.setSelectionRange(had.pos, had.pos); }
873
+ }
874
+ async function runSearch() {
875
+ if (!srch.q.trim()) { srch.hits = null; return renderSearch(); }
876
+ const q = new URLSearchParams({ q: srch.q, limit: "50" });
877
+ if (state.sel) q.set("project", state.sel);
878
+ if (srch.kind) q.set("kind", srch.kind);
879
+ const mine = ++srch.t;
880
+ const j = await fetch(`/v1/memory?${q}`).then((r) => r.json()).catch(() => ({ hits: [] }));
881
+ if (mine !== srch.t) return;
882
+ srch.hits = j.hits ?? [];
883
+ if (state.view === "search" && !state.session) renderSearch();
884
+ }
885
+ document.addEventListener("input", (ev) => { if (ev.target.id === "srchQ") { srch.q = ev.target.value; clearTimeout(srch.db); srch.db = setTimeout(runSearch, 150); } });
688
886
  function renderStats() {
689
887
  const st = statsCache.key === (state.sel ?? "") ? statsCache.data : null;
690
888
  const scope = state.sel ? esc(projName(state.sel)) : "all projects";
@@ -852,7 +1050,69 @@ function sessionStream() {
852
1050
  }
853
1051
  // True when `rows` only extends the rows already in #log (same session, same prefix) → append, don't rebuild.
854
1052
  const isAppend = (rows) => logRendered && rows.length >= logRendered.length && logRendered.every((k, n) => rows[n].key === k);
1053
+ // M4.1 session replay: step through a session's tool calls, one at a time, with full input/output
1054
+ // (lazy-fetched from /v1/events/:seq). replayState holds the current step; nav by buttons or ←/→.
1055
+ const replay = { steps: [], i: 0, cache: new Map() };
1056
+ // M4.4: resume where this died — the daemon builds the prompt from the handoff + tail; we just confirm.
1057
+ async function resumeDead() {
1058
+ const id = state.session; if (!id) return;
1059
+ const plan = await fetch(`/v1/sessions/${encodeURIComponent(id)}/resume`).then((r) => r.json());
1060
+ if (!plan.ok) return alert(plan.error);
1061
+ if (!confirm(`Resume ${plan.task}${plan.owner ? ` as ${plan.owner}` : ""}?\n\n${plan.prompt.slice(0, 900)}${plan.prompt.length > 900 ? "…" : ""}`)) return;
1062
+ const r = await fetch(`/v1/sessions/${encodeURIComponent(id)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then((x) => x.json());
1063
+ if (!r.ok) return alert(r.error);
1064
+ openSession(r.run.sessionId);
1065
+ }
1066
+ function openReplay() {
1067
+ replay.steps = state.log.filter((e) => e.type === "tool.requested").map((e) => ({ seq: e.seq, tool: e.payload?.tool ?? "tool", summary: e.payload?.summary ?? "" }));
1068
+ replay.i = 0;
1069
+ replay.cache.clear();
1070
+ if (!replay.steps.length) { alert("No tool calls in this session yet."); return; }
1071
+ renderReplay();
1072
+ }
1073
+ async function renderReplay() {
1074
+ const n = replay.steps.length;
1075
+ const step = replay.steps[replay.i];
1076
+ let detail = replay.cache.get(step.seq);
1077
+ if (!detail) {
1078
+ // the request event (full input) and the paired completed event (output), both by seq
1079
+ const req = await fetch(`/v1/events/${step.seq}`).then((r) => r.json()).catch(() => null);
1080
+ const done = state.log.find((e) => e.type === "tool.completed" && e.seq > step.seq && e.payload?.summary === step.summary);
1081
+ const res = done ? await fetch(`/v1/events/${done.seq}`).then((r) => r.json()).catch(() => null) : null;
1082
+ detail = { input: req?.payload?.toolInput ?? null, output: res?.payload?.toolResponse ?? null, ts: req?.ts };
1083
+ replay.cache.set(step.seq, detail);
1084
+ }
1085
+ const j = (v) => (v == null ? "" : typeof v === "string" ? v : JSON.stringify(v, null, 2));
1086
+ $("#picker").innerHTML = `<div class="pk wn rp" role="dialog" aria-modal="true">
1087
+ <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>
1088
+ <div class="pk-b">
1089
+ <div class="dim now" style="font-family:var(--mono);font-size:var(--fs-sm);margin-bottom:8px">${esc(step.summary)}</div>
1090
+ <h4>input</h4><pre class="snip">${esc(j(detail.input)) || '<span class="dim">—</span>'}</pre>
1091
+ <h4>output</h4><pre class="snip">${detail.output != null ? esc(j(detail.output)).slice(0, 4000) : '<span class="dim">(no result captured)</span>'}</pre>
1092
+ </div>
1093
+ <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>
1094
+ </div>`;
1095
+ }
1096
+ function replayGo(delta) {
1097
+ const n = replay.steps.length;
1098
+ replay.i = Math.max(0, Math.min(n - 1, replay.i + delta));
1099
+ renderReplay();
1100
+ }
1101
+
855
1102
  // Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
1103
+ // M7.7: questions this session is waiting on a human for
1104
+ function questionCards(s) {
1105
+ const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
1106
+ if (!qs.length) return "";
1107
+ return `<h4>waiting on you</h4>${qs.map((q) => `<div class="perm"><div class="perm-t">${ic("warning", 13)} <b>Question #${q.id}</b>${q.task ? `<span class="dim"> · ${esc(q.task)}</span>` : ""}</div><div class="perm-c">${esc(q.text)}</div><div class="perm-b">${(q.options ?? []).map((o) => `<button class="ok" data-qanswer="${q.id}" data-text="${esc(o)}">${esc(o)}</button>`).join("")}<button data-qanswer="${q.id}">Answer…</button></div></div>`).join("")}`;
1108
+ }
1109
+ async function answerQuestion(id, preset) {
1110
+ const text = preset ?? prompt(`Answer to question #${id}:`);
1111
+ if (!text) return;
1112
+ const r = await fetch(`/v1/questions/${id}/answer`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, by: "dashboard" }) }).then((x) => x.json());
1113
+ if (!r.ok) alert(r.error);
1114
+ return refresh();
1115
+ }
856
1116
  function stdinBox(s) {
857
1117
  if (s.kind !== "spawned") return "";
858
1118
  const run = (state.runs ?? []).find((r) => r.sessionId === s.id);
@@ -872,6 +1132,8 @@ async function sendStdin() {
872
1132
  }
873
1133
  document.addEventListener("click", (ev) => {
874
1134
  if (ev.target.closest("#stdinSend")) return sendStdin();
1135
+ const qa = ev.target.closest("[data-qanswer]");
1136
+ if (qa) { ev.preventDefault(); return answerQuestion(Number(qa.dataset.qanswer), qa.dataset.text); }
875
1137
  const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
876
1138
  const key = a?.dataset.permAllow || d?.dataset.permDeny;
877
1139
  if (key) {
@@ -894,7 +1156,7 @@ function renderSession() {
894
1156
  const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
895
1157
  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" };
896
1158
  const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
897
- 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>`;
1159
+ const head = `<h2 class="hrow"><a class="back" href="#" id="back">${ic("arrow-left", 13)}back</a> ${esc(projName(s.projectId))} · <span class="s ${s.state}"></span> ${kindIcon(s)}${agentBadge(s.agent)}<b>${esc(s.title ?? s.id.slice(0, 8))}</b> <span>${esc(short(s.cwd))}${s.branch ? ` · ${esc(s.branch)}` : ""} · ${s.state}</span><a href="#" class="nav" id="replay" style="margin-left:auto" title="Step through this session's tool calls">${ic("play", 13)} Replay</a>${(state.worktrees[s.projectId] ?? []).some((w) => !w.main && (s.cwd === w.path || s.cwd.startsWith(`${w.path}/`))) ? `<a href="#" class="nav" id="sessDiff" title="What this session's worktree changed">${ic("folders", 13)} Diff</a>` : ""}${s.state === "ended" ? `<a href="#" class="nav" id="resumeDead" title="Spawn a run that picks up this session's task from its handoff + last actions">${ic("reload", 13)} Resume where it died</a>` : ""}</h2>`;
898
1160
  const side = `<div class="stats">
899
1161
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
900
1162
  ${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>`)}
@@ -904,6 +1166,7 @@ function renderSession() {
904
1166
  <h4>tokens</h4>${viz.compositionBar([{ label: "cache read", v: t.cacheRead }, { label: "cache write", v: t.cacheWrite }, { label: "input", v: t.input }, { label: "thinking", v: t.thinking }, { label: "output", v: t.output }])}
905
1167
  ${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
906
1168
  <h4>tools</h4>${tools.length ? viz.hbars(tools.slice(0, 8).map(([k, v]) => [k.replace(/^mcp__[a-z0-9-]+__/i, ""), v])) : '<span class="dim">None yet</span>'}
1169
+ ${questionCards(s)}
907
1170
  ${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
908
1171
  if (logEl && isAppend(rows)) {
909
1172
  // Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
@@ -969,12 +1232,84 @@ function menuSpec(kind, d) {
969
1232
  { 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(); } },
970
1233
  { label: "Copy dashboard URL", icon: "copy", run: () => copy(location.origin) },
971
1234
  { divider: true },
1235
+ { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1236
+ { label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
972
1237
  { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
973
1238
  { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => window.open(feedbackUrl(), "_blank") },
974
1239
  ] };
975
1240
  }
976
1241
  return null;
977
1242
  }
1243
+ // M4.7 desktop notifications: native notifications (web Notification API — works in the browser and
1244
+ // the desktop app's webview) for the things you'd want to walk away and be pinged about — a spawned
1245
+ // run waiting on a permission, and a claim orphaned with unfinished work. Clicking opens the spot to
1246
+ // act. Off until enabled from the settings menu (which requests OS permission). Quiet while focused.
1247
+ const NOTIFY_KEY = "swarm.notify";
1248
+ const notifyOn = () => { try { return localStorage.getItem(NOTIFY_KEY) === "on"; } catch { return false; } };
1249
+ async function enableNotifications() {
1250
+ if (!("Notification" in window)) { alert("This browser doesn't support notifications."); return; }
1251
+ const perm = Notification.permission === "granted" ? "granted" : await Notification.requestPermission();
1252
+ if (perm !== "granted") { alert("Notifications were blocked. Allow them for this site in your browser/OS settings."); return; }
1253
+ try { localStorage.setItem(NOTIFY_KEY, "on"); } catch {}
1254
+ new Notification("Swarm notifications on", { body: "You'll be pinged when a run needs a permission or a claim is orphaned." });
1255
+ }
1256
+ function disableNotifications() { try { localStorage.setItem(NOTIFY_KEY, "off"); } catch {} }
1257
+ let lastNotifyAt = 0;
1258
+ function notifyForEvent(ev) {
1259
+ if (!notifyOn() || !("Notification" in window) || Notification.permission !== "granted") return;
1260
+ if (!document.hidden && ev.type !== "permission.requested" && ev.type !== "question.asked") return; // only prompts that block an agent interrupt while you're looking
1261
+ const now = Date.now();
1262
+ if (now - lastNotifyAt < 1500) return; // don't stack
1263
+ const p = ev.payload || {};
1264
+ let title, body, onClick;
1265
+ if (ev.type === "permission.requested") {
1266
+ title = `Permission needed: ${p.tool ?? "tool"}`;
1267
+ body = `${p.display ?? ""}
1268
+ ${p.reason ?? ""}`.slice(0, 180);
1269
+ onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1270
+ } else if (ev.type === "question.asked") {
1271
+ title = "An agent has a question";
1272
+ body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
1273
+ onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
1274
+ } else if (ev.type === "claim.orphaned") {
1275
+ title = "Claim orphaned";
1276
+ body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
1277
+ onClick = () => { state.view = "board"; state.sel = ev.projectId || state.sel; state.session = null; refresh(); };
1278
+ } else return;
1279
+ lastNotifyAt = now;
1280
+ const n = new Notification(title, { body, tag: `swarm-${ev.type}-${ev.sessionId ?? ev.seq}` });
1281
+ n.onclick = () => { window.focus(); onClick?.(); n.close(); };
1282
+ }
1283
+
1284
+ // What's New: release notes for the running version, from window.RELEASE_NOTES (release-notes.js).
1285
+ // The desktop menu calls window.swarmWhatsNew; the settings menu calls whatsNew(); it also opens
1286
+ // itself once after an upgrade (localStorage remembers the last version the user saw).
1287
+ function releaseNotesFor(version) {
1288
+ const all = window.RELEASE_NOTES || {};
1289
+ if (version && all[version]) return { version, ...all[version] };
1290
+ const latest = Object.keys(all)[0];
1291
+ return latest ? { version: latest, ...all[latest] } : null;
1292
+ }
1293
+ function whatsNew(version) {
1294
+ const n = releaseNotesFor(version || state.version);
1295
+ if (!n) return;
1296
+ try { localStorage.setItem("swarm.seenVersion", n.version); } catch {}
1297
+ $("#picker").innerHTML = `<div class="pk wn" role="dialog" aria-modal="true">
1298
+ <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>
1299
+ <div class="pk-b"><h3>Swarm ${esc(n.version)}</h3>${n.date ? `<div class="date">${esc(n.date)}</div>` : ""}${n.html}</div>
1300
+ <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>
1301
+ </div>`;
1302
+ }
1303
+ window.swarmWhatsNew = (v) => whatsNew(v);
1304
+ // auto-open once per version, but never on the very first run (nothing to compare against)
1305
+ function maybeWhatsNew() {
1306
+ if (!state.version || !window.RELEASE_NOTES) return;
1307
+ let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
1308
+ if (seen === state.version) return;
1309
+ if (!seen) { try { localStorage.setItem("swarm.seenVersion", state.version); } catch {} return; }
1310
+ if (releaseNotesFor(state.version)) whatsNew(state.version);
1311
+ }
1312
+
978
1313
  // Star nudge: once a month at most, never on first open, dismissable for good. Pure localStorage —
979
1314
  // nothing leaves the machine; clicking Star just opens the repo in a browser.
980
1315
  const STAR = { key: "swarm.star", firstAfterMs: 2 * 86_400_000, everyMs: 30 * 86_400_000 };
@@ -1030,7 +1365,7 @@ document.addEventListener("contextmenu", (ev) => {
1030
1365
 
1031
1366
  // ---------- events
1032
1367
  document.addEventListener("click", async (ev) => {
1033
- 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]");
1368
+ const t = ev.target.closest("[data-menu],#settings,#feedback,[data-id],[data-s],#back,[data-view],.chip,[data-tl],[data-days],[data-sdays],[data-release],[data-forcerelease],[data-resrelease],[data-merge],[data-ack],[data-ackall],[data-inc],[data-task-filter],[data-claim],[data-procstop],[data-run],[data-runstop],[data-wtopen],[data-wtrm],[data-wtdiff],[data-wtpr],[data-dffile],#prGo,#sessDiff,#wtnew,#wtgc,[data-gaterun],#dispatch,#dispatchGo,#dispatchClear");
1034
1369
  if (!t) return;
1035
1370
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1036
1371
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
@@ -1050,6 +1385,80 @@ document.addEventListener("click", async (ev) => {
1050
1385
  if (!r.ok) alert(r.error); else state.tasks = null;
1051
1386
  return refresh();
1052
1387
  }
1388
+ if (t.dataset.wtopen) {
1389
+ ev.preventDefault();
1390
+ const i = t.dataset.wtopen.indexOf(":");
1391
+ const r = await fetch("/v1/worktrees/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: t.dataset.wtopen.slice(0, i), worktree: t.dataset.wtopen.slice(i + 1) }) }).then((x) => x.json());
1392
+ if (!r.ok) alert(r.error);
1393
+ return;
1394
+ }
1395
+ if (t.dataset.wtdiff) { ev.preventDefault(); const i = t.dataset.wtdiff.indexOf(":"); return openDiffDrawer(t.dataset.wtdiff.slice(0, i), t.dataset.wtdiff.slice(i + 1)); }
1396
+ if (t.dataset.wtpr) { ev.preventDefault(); const i = t.dataset.wtpr.indexOf(":"); return openPrDrawer(t.dataset.wtpr.slice(0, i), t.dataset.wtpr.slice(i + 1)); }
1397
+ if (t.dataset.dffile !== undefined) { ev.preventDefault(); return loadDiffFile(t.dataset.dffile); }
1398
+ if (t.id === "prGo") { ev.preventDefault(); return submitPr(); }
1399
+ if (t.id === "sessDiff") {
1400
+ ev.preventDefault();
1401
+ const s = state.sessions.find((x) => x.id === state.session);
1402
+ if (!s) return;
1403
+ const w = (state.worktrees[s.projectId] ?? []).find((x) => !x.main && (s.cwd === x.path || s.cwd.startsWith(`${x.path}/`)));
1404
+ return w ? openDiffDrawer(s.projectId, w.path) : null;
1405
+ }
1406
+ if (t.dataset.wtrm) {
1407
+ ev.preventDefault();
1408
+ const i = t.dataset.wtrm.indexOf(":");
1409
+ const projectId = t.dataset.wtrm.slice(0, i), worktree = t.dataset.wtrm.slice(i + 1);
1410
+ const rm = (force) => fetch("/v1/worktrees/remove", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, worktree, force }) }).then((x) => x.json());
1411
+ if (!confirm(`Remove worktree ${short(worktree)}?`)) return;
1412
+ const r = await rm(false);
1413
+ if (!r.ok && (r.refused === "dirty" || r.refused === "unpushed")) {
1414
+ if (confirm(`${r.error}\n\nRemove anyway (discards the work)?`)) await rm(true);
1415
+ } else if (!r.ok) alert(r.error);
1416
+ state.worktrees[projectId] = null;
1417
+ return refresh();
1418
+ }
1419
+ if (t.id === "wtnew") {
1420
+ ev.preventDefault();
1421
+ const name = prompt("Worktree name (folder under ~/.swarm/worktrees/<project>/; branch wt/<name>):");
1422
+ if (!name) return;
1423
+ const r = await fetch("/v1/worktrees", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, name }) }).then((x) => x.json());
1424
+ if (!r.ok) alert(r.error);
1425
+ state.worktrees[state.sel] = null;
1426
+ return refresh();
1427
+ }
1428
+ if (t.id === "wtgc") {
1429
+ ev.preventDefault();
1430
+ const r = await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) }).then((x) => x.json());
1431
+ if (!r.candidates.length) return alert("Nothing to collect — no merged branches or released claims with a worktree left behind.");
1432
+ const lines = r.candidates.map((c) => `${c.removable ? "•" : "✗"} ${c.branch ?? "(detached)"} — ${c.why}${c.blocker ? ` (blocked: ${c.blocker})` : ""}`).join("\n");
1433
+ const n = r.candidates.filter((c) => c.removable).length;
1434
+ if (!n) return alert(`Stale worktrees, none removable without force:\n\n${lines}`);
1435
+ if (!confirm(`Stale worktrees:\n\n${lines}\n\nRemove the ${n} removable one${n === 1 ? "" : "s"}?`)) return;
1436
+ await fetch("/v1/worktrees/gc", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, apply: true }) });
1437
+ state.worktrees[state.sel] = null;
1438
+ return refresh();
1439
+ }
1440
+ if (t.id === "dispatch") { ev.preventDefault(); return openDispatchDrawer(); }
1441
+ if (t.id === "dispatchGo") { ev.preventDefault(); return submitDispatch(); }
1442
+ if (t.id === "dispatchClear") {
1443
+ ev.preventDefault();
1444
+ await fetch("/v1/dispatch", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel }) });
1445
+ state.dispatch = null;
1446
+ return refresh();
1447
+ }
1448
+ if (t.dataset.gaterun) {
1449
+ ev.preventDefault();
1450
+ const task = t.dataset.gaterun;
1451
+ t.textContent = "running…";
1452
+ const r = await fetch("/v1/gates/run", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId: state.sel, task }) }).then((x) => x.json());
1453
+ if (!r.started?.length) alert(r.error ?? r.skipped?.[0]?.reason ?? "nothing ran");
1454
+ else alert(`${task}: ${r.runs.map((x) => `${x.verdict === "pass" ? "✓" : "✗"} ${x.gate} — ${x.rubric}`).join("\n")}${r.skipped.length ? `\n\nskipped: ${r.skipped.map((x) => `${x.gate} (${x.reason})`).join(", ")}` : ""}`);
1455
+ state.tasks = null;
1456
+ return refresh();
1457
+ }
1458
+ if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
1459
+ if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1460
+ if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
1461
+ if (t.id === "drRun") { ev.preventDefault(); return runDryRun(); }
1053
1462
  if (t.dataset.inc) { state.incFilter = t.dataset.inc; state.allIncidents = null; return refresh(); }
1054
1463
  if (t.dataset.ack) {
1055
1464
  ev.preventDefault(); ev.stopPropagation();
@@ -1094,6 +1503,8 @@ document.addEventListener("click", async (ev) => {
1094
1503
  return fetch(`/v1/resources/${encodeURIComponent(t.dataset.resrelease)}?${q}`, { method: "DELETE" }).then(refresh);
1095
1504
  }
1096
1505
  if (t.id === "back") { ev.preventDefault(); state.session = null; return touch(); }
1506
+ if (t.id === "replay") { ev.preventDefault(); return openReplay(); }
1507
+ if (t.id === "resumeDead") { ev.preventDefault(); return resumeDead(); }
1097
1508
  if (t.dataset.s) { ev.preventDefault(); return openSession(t.dataset.s); }
1098
1509
  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(); }
1099
1510
  });
@@ -1145,6 +1556,7 @@ function openRunDrawer(taskId) {
1145
1556
  <label>model<input id="rnModel" placeholder="default" value="${esc(last.model ?? "")}"></label>
1146
1557
  <label>max turns<input id="rnTurns" type="number" min="1" placeholder="∞" value="${esc(last.turns ?? "")}"></label>
1147
1558
  </div>
1559
+ <label>profile<select id="rnProfile" title="full: every tool · no-edits: commands but no file edits · read-only: read and search only">${["full", "no-edits", "read-only"].map((m) => opt(m, last.profile ?? "full")).join("")}</select></label>
1148
1560
  <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>
1149
1561
  </div>
1150
1562
  <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>
@@ -1154,11 +1566,11 @@ function openRunDrawer(taskId) {
1154
1566
  async function submitRun(taskId) {
1155
1567
  const prompt = $("#rnPrompt")?.value.trim();
1156
1568
  if (!prompt) return alert("A prompt is required.");
1157
- const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value;
1158
- localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns }));
1569
+ const mode = $("#rnMode")?.value, model = $("#rnModel")?.value.trim(), turns = $("#rnTurns")?.value, profile = $("#rnProfile")?.value;
1570
+ localStorage.setItem("swarm.runOpts", JSON.stringify({ mode, model, turns, profile }));
1159
1571
  closePicker();
1160
1572
  const r = await fetch("/v1/runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({
1161
- projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined,
1573
+ projectId: state.sel, task: taskId, prompt, owner: "dashboard", permissionMode: mode, model: model || undefined, maxTurns: turns ? Number(turns) : undefined, profile: profile && profile !== "full" ? profile : undefined,
1162
1574
  }) }).then((x) => x.json());
1163
1575
  if (!r.ok) return alert(r.error);
1164
1576
  state.tasks = null;
@@ -1166,6 +1578,102 @@ async function submitRun(taskId) {
1166
1578
  openSession(r.run.sessionId);
1167
1579
  }
1168
1580
 
1581
+ // ---------- dispatch drawer (M7.5)
1582
+ function openDispatchDrawer() {
1583
+ const ready = (state.tasks?.tasks ?? []).filter((t) => t.ready);
1584
+ const cfg = state.dispatch?.config ?? {};
1585
+ const last = (() => { try { return JSON.parse(localStorage.getItem("swarm.runOpts") || "{}"); } catch { return {}; } })();
1586
+ const opt = (v, cur) => `<option value="${v}" ${v === cur ? "selected" : ""}>${v || "default"}</option>`;
1587
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1588
+ <div class="pk-h">${ic("play", 15)}<b>Dispatch</b><span class="dim now" style="flex:1;margin-left:8px">${ready.length} ready task${ready.length === 1 ? "" : "s"}</span></div>
1589
+ <div class="pk-b">
1590
+ <div class="df-files" style="max-height:30vh">${ready.map((t) => `<label style="display:flex;gap:8px;padding:4px 8px;align-items:center"><input type="checkbox" class="dpTask" value="${esc(t.id)}" checked style="width:auto"><b>${esc(t.id)}</b><span class="pa dim">${esc(t.title)}</span></label>`).join("")}</div>
1591
+ <div class="row">
1592
+ <label>at a time<input id="dpPar" type="number" min="1" max="16" value="${cfg.max_parallel ?? 2}"></label>
1593
+ <label>permission mode<select id="dpMode">${["acceptEdits", "auto", "plan", "dontAsk", "manual", "bypassPermissions"].map((m) => opt(m, cfg.permission_mode ?? last.mode ?? "acceptEdits")).join("")}</select></label>
1594
+ <label>max turns<input id="dpTurns" type="number" min="1" placeholder="∞" value="${esc(cfg.max_turns ?? last.turns ?? "")}"></label>
1595
+ </div>
1596
+ <label>profile<select id="dpProfile">${["full", "no-edits", "read-only"].map((m) => opt(m, cfg.profile ?? "full")).join("")}</select></label>
1597
+ <div class="dim" style="font-size:var(--fs-sm)">Each task gets its own claim + worktree and a <code>claude -p</code> run told to work there, run the gates, hand off and open a PR. The rest queue until a slot frees. Swarm derives the outcome from gates and PRs — a task is never flipped done by an agent.</div>
1598
+ </div>
1599
+ <div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="dispatchGo">${ic("play", 13)} Dispatch</button></div>
1600
+ </div>`;
1601
+ $("#pkClose")?.addEventListener("click", closePicker);
1602
+ }
1603
+ async function submitDispatch() {
1604
+ const tasks = [...document.querySelectorAll(".dpTask:checked")].map((i) => i.value);
1605
+ if (!tasks.length) return alert("Pick at least one task.");
1606
+ const prof = $("#dpProfile")?.value;
1607
+ const body = { projectId: state.sel, tasks, maxParallel: Number($("#dpPar")?.value) || undefined, permissionMode: $("#dpMode")?.value, maxTurns: Number($("#dpTurns")?.value) || undefined, profile: prof && prof !== "full" ? prof : undefined, owner: "dashboard" };
1608
+ closePicker();
1609
+ const r = await fetch("/v1/dispatch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then((x) => x.json());
1610
+ if (!r.ok) return alert(r.error);
1611
+ if (r.rejected?.length) alert(`Not dispatched:\n${r.rejected.map((x) => `${x.id} — ${x.reason}`).join("\n")}`);
1612
+ state.tasks = null; state.dispatch = null;
1613
+ return refresh();
1614
+ }
1615
+
1616
+ // ---------- worktree diff + PR drawers (M7.3)
1617
+ const diffState = { projectId: null, worktree: null, base: null, files: [] };
1618
+ function colorPatch(patch) {
1619
+ return esc(patch).split("\n").map((l) => {
1620
+ const c = l.startsWith("+++") || l.startsWith("---") ? "m" : l.startsWith("@@") ? "h" : l.startsWith("+") ? "a" : l.startsWith("-") ? "d" : l.startsWith("diff ") ? "m" : "";
1621
+ return c ? `<span class="${c}">${l}</span>` : l;
1622
+ }).join("\n");
1623
+ }
1624
+ async function openDiffDrawer(projectId, worktree) {
1625
+ const q = new URLSearchParams({ project: projectId, worktree });
1626
+ const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
1627
+ if (d.error) return alert(d.error);
1628
+ Object.assign(diffState, { projectId, worktree: d.worktree, base: d.base, files: d.files });
1629
+ const files = d.files.map((f) => `<a href="#" data-dffile="${esc(f.path)}"><span class="st">${esc(f.status)}</span><span class="pa" title="${esc(f.path)}">${esc(f.path)}</span>${f.added >= 0 ? `<span class="pl">+${f.added}</span><span class="mi">−${f.deleted}</span>` : '<span class="dim">bin</span>'}</a>`).join("");
1630
+ $("#picker").innerHTML = `<div class="pk wide" role="dialog" aria-modal="true">
1631
+ <div class="pk-h">${ic("folders", 15)}<b>Diff</b><span class="dim now" style="flex:1;margin-left:8px">${esc(short(d.worktree))} · vs ${esc(d.baseRef ?? "HEAD")} · ${d.commits.length} commit${d.commits.length === 1 ? "" : "s"} · ${d.files.length} file${d.files.length === 1 ? "" : "s"}${d.dirty ? ' · <span class="badge warn">dirty</span>' : ""}</span></div>
1632
+ <div class="pk-b">
1633
+ ${d.commits.length ? `<div class="dim" style="font-size:var(--fs-sm)">${d.commits.slice(0, 8).map(esc).join("<br>")}${d.commits.length > 8 ? `<br>… ${d.commits.length - 8} more` : ""}</div>` : ""}
1634
+ ${d.files.length ? `<div class="df-files">${files}</div><pre class="df-patch" id="dfPatch"><span class="m">select a file — or view everything below</span></pre>` : '<div class="empty">Nothing changed.</div>'}
1635
+ </div>
1636
+ <div class="pk-f">${d.files.length ? `<a href="#" class="nav" data-dffile="">${ic("folders", 12)} Whole diff</a>` : ""}<span class="grow"></span><button id="pkClose">Close</button></div>
1637
+ </div>`;
1638
+ $("#pkClose")?.addEventListener("click", closePicker);
1639
+ }
1640
+ async function loadDiffFile(file) {
1641
+ const q = new URLSearchParams({ project: diffState.projectId, worktree: diffState.worktree });
1642
+ if (file) q.set("file", file); else q.set("patch", "1");
1643
+ for (const a of document.querySelectorAll(".df-files a")) a.classList.toggle("on", a.dataset.dffile === file);
1644
+ const el = $("#dfPatch"); if (el) el.innerHTML = '<span class="m">loading…</span>';
1645
+ const d = await fetch(`/v1/worktrees/diff?${q}`).then((x) => x.json());
1646
+ if (el) el.innerHTML = d.patch ? colorPatch(d.patch) : '<span class="m">(empty)</span>';
1647
+ }
1648
+ async function openPrDrawer(projectId, worktree) {
1649
+ const q = new URLSearchParams({ project: projectId, worktree });
1650
+ const d = await fetch(`/v1/prs/draft?${q}`).then((x) => x.json());
1651
+ if (!d.ok) return alert(d.error);
1652
+ $("#picker").innerHTML = `<div class="pk" role="dialog" aria-modal="true">
1653
+ <div class="pk-h">${ic("git-pull-request", 15)}<b>Open PR</b><span class="dim now" style="flex:1;margin-left:8px">${esc(d.task)} · ${esc(d.worktree.branch ?? "")}${d.diff.dirty ? ' · <span class="badge warn">uncommitted changes — commit first</span>' : ""}</span></div>
1654
+ <div class="pk-b">
1655
+ <label>title<input id="prTitle" value="${esc(d.title)}"></label>
1656
+ <label>body<textarea id="prBody" style="min-height:220px">${esc(d.body)}</textarea></label>
1657
+ <label style="display:flex;gap:8px;align-items:center"><input type="checkbox" id="prDraft" style="width:auto"> draft</label>
1658
+ <div class="dim" style="font-size:var(--fs-sm)">Pushes <code>${esc(d.worktree.branch ?? "")}</code> to origin and runs <code>gh pr create</code> / <code>glab mr create</code> with your local login. Swarm never commits for you.</div>
1659
+ </div>
1660
+ <div class="pk-f"><span class="grow"></span><button id="pkClose">Cancel</button><button class="primary" id="prGo" data-project="${esc(projectId)}" data-worktree="${esc(d.worktree.path)}" ${d.diff.dirty ? "disabled" : ""}>${ic("git-pull-request", 13)} Open PR</button></div>
1661
+ </div>`;
1662
+ $("#pkClose")?.addEventListener("click", closePicker);
1663
+ }
1664
+ async function submitPr() {
1665
+ const b = $("#prGo"); if (!b) return;
1666
+ const projectId = b.dataset.project, worktree = b.dataset.worktree;
1667
+ const title = $("#prTitle")?.value.trim(), body = $("#prBody")?.value, draft = $("#prDraft")?.checked;
1668
+ b.disabled = true; b.textContent = "opening…";
1669
+ const r = await fetch("/v1/prs/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ projectId, worktree, title, body, draft }) }).then((x) => x.json());
1670
+ if (!r.ok) { b.disabled = false; b.textContent = "Open PR"; return alert(r.error); }
1671
+ closePicker();
1672
+ state.prs = [];
1673
+ await refresh();
1674
+ if (r.url) window.open(r.url, "_blank");
1675
+ }
1676
+
1169
1677
  async function openPicker(focusPath = false) {
1170
1678
  await pickerGo("");
1171
1679
  if (focusPath) { const i = $("#pkPath"); if (i) { i.focus(); i.select(); } }
@@ -1194,13 +1702,21 @@ $("#picker").addEventListener("click", (ev) => {
1194
1702
  if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
1195
1703
  const go = ev.target.closest("[data-go]");
1196
1704
  if (go) return void pickerGo(go.dataset.go);
1705
+ const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
1706
+ if (ctoml) { ev.preventDefault(); copy($(`#toml-${ctoml.dataset.copyToml}`)?.textContent); ctoml.lastChild.textContent = " copied"; return; }
1707
+ if (cles) { ev.preventDefault(); copy($(`#lesson-${cles.dataset.copyLesson}`)?.textContent); cles.lastChild.textContent = " copied"; return; }
1708
+ if (ev.target.closest("#rpPrev")) return replayGo(-1);
1709
+ if (ev.target.closest("#rpNext")) return replayGo(1);
1197
1710
  if (ev.target.closest("#rnCancel")) return closePicker();
1198
1711
  const rnGo = ev.target.closest("#rnGo"); if (rnGo) return submitRun(rnGo.dataset.task);
1199
1712
  if (ev.target.closest("#pkAdd")) { const p = $("#pkPath")?.value.trim() || picker.path; closePicker(); addProject(p); }
1200
1713
  });
1714
+ $("#picker").addEventListener("input", (ev) => { if (ev.target.id === "rpRange") { replay.i = Number(ev.target.value); renderReplay(); } });
1715
+ $("#picker").addEventListener("change", (ev) => { if (ev.target.dataset?.drmode) { dry.modes[ev.target.dataset.drmode] = ev.target.value; } });
1201
1716
  $("#picker").addEventListener("keydown", (ev) => {
1202
1717
  if (ev.key === "Enter" && ev.target.id === "pkPath") { ev.preventDefault(); pickerGo(ev.target.value.trim()); }
1203
1718
  if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey) && ev.target.id === "rnPrompt") { ev.preventDefault(); submitRun($("#rnGo")?.dataset.task); }
1719
+ if ((ev.key === "ArrowRight" || ev.key === "ArrowLeft") && $(".rp")) { ev.preventDefault(); replayGo(ev.key === "ArrowRight" ? 1 : -1); }
1204
1720
  });
1205
1721
  document.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && $("#picker").innerHTML) closePicker(); });
1206
1722
 
@@ -1228,9 +1744,10 @@ function connect() {
1228
1744
  if (state.log.length > LOG_CAP) state.log.shift();
1229
1745
  schedule();
1230
1746
  }
1747
+ if (fresh) notifyForEvent(ev);
1231
1748
  pollSoon();
1232
1749
  };
1233
- 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);
1750
+ for (const t of ["session.started", "session.ended", "prompt.submitted", "tool.requested", "tool.completed", "subagent.started", "subagent.stopped", "agent.text", "session.notification", "incident.opened", "claim.acquired", "claim.released", "resource.acquired", "resource.released", "resource.reaped", "process.started", "process.exited", "gate.recorded", "claim.orphaned", "claim.renewed", "worktree.bootstrapped", "worktree.created", "worktree.removed", "pr.opened", "question.asked", "question.answered", "dispatch.queued", "dispatch.started", "dispatch.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
1234
1751
  }
1235
1752
  refresh().then(() => {
1236
1753
  const sid = new URLSearchParams(location.search).get("session");