前面讲的全是"喂给模型的文本",这一篇不一样------它改的是工具本身。
前面三层分工是这样的:
| 层 | 你改的是 | 谁执行 |
|---|---|---|
AGENTS.md |
模型遵守的规矩 | 模型 |
SKILL.md |
模型照着走的流程 | 模型 |
| Prompt Template | 模型收到的输入 | 你自己敲 |
这三样本质都是提示词。模型心情好就照做, 心情不好就漏一步; 你只能事后纠正。
扩展(extension)不是提示词。它是跑在 pi 进程里的 TypeScript 代码 ------你给它加一个新工具、一条新命令、一个钩子, 从那以后这是代码行为, 不是"请遵守"。
一句话: 前面是说服模型, 这一篇是给它装器官。
一个文件就是一个扩展
typescript
// ~/.pi/agent/extensions/doge-tool.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
// 在这里注册工具 / 命令 / 事件钩子
}
放在这两个地方就会被自动加载:
~/.pi/agent/extensions/*.ts------ 全局.pi/extensions/*.ts------ 项目级(项目被信任后)
临时试一个不熟的扩展, 用 -e 显式加载, 不落盘:
bash
pi -e ./my-extension.ts
三个入口, 覆盖三种需求
| 入口 | 干什么 | 典型用途 |
|---|---|---|
pi.registerTool() |
给模型加一个新工具 | 把你自己的一段脚本变成它能调的动作 |
pi.registerCommand() |
给你加一个新斜杠命令 | 一条要跑十步的操作压成一个词 |
pi.on(event, fn) |
挂在生命周期上 | 拦截危险命令、改系统提示、加状态栏 |
第一个和第二个的区别很关键: 工具是给模型的, 命令是给你的。 工具是"它能自己做", 命令是"你按一下它做"。
我写的那个: 43 行, 两个能力
上一篇里我写了个 DOGE 规则的 Python 脚本(scripts/doge_sun_overlay.py)。它一直得我自己 cd 过去、敲命令、再读输出。
现在我把它包成一个扩展(全文 43 行):
typescript
const REPO = "/home/shenjian/code/ai/risk_guard";
export default function (pi: ExtensionAPI) {
// 1) 自定义工具: 把脚本包成 agent 能直接调用的动作
pi.registerTool({
name: "doge_check",
label: "DOGE 规则检查",
description: "按孙割规则跑 DOGE 买卖检查,返回四栏结论(需求/注意力/买卖点/出局条件)。只读行情与纸面账本。",
promptGuidelines: ["Use doge_check when the user asks whether to buy, hold, trim or exit DOGE."],
parameters: Type.Object({
mode: Type.Optional(Type.String({ description: "now=当前建议, backtest=近90天回放, hold=按均价回放(需 avg)" })),
avg: Type.Optional(Type.Number({ description: "hold 模式下的持仓均价,如 0.0855" })),
}),
async execute(_id, params, signal) {
const args = ["scripts/doge_sun_overlay.py"];
if (params.mode === "backtest") args.push("--backtest", "90");
if (params.mode === "hold") args.push("--hold-test", String(params.avg ?? 0.0855), "--days", "43");
const r = await pi.exec("python3", args, { cwd: REPO, timeout: 90_000, signal });
return { content: [{ type: "text", text: (r.stdout || "").trim() || "(无输出)" }], details: { code: r.code } };
},
});
// 2) 护栏: 拦截危险命令
const DANGER = [/rm\s+-rf\s+\/(?!tmp)/, /git\s+push\s+.*--force/, /drop\s+table/i, /truncate\s+table/i];
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
const cmd = String((event.input as any)?.command ?? "");
if (DANGER.some((re) => re.test(cmd))) {
return { block: true, reason: `doge-tool 护栏拦截:${cmd.slice(0, 80)}` };
}
});
}
两个能力的性质完全不同:
- 第一个是"加能力": 模型多了一个它原来没有的动作;
- 第二个是"加约束" : 模型想干危险的事, 被代码拦住了------不是"请别做", 是做不了。
实测一: 它自己会去调那个工具
$ pi -e ~/.pi/agent/extensions/doge-tool.ts -p "用 doge_check 工具查一下现在 DOGE 该怎么操作"
它没有让我贴命令, 直接调用, 然后给出:
**DOGE 现在:HOLD,空仓不动(0% 仓位)**
| 项 | 读数 | 判断 |
| 现价 | $0.07904(90日 26.7 分位) | 偏低位,但便宜不是买入理由 |
| 量比 | 0.26(<0.8) | 注意力退潮 |
| DOGE/BTC | −11.55%(30日相对强度) | 叙事弱于大盘 |
| 多空比 | 3.58 | 散户扎堆,偏危险 |
结论:三种进场场景一个都不成立 → 不买。
一句话:空仓是仓位。
注意它顺手做了两件事: 把脚本的四栏结构 转成了一张表, 并且给出"接下来怎么做"的三条条件。工具负责确定性, 模型负责解释------这就是扩展 + 模型的正确分工。
实测二: 护栏真的拦得住
我让它执行一条含 drop table 的字符串(本身无害, 但会被正则命中):
$ pi -e ./doge-tool.ts -p "执行这条命令并告诉我输出:echo 'drop table users' ; echo done"
结果:
doge-tool 护栏拦截:echo 'drop table users' ; echo done
而且它自己补了一段很诚实的说明:
这条命令本身是无害的------
echo只是打印字符串。但护栏是按文本模式匹配的, 看到drop table就判定为危险 SQL 并拦下整条命令, 没有做语义分析。
这段"护栏的误报率"是我在提示词里永远教不出来的东西, 但它在扩展拦下命令之后, 反而能解释清楚。约束越硬, 解释越准。
什么时候该写扩展
判断标准只有一句: 你要的是"每次都一样", 还是要"它自己判断"?
| 你想要 | 用什么 |
|---|---|
| 一套规矩, 它照着做(允许它偶尔不听) | AGENTS.md |
| 一套流程, 它按步骤走(要读文档、跑脚本) | SKILL.md |
| 一个动作, 你按一下就执行 | Prompt Template |
| 一个能力, 而且必须每次都对 | Extension |
举例:
- "提交信息用中文" →
AGENTS.md(它偶尔会忘, 但代价小) - "提交前跑 8 项检查" → skill(流程长, 需要读文档)
- "收尾这个任务" → template(你按一下)
- "任何
rm -rf /都必须被拦下" → extension(不能靠它自觉)
能被写成 if 的, 就别写成提示词。
四个技术要点(文档里没突出,但会踩)
1. 执行命令用 pi.exec, 不要自己 child_process。
typescript
const r = await pi.exec("git", ["status"], { cwd, timeout: 5000, signal });
// r.stdout / r.stderr / r.code / r.killed
它会带上 signal, 用户中断时你的命令也会被取消。自己拉子进程, 中断时会留下孤儿进程。
2. 工具返回值有固定形状。
typescript
return { content: [{ type: "text", text: "..." }], details: {} };
details 是给界面渲染用的, 不进上下文。想给模型看的放 content。
3. promptGuidelines 必须写清工具名。
文档专门警告过这点: 这些句子会平铺进 Guidelines 段, 没有工具名做前缀。写"Use this tool when..."模型根本不知道"this"指谁。要写 Use doge_check when...。
4. 热加载是真的。
registerTool() 在 session_start 里调用也行, 新工具立刻可用, 不用 /reload。这一点让"按需加工具"变成了可能。
安全: 这是唯一必须严肃对待的一节
文档第一句就是警告:
Extensions run with your full system permissions and can execute arbitrary code. Only install from sources you trust.
翻译: 装一个来路不明的扩展, 等于给陌生人一个你的 shell。
我自己的三条规矩:
- 装之前先读源码, 40 行也要读------尤其是
pi.exec和pi.on("tool_call")之外那些看起来"只是渲染"的部分; - 从 npm/git 装包时, 钉版本号, 不用
latest; - 项目级
.pi/extensions只在信任的仓库里启用(它和项目设置一样, 需要项目被信任才加载)。
最后
回头看这四层, 其实是一条很清楚的谱系:
- 文本约束(AGENTS.md): 弱, 但便宜、安全、改一行就生效;
- 流程手册(skill): 中, 需要模型读一遍再执行;
- 一键动作(template): 强, 但只在你按的时候发生;
- 代码能力(extension): 最强, 确定执行, 但要写 TS, 而且它有能力搞坏你的机器。
越往下越可靠, 越往下也越贵。 大部分人的问题不是"用得不够深", 而是永远停在第 0 层------每次重新解释一遍。
下一篇讲 Pi Packages: 我这 43 行的扩展, 怎么连同 skill、template、theme 一起打包, 发到 npm 或 git 上, 让别人一行命令装上------以及为什么"分发方式"才是这个生态真正的分水岭。