A cmux extension triggers a recursive spawn loop. Each pi helper subprocess loads the extension, which spawns more helpers. Your machine grinds to a halt under an exponentially growing tree of haiku calls. This is the first thing that goes wrong when you build multi-agent systems, and the reason this lesson comes before everything else.
The loop
The cmux extension auto-names your session. When you type your first prompt, it spawns a cheap haiku call to generate a 2-4 word name:
// cmux.ts — generateSessionName()
const child = spawn("pi", [
"-p",
"--model", NAMING_MODEL,
"--no-session",
"--no-extensions",
// ...
], {
env: { ...process.env, [CMUX_CHILD_ENV]: "1" },
});
See those --no-extensions and CMUX_CHILD_ENV flags? Remove them and watch what happens.
Without --no-extensions, that child pi process loads every extension in your config — including cmux itself. The cmux extension's before_agent_start hook fires. It calls generateSessionName(). That spawns another pi child. Which loads cmux. Which spawns another child. Each generation doubles. By the time you notice, you have hundreds of processes fighting for CPU and memory.
This isn't hypothetical. This is what actually happened during development. The fix required three independent layers of defense, because no single guard is sufficient.
Layer 1: Strip the flags
Every helper subprocess gets launched with flags that prevent it from acting like a full pi session:
const child = spawn("pi", [
"-p",
"--model", NAMING_MODEL,
"--no-session",
"--no-extensions",
"--no-tools",
"--no-skills",
"--no-prompt-templates",
"--system-prompt",
"You are a session namer. Given a project directory and first user prompt..."
], {
stdio: ["pipe", "pipe", "ignore"],
timeout: 15000,
env: { ...process.env, [CMUX_CHILD_ENV]: "1" },
});
--no-extensions is the critical flag. It tells pi not to load any extension code, so cmux never initializes in the child. --no-session prevents the child from creating its own session state. --no-tools, --no-skills, --no-prompt-templates strip everything else down — this child exists to answer one question and exit.
The same pattern appears in every helper spawn: generateSessionName(), reevaluateSessionName(), and generateTurnSummary(). All three use identical flags.
This layer is necessary but not sufficient. Flags are easy to forget. One helper spawn without --no-extensions and you're back to exponential growth.
Layer 2: The environment variable kill switch
Every helper spawn also sets an environment variable:
const CMUX_CHILD_ENV = "PI_CMUX_CHILD";
// In every spawn() call:
env: { ...process.env, [CMUX_CHILD_ENV]: "1" }
And the very first line of the extension checks for it:
export default function cmuxExtension(pi: ExtensionAPI) {
if (process.env[CMUX_CHILD_ENV] === "1") return;
if (!hasCmux()) return;
// ...
}
If PI_CMUX_CHILD=1 is in the environment, the extension returns immediately. No hooks registered. No tools registered. No sidebar updates. Nothing.
This is the belt to Layer 1's suspenders. Even if someone adds a new helper spawn and forgets --no-extensions, the environment variable prevents the extension from initializing. The child inherits the env, so it propagates to any accidental grandchildren too.
Two layers stop the recursion. But there's a third problem these two don't address: what about agents you want to run the extension?
Layer 3: Worker mode
When an orchestrator spawns a worker agent via spawn_pi, it wants that worker to have full cmux integration — sidebar status, notifications, tool activity visible from other workspaces. You don't want --no-extensions on a real worker. But you also don't want that worker spawning its own helpers recursively.
The solution is PI_CMUX_ROLE=worker:
const IS_WORKER = process.env.PI_CMUX_ROLE === "worker";
Workers get the full extension — lifecycle hooks, sidebar updates, notifications, all the cmux tools. But any feature that spawns a subprocess is gated:
// In the before_agent_start hook:
if (!existing && !IS_WORKER) {
generateSessionName(event.prompt, ctx.cwd);
}
// In the agent_end hook:
if (!IS_WORKER) {
generateTurnSummary(lastAssistantText, ctx.cwd);
if (_turnCount % RENAME_INTERVAL_TURNS === 0 && lastAssistantText) {
reevaluateSessionName(lastAssistantText, pi.getSessionName(), ctx.cwd);
}
}
Workers never call generateSessionName(). Workers never call generateTurnSummary(). Workers never call reevaluateSessionName(). These are the three functions that spawn pi subprocesses, and workers skip all of them.
The fleet tools — spawn_pi, read_agent, send_agent, kill_agent, list_agents — are also gated behind !IS_WORKER:
// Fleet tools — orchestrator only (workers can't spawn workers)
if (!IS_WORKER) {
pi.registerTool({
name: "spawn_pi",
// ...
});
}
Workers can't spawn workers. Only orchestrators can. This is how you prevent the spawn tree from growing beyond one level. The orchestrator is the only process in the system with the spawn_pi tool, and it enforces a hard cap:
const MAX_AGENTS = parseInt(process.env.PI_CMUX_MAX_AGENTS || "5");
if (fleet.size >= MAX_AGENTS) {
return {
content: [{ type: "text", text: `Fleet limit reached (${MAX_AGENTS}). Kill an agent first.` }],
isError: true,
};
}
Default: five workers, max. Even if the orchestrator's LLM goes haywire and tries to spawn infinitely, it hits a wall at five.
Three layers, one principle
| Layer | Mechanism | Protects against |
|---|---|---|
--no-extensions | CLI flags on helper spawns | Extension code loading in throwaway subprocesses |
PI_CMUX_CHILD=1 | Env var checked at extension entry | Forgotten flags, accidental grandchildren |
PI_CMUX_ROLE=worker | Role-based feature gating | Workers spawning workers, recursive fleet growth |
Each layer catches what the others miss. Flags are the first line but easy to forget. The env var is the safety net but too coarse for real workers. Worker mode is precise but doesn't help with throwaway helpers.
This is defense in depth applied to agent spawning. If you're building any system where agents can create other agents, you need equivalent guards. The specific mechanism doesn't matter — environment variables, role enums, depth counters, whatever. What matters is that you have multiple independent checks that prevent unbounded recursion.
If you can't prevent recursive spawning, nothing else in this course matters. Every orchestration pattern, every coordination protocol, every fleet management strategy assumes that the spawn tree is bounded. This is the foundation.