Pi is a small coding-agent harness built from four packages. The model proposes actions. Pi owns the loop around the model: context, validation, execution, events, persistence, and intervention.
These notes cover mechanisms that should survive routine refactors. Source
references were checked against
earendil-works/pi at 063fb963c
on 2026-07-28.
1. Orientation
Package boundaries
pi-coding-agent
├── pi-agent-core
│ └── pi-ai
└── pi-tui
| Package | Owns |
|---|---|
pi-ai |
Common model types, provider adapters, normalized streaming |
pi-agent-core |
Generic loop, abstract tools, events, steering, cancellation |
pi-coding-agent |
Coding product, sessions, compaction, extensions, prompts |
pi-tui |
Terminal input, layout, components, differential rendering |
Dependencies point down toward the generic loop and model layer. Runtime events flow up into the coding product and TUI.
flowchart LR M[Model] -->|assistant stream| A[pi-ai adapter] A -->|normalized events| L[pi-agent-core loop] L -->|tool proposal| P[pi-coding-agent policy and tools] P -->|result| L L -->|lifecycle events| U[session, extensions, TUI]
A complete tool trace
| Record | What it proves |
|---|---|
| User message | The harness received an instruction |
| Assistant tool call | The model proposed a named action and arguments |
| Tool result | The harness observed an outcome, paired by call id |
| Final assistant message | The model continued from that outcome |
A missing tool result proves only that no outcome was recorded. It does not prove a specific failure such as a failed file read.
A deterministic fixture replaces the live provider and environment with fixed responses. It lets a test target harness invariants, for example:
- every completed call gets one paired result;
- the call message appears before its result;
- a terminal assistant message follows the tool round.
2. Model I/O and streaming
Provider-neutral core
Pi keeps common messages inside the harness. The adapter selected by
model.api converts them at the provider boundary.
| Same logical call | Representation |
|---|---|
| Pi | Structured arguments plus stable call id |
| Anthropic | tool_use block with object input |
| OpenAI Chat Completions | tool_calls with JSON-string arguments |
The call id is the stable pairing fact. Provider field names can change without changing the stored conversation.
Streaming has two outputs
provider chunks
↓ adapter reconstructs provider state
normalized progress events completed AssistantMessage
for rendering canonical result
delta: the newest fragment.partial: the complete response accumulated so far.- terminal
doneorerror: carries the canonical assistant message.
The adapter reconstructs text, thinking, tool arguments, usage, stop reason, provider metadata, and error state. Joining text deltas in a consumer cannot reliably recover all of that information.
During a stream, Pi keeps one in-flight assistant slot:
message_startadds the first partial.message_updatereplaces it with the newest accumulated partial.message_endreplaces it with the canonical final message.
Replacement prevents one response from becoming many transcript messages. The
public agent state exposes the current response separately as
streamingMessage, then appends it to public messages at message_end.
How EventStream wakes a consumer
EventStream<Event, Result> holds three things:
| State | Purpose |
|---|---|
queue |
Events produced before a consumer asks |
waiting |
Promise resolver functions for suspended consumers |
finalResultPromise |
One Promise for the canonical result |
When the queue is empty, next() creates a Promise and stores its resolver:
return new Promise<IteratorResult<T>>((resolve) => {
waiting.push(resolve);
});
A later push(event) calls that stored function:
const waiter = waiting.shift();
waiter?.({ value: event, done: false });
Calling resolve(...) settles the old Promise. The suspended await resumes
with { value, done }. There is no polling.
end()marks iteration closed and wakes consumers withdone: true.result()returns the separate final-result Promise.[Symbol.asyncIterator]()supplies the protocol used byfor await.
3. The agent loop
Turn and tool continuation
request assistant
↓
append completed AssistantMessage
↓
process its tool-call batch
↓
append one ToolResultMessage per call
↓
request assistant again if continuation is allowed
The assistant call message must precede the paired results in the next model request. Ordinary tool failures become error results so the model can repair them. Provider-stream errors and aborts end the run.
Length-limit guardrail
If the assistant response stops because of the length limit, Pi fails every tool call in that response without executing any. Truncated arguments can look schema-valid even though the model never finished expressing its intent.
Tool calls cross a trust boundary
lookup
→ prepare arguments
→ clone, coerce, validate
→ before-tool policy hook
→ AbortSignal check
→ execute
→ after-tool hook
→ paired ToolResultMessage
- Unknown tool, invalid arguments, policy block, cancellation, or exception: paired error result.
- Validation happens before the trusted policy hook.
- The hook receives a mutable object; Pi does not validate it again after trusted code mutates it.
onUpdate()emits progress only. The returned value becomes the canonical result.- Late updates after settlement are ignored.
tool_execution_start means Pi started processing a proposal. It does not
prove execute() ran. Invalid arguments can produce:
tool_execution_start
validation failure
tool_execution_end { isError: true }
message_start + message_end for paired result
Batch scheduling
One decision applies to the whole batch:
- global sequential mode, or any selected sequential tool: whole batch runs sequentially;
- otherwise calls may execute in parallel.
In parallel mode, event order and transcript order can differ:
completion events: call-2, call-1
stored results: call-1, call-2
Pi stores results in original call order so the canonical transcript remains deterministic.
terminate is a continuation hint
- A tool returns
AgentToolResult.terminate. - The after-tool hook may replace it.
- Pi aggregates only after every call finishes.
- Automatic tool-driven continuation stops when every finalized result has
terminate: true. - Queued steering can still cause another assistant request.
The tool and trusted hook own this boolean. The model chooses the registered tool and arguments, not the termination field.
hasMoreToolCalls is the loop's continuation flag. It starts true for the
first assistant request, resets for each completed response, and becomes
!executedToolBatch.terminate after a non-empty batch.
Steering, follow-up, next turn, abort
| Mechanism | First delivery point | Effect |
|---|---|---|
| Steering | After current response and tool batch | Changes the active run's next model request |
| Follow-up | When the inner loop would stop | Adds another request before agent_end |
| Next turn | A later external prompt() |
Waits without triggering a request |
| Abort | Immediately marks shared signal | Cooperatively asks active work to stop |
outer loop: follow-up re-entry
inner loop: assistant → tools → steering → assistant
Follow-ups are assigned back to the pending-message variable. On re-entry Pi emits their lifecycle events, appends them to context, and requests another assistant response. There is no separate enqueue operation at this boundary.
abort() marks an AbortSignal. Active work stops promptly only when it
observes or forwards that signal.
waitForIdle() awaits the active run Promise. It resolves after final listeners
settle and run-owned state is cleared.
Lifecycle events and subscribers
| Event | Boundary |
|---|---|
message_start |
One logical message begins |
message_update |
Current assistant or tool work has progress |
message_end |
Message reached canonical final form |
tool_execution_start |
Pi began processing a proposal |
tool_execution_update |
Tool emitted non-canonical progress |
tool_execution_end |
Tool pipeline produced its final outcome |
turn_end |
One assistant response and its tool work finished |
agent_end |
This run will emit no more loop events |
subscribe(callback) adds the callback to an in-memory set and returns an
unsubscribe function. Emission directly invokes callbacks in registration
order. If a callback returns a Promise, Pi awaits it before advancing.
An agent_end listener can therefore delay waitForIdle(). The event is final;
listener settlement still belongs to the run lifecycle.
prepareNextTurn?.() is an optional call. If the hook exists, it can replace
context, model, or thinking level for the next iteration. If absent, the
expression evaluates to undefined.
4. Sessions, branching, and compaction
A session JSONL example
Each line is one JSON object. This shortened example keeps the real entry and message shapes while omitting optional provider metadata.
{"type":"session","version":3,"id":"session-1","timestamp":"2026-07-28T10:00:00Z","cwd":"/workspace"}
{"type":"message","id":"u1","parentId":null,"timestamp":"2026-07-28T10:00:01Z","message":{"role":"user","content":"Read config.ts","timestamp":1785232801000}}
{"type":"message","id":"a1","parentId":"u1","timestamp":"2026-07-28T10:00:02Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call-1","name":"read","arguments":{"path":"config.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet","usage":{"input":21,"output":10,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1785232802000}}
{"type":"message","id":"t1","parentId":"a1","timestamp":"2026-07-28T10:00:03Z","message":{"role":"toolResult","toolCallId":"call-1","toolName":"read","content":[{"type":"text","text":"export const port = 3000;"}],"isError":false,"timestamp":1785232803000}}
{"type":"message","id":"a2","parentId":"t1","timestamp":"2026-07-28T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"The configured port is 3000."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet","usage":{"input":44,"output":9,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785232804000}}
What to notice:
- The first line is the session header.
- Every later entry has
idandparentId. a1stores the complete assistant tool-call message.t1.message.toolCallIdpairs the result withcall-1.- The final assistant message is stored as a new entry, not reconstructed from text deltas.
One history, several projections
flowchart LR J[Append-only JSONL] --> B[Selected root-to-leaf branch] B --> C[SessionContext] C --> A[AgentMessage array] A --> M[Model-compatible messages] M --> P[Provider request]
| Representation | Question answered |
|---|---|
| Durable JSONL | What happened and what application state was recorded? |
| Active branch | Which causal path is selected? |
| Agent context | Which application messages belong to this run? |
| Model context | Which messages can the model consume? |
| Provider request | How does this API encode them? |
The durable session is the source of truth. The provider request is derived state.
| Stored entry | Agent context | Model context | Other effect |
|---|---|---|---|
| Ordinary message | Yes | Yes | Conversation history |
| Excluded command execution | Yes | No | Still renderable |
| Branch summary | Yes | Yes, translated | Carries abandoned-branch knowledge |
| Compaction | Yes, as summary | Yes, translated | Replaces old context prefix |
| Model or thinking change | No | No | Restores run configuration |
Extension custom |
No | No | Restores extension state |
Extension custom_message |
Yes | Yes, translated | Application-produced context |
Branching selects a path
A
└── B
├── C
│ └── D
└── E
└── F selected leaf
- JSONL preserves physical append order.
- Parent pointers reconstruct the logical tree.
- Selecting
Fyields active pathA-B-E-F. C-Dremains durable.- A branch summary attaches useful conclusions at the branch point without reactivating the abandoned raw messages.
Compaction changes context, not history
durable path: A → B → C → D → E → K
K summarizes: A → B → C
model context: K(summary), D, E
JSONL: every entry remains
A compaction entry stores:
summary;firstKeptEntryId, where retained raw context begins;- token count before compaction;
- optional usage and extension details.
Boundary rules:
- A retained suffix cannot begin with a tool result because its assistant call would be missing.
- If the boundary splits a long user task, Pi summarizes the earlier task text so retained assistant and tool messages keep their context.
- Repeated compaction includes the previous summary and newly aged-out messages. Earlier raw entries remain recoverable.
Automatic compaction is considered near:
model context window - reserved compaction tokens
keepRecentTokens targets recent raw context; it does not set summary length.
Pi prefers the latest valid provider usage as an accounting anchor and estimates
newer messages. Without valid usage it estimates the full context from
characters. A failed summary request appends no compaction entry.
Summaries are lossy. Append-only JSONL preserves the raw evidence needed to inspect, regenerate, or branch from before an omission.
5. Extensions and product boundaries
Extensions are trusted in-process code
An extension can register tools, handlers, commands, shortcuts, renderers, and providers. Project trust controls whether local code loads. Loaded extension code is not sandboxed.
The coding product maps extension handlers into generic loop hooks. This gives product policy access to the loop without reversing package dependencies.
Three extension data paths
| Path | Durable | Model-visible | Use |
|---|---|---|---|
Tool-result details |
Yes | No | State tied to one result |
custom entry |
Yes | No | Extension or UI state |
custom_message |
Yes | Yes, as user role | Application-produced context |
The extension tool's execute() constructs both result fields:
content: text or media for the model and user;details: structured extension or UI state.
A custom entry can restore a panel preference or indexing state during
replay. A custom_message follows this conversion:
JSONL: type "custom_message"
runtime: role "custom"
provider: role "user"
Its separate runtime type preserves provenance, rendering, details, and
delivery timing. Provider-visible provenance must be written into its content.
deliverAs selects steering, follow-up, or next-turn delivery.
Session JSONL is not a secret store.
What Pi leaves outside the core
| Need | Pi's route | Trade-off |
|---|---|---|
| Background process management | tmux | Shared observable terminal; manual lifecycle discipline |
| Permission UI | Extension | Custom policy; no standard UX |
| Isolation | Container, VM, micro-VM, or OS sandbox | Real security boundary; setup is external |
| MCP | CLI tools or extension | Smaller default surface; weaker built-in interoperability |
| Subagents | Separate Pi processes or extension | Flexible orchestration; no standard protocol |
| Plans and todos | Markdown or extension | Inspectable files; installations differ |
tmux for background work
tmux new-session -d -s project-dev 'npm run dev'
tmux capture-pane -p -t project-dev
tmux attach -t project-dev
- Attach: display and interact with an existing session.
- Detach: leave the display while its commands continue.
capture-pane: read the same terminal output a human sees.
tmux and the operating system own the process state. Pi needs no private process registry. Human and agent can inspect the same pane.
This requires access to the same tmux server. Container boundaries, session names, cleanup, ports, and shutdown remain operational concerns.
Security boundary
Pi runs with the permissions of its process. Real isolation comes from:
- restricted filesystem mounts;
- absent credentials;
- network policy;
- a low-privilege user;
- container, VM, micro-VM, or policy sandbox.
An extension hook can confirm or block a command. It still runs inside the Pi process and cannot replace OS-level filesystem, network, process, or credential isolation.
6. Reference
Design summary
- Ordinary nested async loops drive the runtime.
- Provider conversion happens at the model boundary.
- Progress events and canonical results use separate channels.
- Tool calls cross validation and policy before execution.
- Session history, active branch, model context, and provider request are separate projections.
- Branching and compaction preserve append-only evidence.
- Steering, follow-up, next-turn delivery, and abort have different timing.
- Trusted extensions carry optional product policy and workflow.
- tmux and OS isolation sit outside the core harness.
TypeScript encountered
ESM imports
A TypeScript source file may import "./events.js" while the source file is
events.ts. TypeScript resolves the source during compilation and emits
JavaScript whose runtime import points to events.js.
Optional calls
const messages = (await getSteeringMessages?.()) || [];
callback?.() calls the function when defined and otherwise evaluates to
undefined.
Discriminated unions and never
default: {
const unreachable: never = event;
return unreachable;
}
After every union variant is handled, event narrows to never. A new variant
without a matching case makes the assignment fail at compile time.
Standard protocol types
IteratorResult<T>, AsyncIterator<T>, Promise, and
Symbol.asyncIterator come from the standard JavaScript and TypeScript
libraries. T or Event is a local generic type variable.
Object keys need quotes only when identifier syntax cannot express the key.
type, value, and done work without quotes.
Exercises
1. Truncated batch with intervention
The model streams two valid-looking tool calls, then reaches the length limit. While it streams, the user queues one steering message and one follow-up.
Trace which tools execute, which paired results are created, and when each queued message first becomes model-visible. Label each decision as model-owned or harness-owned.
2. Branch plus compaction
Create a path containing a user message, assistant tool call, result, final assistant response, model change, and another turn. Branch before the model change, add a branch summary and user message, then compact while retaining only the new user message as raw context.
Reconstruct the durable tree, active path, next model context, restored model configuration, and every recoverable raw entry.
3. Trusted hook mutation
A schema requires { path: string }. Valid arguments pass validation. A
trusted hook changes path to a number. The tool emits progress, returns an
error, then emits one late update.
Trace validation, execution input, events, canonical result, and the behaviour owned by trusted code.
4. Extension state and delivery
Place three values: state tied to one tool result, a durable UI preference the model must never see, and a notice visible only with the next external prompt.
For each, give its JSONL entry, runtime projection, provider form, rendering, and delivery timing.
5. Provider-neutral replay
A session records a tool call using Anthropic. The user branches, changes to an OpenAI-compatible model, and resumes from the same common call and result.
Identify what remains provider-neutral, where the path is rebuilt, where wire conversion happens, and what breaks if provider-native blocks were stored as the canonical session representation.
Source map
packages/ai/src/utils/event-stream.ts: queued events, Promise waiters, async iteration, final result.packages/agent/src/agent-loop.ts: continuation, steering, follow-ups, truncation, events.packages/agent/src/types.ts: messages, tools, hooks, lifecycle contracts.packages/coding-agent/src/core/session-manager.ts: JSONL entries, branching, custom messages.packages/coding-agent/src/core/compaction/: boundaries, token estimates, summaries.packages/coding-agent/src/core/extensions/: loading, registration, events, hooks.packages/coding-agent/README.mdanddocs/security.md: omissions and security boundary.