一、引言:为什么值得读 Pi 的源码
Pi 是 earendil-works(作者 Mario Zechner,即知名开源人 badlogic)出品的编码 Agent Harness——一个从零自研的交互式编程 Agent。与 Claude Code、Codex 等闭源工具不同,Pi 的核心是三个可独立复用、按 MIT 协议开源的 npm 包:
@earendil-works/pi-ai:统一多厂商 LLM API,内置 OpenAI、Anthropic、Google、Bedrock、DeepSeek 等 40+ Provider;@earendil-works/pi-agent-core:与模型无关的 Agent 运行时,提供工具调用循环、状态管理与 transport 抽象;@earendil-works/pi-coding-agent:交互式编码 Agent CLI(终端下运行pi),自带 read / write / edit / bash 工具与完整的扩展系统。
作为工程研究对象,Pi 的价值在于它把一条编码 Agent 的主链路全部摊开给你看:模型抽象怎么做、Agent 循环怎么写、终端 UI 怎么差分渲染、权限边界怎么处理。本文基于 pi 仓库(v0.84.1,源码位于 /tmp/pi)逐层拆解这些实现。
本文导读
- 先建立全局认知:引言用一张饼图看清代码库构成;第二节用"架构总览 → 依赖 DAG → 各包体量柱状图"三张图递进,把 9 个 npm 包的层次、依赖与规模讲透。
- 再抓三条核心链路:第三节拆
pi-ai的 Provider 适配器与流式解析;第四节拆pi-agent-core的工具调用循环;第五、六节拆pi-coding-agent的内置工具、扩展系统与pi-tui的差分渲染。 - 最后看工程落地:第七节看 CBOR 协议栈,第八节给出部署与使用方式,第九节用一张表总结全仓的 6 个设计模式亮点。
项目概貌
| 维度 | 数据 |
|---|---|
| 语言 / 构建 | TypeScript(tsgo native-preview 编译),npm workspaces 单仓,统一版本号 0.84.1 |
| 运行时 | Node.js ≥ 22.19;standalone 二进制由 Bun build --compile 产出 |
| 模型抽象 | 40+ Provider 适配器(OpenAI / Anthropic / Google / Bedrock / DeepSeek …),统一流式接口 |
| 流式解析 | partial-json 增量解析,边收 token 边还原 tool call 参数 |
| Agent 运行时 | 双层工具调用循环(agent-loop.ts),事件流驱动,transport 中立 |
| 终端 UI | 自研差分渲染 TUI 库(含 Darwin / Win32 原生模块) |
| 进程协议 | CBOR 编码 + 4 字节长度前缀帧(单帧上限 16MB)+ TypeBox schema |
| 会话持久化 | node:sqlite 后端 + JSONL 传输格式 |
| 遥测 | 厂商中立遥测契约(兼容 OpenTelemetry 风格 span schema) |
| 权限模型 | 默认以启动用户权限运行;沙箱化可选(Gondolin / Docker / OpenShell) |
| 授权 | MIT |
从 1388 个文件的构成看,这是一个彻底的"纯 TypeScript 工程"——代码、测试、脚本全部用 TS 编写,文档与配置只占很小比例:
看图重点:环形图中心是文件总数,外部按扩展名分层展示占比——TypeScript(含源码与测试)占 80.8%,Markdown 文档 7.1%,JSON 配置 3.4%,构建脚本(.mjs)2.0%,其余为 yml / shell / 原生模块(.c / .node)等。
二、整体架构:Monorepo 五层包依赖
如果你只想先建立心智模型,先看下面这张总览图。
看图重点:自底向上共五层——基础层(telemetry / tui / protocol)不依赖任何内部包;模型抽象层
pi-ai负责把异构厂商 API 收敛成统一接口;pi-agent-core在其上实现与模型无关的 Agent 运行时;协议、客户端、服务端与会话后端构成中间支撑;最顶端是pi-coding-agent这个"应用层"。
2.1 包依赖 DAG
根目录 package.json 的 build 脚本按依赖顺序构建各包,这本身就是一张可信的依赖图:
tui → telemetry → ai → agent → sqlite-node → protocol → client → server → coding-agent
完整依赖关系(内部包之间):
| 包 | npm 名称 | 依赖的内部包 | 核心职责 |
|---|---|---|---|
packages/telemetry |
@earendil-works/pi-telemetry |
无 | 厂商中立遥测契约、类型化 span schema、参考适配器、一致性测试 |
packages/tui |
@earendil-works/pi-tui |
无 | 差分渲染终端 UI 库(marked 渲染、原生模块) |
packages/protocol |
@earendil-works/pi-protocol |
无 | CBOR 编解码 + 帧封装 + TypeBox schema |
packages/ai |
@earendil-works/pi-ai |
telemetry | 统一多厂商 LLM API、模型发现、OAuth、Provider 适配器 |
packages/agent |
@earendil-works/pi-agent-core |
ai, telemetry | Agent 运行时、工具调用循环、状态管理、transport 抽象 |
packages/client |
@earendil-works/pi-client |
protocol | 远端会话的传输中立客户端 |
packages/server |
@earendil-works/pi-server |
ai, protocol | 远端会话服务端(实验性) |
packages/session-backends/sqlite-node |
@earendil-works/pi-session-backend-sqlite-node |
ai, agent-core | 基于 node:sqlite 的会话持久化 |
packages/coding-agent |
@earendil-works/pi-coding-agent |
agent-core, ai, client, protocol, tui | CLI / TUI 入口、内置工具、会话管理、扩展系统 |
把表格画成 DAG,依赖关系一眼可见:
看图重点:四层结构自上而下单向依赖,箭头指向"被依赖方"——基础层(telemetry / protocol / tui)是唯一的依赖源;
coding-agent依赖 5 个包,成为入度最大的汇聚点,但没有任意一层向下依赖形成环。
依赖方向非常克制:所有包只依赖"下方的"包,没有任何循环依赖。这种分层带来的直接好处是——想单独研究模型层,跑 npm --prefix packages/ai test 即可,不牵扯任何 Agent 逻辑。
2.2 供应链工程化
仓库把"依赖即代码"贯彻得很彻底:直接外部依赖全部锁定精确版本、.npmrc 开启 save-exact、package-lock.json 作为依赖唯一事实来源、发布包自带 npm-shrinkwrap.json 锁死传递依赖。这些细节说明 Pi 的目标不只是"能跑的 Agent",而是"可以嵌入别人工程、可以发布给全球 npm 用户的 Agent 基础设施"。
2.3 各包体量
依赖分层之外,各包的源码规模揭示了工程重心:
看图重点:横向条形图按
src/下.ts文件数排列——coding-agent(199)与ai(174)两个包合计占全仓源码的 ~80%,是体量绝对重心;agent(49)运行时核心反而精简;协议、客户端、遥测都是十几个文件的小而美模块。体量分布与"分层克制"的设计互为印证:重资产集中在模型抽象与工具生态,运行时与基础层刻意保持轻量。
三、pi-ai:统一多厂商 LLM 抽象层
pi-ai 是全仓库最核心的抽象层,只做一件事:把 40+ 厂商千奇百怪的 API 差异,收敛成一个统一的流式接口。它刻意保持 src/index.ts 无副作用——核心类型只导出,Provider 工厂与 API 实现通过子路径按需加载(@earendil-works/pi-ai/providers/*、/api/*)。
3.1 核心类型体系
Api(src/types.ts):已知协议集合,如"openai-completions" | "mistral-conversations" | "openai-responses" | ... | "pi-messages";ProviderId:40+ 已知 Provider(anthropic、openai、google、github-copilot、deepseek、xai、moonshotai、qwen-*、xiaomi-*…);Model<TApi>:id / name / api / provider / baseUrl / reasoning / input / cost / contextWindow / maxTokens / compat。其中compat是差异化配置的关键——它按 API 类型条件携带兼容性开关(如supportsDeveloperRole、supportsReasoningEffort、supportsEagerToolInputStreaming),让同一模型在不同 API 协议下启用不同的能力位;- 统一消息模型:
UserMessage/AssistantMessage(内容为TextContent | ThinkingContent | ToolCall的数组)/ToolResultMessage,统一StopReason(stop | length | toolUse | error | aborted | deferred)与Usage(含成本拆分)。
3.2 Provider 适配器模式
这是全项目最值得抄的设计:每个厂商就是一个可插拔的运行时单元。Provider 接口(src/models.ts L97-149):
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly baseUrl?: string;
readonly headers?: ProviderHeaders;
readonly auth: ProviderAuth;
/** 当前已知模型列表(静态目录或最近一次 refresh 的结果) */
getModels(): readonly Model<TApi>[];
/** 动态 Provider:刷新远端模型目录 */
refreshModels?(context: RefreshModelsContext): Promise<void>;
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
/** 流式主接口:模型 + 统一 Context → 事件流 */
stream<T extends TApi>(model, context, options?): AssistantMessageEventStream;
streamSimple(model, context, options?): AssistantMessageEventStream;
// fetchDeferred / cancelDeferred:延迟响应的可选能力
}
而 createProvider()(src/models.ts L739-792)是一个把"静态模型目录 + 动态模型拉取 + API 实现"拼装成一个 Provider 的工厂:
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
const baselineModels = input.models;
let dynamicModels: readonly Model<TApi>[] = [];
const fetchModels = input.fetchModels;
const currentModels = (): readonly Model<TApi>[] => {
const merged = [...baselineModels];
for (const model of dynamicModels) {
const index = merged.findIndex((entry) => entry.id === model.id);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
};
// 单个实现,或按 model.api 分派的 map(混合 API 的 Provider)
const single = typeof (input.api as ProviderStreams).stream === "function"
? (input.api as ProviderStreams) : undefined;
const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
// ... 组装 provider 对象
}
两个细节值得注意:
- 混合 API 分派:一个 Provider 的模型可以横跨多种协议(比如同时有
openai-responses和openai-completions模型),api传一个 map 即可按model.api自动分派到对应实现; - 静态 + 动态叠加:
currentModels()把fetchModels()拉到的远端模型按 id 覆盖/追加到静态目录——订阅类 Provider(Copilot、Claude Pro)的模型列表会随账号状态变化。
3.3 流式响应与 partial-json 增量解析
编码 Agent 最麻烦的工程问题是:工具参数是 JSON,但它是逐 token 流过来的。Pi 的解法是给每个 ToolCall 挂一个 partialJson 的 scratch 缓冲,边收边解析。以 Anthropic 适配器为例(src/api/anthropic-messages.ts L654-660):
} else if (event.delta.type === "input_json_delta") {
const index = blocks.findIndex((b) => b.index === event.index);
const block = blocks[index];
if (block && block.type === "toolCall") {
block.partialJson += event.delta.partial_json; // 累积
block.arguments = parseStreamingJson(block.partialJson); // 增量解析
stream.push({
type: "toolcall_delta",
contentIndex: index,
delta: event.delta.partial_json,
partial: output,
});
}
}
看图重点:左侧是 provider 事件流,中间是
partialJson缓冲的逐步累积,右侧是arguments对象的增量更新——三个字段按行同步推进,toolcall_end时删掉 scratch 缓冲,保证"回放只携带已解析的参数"。
这套机制配合 stopReason == "length" 有一个精妙的兜底(详见第四节):截断响应里的工具调用即使解析、校验都通过了,也可能是"静默不完整"的,必须整批判失败——宁可让模型重发,也不执行有残缺参数的 bash。
3.4 模型发现与认证
Models 集合(src/models.ts L156+)负责把 Provider 串起来:getAvailable() 只返回"认证已配齐"的模型;getAuth() 负责解析 API Key 或触发 OAuth 刷新;login() 驱动交互式登录并把凭据持久化到本机。环境变量(如 ANTHROPIC_API_KEY)、auth.json、订阅登录(Claude Pro / ChatGPT Plus / Copilot)三套认证通道在此归一。
四、pi-agent-core:Agent 运行时与工具调用循环
packages/agent 是全仓库的"发动机"。值得先澄清一点:src/harness/agent-harness.ts 里的 AgentHarness 类其实是接口占位(prompt/steer/compact 全部抛 HarnessNotImplemented),真正的循环在包根目录的两个文件里:
agent-loop.ts:底层无状态 agent loop(纯函数式,输入 context + config,输出事件流);agent.ts:有状态的Agent封装(持有 transcript、事件订阅、生命周期)。
4.1 双层工具调用循环
核心在 agent-loop.ts 的 runLoop()(L155-275)。它是双层 while 循环——内层处理工具调用与中途插入的 steering 消息,外层承接 follow-up 消息:
async function runLoop(initialContext, newMessages, initialConfig, signal, emit, streamFunction) {
let currentContext = initialContext;
// 开局检查用户是否在等待期间输入了 steering 消息
let pendingMessages = (await config.getSteeringMessages?.()) || [];
// 外层:follow-up 队列可让 agent 在"本该停止"后继续
while (true) {
let hasMoreToolCalls = true;
// 内层:处理 tool call 与 steering 消息
while (hasMoreToolCalls || pendingMessages.length > 0) {
// 1. 把 pending(steering)消息注入 context
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
currentContext.messages.push(message);
newMessages.push(message);
}
pendingMessages = [];
}
// 2. 调用 LLM(AgentMessage[] → Message[] 的转换边界)
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") { /* 终止 */ }
// 3. 解析 tool call 并执行
const toolCalls = message.content.filter((c) => c.type === "toolCall");
if (toolCalls.length > 0) {
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit) // 截断 → 全部判失败
: await executeToolCalls(currentContext, message, config, signal, emit);
hasMoreToolCalls = !executedToolBatch.terminate;
// 4. tool_result 回灌 context,供下一轮 LLM 调用读取
for (const result of executedToolBatch.messages) {
currentContext.messages.push(result);
newMessages.push(result);
}
}
// 5. 每轮结束询问:是否该停了?
if (await config.shouldStopAfterTurn?.(...)) { /* agent_end */ }
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
// 外层:agent 要停了,但还有 follow-up 排队?
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
pendingMessages = followUpMessages;
continue;
}
break;
}
}
看图重点:中间蓝色主节点是"LLM 流式调用",四周六个步骤按 ① 注入 → ② 调用 → ③ 解析 → ④ 截断防御 → ⑤ 执行 → ⑥ 回灌 构成闭环;右侧红色虚线是异常退出路径,左侧紫色虚线是 follow-up 外层循环。
把循环的控制流视角换成事件流视角,就是下面这张时间线——每个阶段都会发射对应事件,订阅方(TUI / 遥测 / 远端 RPC)据此实时刷新界面:
看图重点:横轴是时间,9 个节点从
agent_start走到agent_end;下方四个阶段色带(准备 / 生成 / 执行 / 收尾)与节点颜色一一对应。注意第 4 节点assistant stream会发射 N 次message_update——这正是 TUI 能"打字机式"刷新的直接原因。
这个循环里有几个很见功力的设计决策:
- steering 与 follow-up 是两个不同阶段:steering 是"本回合进行中用户插话",立刻注入;follow-up 是"agent 本该结束,但队列里还有新任务",由外层循环承接。这使得多轮交互可以"无缝续命"。
stopReason == "length"的截断防御:failToolCallsFromTruncatedMessage(L381-406)对每个工具调用直接回灌错误结果,要求模型重新发出完整参数:
async function failToolCallsFromTruncatedMessage(toolCalls, emit) {
const messages: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
// ...
const finalized = {
toolCall,
result: createErrorToolResult(
`Tool call "${toolCall.name}" was not executed: the response hit the output token limit, ` +
`so its arguments may be truncated. Re-issue the tool call with complete arguments.`,
),
isError: true,
};
// 回灌一条 ToolResultMessage,模型看到后会自行纠正
messages.push(createToolResultMessage(finalized));
}
return { messages, terminate: false };
}
- 循环是事件流驱动的:整个
runLoop不直接返回结果,而是通过emit逐个派发agent_start / turn_start / message_start / message_update / tool_execution_start / tool_execution_end / agent_end等事件。UI、日志、遥测、甚至远程 RPC 都可以订阅同一份事件流——这是"运行时与展示彻底解耦"的根基。
4.2 LLM 调用边界:streamAssistantResponse
streamAssistantResponse(L281-372)是 Agent 消息模型与 LLM 消息模型之间唯一的转换点:
// AgentMessage[] → Message[](可插入 transformContext 钩子做上下文压缩/改写)
const llmMessages = await config.convertToLlm(messages);
const llmContext: Context = { systemPrompt, messages: llmMessages, tools };
// 动态解析 API Key(重要:支持过期 token 刷新)
const resolvedApiKey =
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
const response = await streamFunction(config.model, llmContext, { ...config, apiKey: resolvedApiKey, signal });
let partialMessage: AssistantMessage | null = null;
for await (const event of response) {
switch (event.type) {
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage); // 先把"空"assistant 消息占位
break;
case "text_delta": case "thinking_delta": case "toolcall_delta":
// 每次 delta 就地更新最后一条消息并 emit message_update
break;
case "done": case "error":
// 拿到最终消息,替换占位,emit message_end
break;
}
}
流式事件被逐条转发为 message_start / message_update,TUI 因此能"打字机式"渲染思考与正文;transformContext 钩子则留给上层做上下文压缩(compaction)——Pi 的 packages/agent/src/harness/compaction/ 里就实现了完整的压缩策略。
4.3 工具执行:顺序 / 并行 / 钩子
executeToolCalls(L411-426)决定执行策略:
const hasSequentialToolCall = toolCalls.some(
(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(...);
}
return executeToolCallsParallel(...);
- 并行是默认:多个 tool call 用
Promise.all并发执行,但结果仍按原始顺序回灌(先finalizedCalls收集、最后统一createToolResultMessage); - 单工具可声明
executionMode: "sequential":比如write多个文件时希望严格按序落盘; - 整批可终止:当
result.terminate === true时shouldTerminateToolBatch返回 true,循环结束——工具可以主动宣告"我完成了,不用继续"。
每个工具调用还包裹了 beforeToolCall / afterToolCall 钩子(prepareToolCall L600-668、finalizeExecutedToolCall L713-758):前者可拦截(返回 { block: true, reason } 即拒绝执行)、后者可改写结果(补 usage、追加内容)。权限系统、输出护栏(output-guard.ts)都挂在这两个钩子上。
五、pi-coding-agent:编码 Agent CLI
pi-coding-agent 是顶层应用,把 pi-ai + pi-agent-core + pi-tui 组装成可用的产品。它的 src/core/ 下有 70+ 个文件,这里挑最有代表性的三块讲。
5.1 内置工具与 edit 的 diff 匹配算法
默认给模型四个工具:read(读文件)、write(写文件)、edit(精确替换)、bash(执行命令),外加 grep / find / ls 等只读工具(src/core/tools/)。
edit 工具的设计很值得展开。它要求模型给出 path + edits[],每个 edit 是 oldText / newText 精确替换(src/core/tools/edit.ts L33-53):
const replaceEditSchema = Type.Object({
oldText: Type.String({
description: "Exact text for one targeted replacement. It must be unique in the original file "
+ "and must not overlap with any other edits[].oldText in the same call.",
}),
newText: Type.String({ description: "Replacement text for this targeted edit." }),
});
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
edits: Type.Array(replaceEditSchema, {
description: "One or more targeted replacements. Each edit is matched against the original file, "
+ "not incrementally. ...",
}),
});
配套的 edit-diff.ts 实现了一套模糊匹配归一化,让"模型写的 oldText 和磁盘上实际内容"即使有标点/引号/空白差异也能对上(L33-54):
export function normalizeForFuzzyMatch(text: string): string {
return (
text
.normalize("NFKC")
// 每行去掉行尾空白
.split("\n").map((line) => line.trimEnd()).join("\n")
// 弯引号 → 直引号
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
// 各种连字符 → 半角减号
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
// 各种特殊空格 → 普通空格
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
);
}
再配合"行级 span 定位 + 按变更范围回写原始行"的策略,edit 能精确到"这次改动影响第几行到第几行",为 TUI 的 diff 预览、IDE 跳转(firstChangedLine)提供数据。
5.2 会话管理
src/core/session-manager.ts + packages/agent/src/harness/session/ 构成了会话体系:
- 传输格式为 JSONL(
jsonl.ts),每行一条消息事件,天然可追加、可断点续传; - 多会话并存:
~/.pi/agent/下按项目组织会话文件,pi启动时可session-picker选择历史会话继续; - 持久化后端可插拔:默认
node:sqlite(packages/session-backends/sqlite-node/,附.sqlschema),memory后端用于测试,search.ts提供会话内全文检索; - compaction 钩子:上下文超限时按
compaction.md的策略压缩历史(摘要化 + 截断),把"无限对话"变成可行。
5.3 扩展系统:Pi 的灵魂
examples/extensions/ 下躺着 60+ 个示例扩展(自动提交、权限门禁、Git 检查点、甚至终端小游戏 space-invaders.ts)。一个最小自定义工具只有十几行(examples/extensions/hello.ts):
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
const helloTool = defineTool({
name: "hello",
label: "Hello",
description: "A simple greeting tool",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
};
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(helloTool);
}
扩展系统(src/core/extensions/)的能力边界包括:注册工具(registerTool)、挂接事件总线(event-bus.ts)、拦截工具调用(permission-gate.ts、tool-override.ts)、自定义渲染器(message-renderer.ts)、注入系统提示(system-prompt-header.ts)等。理论上几乎整个 agent 的可见行为都可以被扩展改写——这正是 README 自述"self extensible coding agent"的含义。
5.4 权限与安全边界
Pi 的默认策略是不加内置权限系统:README.md 明确写了"默认以启动它的用户权限运行"。相应的安全诉求被转移到两个层面:
- 工程层:
project-trust.ts(项目信任模型)、output-guard.ts(输出护栏)、protected-paths.ts(保护路径)等扩展提供可选防护; - 部署层:需要强隔离时用沙箱(见第八节)。
containerization.md提供了三档选择,从"仅工具进微 VM"到"整个进程进策略沙箱"。
这一决策值得借鉴:把安全默认值设为"透明",把强隔离做成可插拔选项,而不是在核心运行时里硬编码一套可能阻碍高级用法的策略。
六、pi-tui:差分渲染终端 UI
pi-tui 是一个独立可复用的终端 UI 库(marked 负责 markdown 渲染,native/ 下有 Darwin / Win32 的原生键盘与终端模块)。核心机制是差分渲染(src/tui-main-screen.ts):
// 差分渲染只能触及上次真正可见的区域。
// 如果首个变化行位于上次视口上方,只能全量重绘。
if (firstChanged < prevViewportTop) {
fullRender(true);
return;
}
// 从首个变化行渲染到末尾,整个更新包在"同步输出"中
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
// ... 对每个变化行:光标定位 + 清行 + 写入新内容
看图重点:左右两个缓冲逐行比较,只有变化的行(本例第 4 行)被翻译成 ANSI 序列重写;其余行原样保留,整个更新包用
\x1b[?2026h ... \x1b[?2026l(同步输出)包住,防止终端撕裂。
几个工程细节:
- 视口(viewport)追踪:滚动区域内的行号和硬件光标行号被分开维护,
computeLineDiff只计算"目标行 - 当前屏幕行"的最小光标位移; - 全量重绘的触发条件被刻意收紧:终端宽度变化(换行重排)必须全量;高度变化在 Termux(软键盘弹出/收起)这类环境会跳过全量重绘,避免每次弹键盘都重放整段历史;
- Kitty 图像协议:
terminal-image.ts支持在终端里渲染图片,deleteChangedKittyImages负责清理被覆盖的图块。
七、协议层:CBOR + 帧封装(pi-protocol / client / server)
pi-protocol 是一个传输中立的 RPC 基础层,用于远端会话(pi-server 实验性服务端 + pi-client 客户端)。它把消息编码为 CBOR,外面套一层4 字节大端长度前缀帧(src/framing.ts):
const FRAME_HEADER_LENGTH = 4;
/** 在 payload 前加上无符号 32 位大端字节长度 */
export function encodeFrame(payload: Uint8Array): Uint8Array {
if (payload.byteLength > 0xffff_ffff) throw new RangeError("...");
const frame = new Uint8Array(FRAME_HEADER_LENGTH + payload.byteLength);
const length = payload.byteLength;
frame[0] = length >>> 24; frame[1] = length >>> 16;
frame[2] = length >>> 8; frame[3] = length;
frame.set(payload, FRAME_HEADER_LENGTH);
return frame;
}
FrameDecoder 是增量式的:任意字节块 push() 进去,吐出完整帧,单帧默认上限 16MB,超限即抛 FrameError。所有 schema 由 TypeBox 定义(schemas.ts),编解码、校验、传输格式三位一体。这套栈目前支撑的是"跨进程/跨机器复用同一套 Agent 会话"的扩展方向。
八、部署与使用方式
8.1 安装与认证
Pi 通过 npm 分发,最快上手:
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
cd /path/to/project && pi
认证有两种入口:
# 订阅类:Claude Pro/Max、ChatGPT Plus/Pro、GitHub Copilot
/login
# API Key 类:环境变量或写入 ~/.pi/agent/auth.json
export ANTHROPIC_API_KEY=sk-ant-...
启动后即进入 TUI 交互模式。Pi 默认加载 AGENTS.md(从父目录向上找,AGENTS.override.md 可整体覆盖),用它声明项目级约束——这比把规则写死在系统提示里更符合"仓库即规范"的工程习惯。
8.2 沙箱化三模式
需要隔离时,containerization.md 给出三条路径:
| 模式 | 隔离范围 | 适用场景 |
|---|---|---|
| Gondolin 扩展 | 内置工具与 ! 命令进本地 Linux 微 VM |
主机保留认证、工具进 VM,改动经 /workspace 写回宿主机 |
| Plain Docker | 整个 pi 进程进容器 |
最简单的本地隔离(API Key 会进容器) |
| OpenShell | 整个进程进策略沙箱 | 需要文件/进程/网络/凭据全维度策略控制,支持本地或远端 Kubernetes 网关 |
8.3 构建与发布链路
- 开发:
npm run build(会先刷新模型数据)、npm run check(lint + 类型 + 依赖锁定检查)、./test.sh(跳过依赖 LLM 的用例); - standalone 二进制:
packages/coding-agent的build:binary脚本用bun build --compile产出单文件可执行程序,并拷贝主题、资源、WASM 等运行资产; - 供应链:发布前
npm run prepublishOnly会 clean + build + 生成 shrinkwrap,npm 用户装的包自带npm-shrinkwrap.json。
九、设计模式总结与启发
| # | 模式 | 落地位置 | 一句话价值 |
|---|---|---|---|
| 1 | Provider 适配器 + 工厂 | pi-ai models.ts |
40+ 厂商 API 收敛为统一流式接口,模型按 model.api 自动分派 |
| 2 | partial-json 增量解析 | pi-ai 各 provider |
流式 tool call 参数边收边解析,scratch 缓冲用完即删 |
| 3 | 事件流驱动的双层循环 | agent-core agent-loop.ts |
UI / 遥测 / RPC 订阅同一份事件流,运行时与展示解耦 |
| 4 | 钩子式工具执行 | agent-loop.ts before/afterToolCall |
权限、护栏、改写结果都挂在钩子上,不污染核心循环 |
| 5 | 差分渲染 TUI | pi-tui tui-main-screen.ts |
只重写变化行 + 同步输出防撕裂,整屏重绘被刻意收紧 |
| 6 | 扩展系统作为一等公民 | pi-coding-agent extensions |
defineTool 十几行就能注入能力,Agent 可自扩展 |
如果把这篇拆解浓缩成一句话:Pi 不是"又一个 Claude Code 平替",而是一套把 Agent 主链路拆成可复用积木的工程样板——模型抽象、运行时循环、终端渲染、进程协议各司其职,中间全部用事件与钩子解耦。对想自研编码 Agent 或深度定制 AI 工作流的开发者来说,它的源码就是一份高质量的参考实现。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付