An agent running in a terminal is invisible from the outside. You can't tell if it's thinking, editing a file, waiting for input, or crashed — unless you switch to its tab and read the output. When you have five agents running across five workspaces, that's five tabs you'd have to check manually, constantly.
The pi-cmux extension solves this. It hooks into pi's lifecycle events and pushes status updates to the cmux sidebar, which is visible from every workspace simultaneously. One glance tells you which agents are running, which are waiting, and what each one is doing right now.
This lesson walks through the entire extension — the entry point guards, each lifecycle hook in firing order, the sidebar status constants, and the wrapper functions that make cmux CLI calls safe inside an async event handler.
The entry point
Every pi extension exports a default function that receives the ExtensionAPI:
export default function cmuxExtension(pi: ExtensionAPI) {
if (process.env[CMUX_CHILD_ENV] === "1") return;
if (!hasCmux()) return;
// ... register hooks and tools
}
Two guards run before anything else.
process.env[CMUX_CHILD_ENV] === "1" checks whether this process is a helper subprocess spawned by the extension itself. If it is, the extension returns immediately — no hooks, no tools, nothing. This prevents recursive loading. The next lesson covers why this guard exists in detail.
hasCmux() checks whether the cmux CLI is in your PATH:
function hasCmux(): boolean {
try {
execSync("which cmux", { encoding: "utf-8", timeout: 2000 });
return true;
} catch {
return false;
}
}
If you're not running inside cmux, the extension silently disables itself. No errors, no warnings. The same pi config works in cmux and outside it.
The status constants
Three constants define the sidebar states an agent cycles through:
const STATUS_RUNNING = { value: "Running", icon: "bolt.fill", color: "#4C8DFF" };
const STATUS_IDLE = { value: "Idle", icon: "pause.circle.fill", color: "#8E8E93" };
const STATUS_NEEDS_INPUT = { value: "Needs input", icon: "bell.fill", color: "#4C8DFF" };
Each is an object with a value (the text), an icon (an SF Symbol name), and a color (hex). These get passed to the cmux sidebar via the set-status CLI command.
The lifecycle is a loop: Idle → Running → Needs input → Idle → Running → ... Every agent spends its entire life cycling through these three states, and the sidebar reflects the current state in real time.
The cmux and cmuxSafe wrappers
Every sidebar update goes through one of two wrapper functions:
function cmux(...args: string[]): string {
try {
return execFileSync("cmux", args, {
encoding: "utf-8",
timeout: 5000,
env: process.env,
}).trim();
} catch (e: any) {
const msg = e.stderr?.toString().trim() || e.message;
throw new Error(`cmux ${args[0]} failed: ${msg}`);
}
}
function cmuxSafe(...args: string[]): string | null {
try {
return cmux(...args);
} catch {
return null;
}
}
cmux() is the strict version — it calls execFileSync, waits up to 5 seconds, and throws on failure. Use this in tools where you want the error to propagate to the user.
cmuxSafe() wraps cmux() in a try/catch and returns null on failure. This is for lifecycle hooks. If a sidebar update fails — maybe cmux restarted, maybe the socket timed out — you don't want that to crash the agent's session. The agent should keep working.
This pattern shows up everywhere in the extension. Lifecycle hooks use cmuxSafe. Tool execute functions use cmux. The distinction matters: a failed sidebar update is cosmetic; a failed tool execution is something the agent needs to know about.
The sidebar status helper
Both wrappers feed into a helper that sets the sidebar entry:
const STATUS_KEY = "pi_agent";
function setStatus(status: { value: string; icon: string; color: string }): void {
cmuxSafe("set-status", STATUS_KEY, status.value, "--icon", status.icon, "--color", status.color);
clearBuiltinStatus();
}
function clearStatus(): void {
cmuxSafe("clear-status", STATUS_KEY);
cmuxSafe("clear-status", "claude_code");
}
STATUS_KEY is "pi_agent" — the sidebar entry that shows agent state. Every call to setStatus also clears a built-in claude_code key that cmux sets on its own, preventing duplicate status lines.
Lifecycle hooks, in firing order
Here's what happens during a single turn, from the moment the session starts to the moment it shuts down. Each hook fires at a specific point in pi's lifecycle.
session_start — clean slate
pi.on("session_start", async (_event, ctx) => {
_pendingSessionName = null;
_turnCount = 0;
const existingName = pi.getSessionName();
_hasNamedSession = Boolean(existingName);
cmuxSafe("clear-notifications");
cmuxSafe("clear-log");
setStatus(STATUS_IDLE);
if (existingName) {
cmuxSafe("set-status", SESSION_NAME_KEY, existingName,
"--icon", "text.bubble", "--color", "#8E8E93");
}
});
Fires once when pi starts. Resets all internal state — the pending session name, the turn counter. Clears any stale notifications and log entries from a previous session. Sets the sidebar to Idle. If the session already has a name (from a /continue resume), it displays that name in the sidebar.
This is your blank canvas. Everything starts Idle.
input — user types something
pi.on("input", async () => {
setStatus(STATUS_IDLE);
cmuxSafe("clear-notifications");
cmuxSafe("claude-hook", "prompt-submit");
});
Fires the instant the user submits a prompt. Immediately sets the status back to Idle (clearing any "Needs input" state from a previous turn) and clears any lingering notifications. The claude-hook call integrates with cmux's built-in prompt tracking.
This hook is instant feedback. The sidebar changes the moment you press Enter, before the agent even starts processing.
before_agent_start — first prompt triggers naming
pi.on("before_agent_start", async (event, ctx) => {
if (!_hasNamedSession && event.prompt) {
_hasNamedSession = true;
const existing = pi.getSessionName();
if (!existing && !IS_WORKER) {
generateSessionName(event.prompt, ctx.cwd);
}
}
});
Fires after the prompt is submitted but before the agent starts its LLM call. On the first prompt only, it kicks off an async haiku call to generate a 2-4 word session name. The IS_WORKER guard prevents workers from spawning naming subprocesses — only orchestrators name their sessions.
The naming call is fire-and-forget. It writes to _pendingSessionName when it finishes, and the next hook picks it up.
agent_start — the agent is thinking
pi.on("agent_start", async () => {
cmuxSafe("clear-notifications");
setStatus(STATUS_RUNNING);
startHeartbeat();
if (_pendingSessionName) {
pi.setSessionName(_pendingSessionName);
cmuxSafe("set-status", SESSION_NAME_KEY, _pendingSessionName,
"--icon", "text.bubble", "--color", "#8E8E93");
_pendingSessionName = null;
}
});
Fires when the LLM call begins. Sets the sidebar to Running (blue bolt icon), clears notifications, and starts the heartbeat timer. If the async naming call from before_agent_start has finished, it applies the session name now.
The heartbeat is an interval that updates the sidebar every 3 seconds with the current activity and elapsed time:
const HEARTBEAT_INTERVAL_MS = 3000;
function startHeartbeat(): void {
stopHeartbeat();
_agentStartedAt = Date.now();
_lastToolDesc = null;
_heartbeatTimer = setInterval(() => {
if (!_agentStartedAt) return;
const elapsed = formatElapsed(Date.now() - _agentStartedAt);
const label = _lastToolDesc || "Thinking";
cmuxSafe("set-status", STATUS_KEY,
`${label} · ${elapsed}`,
"--icon", "bolt.fill", "--color", "#4C8DFF");
}, HEARTBEAT_INTERVAL_MS);
}
Between tool calls, the sidebar shows Thinking · 12s. During tool calls, it shows the tool name — Reading ~/.config/pi.json · 15s. The elapsed time ticks up every 3 seconds.
tool_execution_start — live tool activity
pi.on("tool_execution_start", async (event) => {
const desc = describeToolUse(event.toolName, event.args);
_lastToolDesc = desc;
const elapsed = _agentStartedAt
? formatElapsed(Date.now() - _agentStartedAt) : "";
cmuxSafe("set-status", STATUS_KEY,
elapsed ? `${desc} · ${elapsed}` : desc,
"--icon", "bolt.fill", "--color", "#4C8DFF");
});
Fires every time the agent calls a tool. Updates _lastToolDesc so the heartbeat timer picks up the new description on its next tick. Also immediately pushes the update to the sidebar — you don't wait for the next heartbeat interval.
The describeToolUse function turns tool calls into human-readable descriptions:
function describeToolUse(toolName: string, args: any): string {
switch (toolName) {
case "read":
return `Reading ${shortenPath(args.path || "")}`;
case "edit":
return `Editing ${shortenPath(args.path || "")}`;
case "write":
return `Writing ${shortenPath(args.path || "")}`;
case "bash": {
const cmd = args.command || "";
const first = cmd.split(/\s/)[0] || cmd;
return `Running ${first.slice(0, 30)}`;
}
default:
return `Using ${toolName}`;
}
}
This is the feature that makes the sidebar useful for monitoring a fleet. You're in workspace 1, and the sidebar for workspace 3 shows Editing …/src/auth.ts · 45s. You know exactly what that agent is doing without switching to it.
agent_end — done, waiting for input
pi.on("agent_end", async (event, ctx) => {
stopHeartbeat();
_turnCount++;
setStatus(STATUS_NEEDS_INPUT);
// ... extract last assistant message for summary ...
if (!IS_WORKER) {
generateTurnSummary(lastAssistantText, ctx.cwd);
if (_turnCount % RENAME_INTERVAL_TURNS === 0 && lastAssistantText) {
reevaluateSessionName(lastAssistantText, pi.getSessionName(), ctx.cwd);
}
}
if (!isFocused()) {
const sessionName = pi.getSessionName();
notify("pi", sessionName
? `${sessionName} — waiting for input`
: "Waiting for input");
playPeonPing("stop");
}
});
Fires when the agent finishes its response. Stops the heartbeat. Increments the turn counter. Sets the sidebar to Needs Input (blue bell icon).
Then three optional things happen:
-
Turn summary — A fire-and-forget haiku call summarizes what the agent just did in 3-8 words. When it finishes, it overwrites the "Needs input" text with something like
Added cmux extension + tests— still with the bell icon. -
Session rename check — Every 8 turns, another haiku call checks whether the session name still fits. If the work has shifted from "auth refactor" to "deploy pipeline," it updates the name.
-
Notification — If you're not looking at this workspace (checked via
isFocused()), a native macOS notification fires. Ifpeon-pingis installed, it plays an audio cue too.
Workers skip the summary and rename (both spawn subprocesses). They keep the notification — you still want to know when a worker finishes.
session_shutdown — tear it all down
pi.on("session_shutdown", async () => {
stopHeartbeat();
_pendingSessionName = null;
_hasNamedSession = false;
_turnCount = 0;
clearStatus();
cmuxSafe("clear-status", SESSION_NAME_KEY);
cmuxSafe("clear-notifications");
cmuxSafe("clear-progress");
});
Fires when the pi session exits. Stops the heartbeat, resets all state, clears every sidebar entry, clears notifications and progress bars. The workspace goes back to a clean state with no stale status entries from a dead session.
The full cycle
Here's one complete turn, traced through the hooks:
| Event | Sidebar shows | What happens |
|---|---|---|
| Session starts | Idle (gray pause icon) | Clean slate, clear old state |
| You type a prompt | Idle | Clears notifications, triggers naming |
| Agent starts thinking | Running (blue bolt) | Heartbeat begins ticking |
Agent calls read | Reading …/cmux.ts · 3s | Tool description + elapsed |
Agent calls edit | Editing …/cmux.ts · 8s | Updated immediately |
| Agent finishes | Needs input (blue bell) | Notification if unfocused |
| Summary arrives | Added sidebar hooks (blue bell) | Async haiku overwrites text |
| You type again | Idle | Cycle restarts |
From any workspace, you see the current state of every other workspace's agent in the sidebar. That's the entire point of the bridge — taking invisible terminal processes and making their state continuously visible.
What this enables
The extension is infrastructure for everything that follows in this course. The fleet tools (spawn_pi, read_agent, send_agent, kill_agent, list_agents) depend on sidebar status to show fleet health. The orchestrator reads agent state by checking the sidebar. Completion detection polls terminal output, but the sidebar is what you — the human operator — actually watch.
The next lesson covers what happens when this bridge code goes wrong: the recursive spawn loop, and the three-layer defense that prevents it.