Skip to content

Part II · Pi in Source / Ch. 05

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

Xia A and Lao Z guide glowing turn events into an open session journal, where connected history cards branch into a new path.

Chapter guide: Stop ④ on the source route (the core part of the product layer). v1.0.0 moved sessions, compaction, and Skills out of pi-agent-core and into pi-coding-agent/src/core. This stop reads agent-session.ts, session-manager.ts, agent-session-runtime.ts, compaction/compaction.ts, and skills.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 responsibilityLocation in v1.0.0What this chapter reads
Wiring host decisions onto the loopAgentSession in core/agent-session.tsThe hooks the constructor installs
Session file and session treeSessionManager in core/session-manager.ts_persist, loadEntriesFromFile, createBranchedSession
Fork and switching sessionsAgentSessionRuntime in core/agent-session-runtime.tsfork
Compactioncore/compaction/prepareCompaction, compact, getSummarizationFailure
Skillscore/skills.tsloadSkillsFromDir, 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; AgentSession is 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:

ts
// 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 attachesMain boundary
subscribe_handleAgentEvent: emits the extension event first, then notifies listeners, then writes the sessionWriting the session is a synchronous append; a failure throws
prepareNextTurnCompacts first when the threshold is exceeded, then rebuilds the system prompt and the tool listCompaction may fail or be cancelled by an extension
prepareRequestRebuilds 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
finishTurnDispatches the extensions' turn_end boundary; an extension may demand one more requestAn 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."

text
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 running

Writing the session happens in the message_end branch of _handleAgentEvent:

ts
// 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:

ts
// 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."

PathTriggerBehaviorPromise it does not cover
Normal messagemessage_endAppends one line of JSONThat the line is flushed to disk
No conversation yetOnly configuration entries such as model and thinkingStays in memory onlyThat these settings can be recovered after exit
File already existsThe first openSync(..., "wx")ThrowsThat it picks a new file name automatically
Memory modeSessionManager.inMemoryWrites nothing to diskThat 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 typeExampleMeaning
messageAgentMessages such as system, user, assistant, toolResultThe conversation record
model_change / thinking_level_change / usageSwitching model, switching thinking level, usageConfiguration and metering, not conversation
compaction / branch_summaryA compaction summary, a summary of an abandoned branchAffects later context projections
context_editReplaces or hides one piece of contentChanges only the projection, not the original entry
custom / custom_messageData or messages written by extensionsExtension state
label / session_infoLabels, session namesNavigation metadata

"For loading, look at loadEntriesFromFile. It is more permissive than you think," Lao Z said:

ts
// 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."

ts
// 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 choiceWhat it readsError boundary
position: "at"Keeps the path from the root to the target entryThe target does not exist; the original file has never been written to disk
default / beforeThe target must be a user message; keeps up to its parent entryA non-user target or a missing target; the original file has never been written to disk
The target is the first user messageThe parent entry is empty, so it opens a new session directly with parentSession pointing at the original fileCopies no entries at all
The new sessionA new file and a new id; the original file untouchedDoes 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:

ts
// 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.

ts
// 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 ​

  1. List the files in packages/agent/src and read index.ts; confirm that pi-agent-core in v1.0.0 no longer exports any session, compaction, or Skills API.
  2. In the agent-session.ts constructor, find subscribe and the five _install* calls; pick _installAgentBoundaryHooks and explain how it preserves the original finishTurn and still calls it.
  3. Read the message_end branch of _handleAgentEvent; record which roles go through appendMessage, which go through appendCustomMessageEntry, and which are not written here.
  4. Read SessionManager._persist and _hasConversation; predict whether a session that only switched the model and exited without sending a message leaves a file behind.
  5. 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.
  6. Follow from AgentSessionRuntime.fork to createBranchedSession; confirm the new header's parentSession, and confirm that no Git or worktree call appears anywhere in the path.
  7. Follow from AgentSession.compact to prepareCompaction and getSummarizationFailure; write out the outcome separately for error, for length, and for a tool call appearing in the summary.
  8. Read loadSkillFromFile and formatSkillsForPrompt; verify that a file missing its description is not loaded, and name the tool the model uses to read a skill's body.