Skip to content

第 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

dockerfile
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"]
bash
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

tmux
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 也支持默认的 xterm modifyOtherKeys 格式;
  • 配置后 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)不需要新文件,只是从更早的节点长出新子树:

jsonc
// 第一行:会话头(无 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 快速分析一条历史会话:

bash
# 统计本次会话总花费(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.jsonl

TypeScript 解析器示例(官方文档同款结构):

typescript
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 配置:

json
{
  "shellCommandPrefix": "shopt -s expand_aliases\neval \"$(grep '^alias ' ~/.zshrc)\""
}

想给 pi 本身开发或做 fork 白标(改 CLI 名、配置目录、环境变量名),克隆 monorepo:

bash
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.jsonpiConfig 里改 nameconfigDir 即可整体改名。调试可用隐藏命令 /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 别名,原因和解法分别是?

🛠️ 动手实践

  1. 构建 Dockerfile.pi 并在容器里完成一次真实的代码修改任务,对比读写挂载与只读挂载的行为差异。
  2. 写一个 jq 脚本:扫描你所有会话文件,输出最近 10 条 assistant 消息的模型名与单条成本排行。
  3. 给团队仓库写一份 AGENTS.override.md,再配置一个依赖你个人别名的 shellCommandPrefix,验证两者在不同目录层级下的加载优先级。