Date: 2026-04-30 | Author: mawjs-oracle + Nat
Context: Built maw swarm in ~30 min during a marathon session (23 PRs total)
maw swarm spawns multiple AI coding agents (Claude Code, Codex, OpenCode) in colored tmux panes with CC-style layout:
maw swarm claude codex opencode┌──────────────┬──────────────────────────────────┐
│ │ claude-1 (blue border) │
│ leader ├──────────────────────────────────┤
│ (30%) │ codex-2 (green border) │
│ ├──────────────────────────────────┤
│ │ opencode-3 (yellow border) │
└──────────────┴──────────────────────────────────┘
src/commands/plugins/swarm/
├── plugin.json # Metadata — name, version, CLI command
└── index.ts # Handler — the actual logic
{
"name": "swarm",
"version": "1.0.0",
"entry": "./index.ts",
"sdk": "^1.0.0",
"description": "Spawn multi-AI agent panes — claude, codex, opencode side by side.",
"cli": {
"command": "swarm",
"help": "maw swarm [agents...] [--tiled] [--count N]"
},
"weight": 5
}weight: 5 = core tier (shown first in maw --help). 10-49 = standard. 50+ = extra.
import type { InvokeContext, InvokeResult } from "../../../plugin/types";
export const command = {
name: "swarm",
description: "Spawn multi-AI agent panes.",
};
export default async function handler(ctx: InvokeContext): Promise<InvokeResult> {
const args = ctx.source === "cli" ? (ctx.args as string[]) : [];
// ... your logic here
return { ok: true, output: "done" };
}What happened: Built the plugin, ran maw swarm → unknown command: swarm.
Root cause: maw-js discovers plugins from ~/.maw/plugins/, not from src/commands/plugins/. The build bundles everything into dist/maw, but plugin discovery scans the user's plugin directory.
Fix: Symlink the plugin into the user plugin dir:
ln -sf /path/to/maw-js/src/commands/plugins/swarm ~/.maw/plugins/swarmLesson: Source plugins need a symlink. Registry plugins get installed automatically via maw plugin install.
What happened: Spawned claude/codex in panes → they ran, exited, pane closed instantly.
Root cause: tmux split-window 'claude' — when claude exits, the shell exits, tmux closes the pane.
Fix: Wrap the command so zsh takes over after the AI exits:
const shellCmd = `${agentCmd}; exec zsh`;
// Now: claude runs → exits → zsh takes over → pane stays aliveWhat happened: maw team status showed all agents as · exited even though panes were running.
Root cause: Two config files exist:
- Vault manifest (
ψ/memory/mailbox/teams/{name}/manifest.json) — stores members as string array["agent-1", "agent-2"] - Tool store config (
~/.claude/teams/{name}/config.json) — stores fullTeamMemberobjects withtmuxPaneId
Spawn was writing paneId to the vault manifest (wrong format), but status reads from tool store config.
Fix: Write to the correct config (tool store):
const toolConfigPath = join(TEAMS_DIR, teamName, "config.json");
const toolConfig = JSON.parse(readFileSync(toolConfigPath, "utf-8"));
const member = toolConfig.members.find(m => m.name === role);
member.tmuxPaneId = result.paneId;
member.color = result.color;
writeFileSync(toolConfigPath, JSON.stringify(toolConfig, null, 2));What happened: maw team prep 3 first time → works. Second time → agents registered but pane IDs are stale from first run.
Root cause: Insert-if-not-exists logic:
// BAD: skips if name exists, preserving stale paneId
if (!cfg.members.some(m => m.name === name)) {
cfg.members.push(entry);
}Fix: Upsert:
// GOOD: update existing, insert new
const existing = cfg.members.findIndex(m => m.name === name);
if (existing >= 0) cfg.members[existing] = entry;
else cfg.members.push(entry);What happened: Tried to alias maw hey → maw team hey for sending keystrokes to agent panes. Got: error: bare-name target removed — node prefix required.
Root cause: routeComm in cli.ts intercepts hey at line 57 BEFORE alias resolution at line 64. Federation "hey" requires local:agent or node:session:window format.
Dispatch order: routeComm → routeTools → aliases → matchCommand → plugin registry → prefix auto-resolve
Fix: Don't fight the dispatch order. Use maw t hey agent-1 hello (team alias) or maw team hey agent-1 hello instead. Two different "hey" systems:
maw hey= federation (HTTP, cross-node, consent gates)maw team hey= team (tmux send-keys, local panes)
What happened: Created a Claude Code hook to block raw tmux commands and suggest maw equivalents. Then it blocked git commit because the commit message contained the word "tmux send-keys".
Root cause: Hook checked $CMD for tmux patterns but didn't distinguish between actual tmux invocations vs strings inside other commands.
Fix: Skip if the command is git/echo/grep:
echo "$CMD" | grep -qE '(git commit|echo|cat|grep|printf)' && exit 0All the heavy lifting was already done in layout-manager.ts (shipped earlier):
applyTeamLayout(window, leaderPane)→select-layout main-vertical+resize-pane -x 30%stylePaneBorder(paneId, name, color)→ title + colored borderenableBorderStatus(window)→ show titles at bottomwithPaneLock()→ serialize concurrent pane creation
The swarm plugin is just ~130 LOC on top of this foundation.
After building swarm, everything "just works":
maw swarm claude codex opencode # spawn 3 AI agents
maw team status # ● claude-1 running, ● codex-2 running, ● opencode-3 running
maw t hey claude-1 "explain this" # send keystrokes to claude
maw team broadcast "git status" # send to all agents
maw zoom %310 # toggle zoom on codex
maw peek codex-2 # read codex pane
maw close # hide all (break-pane, alive)
maw open # bring back (join-pane)
maw team recover # restore layout after crash
maw layout tiled # switch to equal panes
maw kill %310 # kill one agent
maw team shutdown swarm --force # shutdown everythingClaude Code's swarm system: ~3000 LOC across 15+ files (PaneBackend interface, iTerm2/Windows backends, AsyncLocalStorage, React hooks, permission sync).
Our implementation: ~750 LOC total. Same user experience, 25% of the code. We skipped the abstraction layers we didn't need (single backend = tmux, process-based agents, no React).
maw swarm claude codex opencode
│
▼
swarm/index.ts
│ for each agent:
├── withPaneLock() ← serialize pane creation
│ └── tmux split-window ← spawn pane with AI command
├── stylePaneBorder() ← title + color
├── applyTeamLayout() ← main-vertical, leader 30%
├── enableBorderStatus() ← show titles at bottom
└── upsert to team config ← persist paneId + color
│
▼
saveLayoutSnapshot() ← for crash recovery
- Create
src/commands/plugins/YOUR_PLUGIN/plugin.json+index.ts - Export
commandobject anddefault handlerfunction - Symlink to
~/.maw/plugins/YOUR_PLUGIN bun run build && maw YOUR_PLUGIN
For registry distribution:
- Add entry to
registry.jsoninmaw-plugin-registry - Users install via
maw plugin install YOUR_PLUGIN
Session stats: 23 PRs, 8 alpha releases, 12 issues closed, ~750 LOC added, 3 hours. From CalVer ghost-date bug → full multi-AI swarm system in one sitting.