← blog 技术原理与实践 · 2026-10-06

pi session tree 的实现原理:/tree、/fork、/clone 到底动了什么

读 pi 源码(session-manager、agent-session、agent-session-runtime、interactive-mode)拆解 session tree 的实现:append-only JSONL 的 id/parentId 指针、leafId 活动指针、getBranch 的路径提取,以及 /tree 移动指针、/fork 提取路径到新文件、/clone 复制活动分支三者的真实差异,并修正「fork 仍在原文件」「clone 复制子树」两处常见误解。

Short read

起点:一棵不是线性列表的会话

pi 的 session 约定并不复杂:每次对话自动保存成一个 session,除非用 --no-session 启动。每个 session 是一个 JSONL 文件,放在 ~/.pi/agent/sessions/--<路径>--/<时间戳>_<uuid>.jsonl,按工作目录分组。

入口命令也直白:--continue 打开当前目录最近的 session;--resume / /resume 打开 session 选择器(可搜索、重命名、删除);/new 开新会话;/session 查看当前 session 文件、消息数、token 用量、费用。

真正需要读源码才能讲清的是它的树形结构:session 内的条目通过 id / parentId 构成一棵树,而不是线性列表,所以回到早期节点再开新分支不会丢掉旧分支。围绕这棵树有三个命令——/tree、/fork、/clone——它们看起来都是”切分支”,但实现路径完全不同。本文基于 dist/core/session-manager.js、agent-session.js、agent-session-runtime.js 和 modes/interactive/interactive-mode.js 逐个核对。

一、数据结构:append-only JSONL + 两个指针

每个 entry 一行 JSON,id(8 位 hex)+ parentId 构成树。关键约束是:没有任何删除或修改,全部 append。

SessionManager 在内存里维护两样东西:byId: Map<id, entry> 索引,以及一个 leafId 指针,表示当前活动位置。

// 树的全部"导航"本质只是移动这个指针
branch(branchFromId)     { this.leafId = branchFromId; }   // 指到某节点
resetLeaf()              { this.leafId = null; }            // 指到 root 之前
branchWithSummary(from, summary) { this.leafId = from; ...append({type:"branch_summary", parentId: from, fromId: 旧leaf, summary}) }

这里最容易被误解的一点是:没有任何”分支对象”被创建。所谓分支,只是 leafId 取值不同;后续 appendXXX() 会把这一个新 entry 的 parentId 设为当前 leafId,于是从那个位置长出新子树。整棵树永远留在文件里,只有活动分支会进 LLM 上下文。

活动分支提取:getBranch(fromId)

getBranch(fromId) {
  let current = byId.get(startId);
  while (current) { path.push(current); current = byId.get(current.parentId); }
  path.reverse();  // 从 root 到 leaf 的顺序
  return path;
}

从 leaf 沿 parentId 链一路走到 root,再反转。上下文构建走三步——buildContextEntries()(处理 compaction)→ buildSessionProjection()(应用 context_edit)→ buildSessionContext()(转成 LLM 消息)——全部基于这一条路径。

模型只收到活动分支,即当前 leaf 回溯到 root 的路径,而不是整棵树。接近模型上限时自动 compact(旧的合并成 summary 保留),/compact 可手动触发。

二、条目类型

每个 JSONL 一行,用 type 字段区分:

  • session 表头(version 3 当前版,含 cwd)
  • message(system/user/assistant/toolResult;system 消息携带 prompt sections 和工具声明,按顺序重放即得当前 prompt 和工具集)
  • model_change / thinking_level_change(切模型、切思考级别)
  • compaction(压缩点:summary + firstKeptEntryId + system 检查点)
  • context_edit(对历史条目的追加式替换,只影响未来模型上下文,不改原始记录)
  • branch_summary、usage、custom(扩展状态,不进上下文)、custom_message(扩展注入的消息,进上下文)、label、session_info(/name 起的名字)

三、/tree:在同文件内移动指针

UI 侧是 TreeSelectorComponent,用 getTree() 渲染(内存中按 parentId 建父子关系、按时间戳排序),只显示用户消息的文本。

选中目标后调用 session.navigateTree(entryId, {summarize, customInstructions}):

navigateTree(targetId):
 ├─ 安全检查:正在 streaming / compacting 则拒绝
 ├─ collectEntriesForBranchSummary(oldLeaf, target)
 │    ├─ oldPath = set(getBranch(oldLeaf))           // 旧叶子到 root 的 id 集合
 │    ├─ targetPath = getBranch(target)
 │    └─ 从 targetPath 尾部往前扫,第一个也在 oldPath 里的
 │        就是【共同祖先 commonAncestorId】
 │    └─ 从 oldLeaf 沿 parentId 往上走到 commonAncestor 为止,
 │        收集这些 entry → 这就是"要遗弃的分支"
 ├─ 若选择 Summarize → generateBranchSummary(entriesToSummarize)
 │    ├─ 专门的 BRANCH_SUMMARY_PROMPT:Goal / Constraints /
 │    │   Progress / Key Decisions / Next Steps 结构化格式
 │    ├─ 按 branchSummarySettings.reserveTokens 做 token 预算
 │    │   (太长时从新到旧截取,保证最近上下文;compaction/branch_summary
 │    │   条目即使超预算也会尽量塞进去)
 │    └─ AbortController 支持 Esc 中断;扩展可 hook session_before_tree
 │        提供自定义 summary / 指令 / label
 ├─ 确定新 leaf 位置:
 │    ├─ 目标是 user message → newLeafId = 它的 parentId,
 │    │   用户消息文本被放回编辑器(可修改后重新提交=新分支)
 │    ├─ 目标是 assistant/custom_message → newLeafId = 目标本身
 │    └─ 目标是 root → resetLeaf()(leafId = null)
 ├─ 切换:
 │    ├─ 有 summary → branchWithSummary(newLeafId, summary):
 │    │   移动指针 + append 一条 branch_summary entry
 │    │   (parentId=新位置,fromId=被遗弃的旧叶子)
 │    └─ 无 summary → branch(newLeafId) 只是改指针
 └─ emit session_tree 事件给扩展

也就是说 /tree 不做任何文件操作——它在本文件内移动 leafId。可选生成 branch_summary 时,也只是在原文件 append 一条 entry(parentId=新位置,fromId=被遗弃的旧 leaf)。此后上下文请求里,这条 branch_summary 会转成一条 branchSummary 消息,让 LLM 知道”你去过那条分支,干了这些”。

四、/fork:把路径提取到新文件

UI 侧是 getUserMessagesForForking(),它扫 全部 entry(整棵树,不只当前分支),列出所有有文本的 user 消息,选一条。

执行 runtimeHost.fork(entryId)(默认 position: "before"):

  • targetLeafId = 该用户消息的 parentId(新分支从”这条消息之前”重新开始),selectedText = 该消息文本 → 放回编辑器,相当于从它之前重新开聊
  • createBranchedSession(targetLeafId):
    • getBranch(targetLeafId) 取出 root→该点的路径
    • 过滤掉 label entry 并重新链接——label 是真实树节点,别人可能挂在它下面,摘掉后要补齐父子链,用 replacementByLabelId 把 label 的后代重挂到 label 的父节点
    • 生成新 session 文件(新 uuid 文件名),header 带 parentSession: 原文件路径 回链,内容只含这条路径
  • runtime 切换到新 session,发 session_start reason: "fork", previousSessionFile

原文件留在磁盘(含全部分支),新文件是提取出来的单一路径。

这里有一个容易混淆的点:交互式 /fork 是”截取路径到新文件”,而 CLI 的 --fork 用的是另一个实现 SessionManager.forkFrom()——它把整棵会话树逐行拷贝到目标 cwd 的新文件(适合跨目录迁移)。两个”fork”语义不同。

五、/clone:fork(position: "at")

async handleCloneCommand() {
  const leafId = this.sessionManager.getLeafId();
  const result = await this.runtimeHost.fork(leafId, { position: "at" });
  ...
}

clone 就是 fork 的特例:position: "at" 表示 targetLeafId = 当前 leaf 本身(而不是往前挪到 parent)。于是 createBranchedSession(leafId) 把 root → 当前 leaf 的完整活动分支复制成新 session——相当于”当前状态的一个干净单分支副本”,不带被放弃的其他分支,编辑器清空。

注意它是一条线性路径,不是”子树”:被放弃的兄弟分支不会带过去。

六、一次判断修正:错的、对的、需要精确化的

在核对源码之前,我先把这套机制归纳成了三句话,结果被源码纠正了一处、精确化了三处。

对的:

  1. parentId + leafId 的多叉树。append-only JSONL,每个 entry 的 id + parentId 就是树指针,SessionManager.leafId 是当前活动位置。树永远完整存在文件里,没有分支对象被”创建”,分支只是指针移动 + 后续 entry 的 parentId 指向新位置。
  2. 不同 leaf 通过 parentId 拼独立的上下文。getBranch(fromId) 从 leaf 沿 parentId 链走到 root 再反转,这就是该分支独有的消息序列;compaction / context_edit 都作用在这条路径上。

错的:

  1. “/fork 依然处于原始 jsonl 文件中” —— 反了。交互式 /fork 恰恰是离开原文件:targetLeafId 取所选 user 消息的 parentId,createBranchedSession 取出 root→该点的路径、过滤 label 并重链、生成新 uuid 文件名的新 session 文件(header 带 parentSession),runtime 随后切换到新 session。原文件保留全部分支,新文件只含这一条路径。

需要精确化的:

  1. “/clone 复制一个同样的子树到新文件” —— 大方向对(复制到新文件),但复制的是 root → 当前 leaf 的活动分支,是一条线性路径,被放弃的兄弟分支不会带过去。
  2. “/tree 就是把整棵树拿出来” —— 渲染层确实会扫到 root 并按 parentId 建父子关系展示整棵树,但选中目标后 navigateTree 做的是移动 leafId 指针,没有任何文件操作;可选地在原文件 append 一条 branch_summary。

修正后的一句话版本:

树 = append-only JSONL + parentId 指针 + leafId 活动指针;上下文 = leaf 沿 parentId 走到 root 的路径。/tree 在同文件内移动指针(不动文件,可选生成 branch_summary),/fork 提取 root→目标点的路径到新文件(原文件保留),/clone 是 fork(position:“at”),把当前活动分支复制到新文件。三者中唯一不改文件的是 /tree。

小结

命令本质新 leaf编辑器
/tree同文件内移动 leafId 指针(可选生成 branch_summary)目标或目标.parent目标 user 消息文本
/fork提取 root→某历史点路径到新文件所选 user 消息的 parent该消息文本
/clonefork(当前leaf, position:“at”)当前 leaf 本身清空

贯穿三者的是一个”重新编辑用户消息”模型:当目标是 user 消息时,新分支点都落在它之前,把文本还给你改,改动后提交即产生树上的新分支;而原始消息永远留在树上。

与 mini-agent 的关联

mini-agent 的 PiRpcClient 用的是 pi --mode rpc --no-session,即 RPC 模式下不持久化 session:每次调用起一个全新子进程,无状态,上下文由自己通过 _build_prompt 注入(CodeTaskExecutor 里 wrap 了 <untrusted data> 隔离和 /organization 上下文),与本地交互式 session 完全隔离。理解 session tree 的实现,也就清楚了这条边界为什么是硬的:持久化、树、分支摘要全都属于交互式 session 那一侧。

Sources

No external sources for this entry.

Related

2026-10-06 · blog

pi durable 是怎么把「杀掉进程也能续跑」做出来的

读 pi 仓库 packages/durable 的源码实现:存储层的原子提交、会话层的事务协议、任务状态机的 checkpoint 与崩溃恢复入口、100ms 节流的部分产出提交,以及三层幂等设计,并修正「数据库式存储 + 状态机可追溯」这类总结里不准确的部分。技术原理与实践
2026-10-09 · blog

拆解 Codex 的 /goal:持久化目标系统、自动续跑与状态一致性

在 Codex 里输入 /goal 得到的不是一条注入模型的 prompt,而是一台持续运转的状态机:TUI 命令穿过 app-server RPC 落进独立 SQLite,运行时逐 token 计量消耗,线程空闲时自动开始下一轮,直到目标完成、卡住或预算耗尽。本文从 TUI 到 SQL 逐层拆开这台机器,重点讲它的核心架构决策——状态写入拆成「模型可写的子集」和「系统独占的派生状态」两条路径,一致性靠 SQL 层守卫与原子计量保证,而不是信任模型的自报。技术原理与实践 · 14 min read
2026-10-05 · blog

从 fire-and-forget 到可恢复发布:mini-agent 博客链路的持久化状态机重构

一次完整的架构重构复盘:用持久化发布状态机、Transactional Outbox、隔离 Git worktree 和 rebase/CAS,替换掉 fire-and-forget 任务、不可靠的文件 watcher 和"pull --rebase 赌运气"的同步方式,并记录收敛过程中被 review 和故障注入揪出来的每一个真实缺陷。技术原理与实践 · 25 min read
On this page 起点:一棵不是线性列表的会话一、数据结构:append-only JSONL + 两个指针活动分支提取:getBranch(fromId)二、条目类型三、/tree:在同文件内移动指针四、/fork:把路径提取到新文件五、/clone:fork(position: "at")六、一次判断修正:错的、对的、需要精确化的小结与 mini-agent 的关联