Pi Agent Harness 源码深度技术解析:一个可自扩展编码 Agent 的架构全貌

从统一多厂商 LLM 抽象、Agent 运行时到差分渲染 TUI 与扩展生态的实现解剖

Posted by 爱折腾的工程师 on Tuesday, August 11, 2026

一、引言:为什么值得读 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 五层包依赖

如果你只想先建立心智模型,先看下面这张总览图。

Pi Agent Harness 整体架构

看图重点:自底向上共五层——基础层(telemetry / tui / protocol)不依赖任何内部包;模型抽象层 pi-ai 负责把异构厂商 API 收敛成统一接口;pi-agent-core 在其上实现与模型无关的 Agent 运行时;协议、客户端、服务端与会话后端构成中间支撑;最顶端是 pi-coding-agent 这个"应用层"。

2.1 包依赖 DAG

根目录 package.jsonbuild 脚本按依赖顺序构建各包,这本身就是一张可信的依赖图:

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,依赖关系一眼可见:

包依赖 DAG

看图重点:四层结构自上而下单向依赖,箭头指向"被依赖方"——基础层(telemetry / protocol / tui)是唯一的依赖源;coding-agent 依赖 5 个包,成为入度最大的汇聚点,但没有任意一层向下依赖形成环。

依赖方向非常克制:所有包只依赖"下方的"包,没有任何循环依赖。这种分层带来的直接好处是——想单独研究模型层,跑 npm --prefix packages/ai test 即可,不牵扯任何 Agent 逻辑。

2.2 供应链工程化

仓库把"依赖即代码"贯彻得很彻底:直接外部依赖全部锁定精确版本、.npmrc 开启 save-exactpackage-lock.json 作为依赖唯一事实来源、发布包自带 npm-shrinkwrap.json 锁死传递依赖。这些细节说明 Pi 的目标不只是"能跑的 Agent",而是"可以嵌入别人工程、可以发布给全球 npm 用户的 Agent 基础设施"。

2.3 各包体量

依赖分层之外,各包的源码规模揭示了工程重心:

各包 TypeScript 源码规模

看图重点:横向条形图按 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 核心类型体系

  • Apisrc/types.ts):已知协议集合,如 "openai-completions" | "mistral-conversations" | "openai-responses" | ... | "pi-messages"
  • ProviderId:40+ 已知 Provider(anthropicopenaigooglegithub-copilotdeepseekxaimoonshotaiqwen-*xiaomi-* …);
  • Model<TApi>id / name / api / provider / baseUrl / reasoning / input / cost / contextWindow / maxTokens / compat。其中 compat 是差异化配置的关键——它按 API 类型条件携带兼容性开关(如 supportsDeveloperRolesupportsReasoningEffortsupportsEagerToolInputStreaming),让同一模型在不同 API 协议下启用不同的能力位;
  • 统一消息模型UserMessage / AssistantMessage(内容为 TextContent | ThinkingContent | ToolCall 的数组)/ ToolResultMessage,统一 StopReasonstop | 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 对象
}

两个细节值得注意:

  1. 混合 API 分派:一个 Provider 的模型可以横跨多种协议(比如同时有 openai-responsesopenai-completions 模型),api 传一个 map 即可按 model.api 自动分派到对应实现;
  2. 静态 + 动态叠加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,
		});
	}
}

流式 tool call 参数解析

看图重点:左侧是 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.tsrunLoop()(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;
	}
}

Agent 工具调用主循环

看图重点:中间蓝色主节点是"LLM 流式调用",四周六个步骤按 ① 注入 → ② 调用 → ③ 解析 → ④ 截断防御 → ⑤ 执行 → ⑥ 回灌 构成闭环;右侧红色虚线是异常退出路径,左侧紫色虚线是 follow-up 外层循环。

把循环的控制流视角换成事件流视角,就是下面这张时间线——每个阶段都会发射对应事件,订阅方(TUI / 遥测 / 远端 RPC)据此实时刷新界面:

Agent 事件流时间线

看图重点:横轴是时间,9 个节点从 agent_start 走到 agent_end;下方四个阶段色带(准备 / 生成 / 执行 / 收尾)与节点颜色一一对应。注意第 4 节点 assistant stream 会发射 N 次 message_update——这正是 TUI 能"打字机式"刷新的直接原因。

这个循环里有几个很见功力的设计决策:

  1. steering 与 follow-up 是两个不同阶段:steering 是"本回合进行中用户插话",立刻注入;follow-up 是"agent 本该结束,但队列里还有新任务",由外层循环承接。这使得多轮交互可以"无缝续命"。
  2. 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 };
}
  1. 循环是事件流驱动的:整个 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 === trueshouldTerminateToolBatch 返回 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/ 构成了会话体系:

  • 传输格式为 JSONLjsonl.ts),每行一条消息事件,天然可追加、可断点续传;
  • 多会话并存~/.pi/agent/ 下按项目组织会话文件,pi 启动时可 session-picker 选择历史会话继续;
  • 持久化后端可插拔:默认 node:sqlitepackages/session-backends/sqlite-node/,附 .sql schema),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.tstool-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);
// ... 对每个变化行:光标定位 + 清行 + 写入新内容

TUI 差分渲染

看图重点:左右两个缓冲逐行比较,只有变化的行(本例第 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-agentbuild: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 工作流的开发者来说,它的源码就是一份高质量的参考实现。

「真诚赞赏,手留余香」

爱折腾的工程师

真诚赞赏,手留余香

使用微信扫描二维码完成支付