effectify Plugin service: migrate from Instance.state to Effect service pattern

Replace the legacy Instance.state() lazy-init pattern with the standard
Effect service pattern (Interface, Service class, Layer, promise facades).
Register Plugin.Service in InstanceServices and add its layer to the
instance lookup.
This commit is contained in:
Kit Langton 2026-03-19 15:14:41 -04:00
commit 604697f7f8
3 changed files with 146 additions and 104 deletions

View file

@ -126,7 +126,7 @@ Done now:
Still open and likely worth migrating: Still open and likely worth migrating:
- [ ] `Plugin` - [x] `Plugin`
- [ ] `ToolRegistry` - [ ] `ToolRegistry`
- [ ] `Pty` - [ ] `Pty`
- [ ] `Worktree` - [ ] `Worktree`

View file

@ -10,6 +10,7 @@ import { ProviderAuth } from "@/provider/auth"
import { Question } from "@/question" import { Question } from "@/question"
import { Skill } from "@/skill/skill" import { Skill } from "@/skill/skill"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { InstanceContext } from "./instance-context" import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
@ -26,6 +27,7 @@ export type InstanceServices =
| File.Service | File.Service
| Skill.Service | Skill.Service
| Snapshot.Service | Snapshot.Service
| Plugin.Service
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need // NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the // the full instance context (directory, worktree, project). We read from the
@ -46,6 +48,7 @@ function lookup(_key: string) {
Layer.fresh(File.layer), Layer.fresh(File.layer),
Layer.fresh(Skill.defaultLayer), Layer.fresh(Skill.defaultLayer),
Layer.fresh(Snapshot.defaultLayer), Layer.fresh(Snapshot.defaultLayer),
Layer.fresh(Plugin.layer),
).pipe(Layer.provide(ctx)) ).pipe(Layer.provide(ctx))
} }

View file

@ -5,13 +5,15 @@ import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server" import { Server } from "../server/server"
import { BunProc } from "../bun" import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex" import { CodexAuthPlugin } from "./codex"
import { Session } from "../session" import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot" import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@ -19,126 +21,163 @@ export namespace Plugin {
// Built-in plugins that are directly imported (not installed from npm) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin] const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
const state = Instance.state(async () => { export interface Interface {
const client = createOpencodeClient({ readonly trigger: <
baseUrl: "http://localhost:4096", Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
directory: Instance.directory, Input = Parameters<Required<Hooks>[Name]>[0],
headers: Flag.OPENCODE_SERVER_PASSWORD Output = Parameters<Required<Hooks>[Name]>[1],
? { >(
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, name: Name,
} input: Input,
: undefined, output: Output,
fetch: async (...args) => Server.Default().fetch(...args), ) => Effect.Effect<Output>
}) readonly list: () => Effect.Effect<Hooks[]>
const config = await Config.get() readonly init: () => Effect.Effect<void>
const hooks: Hooks[] = [] }
const input: PluginInput = {
client,
project: Instance.project,
worktree: Instance.worktree,
directory: Instance.directory,
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
$: Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input).catch((err) => { export const layer = Layer.effect(
log.error("failed to load internal plugin", { name: plugin.name, error: err }) Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const hooks: Hooks[] = []
yield* Effect.promise(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: instance.directory,
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().fetch(...args),
})
const config = await Config.get()
const input: PluginInput = {
client,
project: instance.project,
worktree: instance.worktree,
directory: instance.directory,
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
$: Bun.$,
}
for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input).catch((err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
})
if (init) hooks.push(init)
}
let plugins = config.plugin ?? []
if (plugins.length) await Config.waitForDependencies()
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!plugin) continue
}
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
})
}
}) })
if (init) hooks.push(init)
}
let plugins = config.plugin ?? [] const trigger = Effect.fn("Plugin.trigger")(function* <
if (plugins.length) await Config.waitForDependencies() Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0],
for (let plugin of plugins) { Output = Parameters<Required<Hooks>[Name]>[1],
// ignore old codex plugin since it is supported first party now >(name: Name, input: Input, output: Output) {
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue if (!name) return output
log.info("loading plugin", { path: plugin }) yield* Effect.promise(async () => {
if (!plugin.startsWith("file://")) { for (const hook of hooks) {
const lastAtIndex = plugin.lastIndexOf("@") const fn = hook[name]
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin if (!fn) continue
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" // @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
plugin = await BunProc.install(pkg, version).catch((err) => { // give up.
const cause = err instanceof Error ? err.cause : err // try-counter: 2
const detail = cause instanceof Error ? cause.message : String(cause ?? err) await fn(input, output)
log.error("failed to install plugin", { pkg, version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!plugin) continue
}
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// Object.entries(mod) would return both entries pointing to the same function reference.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
} }
}) })
.catch((err) => { return output
const message = err instanceof Error ? err.message : String(err) })
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, { const list = Effect.fn("Plugin.list")(function* () {
error: new NamedError.Unknown({ return hooks
message: `Failed to load plugin ${plugin}: ${message}`, })
}).toObject(),
const init = Effect.fn("Plugin.init")(function* () {
yield* Effect.promise(async () => {
const config = await Config.get()
for (const hook of hooks) {
await (hook as any).config?.(config)
}
Bus.subscribeAll(async (input) => {
for (const hook of hooks) {
hook["event"]?.({
event: input,
})
}
}) })
}) })
} })
return { return Service.of({ trigger, list, init })
hooks, }),
input, )
}
})
export async function trigger< export async function trigger<
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">, Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0], Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1], Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> { >(name: Name, input: Input, output: Output): Promise<Output> {
if (!name) return output return runPromiseInstance(Service.use((svc) => svc.trigger(name, input, output)))
for (const hook of await state().then((x) => x.hooks)) {
const fn = hook[name]
if (!fn) continue
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
// give up.
// try-counter: 2
await fn(input, output)
}
return output
} }
export async function list() { export async function list() {
return state().then((x) => x.hooks) return runPromiseInstance(Service.use((svc) => svc.list()))
} }
export async function init() { export async function init() {
const hooks = await state().then((x) => x.hooks) return runPromiseInstance(Service.use((svc) => svc.init()))
const config = await Config.get()
for (const hook of hooks) {
// @ts-expect-error this is because we haven't moved plugin to sdk v2
await hook.config?.(config)
}
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
for (const hook of hooks) {
hook["event"]?.({
event: input,
})
}
})
} }
} }