@ra3orblade/swarm 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ra3orblade/swarm",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Local-first control plane for AI-agent development: watch every Claude Code / Codex / Grok session on your machine, ledger tasks and worktrees, enforce rules as hook denials.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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: [], attribution: 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, 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`; };
@@ -138,9 +138,12 @@ async function refresh() {
138
138
  }
139
139
  let attrChanged = false;
140
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;
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;
144
147
  } else if (state.view === "spend" && !state.sel) {
145
148
  if (state.attribution) attrChanged = true;
146
149
  state.attribution = null;
@@ -154,12 +157,13 @@ async function refresh() {
154
157
  }
155
158
  let tasksChanged = false;
156
159
  if (state.view === "board" && state.sel && !state.session) {
157
- const [t, g] = await Promise.all([
160
+ const [t, g, d] = await Promise.all([
158
161
  fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
159
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),
160
164
  ]);
161
- tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates);
162
- 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;
163
167
  }
164
168
  let incChanged = false;
165
169
  if (state.view === "incidents" && !state.session) {
@@ -288,8 +292,8 @@ projectsEl.addEventListener("dragend", () => {
288
292
  // Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
289
293
  const FLEET_COLS = [
290
294
  { key: "project", label: "project", width: 104, get: (s) => projName(s.projectId), cell: (s) => esc(projName(s.projectId)) },
291
- { key: "agent", label: "agent", width: 76, get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
292
- { 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>' : ""}` },
293
297
  { key: "branch", label: "branch", width: 134, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
294
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>` },
295
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>` },
@@ -366,7 +370,7 @@ function renderPRs() {
366
370
 
367
371
  // ---------- board (coordination: claims, worktrees, incidents)
368
372
  function renderBoard() {
369
- const parts = [renderTasks(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
373
+ const parts = [renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
370
374
  $("#main").innerHTML = parts.length
371
375
  ? parts.join("").replace(/^(<h2) class="mt-sec"/, "$1") // first section needs no top gap
372
376
  : `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
@@ -597,14 +601,14 @@ function renderTasks() {
597
601
  ];
598
602
  const srcLabel = state.tasks.source === "github" ? "GitHub Issues" : state.tasks.source === "linear" ? "Linear" : state.tasks.source;
599
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>` +
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>` +
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>` +
601
605
  (rows.length
602
606
  ? dataTable({
603
607
  id: "tasks",
604
608
  columns: cols,
605
609
  rows,
606
610
  leading: { width: 24, cell: (t) => `<span class="s ${t.claimedBy ? "active" : t.ready ? "waiting" : "idle"}"></span>` },
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>` : "") },
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>` : ""}` : "") },
608
612
  rowAttrs: () => "",
609
613
  rerender: touch,
610
614
  })
@@ -661,19 +665,71 @@ function renderWorktrees() {
661
665
  { key: "head", label: "head", width: 90, get: (w) => w.head, cell: (w) => `<span class="br">${esc(w.head)}</span>` },
662
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>` },
663
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>') },
664
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>' },
665
670
  ].filter((c) => !(c.key === "project" && state.sel));
666
- 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>` +
667
684
  dataTable({
668
685
  id: "worktrees",
669
686
  columns: cols,
670
687
  rows,
671
688
  leading: { width: 24, cell: (w) => `<span class="s ${inside(w).length ? "active" : w.dirty > 0 ? "waiting" : "ended"}"></span>` },
672
- 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>` : "") },
673
719
  rerender: touch,
674
720
  });
675
721
  }
676
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
+
677
733
  // ---------- spend
678
734
  function renderSpend() {
679
735
  const sp = state.spend;
@@ -723,7 +779,7 @@ function renderSpend() {
723
779
  const hm = sp.hourly.filter(inSel).map((c) => ({ dow: c.dow, hour: c.hour, v: c.cost ?? 0 }));
724
780
  $("#main").innerHTML =
725
781
  `<h2>Spend <span>${state.sel ? esc(projName(state.sel)) : "all projects"}</span>${rangeChips}</h2>
726
- <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>
727
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>
728
784
  <div class="cols">
729
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>
@@ -1044,6 +1100,19 @@ function replayGo(delta) {
1044
1100
  }
1045
1101
 
1046
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
+ }
1047
1116
  function stdinBox(s) {
1048
1117
  if (s.kind !== "spawned") return "";
1049
1118
  const run = (state.runs ?? []).find((r) => r.sessionId === s.id);
@@ -1063,6 +1132,8 @@ async function sendStdin() {
1063
1132
  }
1064
1133
  document.addEventListener("click", (ev) => {
1065
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); }
1066
1137
  const a = ev.target.closest("[data-perm-allow]"), d = ev.target.closest("[data-perm-deny]");
1067
1138
  const key = a?.dataset.permAllow || d?.dataset.permDeny;
1068
1139
  if (key) {
@@ -1085,7 +1156,7 @@ function renderSession() {
1085
1156
  const subTurns = state.turns.filter((x) => x.sidechain || x.agentId);
1086
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" };
1087
1158
  const stat = (k, v) => `<div class="stat"><span>${ic(STAT_ICON[k] ?? "list-bullets", 13)}${k}</span><b>${v}</b></div>`;
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>`;
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>`;
1089
1160
  const side = `<div class="stats">
1090
1161
  ${stat("cost", usd(s.costUsd))}${stat("model", esc(model(s.model)) || "—")}${stat("turns", s.turns)}${stat("tool calls", s.toolCalls)}
1091
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>`)}
@@ -1095,6 +1166,7 @@ function renderSession() {
1095
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 }])}
1096
1167
  ${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
1097
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)}
1098
1170
  ${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
1099
1171
  if (logEl && isAppend(rows)) {
1100
1172
  // Same session, rows only appended: patch header + sidebar, append the new rows — #log keeps its
@@ -1160,7 +1232,7 @@ function menuSpec(kind, d) {
1160
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(); } },
1161
1233
  { label: "Copy dashboard URL", icon: "copy", run: () => copy(location.origin) },
1162
1234
  { divider: true },
1163
- { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "permission prompts, orphans", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1235
+ { label: "Desktop notifications", icon: "bell", pressed: notifyOn(), caption: notifyOn() ? "on" : "off", run: () => { notifyOn() ? disableNotifications() : enableNotifications(); $("#settings").blur(); } },
1164
1236
  { label: "What's New", icon: "star", caption: `v${state.version ?? "?"}`, run: () => whatsNew() },
1165
1237
  { label: "Documentation", icon: "book-open", caption: "getswarm", run: () => window.open("https://getswarm.vercel.app/docs/", "_blank") },
1166
1238
  { label: "Send feedback", icon: "comment-text", caption: "GitHub issue", run: () => window.open(feedbackUrl(), "_blank") },
@@ -1185,7 +1257,7 @@ function disableNotifications() { try { localStorage.setItem(NOTIFY_KEY, "off");
1185
1257
  let lastNotifyAt = 0;
1186
1258
  function notifyForEvent(ev) {
1187
1259
  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
1260
+ if (!document.hidden && ev.type !== "permission.requested" && ev.type !== "question.asked") return; // only prompts that block an agent interrupt while you're looking
1189
1261
  const now = Date.now();
1190
1262
  if (now - lastNotifyAt < 1500) return; // don't stack
1191
1263
  const p = ev.payload || {};
@@ -1195,6 +1267,10 @@ function notifyForEvent(ev) {
1195
1267
  body = `${p.display ?? ""}
1196
1268
  ${p.reason ?? ""}`.slice(0, 180);
1197
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); };
1198
1274
  } else if (ev.type === "claim.orphaned") {
1199
1275
  title = "Claim orphaned";
1200
1276
  body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
@@ -1289,7 +1365,7 @@ document.addEventListener("contextmenu", (ev) => {
1289
1365
 
1290
1366
  // ---------- events
1291
1367
  document.addEventListener("click", async (ev) => {
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]");
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");
1293
1369
  if (!t) return;
1294
1370
  if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
1295
1371
  if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
@@ -1309,6 +1385,76 @@ document.addEventListener("click", async (ev) => {
1309
1385
  if (!r.ok) alert(r.error); else state.tasks = null;
1310
1386
  return refresh();
1311
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
+ }
1312
1458
  if (t.dataset.codify) { ev.preventDefault(); return codifyIncident(t.dataset.codify); }
1313
1459
  if (t.id === "dryrun") { ev.preventDefault(); return openDryRun(); }
1314
1460
  if (t.dataset.skind !== undefined) { ev.preventDefault(); srch.kind = t.dataset.skind; return runSearch().then(renderSearch); }
@@ -1410,6 +1556,7 @@ function openRunDrawer(taskId) {
1410
1556
  <label>model<input id="rnModel" placeholder="default" value="${esc(last.model ?? "")}"></label>
1411
1557
  <label>max turns<input id="rnTurns" type="number" min="1" placeholder="∞" value="${esc(last.turns ?? "")}"></label>
1412
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>
1413
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>
1414
1561
  </div>
1415
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>
@@ -1419,11 +1566,11 @@ function openRunDrawer(taskId) {
1419
1566
  async function submitRun(taskId) {
1420
1567
  const prompt = $("#rnPrompt")?.value.trim();
1421
1568
  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 }));
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 }));
1424
1571
  closePicker();
1425
1572
  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,
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,
1427
1574
  }) }).then((x) => x.json());
1428
1575
  if (!r.ok) return alert(r.error);
1429
1576
  state.tasks = null;
@@ -1431,6 +1578,102 @@ async function submitRun(taskId) {
1431
1578
  openSession(r.run.sessionId);
1432
1579
  }
1433
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
+
1434
1677
  async function openPicker(focusPath = false) {
1435
1678
  await pickerGo("");
1436
1679
  if (focusPath) { const i = $("#pkPath"); if (i) { i.focus(); i.select(); } }
@@ -1504,7 +1747,7 @@ function connect() {
1504
1747
  if (fresh) notifyForEvent(ev);
1505
1748
  pollSoon();
1506
1749
  };
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);
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);
1508
1751
  }
1509
1752
  refresh().then(() => {
1510
1753
  const sid = new URLSearchParams(location.search).get("session");
package/web/index.html CHANGED
@@ -168,6 +168,14 @@
168
168
  .pk-b textarea{min-height:120px;resize:vertical;font-family:var(--mono);font-size:var(--fs-sm);line-height:1.5}
169
169
  .pk-b input:focus,.pk-b textarea:focus,.pk-b select:focus{border-color:var(--acc)}
170
170
  .pk-b .row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
171
+ /* Worktree diff drawer (M7.3) */
172
+ .pk.wide{width:min(960px,96vw)}
173
+ .df-files{display:grid;gap:1px;max-height:22vh;overflow:auto;border:1px solid var(--line);border-radius:var(--r-sm)}
174
+ .df-files a{display:flex;gap:10px;padding:4px 8px;color:var(--fg-2);text-decoration:none;font:var(--fs-sm) var(--mono)}
175
+ .df-files a:hover,.df-files a.on{background:var(--panel-2);color:var(--fg)}
176
+ .df-files .st{width:12px;color:var(--dim)}.df-files .pl{color:var(--ok)}.df-files .mi{color:var(--warn)}.df-files .pa{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
177
+ .df-patch{max-height:44vh;overflow:auto;margin:0;padding:8px 10px;background:var(--panel-2);border:1px solid var(--line);border-radius:var(--r-sm);font:var(--fs-sm) var(--mono);line-height:1.45;white-space:pre}
178
+ .df-patch .a{color:var(--ok)}.df-patch .d{color:var(--warn)}.df-patch .h{color:var(--acc)}.df-patch .m{color:var(--dim)}
171
179
  /* What's New (release notes) */
172
180
  .wn .pk-b{max-height:64vh;overflow:auto;gap:2px}
173
181
  .wn h3{margin:0 0 2px;font:600 var(--fs-lg) var(--sans);color:var(--fg)}
@@ -228,6 +236,8 @@
228
236
  tbody tr:last-child td{border-bottom:0}
229
237
  /* edge tool cell: never ellipsize (the browser's "…" showed as a stray dot on every row) */
230
238
  tbody td.td-tools{text-overflow:clip;padding-left:0;padding-right:0;text-align:center}
239
+ /* badge-only cells: the pill has its own margin, so the cell never needs an ellipsis */
240
+ tbody td.td-badge{text-overflow:clip}tbody td.td-badge .badge{margin-right:0}
231
241
  tbody tr{cursor:pointer;transition:background var(--t-fast)}
232
242
  tbody tr:hover{background:var(--panel-2)}
233
243
  td:first-child{overflow:visible}