@ra3orblade/swarm 0.7.0 → 0.9.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 +63 -10
- package/dist/swarm-hook.js +283 -1
- package/dist/swarm-mcp.js +53 -8
- package/dist/swarm.js +606 -49
- package/dist/swarmd.js +1899 -132
- package/package.json +1 -1
- package/web/app.js +533 -133
- package/web/index.html +106 -3
- package/web/menus.js +1 -1
- package/web/release-notes.js +1 -1
- package/web/viz.js +20 -3
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/swarm-hook.js
CHANGED
|
@@ -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,257 @@ 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
|
+
"workflow.started",
|
|
82
|
+
"workflow.finished"
|
|
83
|
+
]);
|
|
84
|
+
var AUDIT_TYPES_SQL = [...AUDIT_TYPES].map((t) => `'${t}'`).join(", ");
|
|
85
|
+
// packages/core/src/budget.ts
|
|
86
|
+
var BUDGET_ASK_TOOLS = new Set(["Bash", "Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
87
|
+
// packages/core/src/rules.ts
|
|
88
|
+
var LIVE_WINDOW_MS = 10 * 60000;
|
|
89
|
+
function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
|
|
90
|
+
if (!current.toplevel)
|
|
91
|
+
return null;
|
|
92
|
+
for (const s of sessions) {
|
|
93
|
+
if (s.id === current.id)
|
|
94
|
+
continue;
|
|
95
|
+
if (s.state === "ended")
|
|
96
|
+
continue;
|
|
97
|
+
if (s.toplevel !== current.toplevel)
|
|
98
|
+
continue;
|
|
99
|
+
if (now - new Date(s.lastSeenAt).getTime() > withinMs)
|
|
100
|
+
continue;
|
|
101
|
+
return s;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function isBroadStage(cmd) {
|
|
106
|
+
const c = cmd.trim();
|
|
107
|
+
if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
|
|
108
|
+
return true;
|
|
109
|
+
if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
|
|
110
|
+
return true;
|
|
111
|
+
if (/\bgit\s+add\s*$/.test(c))
|
|
112
|
+
return true;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
function isDestructiveGit(cmd) {
|
|
116
|
+
const c = cmd.trim();
|
|
117
|
+
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);
|
|
118
|
+
}
|
|
119
|
+
function isPatternKill(cmd) {
|
|
120
|
+
return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
|
|
121
|
+
}
|
|
122
|
+
function killedPorts(cmd) {
|
|
123
|
+
const ports = new Set;
|
|
124
|
+
const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
|
|
125
|
+
if (!killy)
|
|
126
|
+
return [];
|
|
127
|
+
for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
|
|
128
|
+
ports.add(Number(m[1]));
|
|
129
|
+
for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
|
|
130
|
+
ports.add(Number(m[1]));
|
|
131
|
+
for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
|
|
132
|
+
ports.add(Number(m[1]));
|
|
133
|
+
return [...ports];
|
|
134
|
+
}
|
|
135
|
+
var DEFAULT_MODES = {
|
|
136
|
+
shared_tree: "ask",
|
|
137
|
+
destructive_git: "ask",
|
|
138
|
+
pattern_kill: "ask",
|
|
139
|
+
protected_ports: "ask",
|
|
140
|
+
no_foreign_worktree: "ask",
|
|
141
|
+
claim_required_to_write: "off",
|
|
142
|
+
protected: { ports: [] }
|
|
143
|
+
};
|
|
144
|
+
function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
|
|
145
|
+
const other = () => otherLiveInSameTree(current, sessions, now);
|
|
146
|
+
const hit = (rule, reason) => {
|
|
147
|
+
const mode = modes[rule];
|
|
148
|
+
return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
|
|
149
|
+
};
|
|
150
|
+
if (modes.protected_ports !== "off" && modes.protected.ports.length) {
|
|
151
|
+
const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
|
|
152
|
+
if (target.length) {
|
|
153
|
+
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.`);
|
|
154
|
+
if (d.action !== "allow")
|
|
155
|
+
return d;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
|
|
159
|
+
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.");
|
|
160
|
+
if (d.action !== "allow")
|
|
161
|
+
return d;
|
|
162
|
+
}
|
|
163
|
+
if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
|
|
164
|
+
const o = other();
|
|
165
|
+
if (o) {
|
|
166
|
+
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.`);
|
|
167
|
+
if (d.action !== "allow")
|
|
168
|
+
return d;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
|
|
172
|
+
const o = other();
|
|
173
|
+
if (o) {
|
|
174
|
+
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.`);
|
|
175
|
+
if (d.action !== "allow")
|
|
176
|
+
return d;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { action: "allow" };
|
|
180
|
+
}
|
|
181
|
+
function norm(p) {
|
|
182
|
+
const parts = [];
|
|
183
|
+
for (const seg of p.split("/")) {
|
|
184
|
+
if (seg === "" || seg === ".")
|
|
185
|
+
continue;
|
|
186
|
+
if (seg === "..")
|
|
187
|
+
parts.pop();
|
|
188
|
+
else
|
|
189
|
+
parts.push(seg);
|
|
190
|
+
}
|
|
191
|
+
return `/${parts.join("/")}`;
|
|
192
|
+
}
|
|
193
|
+
function isInside(path, dir) {
|
|
194
|
+
if (!path || !dir)
|
|
195
|
+
return false;
|
|
196
|
+
const a = norm(path);
|
|
197
|
+
const d = norm(dir);
|
|
198
|
+
return a === d || a.startsWith(`${d}/`);
|
|
199
|
+
}
|
|
200
|
+
function absolutePath(path, cwd) {
|
|
201
|
+
if (path.startsWith("/"))
|
|
202
|
+
return path;
|
|
203
|
+
if (path.startsWith("~/"))
|
|
204
|
+
return path;
|
|
205
|
+
return `${cwd.replace(/\/+$/, "")}/${path}`;
|
|
206
|
+
}
|
|
207
|
+
var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
208
|
+
function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
|
|
209
|
+
const hit = (rule, reason) => {
|
|
210
|
+
const mode = modes[rule];
|
|
211
|
+
return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
|
|
212
|
+
};
|
|
213
|
+
const held = claims.filter((c) => c.worktree);
|
|
214
|
+
const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
|
|
215
|
+
if (modes.no_foreign_worktree !== "off") {
|
|
216
|
+
const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
|
|
217
|
+
if (foreign) {
|
|
218
|
+
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.`);
|
|
219
|
+
if (d.action !== "allow")
|
|
220
|
+
return d;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
|
|
224
|
+
const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
|
|
225
|
+
if (inShared) {
|
|
226
|
+
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.`);
|
|
227
|
+
if (d.action !== "allow")
|
|
228
|
+
return d;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { action: "allow" };
|
|
232
|
+
}
|
|
233
|
+
// packages/core/src/ledger.ts
|
|
234
|
+
var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
235
|
+
// packages/core/src/policy.ts
|
|
236
|
+
import { createHash } from "crypto";
|
|
237
|
+
var POLICY_CACHE_VERSION = 1;
|
|
238
|
+
var POLICY_CACHE_FILE = "policy.cache.json";
|
|
239
|
+
var digest = (body) => createHash("sha256").update(JSON.stringify(body)).digest("hex");
|
|
240
|
+
function verifyPolicyCache(raw) {
|
|
241
|
+
if (!raw || typeof raw !== "object")
|
|
242
|
+
return null;
|
|
243
|
+
const c = raw;
|
|
244
|
+
if (c.version !== POLICY_CACHE_VERSION || typeof c.sha256 !== "string")
|
|
245
|
+
return null;
|
|
246
|
+
const { sha256, ...body } = c;
|
|
247
|
+
if (!body.modes || !Array.isArray(body.sessions) || !Array.isArray(body.worktrees))
|
|
248
|
+
return null;
|
|
249
|
+
return digest(body) === sha256 ? c : null;
|
|
250
|
+
}
|
|
251
|
+
function evaluateOffline(cache, raw, toplevel, now = Date.now()) {
|
|
252
|
+
const tool = typeof raw.tool_name === "string" ? raw.tool_name : "";
|
|
253
|
+
const input = raw.tool_input ?? {};
|
|
254
|
+
const id = typeof raw.session_id === "string" ? raw.session_id : "";
|
|
255
|
+
const cwd = typeof raw.cwd === "string" ? raw.cwd : "";
|
|
256
|
+
const isWrite = WRITE_TOOLS.has(tool) && typeof input.file_path === "string";
|
|
257
|
+
const cmd = tool === "Bash" && typeof input.command === "string" ? input.command : null;
|
|
258
|
+
if (!isWrite && !cmd)
|
|
259
|
+
return { action: "allow" };
|
|
260
|
+
const current = { id, cwd, toplevel: toplevel(cwd) };
|
|
261
|
+
const modes = cache.modes;
|
|
262
|
+
if (modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off") {
|
|
263
|
+
const target = isWrite ? absolutePath(input.file_path, cwd) : cwd;
|
|
264
|
+
const w = guardWrite(target, current, cache.worktrees, modes, isWrite ? "file" : "bash");
|
|
265
|
+
if (w.action !== "allow")
|
|
266
|
+
return w;
|
|
267
|
+
}
|
|
268
|
+
if (!cmd)
|
|
269
|
+
return { action: "allow" };
|
|
270
|
+
return guardBash(cmd, current, cache.sessions, now, modes);
|
|
271
|
+
}
|
|
27
272
|
// packages/hook/src/bin.ts
|
|
28
273
|
var event = process.argv[2] ?? "Unknown";
|
|
29
274
|
var input = await Bun.stdin.text();
|
|
30
275
|
var timeout = Number(process.env.SWARM_HOOK_TIMEOUT_MS ?? 400);
|
|
31
276
|
var post = async (base) => {
|
|
32
|
-
const r = await
|
|
277
|
+
const r = await authedFetch(`${base}/v1/hook/${event}`, {
|
|
33
278
|
method: "POST",
|
|
34
279
|
headers: { "content-type": "application/json" },
|
|
35
280
|
body: input || "{}",
|
|
@@ -48,5 +293,42 @@ if (out === null && base !== fallback) {
|
|
|
48
293
|
out = await post(fallback);
|
|
49
294
|
} catch {}
|
|
50
295
|
}
|
|
296
|
+
if (out === null && event === "PreToolUse")
|
|
297
|
+
out = offline();
|
|
51
298
|
process.stdout.write(`${out ?? "{}"}
|
|
52
299
|
`);
|
|
300
|
+
function offline() {
|
|
301
|
+
try {
|
|
302
|
+
const file = join2(swarmHome(), POLICY_CACHE_FILE);
|
|
303
|
+
if (!existsSync2(file))
|
|
304
|
+
return null;
|
|
305
|
+
const cache = verifyPolicyCache(JSON.parse(readFileSync2(file, "utf8")));
|
|
306
|
+
if (!cache)
|
|
307
|
+
return null;
|
|
308
|
+
const raw = input ? JSON.parse(input) : {};
|
|
309
|
+
const d = evaluateOffline(cache, raw, gitToplevel);
|
|
310
|
+
if (d.action === "allow")
|
|
311
|
+
return null;
|
|
312
|
+
return JSON.stringify({
|
|
313
|
+
hookSpecificOutput: {
|
|
314
|
+
hookEventName: "PreToolUse",
|
|
315
|
+
permissionDecision: d.action,
|
|
316
|
+
permissionDecisionReason: `[swarm \xB7 daemon unreachable, policy-locked rule] ${d.reason}`
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function gitToplevel(cwd) {
|
|
324
|
+
let dir = cwd;
|
|
325
|
+
while (dir) {
|
|
326
|
+
if (existsSync2(join2(dir, ".git")))
|
|
327
|
+
return dir;
|
|
328
|
+
const up = dirname(dir);
|
|
329
|
+
if (up === dir)
|
|
330
|
+
return null;
|
|
331
|
+
dir = up;
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
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
|
|
19745
|
+
const r = await authedFetch(`${base}${path}`, init);
|
|
19730
19746
|
return await r.json().catch(() => ({}));
|
|
19731
19747
|
}
|
|
19732
19748
|
async function projectId() {
|
|
@@ -19936,15 +19952,44 @@ ${lines.join(`
|
|
|
19936
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);
|
|
19937
19953
|
});
|
|
19938
19954
|
server.registerTool("swarm_inbox", {
|
|
19939
|
-
title: "
|
|
19940
|
-
description: "Answers
|
|
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.",
|
|
19941
19957
|
inputSchema: {}
|
|
19942
19958
|
}, async () => {
|
|
19943
19959
|
const qs = await api2(`/v1/inbox?session=${encodeURIComponent(SESSION ?? "")}`);
|
|
19944
|
-
|
|
19945
|
-
|
|
19946
|
-
|
|
19947
|
-
|
|
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);
|
|
19948
19993
|
});
|
|
19949
19994
|
server.registerTool("swarm_dispatch", {
|
|
19950
19995
|
title: "Dispatch tasks to spawned agents",
|
|
@@ -20025,7 +20070,7 @@ ${body}`, g);
|
|
|
20025
20070
|
const r = await api2("/v1/claims", {
|
|
20026
20071
|
method: "POST",
|
|
20027
20072
|
headers: { "content-type": "application/json" },
|
|
20028
|
-
body: JSON.stringify({ projectId: pid, task, owner: owner ?? OWNER })
|
|
20073
|
+
body: JSON.stringify({ projectId: pid, task, owner: owner ?? OWNER, sessionId: SESSION })
|
|
20029
20074
|
});
|
|
20030
20075
|
if (!r.ok)
|
|
20031
20076
|
return fail(`REFUSED: ${r.error}`);
|