ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

opencode V2 插件系统设计方案:基于 Effect 的 transform、runtime hook 与域重建机制

opencode V2 插件系统设计方案:基于 Effect 的 transform、runtime hook 与域重建机制 opencode V2 插件系统设计方案:基于 Effect 的 transform、runtime hook 与域重建机制【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode本文以 opencode 仓库中 V2 插件系统实现计划 为核心,完整解读这套 V2 插件系统的目标设计:命令式注册模型、可重放域转换(transform)与运行时钩子(runtime hook)的分工、Registration 作用域语义、重建(rebuild)序列化与合流规则、事件订阅 API,以及从现有插件体系到目标架构的九步迁移路径。读完本文,你可以理解 opencode 插件如何在统一的公共 API 上安全地修改 agent、catalog、integration 等领域状态,并能对照 当前已落地的类型定义 判断计划与实现的差异边界。1. 文档定位:是目标设计,不是现行 API 文档PLAN.md 开篇明确声明:该文档描述的是 V2 插件系统已达成一致的目標設計(agreed target design),属于实现计划,而非当前 API 的文档。理解这一前置条件,是阅读全文的关键——文中标注rebuild、ctx.event、ctx.tool等能力,部分是计划中的目标形态,而 同目录的 README 则记录了当前已实现的reload等接口。本文按计划原文 仓库源码佐证双线展开,差异处会单独说明。1.1 设计目标计划列出了八条核心目标,完整如下:内部插件与外部插件使用同一套公共插件 API;Effect 插件导入opencode-ai/plugin/v2/effect,而不是opencode-ai/core;公共领域值使用生成的opencode-ai/sdk类型;Core 侧可以保留带品牌的 ID(branded IDs)、解码后的 Effect Schema 以及内部服务类型;插件可以在 setup 阶段以命令式方式注册可重放的域转换(replayable domain transforms)和运行时钩子;注册是作用域化的(scope-scoped)、可独立销毁的(independently disposable)、有顺序的(ordered)、可移除的(removable);models.dev、配置文件、skill 目录等动态数据源,可以只重建单个域而不重载整个 Location;首期实现只覆盖 Effect API,Promise API 之后再作为同一能力集的包装层设计。2. 编写模型:插件 setup 是命令式注册,不返回 hook 对象V2 的核心变化是:插件 setup 是一个 Effect,接收PluginHost上下文,在其中命令式地注册 transform 和 hook。计划给出的标准范式如下:export const Plugin define({ id: example, effect: (ctx) Effect.gen(function* () { yield* ctx.agent.transform( Effect.fn(function* (agent) { agent.update(reviewer, (item) { item.description Reviews code for regressions item.mode subagent }) }), ) yield* ctx.tool.hook( execute.before, Effect.fn(function* (event) { event.args.update(sanitizeArgs) }), ) }), })计划特别强调一条约定:Plugin setup does not return hooks(插件 setup 不返回 hook 对象)。这是对整个迁移计划第 7 步移除HookFunctions返回值的前向铺垫。2.1 当前实现与计划的对齐程度对照仓库源码,计划中的define与Plugin契约已经落地,见 plugin.ts:export interface PluginR Scope.Scope { readonly id: string readonly effect: (context: PluginContext) Effect.Effectvoid, never, R } export function defineR Scope.Scope(plugin: PluginR) { return plugin } export interface PluginDomain { readonly add: (plugin: Plugin) Effect.Effectvoid readonly remove: (id: string) Effect.Effectvoid }三个要点可以从源码确认:插件契约就是ideffect两个字段,effect返回Effectvoid, never, R,即没有类型化错误通道(与计划transforms have no typed error channel一致);PluginDomain暴露了add/remove能力,对应计划中插件可增删、同 ID 替换保留顺序位的管理需求;当前 PluginContext 的实际形状是: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 }与计划对照可以得出:agent / catalog / command / integration / reference / skill 六个域的 transform 已就位;而计划中的ctx.event.subscribe与ctx.tool域尚未进入PluginContext——event.ts 中已定义了EventMap与subscribe类型,说明事件 API 处于类型先行阶段。另外 options.ts 中PluginOptions即ReadonlyRecordstring, any,即插件配置以只读记录形式挂在ctx.options上。包入口由 package.json 的exports字段固化,./v2/effect、./v2/effect/integration、./v2/effect/plugin均指向src/v2/effect下的源码文件,与目标Effect 插件导入opencode-ai/plugin/v2/effect一致。3. 公共命名规范计划冻结(设名已定)的公共词汇表如下,后续所有 API 都必须遵循:概念定名可重放域注册transform显式域重放rebuild运行时回调注册hook注册清理dispose事件域单数event其他域均为单数:agent、command、integration、reference、session、skill、tool;catalog保持catalog钩子名点分生命周期名,如execute.before、execute.after一处值得注意的命名差异:计划定名的显式域重放是rebuild,而当前 registration.ts 中的接口是Reload { reload() },同目录 README 也以ctx.catalog.reload()作为当前用法示例。从源码结构看,这属于实现暂用reload、计划定名rebuild的过渡状态;引用文档时需注意两者所指相同——都是重放该域全部活跃 transform 并重新提交有效状态。registration.ts 的完整内容是理解全部注册机制的钥匙:export interface Registration { readonly dispose: Effect.Effectvoid } export interface Reload { readonly reload: () Effect.Effectvoid } export type HooksSpec { readonly [Name in keyof Spec]: ( callback: (input: Spec[Name]) Effect.Effectvoid | void, ) Effect.EffectRegistration, never, Scope.Scope }HooksSpec是单一泛型机制:每个域只需提供一个上下文对象映射Spec,就同时获得全部注册入口的返回类型EffectRegistration, never, Scope.Scope——注册必须挂在当前Scope上,这从类型层面强制了注册是作用域化的这一计划条款。4. Transform API 与 Transform 语义4.1 域接口形状每个可转换域(transformable domain)暴露:interface TransformDomainEditor { transform(callback: (editor: Editor) Effect.Effectvoid): Effect.EffectRegistration, never, Scope.Scope rebuild(): Effect.Effectvoid }实际回调可以用项目惯用的Effect.fn风格书写。计划中跨域读取的完整示例:const registration yield * ctx.catalog.transform( Effect.fn(function* (catalog) { const integration yield* ctx.integration.get(anthropic) if (!integration) return catalog.provider.update(anthropic, (provider) { provider.name Anthropic }) }), )三条关键约束:transform 内部可以执行任意 Effect,包括读取其他 PluginHost 服务、文件系统 I/O、网络 I/O;读取其他域时,观察到的是该域最新已提交状态(latest committed state);transform没有类型化错误通道,意外失败即缺陷(defect)。4.2 Transform 语义十一条(完整继承)计划对 transform 的运行时语义给出了逐条规定,这是整个设计中最精密的部分:每次调用transform()都创建一个独立的注册;同一插件对同一域注册多个 transform 是允许的;transform 执行顺序 插件注册顺序,再叠加插件内 transform 注册顺序;注册作用域关闭时,transform 自动移除;Registration.dispose支持提前移除且幂等;注册或销毁 transform 都会自动触发其所属域的重建;批量插件启动期间,自动重建被延迟,受影响域在批次结束后只重建一次;rebuild()会等待重放与收尾全部完成;rebuild()总是重放该域每一个活跃 transform;重建是**串行化并合流(coalesced)**的:重建进行中到达的调用,最多只追加一次额外重建;重建在开始时快照注册表,并发变更影响的是下一次重建;在重放期间注册/销毁 transform 会被运行时拒绝或延迟;从正在重建的域的 transform 内部调用同域rebuild()会被拒绝;从 transform 内部重建其他域则会延迟到当前 transform 结束之后执行。这些规则共同保证了有效状态 基础状态按序重放全部活跃 transform 的结果这一不变量在任何并发时序下都成立。4.3 当前各域 Editor(草稿)形状计划要求公共领域值使用生成的 SDK 类型。当前各域草稿接口已体现这一点,例如:agent.ts:AgentDraft提供list()、get(id)、default(id)、update(id, fn)、remove(id),操作对象是opencode-ai/sdk/v2/types的AgentV2Info;catalog.ts:CatalogDraft分provider(list/get/update/remove)与model(get/update/remove,以及model.default.get/set)两组,provider 记录携带ProviderV2Info与models: ReadonlyMapstring, ModelV2Info;command.ts:CommandDraft按name键操作CommandV2Info;integration.ts:IntegrationDraft除list/get/update/remove外,还有method.list/update/remove三个能力方法(capability methods),可注册 OAuth / API Key / Env 三类接入方式,并额外暴露connection.active(integrationID)与connection.resolve(connection)用于读取活动连接和换取凭据值;skill.ts:SkillDraft以source(source)追加SkillV2Source、list()列出;reference.ts:ReferenceDraft提供add(name, source)/remove(name)/list(),source 为本地或 git 引用。这些接口印证了计划第 8 步hook 上下文不得暴露 core 草稿或不加限制的内部对象的落地方向:插件拿到的是按域裁剪的草稿面,每个方法都有明确边界。5. Registration API:统一的作用域化注册transform 与运行时 hook返回同一种 Effect 注册类型:interface Registration { readonly dispose: Effect.Effectvoid }注册行为四条规则:自动挂接到当前Scope.Scope;可以在作用域关闭前被显式dispose;销毁影响的是之后的重放或调用;已在飞行中的重建或 hook 调用使用其开始时捕获的注册快照,允许其执行完毕。快照 允许完成是避免并发悬挂的关键设计:插件不会因为一个正在执行的 transform 被中途注销而产生撕裂状态。6. Runtime Hook API 与 Hook 上下文6.1 运行时钩子域通过hook()暴露运行时拦截点:const registration yield * ctx.tool.hook( execute.before, Effect.fn(function* (event) { event.args.update(sanitizeArgs) }), )运行时钩子行为规则:同一 hook 允许多个注册;钩子按插件顺序与注册顺序串行执行;后执行的钩子观察前一个钩子的变更(mutations);注册由作用域持有、可独立销毁;销毁影响之后的调用,飞行中调用使用捕获的注册快照完成;运行时钩子不参与域重建重放;回调没有类型化错误通道。6.2 Hook 上下文:单一目的对象,而非入参/出参分离每个 hook 只接收一个为它定制的上下文对象:ctx.tool.hook(execute.before, (event) { event.args.update((args) ({ ...args, timeout: 30, })) })上下文对象可以包含:只读的、SDK 类型化的操作数据;允许变更所用的专用方法;当操作超出字段赋值时所需的能力方法。且不得暴露 core 草稿或不加限制的内部对象。6.3 当前已落地的运行时钩子:AISDK 解析仓库中已实现的一类运行时钩子是 AI SDK 解析钩子,见 aisdk.ts:export type AISDKHooks Hooks{ sdk: { readonly model: ModelV2Info readonly package: string readonly options: Recordstring, any sdk?: any } language: { readonly model: ModelV2Info readonly sdk: any readonly options: Recordstring, any language?: LanguageModelV3 } }同目录 README 给出的真实用法——为ai-sdk/xai动态装配 SDK 实例与语言模型: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) })这正好演示了第 6 节的三条规则:model/package/options是只读数据,sdk/language是唯一可写出口,两个钩子串成先造 SDK、再选语言模型的有序管道。7. Domain Transform 与 Runtime Hook 的分工两者共用同一套底层作用域化注册注册表,但消费方式不同:ctx.tool.transform(...) // replayed to build effective tool registry state ctx.tool.hook(...) // invoked at a live tool operation boundarytransform面向状态:在域重建时重放,产出该域的有效注册表状态;hook面向行为:在真实操作边界(如工具执行前)被调用。共享的底层机制负责:注册顺序、作用域清理、销毁、注册快照。而何时执行本域的 transform 或 hook,由每个域自己决定。8. Event API:SDK 事件判别式的类型化流Effect API 把既有事件系统暴露为类型化流,使用生成的 SDK 事件判别式:ctx.event.subscribe(catalog.updated) // Stream.StreamEventCatalogUpdated订阅后驱动域重建的完整示例:yield * ctx.event.subscribe(catalog.updated).pipe( Stream.runForEach(() ctx.agent.rebuild()), Effect.forkScoped, )类型推导完全由生成的 SDKEvent联合类型机械产生,即 event.ts 中当前已有的定义:export type EventMap { [Item in SDKEvent as Item[type]]: Item } export interface Event { subscribeType extends keyof EventMap(type: Type): Stream.StreamEventMap[Type] }核心侧的分工是:把公共事件类型字符串解析到内部事件定义,然后委托给EventV2.Service.subscribe。9. 域状态模型与 Finalization9.1 每个域自己持有七样东西计划明确:不引入跨域中央状态管理器,首期实现应演进现有的通用State助手。每个可转换的核心服务继续自行持有:基础状态(base state);有效已提交状态(effective committed state);Editor 的创建;该域有序 transform 注册;重建的串行化与合流;核心收尾逻辑(finalization);提交与提交后事件(commit and post-commit events)。9.2 重建流水线域状态的重放流水线为:base state → replay active transforms in order → core domain finalization → commit effective state → publish updated event计划同时声明:不包含任何跨域 transform 或事务 API。9.3 Finalization:不变量与物化,不是插件扩展位每个域有且仅有一个插件 transform 阶段 核心 finalization结构。核心 finalization 专用于不变量检查与物化,例如:Catalog 策略过滤与校验;Reference 仓库物化;Integration 连接投影;索引构建;提交后的更新事件。Finalizer 必须区分提交前工作与提交后通知;更新事件应在新状态可见之后再发布——这正是第 9.2 节流水线把publish updated event放在commit之后,也是第 8 节事件观察新提交状态能成立的前提。10. 插件顺序与启动批处理10.1 默认分发下的意见化顺序1. Built-in agents, commands, and skills 2. Base data sources such as models.dev 3. Configuration projections 4. Provider-specific normalization and authentication 5. External user plugins 6. Core domain finalizationcatalog 域内部的具体链条:models.dev → config provider overrides → built-in provider normalization → user catalog transforms → catalog finalization这条链取代了现状中setup 安装的 State transform与catalog finalizer 内调用的 catalog hooks两套机制的区分。同 ID 替换的规则:替换插件保留原有顺序位;旧插件在新替换 setup 开始之前被禁用。10.2 启动批处理(Boot Batching)begin batch → initialize plugins sequentially → register transforms and hooks → collect affected domains → rebuild each affected domain once → end batch两条补充语义:注册本身不按插件分阶段。若某插件 setup 失败,关闭其子作用域会移除该失败点之前创建的全部注册——失败即全有或全无;批次之外,transform 的注册与销毁立即触发重建。11. 三个完整示例11.1 Models.dev:动态数据源的域重建Models.dev 在 transform 内直接做 effectful 读取,数据刷新后重建受影响域:export const ModelsDevPlugin define({ id: models-dev, effect: (ctx) Effect.gen(function* () { const modelsDev yield* ModelsDev.Service const event yield* EventV2.Service yield* ctx.integration.transform( Effect.fn(function* (integration) { const data yield* modelsDev.get() applyIntegrations(data, integration) }), ) yield* ctx.catalog.transform( Effect.fn(function* (catalog) { const data yield* modelsDev.get() applyCatalog(data, catalog) }), ) yield* event.subscribe(ModelsDev.Event.Refreshed).pipe( Stream.runForEach( Effect.fn(function* () { yield* ctx.integration.rebuild() yield* ctx.catalog.rebuild() }), ), Effect.forkScoped({ startImmediately: true }), ) }), })注意两个域是顺序重建的,计划明确不为此引入跨域原子事务。11.2 配置监听器export const ConfigPlugin define({ id: config, effect: (ctx) Effect.gen(function* () { const config yield* ConfigSource.Service yield* ctx.agent.transform( Effect.fn(function* (agent) { applyAgentConfig(yield* config.get(), agent) }), ) yield* ctx.command.transform( Effect.fn(function* (command) { applyCommandConfig(yield* config.get(), command) }), ) yield* config.changes.pipe( Stream.runForEach( Effect.fn(function* () { yield* ctx.agent.rebuild() yield* ctx.command.rebuild() }), ), Effect.forkScoped, ) }), })11.3 跨域读取:依赖变化由插件自行安排transform 可以读取其他已提交服务,但必须自己安排在依赖变化时重建自己的域:export const AnthropicAgentPlugin define({ id: anthropic-agent, effect: (ctx) Effect.gen(function* () { yield* ctx.agent.transform( Effect.fn(function* (agent) { const providers yield* ctx.catalog.provider.list() if (!providers.some((provider) provider.id anthropic)) return agent.update(anthropic-reviewer, (item) { item.description Reviews code using Anthropic item.mode subagent item.model { providerID: anthropic, id: claude-sonnet, } }) }), ) yield* ctx.event.subscribe(catalog.updated).pipe( Stream.runForEach(() ctx.agent.rebuild()), Effect.forkScoped, ) }), })计划对此的结论是:运行时不做跨域依赖推断。依赖追踪是插件作者的职责(自动依赖跟踪被列入暂缓决策)。12. 迁移计划:九步路径计划将迁移拆解为九步,每步都是可独立验收的工作包:第 1 步:定义公共契约在opencode-ai/plugin/v2/effect中定义PluginHost域能力;为 agent、catalog、command、integration、reference、skill、tool 定义 SDK 类型化 editor;为每个域定义类型化运行时 hook 映射;定义Registration;定义类型化event.subscribe(type)。第 2 步:泛化注册机制新增一套供 transform 与运行时 hook 共用的底层作用域化注册注册表;保持插件顺序与注册顺序;支持幂等销毁与注册快照;同 ID 替换时保留插件顺序位。第 3 步:演进 State用直接的transform(callback)注册取代当前返回 transform 槽位更新器的方式;支持 Effect 回调;增加公共rebuild();增加重建串行化与合流;增加启动批处理(延迟自动重建);把更新事件发布挪到 commit 之后。第 4 步:扩展域 transform hook:Agent、Catalog、Command、Integration、Reference、Skill、Tool。第 5 步:迁移既有插件:内置 agent transform、内置 command transform、内置 skill transform、Models.dev 的 catalog 与 integration transform、配置 transform、OpenAI integration transform、各 provider 的 catalog transform。第 6 步:迁移运行时钩子:AI SDK 解析、语言模型解析、工具执行钩子、按需的会话 prompt/context 钩子。第 7 步:移除返回式钩子:移除HookFunctions作为插件 setup 返回值;移除 catalog 的 finalizer 触发式插件钩子特殊路径;移除plugin.added的 catalog 变更处理;增删/替换一律改为依赖作用域化注册与域重建。第 8 步:事件适配器:构建 SDK 事件判别式映射;把公共类型字符串解析到内部 EventV2 定义;返回类型化 Effect 流。第 9 步:验证清单(全部要求有自动化验证):transform 顺序确定性;单插件/单域多 transform 可组合;启动批次之外,注册与销毁自动重建;启动阶段每个受影响域只重建一次;插件 setup 失败时移除先前注册;同 ID 替换保留顺序且先禁用旧插件;重建串行化并合流;重放期间的注册变更影响下一次重建;同域递归重建被拒绝;transform 发起的跨域重建请求被延迟;hook 执行串行且基于快照;Models.dev 刷新能重放配置与 provider transform;配置与 skill 监听刷新能移除过期条目;移除插件恢复先前的有效状态;事件观察新提交的状态。13. 内嵌 API 兼容性与暂缓决策命令式注册模型天然映射到未来的应用内嵌(embedding) API:const registration oc.agent.transform((agent) { agent.update(reviewer, configureReviewer) }) registration.dispose()应用级注册存为应用层插件注册:挂接到当前所有 Location,并在未来的 Location 启动时安装;销毁则移除全部当前挂接并阻止未来挂接。Effect 实现始终是权威运行时,Promise 与内嵌包装层在 Effect API 稳定之后再引入。以下七项被明确列为暂缓决策(Deferred Decisions):Promise API 形态;类型化错误模型;transform 超时;跨域原子重建;自动依赖跟踪;整个 Location 的代际重载;当前插件用不到的精确 editor 与运行时 hook 清单。14. 计划与当前仓库实现的对照小结把 PLAN.md 与 packages/plugin/src/v2/effect 目录下的现有源码逐项对照,可以梳理出清晰的落地边界:计划能力仓库现状依据define/Plugin契约已实现(ideffect,无错误通道)plugin.ts统一Registration/HooksSpec已实现,回调可返回void或Effectvoidregistration.ts六域 transform(agent/catalog/command/integration/reference/skill)已实现,editor 均为 SDK 类型化草稿context.ts、catalog.ts 等运行时 hook已实现ctx.aisdk.sdk/ctx.aisdk.language一类aisdk.ts、README显式域重放已实现但暂命名reload(),计划定名rebuild()registration.tsctx.event.subscribe类型化流类型已定义,尚未纳入PluginContextevent.ts、context.tsctx.tool域与execute.before/after点分钩子尚未出现在PluginContextcontext.ts事件发布挪到 commit 之后、启动批处理属 core 侧改造,计划第 3 步执行项PLAN.md 迁移计划这套设计的方法论价值在于:它把插件修改系统状态这一容易失控的行为,收敛为顺序化重放 快照化注册 作用域化生命周期三个正交机制。插件作者面对的是有限的域草稿与明确的注册返回类型,运行时拥有确定性的重建语义,而 SDK 类型(经由 packages/sdk 生成)保证了插件与核心之间的公共契约随协议演进——这正是 V2 插件系统区别于回调挂载 全局单例式传统插件框架的核心所在。【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进