Pi Extension开发
Pi Extension本质上是由Pi直接加载的TypeScript模块,用于扩展Agent运行时本身:注册Tool、Command、事件钩子、UI、Provider以及其它运行时行为。
如果只是补充Agent应该知道什么、按照什么流程执行,优先使用Skill;只有需要改变Pi运行时能力或拦截运行时行为时才使用Extension。Skill的组织方式参考Agent SKILL设计#正文设计和Agent SKILL设计#基本结构。
Extension主要解决下面这些问题:
| 需求 | API / 机制 |
|---|---|
| 给LLM增加新工具 | registerTool |
| 给用户增加命令 | registerCommand |
| 拦截/修改Agent行为 | pi.on(...) |
| 增加交互界面 | ctx.ui |
| 修改Tool集合 | setActiveTools |
| 持久化运行状态 | Tool details / appendEntry |
| 扩展模型Provider | registerProvider |
| 扩展Pi资源发现 | resources_discover |
| Extension之间通信 | pi.events |
版本和包名
2026年5月Pi迁移到了Earendil Works组织,从0.74.0开始核心npm包统一改名:
| 旧包名 | 当前包名 |
|---|---|
@mariozechner/pi-coding-agent | @earendil-works/pi-coding-agent |
@mariozechner/pi-agent-core | @earendil-works/pi-agent-core |
@mariozechner/pi-ai | @earendil-works/pi-ai |
@mariozechner/pi-tui | @earendil-works/pi-tui |
旧包最后版本为0.73.1,之后已经deprecated。
因此旧Extension里常见:
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { StringEnum } from "@mariozechner/pi-ai";当前代码统一写成:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { StringEnum } from "@earendil-works/pi-ai";查旧代码时需要先确认代码对应的Pi版本,不要直接把旧namespace当成第三方fork。
Extension和Skill的边界
两者虽然都可以改变Agent最终行为,但作用层完全不同:
flowchart LR U[User] --> P[Pi Runtime] P --> E[Extension] E --> A[Agent Runtime] A --> S[Skill / Prompt / Context] S --> L[LLM] L --> T[Tools] T --> E
Skill偏向Agent上下文层:
- 约束Agent流程;
- 提供领域知识;
- 提供模板和参考资料;
- 不需要实现运行时代码;
- 可以按需加载,节省上下文。
Extension偏向Harness运行时层:
- 注册真正的新Tool;
- 修改已有Tool行为;
- 拦截Tool执行;
- 管理Session;
- 提供TUI;
- 执行外部程序;
- 增加Provider;
- 维护长期运行资源。
自己的选择原则:
| 需求 | 选择 |
|---|---|
| 只需要告诉Agent“怎么做” | Skill |
| 需要让Agent“获得新能力” | Extension Tool |
| 需要用户主动触发一个动作 | Extension Command |
| 需要修改Pi现有行为 | Event Hook |
| 需要修改终端交互 | Extension UI |
不要为了实现简单工作流就写Extension;这样会把本来可以声明式维护的流程变成代码。
加载模型
Extension由Pi加载,入口模块默认导出一个factory:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// register tools / events / commands...
}factory也可以是异步函数:
export default async function (pi: ExtensionAPI) {
const config = await loadConfig();
// register provider / tools...
}Pi会等待异步factory完成,再继续session_start等启动流程。
TypeScript直接加载
Extension通过jiti加载,因此通常不需要编译TypeScript:
flowchart LR A[index.ts] --> B[jiti] --> C[Pi Runtime]
本地开发时可以直接:
pi -e ./index.ts或者:
pi --extension ./index.ts因此tsconfig.json更多用于IDE和类型检查,而不是运行时构建要求。
Extension发现位置
自动发现位置:
| 作用域 | 自动发现位置 |
|---|---|
| 全局Extension | ~/.pi/agent/extensions/*.ts、~/.pi/agent/extensions/*/index.ts |
| 当前项目Extension | .pi/extensions/*.ts、.pi/extensions/*/index.ts |
项目级Extension需要项目已经被信任才能加载。
也可以在settings.json里额外声明:
{
"extensions": [
"/path/to/extension.ts",
"/path/to/extension"
]
}正式分发时优先做成Pi Package,不直接要求用户复制文件。
项目结构
单文件
很简单的Extension直接一个文件即可:
my-extension.ts
适用于:
- 一个Tool;
- 一个Command;
- 很小的事件Hook;
- 本地临时扩展。
多文件
功能开始复杂后,入口只负责注册:
| 文件 | 职责 |
|---|---|
index.ts | Extension入口,只负责注册Pi能力 |
tool.ts | Tool定义或适配层 |
config.ts | 配置读取、校验与默认值 |
utils.ts | 无状态通用辅助逻辑 |
index.ts尽可能薄:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createTool } from "./tool";
export default function (pi: ExtensionAPI) {
pi.registerTool(createTool(pi));
}具体逻辑放到独立文件,更容易做单元测试。
对于通用的多文件Extension,优先按职责拆分,不把具体业务名称固化到目录范式里:
| 层次 | 常见位置 | 职责 |
|---|---|---|
| Extension入口层 | index.ts 或 extensions/*.ts | 注册Tool、Command、Hook、Provider等Pi能力 |
| 业务实现层 | src/ 或同级Feature模块 | 普通TypeScript逻辑,尽量不依赖TUI和Session上下文 |
| 测试层 | *.test.ts 或 test/ | 测试可独立运行的核心逻辑 |
| 包配置 | package.json | 声明Pi入口、依赖、发布信息 |
| 文档 | README.md | 记录安装、配置和对外使用方式 |
入口文件尽量保持薄,只做组装和注册;具体业务实现单独拆分,方便测试和复用。
生命周期
Pi提供大量事件,但日常开发只需要先记住主流程:
flowchart TD A[Pi启动] --> B[Extension Factory] B --> C[project_trust] C --> D[session_start] D --> E[resources_discover] E --> F[用户输入] F --> G[input] G --> H[before_agent_start] H --> I[agent_start] I --> J[turn_start] J --> K[context] K --> L[LLM] L -->|调用Tool| M[tool_execution_start] M --> N[tool_call] N --> O[Tool execute] O --> P[tool_result] P --> Q[tool_execution_end] Q --> J L -->|完成| R[turn_end] R --> S[agent_end] S --> T[agent_settled] T --> F T --> U[session_shutdown]
最常用事件可以按职责记:
| 事件 | 用途 |
|---|---|
session_start | 初始化Session级状态和资源 |
session_shutdown | 清理连接、Watcher、Process等 |
before_agent_start | 给当前Agent Turn增加上下文或修改System Prompt |
context | 修改真正发给模型的消息上下文 |
tool_call | Tool执行前检查、修改参数、阻止执行 |
tool_result | Tool执行后修改结果 |
model_select | 模型切换后更新状态 |
input | 拦截用户输入 |
resources_discover | 动态增加Skill/Prompt/Theme路径 |
session_start和session_shutdown
长期资源不要在factory里启动。
错误:
export default function (pi: ExtensionAPI) {
const watcher = watch(".");
}因为Extension factory可能被加载,但实际上没有开始Session。
正确做法:
export default function (pi: ExtensionAPI) {
let watcher: ReturnType<typeof watch> | undefined;
pi.on("session_start", async () => {
watcher ??= watch(".");
});
pi.on("session_shutdown", async () => {
watcher?.close();
watcher = undefined;
});
}所有Session级资源都应该满足:
flowchart LR A[session_start] -->|acquire| B[Session级资源] B -->|release| C[session_shutdown]
并且初始化/清理最好是幂等的。
before_agent_start
需要对某个Turn临时增加上下文时使用:
pi.on("before_agent_start", async (event) => {
return {
message: {
customType: "project-context",
content: "当前项目使用Java 21。",
display: false,
},
systemPrompt: `${event.systemPrompt}\n\n优先遵循项目约束。`,
};
});这个Hook适合:
- 动态项目上下文;
- 临时System Prompt约束;
- 根据当前状态注入信息。
不要把固定且很长的领域知识每Turn都注入;这类内容更适合Skill。
ExtensionAPI和Context
开发时需要区分:
| 对象 | 作用 |
|---|---|
pi | ExtensionAPI,用于注册和控制Pi运行时 |
ctx | 当前事件、Command或Tool对应的Context |
常用pi能力:
pi.on():注册生命周期和运行时事件;pi.registerTool():注册LLM可调用的Tool;pi.registerCommand():注册用户Command;pi.registerShortcut():注册快捷键;pi.registerFlag():注册CLI Flag;pi.exec():执行外部Process;pi.appendEntry():写入Extension私有Session Entry;pi.sendMessage()/pi.sendUserMessage():向当前Session追加消息;pi.getActiveTools()/pi.getAllTools()/pi.setActiveTools():读取和修改Tool集合;pi.events:Extension Event Bus;pi.registerProvider():注册模型Provider。
常用ctx能力:
ctx.cwd:当前工作目录;ctx.mode/ctx.hasUI:运行模式与UI可用性;ctx.ui:TUI交互接口;ctx.sessionManager:当前Session与Branch信息;ctx.model/ctx.modelRegistry:当前模型与模型注册表;ctx.signal:当前操作的取消信号;ctx.isIdle()/ctx.abort():运行状态和中止控制;ctx.getContextUsage():Context使用情况;ctx.getSystemPrompt():当前System Prompt。
Command使用的ExtensionCommandContext还额外提供:
ctx.waitForIdle();ctx.newSession();ctx.fork();ctx.navigateTree();ctx.switchSession();ctx.reload()。
Tool开发
Tool是Extension中最常用的能力。
最小结构:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_toolCallId, params) {
return {
content: [
{
type: "text",
text: `Hello ${params.name}`,
},
],
details: {},
};
},
});
}Tool的核心模型:
flowchart TD A[parameters] --> B[模型生成Tool Call] B --> C[Schema Validation] C --> D[execute] D --> E[content: 给LLM] D --> F[details: 状态恢复 / Renderer / Extension内部使用]
Tool Schema
当前使用typebox:
import { Type } from "typebox";枚举不要这样写:
Type.Union([
Type.Literal("get"),
Type.Literal("post"),
]);优先使用:
import { StringEnum } from "@earendil-works/pi-ai";
const method = StringEnum(["get", "post"] as const);原因是StringEnum可以保持Google API兼容性。
execute参数
完整签名主要包含:
async execute(toolCallId, params, signal, onUpdate, ctx) {
}含义:
| 参数 | 用途 |
|---|---|
toolCallId | 当前Tool Call ID |
params | 已通过Schema校验的参数 |
signal | AbortSignal,用于取消执行 |
onUpdate | 发送流式执行状态 |
ctx | 当前ExtensionContext |
执行可能耗时时,需要检查signal,并把它传给支持AbortSignal的API:
const response = await fetch(url, {
signal,
});调用外部程序时优先:
const result = await pi.exec("git", ["status"], {
signal,
timeout: 5000,
});不要自己无必要地重新实现Process管理。
执行进度
执行过程中可以:
onUpdate?.({
content: [
{
type: "text",
text: "Requesting...",
},
],
details: {
phase: "request",
},
});最终再return正式结果。
Tool错误
Tool失败时直接throw:
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}不要通过:
return {
isError: true,
};来表达执行错误。
Pi会捕获execute抛出的异常,并把Tool Result标记为isError: true交给模型。
content和details
推荐明确区分:
return {
content: [
{
type: "text",
text: "HTTP 200 OK",
},
],
details: {
status: 200,
headers,
elapsedMs,
},
};content只放模型真正需要看到的信息。
details主要用于:
- 保存结构化状态;
- Renderer展示;
- Session恢复;
- Extension内部处理。
不要把大量结构化数据全部格式化进content。
Tool描述和Prompt
Tool不仅有description,还可以提供:
pi.registerTool({
name: "http_request",
description: "Send an HTTP request",
promptSnippet: "Send controlled HTTP requests to a target URL",
promptGuidelines: [
"Use http_request instead of shell curl when structured response data is required.",
],
// ...
});三者作用不同:
| 字段 | 作用 |
|---|---|
description | Tool Schema中的工具说明 |
promptSnippet | Available tools中的一行摘要 |
promptGuidelines | 默认Guidelines里的额外规则 |
promptGuidelines是平铺加入Guidelines的,所以必须显式写Tool名。
应该显式写Tool名,例如:Use http_request when structured HTTP response data is required.
不要写模糊的Use this tool ...,否则多个Tool混在一起后模型无法判断this tool指谁。
否则多个Tool混在一起后模型不知道this tool指谁。
Tool事件拦截
Extension也可以不创建新Tool,而是拦截已有Tool。
tool_call
在Tool真正执行前触发:
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
pi.on("tool_call", async (event, ctx) => {
if (!isToolCallEventType("bash", event)) {
return;
}
if (event.input.command.includes("rm -rf")) {
const ok = await ctx.ui.confirm(
"Dangerous command",
event.input.command,
);
if (!ok) {
return {
block: true,
reason: "Blocked by user",
};
}
}
});event.input可以直接修改,并且修改后的参数会真正传给Tool:
if (isToolCallEventType("bash", event)) {
event.input.command = `set -e\n${event.input.command}`;
}但修改后不会重新Schema校验,因此不要把参数改成非法结构。
tool_result
在执行后、结果正式加入Session前触发:
pi.on("tool_result", async (event) => {
if (event.toolName !== "bash") {
return;
}
return {
details: {
...event.details,
inspected: true,
},
};
});多个tool_result处理器类似Middleware:
flowchart TD A[Extension A处理Result] --> B[Extension B收到A修改后的Result] B --> C[Extension C继续处理]
适合:
- Tool输出降噪;
- 日志记录;
- 附加结构化信息;
- 统一错误处理;
- 安全策略。
并行Tool和文件修改
Pi默认可以并行执行同一Assistant Turn里的多个Tool。
因此下面这种逻辑存在Lost Update风险:
flowchart TD A[Tool A读取foo.ts] --> C[基于旧内容A] B[Tool B读取foo.ts] --> D[基于旧内容B] C --> E[Tool A写入] D --> F[Tool B写入] E --> F F --> G[后写入结果可能覆盖A]
自定义Tool如果修改文件,应使用Pi提供的:
withFileMutationQueue()最小模式:
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { resolve } from "node:path";
async execute(_id, params, _signal, _update, ctx) {
const path = resolve(ctx.cwd, params.path);
return withFileMutationQueue(path, async () => {
// read-modify-write transaction
return {
content: [{ type: "text", text: "Updated" }],
details: {},
};
});
}需要把整个read-modify-write事务窗口放进Queue,而不只是最终writeFile()。
输出截断
Tool输出会直接消耗模型Context,因此任何可能产生大输出的Tool都必须考虑截断。
Pi内置默认限制大致为50KB或2000行,先达到哪个就截断。
可以使用:
import {
truncateHead,
truncateTail,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
} from "@earendil-works/pi-coding-agent";选择原则:
| 输出类型 | 截断策略 |
|---|---|
| 文件、搜索结果 | truncateHead |
| 日志、命令输出 | truncateTail |
完整结果如果仍然重要,保存到文件,并在Tool结果里告诉Agent文件位置:摘要进入Context,完整结果落盘。
不要用Context承担大数据存储职责。
状态管理
Tool状态优先放details
如果状态和Tool执行语义有关,优先保存到Tool Result的details。
例如:
let todos: string[] = [];
pi.registerTool({
name: "todo_add",
// ...
async execute(_id, params) {
todos.push(params.text);
return {
content: [{ type: "text", text: "Added" }],
details: {
todos: [...todos],
},
};
},
});Session启动时,从当前Branch重建:
pi.on("session_start", async (_event, ctx) => {
todos = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (
entry.type === "message" &&
entry.message.role === "toolResult" &&
entry.message.toolName === "todo_add"
) {
todos = entry.message.details?.todos ?? todos;
}
}
});关键点是使用:
ctx.sessionManager.getBranch()而不是把Extension状态简单当成一条线性的全局历史。
因为Pi Session本身支持Fork/Tree,Extension状态也应该跟随当前Branch恢复。
appendEntry
不需要进入LLM Context,但需要跟Session一起持久化的数据,可以:
pi.appendEntry("my-state", {
count: 42,
});恢复时:
for (const entry of ctx.sessionManager.getEntries()) {
if (
entry.type === "custom" &&
entry.customType === "my-state"
) {
// entry.data
}
}区别:
| 方式 | 进入LLM Context | 主要用途 |
|---|---|---|
Tool Result details | Tool Result本身参与上下文,details主要用于结构数据 | Tool执行状态、可Fork状态 |
pi.appendEntry() | 否 | Extension私有持久状态、TUI Card |
pi.sendMessage() | 是 | 需要模型真正看到的Extension消息 |
状态不要只保存在模块级变量:
let state = {};这种状态在/reload、/new、/resume、/fork或进程重启之后都可能丢失或失效。
模块变量只能作为当前Session的缓存,真实状态应能从Session或外部存储重建。
Command
用户主动执行的操作适合Command:
pi.registerCommand("stats", {
description: "Show session statistics",
handler: async (_args, ctx) => {
const count = ctx.sessionManager.getEntries().length;
ctx.ui.notify(`${count} entries`, "info");
},
});使用/stats。
带参数:
pi.registerCommand("deploy", {
description: "Deploy to environment",
handler: async (args, ctx) => {
ctx.ui.notify(`Deploying ${args}`, "info");
},
});例如:/deploy staging。
Command比Tool更适合:
- 配置Extension;
- 打开设置界面;
- 切换模式;
- 显示状态;
- Reload;
- 人工触发维护动作。
而Tool应该是给LLM调用的能力。
Shortcut和Flag
注册快捷键:
pi.registerShortcut("ctrl+shift+p", {
description: "Toggle mode",
handler: async (ctx) => {
ctx.ui.notify("Toggled", "info");
},
});注册CLI参数:
pi.registerFlag("proxy", {
description: "Enable proxy mode",
type: "boolean",
default: false,
});
if (pi.getFlag("proxy")) {
// ...
}Flag适合启动级配置,不要把所有配置都设计成Flag。
UI
最常用的UI方法已经足够覆盖大部分Extension:
const item = await ctx.ui.select("Select:", ["A", "B"]);
const ok = await ctx.ui.confirm(
"Confirm",
"Continue?",
);
const value = await ctx.ui.input(
"Name",
"placeholder",
);
const text = await ctx.ui.editor(
"Edit config",
"default value",
);
ctx.ui.notify("Done", "info");状态栏:
ctx.ui.setStatus("http", "requesting...");
ctx.ui.setStatus("http", undefined);Widget:
ctx.ui.setWidget("my-ext", [
"Connected",
"Requests: 12",
]);简单交互优先使用这些高级API,不要一上来实现自定义TUI Component。
只有复杂交互再使用:
ctx.ui.custom(...)甚至:
ctx.ui.setEditorComponent(...)非TUI模式
Extension不一定运行在Interactive TUI:
| 模式 | ctx.mode | ctx.hasUI |
|---|---|---|
| Interactive | tui | true |
| RPC | rpc | true |
| JSON | json | false |
print | false |
因此需要交互时先考虑:
if (!ctx.hasUI) {
return;
}不要默认所有Extension调用都有终端交互环境。
自定义Renderer
Tool可以用renderCall和renderResult控制TUI显示。
例如:
import { Text } from "@earendil-works/pi-tui";
pi.registerTool({
// ...
renderCall(args, theme) {
return new Text(
theme.fg("toolTitle", `HTTP ${args.method}`),
0,
0,
);
},
renderResult(result, { expanded }, theme) {
const status = result.details?.status;
const text = expanded
? JSON.stringify(result.details, null, 2)
: `HTTP ${status}`;
return new Text(
theme.fg("success", text),
0,
0,
);
},
});默认显示尽量紧凑,详细信息放到expanded状态。
Renderer是展示层,不要在Renderer里执行真实业务逻辑。
动态Tool
可以一次注册很多Tool,但只激活少量:
const active = pi.getActiveTools();
pi.setActiveTools([
...active,
"search_tools",
]);Agent调用search_tools后,再动态激活找到的工具。
这种模式适合:
- 大量MCP Tool;
- 大型工具包;
- 按领域动态加载的能力。
核心思想类似Skill的渐进加载:
flowchart LR A[注册全部能力] --> B[Prompt只暴露必要入口] B --> C[按需要激活具体Tool]
这样可以减少Tool Schema对Context和模型选择行为的干扰。
Extension之间通信
Extension共享Event Bus:
pi.events.on("http:request", (data) => {
// ...
});
pi.events.emit("http:request", {
url: "https://example.com",
});适合低耦合扩展之间发送事件。
不要通过直接import另一个Extension内部模块来实现运行时协作,否则会造成加载顺序、状态和包依赖耦合。
Session切换和Reload
Session相关API很容易踩旧Context问题。
例如:
await ctx.newSession({
withSession: async (ctx) => {
await ctx.sendUserMessage("Continue");
},
});withSession中的ctx是新Session Context。
不要提前缓存:
const oldSessionManager = ctx.sessionManager;然后在Session替换后继续使用。
Session切换后:
flowchart TD A[旧Session session_shutdown] --> B[旧Runtime销毁] B --> C[绑定新Session] C --> D[新Extension实例 session_start] D --> E[withSession new ctx]
因此替换Session后只捕获与Session生命周期无关的普通值,例如:
string;- ID;
- 普通JSON配置。
不要捕获SessionManager、旧Context或其它Session绑定对象。
reload
Command里可以:
pi.registerCommand("reload-runtime", {
description: "Reload runtime",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});reload()之后当前Handler仍然属于旧调用栈,因此把reload当作终止操作:
await ctx.reload();
return;不要reload后继续依赖旧Extension内存状态。
Provider扩展
Extension还可以注册模型Provider:
pi.registerProvider("local", {
baseUrl: "http://localhost:8080/v1",
apiKey: "local",
api: "openai-completions",
models: [
{
id: "model-id",
name: "Local Model",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
},
],
});Provider属于另一套比较大的接口,只有下面这些需求再深入:
- 自定义模型代理;
- 公司内部Endpoint;
- OAuth登录;
- 动态模型发现。
普通Extension开发不需要先掌握完整Provider接口。
依赖管理
无第三方依赖
单纯使用Pi API和Node内置模块,本地Extension甚至可以没有package.json,只有一个index.ts也可以被加载。
有第三方依赖
在Extension目录或父目录放package.json并安装依赖:
{
"dependencies": {
"some-library": "^1.0.0"
}
}然后:
npm installjiti会正常解析node_modules。
不要混淆运行时依赖和开发依赖
发布Pi Package时,第三方运行时依赖必须放到:
"dependencies"而不是:
"devDependencies"因为Pi安装npm/git Package时默认使用production install,devDependencies不保证运行时存在。
Pi自己的核心包应该声明成peerDependencies,不要打包进去:
{
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
}
}如果代码根本没有import某个Pi包,也不需要为了“模板完整”强行全部声明。
Pi Package
Extension如果需要分发,最终应该包装为Pi Package。
最小package.json:
{
"name": "pi-example",
"version": "0.1.0",
"type": "module",
"keywords": [
"pi-package"
],
"pi": {
"extensions": [
"./index.ts"
]
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"typebox": "*"
}
}也可以使用约定目录:
| 目录 | 资源类型 |
|---|---|
extensions/ | Extension |
skills/ | Skill |
prompts/ | Prompt |
themes/ | Theme |
如果没有显式pi manifest,Pi会自动从这些约定目录发现资源。
但自己的Package建议显式写manifest:
{
"pi": {
"extensions": ["./extensions"]
}
}这样入口一眼就能确定,也不会因为目录结构调整发生意外加载。
本地开发和安装
单文件快速测试:
pi -e ./index.ts整个Package:
pi -e .本地安装:
pi install .全局npm安装:
pi install npm:pi-example项目级安装:
pi install -l npm:pi-example项目级安装会写入.pi/settings.json,全局安装会写入~/.pi/agent/settings.json。
开发阶段优先使用pi -e .,确认稳定后再执行pi install .,避免调试期间不断修改持久配置。
避免调试期间不断修改持久配置。
Reload
放在自动发现目录中的Extension可以通过/reload重新加载。
适合开发循环:
flowchart LR A[修改代码] --> B[/reload] B --> C[测试] C --> A
pi -e ./path.ts更适合一次性测试;长期开发时放到自动发现目录,可以减少频繁重启Pi。
测试
Extension最好拆成两层测试。
核心逻辑单元测试
像HTTP请求组装、Header处理、配置解析这些代码应该是普通函数:
export function buildHeaders(
headers: Record<string, string>,
): Headers {
return new Headers(headers);
}实现文件和对应的*.test.ts直接做普通单元测试,不需要启动Pi。
Extension集成测试
测试入口层时重点验证:
- 能否被Pi加载;
- Tool是否正确注册;
- Command是否存在;
- 事件Hook是否生效;
/reload是否可以正常清理和重建状态。
最简单的Smoke Test:
pi -e .发布前至少检查:
npm pack --dry-run确认npm tarball真正包含Extension入口、运行时源码、必要资源和package.json。
不要只检查Git仓库里“文件存在”,npm tarball才是用户实际安装到的内容。
发布
发布前检查package.json:
-
name和version正确; -
type为module; -
keywords包含pi-package; -
pi.extensions入口正确; - Runtime Dependencies正确;
-
peerDependencies正确; -
files没有漏掉运行时源码。
然后:
npm pack --dry-run
npm publish发布后安装验证:
pi install npm:pi-example更新Package:
pi update npm:pi-example或者更新所有Extension Package:
pi update --extensions如果用户安装的是固定版本,例如npm:pi-example@1.0.0,则不会自动漂移到新的版本。
安全边界
Extension和Skill最大的区别之一是:Extension是真实代码执行。
Extension拥有与Pi进程相同的系统权限,可以:
- 读取和写入文件;
- 执行Process;
- 访问网络;
- 读取环境变量;
- 操作凭据;
- 修改Agent行为。
因此第三方Extension不能按普通Prompt看待。
自己的安全规则:
- 第三方Extension安装前先看源码;
- 不可信项目不要直接信任项目级
.pi/extensions; - Tool里涉及破坏性操作时增加明确权限检查;
- 不把Secret写进Tool Result或Session;
- 长期资源必须在
session_shutdown清理; - 网络Tool设置超时并支持AbortSignal;
- 文件修改加入Mutation Queue;
- 大输出必须截断。
常见坑
在factory启动Watcher
| 阶段 | 应做的事情 |
|---|---|
| Extension Factory | 只注册Hook,不直接启动长期Process、Socket或Watcher |
session_start | 启动Session级资源 |
session_shutdown | 清理Session级资源 |
只用内存保存状态
let state = {};只能当缓存,不能当Source of Truth。
需要考虑reload、fork、resume、新Session和进程重启。
返回isError而不是throw
错误:
return {
isError: true,
};正确:
throw new Error("request failed");用Type.Union做字符串枚举
Google Provider兼容性可能有问题,使用:
StringEnum([...])Tool输出无限增长
日志、搜索结果、HTTP Body、编译输出和测试输出都需要有明确的截断策略。
文件Tool没有处理并发
Pi Tool默认可能并发,不要假定“Tool A执行完成后Tool B才开始”。
写文件必须考虑Mutation Queue。
reload后继续使用旧状态
await ctx.reload();后面直接return。
Session替换后使用旧ctx
只使用withSession传进来的新Context。
默认认为一定有UI
先看:
ctx.hasUI尤其Extension如果可能运行在JSON / Print模式。
旧包名和新包名混用
当前开发统一使用@earendil-works/*。老项目如果仍固定在Pi <= 0.73.1,再保留@mariozechner/*。
不要在一个新Package里无理由混用两个scope。
自己的Extension设计约定
以后自己的Extension按下面方式组织:
| 层次 | 约定 |
|---|---|
| 入口 | index.ts只负责Pi能力注册和组装 |
| 实现 | src/或Feature模块承载真正业务逻辑 |
| 测试 | *.test.ts优先测试普通TypeScript逻辑 |
Tool设计:
flowchart LR A[输入Schema尽可能严格] --> B[execute只做一件明确的事] B --> C[content只给模型必要结果] C --> D[details保存结构信息] D --> E[大输出落盘 + 摘要]
状态:
| 存储位置 | 定位 |
|---|---|
| Module Variable | 当前Session缓存 |
Session / Tool details | 可恢复状态 |
| 外部文件 / DB | 长期状态 |
交互:
| 触发方式 | 机制 |
|---|---|
| LLM应该调用 | Tool |
| 用户应该调用 | Command |
| 自动发生 | Event Hook |
| 简单UI | ctx.ui Helper |
| 复杂UI | Custom Component |
发布:
flowchart LR A[本地逻辑测试] --> B[pi -e .] B --> C[/reload测试] C --> D[npm pack --dry-run] D --> E[npm publish] E --> F[pi install npm:package]
快速模板
普通Tool型Extension可以直接从下面开始:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "example",
label: "Example",
description: "Run example operation",
promptSnippet: "Run the example operation",
parameters: Type.Object({
input: Type.String(),
}),
async execute(_toolCallId, params, signal, onUpdate, ctx) {
signal?.throwIfAborted();
onUpdate?.({
content: [{ type: "text", text: "Working..." }],
details: {},
});
try {
const result = await doSomething(params.input, {
cwd: ctx.cwd,
signal,
});
return {
content: [
{
type: "text",
text: result.summary,
},
],
details: result,
};
} catch (error) {
throw error instanceof Error
? error
: new Error(String(error));
}
},
});
pi.on("session_shutdown", async () => {
// cleanup
});
}入口文件里不继续堆业务逻辑;doSomething()放到独立模块测试。