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

View File

@ -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<string, any> }[] = []
@ -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 }))
}),
})

View File

@ -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<PluginDraft>
readonly reload: State.Reload
readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
}
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 scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>()
const loading = new Set<ID>()
let host: Parameters<Plugin["effect"]>[0]
const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters<Plugin["effect"]>[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<Map<ID, Plugin>, 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<ID>()
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

View File

@ -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,

View File

@ -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 = <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>) => ({
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),

View File

@ -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),

View File

@ -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",

View File

@ -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()
}),
)

View File

@ -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"),

View File

@ -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
}

View File

@ -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"

View File

@ -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<void>
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
}>

View File

@ -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
}

View File

@ -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"

View File

@ -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<void>
readonly remove: (id: string) => Promise<void>
}