docs(plan): relay server implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
@@ -0,0 +1,956 @@
|
||||
# Relay Server Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a local MCP SSE server that gives PM, Dev-A, and Dev-B Claude Code sessions native `post_message`/`read_messages`/`list_pending` tools, eliminating manual copy-paste during multi-agent development lifts.
|
||||
|
||||
**Architecture:** A single Node.js process hosts an HTTP server with SSE transport for the MCP protocol. Three named in-memory FIFO queues (one per role) hold consume-once messages. A `start.sh` launcher prints copy-paste instructions (default) or spawns a tmux/kitty layout (flags). The multi-agent-kickoff skill templates get a `<<RELAY_PARAGRAPH>>` placeholder injected so every future lift prompt auto-includes relay instructions.
|
||||
|
||||
**Tech Stack:** Node.js v25, `@modelcontextprotocol/sdk` (MCP + SSE transport), `tsx` (dev dep, runs TypeScript directly), Node built-in `node:test` runner. No Express, no Hono, no Zod as a direct dep.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| Action | Path | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `tools/relay/package.json` | npm metadata, scripts, single runtime dep + tsx devDep |
|
||||
| Create | `tools/relay/tsconfig.json` | TypeScript config for ESM Node target |
|
||||
| Create | `tools/relay/queue.ts` | `RelayQueue` class — in-memory FIFO, `post`/`read`/`pending`, `isRole` guard |
|
||||
| Create | `tools/relay/queue.test.ts` | Node `node:test` unit tests for queue (5 cases) |
|
||||
| Create | `tools/relay/server.ts` | MCP `Server` + `SSEServerTransport` HTTP server on port 7331 |
|
||||
| Create | `tools/relay/start.sh` | Launcher: `--manual` (default), `--tmux`, `--kitty` |
|
||||
| Modify | `.gitignore` | Add `tools/relay/node_modules/` |
|
||||
| Modify | `.claude/settings.json` | Add `mcpServers.relay` SSE entry |
|
||||
| Create | `docs/superpowers/MULTI-AGENT.md` | Paradigm reference README |
|
||||
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||
| Modify | `~/.claude/skills/multi-agent-kickoff/SKILL.md` | Placeholder ref + step 8 update + `<<DEV_ROLE>>` placeholder |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold `tools/relay/`
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/package.json`
|
||||
- Create: `tools/relay/tsconfig.json`
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Create `tools/relay/package.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@relicario/relay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npx tsx server.ts",
|
||||
"test": "node --import=tsx/esm --test queue.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `tools/relay/tsconfig.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add to root `.gitignore`**
|
||||
|
||||
Open `/home/alee/Sources/relicario/.gitignore` and append:
|
||||
|
||||
```
|
||||
tools/relay/node_modules/
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Install dependencies and verify**
|
||||
|
||||
```bash
|
||||
cd tools/relay && npm install
|
||||
```
|
||||
|
||||
Expected: `node_modules/` created, no errors. Verify with:
|
||||
|
||||
```bash
|
||||
ls node_modules/@modelcontextprotocol/sdk && ls node_modules/tsx
|
||||
```
|
||||
|
||||
Expected: both directories exist.
|
||||
|
||||
- [ ] **Step 5: Commit scaffold**
|
||||
|
||||
```bash
|
||||
git add tools/relay/package.json tools/relay/tsconfig.json tools/relay/package-lock.json .gitignore
|
||||
git commit -m "chore(relay): scaffold tools/relay with MCP SDK dep"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `queue.ts` — TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/queue.ts`
|
||||
- Create: `tools/relay/queue.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tools/relay/queue.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { RelayQueue, isRole } from "./queue.ts";
|
||||
|
||||
describe("RelayQueue", () => {
|
||||
let q: RelayQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
q = new RelayQueue();
|
||||
});
|
||||
|
||||
it("post + read roundtrip returns the message with correct fields", () => {
|
||||
q.post("dev-b", "pm", "status", "Task P4 DONE");
|
||||
const msgs = q.read("pm");
|
||||
assert.equal(msgs.length, 1);
|
||||
assert.equal(msgs[0].from, "dev-b");
|
||||
assert.equal(msgs[0].to, "pm");
|
||||
assert.equal(msgs[0].kind, "status");
|
||||
assert.equal(msgs[0].body, "Task P4 DONE");
|
||||
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
|
||||
assert.ok(typeof msgs[0].ts === "string");
|
||||
});
|
||||
|
||||
it("consume-once: second read returns empty", () => {
|
||||
q.post("dev-a", "pm", "question", "Should I use approach A?");
|
||||
q.read("pm");
|
||||
const second = q.read("pm");
|
||||
assert.deepEqual(second, []);
|
||||
});
|
||||
|
||||
it("list_pending does not drain inbox", () => {
|
||||
q.post("dev-b", "pm", "directive", "PROCEED");
|
||||
const before = q.pending("pm");
|
||||
assert.equal(before.count, 1);
|
||||
const after = q.read("pm");
|
||||
assert.equal(after.length, 1);
|
||||
});
|
||||
|
||||
it("FIFO ordering across multiple senders", () => {
|
||||
q.post("dev-a", "pm", "status", "first");
|
||||
q.post("dev-b", "pm", "status", "second");
|
||||
q.post("dev-a", "pm", "question", "third");
|
||||
const msgs = q.read("pm");
|
||||
assert.equal(msgs.length, 3);
|
||||
assert.equal(msgs[0].body, "first");
|
||||
assert.equal(msgs[1].body, "second");
|
||||
assert.equal(msgs[2].body, "third");
|
||||
});
|
||||
|
||||
it("isRole rejects unknown strings", () => {
|
||||
assert.ok(isRole("pm"));
|
||||
assert.ok(isRole("dev-a"));
|
||||
assert.ok(isRole("dev-b"));
|
||||
assert.ok(!isRole("dev-c"));
|
||||
assert.ok(!isRole(""));
|
||||
assert.ok(!isRole("PM"));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
```bash
|
||||
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||
```
|
||||
|
||||
Expected: fails with `Cannot find module './queue.ts'` or similar. If it fails with a different error, investigate before continuing.
|
||||
|
||||
- [ ] **Step 3: Write `queue.ts`**
|
||||
|
||||
Create `tools/relay/queue.ts`:
|
||||
|
||||
```typescript
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type Role = "pm" | "dev-a" | "dev-b";
|
||||
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||
|
||||
export interface RelayMessage {
|
||||
id: string;
|
||||
from: Role;
|
||||
to: Role;
|
||||
kind: MessageKind;
|
||||
body: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
|
||||
|
||||
export function isRole(s: string): s is Role {
|
||||
return KNOWN_ROLES.has(s);
|
||||
}
|
||||
|
||||
export class RelayQueue {
|
||||
private readonly queues = new Map<Role, RelayMessage[]>([
|
||||
["pm", []],
|
||||
["dev-a", []],
|
||||
["dev-b", []],
|
||||
]);
|
||||
|
||||
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||
const msg: RelayMessage = {
|
||||
id: randomUUID(),
|
||||
from,
|
||||
to,
|
||||
kind,
|
||||
body,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
this.queues.get(to)!.push(msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
read(forRole: Role): RelayMessage[] {
|
||||
const inbox = this.queues.get(forRole)!;
|
||||
const messages = [...inbox];
|
||||
inbox.length = 0;
|
||||
return messages;
|
||||
}
|
||||
|
||||
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
|
||||
const inbox = this.queues.get(forRole)!;
|
||||
return {
|
||||
count: inbox.length,
|
||||
kinds: inbox.map((m) => m.kind),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||
```
|
||||
|
||||
Expected output (all 5 passing):
|
||||
```
|
||||
▶ RelayQueue
|
||||
✔ post + read roundtrip returns the message with correct fields
|
||||
✔ consume-once: second read returns empty
|
||||
✔ list_pending does not drain inbox
|
||||
✔ FIFO ordering across multiple senders
|
||||
✔ isRole rejects unknown strings
|
||||
▶ RelayQueue (Xms)
|
||||
ℹ tests 5
|
||||
ℹ pass 5
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
If any test fails, fix `queue.ts` before proceeding.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/relay/queue.ts tools/relay/queue.test.ts
|
||||
git commit -m "feat(relay): in-memory queue with consume-once semantics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `server.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/server.ts`
|
||||
|
||||
- [ ] **Step 1: Write `server.ts`**
|
||||
|
||||
Create `tools/relay/server.ts`:
|
||||
|
||||
```typescript
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import http from "node:http";
|
||||
import { RelayQueue, isRole } from "./queue.ts";
|
||||
|
||||
const PORT = 7331;
|
||||
const queue = new RelayQueue();
|
||||
|
||||
const mcpServer = new Server(
|
||||
{ name: "relay", version: "0.1.0" },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: "post_message",
|
||||
description:
|
||||
"Push a message to a recipient's inbox. Returns the assigned message id.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
from: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Your role name",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Recipient role name",
|
||||
},
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["status", "question", "directive", "free"],
|
||||
description: "Message type matching the coordination protocol",
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "Message body — freeform markdown, typically the full formatted block",
|
||||
},
|
||||
},
|
||||
required: ["from", "to", "kind", "body"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_messages",
|
||||
description:
|
||||
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
required: ["for"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_pending",
|
||||
description:
|
||||
"Return count and kinds of pending messages without consuming them.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
for: {
|
||||
type: "string",
|
||||
enum: ["pm", "dev-a", "dev-b"],
|
||||
description: "Your role name",
|
||||
},
|
||||
},
|
||||
required: ["for"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||
|
||||
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const a = args as Record<string, string>;
|
||||
|
||||
if (name === "post_message") {
|
||||
if (!isRole(a.from)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
|
||||
}
|
||||
if (!isRole(a.to)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
|
||||
}
|
||||
const kind = a.kind as "status" | "question" | "directive" | "free";
|
||||
const msg = queue.post(a.from, a.to, kind, a.body);
|
||||
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
|
||||
const ellipsis = a.body.length > 60 ? "..." : "";
|
||||
process.stdout.write(`[${ts}] ${a.from} → ${a.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||
}
|
||||
|
||||
if (name === "read_messages") {
|
||||
if (!isRole(a.for)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||
}
|
||||
const messages = queue.read(a.for);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
||||
}
|
||||
|
||||
if (name === "list_pending") {
|
||||
if (!isRole(a.for)) {
|
||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||
}
|
||||
const result = queue.pending(a.for);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
|
||||
isError: true,
|
||||
};
|
||||
});
|
||||
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "GET" && req.url === "/sse") {
|
||||
const transport = new SSEServerTransport("/message", res);
|
||||
transports.set(transport.sessionId, transport);
|
||||
transport.onclose = () => transports.delete(transport.sessionId);
|
||||
await mcpServer.connect(transport);
|
||||
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
const sessionId = url.searchParams.get("sessionId") ?? "";
|
||||
const transport = transports.get(sessionId);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "session not found" }));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404).end("not found");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[relay] error:", err);
|
||||
if (!res.headersSent) res.writeHead(500).end(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[relay] server ready on :${PORT}`);
|
||||
console.log(`[relay] tools: post_message, read_messages, list_pending`);
|
||||
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Smoke-test server startup**
|
||||
|
||||
In one terminal:
|
||||
```bash
|
||||
cd tools/relay && npx tsx server.ts
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
[relay] server ready on :7331
|
||||
[relay] tools: post_message, read_messages, list_pending
|
||||
[relay] waiting for connections — Ctrl-C to stop
|
||||
```
|
||||
|
||||
In a second terminal, verify the port is listening:
|
||||
```bash
|
||||
curl -s --max-time 2 http://127.0.0.1:7331/sse | head -3
|
||||
```
|
||||
|
||||
Expected: SSE `data:` stream begins (it won't complete — the connection stays open). Ctrl-C both.
|
||||
|
||||
If the server errors on startup, check that `@modelcontextprotocol/sdk` is installed and review any TypeScript errors by running `npx tsc --noEmit` in `tools/relay/`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/relay/server.ts
|
||||
git commit -m "feat(relay): MCP SSE server with post_message/read_messages/list_pending"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `start.sh`
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/relay/start.sh`
|
||||
|
||||
- [ ] **Step 1: Write `start.sh`**
|
||||
|
||||
Create `tools/relay/start.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
|
||||
PORT=7331
|
||||
MODE="manual"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--tmux) MODE="tmux" ;;
|
||||
--kitty) MODE="kitty" ;;
|
||||
--manual) MODE="manual" ;;
|
||||
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Port check
|
||||
if lsof -ti:"$PORT" &>/dev/null; then
|
||||
echo "Error: port $PORT is already in use."
|
||||
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install deps (no-op if node_modules current)
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --silent
|
||||
|
||||
# Discover latest coordination prompts for instructions
|
||||
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
|
||||
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
||||
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||
|
||||
print_manual_instructions() {
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Open 3 new terminals. In each, start Claude Code and paste"
|
||||
echo "the content BELOW the '---' line from the corresponding file."
|
||||
echo ""
|
||||
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
||||
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
||||
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
||||
echo ""
|
||||
echo "This terminal becomes the relay log. Keep it open."
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
launch_tmux() {
|
||||
SESSION="relay-lift"
|
||||
tmux new-session -d -s "$SESSION" -n "relay" \
|
||||
"cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
||||
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
||||
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
||||
echo ""
|
||||
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
|
||||
echo "[relay] Paste the kickoff prompt into each Claude window."
|
||||
echo " Prompts:"
|
||||
echo " PM: $PM_PROMPT"
|
||||
echo " Dev A: $DEV_A_PROMPT"
|
||||
echo " Dev B: $DEV_B_PROMPT"
|
||||
echo ""
|
||||
tmux attach-session -t "$SESSION"
|
||||
}
|
||||
|
||||
launch_kitty() {
|
||||
kitty @ launch --new-tab --tab-title "relay" -- \
|
||||
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||
kitty @ launch --new-window --window-title "PM" -- \
|
||||
bash -c "cd '$REPO_ROOT' && claude"
|
||||
kitty @ launch --new-window --window-title "Dev-A" -- \
|
||||
bash -c "cd '$REPO_ROOT' && claude"
|
||||
kitty @ launch --new-window --window-title "Dev-B" -- \
|
||||
bash -c "cd '$REPO_ROOT' && claude"
|
||||
echo ""
|
||||
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
||||
echo " Paste the kickoff prompts into each Claude window."
|
||||
echo " PM: $PM_PROMPT"
|
||||
echo " Dev A: $DEV_A_PROMPT"
|
||||
echo " Dev B: $DEV_B_PROMPT"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
manual)
|
||||
print_manual_instructions
|
||||
exec npx tsx "$SCRIPT_DIR/server.ts"
|
||||
;;
|
||||
tmux)
|
||||
launch_tmux
|
||||
;;
|
||||
kitty)
|
||||
launch_kitty
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x tools/relay/start.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Smoke-test `--manual` mode**
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario && tools/relay/start.sh
|
||||
```
|
||||
|
||||
Expected: prints the launch box with prompt paths, then server starts and shows `[relay] server ready on :7331`. Ctrl-C to stop.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/relay/start.sh
|
||||
git commit -m "feat(relay): start.sh launcher with --manual/--tmux/--kitty modes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Claude Code MCP configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `.claude/settings.json`
|
||||
|
||||
- [ ] **Step 1: Read current `.claude/settings.json`**
|
||||
|
||||
```bash
|
||||
cat .claude/settings.json
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the relay MCP server entry**
|
||||
|
||||
The file currently has `{ "enabledPlugins": { ... } }`. Add `"mcpServers"` at the top level:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"relay": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:7331/sse"
|
||||
}
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Preserve whatever is already in `enabledPlugins` — only add the `mcpServers` key.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .claude/settings.json
|
||||
git commit -m "chore(relay): add relay MCP server to project Claude config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `docs/superpowers/MULTI-AGENT.md`
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/MULTI-AGENT.md`
|
||||
|
||||
- [ ] **Step 1: Write the paradigm README**
|
||||
|
||||
Create `docs/superpowers/MULTI-AGENT.md`:
|
||||
|
||||
```markdown
|
||||
# Multi-Agent Development Paradigm
|
||||
|
||||
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
|
||||
|
||||
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| Role | Terminal | Branch | Responsibilities |
|
||||
|------|----------|--------|-----------------|
|
||||
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
|
||||
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
|
||||
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
|
||||
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
|
||||
|
||||
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
|
||||
|
||||
---
|
||||
|
||||
## Starting a lift
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
|
||||
- [ ] No uncommitted changes in main that would confuse the devs
|
||||
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
|
||||
|
||||
### Launch sequence
|
||||
|
||||
```bash
|
||||
# 1. Start the relay server (this terminal becomes the relay log)
|
||||
tools/relay/start.sh # prints copy-paste instructions, then starts server
|
||||
|
||||
# Optional: use a multiplexer to auto-open all four terminals
|
||||
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
|
||||
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
|
||||
```
|
||||
|
||||
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
|
||||
|
||||
---
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
|
||||
|
||||
| Kind | Block header | When used |
|
||||
|------|-------------|-----------|
|
||||
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
|
||||
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
|
||||
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
|
||||
| `free` | (none) | Ad-hoc messages not covered by the above |
|
||||
|
||||
A well-formed `status` block:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: 2026-05-02T14:30:00-07:00
|
||||
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||
Task: P4 / error-copy map
|
||||
Status: DONE
|
||||
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
|
||||
Tests: green
|
||||
Notes: No issues. Ready for PM review of P4 before starting B1.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using the relay tools
|
||||
|
||||
All three Claude Code sessions have these tools available when the relay server is running:
|
||||
|
||||
```
|
||||
post_message(from, to, kind, body) → { id }
|
||||
read_messages(for) → RelayMessage[] (drains inbox)
|
||||
list_pending(for) → { count, kinds } (non-destructive)
|
||||
```
|
||||
|
||||
Typical dev flow per task:
|
||||
|
||||
```
|
||||
1. read_messages(for="dev-b") # check for directives before starting
|
||||
2. ... do the work ...
|
||||
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
|
||||
```
|
||||
|
||||
Typical PM flow:
|
||||
|
||||
```
|
||||
1. read_messages(for="pm") # see what devs posted
|
||||
2. ... review ...
|
||||
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## If the relay server isn't running
|
||||
|
||||
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
|
||||
|
||||
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
|
||||
|
||||
To restart a crashed server mid-lift:
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh
|
||||
```
|
||||
|
||||
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
|
||||
|
||||
---
|
||||
|
||||
## Generating kickoff prompts
|
||||
|
||||
Use the `multi-agent-kickoff` skill (in the `superpowers` plugin). It auto-discovers the spec and plans for the release, substitutes all placeholders including the relay paragraph, and writes files to `docs/superpowers/coordination/`.
|
||||
|
||||
The skill reminder: run `tools/relay/start.sh` **before** opening the three Claude Code sessions — the MCP tools need the server to be up when each session initializes.
|
||||
|
||||
---
|
||||
|
||||
## Ending a lift
|
||||
|
||||
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
|
||||
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
|
||||
3. PM tags the release (only after explicit user `yes`)
|
||||
4. Ctrl-C the relay terminal — all in-memory messages are discarded
|
||||
|
||||
---
|
||||
|
||||
## Roles and boundaries (quick reference)
|
||||
|
||||
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
|
||||
|
||||
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
|
||||
|
||||
**User must:** authorize all merges and the release tag. Everything else is delegated.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/MULTI-AGENT.md
|
||||
git commit -m "docs: add multi-agent development paradigm README"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Update `multi-agent-kickoff` skill
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`
|
||||
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`
|
||||
- Modify: `~/.claude/skills/multi-agent-kickoff/SKILL.md`
|
||||
|
||||
- [ ] **Step 1: Read current templates**
|
||||
|
||||
```bash
|
||||
cat ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||
cat ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||
```
|
||||
|
||||
Note where the "Setup" section ends in each template. The relay paragraph goes right after it (before "Required reading").
|
||||
|
||||
- [ ] **Step 2: Add `<<RELAY_PARAGRAPH>>` to `pm-prompt.md`**
|
||||
|
||||
In `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`, find the "## Setup" section and add the placeholder block immediately after it (before the "## Required reading" heading):
|
||||
|
||||
```markdown
|
||||
<<RELAY_PARAGRAPH>>
|
||||
```
|
||||
|
||||
The generated output for this placeholder (substituted by the skill at generation time) is:
|
||||
|
||||
```markdown
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-a", kind="directive", body="...")`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `<<RELAY_PARAGRAPH>>` to `dev-prompt.md`**
|
||||
|
||||
In `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`, find the "## Setup" section and add immediately after it (before "## Required reading"):
|
||||
|
||||
```markdown
|
||||
<<RELAY_PARAGRAPH>>
|
||||
```
|
||||
|
||||
The generated output for this placeholder is role-specific (uses `<<DEV_ROLE>>`):
|
||||
|
||||
```markdown
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"<<DEV_ROLE>>"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="<<DEV_ROLE>>"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="<<DEV_ROLE>>")`. After emitting any status/question block: `post_message(from="<<DEV_ROLE>>", to="pm", kind="status"|"question", body="...")`.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `SKILL.md` — add two entries to the placeholder reference table**
|
||||
|
||||
In `~/.claude/skills/multi-agent-kickoff/SKILL.md`, find the "### Common to all prompts" section of the Placeholder reference and add:
|
||||
|
||||
```markdown
|
||||
- `<<RELAY_PARAGRAPH>>` — the relay server instruction block (substituted from the template above). For the PM prompt, `from` is hardcoded to `"pm"`. For dev prompts, uses `<<DEV_ROLE>>`.
|
||||
```
|
||||
|
||||
In the "### Per-dev" section, add:
|
||||
|
||||
```markdown
|
||||
- `<<DEV_ROLE>>` — lowercase relay role name, e.g. `dev-a`, `dev-b`. Derived from `<<DEV_LETTER>>` by lowercasing and prepending `dev-`. Set when `<<DEV_LETTER>>` is set.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `SKILL.md` — step 8 (kickoff instructions)**
|
||||
|
||||
Find step 8 in the Process section ("Print kickoff instructions") and prepend a bullet:
|
||||
|
||||
```markdown
|
||||
8. **Print kickoff instructions.** Tell the user exactly what to do:
|
||||
- **Start the relay server first:** `tools/relay/start.sh` (or `--tmux`/`--kitty` for auto-layout). The server must be running before the sessions open so the MCP tools initialize correctly.
|
||||
- Open three terminal windows (or panes — their choice of multiplexer)
|
||||
...rest of existing bullets unchanged...
|
||||
```
|
||||
|
||||
Also update the "After generation" section — change bullet 4 ("From that point on, they're the message bus...") to:
|
||||
|
||||
```markdown
|
||||
4. The relay server handles message routing — agents call `post_message`/`read_messages` directly. The user only needs to step in for escalations the PM can't resolve, or if the relay server is down (manual fallback: copy-paste the block to the relevant terminal as before)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit skill changes**
|
||||
|
||||
The skill files live outside the git repo, so no git commit needed. Verify the changes look right:
|
||||
|
||||
```bash
|
||||
grep -n "RELAY_PARAGRAPH\|DEV_ROLE\|relay server" ~/.claude/skills/multi-agent-kickoff/SKILL.md | head -10
|
||||
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||
```
|
||||
|
||||
Expected: each grep returns at least one match.
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Run queue tests one more time from repo root**
|
||||
|
||||
```bash
|
||||
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||
```
|
||||
|
||||
Expected: 5 passing, 0 failing.
|
||||
|
||||
- [ ] **Start server and verify it binds**
|
||||
|
||||
```bash
|
||||
tools/relay/start.sh &
|
||||
sleep 1
|
||||
curl -s --max-time 1 http://127.0.0.1:7331/sse | head -1 || true
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: `data:` line appears (SSE stream started), then server killed cleanly.
|
||||
|
||||
- [ ] **Verify MCP config is present**
|
||||
|
||||
```bash
|
||||
python3 -c "import json; d=json.load(open('.claude/settings.json')); print(d['mcpServers']['relay'])"
|
||||
```
|
||||
|
||||
Expected: `{'type': 'sse', 'url': 'http://localhost:7331/sse'}`
|
||||
|
||||
- [ ] **Verify skill placeholders were added**
|
||||
|
||||
```bash
|
||||
grep "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md \
|
||||
~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||
```
|
||||
|
||||
Expected: one match per file.
|
||||
Reference in New Issue
Block a user