pi-subagents does not implement an agent loop. It creates a child Pi session, observes that session, and uses Pi’s public control points. That distinction is the chapter’s central idea:
The extension owns the policy around a run; Pi owns the run.
Think of an airport control tower. The tower observes position reports, gives course corrections, and can close a runway. It does not climb into the cockpit and fly the aircraft. Likewise, pi-subagents observes Pi events, calls session.steer() or session.abort(), and classifies the outcome after session.prompt() settles. The provider stream, tool loop, steering queue, transcript, and active abort controller remain inside Pi.
This chapter is calibrated to pi-subagents 0.18.0 at commit 3f9d35cd078d18a141eb5a6d8f4fc5010d756280 and Pi 0.84.2 at commit 914cf1472e715297caa30db4b9535d534a9eb718.
One event stream, several projections
After creating and binding a child AgentSession, runAgent installs a synchronous control observer. That observer projects Pi’s event stream into several smaller pieces of orchestration state:
| Pi child event | pi-subagents projection | Consumer |
|---|---|---|
message_update with text_delta | append to current response text | foreground progress and activity display |
tool_execution_start / tool_execution_end | active-tool set; increment completed tool uses | widget, fleet view, result statistics |
assistant message_end | normalize and accumulate message usage | lifetime tokens and cost |
turn_end | increment cumulative turn count | progress display and turn-limit policy |
successful compaction_end | increment compaction count | lifecycle event and UI |
The implementation is compact because Pi already emits the right facts. See the child subscription in runAgent and the UI-side projection in createActivityTracker.
Usage deserves one extra distinction. Every assistant message_end contributes a delta to AgentRecord.lifetimeUsage. That accumulator survives compaction, unlike a total recomputed from the current session messages. Its display total is input + output + cacheWrite; billed reporting also retains cacheRead, because Pi bills the cached prefix when it is reread. The policy lives in usage.ts, not in Pi’s loop.
Pi supplies the rock underneath this projection. Its Agent owns the transcript, the queues, and the low-level loop; it awaits core listeners in subscription order. runPromptMessages() delegates to runAgentLoop, while processEvents() updates Agent state and then notifies listeners. pi-subagents reads that protocol; it does not reproduce it.
The control-plane timeline
The complete fresh-run sequence makes the ownership boundary visible:
AgentManager
-> runAgent()
-> createAgentSession(...)
-> bindExtensions() session_start
-> session.subscribe(control observer + response collector)
-> session.prompt(task)
-> Pi Agent -> Pi agent loop model + tools + queues
<- Pi session events progress + accounting
<- prompt settles
<- response, flags, final-turn failure
-> classify AgentRecord
session.prompt() is more than one provider request. Pi’s session asks its Agent to run, then handles retry, compaction, and queued-continuation work before declaring the session-level operation settled (Pi’s _runAgentPrompt). The extension therefore awaits one explicit session-level settlement boundary instead of trying to guess completion from an agent_end event.
There are also two subscription semantics. Pi’s core Agent.subscribe() listeners may return promises and are awaited. AgentSession.subscribe() listeners are called synchronously by _emit(); their returned promises are not awaited (session emission). The pi-subagents observer intentionally performs its event bookkeeping synchronously. If future work needs asynchronous persistence at a turn boundary, it must create and await its own queue rather than assume the session will wait for an async listener.
This timeline also explains why orchestration state is kept outside Pi. AgentRecord contains policy-facing facts—status, usage, tool count, session reference, and pending early steers. Pi keeps execution-facing facts—messages, active tools, steering queues, and the active run. Neither side needs a shadow copy of the other’s state machine.
Steering has two races, not one API call
A course correction can arrive in either of two phases.
Before the child session exists. AgentManager.spawn() creates the record synchronously, but createAgentSession() completes later. A steer in that gap cannot call Pi yet. The manager appends the message to record.pendingSteers. When onSessionCreated fires, it stores the session, delivers every pending message in order with session.steer(), then clears the queue. The manager implementation covers both enqueueing and the creation-time flush. The integration test proves two early messages are not overwritten and arrive in order (test).
After the session exists. The extension calls Pi’s session.steer(text). Pi expands the text, records it in its visible steering queue, and hands a user message to the underlying Agent (Pi’s queue path). The low-level loop drains steering after the current assistant turn and its tool batch, then injects it before the next model call (loop ordering, queue poll). Steering therefore does not preempt a running tool.
The early queue is adapter-owned because Pi cannot accept a message before Pi exists. The live queue is Pi-owned because Pi alone knows the safe boundary between turns.
A turn limit is a two-stage policy
At each turn_end, runAgent increments turnCount. Suppose maxTurns = 5 and graceTurns = 2:
- Turns 1–4 only update progress.
- At turn 5, the runner latches
softLimitReachedand callssession.steer("…Wrap up immediately…")exactly once. - Pi gives the agent a chance to produce a final answer.
- If turn 7 ends, the runner sets
aborted = trueand callssession.abort().
This is graceful degradation followed by a hard safety boundary, implemented in the turn_end branch. Tests cover below-limit, one-shot steer, the grace window, hard abort, unlimited 0, configuration precedence, and the callback count (tests).
For a fresh spawn, outcome classification preserves the cause. A clean run that finishes after the wrap-up steer becomes steered; one that exhausts grace becomes aborted; an interruption routed through AgentManager.abort() becomes stopped; otherwise it becomes completed. Resume uses the narrower classifier described below.
Abort the work, or only stop waiting?
These are deliberately different operations.
For a foreground spawn, the Agent tool receives Pi’s per-call AbortSignal and passes it to the manager. When it fires, the manager calls abort(id), which aborts the record’s controller and marks the record stopped. runAgent forwards that controller’s signal to the child session.abort() and removes the listener when the run settles (manager wiring, session forwarding). Pi’s active run owns the actual AbortController; Agent.abort() fires it (Pi).
By contrast, cancelling get_subagent_result(wait: true) cancels only the wait. The helper races the record promise against the caller’s signal without touching the child. The child remains running and unconsumed; its later resolution or rejection is safely absorbed by attached handlers (abortable, nested wait). This is why pressing Esc while collecting a background result does not secretly kill useful work.
Detached background spawns and background resumes similarly omit the parent tool-call signal: once control has been handed back, a later interruption of the parent’s turn must not kill the detached child.
Resume the session; judge only the new invocation
Resume reuses the existing Pi AgentSession and calls session.prompt(prompt) again. Pi rejects an unqualified concurrent prompt, so the manager refuses a second background resume while the record is running or queued. startResume() installs a fresh abort controller for a background resume. Foreground resume is separate: it runs inline, forwards the caller signal directly to Pi, and does not install a fresh record controller or assign a distinct stopped status (resume state machine).
The subtle part is result honesty. Pi converts a run failure into a final assistant message and can exhaust retries while session.prompt() still resolves. finalTurnError() therefore inspects the final assistant message created after this invocation began:
stopReason: "error"is a failure, even if partial text exists.stopReason: "length"with no text is a silent output-limit failure.lengthwith text is a usable truncated answer, not an error.- prior answers are outside the invocation boundary and cannot masquerade as the resumed result.
See finalTurnError() and its bounded scan and resume’s startLen boundary. The manager maps that failure to error while retaining this invocation’s partial text.
Close the lifecycle before disposing it
The child was opened with createAgentSession() and then bindExtensions(). That fires session_start, allowing extensions to arm timers, watchers, or sockets. But bare AgentSession.dispose() invalidates the extension runner; it does not emit session_shutdown (Pi disposal). Pi’s higher-level AgentSessionRuntime.dispose() emits shutdown first (Pi runtime).
pi-subagents mirrors that higher-level contract. shutdownChildSession() emits { type: "session_shutdown", reason: "quit" }, races all serial handlers against a finite 3-second timer, and always disposes afterward. Eviction starts this best-effort cleanup without blocking the record sweep; application shutdown awaits all retained child cleanups concurrently. The implementation is here and here. Tests prove ordering, waiting, timeout progress, and graceful degradation for partial sessions (tests).
Retrieval practice: the lifecycle classifier
Recall
Which action changes the child’s run state: cancelling a result wait, steering, or aborting the parent-bound foreground call?
Feedback
Cancelling the result wait changes no child state. Steering changes the child’s queued input but does not terminate it. Aborting the parent-bound foreground call propagates to the child and classifies it as stopped.
Predict
An agent has maxTurns = 3, graceTurns = 2. It receives the wrap-up steer at turn 3, makes another tool call, and finishes cleanly at turn 4. What is its terminal status? What if turn 5 ends instead?
Feedback
Finishing at turn 4 is steered: the soft limit was reached, but the agent finished within grace. Reaching the end of turn 5 triggers session.abort() and yields aborted.
Inspect
Read the Pi loop’s lines 181–260 linked above. Identify the exact safe point where a live steer becomes conversation input. Why should the extension not splice directly into session.messages?
Feedback
Pi polls steering after turn_end, after the assistant turn’s tools have settled, and injects the queued user message before the next model call. Direct transcript mutation would bypass queue ordering, lifecycle events, persistence, and Pi’s active-run invariants.
Practice
Write the fresh-spawn terminal-state classifier using these facts: externallyStopped, hardLimitAborted, finalTurnFailure, and softLimitReached. Then classify “result wait cancelled.”
Feedback
For a fresh spawn, the precedence is: external stop → stopped; hard limit → aborted; failed final turn → error; soft limit → steered; otherwise → completed. “Result wait cancelled” is not a terminal child fact, so retain the child’s existing queued or running state.
Limits and proof gaps
- At these commits, fresh
runAgentcalls enforce and report turn limits.resumeAgentdoes not install anonTurnEndcounter or max-turn state machine. A background resume creates a UI tracker withmaxTurns, but that value is not runtime enforcement. Treat max turns on resumed invocations as unverified/unsupported here. - Foreground resume forwards its caller signal directly to Pi. Pi can resolve an aborted run with
stopReason: "aborted", while the resume classifier only treatserrorand emptylengthas failures; an interrupted foreground resume can therefore be recorded ascompleted. A distinctstoppedoutcome is not established for this path. - Early pending steers are flushed with rejected deliveries swallowed so a race cannot fail the run. Tests prove ordering and clearing, not that a provider eventually acts on every steer.
finalTurnError()classifies transport/output termination shapes, not semantic quality. A confidently wrong answer withstopReason: "stop"is still mechanicallycompleted.- The 3-second shutdown bound proves teardown proceeds. It intentionally cannot prove that a hung extension completed its own cleanup before invalidation.
Next: Chapter 6 asks what that controlled session is actually allowed to see and execute after agent files, call parameters, extensions, and live tool registration are resolved.
Source index
pi-subagents:agent-runner.ts,agent-manager.ts,abortable.ts,nested-tools.ts, andindex.ts.- Pi:
Agent,agent-loop,AgentSession, andAgentSessionRuntime. - Focused proofs: turn limits and parent abort forwarding, wait cancellation without child cancellation, and final-turn failure partitions.