@ra3orblade/swarm 0.9.0 → 0.11.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
@@ -24,7 +24,7 @@ A local-first control plane for AI-agent development on any repository.</p>
24
24
 
25
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
- 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.
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), Gemini, Grok, Aider or opencode 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
 
29
29
  It runs entirely on your machine. No account, no telemetry, works offline. Nothing is added to your repositories.
30
30
 
@@ -32,13 +32,13 @@ It runs entirely on your machine. No account, no telemetry, works offline. Nothi
32
32
  bunx @ra3orblade/swarm setup
33
33
  ```
34
34
 
35
- > Status: early, but real — observability, task claims in isolated worktrees, runtime resources, configurable rules, incidents, a cross-forge merge queue, spawned agents (`swarm run` / `swarm dispatch`), executed verification gates, handoffs, session replay, search and spend budgets are built and dogfooded daily — Swarm dispatches its own tasks. Agent-to-agent messaging, declarative workflows and a built-in review gate are next on the [roadmap](ROADMAP.md).
35
+ > Status: early, but real, and shipping fast eleven releases so far. Observability across six agent CLIs, task claims in isolated worktrees, runtime resources, rules and incidents, a cross-forge merge queue, spawned agents (`swarm run` / `swarm dispatch`), declarative workflows, executed verification gates (including a built-in reviewer), handoffs, agent-to-agent messaging, session replay, outcome tracking, the collision graph, search, budgets, signed org policy and a self-hosted team daemon are all built and dogfooded daily — Swarm dispatches its own tasks. What's next is on the [roadmap](ROADMAP.md).
36
36
 
37
37
  ---
38
38
 
39
39
  ## What you get
40
40
 
41
- **Fleet** — every session across every project, live: agent, title, branch, what it's doing right now, model, a trend sparkline, output tokens, context size, cost, age. Filter by agent (Claude Code, Codex, Grok).
41
+ **Fleet** — every session across every project, live: agent, title, branch, what it's doing right now, model, a trend sparkline, output tokens, context size, cost, age. Filter by agent (all six). A session that starts repeating a failing command gets a **Stuck** badge and a desktop notification — a heuristic, and nothing is ever interrupted.
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. **Replay** steps through its tool calls one at a time with full input and output; an ended session gets **Resume where it died**, which spawns a run from what it left behind; a session inside a worktree shows its **Diff**.
44
44
 
@@ -56,6 +56,10 @@ bunx @ra3orblade/swarm setup
56
56
 
57
57
  **Ask the human** — an agent that hits a decision only you can make calls `swarm_ask`; the question shows on the session page with the options as buttons, Fleet shows an **Asking** badge, a desktop notification fires, and the answer reaches the agent on its own.
58
58
 
59
+ **Workflows** — `[[workflows]] name = "ship" steps = ["implement", "gate:tests", "gate:review", "pr"]` in `.swarm.toml`, and the daemon advances it: run steps spawn an agent in the task's worktree, gate steps must pass to continue, `pr` pushes the branch and opens the pull request. A failed step stops with an incident that says which one.
60
+
61
+ **Messaging** — `swarm_send(to, text)` reaches another session by id, whoever holds a task, or `"lead"` (your interactive session in the project). It arrives as injected context on the recipient's next tool call, over stdin to a spawned run, or via `swarm_inbox` — exactly once.
62
+
59
63
  **PRs** — one merge queue across GitHub and GitLab, read through your already-authenticated `gh` / `glab`. Merge from the dashboard when checks and review are clear. No tokens stored.
60
64
 
61
65
  **Timeline** — session lanes per project, coloured by agent, 3–72 h.
@@ -66,15 +70,21 @@ bunx @ra3orblade/swarm setup
66
70
 
67
71
  **Search** — full-text memory over everything Swarm remembers: handoffs, incidents, gate runs and what sessions said. **Dry-run rules** replays a project's history under rule modes you pick before switching anything on.
68
72
 
73
+ **Outcomes** — did the work survive? Sessions join to a branch, the branch to its PR, the PR to merged or reverted, scored per model and per agent: merge rate, median time from session start to merge, dollars per merge.
74
+
75
+ **Graphs** — a live bipartite graph of running sessions against the files they touch. A file two sessions hold with at least one writer turns red: a merge conflict you can still prevent.
76
+
77
+ <p align="center"><img src="docs/art/screens/outcomes.png" alt="Outcomes view — merge rate, median time to merge and dollars per merge, per model and per agent" width="100%"></p>
78
+
69
79
  <p align="center"><img src="docs/art/screens/stats.png" alt="Stats view" width="100%"></p>
70
80
 
71
- **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.
81
+ **Multi-agent** — six brands, one ledger: Claude Code via its hooks and transcripts (the full picture rules, MCP, spawned runs), and Codex CLI (`~/.codex`), Gemini CLI (`~/.gemini`), Grok (ACP `updates.jsonl`), Aider (`.aider.chat.history.md`) and opencode (its SQLite database, read-only) by reading the session logs they already write. Every session is tagged with its agent; Spend, Timeline and Outcomes break down per agent.
72
82
 
73
83
  **Zero instrumentation** — it reads the hooks and transcripts the agents already write. Every table is a real data grid: sort, resize, reorder, filter, persisted layouts. Light and dark themes.
74
84
 
75
85
  ## Install
76
86
 
77
- Requires [Bun](https://bun.sh) ≥ 1.3, git, and at least one agent: [Claude Code](https://claude.com/claude-code) (`claude` on your PATH — hooks, rules and MCP need it), [Codex CLI](https://github.com/openai/codex) and/or Grok (observed by tailing their logs; no hooks, so no rules). Optional: `gh` and/or `glab` (authenticated) for the PRs view.
87
+ Requires [Bun](https://bun.sh) ≥ 1.3, git, and at least one agent: [Claude Code](https://claude.com/claude-code) (`claude` on your PATH — hooks, rules and MCP need it), plus [Codex CLI](https://github.com/openai/codex), Gemini CLI, Grok, Aider and opencode (observed by reading their logs; no hooks, so no rules). Optional: `gh` and/or `glab` (authenticated) for the PRs view.
78
88
 
79
89
  ```sh
80
90
  bunx @ra3orblade/swarm setup # daemon + hooks + MCP, opens the dashboard
@@ -176,16 +186,15 @@ swarm install | uninstall # add/remove Swarm hooks in ~/.claude/settings.js
176
186
  ## How it works
177
187
 
178
188
  ```
179
- Claude Code sessions ──hooks──▶ swarm-hook ──┐
180
- (any folder, any repo) │
181
- transcripts (JSONL) ──────┼──▶ swarmd ──▶ SQLite (~/.swarm)
182
- Codex CLI ─── ~/.codex rollout logs ────────────┤ │
183
- Grok ─── ACP updates.jsonl ────────────────┘ └──▶ SSE ──▶ dashboard · CLI · MCP
184
- ▼ ▲
185
- rules engine (ask / deny incidents)
186
- ledger: claims · worktrees · gates · handoffs · resources · budgets
187
-
188
- swarm run / dispatch ──▶ claude -p in a claimed worktree ──┘
189
+ Claude Code · Codex · Gemini · Grok · Aider · opencode
190
+ │ hooks · transcripts · session logs
191
+
192
+ swarmd ──▶ SQLite (~/.swarm) the only place state lives
193
+
194
+ ├──▶ rules engine ──▶ ask · deny · incident
195
+ ├──▶ ledger ──▶ claims · worktrees · gates · handoffs · ports · budgets
196
+ ├──▶ SSE ──▶ dashboard · CLI · MCP · desktop app
197
+ └──▶ swarm run / dispatch ──▶ an agent in a claimed worktree
189
198
  ```
190
199
 
191
200
  - **Identity** is the git common dir, so every worktree of a repo maps to one project.
@@ -195,9 +204,15 @@ swarm install | uninstall # add/remove Swarm hooks in ~/.claude/settings.js
195
204
 
196
205
  Design docs (architecture, data model, protocol, interface, roadmap) are rendered at [getswarm.vercel.app/docs/design](https://getswarm.vercel.app/docs/design/) and live in [`docs/`](docs/00-index.md).
197
206
 
207
+ ## Teams
208
+
209
+ One machine is free and always will be. When it outgrows one laptop, `swarm-teamd` is a second, self-hosted service your machines *forward* to — audit events, spend rollups and claims, never transcript text unless a machine opts in, and always after your redaction rules. You get one view of the fleet (machines, cluster-wide claims, spend by person, project, machine and day), a claim taken on one laptop is refused on another with the holder's name, org `policy.toml` is ed25519-signed and verified against a key pinned at `swarm login`, team budgets enforce warn / ask / stop, and monthly chargeback exports come out by user, machine, model or **ticket id**. Every laptop stays local-first and keeps working offline. [Teams guide](https://getswarm.vercel.app/docs/11-teams).
210
+
211
+ *Licensing: `packages/team` is the single source-available package ([FSL-1.1-ALv2](packages/team/LICENSE.md), Apache-2.0 after two years). Everything else is and stays Apache-2.0.*
212
+
198
213
  ## Privacy
199
214
 
200
- Everything is local. Swarm reads the hook payloads and transcript files (Claude Code, Codex, Grok) that already exist on your disk, stores derived state in `~/.swarm/swarm.db`, and serves a dashboard on `127.0.0.1`. Optional outbound paths, all under your control: fetching model prices from the public LiteLLM list (`SWARM_OFFLINE=1` skips it); the PRs view shelling out to your already-authenticated `gh` / `glab` (Swarm stores no forge tokens); and the desktop app asking GitHub Releases for updates when you click *Check for Updates…*. No data about your sessions leaves your machine.
215
+ Everything is local. Swarm reads the hook payloads and session logs (all six agents) that already exist on your disk, stores derived state in `~/.swarm/swarm.db`, and serves a dashboard on `127.0.0.1`. Optional outbound paths, all under your control: fetching model prices from the public LiteLLM list (`SWARM_OFFLINE=1` skips it); the PRs view shelling out to your already-authenticated `gh` / `glab` (Swarm stores no forge tokens); and the desktop app asking GitHub Releases for updates when you click *Check for Updates…*. No data about your sessions leaves your machine.
201
216
 
202
217
  ## Configuration
203
218
 
@@ -221,4 +236,11 @@ bun run dev # daemon with hot reload
221
236
  bun run test # the suite CI runs
222
237
  ```
223
238
 
224
- Apache-2.0.
239
+ Apache-2.0 — everything that runs on one machine: daemon, dashboard, CLI, MCP server, hooks, all
240
+ agent adapters. No telemetry, no account, and that never changes.
241
+
242
+ The one exception is [`packages/team`](packages/team) — the self-hosted **team daemon** (multi-machine,
243
+ paid tier), which is source-available under
244
+ [FSL-1.1-ALv2](packages/team/LICENSE.md): free for internal use, education, research and
245
+ professional services, converting to Apache-2.0 two years after each release. The boundary is
246
+ simple ([OQ-15](docs/07-open-questions.md)): one machine free, a second person is the product.
@@ -47,6 +47,38 @@ function resolveBaseUrl(explicit) {
47
47
  // packages/core/src/actor.ts
48
48
  var HUMAN_ALIASES = new Set(["cli", "dashboard", "me", "desktop", "human"]);
49
49
  var DAEMON_ALIASES = new Set(["daemon", "system", "swarm"]);
50
+ // packages/core/src/art.ts
51
+ var ROBOT = [
52
+ " g g ",
53
+ " X X ",
54
+ " X X ",
55
+ " ddddddddddddd ",
56
+ " dXXXXXXXXXXXd ",
57
+ " dggXXXXXXXXXd ",
58
+ " dddXXddXXXddXXddd ",
59
+ " dddXXddXXXddXXddd ",
60
+ " dddXXXXXXXXXXXddd ",
61
+ " dddXXdddddddXXddd ",
62
+ " dXXXXXXXXXXXd ",
63
+ " dXXXXXXXXXXXd ",
64
+ " ddddddddddddd ",
65
+ " XXX ",
66
+ " ddddd ",
67
+ " ddddddddddd ",
68
+ " dXXdggXXXXXXXdXXd ",
69
+ " dXXdXggggXddddXXd ",
70
+ " dXXdXggggXXXXdXXd ",
71
+ " dXXdXXXXXXddddXXd ",
72
+ " dXXdXXXXXXXXXdXXd ",
73
+ " dXXdXdddXXXXXdXXd ",
74
+ " ddddXXXXXXXXXdddd ",
75
+ " ddddddddddd ",
76
+ " XX XX ",
77
+ " XX XX ",
78
+ " XXXX XXXX ",
79
+ " dddd dddd "
80
+ ];
81
+ var ROBOT_HEAD = ROBOT.slice(0, 13);
50
82
  // packages/core/src/audit.ts
51
83
  var AUDIT_TYPES = new Set([
52
84
  "session.started",
@@ -230,8 +262,17 @@ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file
230
262
  }
231
263
  return { action: "allow" };
232
264
  }
265
+ // packages/core/src/hygiene.ts
266
+ var HYGIENE_DEFAULTS = {
267
+ staleDays: 7,
268
+ abandonedDays: 30,
269
+ hungryRssKb: 1024 * 1024,
270
+ heavyKb: 2 * 1024 * 1024
271
+ };
233
272
  // packages/core/src/ledger.ts
234
273
  var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
274
+ // packages/core/src/outcomes.ts
275
+ var DEFAULT_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
235
276
  // packages/core/src/policy.ts
236
277
  import { createHash } from "crypto";
237
278
  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
@@ -142,7 +150,6 @@ class SwarmClient {
142
150
  throw new Error(`swarmd: ${r.status}`);
143
151
  }
144
152
  }
145
-
146
153
  // packages/core/src/actor.ts
147
154
  var HUMAN_ALIASES = new Set(["cli", "dashboard", "me", "desktop", "human"]);
148
155
  var DAEMON_ALIASES = new Set(["daemon", "system", "swarm"]);
@@ -159,6 +166,38 @@ var HOOK_EVENTS = [
159
166
  "Notification",
160
167
  "PreCompact"
161
168
  ];
169
+ // packages/core/src/art.ts
170
+ var ROBOT = [
171
+ " g g ",
172
+ " X X ",
173
+ " X X ",
174
+ " ddddddddddddd ",
175
+ " dXXXXXXXXXXXd ",
176
+ " dggXXXXXXXXXd ",
177
+ " dddXXddXXXddXXddd ",
178
+ " dddXXddXXXddXXddd ",
179
+ " dddXXXXXXXXXXXddd ",
180
+ " dddXXdddddddXXddd ",
181
+ " dXXXXXXXXXXXd ",
182
+ " dXXXXXXXXXXXd ",
183
+ " ddddddddddddd ",
184
+ " XXX ",
185
+ " ddddd ",
186
+ " ddddddddddd ",
187
+ " dXXdggXXXXXXXdXXd ",
188
+ " dXXdXggggXddddXXd ",
189
+ " dXXdXggggXXXXdXXd ",
190
+ " dXXdXXXXXXddddXXd ",
191
+ " dXXdXXXXXXXXXdXXd ",
192
+ " dXXdXdddXXXXXdXXd ",
193
+ " ddddXXXXXXXXXdddd ",
194
+ " ddddddddddd ",
195
+ " XX XX ",
196
+ " XX XX ",
197
+ " XXXX XXXX ",
198
+ " dddd dddd "
199
+ ];
200
+ var ROBOT_HEAD = ROBOT.slice(0, 13);
162
201
  // packages/core/src/audit.ts
163
202
  var AUDIT_TYPES = new Set([
164
203
  "session.started",
@@ -287,6 +326,9 @@ var DEFAULT_CONFIG = {
287
326
  gates: { required: [], auto: "session-end", defs: {} },
288
327
  workflows: {},
289
328
  budget: { daily: null, weekly: null, warn_at: 0.8, on_exceed: "warn" },
329
+ models: { allow: [] },
330
+ notify: { webhook: null },
331
+ team: { url: null, forward: ["ledger", "cost"], interval: 5 },
290
332
  events: { retain_days: 30 },
291
333
  audit: { retain_days: 0 },
292
334
  privacy: DEFAULT_PRIVACY,
@@ -382,6 +424,26 @@ function validate(c) {
382
424
  on_exceed: b.on_exceed === "ask" || b.on_exceed === "stop" ? b.on_exceed : "warn"
383
425
  },
384
426
  workflows: parseWorkflows(c.workflows),
427
+ notify: {
428
+ webhook: (() => {
429
+ const w = c.notify?.webhook;
430
+ return typeof w === "string" && /^https?:\/\//.test(w.trim()) ? w.trim() : null;
431
+ })()
432
+ },
433
+ models: {
434
+ allow: Array.isArray(c.models?.allow) ? c.models.allow.filter((m) => typeof m === "string" && m.trim() !== "") : []
435
+ },
436
+ team: (() => {
437
+ const t = c.team ?? {};
438
+ const url = typeof t.url === "string" && /^https?:\/\//.test(t.url.trim()) ? t.url.trim().replace(/\/+$/, "") : null;
439
+ const iv = Number(t.interval);
440
+ const KINDS = ["ledger", "cost", "transcripts"];
441
+ return {
442
+ url,
443
+ forward: Array.isArray(t.forward) ? t.forward.filter((k) => typeof k === "string" && KINDS.includes(k)) : ["ledger", "cost"],
444
+ interval: Number.isFinite(iv) && iv >= 1 && iv <= 300 ? Math.round(iv) : 5
445
+ };
446
+ })(),
385
447
  events: {
386
448
  retain_days: days(c.events?.retain_days, 30)
387
449
  },
@@ -499,8 +561,17 @@ function loadConfigDetailed(opts = {}) {
499
561
  // packages/core/src/rules.ts
500
562
  var LIVE_WINDOW_MS = 10 * 60000;
501
563
  var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
564
+ // packages/core/src/hygiene.ts
565
+ var HYGIENE_DEFAULTS = {
566
+ staleDays: 7,
567
+ abandonedDays: 30,
568
+ hungryRssKb: 1024 * 1024,
569
+ heavyKb: 2 * 1024 * 1024
570
+ };
502
571
  // packages/core/src/ledger.ts
503
572
  var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
573
+ // packages/core/src/outcomes.ts
574
+ var DEFAULT_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
504
575
  // packages/core/src/policy.ts
505
576
  var HOOK_MARK = "swarm-hook";
506
577
  var hookIsOurs = (h) => typeof h.command === "string" && (h.command.includes(HOOK_MARK) || h.command.includes("/packages/hook/src/bin.ts"));
@@ -757,6 +828,26 @@ function status() {
757
828
  otherAgents
758
829
  };
759
830
  }
831
+ function setTeamUrl(home, url) {
832
+ const path = join3(home, "config.toml");
833
+ const text = existsSync4(path) ? readFileSync3(path, "utf8") : "";
834
+ let out;
835
+ const section = text.match(/(^|\n)\[team\]([\s\S]*?)(?=\n\[|$)/);
836
+ if (section) {
837
+ const body = section[2] ?? "";
838
+ const newBody = /^\s*url\s*=/m.test(body) ? body.replace(/^\s*url\s*=.*$/m, `url = "${url}"`) : `
839
+ url = "${url}"${body}`;
840
+ out = text.replace(section[0], `${section[1]}[team]${newBody}`);
841
+ } else {
842
+ out = `${text}${text && !text.endsWith(`
843
+ `) ? `
844
+ ` : ""}
845
+ [team]
846
+ url = "${url}"
847
+ `;
848
+ }
849
+ writeFileSync2(path, out);
850
+ }
760
851
 
761
852
  // packages/cli/src/procs.ts
762
853
  import { mkdirSync as mkdirSync2, openSync } from "fs";
@@ -873,7 +964,7 @@ var help = `swarm \u2014 control plane for AI-agent development
873
964
  setup start the daemon, install hooks, open the dashboard (do this first)
874
965
  start | stop | restart manage the background daemon
875
966
  status [-p] live sessions (whole machine, or one project)
876
- doctor check everything and print the fix for each gap
967
+ doctor [--migrate] check everything and print the fix for each gap (--migrate: apply pending db migrations)
877
968
 
878
969
  add <path> [--name n] register (pin) a project
879
970
  ls list projects
@@ -911,8 +1002,10 @@ var help = `swarm \u2014 control plane for AI-agent development
911
1002
  msg ls [-p] [--json] recent messages
912
1003
  demo open a seeded demo dashboard (own home + port; your real data is untouched)
913
1004
  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
1005
+ login [url] [--token t] log in to the team daemon ([team].url) and register this machine (M8.3c)
1006
+ backup [dest] | restore <src> snapshot ~/.swarm (VACUUM INTO, zero downtime) / restore it (daemon stopped)
914
1007
 
915
- install | uninstall add/remove Swarm hooks in ~/.claude/settings.json
1008
+ 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
1009
 
917
1010
  Env: SWARM_URL, SWARM_PORT (default 7777), SWARM_HOME (~/.swarm)`;
918
1011
  async function api(path, init) {
@@ -974,6 +1067,15 @@ Open the dashboard: ${base}`);
974
1067
  case "install": {
975
1068
  const evs = install();
976
1069
  console.log(`installed hooks for ${evs.length} events in ${status().path}`);
1070
+ const cuIdx = rest.indexOf("--config-url");
1071
+ const configUrl = cuIdx >= 0 ? rest[cuIdx + 1] : null;
1072
+ if (configUrl) {
1073
+ if (!/^https?:\/\//.test(configUrl))
1074
+ throw new Error("--config-url must be an http(s) URL to the team daemon");
1075
+ setTeamUrl(swarmHome(), configUrl.replace(/\/+$/, ""));
1076
+ console.log(`[team] url = "${configUrl}" written to ${join5(swarmHome(), "config.toml")}`);
1077
+ console.log("next: swarm login \u2014 to register this machine and pin the org policy key");
1078
+ }
977
1079
  console.log("restart any running claude session for it to report in.");
978
1080
  break;
979
1081
  }
@@ -981,6 +1083,8 @@ Open the dashboard: ${base}`);
981
1083
  console.log(`removed ${uninstall()} hook entries`);
982
1084
  break;
983
1085
  case "doctor": {
1086
+ if (rest.includes("--migrate"))
1087
+ await ensureDaemon({ quiet: true });
984
1088
  const st = status();
985
1089
  const bun = Bun.which("bun");
986
1090
  const claude = Bun.which("claude");
@@ -1020,6 +1124,13 @@ Open the dashboard: ${base}`);
1020
1124
  };
1021
1125
  forge2("gh", ["auth", "status", "--active", "-h", "github.com"]);
1022
1126
  forge2("glab", ["auth", "status"]);
1127
+ if (running) {
1128
+ const t = await api("/v1/team").catch(() => null);
1129
+ if (t?.configured) {
1130
+ const lag = t.pending ? `${t.pending} pending${t.oldest ? ` since ${t.oldest}` : ""}` : "in sync";
1131
+ line(!t.lastError, `team forwarding \u2192 ${t.url} (${lag})`, `last error: ${t.lastError} \u2014 check the team daemon and [team].url`);
1132
+ }
1133
+ }
1023
1134
  if (process.env.GITLAB_TOKEN)
1024
1135
  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
1136
  if (!running)
@@ -1808,6 +1919,102 @@ ${flag("--body") ?? d.body}`);
1808
1919
  }
1809
1920
  throw new Error("usage: swarm msg send <to> <text\u2026> | swarm msg ls");
1810
1921
  }
1922
+ case "backup": {
1923
+ await ensureDaemon({ quiet: true });
1924
+ const dest = resolve3(arg() ?? join5(swarmHome(), "backups", new Date().toISOString().slice(0, 10)));
1925
+ const r = await api("/v1/backup", {
1926
+ method: "POST",
1927
+ headers: { "content-type": "application/json" },
1928
+ body: JSON.stringify({ dest })
1929
+ });
1930
+ console.log(`backed up ${r.files.join(", ")} \u2192 ${r.dest}`);
1931
+ console.log(`restore on any machine with: swarm restore ${r.dest} (daemon stopped)`);
1932
+ break;
1933
+ }
1934
+ case "restore": {
1935
+ const src = arg();
1936
+ if (!src)
1937
+ throw new Error("usage: swarm restore <backup dir> (with the daemon stopped)");
1938
+ if (await daemonRunning())
1939
+ throw new Error("the daemon is running \u2014 stop it first: swarm stop");
1940
+ const home = swarmHome();
1941
+ mkdirSync3(home, { recursive: true });
1942
+ let n = 0;
1943
+ for (const f of readdirSync(resolve3(src))) {
1944
+ if (f === "daemon.json")
1945
+ continue;
1946
+ copyFileSync(join5(resolve3(src), f), join5(home, f));
1947
+ n++;
1948
+ }
1949
+ for (const suffix of ["-wal", "-shm"]) {
1950
+ const p = join5(home, `swarm.db${suffix}`);
1951
+ if (existsSync5(p))
1952
+ unlinkSync(p);
1953
+ }
1954
+ console.log(`restored ${n} files into ${home} \u2014 run: swarm start`);
1955
+ break;
1956
+ }
1957
+ case "login": {
1958
+ await ensureDaemon({ quiet: true });
1959
+ const t = await api("/v1/team");
1960
+ const teamUrl = (arg() ?? t.url)?.replace(/\/+$/, "");
1961
+ if (!teamUrl)
1962
+ throw new Error("no team daemon configured \u2014 set [team] url or run: swarm login <url>");
1963
+ const cfg = await (await authedFetch(`${teamUrl}/t1/auth/config`)).json();
1964
+ const tokIdx = rest.indexOf("--token");
1965
+ let human = null;
1966
+ if (cfg.mode === "oidc") {
1967
+ const flow = await (await authedFetch(`${teamUrl}/t1/auth/device`, { method: "POST" })).json();
1968
+ if (!flow.handle)
1969
+ throw new Error(`login failed: ${flow.error ?? "no device flow"}`);
1970
+ console.log(`visit ${flow.verificationUriComplete ?? flow.verificationUri} and enter code: ${flow.userCode}`);
1971
+ for (;; ) {
1972
+ await new Promise((r) => setTimeout(r, (flow.interval ?? 5) * 1000));
1973
+ const poll = await (await authedFetch(`${teamUrl}/t1/auth/token`, {
1974
+ method: "POST",
1975
+ headers: { "content-type": "application/json" },
1976
+ body: JSON.stringify({ handle: flow.handle })
1977
+ })).json();
1978
+ if (poll.status === "pending")
1979
+ continue;
1980
+ if (poll.status !== "ok" || !poll.token)
1981
+ throw new Error(`login failed: ${poll.error ?? "denied"}`);
1982
+ human = poll.token;
1983
+ writeFileSync3(join5(swarmHome(), "team-token"), JSON.stringify({
1984
+ url: teamUrl,
1985
+ token: poll.token,
1986
+ subject: poll.subject,
1987
+ role: poll.role
1988
+ }), { mode: 384 });
1989
+ console.log(`logged in as ${poll.subject} (${poll.role})`);
1990
+ break;
1991
+ }
1992
+ } else if (cfg.mode === "token") {
1993
+ human = tokIdx >= 0 ? rest[tokIdx + 1] ?? null : null;
1994
+ if (!human)
1995
+ throw new Error("this team daemon uses a shared token \u2014 run: swarm login --token <secret>");
1996
+ } else {
1997
+ console.log("team daemon runs open (no auth configured) \u2014 registering machine directly");
1998
+ }
1999
+ const reg = await (await authedFetch(`${teamUrl}/t1/machines/register`, {
2000
+ method: "POST",
2001
+ headers: {
2002
+ "content-type": "application/json",
2003
+ ...human ? { authorization: `Bearer ${human}` } : {}
2004
+ },
2005
+ body: JSON.stringify({ id: t.machine.id, name: t.machine.name })
2006
+ })).json();
2007
+ const machineToken = reg.token ?? (cfg.mode === "token" ? human : null);
2008
+ if (!machineToken)
2009
+ throw new Error(`machine registration failed: ${reg.error ?? "unknown"}`);
2010
+ await api("/v1/team/credentials", {
2011
+ method: "POST",
2012
+ headers: { "content-type": "application/json" },
2013
+ body: JSON.stringify({ token: machineToken, policyPublicKey: cfg.policyPublicKey })
2014
+ });
2015
+ console.log(`machine ${t.machine.name} (${t.machine.id.slice(0, 8)}) registered \u2014 forwarding is authed`);
2016
+ break;
2017
+ }
1811
2018
  case "audit": {
1812
2019
  if (rest[0] !== "export")
1813
2020
  throw new Error("usage: swarm audit export [--since 30d] [-p] [--type t] [--format jsonl|csv|json] [--limit n]");