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