@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/README.md
CHANGED
|
@@ -22,7 +22,7 @@ A local-first control plane for AI-agent development on any repository.</p>
|
|
|
22
22
|
<a href="LICENSE"><img alt="Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-lightgrey?labelColor=0e1013"></a>
|
|
23
23
|
</p>
|
|
24
24
|
|
|
25
|
-
<p align="center"><a href="https://getswarm.vercel.app"><img src="docs/art/screens/fleet.
|
|
25
|
+
<p align="center"><a href="https://getswarm.vercel.app"><img src="docs/art/screens/fleet.png" alt="Swarm Fleet view — every agent session on the machine, live" width="100%"></a></p>
|
|
26
26
|
|
|
27
27
|
Run more than one [Claude Code](https://claude.com/claude-code) session at a time — or a [Codex CLI](https://github.com/openai/codex) or Grok run on the side — and you lose the thread fast: which session is on which branch, what it's costing, which worktree has uncommitted work nobody owns, why that edit got blocked. Swarm is one daemon that watches every session on your machine — live tool calls, reasoning, token spend, cost — keeps a ledger of who holds which task, worktree and runtime resource, turns the "never do X" prose in `CLAUDE.md` into real permission decisions, and streams all of it to one dashboard.
|
|
28
28
|
|
|
@@ -42,11 +42,11 @@ bunx @ra3orblade/swarm setup
|
|
|
42
42
|
|
|
43
43
|
**Session** — an agent's reasoning and tool calls as a live stream, with cost per turn, cache hit rate, thinking share, tool histogram and the transcript path.
|
|
44
44
|
|
|
45
|
-
<p align="center"><img src="docs/art/screens/session.
|
|
45
|
+
<p align="center"><img src="docs/art/screens/session.png" alt="Session view — live reasoning stream with a stats panel" width="100%"></p>
|
|
46
46
|
|
|
47
47
|
**Board** — the coordination ledger for a project: task **claims** (each in an isolated git worktree), **worktrees** with branch, dirty/unpushed state and which session is inside, **runtime resources** (ports, dev servers, databases held as named singletons), and **incidents** — every command the rules asked about or denied, with the rule and the command.
|
|
48
48
|
|
|
49
|
-
<p align="center"><img src="docs/art/screens/board.
|
|
49
|
+
<p align="center"><img src="docs/art/screens/board.png" alt="Board view — worktrees and incidents" width="100%"></p>
|
|
50
50
|
|
|
51
51
|
**Rules** — guardrails on the Bash commands a Claude Code session runs: `shared_tree`, `destructive_git`, `pattern_kill`, `protected_ports`, plus `no_foreign_worktree` and the opt-in `claim_required_to_write` on file writes; each `ask | deny | off` per repo in `.swarm.toml`. A `deny` is returned to Claude Code as a real permission denial. Ports held as resources are protected automatically. Guardrails against accidents, not a sandbox — see [what rules are and aren't](https://getswarm.vercel.app/docs/03-rules-and-config#what-rules-are--and-arent).
|
|
52
52
|
|
|
@@ -54,11 +54,11 @@ bunx @ra3orblade/swarm setup
|
|
|
54
54
|
|
|
55
55
|
**Timeline** — session lanes per project, coloured by agent, 3–72 h.
|
|
56
56
|
|
|
57
|
-
<p align="center"><img src="docs/art/screens/timeline.
|
|
57
|
+
<p align="center"><img src="docs/art/screens/timeline.png" alt="Timeline view — session lanes per project" width="100%"></p>
|
|
58
58
|
|
|
59
59
|
**Spend & Stats** — cost by project, model and agent, today and all-time; plus the fun numbers: tokens, turns, streaks, activity calendar, words written, what it adds up to in novels and coffee.
|
|
60
60
|
|
|
61
|
-
<p align="center"><img src="docs/art/screens/stats.
|
|
61
|
+
<p align="center"><img src="docs/art/screens/stats.png" alt="Stats view" width="100%"></p>
|
|
62
62
|
|
|
63
63
|
**Multi-agent** — Claude Code via its hooks and transcripts; Codex CLI and Grok by tailing the session logs they already write (`~/.codex`, ACP `updates.jsonl`). Every session is tagged with its agent; Spend breaks down per agent.
|
|
64
64
|
|
package/dist/swarm-mcp.js
CHANGED
|
@@ -19723,6 +19723,7 @@ async function ensureDaemon(opts = {}) {
|
|
|
19723
19723
|
}
|
|
19724
19724
|
// packages/mcp/src/server.ts
|
|
19725
19725
|
var OWNER = process.env.SWARM_OWNER ?? "agent";
|
|
19726
|
+
var SESSION = process.env.CLAUDE_SESSION_ID ?? null;
|
|
19726
19727
|
async function api2(path, init) {
|
|
19727
19728
|
const base = await ensureDaemon({ quiet: true }).catch(() => resolveBaseUrl());
|
|
19728
19729
|
const r = await fetch(`${base}${path}`, init);
|
|
@@ -19764,6 +19765,29 @@ function buildServer() {
|
|
|
19764
19765
|
resources
|
|
19765
19766
|
});
|
|
19766
19767
|
});
|
|
19768
|
+
server.registerTool("swarm_search", {
|
|
19769
|
+
title: "Search Swarm's memory",
|
|
19770
|
+
description: "Full-text search over what Swarm remembers about this repo: handoffs (what was done / what's left), incidents (commands the rules stopped, and why), gate runs (rubric + evidence) and what previous sessions last said. Not the codebase \u2014 grep that. Words are AND-ed, the last one is a prefix; quote a phrase; `kind:incident` / `task:M1.2` filter. Use it before redoing work someone may have done, or when a rule blocks you and you want to know how it was handled before.",
|
|
19771
|
+
inputSchema: {
|
|
19772
|
+
query: exports_external.string(),
|
|
19773
|
+
kind: exports_external.enum(["handoff", "incident", "gate", "session"]).optional(),
|
|
19774
|
+
all_projects: exports_external.boolean().optional().describe("search every project, not just this repo"),
|
|
19775
|
+
limit: exports_external.number().int().min(1).max(100).optional()
|
|
19776
|
+
}
|
|
19777
|
+
}, async ({ query, kind, all_projects, limit }) => {
|
|
19778
|
+
const q = new URLSearchParams({ q: query, limit: String(limit ?? 20) });
|
|
19779
|
+
if (!all_projects)
|
|
19780
|
+
q.set("project", await projectId());
|
|
19781
|
+
if (kind)
|
|
19782
|
+
q.set("kind", kind);
|
|
19783
|
+
const r = await api2(`/v1/memory?${q}`);
|
|
19784
|
+
if (!r.hits.length)
|
|
19785
|
+
return ok(`nothing in memory matches "${query}"`, { hits: [] });
|
|
19786
|
+
return ok(r.hits.map((h) => `[${h.kind}] ${h.title}${h.task ? ` (${h.task})` : ""} \u2014 ${h.ts.slice(0, 16)}
|
|
19787
|
+
${h.text}`).join(`
|
|
19788
|
+
|
|
19789
|
+
`), r.hits);
|
|
19790
|
+
});
|
|
19767
19791
|
server.registerTool("swarm_next_task", {
|
|
19768
19792
|
title: "Next claimable task",
|
|
19769
19793
|
description: "The first unclaimed task in this repo's task source whose dependencies are done \u2014 what to pick up next. Needs `[tasks] source` in .swarm.toml. Pass all=true to list every ready task.",
|
|
@@ -19782,6 +19806,92 @@ function buildServer() {
|
|
|
19782
19806
|
const n = ready[0];
|
|
19783
19807
|
return ok(`next: ${n.id} \u2014 ${n.title} (claim it with swarm_claim)`, n);
|
|
19784
19808
|
});
|
|
19809
|
+
server.registerTool("swarm_handoff", {
|
|
19810
|
+
title: "Leave a handoff",
|
|
19811
|
+
description: "Before stopping or releasing a task, record what was done, what remains (in order), files worth reading first, and how to verify. The next session that starts in this task's worktree receives it automatically as context; `swarm_resume` reads it on demand.",
|
|
19812
|
+
inputSchema: {
|
|
19813
|
+
task: exports_external.string(),
|
|
19814
|
+
done: exports_external.string().describe("what was finished"),
|
|
19815
|
+
remaining: exports_external.string().describe("what is left, in the order to do it"),
|
|
19816
|
+
files: exports_external.array(exports_external.string()).optional().describe("files touched or to read first"),
|
|
19817
|
+
verify: exports_external.string().optional().describe("how to verify the work so far")
|
|
19818
|
+
}
|
|
19819
|
+
}, async ({ task, done, remaining, files, verify }) => {
|
|
19820
|
+
const pid = await projectId();
|
|
19821
|
+
const r = await api2("/v1/handoffs", {
|
|
19822
|
+
method: "POST",
|
|
19823
|
+
headers: { "content-type": "application/json" },
|
|
19824
|
+
body: JSON.stringify({
|
|
19825
|
+
projectId: pid,
|
|
19826
|
+
task,
|
|
19827
|
+
done,
|
|
19828
|
+
remaining,
|
|
19829
|
+
files: files ?? [],
|
|
19830
|
+
verify: verify ?? null,
|
|
19831
|
+
by: OWNER,
|
|
19832
|
+
sessionId: SESSION
|
|
19833
|
+
})
|
|
19834
|
+
});
|
|
19835
|
+
if (!r.ok)
|
|
19836
|
+
return fail(`REFUSED: ${r.error}`);
|
|
19837
|
+
return ok(`handoff recorded on ${task}`, r.handoff);
|
|
19838
|
+
});
|
|
19839
|
+
server.registerTool("swarm_resume", {
|
|
19840
|
+
title: "Read the latest handoff",
|
|
19841
|
+
description: "The latest handoff on a task: done, remaining, files, verify. Read it before continuing someone else's work. Handoffs marked `auto:` were derived by Swarm from what the previous session did (files edited, last verify command) when it stopped without leaving one.",
|
|
19842
|
+
inputSchema: { task: exports_external.string() }
|
|
19843
|
+
}, async ({ task }) => {
|
|
19844
|
+
const pid = await projectId();
|
|
19845
|
+
const j = await api2(`/v1/handoffs?project=${pid}&task=${encodeURIComponent(task)}`);
|
|
19846
|
+
if (!j.text)
|
|
19847
|
+
return ok(`no handoff on ${task}`, null);
|
|
19848
|
+
return ok(j.text, j.handoff);
|
|
19849
|
+
});
|
|
19850
|
+
server.registerTool("swarm_gate_record", {
|
|
19851
|
+
title: "Record a verification gate",
|
|
19852
|
+
description: "Record the result of a verification gate (review, tests, security, \u2026) on a task. The rubric \u2014 what you actually checked \u2014 is required; a verdict without one is rejected. The latest run of a gate decides; failed runs stay on record and open an incident. Check `swarm_gates` for the gates this repo requires.",
|
|
19853
|
+
inputSchema: {
|
|
19854
|
+
task: exports_external.string().describe("task id, e.g. M1.2"),
|
|
19855
|
+
gate: exports_external.string().describe("gate name, e.g. review, tests, security"),
|
|
19856
|
+
verdict: exports_external.enum(["pass", "fail"]),
|
|
19857
|
+
rubric: exports_external.string().describe("what was checked, concretely"),
|
|
19858
|
+
evidence: exports_external.string().optional().describe("how: command output, PR link, notes")
|
|
19859
|
+
}
|
|
19860
|
+
}, async ({ task, gate, verdict, rubric, evidence }) => {
|
|
19861
|
+
const pid = await projectId();
|
|
19862
|
+
const r = await api2("/v1/gates", {
|
|
19863
|
+
method: "POST",
|
|
19864
|
+
headers: { "content-type": "application/json" },
|
|
19865
|
+
body: JSON.stringify({
|
|
19866
|
+
projectId: pid,
|
|
19867
|
+
task,
|
|
19868
|
+
gate,
|
|
19869
|
+
verdict,
|
|
19870
|
+
rubric,
|
|
19871
|
+
evidence,
|
|
19872
|
+
sessionId: SESSION
|
|
19873
|
+
})
|
|
19874
|
+
});
|
|
19875
|
+
if (!r.ok)
|
|
19876
|
+
return fail(`REFUSED: ${r.error}`);
|
|
19877
|
+
return ok(`recorded ${gate} ${verdict} on ${task}`, r.run);
|
|
19878
|
+
});
|
|
19879
|
+
server.registerTool("swarm_gates", {
|
|
19880
|
+
title: "Gate status",
|
|
19881
|
+
description: "The gates this repo requires (.swarm.toml [gates] required) and, for a task, the latest verdict per gate with run history. A task is done only when every required gate's latest run is a pass.",
|
|
19882
|
+
inputSchema: { task: exports_external.string().optional() }
|
|
19883
|
+
}, async ({ task }) => {
|
|
19884
|
+
const pid = await projectId();
|
|
19885
|
+
const q = new URLSearchParams({ project: pid });
|
|
19886
|
+
if (task)
|
|
19887
|
+
q.set("task", task);
|
|
19888
|
+
const g = await api2(`/v1/gates?${q}`);
|
|
19889
|
+
const head = g.required.length ? `required: ${g.required.join(", ")}` : "no required gates declared";
|
|
19890
|
+
const body = task ? (g.status ?? []).map((s) => `${s.gate}: ${s.verdict ?? "not run"}`).join(`
|
|
19891
|
+
`) || `no runs on ${task}` : `${g.runs.length} runs`;
|
|
19892
|
+
return ok(`${head}
|
|
19893
|
+
${body}`, g);
|
|
19894
|
+
});
|
|
19785
19895
|
server.registerTool("swarm_claim", {
|
|
19786
19896
|
title: "Claim a task",
|
|
19787
19897
|
description: "Claim a task and get an isolated git worktree to work in. Fails closed if another session holds it \u2014 pick another task or coordinate. cd into the returned worktree before editing.",
|
package/dist/swarm.js
CHANGED
|
@@ -147,10 +147,51 @@ var HOOK_EVENTS = [
|
|
|
147
147
|
// packages/core/src/rules.ts
|
|
148
148
|
var LIVE_WINDOW_MS = 10 * 60000;
|
|
149
149
|
var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
150
|
+
// packages/core/src/ledger.ts
|
|
151
|
+
var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
150
152
|
// packages/cli/src/install.ts
|
|
151
153
|
var MARK = "swarm-hook";
|
|
152
154
|
var isOurs = (h) => h.command.includes(MARK) || h.command.includes("/packages/hook/src/bin.ts");
|
|
153
155
|
var settingsPath = () => process.env.CLAUDE_SETTINGS ?? join2(homedir2(), ".claude", "settings.json");
|
|
156
|
+
var claudeJsonPath = () => process.env.CLAUDE_JSON ?? join2(homedir2(), ".claude.json");
|
|
157
|
+
function loadClaudeJson() {
|
|
158
|
+
const p = claudeJsonPath();
|
|
159
|
+
if (!existsSync3(p))
|
|
160
|
+
return {};
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(readFileSync2(p, "utf8"));
|
|
163
|
+
} catch {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function saveClaudeJson(c) {
|
|
168
|
+
writeFileSync2(claudeJsonPath(), `${JSON.stringify(c, null, 2)}
|
|
169
|
+
`);
|
|
170
|
+
}
|
|
171
|
+
function registerMcp() {
|
|
172
|
+
const c = loadClaudeJson();
|
|
173
|
+
const mcp = c.mcpServers ?? {};
|
|
174
|
+
mcp.swarm = { type: "stdio", ...mcpServerConfig() };
|
|
175
|
+
c.mcpServers = mcp;
|
|
176
|
+
saveClaudeJson(c);
|
|
177
|
+
}
|
|
178
|
+
function unregisterMcp() {
|
|
179
|
+
const c = loadClaudeJson();
|
|
180
|
+
const mcp = c.mcpServers ?? {};
|
|
181
|
+
if (!mcp.swarm)
|
|
182
|
+
return false;
|
|
183
|
+
delete mcp.swarm;
|
|
184
|
+
if (Object.keys(mcp).length)
|
|
185
|
+
c.mcpServers = mcp;
|
|
186
|
+
else
|
|
187
|
+
delete c.mcpServers;
|
|
188
|
+
saveClaudeJson(c);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
function mcpRegistered() {
|
|
192
|
+
const c = loadClaudeJson();
|
|
193
|
+
return Boolean(c.mcpServers?.swarm);
|
|
194
|
+
}
|
|
154
195
|
var hookCommand = (event) => `${binCommand("swarm-hook")} ${event}`;
|
|
155
196
|
var shimPath = () => resolveBin("swarm-hook").at(-1);
|
|
156
197
|
function mcpServerConfig() {
|
|
@@ -179,10 +220,16 @@ function install() {
|
|
|
179
220
|
added.push(ev);
|
|
180
221
|
}
|
|
181
222
|
s.hooks = hooks2;
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
223
|
+
const stale = s.mcpServers ?? {};
|
|
224
|
+
if (stale.swarm) {
|
|
225
|
+
delete stale.swarm;
|
|
226
|
+
if (Object.keys(stale).length)
|
|
227
|
+
s.mcpServers = stale;
|
|
228
|
+
else
|
|
229
|
+
delete s.mcpServers;
|
|
230
|
+
}
|
|
185
231
|
save(s);
|
|
232
|
+
registerMcp();
|
|
186
233
|
return added;
|
|
187
234
|
}
|
|
188
235
|
function uninstall() {
|
|
@@ -211,23 +258,22 @@ function uninstall() {
|
|
|
211
258
|
else
|
|
212
259
|
delete s.hooks;
|
|
213
260
|
const mcp = s.mcpServers ?? {};
|
|
214
|
-
if (mcp.swarm)
|
|
261
|
+
if (mcp.swarm)
|
|
215
262
|
delete mcp.swarm;
|
|
216
|
-
removed++;
|
|
217
|
-
}
|
|
218
263
|
if (Object.keys(mcp).length)
|
|
219
264
|
s.mcpServers = mcp;
|
|
220
265
|
else
|
|
221
266
|
delete s.mcpServers;
|
|
222
267
|
save(s);
|
|
268
|
+
if (unregisterMcp())
|
|
269
|
+
removed++;
|
|
223
270
|
return removed;
|
|
224
271
|
}
|
|
225
272
|
function status() {
|
|
226
273
|
const s = load();
|
|
227
274
|
const hooks2 = s.hooks ?? {};
|
|
228
275
|
const installed = Object.values(hooks2).some((l) => l.some((g) => g.hooks.some(isOurs)));
|
|
229
|
-
|
|
230
|
-
return { installed, mcp, path: settingsPath(), shim: shimPath() };
|
|
276
|
+
return { installed, mcp: mcpRegistered(), path: settingsPath(), shim: shimPath() };
|
|
231
277
|
}
|
|
232
278
|
|
|
233
279
|
// packages/cli/src/procs.ts
|
|
@@ -348,6 +394,14 @@ var help = `swarm \u2014 control plane for AI-agent development
|
|
|
348
394
|
renew <task> extend the lease; release <task> [--force] release + remove worktree
|
|
349
395
|
claims list claims; reap release abandoned claims (keeps ones holding work)
|
|
350
396
|
tasks [--ready] [--json] the repo's task source (.swarm.toml [tasks] source); --ready = claimable now
|
|
397
|
+
gate record <task> <gate> pass|fail --rubric "\u2026" [--evidence "\u2026"] record a verification run (rubric required)
|
|
398
|
+
gate ls [task] latest verdict per gate (and the run history for one task)
|
|
399
|
+
run --task <id> (--prompt "\u2026" | --prompt-file f) [--model m] [--permission-mode m] [--allowed-tools a,b] [--max-turns n]
|
|
400
|
+
claim the task and spawn claude -p in its worktree; the session shows in Fleet
|
|
401
|
+
run ls | send <task|id> "text" | stop <task|id> steer (stdin) or stop a spawned run, by pid never pattern
|
|
402
|
+
run resume <session-id> [--model m] [--permission-mode m] spawn a run that picks up where a dead session stopped (its handoff + tail)
|
|
403
|
+
handoff <task> --done "\u2026" --remaining "\u2026" [--files a,b] [--verify "\u2026"] leave notes for the next holder
|
|
404
|
+
resume <task> print the latest handoff (the next session gets it automatically on start)
|
|
351
405
|
res ls | acquire <name> [--owner n] [--pid n] [--port n] | release <name> [--force]
|
|
352
406
|
named singletons (ports, processes); fail-closed
|
|
353
407
|
serve start [--name web] [--from-port 3400 | --port n] -- <cmd>
|
|
@@ -355,6 +409,8 @@ var help = `swarm \u2014 control plane for AI-agent development
|
|
|
355
409
|
serve ls | stop [name] list / stop servers this project started (by pid, never by pattern)
|
|
356
410
|
proc start [--name n] -- <cmd> | ls | stop <name|pid> same, for workers without a port
|
|
357
411
|
stats [-p] [--json] all-time totals, streak, records (the dashboard's Stats view)
|
|
412
|
+
search <query\u2026> [-p] [--kind handoff|incident|gate|session] [--json] memory over Swarm's own data (handoffs, incidents, gates, what sessions said)
|
|
413
|
+
rules dryrun [--set rule=mode,\u2026] [--limit n] [--json] replay this repo's history under rule modes; shows what would fire + flaky signals
|
|
358
414
|
|
|
359
415
|
install | uninstall add/remove Swarm hooks in ~/.claude/settings.json
|
|
360
416
|
|
|
@@ -546,6 +602,305 @@ url: ${resolveBaseUrl()}`);
|
|
|
546
602
|
console.log(`${c.state.padEnd(9)} ${c.task.padEnd(16)} ${(c.owner || "").padEnd(12)} ${c.worktree}`);
|
|
547
603
|
break;
|
|
548
604
|
}
|
|
605
|
+
case "run": {
|
|
606
|
+
await ensureDaemon({ quiet: true });
|
|
607
|
+
const proj = await api("/v1/projects", {
|
|
608
|
+
method: "POST",
|
|
609
|
+
headers: { "content-type": "application/json" },
|
|
610
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
611
|
+
});
|
|
612
|
+
const base = new SwarmClient().baseUrl;
|
|
613
|
+
const valueFlags = new Set([
|
|
614
|
+
"--task",
|
|
615
|
+
"--prompt",
|
|
616
|
+
"--prompt-file",
|
|
617
|
+
"--model",
|
|
618
|
+
"--permission-mode",
|
|
619
|
+
"--allowed-tools",
|
|
620
|
+
"--max-turns",
|
|
621
|
+
"--owner"
|
|
622
|
+
]);
|
|
623
|
+
const positionals = [];
|
|
624
|
+
for (let i = 0;i < rest.length; i++) {
|
|
625
|
+
const a = rest[i];
|
|
626
|
+
if (valueFlags.has(a))
|
|
627
|
+
i++;
|
|
628
|
+
else if (!a.startsWith("--"))
|
|
629
|
+
positionals.push(a);
|
|
630
|
+
}
|
|
631
|
+
const flag = (n) => {
|
|
632
|
+
const i = rest.indexOf(n);
|
|
633
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
634
|
+
};
|
|
635
|
+
const sub = positionals[0];
|
|
636
|
+
if (sub === "ls") {
|
|
637
|
+
const runs = await api(`/v1/runs?project=${proj.id}`);
|
|
638
|
+
if (json)
|
|
639
|
+
console.log(JSON.stringify(runs));
|
|
640
|
+
else if (!runs.length)
|
|
641
|
+
console.log("no live runs here");
|
|
642
|
+
else
|
|
643
|
+
for (const r2 of runs)
|
|
644
|
+
console.log(`${r2.id} ${r2.task.padEnd(12)} pid ${String(r2.pid).padEnd(7)} ${r2.owner.padEnd(10)} ${r2.result ? `$${r2.result.costUsd.toFixed(2)} \xB7 ${r2.result.turns} turns${r2.result.isError ? " \xB7 error" : ""}` : "starting\u2026"}`);
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
if (sub === "send" || sub === "stop") {
|
|
648
|
+
const target = positionals[1];
|
|
649
|
+
if (!target)
|
|
650
|
+
throw new Error(`usage: swarm run ${sub} <task|id>${sub === "send" ? ' "text"' : ""}`);
|
|
651
|
+
const r2 = sub === "send" ? await fetch(`${base}/v1/runs/${encodeURIComponent(target)}/send`, {
|
|
652
|
+
method: "POST",
|
|
653
|
+
headers: { "content-type": "application/json" },
|
|
654
|
+
body: JSON.stringify({ text: positionals.slice(2).join(" ") })
|
|
655
|
+
}) : await fetch(`${base}/v1/runs/${encodeURIComponent(target)}`, { method: "DELETE" });
|
|
656
|
+
const j = await r2.json();
|
|
657
|
+
if (json)
|
|
658
|
+
console.log(JSON.stringify(j));
|
|
659
|
+
else if (j.ok)
|
|
660
|
+
console.log(sub === "send" ? `sent to ${target}` : `stopped ${target}`);
|
|
661
|
+
else {
|
|
662
|
+
console.error(`REFUSED: ${j.error}`);
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
const resumeFrom = sub === "resume" ? positionals[1] : undefined;
|
|
668
|
+
if (sub === "resume" && !resumeFrom)
|
|
669
|
+
throw new Error("usage: swarm run resume <session-id>");
|
|
670
|
+
const task = resumeFrom ? "(resumed)" : flag("--task") ?? sub;
|
|
671
|
+
let prompt = resumeFrom ? "(from handoff)" : flag("--prompt");
|
|
672
|
+
const pf = flag("--prompt-file");
|
|
673
|
+
if (!prompt && pf)
|
|
674
|
+
prompt = await Bun.file(resolve3(pf)).text();
|
|
675
|
+
if (!task || !prompt)
|
|
676
|
+
throw new Error('usage: swarm run --task <id> --prompt "\u2026" | --prompt-file f [--model] [--permission-mode] [--allowed-tools a,b] [--max-turns n]');
|
|
677
|
+
const r = await fetch(resumeFrom ? `${base}/v1/sessions/${encodeURIComponent(resumeFrom)}/resume` : `${base}/v1/runs`, {
|
|
678
|
+
method: "POST",
|
|
679
|
+
headers: { "content-type": "application/json" },
|
|
680
|
+
body: JSON.stringify({
|
|
681
|
+
projectId: proj.id,
|
|
682
|
+
task,
|
|
683
|
+
prompt,
|
|
684
|
+
owner: flag("--owner") ?? (resumeFrom ? undefined : process.env.USER ?? "me"),
|
|
685
|
+
model: flag("--model"),
|
|
686
|
+
permissionMode: flag("--permission-mode"),
|
|
687
|
+
allowedTools: flag("--allowed-tools")?.split(",").map((t) => t.trim()).filter(Boolean),
|
|
688
|
+
maxTurns: flag("--max-turns") ? Number(flag("--max-turns")) : undefined
|
|
689
|
+
})
|
|
690
|
+
}).then((x) => x.json());
|
|
691
|
+
if (json)
|
|
692
|
+
console.log(JSON.stringify(r));
|
|
693
|
+
else if (r.ok && r.run)
|
|
694
|
+
console.log(`run ${r.run.id} on ${r.run.task} (pid ${r.run.pid})
|
|
695
|
+
worktree: ${r.run.worktree}
|
|
696
|
+
session: ${r.run.sessionId}
|
|
697
|
+
log: ${r.run.log}
|
|
698
|
+
steer: swarm run send ${r.run.task} "\u2026" stop: swarm run stop ${r.run.task} watch: swarm tail --session ${r.run.sessionId}`);
|
|
699
|
+
else {
|
|
700
|
+
console.error(`REFUSED: ${r.error}`);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
case "handoff":
|
|
706
|
+
case "resume": {
|
|
707
|
+
await ensureDaemon({ quiet: true });
|
|
708
|
+
const task = arg();
|
|
709
|
+
if (!task)
|
|
710
|
+
throw new Error(`usage: swarm ${cmd} <task> \u2026`);
|
|
711
|
+
const proj = await api("/v1/projects", {
|
|
712
|
+
method: "POST",
|
|
713
|
+
headers: { "content-type": "application/json" },
|
|
714
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
715
|
+
});
|
|
716
|
+
if (cmd === "resume") {
|
|
717
|
+
const r2 = await fetch(`${new SwarmClient().baseUrl}/v1/handoffs?project=${proj.id}&task=${encodeURIComponent(task)}`);
|
|
718
|
+
const j = await r2.json();
|
|
719
|
+
if (json)
|
|
720
|
+
console.log(JSON.stringify(j.handoff));
|
|
721
|
+
else
|
|
722
|
+
console.log(j.text ?? `no handoff on ${task}`);
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
const flag = (n) => {
|
|
726
|
+
const i = rest.indexOf(n);
|
|
727
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
728
|
+
};
|
|
729
|
+
const r = await fetch(`${new SwarmClient().baseUrl}/v1/handoffs`, {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: { "content-type": "application/json" },
|
|
732
|
+
body: JSON.stringify({
|
|
733
|
+
projectId: proj.id,
|
|
734
|
+
task,
|
|
735
|
+
done: flag("--done"),
|
|
736
|
+
remaining: flag("--remaining"),
|
|
737
|
+
files: (flag("--files") ?? "").split(",").map((f) => f.trim()).filter(Boolean),
|
|
738
|
+
verify: flag("--verify") ?? null,
|
|
739
|
+
by: flag("--by") ?? process.env.USER ?? null,
|
|
740
|
+
sessionId: process.env.CLAUDE_SESSION_ID ?? null
|
|
741
|
+
})
|
|
742
|
+
}).then((x) => x.json());
|
|
743
|
+
if (json)
|
|
744
|
+
console.log(JSON.stringify(r));
|
|
745
|
+
else if (r.ok)
|
|
746
|
+
console.log(`handoff recorded on ${task} \u2014 the next session in its worktree sees it on start`);
|
|
747
|
+
else {
|
|
748
|
+
console.error(`REFUSED: ${r.error}`);
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
case "gate": {
|
|
754
|
+
await ensureDaemon({ quiet: true });
|
|
755
|
+
const proj = await api("/v1/projects", {
|
|
756
|
+
method: "POST",
|
|
757
|
+
headers: { "content-type": "application/json" },
|
|
758
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
759
|
+
});
|
|
760
|
+
const valueFlags = new Set(["--rubric", "--evidence"]);
|
|
761
|
+
const positionals = [];
|
|
762
|
+
for (let i = 0;i < rest.length; i++) {
|
|
763
|
+
const a = rest[i];
|
|
764
|
+
if (valueFlags.has(a))
|
|
765
|
+
i++;
|
|
766
|
+
else if (!a.startsWith("--"))
|
|
767
|
+
positionals.push(a);
|
|
768
|
+
}
|
|
769
|
+
const flag = (n) => {
|
|
770
|
+
const i = rest.indexOf(n);
|
|
771
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
772
|
+
};
|
|
773
|
+
const sub = positionals[0] ?? "ls";
|
|
774
|
+
if (sub === "record") {
|
|
775
|
+
const [, task, gate, verdict] = positionals;
|
|
776
|
+
if (!task || !gate || !verdict)
|
|
777
|
+
throw new Error('usage: swarm gate record <task> <gate> pass|fail --rubric "what was checked" [--evidence "\u2026"]');
|
|
778
|
+
const r = await fetch(`${new SwarmClient().baseUrl}/v1/gates`, {
|
|
779
|
+
method: "POST",
|
|
780
|
+
headers: { "content-type": "application/json" },
|
|
781
|
+
body: JSON.stringify({
|
|
782
|
+
projectId: proj.id,
|
|
783
|
+
task,
|
|
784
|
+
gate,
|
|
785
|
+
verdict,
|
|
786
|
+
rubric: flag("--rubric"),
|
|
787
|
+
evidence: flag("--evidence"),
|
|
788
|
+
sessionId: process.env.CLAUDE_SESSION_ID ?? null
|
|
789
|
+
})
|
|
790
|
+
}).then((x) => x.json());
|
|
791
|
+
if (json)
|
|
792
|
+
console.log(JSON.stringify(r));
|
|
793
|
+
else if (r.ok)
|
|
794
|
+
console.log(`recorded ${gate} ${verdict} on ${task}`);
|
|
795
|
+
else {
|
|
796
|
+
console.error(`REFUSED: ${r.error}`);
|
|
797
|
+
process.exit(1);
|
|
798
|
+
}
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
if (sub === "ls") {
|
|
802
|
+
const task = positionals[1];
|
|
803
|
+
const q = new URLSearchParams({ project: proj.id });
|
|
804
|
+
if (task)
|
|
805
|
+
q.set("task", task);
|
|
806
|
+
const g = await api(`/v1/gates?${q}`);
|
|
807
|
+
if (json)
|
|
808
|
+
console.log(JSON.stringify(g));
|
|
809
|
+
else if (task) {
|
|
810
|
+
if (!g.status?.length)
|
|
811
|
+
console.log(`no gates on ${task}${g.required.length ? ` (required: ${g.required.join(", ")})` : ""}`);
|
|
812
|
+
for (const st of g.status ?? [])
|
|
813
|
+
console.log(`${(st.verdict ?? "\u2014").padEnd(5)} ${st.gate.padEnd(12)} ${st.runs} run${st.runs === 1 ? "" : "s"}, ${st.fails} fail${st.fails === 1 ? "" : "s"}`);
|
|
814
|
+
for (const r of g.runs)
|
|
815
|
+
console.log(` ${r.createdAt.slice(0, 16)} ${r.gate.padEnd(12)} ${r.verdict.padEnd(5)} ${r.rubric.slice(0, 70)}`);
|
|
816
|
+
} else {
|
|
817
|
+
if (g.required.length)
|
|
818
|
+
console.log(`required: ${g.required.join(", ")}`);
|
|
819
|
+
if (!g.runs.length)
|
|
820
|
+
console.log("no gate runs yet");
|
|
821
|
+
for (const r of g.runs.slice(0, 50))
|
|
822
|
+
console.log(`${r.createdAt.slice(0, 16)} ${r.task.padEnd(10)} ${r.gate.padEnd(12)} ${r.verdict.padEnd(5)} ${r.rubric.slice(0, 60)}`);
|
|
823
|
+
}
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
throw new Error("usage: swarm gate record|ls");
|
|
827
|
+
}
|
|
828
|
+
case "search": {
|
|
829
|
+
await ensureDaemon({ quiet: true });
|
|
830
|
+
const q = new URLSearchParams({ limit: "30" });
|
|
831
|
+
const words = [];
|
|
832
|
+
for (let i = 0;i < rest.length; i++) {
|
|
833
|
+
const a = rest[i];
|
|
834
|
+
if (a === "--kind")
|
|
835
|
+
q.set("kind", rest[++i] ?? "");
|
|
836
|
+
else if (a === "-p") {
|
|
837
|
+
const proj = await api("/v1/projects", {
|
|
838
|
+
method: "POST",
|
|
839
|
+
headers: { "content-type": "application/json" },
|
|
840
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
841
|
+
});
|
|
842
|
+
q.set("project", proj.id);
|
|
843
|
+
} else if (!a.startsWith("--"))
|
|
844
|
+
words.push(a);
|
|
845
|
+
}
|
|
846
|
+
if (!words.length)
|
|
847
|
+
throw new Error("usage: swarm search <query\u2026> [-p] [--kind k]");
|
|
848
|
+
q.set("q", words.join(" "));
|
|
849
|
+
const r = await api(`/v1/memory?${q}`);
|
|
850
|
+
if (json)
|
|
851
|
+
console.log(JSON.stringify(r.hits));
|
|
852
|
+
else if (!r.hits.length)
|
|
853
|
+
console.log("nothing in memory matches");
|
|
854
|
+
else
|
|
855
|
+
for (const h of r.hits)
|
|
856
|
+
console.log(`${h.kind.padEnd(8)} ${h.ts.slice(0, 16).replace("T", " ")} ${h.title}${h.task ? ` [${h.task}]` : ""}
|
|
857
|
+
${h.snippet.split("\x01").join("").split("\x02").join("").replace(/\s+/g, " ")}${h.sessionId ? `
|
|
858
|
+
session ${h.sessionId}` : ""}`);
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
case "rules": {
|
|
862
|
+
if (rest[0] !== "dryrun")
|
|
863
|
+
throw new Error("usage: swarm rules dryrun [--set rule=mode,\u2026] [--limit n]");
|
|
864
|
+
await ensureDaemon({ quiet: true });
|
|
865
|
+
const proj = await api("/v1/projects", {
|
|
866
|
+
method: "POST",
|
|
867
|
+
headers: { "content-type": "application/json" },
|
|
868
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
869
|
+
});
|
|
870
|
+
const q = new URLSearchParams({ project: proj.id });
|
|
871
|
+
const si = rest.indexOf("--set");
|
|
872
|
+
if (si >= 0)
|
|
873
|
+
for (const kv of (rest[si + 1] ?? "").split(",")) {
|
|
874
|
+
const [k, v] = kv.split("=");
|
|
875
|
+
if (k && v)
|
|
876
|
+
q.set(k.trim(), v.trim());
|
|
877
|
+
}
|
|
878
|
+
const li = rest.indexOf("--limit");
|
|
879
|
+
if (li >= 0)
|
|
880
|
+
q.set("limit", rest[li + 1] ?? "");
|
|
881
|
+
const r = await api(`/v1/rules/dryrun?${q}`);
|
|
882
|
+
if (json) {
|
|
883
|
+
console.log(JSON.stringify(r));
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
console.log(`dry-run over ${r.evaluated} of ${r.calls} recorded calls (nothing recorded)`);
|
|
887
|
+
for (const [rule, n] of Object.entries(r.byRule))
|
|
888
|
+
console.log(` ${rule.padEnd(26)} ${String(r.modes[rule]).padEnd(5)} ask ${String(n.ask).padStart(4)} deny ${String(n.deny).padStart(4)}`);
|
|
889
|
+
if (r.flaky.length) {
|
|
890
|
+
console.log(`
|
|
891
|
+
flaky signals:`);
|
|
892
|
+
for (const f of r.flaky)
|
|
893
|
+
console.log(` ${f.display}
|
|
894
|
+
${f.suggestion}`);
|
|
895
|
+
}
|
|
896
|
+
if (r.hits.length) {
|
|
897
|
+
console.log(`
|
|
898
|
+
would have fired (newest last):`);
|
|
899
|
+
for (const h of r.hits.slice(-20))
|
|
900
|
+
console.log(` ${h.ts.slice(11, 19)} ${h.action.padEnd(4)} ${h.rule.padEnd(20)} ${h.display}${h.completed ? " (ran)" : ""}`);
|
|
901
|
+
}
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
549
904
|
case "tasks": {
|
|
550
905
|
await ensureDaemon({ quiet: true });
|
|
551
906
|
const proj = await api("/v1/projects", {
|
|
@@ -559,7 +914,9 @@ url: ${resolveBaseUrl()}`);
|
|
|
559
914
|
if (json)
|
|
560
915
|
console.log(JSON.stringify(rows));
|
|
561
916
|
else if (!t.source)
|
|
562
|
-
console.log('no task source \u2014 add `[tasks] source = "path/to/plan.md"` to .swarm.toml');
|
|
917
|
+
console.log('no task source \u2014 add `[tasks] source = "path/to/plan.md"` (or "github" / "linear") to .swarm.toml');
|
|
918
|
+
else if (t.error && !t.tasks.length)
|
|
919
|
+
console.log(`${t.source}: ${t.error}`);
|
|
563
920
|
else if (!rows.length)
|
|
564
921
|
console.log(ready ? "nothing ready to claim" : `no tasks in ${t.source}`);
|
|
565
922
|
else
|