OpenCode V2 Effect 插件 API 详解:hook、transform 与 reload 的完整实践指南
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
本文基于 OpenCode 仓库中的 V2 Effect 插件 API 文档(packages/plugin/src/v2/effect/README.md)展开,系统讲解如何通过@opencode-ai/plugin/v2/effect中的define定义插件、利用 transform hook 参与 agent/catalog/command 等有状态域的构建、通过 runtime hook 拦截运行时操作,以及使用reload重建域状态。读完后你能够独立编写一个具备 hook 注册、作用域生命周期管理和域重载能力的 V2 插件,并理解其底层的 Scope 语义与重建流程。
一、API 定位:两个进程内能力
Effect 插件 API 为插件提供两个进程内(in-process)能力:
hook:在 OpenCode 的扩展点(extension point)上安装行为;reload:针对某个有状态域(stateful domain)重新执行其全部 transform hook。
文档同时明确了一个边界:公共的 server client 将单独暴露,目前有意地不包含在PluginContext中。也就是说,V2 Effect 插件当前只处理进程内的扩展点,而不是直接操作远程服务端。
整个 API 的入口与导出定义在 index.ts:
export type { PluginContext } from "./context.js" export { define } from "./plugin.js" export type { Plugin } from "./plugin.js"即对外只有三个公共符号:define、PluginContext类型和Plugin类型。该子路径的导出配置见 package.json:
"./v2/effect": "./src/v2/effect/index.ts"因此插件的导入方式固定为:
import { define } from "@opencode-ai/plugin/v2/effect"二、定义一个插件
文档给出的最小插件示例如下:
import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" export const Plugin = define({ id: "example", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((catalog) => { catalog.provider.update("example", (provider) => { provider.name = "Example" }) }) }), })从源码结构看(plugin.ts),define就是一个恒等函数,真正约束插件形状的是Plugin接口:
export interface Plugin<R = Scope.Scope> { readonly id: string readonly effect: (context: PluginContext) => Effect.Effect<void, never, R> }其中几个要点值得注意:
id: string是插件的唯一标识,后续ctx.plugin.remove(id)依赖它来卸载插件;effect接收PluginContext,返回一个 Effect 流程;错误类型固定为never,需求类型默认是Scope.Scope——这直接对应下面第四节的生命周期语义;- 插件 setup命令式地注册 hook,不返回任何 hook 对象。文档特别强调了这一点:“Plugin setup registers hooks imperatively. It does not return a hook object.”
插件还提供PluginDomain(plugin.ts),允许运行时动态管理插件集合:
export interface PluginDomain { readonly add: (plugin: Plugin) => Effect.Effect<void> readonly remove: (id: string) => Effect.Effect<void> }对应ctx.plugin.add(plugin)/ctx.plugin.remove(id)两个操作。
三、PluginContext:插件能拿到什么
PluginContext的完整形状定义在 context.ts:
export interface PluginContext { readonly options: PluginOptions readonly agent: AgentHooks & Reload readonly aisdk: AISDKHooks readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload }按用途可以分成三类:
| 成员 | 能力 | 说明 |
|---|---|---|
options | 读取配置 | 提供给插件的配置,类型见下文 |
agent/catalog/command/integration/reference/skill | transform + reload | 六个有状态域,均支持 transform hook 与reload() |
aisdk | runtime hook | 拦截 AI SDK 的sdk/language生成过程 |
plugin | 插件域管理 | add/remove动态装卸插件 |
其中ctx.options的类型很简单,就是一个只读键值映射(options.ts):
export type PluginOptions = Readonly<Record<string, any>>也就是说,OpenCode 配置中写在插件名下的 options 会原样透传给effect,由插件自行解释字段含义。
四、注册生命周期:Scope 与 dispose
理解 hook 机制的关键在 registration.ts:
export interface Registration { readonly dispose: Effect.Effect<void> } export interface Reload { readonly reload: () => Effect.Effect<void> } export type Hooks<Spec> = { readonly [Name in keyof Spec]: ( callback: (input: Spec[Name]) => Effect.Effect<void> | void, ) => Effect.Effect<Registration, never, Scope.Scope> }这解释了文档中的三句描述:
- 注册是带作用域的:每个 hook 注册调用返回
Effect<Registration, never, Scope.Scope>,即注册结果绑定到一个 EffectScope。这就是为什么 README 说“Registrations are owned by the plugin scope”——插件effect流程所在的作用域持有所有注册项,作用域关闭时它们被自动撤销; - 可以提前撤销:
Registration自带dispose: Effect<void>,因此一个注册可以在作用域关闭前手动移除(“a registration may also be removed early throughdispose”); - callback 可以同步也可以 Effect 化:
(input) => Effect<void> | void两种签名都被接受,简单场景直接写同步回调即可。
Hooks<Spec>是一个映射类型:Spec里列出的每个键都变成一个 hook 注册函数。以 agent 域为例(agent.ts):
export type AgentHooks = Hooks<{ transform: AgentDraft }>所以ctx.agent.transform(callback)就是注册一个接收AgentDraft的转换回调。
五、Transform Hooks:参与有状态域的构建
Transform hook 用于向有状态域贡献状态。文档示例:
yield * ctx.agent.transform((agent) => { agent.update("reviewer", (item) => { item.description = "Reviews code for regressions" item.mode = "subagent" }) })其重建语义由文档明确给出:OpenCode 在任一 transform 被注册或撤销时重建该域;重建从全新的域状态出发,按注册顺序依次执行所有活跃的 transform。这是一个“全量重建”(rebuild-from-scratch)模型,而不是增量修补——每个 transform 都应当假设自己面对的是初始状态。
可用的 transform hook 按域命名空间组织,共六个:
ctx.agent.transform ctx.catalog.transform ctx.command.transform ctx.integration.transform ctx.reference.transform ctx.skill.transform从源码结构看,每个域对应一个源文件(agent.ts、catalog.ts、command.ts、integration.ts、reference.ts、skill.ts),hook 的 draft 参数形状定义在其中。
agent 域:AgentDraft
AgentDraft(agent.ts)暴露五个方法,操作对象是 SDK 类型AgentV2Info:
export interface AgentDraft { list(): readonly AgentV2Info[] get(id: string): AgentV2Info | undefined default(id: string | undefined): void update(id: string, update: (agent: AgentV2Info) => void): void remove(id: string): void }list()/get(id):枚举与查询当前域中的 agent;default(id?):设置(或清空)默认 agent;update(id, fn):就地修改某个 agent,如示例中的item.description、item.mode = "subagent";remove(id):从域中删除。
catalog 域:CatalogDraft
catalog 域结构最丰富,分为 provider 与 model 两个子面(catalog.ts):
export interface CatalogDraft { readonly provider: { list(): readonly CatalogProviderRecord[] get(providerID: string): CatalogProviderRecord | undefined update(providerID: string, update: (provider: ProviderV2Info) => void): void remove(providerID: string): void } readonly model: { get(providerID: string, modelID: string): ModelV2Info | undefined update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void remove(providerID: string, modelID: string): void readonly default: { get(): { providerID: string; modelID: string } | undefined set(providerID: string, modelID: string): void } } }注意provider.list()返回的CatalogProviderRecord同时携带provider: ProviderV2Info和models: ReadonlyMap<string, ModelV2Info>,方便 transform 一次性查看某 provider 下全部模型。model.default是一个特殊的子对象,支持get()/set()读写默认 provider/model 组合——这是 README 开头示例中catalog.provider.update("example", ...)之外另一个常见的定制点。
六、Runtime Hooks:拦截实时操作
与 transform 不同,runtime hook 不重建域状态,而是拦截“活着的”操作。文档给出的示例是替换 AI SDK 的 provider 实例并接管 language model 生成:
yield * ctx.aisdk.sdk( Effect.fn(function* (event) { if (event.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) event.sdk = mod.createXai(event.options) }), ) yield * ctx.aisdk.language((event) => { if (event.model.providerID !== "xai") return event.language = event.sdk.responses(event.model.api.id) })两个 hook 的 event 形状在 aisdk.ts 中有精确定义:
export type AISDKHooks = Hooks<{ sdk: { readonly model: ModelV2Info readonly package: string readonly options: Record<string, any> sdk?: any } language: { readonly model: ModelV2Info readonly sdk: any readonly options: Record<string, any> language?: LanguageModelV3 } }>从字段设计可以看出流水线关系:
sdkhook 的 event 中sdk字段是可选输出(sdk?: any),plugin 按event.package判断是否要接管,然后动态import对应 AI SDK 包并赋给event.sdk;languagehook 的 event 里sdk变为必填输入(sdk: any),说明它一定运行在sdk解析之后;language是可选输出,类型为@ai-sdk/provider的LanguageModelV3;- 若插件不设置
language,系统走默认解析路径;设置后则以插件结果为准。
文档还规定了执行顺序:hooks 按注册顺序串行执行,后面的 hook 能观察到前面 hook 所做的变更(“Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.”)。示例中先注册sdk、再注册language正是利用了这一点——第二个 hook 才能拿到第一个 hook 写入的event.sdk。
七、Reload:域重载的正确姿势
当 transform 捕获的外部数据发生变化时,需要重载受影响域。文档示例:
let data = yield * loadCatalog() yield * ctx.catalog.transform((catalog) => { applyCatalog(data, catalog) }) data = yield * loadCatalog() yield * ctx.catalog.reload()可用的 reload 操作与 transform 一一对应:
ctx.agent.reload() ctx.catalog.reload() ctx.command.reload() ctx.integration.reload() ctx.reference.reload() ctx.skill.reload()这里有一个容易误解的点,文档专门澄清了:reload 属于域,而不是某一次注册。ctx.catalog.reload()会重新执行当前全部活跃的 catalog transform 并发布重建后的 catalog——即使没有任何新注册或 dispose 发生。这为“transform 闭包捕获了可变数据、数据变了但注册没变”的场景提供了手动触发重建的手段。Reload接口的定义也很简单(registration.ts):
export interface Reload { readonly reload: () => Effect.Effect<void> }每个域类型都以& Reload交叉出这个成员(见 context.ts),所以六个域的transform与reload总是成对出现。
八、实践要点小结与源码索引
综合文档与源码,编写一个 V2 Effect 插件时可以遵循以下要点:
- 用
define({ id, effect })定义插件,effect内用Effect.fn组织流程,ctx.options读取配置; - 需要修改域内容(agent、catalog、command、integration、reference、skill)时注册
transform,并保持“每次重建都从全新状态执行”的幂等假设; - 需要干预 AI SDK 实例或 language model 生成时,使用
ctx.aisdk.sdk/ctx.aisdk.language,并注意注册顺序决定数据流; - 注册项生命周期由插件作用域托管,作用域关闭自动清理,也可通过返回的
Registration提前dispose; - 外部数据变化后,调用所属域的
ctx.<domain>.reload()触发全量重建与发布。
相关源码与文档索引:
- API 文档(本文主体):README.md
- 插件定义与 PluginDomain:plugin.ts
- 生命周期与 Hooks 映射类型:registration.ts
- 上下文结构:context.ts
- 各域 draft 类型:agent.ts、catalog.ts、aisdk.ts
- 配置类型:options.ts
- 导出路径配置:package.json
- 同目录还有一份实现计划文档 PLAN.md,以及 Promise 风格的姊妹 API(导出子路径
./v2/promise,见 packages/plugin/src/v2/promise/README.md),两者可对照阅读。
适用前提说明:以上结论均基于当前仓库中@opencode-ai/pluginv2 子包的实际源码;effect与@opencode-ai/sdk均以 workspace 依赖方式引入,effect依赖以 catalog 版本锁定(package.json),具体版本以仓库根目录锁文件为准。
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考