@ra3orblade/swarm 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.
@@ -77,7 +77,9 @@ var AUDIT_TYPES = new Set([
77
77
  "permission.resolved",
78
78
  "incident.opened",
79
79
  "incident.acked",
80
- "run.result"
80
+ "run.result",
81
+ "workflow.started",
82
+ "workflow.finished"
81
83
  ]);
82
84
  var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
83
85
  // packages/core/src/budget.ts
@@ -230,6 +232,8 @@ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file
230
232
  }
231
233
  // packages/core/src/ledger.ts
232
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"]);
233
237
  // packages/core/src/policy.ts
234
238
  import { createHash } from "crypto";
235
239
  var POLICY_CACHE_VERSION = 1;
package/dist/swarm-mcp.js CHANGED
@@ -19952,15 +19952,44 @@ ${lines.join(`
19952
19952
  return ok(`asked as question #${r.question?.id}. A human will see it on the dashboard; the answer arrives as [swarm] context on a later tool call, or via swarm_inbox. Continue with what doesn't depend on it, or stop and say you're waiting.`, r.question);
19953
19953
  });
19954
19954
  server.registerTool("swarm_inbox", {
19955
- title: "Answers waiting for you",
19956
- description: "Answers a human has given to your swarm_ask questions that you haven't received yet.",
19955
+ title: "What's waiting for you",
19956
+ description: "Answers to your swarm_ask questions and messages other agents or the human sent you (swarm_send) that you haven't received yet.",
19957
19957
  inputSchema: {}
19958
19958
  }, async () => {
19959
19959
  const qs = await api2(`/v1/inbox?session=${encodeURIComponent(SESSION ?? "")}`);
19960
- if (!qs.length)
19961
- return ok("no new answers", []);
19962
- return ok(qs.map((q) => `#${q.id} "${q.text}" \u2192 ${q.answeredBy ?? "a human"}: ${q.answer}`).join(`
19963
- `), qs);
19960
+ const ms = await api2(`/v1/messages/inbox?session=${encodeURIComponent(SESSION ?? "")}`);
19961
+ if (!qs.length && !ms.length)
19962
+ return ok("nothing new", []);
19963
+ const lines = [
19964
+ ...qs.map((q) => `answer #${q.id} "${q.text}" \u2192 ${q.answeredBy ?? "a human"}: ${q.answer}`),
19965
+ ...ms.map((m) => `message #${m.id} from ${m.from ?? "unknown"}${m.task ? ` (re ${m.task})` : ""}: ${m.text}`)
19966
+ ];
19967
+ return ok(lines.join(`
19968
+ `), { answers: qs, messages: ms });
19969
+ });
19970
+ server.registerTool("swarm_send", {
19971
+ title: "Message another agent (or the human)",
19972
+ description: `Send a short message to another session (session id), to whoever holds a task (task id), or to "lead" \u2014 the human's interactive session in this project. Delivery is on their next tool call (or immediately to a spawned run); replies come back via swarm_inbox.`,
19973
+ inputSchema: {
19974
+ to: exports_external.string().describe('session id, task id, or "lead"'),
19975
+ text: exports_external.string().max(4000)
19976
+ }
19977
+ }, async ({ to, text }) => {
19978
+ const pid = await projectId();
19979
+ const r = await api2("/v1/messages", {
19980
+ method: "POST",
19981
+ headers: { "content-type": "application/json" },
19982
+ body: JSON.stringify({
19983
+ projectId: pid,
19984
+ to,
19985
+ text,
19986
+ sessionId: SESSION,
19987
+ from: OWNER === "agent" && SESSION ? `agent ${SESSION.slice(0, 8)}` : OWNER
19988
+ })
19989
+ });
19990
+ if (!r.ok)
19991
+ return fail(r.error ?? "send failed");
19992
+ return ok(`sent #${r.message?.id}${r.message?.sessionId ? "" : " (queued until the target appears)"}`, r.message);
19964
19993
  });
19965
19994
  server.registerTool("swarm_dispatch", {
19966
19995
  title: "Dispatch tasks to spawned agents",
package/dist/swarm.js CHANGED
@@ -2,7 +2,15 @@
2
2
  // @bun
3
3
 
4
4
  // packages/cli/src/bin.ts
5
- import { resolve as resolve3 } from "path";
5
+ import {
6
+ copyFileSync,
7
+ existsSync as existsSync5,
8
+ mkdirSync as mkdirSync3,
9
+ readdirSync,
10
+ unlinkSync,
11
+ writeFileSync as writeFileSync3
12
+ } from "fs";
13
+ import { join as join5, resolve as resolve3 } from "path";
6
14
 
7
15
  // packages/client/src/daemon.ts
8
16
  import { existsSync as existsSync2, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -189,7 +197,9 @@ var AUDIT_TYPES = new Set([
189
197
  "permission.resolved",
190
198
  "incident.opened",
191
199
  "incident.acked",
192
- "run.result"
200
+ "run.result",
201
+ "workflow.started",
202
+ "workflow.finished"
193
203
  ]);
194
204
  var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
195
205
  var DEFAULT_PRIVACY = {
@@ -202,14 +212,65 @@ var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookE
202
212
  // packages/core/src/config.ts
203
213
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
204
214
  import { join as join2 } from "path";
215
+
216
+ // packages/core/src/workflows.ts
217
+ var NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
218
+ function isRecord(v) {
219
+ return typeof v === "object" && v !== null && !Array.isArray(v);
220
+ }
221
+ function parseWorkflows(raw) {
222
+ const out = {};
223
+ if (!Array.isArray(raw))
224
+ return out;
225
+ for (const w of raw) {
226
+ if (!isRecord(w) || typeof w.name !== "string" || !NAME_RE.test(w.name))
227
+ continue;
228
+ if (!Array.isArray(w.steps) || !w.steps.length)
229
+ continue;
230
+ const prompts = isRecord(w.prompts) ? w.prompts : {};
231
+ const steps = [];
232
+ for (const s of w.steps) {
233
+ if (typeof s !== "string" || !s.trim()) {
234
+ steps.length = 0;
235
+ break;
236
+ }
237
+ const t = s.trim();
238
+ if (t === "pr")
239
+ steps.push({ kind: "pr" });
240
+ else if (t.startsWith("gate:")) {
241
+ const gate = t.slice(5);
242
+ if (!NAME_RE.test(gate)) {
243
+ steps.length = 0;
244
+ break;
245
+ }
246
+ steps.push({ kind: "gate", gate });
247
+ } else if (NAME_RE.test(t)) {
248
+ const p = prompts[t];
249
+ steps.push({
250
+ kind: "run",
251
+ name: t,
252
+ prompt: typeof p === "string" && p.trim() ? p.trim() : null
253
+ });
254
+ } else {
255
+ steps.length = 0;
256
+ break;
257
+ }
258
+ }
259
+ if (steps.length)
260
+ out[w.name] = { name: w.name, steps };
261
+ }
262
+ return out;
263
+ }
264
+
265
+ // packages/core/src/config.ts
205
266
  var DEFAULT_GATE_TIMEOUT_S = 900;
206
267
  var AUTO_MODES = ["session-end", "stop", "off"];
207
268
  function parseGateDefs(gates) {
208
269
  const out = {};
209
- if (!isRecord(gates))
270
+ if (!isRecord2(gates))
210
271
  return out;
211
272
  for (const [name, v] of Object.entries(gates)) {
212
- if (!isRecord(v))
273
+ if (!isRecord2(v))
213
274
  continue;
214
275
  const builtin = v.builtin === "review" ? "review" : null;
215
276
  const cmd = typeof v.cmd === "string" ? v.cmd.trim() : "";
@@ -232,7 +293,11 @@ var DEFAULT_CONFIG = {
232
293
  daemon: { port: 7777, auth: "loopback-optional" },
233
294
  tasks: { source: null, labels: [], team: null },
234
295
  gates: { required: [], auto: "session-end", defs: {} },
296
+ workflows: {},
235
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 },
236
301
  events: { retain_days: 30 },
237
302
  audit: { retain_days: 0 },
238
303
  privacy: DEFAULT_PRIVACY,
@@ -256,11 +321,11 @@ var DEFAULT_CONFIG = {
256
321
  }
257
322
  };
258
323
  var MODES = ["ask", "deny", "off"];
259
- function isRecord(v) {
324
+ function isRecord2(v) {
260
325
  return typeof v === "object" && v !== null && !Array.isArray(v);
261
326
  }
262
327
  function merge(a, b) {
263
- if (!isRecord(a) || !isRecord(b))
328
+ if (!isRecord2(a) || !isRecord2(b))
264
329
  return b === undefined ? a : b;
265
330
  const out = { ...a };
266
331
  for (const [k, v] of Object.entries(b))
@@ -327,6 +392,27 @@ function validate(c) {
327
392
  warn_at: Number.isFinite(warnAt) && warnAt > 0 && warnAt < 1 ? warnAt : 0.8,
328
393
  on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
329
394
  },
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
+ })(),
330
416
  events: {
331
417
  retain_days: days(c.events?.retain_days, 30)
332
418
  },
@@ -366,7 +452,7 @@ function validate(c) {
366
452
  };
367
453
  }
368
454
  function leafPaths(v, prefix = "") {
369
- if (!isRecord(v))
455
+ if (!isRecord2(v))
370
456
  return prefix ? [prefix] : [];
371
457
  const keys = Object.keys(v);
372
458
  if (keys.length === 0)
@@ -376,7 +462,7 @@ function leafPaths(v, prefix = "") {
376
462
  function getPath(v, path) {
377
463
  let cur = v;
378
464
  for (const seg of path.split(".")) {
379
- if (!isRecord(cur))
465
+ if (!isRecord2(cur))
380
466
  return;
381
467
  cur = cur[seg];
382
468
  }
@@ -386,7 +472,7 @@ function setPath(obj, path, value) {
386
472
  const segs = path.split(".");
387
473
  let cur = obj;
388
474
  for (const seg of segs.slice(0, -1)) {
389
- if (!isRecord(cur[seg]))
475
+ if (!isRecord2(cur[seg]))
390
476
  cur[seg] = {};
391
477
  cur = cur[seg];
392
478
  }
@@ -446,6 +532,8 @@ var LIVE_WINDOW_MS = 10 * 60000;
446
532
  var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
447
533
  // packages/core/src/ledger.ts
448
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"]);
449
537
  // packages/core/src/policy.ts
450
538
  var HOOK_MARK = "swarm-hook";
451
539
  var hookIsOurs = (h) => typeof h.command === "string" && (h.command.includes(HOOK_MARK) || h.command.includes("/packages/hook/src/bin.ts"));
@@ -702,6 +790,26 @@ function status() {
702
790
  otherAgents
703
791
  };
704
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
+ }
705
813
 
706
814
  // packages/cli/src/procs.ts
707
815
  import { mkdirSync as mkdirSync2, openSync } from "fs";
@@ -818,7 +926,7 @@ var help = `swarm \u2014 control plane for AI-agent development
818
926
  setup start the daemon, install hooks, open the dashboard (do this first)
819
927
  start | stop | restart manage the background daemon
820
928
  status [-p] live sessions (whole machine, or one project)
821
- 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)
822
930
 
823
931
  add <path> [--name n] register (pin) a project
824
932
  ls list projects
@@ -851,9 +959,15 @@ var help = `swarm \u2014 control plane for AI-agent development
851
959
  stats [-p] [--json] all-time totals, streak, records (the dashboard's Stats view)
852
960
  search <query\u2026> [-p] [--kind handoff|incident|gate|session] [--json] memory over Swarm's own data (handoffs, incidents, gates, what sessions said)
853
961
  rules dryrun [--set rule=mode,\u2026] [--limit n] [--json] replay this repo's history under rule modes; shows what would fire + flaky signals
962
+ workflow <name> <task> | workflow ls | workflow stop <task> run a [[workflows]] sequence on a task (M7.8)
963
+ msg send <to> <text\u2026> [-p] message a session id, a task's holder, or "lead" (M7.6)
964
+ msg ls [-p] [--json] recent messages
965
+ demo open a seeded demo dashboard (own home + port; your real data is untouched)
854
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)
855
969
 
856
- 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)
857
971
 
858
972
  Env: SWARM_URL, SWARM_PORT (default 7777), SWARM_HOME (~/.swarm)`;
859
973
  async function api(path, init) {
@@ -915,6 +1029,15 @@ Open the dashboard: ${base}`);
915
1029
  case "install": {
916
1030
  const evs = install();
917
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
+ }
918
1041
  console.log("restart any running claude session for it to report in.");
919
1042
  break;
920
1043
  }
@@ -922,6 +1045,8 @@ Open the dashboard: ${base}`);
922
1045
  console.log(`removed ${uninstall()} hook entries`);
923
1046
  break;
924
1047
  case "doctor": {
1048
+ if (rest.includes("--migrate"))
1049
+ await ensureDaemon({ quiet: true });
925
1050
  const st = status();
926
1051
  const bun = Bun.which("bun");
927
1052
  const claude = Bun.which("claude");
@@ -961,6 +1086,13 @@ Open the dashboard: ${base}`);
961
1086
  };
962
1087
  forge2("gh", ["auth", "status", "--active", "-h", "github.com"]);
963
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
+ }
964
1096
  if (process.env.GITLAB_TOKEN)
965
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");
966
1098
  if (!running)
@@ -1660,6 +1792,191 @@ ${flag("--body") ?? d.body}`);
1660
1792
  session ${h.sessionId}` : ""}`);
1661
1793
  break;
1662
1794
  }
1795
+ case "workflow": {
1796
+ await ensureDaemon({ quiet: true });
1797
+ const proj = await api("/v1/projects", {
1798
+ method: "POST",
1799
+ headers: { "content-type": "application/json" },
1800
+ body: JSON.stringify({ path: resolve3(".") })
1801
+ });
1802
+ if (rest[0] === "ls" || !rest[0]) {
1803
+ const w = await api(`/v1/workflows?project=${proj.id}`);
1804
+ const names = Object.keys(w.defs);
1805
+ console.log(names.length ? `declared: ${names.join(", ")}` : "no [[workflows]] declared in .swarm.toml");
1806
+ for (const r2 of w.runs.slice(0, 20))
1807
+ console.log(`#${r2.id} ${r2.task} \xB7 ${r2.workflow} \xB7 ${r2.state === "running" ? `${r2.stepLabel} (${r2.step + 1}/${r2.steps.length})` : r2.state}${r2.detail ? ` \u2014 ${r2.detail.slice(0, 80)}` : ""}`);
1808
+ break;
1809
+ }
1810
+ if (rest[0] === "stop") {
1811
+ const task2 = rest[1];
1812
+ if (!task2)
1813
+ throw new Error("usage: swarm workflow stop <task>");
1814
+ const r2 = await api("/v1/workflows/stop", {
1815
+ method: "POST",
1816
+ headers: { "content-type": "application/json" },
1817
+ body: JSON.stringify({ projectId: proj.id, task: task2 })
1818
+ });
1819
+ if (!r2.ok)
1820
+ throw new Error(r2.error ?? "stop failed");
1821
+ console.log(`stopped the workflow on ${task2}`);
1822
+ break;
1823
+ }
1824
+ const [name, task] = rest;
1825
+ if (!name || !task)
1826
+ throw new Error("usage: swarm workflow <name> <task>");
1827
+ const r = await api("/v1/workflows", {
1828
+ method: "POST",
1829
+ headers: { "content-type": "application/json" },
1830
+ body: JSON.stringify({
1831
+ projectId: proj.id,
1832
+ task,
1833
+ workflow: name,
1834
+ owner: process.env.USER ?? "cli"
1835
+ })
1836
+ });
1837
+ if (!r.ok)
1838
+ throw new Error(r.error ?? "workflow failed to start");
1839
+ console.log(`workflow ${name} started on ${task} (#${r.id}) \u2014 watch the Board`);
1840
+ break;
1841
+ }
1842
+ case "msg": {
1843
+ await ensureDaemon({ quiet: true });
1844
+ const proj = await api("/v1/projects", {
1845
+ method: "POST",
1846
+ headers: { "content-type": "application/json" },
1847
+ body: JSON.stringify({ path: resolve3(".") })
1848
+ });
1849
+ if (rest[0] === "send") {
1850
+ const [to, ...words] = rest.slice(1).filter((a) => a !== "-p");
1851
+ if (!to || !words.length)
1852
+ throw new Error('usage: swarm msg send <session|task|"lead"> <text\u2026>');
1853
+ const r = await api("/v1/messages", {
1854
+ method: "POST",
1855
+ headers: { "content-type": "application/json" },
1856
+ body: JSON.stringify({
1857
+ projectId: proj.id,
1858
+ to,
1859
+ text: words.join(" "),
1860
+ from: process.env.USER ?? "me"
1861
+ })
1862
+ });
1863
+ if (!r.ok)
1864
+ throw new Error(r.error ?? "send failed");
1865
+ console.log(`sent #${r.message?.id}${r.message?.sessionId ? "" : " (queued until the target appears)"}`);
1866
+ break;
1867
+ }
1868
+ if (rest[0] === "ls" || !rest[0]) {
1869
+ const ms = await api(`/v1/messages?project=${proj.id}&limit=50`);
1870
+ if (rest.includes("--json")) {
1871
+ console.log(JSON.stringify(ms, null, 2));
1872
+ break;
1873
+ }
1874
+ if (!ms.length) {
1875
+ console.log("no messages");
1876
+ break;
1877
+ }
1878
+ for (const m of ms)
1879
+ console.log(`#${m.id} ${m.deliveredAt ? "\u2713" : "\xB7"} ${m.from ?? "?"} \u2192 ${m.task ?? m.toKind}: ${m.text.slice(0, 100)}`);
1880
+ break;
1881
+ }
1882
+ throw new Error("usage: swarm msg send <to> <text\u2026> | swarm msg ls");
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
+ }
1663
1980
  case "audit": {
1664
1981
  if (rest[0] !== "export")
1665
1982
  throw new Error("usage: swarm audit export [--since 30d] [-p] [--type t] [--format jsonl|csv|json] [--limit n]");
@@ -2004,6 +2321,33 @@ would have fired (newest last):`);
2004
2321
  }
2005
2322
  break;
2006
2323
  }
2324
+ case "demo": {
2325
+ const home = join5(swarmHome(), "demo");
2326
+ const port = "7799";
2327
+ const [cmd2, ...args] = daemonCommand();
2328
+ if (!cmd2)
2329
+ throw new Error("could not resolve the daemon command");
2330
+ const env = { ...process.env, SWARM_HOME: home, SWARM_PORT: port, SWARM_DEMO: "1" };
2331
+ const up = await authedFetch(`http://127.0.0.1:${port}/v1/health`).then((r) => r.ok).catch(() => false);
2332
+ if (!up) {
2333
+ Bun.spawn([cmd2, ...args], {
2334
+ stdin: "ignore",
2335
+ stdout: "ignore",
2336
+ stderr: "ignore",
2337
+ env
2338
+ }).unref();
2339
+ for (let i = 0;i < 40; i++) {
2340
+ await new Promise((r) => setTimeout(r, 250));
2341
+ if (await authedFetch(`http://127.0.0.1:${port}/v1/health`).then((r) => r.ok).catch(() => false))
2342
+ break;
2343
+ }
2344
+ }
2345
+ const tok = readToken(home);
2346
+ const url = `http://127.0.0.1:${port}/${tok ? `?token=${tok}` : ""}`;
2347
+ Bun.spawn(["open", url]).unref?.();
2348
+ console.log(`demo dashboard: http://127.0.0.1:${port} (home ${home} \u2014 delete it to reset)`);
2349
+ break;
2350
+ }
2007
2351
  case "ui": {
2008
2352
  const base = await ensureDaemon();
2009
2353
  const tok = readToken();