DOCUMENT 03 · 运行时 · 状态机 · 流程
从一次回车到回包
这一篇钻进 agent-session.ts。我们沿着一次用户输入的完整路径走一遍:启动期初始化 → 模式启动 → 对话循环 → 工具调用 → Compaction → 异步事件分发。同时讲清楚 RPC 协议、Extension 钩子顺序、JSONL 会话树的并发安全。
■ 启动序列
从 pi 命令被敲下,到出现交互编辑器,经过的步骤:
1
入口分发
cli.ts 解析 argv:是否带 install/remove/... 包子命令?是 → 走 package-manager-cli.ts。否 → 走主流程。Bun 用户走 bun/cli.ts,先 restore 沙箱环境。
2
CLI 参数 → 配置
cli/args.ts 解析参数;config.ts 合并 settings(全局 + 项目)、auth、模型注册表、键位。整套合并发生在内存里,结果是一份 ResolvedConfig。
3
加载资源
resource-loader.ts 扫描所有路径(全局 ~/.pi/agent、项目 .pi/、npm 包、git 包)发现 extensions / skills / prompts / themes。extensions/loader.ts 把每个 extension 转成"待激活"描述符。
4
激活 Extensions(顺序敏感)
extensions/runner.ts 调用每个 extension 的默认导出(同步或 async 都等)。它们在这一步注册 tool / command / provider / compactor 与事件钩子。pi 必须在启动完成前等所有 async 工厂——这就是为什么 extension 适合做"启动期 fetch 远端模型列表"之类的事。
5
构造 Agent Session
createAgentSession() 创建 session:注入 SessionManager(决定持久化方式)、AuthStorage、ModelRegistry、tool 注册表、事件总线。再加载或新建 JSONL 文件。
6
拼接 System Prompt
system-prompt.ts 组装 prompt:默认模板 → 用户 SYSTEM.md 覆盖 / APPEND_SYSTEM.md 追加 → AGENTS.md 链 → 已加载 skills 的元数据 → extensions 注入的自定义段。
7
进入选定模式
把 session 句柄交给具体 Mode:interactive 启动 TUI 与编辑器;print 直接处理 stdin 或 argv 里的 prompt;rpc 进入 stdio loop;sdk 则把控制权返还调用方。
■ 四种模式 · 同核异壳
关键观察:Agent 核心是一个,模式只是包裹它的 IO 层。所以同一个 session 可以用不同模式接进来。
Interactive 默认
全屏 TUI,自研组件框架。用户敲键 → 编辑器 → 提交 → session → 模型;流式 token 实时渲染、tool 执行内嵌、状态栏实时更新 token/成本。
modes/interactive/interactive-mode.ts
Print 脚本化
一次性输入 → 一次性输出。适合脚本、CI、cron、subprocess 调用。可选 JSON 输出包含完整结构化结果。
modes/print-mode.ts · pi -p "..."
RPC stdio JSONL
stdin/stdout 上跑严格 LF 分隔的 JSONL 协议。非 Node 客户端用这个集成。⚠ 不要用通用 readline 拆行——JSON 里可能有 Unicode 分隔符。
modes/rpc/rpc-mode.ts · pi --mode rpc
SDK 嵌入式
在你自己的 Node 应用里直接 import { createAgentSession } from "@earendil-works/pi-coding-agent",复用 pi 的全部能力。多 session 用 createAgentSessionRuntime()。
core/sdk.ts
■ Agent 对话循环(核心状态机)
这是 agent-session.ts 的主循环。每一次"用户敲下 Enter → 模型回包结束"被称为一个 turn。turn 里可能套娃多个 LLM 调用(因为有工具)。
IDLE
空闲,等用户输入。可接 steering/follow-up 入队、可被模式切到 cancel。
PROMPTING
用户提交后到模型 streaming 开始之间:合并 system prompt + 历史消息 + 注入 skills/context → 发请求。
STREAMING
模型 token 流入。事件总线广播 assistant_delta;UI/RPC/订阅者实时渲染。中途可被 Escape 中断。
TOOL_CALL
模型发出 tool_use。pi 校验 schema → 经 file-mutation-queue 排队(写类工具)→ 调用 tool.run() → 收 stdout。
TOOL_RESULT
把 tool 输出包装成 tool_result message,append 进会话,下一轮再请求模型。可能进入 STREAMING 继续套娃。
COMPACTING
压缩中。STREAMING 完结时若 token 比例超阈值,主动进入;或者 PROMPTING 时发现溢出,自动重试。详见后文。
CANCELLED
用户 Escape 中断。已 streaming 的部分 append 为不完整 message;队列消息恢复到编辑器。
ERROR_RETRY
网络抖动、API 限流、context overflow——按规则重试或回到 IDLE。Extensions 可挂 on_error 决定行为。
TURN_END
本轮收尾:广播事件、应用 queue 里 steering 消息(如有)、回 IDLE。
一次完整 turn 的事件时间线
user submits "implement feature X"
│
▼
[turn_started] ─▶ extensions::on(turn_started) 钩子
│
▼
PROMPTING → 拼 messages → 发 LLM 请求
│
▼
STREAMING ─▶ [assistant_delta] × N (UI 实时渲染)
│
▼ (模型说要调 read)
TOOL_CALL ─▶ [tool_call(name=read, args={...})]
│ ↳ extensions::on(tool_call) 可拦截/审批/重写
│
▼
tools/read.ts::run() ─▶ stdout
│
▼
TOOL_RESULT ─▶ [tool_result] append to session
│
▼ (回到 STREAMING — 因为还要让模型基于结果继续)
STREAMING ─▶ (模型说要 edit)
TOOL_CALL ─▶ edit ─▶ file-mutation-queue.add() ─▶ 执行 ─▶ diff
TOOL_RESULT
│
▼ (模型最终回结论)
STREAMING ─▶ [assistant_final]
│
▼
TURN_END ─▶ [turn_ended] ─▶ extensions::on(turn_ended)
│
▼
IDLE (队列 steering 若有则立即下一 turn)
所有工具走同一条路。这条路的关键设计是串行化写操作,避免模型并发的多次 edit 互相覆盖。
tool_call event
│
▼
schema validation (zod / json schema)
│
▼
permission check ◀── extensions can register guards
│ (block, prompt, allow)
▼
是写类工具?(edit / write / 等 extension 注册的写)
├─ yes ─▶ file-mutation-queue.add({path, op}) ─▶ 等队列轮到
└─ no ─▶ 直接执行
│
▼
tool.run({ args, ctx })
│ ctx 提供:cwd · session · event-bus · auth · settings
│
▼
output-accumulator 流式收集 / output-guard 截断长输出
│
▼
tool_result event ─▶ append to session ─▶ broadcast
file-mutation-queue 的作用
问题:模型在一个 turn 里可能同时下达 N 个 edit 指令,并发执行会产生竞态——A 改完,B 拿到的还是旧文件内容。
解决:所有写类工具都过 core/tools/file-mutation-queue.ts。同一文件路径上的操作严格串行化;不同路径可并行。tool 执行时先 queue.add() 拿到 token,再读 + 改 + 写。
■ 消息队列与中断
"边跑边输入"是 pi 比较有意思的设计。两个独立队列:
| 队列 | 触发键 | 投递时机 | settings 可选 |
| steering | Enter | 当前 turn 的 tool 调用执行完、回到 STREAMING 前注入 | steeringMode: "one-at-a-time" | "all" |
| follow-up | Alt+Enter | 整轮 TURN_END 后才投递 | followUpMode: "one-at-a-time" | "all" |
| cancel | Escape | 立刻中断;队列里的消息恢复到编辑器 | — |
所以"steering"可以理解为"在 agent 走神之前打补丁","follow-up"是"让它老老实实做完再接下一件事"。两种模式都允许批量(all)或一条一条(one-at-a-time)。
■ Compaction 状态机
长会话上下文撑爆怎么办?pi 的策略不是丢消息,而是把老消息总结成一段摘要 message,替换原 N 条。
三种触发方式
| 触发 | 时机 | 行为 |
| 主动 · 接近阈值 | 当前 turn 完成、context 占用 ≥ 配置的高水位 | 异步压缩,下一 turn 用压缩后版本 |
| 被动 · 溢出 | 本次 LLM 请求被 provider 拒绝(context overflow) | 同步压缩 → 用压缩后重试本次请求 |
| 手动 | 用户输入 /compact 或 /compact <指令> | 立即压缩,可附加摘要重点偏好 |
分支感知(branch-summarization.ts 的存在原因)
问题:会话是树,不是线。如果只压"按时间序在前面的",会把别的分支也压掉。
解决:压缩只针对当前 active path 上的消息。其他分支的原始 message 保留,因为 /tree 跳过去时还要能看到。core/compaction/branch-summarization.ts (11KB) 实现这套分支感知的边界判断。
Compaction 是有损的。JSONL 文件保留原始 message——压缩只在内存里替换给模型看的版本,并 append 一条 compaction_summary 消息标记。用 /tree 可以回到压缩前任意节点继续。
可被 Extension 完全替换
pi.registerCompactor({
shouldCompact(ctx) { return ctx.usedTokens / ctx.maxTokens > 0.85; },
async compact(messages, ctx) {
// 用你自己的策略:mini 模型摘要、向量聚类、规则裁剪……
return { summary: "...", removed: [...], kept: [...] };
}
});
■ Extension 生命周期与钩子顺序
钩子按"会话生命周期 + turn 生命周期 + 工具生命周期"分层。多个 extension 注册同一钩子时,按 extension 加载顺序触发。
[发现 → 加载]
extension factory called (async ok, pi waits)
│
[每个 session 启动时]
on(session_started)
│
[每个 turn 开始]
on(turn_started)
│
[每次 tool call 前]
on(tool_call) ← 可拦截 / 修改 / 增加 audit
│
[tool 完成]
on(tool_result)
│
[整 turn 结束]
on(turn_ended)
│
[session 关闭]
on(session_ended)
│
[发生错误]
on(error) ← 全生命周期可触发
几个常见用法
| 需求 | 实现钩子 |
| 给所有 bash 调用加权限确认 | on(tool_call),条件 name==="bash" → 弹出自定义 UI → 等用户回答 |
| 自动 git 提交每次成功 turn | on(turn_ended),调 utils/git.ts |
| 替换默认 Compaction | registerCompactor |
| 注入"项目知识"到 system prompt | on(session_started) 注入额外 system 段 |
| 追加自定义 footer 状态 | registerFooter / 自定义 UI 组件 |
■ RPC JSONL 协议
用于非 Node 客户端集成。pi --mode rpc 在 stdin/stdout 上跑严格 LF 分隔的 JSONL。
⚠ 客户端必须只按 \n 分行。不要用 Node readline、不要用 Python file.readline() 的某些实现——它们会按 Unicode 行分隔符(U+2028 等)拆,结果把 JSON payload 切坏。
协议片段(示意)
→ {"type":"prompt","id":"r-1","payload":{"text":"implement X"}}
← {"type":"event","id":"r-1","event":"turn_started"}
← {"type":"event","id":"r-1","event":"assistant_delta","delta":"Sure, ..."}
← {"type":"event","id":"r-1","event":"tool_call","name":"read","args":{...}}
← {"type":"event","id":"r-1","event":"tool_result","output":"..."}
← {"type":"event","id":"r-1","event":"turn_ended"}
← {"type":"response","id":"r-1","status":"ok"}
具体字段、错误码、并发约束见 docs/rpc.md 与 modes/rpc/rpc-types.ts。modes/rpc/jsonl.ts 实现严格 LF 分帧;rpc-client.ts 给 Node 用户提供一个示例客户端。
■ 会话树的读写
JSONL append-only + parentId 链 = 一棵在文件里活着的树。
磁盘格式
{"id":"a", "parentId":null,"role":"user","content":"..."}
{"id":"b", "parentId":"a", "role":"assistant","content":"..."}
{"id":"c", "parentId":"b", "role":"user","content":"v1 follow-up"}
{"id":"c2","parentId":"b", "role":"user","content":"v2 follow-up"} ← 分支
{"id":"d", "parentId":"c", "role":"assistant","content":"..."}
{"id":"d2","parentId":"c2","role":"assistant","content":"..."}
读取 · active path 重建
session-manager.ts 在打开会话时把所有行读进 in-memory map(按 id 索引),按 parentId 构图。当前指针指向某个叶子 → 从叶子回溯到根 = active path。
/tree 在这棵图上做导航与展示;/fork 从某节点拉新分支;/clone 整段复制 active path 到新文件。
写入并发安全
设计选择:append-only。所有写都加到文件末尾,绝不就地修改既有行。这保证:
• 多进程并发安全(依赖 OS 的 append 原子性)
• 永远可以"时光倒流"——文件就是完整历史
• Compaction 不删除原始消息,只 append 一条 compaction_summary
• 读取时再按 parentId 拼成树
小结 · 设计意图
pi 整套运行时几乎所有"复杂"的位置——Compaction 算法、tool 集合、Provider 接入、Sub-agent、Plan、Permission——都被设计成可被替换的接口点。
agent-session.ts 看上去 103KB 巨大,但它真正的工作是编排,而不是实现。
具体实现下沉到 tools / extensions / compaction 子目录,并由 ExtensionAPI 暴露给外界改写。这就是 README 里"aggressive extensibility"的字面含义。