开源编程 Agent pi 的扩展、技能、提示词模板与包该怎么选
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
选哪个扩展位,判断依据不是”哪个功能更强”,而是三个问题:这段东西什么时候进上下文、由谁触发、能不能拦住一次真实的动作。 想清楚这三点,四个位置基本自动对号入座;想不清楚,最典型的翻车是把本该写成扩展的硬规则写进了技能,上线后才发现它只是一段”建议”,模型不听也没人管得住。
pi 是 earendil-works 开源的编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star。它的可扩展性没有做成一个大而全的插件系统,而是拆成了四个层次分明的位置:扩展(extensions)、技能(skills)、提示词模板(prompt templates)、包(packages)。
站内已经有几篇讲通用方法论的文章 —— 技能机制怎么用讲的是技能这种形态的组织方式,钩子机制讲的是在生命周期上挂拦截的思路,MCP 与扩展框架的关系讲的是协议层的分工。本篇不重复这些方法论,而是拿 pi 这一个具体项目当样本,看这些抽象概念落到一份真实代码库里长什么样、文档明确写了哪些边界。
一、四个位置各装在哪一层
先把全景摆出来。下表的仓库位置都是这个项目里实际存在的路径:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 扩展 Extensions | TypeScript 模块,订阅生命周期事件、注册工具/命令/快捷键/CLI flag,可拦截或改写工具调用 | packages/coding-agent/docs/extensions.md、packages/coding-agent/examples/extensions/ | 需要”程序性”行为:权限门、路径保护、自定义工具、改系统提示 |
| 技能 Skills | 按需加载的能力包,一个目录 + SKILL.md,可带脚本与参考文档 | packages/coding-agent/docs/skills.md | 需要模型在特定任务上按你写好的流程走 |
| 提示词模板 Prompt Templates | Markdown 片段,输入 /name 展开成完整提示词,支持位置参数 | packages/coding-agent/docs/prompt-templates.md | 你每天要重复敲的那几段话 |
| 包 Packages | 把上面三样加上主题打包,通过 npm、git 或本地路径分发 | packages/coding-agent/docs/packages.md | 要把配置发给团队或跨机器复用 |
这四个位置不是并列的四种”插件类型”,而是能力递减、触发确定性递减的一条线:扩展是代码、可以拦;技能和模板是文本、只能建议;包是容器、本身不带能力。
二、扩展:这四层里唯一能拦住动作的一层
扩展是一个导出默认工厂函数的 TypeScript 模块,函数接收 ExtensionAPI。文档里的最小形态是这样:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
}
关键在 { block: true }。在这四个位置里,只有扩展能在 tool_call 事件里把一次工具调用直接掐掉。文档还写明 event.input 是可变的,你可以在执行前原地改写工具参数,后续的 tool_call 处理器会看到前面处理器改过的值,而且改完不会重新做 schema 校验 —— 这既是灵活性,也是你自己要兜住的责任。
事件面铺得很开:会话层有 session_start、session_shutdown、session_before_compact、session_before_fork;Agent 层有 before_agent_start、agent_start、agent_end、agent_settled;工具层有 tool_execution_start、tool_call、tool_result、tool_execution_end;甚至连出站 HTTP 请求都留了口子 —— before_provider_headers 让你增删改请求头,before_provider_request 让你在 payload 发出前替换它,after_provider_response 让你在消费流之前看到状态码和响应头。文档同时提醒,before_provider_request 里对 payload 的改写不会反映到 ctx.getSystemPrompt(),因为后者报告的是 pi 自己那份系统提示字符串,不是最终序列化出去的请求体。
除了拦,扩展还能加。pi.registerTool() 注册模型可调用的工具,pi.registerCommand() 注册 /mycommand,pi.registerShortcut() 注册快捷键,pi.registerFlag() 注册 CLI 参数,pi.registerProvider() 动态注册或覆盖模型 provider。注册工具时有两个容易被忽略的字段:promptSnippet 决定这个工具要不要在系统提示的 Available tools 一节里占一行,promptGuidelines 往 Guidelines 一节追加条目。文档特别标注 promptGuidelines 是平铺追加的、不带工具名前缀,所以每条都得自己点名工具,别写”Use this tool when…”,因为模型分不清”this”指谁。文档把这条写成了加粗的注意事项,说明它是实际踩过的坑,而不是可选的风格建议。文档还补了一句相关的:这些条目只在工具处于激活状态时才会进系统提示,所以你动态切换过工具集之后,看到的 Guidelines 内容也会跟着变。
扩展放哪里决定了它能不能热重载。文档写得很直白:放在 ~/.pi/agent/extensions/(全局)或 .pi/extensions/(项目级)才会被自动发现,也才能用 /reload 热重载;pi -e ./path.ts 只用于快速测试。项目级的 .pi/extensions 条目要等项目被信任之后才加载。
三、技能和提示词模板:都是文本,触发路径完全不同
这两个位置最容易混。它们都是 Markdown、都不含执行逻辑,但进入上下文的方式是两条完全不同的路。
技能走的是渐进披露。pi 在启动时扫描技能目录,只把名字和描述提取进系统提示;当任务匹配上了,模型用 read 去加载完整的 SKILL.md。文档里有一句相当诚实的话:模型并不总会这么做,需要靠提示或者 /skill:name 强制。这句话直接决定了技能的适用边界 —— 它是”可能被用上的能力”,不是”一定会执行的规则”。
技能的 frontmatter 只有 name 和 description 是必填的。校验策略是”多数问题只警告、照常加载”:名字超过 64 字符、含非法字符、首尾带连字符、有连续连字符、描述超过 1024 字符,这些都只 warn。唯一的例外是缺 description 的技能不会被加载。另外还有几个可选字段值得记住:disable-model-invocation 设为 true 时技能从系统提示里消失,只能靠 /skill:name 手动调;allowed-tools 是空格分隔的预批准工具列表,文档标注为实验性。
pi 实现的是 Agent Skills 标准,但明确放宽了一条:标准要求技能名和父目录同名,pi 不要求。文档给的理由是这条规则对多个 Agent 工具共用的技能目录不友好。这也解释了为什么它支持直接把 ~/.claude/skills、~/.codex/skills 写进 skills 设置数组里复用。
提示词模板走的是另一条路:确定性展开。文件名就是命令名,review.md 变成 /review,description 缺省时取第一个非空行。参数支持位置取值和默认值:
---
description: Create a component
---
Create a React component named $1 with features: $@
除了 $1、$@ / $ARGUMENTS,还有 ${1:-default} 这种缺省写法,以及 ${@:N}、${@:N:L} 的切片。argument-hint 会在自动补全下拉里显示在描述前面,用尖括号表示必填、方括号表示可选。
两者的分界线因此很清楚:你希望模型自己判断要不要用,写技能;你希望敲下去就一定是那段话,写模板。 反过来选,代价是可测量的。把一段”每次都得执行”的流程写成技能,你会遇到时灵时不灵;把一份带大段参考资料的能力包写成模板,那份资料每次调用都会整段展开进上下文,渐进披露的好处一次性丢光 —— 这正是上下文预算里最不该浪费的那部分。
四、包:把前三样打出去,以及信任这道门
包本身不提供新能力,它是容器。package.json 里加一个 pi 清单就构成一个包:
{
"name": "my-package",
"keywords": ["pi-package"],
"pi": {
"extensions": ["./extensions"],
"skills": ["./skills"],
"prompts": ["./prompts"],
"themes": ["./themes"]
}
}
没有 pi 清单时走约定目录:extensions/ 加载 .ts 和 .js,skills/ 递归找 SKILL.md 文件夹并把顶层 .md 当技能,prompts/ 加载 .md,themes/ 加载 .json。
分发有三种源:npm(npm:@scope/pkg@1.2.3)、git(git:github.com/user/repo@v1,也接受 HTTPS 和 SSH URL)、本地路径。pi install 默认写用户设置 ~/.pi/agent/settings.json,加 -l 写项目设置 .pi/settings.json。项目设置这条路是团队协作的关键:文档说项目设置可以和团队共享,pi 会在项目被信任之后于启动时自动补装缺失的包。
安装之后的开关另有一套。pi config 用来启用或禁用已安装包和本地目录里的扩展、技能、模板、主题,默认从全局设置进,按 Tab 在全局和项目级之间切换,pi config -l 直接从项目覆盖进入。设置里还能对单个包做过滤,!pattern 排除、+path 强制包含、-path 强制排除,空数组表示这类资源一个都不加载。文档强调过滤是叠加在清单之上的,只能收窄已经被允许的范围,不能反过来放开。
信任这道门贯穿始终。项目级的 .pi 和 .agents/skills 都要等项目被信任才生效,而 project_trust 事件只有用户/全局扩展和 CLI 用 -e 加载的扩展能参与 —— 项目本地扩展在信任解决之前根本没加载,逻辑上不可能让它自己给自己投赞成票。处理器必须返回 { trusted: "yes" | "no" | "undecided" },第一个给出 yes 或 no 的说了算,并且会压掉内置的信任提示;返回 "undecided" 则让给后面的处理器或内置流程。没有任何处理器表态时,先看已保存的 trust.json 决定,再由 defaultProjectTrust 决定默认是问、是信、还是拒。这套安排和最小权限设计的思路一致:先划定谁有资格做决定,再谈决定内容。
五、边界与代价:这套设计明确不管什么
它不做沙箱。 文档里两处安全提示写得毫不含糊:扩展以你的完整系统权限运行、可以执行任意代码,只应安装可信来源;技能可以指使模型执行任何动作、可能包含模型会调用的可执行代码,使用前应审查内容。包的那一节又重申了一遍。换句话说,这个体系把”要不要信”整个推给了你和项目信任机制,没有额外的运行时隔离层。
它不保证技能会被用。 前面那句”模型并不总会这么做”是设计后果而不是 bug:渐进披露省了上下文,代价就是加载时机交给了模型判断。要确定性就得手动 /skill:name,或者干脆改用模板、扩展命令。
它不替你解决冲突,而且两处策略还不一样。 技能重名时只 warn 并保留先发现的那个;扩展命令重名时 pi 全都保留,按加载顺序加数字后缀,比如 /review:1 和 /review:2。要是想当然以为两处策略一致,排查时就会找错方向:技能重名你得去翻启动时的告警,命令重名则要去看补全列表里多出来的那个带后缀的条目。
模板发现不递归。 prompts/ 下的子目录不会被扫到,要用就得显式写进 prompts 设置或包清单。
扩展工厂函数不是启动钩子。 文档明确说工厂可能运行在根本不会启动会话的调用里,因此不要在工厂里起进程、socket、文件监听或定时器。
provider 那层只管接线,不管你能不能连上。 扩展可以注册自定义 provider、覆盖 baseUrl、接 OAuth 登录,但这只是把请求发到你指定的地方。如果你接的是海外模型服务商,官方对中国大陆存在区域限制、不支持直连;市面上存在第三方中转,本文不做背书也不给具体渠道。各家规则不同且会调整,以官方最新说明为准。
六、上手与避坑清单
用 -e 调完就以为装好了。 会踩是因为 -e 跑起来一切正常,看不出区别。但文档把它定位成快速测试,只有放在自动发现目录里的扩展才能被 /reload 热重载。确定长期用就挪进 ~/.pi/agent/extensions/ 或 .pi/extensions/。
在扩展工厂里起后台资源。 会踩是因为工厂看起来就像”初始化”的地方。实际它可能在没有会话的调用里执行,起了的东西没人关。把启动推迟到 session_start 或真正需要它的那次命令、工具、事件,并注册一个幂等的 session_shutdown 处理器负责收尾。
自定义工具改文件不进队列。 会踩是因为单独测试时永远是对的。pi 默认并行执行工具,你的工具和内置 edit 可能在同一轮里都读到 foo.ts 的原始内容、各算各的改动,后落盘的那个把前一个盖掉。用 withFileMutationQueue() 把整个读-改-写窗口包起来,并且传入解析后的绝对路径,不是用户给的原始参数。
用 Type.Union / Type.Literal 写字符串枚举。 会踩是因为在别的 provider 上一切正常。文档写明它对 Google 的 API 不工作,要用 @earendil-works/pi-ai 导出的 StringEnum。
工具失败时 return 一个带错误字段的对象。 会踩是因为返回结构里看起来有地方能表达失败。实际上返回值无论带什么属性都不会置上错误标记,要标记失败只能从 execute 里 throw,抛出的错误会被捕获、以 isError: true 报给模型,流程继续。
技能描述写虚。 会踩是因为写的时候你自己知道它干什么。但描述是模型决定加不加载的唯一依据,“帮你处理 PDF”这种写法基本等于关掉了这个技能。写清楚做什么加什么时候用;确实只想手动触发,就设 disable-model-invocation: true。另外别漏 description,缺它的技能是直接不加载,不是警告。
打包时把 pi 的核心包塞进 dependencies。 会踩是因为本地开发时它确实在 node_modules 里。文档要求 @earendil-works/pi-ai、@earendil-works/pi-agent-core、@earendil-works/pi-coding-agent、@earendil-works/pi-tui、typebox 这几个放 peerDependencies 且范围写 "*"、不要打包;而其它 pi 包要走 dependencies 加 bundledDependencies,再通过 node_modules/ 路径引用。
运行时依赖写进了 devDependencies。 会踩是因为本机跑没问题。包安装默认用生产安装(npm install --omit=dev),运行时拿不到 devDependencies。
以为 git 包会自动跟到新 tag。 会踩是因为”update”这个词的字面意思。ref 是钉住的,pi update --extensions 和 pi update --all 不会把它挪到更新的 ref,只会把已有 clone 对齐到配置里写的那个 ref。换版本要显式 pi install git:host/user/repo@new-ref。
在扩展里硬编码 .pi 目录名。 会踩是因为默认安装下它就叫 .pi。文档建议用 CONFIG_DIR_NAME 拼项目级配置路径,因为改名发行版可能用别的目录名。
收个尾
给自己四个问题,顺着答一遍就知道该往哪层放:这段东西需要拦住或改写一次真实动作吗(要 → 扩展);需要模型自己判断什么时候用吗(要 → 技能,不要 → 模板);带不带大段只在特定任务里才有用的参考资料(带 → 技能,靠渐进披露省上下文);要不要发给别人(要 → 包,并想清楚写用户设置还是项目设置)。
接下来该读哪个文件,看你落在哪一层:写扩展从 packages/coding-agent/docs/extensions.md 的事件生命周期图开始,然后直接翻 packages/coding-agent/examples/extensions/ 里最接近你需求的那个示例,那份目录里的示例覆盖了权限门、Git 检查点、路径保护、输入改写、自定义 provider 等常见形态;写技能和模板,两份文档都不长,一次读完比边写边猜省事;要分发,packages/coding-agent/docs/packages.md 里的依赖规则和过滤语法是最容易搞错的两块,值得先看完再动手。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的上下文压缩 和 把开源编程 Agent pi 嵌进自己的程序。