@ra3orblade/swarm 0.4.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/LICENSE +202 -0
- package/README.md +171 -0
- package/dist/swarm-hook.js +52 -0
- package/dist/swarm-mcp.js +19880 -0
- package/dist/swarm.js +830 -0
- package/dist/swarmd.js +4554 -0
- package/package.json +43 -0
- package/web/app.js +1103 -0
- package/web/fm.css +456 -0
- package/web/icons.js +4 -0
- package/web/index.html +444 -0
- package/web/menus.js +14 -0
- package/web/table.js +262 -0
- package/web/viz.js +272 -0
package/dist/swarm.js
ADDED
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// packages/cli/src/bin.ts
|
|
5
|
+
import { resolve as resolve3 } from "path";
|
|
6
|
+
|
|
7
|
+
// packages/client/src/daemon.ts
|
|
8
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
|
|
12
|
+
// packages/client/src/bins.ts
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { dirname, resolve } from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
var SRC = {
|
|
17
|
+
swarm: "cli",
|
|
18
|
+
swarmd: "daemon",
|
|
19
|
+
"swarm-hook": "hook",
|
|
20
|
+
"swarm-mcp": "mcp"
|
|
21
|
+
};
|
|
22
|
+
function resolveBin(name, from = import.meta.url) {
|
|
23
|
+
const here = dirname(fileURLToPath(from));
|
|
24
|
+
const candidates = [
|
|
25
|
+
resolve(here, `../../${SRC[name]}/src/bin.ts`),
|
|
26
|
+
resolve(here, `${name}.js`)
|
|
27
|
+
];
|
|
28
|
+
for (const c of candidates)
|
|
29
|
+
if (existsSync(c))
|
|
30
|
+
return ["bun", c];
|
|
31
|
+
return [name];
|
|
32
|
+
}
|
|
33
|
+
function binCommand(name, from) {
|
|
34
|
+
return resolveBin(name, from).map((p) => /[\s"']/.test(p) ? JSON.stringify(p) : p).join(" ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// packages/client/src/daemon.ts
|
|
38
|
+
function swarmHome() {
|
|
39
|
+
return process.env.SWARM_HOME ?? join(homedir(), ".swarm");
|
|
40
|
+
}
|
|
41
|
+
var DEFAULT_PORT = Number(process.env.SWARM_PORT ?? 7777);
|
|
42
|
+
var infoFile = () => join(swarmHome(), "daemon.json");
|
|
43
|
+
function readDaemonInfo() {
|
|
44
|
+
const file = infoFile();
|
|
45
|
+
if (!existsSync2(file))
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function alive(pid) {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, 0);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function daemonCommand() {
|
|
62
|
+
return resolveBin("swarmd");
|
|
63
|
+
}
|
|
64
|
+
function resolveBaseUrl(explicit) {
|
|
65
|
+
const raw = explicit ?? process.env.SWARM_URL ?? readDaemonInfo()?.url ?? `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
66
|
+
return raw.replace(/\/$/, "");
|
|
67
|
+
}
|
|
68
|
+
async function pingHealth(baseUrl, timeoutMs = 800) {
|
|
69
|
+
try {
|
|
70
|
+
const r = await fetch(`${baseUrl}/v1/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
71
|
+
return r.ok;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function ensureDaemon(opts = {}) {
|
|
77
|
+
const baseUrl = resolveBaseUrl(opts.baseUrl);
|
|
78
|
+
if (await pingHealth(baseUrl))
|
|
79
|
+
return baseUrl;
|
|
80
|
+
const info = readDaemonInfo();
|
|
81
|
+
if (info && alive(info.pid) && await pingHealth(info.url))
|
|
82
|
+
return info.url;
|
|
83
|
+
const [cmd, ...args] = daemonCommand();
|
|
84
|
+
if (!cmd)
|
|
85
|
+
throw new Error("could not resolve the daemon command");
|
|
86
|
+
if (!opts.quiet)
|
|
87
|
+
process.stderr.write(`starting swarmd\u2026
|
|
88
|
+
`);
|
|
89
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
90
|
+
stdout: "ignore",
|
|
91
|
+
stderr: "ignore",
|
|
92
|
+
stdin: "ignore",
|
|
93
|
+
env: { ...process.env }
|
|
94
|
+
});
|
|
95
|
+
proc.unref();
|
|
96
|
+
for (let i = 0;i < 50; i++) {
|
|
97
|
+
const target = resolveBaseUrl(opts.baseUrl);
|
|
98
|
+
if (await pingHealth(target, 300))
|
|
99
|
+
return target;
|
|
100
|
+
await Bun.sleep(100);
|
|
101
|
+
}
|
|
102
|
+
throw new Error("swarmd did not become healthy within 5s");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// packages/client/src/index.ts
|
|
106
|
+
class SwarmClient {
|
|
107
|
+
baseUrl;
|
|
108
|
+
f;
|
|
109
|
+
constructor(opts = {}) {
|
|
110
|
+
this.baseUrl = resolveBaseUrl(opts.baseUrl);
|
|
111
|
+
this.f = opts.fetch ?? fetch;
|
|
112
|
+
}
|
|
113
|
+
async health() {
|
|
114
|
+
const r = await this.f(`${this.baseUrl}/v1/health`);
|
|
115
|
+
if (!r.ok)
|
|
116
|
+
throw new Error(`swarmd: ${r.status}`);
|
|
117
|
+
return await r.json();
|
|
118
|
+
}
|
|
119
|
+
async emit(event) {
|
|
120
|
+
const r = await this.f(`${this.baseUrl}/v1/events`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "content-type": "application/json" },
|
|
123
|
+
body: JSON.stringify(event)
|
|
124
|
+
});
|
|
125
|
+
if (!r.ok)
|
|
126
|
+
throw new Error(`swarmd: ${r.status}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// packages/cli/src/install.ts
|
|
131
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
132
|
+
import { homedir as homedir2 } from "os";
|
|
133
|
+
import { join as join2 } from "path";
|
|
134
|
+
// packages/core/src/adapters/claude-code/hooks.ts
|
|
135
|
+
var HOOK_EVENTS = [
|
|
136
|
+
"SessionStart",
|
|
137
|
+
"UserPromptSubmit",
|
|
138
|
+
"PreToolUse",
|
|
139
|
+
"PostToolUse",
|
|
140
|
+
"SubagentStart",
|
|
141
|
+
"SubagentStop",
|
|
142
|
+
"Stop",
|
|
143
|
+
"SessionEnd",
|
|
144
|
+
"Notification",
|
|
145
|
+
"PreCompact"
|
|
146
|
+
];
|
|
147
|
+
// packages/core/src/rules.ts
|
|
148
|
+
var LIVE_WINDOW_MS = 10 * 60000;
|
|
149
|
+
var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
150
|
+
// packages/cli/src/install.ts
|
|
151
|
+
var MARK = "swarm-hook";
|
|
152
|
+
var isOurs = (h) => h.command.includes(MARK) || h.command.includes("/packages/hook/src/bin.ts");
|
|
153
|
+
var settingsPath = () => process.env.CLAUDE_SETTINGS ?? join2(homedir2(), ".claude", "settings.json");
|
|
154
|
+
var hookCommand = (event) => `${binCommand("swarm-hook")} ${event}`;
|
|
155
|
+
var shimPath = () => resolveBin("swarm-hook").at(-1);
|
|
156
|
+
function mcpServerConfig() {
|
|
157
|
+
const [command, ...args] = resolveBin("swarm-mcp");
|
|
158
|
+
return { command, args };
|
|
159
|
+
}
|
|
160
|
+
function load() {
|
|
161
|
+
const p = settingsPath();
|
|
162
|
+
return existsSync3(p) ? JSON.parse(readFileSync2(p, "utf8")) : {};
|
|
163
|
+
}
|
|
164
|
+
function save(s) {
|
|
165
|
+
writeFileSync2(settingsPath(), `${JSON.stringify(s, null, 2)}
|
|
166
|
+
`);
|
|
167
|
+
}
|
|
168
|
+
function install() {
|
|
169
|
+
const s = load();
|
|
170
|
+
const hooks2 = s.hooks ?? {};
|
|
171
|
+
const added = [];
|
|
172
|
+
for (const ev of HOOK_EVENTS) {
|
|
173
|
+
const list = hooks2[ev] ?? [];
|
|
174
|
+
const clean = list.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !isOurs(h)) })).filter((g) => g.hooks.length);
|
|
175
|
+
clean.push({
|
|
176
|
+
hooks: [{ type: "command", command: hookCommand(ev), timeout: 5 }]
|
|
177
|
+
});
|
|
178
|
+
hooks2[ev] = clean;
|
|
179
|
+
added.push(ev);
|
|
180
|
+
}
|
|
181
|
+
s.hooks = hooks2;
|
|
182
|
+
const mcp = s.mcpServers ?? {};
|
|
183
|
+
mcp.swarm = { type: "stdio", ...mcpServerConfig() };
|
|
184
|
+
s.mcpServers = mcp;
|
|
185
|
+
save(s);
|
|
186
|
+
return added;
|
|
187
|
+
}
|
|
188
|
+
function uninstall() {
|
|
189
|
+
const s = load();
|
|
190
|
+
const hooks2 = s.hooks ?? {};
|
|
191
|
+
let removed = 0;
|
|
192
|
+
for (const ev of Object.keys(hooks2)) {
|
|
193
|
+
const before = hooks2[ev] ?? [];
|
|
194
|
+
const after = before.map((g) => ({
|
|
195
|
+
...g,
|
|
196
|
+
hooks: g.hooks.filter((h) => {
|
|
197
|
+
if (isOurs(h)) {
|
|
198
|
+
removed++;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
})
|
|
203
|
+
})).filter((g) => g.hooks.length);
|
|
204
|
+
if (after.length)
|
|
205
|
+
hooks2[ev] = after;
|
|
206
|
+
else
|
|
207
|
+
delete hooks2[ev];
|
|
208
|
+
}
|
|
209
|
+
if (Object.keys(hooks2).length)
|
|
210
|
+
s.hooks = hooks2;
|
|
211
|
+
else
|
|
212
|
+
delete s.hooks;
|
|
213
|
+
const mcp = s.mcpServers ?? {};
|
|
214
|
+
if (mcp.swarm) {
|
|
215
|
+
delete mcp.swarm;
|
|
216
|
+
removed++;
|
|
217
|
+
}
|
|
218
|
+
if (Object.keys(mcp).length)
|
|
219
|
+
s.mcpServers = mcp;
|
|
220
|
+
else
|
|
221
|
+
delete s.mcpServers;
|
|
222
|
+
save(s);
|
|
223
|
+
return removed;
|
|
224
|
+
}
|
|
225
|
+
function status() {
|
|
226
|
+
const s = load();
|
|
227
|
+
const hooks2 = s.hooks ?? {};
|
|
228
|
+
const installed = Object.values(hooks2).some((l) => l.some((g) => g.hooks.some(isOurs)));
|
|
229
|
+
const mcp = Boolean(s.mcpServers?.swarm);
|
|
230
|
+
return { installed, mcp, path: settingsPath(), shim: shimPath() };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// packages/cli/src/procs.ts
|
|
234
|
+
import { mkdirSync as mkdirSync2, openSync } from "fs";
|
|
235
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
236
|
+
async function call(path, init) {
|
|
237
|
+
const r = await fetch(`${new SwarmClient().baseUrl}${path}`, init);
|
|
238
|
+
return await r.json();
|
|
239
|
+
}
|
|
240
|
+
var post = (path, body) => call(path, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "content-type": "application/json" },
|
|
243
|
+
body: JSON.stringify(body)
|
|
244
|
+
});
|
|
245
|
+
async function projectId() {
|
|
246
|
+
const p = await post("/v1/projects", { path: resolve2(".") });
|
|
247
|
+
return p.id;
|
|
248
|
+
}
|
|
249
|
+
var slug = (s) => s.replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 60) || "proc";
|
|
250
|
+
async function start(opts) {
|
|
251
|
+
if (!opts.cmd.length)
|
|
252
|
+
throw new Error("nothing to run \u2014 put the command after `--`");
|
|
253
|
+
const pid = await projectId();
|
|
254
|
+
let port = null;
|
|
255
|
+
if (opts.kind === "serve") {
|
|
256
|
+
if (opts.port)
|
|
257
|
+
port = opts.port;
|
|
258
|
+
else {
|
|
259
|
+
const a = await post("/v1/ports/allocate", { from: opts.fromPort });
|
|
260
|
+
if (!a.ok || !a.port)
|
|
261
|
+
throw new Error(a.error ?? "no free port");
|
|
262
|
+
port = a.port;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const logDir = join3(swarmHome(), "logs", slug(pid));
|
|
266
|
+
mkdirSync2(logDir, { recursive: true });
|
|
267
|
+
const log = join3(logDir, `${slug(opts.name)}.log`);
|
|
268
|
+
const fd = openSync(log, "a");
|
|
269
|
+
const cmdline = opts.cmd.join(" ");
|
|
270
|
+
const child = Bun.spawn(["sh", "-c", cmdline], {
|
|
271
|
+
cwd: resolve2("."),
|
|
272
|
+
env: { ...process.env, ...port ? { PORT: String(port) } : {}, SWARM_PROC: opts.name },
|
|
273
|
+
stdin: "ignore",
|
|
274
|
+
stdout: fd,
|
|
275
|
+
stderr: fd,
|
|
276
|
+
detached: true
|
|
277
|
+
});
|
|
278
|
+
child.unref();
|
|
279
|
+
const r = await post("/v1/processes", {
|
|
280
|
+
pid: child.pid,
|
|
281
|
+
projectId: pid,
|
|
282
|
+
sessionId: process.env.CLAUDE_SESSION_ID ?? null,
|
|
283
|
+
kind: opts.kind,
|
|
284
|
+
name: opts.name,
|
|
285
|
+
port,
|
|
286
|
+
cwd: resolve2("."),
|
|
287
|
+
cmd: cmdline,
|
|
288
|
+
owner: opts.owner,
|
|
289
|
+
log
|
|
290
|
+
});
|
|
291
|
+
if (!r.ok || !r.process) {
|
|
292
|
+
try {
|
|
293
|
+
process.kill(child.pid, "SIGTERM");
|
|
294
|
+
} catch {}
|
|
295
|
+
throw new Error(r.error ?? "registration failed");
|
|
296
|
+
}
|
|
297
|
+
return r.process;
|
|
298
|
+
}
|
|
299
|
+
async function list(kind) {
|
|
300
|
+
const pid = await projectId();
|
|
301
|
+
const rows = await call(`/v1/processes?project=${pid}`);
|
|
302
|
+
return kind ? rows.filter((r) => r.kind === kind) : rows;
|
|
303
|
+
}
|
|
304
|
+
async function stop(target, kind) {
|
|
305
|
+
const rows = await list(kind);
|
|
306
|
+
let victims;
|
|
307
|
+
if (!target)
|
|
308
|
+
victims = kind === "serve" && rows.length === 1 ? rows : [];
|
|
309
|
+
else if (/^\d+$/.test(target))
|
|
310
|
+
victims = rows.filter((r) => r.pid === Number(target));
|
|
311
|
+
else
|
|
312
|
+
victims = rows.filter((r) => r.name === target);
|
|
313
|
+
if (!victims.length) {
|
|
314
|
+
if (!target && rows.length > 1)
|
|
315
|
+
throw new Error(`several ${kind}s running \u2014 name one: ${rows.map((r) => r.name).join(", ")}`);
|
|
316
|
+
throw new Error(target ? `no registered ${kind} "${target}" in this project` : `no ${kind} running here`);
|
|
317
|
+
}
|
|
318
|
+
const pid = await projectId();
|
|
319
|
+
for (const v of victims) {
|
|
320
|
+
const r = await call(`/v1/processes/${v.pid}?project=${pid}`, { method: "DELETE" });
|
|
321
|
+
if (!r.ok)
|
|
322
|
+
throw new Error(r.error ?? `could not stop ${v.pid}`);
|
|
323
|
+
}
|
|
324
|
+
return victims;
|
|
325
|
+
}
|
|
326
|
+
function fmt(r) {
|
|
327
|
+
const where = r.port != null ? `:${r.port}`.padEnd(7) : "".padEnd(7);
|
|
328
|
+
return `${r.kind.padEnd(6)} ${r.name.padEnd(14)} pid ${String(r.pid).padEnd(7)} ${where} ${r.cmd.slice(0, 50)}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// packages/cli/src/bin.ts
|
|
332
|
+
var [cmd = "help", ...rest] = process.argv.slice(2);
|
|
333
|
+
var json = rest.includes("--json");
|
|
334
|
+
var arg = () => rest.find((a) => !a.startsWith("--"));
|
|
335
|
+
var help = `swarm \u2014 control plane for AI-agent development
|
|
336
|
+
|
|
337
|
+
setup start the daemon, install hooks, open the dashboard (do this first)
|
|
338
|
+
start | stop | restart manage the background daemon
|
|
339
|
+
status [-p] live sessions (whole machine, or one project)
|
|
340
|
+
doctor check everything and print the fix for each gap
|
|
341
|
+
|
|
342
|
+
add <path> [--name n] register (pin) a project
|
|
343
|
+
ls list projects
|
|
344
|
+
ui open the dashboard
|
|
345
|
+
tail [--project p] [--session id] follow the live event stream
|
|
346
|
+
|
|
347
|
+
claim <task> [--owner n] claim a task in a fresh isolated git worktree (fail-closed)
|
|
348
|
+
renew <task> extend the lease; release <task> [--force] release + remove worktree
|
|
349
|
+
claims list claims; reap release abandoned claims (keeps ones holding work)
|
|
350
|
+
tasks [--ready] [--json] the repo's task source (.swarm.toml [tasks] source); --ready = claimable now
|
|
351
|
+
res ls | acquire <name> [--owner n] [--pid n] [--port n] | release <name> [--force]
|
|
352
|
+
named singletons (ports, processes); fail-closed
|
|
353
|
+
serve start [--name web] [--from-port 3400 | --port n] -- <cmd>
|
|
354
|
+
start a dev server: port allocated, PORT set, pid tracked, port protected
|
|
355
|
+
serve ls | stop [name] list / stop servers this project started (by pid, never by pattern)
|
|
356
|
+
proc start [--name n] -- <cmd> | ls | stop <name|pid> same, for workers without a port
|
|
357
|
+
stats [-p] [--json] all-time totals, streak, records (the dashboard's Stats view)
|
|
358
|
+
|
|
359
|
+
install | uninstall add/remove Swarm hooks in ~/.claude/settings.json
|
|
360
|
+
|
|
361
|
+
Env: SWARM_URL, SWARM_PORT (default 7777), SWARM_HOME (~/.swarm)`;
|
|
362
|
+
async function api(path, init) {
|
|
363
|
+
const base = new SwarmClient().baseUrl;
|
|
364
|
+
const r = await fetch(`${base}${path}`, init);
|
|
365
|
+
if (!r.ok)
|
|
366
|
+
throw new Error(`${path}: ${r.status} ${await r.text()}`);
|
|
367
|
+
return r.status === 204 ? null : r.json();
|
|
368
|
+
}
|
|
369
|
+
async function daemonRunning() {
|
|
370
|
+
try {
|
|
371
|
+
await new SwarmClient().health();
|
|
372
|
+
return true;
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function stopDaemon() {
|
|
378
|
+
const info = readDaemonInfo();
|
|
379
|
+
if (!info)
|
|
380
|
+
return false;
|
|
381
|
+
try {
|
|
382
|
+
process.kill(info.pid, "SIGTERM");
|
|
383
|
+
return true;
|
|
384
|
+
} catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
switch (cmd) {
|
|
390
|
+
case "setup": {
|
|
391
|
+
const base = await ensureDaemon();
|
|
392
|
+
const evs = install();
|
|
393
|
+
console.log(`\u2713 daemon running at ${base}`);
|
|
394
|
+
console.log(`\u2713 installed hooks for ${evs.length} events + MCP server (${status().path})`);
|
|
395
|
+
console.log("\u2713 any Claude session you start now will appear in Swarm");
|
|
396
|
+
Bun.spawn(["open", base]).unref?.();
|
|
397
|
+
console.log(`
|
|
398
|
+
Open the dashboard: ${base}`);
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
case "start": {
|
|
402
|
+
if (await daemonRunning()) {
|
|
403
|
+
console.log(`already running at ${new SwarmClient().baseUrl}`);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
const base = await ensureDaemon();
|
|
407
|
+
console.log(`started at ${base}`);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case "stop":
|
|
411
|
+
console.log(await stopDaemon() ? "stopped" : "not running");
|
|
412
|
+
break;
|
|
413
|
+
case "restart":
|
|
414
|
+
await stopDaemon();
|
|
415
|
+
await Bun.sleep(300);
|
|
416
|
+
console.log(`restarted at ${await ensureDaemon()}`);
|
|
417
|
+
break;
|
|
418
|
+
case "install": {
|
|
419
|
+
const evs = install();
|
|
420
|
+
console.log(`installed hooks for ${evs.length} events in ${status().path}`);
|
|
421
|
+
console.log("restart any running claude session for it to report in.");
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
case "uninstall":
|
|
425
|
+
console.log(`removed ${uninstall()} hook entries`);
|
|
426
|
+
break;
|
|
427
|
+
case "doctor": {
|
|
428
|
+
const st = status();
|
|
429
|
+
const bun = Bun.which("bun");
|
|
430
|
+
const claude = Bun.which("claude");
|
|
431
|
+
const info = readDaemonInfo();
|
|
432
|
+
const running = await daemonRunning();
|
|
433
|
+
const line = (ok, label, fix) => console.log(`${ok ? "\u2713" : "\u2717"} ${label}${ok ? "" : ` \u2192 ${fix}`}`);
|
|
434
|
+
line(Boolean(bun), `bun ${bun ? `(${bun})` : ""}`, "install bun: https://bun.sh");
|
|
435
|
+
line(Boolean(claude), "claude CLI on PATH", "install Claude Code: https://claude.com/claude-code");
|
|
436
|
+
line(running, `daemon ${info ? `(pid ${info.pid}, ${info.url})` : ""}`, "run: swarm start");
|
|
437
|
+
line(st.installed, "hooks installed", "run: swarm install");
|
|
438
|
+
line(st.mcp, "MCP server registered", "run: swarm install");
|
|
439
|
+
const forge2 = (bin, auth) => {
|
|
440
|
+
const path = Bun.which(bin);
|
|
441
|
+
if (!path)
|
|
442
|
+
return console.log(`\xB7 ${bin} not found \u2014 PRs view skips ${bin === "gh" ? "GitHub" : "GitLab"} repos`);
|
|
443
|
+
const ok = Bun.spawnSync([path, ...auth], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
444
|
+
line(ok, `${bin} authenticated (${path})`, `run: ${bin} auth login`);
|
|
445
|
+
};
|
|
446
|
+
forge2("gh", ["auth", "status", "--active", "-h", "github.com"]);
|
|
447
|
+
forge2("glab", ["auth", "status"]);
|
|
448
|
+
if (process.env.GITLAB_TOKEN)
|
|
449
|
+
console.log("\xB7 glab uses GITLAB_TOKEN from this shell \u2014 a daemon started by the desktop app won't see it; run `glab auth login` to store it instead");
|
|
450
|
+
if (!running)
|
|
451
|
+
process.exitCode = 1;
|
|
452
|
+
console.log(`
|
|
453
|
+
settings: ${st.path}
|
|
454
|
+
daemon cmd: ${daemonCommand().join(" ")}
|
|
455
|
+
url: ${resolveBaseUrl()}`);
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
case "add": {
|
|
459
|
+
await ensureDaemon({ quiet: true });
|
|
460
|
+
const p = resolve3(arg() ?? ".");
|
|
461
|
+
const nameIdx = rest.indexOf("--name");
|
|
462
|
+
const name = nameIdx >= 0 ? rest[nameIdx + 1] : undefined;
|
|
463
|
+
const proj = await api("/v1/projects", {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: { "content-type": "application/json" },
|
|
466
|
+
body: JSON.stringify({ path: p, name })
|
|
467
|
+
});
|
|
468
|
+
console.log(json ? JSON.stringify(proj) : `added ${proj.name} (${proj.id}) \u2192 ${proj.root}`);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "claim": {
|
|
472
|
+
await ensureDaemon({ quiet: true });
|
|
473
|
+
const task = arg();
|
|
474
|
+
if (!task)
|
|
475
|
+
throw new Error("usage: swarm claim <task> [--owner name]");
|
|
476
|
+
const ownerIdx = rest.indexOf("--owner");
|
|
477
|
+
const owner = ownerIdx >= 0 ? rest[ownerIdx + 1] : process.env.USER ?? "me";
|
|
478
|
+
const proj = await api("/v1/projects", {
|
|
479
|
+
method: "POST",
|
|
480
|
+
headers: { "content-type": "application/json" },
|
|
481
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
482
|
+
});
|
|
483
|
+
const r = await fetch(`${new SwarmClient().baseUrl}/v1/claims`, {
|
|
484
|
+
method: "POST",
|
|
485
|
+
headers: { "content-type": "application/json" },
|
|
486
|
+
body: JSON.stringify({ projectId: proj.id, task, owner })
|
|
487
|
+
}).then((x) => x.json());
|
|
488
|
+
if (json)
|
|
489
|
+
console.log(JSON.stringify(r));
|
|
490
|
+
else if (r.ok)
|
|
491
|
+
console.log(`claimed ${task} \u2192 ${r.worktree}
|
|
492
|
+
cd ${r.worktree}`);
|
|
493
|
+
else {
|
|
494
|
+
console.error(`REFUSED: ${r.error}`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
case "renew":
|
|
500
|
+
case "release": {
|
|
501
|
+
await ensureDaemon({ quiet: true });
|
|
502
|
+
const task = arg();
|
|
503
|
+
if (!task)
|
|
504
|
+
throw new Error(`usage: swarm ${cmd} <task>${cmd === "release" ? " [--force]" : ""}`);
|
|
505
|
+
const proj = await api("/v1/projects", {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: { "content-type": "application/json" },
|
|
508
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
509
|
+
});
|
|
510
|
+
const r = await fetch(`${new SwarmClient().baseUrl}/v1/claims/${cmd}`, {
|
|
511
|
+
method: "POST",
|
|
512
|
+
headers: { "content-type": "application/json" },
|
|
513
|
+
body: JSON.stringify({ projectId: proj.id, task, force: rest.includes("--force") })
|
|
514
|
+
}).then((x) => x.json());
|
|
515
|
+
if (json)
|
|
516
|
+
console.log(JSON.stringify(r));
|
|
517
|
+
else if (r.ok)
|
|
518
|
+
console.log(cmd === "renew" ? `renewed ${task} until ${r.expiresAt}` : `released ${task}`);
|
|
519
|
+
else {
|
|
520
|
+
console.error(`REFUSED: ${r.error}`);
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case "reap": {
|
|
526
|
+
await ensureDaemon({ quiet: true });
|
|
527
|
+
const r = await api("/v1/claims/reap", { method: "POST" });
|
|
528
|
+
if (json)
|
|
529
|
+
console.log(JSON.stringify(r));
|
|
530
|
+
else if (r.reaped.length)
|
|
531
|
+
for (const x of r.reaped)
|
|
532
|
+
console.log(`${x.action.padEnd(14)} ${x.task}`);
|
|
533
|
+
else
|
|
534
|
+
console.log("nothing to reap");
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
case "claims": {
|
|
538
|
+
await ensureDaemon({ quiet: true });
|
|
539
|
+
const cs = await api("/v1/claims");
|
|
540
|
+
if (json)
|
|
541
|
+
console.log(JSON.stringify(cs));
|
|
542
|
+
else if (!cs.length)
|
|
543
|
+
console.log("no claims");
|
|
544
|
+
else
|
|
545
|
+
for (const c of cs)
|
|
546
|
+
console.log(`${c.state.padEnd(9)} ${c.task.padEnd(16)} ${(c.owner || "").padEnd(12)} ${c.worktree}`);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
case "tasks": {
|
|
550
|
+
await ensureDaemon({ quiet: true });
|
|
551
|
+
const proj = await api("/v1/projects", {
|
|
552
|
+
method: "POST",
|
|
553
|
+
headers: { "content-type": "application/json" },
|
|
554
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
555
|
+
});
|
|
556
|
+
const t = await api(`/v1/tasks?project=${proj.id}`);
|
|
557
|
+
const ready = rest.includes("--ready");
|
|
558
|
+
const rows = ready ? t.tasks.filter((x) => x.ready) : t.tasks;
|
|
559
|
+
if (json)
|
|
560
|
+
console.log(JSON.stringify(rows));
|
|
561
|
+
else if (!t.source)
|
|
562
|
+
console.log('no task source \u2014 add `[tasks] source = "path/to/plan.md"` to .swarm.toml');
|
|
563
|
+
else if (!rows.length)
|
|
564
|
+
console.log(ready ? "nothing ready to claim" : `no tasks in ${t.source}`);
|
|
565
|
+
else
|
|
566
|
+
for (const x of rows) {
|
|
567
|
+
const st = x.claimedBy ? `held:${x.claimedBy}` : x.ready ? "ready" : x.status;
|
|
568
|
+
console.log(`${st.padEnd(14)} ${x.id.padEnd(8)} ${x.title.slice(0, 60).padEnd(60)} ${x.depends.join(",")}`);
|
|
569
|
+
}
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case "serve":
|
|
573
|
+
case "proc": {
|
|
574
|
+
await ensureDaemon({ quiet: true });
|
|
575
|
+
const kind = cmd;
|
|
576
|
+
const dash = rest.indexOf("--");
|
|
577
|
+
const head = dash >= 0 ? rest.slice(0, dash) : rest;
|
|
578
|
+
const tail = dash >= 0 ? rest.slice(dash + 1) : [];
|
|
579
|
+
const flag = (n) => {
|
|
580
|
+
const i = head.indexOf(n);
|
|
581
|
+
return i >= 0 ? head[i + 1] : undefined;
|
|
582
|
+
};
|
|
583
|
+
const num = (n) => {
|
|
584
|
+
const v = flag(n);
|
|
585
|
+
return v != null && Number.isFinite(Number(v)) ? Number(v) : undefined;
|
|
586
|
+
};
|
|
587
|
+
const valueFlags = new Set(["--name", "--from-port", "--port", "--owner"]);
|
|
588
|
+
const positionals = [];
|
|
589
|
+
for (let i = 0;i < head.length; i++) {
|
|
590
|
+
const a = head[i];
|
|
591
|
+
if (valueFlags.has(a))
|
|
592
|
+
i++;
|
|
593
|
+
else if (!a.startsWith("--"))
|
|
594
|
+
positionals.push(a);
|
|
595
|
+
}
|
|
596
|
+
const sub = positionals[0] ?? "ls";
|
|
597
|
+
if (sub === "start") {
|
|
598
|
+
const name = flag("--name") ?? (kind === "serve" ? "web" : tail[0]?.split("/").pop() ?? "proc");
|
|
599
|
+
const p = await start({
|
|
600
|
+
kind,
|
|
601
|
+
name,
|
|
602
|
+
cmd: tail,
|
|
603
|
+
fromPort: num("--from-port"),
|
|
604
|
+
port: num("--port"),
|
|
605
|
+
owner: flag("--owner") ?? process.env.USER ?? "me"
|
|
606
|
+
});
|
|
607
|
+
if (json)
|
|
608
|
+
console.log(JSON.stringify(p));
|
|
609
|
+
else
|
|
610
|
+
console.log(`started ${p.name}${p.port ? ` on :${p.port}` : ""} (pid ${p.pid})
|
|
611
|
+
log: ${p.log}
|
|
612
|
+
stop: swarm ${kind} stop ${p.name}`);
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
if (sub === "ls") {
|
|
616
|
+
const rows = await list(kind);
|
|
617
|
+
if (json)
|
|
618
|
+
console.log(JSON.stringify(rows));
|
|
619
|
+
else if (!rows.length)
|
|
620
|
+
console.log(`no ${kind === "serve" ? "servers" : "processes"} running here`);
|
|
621
|
+
else
|
|
622
|
+
for (const r of rows)
|
|
623
|
+
console.log(fmt(r));
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
if (sub === "stop") {
|
|
627
|
+
const stopped = await stop(positionals[1], kind);
|
|
628
|
+
if (json)
|
|
629
|
+
console.log(JSON.stringify(stopped));
|
|
630
|
+
else
|
|
631
|
+
for (const r of stopped)
|
|
632
|
+
console.log(`stopped ${r.name} (pid ${r.pid})`);
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
throw new Error(`usage: swarm ${kind} start|ls|stop`);
|
|
636
|
+
}
|
|
637
|
+
case "ls": {
|
|
638
|
+
await ensureDaemon({ quiet: true });
|
|
639
|
+
const ps = await api("/v1/projects");
|
|
640
|
+
if (json)
|
|
641
|
+
console.log(JSON.stringify(ps));
|
|
642
|
+
else
|
|
643
|
+
for (const p of ps)
|
|
644
|
+
console.log(`${p.discovered ? "\u25CB" : "\u25CF"} ${p.name.padEnd(20)} ${p.root}`);
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
case "status": {
|
|
648
|
+
await ensureDaemon({ quiet: true });
|
|
649
|
+
const s = await api("/v1/state");
|
|
650
|
+
if (json) {
|
|
651
|
+
console.log(JSON.stringify(s));
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
const name = (id) => s.projects.find((p) => p.id === id)?.name ?? "?";
|
|
655
|
+
const live = s.sessions.filter((x) => x.state === "active" || x.state === "waiting");
|
|
656
|
+
for (const x of live)
|
|
657
|
+
console.log(`${x.state === "active" ? "\u25CF" : "\u25D0"} ${name(x.projectId).padEnd(16)} ${x.id.slice(0, 8)} ${x.last.slice(0, 60).padEnd(60)} ${x.costUsd != null ? `$${x.costUsd.toFixed(2)}` : ""}`);
|
|
658
|
+
if (!live.length)
|
|
659
|
+
console.log("no live sessions");
|
|
660
|
+
for (const r of s.resources ?? []) {
|
|
661
|
+
const via = r.port != null ? `:${r.port}` : r.pid != null ? `pid ${r.pid}` : "";
|
|
662
|
+
console.log(` res ${r.name.padEnd(16)} ${r.owner.padEnd(12)} ${r.projectId ? name(r.projectId) : "global"} ${via}`);
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
case "stats": {
|
|
667
|
+
await ensureDaemon({ quiet: true });
|
|
668
|
+
let q = "";
|
|
669
|
+
if (rest.includes("-p")) {
|
|
670
|
+
const proj = await api("/v1/projects", {
|
|
671
|
+
method: "POST",
|
|
672
|
+
headers: { "content-type": "application/json" },
|
|
673
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
674
|
+
});
|
|
675
|
+
q = `?project=${encodeURIComponent(proj.id)}`;
|
|
676
|
+
}
|
|
677
|
+
const st = await api(`/v1/stats${q}`);
|
|
678
|
+
if (json) {
|
|
679
|
+
console.log(JSON.stringify(st));
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
const T = st.totals;
|
|
683
|
+
const usd = (n) => n == null ? "\u2014" : `$${n.toFixed(2)}`;
|
|
684
|
+
console.log(`since ${T.firstTs?.slice(0, 10) ?? "\u2014"}: ${T.turns} turns, ${T.sessions} sessions, ${T.toolCalls} tool calls, ${usd(T.cost)}`);
|
|
685
|
+
console.log(`tokens: in ${T.input} \xB7 out ${T.output} \xB7 cache read ${T.cacheRead} \xB7 cache write ${T.cacheWrite} \xB7 thinking ${T.thinking}`);
|
|
686
|
+
console.log(`active days (365d): ${st.daily.filter((d) => d.turns).length}`);
|
|
687
|
+
for (const m of st.byModel.slice(0, 5))
|
|
688
|
+
console.log(` ${m.model.padEnd(28)} ${String(m.turns).padStart(6)} turns ${m.output} out`);
|
|
689
|
+
if (st.records.busiestDay)
|
|
690
|
+
console.log(`busiest day: ${st.records.busiestDay.day} (${st.records.busiestDay.turns} turns, ${usd(st.records.busiestDay.cost)})`);
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
case "res": {
|
|
694
|
+
await ensureDaemon({ quiet: true });
|
|
695
|
+
const valueFlags = new Set(["--owner", "--pid", "--port"]);
|
|
696
|
+
const positionals = [];
|
|
697
|
+
for (let i = 0;i < rest.length; i++) {
|
|
698
|
+
const a = rest[i];
|
|
699
|
+
if (valueFlags.has(a))
|
|
700
|
+
i++;
|
|
701
|
+
else if (!a.startsWith("--"))
|
|
702
|
+
positionals.push(a);
|
|
703
|
+
}
|
|
704
|
+
const sub = positionals[0];
|
|
705
|
+
const flag = (n) => {
|
|
706
|
+
const i = rest.indexOf(n);
|
|
707
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
708
|
+
};
|
|
709
|
+
const numFlag = (n) => {
|
|
710
|
+
const v = flag(n);
|
|
711
|
+
const x = v != null ? Number(v) : undefined;
|
|
712
|
+
return x != null && Number.isFinite(x) ? x : undefined;
|
|
713
|
+
};
|
|
714
|
+
if (sub === "ls" || !sub) {
|
|
715
|
+
const list2 = await api("/v1/resources");
|
|
716
|
+
if (json)
|
|
717
|
+
console.log(JSON.stringify(list2));
|
|
718
|
+
else if (!list2.length)
|
|
719
|
+
console.log("no resources held");
|
|
720
|
+
else
|
|
721
|
+
for (const r of list2)
|
|
722
|
+
console.log(`${r.kind.padEnd(8)} ${r.name.padEnd(16)} ${r.owner.padEnd(12)} ${r.port != null ? `:${r.port}` : r.pid != null ? `pid ${r.pid}` : ""}`);
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
if (sub === "acquire") {
|
|
726
|
+
const name = positionals[1];
|
|
727
|
+
if (!name)
|
|
728
|
+
throw new Error("usage: swarm res acquire <name> [--owner n] [--pid n] [--port n]");
|
|
729
|
+
const proj = await api("/v1/projects", {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: { "content-type": "application/json" },
|
|
732
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
733
|
+
});
|
|
734
|
+
const r = await fetch(`${new SwarmClient().baseUrl}/v1/resources`, {
|
|
735
|
+
method: "POST",
|
|
736
|
+
headers: { "content-type": "application/json" },
|
|
737
|
+
body: JSON.stringify({
|
|
738
|
+
name,
|
|
739
|
+
owner: flag("--owner") ?? process.env.USER ?? "me",
|
|
740
|
+
pid: numFlag("--pid"),
|
|
741
|
+
port: numFlag("--port"),
|
|
742
|
+
projectId: proj.id
|
|
743
|
+
})
|
|
744
|
+
}).then((x) => x.json());
|
|
745
|
+
if (json)
|
|
746
|
+
console.log(JSON.stringify(r));
|
|
747
|
+
else if (r.ok)
|
|
748
|
+
console.log(`acquired ${name}`);
|
|
749
|
+
else {
|
|
750
|
+
console.error(`REFUSED: ${r.error}`);
|
|
751
|
+
process.exit(1);
|
|
752
|
+
}
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
if (sub === "release") {
|
|
756
|
+
const name = positionals[1];
|
|
757
|
+
if (!name)
|
|
758
|
+
throw new Error("usage: swarm res release <name> [--owner n] [--force]");
|
|
759
|
+
const proj = await api("/v1/projects", {
|
|
760
|
+
method: "POST",
|
|
761
|
+
headers: { "content-type": "application/json" },
|
|
762
|
+
body: JSON.stringify({ path: resolve3(".") })
|
|
763
|
+
});
|
|
764
|
+
const q = new URLSearchParams({
|
|
765
|
+
project: proj.id,
|
|
766
|
+
owner: flag("--owner") ?? process.env.USER ?? "me"
|
|
767
|
+
});
|
|
768
|
+
if (rest.includes("--force"))
|
|
769
|
+
q.set("force", "1");
|
|
770
|
+
const r = await fetch(`${new SwarmClient().baseUrl}/v1/resources/${encodeURIComponent(name)}?${q}`, { method: "DELETE" }).then((x) => x.json());
|
|
771
|
+
if (json)
|
|
772
|
+
console.log(JSON.stringify(r));
|
|
773
|
+
else if (r.ok)
|
|
774
|
+
console.log(`released ${name}`);
|
|
775
|
+
else {
|
|
776
|
+
console.error(`REFUSED: ${r.error}`);
|
|
777
|
+
process.exit(1);
|
|
778
|
+
}
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
throw new Error("usage: swarm res ls | acquire <name> | release <name> [--force]");
|
|
782
|
+
}
|
|
783
|
+
case "tail": {
|
|
784
|
+
await ensureDaemon({ quiet: true });
|
|
785
|
+
const base = new SwarmClient().baseUrl;
|
|
786
|
+
const pIdx = rest.indexOf("--session");
|
|
787
|
+
const wantSession = pIdx >= 0 ? rest[pIdx + 1] : undefined;
|
|
788
|
+
const res = await fetch(`${base}/v1/events?since=0`);
|
|
789
|
+
const reader = res.body?.getReader();
|
|
790
|
+
if (!reader)
|
|
791
|
+
throw new Error("no stream");
|
|
792
|
+
const dec = new TextDecoder;
|
|
793
|
+
let buf = "";
|
|
794
|
+
for (;; ) {
|
|
795
|
+
const { value, done } = await reader.read();
|
|
796
|
+
if (done)
|
|
797
|
+
break;
|
|
798
|
+
buf += dec.decode(value, { stream: true });
|
|
799
|
+
const parts = buf.split(`
|
|
800
|
+
|
|
801
|
+
`);
|
|
802
|
+
buf = parts.pop() ?? "";
|
|
803
|
+
for (const block of parts) {
|
|
804
|
+
const data = block.split(`
|
|
805
|
+
`).find((l) => l.startsWith("data:"))?.slice(5).trim();
|
|
806
|
+
if (!data)
|
|
807
|
+
continue;
|
|
808
|
+
try {
|
|
809
|
+
const e = JSON.parse(data);
|
|
810
|
+
if (wantSession && e.sessionId !== wantSession)
|
|
811
|
+
continue;
|
|
812
|
+
console.log(`${e.ts.slice(11, 19)} ${e.type.padEnd(18)} ${e.payload?.summary ?? ""}`);
|
|
813
|
+
} catch {}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
case "ui": {
|
|
819
|
+
const base = await ensureDaemon();
|
|
820
|
+
Bun.spawn(["open", base]).unref?.();
|
|
821
|
+
console.log(base);
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
default:
|
|
825
|
+
console.log(help);
|
|
826
|
+
}
|
|
827
|
+
} catch (e) {
|
|
828
|
+
console.error(e.message);
|
|
829
|
+
process.exit(2);
|
|
830
|
+
}
|