@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/dist/swarm-mcp.js +122 -1
- package/dist/swarm.js +458 -7
- package/dist/swarmd.js +1888 -304
- package/package.json +1 -1
- package/web/app.js +266 -23
- package/web/index.html +10 -0
- package/web/release-notes.js +1 -1
- package/web/table.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ra3orblade/swarm",
|
|
3
|
-
"version": "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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
|
142
|
-
|
|
143
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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" : "
|
|
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
|
|
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}
|