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
扩展模型ProviderregisterProvider
扩展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.tsExtension入口,只负责注册Pi能力
tool.tsTool定义或适配层
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.tsextensions/*.ts注册Tool、Command、Hook、Provider等Pi能力
业务实现层src/ 或同级Feature模块普通TypeScript逻辑,尽量不依赖TUI和Session上下文
测试层*.test.tstest/测试可独立运行的核心逻辑
包配置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_callTool执行前检查、修改参数、阻止执行
tool_resultTool执行后修改结果
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

开发时需要区分:

对象作用
piExtensionAPI,用于注册和控制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校验的参数
signalAbortSignal,用于取消执行
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.",
    ],
    // ...
});

三者作用不同:

字段作用
descriptionTool Schema中的工具说明
promptSnippetAvailable 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 detailsTool 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.modectx.hasUI
Interactivetuitrue
RPCrpctrue
JSONjsonfalse
Printprintfalse

因此需要交互时先考虑:

if (!ctx.hasUI) {
    return;
}

不要默认所有Extension调用都有终端交互环境。

自定义Renderer

Tool可以用renderCallrenderResult控制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 install

jiti会正常解析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

  • nameversion正确;
  • typemodule
  • 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看待。

自己的安全规则:

  1. 第三方Extension安装前先看源码;
  2. 不可信项目不要直接信任项目级.pi/extensions
  3. Tool里涉及破坏性操作时增加明确权限检查;
  4. 不把Secret写进Tool Result或Session;
  5. 长期资源必须在session_shutdown清理;
  6. 网络Tool设置超时并支持AbortSignal;
  7. 文件修改加入Mutation Queue;
  8. 大输出必须截断。

常见坑

在factory启动Watcher

阶段应做的事情
Extension Factory只注册Hook,不直接启动长期Process、Socket或Watcher
session_start启动Session级资源
session_shutdown清理Session级资源

只用内存保存状态

let state = {};

只能当缓存,不能当Source of Truth。

需要考虑reloadforkresume、新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
简单UIctx.ui Helper
复杂UICustom 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()放到独立模块测试。

参考资料