一、引言:什么是 session
前八篇文章讲了 pi agent 的完整运行机制:调 LLM、流式事件、循环、工具、hook、skills、Extension API、compaction。这些机制都发生在"一次 agent 运行"里------agent 启动、干活、结束。但真实使用中,用户的需求不止于此:
- agent 跑完后关掉,下次打开想接着之前的对话
- 在某个决策点想换个方向试试,但不丢失原来的探索
- 想回到几十轮前的某个点,从那里重新开始
这些需求需要一个东西来承载------session。
session 是 pi 对"一次对话"的持久化单元。它不只是消息列表------它是一棵树,记录了对话的完整历史,包括用户消息、assistant 回复、工具结果、模型切换、工具集变更、compaction 摘要、分支摘要。每个节点是一个 entry,通过 parentId 连成父子关系。树的根是第一条消息,叶节点标记当前所在的位置。
文章 8 讲的 compaction 和 branch summarization 都是挂在 session 树上的------CompactionEntry 和 BranchSummaryEntry 是树上的节点类型,依赖 session 树的 firstKeptEntryId / fromId 等字段工作。但 session 树本身怎么管理、怎么持久化、怎么分叉------都是黑盒。本章拆开它。
二、session 树结构
文章 8 第 5 章讲了 session 树的最小基础------entry 类型表 + getBranch + buildSessionContext。本章展开树本身的拓扑结构,以及 leaf 节点怎么控制"当前在哪个分支"。
1. 树形拓扑
session 不是链表,是树。链表只能线性追加消息,树允许在任何节点分叉出新分支:
less
root (user: "帮我重构模块")
/ \
(assistant: "好的") (assistant: "我先看看测试")
/ \
(toolResult: read) (toolResult: read test.ts)
/ \
(user: "用方案 A") (user: "用方案 B") ← 分叉点
/ \
(assistant: ...) (assistant: ...)
/ \
(assistant: "完成") (assistant: "完成")
↑ ↑
leaf A leaf B
分叉的形成:用户在某个 assistant 回复后,不走"继续对话",而是通过 /fork 命令从之前的某个节点创建新分支。新分支从分叉点开始,有自己的后续消息,但分叉点之前的消息两个分支共享。
2. leaf 节点------"当前在哪"
树可以有多个分支,但 agent 同一时间只在一个分支上工作。怎么标记"当前在哪个分支"?靠 LeafEntry(types.ts:404-407):
typescript
interface LeafEntry extends SessionTreeEntryBase {
type: "leaf";
targetId: string | null; // 指向当前叶节点(最新位置)的 entry ID
}
LeafEntry 是一种特殊的 entry------它不记录对话内容,而是记录"当前位置指针"。targetId 指向当前分支的最新 entry。
session 存储维护一个 currentLeafId(jsonl-storage.ts:168):
typescript
class JsonlSessionStorage {
private currentLeafId: string | null;
async getLeafId(): Promise<string | null> {
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
// 在文件里追加一条 LeafEntry------记录"当前位置变了"
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.currentLeafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
this.currentLeafId = leafId;
}
}
setLeafId 做两件事:
- 追加一条 LeafEntry 到文件 ------持久化"当前位置变了"这个事件。这样下次加载 session 时,最后一条 LeafEntry 的
targetId就是当前分支。 - 更新内存里的
currentLeafId------后续的getBranch()/appendEntry()都用这个值。
普通消息追加时也自动更新 currentLeafId(jsonl-storage.ts:109-111):
typescript
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
普通 entry 追加后,currentLeafId 变成这条 entry 的 ID------它就是最新的叶节点。只有 setLeafId(显式切换分支)才会把 currentLeafId 指向一个非最新节点。
3. getBranch------从叶到根
getPathToRoot(jsonl-storage.ts:275-288)从 currentLeafId 往上追溯到根,返回当前分支的完整路径:
typescript
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
while (current) {
path.unshift(current); // 往前插------最终是根到叶的正序
if (!current.parentId) break;
current = this.byId.get(current.parentId);
}
return path;
}
从叶节点开始,沿 parentId 链一直追到根(parentId === null)。返回的数组是根到叶的正序------这就是要发给 LLM 的完整对话历史。
4. 完整树示意图
java
entry ID 类型 parentId 内容
───────── ────────────── ──────── ────
a1b2 message(user) null "帮我重构模块"
c3d4 message(assistant) a1b2 "好的,我先读文件"
e5f6 message(toolResult) c3d4 read types.ts
g7h8 message(user) e5f6 "用方案 A"
i9j0 message(assistant) g7h8 "我来修改"
k1l2 message(assistant) i9j0 "完成"
m3n4 leaf k1l2 targetId: k1l2 ← 当前在分支 A
# 用户 /fork 从 g7h8 分叉:
o5p6 message(user) e5f6 "用方案 B" ← 从 e5f6 分叉
q7r8 message(assistant) o5p6 "我换一种方式"
s9t0 message(assistant) q7r8 "完成"
u1v2 leaf s9t0 targetId: s9t0 ← 当前在分支 B
这棵树有两个分支,共享 a1b2 → c3d4 → e5f6 三条 entry,从 e5f6 分叉。最后一条 LeafEntry(u1v2)的 targetId 是 s9t0------当前在分支 B。getPathToRoot("s9t0") 返回 a1b2 → c3d4 → e5f6 → o5p6 → q7r8 → s9t0。
如果用户执行 /tree 切回分支 A,setLeafId("k1l2") 追加一条新 LeafEntry,currentLeafId 变成 k1l2。之后 getPathToRoot("k1l2") 返回 a1b2 → c3d4 → e5f6 → g7h8 → i9j0 → k1l2。
三、JSONL 持久化
session 树需要存到文件里------这样 agent 关掉再打开还能恢复。pi 用 JSONL 格式存储:每行一个 JSON 对象,第一行是 header,后续每行是一个 entry。
1. 文件格式
swift
{"type":"session","version":3,"id":"abc123","timestamp":"2026-01-15T10:30:00Z","cwd":"/project"}
{"type":"message","id":"a1b2","parentId":null,"timestamp":"2026-01-15T10:30:01Z","message":{"role":"user","content":"帮我重构模块"}}
{"type":"message","id":"c3d4","parentId":"a1b2","timestamp":"2026-01-15T10:30:02Z","message":{"role":"assistant","content":[{"type":"text","text":"好的"}]}}
{"type":"message","id":"e5f6","parentId":"c3d4","timestamp":"2026-01-15T10:30:03Z","message":{"role":"toolResult","toolCallId":"call_1","toolName":"read","content":[{"type":"text","text":"file contents..."}]}}
{"type":"compaction","id":"g7h8","parentId":"e5f6","timestamp":"2026-01-15T10:35:00Z","summary":"## Goal\n重构模块\n## Progress\n...","firstKeptEntryId":"c3d4","tokensBefore":95000,"details":{"readFiles":["src/types.ts"],"modifiedFiles":[]}}
{"type":"leaf","id":"i9j0","parentId":"g7h8","timestamp":"2026-01-15T10:35:01Z","targetId":"g7h8"}
逐行注释:
第 1 行------header (jsonl-storage.ts:8-15):
typescript
interface SessionHeader {
type: "session"; // 标识这是 session 文件
version: 3; // 格式版本(当前 v3)
id: string; // session ID
timestamp: string; // 创建时间
cwd: string; // 工作目录
parentSession?: string; // 如果是 fork 出来的,指向父 session 文件路径
}
header 只写一次------创建 session 文件时写入,后续不再修改。parentSession 让 fork 出来的 session 能追溯来源。
第 2+ 行------entries :每行一个 SessionTreeEntry,通过 parentId 连成树。所有类型共享 SessionTreeEntryBase(id / parentId / timestamp),各自加自己的字段。
2. JsonlSessionStorage
JsonlSessionStorage(jsonl-storage.ts:161-293)是 session 的存储引擎。构造时读入全部 entries 到内存:
typescript
class JsonlSessionStorage {
private entries: SessionTreeEntry[]; // 按追加顺序排列
private byId: Map<string, SessionTreeEntry>; // ID → entry 快速查找
private labelsById: Map<string, string>; // 标签缓存
private currentLeafId: string | null; // 当前叶节点
static async open(fs, filePath): Promise<JsonlSessionStorage> {
const loaded = await loadJsonlStorage(fs, filePath);
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
}
}
loadJsonlStorage(jsonl-storage.ts:136-159)读文件、parse header、逐行 parse entries、跟踪 leafId:
typescript
async function loadJsonlStorage(fs, filePath) {
const content = await fs.readTextFile(filePath);
const lines = content.split("\n").filter(line => line.trim());
const header = parseHeaderLine(lines[0], filePath);
const entries = [];
let leafId = null;
for (let i = 1; i < lines.length; i++) {
const entry = parseEntryLine(lines[i], filePath, i + 1);
entries.push(entry);
leafId = leafIdAfterEntry(entry); // 最后一条 LeafEntry 的 targetId,或最后一条普通 entry 的 id
}
return { header, entries, leafId };
}
加载时 leafId 不断被后面的 entry 覆盖------最终值就是"最后一条 entry 标记的当前位置"。如果是 LeafEntry,取 targetId;如果是普通 entry,取 id。
3. appendEntry------追加写入
appendEntry(jsonl-storage.ts:250-259)是 session 写入的唯一入口------每条新消息都调它:
typescript
async appendEntry(entry: SessionTreeEntry): Promise<void> {
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = leafIdAfterEntry(entry);
}
关键设计------只追加,不修改。session 文件从不重写------新 entry 追加到文件末尾。这让写入操作是 O(1) 的(不需要重写整个文件),且不会因崩溃损坏已有内容。
setLeafId 也是追加(jsonl-storage.ts:237-238)------追加一条 LeafEntry,不修改之前的任何行。即使切回旧分支,也只是追加一条新的 leaf 指针,旧的 leaf 指针留在文件里记录历史。
4. create------创建新 session
create(jsonl-storage.ts:191-213)创建新 session 文件:
typescript
static async create(fs, filePath, options: { cwd, sessionId, parentSessionPath? }) {
const header: SessionHeader = {
type: "session",
version: 3,
id: options.sessionId,
timestamp: new Date().toISOString(),
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`); // 写 header + 换行
return new JsonlSessionStorage(fs, filePath, header, [], null); // 空 entries,null leafId
}
创建时只写 header------一个空 session 文件就是一行 JSON。后续所有 entry 通过 appendEntry 追加。
四、分支操作
session 树支持四种操作:创建新 session、从已有 session 分叉、切换到另一个 session、在当前 session 树内切换分支。
操作示意图
less
Session 文件 A Session 文件 B(fork from A 的 e5f6)
┌──────────────────────┐ ┌──────────────────────┐
│ header (id: abc123) │ │ header (id: def456) │
│ a1b2 user │──── fork ────→│ parentSession: A │
│ c3d4 assistant │ 从 e5f6 │ a1b2 user (复制) │
│ e5f6 toolResult │ │ c3d4 assistant (复制) │
│ g7h8 user │ │ e5f6 toolResult(复制)│
│ i9j0 assistant │ │ o5p6 user (新) │
│ leaf: i9j0 │ │ q7r8 assistant (新) │
└──────────────────────┘ │ leaf: q7r8 │
└──────────────────────┘
Session 文件 A 内部(navigateTree):
┌──────────────────────┐
│ ... │
│ g7h8 user │ ← 切到这里(setLeafId g7h8)
│ i9j0 assistant │
│ leaf: i9j0 │ ← 之前在这里
│ │
│ leaf: g7h8 (新追加) │ ← setLeafId 追加的新 LeafEntry
└──────────────────────┘
1. newSession------创建全新 session
伪代码(session-manager.ts:824-849):
kotlin
function newSession(options?):
sessionId = options.id ?? generateSessionId()
header = {
type: "session",
version: 3,
id: sessionId,
timestamp: now(),
cwd: this.cwd,
parentSession: options.parentSession, # 有父 session 才填
}
this.fileEntries = [header] # 只剩 header
this.byId.clear()
this.leafId = null # 空 session,没有叶节点
this.sessionFile = "{timestamp}_{sessionId}.jsonl"
return this.sessionFile
创建全新 session------清空所有状态,写一个新 header 到新文件。后续消息从这个空状态开始追加。如果 parentSession 有值,说明这个 session 是从另一个 session fork 出来的------header 记录溯源。
2. forkFrom------从已有 session 分叉
伪代码(session-manager.ts:1434-1485):
ini
static function forkFrom(sourcePath, targetCwd, options?):
# 1. 读源 session 文件
sourceEntries = loadEntriesFromFile(sourcePath)
sourceHeader = sourceEntries.find(e => e.type == "session")
# 2. 创建新 session 文件
newSessionId = generateSessionId()
newSessionFile = "{timestamp}_{newSessionId}.jsonl"
newHeader = {
type: "session",
version: 3,
id: newSessionId,
timestamp: now(),
cwd: targetCwd,
parentSession: sourcePath, # 指向源文件------溯源
}
writeFile(newSessionFile, newHeader)
# 3. 复制源 session 的所有 entry(不含 header)
for entry in sourceEntries:
if entry.type != "session":
appendFile(newSessionFile, entry)
# 4. 返回新的 SessionManager 实例
return new SessionManager(targetCwd, dir, newSessionFile)
forkFrom 做三件事:
- 读源文件:把源 session 的所有 entry 读出来
- 写新 header :新 ID、新时间戳、
parentSession指向源文件路径------这让 fork 出来的 session 能追溯来源 - 复制 entries:把源文件的所有 entry(不含 header)追加到新文件------全部复制,包括 LeafEntry
关键设计------fork 是文件级别的复制,不是树内部的分叉 。fork 创建一个全新的 JSONL 文件,把源文件的内容全部复制过来。新 session 和旧 session 是两个独立文件,各自演化。新 session 的 parentSession 字段记录溯源关系。
为什么全部复制而不是引用?因为 session 文件是只追加的------引用需要处理"源文件后续新增 entry 怎么办"。全复制更简单------fork 出来的 session 独立演化,不受源文件影响。
3. setSessionFile------切换到另一个 session
伪代码(session-manager.ts:792-822):
kotlin
function setSessionFile(sessionFile):
this.sessionFile = sessionFile
if exists(sessionFile):
this.fileEntries = loadEntriesFromFile(sessionFile)
if this.fileEntries.length == 0:
# 文件空或损坏------重新初始化
this.newSession()
this.sessionFile = sessionFile
this._rewriteFile()
return
header = this.fileEntries.find(e => e.type == "session")
this.sessionId = header.id
# 如果需要迁移旧版本格式
if migrateToCurrentVersion(this.fileEntries):
this._rewriteFile()
this._buildIndex() # 重建 byId Map + leafId
this.flushed = true
else:
# 文件不存在------新建
this.newSession()
this.sessionFile = sessionFile
setSessionFile 是 session 级别的切换------从当前 session 文件切到另一个 session 文件。加载新文件的所有 entry,重建内存索引(byId Map + leafId),后续从新文件的当前位置继续。
_buildIndex(session-manager.ts:851-870)遍历所有 entry 重建 byId Map 和 leafId------和 loadJsonlStorage 一样的逻辑,但这是 SessionManager 自己的实现(coding-agent 层有自己的 session 存储逻辑,不完全依赖 agent 层的 JsonlSessionStorage)。
4. navigateTree------在当前 session 内切换分支
navigateTree 不是一个单独的函数------它是通过 setLeafId 实现的。用户用 /tree 命令选择一个 entry,pi 调 session.setLeafId(targetEntryId),追加一条新 LeafEntry,currentLeafId 指向选中的 entry。
bash
function navigateTree(targetEntryId):
session.setLeafId(targetEntryId)
# setLeafId 内部:
# 1. 追加 LeafEntry { targetId: targetEntryId }
# 2. 更新 currentLeafId = targetEntryId
# 之后 getBranch() 返回新路径
和 setSessionFile 的区别:
| setSessionFile | navigateTree (setLeafId) | |
|---|---|---|
| 切换范围 | 跨文件 | 同一文件内 |
| 操作 | 加载新文件 + 重建索引 | 追加 LeafEntry + 更新 leafId |
| 持久化 | 新文件的内容全部加载 | 只追加一行 LeafEntry |
四种操作对比
| 操作 | 文件级别 | 做什么 | 触发命令 |
|---|---|---|---|
newSession |
创建新文件 | 清空状态,写新 header | /new |
forkFrom |
创建新文件 | 复制源文件内容 + 新 header 指向源 | /fork |
setSessionFile |
切换文件 | 加载目标文件 + 重建索引 | /resume |
setLeafId |
同一文件 | 追加 LeafEntry + 更新 leafId | /tree |
五、session 恢复
agent 启动时需要恢复之前的 session------读 JSONL 文件、重建内存索引、恢复 context。
1. 启动加载流程
scss
pi 启动
→ 检查 session 目录(~/.pi/agent/sessions/<encoded-cwd>/)
→ 有 session 文件?→ 加载最新的 / 用户指定的
→ JsonlSessionStorage.open(filePath)
→ loadJsonlStorage()
→ 读文件全部内容
→ parse header(第一行)
→ 逐行 parse entries
→ 跟踪 leafId(最后一条 entry 标记的位置)
→ 构建 byId Map + labelsById Map + currentLeafId
→ buildSessionContext(pathEntries)
→ 从路径重建 messages 数组(文章 8 第 5 章讲过)
→ 恢复 model / thinkingLevel / activeTools(从 path 上的状态变更 entry 读取)
→ 准备好,等待用户输入
2. loadJsonlStorage------读文件重建内存
loadJsonlStorage(jsonl-storage.ts:136-159)在第 3 章讲过------读文件、parse header、逐行 parse entries、跟踪 leafId。加载完成后,JsonlSessionStorage 的内存状态:
yaml
entries: [header, entry1, entry2, ..., entryN] # 按文件顺序
byId: { "a1b2": entry1, "c3d4": entry2, ... } # ID → entry 快速查找
labelsById: { "a1b2": "重要节点" } # 标签缓存
currentLeafId: "s9t0" # 最后一条 entry 标记的位置
byId Map 是关键------getPathToRoot 用它做 parentId 链追溯。没有它就要每次遍历 entries 数组,O(n) 变 O(1)。
3. buildSessionContext------从路径重建 context
文章 8 第 5 章讲过 buildSessionContext(session.ts:22-80)。它在启动恢复时被调:
typescript
// session.ts:109-115
async getBranch(fromId?: string): Promise<SessionTreeEntry[]> {
const leafId = fromId ?? await this.getLeafId();
return this.storage.getPathToRoot(leafId);
}
async buildContext(): Promise<SessionContext> {
return buildSessionContext(await this.getBranch());
}
getBranch()从currentLeafId往上追溯到根,返回路径上所有 entrybuildSessionContext()遍历路径,处理 compaction entry(用摘要替换旧消息),恢复model/thinkingLevel/activeToolNames
恢复后的 context 和上次关闭时一致------LLM 下一轮看到的是"上次最后一条消息 + 之前的完整历史(或 compaction 摘要 + 保留消息)"。
4. 版本迁移
session 文件格式可能升级(当前 v3)。setSessionFile(session-manager.ts:811-813)加载时检查版本:
typescript
if (migrateToCurrentVersion(this.fileEntries)) {
this._rewriteFile(); # 迁移后重写整个文件
}
如果文件是旧版本,migrateToCurrentVersion 把旧格式 entry 转成新格式,然后 _rewriteFile 重写整个文件。这是 session 管理里唯一需要重写文件的场景------正常操作都是只追加。
5. 恢复后的状态
恢复完成后的状态:
| 状态 | 来源 |
|---|---|
| messages 数组 | buildSessionContext 从 path 重建(含 compaction 摘要 + 保留消息) |
| model / provider | 路径上最后一条 model_change entry 或最后一条 assistant 消息 |
| thinkingLevel | 路径上最后一条 thinking_level_change entry |
| activeToolNames | 路径上最后一条 active_tools_change entry |
| currentLeafId | 文件最后一条 entry 标记的位置 |
agent 从这个状态继续------用户可以接着输入,也可以用 /tree 切分支、/resume 切 session、/fork 分叉新探索。
六、Q&A
Q1:session 文件会无限增长吗?
会,但有 compaction 控制。session 文件是只追加的------每条消息、每次状态变更都追加一行。长对话的文件可能到几 MB。
compaction 不删除旧 entry------它追加一条 CompactionEntry 标记"从这里开始用摘要"。旧的 message entry 仍在文件里,但 buildSessionContext 重建 context 时跳过它们。文件变大了,但 context 不变。
如果文件确实太大,可以 /fork 创建新 session------只复制当前路径上的 entry(compaction 后的近期消息 + 摘要),旧文件留着不删。
Q2:session 文件损坏了怎么办?
loadJsonlStorage 逐行 parse------某行 JSON 损坏只跳过那一行,不影响其他 entry。parseEntryLine 对每行做校验(type / id / parentId / timestamp),损坏的行记 warning 跳过。
如果 header 损坏(第一行不是有效 session header),整个文件不可用------setSessionFile 检测到后 newSession() 重新初始化该文件。
_rewriteFile 是另一个恢复路径------版本迁移时重写整个文件。如果迁移过程中崩溃,原文件可能损坏。pi 用同步写入(writeFileSync),减少部分写入的风险。
Q3:可以在不同项目间共享 session 吗?
可以------session 文件是独立的 JSONL 文件。/import 命令导入外部 session 文件,/export 导出。
但 session 的 header 里有 cwd------加载时如果 cwd 不匹配当前工作目录,工具的文件路径可能不对(如 read 的路径是相对于旧 cwd 的)。fork 时可以指定新的 targetCwd,header 更新成新目录。
Q4:fork 出来的 session 修改了,源 session 会受影响吗?
不会。fork 是文件级别的全复制------新 session 是完全独立的文件。源 session 后续追加的 entry 不会出现在 fork 出来的 session 里,反过来也一样。
唯一保留的关联是 parentSession 字段------fork 出来的 session 的 header 记录源文件路径。这只用于溯源展示(如 UI 显示"forked from xxx"),不影响功能。
七、下一章预告
下一篇文章将进入 pi 的 slash 命令系统------用户输入 /xxx 时 pi 怎么解析、命令的三层来源(内置 / Extension / prompt template)、自动补全机制、命令 handler 的执行流程。本章第 4 章的 /fork / /tree / /resume / /new 都是内置 slash 命令,下一篇拆解它们的注册、解析和执行机制。