← Technical notes
Agent harnesses · Source study

Pi Agent Harness: Source Study Notes

A compact source-level guide to Pi: package boundaries, streaming, the agent loop, session JSONL, branching, compaction, extensions, tmux, and security.

Pi snapshot 063fb963c · Revised 28 July 2026

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:

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

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:

  1. message_start adds the first partial.
  2. message_update replaces it with the newest accumulated partial.
  3. message_end replaces 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.

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

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:

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

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:

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

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:

Boundary rules:

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:

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

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:

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

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