@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 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.
@@ -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 check everything and print the fix for each gap
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]");