pi / Source reading
Pi: a small agent loop inside a configurable coding application
Pi is useful for learning because its core loop is separate from the application around it. A model proposes tool calls; ordinary program code validates and executes them; results become messages; the model runs again. The terminal interface, session history, compaction, and extensions sit around that loop. The important boundary is between deciding what to do, which the model does, and making it happen, which the harness does.
This chapter describes the local earendil-works/pi checkout at commit 482c1381a00d8d00bc81697e4a980c593db6803e, inspected on October 18, 2026. The package manifests identify version 1.3.0. All source links below are pinned to that commit. This is source analysis, not a performance benchmark or a claim about every installed Pi version. The inspected checkout includes built-in MCP integration and parallel tool execution.
The layers and their responsibilities
Scroll sideways to explore the diagram →
Simplified coding-agent reading map. The separate experimental pi-durable runtime is not part of this loop.
The coding application's main composition point is createAgentSession() in sdk.ts. It restores session messages, selects the model and thinking level, constructs Agent, supplies its streaming function, and wraps it in AgentSession. [P1]
Do not merge the two harnesses into one architecture diagram: the coding-agent SDK instantiates Agent, while pi-durable builds on pi-ai and Chord. The latter is explicitly experimental. Its capabilities do not automatically describe the coding CLI's persistence guarantees. [P1, P20]
Follow one request through the loop
Consider an illustrative request: “Fix the empty-input case in src/parser.ts, run the focused parser test, and explain the change.” The following is a possible model trajectory, not a recorded run and not a hardcoded workflow.
AgentSession.prompt() processes extension commands and input hooks, expands skills/templates, checks model/auth availability, and starts a run. Agent.prompt() rejects overlapping prompts; an already-running session requires an explicit steering or follow-up choice. [P2]
- The first provider response requests
read({path: "src/parser.ts"}). The loop resolves the tool, prepares and validates its arguments, runs the pre-execution hook, then calls its implementation. The returned file text becomes a toolResult associated with the original call ID. [P3, P4]
- Another provider request sees that result. The model proposes
edit({path: "src/parser.ts", edits: [{oldText: "...", newText: "..."}]}). The edit implementation reads the file, applies replacements against its original content, writes the result, and returns success text plus a diff for the UI. [P6]
- The model requests
bash({command: "node --test test/parser.test.ts"}) in this hypothetical repository. Bash streams progress and returns the exit status/output. A nonzero exit is an error tool result, so the next model response can investigate and try another edit. [P7]
- Once the model responds without tool calls, the loop checks queued work and continuation hooks. If none request more work, it emits
agent_end; the session may still perform retry or settlement processing before agent_settled. [P3, P12]
The loop does not know what a parser fix is, and a final answer does not itself prove that the tests passed. Correctness comes from the actual tool evidence and the model's interpretation of it.
append prompt and changed tool declarations
emit agent_start, turn_start, prompt message events
repeat:
before subsequent turns: refresh context/model/tools, possibly compact
append pending steering or follow-up messages
prepare request
transform application messages -> provider-compatible messages
stream assistant response and emit message events
if response is error or aborted: finish turn and end this core run
if output hit length limit: fail its tool calls without executing them
else: validate, authorize through hooks, execute, finalize tool batch
append tool-result messages
apply finishTurn decision and emit turn_end
if finishTurn says end: stop
if tool continuation or steering exists: repeat
if follow-up exists: repeat
if finishTurn requested continuation: repeat once with current context
otherwise stop
emit agent_end
This condenses runLoop() rather than reproducing every error branch. One core “turn” is one assistant response and its tools; a user request can require many such turns. Tool declarations and prompt changes are recorded in system messages. declareToolChanges() reconciles the executable tool set with the transcript before requests. [P3, P8]
A tool has a name, description, parameter schema, and executable function. Tool implementations can also provide display details, structured output for programmatic callers, progress updates, argument preparation, and execution-mode constraints. The loop's provider boundary converts application messages only when it requests another response. UI-only or custom information need not be sent to the model. [P4, P9]
prepareToolCall() performs lookup, optional argument repair, schema validation, then beforeToolCall. Unknown names, invalid arguments, blocked calls, and thrown tool errors become error results the model can observe. Validation can coerce values; it is not merely checking that the original JSON already has the correct types. Truncated assistant output is handled more conservatively: all its tool calls fail without execution because even parseable arguments may be incomplete. [P4, P10]
Parallel execution is the default in this checkout. The runtime first prepares calls sequentially, then starts permitted executions concurrently. Completion events can arrive in completion order, but final transcript results are appended in the assistant's original call order. A globally sequential setting, or any tool marked executionMode: "sequential" in that batch, serializes the whole batch. [P5]
For example, two independent reads can overlap while the conversation remains deterministic about their order. Two edits to the same file require another safeguard: withFileMutationQueue() serializes mutations by resolved file identity, while different files can proceed independently. This is a process-local coordination mechanism, not a lock against unrelated editors or processes. A bash command touching the same file does not automatically participate in the edit queue. [P6]
The default coding tool selection is read, bash, edit, and write. Other built-in tools and extension tools exist, and the SDK supports allowlists and denylists. Read output is bounded and supplies continuation offsets; shell output is bounded and points to a temporary file when truncated. This is context management at the tool boundary, before conversation summarization is necessary. [P1, P7]
Steering, follow-up, and cancellation are different operations
steer() means “include this before the next model response after the current assistant turn's tools finish.” It does not cancel the running request or skip the rest of its tool batch. followUp() means “wait until the agent would otherwise stop.” Both queues default to one message at a time and support draining all queued messages instead. [P11]
Suppose the model already requested two edits when the user types “Only change the parser.” Steering will influence the next model request; those already-issued tools still complete. If the user needs execution to stop, cancellation is the relevant operation. Agent.abort() signals the active AbortController; tool implementations must cooperate. Bash kills its process tree on cancellation or timeout. File edits check cancellation after awaited operations and retain their mutation lock until those operations settle. Cancellation does not undo a write that has already happened. [P6, P7, P11]
AgentSession distinguishes a core run ending from the broader activity settling. Its post-run path can retry, compact and continue, or process extension-generated work. This matters for clients deciding when to enable another input or declare a task finished. [P12]
Approval is policy; sandboxing is containment
The inspected default coding tool path executes local filesystem and shell operations. An always-on per-call approval policy or OS sandbox was not found in that path. The core supplies blocking hooks, and the repository includes a permission-gate example that asks about selected shell patterns. That is a policy decision before execution, not a guarantee about what an approved process can access. [P4, P7, P13]
A separate sandbox example replaces bash operations with @anthropic-ai/sandbox-runtime, applying filesystem/network restrictions to shell execution. Its scope is the overridden bash path; it should not be presented as confinement of every local file tool. Project trust is another boundary: it governs loading project configuration/resources, rather than making individual tools safe. [P13, P14]
Code mode introduces a third boundary. pi-codemode runs JavaScript inside a QuickJS/WebAssembly VM with injected tool functions. The script lacks direct host facilities such as process, modules, and fetch; an injected bash tool still has whatever authority its host implementation gives it. Restricted orchestration code and restricted operating-system commands solve different problems. [P15]
Context and persistence: preserve history, project a smaller request
Long sessions must fit inside a model's context window. Pi estimates current usage from provider token accounting plus newer messages, with message-size estimates when necessary. Automatic compaction triggers when usage exceeds the context window minus reserved tokens; the baseline defaults reserve 16,384 tokens and aim to keep roughly 20,000 recent tokens. Settings and model-specific choices can alter effective behavior. [P16]
Compaction selects a retained tail without cutting at a tool result, summarizes older material, and can separately summarize the prefix of a turn that crosses the cut. It carries file-operation information and rejects summaries that hit the output length limit. The session appends a compaction entry containing the summary, the first retained entry ID, and the current system/tool state. The raw historical entries remain available. [P16, P17]
SessionManager stores an append-only JSONL tree: every entry has an ID and parent ID, and a current leaf chooses the active path. buildSessionProjection() turns that path into the effective messages, applying compaction and later context edits. A context edit can omit an earlier failed response from future model input without erasing the raw failure record. This separates “what happened” from “what the next request should see.” [P17]
Resuming opens a session and reconstructs its context. Branching moves the leaf so new entries grow from an earlier point; creating a branched session copies a selected path into another session file. Optional tree-navigation summaries preserve useful information from the abandoned path. These are conversation operations; the inspected session code does not restore filesystem contents to their historical state. [P18]
The coding session persists completed messages at message_end, after extension/public event dispatch. It is therefore inappropriate to describe its UI stream as a transactional crash-recovery log. The first user/assistant message causes initial file creation, and subsequent records append synchronously. [P12, P18]
Providers, retries, and extensions
The agent accepts a StreamFn; the provider must produce a common event stream and encode request failures as final assistant messages with error or aborted stop reasons. pi-ai's Provider interface owns authentication, model enumeration, and streaming; Models.streamSimple() normalizes the transcript, resolves authentication, and dispatches to the provider. This keeps provider-specific request formation outside the loop. [P9, P19]
Transient provider failures receive bounded exponential-backoff retries at the coding-session layer. The failed attempt stays in raw history but is omitted from subsequent model context. Context overflow takes a separate compact-and-retry path, with a guard against repeated recovery; it is not treated as ordinary throttling. Tool failures generally return to the model rather than automatically rerunning potentially side-effecting operations. [P10, P12]
Extensions can register tools and commands, intercept input/tool calls/results, transform context, participate in turn/settlement boundaries, and supply compaction. Built-in extensions include MCP, tool search, code mode, and llama.cpp integration. MCP uses a standalone pi-mcp client with stdio and Streamable HTTP transports, tool discovery, resources, and authentication handling. MCP tool definitions adapt server schemas and results into the same tool pipeline. [P14, P21]
Subagents require precise labeling. Coding-agent includes an example extension, installed separately, which launches child pi --mode json -p --no-session processes. It supports single, parallel, and chained work, with its own concurrency limits. This is not a default subagent tool in the four-tool coding set. Separately, pi-durable provides owned conversations and tasks, with foreground/background subagent examples. [P22, P23]
MCP through code mode
Code mode lets the model write a short program that searches for tools, calls several, and sends only useful results back to the conversation. For example, a script can discover an MCP search tool, fetch three documents concurrently, then emit just their titles and relevant excerpts. The tool description budgets inline declarations; deferred tools remain discoverable through searchTools, describeTool, describeNamespace, and ALL_TOOLS. This reduces repeated model round trips and oversized tool-result messages, while making the script responsible for choosing what evidence to retain. [P25]
The coding-agent extension is registered but inactive until selected; MCP configuration can activate it when required. Each nested tool call goes through ctx.executeTool, preserving argument validation and tool_call/tool_result hooks. Code mode is therefore another caller of the tool pipeline, not a permission bypass. Tools with output schemas return structured content to scripts. MCP error results can resolve as structured objects, so scripts must inspect isError; other failures reject. Successful script store writes become branch-local session entries. The worker has a 256 MiB VM memory limit; cancellation and optional timeouts stop the script, without undoing completed external effects. [P25]
MCP authentication and transport stay on the host side. Connections negotiate capabilities and discover tools; tool-list changes refresh registrations. Resource read/list operations opt into one retry after a transient HTTP failure. callTool does not opt into that retry, regardless of a tool's read-only annotations, because the operation may already have run. An expired-session response has a separate reconnect-and-retry path because the server did not execute the request. These transport decisions are distinct from approval hooks and server-side authorization. [P21, P27]
The newer durable harness changes the recovery problem
Saving a conversation is insufficient when a process crashes after a tool changes an external system but before its result is saved. Replaying that call could duplicate the change.
pi-durable records tool intent before execution. Recovery reruns a call only when both the stored and current tool replay policies say safe; otherwise it settles an interrupted result explaining that the operation may have partially run. Durable subagent examples use ownership plus stable request IDs to reconnect to an existing child/submission instead of creating duplicates. This is explicit recovery design, not a blanket exactly-once guarantee for arbitrary external effects. [P20, P23]
Its documented storage options include memory, JSONL, SQLite, and Cloudflare Durable Object storage. Chord supplies application/document state infrastructure, while pi-env supplies remote execution environments. These are valuable advanced reading paths, but a first harness can start with the smaller Agent loop. [P20]
Multiplayer means shared humans or clients, not subagents
The coding-agent RPC interface is a long-lived subprocess speaking JSONL over stdin/stdout. It correlates command responses and streams session events for custom clients, but the inspected SDK/RPC path does not establish a turnkey authenticated multi-user session service. Merely sharing a transcript file would not solve concurrent input ownership or conflicting filesystem edits. [P1, P26]
The adjacent libraries offer useful building blocks: Chord exposes objects across workers, browser tabs, or servers, with per-principal variants and observable snapshots/deltas. The experimental durable harness's ConversationViews publishes committed conversation state to multiple observers and exposes a replicated state view. Initial snapshots and observer attachment share the session's serialized read boundary, preventing a missed commit between them. These are replication primitives; applications must still define membership, input arbitration, and permission policy. They should be labeled library support for multiplayer, not advertised as a finished coding-agent multiplayer UI. [P26]
Tests, observation, and three design lessons
Core lifecycle events expose assistant deltas, tool progress, final results, duration, and turn boundaries. Session events add compaction, retry, queue, and settlement state; session statistics aggregate usage across stored history. The repository's faux provider makes ordering and failure cases testable without real model calls. For example, regression tests check that a later preflight abort prevents earlier prepared tools from starting, and that injected custom messages never split a tool-call/result pair. These tests were inspected, not executed for this research. [P12, P24]
- Keep the loop small, but define the boundaries rigorously. Provider streaming, application context projection, tool execution, and presentation are independently replaceable. The cost is more contracts: when callbacks run, whether failures throw, and which event means truly idle all matter.
- Concurrency needs transcript rules as well as locks. Completion-order progress gives responsive UI; source-order results preserve a predictable transcript. Same-file queues solve a narrower mutation problem. None of these mechanisms makes dependent tools safe to run concurrently by itself.
- Choose the persistence guarantee deliberately. An append-only conversation tree supports inspection, compaction and branching. Durable task intent and replay policies address interrupted execution. Adopting the latter adds real complexity and is justified when work must survive process loss.
Evidence matrix and reading path
Begin with P1 → P3 → P4/P5 → P12 → P17. Then explore provider adaptation, extensions, and the separate durable runtime. These claim IDs can be reused in the cross-harness comparison.
Scope limits: the core loop, coding-session orchestration, session projection, compaction and principal coding tools received the deepest inspection. Provider-specific wire formats, the full durable scheduler, all extensions, and OS sandbox enforcement were not audited exhaustively. Extra-package descriptions combine manifest/documentation evidence with the explicitly linked implementation/example paths. No dependencies were installed, real providers called, tests run, or source files modified. Negative findings apply only to the inspected default paths; extensions or host applications can change the behavior.