Pi 源码拆解(一):极简 Coding Agent Harness 的分层设计

2 分钟阅读
·

本文是「Pi 源码拆解」系列第 1 篇。系列目录:

  • 2026
    • 06-19 Pi 源码拆解(一):极简 Coding Agent Harness 的分层设计(本篇)

之前我写过 Claude Code 源码拆解系列,那个项目的特点是尽可能多内置:权限系统、MCP、子代理、plan mode、后台任务,harness 替你做好所有决定。最近通读了方向完全相反的一个项目:Mario Zechner(badlogic)的 pi。pi 的 README 直接列了一张「不做」清单:不做权限弹窗、不做 MCP、不做子代理、不做 plan mode、不做内置 to-do、不做后台 bash。这份清单写在被标记为 Philosophy 的小节里,每条附一句话理由和一句「想要就自己建」的指引。

两个项目都属于 coding agent harness,但策略放置的位置不同。Claude Code 将更多能力放在产品内部,pi 则通过宿主、扩展和外部工具承担其中一部分决策。对照两者可以区分运行时机制与产品策略。

这篇讲 pi 的整体分层,以及这些设计取舍在代码中的实现。后面会分别有几篇文章讲解 agent 运行时、会话与上下文管理、客户端/服务端拆分、终端 UI 和 provider 适配层。

四层包结构:策略归属与组合层

pi 的 monorepo 里主线只有四个包,依赖方向是一条链加一个旁支:

  • @earendil-works/pi-ai:统一多 provider 的 LLM API。
  • @earendil-works/pi-agent-core:agent 运行时,依赖 pi-ai。
  • @earendil-works/pi-coding-agent:CLI 本体,依赖 agent-core、pi-ai 和 pi-tui。
  • @earendil-works/pi-tui:终端 UI 库,不依赖上面任何一个包。

依赖图上 pi-tui 是一个旁支:coding-agent 同时依赖 agent-core 和 tui,而 tui 不依赖 agent 或 LLM,可单独用于其他终端应用。仓库里还有 protocol、server、client、storage 一组客户端/服务端拆分的包,全部声明为 experimental。

pi 四层包依赖架构

包结构本身很简单,关键在于通过分层明确关注点、隔离机制与策略,并将可变的产品决策上移到组合层:

第一,agent 核心不依赖 Node。packages/agent/src/agent-loop.ts 全文 792 行,整个 src/ 里只有 harness/env/nodejs.ts 一个文件 import 了 node: 模块;Node 相关能力从 ./node 这个单独出口导出(packages/agent/package.json 的 exports 字段)。核心循环因此可以在非 Node 环境里跑。

第二,agent 核心不内置 provider。packages/agent/src/stream-fn.ts:5-10 的注释写明:宿主可以在这里安装默认 stream 函数,「without making pi-agent-core depend on a provider catalog or compatibility layer」。没注入时 getDefaultStreamFn() 直接抛错。也就是说,运行时连「默认用哪家模型」这个决定都推给了宿主。

第三,agent 核心不做自动 compaction。shouldCompact 在 agent 包里只是个纯函数(packages/agent/src/harness/compaction/compaction.ts:263),输入 token 数和设置,返回布尔值。真正在跑的过程中检查并触发压缩的代码在上层 CLI(packages/coding-agent/src/core/agent-session.ts:2038)。阈值定多少、摘要 prompt 怎么写、压缩后保留最近几条,这些是产品决策,运行时只提供判断和执行压缩的零件。

pi-ai 也遵循相同的接口边界约束:模型 catalog 只收支持 tool calling 的模型(packages/ai/scripts/generate-models.ts:1105tool_call !== true 的直接跳过)。37 家 provider 共享 api 目录下约 10 个接口协议实现,xAI、Groq、OpenRouter 都复用 openai-completionspackages/ai/src/providers/xai.ts:8groq.ts:6openrouter.ts:7)。适配细节后续会展开单独讲。

从这些依赖关系和调用位置看,pi 的分层主要围绕策略归属划分。运行时不决定默认模型、压缩时机或命令审批;这些决策由宿主、上层 CLI 或扩展提供,底层负责相应的执行机制。

不内置的能力及其代价

README 的 Philosophy 清单原文在 packages/coding-agent/README.md:491-507,六条:

  • No MCP. 想要的话建 CLI 工具配 README(走 Skill 机制),或者自己写扩展接 MCP。这条附了作者一篇博客的链接,论点是模型本来就会跑命令行,给它一个带文档的 CLI 比维护一个 MCP server 更通用。
  • No sub-agents. 替代路径是用 tmux 起多个 pi 实例,或者用扩展自己实现,或者装一个按你的方式做这件事的包。
  • No permission popups. 跑在容器里,或者用扩展建符合自己环境要求的确认流程。
  • No plan mode. 把计划写进文件,或者用扩展实现。
  • No built-in to-dos. 原话是 “They confuse models.” 用 TODO.md 文件代替。
  • No background bash. 原话是 “Use tmux. Full observability, direct interaction.” 后台任务的输出放在 tmux 里,人和模型都能直接看、直接干预,比藏在 harness 的后台任务队列里透明。

清单中的每项需求都给出了替代路径,通常是文件、扩展、外部工具或第三方包。pi 不将这些能力作为内置产品功能提供。下面三个官方扩展示例展示了这种替代关系:扩展通过注册事件、命令或工具,将产品策略接入运行时。

权限确认不需要改动 bash 工具。扩展订阅工具执行前事件,在命中风险命令时显示确认界面;无交互界面时默认拒绝:

const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];

pi.on("tool_call", async (event, ctx) => {
  if (event.toolName !== "bash") return;

  const command = event.input.command as string;
  if (!dangerousPatterns.some((pattern) => pattern.test(command))) return;

  if (!ctx.hasUI) {
    return { block: true, reason: "Dangerous command blocked" };
  }

  const choice = await ctx.ui.select(`Dangerous command: ${command}`, ["Yes", "No"]);
  if (choice !== "Yes") return { block: true, reason: "Blocked by user" };
});

这段代码来自 examples/extensions/permission-gate.tstool_call 发生在工具执行之前,返回 { block: true, reason } 即可中止本次调用。

Plan mode同样是扩展状态加事件策略:扩展注册 /plan 命令切换状态,并在计划状态下只允许白名单中的 bash 命令。

pi.registerCommand("plan", {
  description: "Toggle plan mode (read-only exploration)",
  handler: async (_args, ctx) => togglePlanMode(ctx),
});

pi.on("tool_call", async (event) => {
  if (!planModeEnabled || event.toolName !== "bash") return;

  const command = event.input.command as string;
  if (!isSafeCommand(command)) {
    return {
      block: true,
      reason: `Plan mode: command blocked (not allowlisted). Command: ${command}`,
    };
  }
});

完整示例还会禁用 editwrite,并通过 before_agent_start 向上下文加入只读约束;这些行为都位于 examples/extensions/plan-mode/index.ts,而不是核心运行时。

待办列表则作为模型可调用的扩展工具注册。它的状态从会话中的历史工具结果重建,避免为了一个待办功能向核心会话模型增加字段:

pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));

pi.registerTool({
  name: "todo",
  label: "Todo",
  description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
  parameters: TodoParams,

  async execute(_toolCallId, params) {
    if (params.action === "add") {
      const newTodo = { id: nextId++, text: params.text!, done: false };
      todos.push(newTodo);
      return {
        content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
        details: { action: "add", todos: [...todos], nextId },
      };
    }
    // list、toggle、clear 分支省略
  },
});

examples/extensions/todo.ts 将待办数据写入工具结果的 details,并在 session 事件中重新构造状态。核心只需要提供工具注册和会话访问接口。

「不做权限弹窗」并非缺少实现入口:代码将拦截能力明确留给扩展。bash 工具的执行入口经 ops.exec() 抽象调用(packages/coding-agent/src/core/tools/bash.ts:429);未注入自定义 operations 时,默认本地后端才会在 createLocalBashOperations() 中直接调用 spawnbash.ts:97)。edit 和 write 直接写盘,整条路径上没有任何审批调用。全仓库唯一的拦截点是扩展事件 tool_callpackages/coding-agent/src/core/extensions/types.ts:899),注释写明 “Fired before a tool executes. Can block.”,handler 返回 block 加 reason 就能拦下这次调用,事件里的 input 还可变,扩展可以在执行前改写工具参数。

权限的替代方案有两层。一层是 OS 级隔离,根 README 的 “Permissions & Containerization” 小节指向 packages/coding-agent/docs/containerization.md,给了三条路径:Gondolin 扩展把内置工具和命令路由进本地 micro-VM、整个进程跑进纯 Docker、或者用 OpenShell 的策略沙箱。另一层是可编程性:examples 目录里有 permission-gate.tsprotected-paths.ts 两个官方示例,用上面的 tool_call 事件实现项目自己的权限规则。公司内网、个人机器和 CI 容器需要的规则不同,pi 将策略选择留给运行环境和扩展。

工具集也是同一个思路。默认只有 4 个工具:read、bash、edit、write(packages/coding-agent/src/core/tools/index.ts:138-145)。grep、find、ls 可选,其中 grep 和 find 底层调系统的 ripgrep 和 fd,机器上没有时 ensureTool 会自动下载对应二进制(packages/coding-agent/src/utils/tools-manager.ts:326),ls 是纯 Node 实现。系统提示词全文 162 行,完全动态生成(packages/coding-agent/src/core/system-prompt.ts):工具列表只列调用方提供了 snippet 的工具,guidelines 按工具组合条件生成,比如只在「有 bash 但没有 grep/find/ls」时才加一条「Use bash for file operations like ls, rg, find」。项目里的上下文文件(AGENTS.md 之类)以 <project_context> 块追加在末尾,调用方还可以通过 promptGuidelinesappendSystemPrompt 注入自己的规则,提示词的每一节都有明确的来源。

这种取舍也带来明确成本。第一,使用者需要自行开发、组合或安装扩展和 skill。第二,团队环境缺少统一的安全策略入口;各成员的扩展配置可能不同,管理员无法集中收紧策略。第三,README 明确警告(packages/coding-agent/README.md:408):pi packages 以完整系统权限运行,扩展可执行任意代码,安装第三方包前需要审查其源码。安全策略因此由使用者和运行环境负责。

Extensions 机制与文件驱动的扩展点

「刻意不做」能成立,前提是通过扩展补足能力的成本足够低。pi 提供 Extensions、Skills 和 Prompt templates 三类文件驱动的扩展点。

Extensions 是同进程的 TypeScript 模块,用 jiti 在运行时编译加载(packages/coding-agent/src/core/extensions/loader.ts:17)。最小扩展可以只注册一个工具:

import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";

const helloTool = defineTool({
  name: "hello",
  description: "A simple greeting tool",
  parameters: Type.Object({ name: Type.String() }),
  async execute(_toolCallId, params) {
    return {
      content: [{ type: "text", text: `Hello, ${params.name}!` }],
      details: { greeted: params.name },
    };
  },
});

export default function (pi: ExtensionAPI) {
  pi.registerTool(helloTool);
}

该示例来自 examples/extensions/hello.ts。扩展模块在默认导出函数中接收 ExtensionAPI,通过 registerTool 将工具接入模型调用与 TUI 渲染流程。有个工程细节值得写:pi 也发布单文件 Bun 二进制,二进制里没有 node_modules 可以解析,loader 就静态 import 了 typebox、pi-agent-core、pi-ai、pi-tui 和 pi-coding-agent 本体,再通过 VIRTUAL_MODULES(loader.ts:50-74)把这份打包好的依赖喂给 jiti。扩展里 import "@earendil-works/pi-ai" 拿到的是二进制里那一份,扩展与宿主共享同一套类型和运行时状态,从而避免依赖的双实例问题。

Skills 走 agentskills.io 规范,一个 skill 的最小文件是带 YAML frontmatter 的 SKILL.md

---
name: dynamic-resources
description: Example skill loaded from resources_discover
---

# Dynamic Resources Skill

This skill is provided by the dynamic-resources extension.

这个文件来自 examples/extensions/dynamic-resources/SKILL.mdformatSkillsForPromptpackages/coding-agent/src/core/skills.ts:335-361)只把 frontmatter 中的 name、description 和文件 location 以 <available_skills> 写进系统提示词,正文不放入常驻上下文。模型判断任务匹配后,再用 read 工具加载 SKILL.md。这种渐进加载方式减少了每轮请求携带的 skill 正文;我在 agent-skills 那篇讨论过同一思路。Prompt templates 是第三条路径,markdown 文件走同一套资源加载管线,内容更轻。

这三类扩展产物通过 pi packages 分发。包用 package.json 中的 pi 字段声明各类资源目录:

{
  "name": "my-pi-package",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./extensions"],
    "skills": ["./skills"],
    "prompts": ["./prompts"],
    "themes": ["./themes"]
  }
}

安装命令接受 npm、Git 和本地路径来源,并按安装范围写入用户或项目设置:

pi install npm:@foo/pi-tools
pi install git:github.com/user/repo@v1
pi install ./relative/path/to/package -l

-l 表示项目级安装;省略时写入用户级设置。包管理器解析来源类型后,分别调用 npm 安装、Git 克隆或本地路径校验,并将成功安装的来源持久化到对应设置(packages/coding-agent/src/core/package-manager.ts:978-1005)。README 在 Philosophy 开头概括了三者的关系:其他工具内置的功能,在 pi 中可以由扩展或 skill 实现,也可通过第三方包安装。

内置工具和扩展工具都实现 ToolDefinitionpackages/coding-agent/src/core/extensions/types.ts:449)。接口除参数 schema 和 execute 外,还包括供系统提示词引用的 promptSnippet(:457),以及工具 TUI 渲染使用的 renderCall(:489)和 renderResult(:492)。扩展工具因此可以接入与内置工具相同的提示词和终端渲染接口。

系统提示词中的自文档路径是这套机制的重要实现。代码在system-prompt.ts ;默认提示词本身由函数动态拼接,包含角色、当前可用工具、按工具组合生成的规则和 pi 文档索引。省略运行时替换的工具说明和绝对路径后,固定部分如下:

You are an expert coding assistant operating inside pi, a coding agent harness.
You help users by reading files, executing commands, editing code, and writing new files.

Available tools:
- <tool name>: <one-line snippet>

Guidelines:
- <tool-dependent guideline>
- Be concise in your responses
- Show file paths clearly when working with files

Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation: <README absolute path>
- Additional docs: <docs directory absolute path>
- Examples: <examples directory absolute path>
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read pi .md files completely and follow links to related docs

其中 <tool name> 只会列出调用方同时选中且提供 toolSnippets 的工具;默认工具集合是 read、bash、edit、write。若提供 read 工具,提示词末尾还会追加 <available_skills>,其中仅包含每个 skill 的名称、描述和路径。随后依次追加 <project_context>、可选的 appendSystemPrompt 和当前工作目录。pi 没有将扩展 API 全文放入系统提示词,而是通过文档索引和按需读取提供这些信息。

和扩展机制配套的是 project trust。项目目录里的 .pi 设置和项目级扩展可以执行任意代码,pi 在首次进入目录时会问「Trust project folder?」,决定记录在 trust.jsonpackages/coding-agent/src/core/trust-manager.ts:212);信任之前只加载用户级扩展,让它们有机会处理 project_trust 事件。这里需要区分两个概念:供应链信任(是否信任项目代码)和工具执行权限(是否允许执行某条命令)。pi 只处理前者,后者由运行环境和扩展负责。

TUI 概览

pi-tui 是独立的终端 UI 库,组件接口就是 render(width): string[]packages/tui/src/tui.ts:29),组件只产出字符串行。渲染器对行数组做 diff,只重绘变化的区间;默认交互界面渲染进 main screen,历史自然进入终端滚动区,同时也提供可显式启用的 alternate screen 实现;所有更新包在 CSI 2026 同步输出序列里,避免撕裂。这套设计可与扩展体系集成:工具的 renderCall/renderResult 返回的就是这些组件。差分渲染本身是完整的终端 UI 工程话题,值得单独写,这里不展开。

收尾

本文涉及的运行时职责包括 agent 控制流、会话状态、provider 适配与终端呈现。pi 将其中多项策略通过宿主、扩展和外部工具提供。下一篇分析 agent 控制流如何将失败编码为 assistant 消息;后续文章依次讨论会话与上下文管理、客户端/服务端拆分、终端 UI 和 provider 适配层。


751 字 · 56 段落
xi ming

Written by xi mingFollow onGitHub