From 975b1132f1bdfe24caa27e45100f27683cc7748a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 20:10:29 -0400 Subject: [PATCH] refactor(plugin): use direct runtime registry --- packages/core/src/config/plugin/external.ts | 10 +-- packages/core/src/plugin.ts | 84 ++++++++++----------- packages/core/src/plugin/host.ts | 6 +- packages/core/src/plugin/internal.ts | 82 ++++++++++---------- packages/core/src/plugin/promise.ts | 7 +- packages/core/test/location-layer.test.ts | 29 ++++--- packages/core/test/plugin.test.ts | 35 +++++---- packages/core/test/plugin/host.ts | 4 +- packages/plugin/src/v2/effect/context.ts | 4 +- packages/plugin/src/v2/effect/index.ts | 2 +- packages/plugin/src/v2/effect/plugin.ts | 18 +---- packages/plugin/src/v2/promise/context.ts | 4 +- packages/plugin/src/v2/promise/index.ts | 2 +- packages/plugin/src/v2/promise/plugin.ts | 11 +-- 14 files changed, 130 insertions(+), 168 deletions(-) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index d81d9f7c8..d8ace63b3 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -36,12 +36,6 @@ export const Plugin = define({ const fs = yield* FSUtil.Service const location = yield* Location.Service const npm = yield* Npm.Service - const loaded: EffectPlugin[] = [] - - yield* ctx.plugin.transform((plugins) => { - for (const plugin of loaded) plugins.add(plugin) - }) - yield* Effect.gen(function* () { const configured: { package: string; options?: Record }[] = [] @@ -86,14 +80,12 @@ export const Plugin = define({ const mod = yield* Effect.promise(() => import(entrypoint)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - loaded.push({ + yield* ctx.plugin.add({ id: plugin.id, effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), }) }).pipe(Effect.ignoreCause) } - - yield* ctx.plugin.reload() }).pipe(Effect.forkScoped({ startImmediately: true })) }), }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 85c51ea7d..bbaa7dead 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,7 +1,7 @@ export * as PluginV2 from "./plugin" import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" @@ -27,8 +27,8 @@ export const Event = { } export interface Interface { - readonly transform: State.Transform - readonly reload: State.Reload + readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -40,57 +40,49 @@ export const layer = Layer.effect( const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() const active = new Map() + const loading = new Set() let host: Parameters[0] - const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters[0]) { - const id = ID.make(plugin.id) - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = active.get(id) - if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) { + if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) - const child = yield* Scope.fork(scope) - yield* plugin.effect(host).pipe( - Scope.provide(child), - Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), - ) - active.set(id, child) - yield* events.publish(Event.Added, { id }) - }), + yield* locks.withLock(id)( + Effect.sync(() => loading.add(id)).pipe( + Effect.andThen( + State.batch( + Effect.gen(function* () { + const existing = active.get(id) + active.delete(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + + const child = yield* Scope.fork(scope) + yield* effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + active.set(id, child) + yield* events.publish(Event.Added, { id }) + }), + ), + ), + Effect.ensuring(Effect.sync(() => loading.delete(id))), + ), ) }) - const detach = Effect.fn("Plugin.detach")(function* (id: ID) { - yield* locks.withLock(id)( - Effect.gen(function* () { - const current = active.get(id) - active.delete(id) - if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) - }), - ) - }) + const remove = Effect.fn("Plugin.remove")(function* (id: ID) { + if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) - const state = State.create, PluginDraft>({ - initial: () => new Map(), - draft: (draft) => ({ - list: () => Array.from(draft.values()), - add: (plugin) => draft.set(ID.make(plugin.id), plugin), - remove: (id) => draft.delete(ID.make(id)), - }), - finalize: (draft) => + yield* locks.withLock(id)( State.batch( Effect.gen(function* () { - const desired = new Set() - for (const plugin of draft.list()) desired.add(ID.make(plugin.id)) - - for (const id of active.keys()) { - if (!desired.has(id)) yield* detach(id) - } - - for (const plugin of draft.list()) yield* attach(plugin, host) - }).pipe(Effect.withSpan("Plugin.reconcile")), + const current = active.get(id) + active.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) + }), ), + ) }) yield* Effect.addFinalizer((exit) => @@ -101,8 +93,8 @@ export const layer = Layer.effect( ) const service = Service.of({ - transform: state.transform, - reload: state.reload, + add, + remove, }) host = yield* PluginHost.make(service) return service diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 27afc14d2..f6c0e24e0 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -8,7 +8,7 @@ import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Integration } from "../integration" import { ModelV2 } from "../model" -import type { PluginV2 } from "../plugin" +import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import { Reference } from "../reference" import { SkillV2 } from "../skill" @@ -126,8 +126,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, plugin: { - reload: plugin.reload, - transform: plugin.transform, + add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), + remove: (id) => plugin.remove(PluginV2.ID.make(id)), }, reference: { reload: reference.reload, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 9b66a7b6a..6aeef4dc3 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -23,10 +23,8 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { Reference } from "../reference" import { SkillV2 } from "../skill" -import { State } from "../state" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" -import { PluginHost } from "./host" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillPlugin } from "./skill" @@ -73,49 +71,45 @@ export const locationLayer = Layer.effectDiscard( const global = yield* Global.Service const skill = yield* SkillV2.Service const reference = yield* Reference.Service - const host = yield* PluginHost.make(plugin) + const add = (input: Plugin) => { + const loaded = { + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + } + return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) + } - const wrap = (input: Plugin) => ({ - id: input.id, - effect: (context: PluginContext) => - input - .effect(context) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - ), - }) - - yield* State.batch( - Effect.gen(function* () { - yield* plugin.transform((plugins) => { - plugins.add(wrap(AgentPlugin.Plugin)) - plugins.add(wrap(CommandPlugin.Plugin)) - plugins.add(wrap(SkillPlugin.Plugin)) - plugins.add(wrap(ModelsDevPlugin)) - plugins.add(wrap(ConfigProviderPlugin.Plugin)) - plugins.add(wrap(ConfigAgentPlugin.Plugin)) - plugins.add(wrap(ConfigCommandPlugin.Plugin)) - plugins.add(wrap(ConfigSkillPlugin.Plugin)) - plugins.add(wrap(ConfigReferencePlugin.Plugin)) - for (const item of ProviderPlugins) plugins.add(wrap(item)) - }) - - yield* wrap(ConfigExternalPlugin.Plugin).effect(host) - }), - ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + yield* Effect.gen(function* () { + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), ).pipe( Layer.provideMerge(PluginV2.locationLayer), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 9543220ae..49a19af4c 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -67,8 +67,11 @@ export function fromPromise(plugin: Plugin) { reload: () => run(host.integration.reload()), }, plugin: { - transform: transform(host.plugin), - reload: () => run(host.plugin.reload()), + add: (input) => { + const child = fromPromise(input) + return run(host.plugin.add(child)) + }, + remove: (id) => run(host.plugin.remove(id)), }, reference: { transform: transform(host.reference), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 67e811558..5bb064b5f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -197,22 +197,19 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.gen(function* () { const plugins = yield* PluginV2.Service - yield* plugins.transform((draft) => - draft.add( - define({ - id: "reviewer", - effect: (ctx) => - ctx.agent - .transform((agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code" - item.mode = "subagent" - }) - }) - .pipe(Effect.asVoid), - }), - ), - ) + const reviewer = define({ + id: "reviewer", + effect: (ctx) => + ctx.agent + .transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), + }) + yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index a662ed7ca..b787c754d 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -9,35 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) describe("PluginV2", () => { - it.effect("reconciles transformed plugins", () => + it.effect("adds, replaces, and removes plugins", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const agents = yield* AgentV2.Service let description = "first" - const registration = yield* plugins.transform((draft) => { - draft.add( - define({ - id: "managed", - effect: (ctx) => - ctx.agent - .transform((agents) => - agents.update("configured", (agent) => { - agent.description = description - }), - ) - .pipe(Effect.asVoid), - }), - ) - }) + const managed = () => + define({ + id: "managed", + effect: (ctx) => + ctx.agent + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = description + }), + ) + .pipe(Effect.asVoid), + }) + + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") description = "second" - yield* plugins.reload() + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") - yield* registration.dispose + yield* plugins.remove(PluginV2.ID.make("managed")) expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 02bce652c..62fa8391e 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -33,8 +33,8 @@ export function host(overrides: Overrides = {}): PluginContext { reload: () => Effect.die("unused integration.reload"), }, plugin: overrides.plugin ?? { - transform: () => Effect.die("unused plugin.transform"), - reload: () => Effect.die("unused plugin.reload"), + add: () => Effect.die("unused plugin.add"), + remove: () => Effect.die("unused plugin.remove"), }, reference: overrides.reference ?? { transform: () => Effect.die("unused reference.transform"), diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 76ba79674..9089334ee 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" import type { IntegrationHooks } from "./integration.js" -import type { PluginHooks } from "./plugin.js" +import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" @@ -16,7 +16,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload - readonly plugin: PluginHooks & Reload + readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload } diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 928649c05..f13614a54 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,3 +1,3 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" -export type { Plugin, PluginDraft } from "./plugin.js" +export type { Plugin } from "./plugin.js" diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 75008d92e..7352797b8 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,7 +1,5 @@ import type { Effect, Scope } from "effect" import type { PluginContext } from "./context.js" -import type { PluginOptions } from "../options.js" -import type { Hooks } from "./registration.js" export interface Plugin { readonly id: string @@ -12,17 +10,7 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginRef { - readonly package: string - readonly options?: PluginOptions +export interface PluginDomain { + readonly add: (plugin: Plugin) => Effect.Effect + readonly remove: (id: string) => Effect.Effect } - -export interface PluginDraft { - list(): readonly Plugin[] - add(plugin: Plugin): void - remove(id: string): void -} - -export type PluginHooks = Hooks<{ - transform: PluginDraft -}> diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 76ba79674..9089334ee 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" import type { IntegrationHooks } from "./integration.js" -import type { PluginHooks } from "./plugin.js" +import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" @@ -16,7 +16,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload - readonly plugin: PluginHooks & Reload + readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload } diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 41254707f..23394fa01 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,7 +1,7 @@ export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" export { define } from "./plugin.js" -export type { Plugin, PluginDraft, PluginHooks, PluginRef } from "./plugin.js" +export type { Plugin, PluginDomain } from "./plugin.js" export type { Registration, Reload } from "./registration.js" export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index f7ff2ad45..ab59fb95f 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,6 +1,4 @@ import type { PluginContext } from "./context.js" -import type { PluginDraft, PluginRef } from "../effect/plugin.js" -import type { Hooks } from "./registration.js" export interface Plugin { readonly id: string @@ -11,8 +9,7 @@ export function define(plugin: Plugin) { return plugin } -export type { PluginDraft, PluginRef } - -export type PluginHooks = Hooks<{ - transform: PluginDraft -}> +export interface PluginDomain { + readonly add: (plugin: Plugin) => Promise + readonly remove: (id: string) => Promise +}