
Pi 这个 agent 我拆到第 7 篇了。前面几篇聊了它的任务调度、上下文窗口管理、工具调用链路还有和 opencode、codex 这类同类工具放在一起对比时的定位取舍。今天这篇聊 Extension API。老实说这是整个系列里我最想写的一篇因为前面那些能力解决的是“Pi 能不能用”而扩展系统解决的是“Pi 能不能变成你自己的”。一个没有扩展机制的 agent功能再强也是别人规定好的形状有了扩展 API它才能长成你项目里最顺手的样子。这篇文章我会从 API 的设计逻辑讲起然后带你把一个扩展从零写完跑通最后再分享几个实际踩过的坑。不管你是想给团队内部接一套自动化工作流还是单纯想给 Pi 加几个私有命令这篇都能给到可以直接抄作业的方案。1. 先搞懂设计逻辑Pi 的扩展系统到底在扩展什么1.1 Pi agent 的定位和它的“功能边界”Pi 本质上是一个跑在终端里的 AI 编程代理你给它自然语言指令它自己拆解任务、调用工具、读写代码、跑测试整个过程像是一个坐在你旁边干活儿的结对程序员。这个定位决定了它的能力边界内置的“技能”永远有限但每个团队、每个项目的诉求却无限。有的团队希望 Pi 提交代码前自动跑一遍 lint有的希望它能把项目里的 TODO 汇总成周报有的想让它对接公司内部 API这些需求如果全靠官方内置Pi 的体量和维护成本会立刻失控。所以 Pi 从一开始就把能力分成两层核心引擎负责“思考与执行”扩展负责“接入外部世界”。这个思路跟 VS Code 的插件系统、Chrome 的扩展生态是同一个逻辑——核心保持精简能力交给生态。社区里后来出现的 oh-my-pi 这类配置管理项目本质上也是把这一层再往上叠像 oh-my-zsh 之于 zsh 那样把扩展、别名、主题统一管理起来方便用户一键装配。1.2 没有扩展 API 的 agent 会遇到什么麻烦我用过不少没有开放扩展机制的 agent 工具感受最深的不是“少几个功能”而是“想改没处改”。比如想让某个 agent 在每次会话开始时自动把当前分支名和最近提交记录塞进上下文它没有事件钩子我只能手动复制粘贴想让它在调用某个内部工具前先经过一层审批它没有拦截点我只能放弃。这些需求不复杂但因为没有扩展 API全都变成了“做不到”。更麻烦的是这类工具一旦内置功能不满足需求用户只能等官方更新或者干脆换工具。而换工具的迁移成本在 agent 场景下尤其高因为你积累的 prompt 模板、工具配置、命令习惯全都要重来。扩展 API 的价值就在这里它把“等官方做”变成了“我自己做”把工具的终态从“作者定义的功能集”变成了“生态自然生长的平台”。1.3 Extension API 要解决的核心问题拆解Pi 的 Extension API 要回答的核心问题有三个扩展能在哪些地方介入扩展之间、扩展和核心之间的边界怎么划扩展的安全性怎么保证介入点决定了扩展的能力上限。Pi 在架构上把扩展可以挂载的位置划分成了几类一是命令层用户可以注册自定义斜杠命令二是工具层扩展可以注册新工具喂给 agent 的模型调用三是事件层扩展可以监听会话开始、消息接收、工具执行完成等生命周期事件四是渲染层扩展可以往终端 UI 里塞自定义输出组件。这四类覆盖了从“用户主动触发”到“事件自动触发”的全部场景我后面会逐个演示。边界问题则是通过权限模型来解决。Pi 给每个扩展一个沙箱环境扩展要访问文件系统、网络、环境变量都得在扩展清单里按需声明这一点很像移动端的权限申请机制。安全意识不是小题大做因为 agent 本身就有执行任意命令的能力如果扩展能无限制地往 agent 的工具列表里塞东西风险会非常大。2. 扩展的生命周期与核心挂载点2.1 从加载到卸载扩展生命周期拆解先建立一个整体认知一个 Pi 扩展从进入系统到被卸载会经历完整的生命周期阶段。我用自己写扩展的实际经验来解释每个阶段因为好多问题都出在生命周期没理解透。installed - loading - initialized - ready - disabledloading 阶段发生在 Pi 启动时扫描扩展目录解析扩展的清单文件校验依赖和权限声明。这里最常见的坑是清单文件格式写错导致扩展连 loaded 状态都进不去。initialized 阶段会调用扩展的 setup 函数此时扩展可以注册命令、注册工具、订阅事件但还不能立即执行具体业务逻辑因为此时会话还没真正开始。ready 表示扩展完成初始化进入可用状态。disabled 则可能是用户手动禁用也可能是扩展抛出了不可恢复的异常被系统主动隔离。理解这个顺序很重要。我见过有人想在 setup 阶段就直接发起网络请求结果因为时机不对拿不到会话上下文在这个阶段卡了半天最后才明白是生命周期的问题。2.2 四大类挂载点命令、工具、事件、渲染Pi 的扩展能力可以用一句话概括你在 agent 工作流的四个位置上都可以插一手。第一类是命令挂载这是最直观的。用户在对话里输入/your-commandPi 会把参数解析后交给扩展注册的处理函数。适合做“手动触发型”能力比如/review-last-commit、/deploy-staging。第二类是工具挂载这是最有 agent 特色的扩展点。Pi 本身有工具调用机制能让大模型决定何时调用什么工具扩展注册的新工具也会被纳入这个调度范围。这样你可以把自己写的内部 API 包装成一个工具让 agent 在合适的时候自己调用。第三类是事件挂载适合做“自动化触发型”能力监听类似“开始新会话”“一条消息处理完成”这类事件。第四类是渲染挂载适合做终端 UI 增强比如自定义输出卡片。这四类挂载点之间的关系不是互斥的一个成熟扩展往往会同时用多个。比如我写过一个代码审查扩展命令层注册/review触发审查流程工具层注册了一个get_diff工具让 agent 自行获取差异内容事件层监听session:end自动生成审查摘要渲染层则用卡片展示审查结果。2.3 权限模型与沙箱隔离Pi 默认把每个扩展放在受限沙箱里运行没有声明的能力一律不可用。在扩展清单文件里你需要显式声明权限字段。我自己常用的权限声明大概是这样的{ name: my-extension, version: 0.1.0, permissions: { fs: [read, write], network: [fetch, listen], env: [ALLOWED_KEY_PREFIX], shell: [run] } }权限粒度直接决定扩展能做什么。比如只想让扩展读取项目配置就不要给它写文件权限只想让它请求特定域名的接口网络权限里应该做域名限制。这个设计看着保守但实际用起来非常稳。有一次我在调试一个第三方扩展它一直报权限错误我顺着日志一查是它在未经声明的情况下尝试写临时目录——系统拦住了我的项目目录也因此没被写乱。3. 从零动手写出并运行你的第一个 Pi 扩展3.1 环境准备与项目结构动手之前先把环境确认一遍。你需要安装带扩展系统支持的 Pi 版本建议直接用最新稳定版。然后是 Node.js 运行时因为 Pi 的扩展 API 目前以 JavaScript/TypeScript 为主。最后准备一个测试用项目目录随便一个空目录就行主要是为了观察扩展在真实会话里的表现。初始化扩展项目只需要一条命令pi extension init hello-extension命令执行后会自动生成一个标准扩展目录hello-extension/ ├── pi-extension.json ├── src/ │ └── index.ts ├── package.json └── tsconfig.jsonpi-extension.json是扩展的元数据文件声明名称、版本、权限src/index.ts是扩展入口。把生成的目录放到 Pi 的扩展加载目录里对应平台不同位置会有差异官方文档里写得很清楚。放好之后重新启动 Pi在交互界面里输入/extensions能看到自己刚加进去的扩展说明环境已经通了。3.2 注册第一个自定义命令环境通畅之后我们一步步把扩展做成能实际干活的状态。第一步注册自定义命令。用官方脚手架生成的基础代码稍作修改即可import { defineExtension } from pi/extension-api; export default defineExtension({ name: hello-extension, version: 0.1.0, setup(ctx) { ctx.registerCommand({ name: hello, description: 向 Pi 的会话发送一条问候, async handler(args, { output }) { const name args.trim() || Pi; output(Hello, ${name}! This message comes from a Pi extension.); return { success: true }; } }); } });这段代码有几个细节值得注意。defineExtension是类型友好的包装函数能给你完整的参数提示。registerCommand接收一个对象name是触发命令名handler是回调函数。回调的第二个参数output是输出通道扩展往终端里写的所有内容都应该走这个通道这样 Pi 才能正确格式化输出。在 Pi 会话里输入/hello 张三终端会回一行Hello, 张三! ...。到这里你的第一个扩展已经跑起来了。注意命令名尽量用短横线拼接不要包含空格。Pi 在解析斜杠命令时是整词匹配的命令名里带空格会让解析结果很难预料。3.3 注册工具让 agent 自己调用你的能力自定义命令只能由用户手动触发这还不够有意思。真正让 agent 强大起来的是让模型在判断需要时主动调用你提供的能力。Pi 的registerTool就是干这个的。我写一个查询项目依赖版本的工具作为示例setup(ctx) { ctx.registerTool({ name: get_package_version, description: 读取项目 package.json 中指定依赖的版本号, parameters: { type: object, properties: { packageName: { type: string, description: 要查询的依赖包名称 } }, required: [packageName] }, async execute({ packageName }, context) { const pkg await context.fs.readJson(package.json); const version pkg.dependencies?.[packageName] || pkg.devDependencies?.[packageName]; return { packageName, version: version || not found }; } }); }工具注册和命令注册有几个关键差异。工具必须有parameters字段它是一个 JSON Schema模型会根据这个描述来决定传什么参数。description字段要写清楚工具是干什么的、什么时候应该用因为模型选择工具时很大程度上依赖这段描述的语义匹配度。execute函数是真正干活的函数它的返回值会被塞进模型下一次推理的上下文里所以一定要结构清晰。context参数里带着一堆能力入口比如context.fs、context.network、context.logger这些能力和你声明的权限一一对应。我没在权限声明里放开网络权限所以这里只能用文件读取。注册完成后在 Pi 会话里问一句看一下项目里 axios 的版本号是多少模型会自己决定调用get_package_version工具把结果组织成自然语言回复你。这个过程的调度和工具选择你完全不用插手agent 会自动完成。3.4 生命周期事件把自动化流程串起来命令和工具解决的是“按需调用”生命周期事件解决的则是“自动触发”。写过 Node.js 服务的人应该很熟悉事件订阅的思路Pi 的扩展事件机制也类似setup(ctx) { ctx.on(session:start, async ({ cwd }) { ctx.logger.info([auto] 新会话启动工作目录${cwd}); }); ctx.on(tool:beforeInvoke, async ({ toolName, input }) { if (toolName run_command input.command.includes(rm -rf)) { return { allowed: false, reason: 危险操作已被扩展拦截 }; } return { allowed: true }; }); }我特别想聊聊tool:beforeInvoke这个事件的实战价值。它本质上是一个拦截器可以在工具真正执行前介入。我团队里有个需求禁止 agent 执行含rm -rf的命令。用这个钩子实现非常干净扩展直接拒绝并把原因返回给模型模型就会自行换一种安全的方式完成任务整个过程符合预期的观察结果也都记录在案。这里要提醒一句事件回调里不要做耗时太长的操作尤其不要让事件处理器陷入阻塞型等待。因为事件处理器是在 Pi 主流程里同步执行的一个事件卡住整个 agent 的响应都会卡住。如果确实有耗时操作应该异步执行并立即返回。3.5 配置项支持不做硬编码的扩展只满足单一需求的扩展可以写死参数但一个称得上“真工具”的扩展应该支持配置。Pi 的扩展系统允许你在清单文件里定义配置项用户可以在全局配置或项目配置里覆盖。{ name: hello-extension, version: 0.1.0, config: { defaultName: { type: string, default: Pi, description: 问候语中使用的默认名称 }, enableLog: { type: boolean, default: false, description: 是否输出详细日志 } } }然后在代码里读取这些配置setup(ctx, config) { ctx.registerCommand({ name: hello, async handler(args, { output }) { const name args.trim() || config.defaultName || Pi; if (config.enableLog) { ctx.logger.info(hello 命令被调用args${args}); } output(Hello, ${name}!); } }); }配置项的价值在于让扩展在不同团队、不同项目之间复用。把配置集中管理起来之后和团队的 oh-my-pi 配置放一起一个新成员加入时拉同一份配置所有偏好直接生效非常省事。4. 把扩展做得更扎实的进阶技巧4.1 状态管理与跨命令数据共享很多场景需要多个命令或工具之间共享数据。比如一个部署扩展/deploy:init负责初始化部署参数/deploy:run负责真正执行两者得共享同一份状态。直接用模块级变量可能踩坑因为 Pi 在热更新时可能重新执行扩展代码模块级变量会被重置。官方推荐把状态挂到上下文里setup(ctx) { const state ctx.createState(deploy-state); ctx.registerCommand({ name: deploy:init, async handler(args, { state: session }) { state.set(config, JSON.parse(args)); return { success: true }; } }); ctx.registerCommand({ name: deploy:run, async handler(args, { output }) { const config state.get(config); if (!config) { output(还没有初始化部署配置请先运行 /deploy:init); return { success: false }; } output(开始部署到 ${config.target}...); // 执行部署逻辑 } }); }ctx.createState创建的状态和扩展实例绑定热更新时只要扩展实例没有被完全销毁状态就能保留。不过要记住不要把特别重要的数据只放在内存里需要持久化的内容要主动写文件或存数据库。4.2 让大模型更准确调用你的工具很多人写完工具后发现模型总是不按预期调用它或者参数传得乱七八糟。这通常不是代码问题而是工具描述写得不够好。经验是这样的description里要写清楚“这个工具是干嘛的”和“什么时候该用”参数描述要写清楚“这个参数代表什么”和“取值来自哪里”。同样一个查询用户信息的工具两种描述差距很大# 差的描述 查询用户信息。 # 好的描述 查询用户的详细资料。当用户询问某个用户的名字、邮箱、头像或所属团队时使用。 需要传用户 ID可以从会话上下文中提取也可以让用户直接提供。模型是一个概率系统你给它的描述越接近它在上文里看到的表达方式它就越容易做出正确决策。另外参数不要设计得太多太碎。能用一两个参数搞定的就不要拆成一堆参数一多模型犯错概率会显著上升。4.3 调试是门技术活日志、断点与沙箱写扩展最头疼的就是调试。我在 Pi 上做过很多次实验总结下来三层调试手段最有效。最基本的是ctx.loggerPi 会按扩展名字自动归类日志可以用环境变量控制日志级别跑起来后用一行命令过滤DEBUGpi:extension:my-extension:* pi这会把该扩展的调试日志单独拎出来看。但日志在跑 agent 这样交互频繁的场景里不够直观这时可以引出第二层断点调试。Pi 的扩展进程支持 Node.js 调试协议启动时加参数再用 IDE 连接就能打断点pi --inspect-extensions第三层是沙箱运行。准备一个空的测试目录里面放好最小可复现的项目结构然后在这个目录里启动 Pi 调用你的扩展。因为是空目录出任何问题你都能立刻辨别是扩展本身的逻辑问题还是被项目环境干扰了。这个习惯帮我排查过好多次“我在项目里好好的怎么到你这儿就崩了”的诡异问题。4.4 发布、分享与团队分发扩展写完之后可以选择分享。Pi 支持把扩展打包成单个归档文件别人可以直接安装pi extension package ./hello-extension pi extension install hello-extension-0.1.0.pi-ext团队内部使用的话更推荐直接把扩展目录纳入 Git 仓库再配合配置管理工具统一安装。把扩展包放到内部源上新成员一条命令装好所有扩展省去一堆手动拷贝的事。这里有个经验扩展版本号千万要遵循语义化版本规则。因为 Pi 在解析扩展依赖时是有版本约束的随便改版本号可能导致某个扩展在你的环境下解析失败但你自己察觉不到。发布之前跑一遍打包命令确认产物完整再分发。5. 常见问题与排查实录5.1 扩展加载了但命令不出现现象/extensions里能看到扩展但输入注册的命令提示“命令不存在”。排查下来最常见的原因是扩展注册的 scope 和当前会话不匹配。Pi 里扩展可以限定只在特定类型的会话里生效比如只对 Git 仓库内的会话生效。如果你的扩展声明了scopes: [git]而你在一个非 Git 目录里启动 Pi命令就不会注册。解决方法是查看扩展的当前状态/extension:inspect hello-extension确认 scope 匹配。我给这个加个更细节的提醒改完扩展配置后不一定立即生效。Pi 对扩展配置有缓存遇到改了配置没反应的先执行pi extension reload强制重载通常能解决一大部分“改了什么都没变化”的问题。5.2 权限申请了但依然报错现象扩展在权限声明里写好了fs: [read, write]但运行时调用写入接口还是抛权限异常。这种情况八成是权限路径的匹配问题。Pi 的文件权限是可以用 glob 约束目录范围的比如fs: [read:./src/**, write:./src/**]如果实际写入的目标路径没被任何 glob 规则覆盖即使声明了 write 也没用。遇到过一例是扩展往$HOME/.cache写临时文件权限声明里只写了项目目录的 write 规则结果系统拒绝。解决方法是把临时目录加到权限范围或者使用系统提供的高速缓存目录接口。读权限报错也一样去检查路径匹配。5.3 事件触发时机不对现象在session:start里调用工具执行命令结果偶尔成功偶尔失败。原因是这个事件触发时agent 的初始上下文可能还没完全准备好有些系统服务还没就绪。解决方案是对这种依赖系统服务的调用做“就绪检查”轮询等待目标服务可用而不是一进入事件就立刻操作。实操下来最省心的是在需要依赖系统状态的地方把逻辑改到tool:beforeInvoke去处理因为工具执行时核心链路已经完整跑起来了。这里隐含一个常见的认知误区事件名字带 start 不代表整个系统都已经 start 完成。它只代表“这个阶段开始了”至于这个阶段的资源有没有就绪要看你依赖的具体对象。摸清事件触发顺序最简单的方式是先写一个空壳扩展在每一个事件里打一条日志跑一遍典型会话把所有日志按时间排出来很快就全明白了。5.4 扩展热更新失败或状态残留现象开发时反复改代码Pi 的热更新偶尔不生效或者更新之后出现重复注册的错误。这个坑的根源在于旧扩展实例没有完全卸载。Pi 的热更新机制会先调用扩展的dispose清理函数如果扩展没正确实现资源释放旧实例还占着资源新实例注册时就冲突了。正确的做法是给扩展实现dispose把订阅的事件、定时器、网络连接都显式清理掉export default defineExtension({ name: hello-extension, setup(ctx) { const timer setInterval(() {}, 1000); ctx.on(session:end, () { /* ... */ }); return { dispose() { clearInterval(timer); // 其余清理逻辑 } }; } });特别是借用 Node.js 全局能力的扩展比如打开了子进程、建立了长连接一定要在 dispose 里释放。不然你会在日志里看到各种奇怪现象命令被触发两次、定时器跑到多个实例、内存占用节节攀升。这些都是热更新没清理干净留下的后遗症。6. 踩坑之后我对扩展 API 的一些体会把整个系列拆到第七篇我能明显感受到 Pi 这个 agent 在架构上的克制。它没有把所有功能都塞进内核而是留出了一层干净、边界清晰的扩展层。这种设计的真实价值要在实际用了一段时间之后才体会得到——你的团队工作流会被慢慢沉淀成一个可共享的扩展集合新成员加入时不需要从头教 agent 各种内部习惯给他一份扩展配置Pi 就已经变成你们团队自己的样子。从更广的视角看AI 编程 agent 这个赛道上的几个主流工具都在往插件化方向走opencode 有它的模块机制codex 也有其配置体系。Pi 的扩展 API 在介入点的完整度上做得比较彻底从命令、工具、事件到渲染都覆盖到了。工具本身好不好用是看天赋但生态能不能长起来看的绝对是扩展系统够不够开放、够不够稳。目前社区里已经有人在围绕 Pi 做各种有意思的扩展从代码审查到自动部署到周报生成我预计这个生态会像当年的编辑器插件生态一样爆炸式增长。最后给一个小经验遇到扩展行为不符合预期时不要怀疑“我是不是用错了”先去看日志。Pi 的扩展日志颗粒度非常细而且把每次拦截、每次权限拒绝都记录得清清楚楚。95% 的诡异问题都能从日志里找到直接答案剩下的 5%大部分也可以通过加日志自己解决。扩展开发这件事本质上就是把文档里的 API 变成了你自己顺手的工作流逻辑通了剩下的就是时间问题。