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 像 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_NAME,config.ts:491),sessions 来自 getSessionsDir()(config.ts:559-561),最后一段是把当前工作目录(cwd)编码成一个安全的目录名:
getDefaultSessionDirPath所以在 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」:
newSession// 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,共用同一个基类:
SessionEntryBaseSessionEntry 是一个可辨识联合(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)——它只需要在单个文件内唯一,不需要全局唯一。
下面是一个最小但合法的会话文件(本书为教学构造,不是运行输出;本章末尾的实践任务会用到它):
{"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 主动写了它们,好让下次恢复能还原模型与思考等级:
agent.state.messages什么时候写盘
事件驱动:message_end 先写盘,再转发
AgentSession 在构造时订阅 Agent 的事件流(agent-session.ts:393),持久化就发生在这个回调里。关键顺序是:先交给扩展 → 再广播给界面 → 然后才写 session(agent-session.ts:618-625)。
_handleAgentEvent// 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 elsewheremessage_end 由 Agent Loop(Agent 循环)在四个位置发出:用户 prompt 入队时(packages/agent/src/agent-loop.ts:113)、assistant 消息定稿时(agent-loop.ts:357 与 agent-loop.ts:370)、工具结果产生时(agent-loop.ts:791)。也就是说,一轮对话里每出现一条消息就写一次,不是等整轮结束才批量写。
注释最后一行点出了三个例外:bash 执行消息、compaction 摘要、分支摘要不走这里。它们各有专门入口——bash 在 agent-session.ts:2825 与 2860 直接 appendMessage,compaction 在 agent-session.ts:1872(手动 /compact)与 2153(自动触发)调 appendCompaction,分支摘要经 branchWithSummary(agent-session.ts:3040)。
延迟建文件:没有回答就不留垃圾
appendMessage → _appendEntry(session-manager.ts:1044-1049)→ _persist。真正碰磁盘的只有最后一个方法,而它藏着一个容易忽略的工程细节:
// 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:312 的 existsSync 检查就是为它准备的)。
对照:pi-agent-core 的新 harness 走的是另一条路
仓库里还有第二套会话子系统:packages/agent/src/harness/session/ 下的 Session + SessionStorage。它给 AgentHarness 使用,磁盘布局与上面刻意保持一致,但写盘时机不同:
handleAgentEventmessage_end 立刻写、其余变更(模型切换、工具集变化、leaf 移动等)先进 pendingSessionWrites 队列,在 turn_end 由 flushPendingSessionWrites(agent-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 起)是下一次启动时的恢复路径,本章后半段展开。
图里每个节点都能在源码中找到对应:newSession(session-manager.ts:930)、_persist(1015)、findMostRecentSession(635)、loadEntriesFromFile(514)、migrateToCurrentVersion(281)、_buildIndex(958)、buildSessionContext(461)。特别注意 F 这个菱形只看内存里的 fileEntries,不看磁盘——所以 SessionManager.open 打开一个已存在的文件后 flushed 直接被置为 true(session-manager.ts:922),后续 entry 立即追加,不再走缓冲逻辑。
树、分支与 compaction
parentId 就是全部
SessionManager 的类注释把模型讲得很清楚(session-manager.ts:844-854):每个 entry 有 id 和 parentId,「leaf」指针表示当前位置,追加就是给当前 leaf 挂一个孩子。第一个 entry 的 parentId 是 null。
分支只有一个动作——把 leaf 指针往回挪:
分支在 Pi 里有三个来源:
/tree导航:AgentSession.navigateTree(agent-session.ts:2895)根据用户选择调branch/resetLeaf/branchWithSummary(session-manager.ts:1381-1405),后者会额外追加一个branch_summaryentry 记录被放弃那条路径的摘要,然后用buildSessionContext重建消息数组(agent-session.ts:3068)。整个过程在同一个文件里完成。/fork与/clone:AgentSessionRuntime.fork(agent-session-runtime.ts:262)底层调createBranchedSession(session-manager.ts:1412-1512),把「根 → 指定 leaf」这一条单链抽出来写成新文件,新文件 header 的parentSession指向旧文件。- 命令行
--fork:SessionManager.forkFrom(session-manager.ts:1579-1630)把源文件的全部 entry 复制到新 cwd 下的新文件,同样用parentSession记录来源。
而上下文压缩不产生分支。appendCompaction(session-manager.ts:1097-1119)只是在当前路径上挂一个 compaction 节点,记录摘要、firstKeptEntryId 和压缩前的 token 数。重建上下文时由 buildContextEntries 做折叠:
// 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 有两个孩子(A1 和 BS),这就是分支——它由一次 branchWithSummary(U1, ...) 造成,被放弃的 U2/A2 原封不动留在文件里。第二,branch_summary 是树上的节点而不是消息里的一段文字,所以下次加载能被 sessionEntryToContextMessages(session-manager.ts:401-403)还原成一条摘要消息。第三,compaction 挂在当前路径末端,它不新开分支;buildContextEntries 会据此把 M1→U1 这段折叠掉。
leaf 指针没有存进文件
一个容易踩的点:coding-agent 这套实现不持久化 leaf。重新加载时,_buildIndex 简单地把 leaf 设成文件里最后一个 entry:
_buildIndex因为 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),重放文件即可恢复当前位置。
恢复:五条启动路径
命令行参数在 main()(packages/coding-agent/src/main.ts:521)里被解析后,会话的构造统一交给一个函数:
createSessionManager// 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-session | inMemory(session-manager.ts:1568-1570) | 全程只在内存里,persist=false | 无文件 |
--fork <path|id> | forkFrom(1579-1630) | 复制全部 entry 到新文件,header 记 parentSession | 是 |
--session <path|id> | open(1530-1550) | 在原文件上继续追加 | 否 |
--resume / -r | 选择器 → open | 同上,先弹交互列表 | 否 |
--continue / -c | continueRecent(1557-1565) | 找最近修改的文件再 open;找不到就新建 | 否(除非没有) |
continueRecent 的「最近」是按文件修改时间(mtime)排序的,并且只读每个候选文件的第一行 header 来做筛选,不整文件加载(findMostRecentSession,session-manager.ts:635-656)。会话多了以后这个优化很关键。
open 之后的加载链条是:_setSessionFile(895-928)→ loadEntriesFromFile(514-556,流式按行 JSON.parse,坏行直接跳过而不是整体失败)→ migrateToCurrentVersion(281-291)→ 若发生迁移则 _rewriteFile 整文件重写 → _buildIndex。版本迁移有两级:v1→v2 补上 id/parentId 把线性序列变成树(231-257),v2→v3 把旧的 hookMessage role 改名为 custom(260-275)。
最后一步由 SDK 完成:createAgentSession(packages/coding-agent/src/core/sdk.ts:169)先 sessionManager.buildSessionContext()(sdk.ts:188)拿到消息、思考等级与模型,尝试还原模型(sdk.ts:196-204),再把消息灌回 Agent(sdk.ts:364)。
完整调用链
把两条主链路写全,方便你自己用全文搜索复核:
写盘链runAgentLoop 发 message_end(packages/agent/src/agent-loop.ts:113、357、370、791) → AgentSession._handleAgentEvent(订阅点 packages/coding-agent/src/core/agent-session.ts:393,定义 agent-session.ts:595,持久化分支 agent-session.ts:624-643) → SessionManager.appendMessage(session-manager.ts:1057-1067) → _appendEntry(session-manager.ts:1044-1049) → _persist(session-manager.ts:1015-1042) → appendFileSync / openSync("wx")。
恢复链main(packages/coding-agent/src/main.ts:521) → createSessionManager(main.ts:312-403) → SessionManager.continueRecent(session-manager.ts:1557-1565) → findMostRecentSession(session-manager.ts:635-656) → 私有构造器(session-manager.ts:868-888)→ _setSessionFile(895-928)→ loadEntriesFromFile(514-556)+ migrateToCurrentVersion(281-291)+ _buildIndex(958-977) → createAgentSession(packages/coding-agent/src/core/sdk.ts:169) → buildSessionContext(sdk.ts:188 → session-manager.ts:461-470) → agent.state.messages = existingSession.messages(sdk.ts:364)。
图 5.5-3 一轮对话里消息落盘的先后顺序
按时间自上而下。注意第一条用户消息并没有立刻触达磁盘,直到 assistant 消息到达才连同它一起写出——这正是 `_persist` 里 `hasAssistant` 判断的效果。
这张图对应的源码:左侧两条 message_end 来自 agent-loop.ts:113 与 agent-loop.ts:370,工具结果那条来自 agent-loop.ts:791;SessionManager 那一列的三次动作全在 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(新文件)的对照表。
需要注意的不一致有三处(均为本书核对源码后的发现):
retainedTail只存在于 harness 实现。官方文档说明「较新的 harness 生成的 compaction 会把保留的尾部上下文直接嵌在 entry 上」(session-format.md:237、session-format.md:245),并在 Context Building 一节声称会据此重建上下文(session-format.md:327、session-format.md:342)。但 coding-agent 的CompactionEntry(session-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的文件,压缩点之后本应保留的尾部消息不会进入上下文。leaf与active_tools_change两种 entry、以及 header 的metadata字段没有出现在文档的 Entry Types 清单里,但 harness 的 JSONL 会写入它们。对「SessionManager 实现」而言文档没写错,但作为通用的「Session File Format」文档并不完整。- 两处 API 签名漏了参数:文档写
appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?)(session-format.md:410)与branchWithSummary(entryId, summary, details?, fromHook?)(session-format.md:426),源码里两者都还有末尾的usage?: Usage(session-manager.ts:1097-1104、1381-1387)。
实践任务
目标:不依赖任何 API Key,亲手造出一个合法的会话文件,用 pi --export 把它渲染成 HTML,并从导出结果里读出 Pi 是怎么理解这棵树的。
为什么无需 Key:--export 分支在 packages/coding-agent/src/main.ts:578-590,位于模型选择与会话创建之前,处理完就 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@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——不是因为它「更深」,而是因为 _buildIndex(session-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.ts(getDefaultSessionDirPath:476、newSession:930、_buildIndex:958、_persist:1015、appendMessage:1057、branch:1360、branchWithSummary:1381、createBranchedSession:1412、open:1530、continueRecent:1557、forkFrom:1579)、packages/coding-agent/src/core/agent-session.ts:595、packages/coding-agent/src/core/sdk.ts:362-374、packages/coding-agent/src/main.ts:312-403、packages/agent/src/harness/agent-harness.ts:512-556;测试packages/coding-agent/test/session-manager/tree-traversal.test.ts、file-operations.test.ts、packages/coding-agent/test/sdk-session-manager.test.ts、packages/agent/test/harness/session.test.ts。 - 自测问题:① 你运行
pi、输入一句话、模型还没回答就按了 Ctrl+C,~/.pi/agent/sessions/下会多出文件吗?说出决定这一点的那个变量名。②/tree切到旧节点继续聊,会新建一个.jsonl吗?/clone呢?③ 一个含分支的会话文件,最后一行一定属于当前对话路径吗?④ 为什么新会话的前两个 entry 总是model_change和thinking_level_change? - 下一章:5.6 取消与错误处理——当你按下 Esc、或模型中途报错时,已经写进 session 的那些行会怎样。
- 尚未展开:会话存储的另一套抽象(
SessionStorage/SessionRepo接口、内存后端、SQLite 后端)留给 6.5 Session 存储格式与会话树;压缩摘要如何生成留给 6.6 Context 构造与 Compaction;/resume选择器的界面实现留给 6.9 pi-tui:终端界面库。