Pi Agent Extensions 开发与实现机制

Pi 的 extension 不是一个单纯的事件监听器,也不是传统意义上带独立沙箱的插件。它是一段与 Pi 同进程运行的 TypeScript/JavaScript 代码,通过统一 API 向 AgentSession 注册工具、命令、事件处理器、Provider 和 UI 能力。

从实现上看,完整的扩展系统由五层组成:

  1. DefaultPackageManager 解析扩展来源、作用域、启用状态和优先级。
  2. DefaultResourceLoader 处理 project trust、两阶段加载、缓存和动态资源。
  3. loader.tsjiti 导入模块,执行 factory,生成静态注册表。
  4. ExtensionRunner 把注册表绑定到当前 session,并按不同规则分发事件。
  5. AgentSession 把 runner 接入 prompt、agent loop、工具、Provider、会话和 UI 生命周期。

本章重点解释这条实现链路。API 的最终类型定义以 packages/coding-agent/src/core/extensions/types.ts 为准,相关文件与符号位置见源码索引

1. 总体架构

flowchart TD Source["settings / .pi/extensions / pi package / CLI -e / SDK factory"] PM["DefaultPackageManager<br/>解析、过滤、排序、去重"] RL["DefaultResourceLoader<br/>project trust 与两阶段加载"] Loader["loader.ts + jiti<br/>导入模块并执行 factory"] Ext["Extension[]<br/>handlers / tools / commands / flags / renderers"] Runtime["ExtensionRuntime<br/>共享状态与延迟绑定动作"] Runner["ExtensionRunner<br/>context、冲突处理、事件合并"] Session["AgentSession"] Agent["pi-agent-core Agent / agentLoop"] Mode["TUI / RPC / JSON / Print"] Source --> PM --> RL --> Loader Loader --> Ext Loader --> Runtime Ext --> Runner Runtime --> Runner Runner <--> Session Session <--> Agent Mode --> Session Mode --> Runner

这里最重要的设计是“注册”和“运行”分离:

  • factory 执行时主要构造注册表。
  • session 创建后,runner 才把 pi.sendMessage()pi.setModel() 等动作绑定到真实对象。
  • 每次 new/resume/fork 都会创建新的 session、resource loader 结果和 runner。
  • 同一个扩展可以在 TUI、RPC、JSON、print 模式运行,UI 能力由 mode 注入。

2. 扩展模块的最小形态

扩展默认导出一个同步或异步 factory:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function extension(pi: ExtensionAPI) {
  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") return;

    const command = String(event.input.command ?? "");
    if (!command.includes("rm -rf")) return;

    if (!ctx.hasUI) {
      return { block: true, reason: "非交互模式拒绝危险命令" };
    }

    const allowed = await ctx.ui.confirm("危险命令", command);
    if (!allowed) {
      return { block: true, reason: "用户拒绝执行" };
    }
  });

  pi.registerTool({
    name: "project_summary",
    label: "Project Summary",
    description: "Summarize the current project",
    parameters: Type.Object({}),
    async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
      const result = await pi.exec("git", ["status", "--short"], {
        cwd: ctx.cwd,
        signal,
      });
      return {
        content: [{ type: "text", text: result.stdout || "Working tree clean" }],
        details: { exitCode: result.code },
      };
    },
  });

  pi.registerCommand("extension-info", {
    description: "Show extension runtime information",
    handler: async (_args, ctx) => {
      ctx.ui.notify(`mode=${ctx.mode}, cwd=${ctx.cwd}`);
    },
  });
}

factory 可以是 async,loader 会等待它完成。不过加载阶段还没有可用的 session runtime,不应该在 factory 顶层调用 sendMessagesetModelsetActiveTools 等 session 动作。

加载阶段可以安全完成的工作主要是:

  • pi.on(...)
  • pi.registerTool(...)
  • pi.registerCommand(...)
  • pi.registerShortcut(...)
  • pi.registerFlag(...)
  • pi.registerMessageRenderer(...)
  • pi.registerEntryRenderer(...)
  • pi.registerProvider(...),此时先进入待处理队列
  • pi.exec(...),它直接使用扩展加载时的 cwd 执行,不依赖 session bind

3. 资源来源、发现规则与优先级

3.1 扩展来源

Pi 可以从以下入口得到 extension:

来源 典型位置或入口 作用域
项目自动发现 <cwd>/.pi/extensions/ project
用户自动发现 ~/.pi/agent/extensions/ user
settings 显式路径 .pi/settings.json 或用户 settings.jsonextensions project / user
Pi package settings 的 packages,来源可为 npm、git、本地路径 project / user
CLI 临时扩展 pi -e ./extension.ts temporary
SDK 路径 additionalExtensionPaths temporary
SDK factory extensionFactories inline temporary

--no-extensions 会关闭常规 settings、package 和自动发现扩展,但 CLI 临时扩展仍然加载;SDK 的 inline factory 也由 SDK 显式控制。

3.2 目录发现

extensions/ 目录,自动发现只检查当前目录及下一层入口,不做无限递归:

extensions/
├── audit.ts              # 直接加载
├── metrics.js            # 直接加载
├── deploy/
│   └── index.ts          # 作为一个扩展加载
└── package-style/
    ├── package.json      # 若有 pi.extensions,按 manifest 加载
    └── src/...

一个目录的入口解析顺序是:

  1. package.json 中非空的 pi.extensions
  2. index.ts
  3. index.js

如果目录本身已经命中入口,就把它作为一个扩展单元,不再扫描其下所有 .ts 文件。自动扫描会忽略隐藏项、node_modules,并应用 .gitignore.ignore.fdignore 规则。

3.3 资源优先级

Package manager 给每个路径附加 PathMetadata

interface PathMetadata {
  source: string;
  scope: "user" | "project" | "temporary";
  origin: "package" | "top-level";
  baseDir?: string;
}

常规资源从高到低排序为:

  1. project settings 显式资源
  2. project 自动发现资源
  3. user settings 显式资源
  4. user 自动发现资源
  5. package 内资源

在 ResourceLoader 组装 extension 列表时,CLI/SDK 的临时路径放在常规资源之前,inline factory 放在路径扩展之后。路径还会经过 canonical path 去重。

优先级不仅影响显示顺序,也影响冲突结果,因为大部分 extension registry 都按加载顺序解析。

4. Project Trust 与两阶段加载

项目扩展拥有进程权限,因此 Pi 不能在用户确认信任之前执行 <cwd>/.pi/extensions/。为了解决“扩展本身可能想实现 trust UI”这个循环依赖,ResourceLoader 使用两阶段加载。

sequenceDiagram participant Main as main/createRuntime participant RL as DefaultResourceLoader participant PM as PackageManager participant L as Extension Loader participant T as Trust Resolver Main->>RL: reload(resolveProjectTrust) RL->>RL: setProjectTrusted(false) RL->>PM: resolve() Note over PM: 仅用户/全局资源可见 RL->>L: 加载用户扩展、CLI 扩展、inline factory RL->>T: project_trust T-->>RL: trusted / rejected RL->>RL: 保存本次 trust 状态并 reload settings RL->>PM: 按最终 trust 状态重新 resolve() RL->>L: 复用预加载扩展,补载剩余项目扩展 L-->>RL: 最终有序 Extension[] + 共享 runtime

具体规则:

  • bootstrap pass 强制 projectTrusted=false
  • project_trust 只由 user/global、CLI 和 inline 扩展参与。
  • handler 使用受限的 ProjectTrustContext,只能访问 cwd、mode、hasUI 和少量 dialog API。
  • 按扩展和注册顺序串行执行;第一个返回 yesno 的 handler 胜出,undecided 继续。
  • handler 都不决定时,再查询 trust store、defaultProjectTrust 和内置确认 UI。
  • 信任后,project settings、project package、.pi/extensions.agents/skills 等资源才进入最终解析。
  • bootstrap 已成功导入的扩展对象会被复用,避免 factory 在一次启动中执行两次;其余路径使用同一个 ExtensionRuntime 继续加载。
  • 最后重新按最终的 extension path 顺序排列,保证优先级稳定。

如果项目没有任何需要 trust 的动态资源,Pi 直接视为 trusted,不显示确认。

5. 模块加载:jiti、alias 与 cache

loader.tsjiti 导入 extension,所以开发者可以直接编写 TypeScript,不需要先手工编译。

加载过程如下:

  1. 相对路径按当前 runtime cwd 解析,并规范 Unicode 空格。
  2. 为本次导入创建 jiti 实例。
  3. Node/开发模式使用 alias,把 Pi 核心包解析到当前 workspace 或已安装包。
  4. Bun 单文件二进制使用 virtualModules,把编译进二进制的模块暴露给扩展。
  5. 以 default export 导入模块。
  6. 验证 default export 是函数。
  7. 创建空的 Extension 对象和对应的 ExtensionAPI
  8. await factory(api),把注册结果写入该 Extension
  9. 导入或 factory 失败时记录到 LoadExtensionsResult.errors,继续加载其他扩展。

Pi 为扩展暴露的核心 import 包括:

  • @earendil-works/pi-coding-agent
  • @earendil-works/pi-agent-core
  • @earendil-works/pi-ai
  • @earendil-works/pi-ai/compat
  • @earendil-works/pi-ai/oauth
  • @earendil-works/pi-ai/providers/all
  • @earendil-works/pi-tui
  • typeboxtypebox/compiletypebox/value

同时保留旧 @mariozechner/* 名称的兼容 alias。

jitimoduleCache 被关闭,但 ResourceLoader 另有一层 factory cache:

  • cache key 是解析后的 extension path。
  • cache 绑定 cwd;cwd 改变会清空。
  • /reload 会显式清空。
  • cache 保存的是 factory,不是已经注册完成的 Extension 对象。
  • project-trust 两阶段加载还会直接复用 bootstrap pass 的 Extension 对象。

有第三方依赖的目录应带自己的 package.jsonnode_modules。通过 npm/git 安装的 Pi package 会安装其 runtime dependencies。

6. 内部数据结构

每个 factory 最终产生一个 Extension

interface Extension {
  path: string;
  resolvedPath: string;
  hidden?: boolean;
  sourceInfo: SourceInfo;
  handlers: Map<string, HandlerFn[]>;
  tools: Map<string, RegisteredTool>;
  messageRenderers: Map<string, MessageRenderer>;
  entryRenderers: Map<string, EntryRenderer>;
  commands: Map<string, RegisteredCommand>;
  flags: Map<string, ExtensionFlag>;
  shortcuts: Map<KeyId, ExtensionShortcut>;
}

这是一份“扩展自己的注册表”,不是全局 registry。好处是 Pi 始终知道某个工具、命令或错误来自哪个文件,并能在最后统一处理冲突。

所有 extension API 共享一个 ExtensionRuntime

ExtensionRuntime
├── flagValues
├── pendingProviderRegistrations
├── pendingNativeProviderRegistrations
├── assertActive / invalidate
└── session actions
    ├── sendMessage / sendUserMessage / appendEntry
    ├── setSessionName / setLabel
    ├── getAllTools / setActiveTools / refreshTools
    ├── getCommands
    └── setModel / getThinkingLevel / setThinkingLevel

共享 runtime 解决了两个问题:

  • factory 创建出来的所有 pi.* 闭包能在稍后统一绑定到真实 session。
  • runtime 被 invalidate 后,旧 pi 对象的 session 动作会一致失败,而不是悄悄写到错误会话。

7. 两阶段 API:注册期与绑定期

createExtensionAPI() 中有两类方法。

7.1 注册方法

注册方法直接写入当前 Extension 的 Map:

pi.on                    -> extension.handlers
pi.registerTool          -> extension.tools
pi.registerCommand       -> extension.commands
pi.registerShortcut      -> extension.shortcuts
pi.registerFlag          -> extension.flags + runtime.flagValues
pi.register*Renderer     -> extension.*Renderers

同一个扩展内,Map 类型的同名注册通常是后一次覆盖前一次;事件 handler 使用数组,因此同一事件可以注册多个 handler。

registerTool() 在 runtime 已绑定后仍然可调用。它更新 extension registry 后调用 runtime.refreshTools(),因此动态工具可以在当前 session 立即进入工具 registry。

7.2 动作方法

动作方法闭包只保存共享 runtime 的引用:

sendMessage(message, options) {
  runtime.assertActive();
  runtime.sendMessage(message, options);
}

初始 runtime 中的动作是 throwing stub。AgentSession._bindExtensionCore() 调用 runner.bindCore() 后,才替换成真实实现。

Provider 是特殊情况:

  • factory 中注册 Provider 时先进入 pending queue。
  • bindCore() 得到 ModelRegistry/ModelRuntime 后按顺序 flush。
  • bind 完成后再次注册或注销 Provider 会立即生效,不需要 /reload

8. Runner 如何绑定当前 Session

AgentSession 创建 runner 时注入:

  • 最终 Extension[]
  • 共享 ExtensionRuntime
  • 当前 cwd
  • 当前 SessionManager
  • 当前 ModelRegistry

随后完成三组绑定。

8.1 Core actions

bindCore() 将 API 动作指向当前 AgentSession

  • message API -> sendCustomMessage() / sendUserMessage()
  • entry API -> SessionManager.appendCustomEntry()
  • session metadata -> setSessionName() / appendLabelChange()
  • tools -> AgentSession 的 tool registry
  • model/thinking -> 当前 Agent state 和 ModelRuntime
  • provider -> ModelRuntime 的 register/unregister

8.2 Context actions

事件 handler 的 ExtensionContext 不是静态快照。createContext() 用 getter 和函数在访问时读取:

  • 当前 model、thinking level
  • agent 是否 idle
  • 当前 abort signal
  • pending message
  • context usage
  • system prompt
  • project trust 状态

每次访问前还会执行 assertActive()

8.3 Mode bindings

TUI、RPC、JSON、print 模式通过 bindExtensions() 注入:

  • ExtensionUIContext
  • ExtensionMode
  • command-only session actions
  • abort/shutdown handler
  • extension error listener

然后触发 session_start,再触发 resources_discover

9. ExtensionContext 与 CommandContext

普通事件和工具拿到 ExtensionContext。它适合当前 turn 内的动作,但不允许替换整个 session。

扩展命令拿到 ExtensionCommandContext,额外提供:

  • waitForIdle()
  • newSession()
  • fork()
  • navigateTree()
  • switchSession()
  • reload()
  • getSystemPromptOptions()

这个权限差异是刻意的。命令由用户显式发起,适合执行改变会话拓扑的操作;LLM 可直接调用的工具不应随意切换或重载 session。

createCommandContext() 不使用普通对象 spread,而是复制 property descriptor。这样 modelcwdsessionManager 等 guarded getter 保持惰性,不会在创建 command context 时被固化成旧值。

10. 事件分发不是统一的 EventEmitter

Runner 始终按“extension 加载顺序 → 同一 extension 内 handler 注册顺序”串行执行,但不同事件有不同结果代数。

分发模型 事件 规则
首个决定 project_trust undecided 继续,第一个 yes/no 返回
首个结果 user_bash 第一个非空结果返回
可取消 session_before_switch/fork/compact/tree 保存 handler 结果,遇到 cancel 立即短路
可阻断 tool_call 参数对象可原地修改;遇到 block 立即短路
可接管 input transform 串行传递,handled 立即短路
串行变换 context 当前 messages 传给下一个 handler
串行变换 before_provider_request 当前 payload 传给下一个 handler
原地变换 before_provider_headers handler 直接改共享 headers,返回值忽略
串行变换 message_end replacement 传给下一个 handler,且 role 必须不变
字段累积 tool_result content/details/isError/usage 按返回字段逐步覆盖
多结果聚合 before_agent_start custom message 全部收集,system prompt 串行替换
路径聚合 resources_discover 汇总全部 skill/prompt/theme path,并保留来源
广播通知 其他 lifecycle 事件 调用全部 handler,不消费返回值

这种实现比一个通用 emit() 更复杂,但能明确表达每个插入点的安全语义。

11. 完整事件生命周期

11.1 启动

project_trust              仅在需要解析信任时,且只运行 bootstrap 扩展
session_start              startup / reload / new / resume / fork
resources_discover         startup / reload

resources_discover 发生在 session_start 之后。扩展返回的路径会进入 ResourceLoader,重新装载 skills、prompts、themes,并重建 system prompt。它不能再动态返回 extension path,从而避免运行中递归加载扩展。

11.2 Prompt 与 Agent

sequenceDiagram participant U as User/Host participant S as AgentSession participant R as ExtensionRunner participant A as Agent Loop participant P as Provider participant T as Tool U->>S: prompt(text) S->>S: 检查 extension command S->>R: input R-->>S: continue / transform / handled S->>S: skill/template expansion S->>R: before_agent_start R-->>S: custom messages + system prompt S->>A: agent.prompt(messages) A->>R: agent_start / turn_start A->>R: context R-->>A: transformed messages A->>R: before_provider_headers A->>R: before_provider_request A->>P: HTTP request P-->>A: response stream A->>R: after_provider_response A->>R: message_start/update/end A->>R: tool_execution_start A->>R: tool_call R-->>A: allow / block A->>T: execute T-->>A: partial/final result A->>R: tool_result A->>R: tool_execution_end / turn_end A->>R: agent_end S->>R: agent_settled

关键边界:

  • extension command 在 input 之前匹配,命中后不进入 agent loop。
  • input 在 skill 和 prompt template 展开之前发生。
  • before_agent_start 每次用户启动一次 agent run 时发生,可改本次 system prompt。
  • context 在每次 LLM 调用前发生;一个 agent run 内调用工具后可能再次触发。
  • before_provider_headers/requestafter_provider_response 以真实 provider 请求为粒度。
  • message_update 是流式高频事件,handler 不应执行重 I/O。
  • agent_end 后仍可能发生自动重试、压缩或 queued continuation;只有 agent_settled 表示整个 run 已无自动后续。

11.3 Session

主要会话事件包括:

  • session_before_switch
  • session_before_fork
  • session_before_compact
  • session_before_tree
  • session_compact
  • session_tree
  • session_info_changed
  • session_shutdown

before 事件用于 cancel 或替换摘要结果;after 事件用于同步扩展状态和 UI。

12. Tool 扩展链路

12.1 注册与覆盖

扩展工具先保存为 ToolDefinitionAgentSession._refreshToolRegistry() 会:

  1. 创建内置工具定义。
  2. 收集 extension 注册工具和 SDK customTools
  3. 用自定义工具覆盖同名内置定义。
  4. 构建 prompt snippets 和 prompt guidelines。
  5. 通过 wrapRegisteredTools() 转成 AgentTool
  6. 更新 active tool names 和 Agent state。

注册与激活是两回事:

  • pi.getAllTools() 返回所有已配置定义和来源信息。
  • pi.getActiveTools() 返回当前暴露给模型的工具名。
  • pi.setActiveTools() 修改当前 active 集合并重建 system prompt。
  • 动态 registerTool() 会刷新 registry;一个此前不存在的新工具会自动激活,但仍受 SDK 的 allow/exclude 配置约束。同名覆盖会保留原有 active 状态。

同名 extension 工具冲突时,ResourceLoader 记录诊断;ExtensionRunner.getAllRegisteredTools() 对扩展之间采用 first-wins。进入 AgentSession 后,自定义工具可覆盖同名内置工具。

12.2 参数校验与 tool_call

真实执行顺序是:

找到 AgentTool
  -> prepareArguments(rawArgs)
  -> TypeBox schema 校验
  -> tool_call hook
  -> execute(validatedArgs)
  -> tool_result hook
  -> 生成 ToolResultMessage

tool_callevent.input 就是已校验后的参数对象。handler 可以原地修改它,后续 handler 和 execute() 会看到修改结果。

Warning

tool_call 修改参数后不会再次 schema 校验。用于安全策略时应自行验证修改值,不要把未检查的外部输入写回 event.input

tool_call handler 返回 { block: true, reason } 后,agent-core 生成一个 error tool result,真实工具不会执行。handler 自己抛错也会被 agent loop 转成 error tool result,因此该路径采用失败关闭。

12.3 执行与结果

扩展工具的 execute() 比低层 AgentTool.execute() 多一个 ExtensionContext。wrapper 在调用时通过 runner 创建当前 context。

工具结果分成:

  • content: 发给模型的 text/image。
  • details: 给 renderer、session、导出和扩展状态恢复的结构化数据。
  • usage: 工具内部调用模型时的用量。
  • terminate: 当前批次所有工具都为 true 时,可停止自动 follow-up LLM call。

tool_result handler 可串行改写 contentdetailsisErrorusage。未返回的字段保留前一个 handler 的值。

如果工具执行期间动态增加了 active tools,wrapper 会在结果中补充 addedToolNames,供支持 deferred tool loading 的模型感知新增工具。

12.4 并发

agent-core 支持同一 assistant turn 中并行执行工具。扩展可以用:

executionMode: "sequential"

强制某工具与其他调用顺序执行。若自定义工具会读改写文件,还应使用 coding-agent 暴露的 withFileMutationQueue(),按真实目标路径串行化完整的 read-modify-write 窗口。

13. Registry 冲突规则

不同 registry 的冲突规则并不相同:

注册项 冲突行为
tool 扩展间 first-wins 并记录诊断;自定义工具覆盖同名 built-in
flag 汇总时 first-wins,重复名称记录诊断
command 全部保留;重名时 invocation name 变为 name:1name:2
shortcut 后加载扩展覆盖先加载扩展并报警;保留级内置快捷键不可覆盖
message renderer first-wins
entry renderer first-wins
event handler 全部保留并按顺序执行

快捷键的保留列表包含 interrupt、clear、exit、submit、select confirm/cancel 等核心操作,避免扩展让终端失去基本可控性。非保留内置快捷键可以被 extension 覆盖,但会产生 warning。

14. 消息、持久化与分支安全状态

扩展有三种常见状态通道。

14.1 sendMessage

写入 CustomMessage,可显示并进入 LLM context。它支持:

  • steer
  • followUp
  • nextTurn
  • idle 时用 triggerTurn 启动模型

14.2 appendEntry

写入 CustomEntry,持久化到 session JSONL,但不进入 LLM context。适合书签、UI 状态、扩展配置快照等。

14.3 Tool result details

如果状态必须正确跟随 branch/fork,优先把它保存在相关 tool result 的 details,然后在 session_start 中从当前 branch 重建:

export default function extension(pi: ExtensionAPI) {
  let items: string[] = [];

  pi.on("session_start", (_event, ctx) => {
    items = [];
    for (const entry of ctx.sessionManager.getBranch()) {
      if (
        entry.type === "message" &&
        entry.message.role === "toolResult" &&
        entry.message.toolName === "todo"
      ) {
        items = entry.message.details?.items ?? items;
      }
    }
  });

  // execute() 返回 details: { items: [...items] }
}

只保存在模块全局变量或闭包里的状态无法正确跟随 session tree,也会在 /reload 后丢失。

registerMessageRenderer() 渲染 CustomMessage;registerEntryRenderer() 渲染 CustomEntry。renderer 只影响表现,不改变持久化内容或 LLM context。

15. Session 替换、失效与清理

new/resume/fork 由 AgentSessionRuntime 完成,不是在旧 AgentSession 上原地换文件:

session_before_switch / session_before_fork
  -> session_shutdown(old)
  -> host 同步拆除旧 extension UI
  -> old AgentSession.dispose()
  -> old runner/runtime invalidate
  -> 创建新 Settings/Resources/AgentSession/Runner
  -> host rebind 新 session
  -> session_start(new)
  -> resources_discover(new)
  -> withSession(freshCtx)

dispose() 会使旧 runner 和共享 runtime 失效。旧 pi、旧 command ctx 的 guarded API 再被访问时会抛出 stale context 错误。

会话替换后继续工作的正确方式是 withSession

pi.registerCommand("handoff", {
  handler: async (_args, ctx) => {
    const kickoff = "Continue in the new session";

    await ctx.newSession({
      withSession: async (freshCtx) => {
        await freshCtx.sendUserMessage(kickoff);
      },
    });
  },
});

注意:

  • withSession 在新 session 完成 bind、session_start 和动态资源发现后调用。
  • callback 仍属于旧 factory 的 JavaScript 闭包,只应捕获 string、id、序列化配置等纯数据。
  • 不要捕获旧 SessionManager、旧 UI component 或旧 pi 后在 callback 中使用。
  • 长期 timer、watcher、socket 应在 session_shutdown 中清理。

ctx.reload() 会发出 session_shutdown,重新加载 settings、extensions、skills、prompts、themes 和 context files,再用 reason: "reload" 发出 session_start/resources_discover。当前 command handler 的调用栈仍来自旧代码,因此应把 reload 当成 terminal operation:

await ctx.reload();
return;

16. 动态资源发现

扩展可以在 session_start 后提供附加资源:

import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const extensionDir = dirname(fileURLToPath(import.meta.url));

pi.on("resources_discover", (_event, _ctx) => ({
  skillPaths: [join(extensionDir, "skills")],
  promptPaths: [join(extensionDir, "prompts")],
  themePaths: [join(extensionDir, "themes")],
}));

Runner 聚合每个 handler 的路径,并记录提供该路径的 extension。AgentSession 将它们转换成 temporary PathMetadata,ResourceLoader 随后:

  1. 解析成当前 cwd 下的绝对路径。
  2. 重新加载对应资源类型。
  3. 为资源附加 extension source info。
  4. 重建 base system prompt。

返回的相对路径按当前 session cwd 解析,而不是按 extension 文件目录解析。因此 package 内的动态资源通常应像上例一样先构造绝对路径。

该事件在 startup 和 reload 时运行。返回路径应该是幂等的;ResourceLoader 会做 canonical path 去重,但扩展仍不应在 handler 中重复创建不可回收资源。

17. Provider 扩展

pi.registerProvider() 支持两种形式:

  • 原生 pi-ai Provider
  • name + ProviderConfig

ProviderConfig 可以:

  • 覆盖已有 provider 的 base URL。
  • 注册一组新模型。
  • 提供 API key 表达式和自定义 headers。
  • 提供自定义 streamSimple
  • 注册 OAuth login/refresh/getApiKey。
  • 动态 refreshModels()

加载期 Provider 先排队,runner.bindCore() 时 flush 到 ModelRuntime。运行期注册立即更新 registry,并刷新当前 model 引用;unregisterProvider() 会删除扩展 Provider,并恢复被覆盖的内置模型。

Provider 请求还可通过事件扩展:

  • before_provider_headers: 在认证、attribution header 组装后原地增删 header;值为 null 表示删除。
  • before_provider_request: 串行替换最终 provider payload。
  • after_provider_response: 在响应流被消费前读取 status 和 response headers。

18. UI 抽象与模式退化

扩展不直接依赖 InteractiveMode,而是面向 ExtensionUIContext

模式 ctx.mode ctx.hasUI 行为
TUI tui true 完整 dialog、widget、component、editor、footer/header、terminal input
RPC rpc true dialog/notify/status/string widget/title/editor 通过 JSONL bridge
JSON json false UI 为 no-op,agent event 输出 JSON
Print print false UI 为 no-op

RPC 中只有可序列化能力可以跨进程:

  • select/confirm/input/editorextension_ui_request 并等待 response。
  • notify/setStatus/setWidget/setTitle/set_editor_text fire-and-forget。
  • TUI component factory、custom footer/header、raw terminal input、自定义 editor component 不可用。
  • custom() 在 RPC 中返回 undefined

因此:

if (ctx.mode === "tui") {
  // TUI component、terminal input 等终端专属能力
} else if (ctx.hasUI) {
  // TUI 和 RPC 都支持的 dialog
} else {
  // JSON/print 的非交互策略
}

安全 extension 不应在 headless 模式把 no-op confirm 的 false 当成异常;应明确选择拒绝、使用默认值或跳过交互。

19. 错误隔离与失败策略

错误被分成几个层次:

位置 处理方式
模块 import / factory 写入 LoadExtensionsResult.errors,继续加载其他扩展
普通 lifecycle handler runner 捕获并发出 ExtensionError,继续下一个 handler
project_trust 记录错误并继续寻找下一个决定
input/context/result/provider 变换 记录错误,保留当前累计值并继续
message_end role 改变 拒绝 replacement,记录 extension error
tool_call 抛错进入 agent-core tool error result,真实工具不执行
command handler AgentSession 捕获并发出 extension error,命令仍视为已处理
tool execute() agent-core 转为 isError: true 的 tool result,反馈给模型

扩展错误由不同 mode 显示:

  • TUI 通常进入诊断/通知界面。
  • RPC 发出 extension_error
  • JSON/print 写标准错误输出或 mode 对应诊断。

安全相关逻辑应优先放在 tool_call,并在无法完成检查时 block。仅监听 tool_execution_start 不足以阻止执行。

20. Pi Package 分发

Pi package 可以同时分发 extensions、skills、prompts、themes:

{
  "name": "@example/pi-team-workflow",
  "keywords": ["pi-package"],
  "peerDependencies": {
    "@earendil-works/pi-coding-agent": "*",
    "typebox": "*"
  },
  "pi": {
    "extensions": ["./extensions"],
    "skills": ["./skills"],
    "prompts": ["./prompts"],
    "themes": ["./themes"]
  }
}

没有 pi manifest 时,Pi 使用同名约定目录。manifest 路径相对 package root,可使用 glob 和 !exclude。settings 中针对 package 的资源过滤器还支持 +exact-path-exact-path

Package 来源支持:

  • npm:@scope/pkg@version
  • git:github.com/org/repo@ref
  • https://...
  • ssh://...
  • 本地文件或目录

project package 只有在项目 trusted 后才会访问或安装。同一 package 同时出现在 user 和 project settings 时,project 配置优先;autoload:false 可作为 user package 上的 project delta。

Extension 与 Pi 同进程运行,package 安装不是权限隔离。安装第三方包前必须审查其源码和依赖。

21. 推荐的开发模式

21.1 目录结构

简单扩展用单文件即可;有依赖和测试时建议:

my-extension/
├── package.json
├── index.ts
├── src/
├── test/
└── README.md

开发时可临时加载:

pi -e ./my-extension/index.ts

稳定后再放入用户目录、项目目录或打成 Pi package。

21.2 生命周期清单

开发 extension 时至少确认:

  1. factory 顶层是否只做注册和轻量初始化。
  2. watcher、timer、socket 是否在 session_shutdown 清理。
  3. 状态能否从当前 branch 的 session entry 重建。
  4. TUI 专属代码是否使用 ctx.mode === "tui" 保护。
  5. tool_call 修改参数后是否自行验证。
  6. 文件 mutation 是否考虑并行调用。
  7. ctx.reload() 后是否立即 return。
  8. new/fork/switch 后是否只使用 withSession 的 fresh context。
  9. command、tool、flag、shortcut 名称是否可能冲突。
  10. headless/RPC 模式是否有明确退化策略。

22. 关键源码阅读顺序

建议按以下顺序阅读实现:

  1. packages/coding-agent/src/core/extensions/types.ts
  2. packages/coding-agent/src/core/extensions/loader.ts
  3. packages/coding-agent/src/core/resource-loader.ts
  4. packages/coding-agent/src/core/package-manager.ts
  5. packages/coding-agent/src/core/extensions/runner.ts
  6. packages/coding-agent/src/core/extensions/wrapper.ts
  7. packages/coding-agent/src/core/sdk.ts
  8. packages/coding-agent/src/core/agent-session.ts
  9. packages/coding-agent/src/core/agent-session-runtime.ts
  10. packages/coding-agent/src/modes/rpc/rpc-mode.ts

示例入口:

  • packages/coding-agent/examples/extensions/README.md
  • packages/coding-agent/examples/extensions/permission-gate.ts
  • packages/coding-agent/examples/extensions/dynamic-tools.ts
  • packages/coding-agent/examples/extensions/dynamic-resources/index.ts
  • packages/coding-agent/examples/extensions/reload-runtime.ts
  • packages/coding-agent/examples/extensions/tool-override.ts
  • packages/coding-agent/examples/extensions/subagent/

23. 设计总结

Pi extensions 的核心机制可以概括为:

  • PackageManager 负责“哪些代码有资格加载”。
  • ResourceLoader 负责“何时加载”,尤其是 project trust 和 reload。
  • Loader 负责“把模块变成注册表”。
  • Runtime 负责“把加载期闭包延迟绑定到当前 session”。
  • Runner 负责“按事件语义组合多个扩展”。
  • AgentSession 负责“把扩展插到真实产品生命周期里”。
  • AgentSessionRuntime 负责“替换 session,并让旧上下文失效”。
  • UIContext 负责“让相同扩展跨 TUI、RPC 和 headless 运行”。

它的强大之处不只在 hook 数量,而在于每个 hook 都有清晰的执行顺序、合并规则、生命周期和信任边界。