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 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 }))
}),
})
+23 -31
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,17 +40,23 @@ 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)
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}`)
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* plugin.effect(host).pipe(
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)),
@@ -58,39 +64,25 @@ export const layer = Layer.effect(
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) {
if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
yield* locks.withLock(id)(
State.batch(
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>({
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(
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")),
),
)
})
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
+3 -3
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,
+18 -24
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,9 +71,8 @@ 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 wrap = <R>(input: Plugin<R>) => ({
const add = <R>(input: Plugin<R>) => {
const loaded = {
id: input.id,
effect: (context: PluginContext) =>
input
@@ -96,26 +93,23 @@ export const locationLayer = Layer.effectDiscard(
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
),
})
}
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
}
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),
+5 -2
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),
+3 -6
View File
@@ -197,9 +197,7 @@ describe("LocationServiceMap", () => {
Effect.flatMap((dir) =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
yield* plugins.transform((draft) =>
draft.add(
define({
const reviewer = define({
id: "reviewer",
effect: (ctx) =>
ctx.agent
@@ -210,9 +208,8 @@ describe("LocationServiceMap", () => {
})
})
.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",
+6 -7
View File
@@ -9,14 +9,13 @@ 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(
const managed = () =>
define({
id: "managed",
effect: (ctx) =>
@@ -27,17 +26,17 @@ describe("PluginV2", () => {
}),
)
.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()
}),
)
+2 -2
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"),
+2 -2
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
}
+1 -1
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"
+3 -15
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
}>
+2 -2
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
}
+1 -1
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"
+4 -7
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>
}