Skip to content

Instantly share code, notes, and snippets.

@nazt
Created April 30, 2026 08:17
Show Gist options
  • Select an option

  • Save nazt/dc7e20dee92e6c299db27e735d778d9c to your computer and use it in GitHub Desktop.

Select an option

Save nazt/dc7e20dee92e6c299db27e735d778d9c to your computer and use it in GitHub Desktop.
How to create a maw-js plugin — lessons from building maw swarm (spawn claude/codex/opencode side by side)

How to Create a maw-js Plugin — Lessons from Building maw swarm

Date: 2026-04-30 | Author: mawjs-oracle + Nat Context: Built maw swarm in ~30 min during a marathon session (23 PRs total)


What We Built

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)        │
└──────────────┴──────────────────────────────────┘

Step 1: Plugin Structure (3 files)

src/commands/plugins/swarm/
├── plugin.json    # Metadata — name, version, CLI command
└── index.ts       # Handler — the actual logic

plugin.json — declare the plugin

{
  "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.

index.ts — the handler

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" };
}

Step 2: The Errors We Hit (and How We Fixed Them)

Error 1: "unknown command: swarm"

What happened: Built the plugin, ran maw swarmunknown 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/swarm

Lesson: Source plugins need a symlink. Registry plugins get installed automatically via maw plugin install.


Error 2: Panes flash and disappear

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 alive

Error 3: Agents show "exited" in status despite being alive

What happened: maw team status showed all agents as · exited even though panes were running.

Root cause: Two config files exist:

  1. Vault manifest (ψ/memory/mailbox/teams/{name}/manifest.json) — stores members as string array ["agent-1", "agent-2"]
  2. Tool store config (~/.claude/teams/{name}/config.json) — stores full TeamMember objects with tmuxPaneId

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));

Error 4: Re-prep skips existing agents (stale pane IDs)

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);

Error 5: maw hey intercepted by federation

What happened: Tried to alias maw heymaw 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)

Error 6: Hook blocks git commits mentioning "tmux"

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 0

Step 3: What Worked Beautifully

The layout-manager.ts foundation

All 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 border
  • enableBorderStatus(window) → show titles at bottom
  • withPaneLock() → serialize concurrent pane creation

The swarm plugin is just ~130 LOC on top of this foundation.

The full command surface

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 everything

The 80/10 rule

Claude 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).


Architecture

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

How to Create Your Own Plugin

  1. Create src/commands/plugins/YOUR_PLUGIN/plugin.json + index.ts
  2. Export command object and default handler function
  3. Symlink to ~/.maw/plugins/YOUR_PLUGIN
  4. bun run build && maw YOUR_PLUGIN

For registry distribution:

  1. Add entry to registry.json in maw-plugin-registry
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment