会话树、分支与会话文件格式
JSONL 会话文件长什么样,分支是怎么长出来的
会话是 Pi 里最容易被误解的一块。误解的来源不是它复杂——它的数据结构简单到可以一眼看完——而是「会话」这个词听起来应该是一条线,实际上它是一棵树。
更麻烦的是,Pi 有三个不同的命令都能「分支」,它们产生的效果完全不同:一个在原文件里长出新枝,一个开出新文件,一个把整棵树复制一份。旧资料经常把这三个混着讲,导致读者照着做完之后发现自己的文件数量对不上。
所以这一篇先给结论,后面逐条展开:
会话是一个 JSONL 文件,格式版本 v3。文件的每一行是一条 entry,entry 用
id/parentId连成一棵树。当前条目(叶子)标记活跃分支,分支动作只移动这个指针。从更早的条目继续,会在同一个文件里长出另一个分支——旧分支一条都不会少。
三个命令的分工一句话记:
交互层面的细节在 交互式会话 里已经出现过一次,本文要补的是它们在磁盘上到底长什么样。这一篇也是 上下文装配与压缩 的前置文章——压缩写进会话的 compaction 条目,就是本文这套 entry 模型的一个特化实例。
14.1 会话文件在哪
默认位置是:
~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl这个路径有三层含义,源码在 src/core/session-manager.ts:589 的 getDefaultSessionDirPath()。
目录名:工作目录路径编码出来的
目录名不是哈希,是可读编码。源码只有一行:
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;三步:先解析成绝对路径,剥掉开头的一个 /(或 \),再把剩下的 /、\、: 全换成 -,最后用 -- 包起来。举几个例子:
Windows 上的盘符冒号也被换成 -,所以 C:\... 会变成 C--Users...。
这个设计的好处是 ls ~/.pi/agent/sessions/ 直接就是一份可读的项目清单,你不用打开任何文件就知道哪些项目在这里跑过 Agent。
代价是这个编码是有损的,而且源码没有做任何消歧:/work/api 和 /work-api 都会编码成 --work-api--。
有损本身不致命,因为真正的 cwd 写在文件头的 cwd 字段里(见下节),选择器读的是头不是目录名。但如果你在两个路径只差一个斜杠的项目之间来回切,--continue 有可能挑错。规避办法是把 sessionDir 显式设成不同的目录,或者给会话起名(/name),选择器列表里会以名字显示。
文件名:ISO 时间戳 + 会话 ID
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);timestamp 是 new Date().toISOString(),冒号和句点都换成 -,所以时间戳从 2026-10-23T14:00:00.000Z 变成 2026-10-23T14-00-00-000Z。剩下的字符(T、Z、连字符)都能安全地出现在文件名里。
会话 ID 默认是 uuidv7()(session-manager.ts:264)。UUIDv7 的前 48 位是毫秒时间戳,所以按文件名排序约等于按创建时间排序——这就是 --continue 打开「最近一次会话」的基础。
你也可以自带 ID。命令行 --session-id,或者 SDK 里 newSession({ id })。ID 有格式约束,源码在 assertValidSessionId()(session-manager.ts:268):
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {也就是「字母数字开头结尾,中间可以是字母数字、.、_、-」。这条约束存在的理由很实际:ID 直接进了文件名,得能安全地在各种文件系统上存在。
最终一个真实文件名长这样:
2026-10-23T14-00-00-000Z_0198f3a2-7c41-7b6e-9a12-3d5e8f0b7c22.jsonl一个反直觉的细节:文件是懒创建的
newSession() 在内存里建好 header 就返回了,磁盘上的文件这时还不存在。真正的写入推迟到 _persist(),而它的第一个条件是 _hasConversation():
private _hasConversation(): boolean {
return this.fileEntries.some(
(e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
);
}只有出现了一条用户消息或助手消息,文件才会被真正创建。所以「打开 pi、敲 /help、退出」不会在磁盘上留任何痕迹。
源码注释解释了为什么门槛是「用户消息」而不是「第一条助手回复」:
* Setup entries alone (model, thinking level, system prompt) stay in memory so opening and
* closing pi without chatting leaves no file behind. Starting at the user message (not the
* first assistant reply) keeps the prompt on disk if the first turn never completes (#10000).后半句是关键:如果模型请求发了、回复回来之前崩了,你至少还有你的提问留在磁盘上。这是一个小但正确的产品决策。
存储位置可以改,三层优先级
{
"sessionDir": "./my-sessions"
}三层来源,从高到低:
--session-dir <dir>(命令行)PI_CODING_AGENT_SESSION_DIR环境变量settings.json里的sessionDir
settings.json 可以是用户级的 ~/.pi/agent/settings.json,也可以是项目级的 .pi/settings.json。相对路径按工作目录解析。
这一层有个值得注意的耦合:项目级的 sessionDir 是在 project trust 判定之前被读取的,官方文档自己把这条列为已知缺口,细节在 工具系统与信任边界。如果你在不可信的目录里跑 Pi,别指望拒绝信任能把会话文件写到你想让它去的地方。
想完全不落盘,用 --no-session:会话只存在于内存里,退出即消失,也无法 --continue 回来。
会话是追加写的
这是理解整个文件格式的钥匙。getEntries() 上方那句注释说得很直接:
/**
* Get all session entries (excludes header). Returns a shallow copy.
* The session is append-only: use appendXXX() to add entries, branch() to
* change the leaf pointer. Entries cannot be modified or deleted.
*/一次普通运行里,磁盘写入路径只有一条:_persist() 在每追加一条 entry 后 appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n")。一个 entry 一行,永不改写。
只有三个地方会重写整个文件:_rewriteFile() 被迁移(14.5 节)、createBranchedSession()(14.4 节)、newSession() 初始化时调用。
这个选择的好处在长会话上非常明显:一个跑了几百轮的会话文件可能有几 MB,但你每写一行只需要 append 一次,不需要重写几 MB。崩溃时最多丢最后一行,已写的内容永远完整。
删除
会话文件就是普通文件,rm 掉就删了:
rm ~/.pi/agent/sessions/--Users-me-work-api--/2026-10-23T14-00-00-000Z_0198f3a2.jsonl交互式也能删:/resume 打开选择器,选中一条会话按 Ctrl+D,然后确认。文档提到有 trash CLI 时会用它,避免永久删除——因为会话文件里通常有你整个项目的代码内容、命令输出和可能的环境变量。
找回一个会话
三档,按「你知道多少」从少到多:
# 我知道这个项目最近在做什么
pi --continue
# 我知道项目,但不确定是哪一次
pi --resume # 交互式里等价于 /resume
# 我知道会话 ID 的开头几个字符
pi --session 0198f3a2--session 接受完整路径、完整 ID 或部分 ID。解析顺序是「先在当前项目里找,跨项目命中就问你一句要不要 fork 进来」。
选择器本身能做不少事:搜索、重命名、删除、显示路径、切换排序、只显示有名字的会话。具体键位在 docs/keybindings.md 的 Sessions 段。
给会话起名值得多说一句。/name foo(或启动时 --name foo)写一条 session_info 条目,选择器里就用这个名字代替第一条消息显示。默认的选择器按第一条消息排序,名字往往比消息开头更能说明这条会话干了什么。
把外部会话导进来
/import <path> 接受一个 JSONL 会话文件并切换过去。源码在 agent-session-runtime.ts 的 importFromJsonl(),行为是:解析目标文件,拷进当前项目的 session 目录(重名时自动加 -1、-2 后缀),然后 SessionManager.open()。
这条命令没有做任何格式校验——它就是拷过去。所以从别处拿来的 JSONL 如果格式不对,症状会表现为条目莫名其妙地少(坏行被静默跳过,见 14.5 节),或者文件头缺失时整个会话显示异常。
14.2 JSONL 与条目类型
会话文件是 JSONL(JSON Lines):一行一个 JSON 对象,行与行之间没有结构,只靠顺序和 id / parentId 关联。
每条 entry 的骨架
除了文件头,所有 entry 都长这样(session-manager.ts:57):
interface SessionEntryBase {
type: string;
id: string;
parentId: string | null; // 根条目的 parentId 是 null
timestamp: string; // ISO 8601
}三个字段值得逐个说:
id 是 8 个十六进制字符,来自 generateId():
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
if (!byId.has(id)) return id;
}
// Fallback to full UUID if somehow we have collisions
return randomUUID();32 位空间(randomUUID 的前 8 个 hex),在单个会话的条目数范围内碰撞概率可以忽略;但代码仍然查重重试 100 次,实在不行退化成完整 UUID。id 只在单个会话文件内唯一,不同会话之间会重名——所以任何按 id 索引的逻辑都必须先确定在哪个会话里。
parentId 是 id 或 null。null 表示这是一棵树的根。
timestamp 是 ISO 8601 字符串。这里有个容易踩的双时间戳陷阱:
同一行 JSON 里可能同时有两个时间戳,格式不一样。官方 docs/message-types.md 专门提醒过这一点,因为前者排序友好(字符串比较即可),后者是消息协议的一部分。
十二种 entry 类型
FileEntry 联合类型是 SessionHeader | SessionEntry(session-manager.ts:197),而 SessionEntry 有 11 个成员(session-manager.ts:183)。加上文件头,一共 12 种 type 值:
「进不进上下文」这一列不是文档注释,是可以直接从代码读出来的。sessionEntryToContextMessages()(session-manager.ts:439)只对四种 entry 返回消息:
if (entry.type === "message") { … return [message]; }
if (entry.type === "custom_message") { … }
if (entry.type === "branch_summary" && entry.summary) { … }
if (entry.type === "compaction") { … }
return [];所以三种 entry 天然沉默:usage、custom、model_change、thinking_level_change、label、session_info、context_edit 全部走那个 return []。
这个区分很重要,因为**「记在会话里」和「发给模型」是两件事**。举个现成的例子:!! 前缀的命令会记进会话文件,但标记 excludeFromContext: true,在 convertToLlm()(src/core/messages.ts:154)时被过滤掉,所以模型永远看不到它,文件里却完整保留了执行输出。这条机制的完整描述在 交互式会话。
文件头:唯一不在树里的东西
第一行永远是 session:
{"type":"session","version":3,"id":"0198f3a2-7c41-7b6e-9a12-3d5e8f0b7c22","timestamp":"2026-10-23T14:00:00.000Z","cwd":"/Users/me/work/api"}它有 id(会话 ID)但没有 id / parentId 字段——它不是树节点。源码里它是一个独立接口 SessionHeader(session-manager.ts:43),不属于 SessionEntry。
用了 /fork 或 /clone 之后,新会话的文件头会多一个 parentSession 字段,指向来源文件的完整路径:
{"type":"session","version":3,"id":"...","timestamp":"...","cwd":"/Users/me/work/api","parentSession":"/Users/me/.pi/agent/sessions/--Users-me-work-api--/2026-10-23T09-12-00-000Z_0198f1ab....jsonl"}**这是唯一一条跨会话的血缘线。**会话内容本身不知道自己的祖先,全靠文件头这一行。
version 字段是可选的——v1 的会话根本没有它。这也是 14.5 节迁移逻辑的起点。
一个真实片段
下面是一个走过一次 /tree 分支的会话文件的真实形状(长字段做了缩写,结构原样):
{"type":"session","version":3,"id":"0198f3a2-…","timestamp":"2026-10-23T14:00:00.000Z","cwd":"/Users/me/work/api"}
{"type":"message","id":"a0b1c2d3","parentId":null,"timestamp":"…T14:00:01.000Z","message":{"role":"system","content":"","sections":{"preamble":"You are an expert coding assistant…","tools":"<tools>\n- read: …\n</tools>","cwd":"/Users/me/work/api"},"toolsAdded":[{"name":"read","description":"…","parameters":{}}],"timestamp":1733234401000}}
{"type":"message","id":"a1b2c3d4","parentId":"a0b1c2d3","timestamp":"…T14:00:02.000Z","message":{"role":"user","content":"把 auth 模块的 refresh token 逻辑抽出来","timestamp":…}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"…T14:00:20.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"src/auth/refresh.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{…},"stopReason":"toolUse","timestamp":…}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"…T14:00:21.000Z","message":{"role":"toolResult","toolCallId":"call_1","toolName":"read","content":[{"type":"text","text":"export async function refresh() {…}"}],"isError":false,"timestamp":…}}
{"type":"message","id":"d4e5f6a7","parentId":"c3d4e5f6","timestamp":"…T14:00:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"可以抽成 service,建议先看 tests。"}],"stopReason":"stop","timestamp":…}}
{"type":"message","id":"e5f6a7b8","parentId":"d4e5f6a7","timestamp":"…T14:01:02.000Z","message":{"role":"user","content":"先看看现有测试","timestamp":…}}
{"type":"message","id":"f6a7b8c9","parentId":"e5f6a7b8","timestamp":"…T14:01:10.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_2","name":"read","arguments":{"path":"tests/auth.test.ts"}}],"stopReason":"toolUse","timestamp":…}}上面八行是一条直线。现在在 d4e5f6a7(那句「可以抽成 service」)处 /tree 导航回用户消息、改写重发,会话文件尾部会多出这些行:
{"type":"message","id":"1a2b3c4d","parentId":"d4e5f6a7","timestamp":"…T14:03:00.000Z","message":{"role":"user","content":"改成 class 实现,保留现有导出","timestamp":…}}
{"type":"message","id":"2b3c4d5e","parentId":"1a2b3c4d","timestamp":"…T14:03:12.000Z","message":{"role":"assistant","content":[{"type":"text","text":"好,我先看 exports。"}],"stopReason":"stop","timestamp":…}}注意发生了什么:e5f6a7b8、f6a7b8c9 这两条既没被删也没被改,它们只是不再是活跃分支的一部分。新分支的第一条 parentId 指向 d4e5f6a7,和旧分支从同一个点分叉出去。这是 14.3 节那个「文件是追加写的」的直接后果——分支不删除任何东西。
如果当时在摘要那一步选了「Summarize branch」,新分支的第一行还会多一条 branch_summary,而它的 parentId 仍然是 d4e5f6a7。
反过来,如果你打开的是一条被分过叉的会话,head 看到的是文件开头那条线,不是当前分支。当前分支是哪条,要看最后一行往上顺着 parentId 爬。
message 条目:一个类型撑起整个对话
message 条目的 message 字段是一个 AgentMessage。在 coding-agent 里,这个联合类型有八个角色:
type AgentMessage =
| SystemMessage | UserMessage | AssistantMessage | ToolResultMessage
| BashExecutionMessage | CustomMessage | BranchSummaryMessage | CompactionSummaryMessage;其中前四个来自 pi-ai 的基础消息类型,后四个是 coding-agent 加的——BashExecutionMessage 记录 ! 命令的执行,CustomMessage 是扩展注入的上下文,BranchSummaryMessage 和 CompactionSummaryMessage 则是从持久化的 branch_summary / compaction 条目现场构造出来的上下文消息。
注意最后两个角色:它们不是 message 条目,而是独立的 entry 类型。branch_summary 和 compaction 各有自己的 entry,Pi 在重建上下文时把它们转换成消息(sessionEntryToContextMessages 里那两行)。这样设计的好处是摘要本身是树的一等公民——它有 id、有 parentId、能被分支、被引用,而不是一段藏在某条消息里的文本。
system 消息:提示词状态靠重放维持
message 条目里 role: "system" 的那些不是普通消息,它们携带的是系统提示词状态。SystemMessage 的形状:
interface SystemMessage {
role: "system";
content: string | TextContent[];
sections?: Record<string, string | null>;
toolsAdded?: Tool[];
toolsRemoved?: ToolReference[];
timestamp: number;
}规则是:
- 会话的第一次模型请求持久化一条 system 消息,带全部
sections和完整工具声明; - 之后的每次变化(改了
SYSTEM.md、加载了扩展、改了工具选择)持久化另一条 system 消息,按名字 patchsections(值为null表示删掉这一段),并列出toolsAdded/toolsRemoved; - 按顺序重放这些消息,就得到当前的提示词和工具清单。
**没有单独的「提示词状态条目」这种东西。**整个状态就是这串消息的重放结果。
这个设计解决的问题很具体:提示词是分片构成的(有 AGENTS.md、SYSTEM.md、skills、工具列表、cwd),任何一处变化都要能表达「这一段变了」而不是「整段重发」。按名字 patch 是最小表达方式。它还有个副产品:打开会话文件时,你能看到提示词是怎么一步步长出来的。
buildContextEntries() 在压缩之后还会额外处理 system 消息——被保留区间里的 system 消息会被丢掉,因为压缩条目自带了完整的提示词快照(14.3 节详述)。
实用:直接读会话文件
会话文件是纯文本,值得会直接看。几个常用操作:
# 看会话文件在哪、当前多大了
ls -lh ~/.pi/agent/sessions/--Users-me-work-api--/
# 第一行是文件头,看版本和 cwd
head -1 ~/.pi/agent/sessions/--Users-me-work-api--/*.jsonl | tail -1
# 把整个会话摊平成「谁说了什么」,倒序看最新
jq -r 'select(.type=="message")
| .message
| select(.role=="user" or .role=="assistant")
| "\(.role)\t\((.content | if type=="string" then . else ([.[]? | select(.type=="text") | .text] | join(" ")) end) | .[0:120])"'
~/.pi/agent/sessions/--Users-me-work-api--/*.jsonl | tail -40
# 看分支结构:id、parentId、类型、摘要首行
jq -r 'select(.type!="session")
| "\(.id) ← \(.parentId // "ROOT") \(.type)\(if .summary then " " + (.summary[0:60]) else "" end)"' \
~/.pi/agent/sessions/--Users-me-work-api--/*.jsonl最后一条是调试分支问题最有用的:它把 parentId 关系摊平了,一眼能看出哪一行挂在哪一行下面。
一个要注意的点:JSONL 没有跨行的结构,jq 一次处理一个流式对象,所以不需要 --slurp,也不应该加。
14.3 树与分支模型
指针模型:只有叶子在动
会话是树,但操作模型比树简单得多。整个模型只有两个可变状态:
- 条目集合——只增不改,
appendXXX()加 - 叶子指针(
leafId)——唯一会「回退」的东西
三个导航原语,全在 session-manager.ts:1579 起:
/** Start a new branch from an earlier entry.
* Moves the leaf pointer to the specified entry. The next appendXXX() call
* will create a child of that entry, forming a new branch. */
branch(branchFromId: string): void {
if (!this.byId.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
this.leafId = branchFromId;
}
/** Reset the leaf pointer to null (before any entries).
* Use this when navigating to re-edit the first user message. */
resetLeaf(): void {
this.leafId = null;
}
/** Start a new branch with a summary of the abandoned path. */
branchWithSummary(branchFromId, summary, details?, fromHook?, usage?): stringbranch() 本身不写任何东西——它只是把 leafId 指到别的条目上。文件长度不变。你下一次发消息,appendMessage() 才会创建一个 parentId 指向那条旧 entry 的新节点,分支就是这么长出来的。
官方文档给的示意图(docs/session-format.md):
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
│
└─ [branch_summary] ─── [user msg] ← alternate branch/tree 选中不同东西,落点不同
/tree 打开一个树选择器。选中一个条目之后,落点由条目的类型决定,逻辑在 navigateTree()(src/core/agent-session.ts:4122 起):
这个区别是设计出来的,不是偶然:用户消息是可以「重新说一遍」的,助手回复不行——你没法「编辑」一次助手回复。你想回到一个助手回复之后重新提问,语义上等价于回到那个提问之前,所以那条助手回复也一起被跳过;落点就是它的父节点。
同理,如果你回到第一条用户消息,落点 parentId 是 null,走 resetLeaf(),新消息会成为一个新根。docs/session-format.md 明确记了这件事:
Entries normally form one tree, but navigation APIs can create multiple roots:
- A root entry has parentId: null; the first entry is initially the root
- …
- Calling resetLeaf() or branchWithSummary(null, ...) allows a later entry to become another root所以「一棵树」是常态而不是保证。真出现多个根时,getTree() 会把它们都当根返回,孤儿子树(父链断了的条目)也一样。源码注释说得直接:
/** A well-formed session has exactly one root (first entry with parentId === null).
* Orphaned entries (broken parent chain) are also returned as roots. */打开会话时叶子在哪儿
值得单独说,因为它和「时间最新」不完全等价。_buildIndex()(session-manager.ts:1103)重建索引时是这样写的:
for (const entry of this.fileEntries) {
if (entry.type === "session") continue;
this.byId.set(entry.id, entry);
this.leafId = entry.id; // 每轮都覆盖,最终留下最后一行
…
}leafId 最后停在文件里的最后一条 entry,而不是树上最深的节点。这有一个实际后果:你在 /tree 里往回导航、切到旧分支之后直接退出,下次打开会话会回到那条旧分支上,因为分支摘要条目或者你的新消息已经被追加到了文件末尾。
这不是 bug,是「文件顺序 = 活动轨迹」的必然结果。
从叶子到消息:三层构建
知道叶子在哪儿之后,「发给模型的东西是怎么算出来的」有三层,每层解决一个问题。
第一层:buildContextEntries()(session-manager.ts:476)——挑出这一条分支上要参与渲染的条目。
从叶子沿 parentId 一路走到根,得到路径。然后处理压缩:
const path = buildSessionPath(entries, leafId, byId);
let compaction: CompactionEntry | null = null;
for (const entry of path) {
if (entry.type === "compaction") compaction = entry; // 取最后一个
}
if (!compaction) return path;
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));规则很清楚:路径上如果有压缩条目,取最后一个;它之前只有 firstKeptEntryId 开始的区间留下,且区间里的 system 消息被丢弃;它之后的全部留下。
丢弃区间里的 system 消息是因为压缩条目自带了完整的提示词快照,留着会重复。压缩的具体机制在 上下文装配与压缩。
第二层:buildSessionProjection()(session-manager.ts:543)——应用 context_edit。
context_edit 条目不产生消息,它修改别人的贡献:
const edits = new Map<string, ContextEditEntry>();
for (const entry of contextEntries) {
if (entry.type === "context_edit") edits.set(entry.targetId, entry);
}同一个目标被多次编辑时,最后一条赢(Map.set 覆盖)。replacement: null 表示「把目标从模型上下文里彻底拿掉」,非 null 则只换内容、保留角色和元数据。
这一步的核心性质写在 docs/session-format.md 里:
It changes only future model context; the target entry and its metadata remain unchanged in raw history, UI, exports, and session accounting.
也就是说:**context_edit 改的是投影,不是原始数据。**原始条目在文件里、在 UI 里、在导出里、在 token 统计里都还是原样。这是 Pi 里少见的「可变视图叠在不可变日志上」的设计。
编辑是分支相对的:导航到这条 edit 之前,被它遮住的原始内容又回来了。
context_edit 能改什么?appendContextEdit()(session-manager.ts:1360)做了完整校验:目标必须在活跃分支上(!this.getBranch().some(...) 就报错),且必须是 user / assistant / toolResult / custom_message 四类之一。
第三层:buildSessionContext()(session-manager.ts:576)——取出模型和思考等级。
模型和思考等级不在消息里,而是从路径上扫出来的。getSessionContextSettings()(session-manager.ts:418)的遍历顺序有讲究:
for (const entry of path) {
if (entry.type === "thinking_level_change") thinkingLevel = entry.thinkingLevel;
else if (entry.type === "model_change") model = { provider, modelId };
else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
}
}后出现的覆盖先出现的。注意第三条:assistant 消息也携带模型信息,所以即使你只在文件里改了一条 model_change 就删掉了,任何一条 assistant 消息都会把它盖回去。这也是为什么说「最后一条 model_change 是当前选择的模型」——前提是它在那条 assistant 消息之后。
路径上一个 model_change 都没有、也没有 assistant 消息时,model 是 null,thinkingLevel 兜底为 "off"。
14.4 tree、fork、clone 的区别
这一节是本文的核心。三个命令做三件事,但它们的关系比看起来更紧——/fork 和 /clone 其实是同一个原语的两种参数。
三者对照
选型可以直接读最后一行:如果你探索的分支回头还要一起看,用 /tree;如果它会变成一个独立的功能分支,用 /fork;如果只是想拿一份当前状态做对照,用 /clone。
/tree 只动指针
前面讲过了,这里只补一句实践含义:用 /tree 分支,文件数量不变。你来回探索十个方向,磁盘上还是一个文件,只是里面多了十个分支。文件会变大(每个分支的内容都留着),但条目数是唯一代价。
/fork 和 /clone 是同一个函数
它们都走 AgentSessionRuntime.fork()(src/core/agent-session-runtime.ts:262),签名是:
async fork(entryId: string, options?: { position?: "before" | "at"; withSession?: … }): Promise<…>position 默认是 "before"。函数体的分岔只有三行:
if (position === "at") {
targetLeafId = selectedEntry.id;
} else {
if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry ID for forking");
}
targetLeafId = selectedEntry.parentId;
selectedText = extractUserMessageText(selectedEntry.message.content);
}然后两边汇合到同一个出口:
const sessionManager = SessionManager.open(currentSessionFile, sessionDir);
const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId);两个调用点:
// interactive-mode.ts —— /clone
const result = await this.runtimeHost.fork(leafId, { position: "at" });
this.editor.setText("");
this.showStatus("Cloned to new session");
// interactive-mode.ts —— /fork
this.showUserMessageSelector(); // 只列用户消息所以完整的对应关系是:
/clone = fork(currentLeaf, position: "at")
/fork = fork(selectedUserMessage, position: "before") ← 默认position: "at" 分支不做类型校验(任意条目都行),"before" 分支强制要求用户消息,不满足直接抛 Invalid entry ID for forking。这就是为什么 /fork 的选择器只列用户消息,而 /tree 的选择器列全部。
新会话文件里装的是什么
createBranchedSession()(session-manager.ts:1632)只写从根到 leafId 的那一条路径:
const path = this.getBranch(leafId);
…
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this._rewriteFile();所以:
- 其它分支的内容不会进新文件。 fork 是在提取一条路径,不是复制整个文件。
- label 条目被单独处理。 因为 label 本身是树上的真实节点(
parentId有值),直接过滤掉会留下孤儿子树。源码的处理是把 label 从路径里摘出来重新生成,挂在链尾。 - 压缩条目的
firstKeptEntryId要跟着重映射:
entry.type === "compaction"
? { ...entry, parentId: pathParentId,
firstKeptEntryId: entry.firstKeptEntryId === entry.id
? entry.id
: (replacementByLabelId.get(entry.firstKeptEntryId) ?? entry.firstKeptEntryId) }
: { ...entry, parentId: pathParentId },因为 firstKeptEntryId 指向的条目可能是一条被摘掉的 label,得换成它下一个实际存在的条目。firstKeptEntryId 等于自身的那种「什么都不保留」的压缩则原样保留。
一个边界情况:fork 第一条用户消息
如果 targetLeafId 是 null(你在 fork 第一条用户消息,或者它的父就是根),走的是另一条路:
if (!targetLeafId) {
const sessionManager = SessionManager.create(this.cwd, sessionDir);
sessionManager.newSession({ parentSession: currentSessionFile });
…
return { cancelled: false, selectedText };
}新会话一条 entry 都没有,只有一个带 parentSession 的文件头。你的那条提问文本在 selectedText 里返回给交互层放回编辑器。效果就是「保留血缘关系,从零开始」。
一个陷阱:命令行的 --fork 是第三件事
命令行 pi --fork <path|id> 走的不是 runtime.fork(),而是 SessionManager.forkFrom()(session-manager.ts:1815)。它的行为完全不同:
// Write new header pointing to source as parent, with updated cwd
const newHeader: SessionHeader = { …, parentSession: resolvedSourcePath };
writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
// Copy all non-header entries from source
for (const entry of sourceEntries) {
if (entry.type !== "session") {
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
}
}它复制除文件头以外的全部条目——整棵树,所有分支——并且把 cwd 改成当前项目。
--session 命中跨项目会话时也会走这条路:它先问一句「把这个会话 fork 到当前目录?」,同意后调 forkSessionOrExit()。所以「打开别的项目的会话」实际是在复制它。
这条约束在 docs/cli.md 里写得很明确:--fork 不能和 --session、--continue、--resume、--no-session 组合使用。
分支时能带一个摘要
/tree 导航时,Pi 会问一句「Summarize branch?」,三个选项:不摘要、摘要、带自定义指令摘要。源码在 interactive-mode.ts:5610 附近。
选摘要时,branchWithSummary() 在新落点上追加一条 branch_summary 条目——注意是新分支上,不是旧分支:
// Summary is attached at the navigation target position (newLeafId), not the old branch
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension, summaryUsage);它记录了两件事:
{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","fromId":"f6g7h8i9","summary":"Branch explored approach A...","details":{"readFiles":[],"modifiedFiles":[]}}parentId 是新分支继续的那个条目,fromId 是你刚刚离开的那个叶子。这两个字段一起把「我从哪来、我在哪」记全了。
摘要的具体机制在 上下文装配与压缩 的 15.4 节。
血缘:怎么找回「这条分支是从哪来的」
会话内容自己不知道祖先,全靠文件头。查一个会话的来历:
# 从当前会话文件读出 parentSession
jq -r 'select(.type=="session") | .parentSession // "(no parent)"' \
~/.pi/agent/sessions/--Users-me-work-api--/*.jsonl输出是一个绝对路径。拿它当下一个会话文件的路径继续查,就能一路回溯到根。中间断了也不要紧——断掉的那一环意味着中间有人删过文件或者手工改过。
SessionInfo 结构里有对应字段 parentSessionPath,/resume 的选择器界面和 SDK 都能读到。
14.5 版本与迁移
三个版本
session-manager.ts:41 一行常量:
export const CURRENT_SESSION_VERSION = 3;v1→v2 是真正的结构迁移,v2→v3 只是改一个字符串:
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
function migrateV1ToV2(entries: FileEntry[]): void {
const ids = new Set<string>();
let prevId: string | null = null;
for (const entry of entries) {
if (entry.type === "session") { entry.version = 2; continue; }
entry.id = generateId(ids);
entry.parentId = prevId;
prevId = entry.id;
if (entry.type === "compaction") { // 索引改成 id
const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
if (typeof comp.firstKeptEntryIndex === "number") {
const targetEntry = entries[comp.firstKeptEntryIndex];
if (targetEntry && targetEntry.type !== "session") {
comp.firstKeptEntryId = targetEntry.id;
}
delete comp.firstKeptEntryIndex;
}
}
}
}
/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
function migrateV2ToV3(entries: FileEntry[]): void {
for (const entry of entries) {
if (entry.type === "session") { entry.version = 3; continue; }
if (entry.type === "message") {
const msgEntry = entry as SessionMessageEntry;
if (msgEntry.message && (msgEntry.message as {role: string}).role === "hookMessage") {
(msgEntry.message as {role: string}).role = "custom";
}
}
}
}v1→v2 把线性序列串成一条链:第一条 parentId 是 null,之后每条的 parentId 是上一条的 id。所以 v1 会话迁移后仍然是一条直线,树结构要等第一次 /tree 导航才长出来。
顺带把 v1 时代的 firstKeptEntryIndex(数组下标)翻译成 v2 的 firstKeptEntryId(条目 ID),并删掉旧字段。下标在插入条目后会失效,ID 不会。
「自动迁移」意味着你的文件被改写
这一点值得明确,因为它不写在文档标题里。加载时的流程是:
private _loadEntries(entries: FileEntry[], options?: NewSessionOptions): void {
const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
if (header) {
this.fileEntries = entries;
this.sessionId = header.id;
if (migrateToCurrentVersion(this.fileEntries)) {
this._rewriteFile(); // ← 原地重写磁盘文件
}
} else {
this.newSession(options);
this.fileEntries = this.fileEntries.concat(entries);
}
this._buildIndex();
}migrateToCurrentVersion() 返回是否真的迁移过;返回 true 才触发 _rewriteFile()。所以:
- v3 文件:只读,不写。
- v1/v2 文件:第一次被 Pi 打开时,整份文件被重写成 v3,然后才继续。
版本判断的兜底是 header?.version ?? 1——文件头里没有 version 字段就当 v1。所以手写或裁剪过的文件头会被当成 v1 走完整迁移。
重写用的是 _rewriteFile(),openSync(path, "w") 截断后逐条写回,没有临时文件 + rename 的原子替换。理论上写到一半崩溃会损坏文件。实际风险很低(写的是同一个目录、同一份数据),但如果你有重要会话,值得在做版本升级前先备份一份目录。
两个解析层级的容错
解析时坏行被静默跳过。parseSessionEntries():
for (const line of lines) {
if (!line.trim()) continue;
try {
entries.push(JSON.parse(line) as FileEntry);
} catch {
// Skip malformed lines
}
}没有告警、没有计数。这意味着:磁盘写崩了留下的半行、你手工编辑时切错了引号、被别的工具截断了文件——这些行会无声消失。会话仍然能打开,只是少了几条消息。
排障时的对策是直接数行数:
total=$(wc -l < session.jsonl)
parsed=$(jq -c . session.jsonl 2>/dev/null | wc -l)
echo "文件 $total 行,能解析出 $parsed 行"两个数不等就说明有坏行。
文件头丢失不会崩。_loadEntries() 那个 else 分支走 newSession() 然后把解析出来的条目接在后面。也就是说,一个没有 session 行的文件会被当成「新会话 + 一堆孤儿条目」,_buildIndex() 里它们全部按 parentId 串起来,叶子指向最后一条。
后果是这个会话在 /resume 里显示得很奇怪(没有创建时间、没有 cwd),但内容还在。补救办法是手工补一行文件头——id 随便填个合法 ID,version 填 3,cwd 填项目绝对路径,timestamp 填 ISO 时间。
兼容性承诺
v3 目前是稳定的。官方文档的措辞是 "Existing sessions are automatically migrated to the current version (v3) when loaded",也就是说每条加载路径都做迁移:--continue、--resume、--session、/import、SessionManager.open()、forkFrom() 都一样。
写第三方工具读会话文件时,有两条实践建议:
- **别假设
version存在。**v1 文件没有这个字段,缺失时按 1 处理。 - 遇到不认识的
type要跳过而不是报错。SessionEntry是联合类型,扩展可以通过声明合并扩展消息角色;UsageEntry的kind也是任意字符串,官方文档明确说 "Consumers should treat unknownkindvalues as normal usage rather than rejecting them"。前向兼容靠的就是这个。
14.6 事实来源
本文所有数字、字段名和路径规则都可在仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。