@ra3orblade/swarm 0.9.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 +2 -0
- package/dist/swarm.js +171 -2
- package/dist/swarmd.js +1179 -20
- package/package.json +1 -1
- package/web/app.js +184 -18
- package/web/index.html +21 -12
- package/web/release-notes.js +1 -1
- package/web/viz.js +47 -1
package/README.md
CHANGED
|
@@ -221,4 +221,11 @@ bun run dev # daemon with hot reload
|
|
|
221
221
|
bun run test # the suite CI runs
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
Apache-2.0
|
|
224
|
+
Apache-2.0 — everything that runs on one machine: daemon, dashboard, CLI, MCP server, hooks, all
|
|
225
|
+
agent adapters. No telemetry, no account, and that never changes.
|
|
226
|
+
|
|
227
|
+
The one exception is [`packages/team`](packages/team) — the self-hosted **team daemon** (multi-machine,
|
|
228
|
+
paid tier), which is source-available under
|
|
229
|
+
[FSL-1.1-ALv2](packages/team/LICENSE.md): free for internal use, education, research and
|
|
230
|
+
professional services, converting to Apache-2.0 two years after each release. The boundary is
|
|
231
|
+
simple ([OQ-15](docs/07-open-questions.md)): one machine free, a second person is the product.
|
package/dist/swarm-hook.js
CHANGED
|
@@ -232,6 +232,8 @@ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file
|
|
|
232
232
|
}
|
|
233
233
|
// packages/core/src/ledger.ts
|
|
234
234
|
var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
235
|
+
// packages/core/src/outcomes.ts
|
|
236
|
+
var DEFAULT_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
|
|
235
237
|
// packages/core/src/policy.ts
|
|
236
238
|
import { createHash } from "crypto";
|
|
237
239
|
var POLICY_CACHE_VERSION = 1;
|
package/dist/swarm.js
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// packages/cli/src/bin.ts
|
|
5
|
+
import {
|
|
6
|
+
copyFileSync,
|
|
7
|
+
existsSync as existsSync5,
|
|
8
|
+
mkdirSync as mkdirSync3,
|
|
9
|
+
readdirSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync as writeFileSync3
|
|
12
|
+
} from "fs";
|
|
5
13
|
import { join as join5, resolve as resolve3 } from "path";
|
|
6
14
|
|
|
7
15
|
// packages/client/src/daemon.ts
|
|
@@ -287,6 +295,9 @@ var DEFAULT_CONFIG = {
|
|
|
287
295
|
gates: { required: [], auto: "session-end", defs: {} },
|
|
288
296
|
workflows: {},
|
|
289
297
|
budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
|
|
298
|
+
models: { allow: [] },
|
|
299
|
+
notify: { webhook: null },
|
|
300
|
+
team: { url: null, forward: ["ledger", "cost"], interval: 5 },
|
|
290
301
|
events: { retain_days: 30 },
|
|
291
302
|
audit: { retain_days: 0 },
|
|
292
303
|
privacy: DEFAULT_PRIVACY,
|
|
@@ -382,6 +393,26 @@ function validate(c) {
|
|
|
382
393
|
on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
|
|
383
394
|
},
|
|
384
395
|
workflows: parseWorkflows(c.workflows),
|
|
396
|
+
notify: {
|
|
397
|
+
webhook: (() => {
|
|
398
|
+
const w = c.notify?.webhook;
|
|
399
|
+
return typeof w === "string" && /^https?:\/\//.test(w.trim()) ? w.trim() : null;
|
|
400
|
+
})()
|
|
401
|
+
},
|
|
402
|
+
models: {
|
|
403
|
+
allow: Array.isArray(c.models?.allow) ? c.models.allow.filter((m) => typeof m === "string" && m.trim() !== "") : []
|
|
404
|
+
},
|
|
405
|
+
team: (() => {
|
|
406
|
+
const t = c.team ?? {};
|
|
407
|
+
const url = typeof t.url === "string" && /^https?:\/\//.test(t.url.trim()) ? t.url.trim().replace(/\/+$/, "") : null;
|
|
408
|
+
const iv = Number(t.interval);
|
|
409
|
+
const KINDS = ["ledger", "cost", "transcripts"];
|
|
410
|
+
return {
|
|
411
|
+
url,
|
|
412
|
+
forward: Array.isArray(t.forward) ? t.forward.filter((k) => typeof k === "string" && KINDS.includes(k)) : ["ledger", "cost"],
|
|
413
|
+
interval: Number.isFinite(iv) && iv >= 1 && iv <= 300 ? Math.round(iv) : 5
|
|
414
|
+
};
|
|
415
|
+
})(),
|
|
385
416
|
events: {
|
|
386
417
|
retain_days: days(c.events?.retain_days, 30)
|
|
387
418
|
},
|
|
@@ -501,6 +532,8 @@ var LIVE_WINDOW_MS = 10 * 60000;
|
|
|
501
532
|
var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
502
533
|
// packages/core/src/ledger.ts
|
|
503
534
|
var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
535
|
+
// packages/core/src/outcomes.ts
|
|
536
|
+
var DEFAULT_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
|
|
504
537
|
// packages/core/src/policy.ts
|
|
505
538
|
var HOOK_MARK = "swarm-hook";
|
|
506
539
|
var hookIsOurs = (h) => typeof h.command === "string" && (h.command.includes(HOOK_MARK) || h.command.includes("/packages/hook/src/bin.ts"));
|
|
@@ -757,6 +790,26 @@ function status() {
|
|
|
757
790
|
otherAgents
|
|
758
791
|
};
|
|
759
792
|
}
|
|
793
|
+
function setTeamUrl(home, url) {
|
|
794
|
+
const path = join3(home, "config.toml");
|
|
795
|
+
const text = existsSync4(path) ? readFileSync3(path, "utf8") : "";
|
|
796
|
+
let out;
|
|
797
|
+
const section = text.match(/(^|\n)\[team\]([\s\S]*?)(?=\n\[|$)/);
|
|
798
|
+
if (section) {
|
|
799
|
+
const body = section[2] ?? "";
|
|
800
|
+
const newBody = /^\s*url\s*=/m.test(body) ? body.replace(/^\s*url\s*=.*$/m, `url = "${url}"`) : `
|
|
801
|
+
url = "${url}"${body}`;
|
|
802
|
+
out = text.replace(section[0], `${section[1]}[team]${newBody}`);
|
|
803
|
+
} else {
|
|
804
|
+
out = `${text}${text && !text.endsWith(`
|
|
805
|
+
`) ? `
|
|
806
|
+
` : ""}
|
|
807
|
+
[team]
|
|
808
|
+
url = "${url}"
|
|
809
|
+
`;
|
|
810
|
+
}
|
|
811
|
+
writeFileSync2(path, out);
|
|
812
|
+
}
|
|
760
813
|
|
|
761
814
|
// packages/cli/src/procs.ts
|
|
762
815
|
import { mkdirSync as mkdirSync2, openSync } from "fs";
|
|
@@ -873,7 +926,7 @@ var help = `swarm \u2014 control plane for AI-agent development
|
|
|
873
926
|
setup start the daemon, install hooks, open the dashboard (do this first)
|
|
874
927
|
start | stop | restart manage the background daemon
|
|
875
928
|
status [-p] live sessions (whole machine, or one project)
|
|
876
|
-
doctor
|
|
929
|
+
doctor [--migrate] check everything and print the fix for each gap (--migrate: apply pending db migrations)
|
|
877
930
|
|
|
878
931
|
add <path> [--name n] register (pin) a project
|
|
879
932
|
ls list projects
|
|
@@ -911,8 +964,10 @@ var help = `swarm \u2014 control plane for AI-agent development
|
|
|
911
964
|
msg ls [-p] [--json] recent messages
|
|
912
965
|
demo open a seeded demo dashboard (own home + port; your real data is untouched)
|
|
913
966
|
audit export [--since 30d|ISO] [-p] [--type claim.acquired] [--format jsonl|csv|json] [--limit n] the audit log (ledger changes + decisions, with actor) to stdout
|
|
967
|
+
login [url] [--token t] log in to the team daemon ([team].url) and register this machine (M8.3c)
|
|
968
|
+
backup [dest] | restore <src> snapshot ~/.swarm (VACUUM INTO, zero downtime) / restore it (daemon stopped)
|
|
914
969
|
|
|
915
|
-
install | uninstall add/remove Swarm hooks in ~/.claude/settings.json
|
|
970
|
+
install [--config-url <team url>] | uninstall add/remove Swarm hooks in ~/.claude/settings.json (--config-url also points this machine at a team daemon)
|
|
916
971
|
|
|
917
972
|
Env: SWARM_URL, SWARM_PORT (default 7777), SWARM_HOME (~/.swarm)`;
|
|
918
973
|
async function api(path, init) {
|
|
@@ -974,6 +1029,15 @@ Open the dashboard: ${base}`);
|
|
|
974
1029
|
case "install": {
|
|
975
1030
|
const evs = install();
|
|
976
1031
|
console.log(`installed hooks for ${evs.length} events in ${status().path}`);
|
|
1032
|
+
const cuIdx = rest.indexOf("--config-url");
|
|
1033
|
+
const configUrl = cuIdx >= 0 ? rest[cuIdx + 1] : null;
|
|
1034
|
+
if (configUrl) {
|
|
1035
|
+
if (!/^https?:\/\//.test(configUrl))
|
|
1036
|
+
throw new Error("--config-url must be an http(s) URL to the team daemon");
|
|
1037
|
+
setTeamUrl(swarmHome(), configUrl.replace(/\/+$/, ""));
|
|
1038
|
+
console.log(`[team] url = "${configUrl}" written to ${join5(swarmHome(), "config.toml")}`);
|
|
1039
|
+
console.log("next: swarm login \u2014 to register this machine and pin the org policy key");
|
|
1040
|
+
}
|
|
977
1041
|
console.log("restart any running claude session for it to report in.");
|
|
978
1042
|
break;
|
|
979
1043
|
}
|
|
@@ -981,6 +1045,8 @@ Open the dashboard: ${base}`);
|
|
|
981
1045
|
console.log(`removed ${uninstall()} hook entries`);
|
|
982
1046
|
break;
|
|
983
1047
|
case "doctor": {
|
|
1048
|
+
if (rest.includes("--migrate"))
|
|
1049
|
+
await ensureDaemon({ quiet: true });
|
|
984
1050
|
const st = status();
|
|
985
1051
|
const bun = Bun.which("bun");
|
|
986
1052
|
const claude = Bun.which("claude");
|
|
@@ -1020,6 +1086,13 @@ Open the dashboard: ${base}`);
|
|
|
1020
1086
|
};
|
|
1021
1087
|
forge2("gh", ["auth", "status", "--active", "-h", "github.com"]);
|
|
1022
1088
|
forge2("glab", ["auth", "status"]);
|
|
1089
|
+
if (running) {
|
|
1090
|
+
const t = await api("/v1/team").catch(() => null);
|
|
1091
|
+
if (t?.configured) {
|
|
1092
|
+
const lag = t.pending ? `${t.pending} pending${t.oldest ? ` since ${t.oldest}` : ""}` : "in sync";
|
|
1093
|
+
line(!t.lastError, `team forwarding \u2192 ${t.url} (${lag})`, `last error: ${t.lastError} \u2014 check the team daemon and [team].url`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1023
1096
|
if (process.env.GITLAB_TOKEN)
|
|
1024
1097
|
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");
|
|
1025
1098
|
if (!running)
|
|
@@ -1808,6 +1881,102 @@ ${flag("--body") ?? d.body}`);
|
|
|
1808
1881
|
}
|
|
1809
1882
|
throw new Error("usage: swarm msg send <to> <text\u2026> | swarm msg ls");
|
|
1810
1883
|
}
|
|
1884
|
+
case "backup": {
|
|
1885
|
+
await ensureDaemon({ quiet: true });
|
|
1886
|
+
const dest = resolve3(arg() ?? join5(swarmHome(), "backups", new Date().toISOString().slice(0, 10)));
|
|
1887
|
+
const r = await api("/v1/backup", {
|
|
1888
|
+
method: "POST",
|
|
1889
|
+
headers: { "content-type": "application/json" },
|
|
1890
|
+
body: JSON.stringify({ dest })
|
|
1891
|
+
});
|
|
1892
|
+
console.log(`backed up ${r.files.join(", ")} \u2192 ${r.dest}`);
|
|
1893
|
+
console.log(`restore on any machine with: swarm restore ${r.dest} (daemon stopped)`);
|
|
1894
|
+
break;
|
|
1895
|
+
}
|
|
1896
|
+
case "restore": {
|
|
1897
|
+
const src = arg();
|
|
1898
|
+
if (!src)
|
|
1899
|
+
throw new Error("usage: swarm restore <backup dir> (with the daemon stopped)");
|
|
1900
|
+
if (await daemonRunning())
|
|
1901
|
+
throw new Error("the daemon is running \u2014 stop it first: swarm stop");
|
|
1902
|
+
const home = swarmHome();
|
|
1903
|
+
mkdirSync3(home, { recursive: true });
|
|
1904
|
+
let n = 0;
|
|
1905
|
+
for (const f of readdirSync(resolve3(src))) {
|
|
1906
|
+
if (f === "daemon.json")
|
|
1907
|
+
continue;
|
|
1908
|
+
copyFileSync(join5(resolve3(src), f), join5(home, f));
|
|
1909
|
+
n++;
|
|
1910
|
+
}
|
|
1911
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
1912
|
+
const p = join5(home, `swarm.db${suffix}`);
|
|
1913
|
+
if (existsSync5(p))
|
|
1914
|
+
unlinkSync(p);
|
|
1915
|
+
}
|
|
1916
|
+
console.log(`restored ${n} files into ${home} \u2014 run: swarm start`);
|
|
1917
|
+
break;
|
|
1918
|
+
}
|
|
1919
|
+
case "login": {
|
|
1920
|
+
await ensureDaemon({ quiet: true });
|
|
1921
|
+
const t = await api("/v1/team");
|
|
1922
|
+
const teamUrl = (arg() ?? t.url)?.replace(/\/+$/, "");
|
|
1923
|
+
if (!teamUrl)
|
|
1924
|
+
throw new Error("no team daemon configured \u2014 set [team] url or run: swarm login <url>");
|
|
1925
|
+
const cfg = await (await authedFetch(`${teamUrl}/t1/auth/config`)).json();
|
|
1926
|
+
const tokIdx = rest.indexOf("--token");
|
|
1927
|
+
let human = null;
|
|
1928
|
+
if (cfg.mode === "oidc") {
|
|
1929
|
+
const flow = await (await authedFetch(`${teamUrl}/t1/auth/device`, { method: "POST" })).json();
|
|
1930
|
+
if (!flow.handle)
|
|
1931
|
+
throw new Error(`login failed: ${flow.error ?? "no device flow"}`);
|
|
1932
|
+
console.log(`visit ${flow.verificationUriComplete ?? flow.verificationUri} and enter code: ${flow.userCode}`);
|
|
1933
|
+
for (;; ) {
|
|
1934
|
+
await new Promise((r) => setTimeout(r, (flow.interval ?? 5) * 1000));
|
|
1935
|
+
const poll = await (await authedFetch(`${teamUrl}/t1/auth/token`, {
|
|
1936
|
+
method: "POST",
|
|
1937
|
+
headers: { "content-type": "application/json" },
|
|
1938
|
+
body: JSON.stringify({ handle: flow.handle })
|
|
1939
|
+
})).json();
|
|
1940
|
+
if (poll.status === "pending")
|
|
1941
|
+
continue;
|
|
1942
|
+
if (poll.status !== "ok" || !poll.token)
|
|
1943
|
+
throw new Error(`login failed: ${poll.error ?? "denied"}`);
|
|
1944
|
+
human = poll.token;
|
|
1945
|
+
writeFileSync3(join5(swarmHome(), "team-token"), JSON.stringify({
|
|
1946
|
+
url: teamUrl,
|
|
1947
|
+
token: poll.token,
|
|
1948
|
+
subject: poll.subject,
|
|
1949
|
+
role: poll.role
|
|
1950
|
+
}), { mode: 384 });
|
|
1951
|
+
console.log(`logged in as ${poll.subject} (${poll.role})`);
|
|
1952
|
+
break;
|
|
1953
|
+
}
|
|
1954
|
+
} else if (cfg.mode === "token") {
|
|
1955
|
+
human = tokIdx >= 0 ? rest[tokIdx + 1] ?? null : null;
|
|
1956
|
+
if (!human)
|
|
1957
|
+
throw new Error("this team daemon uses a shared token \u2014 run: swarm login --token <secret>");
|
|
1958
|
+
} else {
|
|
1959
|
+
console.log("team daemon runs open (no auth configured) \u2014 registering machine directly");
|
|
1960
|
+
}
|
|
1961
|
+
const reg = await (await authedFetch(`${teamUrl}/t1/machines/register`, {
|
|
1962
|
+
method: "POST",
|
|
1963
|
+
headers: {
|
|
1964
|
+
"content-type": "application/json",
|
|
1965
|
+
...human ? { authorization: `Bearer ${human}` } : {}
|
|
1966
|
+
},
|
|
1967
|
+
body: JSON.stringify({ id: t.machine.id, name: t.machine.name })
|
|
1968
|
+
})).json();
|
|
1969
|
+
const machineToken = reg.token ?? (cfg.mode === "token" ? human : null);
|
|
1970
|
+
if (!machineToken)
|
|
1971
|
+
throw new Error(`machine registration failed: ${reg.error ?? "unknown"}`);
|
|
1972
|
+
await api("/v1/team/credentials", {
|
|
1973
|
+
method: "POST",
|
|
1974
|
+
headers: { "content-type": "application/json" },
|
|
1975
|
+
body: JSON.stringify({ token: machineToken, policyPublicKey: cfg.policyPublicKey })
|
|
1976
|
+
});
|
|
1977
|
+
console.log(`machine ${t.machine.name} (${t.machine.id.slice(0, 8)}) registered \u2014 forwarding is authed`);
|
|
1978
|
+
break;
|
|
1979
|
+
}
|
|
1811
1980
|
case "audit": {
|
|
1812
1981
|
if (rest[0] !== "export")
|
|
1813
1982
|
throw new Error("usage: swarm audit export [--since 30d] [-p] [--type t] [--format jsonl|csv|json] [--limit n]");
|