You have a terminal multiplexer that knows about agents. You have an extension that bridges pi and cmux. You have three layers of defense against recursive spawning. Now you build the thing all of that exists to support: a tool that launches a worker agent in a visible pane, right next to you, where you can watch it work.
By the end of this lesson, spawn_pi is registered as a tool in your orchestrator session. You call it with a prompt, and a new pi agent appears in a split pane to the right. It starts working immediately. You read its output without leaving your session.
The fleet tracker
Before you can spawn anything, you need a place to track what you've spawned. Two pieces: an interface describing each agent, and a Map holding all of them.
interface AgentInfo {
id: string;
surfaceRef: string;
workspaceRef: string;
model: string;
cwd: string;
prompt: string; // first 200 chars
status: "starting" | "running" | "idle" | "completed" | "failed";
spawnedAt: number;
}
const fleet = new Map<string, AgentInfo>();
const MAX_AGENTS = parseInt(process.env.PI_CMUX_MAX_AGENTS || "5");
AgentInfo is the identity record for every spawned worker. Each field earns its place:
id— A random 8-character string (Math.random().toString(36).slice(2, 10)). Short enough to type, unique enough to not collide with five agents.surfaceRef— The cmux surface reference (surface:7). This is how you talk to the agent's terminal — read its screen, send it text, kill it.workspaceRef— Which cmux workspace the agent lives in. Used for cleanup.model— Which LLM the worker is using. Visible inlist_agentsoutput so the orchestrator knows what it's paying for.prompt— The first 200 characters of what you told the agent to do. Enough to identify it in a list without storing the entire prompt.status— Lifecycle state. Starts at"starting", moves to"running"when pi boots, then"idle"when the agent finishes its turn. Completion detection (a later lesson) drives these transitions.spawnedAt— Unix timestamp. Powers the elapsed-time display inlist_agents.
The fleet Map is the entire fleet state. In-memory only — no persistence, no database, no shared files. If the orchestrator's session dies, fleet state is gone. That's fine for now. The orchestrator is the single source of truth, and it cleans up all workers on shutdown.
MAX_AGENTS defaults to 5. That's the hard cap from The Fork Bomb lesson — even if the LLM goes haywire and tries to spawn infinitely, it hits a wall. Override it with PI_CMUX_MAX_AGENTS if you need more, but five visible workers in split panes is already pushing the limits of a readable screen.
The tool registration
The spawn_pi tool is registered inside the !IS_WORKER gate. Workers can't spawn workers — that's Layer 3 of the fork bomb defense.
pi.registerTool({
name: "spawn_pi",
label: "Spawn Pi Agent",
description: [
"Spawn a new pi agent in a cmux workspace. The agent runs in a visible",
"terminal you can switch to at any time.",
"",
"The spawned agent loads extensions normally but with PI_CMUX_ROLE=worker,",
"which keeps sidebar/notifications but prevents recursive spawning.",
].join("\n"),
parameters: Type.Object({
prompt: Type.String({ description: "Initial prompt for the agent" }),
cwd: Type.Optional(Type.String({ description: "Working directory (default: current)" })),
model: Type.Optional(Type.String({ description: "Model to use (default: anthropic/claude-opus-4-6)" })),
direction: Type.Optional(Type.Union([
Type.Literal("right"),
Type.Literal("down"),
], { description: "Split direction for the worker pane (default: first worker splits right, subsequent split down)" })),
skills: Type.Optional(Type.Array(Type.String(), {
description: "Skill names to load (e.g. ['next-best-practices'])",
})),
}),
Five parameters. Only prompt is required:
prompt— What the worker should do. This becomes the worker's initial instruction.cwd— Where the worker starts. Defaults to the orchestrator's current directory. Pass a different path if you want the worker in a different repo.model— Defaults toanthropic/claude-opus-4-6. The explicit provider prefix matters — pi won't resolve bare model names.direction—"right"or"down". Controls how the new pane splits. Defaults to adaptive: the first worker splits right (creating a worker column beside the orchestrator), subsequent workers split down (stacking vertically in that column). This prevents multiple vertical splits from crushing pane widths below pi's minimum renderable size.skills— Optional skill names to load. If your worker needsnext-best-practicesorvitest, list them here instead of baking them into the prompt.
The description is written for the LLM, not for you. When the orchestrator's model reads the tool list, this text tells it what spawn_pi does, what happens to the spawned agent (loads extensions, gets worker mode), and that the result is visible. Good tool descriptions make the LLM use the tool correctly without elaborate system prompts.
The execute function, step by step
The execute function is where it all happens. Walk through it in order.
Gate check
async execute(_id, params) {
if (fleet.size >= MAX_AGENTS) {
return {
content: [{ type: "text", text: `Fleet limit reached (${MAX_AGENTS}). Kill an agent first.` }],
isError: true,
};
}
const agentId = Math.random().toString(36).slice(2, 10);
const cwd = params.cwd || process.cwd();
const model = params.model || "anthropic/claude-opus-4-6";
// First worker splits right (vertical), subsequent workers split down (horizontal)
const direction = params.direction || (fleet.size === 0 ? "right" : "down");
First thing: check the fleet cap. If you're already at MAX_AGENTS, return an error. The isError: true flag tells the LLM the call failed — it'll either kill an agent or give up.
Then generate the agent ID and resolve defaults. The agentId is 8 random alphanumeric characters. Not a UUID — you don't need global uniqueness for five agents that live for minutes.
Step 1: Create the terminal pane
// 1. Create the surface
let surfaceRef: string;
let workspaceRef: string;
{
const splitDir = direction;
const paneResult = cmux("new-pane", "--type", "terminal", "--direction", splitDir);
const sfMatch = paneResult.match(/surface:\d+/);
const pnMatch = paneResult.match(/pane:\d+/);
surfaceRef = sfMatch ? sfMatch[0] : "";
if (!surfaceRef) throw new Error(`Failed to create terminal pane: ${paneResult}`);
// Resize worker pane to ~1/3
if (pnMatch) cmuxSafe("resize-pane", "--pane", pnMatch[0], "-L", "--amount", "40");
// Get current workspace ref
const identify = cmuxSafe("identify");
if (identify) {
try {
const info = JSON.parse(identify);
workspaceRef = info.caller?.workspace_ref || "current";
} catch { workspaceRef = "current"; }
} else {
workspaceRef = "current";
}
}
cmux new-pane --type terminal --direction right splits the current pane and creates a new terminal surface. The --type terminal flag is critical — without it, you get a non-writable surface. That was a hard-won discovery.
The result string contains both a surface ref (surface:7) and a pane ref (pane:3). Parse both out with regex. The surface ref is how you'll talk to this terminal later. The pane ref is for resizing.
The resize call (resize-pane --pane pane:3 -L --amount 40) shrinks the new worker pane to roughly one-third of the available width. Without this, the split is 50/50 and the orchestrator's pane feels cramped. cmuxSafe instead of cmux because resize failing shouldn't abort the spawn.
cmux identify returns JSON about the current session — which workspace you're in, which surface has focus. Extract the workspace ref so you can record where this agent lives.
Step 2: Write the prompt to a temp file
// 2. Write prompt to temp file to avoid shell escaping nightmares
const promptFile = path.join(cwd, `.pi-worker-${agentId}.md`);
writeFileSync(promptFile, params.prompt);
This is one line of code that replaced fifty lines of failed alternatives. The prompt is arbitrary text — it can contain quotes, backticks, exclamation marks, markdown code fences, newlines, anything. Passing it through the shell as a command-line argument is a minefield. Zsh interprets ! as history expansion. Nested quotes break. Escaped newlines get eaten.
The fix: don't pass the prompt through the shell at all. Write it to a file. The file name includes the agent ID so multiple workers don't collide. The .pi-worker- prefix makes it obvious what these files are. The full story of how this decision was reached is in the next lesson — Shell Escaping Nightmares.
Step 3: Build the pi command
// 3. Build the pi command with @file reference
const piArgs = ["pi", "--model", model];
if (params.skills?.length) {
for (const skill of params.skills) piArgs.push("--skill", skill);
}
piArgs.push(`@${promptFile}`);
Pi's @file syntax reads a file and uses its contents as the initial prompt. @.pi-worker-1a0g6g1w.md tells pi "read that file, treat it as my first message." Clean, no escaping needed.
The --model flag takes the full provider-prefixed name. Skills are appended with --skill flags if any were requested.
Step 4: Send the command and register
// 4. Send cd + pi as one chained command, clean up prompt file after
const fullCmd = `cd ${cwd} && PI_CMUX_ROLE=worker ${piArgs.join(" ")}; rm -f ${promptFile}`;
cmux("send", "--surface", surfaceRef, fullCmd);
cmux("send-key", "--surface", surfaceRef, "Enter");
This is the moment the worker comes alive. Two cmux calls:
-
cmux send— Types the command string into the terminal surface. Like a human typing, but instant. The command chains three things:cd ${cwd}— Navigate to the working directoryPI_CMUX_ROLE=worker ${piArgs.join(" ")}— Launch pi with the worker environment variablerm -f ${promptFile}— Clean up the temp file after pi exits (the;means this runs whether pi succeeds or fails)
-
cmux send-key Enter— Presses Enter. The command executes.
The PI_CMUX_ROLE=worker prefix is the Layer 3 defense. The spawned pi process loads the cmux extension normally — it gets sidebar status, notifications, tool activity. But the extension sees PI_CMUX_ROLE=worker and skips registering spawn_pi, read_agent, send_agent, kill_agent, and list_agents. It also skips spawning helper subprocesses for session naming and turn summaries. The worker is a full agent with full visibility, but it can't spawn children.
Step 5: Register in fleet and update sidebar
// 4. Register in fleet
const agent: AgentInfo = {
id: agentId,
surfaceRef,
workspaceRef,
model,
cwd,
prompt: params.prompt.slice(0, 200),
status: "starting",
spawnedAt: Date.now(),
};
fleet.set(agentId, agent);
// Start completion detection if not already running
startCompletionPolling();
// 5. Update sidebar with fleet count
cmuxSafe("set-status", "fleet",
`${fleet.size} agent${fleet.size > 1 ? "s" : ""}`,
"--icon", "person.3.fill", "--color", "#4C8DFF");
Create the AgentInfo record with status "starting" and store it in the fleet Map. The prompt is truncated to 200 characters — enough for identification, not enough to bloat memory.
startCompletionPolling() kicks off a setInterval loop that monitors worker terminals for idle state. You'll build that in a later lesson. For now, just know it starts when the first worker spawns and stops when the last one dies.
The sidebar update shows the fleet count visible from any workspace. Switch to a different workspace and you'll see "2 agents" with the person.3.fill icon. It's the quiet signal that work is happening somewhere.
Return value
return {
content: [{
type: "text",
text: JSON.stringify({
agent_id: agentId,
workspace: workspaceRef,
surface: surfaceRef,
model,
cwd,
status: "launched",
}, null, 2),
}],
};
The return value is JSON with the agent ID and all the refs needed to interact with the worker. The orchestrator's LLM uses the agent_id in subsequent calls to read_agent, send_agent, or kill_agent. The surface and workspace refs are informational — the fleet Map already has them, but surfacing them in the response makes the tool output self-documenting.
The render functions
Every pi tool has two render functions that control how the tool call appears in the TUI:
renderCall(args, theme) {
const model = args.model ? theme.fg("dim", ` (${args.model})`) : "";
const prompt = args.prompt.length > 50 ? args.prompt.slice(0, 47) + "…" : args.prompt;
return new Text(
theme.fg("toolTitle", theme.bold("spawn_pi")) + model + " " + theme.fg("accent", prompt),
0, 0,
);
},
renderResult(result, _opts, theme) {
const txt = result.content[0];
const text = txt?.type === "text" ? txt.text : "";
if (result.isError) return new Text(theme.fg("error", `✗ ${text}`), 0, 0);
try {
const data = JSON.parse(text);
return new Text(
theme.fg("success", "✓") + ` agent:${data.agent_id} → ${data.workspace}`,
0, 0,
);
} catch {
return new Text(theme.fg("success", `✓ ${text}`), 0, 0);
}
},
renderCall shows what the LLM is about to do: spawn_pi (anthropic/claude-opus-4-6) Fix the auth bug in... — the tool name bolded, model dimmed, prompt in the accent color, truncated at 50 characters.
renderResult shows what happened: ✓ agent:1a0g6g1w → workspace:3 for success, ✗ Fleet limit reached (5) for failure. Clean one-line feedback. The orchestrator session never shows a wall of JSON — just the agent ID and where it landed.
Spawn a test worker
Save your changes. Open a pi session in cmux. You're the orchestrator — no PI_CMUX_ROLE set, so you get all the fleet tools. Tell pi:
Spawn a worker to list the files in this directory and describe what each one does.
The LLM calls spawn_pi with that prompt. Watch the right side of your terminal — a new pane appears. Pi boots in it, reads the @file prompt, and starts working. Your orchestrator session shows:
✓ agent:1a0g6g1w → current
The sidebar updates to show "1 agent" with the fleet icon. The worker's pane has its own sidebar showing "Running" with a blue bolt.
Now read what the worker is doing:
Read what agent 1a0g6g1w is doing.
The LLM calls read_agent with the agent ID. It returns the last 30 lines of the worker's terminal — whatever pi is currently displaying in that pane. You see the worker's output without switching panes, without losing your place, without interrupting your own session.
That's it. One tool call created a visible, interactive, fully-equipped pi agent in a split pane. Another tool call read its screen. The worker has its own sidebar status, its own session, its own model context. It just can't spawn children.
The spawn_pi tool is 90 lines. The fleet tracker is 12. The entire spawn-and-track machinery fits in the space of a medium-sized React component. Everything else — reading, steering, killing, listing — builds on the same fleet Map and surfaceRef pattern. That's the next lesson.