上一章看到 Loader 调用:
await factory(api);
为什么不直接写成:
await factory(agentSession);
因为 Pi 希望 Extension 依赖一组稳定能力,而不是依赖 AgentSession 的全部内部字段。本章把
ExtensionAPI、ExtensionContext、ExtensionRuntime 与 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。完整管线分工是:
- 通用 Wrapper 保留 Schema 与
prepareArguments,并注入 Context; - Agent Core 在
prepareToolCall()中执行 Prepare 和 Schema Validation; - Registered-tool Wrapper 观察执行前后 Active Tool Set;
- 必要时给 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
withSessionContext。
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. 自测
- ExtensionAPI 的 Registration 和 Action 有什么区别?
- Shared Runtime 为什么先放 Throwing Stub?
- Provider Registration 为什么能在 Factory 阶段使用?
- ExtensionContext 为什么用 Getter?
- 哪些能力只属于 Command Context?
- Session Replacement 后为什么要使用
withSession的 Fresh Context? - 闭包状态和
appendEntry()状态有什么生命周期差异? - API 解耦为什么不等于 Extension 被沙箱隔离?