Part II Chapter 5: The Dispatch Desk — pi-coding-agent

Chapter guide: Stop ④ on the source route (the core part of the product layer).
v1.0.0moved sessions, compaction, and Skills out ofpi-agent-coreand intopi-coding-agent/src/core. This stop readsagent-session.ts,session-manager.ts,agent-session-runtime.ts,compaction/compaction.ts, andskills.ts. It has to break Xiao A's illusion that "a fork is just a Git branch" and show clearly what a session actually relies on to outlive one process.
Following Part II Chapter 4, Xiao A had the state changes inside the loop straight. But he searched all of packages/agent/src, found only five files, and saw not one line of session code. Then he noticed the method name fork in coding-agent and an idea popped up: "Isn't that just a Git branch? I fork a session, let two Agents each change their own files, and they stay out of each other's way?"
"Stop," Lao Z said. "A session fork is not a Git branch, and it neither creates nor isolates a worktree. Two concepts are welded together in that sentence. First get clear on where the session code moved; then, at the assembly layer, make 'a session outliving one process' precise."
The low-level loop can drive one stream and its tool results to completion, but it does not know where a session should be saved, when to compact, or which Skills are available. In v1.0.0, what wires these host decisions onto the loop is the coding-agent's AgentSession.
This chapter is based on the upstream pi tag v1.0.0 (commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32) and @earendil-works/pi-coding-agent 1.0.0. It follows AgentSession's assembly hooks, the writes of one prompt, the JSONL session file, fork, compaction, and skills. It does not cover how the coding-agent parses the command line or picks the working directory (see Part II Chapter 7), and it proves nothing about the file system's durability under a power loss.
Where the Harness Went
Xiao A asked, "The older material says pi-agent-core had an AgentHarness that managed sessions and compaction. What about now?"
"The title of upstream commit 7fd478a2 is 'remove the experimental harness from pi-agent-core'," Lao Z said. "packages/agent/src/index.ts in v1.0.0 exports only agent.ts, agent-loop.ts, proxy.ts, setDefaultStreamFn, and types.ts. The responsibilities of that former assembly layer got distributed into the product layer, as the table below shows."
| Old responsibility | Location in v1.0.0 | What this chapter reads |
|---|---|---|
| Wiring host decisions onto the loop | AgentSession in core/agent-session.ts | The hooks the constructor installs |
| Session file and session tree | SessionManager in core/session-manager.ts | _persist, loadEntriesFromFile, createBranchedSession |
| Fork and switching sessions | AgentSessionRuntime in core/agent-session-runtime.ts | fork |
| Compaction | core/compaction/ | prepareCompaction, compact, getSummarizationFailure |
| Skills | core/skills.ts | loadSkillsFromDir, formatSkillsForPrompt |
The pi-durable package has its own harness/ plus SQLite storage — a different runtime built on "commit first, show later." This chapter does not open it; Part II Chapter 13: Commit First, Show Later — pi-durable reads that kernel and contrasts it with this chapter.
Metaphor: the dispatch desk
The loop is the engine;
AgentSessionis the dispatch desk. It decides where the session gets written, when to compact, and which skills are visible. It did not build the engine — it only wires into the hooks the engine leaves open.
Which Hooks the Dispatch Desk Plugs Into
Xiao A guessed, "Doesn't AgentSession sound like a bigger loop?"
"It isn't. It holds one Agent, then wires into the hooks from Part II Chapter 4." Lao Z opened the constructor:
// packages/coding-agent/src/core/agent-session.ts, AgentSession's constructor (v1.0.0, excerpt)
// Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
this._installAgentToolHooks();
this._installAgentNextTurnRefresh();
this._installAgentRequestProjection();
this._installAgentBoundaryHooks();
this._installHiddenDeclarationsProjection();"_installAgentToolHooks replaces beforeToolCall / afterToolCall directly with dispatching the extensions' tool_call event; the overall shape of extension events is Part II Chapter 8's subject. Each remaining _install* first saves the callback already on the Agent, then wraps a new layer around it, and the original callback still gets called," Lao Z said. "Note that this does not mean every feature is active: when an extension registers no handler, the matching boundary event is simply skipped."
| Hook (Part II Chapter 4) | What AgentSession attaches | Main boundary |
|---|---|---|
subscribe | _handleAgentEvent: emits the extension event first, then notifies listeners, then writes the session | Writing the session is a synchronous append; a failure throws |
prepareNextTurn | Compacts first when the threshold is exceeded, then rebuilds the system prompt and the tool list | Compaction may fail or be cancelled by an extension |
prepareRequest | Rebuilds the messages to send to the model before every request with buildSessionProjection() | The projection comes from the session tree, not from the loop's in-memory array |
finishTurn | Dispatches the extensions' turn_end boundary; an extension may demand one more request | An extension cannot turn error / aborted back into success |
Xiao A noticed the third row. "The messages get replaced before the request?"
"Right. The canonicalContext inside _installAgentRequestProjection uses this.sessionManager.buildSessionProjection().messages," Lao Z said. "So the context the model sees is whatever the session tree says: the compaction summary, the branch summary, and context_edit all take effect at this step. The loop's currentContext is only this run's working copy."
The Call and Write Sequence of One prompt
Xiao A asked him to walk one call from beginning to end. "From entering prompt until the session is on disk — how is that path connected?"
"Look at the rules for when it is busy first," Lao Z said. "While the Agent is streaming, AgentSession.prompt requires the caller to give a streamingBehavior, and the value must be steer or followUp; otherwise it throws 'Agent is already processing'. That matches the single-flight rule of Agent.prompt in Part II Chapter 4; the product layer only adds a queueing option."
AgentSession.prompt(text)
→ slash commands and template expansion; while streaming, queue by streamingBehavior
→ if needed, check compaction once first
→ before_agent_start extension event
→ agent.prompt(messages)
→ prepareRequest: buildSessionProjection rebuilds messages
→ model stream → message_end → _handleAgentEvent → sessionManager.appendMessage
→ tool result message_end → appended the same way
→ finishTurn: extensions' turn_end boundary
→ prepareNextTurn: threshold compaction, refresh system prompt and tool list
→ agent_end: afterwards, decide by overflow / threshold whether to compact and whether to keep runningWriting the session happens in the message_end branch of _handleAgentEvent:
// packages/coding-agent/src/core/agent-session.ts, _handleAgentEvent (v1.0.0, excerpt)
await this._emitExtensionEvent(event);
this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
if (event.type === "message_end") {
if (event.message.role === "custom") {
entryId = this.sessionManager.appendCustomMessageEntry(/* customType, content, display, details */);
} else if (
event.message.role === "system" ||
event.message.role === "user" ||
event.message.role === "assistant" ||
event.message.role === "toolResult"
) {
entryId = this.sessionManager.appendMessage(event.message);
}
}"The order is: the extensions see the event first, then the public listeners, and only then the session write," Lao Z said. "Part II Chapter 4 said Agent awaits every listener, so the loop does not move to its next step until the session write is finished. Note that system messages are appended too: the system message in Part II Chapter 4 that declares a tool change enters the session file as an ordinary message entry."
Writing Is Lazy Creation Plus Synchronous Append
Xiao A sounded uneasy. "What if the process dies in the middle of a write? Could the session end up with half an entry?"
"Look at SessionManager._persist," Lao Z said:
// packages/coding-agent/src/core/session-manager.ts, _persist (v1.0.0, excerpt)
_persist(entry: SessionEntry): void {
if (!this.persist || !this.sessionFile) return;
if (!this.flushed) {
if (!this._hasConversation()) return;
const fd = openSync(this.sessionFile, "wx");
try {
for (const e of this.fileEntries) writeFileSync(fd, `${JSON.stringify(e)}\n`);
} finally { closeSync(fd); }
this.flushed = true;
} else {
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
}
}"Three things can be confirmed. First, the file is created lazily: it is created only after a user or assistant message appears. The comment says this way opening and then closing pi leaves no empty file behind, and because writing starts at a user message, the prompt survives even when the first round never finishes. Second, the initial creation uses the wx flag, so it fails if the file already exists and never overwrites someone else's file. Third, every entry after that is a synchronous appendFileSync — no write queue, no fsync."
"What follows from this: within one process, the write order is the event order. But this code cannot prove that two processes appending the same session file at once is safe, and it cannot prove the last line is intact after a power loss."
| Path | Trigger | Behavior | Promise it does not cover |
|---|---|---|---|
| Normal message | message_end | Appends one line of JSON | That the line is flushed to disk |
| No conversation yet | Only configuration entries such as model and thinking | Stays in memory only | That these settings can be recovered after exit |
| File already exists | The first openSync(..., "wx") | Throws | That it picks a new file name automatically |
| Memory mode | SessionManager.inMemory | Writes nothing to disk | That it survives the process exiting |
The JSONL Unit Is a Session Header Plus Tree Entries
Xiao A jumped to a conclusion. "It is JSONL, so every line is a message?"
"No," Lao Z said. "The first line is a SessionHeader: type: "session", version (the current constant is CURRENT_SESSION_VERSION = 3), id, timestamp, cwd, and an optional parentSession. Every line after it is one SessionEntry, each carrying id, parentId, and timestamp. The parentId chains entries into a tree, and the current position is what leafId points at. 'Every line is a message' does not hold here."
Entry type | Example | Meaning |
|---|---|---|
message | AgentMessages such as system, user, assistant, toolResult | The conversation record |
model_change / thinking_level_change / usage | Switching model, switching thinking level, usage | Configuration and metering, not conversation |
compaction / branch_summary | A compaction summary, a summary of an abandoned branch | Affects later context projections |
context_edit | Replaces or hides one piece of content | Changes only the projection, not the original entry |
custom / custom_message | Data or messages written by extensions | Extension state |
label / session_info | Labels, session names | Navigation metadata |
"For loading, look at loadEntriesFromFile. It is more permissive than you think," Lao Z said:
// packages/coding-agent/src/core/session-manager.ts, loadEntriesFromFile (v1.0.0, excerpt)
function parseSessionEntryLine(line: string): FileEntry | null {
if (!line.trim()) return null;
try { return JSON.parse(line) as FileEntry; }
catch { return null; } // Skip malformed lines
}
// … after reading the chunks:
if (header.type !== "session" || typeof header.id !== "string") return [];
if (pending) appendFileSync(resolvedFilePath, "\n");"A malformed line is skipped outright, with no error. If the first record is not a valid header, the whole file is treated as an empty session. If the file ends in a partial line with no newline, a newline is appended after reading, so the next append cannot stick onto the end of that partial line," Lao Z said. "So recoverability comes from skipping lines it cannot read, and the price is that a malformed line's content is silently dropped. That is a trade-off, not 'append, therefore nothing is ever lost'."
fork Copies the Session Path, Not the Workspace
Xiao A pulled the topic back to the opening. "What does fork actually copy?"
"Look at AgentSessionRuntime.fork," Lao Z said. "It takes an entryId and a position, which defaults to before."
// packages/coding-agent/src/core/agent-session-runtime.ts, fork (v1.0.0, excerpt)
const beforeResult = await this.emitBeforeFork(entryId, { position });
if (beforeResult.cancelled) return { cancelled: true };
const selectedEntry = this.session.sessionManager.getEntry(entryId);
if (!selectedEntry) throw new Error("Invalid entry ID for forking");
if (position === "at") {
targetLeafId = selectedEntry.id;
} else {
if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry ID for forking");
}
targetLeafId = selectedEntry.parentId;
}
// If already persisted: SessionManager.open(the current file) → createBranchedSession(targetLeafId) → swap in the new runtime"An extension can cancel at session_before_fork. before requires the target to be a user message and cuts at its parent entry; the selected user text is returned to the interface so it is easy to re-edit. at keeps everything up to and including the target entry. A target that does not exist, or has the wrong type, throws 'Invalid entry ID for forking' either way."
"What actually does the copying is SessionManager.createBranchedSession. It uses getBranch(leafId) to take the one path from the root to the target, drops the label entries and rebuilds them from the resolved labels, then writes a new JSONL file whose header's parentSession points at the original file. The whole procedure touches only session entries and metadata: no Git command, no directory copy, no worktree API."
| fork choice | What it reads | Error boundary |
|---|---|---|
position: "at" | Keeps the path from the root to the target entry | The target does not exist; the original file has never been written to disk |
default / before | The target must be a user message; keeps up to its parent entry | A non-user target or a missing target; the original file has never been written to disk |
| The target is the first user message | The parent entry is empty, so it opens a new session directly with parentSession pointing at the original file | Copies no entries at all |
| The new session | A new file and a new id; the original file untouched | Does not copy a worktree on disk |
"One more thing that gets mixed up easily: tree navigation inside the same file," Lao Z said. "SessionManager.branch(id) only moves leafId, so the next append becomes a new child of that entry, and no existing entry is changed or deleted. The comment on navigateTree states outright that it differs from fork() and stays in the same file."
Xiao A followed the thread. "So if I want two experiments to change their own files separately, I have to find another way?"
"Right — use a Git branch / worktree or some other workspace isolation," Lao Z said. "A session fork means starting another path off the historical context. Deriving file isolation from it is something the source gives no evidence for."
Compaction Is Not Lossless Truncation
Xiao A changed the subject. "When the context is full, whose job is compaction?"
"AgentSession has two entry points," Lao Z said. "The manual one is compact(), which /compact, RPC, and extensions all go through. The automatic one is _checkCompaction() plus _runAutoCompaction(), and its reasons come in two kinds: overflow and threshold. Both paths end up calling compact() in compaction/."
"The manual compact() first calls abort() on the current operation; the comment says it will not retry or continue the interrupted turn. Then prepareCompaction(sessionManager.getBranch(), settings) picks the keep boundary and estimates tokens. When it returns nothing, compact() throws 'Already compacted' if the last entry is already a compaction, and 'Nothing to compact' otherwise. An extension can cancel at session_before_compact or supply a summary directly. With neither, a default summary is generated, and appendCompaction runs only after that succeeds."
The failure test for the summary request lives in getSummarizationFailure:
// packages/coding-agent/src/core/compaction/compaction.ts, getSummarizationFailure (v1.0.0)
export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined {
if (response.stopReason === "error") {
return `${label} failed: ${response.errorMessage || "Unknown error"}`;
}
if (response.stopReason === "length") {
return `${label} failed: generation hit the token cap and the summary is incomplete`;
}
return undefined;
}"The comment is explicit: a length stop means the summary is incomplete, so it cannot become a session checkpoint. A tool call appearing in the summary counts as a failure too. Every summary request goes through completeSummarization. It sets cacheRetention: "none", reuses the caller's sessionId when there is one, and generates a new one when there is not. A transient stream interruption is retried per the retry policy, while deterministic errors and aborts return immediately."
"This is a model request sent on its own; the original entries are never deleted, and a compaction entry only changes the later projections. The payoff is a bounded context length that keeps the summary and the recent tail. The price is that a summary loses information and generating one costs tokens, and error, length, or abort each leaves this write undone."
Skills Enter the List Only; They Grant No Execution Rights
"What about Skills?" Xiao A asked. "Once one is loaded, can the model just use it?"
"Loading, displaying, and granting are three different things," Lao Z said, pulling up skills.ts:
The discovery rules of loadSkillsFromDir are written in its comments: if a directory contains SKILL.md, that directory is treated as the skill root and nothing below it is searched further; otherwise the direct .md files under the root are loaded, and subdirectories are searched recursively for SKILL.md. It honors .gitignore, .ignore, and .fdignore. A missing directory returns an empty result; a failed file read, a failed frontmatter parse, or a name or description that fails validation is recorded as warning-level diagnostics.
// packages/coding-agent/src/core/skills.ts, Skill and formatSkillsForPrompt (v1.0.0, excerpt)
export interface Skill {
name: string;
description: string;
filePath: string;
baseDir: string;
sourceInfo: SourceInfo;
disableModelInvocation: boolean;
}
// formatSkillsForPrompt:
// "Use the read tool to load a skill's file when the task matches its description.""Note that Skill has no body field," Lao Z said. "formatSkillsForPrompt lists only name and description into the system prompt, telling the model to load that file with the read tool when a task matches its description. A skill with disableModelInvocation true does not enter the list. A file with no description is not loaded; a file whose name fails validation is still loaded, with only a warning recorded."
"So a skill itself executes no shell, enables no tool, and enforces no path permission. The model reads a skill file through the ordinary read tool, under the ordinary tool's approval rules. Treating the suggestions in a SKILL.md as a security sandbox rests on a premise that does not hold."
Payoffs, Costs, and What Is Not Covered
What the source supports as the payoff: pi-agent-core is back to a pure loop, so any host can take just the loop without carrying a session format; AgentSession concentrates sessions, compaction, extensions, and projections in the product layer through the hooks. The cost is that this assembly layer is thick: agent-session.ts runs to more than four thousand lines, the hooks wrap one another layer by layer, and debugging means working out which layer's callback made a change. Session writing is a synchronous append plus permissive reading — simple and dependable, but malformed lines are silently skipped.
This chapter did not cover how the coding-agent locates .pi and the session directory, and it did not verify JSONL crash recovery. The content of Skills is not treated as trusted instructions. A session fork describes context history only; a Git worktree belongs to the version-control layer.
Summary
What this chapter answered is how a session "lives" past a process restart once a turn has run. In v1.0.0 the answer is the dispatch desk, the coding-agent's AgentSession: it wires into Agent's events and the three turn hooks, appends messages synchronously to JSONL at message_end, and rebuilds the model's context from the session tree before each request. createBranchedSession copies the session path from root to target, not a Git workspace; compaction is a separate summary request that can fail, not a silent truncation; skills contribute only name and description to the prompt and grant no execution rights.
- Boundary: a session fork creates no Git branch and no worktree; to isolate file side effects, use Git or a workspace mechanism, do not count on a session fork. Both error and length make summarization fail, and an incomplete summary is never written into the session.
Remember: a session fork is not a Git branch.
It copies context history, not the file system. Reading "fork a session" as "branch the workspace" leads you to exactly the wrong safety assumption about file isolation — the easiest trap in this chapter.
Source Walkthrough
- List the files in
packages/agent/srcand readindex.ts; confirm that pi-agent-core inv1.0.0no longer exports any session, compaction, or Skills API. - In the
agent-session.tsconstructor, findsubscribeand the five_install*calls; pick_installAgentBoundaryHooksand explain how it preserves the originalfinishTurnand still calls it. - Read the
message_endbranch of_handleAgentEvent; record which roles go throughappendMessage, which go throughappendCustomMessageEntry, and which are not written here. - Read
SessionManager._persistand_hasConversation; predict whether a session that only switched the model and exited without sending a message leaves a file behind. - Hand-build three lines of JSONL: a valid header, a valid entry, and one malformed JSON line. Against
loadEntriesFromFile, predict how many entries the load yields. Do not write this into a real session directory. - Follow from
AgentSessionRuntime.forktocreateBranchedSession; confirm the new header'sparentSession, and confirm that no Git or worktree call appears anywhere in the path. - Follow from
AgentSession.compacttoprepareCompactionandgetSummarizationFailure; write out the outcome separately for error, for length, and for a tool call appearing in the summary. - Read
loadSkillFromFileandformatSkillsForPrompt; verify that a file missing its description is not loaded, and name the tool the model uses to read a skill's body.
