第 19 章 · 高级场景:容器化/tmux/会话格式
本章目标:掌握三种隔离运行模式(Docker/Gondolin/OpenShell)、tmux 键位修正、会话 JSONL 格式的程序化分析,以及 shell 别名与 pi 自身的开发速览。
19.1 容器化:三种隔离模式选型
上一章讲了"为什么必须隔离",这一章讲"怎么隔离"。官方给出三条路线:
| 模式 | 隔离对象 | 适用场景 | 备注 |
|---|---|---|---|
| Gondolin 扩展 | 内置工具与 ! 命令 | 想把认证留在宿主机,只把工具执行送进微 VM | 需要 Node ≥ 23.6 + QEMU |
| Plain Docker | 整个 pi 进程 | 最简单的本地隔离 | API key 会进容器 |
| OpenShell | 整个 pi 进程 | 需要策略管控(文件/进程/网络/凭据/推理)的沙箱 | 需要 OpenShell 网关 |
Plain Docker 是最常用的起步方案。官方 Dockerfile.pi:
FROM node:24-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
WORKDIR /workspace
ENTRYPOINT ["pi"]docker build -t pi-sandbox -f Dockerfile.pi .
# 只挂工作区;用命名卷存容器自己的配置/会话
docker run --rm -it \
-e ANTHROPIC_API_KEY \
-v "$PWD:/workspace" \
-v pi-agent-home:/root/.pi/agent \
pi-sandbox两个安全细节:-v "$PWD:/workspace" 是读写挂载,容器内的写会直接落到宿主文件——要更强保护就改只读挂载或拷入拷出;不要把宿主的 ~/.pi/agent 直接挂进去,那会把宿主的认证和全部会话暴露给容器。
Gondolin 走的是另一条思路:pi 在宿主上跑(保留登录态),但通过扩展把 read/write/edit/bash/grep/find/ls 全部路由进本地 Linux 微虚拟机,工作区挂载为 VM 内的 /workspace 并回写宿主。
OpenShell(NVIDIA)适合企业场景:网关注入上游 provider 凭据,沙箱内代码只能访问 https://inference.local,原始 API key 不落沙箱;远程网关时文件不 bind-mount,用 openshell sandbox upload/download 传输。
19.2 tmux 集成:修好 Shift+Enter
pi 在 tmux 里能直接跑,但 tmux 默认会剥掉修饰键信息,导致 Shift+Enter 与普通 Enter 无法区分。官方推荐配置写入 ~/.tmux.conf:
set -g extended-keys on
set -g extended-keys-format csi-u然后完全重启 tmux(tmux kill-server && tmux)。要点:
csi-u格式需要 tmux 3.5+(tmux -V查看);3.2–3.4 就省略第二行,pi 也支持默认的 xtermmodifyOtherKeys格式;- 配置后 Shift+Enter 从裸
\r变成\x1b[13;2u,换行/提交的默认键位才能正常工作; - 还需终端模拟器支持扩展键位(Ghostty/Kitty/iTerm2/WezTerm/Windows Terminal)。
19.3 会话 JSONL:格式与程序化分析
会话文件按工作目录存放在 ~/.pi/agent/sessions/--<路径>--/<时间戳>_<uuid>.jsonl。当前版本 v3,每行一个 JSON 对象,通过 id/parentId 构成一棵树——分支(fork/tree)不需要新文件,只是从更早的节点长出新子树:
// 第一行:会话头(无 id/parentId)
{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path/to/project"}
// 消息条目:message 字段是 AgentMessage
{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"...","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"...","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"stopReason":"stop", "...":"..."}}
// 其他条目类型
{"type":"model_change","provider":"openai","modelId":"gpt-4o", "...":"..."}
{"type":"compaction","summary":"用户讨论了 X/Y/Z","tokensBefore":50000, "...":"..."}
{"type":"branch_summary","fromId":"f6g7h8i9","summary":"该分支尝试了方案 A", "...":"..."}
{"type":"custom","customType":"my-extension","data":{"count":42}, "...":"..."} // 不进 LLM 上下文
{"type":"custom_message","content":"注入的上下文","display":true, "...":"..."} // 进 LLM 上下文
{"type":"label","targetId":"a1b2c3d4","label":"checkpoint-1", "...":"..."}用 jq 快速分析一条历史会话:
# 统计本次会话总花费(cost.total 在 assistant 消息的 usage 里)
jq -s '[ .[] | select(.type=="message" and .message.role=="assistant")
| .message.usage.cost.total // 0 ] | add' \
~/.pi/agent/sessions/--*Users*me*project*/2024*.jsonl
# 列出所有压缩点及其压缩前 token 数
jq 'select(.type == "compaction") | {tokensBefore, summary}' session.jsonlTypeScript 解析器示例(官方文档同款结构):
import { readFileSync } from "fs";
const lines = readFileSync("session.jsonl", "utf8").trim().split("\n");
for (const line of lines) {
const entry = JSON.parse(line);
switch (entry.type) {
case "session":
console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
break;
case "message":
console.log(`[${entry.id}] ${entry.message.role}`);
break;
case "compaction":
console.log(`[${entry.id}] 压缩前 ${entry.tokensBefore} tokens`);
break;
case "branch_summary":
console.log(`[${entry.id}] 从 ${entry.fromId} 分出: ${entry.summary}`);
break;
}
}树与上下文构建
buildContextEntries() 从当前叶子走到根得到活跃分支;遇到 compaction 条目时,新版会带 retainedTail(保留的消息尾),相当于自包含检查点。理解这棵树就同时理解了 /tree、/fork 和 /clone 的底层。
19.4 Shell Aliases 与开发速览
pi 的 bash 工具以非交互方式运行(bash -c),默认不展开你的 shell 别名。需要别名时在 ~/.pi/agent/settings.json 配置:
{
"shellCommandPrefix": "shopt -s expand_aliases\neval \"$(grep '^alias ' ~/.zshrc)\""
}想给 pi 本身开发或做 fork 白标(改 CLI 名、配置目录、环境变量名),克隆 monorepo:
git clone https://github.com/earendil-works/pi-mono
cd pi-mono && npm install && npm run build
/path/to/pi-mono/pi-test.sh # 从源码运行,保持调用方 cwd
./test.sh # 非 LLM 测试,无需 API key仓库分四个包:ai(LLM provider 抽象)、agent(Agent 循环与消息类型)、tui(终端 UI 组件)、coding-agent(CLI 与交互模式)。Fork 时在 package.json 的 piConfig 里改 name 与 configDir 即可整体改名。调试可用隐藏命令 /debug,输出写到 ~/.pi/agent/pi-debug.log(含 TUI 渲染行与发给 LLM 的最后消息)。
19.5 本章小结
- 三种隔离路线:Gondolin(工具进微 VM)、Plain Docker(整进程)、OpenShell(策略管控沙箱);
- Docker 场景:命名卷代替挂载宿主配置目录、注意读写挂载的回写风险;
- tmux 3.5+ 用
extended-keys-format csi-u修复修饰 Enter 键; - 会话是 v3 JSONL 树:
id/parentId+ compaction/branch_summary/custom 等条目类型,可直接 jq 分析; shellCommandPrefix让 bash 工具获得你的别名;pi-mono 四包结构与/debug支持二次开发。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. Plain Docker 模式下,为什么不建议把宿主的 ~/.pi/agent 直接挂载进容器?
2. tmux 中让 Shift+Enter 正常工作的推荐配置是什么?
3. 关于 custom 与 custom_message 两类条目,正确的说法是?
4. pi 的 bash 工具默认拿不到你的 shell 别名,原因和解法分别是?
🛠️ 动手实践
- 构建
Dockerfile.pi并在容器里完成一次真实的代码修改任务,对比读写挂载与只读挂载的行为差异。 - 写一个 jq 脚本:扫描你所有会话文件,输出最近 10 条 assistant 消息的模型名与单条成本排行。
- 给团队仓库写一份
AGENTS.override.md,再配置一个依赖你个人别名的shellCommandPrefix,验证两者在不同目录层级下的加载优先级。