The fastest way to misunderstand pi-subagents is to imagine that “spawn an agent” means “start another program.” It does not. A child agent is an ordinary Pi AgentSession object constructed inside the same host process as its parent. There is no fork(), worker thread, or second Pi CLI in this path. pi-subagents contributes orchestration policy and optional nested tool definitions; Pi supplies the agent loop, model transport, built-in tools and tool runtime, session history, extension runtime, steering, compaction, and events.
This chapter follows one construction from AgentManager to session.prompt(). By the end, you should be able to locate the owner of every long-lived object and explain why a completed child cannot simply be discarded.
Source lock. This chapter describes pi-subagents 0.18.0 at
3f9d35cd078d18a141eb5a6d8f4fc5010d756280against Pi 0.84.2 at914cf1472e715297caa30db4b9535d534a9eb718. All source links are commit-pinned. “Child” means a child session in the orchestration graph, not an operating-system process or security boundary.
1. The object graph before the call graph
Start with ownership, because call order alone hides the lifecycle:
| Layer | What it owns | When that ownership ends |
|---|---|---|
AgentManager | The AgentRecord, queue state, abort controller, parent/child relation, live AgentSession, and worktree metadata | The live worktree is cleaned when the run settles; the record and attached session remain until record eviction, session-boundary cleanup, or manager shutdown |
runAgent() | The construction plan and subscriptions needed for one prompt run | Its session.prompt() settles; run-scoped listeners are removed |
Pi AgentSession | Pi Agent, tool registry, messages, extension runner, prompt lifecycle, steering, retry, compaction, and persistence hooks | session.dispose() |
Pi SessionManager | The append-only session tree, either in memory or in a JSONL file | With the session that uses it |
Pi DefaultResourceLoader | The child’s loaded extensions, skills, prompt resources, and system-prompt inputs | With the session that uses it |
The central asymmetry is intentional: runAgent() finishes, but the session does not. The manager retains it so a later resume can call prompt() on the same conversation. A worktree-isolated run is the important exception to “ready to continue”: settlement attempts best-effort worktree cleanup, and after successful removal the retained session still points its tools at that old working directory.
Recall — answer without looking up. Which object owns orchestration/concurrency queueing? Which object executes the agent loop? Which object must remain alive for an in-place resume?
Feedback
AgentManager owns orchestration/concurrency queueing. Pi’s AgentSession and underlying Agent execute the loop and own the steering/follow-up message queues. The same AgentSession stored on the manager’s record must remain alive for an in-place resume.
2. The whole path in one view
This is the fresh-spawn path after any queue wait, with policy resolution and side effects collapsed and error branches omitted:
AgentManager.spawn()
├─ create AgentRecord and AbortController
└─ startAgent(record, ...)
└─ runAgent(ctx, type, userPrompt, options)
├─ resolve cwd, config, environment, model, tools, system prompt
├─ new DefaultResourceLoader(...) → reload()
├─ SessionManager.open() | create() | inMemory()
├─ createAgentSession(sessionOpts) ← Pi SDK
├─ session.setSessionName(...)
├─ session.bindExtensions(...) ← session_start
├─ install observers and abort forwarding
├─ session.prompt(effectivePrompt) ← Pi agent loop
├─ unsubscribe run-scoped observers
└─ return { responseText, session, ... }
├─ settle/finalize AgentRecord, retaining an attached session
└─ later: session_shutdown → session.dispose()
The manager-to-runner handoff passes a snapshot of execution policy: selected model, turn limit, thinking level, context inheritance, persistence/reopen information, working and configuration directories, the manager-owned abort signal, callbacks, and nested ownership metadata. See the exact handoff in AgentManager.startAgent().
This is dependency injection, not a second runtime. The manager decides what child to build; runAgent() translates that decision into Pi SDK objects; Pi decides how an agent session behaves.
A before/after ownership trace
Immediately before runAgent() starts, the manager has a record and an abort controller, but no session. The record can already be visible as running, so a steering message may arrive during construction. The manager cannot deliver it yet; it stores the text in pendingSteers.
After createAgentSession(), extension binding, and any applicable live tool scoping succeed, runAgent() invokes onSessionCreated. The manager attaches the session to the record, records its optional session-file path, and flushes those pending messages through session.steer(). Construction has crossed an observable boundary: a Pi session is now attached, but its original prompt has not necessarily finished.
When session.prompt() resolves normally, run-scoped subscriptions are removed and runAgent() returns the same session. The manager maps max-turn and final-turn outcomes to a terminal state unless an external stop has already marked the record stopped. Regardless of that guard, it stores the response, flushes output, attempts worktree cleanup, aborts owned children, invokes completion handling, and settles queue accounting. If the prompt rejects, runAgent() rejects too; the manager’s catch branch settles the record whose session was attached earlier. Session ownership never returns to runAgent(); it remains with the record until automatic eviction, session-boundary cleanup, or manager shutdown. This sequence explains why “prompt completed,” “record settled,” and “session disposed” are three different moments. The callback and settlement branches are together in agent-manager.ts.
3. Assemble the child’s inputs
runAgent() first separates two roots:
effectiveCwdis where built-in tools operate.configCwdis where.piresources and settings are discovered.
They normally match. A caller-supplied working directory can change the first while preserving the parent project’s configuration in the second. This prevents an unrelated target directory’s .pi extensions from silently becoming the child’s configuration.
Next, pi-subagents builds the policy Pi will consume:
- Prompt. It reads the agent definition, environment facts, optional memory and preloaded skills, then produces a system-prompt override. Parent conversation inheritance is different: when
inherit_contextis enabled, a text projection of the parent branch is prepended later to the user prompt. - Resources. A fresh
DefaultResourceLoaderreceivesconfigCwd, extension and skill policy, disabled prompt templates/themes/context files, and the system-prompt override.reload()materializes the resource set before the session is created. Because pi-subagents supplies this loader tocreateAgentSession(), Pi will not reload it on the caller’s behalf; the explicitawait loader.reload()is a construction precondition. Pi owns the loader implementation; pi-subagents owns the filtering inputs. Follow this phase inrunAgent()and Pi’sDefaultResourceLoader. - Model. An explicit
RunOptions.modelwins; otherwise an exact model from the agent definition is resolved through the parent registry; otherwise the parent model is reused. Thinking level is resolved separately. - Tools. Without extensions, Pi receives a static
toolsallowlist. With extensions, pi-subagents supplies exclusions that are reapplied on registry refresh and later maintains the active set, because extensions may register tools after resource loading. Optional nested-agent tools are ordinary Pi custom tools.
Resource loading and session construction run inside an AsyncLocalStorage marker. If pi-subagents itself appears in the child’s extension set, its extension factory sees that marker and returns immediately instead of recursively creating another manager. That guard is visible in child-context.ts and the extension entry. This only makes sense because parent and child are in the same process.
Predict. If a child has already been created and is resumed by its live record ID, will this resource-and-tool assembly run again?
Feedback
No. An in-place resume reuses the stored session and calls session.prompt() again. It does not rebuild resources or recreate a removed worktree, so retained conversation state does not guarantee that the session’s original tool working directory still exists. Reopening an evicted persisted conversation is different: it constructs a new session around SessionManager.open(), using the current agent definition.
4. Choose history, then hand the snapshot to Pi
The session history branch is small but semantically important. Conceptually, it reduces to:
const sessionManager = resumeSessionFile
? SessionManager.open(resumeSessionFile, sessionDir)
: persistSession
? SessionManager.create(effectiveCwd, sessionDir, { parentSession })
: SessionManager.inMemory(effectiveCwd);
- In this runner,
open()normally loads the expected existingresumeSessionFile; Pi can also initialize an absent or empty explicit path. create()starts a persistable session with a new file target.inMemory()maintains the same session abstraction without a file.
These are Pi primitives, not pi-subagents storage formats. Pi’s implementations are pinned at SessionManager.create/open/inMemory.
Option-snapshot exercise
Suppose the manager calls runAgent() with this hypothetical snapshot:
ctx.cwd = /repo
ctx.parent branch = non-empty
options.cwd = /tmp/review-wt
options.configCwd = /repo
options.nested = true
options.resumeSessionFile = undefined
options.model = MODEL_M
options.inheritContext = true
agent.persistSession = undefined
agent.extensions = false
agent.tools = [read, grep]
agent.thinking = high
rememberAgents = true
Before revealing the answer, predict the loader root, session-manager branch, important createAgentSession() options, and final user prompt.
Feedback
- The tools work in
/tmp/review-wt; resources and settings load from/repo. - The nested default makes
persistSessionfalse, so the runner choosesSessionManager.inMemory("/tmp/review-wt").rememberAgentssupplies the default only for top-level children. - Pi receives
cwd: "/tmp/review-wt", the in-memory manager, the settings manager,model: MODEL_M,thinkingLevel: "high",tools: ["read", "grep"], no nested custom tools unless separately enabled, and the prepared resource loader. - Because
inheritContextis true, the prompt is the parent-branch text projection followed by the requested task. The system prompt is still the separately prepared resource-loader override.
The actual sessionOpts assembly and SDK call are in agent-runner.ts. Pi’s createAgentSession() restores existing messages, resolves defaults, constructs Pi’s Agent, installs model streaming and tool state, and returns a real AgentSession.
The fragile compatibility bridge
One field crosses a less stable seam. Current Pi’s SDK accepts modelRuntime, while ExtensionContext publicly exposes a ModelRegistry facade. pi-subagents casts that facade to read its TypeScript-private .runtime field, then passes both modelRegistry and modelRuntime in the option object. At Pi 0.84.2, the excess modelRegistry property has no effect; forwarding the extracted modelRuntime is the load-bearing step. The pinned Pi facade does contain private readonly runtime, but it is not part of the public interface; a rename or conversion to JavaScript #private would silently stop the bridge from forwarding the parent runtime. Compare the bridge with Pi’s ModelRegistry. This is compatibility engineering, not a stable ownership transfer.
5. Bind, observe, prompt
Construction loads extension factories and builds the ExtensionRunner and tool registry, but it does not emit session_start. The runner names the session, then awaits session.bindExtensions(). Pi applies bindings, emits session_start, and lets extensions contribute additional resources. For extension-enabled children, only after binding does pi-subagents install live extension-tool scoping; extension-free children already use Pi’s construction-time static allowlist. It then notifies the manager that the session exists.
The runner then subscribes to Pi events for turn limits, text streaming, tool activity, usage, and compaction. A second subscriber captures the last assistant text, while an abort bridge maps the manager’s AbortSignal to session.abort(). Pi’s session.subscribe() returns a per-listener unsubscribe function; event dispatch calls those listeners synchronously, so they are observers rather than an async backpressure mechanism. Finally:
try {
await session.prompt(effectivePrompt);
} finally {
unsubscribeRunEvents();
unsubscribeTextCollector();
detachAbortBridge();
}
Pi’s prompt() handles commands, input interception, and template expansion; validates the model and authentication; builds the user message; runs before_agent_start; then awaits Pi’s full agent run, including retry and continuation handling. pi-subagents observes that loop; it does not reimplement it. The complete bind-to-prompt sequence is in agent-runner.ts.
6. The lifecycle tail is part of correctness
After the prompt settles, runAgent() returns both output and session. The manager settles status and usage but retains the session for inspection and resume. Steering is an in-flight operation: the manager accepts it only while a record is running or queued. Long-lived session listeners, including extension-tool scoping, remain valid until the session itself is removed.
The shutdown order must be:
emit session_shutdown → allow handlers a bounded wait → session.dispose()
Why manual work? pi-subagents creates a bare AgentSession, not Pi’s higher-level AgentSessionRuntime. In pinned Pi, AgentSession.dispose() aborts retry, compaction, bash, and agent work; invalidates the extension runner; disconnects its internal agent subscription; clears session event listeners; and runs registered session-resource cleanup callbacks. It does not emit session_shutdown. Pi’s AgentSessionRuntime.dispose() emits that event first.
pi-subagents must reproduce that missing half. Its shutdownChildSession() emits session_shutdown, races handlers against a three-second ceiling, and disposes even after error or timeout. Eviction detaches the record and starts this cleanup; root shutdown snapshots the currently attached sessions and awaits their child-session lifecycle shutdowns concurrently before exiting. The intended ordering and timeout behavior are encoded in child-session-shutdown.test.ts.
Boundary reminder. “Separate session” means separate conversation state, tools, prompt, and extension lifecycle. It does not imply separate memory protection, operating-system identity, credentials, filesystem permissions, or crash containment.
7. What to carry forward
The design is powerful because the extension does not build another agent framework. It creates a policy snapshot, selects Pi’s resource and session primitives, constructs an ordinary Pi session, and owns the orchestration lifecycle around it. The hard parts are therefore not the model loop; they are ownership, dynamic tool scoping, compatibility seams, and correct shutdown.
When reading later features—background execution, nested delegation, worktrees, or resume—ask one question first: does this feature change the Pi session, or only the orchestration around it? That distinction keeps the architecture legible.
Next
With the construction boundary established, the next step is to follow a live session through orchestration: running versus queued state, steering while work is in flight, settlement, result delivery, resume, and eventual cleanup. Keep the ownership table nearby; those features change who may act on the session and when, not the Pi agent loop inside it.