@ra3orblade/swarm 0.7.0 → 0.8.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
@@ -32,7 +32,7 @@ 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, runtime resources, configurable rules, incidents and a cross-forge merge queue are built and dogfooded daily. Spawned agents and verification gates are on the [roadmap](ROADMAP.md).
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).
36
36
 
37
37
  ---
38
38
 
@@ -40,23 +40,31 @@ bunx @ra3orblade/swarm setup
40
40
 
41
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).
42
42
 
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.
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
 
45
45
  <p align="center"><img src="docs/art/screens/session.png" alt="Session view — live reasoning stream with a stats panel" width="100%"></p>
46
46
 
47
- **Board** — the coordination ledger for a project: task **claims** (each in an isolated git worktree), **worktrees** with branch, dirty/unpushed state and which session is inside, **runtime resources** (ports, dev servers, databases held as named singletons), and **incidents** — every command the rules asked about or denied, with the rule and the command.
47
+ **Board** — the coordination ledger for a project: **tasks** from your backlog (a markdown file, GitHub Issues or Linear) with a ✓ / ✗ per gate and **Run** / **Dispatch** actions, task **claims** (each in an isolated git worktree), **worktrees** with branch, drift, dirty/unpushed state and which session is inside — plus **Diff**, **PR** (push + open a pull/merge request prefilled from the task and handoff), **Open** and **Remove** per row — **runtime resources** (ports, dev servers, databases held as named singletons), **dispatch** status, recent **gates**, and **incidents** — every command the rules asked about or denied, with the rule and the command, and a **Codify** action that turns one into a `.swarm.toml` rule and a `CLAUDE.md` lesson.
48
48
 
49
49
  <p align="center"><img src="docs/art/screens/board.png" alt="Board view — worktrees and incidents" width="100%"></p>
50
50
 
51
51
  **Rules** — guardrails on the Bash commands a Claude Code session runs: `shared_tree`, `destructive_git`, `pattern_kill`, `protected_ports`, plus `no_foreign_worktree` and the opt-in `claim_required_to_write` on file writes; each `ask | deny | off` per repo in `.swarm.toml`. A `deny` is returned to Claude Code as a real permission denial. Ports held as resources are protected automatically. Guardrails against accidents, not a sandbox — see [what rules are and aren't](https://getswarm.vercel.app/docs/03-rules-and-config#what-rules-are--and-arent).
52
52
 
53
+ **Run & Dispatch** — start an agent from Swarm: `swarm run --task X` (or **Run** on a task row) claims the task and spawns `claude -p` in its worktree; steer it by stdin, stop it by pid. Its permission prompts go through the same rules as your interactive sessions — `deny` auto-denies, `ask` becomes an **Allow / Deny** card on the session, nothing else blocks. `swarm dispatch --ready` hands every claimable task to its own run, N at a time; when a run ends Swarm re-runs the gates and looks for the PR, and reports done / gates-failed / no-pr / crashed. Run profiles (`full | no-edits | read-only`) narrow what a spawned agent may do.
54
+
55
+ **Gates & handoffs** — `.swarm.toml` declares the gates every task must pass; a gate with a `cmd` is executed in the task's worktree (exit 0 passes, the output tail is the evidence) — on demand, from the agent, or automatically when a session in a held worktree ends. The latest run decides, failed runs are never deleted. Every session that pauses leaves an auto-handoff (files edited, last verification command, last request); the next session in that worktree gets it, the lease left, gate status and held resources injected at start.
56
+
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
+
53
59
  **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.
54
60
 
55
61
  **Timeline** — session lanes per project, coloured by agent, 3–72 h.
56
62
 
57
63
  <p align="center"><img src="docs/art/screens/timeline.png" alt="Timeline view — session lanes per project" width="100%"></p>
58
64
 
59
- **Spend & Stats** — cost by project, model and agent, today and all-time; plus the fun numbers: tokens, turns, streaks, activity calendar, words written, what it adds up to in novels and coffee.
65
+ **Spend & Stats** — cost by project, model, agent and **task**, today and all-time, with a context-budget table that ranks sessions by how much context they re-read; per-repo **budgets** (`daily` / `weekly` ceilings that warn, ask or stop); plus the fun numbers: tokens, turns, streaks, activity calendar, words written, what it adds up to in novels and coffee.
66
+
67
+ **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.
60
68
 
61
69
  <p align="center"><img src="docs/art/screens/stats.png" alt="Stats view" width="100%"></p>
62
70
 
@@ -93,9 +101,27 @@ claim_required_to_write = "off" # opt-in: writes to the shared checkout need a
93
101
 
94
102
  [rules.protected]
95
103
  ports = [5432]
104
+
105
+ [gates] # what every task must pass; a gate with a cmd is executed, not vouched for
106
+ required = ["tests", "review"]
107
+ [gates.tests]
108
+ cmd = "bun test"
109
+
110
+ [worktree] # start every new worktree warm
111
+ copy = [".env.local"]
112
+ setup = "bun install"
113
+
114
+ [tasks] # the backlog the Board, `swarm tasks` and dispatch read
115
+ source = "docs/plan.md" # or "github" / "linear"
116
+
117
+ [budget] # USD ceilings; on_exceed = "warn" | "ask" | "stop"
118
+ daily = 25
119
+
120
+ [dispatch]
121
+ max_parallel = 2
96
122
  ```
97
123
 
98
- Every `ask` and `deny` is recorded as an incident on the Board. Reference: [Rules and configuration](https://getswarm.vercel.app/docs/03-rules-and-config).
124
+ Every `ask` and `deny` is recorded as an incident on the Board. Reference: [Rules and configuration](https://getswarm.vercel.app/docs/03-rules-and-config) · full [`docs/13-config.md`](docs/13-config.md).
99
125
 
100
126
  ## CLI
101
127
 
@@ -107,22 +133,45 @@ swarm doctor # check setup, print the fix for each gap
107
133
  swarm add <path> · ls · ui # pin a project · list projects · open the dashboard
108
134
  swarm tail # follow the live event stream
109
135
 
136
+ swarm tasks [--ready] # the repo's task source; --ready = claimable now
110
137
  swarm claim <task> [--owner n] # claim a task in a fresh isolated worktree (fail-closed)
111
138
  swarm renew | release <task> # extend the lease · release + remove the worktree
112
139
  swarm claims · reap # list claims · release abandoned ones (keeps those holding work)
140
+ swarm handoff <task> --done "…" --remaining "…" # leave notes for the next holder · resume <task> reads them
141
+ swarm gate run <task> [gate…] # execute the repo's [gates.<name>] cmd gates in the worktree and record them
142
+ swarm gate record <task> <gate> pass|fail --rubric "…" # record a verification run by hand · gate ls
143
+ swarm wt [ls|create|open|diff|rm|gc] # first-class worktrees: task-less ones, drift, diff, collect stale
144
+ swarm pr open <task> # push the branch and open a PR/MR prefilled from task, handoff, gates, files
145
+
146
+ swarm run --task <id> --prompt "…" [--profile no-edits|read-only] # spawn claude -p in the task's worktree
147
+ swarm run ls | send <task> "…" | stop <task> # steer (stdin) or stop a spawned run — by pid, never pattern
148
+ swarm run resume <session-id> # pick up where a dead session stopped (its handoff + tail)
149
+ swarm dispatch --ready | <task…> · status · clear # claim + spawn a run per task, max_parallel at a time
150
+ swarm questions · answer <id> <text> # what agents are waiting on a human for
151
+
113
152
  swarm res ls | acquire <name> [--pid n] [--port n] | release <name> [--force]
153
+ swarm serve start [--name web] -- <cmd> # dev server: port allocated, PORT set, pid tracked, port protected
154
+ swarm proc start [--name n] -- <cmd> # same, for workers without a port · serve/proc ls | stop
114
155
  swarm stats [--json] # all-time totals, streak, records
156
+ swarm search <query> # memory over handoffs, incidents, gates, what sessions said
157
+ swarm rules dryrun [--set rule=mode,…] # replay history under rule modes; shows what would fire
115
158
 
116
- swarm install | uninstall # add/remove Swarm hooks in ~/.claude/settings.json
159
+ swarm install | uninstall # add/remove Swarm hooks in ~/.claude/settings.json + the MCP server
117
160
  ```
118
161
 
119
162
  ## For agents (MCP)
120
163
 
121
- `swarm setup` registers an MCP server, so an agent can coordinate itself instead of you running the CLI:
164
+ `swarm setup` registers an MCP server with Claude Code (and with Codex CLI and Gemini CLI when installed), so an agent can coordinate itself instead of you running the CLI:
122
165
 
123
- - `swarm_status` — what's claimed, who's live, what's held in this project
124
- - `swarm_claim` / `swarm_renew` / `swarm_release` / `swarm_reap` — task claims in isolated worktrees (fail-closed)
166
+ - `swarm_status` / `swarm_context` — what's claimed, who's live, what's held; what Swarm told this session at start, current as of now
167
+ - `swarm_next_task` / `swarm_claim` / `swarm_renew` / `swarm_release` / `swarm_reap` — the backlog and task claims in isolated worktrees (fail-closed)
168
+ - `swarm_handoff` / `swarm_resume` — leave notes for the next holder, read the last ones
169
+ - `swarm_gates` / `swarm_gate_run` / `swarm_gate_record` — see, execute or record verification gates
170
+ - `swarm_pr_open` — push the branch and open a PR/MR prefilled from the task and handoff
171
+ - `swarm_dispatch` — a lead agent hands ready tasks to autonomous runs in their own worktrees
172
+ - `swarm_ask` / `swarm_inbox` — ask the human a question (with options); the answer arrives on its own
125
173
  - `swarm_acquire_resource` / `swarm_release_resource` / `swarm_resources` — hold a port, dev server or database as a named singleton; held ports are protected from other agents automatically
174
+ - `swarm_search` — memory over handoffs, incidents, gates and what sessions said
126
175
 
127
176
  ## How it works
128
177
 
@@ -132,13 +181,17 @@ swarm install | uninstall # add/remove Swarm hooks in ~/.claude/settings.js
132
181
  transcripts (JSONL) ──────┼──▶ swarmd ──▶ SQLite (~/.swarm)
133
182
  Codex CLI ─── ~/.codex rollout logs ────────────┤ │
134
183
  Grok ─── ACP updates.jsonl ────────────────┘ └──▶ SSE ──▶ dashboard · CLI · MCP
135
-
184
+
136
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 ──┘
137
189
  ```
138
190
 
139
191
  - **Identity** is the git common dir, so every worktree of a repo maps to one project.
140
192
  - **State** lives in `~/.swarm/` — never in your repositories. Uninstalling leaves your repos untouched.
141
193
  - **Hooks** are installed once at the Claude Code user level, so every session on the machine reports in. The hook fails open: if the daemon is down, nothing blocks your work.
194
+ - **Spawned runs** are stopped by pid from the process registry, never by command pattern; a run that ends short of done keeps its claim and opens an incident rather than cleaning up behind your back.
142
195
 
143
196
  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).
144
197
 
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
+ // packages/hook/src/bin.ts
5
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
6
+ import { dirname, join as join2 } from "path";
7
+
4
8
  // packages/client/src/daemon.ts
5
9
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
6
10
  import { homedir } from "os";
@@ -20,16 +24,255 @@ function readDaemonInfo() {
20
24
  return null;
21
25
  }
22
26
  }
27
+ var tokenFile = (home = swarmHome()) => join(home, "token");
28
+ function readToken(home = swarmHome()) {
29
+ try {
30
+ const t = readFileSync(tokenFile(home), "utf8").trim();
31
+ return /^[a-f0-9]{64}$/.test(t) ? t : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ var authedFetch = (input, init) => {
37
+ const h = new Headers(init?.headers);
38
+ const t = readToken();
39
+ if (t && !h.has("authorization"))
40
+ h.set("authorization", `Bearer ${t}`);
41
+ return fetch(input, { ...init, headers: h });
42
+ };
23
43
  function resolveBaseUrl(explicit) {
24
44
  const raw = explicit ?? process.env.SWARM_URL ?? readDaemonInfo()?.url ?? `http://127.0.0.1:${DEFAULT_PORT}`;
25
45
  return raw.replace(/\/$/, "");
26
46
  }
47
+ // packages/core/src/actor.ts
48
+ var HUMAN_ALIASES = new Set(["cli", "dashboard", "me", "desktop", "human"]);
49
+ var DAEMON_ALIASES = new Set(["daemon", "system", "swarm"]);
50
+ // packages/core/src/audit.ts
51
+ var AUDIT_TYPES = new Set([
52
+ "session.started",
53
+ "session.ended",
54
+ "tool.denied",
55
+ "claim.acquired",
56
+ "claim.renewed",
57
+ "claim.released",
58
+ "claim.expired",
59
+ "claim.orphaned",
60
+ "worktree.created",
61
+ "worktree.removed",
62
+ "worktree.bootstrapped",
63
+ "pr.opened",
64
+ "question.asked",
65
+ "question.answered",
66
+ "dispatch.queued",
67
+ "dispatch.started",
68
+ "dispatch.finished",
69
+ "resource.acquired",
70
+ "resource.released",
71
+ "resource.reaped",
72
+ "process.started",
73
+ "process.exited",
74
+ "gate.recorded",
75
+ "handoff.recorded",
76
+ "permission.requested",
77
+ "permission.resolved",
78
+ "incident.opened",
79
+ "incident.acked",
80
+ "run.result"
81
+ ]);
82
+ var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
83
+ // packages/core/src/budget.ts
84
+ var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
85
+ // packages/core/src/rules.ts
86
+ var LIVE_WINDOW_MS = 10 * 60000;
87
+ function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
88
+ if (!current.toplevel)
89
+ return null;
90
+ for (const s of sessions) {
91
+ if (s.id === current.id)
92
+ continue;
93
+ if (s.state === "ended")
94
+ continue;
95
+ if (s.toplevel !== current.toplevel)
96
+ continue;
97
+ if (now - new Date(s.lastSeenAt).getTime() > withinMs)
98
+ continue;
99
+ return s;
100
+ }
101
+ return null;
102
+ }
103
+ function isBroadStage(cmd) {
104
+ const c = cmd.trim();
105
+ if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
106
+ return true;
107
+ if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
108
+ return true;
109
+ if (/\bgit\s+add\s*$/.test(c))
110
+ return true;
111
+ return false;
112
+ }
113
+ function isDestructiveGit(cmd) {
114
+ const c = cmd.trim();
115
+ return /\bgit\s+reset\s+[^|&;]*--hard\b/.test(c) || /\bgit\s+checkout\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+checkout\s+-f\b/.test(c) || /\bgit\s+restore\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+clean\s+[^|&;]*-[a-zA-Z]*f/.test(c) || /\bgit\s+stash\s+(drop|clear)\b/.test(c) || /\bgit\s+branch\s+[^|&;]*-[a-zA-Z]*D/.test(c);
116
+ }
117
+ function isPatternKill(cmd) {
118
+ return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
119
+ }
120
+ function killedPorts(cmd) {
121
+ const ports = new Set;
122
+ const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
123
+ if (!killy)
124
+ return [];
125
+ for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
126
+ ports.add(Number(m[1]));
127
+ for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
128
+ ports.add(Number(m[1]));
129
+ for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
130
+ ports.add(Number(m[1]));
131
+ return [...ports];
132
+ }
133
+ var DEFAULT_MODES = {
134
+ shared_tree: "ask",
135
+ destructive_git: "ask",
136
+ pattern_kill: "ask",
137
+ protected_ports: "ask",
138
+ no_foreign_worktree: "ask",
139
+ claim_required_to_write: "off",
140
+ protected: { ports: [] }
141
+ };
142
+ function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
143
+ const other = () => otherLiveInSameTree(current, sessions, now);
144
+ const hit = (rule, reason) => {
145
+ const mode = modes[rule];
146
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
147
+ };
148
+ if (modes.protected_ports !== "off" && modes.protected.ports.length) {
149
+ const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
150
+ if (target.length) {
151
+ const d = hit("protected_ports", `Port${target.length > 1 ? "s" : ""} ${target.join(", ")} ${target.length > 1 ? "are" : "is"} protected in the Swarm config \u2014 something the owner relies on is listening there. Don't kill it.`);
152
+ if (d.action !== "allow")
153
+ return d;
154
+ }
155
+ }
156
+ if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
157
+ const d = hit("pattern_kill", "This kills processes by command pattern \u2014 it will match every process on the machine that fits, including other agents' or the owner's. Kill by pid instead.");
158
+ if (d.action !== "allow")
159
+ return d;
160
+ }
161
+ if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
162
+ const o = other();
163
+ if (o) {
164
+ const d = hit("shared_tree", `Another session (${o.id.slice(0, 8)}) is active in this same checkout. \`git add -A\` / \`git commit -a\` will sweep its uncommitted changes into your commit. Stage explicit paths (\`git add <path>\`), or give each session its own git worktree.`);
165
+ if (d.action !== "allow")
166
+ return d;
167
+ }
168
+ }
169
+ if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
170
+ const o = other();
171
+ if (o) {
172
+ const d = hit("destructive_git", `Another session (${o.id.slice(0, 8)}) is active in this same checkout and may have uncommitted work. This command can discard it. Coordinate, or use a separate git worktree.`);
173
+ if (d.action !== "allow")
174
+ return d;
175
+ }
176
+ }
177
+ return { action: "allow" };
178
+ }
179
+ function norm(p) {
180
+ const parts = [];
181
+ for (const seg of p.split("/")) {
182
+ if (seg === "" || seg === ".")
183
+ continue;
184
+ if (seg === "..")
185
+ parts.pop();
186
+ else
187
+ parts.push(seg);
188
+ }
189
+ return `/${parts.join("/")}`;
190
+ }
191
+ function isInside(path, dir) {
192
+ if (!path || !dir)
193
+ return false;
194
+ const a = norm(path);
195
+ const d = norm(dir);
196
+ return a === d || a.startsWith(`${d}/`);
197
+ }
198
+ function absolutePath(path, cwd) {
199
+ if (path.startsWith("/"))
200
+ return path;
201
+ if (path.startsWith("~/"))
202
+ return path;
203
+ return `${cwd.replace(/\/+$/, "")}/${path}`;
204
+ }
205
+ var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
206
+ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
207
+ const hit = (rule, reason) => {
208
+ const mode = modes[rule];
209
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
210
+ };
211
+ const held = claims.filter((c) => c.worktree);
212
+ const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
213
+ if (modes.no_foreign_worktree !== "off") {
214
+ const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
215
+ if (foreign) {
216
+ const d = hit("no_foreign_worktree", kind === "bash" ? `This command runs inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 work in your own checkout, or claim the task.` : `${target} is inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 edit your own checkout, or claim the task.`);
217
+ if (d.action !== "allow")
218
+ return d;
219
+ }
220
+ }
221
+ if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
222
+ const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
223
+ if (inShared) {
224
+ const d = hit("claim_required_to_write", `This repo requires a claim before writing to its shared checkout. Run \`swarm claim <task>\` (or the swarm_claim MCP tool) and work in the worktree it creates.`);
225
+ if (d.action !== "allow")
226
+ return d;
227
+ }
228
+ }
229
+ return { action: "allow" };
230
+ }
231
+ // packages/core/src/ledger.ts
232
+ var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
233
+ // packages/core/src/policy.ts
234
+ import { createHash } from "crypto";
235
+ var POLICY_CACHE_VERSION = 1;
236
+ var POLICY_CACHE_FILE = "policy.cache.json";
237
+ var digest = (body) => createHash("sha256").update(JSON.stringify(body)).digest("hex");
238
+ function verifyPolicyCache(raw) {
239
+ if (!raw || typeof raw !== "object")
240
+ return null;
241
+ const c = raw;
242
+ if (c.version !== POLICY_CACHE_VERSION || typeof c.sha256 !== "string")
243
+ return null;
244
+ const { sha256, ...body } = c;
245
+ if (!body.modes || !Array.isArray(body.sessions) || !Array.isArray(body.worktrees))
246
+ return null;
247
+ return digest(body) === sha256 ? c : null;
248
+ }
249
+ function evaluateOffline(cache, raw, toplevel, now = Date.now()) {
250
+ const tool = typeof raw.tool_name === "string" ? raw.tool_name : "";
251
+ const input = raw.tool_input ?? {};
252
+ const id = typeof raw.session_id === "string" ? raw.session_id : "";
253
+ const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
254
+ const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
255
+ const cmd = tool === "Bash" && typeof input.command === "string" ? input.command : null;
256
+ if (!isWrite && !cmd)
257
+ return { action: "allow" };
258
+ const current = { id, cwd, toplevel: toplevel(cwd) };
259
+ const modes = cache.modes;
260
+ if (modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off") {
261
+ const target = isWrite ? absolutePath(input.file_path, cwd) : cwd;
262
+ const w = guardWrite(target, current, cache.worktrees, modes, isWrite ? "file" : "bash");
263
+ if (w.action !== "allow")
264
+ return w;
265
+ }
266
+ if (!cmd)
267
+ return { action: "allow" };
268
+ return guardBash(cmd, current, cache.sessions, now, modes);
269
+ }
27
270
  // packages/hook/src/bin.ts
28
271
  var event = process.argv[2] ?? "Unknown";
29
272
  var input = await Bun.stdin.text();
30
273
  var timeout = Number(process.env.SWARM_HOOK_TIMEOUT_MS ?? 400);
31
274
  var post = async (base) => {
32
- const r = await fetch(`${base}/v1/hook/${event}`, {
275
+ const r = await authedFetch(`${base}/v1/hook/${event}`, {
33
276
  method: "POST",
34
277
  headers: { "content-type": "application/json" },
35
278
  body: input || "{}",
@@ -48,5 +291,42 @@ if (out === null && base !== fallback) {
48
291
  out = await post(fallback);
49
292
  } catch {}
50
293
  }
294
+ if (out === null && event === "PreToolUse")
295
+ out = offline();
51
296
  process.stdout.write(`${out ?? "{}"}
52
297
  `);
298
+ function offline() {
299
+ try {
300
+ const file = join2(swarmHome(), POLICY_CACHE_FILE);
301
+ if (!existsSync2(file))
302
+ return null;
303
+ const cache = verifyPolicyCache(JSON.parse(readFileSync2(file, "utf8")));
304
+ if (!cache)
305
+ return null;
306
+ const raw = input ? JSON.parse(input) : {};
307
+ const d = evaluateOffline(cache, raw, gitToplevel);
308
+ if (d.action === "allow")
309
+ return null;
310
+ return JSON.stringify({
311
+ hookSpecificOutput: {
312
+ hookEventName: "PreToolUse",
313
+ permissionDecision: d.action,
314
+ permissionDecisionReason: `[swarm \xB7 daemon unreachable, policy-locked rule] ${d.reason}`
315
+ }
316
+ });
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+ function gitToplevel(cwd) {
322
+ let dir = cwd;
323
+ while (dir) {
324
+ if (existsSync2(join2(dir, ".git")))
325
+ return dir;
326
+ const up = dirname(dir);
327
+ if (up === dir)
328
+ return null;
329
+ dir = up;
330
+ }
331
+ return null;
332
+ }
package/dist/swarm-mcp.js CHANGED
@@ -19670,6 +19670,22 @@ function readDaemonInfo() {
19670
19670
  return null;
19671
19671
  }
19672
19672
  }
19673
+ var tokenFile = (home = swarmHome()) => join(home, "token");
19674
+ function readToken(home = swarmHome()) {
19675
+ try {
19676
+ const t = readFileSync(tokenFile(home), "utf8").trim();
19677
+ return /^[a-f0-9]{64}$/.test(t) ? t : null;
19678
+ } catch {
19679
+ return null;
19680
+ }
19681
+ }
19682
+ var authedFetch = (input, init) => {
19683
+ const h = new Headers(init?.headers);
19684
+ const t = readToken();
19685
+ if (t && !h.has("authorization"))
19686
+ h.set("authorization", `Bearer ${t}`);
19687
+ return fetch(input, { ...init, headers: h });
19688
+ };
19673
19689
  function alive(pid) {
19674
19690
  try {
19675
19691
  process.kill(pid, 0);
@@ -19726,7 +19742,7 @@ var OWNER = process.env.SWARM_OWNER ?? "agent";
19726
19742
  var SESSION = process.env.CLAUDE_SESSION_ID ?? null;
19727
19743
  async function api2(path, init) {
19728
19744
  const base = await ensureDaemon({ quiet: true }).catch(() => resolveBaseUrl());
19729
- const r = await fetch(`${base}${path}`, init);
19745
+ const r = await authedFetch(`${base}${path}`, init);
19730
19746
  return await r.json().catch(() => ({}));
19731
19747
  }
19732
19748
  async function projectId() {
@@ -20025,7 +20041,7 @@ ${body}`, g);
20025
20041
  const r = await api2("/v1/claims", {
20026
20042
  method: "POST",
20027
20043
  headers: { "content-type": "application/json" },
20028
- body: JSON.stringify({ projectId: pid, task, owner: owner ?? OWNER })
20044
+ body: JSON.stringify({ projectId: pid, task, owner: owner ?? OWNER, sessionId: SESSION })
20029
20045
  });
20030
20046
  if (!r.ok)
20031
20047
  return fail(`REFUSED: ${r.error}`);