@ra3orblade/swarm 0.8.0 → 0.10.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 +8 -1
- package/dist/swarm-hook.js +5 -1
- package/dist/swarm-mcp.js +35 -6
- package/dist/swarm.js +355 -11
- package/dist/swarmd.js +2126 -88
- package/package.json +1 -1
- package/web/app.js +333 -28
- package/web/index.html +64 -12
- package/web/release-notes.js +1 -1
- package/web/viz.js +67 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ra3orblade/swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Local-first control plane for AI-agent development: watch every Claude Code / Codex / Grok session on your machine, ledger tasks and worktrees, enforce rules as hook denials.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/web/app.js
CHANGED
|
@@ -65,7 +65,7 @@ document.addEventListener("keydown", (ev) => {
|
|
|
65
65
|
window.swarmZoom(dir);
|
|
66
66
|
});
|
|
67
67
|
// `dirty`: a UI-side change (selection, view, filter) needs a render even when the daemon snapshot is unchanged.
|
|
68
|
-
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 };
|
|
68
|
+
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, collisions: null, outcomes: null, dirty: true };
|
|
69
69
|
|
|
70
70
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
71
71
|
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`; };
|
|
@@ -155,7 +155,7 @@ async function refresh() {
|
|
|
155
155
|
const txt = await (await fetch("/v1/state")).text();
|
|
156
156
|
const same = txt === lastSnap;
|
|
157
157
|
if (!same) { lastSnap = txt; Object.assign(state, JSON.parse(txt)); }
|
|
158
|
-
if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; maybeWhatsNew(); }).catch(() => {});
|
|
158
|
+
if (!state.version) fetch("/v1/health").then((r) => r.json()).then((h) => { state.version = h.version; state.hooksInstalled = h.hooksInstalled !== false; maybeUpdateNudge(h); maybeWhatsNew(); }).catch(() => {});
|
|
159
159
|
let prsChanged = false;
|
|
160
160
|
if (state.view === "prs" && !state.session) {
|
|
161
161
|
const prs = await (await fetch("/v1/prs")).json().catch(() => state.prs ?? []);
|
|
@@ -175,6 +175,10 @@ async function refresh() {
|
|
|
175
175
|
state.attribution = null;
|
|
176
176
|
}
|
|
177
177
|
let runsChanged = false;
|
|
178
|
+
if (state.session) {
|
|
179
|
+
const ms = await fetch(`/v1/messages?session=${encodeURIComponent(state.session)}&limit=50`).then((r) => r.json()).catch(() => state.msgs ?? []);
|
|
180
|
+
if (JSON.stringify(ms) !== JSON.stringify(state.msgs)) { state.msgs = ms; state.dirty = true; }
|
|
181
|
+
}
|
|
178
182
|
const openSpawned = state.session && state.sessions.find((x) => x.id === state.session)?.kind === "spawned";
|
|
179
183
|
if (openSpawned || (state.view === "board" && !state.session) || (state.view === "fleet" && !state.session)) {
|
|
180
184
|
const runs = await fetch("/v1/runs").then((r) => r.json()).catch(() => state.runs ?? []);
|
|
@@ -183,13 +187,14 @@ async function refresh() {
|
|
|
183
187
|
}
|
|
184
188
|
let tasksChanged = false;
|
|
185
189
|
if (state.view === "board" && state.sel && !state.session) {
|
|
186
|
-
const [t, g, d] = await Promise.all([
|
|
190
|
+
const [t, g, d, wf] = await Promise.all([
|
|
187
191
|
fetch(`/v1/tasks?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.tasks),
|
|
188
192
|
fetch(`/v1/gates?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.gates),
|
|
189
193
|
fetch(`/v1/dispatch?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.dispatch),
|
|
194
|
+
fetch(`/v1/workflows?project=${encodeURIComponent(state.sel)}`).then((r) => r.json()).catch(() => state.workflows),
|
|
190
195
|
]);
|
|
191
|
-
tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch);
|
|
192
|
-
state.tasks = t; state.gates = g; state.dispatch = d;
|
|
196
|
+
tasksChanged = JSON.stringify(t) !== JSON.stringify(state.tasks) || JSON.stringify(g) !== JSON.stringify(state.gates) || JSON.stringify(d) !== JSON.stringify(state.dispatch) || JSON.stringify(wf) !== JSON.stringify(state.workflows);
|
|
197
|
+
state.tasks = t; state.gates = g; state.dispatch = d; state.workflows = wf;
|
|
193
198
|
}
|
|
194
199
|
let incChanged = false;
|
|
195
200
|
if (state.view === "incidents" && !state.session) {
|
|
@@ -198,9 +203,39 @@ async function refresh() {
|
|
|
198
203
|
incChanged = JSON.stringify(inc) !== JSON.stringify(state.allIncidents);
|
|
199
204
|
state.allIncidents = inc;
|
|
200
205
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
206
|
+
let colChanged = false;
|
|
207
|
+
if (state.view === "graphs" && !state.session) {
|
|
208
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
209
|
+
const col = await fetch(`/v1/graphs/collisions${q}`).then((r) => r.json()).catch(() => state.collisions);
|
|
210
|
+
colChanged = JSON.stringify(col) !== JSON.stringify(state.collisions);
|
|
211
|
+
state.collisions = col;
|
|
212
|
+
}
|
|
213
|
+
let outChanged = false;
|
|
214
|
+
if (state.view === "outcomes" && !state.session) {
|
|
215
|
+
const q = state.sel ? `?project=${encodeURIComponent(state.sel)}` : "";
|
|
216
|
+
const o = await fetch(`/v1/outcomes${q}`).then((r) => r.json()).catch(() => state.outcomes);
|
|
217
|
+
outChanged = JSON.stringify(o) !== JSON.stringify(state.outcomes);
|
|
218
|
+
state.outcomes = o;
|
|
219
|
+
}
|
|
220
|
+
if (!same || prsChanged || incChanged || tasksChanged || runsChanged || attrChanged || colChanged || outChanged || state.dirty || Date.now() - lastRenderAt > 30_000) schedule();
|
|
221
|
+
}
|
|
222
|
+
// M9.1: the view registry — the one source of truth that the sidebar nav, render dispatch,
|
|
223
|
+
// deep links and the ⌘K palette all derive from. Adding a view = one entry here + its render fn.
|
|
224
|
+
const VIEW_DEFS = [
|
|
225
|
+
{ id: "fleet", label: "Fleet", icon: "squares-four", group: "Observe", render: () => renderFleet() },
|
|
226
|
+
{ id: "timeline", label: "Timeline", icon: "clock-counter-clockwise", group: "Observe", render: () => renderTimeline() },
|
|
227
|
+
{ id: "graphs", label: "Graphs", icon: "tree-structure", group: "Observe", render: () => renderGraphs(), badge: () => state.collisions?.contested ?? 0 },
|
|
228
|
+
{ id: "board", label: "Board", icon: "stack", group: "Work", render: () => renderBoard() },
|
|
229
|
+
{ id: "prs", label: "PRs", icon: "git-pull-request", group: "Work", render: () => renderPRs() },
|
|
230
|
+
{ id: "outcomes", label: "Outcomes", icon: "check", group: "Insight", render: () => renderOutcomes() },
|
|
231
|
+
{ id: "spend", label: "Spend", icon: "coins", group: "Insight", render: () => renderSpend() },
|
|
232
|
+
{ id: "stats", label: "Stats", icon: "chart-bar", group: "Insight", render: () => { loadStats(); renderStats(); } }, // loadStats is a no-op while the cache is fresh
|
|
233
|
+
{ id: "search", label: "Search", icon: "magnifying-glass", group: "Insight", render: () => renderSearch() },
|
|
234
|
+
{ id: "incidents", label: "Incidents", icon: "warning", group: "Guard", render: () => renderIncidentsView(), badge: () => state.openIncidents ?? 0 },
|
|
235
|
+
];
|
|
236
|
+
const viewDef = (id) => VIEW_DEFS.find((v) => v.id === id);
|
|
237
|
+
const VIEWS = VIEW_DEFS.map((v) => v.id);
|
|
238
|
+
let navHtml = ""; // last-rendered nav html; declared before the restore block below calls renderNav()
|
|
204
239
|
// restore last view + project selection (persisted UI state)
|
|
205
240
|
{
|
|
206
241
|
const v = localStorage.getItem("swarm.view");
|
|
@@ -212,7 +247,7 @@ const VIEWS = ["fleet", "board", "incidents", "prs", "timeline", "spend", "stats
|
|
|
212
247
|
if (VIEWS.includes(q.get("view"))) state.view = q.get("view");
|
|
213
248
|
if (q.has("project")) state.sel = q.get("project") || null;
|
|
214
249
|
// Mark the restored tab before the first snapshot lands, so the nav doesn't flash "Fleet".
|
|
215
|
-
|
|
250
|
+
renderNav();
|
|
216
251
|
}
|
|
217
252
|
function render() {
|
|
218
253
|
// A row menu is anchored to DOM that a re-render would replace (and the focus jump closes it):
|
|
@@ -226,14 +261,7 @@ function render() {
|
|
|
226
261
|
if (!dragPid) renderProjects(); // a re-render mid-drag would yank the row out from under the cursor
|
|
227
262
|
renderHeader();
|
|
228
263
|
if (state.session) renderSession();
|
|
229
|
-
else
|
|
230
|
-
else if (state.view === "stats") { loadStats(); renderStats(); } // loadStats is a no-op while the cache is fresh
|
|
231
|
-
else if (state.view === "search") renderSearch();
|
|
232
|
-
else if (state.view === "timeline") renderTimeline();
|
|
233
|
-
else if (state.view === "board") renderBoard();
|
|
234
|
-
else if (state.view === "incidents") renderIncidentsView();
|
|
235
|
-
else if (state.view === "prs") renderPRs();
|
|
236
|
-
else renderFleet();
|
|
264
|
+
else (viewDef(state.view)?.render ?? viewDef("fleet").render)();
|
|
237
265
|
if (keep) {
|
|
238
266
|
const el = document.querySelector(`input[data-filter="${keep.key}"][data-tid="${keep.tid}"]`);
|
|
239
267
|
if (el) { el.focus(); el.setSelectionRange(keep.pos, keep.pos); }
|
|
@@ -244,9 +272,22 @@ function renderHeader() {
|
|
|
244
272
|
const today = state.spend ? sumBy(state.spend.byProjectToday, (x) => x.cost) : 0;
|
|
245
273
|
const html = `Today <b>${usd(today)}</b>`;
|
|
246
274
|
if (html !== todayHtml) { todayHtml = html; $("#today").innerHTML = html; }
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
275
|
+
renderNav();
|
|
276
|
+
}
|
|
277
|
+
// M9.1: grouped view nav in the sidebar, generated from VIEW_DEFS. Rebuilt only when the
|
|
278
|
+
// html changes (active view, badges) so the 5s poll doesn't churn the DOM.
|
|
279
|
+
function renderNav() {
|
|
280
|
+
const groups = [];
|
|
281
|
+
for (const v of VIEW_DEFS) {
|
|
282
|
+
const g = groups.find((x) => x.name === v.group) ?? groups[groups.push({ name: v.group, views: [] }) - 1];
|
|
283
|
+
g.views.push(v);
|
|
284
|
+
}
|
|
285
|
+
const link = (v) => {
|
|
286
|
+
const n = v.badge?.() ?? 0;
|
|
287
|
+
return `<a href="#" data-view="${v.id}" class="nav ${!state.session && state.view === v.id ? "on" : ""}" title="${v.label}">${ic(v.icon, 14)}<span class="nm">${v.label}</span>${n ? `<b class="navcount">${n > 99 ? "99+" : n}</b>` : ""}</a>`;
|
|
288
|
+
};
|
|
289
|
+
const html = groups.map((g) => `<h4>${g.name}</h4>${g.views.map(link).join("")}`).join("");
|
|
290
|
+
if (html !== navHtml) { navHtml = html; $("#viewnav").innerHTML = html; }
|
|
250
291
|
}
|
|
251
292
|
|
|
252
293
|
const isLive = (s) => s.state === "active" || s.state === "waiting";
|
|
@@ -256,6 +297,12 @@ function liveCounts() {
|
|
|
256
297
|
for (const s of state.sessions) if (isLive(s)) { m.set(s.projectId, (m.get(s.projectId) ?? 0) + 1); m.set("", (m.get("") ?? 0) + 1); }
|
|
257
298
|
return m;
|
|
258
299
|
}
|
|
300
|
+
// M5.7: 14-day spend sparkline per pinned project; hidden when the fortnight cost is ~zero.
|
|
301
|
+
function spendSpark(pid) {
|
|
302
|
+
const pts = state.spendSparks?.[pid];
|
|
303
|
+
if (!pts || pts.reduce((a, b) => a + b, 0) < 0.5) return "";
|
|
304
|
+
return `<span class="proj-spark" title="last 14 days · $${pts.reduce((a, b) => a + b, 0).toFixed(0)}">${viz.sparkline(pts, "var(--c1)")}</span>`;
|
|
305
|
+
}
|
|
259
306
|
function renderProjects() {
|
|
260
307
|
const lc = liveCounts();
|
|
261
308
|
const live = (pid) => lc.get(pid) ?? 0;
|
|
@@ -272,7 +319,7 @@ function renderProjects() {
|
|
|
272
319
|
const row = (p) => {
|
|
273
320
|
const act = `<span class="act more" data-menu="project" data-pid="${p.id}" title="Project actions">${ic("dots-three", 15)}</span>`;
|
|
274
321
|
return `<div class="proj ${state.sel === p.id ? "sel" : ""}" data-id="${p.id}" data-ctx="project" data-pid="${p.id}" title="${esc(p.root)}"${p.discovered ? "" : ' draggable="true"'}>
|
|
275
|
-
<span class="st ${live(p.id) ? "live" : ""}"></span>${projGlyph(p)}<span class="nm">${disamb(p)}${esc(p.name)}</span
|
|
322
|
+
<span class="st ${live(p.id) ? "live" : ""}"></span>${projGlyph(p)}<span class="nm">${disamb(p)}${esc(p.name)}</span>${spendSpark(p.id)}<small>${live(p.id) || ""}</small>${act}</div>`;
|
|
276
323
|
};
|
|
277
324
|
const liveAll = live("");
|
|
278
325
|
$("#projects").innerHTML =
|
|
@@ -317,12 +364,27 @@ projectsEl.addEventListener("dragend", () => {
|
|
|
317
364
|
fetch("/v1/projects/order", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }) }).then(refresh);
|
|
318
365
|
});
|
|
319
366
|
|
|
367
|
+
// First run: no sessions have ever been seen. Say exactly what to do next, and whether hooks are in.
|
|
368
|
+
function onboarding() {
|
|
369
|
+
const hooksOk = state.hooksInstalled !== false;
|
|
370
|
+
const step = (n, done, html) => `<div class="ob-step ${done ? "done" : ""}"><span class="ob-n">${done ? "✓" : n}</span><div>${html}</div></div>`;
|
|
371
|
+
return `<div class="onboard">${PX.idle()}
|
|
372
|
+
<h3>Swarm is running and watching this machine.</h3>
|
|
373
|
+
<div class="ob-steps">
|
|
374
|
+
${step(1, hooksOk, `<b>Hook into Claude Code</b> — <code>swarm install</code> once${hooksOk ? "" : " <span class='badge warn'>not installed</span>"}. Codex and Grok are picked up automatically, nothing to configure.`)}
|
|
375
|
+
${step(2, false, `<b>Open any agent session</b> — run <code>claude</code> in any repository, in any terminal. No changes to the repo, the agent doesn't know Swarm is there.`)}
|
|
376
|
+
${step(3, false, `<b>Watch it appear here</b> — live status, branch, tokens and cost per session; Board, Timeline and Spend fill up as you work.`)}
|
|
377
|
+
</div>
|
|
378
|
+
<div class="dim">Something off? <code>swarm doctor</code> checks every piece and prints the fix.</div>
|
|
379
|
+
</div>`;
|
|
380
|
+
}
|
|
381
|
+
|
|
320
382
|
// ---------- fleet
|
|
321
383
|
// Fleet data-grid columns (sortable/resizable/reorderable/filterable via table.js).
|
|
322
384
|
const FLEET_COLS = [
|
|
323
385
|
{ key: "project", label: "project", width: 112, get: (s) => projName(s.projectId), cell: (s) => projCell(s.projectId) },
|
|
324
386
|
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (s) => agentLabel(s.agent), cell: (s) => agentBadge(s.agent) },
|
|
325
|
-
{ key: "session", label: "session", width: 210, 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>' : ""}` },
|
|
387
|
+
{ key: "session", label: "session", width: 210, 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>' : ""}${s.stuck ? ` <span class="badge bad" title="${esc(s.stuck)} — heuristic, nothing was interrupted; open the session to judge">Stuck</span>` : ""}` },
|
|
326
388
|
{ key: "branch", label: "branch", width: 116, get: (s) => s.branch ?? "", cell: (s) => `<span class="br">${esc(s.branch ?? "")}</span>` },
|
|
327
389
|
{ key: "now", label: "now", flex: true, get: (s) => s.last, cell: (s) => {
|
|
328
390
|
const line = s.lastText ? s.lastText.split("\n").find((l) => l.trim()) ?? "" : "";
|
|
@@ -363,7 +425,7 @@ function renderFleet() {
|
|
|
363
425
|
: "";
|
|
364
426
|
$("#main").innerHTML = chips +
|
|
365
427
|
`<h2>Live <span>${live.length} sessions · ${usd(sumBy(live, (s) => s.costUsd))}</span></h2>` +
|
|
366
|
-
(live.length ? table(live, "fleet-live") : `<div class="empty">${PX.idle()}Nothing running
|
|
428
|
+
(live.length ? table(live, "fleet-live") : state.sessions.length ? `<div class="empty">${PX.idle()}Nothing running.</div>` : onboarding()) +
|
|
367
429
|
(rest.length ? `<h2 class="mt-sec">Earlier <span>${rest.length}</span></h2>${table(rest.slice(0, 30), "fleet-earlier")}` : "") +
|
|
368
430
|
"";
|
|
369
431
|
}
|
|
@@ -430,7 +492,7 @@ function renderBoardKpis() {
|
|
|
430
492
|
}
|
|
431
493
|
|
|
432
494
|
function renderBoard() {
|
|
433
|
-
const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
495
|
+
const parts = [renderBoardKpis(), renderTasks(), renderDispatch(), renderWorkflowRuns(), renderGates(), renderProcesses(), renderResources(), renderClaims(), renderWorktrees(), renderIncidents()].filter(Boolean);
|
|
434
496
|
$("#main").innerHTML = parts.length
|
|
435
497
|
? parts.join("").replace(/^(<div class="kpis[^>]*>[\s\S]*?<\/div><\/div>|)(<h2) class="mt-sec"/, "$1$2") // first section needs no top gap
|
|
436
498
|
: `<div class="empty">${PX.idle()}Nothing on the board.<br>Tasks, processes, claims, worktrees, and incidents appear here.</div>`;
|
|
@@ -627,7 +689,13 @@ function renderGates() {
|
|
|
627
689
|
{ 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>') },
|
|
628
690
|
{ 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>') },
|
|
629
691
|
];
|
|
630
|
-
|
|
692
|
+
const history = (gate) => {
|
|
693
|
+
const rs = runs.filter((r) => r.gate === gate).slice(0, 12).reverse();
|
|
694
|
+
if (!rs.length) return "";
|
|
695
|
+
return `<span class="gh" title="${esc(gate)} — last ${rs.length} run${rs.length === 1 ? "" : "s"}, oldest first">${esc(gate)} ${rs.map((r) => `<i class="${r.verdict === "pass" ? "ok" : "bad"}" title="${esc(r.rubric)}"></i>`).join("")}</span>`;
|
|
696
|
+
};
|
|
697
|
+
const gateNames = [...new Set(runs.map((r) => r.gate))];
|
|
698
|
+
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>${gateNames.length ? `<span class="grow"></span><span class="gh-strip">${gateNames.map(history).join("")}</span>` : ""}</h2>` +
|
|
631
699
|
(runs.length
|
|
632
700
|
? dataTable({
|
|
633
701
|
id: "gates",
|
|
@@ -772,6 +840,37 @@ function renderWorktrees() {
|
|
|
772
840
|
}));
|
|
773
841
|
}
|
|
774
842
|
|
|
843
|
+
// ---------- workflows (M7.8)
|
|
844
|
+
function renderWorkflowRuns() {
|
|
845
|
+
const w = state.workflows;
|
|
846
|
+
if (!state.sel || !w?.runs?.length) return "";
|
|
847
|
+
const chip = (r, i) => {
|
|
848
|
+
const label = esc(r.steps[i]);
|
|
849
|
+
if (i < r.step || (r.state === "done" && i <= r.step)) return `<span class="wfs ok" title="${label}">✓ ${label}</span>`;
|
|
850
|
+
if (i === r.step) return r.state === "running" ? `<span class="wfs run" title="${label}">● ${label}</span>` : r.state === "failed" ? `<span class="wfs bad" title="${label}">✗ ${label}</span>` : `<span class="wfs" title="${label}">◦ ${label}</span>`;
|
|
851
|
+
return `<span class="wfs" title="${label}">○ ${label}</span>`;
|
|
852
|
+
};
|
|
853
|
+
const badge = (r) => r.state === "running" ? '<span class="badge acc">Running</span>' : r.state === "done" ? '<span class="badge ok">Done</span>' : r.state === "failed" ? '<span class="badge warn">Failed</span>' : '<span class="badge">Stopped</span>';
|
|
854
|
+
const cols = [
|
|
855
|
+
{ key: "task", label: "task", width: 90, get: (r) => r.task, cell: (r) => `<b>${esc(r.task)}</b>` },
|
|
856
|
+
{ key: "workflow", label: "workflow", width: 100, get: (r) => r.workflow, cell: (r) => `<span class="br">${esc(r.workflow)}</span>` },
|
|
857
|
+
{ key: "steps", label: "steps", flex: true, sortable: false, get: (r) => r.step, cell: (r) => `<span class="wf-steps">${r.steps.map((_, i) => chip(r, i)).join("")}</span>` },
|
|
858
|
+
{ key: "state", label: "state", width: 90, get: (r) => r.state, cell: badge },
|
|
859
|
+
{ key: "detail", label: "detail", width: 260, get: (r) => r.detail ?? "", cell: (r) => `<span class="dim now" title="${esc(r.detail ?? "")}">${esc(r.detail ?? "")}</span>` },
|
|
860
|
+
{ key: "when", label: "updated", width: 76, get: (r) => r.updatedAt, cell: (r) => `<span class="dim">${ago(r.updatedAt)}</span>` },
|
|
861
|
+
];
|
|
862
|
+
const running = w.runs.filter((r) => r.state === "running").length;
|
|
863
|
+
return `<h2 class="mt-sec">Workflows <span>${running ? `${running} running · ` : ""}${Object.keys(w.defs ?? {}).map(esc).join(", ") || "none declared"}</span></h2>` +
|
|
864
|
+
dataTable({
|
|
865
|
+
id: "workflows",
|
|
866
|
+
columns: cols,
|
|
867
|
+
rows: w.runs.slice(0, 20),
|
|
868
|
+
leading: { width: 24, cell: (r) => `<span class="s ${r.state === "running" ? "active" : r.state === "failed" ? "waiting" : "ended"}"></span>` },
|
|
869
|
+
trailing: { width: 60, cell: (r) => (r.state === "running" ? `<a href="#" data-wfstop="${esc(r.task)}">Stop</a>` : "") },
|
|
870
|
+
rerender: touch,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
775
874
|
// ---------- dispatch (M7.5)
|
|
776
875
|
function renderDispatch() {
|
|
777
876
|
const d = state.dispatch;
|
|
@@ -1059,7 +1158,92 @@ function renderStats() {
|
|
|
1059
1158
|
}
|
|
1060
1159
|
|
|
1061
1160
|
// ---------- timeline
|
|
1161
|
+
let tlDetail = { key: "", data: null, busy: false };
|
|
1162
|
+
async function loadTimelineDetail() {
|
|
1163
|
+
const hours = state.tlHours ?? 12;
|
|
1164
|
+
const key = `${hours}:${state.sel ?? ""}`;
|
|
1165
|
+
if (tlDetail.busy || (tlDetail.key === key && tlDetail.at && Date.now() - tlDetail.at < 15_000)) return;
|
|
1166
|
+
tlDetail.busy = true;
|
|
1167
|
+
try {
|
|
1168
|
+
const q = new URLSearchParams({ hours: String(hours) });
|
|
1169
|
+
if (state.sel) q.set("project", state.sel);
|
|
1170
|
+
const data = await (await fetch(`/v1/timeline?${q}`)).json();
|
|
1171
|
+
tlDetail = { key, at: Date.now(), data, busy: false };
|
|
1172
|
+
if (state.view === "timeline" && !state.session) touch();
|
|
1173
|
+
} finally { tlDetail.busy = false; }
|
|
1174
|
+
}
|
|
1175
|
+
// M9.2: Outcomes — did the agent's work survive? Branch → PR → merged / reverted, with
|
|
1176
|
+
// scorecards per model and per agent. Data from /v1/outcomes (fetched by the poll while open).
|
|
1177
|
+
const outBadge = (o) => ({ merged: '<span class="badge ok">merged</span>', reverted: '<span class="badge bad">reverted</span>', open: '<span class="badge acc">open</span>', "no-pr": '<span class="badge">no PR</span>' })[o] ?? esc(o);
|
|
1178
|
+
const ratePct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
|
|
1179
|
+
const hrs = (x) => (x == null || x < 0 ? "—" : x < 1 ? `${Math.round(x * 60)}m` : x < 48 ? `${x.toFixed(1)}h` : `${(x / 24).toFixed(1)}d`);
|
|
1180
|
+
const scoreCols = (label) => [
|
|
1181
|
+
{ key: "key", label, width: 150, get: (r) => r.key, cell: (r) => `<b>${esc(label === "model" ? model(r.key) : viz.agentName(r.key))}</b>` },
|
|
1182
|
+
{ key: "branches", label: "branches", width: 80, num: true, get: (r) => r.branches, cell: (r) => String(r.branches) },
|
|
1183
|
+
{ key: "merged", label: "merged", width: 72, num: true, get: (r) => r.merged, cell: (r) => String(r.merged) },
|
|
1184
|
+
{ key: "reverted", label: "reverted", width: 78, num: true, get: (r) => r.reverted, cell: (r) => (r.reverted ? `<b style="color:var(--bad)">${r.reverted}</b>` : "0") },
|
|
1185
|
+
{ key: "open", label: "open", width: 60, num: true, get: (r) => r.open, cell: (r) => String(r.open) },
|
|
1186
|
+
{ key: "nopr", label: "no PR", width: 64, num: true, get: (r) => r.noPr, cell: (r) => String(r.noPr) },
|
|
1187
|
+
{ key: "rate", label: "merge rate", width: 92, num: true, get: (r) => r.mergeRate ?? -1, cell: (r) => ratePct(r.mergeRate) },
|
|
1188
|
+
{ key: "lead", label: "median lead", width: 98, num: true, get: (r) => r.medianLeadHours ?? -1, cell: (r) => hrs(r.medianLeadHours) },
|
|
1189
|
+
{ key: "cpm", label: "$ / merge", width: 84, num: true, get: (r) => r.costPerMerge ?? -1, cell: (r) => (r.costPerMerge == null ? "—" : usd(r.costPerMerge)) },
|
|
1190
|
+
];
|
|
1191
|
+
const BRANCH_COLS = [
|
|
1192
|
+
{ key: "branch", label: "branch", width: 190, get: (r) => r.branch, cell: (r) => `<span class="br">${esc(r.branch)}</span>` },
|
|
1193
|
+
{ key: "outcome", label: "outcome", width: 92, cls: "td-badge", get: (r) => r.outcome, cell: (r) => outBadge(r.outcome) },
|
|
1194
|
+
{ key: "pr", label: "PR", flex: true, get: (r) => r.title ?? "", cell: (r) => (r.prNumber ? `<a href="${esc(r.url ?? "#")}" target="_blank" rel="noreferrer">#${r.prNumber}</a> <span class="dim">${esc(r.title ?? "")}</span>` : '<span class="faint">—</span>') },
|
|
1195
|
+
{ key: "model", label: "model", width: 92, get: (r) => model(r.model), cell: (r) => `<span class="br">${esc(model(r.model))}</span>` },
|
|
1196
|
+
{ key: "agent", label: "agent", width: 78, cls: "td-badge", get: (r) => agentLabel(r.agent), cell: (r) => agentBadge(r.agent) },
|
|
1197
|
+
{ key: "sessions", label: "sessions", width: 76, num: true, get: (r) => r.sessions.length, cell: (r) => String(r.sessions.length) },
|
|
1198
|
+
{ key: "cost", label: "cost", width: 64, num: true, get: (r) => r.costUsd, cell: (r) => usd(r.costUsd) },
|
|
1199
|
+
{ key: "lead", label: "lead", width: 64, num: true, get: (r) => r.leadHours ?? -1, cell: (r) => hrs(r.leadHours) },
|
|
1200
|
+
];
|
|
1201
|
+
function renderOutcomes() {
|
|
1202
|
+
const o = state.outcomes;
|
|
1203
|
+
const head = (sub) => `<h2>Outcomes <span>${sub}</span></h2>`;
|
|
1204
|
+
if (!o) {
|
|
1205
|
+
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}Loading…</div>`;
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (!o.branches?.length) {
|
|
1209
|
+
$("#main").innerHTML = head("did the work survive?") + `<div class="empty">${PX.idle()}No agent branches yet${state.sel ? " in this project" : ""}.<br>Outcomes fill in as sessions work on branches and their PRs merge — or get reverted.</div>`;
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const n = (k) => o.branches.filter((b) => b.outcome === k).length;
|
|
1213
|
+
const rev = n("reverted");
|
|
1214
|
+
$("#main").innerHTML =
|
|
1215
|
+
head(`${o.branches.length} branch${o.branches.length === 1 ? "" : "es"} · ${n("merged")} merged · ${rev ? `<b style="color:var(--bad)">${rev} reverted</b>` : "0 reverted"} · ${n("open")} open`) +
|
|
1216
|
+
`<h2 class="mt-sec">By model <span>who ships work that survives</span></h2>` +
|
|
1217
|
+
dataTable({ id: "outcomes-model", columns: scoreCols("model"), rows: o.byModel }) +
|
|
1218
|
+
(o.byAgent.length > 1 ? `<h2 class="mt-sec">By agent</h2>${dataTable({ id: "outcomes-agent", columns: scoreCols("agent"), rows: o.byAgent })}` : "") +
|
|
1219
|
+
`<h2 class="mt-sec">Branches <span>latest first</span></h2>` +
|
|
1220
|
+
dataTable({ id: "outcomes-branches", columns: BRANCH_COLS, rows: o.branches.slice(0, 100) });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// M9.12: live file-collision graph — which live sessions touch which files, contested files
|
|
1224
|
+
// highlighted. Data from /v1/graphs/collisions (fetched by the poll while the view is open).
|
|
1225
|
+
function renderGraphs() {
|
|
1226
|
+
const g = state.collisions;
|
|
1227
|
+
const title = (s) => s.title ?? s.id.slice(0, 8);
|
|
1228
|
+
const head = (sub) => `<h2>Graphs <span>${sub}</span></h2>`;
|
|
1229
|
+
if (!g || !g.sessions.length) {
|
|
1230
|
+
$("#main").innerHTML = head("live file collisions") + `<div class="empty">${PX.idle()}No live sessions${state.sel ? " in this project" : ""}.<br>The collision graph shows who is touching what, the moment two agents run at once.</div>`;
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (!g.files.length) {
|
|
1234
|
+
$("#main").innerHTML = head(`${g.sessions.length} live session${g.sessions.length === 1 ? "" : "s"}`) + `<div class="empty">${PX.idle()}No file touches recorded yet — the graph fills in as agents read and edit.</div>`;
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
const sessions = g.sessions.map((s) => ({ ...s, label: title(s) }));
|
|
1238
|
+
const agents = [...new Set(sessions.map((s) => s.agent))].sort(viz.agentSort);
|
|
1239
|
+
const sub = `${sessions.length} live session${sessions.length === 1 ? "" : "s"} · ${g.files.length} file${g.files.length === 1 ? "" : "s"} · ${g.contested ? `<b class="navcount">${g.contested} contested</b>` : "no collisions"}`;
|
|
1240
|
+
$("#main").innerHTML = head(sub) +
|
|
1241
|
+
`<div class="card" style="padding:14px">${viz.bipartite(sessions, g.files)}</div>
|
|
1242
|
+
<div style="margin-top:10px;display:flex;gap:16px;align-items:center">${viz.legend(agents)}<span class="dim" style="font-size:var(--fs-sm)">solid edge = writing · faint edge = reading · <span style="color:var(--bad)">red file</span> = two sessions on it, at least one writing</span></div>`;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1062
1245
|
function renderTimeline() {
|
|
1246
|
+
loadTimelineDetail();
|
|
1063
1247
|
const now = Date.now();
|
|
1064
1248
|
const hours = state.tlHours ?? 12;
|
|
1065
1249
|
const from = now - hours * 3.6e6, to = now + 0.25 * 3.6e6;
|
|
@@ -1068,7 +1252,7 @@ function renderTimeline() {
|
|
|
1068
1252
|
const chip = (h) => `<a href="#" class="nav ${hours === h ? "on" : ""}" data-tl="${h}">${h}h</a>`;
|
|
1069
1253
|
$("#main").innerHTML =
|
|
1070
1254
|
`<h2>Timeline <span>${rows.length} sessions · last ${hours}h · ${usd(sumBy(rows, (s) => s.costUsd))}</span><span style="margin-left:auto;display:flex;gap:2px">${[3, 6, 12, 24, 72].map(chip).join("")}</span></h2>
|
|
1071
|
-
${rows.length ? viz.timeline(rows, { from, to, projName, now }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
|
|
1255
|
+
${rows.length ? viz.timeline(rows, { from, to, projName, now, detail: tlDetail.key === `${hours}:${state.sel ?? ""}` ? tlDetail.data : null }) : `<div class="empty">${PX.clock()}No sessions in the last ${hours}h.</div>`}
|
|
1072
1256
|
${agents.length ? `<div style="margin-top:10px">${viz.legend(agents)}</div>` : ""}`;
|
|
1073
1257
|
}
|
|
1074
1258
|
|
|
@@ -1185,6 +1369,18 @@ function replayGo(delta) {
|
|
|
1185
1369
|
}
|
|
1186
1370
|
|
|
1187
1371
|
// Spawned sessions get a stdin box while their run is live (M3.3); interactive ones are told where to type.
|
|
1372
|
+
// M7.6: the session's message thread (sent + received) and a compose box.
|
|
1373
|
+
function messageThread(s) {
|
|
1374
|
+
const ms = (state.msgs ?? []).filter((m) => m.sessionId === s.id || m.fromSession === s.id).slice().reverse();
|
|
1375
|
+
const row = (m) => {
|
|
1376
|
+
const out = m.fromSession === s.id;
|
|
1377
|
+
return `<div class="msg ${out ? "out" : "in"}" title="${esc(m.createdAt)}${m.deliveredAt ? "" : " · not delivered yet"}">
|
|
1378
|
+
<span class="msg-f">${out ? `→ ${esc(m.task ?? m.toKind)}` : esc(m.from ?? "?")}${m.deliveredAt ? "" : ' <i class="dim">·queued</i>'}</span>${esc(m.text)}</div>`;
|
|
1379
|
+
};
|
|
1380
|
+
return `<h4>messages</h4>${ms.length ? `<div class="msgs">${ms.map(row).join("")}</div>` : '<span class="dim">None yet.</span>'}
|
|
1381
|
+
<div class="msg-compose"><input id="msgText" placeholder="Message this agent… (delivered on its next tool call)" autocomplete="off"><button id="msgSend" data-sid="${s.id}" data-pid="${s.projectId}">${ic("arrow-right", 13)}</button></div>`;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1188
1384
|
// M7.7: questions this session is waiting on a human for
|
|
1189
1385
|
function questionCards(s) {
|
|
1190
1386
|
const qs = (state.questions ?? []).filter((q) => q.sessionId === s.id);
|
|
@@ -1251,6 +1447,7 @@ function renderSession() {
|
|
|
1251
1447
|
<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 }])}
|
|
1252
1448
|
${state.turns.length > 1 ? `<h4>cost per turn</h4>${viz.turnStrip(state.turns, { height: 54 })}` : ""}
|
|
1253
1449
|
<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>'}
|
|
1450
|
+
${messageThread(s)}
|
|
1254
1451
|
${questionCards(s)}
|
|
1255
1452
|
${s.transcriptPath ? `<h4>transcript</h4><div class="dim mono" style="word-break:break-all">${ic("file-text", 12)} ${esc(short(s.transcriptPath))}</div>` : ""}`;
|
|
1256
1453
|
if (logEl && isAppend(rows)) {
|
|
@@ -1496,6 +1693,10 @@ ${p.reason ?? ""}`.slice(0, 180);
|
|
|
1496
1693
|
title = "An agent has a question";
|
|
1497
1694
|
body = `${p.task ? `${p.task}: ` : ""}${p.text ?? ""}`.slice(0, 180);
|
|
1498
1695
|
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
1696
|
+
} else if (ev.type === "session.stuck") {
|
|
1697
|
+
title = "Session looks stuck";
|
|
1698
|
+
body = (p.reason ?? p.summary ?? "").slice(0, 180);
|
|
1699
|
+
onClick = () => { if (ev.sessionId) openSession(ev.sessionId); };
|
|
1499
1700
|
} else if (ev.type === "claim.orphaned") {
|
|
1500
1701
|
title = "Claim orphaned";
|
|
1501
1702
|
body = `${p.task ?? "a task"} — its lease expired with unfinished work in the worktree.`;
|
|
@@ -1527,6 +1728,33 @@ function whatsNew(version) {
|
|
|
1527
1728
|
}
|
|
1528
1729
|
window.swarmWhatsNew = (v) => whatsNew(v);
|
|
1529
1730
|
// auto-open once per version, but never on the very first run (nothing to compare against)
|
|
1731
|
+
// M-launch: after an update the running daemon is the old build until it restarts. The daemon
|
|
1732
|
+
// reports the version on disk; when it differs, offer a one-click restart, then reload.
|
|
1733
|
+
let updateNudged = false;
|
|
1734
|
+
setInterval(() => { fetch("/v1/health").then((r) => r.json()).then(maybeUpdateNudge).catch(() => {}); }, 300_000);
|
|
1735
|
+
function maybeUpdateNudge(h) {
|
|
1736
|
+
if (!h?.disk || !h.version || h.disk === h.version || updateNudged) return;
|
|
1737
|
+
updateNudged = true;
|
|
1738
|
+
const el = document.createElement("div");
|
|
1739
|
+
el.className = "nudge";
|
|
1740
|
+
el.innerHTML = `${ic("arrows-clockwise", 18, "ic")}<div><b>Swarm ${esc(h.disk)} is installed</b>The daemon is still running ${esc(h.version)} — restart it to switch. Sessions and history are unaffected.
|
|
1741
|
+
<div class="row"><button class="pri" id="updRestart">${ic("arrows-clockwise", 13)} Restart daemon</button><button id="updLater">Later</button></div></div>`;
|
|
1742
|
+
document.body.appendChild(el);
|
|
1743
|
+
el.addEventListener("click", async (e) => {
|
|
1744
|
+
if (e.target.id === "updLater") return el.remove();
|
|
1745
|
+
if (e.target.id !== "updRestart") return;
|
|
1746
|
+
e.target.textContent = "restarting…";
|
|
1747
|
+
await fetch("/v1/daemon/restart", { method: "POST" }).catch(() => {});
|
|
1748
|
+
const t0 = Date.now();
|
|
1749
|
+
const wait = setInterval(async () => {
|
|
1750
|
+
try {
|
|
1751
|
+
const j = await (await fetch("/v1/health")).json();
|
|
1752
|
+
if (j.version === h.disk) { clearInterval(wait); location.reload(); }
|
|
1753
|
+
} catch {}
|
|
1754
|
+
if (Date.now() - t0 > 30_000) { clearInterval(wait); el.remove(); }
|
|
1755
|
+
}, 800);
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1530
1758
|
function maybeWhatsNew() {
|
|
1531
1759
|
if (!state.version || !window.RELEASE_NOTES) return;
|
|
1532
1760
|
let seen; try { seen = localStorage.getItem("swarm.seenVersion"); } catch {}
|
|
@@ -1581,6 +1809,9 @@ function openMenu(kind, anchor, d) {
|
|
|
1581
1809
|
if (!window.menus) { console.warn("menus.js not built — run: bun run build:web"); return; }
|
|
1582
1810
|
window.menus.open(anchor, spec);
|
|
1583
1811
|
}
|
|
1812
|
+
document.addEventListener("keydown", (e) => {
|
|
1813
|
+
if (e.key === "Enter" && e.target.id === "msgText") { e.preventDefault(); $("#msgSend")?.click(); }
|
|
1814
|
+
});
|
|
1584
1815
|
// Enter / Space on a focused card, tile or kebab opens its menu like a click.
|
|
1585
1816
|
document.addEventListener("keydown", (ev) => {
|
|
1586
1817
|
if (ev.key !== "Enter" && ev.key !== " ") return;
|
|
@@ -1597,8 +1828,11 @@ document.addEventListener("contextmenu", (ev) => {
|
|
|
1597
1828
|
});
|
|
1598
1829
|
|
|
1599
1830
|
// ---------- events
|
|
1831
|
+
// Every id / data-attr a branch below matches on MUST be in this selector, or the branch is
|
|
1832
|
+
// unreachable (closest() returns null and the click dies silently) — that is how Replay,
|
|
1833
|
+
// Resume-where-it-died and the dry-run Re-run button all shipped dead.
|
|
1600
1834
|
document.addEventListener("click", async (ev) => {
|
|
1601
|
-
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],[data-codify],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#dispatch,#dispatchGo,#dispatchClear");
|
|
1835
|
+
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,#replay,#resumeDead,#drRun,#wtnew,#wtgc,[data-gaterun],[data-codify],[data-wfstop],[data-bmode],[data-emoji],#psAllEmoji,.swatch,#psSave,#msgSend,#dispatch,#dispatchGo,#dispatchClear");
|
|
1602
1836
|
if (!t) return;
|
|
1603
1837
|
if (t.dataset.menu) { ev.preventDefault(); ev.stopPropagation(); return openMenu(t.dataset.menu, t, t.dataset); }
|
|
1604
1838
|
if (t.id === "settings") { ev.preventDefault(); return openMenu("settings", t, {}); }
|
|
@@ -1609,7 +1843,25 @@ document.addEventListener("click", async (ev) => {
|
|
|
1609
1843
|
if (t.dataset.emoji !== undefined) { $("#psIcon").value = t.dataset.emoji; $("#psImage").value = ""; setIconPreview(t.dataset.emoji); for (const e of $$(".emoji")) e.classList.toggle("on", e.dataset.emoji === t.dataset.emoji); return; }
|
|
1610
1844
|
if (t.id === "psAllEmoji") { const all = $("#psEmojiAll"); if (all.hidden) { all.innerHTML = buildEmojiGrid(); all.hidden = false; } else all.hidden = true; return; }
|
|
1611
1845
|
if (t.dataset.color !== undefined && t.classList.contains("swatch")) { for (const e of $$(".swatch")) e.classList.toggle("on", e === t); return; }
|
|
1846
|
+
if (t.id === "msgSend") {
|
|
1847
|
+
ev.preventDefault();
|
|
1848
|
+
const text = $("#msgText")?.value.trim();
|
|
1849
|
+
if (!text) return;
|
|
1850
|
+
const r = await post("/v1/messages", { projectId: t.dataset.pid, to: t.dataset.sid, text, from: "dashboard" });
|
|
1851
|
+
if (!r.ok) return alert(r.error);
|
|
1852
|
+
$("#msgText").value = "";
|
|
1853
|
+
state.msgs = null;
|
|
1854
|
+
return refresh();
|
|
1855
|
+
}
|
|
1612
1856
|
if (t.id === "psSave") { ev.preventDefault(); return saveProjectSettings(t.dataset.pid); }
|
|
1857
|
+
if (t.dataset.wfstop !== undefined) {
|
|
1858
|
+
ev.preventDefault();
|
|
1859
|
+
if (!confirm(`Stop the workflow on ${t.dataset.wfstop}? A live step's run is stopped too.`)) return;
|
|
1860
|
+
const r = await post("/v1/workflows/stop", { projectId: state.sel, task: t.dataset.wfstop });
|
|
1861
|
+
if (!r.ok) alert(r.error);
|
|
1862
|
+
state.workflows = null;
|
|
1863
|
+
return refresh();
|
|
1864
|
+
}
|
|
1613
1865
|
if (t.dataset.bmode) { ev.preventDefault(); const [k, v] = t.dataset.bmode.split(":"); localStorage.setItem(`swarm.board.${k}`, v); return touch(); }
|
|
1614
1866
|
if (t.dataset.run) { ev.preventDefault(); return openRunDrawer(t.dataset.run); }
|
|
1615
1867
|
if (t.dataset.runstop) {
|
|
@@ -1716,6 +1968,57 @@ $("#sbToggle")?.addEventListener("click", () => {
|
|
|
1716
1968
|
});
|
|
1717
1969
|
sbApply();
|
|
1718
1970
|
|
|
1971
|
+
// ---------- ⌘K palette (M9.1): jump to any view, project or session; falls through to Search.
|
|
1972
|
+
const pal = { items: [], view: [], q: "", i: 0 };
|
|
1973
|
+
function palBuild() {
|
|
1974
|
+
const items = VIEW_DEFS.map((v) => ({ icon: v.icon, label: v.label, grp: v.group.toLowerCase(), run: () => { state.view = v.id; localStorage.setItem("swarm.view", v.id); state.session = null; state.dirty = true; refresh(); } }));
|
|
1975
|
+
for (const p of state.projects) items.push({ icon: "folder-simple", label: p.name, grp: "project", run: () => { state.sel = p.id; localStorage.setItem("swarm.sel", p.id); state.session = null; state.dirty = true; refresh(); } });
|
|
1976
|
+
const pname = (id) => state.projects.find((p) => p.id === id)?.name ?? "";
|
|
1977
|
+
for (const s of state.sessions) items.push({ icon: "terminal-window", label: s.title || s.id.slice(0, 8), sub: pname(s.projectId), live: isLive(s), grp: "session", run: () => openSession(s.id) });
|
|
1978
|
+
return items;
|
|
1979
|
+
}
|
|
1980
|
+
function palFilter() {
|
|
1981
|
+
const q = pal.q.trim().toLowerCase();
|
|
1982
|
+
const rank = (x) => Math.min(...[x.label, x.sub ?? ""].map((t) => { const i = t.toLowerCase().indexOf(q); return i < 0 ? 1e9 : i; }));
|
|
1983
|
+
const out = q
|
|
1984
|
+
? pal.items.map((x) => ({ x, r: rank(x) })).filter((h) => h.r < 1e9).sort((a, b) => a.r - b.r).map((h) => h.x).slice(0, 12)
|
|
1985
|
+
: pal.items.filter((x) => x.grp !== "session" || x.live).slice(0, 16); // idle: every view + project + live sessions
|
|
1986
|
+
if (q) out.push({ icon: "magnifying-glass", label: `Search Swarm for “${pal.q.trim()}”`, grp: "search", run: () => { srch.q = pal.q.trim(); state.view = "search"; localStorage.setItem("swarm.view", "search"); state.session = null; state.dirty = true; runSearch(); refresh(); } });
|
|
1987
|
+
return out;
|
|
1988
|
+
}
|
|
1989
|
+
function palRender() {
|
|
1990
|
+
pal.view = palFilter();
|
|
1991
|
+
if (pal.i >= pal.view.length) pal.i = Math.max(0, pal.view.length - 1);
|
|
1992
|
+
const row = (x, i) => `<div class="pk-row pal-row ${i === pal.i ? "on" : ""}" data-pal="${i}">${ic(x.icon, 14)}<span class="nm">${esc(x.label)}${x.sub ? ` <span class="dim">· ${esc(x.sub)}</span>` : ""}</span><span class="grp">${x.grp}</span></div>`;
|
|
1993
|
+
const el = $("#palList");
|
|
1994
|
+
if (el) el.innerHTML = pal.view.map(row).join("") || '<div class="empty" style="padding:16px">No matches.</div>';
|
|
1995
|
+
}
|
|
1996
|
+
function palRun(i) {
|
|
1997
|
+
const x = pal.view[i];
|
|
1998
|
+
if (!x) return;
|
|
1999
|
+
closePicker();
|
|
2000
|
+
x.run();
|
|
2001
|
+
}
|
|
2002
|
+
function openPalette() {
|
|
2003
|
+
pal.items = palBuild(); pal.q = ""; pal.i = 0;
|
|
2004
|
+
$("#picker").innerHTML = `<div class="pk pal" role="dialog" aria-modal="true">
|
|
2005
|
+
<div class="pk-h">${ic("magnifying-glass", 15)}<input id="palQ" placeholder="Jump to view, project or session…" spellcheck="false" autocomplete="off"></div>
|
|
2006
|
+
<div class="pk-list" id="palList"></div>
|
|
2007
|
+
</div>`;
|
|
2008
|
+
palRender();
|
|
2009
|
+
const inp = $("#palQ");
|
|
2010
|
+
inp.focus();
|
|
2011
|
+
inp.addEventListener("input", () => { pal.q = inp.value; pal.i = 0; palRender(); });
|
|
2012
|
+
inp.addEventListener("keydown", (ev) => {
|
|
2013
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); pal.i = Math.max(0, Math.min(pal.view.length - 1, pal.i + (ev.key === "ArrowDown" ? 1 : -1))); palRender(); }
|
|
2014
|
+
else if (ev.key === "Enter") { ev.preventDefault(); palRun(pal.i); }
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
$("#palBtn")?.addEventListener("click", openPalette);
|
|
2018
|
+
document.addEventListener("keydown", (ev) => {
|
|
2019
|
+
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { ev.preventDefault(); if ($("#palQ")) closePicker(); else openPalette(); }
|
|
2020
|
+
});
|
|
2021
|
+
|
|
1719
2022
|
// ---------- folder picker
|
|
1720
2023
|
const picker = { path: null };
|
|
1721
2024
|
// Run drawer (M3.3): prompt prefilled from the task row; submit = POST /v1/runs.
|
|
@@ -1961,6 +2264,8 @@ async function pickerGo(path) {
|
|
|
1961
2264
|
const closePicker = () => { $("#picker").innerHTML = ""; };
|
|
1962
2265
|
$("#picker").addEventListener("click", (ev) => {
|
|
1963
2266
|
if (ev.target.id === "picker" || ev.target.closest("#pkCancel")) return closePicker();
|
|
2267
|
+
const pr = ev.target.closest("[data-pal]");
|
|
2268
|
+
if (pr) return palRun(Number(pr.dataset.pal));
|
|
1964
2269
|
const go = ev.target.closest("[data-go]");
|
|
1965
2270
|
if (go) return void pickerGo(go.dataset.go);
|
|
1966
2271
|
const ctoml = ev.target.closest("[data-copy-toml]"), cles = ev.target.closest("[data-copy-lesson]");
|
|
@@ -2008,7 +2313,7 @@ function connect() {
|
|
|
2008
2313
|
if (fresh) notifyForEvent(ev);
|
|
2009
2314
|
pollSoon();
|
|
2010
2315
|
};
|
|
2011
|
-
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);
|
|
2316
|
+
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", "message.sent", "dispatch.queued", "dispatch.started", "dispatch.finished", "workflow.started", "workflow.step", "workflow.finished", "permission.requested", "permission.resolved"]) es.addEventListener(t, onAny);
|
|
2012
2317
|
}
|
|
2013
2318
|
refresh().then(() => {
|
|
2014
2319
|
const sid = new URLSearchParams(location.search).get("session");
|