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