返回课程首页

24

Extension API 与 Runtime 原理

理解注册阶段、运行阶段与 AgentSession Bindings。

源码基线:Pi v0.82.0 · commit 518855d

上一章看到 Loader 调用:

await factory(api);

为什么不直接写成:

await factory(agentSession);

因为 Pi 希望 Extension 依赖一组稳定能力,而不是依赖 AgentSession 的全部内部字段。本章把 ExtensionAPIExtensionContextExtensionRuntime 与 Bindings 一层层拆开。

1. 三个对象,三个时机

flowchart LR
    L["加载阶段"] --> API["ExtensionAPI<br/>注册 + 委托 Action"]
    E["事件/Tool 执行"] --> CTX["ExtensionContext<br/>当前运行快照"]
    C["用户命令"] --> CMD["ExtensionCommandContext<br/>会话替换能力"]
    API --> RT["Shared Runtime"]
    CTX --> R["ExtensionRunner"]
    CMD --> R
    RT --> B["AgentSession Bindings"]
对象 谁拿到 主要用途
ExtensionAPI Factory 闭包 注册能力、发起 Runtime Action
ExtensionContext Event Handler / Tool 读取当前 Run 状态、Abort、Compact
ExtensionCommandContext Command Handler 等待 Idle、New/Fork/Switch/Reload

2. ExtensionAPI 的两半

Registration Methods

on
registerTool
registerCommand
registerShortcut
registerFlag
registerMessageRenderer
registerEntryRenderer

这些方法写入当前 Extension 的 Map。

Action Methods

sendMessage / sendUserMessage / appendEntry
setSessionName / setLabel
getActiveTools / setActiveTools
setModel / setThinkingLevel
registerProvider / unregisterProvider

这些方法不直接操作 AgentSession,而是委托 Shared Runtime。

exec() 是一个特殊 Action:Loader 构造 API 时就把它连接到 execCommand(),因此它不像 大多数 Session Action 那样等待 Bind。

3. 为什么注册与动作分开

Factory 阶段 AgentSession 可能尚未创建,但 Loader 已经需要知道:

  • 有哪些 Tool;
  • 订阅哪些 Event;
  • 有哪些 Command/Flag;
  • 哪些 Provider 要注册。

所以先声明,后连接:

Factory: “我提供什么”
Bindings: “这些动作在当前宿主里怎样实现”
Runner: “什么时候调用谁”

4. ExtensionRuntime 是什么

源码把 Runtime 定义成:

type ExtensionRuntime =
  ExtensionRuntimeState
  & ExtensionActions;

State 包含:

  • Flag Values;
  • Pending Provider Registrations;
  • Active/Stale Guard;
  • Provider Register/Unregister 委托。

Actions 包含:

  • Message;
  • Session Metadata;
  • Tool Active Set;
  • Model 与 Thinking Level;
  • Command 查询。

所有 ExtensionAPI 指向同一个 Runtime,因此 Bind 一次后,之前创建的 API 闭包都能使用新 实现。

5. 加载前的 Throwing Stub

createExtensionRuntime() 先填入:

const notInitialized = () => {
  throw new Error(
    "Extension runtime not initialized. " +
    "Action methods cannot be called during extension loading.",
  );
};

这会阻止 Factory 过早调用 sendMessage()setModel() 等。

为什么不用 undefined?明确抛错能告诉 Extension 作者“时机不对”,而不是得到难以定位的 Null Error。

6. Provider 为什么可以在 Factory 注册

Provider 是启动阶段需要的资源,所以 registerProvider() 有 Pre-bind Queue:

Factory registerProvider
→ pendingProviderRegistrations
→ Runner.bindCore
→ ModelRegistry.registerProvider
→ 清空 Queue

Bind 完成后,Runtime 的 Provider Action 被替换为立即生效的实现,不再要求 Reload。

这是一项有意设计的例外,不代表所有 Action 都能在 Factory 调用。

7. AgentSession 怎样 Bind Core

_bindExtensionCore() 调用 runner.bindCore(),提供两组对象。

ExtensionActions

连接 API Action:

sendMessage → sendCustomMessage
appendEntry → SessionManager.appendCustomEntry
setActiveTools → Tool Registry
setModel → Model Runtime
setThinkingLevel → Agent State

ExtensionContextActions

连接运行时状态:

getModel / isIdle / isProjectTrusted
getSignal / abort / hasPendingMessages
getContextUsage / compact
getSystemPrompt

Runner 只知道函数签名,不知道 AgentSession 私有字段。

8. ExtensionContext 为什么每次创建

Runner 发 Event 时调用 createContext()。Context 的 Property 使用 Getter:

get model() {
  return getModel();
}

它不会在 Extension 加载时把 Model 固定下来。切换 Model 后,下一次读取 ctx.model 得到 当前值。

同理:

  • ctx.signal 指向当前 Run 的 Abort Signal;
  • ctx.isIdle() 查询当前状态;
  • ctx.getSystemPrompt() 查询当前有效 Prompt;
  • ctx.sessionManager 是 Readonly View。

9. Context Actions 能做什么

ExtensionContext 面向普通 Handler 与 Tool:

pi.on("agent_end", async (_event, ctx) => {
  const usage = ctx.getContextUsage();
  if (usage?.percent && usage.percent > 80) {
    ctx.compact();
  }
});

主要能力:

  • 观察 CWD、Model、Thinking;
  • 判断 Idle、Trust、Pending Messages;
  • 读取 Context Usage;
  • Abort 当前 Operation;
  • 触发 Compaction;
  • 请求 Graceful Shutdown;
  • 读取 Effective System Prompt。

compact() 是 Fire-and-forget;Completion/Error 通过 Options Callback 处理。

10. 为什么 SessionManager 是只读的

普通 Handler 在 TypeScript 能力面得到 ReadonlySessionManager,可以观察 Entry,但不应 任意改写 Session Tree。它是 Pick<SessionManager, ...>,不是运行时 Readonly Proxy; Runner 实际返回同一个 SessionManager Instance,恶意代码仍可用 Cast/Reflection 触及写 方法。这是类型化 Extension 的架构约束,不是安全隔离。

需要持久化 Extension State 时,用:

pi.appendEntry("approval-policy", {
  version: 1,
  allowedDomains: ["internal.example"],
});

Custom Entry 不进入 LLM Context。它经过 AgentSession 的正式持久化入口,更容易保持 JSONL 结构一致。

11. Command Context 多了什么

Command 是用户显式发起的控制操作,因此它可以获得:

waitForIdle
newSession
fork
navigateTree
switchSession
reload
getSystemPromptOptions

这些能力不会放进普通 Event Context。否则任意 message_update Handler 都能在 Streaming 中途偷偷替换 Session,生命周期会非常难以推理。

12. Session 替换后的 Fresh Context

newSession()fork()switchSession() 接受 withSession

await ctx.newSession({
  withSession: async (nextCtx) => {
    await nextCtx.sendUserMessage("继续初始化");
  },
});

nextCtx 绑定替换后的 Session。API 契约明确警告:Session Replacement 或 Reload 后不应 继续使用捕获的旧 pi/Command Context;替换后的工作放进 withSession()

在 v0.82.0 基线中,Session Dispose/Replacement 会调用 runner.invalidate(),旧句柄会由 Guard 拒绝;AgentSession.reload() 当前直接重建 Runtime,却没有显式 Invalidate 旧 Runner。这是实现缺口,不能依赖 Reload 自动拒绝旧句柄,Extension 仍必须遵守契约主动停止 使用。

这避免把旧 Session 的 Action 错发到新 Session。

13. Runtime State 怎样保存

Runtime 内置保存的是运行期基础状态,例如 Flag Values 与 Pending Provider Queue。

它不自动保存 Extension 的任意闭包变量:

let count = 0; // Reload 后 Factory 重跑,重新变成 0

生命周期要分清:

  • 同一个持久化 Session 的 Reload/Resume:可从 Custom Entry 恢复;
  • In-memory Session:进程结束后不会持久;
  • New/Switch 到无关 Session:不会自动携带原 Session Entry;
  • 跨 Session 或全局状态:使用外部 Store。

Fork 只可能继承所选 Branch 中已有的 Entry,不能把它当全局数据。

14. Tool Definition 为什么也分层

Extension 注册 ToolDefinition

{
  name,
  description,
  parameters,
  execute(..., ctx)
}

Agent Core 需要 AgentTool。完整管线分工是:

  1. 通用 Wrapper 保留 Schema 与 prepareArguments,并注入 Context;
  2. Agent Core 在 prepareToolCall() 中执行 Prepare 和 Schema Validation;
  3. Registered-tool Wrapper 观察执行前后 Active Tool Set;
  4. 必要时给 Result 附加 addedToolNames

Extension 不需要依赖 AgentSession 私有 Tool Registry。

15. Extension Actions 与 Context Actions 的区别

类型 调用入口 例子
API Action 捕获的 pi pi.sendMessage()
Context Action 当前 ctx ctx.abort()
Command Context Action Command 的 ctx ctx.newSession()

经验规则:

  • 注册和异步通知用 pi
  • 与当前 Event/Run 绑定的动作优先用 ctx
  • 会话替换只在 Command Context;
  • Session Replacement 后使用 Fresh withSession Context。

16. Extension Event Bus

pi.events 是 Loader 为这组 Extension 共享的 EventBus。它允许 Extension 之间传递自定义 事件,但不等于 Agent Lifecycle Event:

pi.on("tool_call", ...)  → ExtensionRunner 管理
pi.events.emit(...)      → Extension 间自定义通信

Event Bus 的协议、Payload Version 和 Cleanup 由 Extension 作者共同维护。

17. 没有桌面 Binding 时会怎样

Runner 有 No-op UI Context 和默认 Context Action。完整 coding-agent 会绑定 Core; SDK 宿主还需通过 AgentSession.bindExtensions() 提供它需要的外围能力,例如:

  • UI Context;
  • Command Context Actions;
  • Abort/Shutdown Integration;
  • Extension Error Listener。

本课程不使用 Pi TUI。桌面 Agent 可以实现自己的 ExtensionUIContext 适配层,或只使用不 依赖 Dialog 的 Extension。没有可用 UI 时必须检查 ctx.hasUI,不能把 No-op Confirm 当作 用户批准。

18. 解耦并不等于无权限

API/Runtime/Bindings 带来结构解耦:

  • Extension 不引用 AgentSession Private Field;
  • 不同 Mode 可提供不同 Binding;
  • Core 可以替换实现而保持接口;
  • 测试可注入 Fake Action。

但 Extension Module 仍是本地代码,能直接导入 Node API。这个设计控制的是架构耦合,不是 Security Sandbox。

19. 完整调用示例

sequenceDiagram
    participant F as Factory
    participant API as ExtensionAPI
    participant RT as Shared Runtime
    participant R as ExtensionRunner
    participant S as AgentSession

    F->>API: registerTool(definition)
    API->>API: extension.tools.set(...)
    S->>R: bindCore(actions, contextActions)
    R->>RT: 替换 Stub
    S->>R: emit(tool event)
    R->>R: createContext()
    R->>F: handler(event, ctx)
    F->>RT: pi.appendEntry(...)
    RT->>S: bound appendEntry

20. 常见误区

“ExtensionAPI 就是 AgentSession”

错误。它是注册面与一组委托 Action。

“ctx 是加载时状态快照”

错误。Runner 在调用时创建,Getter 读取当前状态。

“所有 Session 修改都能从普通 Handler 调”

错误。New/Fork/Switch/Reload 属于 Command Context。

“闭包变量会由 Runtime 自动持久化”

错误。持久状态要写 Session Entry 或外部 Store。

“No-op UI 的 confirm 可以当默认允许”

错误。No-op Confirm 返回 False;更重要的是宿主应依据 hasUI 明确设计无 UI Policy。

21. 本章小结

  • ExtensionAPI 由 Registration 与 Runtime Action 两部分组成;
  • ExtensionRuntime 是所有 API 共享的 State + Action 委托;
  • 大多数 Action 在 Bind 前抛错,Provider Registration 进入专用 Queue;
  • AgentSession 通过 Runner Bind Core Actions,不把自身完整暴露给 Extension;
  • Context 在调用时创建,Getter 读取当前 Model、Signal 和 Session 状态;Readonly SessionManager 只是 TypeScript 能力面;
  • 普通 Context、Command Context 和 Fresh Replacement Context 权限不同;
  • appendEntry() 是 Extension State 的正式 Session 持久化入口;
  • ToolDefinition 经 Wrapper 适配为 AgentTool;
  • Event Bus 是 Extension 间协议,不是 Agent Lifecycle Event;
  • 结构解耦不构成代码沙箱。

22. 自测

  1. ExtensionAPI 的 Registration 和 Action 有什么区别?
  2. Shared Runtime 为什么先放 Throwing Stub?
  3. Provider Registration 为什么能在 Factory 阶段使用?
  4. ExtensionContext 为什么用 Getter?
  5. 哪些能力只属于 Command Context?
  6. Session Replacement 后为什么要使用 withSession 的 Fresh Context?
  7. 闭包状态和 appendEntry() 状态有什么生命周期差异?
  8. API 解耦为什么不等于 Extension 被沙箱隔离?