Skip to content

5.5 Session 的创建、保存与恢复 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:你和 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)时要带上的那份历史。它没有历史分叉,也不知道磁盘的存在。在 coding-agent 里,它只是会话条目树算出来的一份**运行时副本**:每次请求模型之前,`AgentSession` 都会按会话重新算一遍消息、替换掉请求上下文,所以直接给它赋值并不会改变后续请求。
📘 概念会话条目树(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 做的正是这件事。这个类比在 Pi 里还有一层意思:真正说了算的是 commit 图。发给模型的历史每次都从 entry 树重新算出,工作区只是它的镜像。

一句话概括本章:Agent 自己不做任何持久化;持久化是 AgentSession 订阅 Agent 事件后、在事件回调里顺手做的事,而下一次请求带哪些历史,又反过来由这份持久化的会话决定。

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

路径规则 ​

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

earendil-works/pi@16787ad第 589–594 行在 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@16787ad第 1057–1083 行在 GitHub 查看 ↗
新建会话:造出 header 对象放进内存,并按「ISO 时间戳 + 下划线 + sessionId + .jsonl」算出文件路径。注意这里**没有**任何写文件的调用。
ts
// packages/coding-agent/src/core/session-manager.ts:1063-1081(节选)
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:264-266),uuidv7 的前缀按时间递增,所以文件名和 id 都是天然有序的。

文件内容 ​

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

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

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

type作用定义位置
message包裹一条 system / user / assistant / toolResult / bash 等消息session-manager.ts:64-67
thinking_level_change记录思考等级切换session-manager.ts:69-72
model_change记录模型切换session-manager.ts:74-78
usage记录不属于任何 assistant 消息的模型用量(例如缓存预热),不进模型上下文session-manager.ts:80-89
compaction上下文压缩产生的摘要节点session-manager.ts:91-104
branch_summary切换分支时对被放弃路径的摘要session-manager.ts:106-116
custom扩展(Extension)私有状态,不进模型上下文session-manager.ts:128-132
custom_message扩展注入的消息,会进模型上下文session-manager.ts:159-165
context_edit对更早某个 entry 的「上下文编辑」:从此以后省略它,或替换它的内容session-manager.ts:175-180
label给某个 entry 打书签session-manager.ts:135-139
session_info会话显示名session-manager.ts:142-145

context_edit 值得多说一句。它的 replacement 为 null 时,目标消息从后续发给模型的上下文里消失;不为 null 时,只替换目标消息的内容。原始那条 entry 一个字节都不改,会话文件、界面历史、用量统计里都还是它原来的样子——这就是「只追加」结构下做「修改」的办法。5.6 讲的自动重试就用它省略失败的那次尝试(appendContextEdit,session-manager.ts:1358-1398)。

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

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

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_change 和 thinking_level_change?因为新建会话时 SDK 主动写了它们,好让下次恢复能还原模型与思考等级:

earendil-works/pi@16787ad第 402–413 行在 GitHub 查看 ↗
恢复已有会话:只在缺少思考等级记录时补一条;新建会话:先写入 model_change + thinking_level_change 两个 entry。

恢复已有会话时,历史消息则作为初始状态交给 Agent(sdk.ts:366-372)。

什么时候写盘 ​

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

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

earendil-works/pi@16787ad第 894–943 行在 GitHub 查看 ↗
Agent 事件处理器。message_end 时按 role 分派:custom 走 appendCustomMessageEntry,system/user/assistant/toolResult 走 appendMessage,并记下「这条消息对应哪个 entry id」。
ts
// packages/coding-agent/src/core/agent-session.ts:921-943(节选)
// Handle session persistence
if (event.type === "message_end") {
    let entryId: string | undefined;
    if (event.message.role === "custom") {
        entryId = this.sessionManager.appendCustomMessageEntry(/* …(省略:4 个参数) */);
    } else if (
        event.message.role === "system" ||
        event.message.role === "user" ||
        event.message.role === "assistant" ||
        event.message.role === "toolResult"
    ) {
        // Regular LLM message - persist as SessionMessageEntry
        entryId = this.sessionManager.appendMessage(event.message);
    }
    if (entryId) this._entryIdsByMessage.set(event.message, entryId);
    // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere

message_end 由 Agent Loop(Agent 循环)在这几处发出:用户 prompt 入队时(packages/agent/src/agent-loop.ts:120)、steering / follow-up 等排队消息注入时(agent-loop.ts:212)、assistant 消息定稿时(agent-loop.ts:451 与 agent-loop.ts:464)、工具结果产生时(agent-loop.ts:897)。也就是说,一轮对话里每出现一条消息就写一次,不是等整轮结束才批量写。

注意分派条件里的 system。系统提示词和工具声明本身也是对话记录里的 system 消息(5.3 讲过 declareToolChanges),它们同样经过 message_end,于是也会作为 message entry 写进会话。从源码结构看,一个真实会话文件在第一条 user 消息之前,通常会先出现一条 role 为 system 的 message entry,里面装着这次运行的提示词分段与工具定义(本书没有为此单独采集运行输出)。

为什么这里的写盘必须及时?因为在 coding-agent 里,下一次请求的历史就是从会话里算出来的。AgentSession 给 Agent 挂了一个 prepareRequest 钩子,每次请求模型之前都用 sessionManager.buildSessionProjection().messages 替换请求上下文(agent-session.ts:608-633,在构造函数 :437 安装);而 Agent 会逐个 await 事件监听器(packages/agent/src/agent.ts:605-607),所以消息先落进会话,下一次请求才能看到它。这也是为什么官方 CHANGELOG 在 0.87.0 专门提醒:直接给 session.agent.state.messages 赋值不再改变后续请求历史,要改就通过 session.sessionManager 追加,再调 session.refreshContext()(packages/coding-agent/CHANGELOG.md:15;refreshContext 见 agent-session.ts:1197-1200)。

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

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

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

earendil-works/pi@16787ad第 1160–1187 行在 GitHub 查看 ↗
延迟落盘:只要这个会话里还没有出现过 assistant 消息,entry 就只留在内存;第一条 assistant 消息到达时用 wx 一次性写出全部 entry,之后逐行追加。
ts
// packages/coding-agent/src/core/session-manager.ts:1160-1187(节选)
_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:312 的 existsSync 检查就是为它准备的)。

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

仓库里还有第二套会话子系统:packages/agent/src/harness/session/ 下的 Session(session.ts)+ Storage,JSONL 后端是 JsonlStorage(packages/agent/src/harness/session/jsonl/storage.ts)。它给 AgentHarness 使用,但和上面这套已经不是同一种文件格式:

  • 写盘单位是一次事务,而不是一条 entry。一次提交里的所有写入——新条目、分支的当前位置、operation 的执行状态等——被序列化成同一行 JSON(packages/agent/src/harness/session/jsonl/io.ts:76-78)。新建文件时先写到 .tmp 临时文件,写完再原子地发布成正式文件(publishFileAtomically,io.ts:81 起)。
  • 条目类型只有四种:message、compaction、branch_summary、custom(packages/agent/src/harness/session/types.ts:16)。模型、思考等级、激活的工具不再是树上的节点,而是按 lane(通道)保存的配置值。
  • 当前位置是持久化的。每个分支的 tip 存成一个名为 pi.branch.tip 的值(packages/agent/src/harness/session/values.ts:158),重新打开文件直接读出来,不靠「最后一行」推断。
  • 它还能读取 v3 格式的旧文件(jsonl/legacy-v3.ts)。

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

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

图里每个节点都能在源码中找到对应:newSession(session-manager.ts:1057)、_persist(1160)、findMostRecentSession(749)、loadEntriesFromFile(627)、migrateToCurrentVersion(337)、_buildIndex(1103)、buildSessionContext(576)。特别注意 F 这个菱形只看内存里的 fileEntries,不看磁盘——所以 SessionManager.open 打开一个已存在的文件后 flushed 直接被置为 true(session-manager.ts:1050),后续 entry 立即追加,不再走缓冲逻辑。

树、分支与 compaction ​

parentId 就是全部 ​

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

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

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

分支在 Pi 里有三个来源:

  1. /tree 导航:AgentSession.navigateTree(agent-session.ts:3581)根据用户选择调 branch / resetLeaf / branchWithSummary(session-manager.ts:1593-1618),后者会额外追加一个 branch_summary entry 记录被放弃那条路径的摘要(它的 fromId 记的是切走之前的旧 leaf,session-manager.ts:1603),然后按会话投影重建消息数组(_refreshFinalizedContext,agent-session.ts:3758)。整个过程在同一个文件里完成。
  2. /fork 与 /clone:AgentSessionRuntime.fork(agent-session-runtime.ts:262)底层调 createBranchedSession(session-manager.ts:1625-1745),把「根 → 指定 leaf」这一条单链抽出来写成新文件,新文件 header 的 parentSession 指向旧文件。
  3. 命令行 --fork:SessionManager.forkFrom(session-manager.ts:1812-1863)把源文件的全部 entry 复制到新 cwd 下的新文件,同样用 parentSession 记录来源。

而上下文压缩不产生分支。appendCompaction(session-manager.ts:1259-1285)只是在当前路径上挂一个 compaction 节点,记录摘要、firstKeptEntryId、压缩前的 token 数,以及压缩那一刻完整的提示词与工具状态(systemMessage 字段)。firstKeptEntryId 传 null 表示一条都不保留:源码会把它设成这个 compaction 节点自己的 id(session-manager.ts:1276)。重建上下文时由 buildContextEntries 做折叠:

ts
// packages/coding-agent/src/core/session-manager.ts:499-511(节选)
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 && !(entry.type === "message" && entry.message.role === "system")) {
        contextEntries.push(entry);
    }
}
contextEntries.push(...path.slice(compactionIdx + 1));
return contextEntries;

也就是:压缩点之前只保留从 firstKeptEntryId 起的尾巴,其余用一条摘要消息代替;压缩点之后的全部保留。保留的尾巴里的 system 消息会被跳过——压缩节点自带的 systemMessage 已经是当时完整的提示词与工具状态,它会排在摘要前面(sessionEntryToContextMessages,session-manager.ts:461-464)。旧 entry 仍然躺在文件里,只是不再进入发给模型的上下文。

在这份 entry 列表之上,buildSessionProjection 再应用每个目标最新的一条 context_edit:被省略的消息不产出任何内容,被替换的只换内容(session-manager.ts:542-573)。buildSessionContext 就是取这份投影里的消息(:575-583)。压缩机制本身留到 6.6 Context 构造与 Compaction 展开。

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

读这张图时请关注三点。第一,U1 有两个孩子(A1 和 BS),这就是分支——它由一次 branchWithSummary(U1, ...) 造成,被放弃的 U2/A2 原封不动留在文件里。第二,branch_summary 是树上的节点而不是消息里的一段文字,所以下次加载能被 sessionEntryToContextMessages(session-manager.ts:458-460)还原成一条摘要消息;它的 fromId 记下的是切走前的 leaf(图中的 A2)。第三,compaction 挂在当前路径末端,它不新开分支;buildContextEntries 会据此把 M1 到 BS 这段折叠掉,只留 C1、U3、A3。

leaf 指针没有存进文件 ​

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

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

因为 append-only 追加时 leaf 总是跟着最后一行走,这个近似在正常流程里是准确的。相对地,harness 那套实现把每个分支的当前位置存成一个持久化的值(pi.branch.tip,packages/agent/src/harness/session/values.ts:158),重新打开即可恢复,不必推断。

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

下面用 CLI 当前实际采用的 SessionManager 做一次分支。先选中 U2 并“编辑这条用户消息”:你会看到 leaf 不是停在 U2,而是退回它的父节点 A1,原文字被送回编辑器;追加 U2b 后它才和旧 U2 成为兄弟。再试“模拟重启”,观察为什么内存 leaf 会回到物理文件最后一行。这里没有 harness 那套持久化的分支位置,两套实现不要混读。

SessionManager · append-only tree

文件保存整棵树,leaf 只选择当前上下文

本实验使用 coding-agent 的 SessionManager;它不单独持久化 leaf 指针,不要和 agent harness 的另一套会话实现混读。

selected U2memory leaf A2file last A2

关键:branch(id) 只移动内存 leaf,不修改也不追加文件;选中 user 进入编辑流程时,leaf 会退到该 user 的 parentId。只有 append 或 branchWithSummary() 才会写新行。

完整树

节点是原生按钮:Tab 移动焦点,Enter 或 Space 选择;再决定 branch 或编辑 user。

active branch → LLM context

  1. U1user
  2. A1assistant
  3. U2user
  4. A2assistant
[
  {
    "role": "user",
    "text": "先读懂项目结构。"
  },
  {
    "role": "assistant",
    "text": "我先检查目录和入口文件。"
  },
  {
    "role": "user",
    "text": "改为先解释 SessionManager。"
  },
  {
    "role": "assistant",
    "text": "SessionManager 用 append-only JSONL 保存一棵树。"
  }
]

物理 JSONL(append 顺序)

  1. {"type":"session","version":3,"id":"demo-session","timestamp":"2026-08-09T08:00:00.000Z","cwd":"/workspace/pi-demo"}
  2. {"type":"message","id":"U1","parentId":null,"timestamp":"2026-08-09T08:01:00.000Z","message":{"role":"user","content":[{"type":"text","text":"先读懂项目结构。"}],"timestamp":1786248060000}}
  3. {"type":"message","id":"A1","parentId":"U1","timestamp":"2026-08-09T08:02:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"我先检查目录和入口文件。"}],"provider":"anthropic","model":"claude-sonnet","stopReason":"stop","timestamp":1786248120000}}
  4. {"type":"message","id":"U2","parentId":"A1","timestamp":"2026-08-09T08:03:00.000Z","message":{"role":"user","content":[{"type":"text","text":"改为先解释 SessionManager。"}],"timestamp":1786248180000}}
  5. {"type":"message","id":"A2","parentId":"U2","timestamp":"2026-08-09T08:04:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"SessionManager 用 append-only JSONL 保存一棵树。"}],"provider":"anthropic","model":"claude-sonnet","stopReason":"stop","timestamp":1786248240000}}

会话树已就绪。当前内存 leaf 与文件最后一行都是 A2。

恢复:五条启动路径 ​

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

packages/coding-agent/src/main.ts · createSessionManager
earendil-works/pi@16787ad第 357–448 行在 GitHub 查看 ↗
按 flag 分派出五类 SessionManager:内存态、fork、指定文件、交互选择、继续最近,以及兜底的新建。分派顺序即优先级。
ts
// packages/coding-agent/src/main.ts:357-448(骨架,省略错误处理与 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-sessioninMemory(session-manager.ts:1801-1803)全程只在内存里,persist=false无文件
--fork <path|id>forkFrom(1812-1863)复制全部 entry 到新文件,header 记 parentSession是
--session <path|id>open(1763-1783)在原文件上继续追加否
--resume / -r选择器 → open同上,先弹交互列表否
--continue / -ccontinueRecent(1790-1798)找最近修改的文件再 open;找不到就新建否(除非没有)

continueRecent 的「最近」是按文件修改时间(mtime)排序的,并且只扫描到每个候选文件的 header 就做筛选,不整文件加载(findMostRecentSession,session-manager.ts:749-768)。会话多了以后这个优化很关键。

open 之后的加载链条是:_setSessionFile(1029-1055)→ loadEntriesFromFile(627-670,流式按行 JSON.parse,坏行直接跳过而不是整体失败)→ _loadEntries(1085-1101)里先 migrateToCurrentVersion(337-347),若发生迁移则 _rewriteFile 整文件重写,再 _buildIndex。版本迁移有两级:v1→v2 补上 id/parentId 把线性序列变成树(287-313),v2→v3 把旧的 hookMessage role 改名为 custom(316-331)。

最后一步由 SDK 完成:createAgentSession(packages/coding-agent/src/core/sdk.ts:175)先 sessionManager.buildSessionContext()(sdk.ts:194)拿到消息、思考等级与模型,尝试还原模型(sdk.ts:202-210),再把消息作为初始状态交给 Agent(sdk.ts:372)。

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

完整调用链 ​

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

写盘链runAgentLoop 发 message_end(packages/agent/src/agent-loop.ts:120、212、451、464、897) → AgentSession._handleAgentEvent(订阅点 packages/coding-agent/src/core/agent-session.ts:434,定义 agent-session.ts:894,持久化分支 agent-session.ts:921-943) → SessionManager.appendMessage(session-manager.ts:1202-1212) → _appendEntry(session-manager.ts:1189-1194) → _persist(session-manager.ts:1160-1187) → appendFileSync / openSync("wx")。

恢复链main(packages/coding-agent/src/main.ts:566) → createSessionManager(main.ts:357-448) → SessionManager.continueRecent(session-manager.ts:1790-1798) → findMostRecentSession(session-manager.ts:749-768) → 私有构造器(session-manager.ts:1000-1022)→ _setSessionFile(1029-1055)→ loadEntriesFromFile(627-670)+ _loadEntries(1085-1101,内含 migrateToCurrentVersion 与 _buildIndex) → createAgentSession(packages/coding-agent/src/core/sdk.ts:175) → buildSessionContext(sdk.ts:194 → session-manager.ts:1495-1501) → new Agent({ initialState: { messages: existingSession.messages } })(sdk.ts:366-372) → 之后每次请求前,prepareRequest 钩子再从会话投影重算一次(agent-session.ts:608-633)。

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

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

与官方文档对照 ​

官方文档 packages/coding-agent/docs/session-format.md 是这套格式的权威说明,packages/coding-agent/docs/sessions.md 是面向用户的功能说明。逐条核对后,一致的部分包括:文件位置与 cwd 编码规则、v1/v2/v3 版本历史与自动迁移、8 字符 entry id、11 种 entry 的 JSON 示例(含 usage 与 context_edit)、树结构与 leaf 的描述、branch_summary 的 fromId 是切走前的旧 leaf、buildContextEntries 的压缩折叠规则(包括跳过保留段里的 system 消息、retain-none 压缩把自己的 id 存进 firstKeptEntryId)、buildSessionProjection 如何应用 context_edit,以及 sessions.md 里 /tree(同文件分支)与 /fork、/clone(新文件)的对照表。

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

  1. API 清单漏了两个方法。文档的「Appending」清单(session-format.md:460-469)没有列出 appendContextEdit(targetId, replacement),「Context & Info」清单(session-format.md:483 起)没有列出 buildSessionProjection()——虽然前文 ContextEditEntry 与 Context Building 两节都描述了它们的行为,源码里两者也都是公开方法(session-manager.ts:1358、1491)。
  2. appendUsage 的签名与返回值对不上。文档写作 appendUsage(kind, provider, model, usage),并放在标题注明「all return entry ID」的清单里(session-format.md:460、:464);源码里它还有末尾的 note?: string 参数,返回的是整个 UsageEntry 对象而不是 id(session-manager.ts:1242-1256)。

实践任务 ​

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

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

为什么无需 Key:--export 分支在 packages/coding-agent/src/main.ts:624-636,位于模型选择与会话创建之前,处理完就 process.exit(0)。它只调用 exportFromFile(packages/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-env 是 pi-test.sh 提供的开关,会先清空所有 API Key 环境变量):

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

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

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

当前目录出现一个约 260 KB 的 demo.html(本书实测 269806 字节;模板与内嵌的两个 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——不是因为它「更深」,而是因为 _buildIndex(session-manager.ts:1103-1122)无脑把 leaf 设成文件里最后一个 entry。这就直接验证了「leaf 指针不持久化」这个结论。

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

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

对应源码位置:main.ts:624-636(--export 分支)、export-html/index.ts:288-316(exportFromFile)、export-html/index.ts:143-175(generateHtml 与 base64 内嵌)、session-manager.ts:627-670(加载与校验)、session-manager.ts:1103-1122(leaf 重建)。

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

本章小结 ​

  • 会话有三层表示:agent.state.messages(当前上下文)→ entry 树(带 id/parentId)→ JSONL 文件(一行一个 entry)。Agent 自己不持久化,AgentSession 在事件回调里代劳;反过来,每次请求模型前,AgentSession 都从 entry 树重新算出上下文,entry 树才是权威。
  • 文件位置 ~/.pi/agent/sessions/--<编码 cwd>--/<时间戳>_<id>.jsonl;第一行是 header,其后 11 种 entry 组成可辨识联合。context_edit 让「只追加」的文件也能从后续上下文里省略或替换一条旧消息。
  • 写盘由 message_end 事件驱动,一条消息一行;但 _persist 会延迟到第一条 assistant 消息才真正创建文件,所以「只问不答」的会话不留垃圾。harness 那套则以事务为单位写盘,一次提交一行,分支位置也一并持久化。
  • 树是 append-only 的:分支 = 把 leaf 指针往回挪(/tree),或把一条路径抽成新文件(/fork、/clone、--fork)。compaction 只是路径上的一个折叠节点,不产生分支也不产生新文件。
  • 恢复有五条路径,全部汇聚在 createSessionManager;--continue 按 mtime 找最近文件,--resume 弹选择器,--session 直接打开,--fork 复制成新文件,--no-session 完全不落盘。
  • 官方 session-format.md 与实现基本一致,只有两处 API 清单上的出入:漏列了 appendContextEdit 与 buildSessionProjection,appendUsage 的签名与返回值写得不对。
  • 关键术语:会话(Session)、entry(会话条目)、JSONL(JSON Lines)、leaf(当前叶子指针)、branch(分支)、上下文压缩(Context Compaction)、上下文编辑(context edit)、append-only(只追加)。
  • 关键源码索引:packages/coding-agent/src/core/session-manager.ts(getDefaultSessionDirPath:589、buildContextEntries:476、buildSessionProjection:543、newSession:1057、_buildIndex:1103、_persist:1160、appendMessage:1202、appendCompaction:1259、appendContextEdit:1358、branch:1572、branchWithSummary:1593、createBranchedSession:1625、open:1763、continueRecent:1790、forkFrom:1812)、packages/coding-agent/src/core/agent-session.ts:608-633(请求前从会话重算上下文)与 :894(事件处理器)、packages/coding-agent/src/core/sdk.ts:366-413、packages/coding-agent/src/main.ts:357-448、packages/agent/src/harness/session/jsonl/storage.ts;测试 packages/coding-agent/test/session-manager/tree-traversal.test.ts、file-operations.test.ts、build-context.test.ts、packages/coding-agent/test/sdk-session-manager.test.ts、packages/agent/test/harness/jsonl-storage.test.ts。
  • 自测问题:① 你运行 pi、输入一句话、模型还没回答就按了 Ctrl+C,~/.pi/agent/sessions/ 下会多出文件吗?说出决定这一点的那个变量名。② /tree 切到旧节点继续聊,会新建一个 .jsonl 吗?/clone 呢?③ 一个含分支的会话文件,最后一行一定属于当前对话路径吗?④ 为什么新会话的前两个 entry 总是 model_change 和 thinking_level_change?
  • 下一章:5.6 取消与错误处理——当你按下 Esc、或模型中途报错时,已经写进 session 的那些行会怎样。
  • 尚未展开:会话存储的另一套抽象(pi-agent-core harness 的 Storage / Session / SessionRepo 三层接口,以及 JSONL、内存、SQLite 三种后端)留给 6.5 Session 存储格式与会话树;压缩摘要如何生成留给 6.6 Context 构造与 Compaction;/resume 选择器的界面实现留给 6.9 pi-tui:终端界面库。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 不会。决定这一点的变量是 _persist 里的 hasAssistant(session-manager.ts:1160 一段):文件要等到内存 fileEntries 里出现第一条 role === "assistant" 的消息才真正被创建,在那之前用户消息只留在内存。所以「只问不答」的会话不会在磁盘上留下垃圾文件。
  2. /tree 不会新建文件——它只是把 leaf 指针往回挪到旧节点,之后的新 entry 挂在那个节点下面,仍然写进同一个 .jsonl,于是这个文件里长出了一个分支。/clone(以及 /fork、命令行的 --fork)会新建文件:它把选定的那条路径抽出来复制成一个新的会话文件,两边从此各走各的。
  3. 不一定。文件是 append-only 的,最后一行只是「最后写进去的那个 entry」,它可能属于某条已经被 /tree 切走的旧分支。真正的当前路径要靠 parentId 从 leaf 往回串。顺带一提,leaf 指针本身不持久化——_buildIndex(session-manager.ts:1103-1122)重新打开文件时会无脑把 leaf 设成文件里的最后一个 entry,本章实践任务第 4 步造一个平级分支就能亲眼看到这个行为。
  4. 因为新建会话时 SDK 主动写了这两条(core/sdk.ts:402-413),好让下次恢复能还原「当时用的是哪个模型、思考等级开到几档」。这两样都是会话级的状态而不是消息,用专门的 entry 类型记下来,恢复时顺着树走一遍就能重放出最终值——比在 header 里存一个「当前模型」更适合 append-only 的结构,因为中途 /model 切换同样只是再追加一条 model_change。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)