refactor(plugin): use direct runtime registry

This commit is contained in:
Dax Raad 2026-06-22 20:10:29 -04:00
commit 975b1132f1
14 changed files with 129 additions and 167 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),