Persist sessions
Create, resume, inspect, branch, and safely retain append-only JSONL sessions.
Use SessionManager when a conversation must outlive the current process. A persistent manager owns a Pi session file and appends state as an AgentSession runs; an in-memory manager can instead project entries whose durable storage is owned by your host.
When you need this
- Resume a coding task after restarting a CLI, service, or worker
- Keep an auditable conversation and Tool history
- Rewind within one history or extract a selected path into a new session
The examples target Node.js >=22.19.0 and ESM. Install the SDK with npm install @earendil-works/pi-coding-agent@0.85.0 @earendil-works/pi-server@0.85.0, plus tsx, TypeScript, and Node types for the commands below.
Pi 0.85.0 packaging workaround: the published Coding Agent manifest omits this runtime dependency even though its public root export loads it. This workaround is release-scoped, not a permanent dependency rule for later Pi versions.
The session model
A persistent session is one append-only JSONL file, not one file per turn. Its first record is a header with a session id, timestamp, cwd, format version, and optional parentSession. Later records are tree entries. Every entry has its own id, a parentId, and a timestamp; messages, model changes, compaction, labels, and extension state can therefore share one history.
parentId links entries inside a file. parentSession records lineage between files created by extraction or forking. Read the current ID with getSessionId(); do not invent filenames or edit either relationship yourself.
By default, SDK-created files live under ~/.pi/agent/sessions/<encoded-cwd>/. SessionManager.create(cwd, sessionDir) and continueRecent(cwd, sessionDir) use the explicit second argument when supplied; otherwise they use that default. SessionManager.inMemory(cwd) keeps the same tree API without writing a file.
1. Start a session
With a model and credentials already configured, create the manager first and pass that exact instance into the agent:
import {
createAgentSession,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
const sessionDir = process.env.APP_SESSION_DIR;
const sessionManager = SessionManager.create(cwd, sessionDir);
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
cwd,
modelRuntime,
sessionManager,
});
try {
await session.prompt("Create a refactoring plan for this project.");
console.log(sessionManager.getSessionFile());
} finally {
session.dispose();
}Run it with npx tsx start-session.ts. The agent appends completed messages and state changes automatically; there is no save() call. A new manager can report its prospective path immediately, but Pi delays creating the file until the first assistant message arrives. An in-flight first response is therefore not yet durable.
2. Resume a session
continueRecent(cwd, sessionDir?) opens the newest session matching cwd, or prepares a new one when no matching session exists. With an explicit custom sessionDir, it filters candidate headers by their stored cwd; the default encoded-CWD directory is already project-scoped:
import {
createAgentSession,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
const sessionDir = process.env.APP_SESSION_DIR;
const sessionManager = SessionManager.continueRecent(cwd, sessionDir);
const modelRuntime = await ModelRuntime.create();
const { session, modelFallbackMessage } = await createAgentSession({
cwd,
modelRuntime,
sessionManager,
});
if (modelFallbackMessage) console.warn(modelFallbackMessage);
try {
await session.prompt("Continue with the first safe change.");
} finally {
session.dispose();
}Always surface modelFallbackMessage. It explains that a saved provider/model could not be restored and, when possible, names the replacement. The current API restores model and thinking changes recorded on the active branch; there is no pinModel session option.
To resume a chosen file, pass an absolute path from SessionManager.list() or listAll() to SessionManager.open(path). open() normally restores the cwd stored in the header. Its third argument is an explicit cwdOverride; use it only when you intentionally relocate the worktree. Validate user-supplied paths against an allowed session root before opening them.
Restore externally stored entries
Pi 0.85.0 can rebuild the Coding Agent tree from a FileEntry[] held in a database, object store, or another application-owned medium:
import {
type FileEntry,
SessionManager,
} from "@earendil-works/pi-coding-agent";
export function restoreExternalSessionEntries(
sessionId: string,
entries: FileEntry[],
cwd = process.cwd(),
): SessionManager {
return SessionManager.inMemory(cwd, { id: sessionId }, entries);
}Here the host owns the external storage lifecycle. SessionManager.inMemory(cwd, { id: sessionId }, entries) restores the append-only tree and its active leaf in process, but it does not create or append a Pi session file. New entries stay in that manager until the host reads getHeader() plus getEntries() and writes its own snapshot or append log. This overload is restoration, not a SessionStorage adapter and not automatic synchronization back to the external store.
The caller is responsible for supplying a well-formed FileEntry[]. A normal snapshot starts with one SessionHeader whose type is "session" and whose id, timestamp, cwd, and version have the published shapes; later SessionEntry records must have valid id, parentId, timestamp, discriminant, and payload fields. When a header is present its id becomes the restored manager ID; { id: sessionId } supplies the identity for a headerless entry list. The cwd argument is the live manager cwd, so validate it independently of any externally supplied header.
Do schema and authorization validation before construction. parseSessionEntries() is a permissive JSONL recovery helper: it skips malformed JSON lines and does not prove that parsed objects satisfy the FileEntry union. migrateSessionEntries() upgrades older versioned entries in place and therefore mutates the array; inMemory() also applies the supported v1-to-v2-to-v3 migration while loading a header. Copy data first if the external store needs the original representation, and treat unsupported or malformed shapes as an application migration error rather than relying on Pi for broad validation.
3. Branch a session
Choose between two operations:
branch(entryId)only moves the active leaf in the current manager. The next append becomes another child in the same file; existing entries remain untouched.createBranchedSession(leafId)copies the root-to-leaf path into a new file. On a persistent manager it also switches that manager to the new file and session ID.
A. Continue on a new branch in the same file. This program requires a configured model and credentials. It moves the leaf, then prompt() appends the divergent user and assistant messages through the same manager:
import { isAbsolute } from "node:path";
import {
createAgentSession,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";
const sessionPath = process.argv[2];
const checkpointId = process.argv[3];
if (!sessionPath || !isAbsolute(sessionPath) || !checkpointId) {
throw new Error(
"Usage: branch-in-place.ts /absolute/session.jsonl ENTRY_ID",
);
}
const sessionManager = SessionManager.open(sessionPath);
if (!sessionManager.getEntry(checkpointId)) {
throw new Error("Unknown entry ID");
}
sessionManager.branch(checkpointId);
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
modelRuntime,
sessionManager,
});
try {
await session.prompt("Explore the alternative approach from this checkpoint.");
console.log({
activeFile: sessionManager.getSessionFile(),
activePath: sessionManager.getBranch().map((entry) => entry.id),
});
} finally {
session.dispose();
}Run it with npx tsx branch-in-place.ts /absolute/session.jsonl ENTRY_ID.
B. Extract one path into a new file. Open the original path in a separate manager; do not reuse the manager from workflow A. Save its file and ID before extraction because the call switches that manager:
import { isAbsolute } from "node:path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
const sessionPath = process.argv[2];
const checkpointId = process.argv[3];
if (!sessionPath || !isAbsolute(sessionPath) || !checkpointId) {
throw new Error("Usage: extract-branch.ts /absolute/session.jsonl ENTRY_ID");
}
const manager = SessionManager.open(sessionPath);
if (!manager.getEntry(checkpointId)) throw new Error("Unknown entry ID");
const parentFile = manager.getSessionFile();
const parentSessionId = manager.getSessionId();
const extractedFile = manager.createBranchedSession(checkpointId);
if (!parentFile || !extractedFile) throw new Error("Persistent file required");
console.log({
parentFile,
parentSessionId,
extractedFile,
activeFile: manager.getSessionFile(),
activeSessionId: manager.getSessionId(),
});Run it with npx tsx extract-branch.ts /absolute/session.jsonl ENTRY_ID. forkFrom(sourcePath, targetCwd, sessionDir) is the cross-project alternative: it creates a new file and copies the source file's full non-header history, while recording the source path as parentSession. Pi 0.85.0 also preserves the applicable compaction boundary when a session path is forked, so the extracted context does not accidentally expose history that the source projection had already summarized.
4. Walk the tree
getEntries() returns every non-header entry. getBranch(leafId?) returns one root-to-leaf path. getTree() exposes all branches as nodes with children and resolved labels.
import { isAbsolute } from "node:path";
import {
SessionManager,
type SessionTreeNode,
} from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
const sessionDir = process.env.APP_SESSION_DIR;
const projectSessions = await SessionManager.list(cwd, sessionDir);
const searchableSessions = sessionDir
? await SessionManager.listAll(sessionDir)
: await SessionManager.listAll();
const selected = projectSessions[0] ?? searchableSessions[0];
if (!selected || !isAbsolute(selected.path)) throw new Error("No saved session");
const manager = SessionManager.open(selected.path, sessionDir);
console.log("cwd", manager.getCwd());
console.log("entries", manager.getEntries().length);
console.log("active path", manager.getBranch().map((entry) => entry.id));
function printTree(nodes: SessionTreeNode[], depth = 0): void {
for (const node of nodes) {
console.log(`${" ".repeat(depth)}${node.entry.type} ${node.entry.id}`);
printTree(node.children, depth + 1);
}
}
printTree(manager.getTree());list(cwd, sessionDir?) is project-scoped. With no argument, listAll() searches all encoded project directories under Pi's default session root. Its string argument is a session directory, not a cwd; do not write listAll(process.cwd()) unless the working directory really is the storage directory.
The following optional, no-secret fixture exercises the storage operations without contacting a provider. It asserts delayed file creation, create/open/continue/list, tree branching, path extraction, forkFrom(), and the manager switch after extraction. Run it with npx tsx verify-sessions.ts.
5. Privacy and cleanup
Treat a session file like source code plus operational logs. It may contain prompts, model output, Tool arguments and results, local paths, images, and extension data. Restrict the storage directory to the application account, validate paths at trust boundaries, encrypt backups when appropriate, and never commit sessions or credentials to a repository.
Retention, redaction, and backup schedules belong to the host application or operator; SessionManager does not expose the baseline guide's redact() or retention hooks. Stop and dispose the session before external maintenance. In the interactive /resume selector, Ctrl+D followed by confirmation deletes the selected session and uses the system trash command when available. For SDK cleanup, delete only the resolved file returned by getSessionFile() after verifying it is inside the intended session directory.
Back up files before upgrades or bulk cleanup. The loader automatically migrates v1 to v2 and v2 to v3 when opening an older valid session, rewriting it in the current format. Let SessionManager.open() perform that migration; do not parse, patch, or write version yourself.
Pitfalls
- Calling
save(): there is no current save step.AgentSessionappends completed events through its manager. - Expecting the file too early: a new persistent session is held in memory until its first assistant message. A crash can lose that in-flight response. Completed later entries are appended synchronously, while malformed JSONL lines—including a partial tail—are skipped on reload; this is recovery behavior, not an atomic-durability guarantee.
- Sharing one file between writers: the implementation has no inter-process locking. Use one live manager/process per file, and never edit a file while it is open. This follows from the synchronous append and rewrite paths rather than a concurrency contract.
- Confusing CLI and SDK storage rules: the CLI resolves
--session-dir, thenPI_CODING_AGENT_SESSION_DIR, thensessionDirinsettings.json. Direct SDK calls do not read that precedence chain; passsessionDirexplicitly or accept the default. - Passing a project path to
listAll(): its first string argument is a storage directory. Uselist(cwd)for one project or zero-argumentlistAll()for all default project directories. - Overriding
cwdaccidentally: prefer the absoluteSessionInfo.pathreturned by the list methods and letopen()restore the header's working directory. - Reading collision fixes as a locking guarantee: Pi 0.85.0 gives imported JSONL a suffixed destination when the same filename already exists, and concurrent session shares no longer overwrite one another. Neither fix adds inter-process locking to a live
SessionManagerfile; retain the one-writer rule above.
Next
- Chapter 10: Session Management explains the JSONL tree, compaction-aware projection, rewinds, and rewrite boundaries.
- Reference: Configuration lists the current session and resource settings.