Inside Pi

Part III · Agent core · Chapter 7

AgentSession and the run modes

9 min read · checked against pi@4954d76 on 2026-10-16

Agent knows how to run a loop; it knows nothing about files, settings, extensions or terminals. AgentSession (packages/coding-agent/src/core/agent-session.ts, about 4,400 lines) is the facade that turns it into a coding agent: it persists every message, expands commands and templates, queues input, retries transient failures, triggers compaction, switches models, runs extensions and exposes all of this through one API that every interface shares. Interactive mode, print mode, JSON mode, RPC mode and the SDK are thin I/O layers over the same session.

Learning goals. List the responsibilities AgentSession adds to Agent; trace session.prompt() from text to the first model request; explain how the CLI chooses a mode; describe the RPC protocol's framing and command/response/event model; explain why non-interactive modes "take over" stdout.

7.1 The layering

The file's header describes the class's purpose:

This class is shared between all run modes (interactive, print, rpc). It encapsulates: Agent state access; Event subscription with automatic session persistence; Model and thinking level management; Compaction (manual and auto); Bash execution; Session switching and branching. Modes use this class and add their own I/O layer on top.

From the inside out, the stack is:

LayerTypeOwns
LooprunLoop() in pi-agent-coreTurns, tool execution, events
AgentAgentTranscript in memory, queues, abort, listeners
SessionAgentSessionPersistence, settings, model runtime, tool registry, extensions, retries, compaction
RuntimeAgentSessionRuntimeReplacing the session: newSession(), switchSession(), fork(), importFromJsonl()
Modeinteractive / print / json / rpc / SDK callerInput and output

AgentSessionRuntime exists because some operations replace the session wholesale — switching to another session file may change the working directory and therefore the project's settings, extensions and trust. The SDK documentation warns: "After a runtime replacement, subscriptions belong to the old AgentSession and must be rebound." Each mode registers a rebindSession callback for exactly this reason.

7.2 What the session adds to the event stream

AgentSessionEvent is AgentEvent plus events the agent cannot know about: queue_update (the UI shows queued steering and follow-up texts), compaction_start/compaction_end, auto_retry_start/auto_retry_end, entry_appended, session_info_changed, thinking_level_changed, bash_execution_update, and agent_settled. It also extends agent_end with willRetry: boolean, so a print-mode caller does not exit when the run is about to be retried.

The session's handler for agent events, _handleAgentEvent, shows the ordering contract: emit to extensions first, then to public listeners, then persist. On every message_end it appends the message to the session file (sessionManager.appendMessage, or appendCustomMessageEntry for extension messages) and remembers which entry id holds which message object in a map (_entryIdsByMessage). On message_start of a user message it removes the text from the visible queue, so the UI updates its queue display before the message appears in the transcript.

7.3 Tracing session.prompt()

prompt(text, options) is a pipeline of interceptors. In order:

  1. Deferred while settling. If the session is in the middle of emitting agent_settled, the call is queued and replayed afterwards.
  2. Extension commands. Text starting with / is offered to registered extension commands, which "execute immediately, even during streaming" and handle their own model interaction.
  3. Compaction guard. Prompts are refused while compaction runs.
  4. Input handlers. Extensions may inspect, rewrite or swallow the input (_runInputHandlers) before any expansion.
  5. Expansion. /skill:name args becomes the skill's instructions; /template args becomes the expanded prompt template (Chapter 10).
  6. Queueing while busy. If the agent is streaming, the caller must say whether this is a steer or a follow-up; otherwise the call fails with "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."
  7. Preflight. A model must be selected and its provider must have credentials; the error message suggests /login for OAuth providers.
  8. Start the run. _startRun() builds the user message and calls into the agent.

The optional preflightResult callback reports how the prompt was dispatched — "handled", "queued" or "started" — which RPC mode uses to answer the client before the run finishes.

7.4 Retries and compaction around the run

The agent loop stops on an error response. AgentSession decides what happens next in _handlePostAgentRun():

  • If the error is retryable (Chapter 4's classifier) and auto-retry is enabled, _prepareRetry() increments the attempt, computes retryDelayMs(settings, attempt) (exponential, capped), emits auto_retry_start, and sleeps on an abortable timer. The failed response is kept in the raw history but marked so it is "durably omitted from model projection": the session file records what happened, while the model never sees the failed attempt.
  • Otherwise _checkCompaction() looks for context overflow or a compaction threshold. It skips messages from a different model ("the overflow error from the old model shouldn't trigger compaction for the new model") and messages older than the latest compaction ("This prevents a stale pre-compaction usage/error from retriggering compaction").
  • Finally, messages queued by agent_end handlers start a fresh run.

This is a supervisor pattern in miniature: the inner component fails fast and reports; an outer component, with more context, chooses retry, repair (compaction) or surrender.

7.5 Starting the CLI

main() in src/main.ts is a long, linear startup script. Its first decisions are about what kind of process this is:

  • Subcommands that are not agent runs are handled and the process exits: authentication commands, pi install/update (package management), configuration, pi mcp …, --version, --export.
  • The mode is resolved:
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
	if (parsed.mode === "rpc") {
		return "rpc";
	}
	if (parsed.mode === "json") {
		return "json";
	}
	if (parsed.print || !stdinIsTTY || !stdoutIsTTY) {
		return "print";
	}
	return "interactive";
}

The TTY test is a classic Unix convention: a TTY (teletype) is an interactive terminal device. If either standard input or standard output is not a terminal — because input is piped (cat log | pi "explain") or output is redirected (pi "…" > out.txt) — a full-screen UI makes no sense, so Pi falls back to print mode. Later, piped stdin content is read and prepended to the initial message.

Then comes the expensive part: create the session manager (new, resumed, forked or in-memory), resolve project trust (whether to load executable resources from the working folder), build services (settings, model runtime with a 15-second timeout for catalog work, resource loader), resolve model scope and the initial model, and finally create the AgentSessionRuntime. Only then does main dispatch to runRpcMode, InteractiveMode or runPrintMode.

Project trust deserves a note, because docs/security.md is careful about what it is not: "Project trust controls whether Pi loads most settings and resources supplied by a working folder. It prevents a folder from silently loading executable extensions before you approve it." But "Project trust does not limit what tool calls can access or affect", and context files such as AGENTS.md load regardless — "Treat instructions in a folder as untrusted input even when you decline project trust." Trust is a defence against code auto-loading, not against prompt injection.

7.6 Owning stdout

In print, JSON and RPC modes, stdout is a data channel. A stray console.log from an extension or a dependency would corrupt the JSON stream that a parent program is parsing. core/output-guard.ts therefore takes over stdout: takeOverStdout() replaces process.stdout.write with a function that writes to stderr, and keeps the raw stdout writer for Pi's own protocol output. Diagnostics still appear (on stderr); the data channel stays clean. Replacing a method on a global object at runtime is monkey patching — powerful and fragile, used here deliberately and reversibly (restoreStdout()).

Print mode also installs handlers for SIGTERM and SIGHUP that kill tracked child processes, dispose the runtime and exit with the conventional status 128 + signal number (143 for SIGTERM, 129 for SIGHUP).

7.7 Print and JSON modes

runPrintMode() binds extensions with mode "print" or "json", sends the initial message (and any further messages from the command line), waits for the session to become idle, and prints. In text mode it writes the final assistant text; in JSON mode it writes every session event as one JSON object per line, so a script can consume the full event stream with any JSON-lines reader. modes/json-event.ts defines that wire shape: message_update events drop the growing partial message and carry only the delta plus current usage — the same O(n²) bandwidth fix as the proxy in Chapter 6. The documentation summarizes: "Print mode runs a prompt and writes the final response. JSON mode writes agent events as JSONL."

7.8 RPC mode: a protocol over stdin and stdout

RPC mode turns Pi into a long-lived subprocess controlled by another program in any language. Messages are JSON Lines: one JSON value per line. Three kinds of message flow:

  • Commands from client to Pi, a discriminated union on type (prompt, steer, follow_up, abort, get_state, set_model, compact, bash, fork, get_tree, get_messages, …), each with an optional id.
  • Responses from Pi, { id, type: "response", command, success, data? | error? }, echoing the id so the client can match responses to requests even when they arrive out of order.
  • Events, the session's event stream, interleaved with responses.

This is a request/response protocol with correlation ids, the same idea as JSON-RPC (Chapter 18). The prompt command shows why correlation matters: Pi starts the prompt and "emit[s] the authoritative response only after prompt preflight succeeds", so the response may follow some events, and the run's completion is signalled by events, not by the response.

The framing code in modes/rpc/jsonl.ts makes a point most implementations miss:

/**
 * Attach an LF-only JSONL reader to a stream.
 *
 * This intentionally does not use Node readline. Readline splits on additional
 * Unicode separators that are valid inside JSON strings and therefore does not
 * implement strict JSONL framing.
 */

JSON permits the characters U+2028 (line separator) and U+2029 (paragraph separator) unescaped inside strings, and Node's readline treats them as line breaks. A model reply containing one would split a record in two and break the protocol. The reader splits on \n only, strips a trailing \r, and uses a StringDecoder so a multi-byte UTF-8 character split across chunks is reassembled — the same framing lesson as SSE in Chapter 4.

rpc-client.ts is a TypeScript client for this protocol, spawning pi --mode rpc and exposing typed methods, useful both for integration and as a reference for clients in other languages.

7.9 The SDK

For TypeScript hosts, createAgentSession() in core/sdk.ts builds the same session in-process:

import { createAgentSession } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession();
try {
  await session.prompt("What files are in the current directory?");
  console.log(session.getLastAssistantText());
} finally {
  session.dispose();
}

Options allow an in-memory SessionManager, custom tools, a different working directory, and custom resources. The SDK documentation makes an important ownership statement: "SessionManager is authoritative for finalized model context. … Assigning session.agent.state.messages does not replace persisted context." In other words, the session file — not the agent's in-memory array — is the source of truth (Chapter 9).

7.10 Concepts in this chapter

  • Facade pattern. A single interface over a subsystem (agent, session storage, settings, extensions) so clients do not coordinate the parts themselves.
  • Interceptor / middleware pipeline. Input passes through ordered stages, each able to transform or consume it.
  • Supervisor pattern. An outer component observes failures of an inner one and decides to retry, repair or stop.
  • TTYs and standard streams. Processes inherit stdin, stdout and stderr; isTTY tells whether a stream is an interactive terminal. Unix tools adapt their behaviour accordingly.
  • Signals and exit codes. Asynchronous notifications such as SIGTERM and SIGHUP; by convention a process killed by signal N reports status 128 + N.
  • Inter-process communication over pipes. A parent controls a child through its standard streams with a line-delimited protocol.
  • Framing and correlation ids. Delimit messages unambiguously; tag requests so responses can be matched asynchronously.
  • Monkey patching. Replacing behaviour at runtime; isolate and make reversible.
  • Single source of truth. One authoritative store (the session file); other copies are derived.

Exercises

  1. Write a 30-line Python program that starts pi --mode rpc, sends a prompt command with id: "1", prints text deltas from message_update events, and exits on agent_end with willRetry: false.
  2. Why does agent_end carry willRetry? Describe the bug in print mode that would occur without it.
  3. Construct a JSON string containing U+2028 and show how readline and attachJsonlLineReader frame it differently.
  4. Run echo "summarize this" | pi and explain, from resolveAppMode, which mode runs and why.
  5. List the stages of prompt() that can consume the input without starting a run, and the disposition each reports.
  6. An extension calls console.log("debug") in JSON mode. Trace where the text goes and why the JSON consumer is unaffected.

Source files for this chapter

Every quote is checked against these files at 4954d76. A change under packages/coding-agent, packages/agent flags this chapter for review.