到目前为止,Extension 都在改变已有 Agent。现在把边界再向外推:
- 接入公司模型网关;
- 动态发现模型;
- 动态提供 Skill;
- 把 Bash 放进容器或远程主机;
- 让多个 Extension 协作;
- 在 Session Branch 中恢复状态。
这些能力都很强,也更接近系统权限和供应链边界。
1. 高级扩展的五条通路
flowchart TD
E["Extension"] --> P["Provider / Auth / Models"]
E --> R["Skill / Prompt / Theme Paths"]
E --> X["Tool Operations / Bash Spawn"]
E --> B["Inter-extension Event Bus"]
E --> S["Session / External State"]
Pi 没有把它们包装成一个“插件万能对象”;每条通路有独立 Contract。
2. 两种 Provider 注册形式
完整 Provider Object
pi.registerProvider(
createProvider({
id: "company-ai",
name: "Company AI",
baseUrl: "https://ai.internal/v1",
auth: { /* canonical auth */ },
models: [],
api: openAICompletionsApi(),
}),
);
它可提供 Native Auth、Model Discovery/Refresh/Filter、Stream/StreamSimple 等完整行为。
Name + ProviderConfig
pi.registerProvider("company-ai", {
baseUrl: "https://ai.internal/v1",
apiKey: "$COMPANY_AI_KEY",
api: "openai-completions",
models: [/* definitions */],
});
这是兼容配置形式,适合 Proxy、固定 Catalog 和 Legacy OAuth。
3. 注册发生在什么时候
Factory 阶段:
registerProvider
→ Pending Queue
→ Runner.bindCore
→ Model Registry
绑定后从 Command/Event 再注册或注销,会立即作用于当前 Registry,不需要 Reload。
unregisterProvider(name) 按 Provider ID 从共享 Registry 删除 Provider/Models,并恢复被
覆盖的 Built-in Models(若有)。这里没有“只能注销本扩展所注册 Provider”的所有权检查;
多个 Extension 共用 Registry,因此 Provider ID 必须命名空间化,宿主也应限制谁能注销谁。
4. 三种动态模型刷新契约
这些 API 名字相近,但返回值和持久化责任不同。
Legacy ProviderConfig
ProviderConfig.refreshModels() 返回 ProviderModelConfig[]:
let cachedModels: ProviderModelConfig[] = [];
async refreshModels({ signal, allowNetwork }) {
if (!allowNetwork) return cachedModels;
const response = await fetch(
"http://localhost:8080/v1/models",
{ signal },
);
const payload = await response.json();
cachedModels = payload.data.map(toModelConfig);
return cachedModels;
}
刷新返回的列表替换该 Extension 提供的 Models。allowNetwork=false 表示只允许离线/缓存
刷新;Provider 注册时也会主动触发一次这种刷新,不能无条件请求网络。Context 还提供
allowNetwork、Credential、Store 与 Signal;它没有额外的 Network Client。
完整 Provider Object
完整 Provider.refreshModels() 返回 Promise<void>,刷新后通过同步 getModels() 发布列表:
let models: Model<Api>[] = [];
const provider: Provider = {
// ...
getModels: () => models,
async refreshModels(context) {
if (!context.allowNetwork) return;
models = await fetchFullModels(context.signal);
},
};
createProvider()
createProvider({ fetchModels }) 接收抓取模型的 Callback,并替调用方处理 ModelsStore 的读取与
写入。不要把它误写成 Legacy refreshModels()。
因此“谁持久化 Catalog”取决于所选契约:Legacy/手写 Provider 由扩展设计,createProvider
则已有 Store 组合逻辑。实时本地 Server 可以在线查询,静态企业 Catalog 可以使用 TTL 缓存;
两者都必须尊重 allowNetwork。
5. Model Definition 不能省略什么
ProviderConfig.models 使用较轻的 ProviderModelConfig[],至少要正确声明:
- ID 与 Name;
- Reasoning;
- Input Types;
- Cost;
- Context Window;
- Max Tokens。
Provider 顶层可给这类兼容配置提供 API/Base URL 等默认信息。
但 createProvider({ models }) 接收的是完整 Model[]。每个 Model 除了上述字段,还必须显式
包含:
provider;api;baseUrl。
createProvider 顶层的 baseUrl 不会自动把这些字段补进每个完整 Model。两种结构不能混用。
错误的 Context Window 会让 Compaction Threshold 失真;错误 Cost 会污染 Usage;错误 Reasoning Capability 会影响 Thinking Level Clamp。
6. 自定义认证
完整 Provider 有两种认证契约:
ApiKeyAuth:可选login、可选check,必需resolve;OAuthAuth:login、refresh、toAuth。
兼容 Config 还支持 Legacy OAuth:
login callbacks
→ credentials
→ refreshToken
→ getApiKey
桌面 Agent 不应把 Access Token 通过 Custom Message 或 Tool Result 传给模型。Credential 交给 Auth Storage/Provider Runtime,UI 只处理授权状态。
apiKey Config 支持 Environment Reference,甚至 Leading Command 解析。第三方 Provider
Config 因而也是执行与 Secret 风险,必须审查来源。
7. 动态发现 Skill
pi.on("resources_discover", async (event) => ({
skillPaths: [
resolve(event.cwd, ".generated/skills"),
],
}));
Runner 累积所有 Extension 返回的 Paths,并记录 Extension Path。AgentSession:
- 转成 Temporary Top-level Resource Metadata;
- 调用
ResourceLoader.extendResources(); - 重新加载 Skill/Prompt/Theme;
- 重建 Base System Prompt。
Event 发生在 Session Start 后,Reason 是 Startup 或 Reload。
8. Dynamic Resource 的边界
resources_discover 当前只能贡献:
- Skill Paths;
- Prompt Paths;
- Theme Paths。
它不能通过任意 customResources 字段发明新的 Core Resource Type,也不用于再次添加
Extension Path。
若应用需要自己的索引、知识库或配置文件,应由 Extension 自己管理,再通过 Tool、 before_agent_start 或 Context 暴露给 Agent。
9. 相对路径与来源
Extension 返回的 Resource Path 最终交给相应 Loader。为了避免 CWD/Reload 差异,动态资源 应优先返回 Absolute Path。
AgentSession 为它生成:
source: extension:<name>
scope: temporary
origin: top-level
baseDir: extension directory(文件 Extension)
Inline Extension 没有普通文件 BaseDir。复杂相对引用需要 Extension 自己解析。
10. 自定义 Tool Operations
Built-in Tool Factory 支持可替换 Operations:
const remoteRead = createReadTool(cwd, {
operations: {
readFile: remoteReadFile,
access: remoteAccess,
},
});
Read/Write/Edit/Bash/Ls/Grep/Find 各有对应 Operations Interface。Extension 可把执行路由到:
- SSH;
- Container;
- Micro-VM;
- Remote Workspace;
- Broker Service。
注册时仍要保证 Result Shape 与内置 Contract 匹配。
11. Bash Spawn Hook
const bash = createBashTool(cwd, {
spawnHook: ({ command, cwd, env }) => ({
command: wrapInSandbox(command),
cwd: mapWorkspace(cwd),
env: { ...env, CI: "1" },
}),
});
Spawn Hook 可以改 Command、CWD、Environment。它不是沙箱本身;wrapInSandbox() 必须连接
真实 OS Sandbox/Container/Remote Executor。
若返回本地 Shell Command 但没有权限隔离,改个目录名称不会形成安全边界。
12. Session Environment
createBashTool() 默认向命令暴露:
PI_SESSION_ID
PI_SESSION_FILE
PI_PROVIDER
PI_MODEL
PI_REASONING_LEVEL
注入发生在 Spawn Hook 前,所以 Hook 收到这些 Environment。PI_SESSION_FILE 仅在持久化
Session 有文件路径时存在,临时 Session 不会有它。若不需要:
createBashTool(cwd, {
exposeSessionEnvironment: false,
});
这能减少泄漏面,但不能清除进程继承的其他 Environment;Sandbox 还应建立 Environment Allowlist。
这些变量只注入“注册到 Pi、可被模型调用”的 Bash Tool。用户直接输入的 ! / !! 走另一条
路径,不会自动获得这组 PI_* 变量。
13. User Bash 与模型 Bash 不同
user_bash Event 处理用户直接输入的 ! / !! Command,可:
- 提供 Custom BashOperations;
- 包装 Local Operations;
- 直接返回完整 BashResult。
模型调用 Bash Tool 则走 tool_call、Execute、tool_result。要统一远程执行环境,两个入口
都要覆盖,不能只替换其中一个。
14. External Service Integration
Extension 可直接使用 Node/Fetch 访问外部服务,但应设计:
- AbortSignal;
- Timeout;
- Retry Budget;
- Idempotency Key;
- Credential Scope;
- Response Size Limit;
- Circuit Breaker;
- Audit/Redaction。
外部服务响应属于不可信输入。即使来自“内部 API”,也不要未经边界标记就把其中的自然语言 当成高优先级指令。
15. Extension Event Bus
const unsubscribe = pi.events.on(
"approval:changed",
(data) => {
// validate data
},
);
pi.events.emit("approval:changed", {
version: 1,
mode: "readonly",
});
源码是 Node EventEmitter 的薄封装:
- Channel 是 String;
- Data 是 Unknown;
- Emit 是同步触发 Listener;
- Wrapper 支持 Async Handler,但
emit()不 Await; - Handler Error 被 Catch 后写 Console;
on()返回 Unsubscribe;- 内部/宿主
EventBusController可clear()所有 Listener。
因此不要把 Event Bus 当成可靠 Message Queue:没有持久化、Ack、Replay 或 Backpressure。
Extension 得到的 pi.events 类型只有 on() 与 emit(),不能调用 clear()。
16. Extension 间通信协议
共享 Bus 意味着 Channel Collision 也可能发生。建议:
<publisher>/<domain>/<event>@v<version>
Payload 进入时 Runtime Validate。不要只用 TypeScript Interface,因为另一个 Extension 或旧 版本在运行时仍可能发送任意对象。
对强安全决策,Bus 只能传递状态通知,最终 Policy 应查询权威 Store 或 Host Binding。
17. Extension 自身状态
三种生命周期:
| 状态 | 保存位置 | 生命周期 |
|---|---|---|
| 临时 Cache | Factory Closure | 当前 Extension Instance |
| Branch-aware Tool State | Tool Result Details | 当前 Session Branch |
| 非模型 Branch State | Custom Entry | 当前 Session Branch |
| 跨 Session Account/Config | External Store | 由宿主管理 |
Tool Result Details 很适合 Branch-aware State:session_start 遍历当前 Branch,找到本 Tool
的 Result Details,重建最新状态。
Custom Entry 同样有 parentId,属于 Session Tree,也会出现在当前 getBranch() 中。它适合
保存不进入模型上下文、但仍需随 Branch 切换的状态;它不是跨所有 Branch 的 Session Global。
18. 为什么 Tool Details 适合 Branch
假设 Todo Tool 每次 Result 保存完整 Items:
Branch A: result items=[A]
Branch B: result items=[A,B]
切换 Branch 后从 ctx.sessionManager.getBranch() Replay,就能得到该 Branch 自己的状态。
如果只写一个全局 JSON 文件,Branch Navigation 不会自动回滚它。是否需要 Branch Semantics 决定存储选择。
19. Reload 与清理
高级 Extension 往往有:
- Socket;
- File Watcher;
- Remote Client;
- Token Refresh Timer;
- Event Bus Subscription。
在 session_shutdown:
unsubscribe?.();
watcher?.close();
controller?.abort();
清理函数要幂等。Reload 会重新执行 Factory 和 Session Start;漏清理会产生重复 Listener、 重复网络连接和重复 Side Effect。
20. 沙箱分层
flowchart TD
P["Prompt / Skill"] --> G["Extension Gate"]
G --> O["Tool Operations"]
O --> S["OS/Container Sandbox"]
S --> E["External Service"]
- Prompt:软规则;
- Gate:受控 Chain 中的 Policy;
- Operations:把执行路由到隔离后端;
- OS/Container:真正限制 Filesystem/Network/Process;
- External Service:还需要服务端 Authorization。
Pi 提供接点,不自动创建完整 Sandbox。
21. 一个企业桌面 Agent 组合
flowchart LR
UI["Desktop UI"] --> SDK["AgentSession"]
SDK --> PR["Company Provider"]
SDK --> DR["Dynamic Skills"]
SDK --> TO["Remote Tool Operations"]
TO --> VM["Sandbox Worker"]
SDK --> EB["Extension Bus"]
EB --> AU["Audit Extension"]
EB --> PO["Policy Extension"]
每块可独立测试,但 Provider、Resource、Tool 与 Bus 的 Version Contract 必须一起治理。
22. 常见误区
“registerProvider 后一定要 Reload”
不对。Factory 阶段排队;Bind 后调用立即生效。
“resources_discover 能发明任意资源类型”
错误。当前只支持 Skill、Prompt、Theme Path。
“Spawn Hook 就是 Sandbox”
错误。它只是改写执行入口。
“Event Bus 会等待 Async Listener”
错误。Emit 不 Await,也没有 Delivery Guarantee。
“Custom Entry 是整个 Session 共用的一份全局状态”
错误。它属于带 Parent 的 Session Tree Entry,读取当前 Branch 时才出现;跨 Session 用 External Store。
23. 本章小结
- Extension 可注册完整 Provider 或兼容 ProviderConfig;
- Factory Provider Registration 排队,Bind 后注册立即生效;
- Legacy Config、完整 Provider 与 createProvider 有三种不同刷新契约;
- 动态刷新必须遵守
allowNetwork,Store 责任取决于所选契约; - resources_discover 贡献 Skill/Prompt/Theme Path,并触发 Loader 与 Prompt 重建;
- 自定义业务资源应由 Extension 自己管理,通过 Tool/Context 暴露;
- Built-in Tool Operations 可路由到 Remote/Container Backend;
- Spawn Hook 可改 Command/CWD/Env,但不是 Sandbox;
- User Bash 与模型 Bash 是不同入口;
- Event Bus 是进程内、无持久化、Emit 不 Await 的通信层;
- Extension State 要按 Instance、Branch、Session、Global 生命周期选存储;
- 高级资源必须在 Shutdown 幂等清理。
24. 自测
- 完整 Provider Object 与 ProviderConfig 有什么区别?
- Factory 阶段 Provider Registration 为什么排队?
- 三种动态模型刷新契约的返回值与 Store 责任有什么不同?
- resources_discover 当前支持哪三类资源?
- Spawn Hook 为什么不等于 Sandbox?
- 如何同时接管用户
!Command 与模型 Bash Tool? - Event Bus 的 Async Handler 会被 emit Await 吗?
- Branch-aware State 为什么适合放 Tool Result Details?
- 跨 Session 配置应放在哪里?
- Reload 时哪些长期资源需要清理?