import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule, WorkspaceAdapter as PluginWorkspaceAdapter, } from "@opencode-ai/plugin" import { Config } from "@/config/config" import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { AzureAuthPlugin } from "./azure" import { DigitalOceanAuthPlugin } from "./digitalocean" import { XaiAuthPlugin } from "./xai" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { registerAdapter } from "@/control-plane/adapters" import type { WorkspaceAdapter } from "@/control-plane/types" import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "plugin" }) type State = { hooks: Hooks[] } // Hook names that follow the (input, output) => Promise trigger pattern type TriggerName = { [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never }[keyof Hooks] export interface Interface { readonly trigger: < Name extends TriggerName, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >( name: Name, input: Input, output: Output, ) => Effect.Effect readonly list: () => Effect.Effect readonly init: () => Effect.Effect } export class Service extends Context.Service()("@opencode/Plugin") {} // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [ CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin, CloudflareWorkersAuthPlugin, CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, DigitalOceanAuthPlugin, XaiAuthPlugin, ] function isServerPlugin(value: unknown): value is PluginInstance { return typeof value === "function" } function getServerPlugin(value: unknown) { if (isServerPlugin(value)) return value if (!value || typeof value !== "object" || !("server" in value)) return if (!isServerPlugin(value.server)) return return value.server } function getLegacyPlugins(mod: Record) { const seen = new Set() const result: PluginInstance[] = [] for (const entry of Object.values(mod)) { if (seen.has(entry)) continue seen.add(entry) const plugin = getServerPlugin(entry) if (!plugin) throw new TypeError("Plugin export is not a function") result.push(plugin) } return result } async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) hooks.push(await (plugin as PluginModule).server(input, load.options)) return } for (const server of getLegacyPlugins(load.mod)) { hooks.push(await server(input, load.options)) } } export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const config = yield* Config.Service const flags = yield* RuntimeFlags.Service const state = yield* InstanceState.make( Effect.fn("Plugin.state")(function* (ctx) { const hooks: Hooks[] = [] const bridge = yield* EffectBridge.make() function publishPluginError(message: string) { bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) } const { Server } = yield* Effect.promise(() => import("../server/server")) const client = createOpencodeClient({ baseUrl: "http://localhost:4096", directory: ctx.directory, headers: ServerAuth.headers(), fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() const input: PluginInput = { client, project: ctx.project, worktree: ctx.worktree, directory: ctx.directory, experimental_workspace: { register(type: string, adapter: PluginWorkspaceAdapter) { registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) }, }, get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, // @ts-expect-error $: typeof Bun === "undefined" ? undefined : Bun.$, } for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) { log.info("loading internal plugin", { name: plugin.name }) const init = yield* Effect.tryPromise({ try: () => plugin(input), catch: (err) => { log.error("failed to load internal plugin", { name: plugin.name, error: err }) }, }).pipe(Effect.option) if (init._tag === "Some") hooks.push(init.value) } const plugins = flags.pure ? [] : (cfg.plugin_origins ?? []) if (flags.pure && cfg.plugin_origins?.length) { log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) } if (plugins.length) yield* config.waitForDependencies() const loaded = yield* Effect.promise(() => PluginLoader.loadExternal({ items: plugins, kind: "server", report: { start(candidate) { log.info("loading plugin", { path: candidate.plan.spec }) }, missing(candidate, _retry, message) { log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) }, error(candidate, _retry, stage, error, resolved) { const spec = candidate.plan.spec const cause = error instanceof Error ? (error.cause ?? error) : error const message = stage === "load" ? errorMessage(error) : errorMessage(cause) if (stage === "install") { const parsed = parsePluginSpecifier(spec) log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) return } if (stage === "compatibility") { log.warn("plugin incompatible", { path: spec, error: message }) publishPluginError(`Plugin ${spec} skipped: ${message}`) return } if (stage === "entry") { log.error("failed to resolve plugin server entry", { path: spec, error: message }) publishPluginError(`Failed to load plugin ${spec}: ${message}`) return } log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) publishPluginError(`Failed to load plugin ${spec}: ${message}`) }, }, }), ) for (const load of loaded) { if (!load) continue // Keep plugin execution sequential so hook registration and execution // order remains deterministic across plugin runs. yield* Effect.tryPromise({ try: () => applyPlugin(load, input, hooks), catch: (err) => { const message = errorMessage(err) log.error("failed to load plugin", { path: load.spec, error: message }) return message }, }).pipe( Effect.catch(() => { // TODO: make proper events for this // bus.publish(Session.Event.Error, { // error: new NamedError.Unknown({ // message: `Failed to load plugin ${load.spec}: ${message}`, // }).toObject(), // }) return Effect.void }), ) } // Notify plugins of current config for (const hook of hooks) { yield* Effect.tryPromise({ try: () => Promise.resolve((hook as any).config?.(cfg)), catch: (err) => { log.error("plugin config hook failed", { error: err }) }, }).pipe(Effect.ignore) } // Subscribe to bus events, fiber interrupted when scope closes yield* (yield* bus.subscribeAll()).pipe( Stream.runForEach((input) => Effect.sync(() => { for (const hook of hooks) { void hook["event"]?.({ event: input as any }) } }), ), Effect.forkScoped, ) return { hooks } }), ) const trigger = Effect.fn("Plugin.trigger")(function* < Name extends TriggerName, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output) { if (!name) return output const s = yield* InstanceState.get(state) for (const hook of s.hooks) { const fn = hook[name] as any if (!fn) continue yield* Effect.promise(async () => fn(input, output)) } return output }) const list = Effect.fn("Plugin.list")(function* () { const s = yield* InstanceState.get(state) return s.hooks }) const init = Effect.fn("Plugin.init")(function* () { yield* InstanceState.get(state) }) return Service.of({ trigger, list, init }) }), ) export const defaultLayer = layer.pipe( Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ) export * as Plugin from "."