跳到主要内容

pi-coding-agent:工具集、自扩展与 CLI 模式

30 秒导读: 前面几章讲的 pi-ai(统一 LLM 层)、pi-agent-core(agent 循环)、Harness(会话树/系统提示/Skills)都是通用的——它们不知道"编码"是什么。本章讲的 pi-coding-agent 就是把这些通用件焊成一个真正能改代码的命令行工具的那一层:它给模型装上"手脚"(读文件、跑 shell、精确改文件),提供一个能在运行时用 TypeScript 长出新能力的自扩展系统,并让同一个 agent 能被(终端 UI)和程序(RPC/SDK)两种方式驱动。

本章聚焦"特化"与"扩展机制"。纯 agent 循环见 02-agent-loop.md,harness/系统提示/压缩见 03-harness-context.md,终端渲染库见 05-tui.md


1. 这是什么(零基础也能懂)

一句话定义: pi-coding-agent 是 pi 这个包(@earendil-works/pi-coding-agent),它把通用 agent 变成一个编码 agent——一个住在你终端里、能读你项目、能跑命令、能精确改文件、还能被你自己写插件魔改的 AI 助手。

它给通用 agent 补了三样东西:

补的东西白话本章第几节
手脚(内置工具)让模型能真的 read/bash/edit/write/grep/find/ls§3
自扩展(可编程性)让你用一个 TS 文件在运行时注册新工具/命令/键位/模型 provider§4(重点)
三种驱动方式同一个 agent,人用 TUI 聊、程序用 RPC 调、脚本用 -p 一次性跑§6

给谁用: 想在终端里让 AI 帮忙改一个真实代码库的工程师;以及想把 pi 当库嵌进自己产品、或给它定制行为的开发者。

用起来什么样(最小真实示例):

# 交互式:开一个终端 UI,像聊天一样让它改代码
pi

# print / 非交互:一次性跑完就退出,适合脚本和 CI
pi -p "把 src/ 里所有 var 换成 const,跑一遍测试"

# RPC:作为子进程被别的程序驱动(stdin 发命令,stdout 收事件)
pi --mode rpc

作为嵌入也只要一个函数(packages/coding-agent/src/core/sdk.ts:166 createAgentSession):

// 示意,非源码
import { createAgentSession } from "@earendil-works/pi-coding-agent";

// 默认就带上 read/bash/edit/write 四个内置工具
const { session } = await createAgentSession({
tools: ["read", "bash"], // 只开这两个
customTools: [myDeployTool], // 再塞一个你自己的工具
});
await session.prompt("看一眼 README 然后总结这个项目");

一句话直觉: 把通用 agent 想成一台只有大脑、没有身体的机器人pi-coding-agent 给它装上了标准的手脚(文件/shell 工具),又留了一排外接口(扩展系统),你可以随时插上自制的义肢;最后它还有三种遥控器(TUI / print / RPC)。


2. 顶层全景(它大概怎么转)

先看这一层各部件怎么摆放。怎么读这张图: 上半是"能力供给"(工具 + 扩展),中间 AgentSession 是总装配台,下半是三种"驱动壳"。数据从下往上:某个 mode 收到用户/程序的输入 → 交给 AgentSession → 它调 pi-agent-core 的循环 → 循环调工具 → 工具结果回流。

能力供给层
┌──────────────────────┐ ┌───────────────────────────┐
│ 内置工具 (core/tools) │ │ 自扩展系统 (core/extensions)│
│ read bash edit write │ │ loader(jiti+虚拟模块) │
│ grep find ls │ │ runner(事件/上下文) │
└──────────┬───────────┘ └──────────────┬────────────┘
│ ToolDefinition │ registerTool/命令/键位/flag/provider
└───────────────┬────────────────┘

┌─────────────────────────┐
│ AgentSession │ 总装配台
│ · 工具注册表 + 活跃工具 │
│ · 系统提示(随工具重建) │ → 调 pi-agent-core 的 Agent 循环
│ · beforeToolCall 钩子 │ → 用 pi-ai 说话
│ · 模型/信任/会话服务 │
└───────────┬─────────────┘
│ 同一个 session,三种壳
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
交互式 TUI print / 非交互 RPC / SDK
(人来聊) (pi -p,跑完退出) (程序 stdin/stdout 驱动)
modes/interactive modes/print-mode modes/rpc

部件一句话职责:

部件干什么在哪
内置工具7 个文件/shell 工具的定义+执行+渲染packages/coding-agent/src/core/tools/
ToolDefinition一个工具的完整契约(schema+execute+render)core/extensions/types.ts:435
扩展 loader用 jiti 把 TS 扩展动态编译加载core/extensions/loader.ts
扩展 runner持有已加载扩展,分发生命周期事件、造 ExtensionContextcore/extensions/runner.ts
AgentSession总装配台:建工具注册表、拼系统提示、装 agent-core 钩子core/agent-session.ts:266
ModelRegistry / 信任模型/API key 解析、项目信任门禁core/model-registry.tscore/trust-manager.ts
三种 mode把 session 接到人/程序src/modes/
orchestrator(实验)监督多个 pi RPC 子进程packages/orchestrator/(§7)

主线走一遍(高层): 启动时 mode 造出一个 AgentSession → session 把内置工具定义扩展注册的工具合并成一张注册表,再据此拼系统提示 → 用户发一句话 → session 调 agent-core 的循环 → 模型要调 edit → 循环执行 edit 工具的 execute() → 结果回流、渲染 → 循环继续,直到模型不再要工具。


3. 内置工具集:从定义到执行到渲染

这节讲清一个工具的完整链路:它是怎么被定义、被 LLM 调用、执行、再渲染回终端的。这是"手脚"这一层。

3.1 一个工具的三段式契约

pi 里每个工具都是一个 ToolDefinition(core/extensions/types.ts:435)。它同时服务三个不同读者,所以有三段:

给谁看字段
给 LLM模型决定要不要调、怎么调namedescriptionparameters(TypeBox schema)、promptSnippetpromptGuidelines
执行运行时真正干活execute(toolCallId, params, signal, onUpdate, ctx)
给人看TUI 里怎么画这次调用/结果renderCallrenderResult

参数 schema 用 TypeBox(Type.Object({...}))声明,既是运行时校验器、又能推出 TS 静态类型。看 edit 的 schema(core/tools/edit.ts:44 editSchema):一个 path 加一个 edits[] 数组,每项 {oldText, newText},description 字段是直接写给模型看的用法说明

工具定义完还要包一层才能进 agent 循环。wrapToolDefinition(core/tools/tool-definition-wrapper.ts:5)把富定义削成 agent-core 认识的最小 AgentTool——只留 name/description/parameters/execute,把 renderCall/renderResult 这些 UI 细节留在外面:

// 示意,非源码 —— wrapper 的核心就是"降维"
return {
name, label, description, parameters,
// 执行时把 runner 造的 ExtensionContext 注进去(ctxFactory)
execute: (id, params, signal, onUpdate) =>
definition.execute(id, params, signal, onUpdate, ctxFactory?.()),
};

为什么要分这两层? 因为 agent-core 只关心"怎么调工具",不关心"终端里长啥样"。渲染是 TUI 的事(见 05-tui.md)。这个切分让内置工具和扩展工具走完全一样的通路。

3.2 内置的七个工具

工具干什么关键实现点
read读文件(文本+图片)支持 offset/limit;超限截头并提示"用 offset 续读";图片走附件
bash跑 shell 命令流式输出、可超时、进程树 kill、输出截尾存临时文件
edit精确文本替换多处 disjoint 编辑一次做完;模糊匹配容错;写前串行化
write建/覆盖文件自动建父目录;流式语法高亮预览
grep搜内容底层调 ripgrep --json,尊重 .gitignore
find按 glob 找文件同样走 ripgrep,尊重 .gitignore
ls列目录字母序、目录带 /、含 dotfile

这七个由 core/tools/index.ts 统一组装。注意几组预设集合(index.ts:138 起):createCodingToolDefinitions = read/bash/edit/write(默认能改代码的集合),createReadOnlyToolDefinitions = read/grep/find/ls(只读)。SDK 默认活跃工具就是前四个(sdk.ts:244 defaultActiveToolNames)。

3.3 重点走读:edit 工具怎么"精确落到文件上"

edit 是这层工程含量最高的一支,因为它要解决一个反复出现的难题:模型给的 oldText 几乎从不和文件里的字节一字不差(智能引号、破折号、行尾空格、CRLF、BOM……),但你又必须唯一、精确地替换,否则会改错地方。

它的做法是先试精确、再退模糊,匹配到了才动手。核心在 core/tools/edit-diff.ts:206 fuzzyFindText:

oldText 在文件里找不到精确匹配?
│ 是

归一化(normalizeForFuzzyMatch):
· NFKC 归一
· 每行去尾部空白
· 智能引号 → ' "
· 各种破折号 → -
· 各种特殊空格 → 普通空格


在"归一化后的文件"里再找一次

├─ 找到 → 用归一化坐标做替换,再把改动"贴回"原文件
│ (applyReplacementsPreservingUnchangedLines,只重写被碰到的行)
└─ 没找到 → 报"找不到"错误

真实实现的护栏很密(edit-diff.ts:304 applyEditsToNormalizedContent):

  • 唯一性检查:countOccurrences 若 >1,报"有 N 处,请加上下文让它唯一"(edit-diff.ts:268)。
  • 重叠检查:多个 edit 排序后若区间相交,报"合并成一个 edit"(edit-diff.ts:349)。
  • 空改检查:替换后内容没变就报错,避免静默无操作(edit-diff.ts:361)。
  • 行尾/ BOM 保真:先 stripBom + normalizeToLF 匹配,写回时 restoreLineEndings 还原 CRLF、再补回 BOM(edit.ts:340-347)。

有一个兼容性巧思edit.ts:94 prepareEditArguments:有些模型(注释里点名 Opus 4.6、GLM-5.1)会把 edits 数组当成 JSON 字符串发过来,或者用老的顶层 oldText/newTextprepareArguments 这个钩子在 schema 校验之前先把这些形状掰正——这是"迁就现实里模型的坏习惯"的典型容错。

3.4 输出为什么不会撑爆上下文:截断 + 累加器

工具输出可能巨大(bash 跑个 build、read 读个大文件)。pi 用两个限额谁先到谁生效来截(core/tools/truncate.ts:11):默认 2000 行50KB

方向还不一样:

  • readtruncateHead(留开头,你要看文件前面)。
  • bashtruncateTail(留结尾,你要看错误和最终结果)。

bash 的流式输出靠 OutputAccumulator(core/tools/output-accumulator.ts:35)做到有界内存:它用流式 UTF-8 解码器边收边算,只在内存里保留一段"尾巴",一旦超限就开一个临时文件把全量落盘,并在给模型的文本里附上 Full output: /tmp/pi-bash-xxx.log 的指引(bash.ts:368)。这样模型看到截断版、又知道去哪拿全量。

3.5 并发安全:同文件写操作串行化

editwrite 都可能被并行触发(模型一轮里同时改两个文件)。为避免同一文件被两个写操作踩踏,它们都包在 withFileMutationQueue(core/tools/file-mutation-queue.ts:32)里:

  • 同一文件的变更排队串行;不同文件照样并行。
  • key 用 realpath 解析(file-mutation-queue.ts:16),这样 ./a.ts 和符号链接指向同一真实文件时也认得出是同一个,不会漏锁。

一个容易忽略的正确性细节:edit 的 abort 不从事件监听器里 reject,而是每次 await 后检查 signal.aborted(edit.ts:317 throwIfAborted)。注释解释了原因——从监听器 reject 会在文件操作还没落定时就释放队列锁,导致下一个排队者提前进场。这是"锁必须锁到操作真正结束"的教科书式处理。


4.【重点】自扩展系统:让 agent 在运行时长出新能力

这是 pi 的核心卖点。前面的内置工具是"出厂配置",而扩展系统让你不改 pi 源码、不重新编译,只写一个 TypeScript 文件,就能给 agent 注册新工具、新斜杠命令、新键位、新 CLI flag、甚至新的模型 provider,还能挂各种生命周期钩子。

4.1 一个扩展长什么样

扩展就是一个默认导出工厂函数的模块,拿到一个 pi: ExtensionAPI 对象,在里面注册东西:

// 示意,非源码 —— 一个最小扩展
import { Type } from "typebox";
export default (pi) => {
// 注册一个 LLM 能调的新工具
pi.registerTool({
name: "deploy",
label: "deploy",
description: "把当前分支部署到 staging",
parameters: Type.Object({ env: Type.String() }),
async execute(id, { env }, signal, onUpdate, ctx) {
const r = await ctx /* 用 ctx.exec 等能力干活 */;
return { content: [{ type: "text", text: `deployed to ${env}` }] };
},
});

// 注册斜杠命令、键位、CLI flag
pi.registerCommand("standup", { handler: async (args, ctx) => { /* ... */ } });
pi.registerShortcut("ctrl+g", { handler: (ctx) => { /* ... */ } });
pi.registerFlag("verbose", { type: "boolean", default: false });

// 挂生命周期钩子
pi.on("tool_call", (event, ctx) => {
if (event.toolName === "bash" && /rm -rf/.test(event.input.command)) {
return { block: true, reason: "危险命令,已拦截" }; // 能拦!
}
});
};

ExtensionAPI 的完整能力面在 core/extensions/types.ts:1128。它是这套系统的"公开 API 契约",分几大块:事件订阅 on(...)、工具注册 registerTool、命令/键位/flag 注册、消息渲染器、以及一批运行时动作(sendMessage/setModel/setActiveTools/registerProvider…)。

4.2 loader:用 jiti 动态加载 TypeScript

扩展是 .ts 文件,而 pi 常以编译好的 Bun 单文件二进制分发——问题来了:运行时怎么加载并执行一段从没被编译进二进制的 TS?

答案是 jiti(一个即时 TS/ESM 加载器)。core/extensions/loader.ts:382 每次为一个扩展新建 jiti 实例并 jiti.import()。难点是扩展里 import "@earendil-works/pi-tui" 这类对 pi 自身包的引用——二进制里没有 node_modules。pi 用两条路解决(loader.ts:44 VIRTUAL_MODULES):

运行形态机制效果
Bun 二进制virtualModules + tryNative:false静态 import 进来的 bundled 包(pi-ai/pi-tui/pi-agent-core/typebox…)当"虚拟模块"喂给扩展,jiti 全权接管所有 import,不碰文件系统
Node / 开发态alias把包名解析到 workspace 里的 dist/node_modules

关键点在 loader.ts:16-25 的注释:那些 import * as _bundledPiTui ... 必须是静态 import,这样 Bun 打包时才会把它们塞进二进制;然后 VIRTUAL_MODULES 再把这些已在内存里的模块对象注入扩展的沙箱。这解决了"编译型二进制却要动态执行第三方 TS 并共享同一份运行时对象"的根本矛盾——扩展拿到的 pi-tui 和主程序用的是同一个实例,不会出现两份互不认识的类型。

发现规则(loader.ts:606 discoverExtensionsInDir,一层不递归):

  1. 项目级 cwd/.pi/extensions/
  2. 全局 ~/.pi/agent/extensions/
  3. extensions/*.ts 直接文件;或子目录带 index.ts;或子目录带 package.jsonpi.extensions 声明

4.3 runner:注册与运行时的两阶段绑定

加载扩展时有个先有鸡还是先有蛋的问题:扩展工厂里可能调 pi.sendMessage(),但那时 AgentSession 还没建好。pi 用两阶段化解:

阶段一:加载期(createExtensionRuntime, loader.ts:159)
动作方法全是"抛错桩"(notInitialized):
sendMessage/setModel/... 现在调 = 报错
但 registerTool/registerCommand/on(...) 可以正常写入 extension 对象


阶段二:绑定期(runner.bindCore, runner.ts:307)
AgentSession 建好后,把真实动作实现拷进共享 runtime
→ 之前的抛错桩被替换成真函数
→ 加载期排队的 provider 注册也在此刻 flush

registerProvider 就是这个模式的好例子:加载期调用只是把配置塞进 pendingProviderRegistrations 队列(loader.ts:196);等 bindCore 时统一 flush 进 ModelRegistry(runner.ts:344);此后再调则立即生效、无需 /reload(runner.ts:365)。

runner 还负责ExtensionContext(runner.ts:617 createContext),这就是每个事件钩子和工具 execute 拿到的那个 ctx。它用 getter 而非快照(get model() / get ui()…),这样 bindCore 之后的变化能实时反映;并且每次访问都 assertActive()——session 被替换/reload 后,旧 ctx 会被标记 stale 并抛错(runner.ts:519),防止扩展抓着过期上下文乱写。

4.4 生命周期事件:扩展的"插入点"

ExtensionAPI.on(...) 能订阅一长串事件,覆盖 agent 运行的每个关节。挑几个有代表性的(全表见 types.ts:993 ExtensionEvent):

事件时机能干什么
project_trust决定是否信任项目目录自定义信任策略(见 §5.3)
before_agent_start用户发 prompt 后、循环前改系统提示、附加消息
context每次 LLM 调用前改要发给模型的 messages
tool_call某工具执行前拦截/改参数(mutate event.input)
tool_result某工具执行后改结果内容/isError
session_before_compact压缩前取消或自定义压缩
message_end一条消息定稿替换消息(须同 role)

tool_call 的拦截能力接得很直接:AgentSessioncore/agent-session.ts:415 _installAgentToolHooks 里把 agent-core 的 beforeToolCall 钩子接到 runner.emitToolCall,任一扩展返回 {block:true}中止该工具执行(runner.ts:862)。这让"命令白名单""危险操作二次确认"这类策略成为纯扩展就能实现的东西。

事件分发遵循几条一致规则(看 runner.tsemitXxx):多个扩展按加载顺序依次跑;某扩展抛错不影响别的扩展(emitError 记下继续);像 context/before_provider_request 这种变换型事件,前一个扩展的输出是后一个的输入,形成链式管道

4.5 UI 抽象:同一个扩展在三种模式下都能跑

扩展可能想弹个选择框(ctx.ui.select(...))。但 TUI 有真终端、print 模式没有、RPC 模式的 UI 在另一个进程里。pi 用 ExtensionUIContext 接口(types.ts:124)把 UI 抽象成方法,每种 mode 提供自己的实现:

  • TUI:真画一个覆盖层组件。
  • print:用 noOpUIContext(runner.ts:229),select 直接返回 undefined。
  • RPC:把请求序列化成 extension_ui_request 事件发给宿主(rpc-types.ts:230),宿主答复再回传。

于是同一份扩展代码在三种壳里都能跑,只是 UI 能力有强弱。


5. 会话运行时接线:把零件焊成一台机器

前面是"零件",这节讲 AgentSession 怎么把它们焊起来,以及围绕它的几个服务。

5.1 AgentSession:总装配台

AgentSession(core/agent-session.ts:266)是这层的中枢。它持有一个 agent-core 的 Agent,并维护几张关键的表:

内部状态作用
_baseToolDefinitions内置工具的定义
_customToolsSDK 传入的自定义工具
_toolRegistry合并后可用的全部工具(包在 AgentTool 里)
_toolDefinitions富定义(含 promptSnippet/guidelines/来源)

工具注册表怎么建(agent-session.ts:2304 _refreshToolRegistry):把"内置定义 + 扩展注册的工具 + SDK 自定义工具"合并,过一遍 allowed/excluded 过滤(对应 SDK 的 tools/excludeTools/noTools),再逐个 wrapRegisteredTools 包成 AgentTool。合并时扩展/自定义工具能覆盖同名内置工具(先放内置,再 set 覆盖)——这给了扩展"替换内置行为"的口子。

活跃工具与系统提示联动是一个巧点。setActiveToolsByName(agent-session.ts:813)不仅换 agent.state.tools,还重建系统提示(_rebuildSystemPrompt,agent-session.ts:908):只有当前活跃工具的 promptSnippetpromptGuidelines 会被拼进提示。也就是说——关掉一个工具,系统提示里关于它的说明也随之消失,模型看到的能力面和实际可调集合始终一致。

5.2 分层的创建 API

创建一个可用 session 被刻意切成几层,方便嵌入方按需组合:

createAgentSession (sdk.ts:166) ← 最常用:一把梭

createAgentSessionFromServices ← 已有服务,只建 session

createAgentSessionServices (services.ts) ← 建 cwd 绑定的服务
(authStorage / settingsManager /
modelRegistry / resourceLoader)

AgentSessionRuntime (runtime.ts:74) ← 拥有当前 session + 服务,
管 /new /resume /fork /switch 的整体替换

为什么分这么细?因为 pi 支持会话树导航(见 03):切换/派生会话时,cwd 可能变,于是 cwd 绑定的服务(设置、模型、资源加载器)都要重建AgentSessionRuntime(agent-session-runtime.ts:74)就是干这个的——它 teardownCurrent(发 session_shutdown 事件、dispose 旧 session)再 apply 新 runtime(runtime.ts:167177),把"换一整个会话"做成原子操作。

5.3 信任模型:执行别人代码前先问一句

扩展系统很强,但"运行项目目录里的任意 TS"本身就是安全边界——一个恶意仓库塞个 .pi/extensions/evil.ts 就能在你机器上跑代码。pi 用项目信任门禁:

  • hasTrustRequiringProjectResources(core/project-trust.ts 引用的 trust-manager.ts:184)扫 cwd 及祖先,只有当项目里真的有需要信任的资源(.pi/settings.jsonextensionsskillsSYSTEM.md 等,列表见 trust-manager.ts:29)才触发询问。
  • resolveProjectTrusted(project-trust.ts:46)决定信/不信:先看 --trust 覆盖 → 再给扩展的 project_trust 钩子机会 → 再查已存决定 → 最后按 ask/always/never 策略,必要时弹选择框。
  • 决定持久化在 ~/.pi/agent/trust.json,用文件锁保证并发写安全(trust-manager.ts:208 ProjectTrustStore),并支持"信任父目录""仅本次会话"等粒度(trust-manager.ts:65)。

关键设计: 没有需信任资源的普通项目完全不打扰你(resolveProjectTrusted 直接返回 true);只有当项目试图给 agent 塞配置/代码时才把决定权交回用户。

5.4 ModelRegistry:模型与 API key 从哪来

ModelRegistry(core/model-registry.ts)是模型层的目录:管内置 provider、扩展注册的 provider(§4.3)、以及从设置文件加载的自定义 provider,并负责解析 API key(可以是字面量、$ENV_VAR、或 !command 取命令输出)。扩展通过 pi.registerProvider(契约见 types.ts:1337)就能接一个企业代理或自建网关,甚至带 OAuth 登录流程——这让 pi 不绑定任何单一厂商(详见 01-unified-llm.md)。


6. 三种运行模式:同一个 agent,人和程序两种驱动

同一个 AgentSession,套上不同的"壳"就得到不同的驱动方式。三种壳都做同一件事:把某种输入接到 session.prompt(),把 session 的事件接到某种输出

模式入口谁在驱动输入 → 输出
交互式 TUImodes/interactive/interactive-mode.ts键盘 → 差分渲染的终端界面
print / 非交互modes/print-mode.ts脚本-p "..." → stdout 文本或 JSON 事件流
RPC / SDKmodes/rpc/(经 rpc-entry.ts)别的程序stdin JSON 命令 → stdout JSON 响应+事件

6.1 print 模式:最能看清"接线"本质

runPrintMode(print-mode.ts:32)代码短,最能看清一个 mode 干的三件事:

  1. 绑扩展:session.bindExtensions({ mode, ... }),把这个 mode 的能力(waitForIdle/newSession/fork…)交给 runner。
  2. 订阅:session.subscribe(event => ...),JSON 模式把每个事件 JSON.stringify 打到 stdout(print-mode.ts:104)。
  3. 喂 prompt:await session.prompt(initialMessage),再依次跑后续 messages,最后(text 模式)只打最后一条 assistant 文本。

跑完即退,还注册了 SIGTERM/SIGHUP 处理确保 dispose(print-mode.ts:47)。这就是 pi -ppi --mode json 背后的全部。

6.2 RPC 模式:把 agent 变成可编程子进程

RPC 模式(rpc-entry.tsmain(["--mode","rpc", ...]))让 pi 成为一个长命子进程:宿主程序往 stdin 写 JSON 命令、从 stdout 读 JSON 响应和事件(JSONL 协议)。协议面在 modes/rpc/rpc-types.ts:

  • 命令(rpc-types.ts:20 RpcCommand):prompt/steer/follow_up/abortset_model/cycle_modelcompactfork/switch_sessionget_state/get_messages/get_tree… 基本把交互式能做的都暴露成了可编程命令。
  • 响应(rpc-types.ts:114 RpcResponse):每条带可选 id 对应请求,success 真假分明。
  • 扩展 UI 跨进程:扩展要弹框时,发 extension_ui_request(rpc-types.ts:230),宿主用 extension_ui_response 回话——这就是 §4.5 说的"UI 在另一个进程"如何工作。

同一个 agent 被人和程序两种方式驱动,靠的正是这层:交互式 TUI 和 RPC 命令最终都落到 session.prompt() / session.setModel() 等同一组方法上,只是外面包的"翻译层"不同。


7. 顺带一提:orchestrator(实验性多 agent 监督)

packages/orchestrator/ 是一个独立的实验性包,定位是"监督多个 pi 实例"——不是本章主角,只点名它在版图里的位置。

它本质是个 supervisor,把上面的 RPC 模式当作"被监督对象":

  • supervisor.ts:63 OrchestratorSupervisor 维护一张 liveInstances 表,每个条目是一个 pi RPC 子进程(rpc-process.ts 负责 spawn pi --mode rpc 并做 JSONL 桥接),并把子进程的事件转发给订阅者。
  • handler.ts 处理外部请求(spawn/list/status/stop/rpc),ipc/(client/server/protocol)是它对外的进程间协议,radius.ts 做实例存在性/发现(presence)。

一句话:它把"一个 agent"扩展成"一群受管的 agent",复用的正是 §6.2 的 RPC 协议。细节不在本章展开。


8. 巧妙之处(可借鉴的技术)

  • prepareArguments 兼容层:在 schema 校验前把模型的坏形状(JSON-字符串化的数组、老参数名)掰正(edit.ts:94),而不是让校验直接失败——迁就现实里模型的不稳定。
  • 虚拟模块共享运行时:编译型二进制里,用静态 import + VIRTUAL_MODULES 让动态加载的扩展和主程序共用同一份包实例(loader.ts:44),避免"两份 pi-tui 互不认识"。
  • 活跃工具驱动系统提示:关工具即从提示里抹掉其说明(agent-session.ts:908),让"模型看到的"和"实际可调的"永不失配。
  • 锁到操作结束再释放:文件写用 realpath 归一 key 串行化(file-mutation-queue.ts),且 abort 用轮询检查而非监听器 reject,防止提前解锁(edit.ts:317)。
  • 两阶段扩展绑定:加载期用抛错桩、绑定期换真实现,让"扩展工厂在 session 建好前就跑"成为可能(loader.ts:159 + runner.ts:307)。
  • 无扰动信任门禁:只有项目真带需信任资源才询问,普通项目零打扰(project-trust.ts:50)。

9. 边界与局限(诚实)

  • 扩展是全权限代码。扩展在主进程内执行(jiti import 后直接 factory(api),loader.ts:442),没有沙箱隔离;唯一的门禁是 §5.3 的项目信任提示。信任了就等于同意跑它的任意代码。
  • 扩展发现只下探一层(loader.ts:606 注释),复杂扩展包必须用 package.jsonpi.extensions 显式声明入口。
  • 模糊匹配有边界:edit 只归一化引号/破折号/空白类(edit-diff.ts:33),不做语义级对齐;若模型给的 oldText 在归一化后仍不唯一或找不到,直接报错要求补上下文,而猜。
  • grep/find 依赖 ripgrep:找不到 rg 且下载失败时 grep 直接失败(grep.ts:173)。
  • orchestrator 是实验性的,API/协议可能变动,本章不对其做深入保证。

10. 代码地图(导航索引)

主题文件关键符号
工具定义契约packages/coding-agent/src/core/extensions/types.tsToolDefinitiondefineTool
工具降维包装core/tools/tool-definition-wrapper.tswrapToolDefinitionwrapToolDefinitions
工具组装/预设集合core/tools/index.tscreateAllToolDefinitionscreateCodingToolDefinitionsallToolNames
edit:执行与渲染core/tools/edit.tscreateEditToolDefinitionprepareEditArguments
edit:模糊匹配与 diffcore/tools/edit-diff.tsfuzzyFindTextapplyEditsToNormalizedContentnormalizeForFuzzyMatch
bash:流式执行core/tools/bash.tscreateBashToolDefinitioncreateLocalBashOperations
输出截断core/tools/truncate.tstruncateHeadtruncateTailDEFAULT_MAX_LINES
有界内存累加器core/tools/output-accumulator.tsOutputAccumulator
同文件写串行化core/tools/file-mutation-queue.tswithFileMutationQueue
grep/find/lscore/tools/{grep,find,ls}.tscreateGrepToolDefinition
扩展 API 契约core/extensions/types.tsExtensionAPIExtensionContextExtensionEvent
扩展加载(jiti)core/extensions/loader.tsloadExtensionModuleVIRTUAL_MODULESdiscoverExtensionsInDircreateExtensionRuntime
扩展运行/事件分发core/extensions/runner.tsExtensionRunnerbindCorecreateContextemitToolCall
扩展工具包装core/extensions/wrapper.tswrapRegisteredTools
会话中枢core/agent-session.tsAgentSession_refreshToolRegistrysetActiveToolsByName_rebuildSystemPrompt_installAgentToolHooks
分层创建/服务core/sdk.tscore/agent-session-services.tscore/agent-session-runtime.tscreateAgentSessioncreateAgentSessionServicesAgentSessionRuntime
模型目录core/model-registry.tsModelRegistryregisterProvider
项目信任core/project-trust.tscore/trust-manager.tsresolveProjectTrustedhasTrustRequiringProjectResourcesProjectTrustStore
交互 TUIsrc/modes/interactive/interactive-mode.tsrunInteractiveMode
print 模式src/modes/print-mode.tsrunPrintMode
RPC 模式与协议src/rpc-entry.tssrc/modes/rpc/rpc-types.tsmodes/rpc/rpc-mode.tsRpcCommandRpcResponseRpcExtensionUIRequest
多 agent 监督(实验)packages/orchestrator/src/supervisor.tshandler.tsipc/OrchestratorSupervisor