桌面 Agent 已经能对话、调用 Tool,也能用 Skill 分析日志。现在出现一个新要求:
所有危险 Tool 执行前都要经过审批,但我不想修改 Pi 的 Agent Loop。
如果把判断散落到每个 Tool,会重复;如果直接改 Agent Loop,后续升级又难以合并。Extension 就是 Pi 留出的运行行为扩展层。
1. 先看没有 Extension 时会怎样
flowchart LR
M["模型"] --> C["Tool Call"]
C --> T["Tool.execute()"]
T --> R["Tool Result"]
R --> M
审批逻辑可以写进 Tool:
execute: async (_id, params) => {
await requestApproval(params);
return runTool(params);
}
但每个 Tool 都要重复,而且内置 Tool 或第三方 Tool 不一定由你实现。
2. Extension 插在哪里
Extension 可以监听 tool_call:
flowchart LR
M["模型"] --> C["Tool Call"]
C --> E["Extension Runner<br/>tool_call"]
E -->|允许| T["Tool.execute()"]
E -->|block| B["错误 Tool Result"]
T --> R["Tool Result"]
R --> M
B --> M
它不是替代 Agent Loop,而是在 Core 明确发出的事件点观察、修改或阻止流程。
3. Extension 的定位
Pi 的 Extension 是默认导出 Factory 的 TypeScript 或 JavaScript Module:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event) => {
// 注册行为
});
}
Factory 在加载阶段拿到受控的 ExtensionAPI,用它注册:
- Event Handler;
- Custom Tool;
- Command;
- Provider;
- Flag 等能力。
Factory 可以是 Async,Loader 会等待它完成,再进入后续启动阶段。
4. Extension、Skill 与 Tool 的区别
| 概念 | 核心形态 | 谁决定使用 | 能否改变运行流程 |
|---|---|---|---|
| Skill | Markdown 工作流 | 模型或显式调用方 | 只能通过指令间接影响 |
| Tool | Schema + execute | 模型产生 Tool Call | 执行一个具体能力 |
| Extension | TS/JS Factory + 注册结果 | 宿主在加载时启用 | 可监听、修改、阻止和注册 |
error-log-analysis Skill 告诉模型“怎样分析”;read Tool 负责读文件;审批 Extension 决定
某次 Tool Call 是否允许执行。
5. 为什么 Skill 不适合做强制审批
在 Skill 中写:
删除文件前必须询问用户。
这是给模型的软指令。模型可能没读取 Skill,Skill 也可能在 Compaction 后只剩摘要。
tool_call Handler 处于实际执行路径:
return {
block: true,
reason: "Approval required",
};
Runner 把 Block 结果交回 Tool 流程,Tool 不会执行。但多个 tool_call Handler 共享同一
可变 Input,后续 Extension 还能改动已审批参数,而且修改后不会再次 Schema Validation。
因此 Extension Gate 只有在受控的 Extension Chain 中才能形成可靠拦截;真正的桌面安全
边界应在宿主最终执行层检查最终参数,或严格控制 Extension 顺序与 Trust Set。无论如何,
都不能只依赖 Prompt/Skill。
6. 为什么不直接修改 Agent Loop
直接改 Core 的成本包括:
- 每次升级都要重新合并;
- Policy 与模型循环耦合;
- 难以按项目启用;
- 难以单独测试、分发和停用;
- 多种需求不断增加分支。
Extension 让 Core 维护稳定事件点,让产品逻辑在外部组合。这不是“Extension 一定安全”, 而是把变化放到明确边界。
7. 为什么采用事件驱动
同一个 Run 中有多个阶段:
flowchart TD
I["input"] --> B["before_agent_start"]
B --> A["agent_start"]
A --> T["turn_start"]
T --> M["message events"]
M --> S["tool_execution_start"]
S --> C["tool_call"]
C -->|允许| X["tool_execution_update(可选)"]
C -->|阻止| E["tool_execution_end(错误)"]
X --> R["tool_result"]
R --> E["tool_execution_end"]
E --> Z["ToolResult message"]
Z --> Q["turn_end / agent_end"]
不同需求只订阅自己关心的阶段:
- 输入规范化监听
input; - System Prompt 注入监听
before_agent_start; - 审批监听
tool_call; - 审计监听 Tool Execution Event;
- 清理资源监听
session_shutdown。
Core 不需要知道每个 Extension 的业务含义。
8. 第一个最小 Extension
课程提供本地示例:
examples/extensions/block-destructive-shell.ts.example
.ts.example 是为了避免课程站点把独立 Extension 当作自身源码编译。实际使用时先复制或
改名为 .ts,再用 pi -e /absolute/path/block-destructive-shell.ts 临时加载。
核心代码:
export default function blockDestructiveShell(pi: ExtensionAPI) {
pi.on("tool_call", async (event) => {
if (event.toolName !== "bash") return;
const command =
typeof event.input.command === "string"
? event.input.command
: "";
if (/(^|\s)rm\s+-rf(\s|$)/.test(command)) {
return {
block: true,
reason: "Blocked by the desktop agent policy.",
};
}
});
}
这不是完整 Shell Security Parser,只是让第一条 Extension 链路足够小:
- Module 被加载;
- Factory 被调用;
- Handler 注册到
tool_call; - 模型产生 Bash Tool Call;
- Runner 调用 Handler;
- 命中规则时返回 Block。
9. 从硬阻止走向桌面审批
最小示例直接阻止。完整桌面产品通常需要:
tool_call
→ 生成 Approval Request
→ UI 展示 Tool + Arguments + 风险
→ 用户允许/拒绝
→ Handler 返回 Allow 或 Block
关键是 Handler 的 Promise 必须等到桌面 UI 回答。具体 Bridge 属于宿主集成,将在 Tool Extension 与桌面状态章节完成;本章先确认拦截点。
10. Extension 的发现位置
coding-agent 自动发现 .ts 与 .js 文件:
用户:~/.pi/agent/extensions/*.{ts,js}
项目:.pi/extensions/*.{ts,js}
目录:.../extensions/<name>/index.{ts,js}
项目自动资源与项目 Settings 中的 Extension 只在项目被 Trust 后加载。用户/全局 Extension
和临时 CLI Extension 会在 Pre-trust 阶段加载,并可参与 project_trust 决策。Settings 和
Pi Package 也可以提供路径。
显式 --extension / -e 适合临时测试;其 Path 保存在 Resource Loader 的 Additional
Extension Paths 中,Reload 时也会重新解析加载,只是 Scope 为 Temporary。
11. Factory 不是后台服务启动点
文档特别要求:不要在 Factory 中启动长期 Process、Socket、Watcher 或 Timer。
原因是 Factory 可能在根本不会启动 Session 的调用中运行,例如只列模型。正确模式:
Factory:注册能力,可做有界的一次性初始化
session_start:启动 Session Scoped Resource
session_shutdown:幂等清理
Async Factory 适合一次性初始化,例如获取 Provider Model List;长期资源仍应推迟。
12. Extension 的权限边界
Extension Module 在本机进程中运行,拥有用户进程权限,可以:
- 读写文件;
- 访问环境变量;
- 发起网络请求;
- 启动子进程;
- 注册高权限 Tool;
- 拦截和改变 Agent 行为。
所以“Extension API 是受控接口”不等于 JavaScript Module 被沙箱隔离。API 限制的是它与 AgentSession 的正式耦合面,不限制 Module 自身的 Node 能力。
13. 本章小结
- Extension 是改变 Pi 运行行为的 TS/JS 扩展层;
- Skill 是工作流指令,Tool 是具体执行能力,Extension 是注册和拦截机制;
- 强制审批不能只依赖 Skill/Prompt;Extension Gate 也必须位于受控链中,最终参数应由宿主 执行层兜底检查;
- Event 让不同需求接入明确生命周期,而不修改 Agent Loop;
tool_call可以在 Tool 执行前返回 Block;某 Handler 放行后,后续 Handler 仍可能修改 Input,一旦 Block 则立即停止后续 Handler;- Factory 用于注册,长期资源应在 Session Start 启动并在 Shutdown 清理;
- 项目 Extension 受 Project Trust 控制;
- Extension 有完整系统权限,只能加载可信源码;
- 最小示例只演示拦截链,不声称简单正则可以完整理解 Shell。
14. 自测
- 为什么审批逻辑不适合只写在 Skill 中?
- Tool 与 Extension 各负责什么?
tool_call返回 Block 后,Tool 是否还会执行?- Event Driven 给 Core 和产品逻辑带来什么边界?
- 为什么不应在 Extension Factory 中启动长期 Watcher?
- “Extension API 受控”为什么不等于 Extension 被沙箱隔离?