refactor(plugin): use direct runtime registry

This commit is contained in:
Dax Raad
2026-06-22 20:10:29 -04:00
parent cd97de7391
commit 975b1132f1
14 changed files with 130 additions and 168 deletions
+1 -9
View File
@@ -36,12 +36,6 @@ export const Plugin = define({
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const npm = yield* Npm.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* () { yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = [] const configured: { package: string; options?: Record<string, any> }[] = []
@@ -86,14 +80,12 @@ export const Plugin = define({
const mod = yield* Effect.promise(() => import(entrypoint)) const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
loaded.push({ yield* ctx.plugin.add({
id: plugin.id, id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
}) })
}).pipe(Effect.ignoreCause) }).pipe(Effect.ignoreCause)
} }
yield* ctx.plugin.reload()
}).pipe(Effect.forkScoped({ startImmediately: true })) }).pipe(Effect.forkScoped({ startImmediately: true }))
}), }),
}) })
+38 -46
View File
@@ -1,7 +1,7 @@
export * as PluginV2 from "./plugin" export * as PluginV2 from "./plugin"
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" 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 { AgentV2 } from "./agent"
import { AISDK } from "./aisdk" import { AISDK } from "./aisdk"
import { Catalog } from "./catalog" import { Catalog } from "./catalog"
@@ -27,8 +27,8 @@ export const Event = {
} }
export interface Interface { export interface Interface {
readonly transform: State.Transform<PluginDraft> readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
readonly reload: State.Reload readonly remove: (id: ID) => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
@@ -40,57 +40,49 @@ export const layer = Layer.effect(
const locks = KeyedMutex.makeUnsafe<ID>() const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make() const scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>() const active = new Map<ID, Scope.Closeable>()
const loading = new Set<ID>()
let host: Parameters<Plugin["effect"]>[0] let host: Parameters<Plugin["effect"]>[0]
const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters<Plugin["effect"]>[0]) { const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
const id = ID.make(plugin.id) if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${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 child = yield* Scope.fork(scope) yield* locks.withLock(id)(
yield* plugin.effect(host).pipe( Effect.sync(() => loading.add(id)).pipe(
Scope.provide(child), Effect.andThen(
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), State.batch(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.gen(function* () {
) const existing = active.get(id)
active.set(id, child) active.delete(id)
yield* events.publish(Event.Added, { 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) { const remove = Effect.fn("Plugin.remove")(function* (id: ID) {
yield* locks.withLock(id)( if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
Effect.gen(function* () {
const current = active.get(id)
active.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
}),
)
})
const state = State.create<Map<ID, Plugin>, PluginDraft>({ yield* locks.withLock(id)(
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) =>
State.batch( State.batch(
Effect.gen(function* () { Effect.gen(function* () {
const desired = new Set<ID>() const current = active.get(id)
for (const plugin of draft.list()) desired.add(ID.make(plugin.id)) active.delete(id)
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
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")),
), ),
)
}) })
yield* Effect.addFinalizer((exit) => yield* Effect.addFinalizer((exit) =>
@@ -101,8 +93,8 @@ export const layer = Layer.effect(
) )
const service = Service.of({ const service = Service.of({
transform: state.transform, add,
reload: state.reload, remove,
}) })
host = yield* PluginHost.make(service) host = yield* PluginHost.make(service)
return service return service
+3 -3
View File
@@ -8,7 +8,7 @@ import { Catalog } from "../catalog"
import { CommandV2 } from "../command" import { CommandV2 } from "../command"
import { Integration } from "../integration" import { Integration } from "../integration"
import { ModelV2 } from "../model" import { ModelV2 } from "../model"
import type { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider" import { ProviderV2 } from "../provider"
import { Reference } from "../reference" import { Reference } from "../reference"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
@@ -126,8 +126,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
), ),
}, },
plugin: { plugin: {
reload: plugin.reload, add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
transform: plugin.transform, remove: (id) => plugin.remove(PluginV2.ID.make(id)),
}, },
reference: { reference: {
reload: reference.reload, reload: reference.reload,
+38 -44
View File
@@ -23,10 +23,8 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
import { Reference } from "../reference" import { Reference } from "../reference"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { State } from "../state"
import { AgentPlugin } from "./agent" import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command" import { CommandPlugin } from "./command"
import { PluginHost } from "./host"
import { ModelsDevPlugin } from "./models-dev" import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider" import { ProviderPlugins } from "./provider"
import { SkillPlugin } from "./skill" import { SkillPlugin } from "./skill"
@@ -73,49 +71,45 @@ export const locationLayer = Layer.effectDiscard(
const global = yield* Global.Service const global = yield* Global.Service
const skill = yield* SkillV2.Service const skill = yield* SkillV2.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
const host = yield* PluginHost.make(plugin) const add = <R>(input: Plugin<R>) => {
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 = <R>(input: Plugin<R>) => ({ yield* Effect.gen(function* () {
id: input.id, yield* add(AgentPlugin.Plugin)
effect: (context: PluginContext) => yield* add(CommandPlugin.Plugin)
input yield* add(SkillPlugin.Plugin)
.effect(context) yield* add(ModelsDevPlugin)
.pipe( yield* add(ConfigProviderPlugin.Plugin)
Effect.provideService(Catalog.Service, catalog), yield* add(ConfigAgentPlugin.Plugin)
Effect.provideService(CommandV2.Service, commands), yield* add(ConfigCommandPlugin.Plugin)
Effect.provideService(Integration.Service, integration), yield* add(ConfigSkillPlugin.Plugin)
Effect.provideService(AgentV2.Service, agents), yield* add(ConfigReferencePlugin.Plugin)
Effect.provideService(Config.Service, config), for (const item of ProviderPlugins) yield* add(item)
Effect.provideService(Location.Service, location), yield* add(ConfigExternalPlugin.Plugin)
Effect.provideService(ModelsDev.Service, modelsDev), }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
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 }))
}), }),
).pipe( ).pipe(
Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(PluginV2.locationLayer),
+5 -2
View File
@@ -67,8 +67,11 @@ export function fromPromise(plugin: Plugin) {
reload: () => run(host.integration.reload()), reload: () => run(host.integration.reload()),
}, },
plugin: { plugin: {
transform: transform(host.plugin), add: (input) => {
reload: () => run(host.plugin.reload()), const child = fromPromise(input)
return run(host.plugin.add(child))
},
remove: (id) => run(host.plugin.remove(id)),
}, },
reference: { reference: {
transform: transform(host.reference), transform: transform(host.reference),
+13 -16
View File
@@ -197,22 +197,19 @@ describe("LocationServiceMap", () => {
Effect.flatMap((dir) => Effect.flatMap((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
yield* plugins.transform((draft) => const reviewer = define({
draft.add( id: "reviewer",
define({ effect: (ctx) =>
id: "reviewer", ctx.agent
effect: (ctx) => .transform((agent) => {
ctx.agent agent.update("reviewer", (item) => {
.transform((agent) => { item.description = "Reviews code"
agent.update("reviewer", (item) => { item.mode = "subagent"
item.description = "Reviews code" })
item.mode = "subagent" })
}) .pipe(Effect.asVoid),
}) })
.pipe(Effect.asVoid), yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect)
}),
),
)
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
description: "Reviews code", description: "Reviews code",
+17 -18
View File
@@ -9,35 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
describe("PluginV2", () => { describe("PluginV2", () => {
it.effect("reconciles transformed plugins", () => it.effect("adds, replaces, and removes plugins", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
let description = "first" let description = "first"
const registration = yield* plugins.transform((draft) => { const managed = () =>
draft.add( define({
define({ id: "managed",
id: "managed", effect: (ctx) =>
effect: (ctx) => ctx.agent
ctx.agent .transform((agents) =>
.transform((agents) => agents.update("configured", (agent) => {
agents.update("configured", (agent) => { agent.description = description
agent.description = description }),
}), )
) .pipe(Effect.asVoid),
.pipe(Effect.asVoid), })
}),
) yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
})
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
description = "second" 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") 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() expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
}), }),
) )
+2 -2
View File
@@ -33,8 +33,8 @@ export function host(overrides: Overrides = {}): PluginContext {
reload: () => Effect.die("unused integration.reload"), reload: () => Effect.die("unused integration.reload"),
}, },
plugin: overrides.plugin ?? { plugin: overrides.plugin ?? {
transform: () => Effect.die("unused plugin.transform"), add: () => Effect.die("unused plugin.add"),
reload: () => Effect.die("unused plugin.reload"), remove: () => Effect.die("unused plugin.remove"),
}, },
reference: overrides.reference ?? { reference: overrides.reference ?? {
transform: () => Effect.die("unused reference.transform"), transform: () => Effect.die("unused reference.transform"),
+2 -2
View File
@@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js"
import type { CatalogHooks } from "./catalog.js" import type { CatalogHooks } from "./catalog.js"
import type { CommandHooks } from "./command.js" import type { CommandHooks } from "./command.js"
import type { IntegrationHooks } from "./integration.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 { ReferenceHooks } from "./reference.js"
import type { SkillHooks } from "./skill.js" import type { SkillHooks } from "./skill.js"
import type { Reload } from "./registration.js" import type { Reload } from "./registration.js"
@@ -16,7 +16,7 @@ export interface PluginContext {
readonly catalog: CatalogHooks & Reload readonly catalog: CatalogHooks & Reload
readonly command: CommandHooks & Reload readonly command: CommandHooks & Reload
readonly integration: IntegrationHooks & Reload readonly integration: IntegrationHooks & Reload
readonly plugin: PluginHooks & Reload readonly plugin: PluginDomain
readonly reference: ReferenceHooks & Reload readonly reference: ReferenceHooks & Reload
readonly skill: SkillHooks & Reload readonly skill: SkillHooks & Reload
} }
+1 -1
View File
@@ -1,3 +1,3 @@
export type { PluginContext } from "./context.js" export type { PluginContext } from "./context.js"
export { define } from "./plugin.js" export { define } from "./plugin.js"
export type { Plugin, PluginDraft } from "./plugin.js" export type { Plugin } from "./plugin.js"
+3 -15
View File
@@ -1,7 +1,5 @@
import type { Effect, Scope } from "effect" import type { Effect, Scope } from "effect"
import type { PluginContext } from "./context.js" import type { PluginContext } from "./context.js"
import type { PluginOptions } from "../options.js"
import type { Hooks } from "./registration.js"
export interface Plugin { export interface Plugin {
readonly id: string readonly id: string
@@ -12,17 +10,7 @@ export function define(plugin: Plugin) {
return plugin return plugin
} }
export interface PluginRef { export interface PluginDomain {
readonly package: string readonly add: (plugin: Plugin) => Effect.Effect<void>
readonly options?: PluginOptions readonly remove: (id: string) => Effect.Effect<void>
} }
export interface PluginDraft {
list(): readonly Plugin[]
add(plugin: Plugin): void
remove(id: string): void
}
export type PluginHooks = Hooks<{
transform: PluginDraft
}>
+2 -2
View File
@@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js"
import type { CatalogHooks } from "./catalog.js" import type { CatalogHooks } from "./catalog.js"
import type { CommandHooks } from "./command.js" import type { CommandHooks } from "./command.js"
import type { IntegrationHooks } from "./integration.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 { ReferenceHooks } from "./reference.js"
import type { SkillHooks } from "./skill.js" import type { SkillHooks } from "./skill.js"
import type { Reload } from "./registration.js" import type { Reload } from "./registration.js"
@@ -16,7 +16,7 @@ export interface PluginContext {
readonly catalog: CatalogHooks & Reload readonly catalog: CatalogHooks & Reload
readonly command: CommandHooks & Reload readonly command: CommandHooks & Reload
readonly integration: IntegrationHooks & Reload readonly integration: IntegrationHooks & Reload
readonly plugin: PluginHooks & Reload readonly plugin: PluginDomain
readonly reference: ReferenceHooks & Reload readonly reference: ReferenceHooks & Reload
readonly skill: SkillHooks & Reload readonly skill: SkillHooks & Reload
} }
+1 -1
View File
@@ -1,7 +1,7 @@
export type { PluginContext } from "./context.js" export type { PluginContext } from "./context.js"
export type { PluginOptions } from "../options.js" export type { PluginOptions } from "../options.js"
export { define } from "./plugin.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 { Registration, Reload } from "./registration.js"
export type { AgentDraft, AgentHooks } from "./agent.js" export type { AgentDraft, AgentHooks } from "./agent.js"
export type { AISDKHooks } from "./aisdk.js" export type { AISDKHooks } from "./aisdk.js"
+4 -7
View File
@@ -1,6 +1,4 @@
import type { PluginContext } from "./context.js" import type { PluginContext } from "./context.js"
import type { PluginDraft, PluginRef } from "../effect/plugin.js"
import type { Hooks } from "./registration.js"
export interface Plugin { export interface Plugin {
readonly id: string readonly id: string
@@ -11,8 +9,7 @@ export function define(plugin: Plugin) {
return plugin return plugin
} }
export type { PluginDraft, PluginRef } export interface PluginDomain {
readonly add: (plugin: Plugin) => Promise<void>
export type PluginHooks = Hooks<{ readonly remove: (id: string) => Promise<void>
transform: PluginDraft }
}>