π Pi Agent 源码学习课程 阶段四 · 10/14

阶段四 · 编码代理 | 第 10 讲(全 14 讲)

第 10 讲内置工具集与会话持久化(JSONL 树、分支、Fork)

工具是 Agent 的手,会话是 Agent 的记忆:精读七个内置工具的工程质量细节,再拆开 JSONL 事件树,看「会话即树、分支即路径」如何用 append-only 日志实现。

预计 4 小时 前置:第 7 讲工具系统 · 第 8 讲 AgentSession 配套源码:pi-main 本地仓库
会话 entry 树示意:线性对话在某处分叉,leaf 指针决定活跃分支 session 头 u1 a1 a2 u3 ←leaf u2′ 分支 每行一个 entry,id / parentId 把线性 JSONL 连成一棵树; 「当前指针 leafId」决定模型下次请求回放哪条路径。

学习目标

说出七个内置工具(read / write / edit / bash / ls / find / grep,Windows 另有 powershell)各自的参数 schema、返回结构与系统提示词贡献。
讲清工具输出治理的三层防线:工具自身条目上限、truncate.ts 的 2000 行 / 50KB 截断、OutputAccumulator 临时文件与 bash 的 1MiB structuredContent。
解释 file-mutation-queue.ts 为什么用按真实路径串行的全局队列保护写操作,以及 path-utils 的路径规范化策略。
徒手画出一个会话 JSONL 文件的 entry 树,说明 id / parentId / leafId 与各 entry 类型的作用。
对比 /tree、/fork、/clone 三种分支操作在文件层面的差别,理解 resume = 活跃分支回放。
说明 steering / follow-up / abort 如何落在持久化层,以及 convertToLlm 对自定义消息类型的过滤规则。
  • packages/coding-agent/src/core/tools/:read.ts / write.ts / edit.ts / bash.ts(含 powershell.ts)精读,truncate.ts / output-accumulator.ts / file-mutation-queue.ts / path-utils.ts 通读
  • packages/coding-agent/src/core/session-manager.ts(entry 类型、SessionManager 类)与 messages.ts(自定义消息与 convertToLlm)
  • packages/coding-agent/docs/session-format.md(精读)、sessions.md(后半:分支与存储)、message-types.md

§10.1七大内置工具总览:参数、返回与提示词贡献

coding-agent 的工具都住在 packages/coding-agent/src/core/tools/ 下,由 index.ts 统一导出。全部工具名集合是 "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls";其中 powershell 是 Windows 专属的 bash 变体(复用同一套 shell 机制),所以通常说「七大内置工具」。每个工具同时提供三样东西:给模型的 参数 schema(TypeBox 定义)、给运行时的返回结构(content 文本块 + 可选 details / structuredContent)、给系统提示词的 promptSnippet / promptGuidelines(第 9 讲组装提示词时已见过它们如何拼进 <tools> 段落)。

工具参数 schema返回结构prompt 描述(节选)
read path;offset?(1 起始行号);limit?(行数) 文本块(截断时附续读提示);图片返回 image 块;codemode 走 structuredContent(字符串或 {type:"image",data,mimeType,note}) snippet:“Read file contents”;guideline:“Use read to examine files instead of cat or sed.”;描述声明截断到 2000 行或 50KB、支持 jpg/png/gif/webp/bmp 附件
write path;content 文本 Successfully wrote to ${path};自动递归建父目录 snippet:“Create or overwrite files”;guideline:“Use write only for new files or complete rewrites.”
edit path;edits[](每个 oldText/newText,须唯一、不重叠) 文本 Successfully replaced N block(s) in ${path};details 含 diff、patch、firstChangedLine snippet:“Make precise file edits with exact text replacement, including multiple disjoint edits in one call”;guideline 要求一次调用批量改同一文件
bash command;timeout?(秒,可选、无默认超时) stdout+stderr 合流,保留最后 2000 行 / 50KB;截断时附 full_output_path;structuredContent = {output, truncated, full_output_path?, exit_code, wall_time_seconds},非零退出码置 isError: true snippet:“Execute bash commands (ls, grep, find, etc.)”;guideline:可检查 PI_* 环境变量获取会话/模型信息
ls path?(默认当前目录);limit?(默认 500 条) 字母序条目列表,目录带 / 后缀,含 dotfiles;截断附条目上限提示 snippet:“List directory contents”;描述:截断到 500 条或 50KB
find pattern(glob);path?;limit?(默认 1000 条) 相对搜索目录的文件路径列表;底层调用 fd(自动下载),遵守 .gitignore;截断附提示 snippet:“Find files by glob pattern (respects .gitignore)”;描述:截断到 1000 条或 50KB
grep pattern;path?;glob?;ignoreCase?;literal?;context?(上下文行数);limit?(默认 100 个匹配) 文件:行号: 内容 格式匹配行;底层调用 rg(JSON 输出);details 含 matchLimitReached、linesTruncated snippet:“Search file contents for patterns (respects .gitignore)”;描述:100 匹配 / 50KB,长行截到 500 字符
powershell
Windows 专属
与 bash 相同(command / timeout?) 复用 createShellToolDefinition;临时文件前缀 pi-powershell snippet:“Execute PowerShell commands”;命令前强制 UTF-8 输出编码,并修补 PowerShell 5.1 原生命令 stderr 导致的假退出码

表中描述均摘自各工具源码里的 description 与 xxxToolSystemPromptContribution 常量;「截断到 N」的数值就是 truncate.ts 的 DEFAULT_MAX_LINES = 2000 与 DEFAULT_MAX_BYTES = 50 * 1024。

一个值得注意的工程模式:每个工具都定义了 Pluggable Operations 接口(ReadOperations / WriteOperations / EditOperations / BashOperations…),默认实现走本地文件系统,扩展可以整体替换成 SSH 等远端实现。工具本体因此只关心「参数校验、路径解析、输出治理、队列」这些与存储位置无关的策略。

§10.2精读四个核心工具:read / write / edit / bash

10.2.1read:流式选行,截断提示「可继续」

read 不只是 fs.readFile 的包装。对文本文件,它优先走 createReadStream 的流式选行路径:只把 truncateHead 可能展示的行存进内存,其余行只计数不保存,所以读一个几 GB 的日志也不会把进程撑爆。图片走另一条分支:按 MIME 检测后用 processImage 自动缩放,再作为 image 块返回;如果当前模型不支持视觉输入,会附加一条说明文本。

packages/coding-agent/src/core/tools/read.tsTypeScript · 节选
// 截断发生时,给模型一条可行动的续读提示(节选自 execute 内部)
if (truncation.truncated) {
    const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
    const nextOffset = endLineDisplay + 1;
    outputText = truncation.content;
    if (truncation.truncatedBy === "lines") {
        outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
    } else {
        outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
    }
    details = { truncation };
}
两条细节:用户显式传的 limit 优先于截断(截断前先尊重 limit);单行本身超过 50KB 时不会返回半行,而是提示改用 bash: sed -n 'Np' file | head -c 51200 兜底。

10.2.2write:一次全量写,队列内检查中止

write 的语义是「整文件覆盖」:递归创建父目录后一次性写入。它的执行体被 withFileMutationQueue 包住(§10.4 详述),并且在每个 await 之后检查 signal.aborted,而不是在 abort 事件回调里直接 reject——注释里写明了原因:在监听器里 reject 会提前释放队列锁,而此时磁盘上的操作可能还没结束。

packages/coding-agent/src/core/tools/write.tsTypeScript · 节选
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(absolutePath, async () => {
    // Do not reject from an abort event listener here: that would release the
    // mutation queue while an in-flight filesystem operation may still finish.
    const throwIfAborted = (): void => {
        if (signal?.aborted) throw new Error("Operation aborted");
    };

    throwIfAborted();
    await ops.mkdir(dir);      // Create parent directories if needed.
    throwIfAborted();

    await ops.writeFile(absolutePath, content);
    throwIfAborted();

    return { content: [{ type: "text", text: `Successfully wrote to ${path}` }], details: undefined };
});
注意:即使 abort 了,finally 也会先 releaseNext() 再抛错,队列锁不会泄漏。

10.2.3edit:先校验、后 diff、再写入,容忍模型格式漂移

edit 是对「精确文本替换」的严谨实现。执行顺序刻意安排为:读取 → 剥离 BOM → 归一为 LF → 应用替换 → 先算 diff → 再写盘(diff 计算失败时文件不会被破坏)。写回时用「替换块拼接回原始文本」的方式,让未改动行保留原本的 CRLF 行尾。另一个有趣细节在 prepareEditArguments:某些模型(源码注释点名 Opus 4.6、GLM-5.1)会把 edits 塞成 JSON 字符串或单个对象,工具在这里做兼容归一,同时兼容旧的顶层 oldText/newText 参数。

packages/coding-agent/src/core/tools/edit.tsTypeScript · 节选
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const { bom, text: content } = splitBom(rawContent);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent, replacements } =
    applyEditsToNormalizedContent(normalizedContent, edits, path);
// Diff before writing, so diff computation cannot fail after the write.
const editDiff = generateEditDiff(path, baseContent, newContent, replacements);
throwIfAborted();

// Splice into the original text, so untouched lines keep their line endings.
const finalContent =
    bom + (content === normalizedContent
        ? newContent
        : applyReplacementsToOriginal(content, replacements));
await ops.writeFile(absolutePath, finalContent);
每个 edits[].oldText 都按原始文件匹配(不是增量应用),因此重叠/嵌套的编辑会直接报错——描述里明确要求把相邻改动合并成一个 edit。

10.2.4bash:进程树、超时、退出码与流式输出

bash 与 powershell 共用 createShellToolDefinition,本地执行后端是 createLocalShellOperations。它值得列出至少六条输出/超时/退出码规则(动手实践第 3 步会验证):

  1. 无默认超时;timeout 参数为秒,必须是有限正数,换算成毫秒后不得超过 2_147_483_647(MAX_TIMEOUT_MS)。
  2. 工作目录不存在直接报错:Working directory does not exist: ...,不会 fallback 到别处。
  3. stdout 与 stderr 合流进同一个 OutputAccumulator;UI 更新经 onUpdate 节流,间隔 BASH_UPDATE_THROTTLE_MS = 100 毫秒。
  4. abort 与超时都调用 killProcessTree 杀整个进程组(Unix 上 shell 以 detached 进程组启动);被信号杀死时退出码按 shell 惯例折算为 128 + 信号编号。
  5. 超时错误保持 timeout:<seconds> 消息形态;若 shell 本身早已退出、只是后台进程还在写管道(issue #11613),ShellTimeoutError.shellExit 会把两个事实都写进错误信息。
  6. 非零退出码不是异常而是错误结果:输出照常返回,追加 Command exited with code N,并置 isError: true;退出码为 null 才算「进程未正常终止」的失败。
packages/coding-agent/src/core/tools/bash.tsTypeScript · 节选
/** Output limit of `structuredContent.output`, which programmatic callers such as codemode scripts receive. */
const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024;  // 1MiB

const bashOutputSchema = Type.Object({
    output: Type.String({ description: "Combined stdout and stderr, possibly truncated" }),
    truncated: Type.Boolean(),
    full_output_path: Type.Optional(Type.String({ description: "Full output, when truncated" })),
    exit_code: Type.Number(),
    wall_time_seconds: Type.Number(),
});
模型看到的是 content 里最多 2000 行 / 50KB 的尾部快照;codemode 这类程序化调用方拿到 structuredContent,最多 1MiB,超长部分首尾各半、中间用 [... N bytes omitted ...] 标记,并附 full_output_path 指向临时文件全文。

§10.3输出治理:大输出为什么不炸上下文

LLM 的上下文窗口是稀缺资源,一次 cat 一个 200MB 文件就能把会话毁掉。Pi 用三层防线把任何工具输出压回安全规模——这三层的职责完全不同:第一层减少「产生的行数」,第二层限制「进入上下文的字节数」,第三层保证「全文仍可找回」。

工具输出截断的三层防线示意图 任何工具输出 stdout / 文件内容 / 搜索结果 第一层 · 工具自身的条目上限 ls ≤ 500 条 · find ≤ 1000 条 · grep ≤ 100 个匹配且单行 ≤ 500 字符 read 支持参数级 offset/limit;超限输出尾部附可行动提示(Use limit=… / Use read tool …) 第二层 · truncate.ts 全局上限:2000 行 或 50KB(先到者赢) read / ls / find / grep → truncateHead(保留开头,适合查看文件) bash → truncateTail(保留结尾,错误与最终结果通常在末尾) 永不返回半行(唯一例外:bash 尾截断时最后一行本身超 50KB);TruncationResult 记录 truncatedBy / totals 第三层 · 全文保全:OutputAccumulator + full_output_path 超过上限即写入临时日志文件(pi-bash-*.log / pi-powershell-*.log),内存只留 2×50KB 滚动尾部 codemode 的 structuredContent ≤ 1MiB(首尾各半 + 省略标记);模型可按提示用 read/bash 读取 full_output_path
图 10-3 工具输出截断的三层防线。方向差异是有意设计:文件阅读关心开头,命令执行关心结尾。

10.3.1truncate.ts:两个独立上限,先到者赢

truncate.ts 是所有工具共享的截断工具箱。规则要点:行上限 2000、字节上限 50KB(Buffer.byteLength 按 UTF-8 计,不是字符数);两个上限独立判断,谁先触顶谁生效;truncatedBy 告诉调用方是 "lines" 还是 "bytes"。首行单独超过 50KB 时,truncateHead 返回空内容并置 firstLineExceedsLimit: true,由 read 工具转成「改用 bash」的建议。

packages/coding-agent/src/core/tools/truncate.tsTypeScript · 节选
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line

export interface TruncationResult {
    content: string;        // 截断后的内容
    truncated: boolean;
    truncatedBy: "lines" | "bytes" | null;
    totalLines: number;       // 原始总行数
    totalBytes: number;      // 原始总字节
    outputLines: number;
    outputBytes: number;
    lastLinePartial: boolean;  // 仅 bash 尾截断可能出现半行
    firstLineExceedsLimit: boolean;
    maxLines: number;
    maxBytes: number;
}
truncateHead 供 read/ls/find/grep(保留开头);truncateTail 供 bash(保留结尾);另有 truncateLine 专治 grep 长匹配行(500 字符 + ... [truncated] 后缀)。

10.3.2OutputAccumulator:有界内存 + 溢写临时文件

bash 的输出是流式的,可能持续几分钟、几个 GB。OutputAccumulator 的策略是:用流式 UTF-8 解码器逐块拼接,内存里只保留一个「尾部滚动窗口」(约 2×50KB,足够 truncateTail 使用);一旦总输出越过任一上限,就把原始字节流落到临时文件,滚动窗口与计数继续工作。结束时 snapshot({ persistIfTruncated: true }) 产出模型可见的尾部快照与 fullOutputPath。

packages/coding-agent/src/core/tools/output-accumulator.tsTypeScript · 节选
append(data: Buffer): void {
    this.totalRawBytes += data.length;
    this.appendDecodedText(this.decoder.decode(data, { stream: true }));

    if (this.tempFileStream || this.shouldUseTempFile()) {
        this.ensureTempFile();
        this.tempFileStream?.write(data);
    } else if (data.length > 0) {
        this.rawChunks.push(data);
    }
}

private shouldUseTempFile(): boolean {
    return (
        this.totalRawBytes > this.maxBytes ||
        this.totalDecodedBytes > this.maxBytes ||
        this.totalLines > this.maxLines
    );
}
readFullOutput(maxBytes) 在 finish() 与 closeTempFile() 之后调用:文件不超 maxBytes 时原样返回;超过则取首尾各半、中间插入 [... N bytes omitted ...],并且都从合法 UTF-8 字符边界切分(跳过续字节)。
Java 视角:OutputAccumulator 就是一个「有界环形缓冲 + 溢写磁盘」的日志采集器

它等价于你在 Java 里写过的 tail 采集器:内存放一个 ArrayDeque<byte[]> 滚动窗口保最近 N 字节,越过阈值就切到 FileChannel 追加写。区别在于它同时维护三套账本——原始字节(rawBytes)、解码字节(decodedBytes)、行数(lines),任何一套超限都触发落盘;而 Promise 化的 closeTempFile() 保证快照与文件句柄生命周期一致,避免读到半截文件。

§10.4写操作串行化:file-mutation-queue 与 path-utils

Agent 的一次回复可能同时发出多个工具调用,运行时会并发执行它们;两个 edit 同时读写同一个文件就是教科书级的竞态:A 读旧内容、B 读旧内容、A 写、B 写——A 的修改静默丢失。file-mutation-queue.ts 用一个模块级 Map<string, Promise<void>> 解决:key 是文件的真实路径,value 是该文件当前排队的 Promise 链。

packages/coding-agent/src/core/tools/file-mutation-queue.tsTypeScript · 全文 46 行 · 节选
const fileMutationQueues = new Map<string, Promise<void>>();
let registrationQueue = Promise.resolve();

async function getMutationQueueKey(filePath: string): Promise<string> {
    const resolvedPath = resolve(filePath);
    try {
        return await realpath(resolvedPath);   // 符号链接归一,同一目标同一把锁
    } catch (error) {
        if (isMissingPathError(error)) return resolvedPath;  // 新文件尚不存在
        throw error;
    }
}

export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
    const registration = registrationQueue.then(async () => {
        const key = await getMutationQueueKey(filePath);
        const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
        let releaseNext!: () => void;
        const nextQueue = new Promise<void>((resolveQueue) => { releaseNext = resolveQueue; });
        const chainedQueue = currentQueue.then(() => nextQueue);
        fileMutationQueues.set(key, chainedQueue);
        return { key, currentQueue, chainedQueue, releaseNext };
    });
    registrationQueue = registration.then(() => undefined, () => undefined);

    const { key, currentQueue, chainedQueue, releaseNext } = await registration;
    await currentQueue;          // 等前面同文件的写完成
    try {
        return await fn();
    } finally {
        releaseNext();           // 唤醒下一个排队者
        if (fileMutationQueues.get(key) === chainedQueue) {
            fileMutationQueues.delete(key);  // 队列清空后回收 Map 项
        }
    }
}
三处细节:① key 用 realpath,符号链接与不同写法指向同一物理文件时共享同一队列(文件还不存在时退回 resolve 后的路径);② registrationQueue 让"查 Map、接链"这个注册段本身串行,消除 check-then-act 竞态;③ 不同文件的队列彼此独立,仍然并行。
Java 视角:这就是按 key 的 Striped Lock,只是锁实现换成了 Promise 链

Map<path, Promise> 相当于 Guava 的 Striped<Lock>:同 key 互斥、异 key 并行。currentQueue.then(() => nextQueue) 是一条 CompletableFuture 链——新任务排在队尾,自己的完成句柄 releaseNext 就是「解锁」。最值得学的是 registrationQueue:因为「读取-判断-写入 Map」本身是异步的(要 await realpath),如果不先把注册段串行化,两个并发注册可能都读到旧队尾、各接各的链,锁就失效了。这是 Java 里 ConcurrentHashMap.computeIfAbsent 一步到位、异步世界却必须显式排队的经典差异。

10.4.1path-utils:路径解析与那些"奇怪"的兼容分支

所有工具入口都先做路径归一:resolveToCwd(path, cwd) 基于 utils/paths.ts 的 resolvePath,支持 ~ 展开、去掉模型爱加的 @ 前缀、归一 Unicode 空格。读类工具走 resolveReadPathAsync,若解析结果不存在,还会依次尝试三类 macOS 截图命名的变体:AM/PM 前的窄不换行空格(U+202F)、NFD 分解形式的文件名、法文弯引号 U+2019。这些分支看似琐碎,却真实对应「用户把截图文件名贴给模型」的高频场景。

§10.5会话持久化:JSONL 每行一个 entry,串成一棵树

第 8 讲我们看到 AgentSession 在事件回调里调用 sessionManager.appendMessage(...);现在打开落盘格式。会话是一个 JSONL 文件:第一行是 SessionHeader(不参与树),之后每行一个 entry,全部继承 SessionEntryBase——四个字段就够把线性文件变成树:type、id(通常 8 位十六进制短 ID)、parentId(根为 null)、ISO 时间戳。

packages/coding-agent/src/core/session-manager.tsTypeScript · 节选
export const CURRENT_SESSION_VERSION = 3;

export interface SessionHeader {
    type: "session";
    version?: number;   // v1 sessions don't have this
    id: string;          // 会话 ID(UUIDv7)
    timestamp: string;
    cwd: string;          // 会话绑定的工作目录,创建后不变
    parentSession?: string; // /fork、/clone 创建时指向源文件
}

export interface SessionEntryBase {
    type: string;
    id: string;              // Usually an 8-char hex ID
    parentId: string | null;  // Parent entry ID (null for a root entry)
    timestamp: string;        // ISO timestamp
}
文件版本演进:v1 线性序列(加载时自动迁移补 id/parentId)→ v2 树结构 → v3 把 hookMessage 角色改名 custom。旧文件读入时 migrateToCurrentVersion 原地升级并重写。

10.5.1entry 类型清单

type作用进入 LLM 上下文?
message承载一条 AgentMessage(system / user / assistant / toolResult / bashExecution / custom);system 消息按段落增量记录提示词与工具增减是(经投影)
model_change / thinking_level_change记录中途切换模型 / 思考档位;路径上最新者生效仅影响设置,不产生消息
usage非 assistant 消息的用量记账(如 kind: "cache_warm"),计入会话统计否
compaction压缩边界:摘要 + firstKeptEntryId + 完整 system 快照是(摘要替代更早历史)
branch_summary切分支时对被离开路径的摘要,fromId 指向旧 leaf是(转成 branchSummary 消息)
custom扩展私有状态持久化(如虚拟模型路由状态 pi.virtual-model-state)否(buildSessionContext 忽略)
custom_message扩展注入上下文的可见消息,display 控制 TUI 呈现是(转成 user 消息)
context_edit对更早某条可见 entry 的追加式编辑:replacement: null 表示从模型上下文剔除改变被目标的贡献,自身不产生消息
label给 entry 打书签(/tree 里显示、可跳转)否
session_info会话显示名(/name、--name)否

10.5.2current 指针与「活跃分支回放」

SessionManager 内存里维护一个 leafId——当前树位置。每次 _appendEntry 都把新 entry 挂到当前 leaf 下并把 leaf 前移;_buildIndex(加载文件时)把 leaf 重置为文件中最后一条 entry。构建模型上下文时的调用链是:buildSessionContext() → buildSessionProjection() → buildSessionPath()(从 leaf 沿 parentId 走到根)→ 应用压缩边界与 context_edit 投影。也就是说,resume 的本质就是活跃分支回放:文件里所有分支都在,但只有 leaf 到根这条路径会发给模型。

会话 JSONL 行序列与 entry 树的对应关系,含 /tree 新分支、branch_summary 与 /fork 产生的新文件 左:文件行序(append-only) {"type":"session","version":3,...} {"type":"message","id":"u1","parentId":null,...} {"type":"message","id":"a1","parentId":"u1",...} {"type":"message","id":"u2","parentId":"a1",...} {"type":"message","id":"a2","parentId":"u2",...} {"type":"message","id":"u3","parentId":"a2",...} {"type":"branch_summary","id":"bs", "parentId":"a2","fromId":"u3",...} {"type":"message","id":"u2b","parentId":"bs"} // label / usage / custom … // 都只是普通树节点 右:id/parentId 构成的树 + leaf 指针 u1 user a1 u2 a2 分叉点 u3 旧 leaf(被离开) bs branch_summary fromId=u3 u2b ← leafId 当前活跃分支 /fork、/clone:新文件 = 一条路径 createBranchedSession(leafId) 把 root→leaf 的路径重写进新的 .jsonl, header.parentSession 指回源文件。 /tree:同文件内移动 leaf branch(id) 只改 leafId,不改任何行; 回到根可 resetLeaf()(parentId=null); 旧分支永远留在文件里。
图 10-5 会话 entry 树与分支。左列是 JSONL 的物理行序(append-only,永远只增不改);右图是同一份数据按 parentId 重建的逻辑树。/tree 选中 u2 改写重发后,新 user 消息成为 a2 的新子节点,与 u3 并列成分支;开启「Summarize」时先追加 branch_summary(其 parentId=新起点、fromId=旧 leaf),摘要进入新分支上下文。
Java 视角:JSONL 树 = 事件溯源(Event Sourcing)+ 一根可移动的 HEAD 指针

每行 entry 是一个不可变领域事件,等价于 Event Store 里的 append-only 记录;leafId 就是分支 HEAD。分支不复制数据,只移动指针(branch()),这正是 Git 的做法;压缩(compaction)相当于 Kafka 的 log compaction / 事件溯源里的快照——旧事件不删除,只在回放时被摘要替代。第 9 讲的 context_edit 则是「投影修正」:原始事件不动,读取端按最新 edit 重算视图,与 CQRS 读模型异曲同工。

§10.6分支操作语义:/tree、/fork、/clone 与 resume

三种「回到过去」的操作在用户看来越来越重,在存储层面的差别其实非常清晰:

操作存储层动作产生新文件?适用场景(官方文档原话意译)
/tree branch(branchFromId) 仅移动 leafId;选中 user 消息时把 leaf 移到其 parentId 并把原文放回编辑器,改写重发即形成新分支;到根用 resetLeaf();可选先追加 branch_summary(branchWithSummary) 否(同一文件内树内移动) 相关备选方案应留在同一会话里对照
/fork 选中更早的 user 消息 → createBranchedSession(targetLeafId):取 root→目标 leaf 路径,剥离/重建 label 链,写入新文件;新 header 的 parentSession 指向源文件;运行时以 session_start: reason "fork" 重建 是(新 ID + 新文件) 备选方案要独立成一项长期工作
/clone 等价于在当前 leaf 上 fork(runtimeHost.fork(leafId, { position: "at" })):复制当前活跃分支为新会话 是 想为当前状态留一份独立副本

跨目录 fork 由 SessionManager.forkFrom(sourcePath, targetCwd) 支持:读取源文件全部 entry,换新 cwd 写入目标目录的新会话文件。

10.6.1resume:加载文件 → 重建索引 → 活跃分支回放

--continue 走 SessionManager.continueRecent(cwd):在会话目录里找最近文件并 open;--resume 打开选择器。打开文件时 loadEntriesFromFile 逐行解析(带 1MiB 读缓冲),migrateToCurrentVersion 升级旧版本,_buildIndex 建立 byId 索引并把 leafId 设为最后一条 entry——于是「上次说到哪」自动恢复。下一次模型请求前,buildSessionContext() 沿 leaf→root 回放,再叠加系统提示词、上下文文件、技能描述(第 9 讲)。

10.6.2steering、follow-up、abort 的持久化体现

运行中的插话与打断最终都收敛为「普通 entry 落盘」,没有专用的 steering 文件:

  • steering(中途插话):消息先进 _steeringMessages 内存队列并 agent.steer(...) 打断当前循环;当它作为 message_start(user)真正进入历史时,从队列摘除。持久化发生在 message_end:sessionManager.appendMessage(event.message),与普通消息无异。
  • follow-up(排队跟进):同理走 _followUpMessages 队列,等 agent 空闲后按序投递,投递后同样以 user entry 落盘。
  • abort(取消):被中止的 assistant 回调仍会以 message_end 结束(stopReason: "aborted")并持久化——恢复会话时模型能看到「上次被打断在哪」;流式中的 "pending" 消息则不会持久化。
  • ! 直连 shell:以 role: "bashExecution" 的 message entry 落盘(recordBashResult);若 agent 正在流式中则先挂 _pendingBashMessages,等 agent_end 再追加,避免插进某个 toolUse/toolResult 对中间。
packages/coding-agent/src/core/agent-session.tsTypeScript · 节选(_handleAgentEvent)
// Handle session persistence
if (event.type === "message_end") {
    let entryId: string | undefined;
    if (event.message.role === "custom") {
        // Persist as CustomMessageEntry
        entryId = this.sessionManager.appendCustomMessageEntry(
            event.message.customType, event.message.content,
            event.message.display, event.message.details,
        );
    } else if (
        event.message.role === "system" || event.message.role === "user" ||
        event.message.role === "assistant" || event.message.role === "toolResult"
    ) {
        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,谁就进树。这也是扩展注入消息(sendMessage)能被持久化的原因——它们以 custom 角色走同一条路径。

§10.7存储布局与自定义消息类型

10.7.1按工作目录分组的会话目录

每个会话记录创建时的 cwd 且永不改变;工具、@path 引用、项目配置都从这个目录解析。默认存储按工作目录分组:

packages/coding-agent/src/core/session-manager.tsTypeScript · 节选
// ~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl
function getDefaultSessionDirPath(cwd: string, agentDir = getDefaultAgentDir()): string {
    const resolvedCwd = resolvePath(cwd);
    const resolvedAgentDir = resolvePath(agentDir);
    const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
    return join(resolvedAgentDir, "sessions", safePath);
}
Windows 下 C:\work\pi 会编码成 --C-work-pi--;文件名形如 2026-11-10T09-30-00-000Z_<uuidv7>.jsonl。可用 --session-dir、PI_CODING_AGENT_SESSION_DIR 或 sessionDir 设置改位置(CLI 优先级最高)。

两个关于「何时才写文件」的细节:其一,会话文件直到出现第一条 user 或 assistant 消息才创建(_hasConversation() 判定)——只改了模型、思考档位这类 setup entry 的会话留在内存里,开完就关不会留下垃圾文件;首个落盘 entry 用 "wx" 独占创建整文件,此后逐条 append。其二,--no-session 走 SessionManager.inMemory()(persist = false),完全 ephemeral,退出即失。

10.7.2扩展消息类型与 convertToLlm 过滤

coding-agent 通过 TypeScript 的 declaration merging 给基础 AgentMessage 联合加了四个角色:bashExecution、custom、branchSummary、compactionSummary。它们能进会话树,但 LLM API 只认 system/user/assistant/toolResult,所以发给模型前必须经过 convertToLlm 转换或过滤:

packages/coding-agent/src/core/messages.tsTypeScript · 节选
export function convertToLlm(messages: AgentMessage[]): Message[] {
    return messages
        .map((m): Message | undefined => {
            switch (m.role) {
                case "bashExecution":
                    if (m.excludeFromContext) return undefined;  // “!!” 前缀:不出现在上下文
                    return { role: "user",
                        content: [{ type: "text", text: bashExecutionToText(m) }],
                        timestamp: m.timestamp };
                case "custom":
                    return { role: "user", content: ..., timestamp: m.timestamp };
                case "branchSummary":
                    return { role: "user", content: BRANCH_SUMMARY_PREFIX + m.summary + ..., ... };
                case "compactionSummary":
                    return { role: "user", content: COMPACTION_SUMMARY_PREFIX + m.summary + ..., ... };
                case "system": case "user":
                case "assistant": case "toolResult":
                    return m;               // 原样通过
                default:
                    return undefined;         // 未知角色直接剔除
            }
        })
        .filter((m) => m !== undefined);
}
过滤规则一目了然:可转成 user 文本的都转(bash 输出包成 Ran \`cmd\` + 代码块);excludeFromContext 的 bashExecution 与未知角色返回 undefined 剔除;四类标准角色原样通过。它同时被 prompt 调用、压缩摘要生成和扩展复用。

再对照 §10.5.1 的两个 entry:custom entry 是纯扩展状态,buildSessionContext 根本不会把它变成消息(convertToLlm 都到不了);custom_message entry 会变成 CustomMessage,再由 convertToLlm 转成 user 消息进入上下文。「进树」与「进上下文」是两个独立决策——这正是扩展持久化状态而不污染对话的机制。

动手实践

手工解析一个真实会话 JSONL,画出 entry 树 在 ~/.pi/agent/sessions/ 下任选一个较大的 .jsonl 文件,用 30 行以内的脚本逐行 JSON.parse,按 type 分类打印,再按 parentId 重建树(孤儿节点当额外根处理,与 getTree() 一致)。验证三件事:header 不在树里;_buildIndex 语义下的「最后一条 entry」即默认 leaf;若文件里有 compaction,对照 firstKeptEntryId 找出被摘要替代的范围。 参考 docs/session-format.md 末尾的 Parsing Example;也可以直接调用 parseSessionEntries() 对照结果。
用 /tree 制造两个分支,再 /fork,对比三个文件的树结构 先正常对话两轮;/tree 选中第一条 user 消息改写重发得到分支 B(开一次 Summarize,观察 branch_summary 的 fromId);回到分支 A 后执行 /fork 选同一条 user 消息。用第 1 步的脚本分别画出原文件与 fork 文件:原文件应是一棵带分叉的树,fork 文件只有一条 root→目标 的链,且 header 里 parentSession 指向原文件。最后 /clone 一次,确认它与「在当前 leaf fork」等价。
读 bash.ts,列出至少 5 条输出/超时/退出码处理规则 对照 §10.2.4 的清单逐条在源码中找到行号:无默认超时与 MAX_TIMEOUT_MS 校验;cwd 不存在报错;输出合流与 100ms 节流;killProcessTree 与 128+信号 折算;ShellTimeoutError.shellExit 的双事实消息;非零退出码走 isError: true 而非异常;exitCode === null 才算终止失败。再各自构造一个触发场景(如 sleep 2 配 timeout: 1、exit 3、后台进程写管道的脚本)验证提示文案。

常见误区

误区一:/tree 回退会「删掉」走错的那段对话。
真相:会话文件是 append-only 的,branch() 只改内存里的 leafId,不修改、不删除任何一行。被离开的分支永远在文件里,随时可以再 /tree 回去;只有 /fork//clone 才产生只含一条路径的新文件(源文件同样原封不动)。

误区二:Agent 一次只执行一个工具,写队列是过度设计。
真相:模型一次回复可携带多个 toolCall,运行时会并发执行;两个 edit 同时读改写同一文件就会丢失更新。队列按 realpath 串行同文件、并行异文件,且注册段本身也被 registrationQueue 串行化,防止「两个注册都接到旧队尾」。

误区三:截断上限 50KB 是「5 万个字符」。
真相:DEFAULT_MAX_BYTES 用 Buffer.byteLength(content, "utf-8") 度量,中文等多字节字符按实际字节计。同时行数上限(2000)独立生效,先触顶者赢;这也是为什么 truncateTail 特意处理 UTF-8 字符边界,避免切出半个字符。

误区四:启动 pi 就一定会创建会话文件。
真相:_persist() 在首次落盘前检查 _hasConversation()——只有出现 user/assistant 消息才以 "wx" 创建文件;--no-session 则用 inMemory() 完全不落盘。临时开个 pi 查个问题不会污染 sessions 目录。

自测问答

为什么 write / edit 需要一个全局的文件变更串行队列?点击展开答案

因为 Agent 一次回复中的多个工具调用可能并发执行,两个写操作对同一文件的「读-改-写」交错会静默丢失先写入的修改。withFileMutationQueue 以 realpath 为 key 把同文件操作串成 Promise 链(同文件互斥),不同文件仍并行;注册段本身再由 registrationQueue 串行,避免异步的「查 Map 接链」竞态。写工具还在每个 await 后检查 signal.aborted,而不是在 abort 监听器里 reject,确保队列锁等到当前磁盘操作 settle 后才释放。

/fork 与 /tree 分支的本质区别是什么?/clone 又算哪一种?

/tree 是同文件内的指针移动:branch(id) 只把 leafId 移到历史 entry,之后追加的消息成为该 entry 的新子节点,与旧分支并存于一棵树里。/fork 是跨文件拷贝:createBranchedSession(leafId) 把 root→目标 leaf 的路径重写进一个新 .jsonl(新会话 ID,header 的 parentSession 指回源文件),原文件不动。/clone 本质是「在当前 leaf 位置 fork」,即把当前活跃分支复制成新文件。

大输出的三层防线分别是什么?read 与 bash 的截断方向为何不同?

第一层:工具自身条目上限——ls ≤500 条、find ≤1000 条、grep ≤100 个匹配且单行 500 字符、read 支持 offset/limit。第二层:truncate.ts 全局 2000 行或 50KB(UTF-8 字节)上限,先到者赢,绝不返回半行(bash 尾截断的单行超限是唯一例外)。第三层:OutputAccumulator 把超限输出全文写入临时文件(full_output_path),codemode 的 structuredContent.output 最多 1MiB 且首尾各半加省略标记。方向差异:read 用 truncateHead 保留开头(读文件通常从头看),bash 用 truncateTail 保留结尾(错误信息与最终结果通常在末尾)。

会话 JSONL 里 entry 如何构成树?current(leaf)指针决定了什么?

除首行 SessionHeader 外每行 entry 都有 id 与 parentId(根为 null),按引用连成树;加载时 _buildIndex 把 leaf 设为文件最后一条 entry。leafId 决定「活跃分支」:buildSessionPath 从 leaf 沿 parentId 走到根,这条路径经过 buildSessionProjection(应用压缩与 context_edit)后就是发给模型的消息——所以 resume 等于活跃分支回放,其他分支虽在文件里但不进上下文。

custom entry 与 custom_message entry 有何区别?convertToLlm 会怎么处理它们?

custom entry 是扩展私有状态(如虚拟模型路由状态),会进树但不参与 LLM 上下文,buildSessionContext 直接忽略;custom_message entry 是扩展注入的可见消息,回放时变成 CustomMessage(display 只控制 TUI 呈现,details 不发给模型)。convertToLlm 把 CustomMessage 内容转成 user 消息;同时它会把 excludeFromContext 的 bashExecution(!! 前缀)与未知角色过滤为 undefined,branchSummary/compactionSummary 则包上 <summary> 前后缀转成 user 文本。

steering 插话、follow-up 与 abort 分别如何体现在持久化文件里?

三者最终都落成普通 message entry:steering/follow-up 消息在真正进入历史时以 user entry 由 message_end 处理器 appendMessage 落盘(内存队列只负责投递时机);被打断的 assistant 回复以 stopReason: "aborted" 的 assistant entry 落盘(流式 "pending" 状态不持久化);! 直连 shell 以 role: "bashExecution" entry 落盘,流式期间先挂起、agent_end 再追加以维持 toolUse/toolResult 配对完整。