Lesson

The Fleet Lifecycle

Read what workers are doing, steer them mid-flight, and shut them down — the four companion tools that make spawn_pi useful.

A worker you can't observe is a worker you can't trust. A worker you can't steer is a worker you'll have to kill and restart. And a worker you can't kill is a worker that will eventually cost you money doing the wrong thing.

spawn_pi launches agents. These four tools make them manageable: read_agent reads a worker's terminal output, send_agent steers a worker mid-task, kill_agent shuts one down, and list_agents shows the entire fleet at a glance. Together with spawn_pi, these five tools are the complete fleet control surface — everything the orchestrator needs to manage visible workers.

All four tools follow the same pattern: look up the agent in the fleet Map by ID, use its surfaceRef to interact with the cmux surface, and return structured output for the LLM.

read_agent — observe without switching

The simplest tool in the fleet. It reads the terminal screen of a worker's surface:

pi.registerTool({
  name: "read_agent",
  label: "Read Agent",
  description: "Read the terminal output of a spawned agent.",
  parameters: Type.Object({
    agent_id: Type.String({ description: "Agent ID from spawn_pi" }),
    lines: Type.Optional(Type.Number({ description: "Lines to read (default: 30)" })),
  }),

  async execute(_id, params) {
    const agent = fleet.get(params.agent_id);
    if (!agent) {
      return {
        content: [{ type: "text", text: `Agent ${params.agent_id} not found. Use list_agents to see active agents.` }],
        isError: true,
      };
    }
    try {
      const lines = String(params.lines || 30);
      const screen = cmux("read-screen", "--surface", agent.surfaceRef, "--lines", lines);
      return { content: [{ type: "text", text: screen }] };
    } catch (e: any) {
      return { content: [{ type: "text", text: e.message }], isError: true };
    }
  },

Two parameters: agent_id (required) and lines (optional, defaults to 30).

The function does three things:

  1. Look up the agent in the fleet Map. If it's not there, return an error with a helpful hint to use list_agents.
  2. Call cmux read-screen --surface <ref> --lines 30. This reads the terminal buffer — whatever text is currently visible in the worker's pane, plus scrollback.
  3. Return the raw screen text. No parsing, no summarizing. The orchestrator's LLM reads the terminal output and decides what it means.

Notice this uses cmux() (strict), not cmuxSafe(). If the read fails — the surface was closed, cmux crashed, the ref is stale — the error propagates to the LLM. The orchestrator needs to know when observation fails so it can react (kill the dead agent, spawn a replacement).

The 30-line default is enough to see the agent's current activity without flooding the orchestrator's context. If you need more history — maybe the agent wrote a long output — pass lines: 100. But be aware that every line you read is a line in the orchestrator's context window.

The render functions

renderCall(args, theme) {
  return new Text(
    theme.fg("toolTitle", theme.bold("read_agent")) + " " + theme.fg("accent", args.agent_id),
    0, 0,
  );
},

renderResult(result, _opts, theme) {
  const txt = result.content[0];
  const text = txt?.type === "text" ? txt.text : "";
  const icon = result.isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
  const lines = text.split("\n");
  const preview = lines.slice(0, 5).join("\n");
  const suffix = lines.length > 5 ? theme.fg("dim", `\n… ${lines.length - 5} more lines`) : "";
  return new Text(`${icon} ${preview}${suffix}`, 0, 0);
},

The call render shows read_agent 1a0g6g1w. The result render shows a 5-line preview with a "… 25 more lines" suffix. The full 30 lines go to the LLM; the human sees a compact preview. This matters when you're reading multiple agents in sequence — the TUI stays clean while the LLM gets the full picture.

send_agent — steer mid-flight

Sometimes a worker is headed the wrong direction. Maybe it's refactoring a file you didn't mean to include. Maybe it's running tests when you need it writing code. send_agent lets you inject a message into a running worker's pi session:

pi.registerTool({
  name: "send_agent",
  label: "Send to Agent",
  description: "Send a message or instruction to a spawned agent.",
  parameters: Type.Object({
    agent_id: Type.String({ description: "Agent ID from spawn_pi" }),
    message: Type.String({ description: "Message to send to the agent" }),
  }),

  async execute(_id, params) {
    const agent = fleet.get(params.agent_id);
    if (!agent) {
      return {
        content: [{ type: "text", text: `Agent ${params.agent_id} not found.` }],
        isError: true,
      };
    }
    try {
      cmux("send", "--surface", agent.surfaceRef, params.message);
      cmux("send-key", "--surface", agent.surfaceRef, "Enter");
      return { content: [{ type: "text", text: `Sent to agent ${params.agent_id}` }] };
    } catch (e: any) {
      return { content: [{ type: "text", text: e.message }], isError: true };
    }
  },

Two cmux calls back-to-back:

  1. cmux send --surface <ref> "your message" — types the message text into the worker's terminal, as if a human typed it.
  2. cmux send-key --surface <ref> Enter — presses Enter to submit.

This is the same mechanism used to launch the worker in spawn_pi — literally typing into a terminal surface. The difference is timing. During spawn, you're typing the pi command into a fresh shell. During send, you're typing a new prompt into a running pi session.

When to use it: The worker has finished a turn and is showing "Needs input" in the sidebar. The orchestrator reads the worker's output, decides it needs adjustment, and sends a follow-up instruction. The worker picks up the new prompt and continues.

When NOT to use it: The worker is mid-turn (sidebar shows "Running"). Sending text while pi is processing a turn will queue the text — it'll appear as input when the current turn finishes. This isn't harmful, but it means your message might arrive at an unexpected moment. Check list_agents for the agent's status before sending.

The render functions

renderCall(args, theme) {
  const msg = args.message.length > 50 ? args.message.slice(0, 47) + "…" : args.message;
  return new Text(
    theme.fg("toolTitle", theme.bold("send_agent")) + " " +
    theme.fg("accent", args.agent_id) + " " + theme.fg("dim", msg),
    0, 0,
  );
},

Shows send_agent 1a0g6g1w Fix the auth bug instead… — agent ID in accent color, message preview dimmed. You can tell at a glance which agent is being steered and what it's being told.

kill_agent — controlled shutdown

When a worker is stuck, doing the wrong thing, or simply finished and you want the pane back:

pi.registerTool({
  name: "kill_agent",
  label: "Kill Agent",
  description: "Stop a spawned agent. Sends Ctrl-C and closes the pane.",
  parameters: Type.Object({
    agent_id: Type.String({ description: "Agent ID from spawn_pi" }),
  }),

  async execute(_id, params) {
    const agent = fleet.get(params.agent_id);
    if (!agent) {
      return {
        content: [{ type: "text", text: `Agent ${params.agent_id} not found.` }],
        isError: true,
      };
    }
    try {
      // Send Ctrl-C to interrupt, then close the surface
      cmuxSafe("send-key", "--surface", agent.surfaceRef, "C-c");
      setTimeout(() => {
        cmuxSafe("send", "--surface", agent.surfaceRef, "exit");
        cmuxSafe("send-key", "--surface", agent.surfaceRef, "Enter");
        setTimeout(() => {
          cmuxSafe("close-surface", "--surface", agent.surfaceRef);
        }, 500);
      }, 500);

      agent.status = "failed";
      fleet.delete(params.agent_id);

      // Update sidebar
      if (fleet.size > 0) {
        cmuxSafe("set-status", "fleet",
          `${fleet.size} agent${fleet.size > 1 ? "s" : ""}`,
          "--icon", "person.3.fill", "--color", "#4C8DFF");
      } else {
        cmuxSafe("clear-status", "fleet");
      }

      return { content: [{ type: "text", text: `Killed agent ${params.agent_id}` }] };
    } catch (e: any) {
      return { content: [{ type: "text", text: e.message }], isError: true };
    }
  },

The shutdown sequence is deliberate — three steps with timing:

  1. send-key C-c — Sends Ctrl-C to interrupt whatever pi is doing. If the agent is mid-turn, this breaks the LLM call. If it's at the input prompt, Ctrl-C signals the process.

  2. send "exit" + send-key Enter (after 500ms) — Types exit and presses Enter. This tells pi to shut down cleanly. The 500ms delay gives pi time to handle the Ctrl-C before receiving the exit command.

  3. close-surface (after another 500ms) — Closes the pane entirely. The worker's terminal disappears from the workspace. Another 500ms delay lets the exit command process before the surface is yanked away.

Every cmux call here uses cmuxSafe, not cmux. The kill sequence should be best-effort — if the surface is already gone (maybe the worker crashed), you don't want the kill attempt to throw an error. The important thing is removing the agent from the fleet Map and updating the sidebar, which happens regardless of whether the cmux calls succeed.

After deletion, the sidebar fleet count updates. If this was the last agent, the fleet status is cleared entirely — no "0 agents" cluttering the sidebar.

Why status = "failed"? Because kill_agent is an abnormal termination. The worker didn't finish its task — it was aborted. Normal completion flows through the completion detection system (a later lesson) and sets status = "idle" or "completed". If you see "failed" in a log, you know someone or something killed the agent.

list_agents — the fleet at a glance

The diagnostic tool. No parameters, just a dump of everything in the fleet Map:

pi.registerTool({
  name: "list_agents",
  label: "List Agents",
  description: "Show all spawned agents and their status.",
  parameters: Type.Object({}),

  async execute() {
    if (fleet.size === 0) {
      return { content: [{ type: "text", text: "No agents spawned." }] };
    }

    const lines: string[] = [];
    for (const [id, agent] of fleet) {
      const elapsed = Math.round((Date.now() - agent.spawnedAt) / 1000);
      const mins = Math.floor(elapsed / 60);
      const secs = elapsed % 60;
      const time = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
      lines.push(
        `${id}  ${agent.workspaceRef}  ${agent.model}  ${agent.status}  ${time}\n` +
        `  cwd: ${agent.cwd}\n` +
        `  prompt: ${agent.prompt}`
      );
    }

    return { content: [{ type: "text", text: lines.join("\n\n") }] };
  },

For each agent: ID, workspace ref, model, status, elapsed time, working directory, and prompt (first 200 characters). The output looks like:

1a0g6g1w  current  anthropic/claude-opus-4-6  running  2m 14s
  cwd: /Users/joel/Code/project
  prompt: Fix the authentication bug in the login flow. The session token...

9b2c3d4e  current  anthropic/claude-sonnet-4-20250514  idle  45s
  cwd: /Users/joel/Code/project
  prompt: Write tests for the auth module covering edge cases...

The elapsed time comes from Date.now() - agent.spawnedAt. This is wall-clock time since spawn, not LLM processing time. A worker that's been alive for 5 minutes might have spent 4 minutes thinking and 1 minute waiting for input.

The status field is the most important column. The orchestrator's LLM uses it to decide what to do next:

  • starting — Just spawned, pi is booting. Wait.
  • running — Working on its task. Either wait or read its screen to check progress.
  • idle — Finished its turn, waiting for input. Read its output, then send a follow-up or kill it.
  • completed — Task done successfully. Read the final output, then kill to free the pane.
  • failed — Something went wrong. Read the screen to see the error, then kill and maybe respawn.

The render functions

renderCall(_args, theme) {
  return new Text(
    theme.fg("toolTitle", theme.bold("list_agents")) + " " + theme.fg("dim", `(${fleet.size})`),
    0, 0,
  );
},

renderResult(result, _opts, theme) {
  const txt = result.content[0];
  const text = txt?.type === "text" ? txt.text : "";
  if (fleet.size === 0) return new Text(theme.fg("dim", "No agents"), 0, 0);
  const lines = text.split("\n");
  const preview = lines.slice(0, 6).join("\n");
  const suffix = lines.length > 6 ? theme.fg("dim", `\n… ${lines.length - 6} more lines`) : "";
  return new Text(`${preview}${suffix}`, 0, 0);
},

The call render shows list_agents (3) — just the count. The result render previews the first 6 lines (two agents' worth of output) and truncates the rest. The LLM gets the full list for decision-making; the human sees enough to know the fleet's shape.

Session cleanup

All five fleet tools are registered inside the !IS_WORKER block. Workers never see these tools — they can't spawn, read, steer, kill, or list agents. But there's one piece of fleet infrastructure that runs for ALL sessions: the shutdown handler.

pi.on("session_shutdown", async () => {
  // Kill all spawned agents
  for (const [id, agent] of fleet) {
    cmuxSafe("send-key", "--surface", agent.surfaceRef, "C-c");
    setTimeout(() => {
      cmuxSafe("send", "--surface", agent.surfaceRef, "exit");
      cmuxSafe("send-key", "--surface", agent.surfaceRef, "Enter");
    }, 500);
  }
  fleet.clear();
  cmuxSafe("clear-status", "fleet");
});

When the orchestrator exits — whether you type /exit, press Ctrl-D, or pi crashes — every spawned agent gets the Ctrl-C + exit sequence. Then the fleet Map is cleared and the sidebar fleet count is removed.

This handler is outside the !IS_WORKER gate because pi's session_shutdown fires for every session, including workers. Workers have an empty fleet Map, so the loop does nothing. But the handler must exist on all sessions because the orchestrator's cleanup depends on it.

Why not close-surface? The shutdown handler skips the surface close. When the orchestrator exits, the cmux workspace is likely closing too — the surfaces will be cleaned up by cmux itself. Attempting to close surfaces during workspace teardown can race with cmux's own cleanup. The Ctrl-C + exit is enough to stop the workers from doing more LLM work and burning tokens.

The five-tool surface

Here's the complete fleet control surface, in the order you'd typically use it:

ToolWhat it doesWhen to use it
spawn_piCreate a workerYou have a task for a parallel agent
list_agentsShow all agentsCheck fleet status before acting
read_agentRead a worker's screenSee what a worker produced or is doing
send_agentSteer a workerWorker needs a follow-up or correction
kill_agentShut down a workerWorker is done, stuck, or wrong

Five tools. One Map. One surface ref per agent. That's the entire fleet management API. There's no pause_agent, no clone_agent, no migrate_agent. Each of those could exist, but they don't need to. You can pause by killing and respawning with the same prompt. You can clone by spawning with the same prompt twice. You can migrate by killing in one directory and spawning in another.

The simplicity is the point. Every additional tool is a surface the LLM has to understand and choose between. Five tools is a decision space the model handles well. Fifteen tools would make it hesitate, overthink, and pick wrong.

The next lesson covers a debugging story that shaped the most non-obvious part of spawn_pi — how the prompt gets from the orchestrator to the worker through the shell boundary without being mangled.