Skip to content

5.5 Session 的创建、保存与恢复

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题:你和 Pi 聊完一轮、关掉终端,第二天 pi -c 又能接着聊——这中间发生了什么?会话(Session)存在哪、什么时候写盘、为什么它是一棵树而不是一条线、--continue / --resume / --fork 分别走哪条代码路径。 前置知识3.6 Agent Harness、Session 与状态5.2 一次普通请求的完整路径2.8 Node.js 文件、路径与进程 API学习目标:读完后你能 ① 说出会话文件的完整路径规则并在自己机器上找到它;② 逐行读懂一个 JSONL 会话文件;③ 指出「哪一行代码决定了这条消息什么时候落到磁盘」;④ 解释分支从哪里来、compaction 为什么不产生新文件;⑤ 对着源码说清 --continue--fork 的差别。

建立直觉:三层状态

先把「会话」这个词拆开。Pi 运行时同时存在三份看起来很像、但职责完全不同的数据:

📘 概念消息数组(agent.state.messages)
pi-agent-core 里 `Agent` 持有的当前上下文(Context)——一个扁平的消息数组,就是下一次调用大语言模型(LLM,Large Language Model)时要发出去的东西。它没有历史分叉,也不知道磁盘的存在。
📘 概念会话条目树(Session entry tree)
`SessionManager` 维护的一棵树。每个节点叫一个 **entry**(条目),除了包裹一条消息,还带 `id`、`parentId`、`timestamp`。模型切换、思考等级切换、上下文压缩(Context Compaction)、书签,都是树上的节点,而不是消息。
📘 概念JSONL 文件(JSON Lines file)
entry 树在磁盘上的表示:一个纯文本文件,每一行是一个独立的 JSON 对象。追加一个 entry = 追加一行。JSONL(JSON Lines)的好处是**可以只追加不重写**,崩溃时最多丢最后一行。

一个有用的类比:agent.state.messages 像 git 的工作区(只反映当前这一份),entry 树像 commit 图(有父指针、有分叉、只增不改),JSONL 文件像 .git 目录里的对象库。切换分支时,你不是修改历史,而是把「当前位置」这个指针挪到另一个节点,再让工作区按新位置重建——Pi 的 /tree 做的正是这件事。

一句话概括本章:Agent 自己不做任何持久化;持久化是 AgentSession 订阅 Agent 事件后、在事件回调里顺手做的事。

会话文件在哪里、长什么样

路径规则

默认目录是 ~/.pi/agent/sessions/--<编码后的cwd>--/。三段分别来自三处源码:~/.pi/agent 来自 getAgentDir()packages/coding-agent/src/config.ts:515-521,其中 .pi 来自 CONFIG_DIR_NAMEconfig.ts:491),sessions 来自 getSessionsDir()config.ts:559-561),最后一段是把当前工作目录(cwd)编码成一个安全的目录名:

earendil-works/pi@c13ffe1第 476–481 行在 GitHub 查看 ↗
cwd 编码规则:去掉开头的斜杠,再把所有 / \ : 换成短横线,前后各加两个短横线。

所以在 macOS 上 /Users/me/proj 会变成 --Users-me-proj--。这条规则是源码事实,packages/coding-agent/test/sdk-session-manager.test.ts:28-45 的用例 “uses agentDir for the default persisted session path” 把同一个正则原样写了一遍来断言路径,可以当作可执行的规格说明。

文件名则是「时间戳 + 会话 id」:

earendil-works/pi@c13ffe1第 930–956 行在 GitHub 查看 ↗
新建会话:造出 header 对象放进内存,并按「ISO 时间戳 + 下划线 + sessionId + .jsonl」算出文件路径。注意这里**没有**任何写文件的调用。
ts
// packages/coding-agent/src/core/session-manager.ts:936-954(节选)
const header: SessionHeader = {
    type: "session",
    version: CURRENT_SESSION_VERSION,
    id: this.sessionId,
    timestamp,
    cwd: this.cwd,
    parentSession: options?.parentSession,
};
this.fileEntries = [header];
// …(省略:清空 byId / labels 索引,leafId = null,flushed = false)
if (this.persist) {
    const fileTimestamp = timestamp.replace(/[:.]/g, "-");
    this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
}

ISO 时间戳里的 :. 会被换成 -(否则 Windows 上不是合法文件名)。会话 id 默认是 uuidv7(session-manager.ts:208-210),uuidv7 的前缀按时间递增,所以文件名和 id 都是天然有序的。

文件内容

第一行是 header,之后每行一个 entry,共用同一个基类:

earendil-works/pi@c13ffe1第 32–51 行在 GitHub 查看 ↗
header 结构与 entry 公共基类。当前版本 CURRENT_SESSION_VERSION = 3(session-manager.ts:30)。

SessionEntry 是一个可辨识联合(Discriminated Union),共 9 种,全部列在 session-manager.ts:144-153

type作用定义位置
message包裹一条 user / assistant / toolResult / bash 消息session-manager.ts:53-56
thinking_level_change记录思考等级切换session-manager.ts:58-61
model_change记录模型切换session-manager.ts:63-67
compaction上下文压缩产生的摘要节点session-manager.ts:69-80
branch_summary切换分支时对被放弃路径的摘要session-manager.ts:82-92
custom扩展(Extension)私有状态,不进模型上下文session-manager.ts:104-108
custom_message扩展注入的消息,会进模型上下文session-manager.ts:135-141
label给某个 entry 打书签session-manager.ts:111-115
session_info会话显示名session-manager.ts:118-121

entry 的 id 只有 8 个字符(randomUUID().slice(0, 8),冲突则重试,最多 100 次,session-manager.ts:221-228)——它只需要在单个文件内唯一,不需要全局唯一。

下面是一个最小但合法的会话文件(本书为教学构造,不是运行输出;本章末尾的实践任务会用到它):

json
{"type":"session","version":3,"id":"01930000-0000-7000-8000-000000000001","timestamp":"2026-07-30T10:00:00.000Z","cwd":"/tmp/demo"}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"timestamp":"2026-07-30T10:00:00.100Z","provider":"anthropic","modelId":"claude-sonnet-4"}
{"type":"thinking_level_change","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-07-30T10:00:00.200Z","thinkingLevel":"off"}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-07-30T10:00:01.000Z","message":{"role":"user","content":"Hello, session!","timestamp":1785000001000}}

为什么真实会话的前两个 entry 一定是 model_changethinking_level_change?因为新建会话时 SDK 主动写了它们,好让下次恢复能还原模型与思考等级:

earendil-works/pi@c13ffe1第 362–374 行在 GitHub 查看 ↗
恢复分支:把历史消息灌回 Agent;新建分支:先写入 model_change + thinking_level_change 两个 entry。

什么时候写盘

事件驱动:message_end 先写盘,再转发

AgentSession 在构造时订阅 Agent 的事件流(agent-session.ts:393),持久化就发生在这个回调里。关键顺序是:先交给扩展 → 再广播给界面 → 然后才写 sessionagent-session.ts:618-625)。

earendil-works/pi@c13ffe1第 595–643 行在 GitHub 查看 ↗
Agent 事件处理器。message_end 时按 role 分派:custom 走 appendCustomMessageEntry,user/assistant/toolResult 走 appendMessage。
ts
// packages/coding-agent/src/core/agent-session.ts:624-643(节选)
// Handle session persistence
if (event.type === "message_end") {
    if (event.message.role === "custom") {
        this.sessionManager.appendCustomMessageEntry(/* …(省略:4 个参数) */);
    } else if (
        event.message.role === "user" ||
        event.message.role === "assistant" ||
        event.message.role === "toolResult"
    ) {
        // Regular LLM message - persist as SessionMessageEntry
        this.sessionManager.appendMessage(event.message);
    }
    // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere

message_end 由 Agent Loop(Agent 循环)在四个位置发出:用户 prompt 入队时(packages/agent/src/agent-loop.ts:113)、assistant 消息定稿时(agent-loop.ts:357agent-loop.ts:370)、工具结果产生时(agent-loop.ts:791)。也就是说,一轮对话里每出现一条消息就写一次,不是等整轮结束才批量写。

注释最后一行点出了三个例外:bash 执行消息、compaction 摘要、分支摘要不走这里。它们各有专门入口——bash 在 agent-session.ts:28252860 直接 appendMessage,compaction 在 agent-session.ts:1872(手动 /compact)与 2153(自动触发)调 appendCompaction,分支摘要经 branchWithSummaryagent-session.ts:3040)。

延迟建文件:没有回答就不留垃圾

appendMessage_appendEntrysession-manager.ts:1044-1049)→ _persist。真正碰磁盘的只有最后一个方法,而它藏着一个容易忽略的工程细节:

earendil-works/pi@c13ffe1第 1015–1042 行在 GitHub 查看 ↗
延迟落盘:只要这个会话里还没有出现过 assistant 消息,entry 就只留在内存;第一条 assistant 消息到达时用 wx 一次性写出全部 entry,之后逐行追加。
ts
// packages/coding-agent/src/core/session-manager.ts:1015-1042(节选)
_persist(entry: SessionEntry): void {
    if (!this.persist || !this.sessionFile) return;

    const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
    if (!hasAssistant) {
        if (this.flushed) {
            appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
        } else {
            this.flushed = false; // 等 assistant 到来时统一写出
        }
        return;
    }

    if (!this.flushed) {
        const fd = openSync(this.sessionFile, "wx"); // wx:文件已存在则报错
        // …(省略:for 循环把 fileEntries 全部 writeFileSync 后 closeSync)
        this.flushed = true;
    } else {
        appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
    }
}

效果是:你启动 pi、敲了一句话就 Ctrl+C,磁盘上不会留下任何文件。这个契约在别处也被依赖——/fork 遇到尚未落盘的会话时会明确报错「Wait for the first assistant response before cloning or forking it.」(packages/coding-agent/src/core/agent-session-runtime.ts:312-315)。

一种看法是:这个设计让会话列表保持干净,代价是 SessionManager 多背了一个 flushed 状态位,而且「文件路径已算出但文件还不存在」成了一个必须处理的中间态(agent-session-runtime.ts:312existsSync 检查就是为它准备的)。

对照:pi-agent-core 的新 harness 走的是另一条路

仓库里还有第二套会话子系统:packages/agent/src/harness/session/ 下的 Session + SessionStorage。它给 AgentHarness 使用,磁盘布局与上面刻意保持一致,但写盘时机不同

earendil-works/pi@c13ffe1第 538–556 行在 GitHub 查看 ↗
harness 侧:message_end 立即 appendMessage;其它变更先排进 pendingSessionWrites 队列,等 turn_end 统一 flush,然后发出 save_point 事件。

message_end 立刻写、其余变更(模型切换、工具集变化、leaf 移动等)先进 pendingSessionWrites 队列,在 turn_endflushPendingSessionWritesagent-harness.ts:512-536)一次性落盘,随后广播一个 save_point 事件告诉外界「到这里为止磁盘和内存是一致的」。agent_end 时再兜底 flush 一次(agent-harness.ts:557-558)。

从源码结构看,这两套实现互不引用:今天 pi 命令行界面(CLI,Command-Line Interface)用的是 SessionManager,harness 那套服务于 pi-agent-core 的新接口。据此推断(尚未在源码中直接证实)CLI 未来会迁移过去——仓库里没有任何迁移 TODO 或引用可以佐证这个方向。

图加载中…

图 5.5-1 一个会话文件的完整生命周期
从左上到右下按时间读。菱形是 `_persist` 里唯一的分支判断,它解释了「为什么只发了 prompt 的会话不会留下文件」。虚线以下(K 起)是下一次启动时的恢复路径,本章后半段展开。

图里每个节点都能在源码中找到对应:newSessionsession-manager.ts:930)、_persist1015)、findMostRecentSession635)、loadEntriesFromFile514)、migrateToCurrentVersion281)、_buildIndex958)、buildSessionContext461)。特别注意 F 这个菱形只看内存里的 fileEntries,不看磁盘——所以 SessionManager.open 打开一个已存在的文件后 flushed 直接被置为 truesession-manager.ts:922),后续 entry 立即追加,不再走缓冲逻辑。

树、分支与 compaction

parentId 就是全部

SessionManager 的类注释把模型讲得很清楚(session-manager.ts:844-854):每个 entry 有 idparentId,「leaf」指针表示当前位置,追加就是给当前 leaf 挂一个孩子。第一个 entry 的 parentIdnull

分支只有一个动作——把 leaf 指针往回挪

earendil-works/pi@c13ffe1第 1354–1374 行在 GitHub 查看 ↗
branch 把 leaf 移到某个更早的 entry,下一次 append 自然成为新分支;resetLeaf 把 leaf 置空,下一个 entry 成为新的根。历史一律不修改、不删除。

分支在 Pi 里有三个来源:

  1. /tree 导航AgentSession.navigateTreeagent-session.ts:2895)根据用户选择调 branch / resetLeaf / branchWithSummarysession-manager.ts:1381-1405),后者会额外追加一个 branch_summary entry 记录被放弃那条路径的摘要,然后用 buildSessionContext 重建消息数组(agent-session.ts:3068)。整个过程在同一个文件里完成。
  2. /fork/cloneAgentSessionRuntime.forkagent-session-runtime.ts:262)底层调 createBranchedSessionsession-manager.ts:1412-1512),把「根 → 指定 leaf」这一条单链抽出来写成新文件,新文件 header 的 parentSession 指向旧文件。
  3. 命令行 --forkSessionManager.forkFromsession-manager.ts:1579-1630)把源文件的全部 entry 复制到新 cwd 下的新文件,同样用 parentSession 记录来源。

上下文压缩不产生分支appendCompactionsession-manager.ts:1097-1119)只是在当前路径上挂一个 compaction 节点,记录摘要、firstKeptEntryId 和压缩前的 token 数。重建上下文时由 buildContextEntries 做折叠:

ts
// packages/coding-agent/src/core/session-manager.ts:441-453(节选)
const contextEntries: SessionEntry[] = [compaction];
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
    const entry = path[i];
    if (entry.id === compaction.firstKeptEntryId) {
        foundFirstKept = true;
    }
    if (foundFirstKept) {
        contextEntries.push(entry);
    }
}
contextEntries.push(...path.slice(compactionIdx + 1));
return contextEntries;

也就是:压缩点之前只保留从 firstKeptEntryId 起的尾巴,其余用一条摘要消息代替;压缩点之后的全部保留。旧 entry 仍然躺在文件里,只是不再进入发给模型的上下文。压缩机制本身留到 6.6 Context 构造与 Compaction 展开。

图加载中…

图 5.5-2 一个含分支与压缩的 entry 树
箭头方向是「父 → 子」,实际存储里每个节点只记录反向的 parentId。header 用虚线连接,因为它不是树节点,只是文件第一行。

读这张图时请关注三点。第一,U1 有两个孩子(A1BS),这就是分支——它由一次 branchWithSummary(U1, ...) 造成,被放弃的 U2/A2 原封不动留在文件里。第二,branch_summary树上的节点而不是消息里的一段文字,所以下次加载能被 sessionEntryToContextMessagessession-manager.ts:401-403)还原成一条摘要消息。第三,compaction 挂在当前路径末端,它不新开分支;buildContextEntries 会据此把 M1U1 这段折叠掉。

leaf 指针没有存进文件

一个容易踩的点:coding-agent 这套实现不持久化 leaf。重新加载时,_buildIndex 简单地把 leaf 设成文件里最后一个 entry:

earendil-works/pi@c13ffe1第 958–977 行在 GitHub 查看 ↗
重建索引:遍历所有 entry 建 byId 映射,顺带把 leafId 覆盖为最后一个 entry 的 id;label entry 另外维护一张表。

因为 append-only 追加时 leaf 总是跟着最后一行走,这个近似在正常流程里是准确的。相对地,harness 那套实现引入了第 10 种 entry——leaf(类型定义 packages/agent/src/harness/types.ts:448-451),setLeafId 会真的往文件里写一行 {"type":"leaf","targetId":...}packages/agent/src/harness/session/jsonl-storage.ts:254-272),重放文件即可恢复当前位置。

⚠️ 常见误解以为 JSONL 的最后一行就是「当前对话的最后一条消息」
在有分支的会话里不成立。最后一行只是最后写入的 entry;当前对话路径要从 leaf 沿 parentId 回溯到根才能得到(`buildSessionPath`,`session-manager.ts:334`)。本章实践任务会让你亲眼看到这个差别。

恢复:五条启动路径

命令行参数在 main()packages/coding-agent/src/main.ts:521)里被解析后,会话的构造统一交给一个函数:

packages/coding-agent/src/main.ts · createSessionManager
earendil-works/pi@c13ffe1第 312–403 行在 GitHub 查看 ↗
按 flag 分派出五类 SessionManager:内存态、fork、指定文件、交互选择、继续最近,以及兜底的新建。分派顺序即优先级。
ts
// packages/coding-agent/src/main.ts:312-403(骨架,省略错误处理与 id 冲突检查)
async function createSessionManager(parsed, cwd, sessionDir, settingsManager) {
    if (parsed.noSession || parsed.help || parsed.listModels !== undefined) {
        return SessionManager.inMemory(cwd, /* …(省略:sessionId) */);
    }
    if (parsed.fork) {
        // …(省略:resolveSessionPath 把 id / 部分 uuid / 路径解析成文件路径)
        return forkSessionOrExit(resolved.path, cwd, sessionDir, parsed.sessionId);
    }
    if (parsed.session) {
        // …(省略:同项目直接 open;跨项目询问是否 fork 过来)
        return openSessionOrExit(resolved.path, sessionDir);
    }
    if (parsed.resume) {
        const selectedPath = await selectSession(/* …(省略:列表与进度回调) */);
        return SessionManager.open(selectedPath, sessionDir);
    }
    if (parsed.continue) {
        return SessionManager.continueRecent(cwd, sessionDir);
    }
    // …(省略:--session-id 命中已有会话则 open)
    return SessionManager.create(cwd, sessionDir, { id: parsed.sessionId });
}

五条路径的差别:

参数入口方法行为是否新文件
--no-sessioninMemorysession-manager.ts:1568-1570全程只在内存里,persist=false无文件
--fork <path|id>forkFrom1579-1630复制全部 entry 到新文件,header 记 parentSession
--session <path|id>open1530-1550在原文件上继续追加
--resume / -r选择器 → open同上,先弹交互列表
--continue / -ccontinueRecent1557-1565找最近修改的文件再 open;找不到就新建否(除非没有)

continueRecent 的「最近」是按文件修改时间(mtime)排序的,并且只读每个候选文件的第一行 header 来做筛选,不整文件加载(findMostRecentSessionsession-manager.ts:635-656)。会话多了以后这个优化很关键。

open 之后的加载链条是:_setSessionFile895-928)→ loadEntriesFromFile514-556,流式按行 JSON.parse坏行直接跳过而不是整体失败)→ migrateToCurrentVersion281-291)→ 若发生迁移则 _rewriteFile 整文件重写 → _buildIndex。版本迁移有两级:v1→v2 补上 id/parentId 把线性序列变成树(231-257),v2→v3 把旧的 hookMessage role 改名为 custom260-275)。

最后一步由 SDK 完成:createAgentSessionpackages/coding-agent/src/core/sdk.ts:169)先 sessionManager.buildSessionContext()sdk.ts:188)拿到消息、思考等级与模型,尝试还原模型(sdk.ts:196-204),再把消息灌回 Agent(sdk.ts:364)。

🌱 初学者提示cwd 消失了怎么办
会话 header 里存着创建时的 cwd。如果那个目录已经被删掉,`assertSessionCwdExists`(`packages/coding-agent/src/core/session-cwd.ts:54-59`)会抛出 `MissingSessionCwdError`,由 `main.ts:638` 附近询问用户后用 `cwdOverride` 重新打开。相关用例见 `packages/coding-agent/test/session-cwd.test.ts`。

完整调用链

把两条主链路写全,方便你自己用全文搜索复核:

写盘链runAgentLoopmessage_endpackages/agent/src/agent-loop.ts:113357370791) → AgentSession._handleAgentEvent(订阅点 packages/coding-agent/src/core/agent-session.ts:393,定义 agent-session.ts:595,持久化分支 agent-session.ts:624-643) → SessionManager.appendMessagesession-manager.ts:1057-1067) → _appendEntrysession-manager.ts:1044-1049) → _persistsession-manager.ts:1015-1042) → appendFileSync / openSync("wx")

恢复链mainpackages/coding-agent/src/main.ts:521) → createSessionManagermain.ts:312-403) → SessionManager.continueRecentsession-manager.ts:1557-1565) → findMostRecentSessionsession-manager.ts:635-656) → 私有构造器(session-manager.ts:868-888)→ _setSessionFile895-928)→ loadEntriesFromFile514-556)+ migrateToCurrentVersion281-291)+ _buildIndex958-977) → createAgentSessionpackages/coding-agent/src/core/sdk.ts:169) → buildSessionContextsdk.ts:188session-manager.ts:461-470) → agent.state.messages = existingSession.messagessdk.ts:364)。

图加载中…

图 5.5-3 一轮对话里消息落盘的先后顺序
按时间自上而下。注意第一条用户消息并没有立刻触达磁盘,直到 assistant 消息到达才连同它一起写出——这正是 `_persist` 里 `hasAssistant` 判断的效果。

这张图对应的源码:左侧两条 message_end 来自 agent-loop.ts:113agent-loop.ts:370,工具结果那条来自 agent-loop.ts:791SessionManager 那一列的三次动作全在 session-manager.ts:1015-1067 之间。如果你在 5.3 一次 Tool Call 的完整循环 里数过一轮里有几条消息,就能预判文件里会多出几行。

与官方文档对照

官方文档 packages/coding-agent/docs/session-format.md 是这套格式的权威说明,packages/coding-agent/docs/sessions.md 是面向用户的功能说明。逐条核对后,一致的部分包括:文件位置与 cwd 编码规则、v1/v2/v3 版本历史与自动迁移、8 字符 entry id、9 种 entry 的 JSON 示例、树结构与 leaf 的描述、buildContextEntries 的压缩折叠规则、SessionManager 的 API 清单,以及 sessions.md/tree(同文件分支)与 /fork/clone(新文件)的对照表。

需要注意的不一致有三处(均为本书核对源码后的发现):

  1. retainedTail 只存在于 harness 实现。官方文档说明「较新的 harness 生成的 compaction 会把保留的尾部上下文直接嵌在 entry 上」(session-format.md:237session-format.md:245),并在 Context Building 一节声称会据此重建上下文(session-format.md:327session-format.md:342)。但 coding-agent 的 CompactionEntrysession-manager.ts:69-80没有 retainedTail 字段,全 packages/coding-agent/src 搜索该词 0 命中;它只在 harness 侧实现(类型 packages/agent/src/harness/types.ts:403-412,读路径 packages/agent/src/harness/session/jsonl-storage.ts:350-369)。据此推断(尚未在源码中直接证实):用 CLI 的 SessionManager 打开一个含 retainedTail 的文件,压缩点之后本应保留的尾部消息不会进入上下文。
  2. leafactive_tools_change 两种 entry、以及 header 的 metadata 字段没有出现在文档的 Entry Types 清单里,但 harness 的 JSONL 会写入它们。对「SessionManager 实现」而言文档没写错,但作为通用的「Session File Format」文档并不完整。
  3. 两处 API 签名漏了参数:文档写 appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)session-format.md:410)与 branchWithSummary(entryId, summary, details?, fromHook?)session-format.md:426),源码里两者都还有末尾的 usage?: Usagesession-manager.ts:1097-11041381-1387)。

实践任务

🛠 实践任务手写一个最小 session.jsonl 并导出成 HTML

目标:不依赖任何 API Key,亲手造出一个合法的会话文件,用 pi --export 把它渲染成 HTML,并从导出结果里读出 Pi 是怎么理解这棵树的。

为什么无需 Key--export 分支在 packages/coding-agent/src/main.ts:578-590,位于模型选择与会话创建之前,处理完就 process.exit(0)。它只调用 exportFromFilepackages/coding-agent/src/core/export-html/index.ts:288-316),全程不接触 Provider(模型服务提供方)。

步骤 1 · 造文件。在任意空目录建 demo.jsonl,写入下面 5 行(每行必须是完整的一行 JSON,不能换行):

{"type":"session","version":3,"id":"01930000-0000-7000-8000-000000000001","timestamp":"2026-07-30T10:00:00.000Z","cwd":"/tmp/demo"}
{"type":"model_change","id":"a1b2c3d4","parentId":null,"timestamp":"2026-07-30T10:00:00.100Z","provider":"anthropic","modelId":"claude-sonnet-4"}
{"type":"thinking_level_change","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-07-30T10:00:00.200Z","thinkingLevel":"off"}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-07-30T10:00:01.000Z","message":{"role":"user","content":"Hello, session!","timestamp":1785000001000}}
{"type":"message","id":"d4e5f6a7","parentId":"c3d4e5f6","timestamp":"2026-07-30T10:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi from a hand-written session file."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4","usage":{"input":10,"output":8,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1785000002000}}

步骤 2 · 导出--no-envpi-test.sh 提供的开关,会先清空所有 API Key 环境变量):

<pi 仓库根>/pi-test.sh --no-env --export demo.jsonl demo.html

预期现象(本书在 pi@c13ffe18 上真实运行所得):

Running without API keys...
Exported to: demo.html

当前目录出现一个约 260 KB 的 demo.html(本书实测 269314 字节;模板与内嵌的两个 vendor 脚本占了绝大部分体积)。页面内容由内嵌脚本在浏览器里渲染,因此这一步的正确性用下面的步骤 3 来判定,比肉眼看页面更可靠。原始的 demo.jsonl 不会被修改——exportFromFile 只做打开与读取,本书实测导出前后该文件的修改时间未变。

步骤 3 · 读出 Pi 眼中的树。会话数据是被 base64 编码后塞进 HTML 的(export-html/index.ts:160),所以直接 grep 原文搜不到消息内容——这本身就是一个值得注意的观察点。用下面的脚本解码:

python3 -c "
import re,base64,json
d=open('demo.html',encoding='utf-8').read()
m=re.search(r'<script id=\"session-data\" type=\"application/json\">([A-Za-z0-9+/=]+)</script>',d)
data=json.loads(base64.b64decode(m.group(1)))
print('leafId:',data['leafId'])
for e in data['entries']: print(' ',e['type'],e['id'],'<-',e['parentId'])
"

输出应为 leafId: d4e5f6a7,以及 4 个 entry(header 不算 entry),parentId 串成一条链。

步骤 4 · 造一个分支。往 demo.jsonl 末尾再追加一行 assistant 消息,但把它的 parentId 设成 "c3d4e5f6"(即挂在用户消息下、与 d4e5f6a7 平级),id"e5f6a7b8"。重新导出后再跑步骤 3 的脚本。

如何判断成功:你能解释为什么 leafId 变成了 e5f6a7b8——不是因为它「更深」,而是因为 _buildIndexsession-manager.ts:958-977)无脑把 leaf 设成文件里最后一个 entry。这就直接验证了「leaf 指针不持久化」这个结论。

常见错误:① JSON 写成多行——loadEntriesFromFile 按行解析,坏行会被静默跳过(session-manager.ts:503-511),表现为 entry 数量对不上;② 首行不是合法 header——整个文件会被判为非 pi 会话并返回空数组(session-manager.ts:548-553),导出时报错;③ 忘了 assistant 消息的 usage/stopReason 等必填字段,渲染可能异常。

顺带一提:交互模式里也有 /export/import 两个斜杠命令(真实采集见 research/cli-captures/pi-tui-slash.txt),前者默认导出 HTML,后者从 JSONL 导入并恢复一个会话。

对应源码位置main.ts:578-590(--export 分支)、export-html/index.ts:288-316(exportFromFile)、export-html/index.ts:143-175(generateHtml 与 base64 内嵌)、session-manager.ts:514-556(加载与校验)、session-manager.ts:958-977(leaf 重建)。

补充验证:想确认这些行为不是本书杜撰,可以直接跑 npm test --workspace=@earendil-works/pi-coding-agent -- test/session-manager/。本书在锁定 commit 上实测 7 个测试文件、97 个用例全部通过,且不需要任何 API Key。

本章小结

  • 会话有三层表示:agent.state.messages(当前上下文)→ entry 树(带 id/parentId)→ JSONL 文件(一行一个 entry)。Agent 自己不持久化,AgentSession 在事件回调里代劳。
  • 文件位置 ~/.pi/agent/sessions/--<编码 cwd>--/<时间戳>_<id>.jsonl;第一行是 header,其后 9 种 entry 组成可辨识联合。
  • 写盘由 message_end 事件驱动,一条消息一行;但 _persist 会延迟到第一条 assistant 消息才真正创建文件,所以「只问不答」的会话不留垃圾。harness 那套则是 message_end 立即写、其余变更在 turn_end 统一 flush 并发出 save_point
  • 树是 append-only 的:分支 = 把 leaf 指针往回挪(/tree),或把一条路径抽成新文件(/fork/clone--fork)。compaction 只是路径上的一个折叠节点,不产生分支也不产生新文件。
  • 恢复有五条路径,全部汇聚在 createSessionManager--continue 按 mtime 找最近文件,--resume 弹选择器,--session 直接打开,--fork 复制成新文件,--no-session 完全不落盘。
  • 官方 session-format.md 与实现有三处出入,最重要的是 retainedTail 描述的其实是 harness 行为,CLI 的 SessionManager 并不认识这个字段。
  • 关键术语:会话(Session)、entry(会话条目)、JSONL(JSON Lines)、leaf(当前叶子指针)、branch(分支)、上下文压缩(Context Compaction)、append-only(只追加)。
  • 关键源码索引packages/coding-agent/src/core/session-manager.tsgetDefaultSessionDirPath:476newSession:930_buildIndex:958_persist:1015appendMessage:1057branch:1360branchWithSummary:1381createBranchedSession:1412open:1530continueRecent:1557forkFrom:1579)、packages/coding-agent/src/core/agent-session.ts:595packages/coding-agent/src/core/sdk.ts:362-374packages/coding-agent/src/main.ts:312-403packages/agent/src/harness/agent-harness.ts:512-556;测试 packages/coding-agent/test/session-manager/tree-traversal.test.tsfile-operations.test.tspackages/coding-agent/test/sdk-session-manager.test.tspackages/agent/test/harness/session.test.ts
  • 自测问题:① 你运行 pi、输入一句话、模型还没回答就按了 Ctrl+C,~/.pi/agent/sessions/ 下会多出文件吗?说出决定这一点的那个变量名。② /tree 切到旧节点继续聊,会新建一个 .jsonl 吗?/clone 呢?③ 一个含分支的会话文件,最后一行一定属于当前对话路径吗?④ 为什么新会话的前两个 entry 总是 model_changethinking_level_change
  • 下一章5.6 取消与错误处理——当你按下 Esc、或模型中途报错时,已经写进 session 的那些行会怎样。
  • 尚未展开:会话存储的另一套抽象(SessionStorage / SessionRepo 接口、内存后端、SQLite 后端)留给 6.5 Session 存储格式与会话树;压缩摘要如何生成留给 6.6 Context 构造与 Compaction/resume 选择器的界面实现留给 6.9 pi-tui:终端界面库

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)