Pify

Chapter 10: Session management: storing, resuming, and branching conversations

How Pi stores one session as a parent-linked JSONL tree, reconstructs active context, and exposes it through SessionManager.

Chapter 9 ended with a CompactionEntry on a Session Tree. That compact description leaves two questions open: where does the tree live, and how does buildSessionContext() turn it back into the linear message list required by a model call?

Pi's Coding Agent answers both with one durable session file. Each non-header record points to its parent, an in-memory leaf marks the current position, and context reconstruction projects one root-to-leaf path. The same file can therefore retain several approaches while a model sees only the branch the user selected.

1. Start with two storage questions

context.messages is an array at the model boundary. It does not tell us where a conversation survives after the process exits or what historical shape the application keeps. A persistence design should answer those questions separately:

  • Storage medium: where are records written?
  • Logical structure: how are records related?

A database can store a tree, and a file can store a linear sequence. Choosing a medium does not settle the shape.

Question A: where is a session stored?

Coding Agent writes local JSONL by default. Sessions live below ~/.pi/agent/sessions/; the working directory is encoded into a directory such as --work-project--, and each session gets one .jsonl file. The files do not live in a project's .pi/sessions/ directory. Project grouping happens under the user-level Agent directory.

This choice fits a local CLI. It needs no database service, the records remain inspectable with ordinary text tools, and moving between projects naturally selects a different encoded directory. It does not provide database-style transactions, cross-machine queries, or multi-writer coordination. Those are separate requirements, not properties of JSONL.

The built-in commands expose the storage policy without making callers assemble paths:

pi -c                  # Continue the most recent session
pi -r                  # Browse sessions
pi --no-session        # Keep the run in memory
pi --name "auth audit" # Name a new session
pi --session <path|id> # Open one session
pi --fork <path|id>    # Copy history into a new session

/session shows the current file, ID, message count, token totals, and cost. /resume, /new, /name, /tree, /fork, and /clone operate at a higher product layer.

Agent Core owns the live in-memory transcript and Agent events; it does not export a session storage harness in v1.0.0. Coding Agent's synchronous SessionManager owns this JSONL tree, while AgentSession coordinates its live runtime. Pi Durable offers a separate experimental conversation and document substrate. Section 7 returns to these ownership boundaries.

Question B: what shape does a session have?

A linear transcript works until a user retries from an earlier point, compares two approaches, or returns to an abandoned path. Rewriting a single array would either discard the old suffix or require copying it elsewhere.

Coding Agent keeps a parent-linked tree. Appending adds a record; rewinding moves the current leaf; the next append makes a new child of the selected entry. Older children remain in memory and in the JSONL file. A separate file is needed only when the user explicitly forks or clones a session.

DimensionCommon optionCoding Agent default
Storage mediumRelational or document databaseOne local JSONL file per session
Logical structureLinear transcriptParent-linked append-only tree

These choices explain the rest of the chapter. JSONL supplies the physical sequence, while id, parentId, and leafId supply the logical tree.

2. Grow a session tree step by step

Consider an authentication bug. The developer switches models, asks why salt verification fails, lets the Agent inspect two functions, rejects the first diagnosis, and tries a different route.

The following is a scenario outline, not literal JSONL:

1. Switch to anthropic/claude-sonnet-4-6.
2. Ask why salt verification fails in src/auth.ts.
3. The assistant calls read.
4. read returns the file contents.
5. The assistant blames the comparison at line 23.
6. Move the leaf back to the question.
7. Ask to inspect the hash implementation first.
8. The assistant calls grep and read, then gives a new diagnosis.

We use short aliases e1, e2, and so on in the diagrams. Persisted Coding Agent entry IDs are generated as collision-checked eight-character UUID prefixes, not these teaching aliases.

Step 1: switch model and create the root entry

The first physical line is a SessionHeader, which is metadata rather than a tree node. Switching models appends the first ModelChangeEntry. Its real payload has both provider and modelId:

e1: ModelChangeEntry
  parentId: null
  provider: "anthropic"
  modelId: "claude-sonnet-4-6"

The in-memory leaf advances to e1:

e1 model_change  <- leafId

parentId: null identifies a root entry. The header has no entry id and cannot be a parent.

Step 2: append the user message

The question becomes a SessionMessageEntry whose message is a Pi AI UserMessage. This sketch omits the entry's ISO timestamp and the message's Unix-millisecond timestamp; both appear in the persisted example in Section 6.

e2: SessionMessageEntry
  parentId: e1
  message:
    role: "user"
    content: "Why does salt verification fail in src/auth.ts?"

The parent link extends the active path:

e1 model_change
└─ e2 user  <- leafId

e2 points to the preceding entry on this branch, not merely to the preceding physical line. The two happen to coincide while the tree is linear.

Step 3: persist an assistant Tool call

The assistant decides to inspect src/auth.ts. Text and the read call share one AssistantMessage.content array:

e3: SessionMessageEntry
  parentId: e2
  message:
    role: "assistant"
    content:
      - { type: "text", text: "I'll inspect the verifier." }
      - { type: "toolCall", id: "call_001", name: "read",
          arguments: { path: "src/auth.ts" } }
    stopReason: "toolUse"

The tree still has one branch:

e1 model_change
└─ e2 user
   └─ e3 assistant + read call  <- leafId

The persisted assistant message also carries api, provider, model, full usage, and a numeric message timestamp. Those fields describe the completed provider response; the surrounding session entry has its own ISO timestamp.

Tool execution produces a separate ToolResultMessage inside another SessionMessageEntry:

e4: SessionMessageEntry
  parentId: e3
  message:
    role: "toolResult"
    toolCallId: "call_001"
    toolName: "read"
    content: [{ type: "text", text: "export function verifySalt(...) { ... }" }]
    isError: false

The result extends the same parent chain:

e1 model_change
└─ e2 user
   └─ e3 assistant + read call
      └─ e4 read result  <- leafId

toolCallId matches the ToolCall.id in e3; toolName identifies the executed Tool. The parent link orders session entries, while the Tool-call ID records the protocol relationship between the two messages.

Step 5: append the first diagnosis

The assistant consumes the Tool result and replies:

e5: SessionMessageEntry
  parentId: e4
  message:
    role: "assistant"
    content: [{ type: "text", text: "The comparison at line 23 uses the wrong encoding." }]
    stopReason: "stop"

The first attempt is now a five-entry path:

e1 model_change
└─ e2 user: salt verification
   └─ e3 assistant: read
      └─ e4 toolResult: auth.ts
         └─ e5 assistant: line 23 diagnosis  <- leafId

Every append created one new entry and advanced leafId. No older entry acquired a child list or changed its contents.

Step 6: rewind by moving the leaf

The developer wants another approach. Programmatic navigation can place the leaf on e2. This is source-aligned pseudocode; the public branch() method performs the existence check and assignment synchronously:

if (!byId.has("e2")) throw new Error("Entry e2 not found");
leafId = "e2";

The old suffix remains present:

e1 model_change
└─ e2 user: salt verification  <- leafId
   └─ e3 assistant: read
      └─ e4 toolResult: auth.ts
         └─ e5 assistant: line 23 diagnosis

Moving the pointer alone is an in-memory operation. leafId is not a field in Coding Agent's v3 header and branch() does not append a navigation record. A later append records the branch durably through its parentId. If the process exits immediately after branch(), reopening the file selects the last physical entry as the leaf.

Step 7: append below the rewound position

The developer asks a narrower question. Because the current leaf is e2, the new user entry points to e2, just as e3 does:

e6: SessionMessageEntry
  parentId: e2
  message:
    role: "user"
    content: "Inspect the hash implementation before the verifier."

Two children with the same parent form two branches:

e1 model_change
└─ e2 user: salt verification
   ├─ e3 assistant: first approach
   │  └─ e4 toolResult
   │     └─ e5 assistant: first diagnosis
   └─ e6 user: inspect hash first  <- leafId

This programmatic example deliberately appends a second user message after e2. Interactive /tree behaves differently when the selected entry itself is a user or custom message: it moves to that entry's parent, copies the selected text into the editor, and lets the edited submission become a sibling of the selected entry.

Step 8: continue the new branch

The assistant requests both a search and a file read. Each returned ToolResultMessage becomes its own session entry before the final answer:

e1 model_change
└─ e2 user: salt verification
   ├─ e3 assistant: first approach
   │  └─ e4 read result
   │     └─ e5 assistant: first diagnosis
   └─ e6 user: inspect hash first
      └─ e7 assistant: grep + read calls
         └─ e8 grep result
            └─ e9 read result
               └─ e10 assistant: new diagnosis  <- leafId

The file now contains ten tree entries in append order. The active path is e1 -> e2 -> e6 -> e7 -> e8 -> e9 -> e10. Entries e3 through e5 are still addressable through getEntry(), visible through getTree(), and available for later navigation, but they are absent from the next model context.

3. Read a session entry

The tree becomes concrete when we inspect one persisted record.

A complete SessionMessageEntry

This is a valid JSON object for the assistant Tool call. It shows every required field used by the current Pi AI AssistantMessage; real token and cost values come from the provider.

{
  "type": "message",
  "id": "c3d4e5f6",
  "parentId": "b2c3d4e5",
  "timestamp": "2026-08-24T10:23:30.000Z",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "text", "text": "I'll inspect the verifier." },
      {
        "type": "toolCall",
        "id": "call_001",
        "name": "read",
        "arguments": { "path": "src/auth.ts" }
      }
    ],
    "api": "anthropic-messages",
    "provider": "anthropic",
    "model": "claude-sonnet-4-6",
    "usage": {
      "input": 1250,
      "output": 80,
      "cacheRead": 0,
      "cacheWrite": 0,
      "totalTokens": 1330,
      "cost": {
        "input": 0,
        "output": 0,
        "cacheRead": 0,
        "cacheWrite": 0,
        "total": 0
      }
    },
    "stopReason": "toolUse",
    "timestamp": 1787567010000
  }
}
FieldRuntime meaningWhy it is stored
typeDiscriminates the entry unionThe tree contains messages, state changes, summaries, and metadata
idIdentifies this entryChildren and labels refer to an entry by ID
parentIdPoints to the preceding entry on this branchOne pointer is enough to reconstruct a root-to-leaf path
Entry timestampISO creation timeSession ordering, tree display, and debugging use it
messageThe complete AgentMessage payloadResuming preserves the provider response, usage, and Tool protocol fields
Message timestampUnix millisecondsPi AI messages retain their own time representation

The schema does not validate every parsed line when SessionManager loads a v3 file. Treat these TypeScript definitions as the format contract, and do not infer that an arbitrary JSON object is safe because parsing succeeded.

Eleven Coding Agent entry types

SessionEntry is a union of eleven types. Grouping them by their effect on context explains why they are separate.

Four entry types can project one or more messages into active context:

Entry typeProjection
SessionMessageEntry (message)Returns its stored AgentMessage; appendMessage() accepts user, assistant, Tool-result, custom, and Bash-execution messages, but summary messages use dedicated entries
CustomMessageEntry (custom_message)Creates a CustomMessage with customType, content, display, details, and the entry timestamp
CompactionEntry (compaction)Creates a CompactionSummaryMessage; buildContextEntries() also selects its retained boundary
BranchSummaryEntry (branch_summary)Creates a BranchSummaryMessage tied to fromId

Two entry types change the state returned beside the messages:

Entry typeState effect
ModelChangeEntry (model_change)Sets { provider, modelId } for the selected path
ThinkingLevelChangeEntry (thinking_level_change)Sets the current thinkingLevel string

ContextEditEntry (context_edit) controls the projection without producing a message of its own. Its targetId names an earlier entry on the selected path. replacement: null omits that target from future provider context; a non-null { content } replaces only its content while retaining its role and message metadata.

Four entry types persist metadata without entering model context:

Entry typePurpose
UsageEntry (usage)Stores model-attributed usage that does not contribute a message
CustomEntry (custom)Stores extension-owned data under a customType
LabelEntry (label)Applies or clears a label on targetId; latest appended change wins
SessionInfoEntry (session_info)Sets or clears the display name; latest appended entry wins

The top-level SessionHeader is another file-record shape, but it is not a SessionEntry and has no parent. Pi Durable defines separate conversation entries and Documents; neither uses this Coding Agent JSONL schema. Mixing those schemas produces incorrect parsers.

Context edits are themselves append-only tree entries:

OperationStored changeProjection effect
appendContextEdit(targetEntryId, null)append context_editomit target from future provider context
appendContextEdit(targetEntryId, { content })append context_editreplace target content in future provider context
raw transcript / UI historyappend-onlyunchanged
returned editIdnew context_edit entrynot target entry
appendContextEdit(targetEntryId, null) -> context_edit(editId) -> provider context - targetEntryId
appendContextEdit(targetEntryId, { content }) -> context_edit(editId) -> provider context[targetEntryId] = content
context_edit -> raw transcript append-only
editId != targetEntryId

The returned ID identifies the new edit entry, not the target. An exhaustive SessionEntry switch must handle context_edit, even though sessionEntryToContextMessages() emits no message for the edit itself.

Why entries store only their parent

A parent pointer lets append leave all older entries untouched. If e2 stored children: [e3], adding e6 would require rewriting it as children: [e3, e6]. With parentId, the new entry alone records the relationship.

Coding Agent keeps byId: Map<string, SessionEntry> for direct parent lookup and leaf-to-root traversal. getChildren(parentId) derives children by scanning the map values; there is no second persisted child index. getTree() performs a similar pass, treats missing-parent entries as roots, attaches resolved labels, and sorts each child array by ISO timestamp.

This representation makes in-memory append and branch() constant-time on average. Building a full child view costs a scan, and storage I/O has its own cost. The parent-pointer design avoids an old-entry rewrite; it does not make every tree query constant-time.

4. Append, rewind, branch, and summarize

The walkthrough can now be reduced to four operations without losing their product behavior.

Operation 1: append a child

The following source-aligned pseudocode describes _appendEntry() and its callers. It is pseudocode because entry creation is split among the typed appendXXX() methods in the implementation.

id = generateCollisionCheckedId(byId)
entry = { type, id, parentId: leafId, timestamp: nowIso(), ...payload }
fileEntries.push(entry)
byId.set(id, entry)
leafId = id
persistAccordingToLazyWritePolicy(entry)
return id

For a normal message, callers use the public API rather than constructing the entry:

entryId = session.appendMessage(agentMessage)

The in-memory mutations are append, Map.set, and pointer assignment. Persistence is synchronous in this SessionManager, so the wall-clock operation also includes file creation, the first full-file write, or appendFileSync, depending on state.

Operation 2: rewind the current pointer

branch(entryId) checks that the entry exists and assigns leafId. resetLeaf() sets it to null, allowing the next append to create another root.

branch("e2")
// leafId is now "e2"; e3, e4, and e5 remain unchanged.

This call does not compute or persist a branch object. The path is derived later by following parents. The next append supplies the durable evidence of the new route.

Operation 3: append after the rewind

Once the leaf has moved, ordinary append creates the branch. No separate branch type exists:

branch("e2")
e6 = appendMessage(revisedQuestion) // e6.parentId === "e2"

Interactive operations choose between retaining branches in the same file and copying a path to another file:

OperationFile resultSelection behavior
/treeSame session fileMoves within the full tree; a selected user/custom message is copied to the editor and navigation lands on its parent
/forkNew session fileSelects an earlier user message and extracts the path before it, then preloads the selected text for editing
/cloneNew session fileCopies the current active branch
/resumeOpens another existing fileUses the session picker for the project
/newAllocates a new sessionStarts with a fresh header and null leaf

createBranchedSession(leafId) extracts a root-to-leaf path. It removes LabelEntry nodes from that copied path, re-chains the retained entries, then appends label records for labels whose targets survived. The new header records the previous file in parentSession. The manager itself switches to the new session; the method is not a pure exporter.

Attach an optional branch summary

When /tree leaves one path for another, AgentSession.navigateTree() can summarize the abandoned suffix. It finds the deepest common ancestor between the old and target paths, collects entries from that ancestor's child through the old leaf, and generates the summary before it moves the leaf.

The following is lifecycle pseudocode, not the signature of one async method:

fromExtension = false
result = await generateBranchSummary(entriesToSummarize, {
  model: requestModel, apiKey, headers, env, signal,
  customInstructions, replaceInstructions, reserveTokens,
  streamFn, retry, callbacks
})
summaryText = result.summary
summaryDetails = { readFiles: result.readFiles || [],
                   modifiedFiles: result.modifiedFiles || [] }
summaryUsage = result.usage
summaryId = session.branchWithSummary(newLeafId, summaryText, summaryDetails,
                                      fromExtension, summaryUsage)

branchWithSummary() itself is synchronous. It captures the old leaf in fromId, moves to the requested target, appends a BranchSummaryEntry as a child of that target, and makes the summary entry the new leaf:

e2 user: salt verification
├─ e3 ... e5 abandoned attempt
└─ bs1 branch_summary(fromId: "e5")  <- leafId
   └─ next appended entry

The default summarizer works within a token budget, keeps recent eligible messages, carries file-operation details, and converts existing compaction or branch summaries into summary context. It omits raw toolResult messages from the branch-summary prompt because the assistant Tool calls already identify the actions. An extension can cancel navigation, replace the summary, adjust instructions, or provide details.

Without a summary, navigation calls branch() or resetLeaf(). With a summary, the abandoned entries remain in the file and the compact account enters the target branch as a distinct BranchSummaryMessage. No branch is merged or deleted.

5. Reconstruct active context

Storage is tree-shaped, while Agent and provider adapters consume a linear AgentMessage[]. Coding Agent makes the projection explicit instead of treating the JSONL order as model context.

Why projection is a separate step

Physical order answers “when was this record appended?” Parent order answers “which history belongs to this position?” After branching, those orders differ. Sending all physical lines would mix competing attempts, label records, and state from paths the user left.

getBranch() exposes the full selected root-to-leaf path. buildContextEntries() applies the latest compaction on that path. buildSessionProjection() applies the newest context_edit for each selected target without changing the raw entries. buildSessionContext() then returns projected messages and resolves model and thinking state from the full path.

Step 1: walk from leaf to root

This source-aligned pseudocode mirrors the internal buildSessionPath() helper:

if (leafId === null) return []
leaf = leafId ? byId.get(leafId) : entries.at(-1)
path = []
while (leaf exists):
  path.push(leaf)
  leaf = leaf.parentId ? byId.get(leaf.parentId) : undefined
return path.reverse()

For the completed second attempt, the selected path is:

[e1, e2, e6, e7, e8, e9, e10]

The e3 -> e5 suffix is absent. A broken parent ends traversal early because lookup returns undefined; SessionManager does not synthesize the missing history.

Step 2: project selected entry types

After compaction selection, sessionEntryToContextMessages() applies this dispatch:

message          -> stored AgentMessage
custom_message   -> CustomMessage
branch_summary   -> BranchSummaryMessage
compaction       -> CompactionSummaryMessage
context_edit      -> no message; modifies its selected target during projection
model_change     -> no message
thinking change  -> no message
usage            -> no message
custom           -> no message
label            -> no message
session_info     -> no message

The active message sequence for the example has this shape:

[
  UserMessage(e2),
  UserMessage(e6),
  AssistantMessage(e7: grep + read calls),
  ToolResultMessage(e8: grep),
  ToolResultMessage(e9: read),
  AssistantMessage(e10: new diagnosis)
]

The two user messages are consecutive because this example called branch("e2") and then appended below it. Provider-facing normalization happens later at convertToLlm; session projection preserves the Agent-message history rather than guessing a provider's wire rules.

Resolve model and thinking state

State extraction walks the complete selected path from root to leaf. thinkingLevel starts as "off". Every thinking_level_change overwrites it. model starts as null; every model_change overwrites it, and an assistant message also updates it from the provider and model that produced that response.

With a selected Virtual Model, the model_change entry is the session/tree state for that selection, while router state is stored separately in a custom entry named pi.virtual-model-state. Restore or branch selection reconstructs the registered virtual selection from the active path; if that Virtual Model is unregistered, Pi falls back to the latest physical response on the branch. The physical model used for each assistant turn is recorded on the assistant message. Compaction changes the canonical context projection; it does not replay a past routing decision or reroute stored turns.

e1 model_change anthropic/claude-sonnet-4-6 -> model = that pair
e7 assistant from the same pair             -> model = that pair
no thinking_level_change on the path        -> thinkingLevel = "off"

The last applicable entry on the selected path wins. Moving the leaf before a state-change entry removes that change from the path. Recording state transitions as entries therefore makes rewind reproduce historical state without mutating a global session setting.

Apply the latest CompactionEntry

buildContextEntries() finds the last CompactionEntry on the active path. Current Coding Agent v3 stores firstKeptEntryId; Pi Durable's separate compaction task uses its own entry and cut-point contract.

e1 user: old request
e2 assistant: old work
e3 user: recent request          <- firstKeptEntryId
e4 assistant: recent work
e5 compaction(summary, firstKeptEntryId: "e3")
e6 user: work after compaction

The projection places the compaction entry first, then entries from firstKeptEntryId up to the compaction, then entries after it:

[
  CompactionSummaryMessage(from e5),
  UserMessage(e3),
  AssistantMessage(e4),
  UserMessage(e6)
]

Raw entries e1 and e2 remain in JSONL. Navigating to a leaf before e5 selects a path without that compaction, so those messages can reappear. If firstKeptEntryId cannot be found on the selected path, the current implementation returns the summary and post-compaction entries without inventing a retained prefix.

6. Persist the tree as JSONL

The file representation is deliberately plain, but its two timestamp domains and lazy creation policy deserve careful handling.

Store one record per line

The first line is a v3 header. Every later line is one SessionEntry. This sample is valid JSONL; each physical line parses independently:

{"type":"session","version":3,"id":"01992742-9d1a-7aa0-b123-112233445566","timestamp":"2026-08-24T10:00:00.000Z","cwd":"/work/auth"}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"timestamp":"2026-08-24T10:00:05.000Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-08-24T10:23:00.000Z","message":{"role":"user","content":"Why does salt verification fail in src/auth.ts?","timestamp":1787566980000}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-08-24T10:23:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll inspect the verifier."},{"type":"toolCall","id":"call_001","name":"read","arguments":{"path":"src/auth.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":1250,"output":80,"cacheRead":0,"cacheWrite":0,"totalTokens":1330,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1787567010000}}
{"type":"message","id":"d4e5f6a7","parentId":"c3d4e5f6","timestamp":"2026-08-24T10:23:31.000Z","message":{"role":"toolResult","toolCallId":"call_001","toolName":"read","content":[{"type":"text","text":"export function verifySalt(...) { ... }"}],"isError":false,"timestamp":1787567011000}}
{"type":"message","id":"e5f6a7b8","parentId":"d4e5f6a7","timestamp":"2026-08-24T10:24:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"The comparison uses the wrong encoding."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":1400,"output":40,"cacheRead":0,"cacheWrite":0,"totalTokens":1440,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1787567040000}}
{"type":"message","id":"f6a7b8c9","parentId":"b2c3d4e5","timestamp":"2026-08-24T10:30:00.000Z","message":{"role":"user","content":"Inspect the hash implementation first.","timestamp":1787567400000}}

By default the directory is ~/.pi/agent/sessions/--<encoded-cwd>--/. newSession() names a file <ISO-with-colons-and-dots-replaced>_<sessionId>.jsonl. The default session ID is UUIDv7. A caller may supply an ID containing alphanumerics, ., _, and -, with an alphanumeric first and last character.

Entry IDs come from the first eight characters of a random UUID, with up to 100 collision checks against byId and a full-UUID fallback. Entry timestamps are ISO strings. Nested Pi AI message timestamps are numbers in Unix milliseconds. parentSession appears in a header created from another session by fork, clone, or newSession({ parentSession }).

JSONL makes the common write a line append instead of serializing one growing JSON array. It also exposes the branch: two records with the same parentId are siblings even when many physical lines separate them.

Delay disk creation until the conversation begins

Creating a SessionManager or session object allocates a prospective path and may append setup entries, but does not create the session file. In a normal prompt flow, the session file is created when the first user message is appended, before the provider produces an assistant response. Internally, _hasConversation() accepts either a user or assistant message, so a host that appends an assistant message directly also crosses the boundary. The policy in _persist() is:

User or assistant exists in fileEntriesflushedWrite behavior
NofalseKeep setup entries in memory; no file is created
NotrueAppend the current entry, an edge used after opening an already flushed file
YesfalseOpen the new path with "wx", write the header and every buffered entry, then set flushed
YestrueAppend only the current entry with appendFileSync

The delay prevents opening and closing Pi without chatting from leaving a setup-only file. Persisting from the first user message preserves the prompt when the first turn never produces an assistant response. It does not guarantee that every persisted turn is complete forever: a crash or provider failure may still leave a user message without a following assistant response.

isPersisted() reports whether the manager is configured for persistence. It does not check whether lazy creation has produced the file. A persisted manager can therefore return a prospective getSessionFile() path that does not exist yet.

Treat rewrites, partial tails, and backups explicitly

Normal writes append, but Coding Agent's SessionManager also rewrites:

  • Loading a v1 or v2 session migrates it to v3 and calls _rewriteFile() on the same path.
  • Opening an explicitly supplied empty file initializes and rewrites it.
  • createBranchedSession() constructs a new file from one path and resolved labels.
  • forkFrom() creates a new header with "wx" and appends every non-header source entry.

_rewriteFile() opens its target with "w", truncates it, and writes lines in a loop. It does not stage a sibling file, rename atomically, or create a backup. Copy a valuable session before migration or a bulk transform. “Append-only tree” describes the logical entry model; it is not a promise that the physical file is never rewritten.

The v3 loader reads UTF-8 in chunks, parses each complete line, and silently skips malformed lines. It also tries to parse a final unterminated fragment and skips it if parsing fails. It validates that the first parsed entry is a session header with a string ID, but it does not report every bad line or repair a torn tail. A parser built for audit or migration should be stricter than this runtime recovery behavior.

Coding Agent's manager uses synchronous file calls and has no file lock, fsync, compare-and-swap, or cross-process writer queue. Keep one writer per session file. list() and listAll() are read-side operations; metadata loading is capped at ten concurrent files, unreadable files are omitted, and results are sorted by derived activity time.

Pi Durable storage has its own commit and recovery contracts. Those contracts do not apply to Coding Agent's v3 SessionManager; evaluate each implementation at its own storage boundary.

Report a bug without assuming the transcript is public

/bug [description] prepares a private diagnostic bundle for Pi's developers. It collects environment, model, provider, Extension, and settings metadata with secret values redacted, plus recorded assistant-message and error diagnostics. Redaction reduces accidental exposure, but you should still inspect the report because prompts and Tool output can contain sensitive values that are not recognizable as credentials.

The confirmation flow lets you include or omit the session transcript. If you omit it, you can ask the current model to write a summary instead. Generating that summary sends selected transcript messages to the current model provider with your credentials and tokens. Pi attaches the generated summary to the bug report, not the transcript. Review an included transcript or generated summary before sharing it. A report may be uploaded to Radius, or exported as a local ZIP for inspection and manual transfer. In offline mode, local ZIP export remains available but Pi cannot upload the report to Radius.

Diagnostic boundaryModeResult
metadata.secretsallredacted
transcriptconsent: includeattached
transcriptconsent: omitnot attached
model summary requestconsent: model-writtentranscript sent to current provider
bug-report attachmentmodel-written summarysummary attached; transcript not attached
Radius uploadonlineallowed
Radius uploadofflineblocked
local ZIPonlineallowed
local ZIPofflineallowed
crash metadataall~/.pi/agent/crashes.json

Radius upload does not require login; an authenticated Radius session attributes the report so maintainers can follow up. After upload, Pi records the report ID in the session as a pi.bug-report entry. Process crashes are recorded separately in ~/.pi/agent/crashes.json, announced once at the next startup, and attached to the next report. That crash file is diagnostic state, not a substitute for the session JSONL.

Session reliability corrections retained in Pi 1.0.0

Four fixes tighten specific workflows without changing the storage model. Imported JSONL with the same filename as an existing destination now receives a numeric suffix instead of overwriting that file. Concurrent session shares do not overwrite one another. A fork now retains the applicable compaction boundary, so its reconstructed context respects the source checkpoint. An in-memory session fork requested before an active turn settles is handled only after runtime teardown has awaited the active response, preserving the completed or aborted turn before the manager is mutated.

These are collision and ordering fixes, not a new transaction layer. Coding Agent SessionManager still has the rewrite, backup, and one-writer limits above; imported content still needs trust-boundary validation, and a share destination is not a general concurrent session store.

7. Separate runtime ownership and use SessionManager

Pi 1.0.0 keeps the Coding Agent session APIs at the Coding Agent boundary. Pi Durable is a separate experimental substrate with its own conversations and Documents:

PropertyCoding Agent SessionManagerPi Durable experimental substrate
Public abstractionConcrete synchronous classHarness, Conversation, Session, and Storage contracts
Current entries11 SessionEntry types, including usage, context edits, labels, and session infoConversation entries plus separately addressed Documents
Storage schemav3 type: "session" JSONL header and ISO entry timestampsDurable entries, tasks, submissions, and document revisions
File implementationDirect fs calls inside SessionManagerStorage implementations selected for a Durable session
Memory implementationSessionManager.inMemory()MemoryStorage and createSession()
Custom backendNo storage-adapter injection pointImplement Durable Storage; this is not a SessionManager adapter

Agent Core's public Agent remains the lower-level in-memory execution loop. Coding Agent owns SessionManager, AgentSession, and AgentSessionRuntime, including the session projection and extension lifecycle described here. An external server or host can use Coding Agent's supported runtime APIs and persist its own FileEntry[]; it cannot pass a Pi Durable Storage into SessionManager. Durable's conversation, compaction task, and Documents follow separate contracts and do not replace this session guidance.

SessionManager is still useful outside the CLI because the package exports the class and its entry/context types. The main static methods are:

Static methodBehavior
create(cwd, sessionDir?, options?)Allocate a new persisted session and prospective file path
open(path, sessionDir?, cwdOverride?)Load one file, migrate old versions, build indices, and use the header cwd unless overridden
continueRecent(cwd, sessionDir?)Open the most recent matching session or create a new one
inMemory(cwd?, options?, entries?)Use the same tree behavior without a file; optionally restore an external FileEntry[]
forkFrom(sourcePath, targetCwd, sessionDir?, options?)Copy the source file's non-header entries under a new v3 header and parentSession
list(cwd, sessionDir?, onProgress?)Return project sessions asynchronously, newest derived activity first
listAll(onProgress?) or listAll(sessionDir?, onProgress?)Search all encoded project directories or one supplied directory

Its instance surface falls into four groups:

GroupCurrent methods
Lifecycle and identitynewSession(), setSessionFile(), createBranchedSession(), isPersisted(), usesDefaultSessionDir(), getCwd(), getSessionDir(), getSessionId(), getSessionFile()
AppendappendMessage(), appendThinkingLevelChange(), appendModelChange(), appendUsage(), appendCompaction(), appendContextEdit(), appendCustomEntry(), appendCustomMessageEntry(), appendLabelChange(), appendSessionInfo()
Tree and labelsgetLeafId(), getLeafEntry(), getEntry(), getChildren(), getBranch(), getTree(), getLabel(), branch(), resetLeaf(), branchWithSummary()
Projection and inspectionbuildContextEntries(), buildSessionProjection(), buildSessionContext(), getEntries(), getHeader(), getSessionName()

All append methods except appendUsage() return the new entry ID; appendUsage() returns its new UsageEntry. appendCompaction() and branchWithSummary() accept optional details, fromHook, and usage; summary generation belongs to AgentSession, not to the manager. getEntries() returns a new array containing the stored entry objects, so callers should treat those objects as read-only.

This copyable example uses only exported public methods:

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

const session = SessionManager.create(process.cwd());
const firstQuestionId = session.appendMessage({
  role: "user",
  content: "Review the authentication flow.",
  timestamp: Date.now(),
});

session.appendThinkingLevelChange("high");
session.branch(firstQuestionId);
session.appendMessage({
  role: "user",
  content: "Start with the password hash implementation.",
  timestamp: Date.now(),
});

const { messages, model, thinkingLevel } = session.buildSessionContext();
const sessions = await SessionManager.list(process.cwd());

console.log({
  messages: messages.length,
  model,
  thinkingLevel,
  sessions: sessions.length,
});

Several exported functions expose the machinery without a manager instance: buildContextEntries(), buildSessionProjection(), buildSessionContext(), sessionEntryToContextMessages(), parseSessionEntries(), migrateSessionEntries(), and getLatestCompactionEntry(). Internal class helpers such as _buildIndex(), _appendEntry(), _persist(), and _rewriteFile() implement storage policy and should not be treated as stable application APIs.

For externally owned persistence, call SessionManager.inMemory(cwd, { id: sessionId }, entries) with a well-formed FileEntry[]. This restores the append-only tree and its canonical provider projection, but never creates a Pi JSONL file or writes changes back to the external store. Navigate a live AgentSession through session.navigateTree(). After direct appends through session.sessionManager, call session.refreshContext() so public finalized messages are rebuilt from the manager. Replacing the underlying Agent's message array is not a session-context mutation API. The host owns validation, migration policy, snapshots, concurrency, and durable writes; parseSessionEntries() skips malformed JSON and is not a full schema validator, while migrateSessionEntries() mutates its input array.

Creation and opening carry details that affect callers. create() can return a manager whose getSessionFile() is only a prospective path, while newSession() can return that path directly, because writes are lazy. open() derives sessionDir from the file's parent unless one is supplied. continueRecent() filters by header cwd when a custom shared directory is used. list() and listAll() return SessionInfo metadata, not open managers; call open(info.path) to resume one.

8. Carry the design into another system

The session tree solves a specific local CLI problem, but its design questions travel well.

Keep medium and structure independent

DecisionQuestionCoding Agent answer
MediumWhere and how is durable data written?One local JSONL file per session, grouped by encoded cwd
StructureHow are histories related?Immutable historical entries linked to one parent
SelectionWhich history is active?An in-memory leaf and its root path
ProjectionWhat reaches the model?Compaction-aware entries converted to AgentMessage[]

A service may keep the same parent-linked shape in SQL. A small test may keep it entirely in memory. The tree does not depend on JSONL, and JSONL does not require a tree.

Use append-only history for rewind and comparison

Parent links preserve old attempts without copying them inside the same session. The cost is retained storage and linear scans for some views. The benefit is inspectable history and cheap pointer movement.

Define durability separately. Coding Agent persists the branch only when a new entry points at the selected parent. A system that must persist navigation itself needs an explicit pointer record or another transactional pointer store.

Store state changes where they take effect

Model and thinking changes belong on the history path because their meaning depends on position. A rewind should restore the settings that applied there. Coding Agent also uses assistant-message provider/model fields as model state during reconstruction, which makes a resumed path recover the model that produced its latest assistant response.

Apply the same test to other state: if moving to an older node should restore a value, record the change as a path event. Keep session-wide facts, such as the latest display name, outside branch-scoped reconstruction when their semantics are global.

The handoff for an implementation review is concrete: specify header and entry schemas, define the current-pointer durability rule, separate branch selection from branch extraction, document projection, decide how summaries enter context, and state rewrite, backup, and concurrency guarantees. A tree diagram alone leaves all six contracts unresolved.

9. Continue from the session boundary

Chapters 3 through 10 now connect the full runtime path: the loop emits messages, Tools add call/result pairs, context engineering bounds inputs, compaction appends a summary checkpoint, and session projection selects the branch used by the next model call.

Pi's extension system sits on both sides of this boundary. Extensions can append custom state, inject custom_message context, provide compaction or branch summaries, label entries, and observe navigation. The source files to read next are:

This chapter targets Pi 1.0.0 at commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32.

On this page