diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 72b1c1ff0f..d1262372a9 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -31,6 +31,7 @@ import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" +import { SdkPlugins } from "./sdk" import { SkillPlugin } from "./skill" import { VariantPlugin } from "./variant" @@ -65,6 +66,7 @@ const layer = Layer.effectDiscard( const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service const plugin = yield* PluginV2.Service + const sdkPlugins = yield* SdkPlugins.Service const integration = yield* Integration.Service const agents = yield* AgentV2.Service const config = yield* Config.Service @@ -119,6 +121,8 @@ const layer = Layer.effectDiscard( yield* add(ConfigExternalPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin) yield* add(VariantPlugin.Plugin) + // Embedder-contributed plugins are added last so they layer over config. + for (const plugin of sdkPlugins.all()) yield* add(plugin) }), ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), @@ -151,5 +155,6 @@ export const node = makeLocationNode({ httpClient, SkillV2.node, Reference.node, + SdkPlugins.node, ], }) diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts new file mode 100644 index 0000000000..d3788116f7 --- /dev/null +++ b/packages/core/src/plugin/sdk.ts @@ -0,0 +1,37 @@ +export * as SdkPlugins from "./sdk" + +import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "../effect/app-node" + +/** + * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, + * so `PluginInternal` can add them on every Location boot through the ordinary + * `ctx.plugin.add` seam — the same path `ConfigExternalPlugin` uses for plugins + * discovered from config. A plugin registered after a Location has booted only + * applies to Locations booted afterward, matching config-plugin timing; + * embedders register at startup before creating Sessions. + * + * State lives in this global-node service (like `ApplicationTools`) rather than + * module scope, so the list belongs to one embedded instance and is disposed + * with it instead of leaking across `OpenCode.create` calls. + */ +export interface Interface { + readonly register: (plugin: Plugin) => Effect.Effect + readonly all: () => readonly Plugin[] +} + +export class Service extends Context.Service()("@opencode/SdkPlugins") {} + +export const layer = Layer.effect( + Service, + Effect.sync(() => { + const plugins: Plugin[] = [] + return Service.of({ + register: (plugin) => Effect.sync(() => void plugins.push(plugin)), + all: () => plugins, + }) + }), +) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 5256548efc..c6324067cd 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,5 +1,6 @@ import { OpenCode } from "@opencode-ai/client/effect" import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import { Context, Effect, Layer, Scope } from "effect" @@ -9,11 +10,12 @@ export const create = Effect.fn("OpenCode.create")(function* () { const scope = yield* Scope.Scope const memoMap = yield* Layer.makeMemoMap const context = yield* Layer.buildWithMemoMap( - Layer.merge(ApplicationTools.layer, PermissionSaved.defaultLayer), + Layer.mergeAll(ApplicationTools.layer, PermissionSaved.defaultLayer, SdkPlugins.layer), memoMap, scope, ) const tools = Context.get(context, ApplicationTools.Service) + const plugins = Context.get(context, SdkPlugins.Service) const permissions = Context.get(context, PermissionSaved.Service) const web = yield* Effect.acquireRelease( Effect.sync(() => @@ -37,6 +39,12 @@ export const create = Effect.fn("OpenCode.create")(function* () { return { ...client, tools: { register: tools.register }, + // The embedded host contributes plugins through the ordinary discovery flow: + // each plugin's `effect` runs inside every Location with the real + // `PluginContext`, so `ctx.agent.transform` and every other hook behave exactly + // as they do for a config-discovered plugin. Define agent profiles here at + // startup, then select one per Session with `sessions.create({ agent })`. + plugin: plugins.register, } })