17 KiB
SwarmForge — Description, protocol, and migration requirements
Working document. Project source: https://github.com/unclebob/swarm-forge Goal: understand SwarmForge's architecture and evaluate adapting it to pi as the agent, on Linux, with DeepSeek / GLM / Qwen models.
1. Project description
SwarmForge is a tmux-based agent orchestration platform that turns a swarm of AI agents into a coordinated software engineering team. It was created by Robert C. Martin and applies his own engineering discipline (TDD, Gherkin/acceptance testing, mutation testing, CRAP/DRY analysis) to the problem of coordinating agents.
Core idea: each agent lives in its own git worktree and its own tmux session, and agents communicate via a file-based handoff protocol delivered by a daemon. There are no direct messages between agents and no direct access to the tmux socket by them.
Branch structure
| Branch | Description | Roles |
|---|---|---|
main |
Documentary: shared operational scripts + default constitution articles | — |
two-pack |
Fast backend workflow (TDD + hardening, no Gherkin) | coder → cleaner → coder |
four-pack |
Compact workflow with Gherkin specification | specifier → coder → refactorer → architect → specifier |
six-pack |
Full workflow with all quality gates separated | specifier → coder → cleaner → architect → hardender → QA → end |
Each executable branch contains the project config: swarmforge.conf (topology), roles/<role>.prompt (per-role prompts) and constitution.prompt + articles (shared rules). On startup, the ./swarm wrapper downloads the shared operational scripts from main (first time only) and launches the orchestrator.
How it works at a high level
- Declarative configuration:
swarmforge.confdefines the swarm window by window:window <role> <agent> <worktree> [task|batch] [extra-args...] - Launcher (
swarmforge.bb, Babashka): validates the config, initializes the git repo if needed, creates a worktree per role under.worktrees/, creates a tmux session per role on a project-owned socket and launches each agent with its initial prompt. - Agents: each runs as an interactive TUI in its tmux pane, inside its worktree, with the handoff scripts on its
PATH. - Daemon (
handoffd.bb): owner of the tmux socket. Watches agent outboxes, delivers handoffs to recipient inboxes and wakes agents with a message typed into their pane. - Handoff protocol: agents create validated drafts, receive them as tasks or batches (
task/batch), and report completion withdone_with_current.sh. - Optional viewer: terminal adapters (
terminal-adapters/*.sh) open one window per role for real-time observation, with a watchdog that reopens closed windows without losing agent state.
Key features
- Config-driven topology: swarm shape comes from
swarmforge.conf, not from code. - Per-project roles:
swarmforge/roles/<role>.promptper branch/backlog. - Layered constitution:
constitution.promptdirects agents to read articles underswarmforge/constitution/articles/(shared engineering, handoff and workflow rules + local per-branch rules). - Per-role backends: each role can use a different agent CLI (
claude,codex,copilot,grok). - Observable: one terminal window per role, or headless in tmux.
- Self-hosted and light: only needs tmux, git, zsh and Babashka; all state lives in
.swarmforge/inside the project. - Operational robustness: host sleep prevention (
caffeinate/systemd-inhibit), task resumption after restart, file-based audit (new→in_process→completed).
2. Handoff protocol (summary)
The protocol separates state (files on the filesystem, durable and auditable) from control (tmux, only for notification and liveness).
Messages
Only two types, both strictly validated:
type: git_handoff type: note
to: <role>[,<role>...] to: <role>[,<role>...]
priority: NN (00-99) priority: NN (00-99)
task: <stable-name> message: <1 line, max 80 chars>
commit: <10 hex>
git_handoff: the sender has committed work; the receiver doesmerge_and_process <role> <commit>.note: short message; only when the constitution or role explicitly authorizes it.
Flow
- The agent commits and writes a draft with headers only.
swarm_handoff.shis the validation gate: rejects reserved fields, unknown roles, invalid priorities, ambiguous commits (canonicalizes the hash withgit rev-parse --disambiguate) and bodies that are not generated.- The helper generates the payload (
id,from,role,task,created_at, body) and installs it atomically inoutbox/. - The daemon polls (1s), copies the handoff to each recipient's
inbox/new/(addingrecipientandenqueued_at) and wakes the receiver. - The receiver runs
ready_for_next.sh→ moves toinbox/in_process/(addsdequeued_at) and printsTASK:/BATCH:with the payload. - On completion,
done_with_current.shmoves toinbox/completed/(addscompleted_at) and picks up the next task if one exists. - The daemon moves the sender's original to
sent/orfailed/.
Wake-up (control plane)
The daemon "wakes" an agent by typing into its tmux pane:
tmux send-keys -t <session> -l "You have new handoff mail. If idle, run ready_for_next.sh."
tmux send-keys -t <session> C-m # Enter
tmux send-keys -t <session> C-j # robustness LF
The agent receives it as a user message. Protocol rules: if it is already working, ignore the wake-up; done_with_current.sh picks up the next task when finished. In practice, an agent with a message queue (like pi) enqueues the wake-up and delivers it when the turn ends.
Chain rules
- Intermediate roles always forward a
git_handoffto the next role in the chain, no matter what (even if the change is non-functional). - The final handoff of the chain (broadcast) is merge-only: recipients merge and do not forward.
- Task names (
task:) are preserved along the chain.
3. Migration requirements
3.1 Agent contract (necessary condition)
SwarmForge requires the agent to be a long-lived interactive process in a tmux pane that satisfies:
- Interactive CLI (TUI/REPL) that keeps running — wake-ups arrive as typed text + Enter; a one-shot CLI cannot receive work.
- Initial prompt via command line (or injectable via
tmux send-keysafter startup). - Work in the worktree directory (
cd <worktree> && <agent> ...). - Ability to run commands (the helpers
swarm_handoff.sh,ready_for_next.sh,done_with_current.share shell/bb onPATH— they are model-agnostic).
Everything else in the protocol (handoffs, worktrees, daemon, wake-ups, watchdog) does not know about the model: the only integration point is the launch arm in swarmforge.bb and the validated backend list in parse-config.
3.2 Validation: pi as agent
Fits out of the box. Verified in docs and installed binary:
pi "<prompt>"starts the TUI, sends the initial message and stays interactive (confirmed indist/modes/interactive/interactive-mode.js).- tmux officially supported (
docs/tmux.md). Recommendation: tmux ≥ 3.5 withextended-keys-format csi-ufor modified keys; the basic protocol (Enter) works with any version. - Compatible wake-up: in pi
Enter= send,Ctrl+J= new line (the daemon'sC-jis harmless). - Message queue: a message typed while pi is working is enqueued and delivered when the turn ends — ideal for the protocol's wake-up semantics.
- Sessions:
pi -c(continue),--session,--name "SwarmForge <Role>"(session name).
Operational requirements with pi:
| Requirement | Detail |
|---|---|
| Trust prompt | pi asks on startup in a new project and would block the agent. Use -a/--approve in the launch arm or pre-seed ~/.pi/agent/trust.json. |
| Fixed model | Use --model <provider>/<model> so each role does not start at the login selector. |
| Runtime | Node.js ≥ 22 (npm install) or standalone script; Linux supported natively. |
Proposed launch arm in swarmforge.bb:
"pi" (str "pi -a --name " (sq (str "SwarmForge " display))
" --model " (sq model) " "
(extra-args-prefix row)
"\"$(cat " (sq (str prompt-file)) ")\"")
3.3 Validation: opencode as agent
Fits with a mandatory adaptation. Verified on the real v1.18.15 binary and in source:
opencode(no args) starts the persistent interactive TUI.- ⚠️ The TUI does not accept an initial message via CLI:
--promptin TUI mode calls a Noderl.question(waits for stdin input); only--mini --promptsends it as a message, but withinteractive: false(runs and exits).opencode run "<msg>"is headless one-shot — not usable as a swarm agent. - Solution: launch the TUI (
opencode --auto) and inject the prompt withtmux send-keysafter startup — the same mechanism the daemon already uses to wake. About ~10 lines inlaunch-role!(launch → sleep →send-keys -l "$(cat prompt)"+ Enter).
opencode --auto -m <provider>/<model> # in the role's tmux session
# after ~2s:
tmux send-keys -t <target> -l "<initial prompt>" ; tmux send-keys -t <target> C-m
Operational requirements with opencode:
| Requirement | Detail |
|---|---|
| Permissions | --auto (auto-approve; also the hidden aliases --yolo / --dangerously-skip-permissions) — equivalent to the swarm's autonomous mode. |
| Sessions | -c/--continue, -s/--session for the restart flow ("on restart, run ready_for_next.sh"). |
| Runtime | Static binary (npm opencode-ai or GitHub release); Linux supported. |
| Future | opencode serve + attach/SDK/ACP would allow a native message queue without keyboard wake-ups (would require changing the architecture, not adapting it). |
3.4 Models: DeepSeek / GLM / Qwen
| Model | pi | opencode |
|---|---|---|
| DeepSeek | Native: DEEPSEEK_API_KEY, provider deepseek, --model deepseek/... |
Native in catalog (models.dev): deepseek-* |
| Qwen | Native: QWEN_TOKEN_PLAN_API_KEY, providers qwen-token-plan / -individual / -cn (China) |
Native: qwen3.x-*, alibaba-*/qwen* |
| GLM (Zhipu) | Not native: needs a custom provider extension (OpenAI-compatible, api: "openai-completions", thinkingFormat: "zai") or OpenAI-compatible proxy |
Native: glm-4.x/glm-5.x (opencode-go/glm-*, alibaba-*/glm-*) |
Note: pi already implements the thinking formats of all three families (thinkingFormat: "deepseek" | "zai" | "qwen" in docs/custom-provider.md), which simplifies GLM integration: you only need to register the endpoint and models with that extension.
3.5 Linux (runtime)
| Requirement | Status | Detail |
|---|---|---|
zsh |
Hard requirement | Scripts use #!/usr/bin/env zsh. Arch: pacman -S zsh. |
tmux |
Required | Recommended ≥ 3.5 (pi with extended keys). |
git |
Required | Worktrees and commit protocol. |
Babashka (bb) |
Required | Launcher and all helpers are Babashka (cross-platform). |
| Node.js ≥ 22 | pi only | npm install of pi (or standalone script). |
| Terminal | Headless works | By default on Linux (no osascript/wt.exe) the launcher falls back to none: attaches the current shell to the first role's session and the rest stay detached (tmux -S <socket> attach -t swarmforge-<role>). The swarm runs fully without windows. |
| Automatic windows (optional) | To build | Write a terminal-adapters/wezterm.sh (or kitty) for Linux: 5-function contract (~40 lines). WezTerm is the most scriptable (wezterm cli); Ghostty on Linux has no remote control. |
| Shutdown | Plan for | The close-swarm script lives on the main branch; executable branches do not carry it — copy it into the project or use shutdown by "closing the first window". |
| Sleep prevention | Works | systemd-inhibit on Linux (systemd running). Disable with SWARMFORGE_PREVENT_SLEEP=0. |
3.6 Necessary code changes (minimal)
In swarmforge/scripts/swarmforge.bb (the working branch, e.g. four-pack):
parse-config: add the backend to the validated list, e.g.#{"claude" "codex" "copilot" "grok" "pi"}.launch-command: add the new backend's arm (pi: section 3.2; opencode: section 3.3).check-backend-dependencies!: no changes — already checks that the binary exists onPATH.
In the project config:
swarmforge.conf:window coder pi master(oropencode), with[task|batch]and extra args per role.
Optional depending on goal:
- Shared constitution articles in
swarmforge/constitution/articles/of the branch (the wrapper only stages them inscripts/shared-articles/; confirm agents read what the branch needs). - Linux terminal adapter (section 3.5).
close-swarmin the project.
4. Architecture diagram
flowchart TB
subgraph Config["Configuration (per project/branch)"]
CONF["swarmforge.conf<br/>window role agent worktree [task|batch] [args]"]
ROLES["swarmforge/roles/<role>.prompt"]
CONST["swarmforge/constitution.prompt<br/>+ constitution/articles/"]
end
subgraph Launcher["Launcher — swarmforge.bb (Babashka)"]
PARSE["Validate config and prompts"]
WT["Git worktrees<br/>.worktrees/<role> (branch per role)"]
TMUX["tmux sessions<br/>swarmforge-<role> · project-owned socket"]
LAUNCH["send-keys: export SWARMFORGE_ROLE<br/>+ PATH helpers + cd worktree<br/>+ <agent> '$(cat prompt)'"]
end
subgraph Swarm["Swarm (1 agent per role)"]
A1["Agent TUI<br/>(tmux pane)"]
A2["Agent TUI<br/>(tmux pane)"]
A3["Agent TUI<br/>(tmux pane)"]
end
subgraph State["Durable state — filesystem (.swarmforge/handoffs)"]
OUT["outbox/ · sent/ · failed/"]
IN["inbox/ new · in_process · completed"]
end
subgraph Control["Control — daemon handoffd.bb"]
DAEMON["Poll outbox → deliver to inbox<br/>→ wake-up via tmux send-keys"]
end
subgraph Viewer["Viewer (optional)"]
ADAPT["terminal-adapters/*.sh"]
WATCH["swarm-window-watchdog"]
end
CONF --> PARSE
ROLES --> PARSE
CONST --> PARSE
PARSE --> WT --> LAUNCH
PARSE --> TMUX --> LAUNCH
LAUNCH --> A1 & A2 & A3
A1 & A2 & A3 -->|"helpers on PATH:<br/>swarm_handoff.sh"| OUT
OUT --> DAEMON
DAEMON -->|"deliver .handoff"| IN
DAEMON -->|"wake-up: text + Enter"| A1 & A2 & A3
A1 & A2 & A3 -->|"ready_for_next.sh<br/>done_with_current.sh"| IN
A1 & A2 & A3 -->|"work (git)"| WT
A1 & A2 & A3 -->|"observe: tmux attach"| TMUX
TMUX --> ADAPT --> WATCH
5. Protocol diagram (one handoff cycle)
sequenceDiagram
autonumber
participant S as Sender agent (e.g. coder)
participant V as swarm_handoff.sh (gate)
participant O as outbox/ (sender)
participant D as Daemon handoffd.bb
participant I as inbox/ (receiver)
participant R as Receiver agent (e.g. cleaner)
Note over S: git commit (message with role byline)
S->>S: Write draft (type/to/priority/task/commit)
S->>V: swarm_handoff.sh `<draft>`
V->>V: Validate: known roles, priority 00-99,<br/>canonical commit (10 hex, --disambiguate),<br/>reserved fields, body forbidden
V->>O: Install generated .handoff (id, from, role,<br/>task, created_at, merge_and_process payload)
V-->>S: HANDOFF QUEUED
O->>D: Poll (1 s)
D->>I: Copy to each recipient inbox/new/<br/>+ recipient, enqueued_at headers
D->>R: tmux send-keys -l 'You have new handoff mail...'<br/>+ C-m (Enter) + C-j (robustness)
Note over R: If busy → ignore (queue or next<br/>done_with_current will pick it up)
R->>I: ready_for_next.sh → move to in_process/<br/>+ dequeued_at header
I-->>R: TASK: `<path>` / BATCH: `<items>` + PAYLOAD
R->>R: merge_and_process `<sender>` `<commit>`<br/>+ process the task in its worktree
R->>I: done_with_current.sh → completed/<br/>+ completed_at header
I-->>R: Next task or NO_TASK
D->>O: Move original to sent/ (or failed/)
Inbox task lifecycle
stateDiagram-v2
[*] --> new: daemon delivers .handoff
new --> in_process: ready_for_next.sh (dequeued_at)
in_process --> completed: done_with_current.sh (completed_at)
in_process --> in_process: next queued task
new --> [*]: NO_TASK (empty queue)
failed --> [*]: delivery error (sender outbox)
6. Executive summary
- The architecture is model-agnostic: the only integration point for a new backend is the launch arm + the validated backend list in
swarmforge.bb; the handoff protocol, worktrees, daemon and wake-ups do not know about the agent. - pi fits directly: interactive initial message via CLI, tmux supported, message queue aligned with wake-up semantics, native DeepSeek/Qwen and GLM with a small extension.
- opencode fits with an adaptation: the TUI does not accept an initial prompt via CLI → inject via
tmux send-keysafter startup (mechanism already in the system). Native DeepSeek/GLM/Qwen. - Linux is a first-class citizen by design: the swarm lives in tmux, not in windows; headless runs fully. Automatic windows are only an optional terminal adapter.
- Minimum requirements: zsh + tmux (≥3.5 recommended) + git + Babashka + (Node.js for pi) + ~15 lines of changes in
swarmforge.bb+ provider config.