From a9b7bd9e2f2d3ff4429940dbbd850a62b6f3776d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 5 Jul 2026 15:51:15 -0400 Subject: [PATCH] feat(core): manage configurable plugin generations --- packages/core/src/config.ts | 2 +- packages/core/src/config/plugin/agent.ts | 4 +- packages/core/src/config/plugin/command.ts | 4 +- packages/core/src/config/plugin/external.ts | 136 ------ packages/core/src/config/plugin/provider.ts | 4 +- packages/core/src/config/plugin/reference.ts | 4 +- packages/core/src/config/plugin/skill.ts | 4 +- packages/core/src/event.ts | 24 +- packages/core/src/location-services.ts | 60 ++- packages/core/src/plugin.ts | 139 ++---- packages/core/src/plugin/agent.ts | 4 +- packages/core/src/plugin/command.ts | 4 +- packages/core/src/plugin/host.ts | 2 - packages/core/src/plugin/internal.ts | 288 +++++------- packages/core/src/plugin/models-dev.ts | 4 +- packages/core/src/plugin/promise.ts | 7 +- packages/core/src/plugin/provider.ts | 3 +- packages/core/src/plugin/provider/alibaba.ts | 4 +- .../src/plugin/provider/amazon-bedrock.ts | 4 +- .../core/src/plugin/provider/anthropic.ts | 4 +- packages/core/src/plugin/provider/azure.ts | 6 +- packages/core/src/plugin/provider/cerebras.ts | 4 +- .../plugin/provider/cloudflare-ai-gateway.ts | 4 +- .../plugin/provider/cloudflare-workers-ai.ts | 4 +- packages/core/src/plugin/provider/cohere.ts | 4 +- .../core/src/plugin/provider/deepinfra.ts | 4 +- packages/core/src/plugin/provider/dynamic.ts | 4 +- packages/core/src/plugin/provider/gateway.ts | 4 +- .../src/plugin/provider/github-copilot.ts | 4 +- packages/core/src/plugin/provider/gitlab.ts | 4 +- .../core/src/plugin/provider/google-vertex.ts | 6 +- packages/core/src/plugin/provider/google.ts | 4 +- packages/core/src/plugin/provider/groq.ts | 4 +- packages/core/src/plugin/provider/kilo.ts | 4 +- .../core/src/plugin/provider/llmgateway.ts | 4 +- packages/core/src/plugin/provider/mistral.ts | 4 +- packages/core/src/plugin/provider/nvidia.ts | 4 +- .../src/plugin/provider/openai-compatible.ts | 4 +- packages/core/src/plugin/provider/openai.ts | 5 +- packages/core/src/plugin/provider/opencode.ts | 2 +- .../core/src/plugin/provider/openrouter.ts | 4 +- .../core/src/plugin/provider/perplexity.ts | 4 +- .../core/src/plugin/provider/sap-ai-core.ts | 4 +- .../src/plugin/provider/snowflake-cortex.ts | 4 +- .../core/src/plugin/provider/togetherai.ts | 4 +- packages/core/src/plugin/provider/venice.ts | 4 +- packages/core/src/plugin/provider/vercel.ts | 4 +- packages/core/src/plugin/provider/xai.ts | 4 +- packages/core/src/plugin/provider/zenmux.ts | 4 +- packages/core/src/plugin/sdk.ts | 6 +- packages/core/src/plugin/skill.ts | 4 +- packages/core/src/plugin/supervisor.ts | 270 +++++++++++ packages/core/src/plugin/variant.ts | 4 +- packages/core/src/state.ts | 29 +- packages/core/src/tool/apply-patch.ts | 2 +- packages/core/src/tool/edit.ts | 2 +- packages/core/src/tool/glob.ts | 2 +- packages/core/src/tool/grep.ts | 2 +- packages/core/src/tool/question.ts | 2 +- packages/core/src/tool/read.ts | 2 +- packages/core/src/tool/shell.ts | 2 +- packages/core/src/tool/skill.ts | 2 +- packages/core/src/tool/subagent.ts | 2 +- packages/core/src/tool/todowrite.ts | 2 +- packages/core/src/tool/webfetch.ts | 2 +- packages/core/src/tool/websearch.ts | 2 +- packages/core/src/tool/write.ts | 2 +- packages/core/test/config/plugin.test.ts | 437 ++++++++---------- packages/core/test/config/reload.test.ts | 19 +- packages/core/test/location-layer.test.ts | 126 ++++- packages/core/test/plugin.test.ts | 134 ++++-- .../test/plugin/fixtures/failing-plugin.ts | 7 + .../plugin/fixtures/variant-source-plugin.ts | 29 ++ packages/core/test/plugin/host.ts | 2 - .../core/test/plugin/provider-kilo.test.ts | 4 +- .../test/plugin/provider-llmgateway.test.ts | 4 +- .../core/test/plugin/provider-nvidia.test.ts | 4 +- .../test/plugin/provider-openrouter.test.ts | 4 +- .../plugin/provider-snowflake-cortex.test.ts | 6 +- .../core/test/plugin/provider-zenmux.test.ts | 4 +- packages/opencode/src/agent/agent.ts | 4 +- packages/plugin/src/v2/effect/plugin.ts | 5 +- packages/plugin/src/v2/promise/plugin.ts | 5 +- 83 files changed, 1127 insertions(+), 830 deletions(-) delete mode 100644 packages/core/src/config/plugin/external.ts create mode 100644 packages/core/src/plugin/supervisor.ts create mode 100644 packages/core/test/plugin/fixtures/failing-plugin.ts create mode 100644 packages/core/test/plugin/fixtures/variant-source-plugin.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 48c380aef3..4bd33ca240 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -102,7 +102,7 @@ export class Info extends Schema.Class("Config.Info")({ description: "Named local directories or Git repositories available as external context", }), plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ - description: "Ordered external plugin packages to load", + description: "Ordered plugin enablement directives and external package declarations", }), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 3aebf88fbe..fa8b61ce18 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,6 +1,6 @@ export * as ConfigAgentPlugin from "./agent" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" import { Effect, Option, Schema, Stream } from "effect" import { AgentV2 } from "../../agent" @@ -34,7 +34,7 @@ const agentKeys = new Set([ ]) export const Plugin = define({ - id: "config-agent", + id: "opencode.config.agent", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index 43ba4cd375..a7babbb5aa 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,6 +1,6 @@ export * as ConfigCommandPlugin from "./command" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" import { Effect, Option, Schema, Stream } from "effect" import { CommandV2 } from "../../command" @@ -13,7 +13,7 @@ import { ConfigMarkdown } from "../markdown" const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) export const Plugin = define({ - id: "config-command", + id: "opencode.config.command", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const fs = yield* FSUtil.Service diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts deleted file mode 100644 index 70271dec23..0000000000 --- a/packages/core/src/config/plugin/external.ts +++ /dev/null @@ -1,136 +0,0 @@ -export * as ConfigExternalPlugin from "./external" - -import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" -import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" -import { Effect, Schema } from "effect" -import path from "path" -import { fileURLToPath, pathToFileURL } from "url" -import { Config } from "../../config" -import { FSUtil } from "../../fs-util" -import { Location } from "../../location" -import { Npm } from "../../npm" -import { define } from "../../plugin/internal" -import { PluginPromise } from "../../plugin/promise" - -const PluginModule = Schema.Struct({ - default: Schema.Union([ - Schema.Struct({ - id: Schema.String, - effect: Schema.declare( - (input): input is EffectPlugin["effect"] => typeof input === "function", - ), - }), - Schema.Struct({ - id: Schema.String, - setup: Schema.declare( - (input): input is PromisePlugin["setup"] => typeof input === "function", - ), - }), - ]), -}) - -const PluginPackage = Schema.Struct({ - exports: Schema.optional(Schema.Unknown), - main: Schema.optional(Schema.String), - module: Schema.optional(Schema.String), -}) - -export const Plugin = define({ - id: "config-plugin", - effect: Effect.fn(function* (ctx) { - const config = yield* Config.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const load = Effect.fn("ConfigExternalPlugin.load")(function* () { - const configured: { package: string; options?: Record }[] = [] - - for (const entry of yield* config.entries()) { - if (entry.type === "document") { - const directory = entry.path ? path.dirname(entry.path) : location.directory - for (const item of entry.info.plugins ?? []) { - const ref = typeof item === "string" ? { package: item } : item - const packageName = (() => { - if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) - if (ref.package.startsWith("./") || ref.package.startsWith("../")) { - return path.resolve(directory, ref.package) - } - return ref.package - })() - configured.push({ package: packageName, options: ref.options }) - } - } - - if (entry.type === "directory") { - const files = yield* fs - .glob("{plugin,plugins}/*.{ts,js}", { - cwd: entry.path, - absolute: true, - include: "file", - dot: true, - symlink: true, - }) - .pipe(Effect.orElseSucceed(() => [])) - const directories = yield* fs - .glob("{plugin,plugins}/*", { - cwd: entry.path, - absolute: true, - include: "all", - dot: true, - symlink: true, - }) - .pipe( - Effect.flatMap((items) => - Effect.filter(items, (item) => fs.isDir(item), { - concurrency: "unbounded", - }), - ), - Effect.orElseSucceed(() => []), - ) - const packages = yield* Effect.forEach( - directories.sort(), - (directory) => resolvePackageEntrypoint(fs, directory), - { concurrency: "unbounded" }, - ).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined))) - files.sort() - for (const file of files) configured.push({ package: file }) - for (const file of packages) configured.push({ package: file }) - } - } - - return yield* Effect.forEach(configured, (ref) => - Effect.gen(function* () { - const entrypoint = path.isAbsolute(ref.package) - ? pathToFileURL(ref.package).href - : (yield* npm.add(ref.package)).entrypoint - if (!entrypoint) return - yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint }) - const mod = yield* Effect.promise(() => import(entrypoint)) - const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default - const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - return { - id: plugin.id, - effect: (host: Parameters[0]) => - plugin.effect({ ...host, options: ref.options ?? {} }), - } - }).pipe(Effect.catchCause(() => Effect.succeed(undefined))), - ).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined))) - }) - for (const plugin of yield* load()) yield* ctx.plugin.add(plugin) - }), -}) - -const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { - const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), - Effect.catch(() => Effect.succeed(undefined)), - ) - const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined - const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"] - - return yield* Effect.forEach(entries, (entry) => { - if (!entry) return Effect.succeed(undefined) - const file = path.resolve(directory, entry) - return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined))) - }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined))) -}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index d2d6a26029..320895d128 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,12 +1,12 @@ export * as ConfigProviderPlugin from "./provider" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" export const Plugin = define({ - id: "config-provider", + id: "opencode.config.provider", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const loaded = { entries: yield* config.entries() } diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index d33332f92d..75e0cc10e4 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -1,6 +1,6 @@ export * as ConfigReferencePlugin from "./reference" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" import { Effect, Stream } from "effect" import { Config } from "../../config" @@ -11,7 +11,7 @@ import { Global } from "../../global" import { Location } from "../../location" export const Plugin = define({ - id: "core/config-reference", + id: "opencode.config.reference", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const location = yield* Location.Service diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index ff83c53ff1..84546b901c 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,6 +1,6 @@ export * as ConfigSkillPlugin from "./skill" -import { define } from "../../plugin/internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" import { Effect, Stream } from "effect" import { Config } from "../../config" @@ -10,7 +10,7 @@ import { Global } from "../../global" import { Location } from "../../location" export const Plugin = define({ - id: "config-skill", + id: "opencode.config.skill", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service const global = yield* Global.Service diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 3e352c6e6b..a5b052b42f 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -583,12 +583,32 @@ export const layerWith = (options?: LayerOptions) => .pipe(Effect.orDie) } + const local = (stream: Stream.Stream) => + Stream.unwrap( + Effect.serviceOption(Location.Service).pipe( + Effect.map((location) => + Option.match(location, { + onNone: () => stream, + onSome: (location) => + stream.pipe( + Stream.filter( + (event) => + !event.location || + (event.location.directory === location.directory && + event.location.workspaceID === location.workspaceID), + ), + ), + }), + ), + ), + ) + const subscribe = (definition: D): Stream.Stream> => - Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( + local(Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))).pipe( Stream.map((event) => event as Payload), ) - const streamLive = (): Stream.Stream => Stream.fromPubSub(pubsub.live) + const streamLive = (): Stream.Stream => local(Stream.fromPubSub(pubsub.live)) const readAfter = ( aggregateID: string, diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 2a4e0d6ef6..bbb60482c1 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -5,12 +5,16 @@ import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { Config } from "./config" import { LayerNode } from "./effect/layer-node" -import { Node } from "./effect/app-node" +import { makeLocationNode, Node } from "./effect/app-node" +import { httpClient } from "./effect/app-node-platform" +import { EventV2 } from "./event" import { FileMutation } from "./file-mutation" import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" +import { FSUtil } from "./fs-util" import { Generate } from "./generate" import { Form } from "./form" +import { Global } from "./global" import { LocationWatcher } from "./filesystem/location-watcher" import { Image } from "./image" import { Integration } from "./integration" @@ -18,15 +22,20 @@ import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" +import { ModelsDev } from "./models-dev" +import { Npm } from "./npm" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" -import { PluginInternal } from "./plugin/internal" +import { PluginRuntime } from "./plugin/runtime" +import { SdkPlugins } from "./plugin/sdk" +import { PluginSupervisor } from "./plugin/supervisor" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" +import { Ripgrep } from "./ripgrep" import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" @@ -42,11 +51,49 @@ import { SessionInstructions } from "./session/instructions" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" +import { WebSearchTool } from "./tool/websearch" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" export { LocationServiceMap } from "./location-service-map" +const pluginSupervisorNode = makeLocationNode({ + service: PluginSupervisor.Service, + layer: PluginSupervisor.layer, + deps: [ + PluginV2.node, + SdkPlugins.node, + AgentV2.node, + Catalog.node, + CommandV2.node, + Config.node, + EventV2.node, + FileMutation.node, + FileSystem.node, + FSUtil.node, + Global.node, + httpClient, + Image.node, + Integration.node, + Location.node, + LocationMutation.node, + ModelsDev.node, + Npm.node, + PermissionV2.node, + PluginRuntime.node, + QuestionV2.node, + ReadToolFileSystem.node, + Reference.node, + Ripgrep.node, + SessionInstructions.node, + SessionTodo.node, + Shell.node, + SkillV2.node, + ToolRegistry.toolsNode, + WebSearchTool.configNode, + ], +}) + const locationServiceNodes = [ Location.node, Config.node, @@ -57,12 +104,11 @@ const locationServiceNodes = [ Catalog.node, AISDK.node, PluginV2.node, - PluginInternal.node, + pluginSupervisorNode, ProjectCopy.node, ProjectCopy.refreshNode, FileSystemSearch.node, FileSystem.node, - LocationWatcher.node, Pty.node, Shell.node, SkillV2.node, @@ -92,6 +138,8 @@ const locationServiceNodes = [ Snapshot.node, SessionRunnerLLM.node, Vcs.node, + // Start repository watches only after boot-critical filesystem and Git work. + LocationWatcher.node, ] as const satisfies readonly Node.LocationNode[] export const locationServices = LayerNode.group(locationServiceNodes) @@ -106,6 +154,7 @@ export function buildLocationServiceMap( LocationServiceMap.Service, LayerMap.make( (ref: Location.Ref) => { + const startedAt = performance.now() const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) // Apply replacements during hoist, not afterward: replacements can // introduce new tagged dependencies (Location.boundNode depends on @@ -116,9 +165,10 @@ export function buildLocationServiceMap( return LayerNode.compile(location.node).pipe( Layer.fresh, Layer.tap(() => - Effect.logInfo("booting location services", { + Effect.logInfo("location services booted", { directory: ref.directory, workspaceID: ref.workspaceID, + durationMs: Math.round(performance.now() - startedAt), }), ), Layer.provide(LayerNode.compile(location.hoisted)), diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index a6ff337c62..8ffdf93ba1 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,8 +1,7 @@ export * as PluginV2 from "./plugin" import { makeLocationNode } from "./effect/app-node" -import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" -import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect" +import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" @@ -10,7 +9,6 @@ import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { EventV2 } from "./event" import { Integration } from "./integration" -import { KeyedMutex } from "./effect/keyed-mutex" import { Location } from "./location" import { PluginHost } from "./plugin/host" import { PluginRuntime } from "./plugin/runtime" @@ -27,9 +25,7 @@ export type Info = Plugin.Info export const Event = Plugin.Event export interface Interface { - readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly wait: (id: ID) => Effect.Effect + readonly activate: (plugins: readonly import("@opencode-ai/plugin/v2/effect").Plugin[]) => Effect.Effect readonly list: () => Effect.Effect } @@ -39,110 +35,73 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() const active = new Map() - const loading = new Set() - const waiters = new Map>>() - const failures = new Map>() - let host: Parameters[0] + const lock = Semaphore.makeUnsafe(1) + let generation: readonly ID[] | undefined = [] + let host: Parameters[0] - const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) { - if (loading.has(id)) return yield* Effect.die(new Error(`Plugin load cycle detected for ${id}`)) + const activate = Effect.fn("Plugin.activate")(function* ( + plugins: readonly import("@opencode-ai/plugin/v2/effect").Plugin[], + ) { + const definitions = plugins.map((plugin) => ({ ...plugin, id: ID.make(plugin.id) })) + const ids = new Set() + for (const definition of definitions) { + if (ids.has(definition.id)) return yield* Effect.die(new Error(`Duplicate plugin ID: ${definition.id}`)) + ids.add(definition.id) + } - yield* locks.withLock(id)( - Effect.sync(() => { - loading.add(id) - failures.delete(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) + yield* lock.withPermit( + Effect.gen(function* () { + if ( + generation !== undefined && + generation.length === definitions.length && + generation.every((id, index) => id === definitions[index]?.id) + ) { + return + } + generation = undefined + const exit = yield* State.batch( + Effect.gen(function* () { + const scopes = Array.from(active.values()).toReversed() + active.clear() + const inherit = yield* State.inherit() + yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void).pipe(Effect.ignore), { + discard: true, + }) + for (const definition of definitions) { const child = yield* Scope.fork(scope) - yield* effect(host).pipe( - Scope.provide(child), - Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + const loaded = yield* Effect.suspend(() => definition.effect(host)).pipe( + inherit, + Effect.updateContext((_context: Context.Context) => Context.make(Scope.Scope, child)), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": definition.id } }), + Effect.andThen(events.publish(Event.Added, { id: definition.id })), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + Effect.exit, ) - yield* events.publish(Event.Added, { id }) - active.set(id, child) - yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { - discard: true, - }) - waiters.delete(id) - }), - ), - ), - Effect.onExit((exit) => { - if (Exit.isSuccess(exit)) return Effect.void - failures.set(id, exit) - return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), { - discard: true, - }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id)))) - }), - Effect.ensuring(Effect.sync(() => loading.delete(id))), - ), - ) - }) - - const remove = Effect.fn("Plugin.remove")(function* (id: ID) { - if (loading.has(id)) return yield* Effect.die(new Error(`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) - failures.delete(id) - if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) - }), - ), - ) - }) - - const wait = Effect.fn("Plugin.wait")(function* (id: ID) { - const waiter = yield* Deferred.make() - const pending = yield* locks.withLock(id)( - Effect.sync(() => { - if (active.has(id)) return false - const failure = failures.get(id) - if (failure) return failure - const current = waiters.get(id) ?? new Set() - current.add(waiter) - waiters.set(id, current) - return true - }), - ) - if (!pending) return - if (typeof pending !== "boolean") return yield* pending - yield* Deferred.await(waiter).pipe( - Effect.ensuring( - locks.withLock(id)( - Effect.sync(() => { - const current = waiters.get(id) - current?.delete(waiter) - if (current?.size === 0) waiters.delete(id) + if (Exit.isFailure(loaded)) return loaded + active.set(definition.id, child) + } + return Exit.void }), - ), - ), + ) + if (Exit.isFailure(exit)) return yield* exit + generation = definitions.map((definition) => definition.id) + }), ) }) yield* Effect.addFinalizer((exit) => Effect.gen(function* () { active.clear() + generation = [] yield* State.batch(Scope.close(scope, exit)) }), ) const service = Service.of({ - add, - remove, - wait, + activate, list: Effect.fn("Plugin.list")(function* () { return Array.from(active.keys()).map((id) => ({ id })) }), diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index a0e3556ce8..7549089bf3 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,7 +1,7 @@ export * as AgentPlugin from "./agent" import path from "path" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" @@ -100,7 +100,7 @@ Rules: - If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary` export const Plugin = define({ - id: "agent", + id: "opencode.agent", effect: Effect.fn(function* (ctx) { const location = yield* Location.Service const worktree = location.directory diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 1989641238..7bb14dad31 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,13 +1,13 @@ export * as CommandPlugin from "./command" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect } from "effect" import { Location } from "../location" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" export const Plugin = define({ - id: "command", + id: "opencode.command", effect: Effect.fn(function* (ctx) { const location = yield* Location.Service yield* ctx.command.transform((draft) => { diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 4c7bb8538c..2e58c6b144 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -273,8 +273,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, plugin: { list: () => response(plugin.list()), - add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), - remove: (id) => plugin.remove(PluginV2.ID.make(id)), }, reference: { list: () => response(reference.list()), diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 939bb4e731..63637c96b0 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,16 +1,14 @@ export * as PluginInternal from "./internal" -import { makeLocationNode } from "../effect/app-node" -import { httpClient } from "../effect/app-node-platform" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import { Context, Effect, Layer, Scope } from "effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Context, Effect, Scope } from "effect" +import { HttpClient } from "effect/unstable/http" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" -import { ConfigExternalPlugin } from "../config/plugin/external" import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" @@ -25,8 +23,6 @@ import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { ModelsDev } from "../models-dev" import { Npm } from "../npm" -import { PluginV2 } from "../plugin" -import { PluginRuntime } from "../plugin/runtime" import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" import { Reference } from "../reference" @@ -35,177 +31,137 @@ import { SessionInstructions } from "../session/instructions" import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" -import { State } from "../state" -import { ToolRegistry } from "../tool/registry" -import { Tools } from "../tool/tools" -import { HttpClient } from "effect/unstable/http" -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" import { ApplyPatchTool } from "../tool/apply-patch" import { EditTool } from "../tool/edit" import { GlobTool } from "../tool/glob" import { GrepTool } from "../tool/grep" import { QuestionTool } from "../tool/question" -import { ReadTool } from "../tool/read" import { ReadToolFileSystem } from "../tool/read-filesystem" +import { ReadTool } from "../tool/read" import { ShellTool } from "../tool/shell" import { SkillTool } from "../tool/skill" import { SubagentTool } from "../tool/subagent" import { TodoWriteTool } from "../tool/todowrite" +import { Tools } from "../tool/tools" import { WebFetchTool } from "../tool/webfetch" import { WebSearchTool } from "../tool/websearch" import { WriteTool } from "../tool/write" +import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" +import { ModelsDevPlugin } from "./models-dev" +import { ProviderPlugins } from "./provider" +import { PluginRuntime } from "./runtime" +import { SkillPlugin } from "./skill" +import { VariantPlugin } from "./variant" -export type Requirements = - | AgentV2.Service - | Catalog.Service - | CommandV2.Service - | Config.Service - | EventV2.Service - | FileMutation.Service - | FileSystem.Service - | FSUtil.Service - | Global.Service - | HttpClient.HttpClient - | Image.Service - | Integration.Service - | Location.Service - | LocationMutation.Service - | ModelsDev.Service - | Npm.Service - | PermissionV2.Service - | PluginRuntime.Service - | QuestionV2.Service - | ReadToolFileSystem.Service - | Reference.Service - | Ripgrep.Service - | SessionInstructions.Service - | SessionTodo.Service - | Shell.Service - | SkillV2.Service - | Tools.Service - | WebSearchTool.ConfigService - -export interface Plugin { - readonly id: string - readonly effect: (context: PluginContext) => Effect.Effect -} - -export function define(plugin: Plugin) { - return plugin -} - -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const sdkPlugins = yield* SdkPlugins.Service - const services = Context.mergeAll( - Context.make(Catalog.Service, yield* Catalog.Service), - Context.make(CommandV2.Service, yield* CommandV2.Service), - Context.make(Integration.Service, yield* Integration.Service), - Context.make(AgentV2.Service, yield* AgentV2.Service), - Context.make(Config.Service, yield* Config.Service), - Context.make(Location.Service, yield* Location.Service), - Context.make(ModelsDev.Service, yield* ModelsDev.Service), - Context.make(Npm.Service, yield* Npm.Service), - Context.make(EventV2.Service, yield* EventV2.Service), - Context.make(FSUtil.Service, yield* FSUtil.Service), - Context.make(FileSystem.Service, yield* FileSystem.Service), - Context.make(Global.Service, yield* Global.Service), - Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient), - Context.make(LocationMutation.Service, yield* LocationMutation.Service), - Context.make(FileMutation.Service, yield* FileMutation.Service), - Context.make(Image.Service, yield* Image.Service), - Context.make(PermissionV2.Service, yield* PermissionV2.Service), - Context.make(QuestionV2.Service, yield* QuestionV2.Service), - Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service), - Context.make(SessionInstructions.Service, yield* SessionInstructions.Service), - Context.make(SessionTodo.Service, yield* SessionTodo.Service), - Context.make(SkillV2.Service, yield* SkillV2.Service), - Context.make(Reference.Service, yield* Reference.Service), - Context.make(Ripgrep.Service, yield* Ripgrep.Service), - Context.make(Shell.Service, yield* Shell.Service), - Context.make(Tools.Service, yield* Tools.Service), - Context.make(PluginRuntime.Service, yield* PluginRuntime.Service), - Context.make(WebSearchTool.ConfigService, yield* WebSearchTool.ConfigService), - ) - const add = (input: Plugin) => - plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) => - input.effect(context).pipe(Effect.provide(services)), - ) - - yield* State.batch( - Effect.gen(function* () { - yield* add(ConfigReferencePlugin.Plugin) - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigExternalPlugin.Plugin) - yield* add(ApplyPatchTool.Plugin) - yield* add(EditTool.Plugin) - yield* add(GlobTool.Plugin) - yield* add(GrepTool.Plugin) - yield* add(QuestionTool.Plugin) - yield* add(ReadTool.Plugin) - yield* add(ShellTool.Plugin) - yield* add(SkillTool.Plugin) - yield* add(SubagentTool.Plugin) - yield* add(TodoWriteTool.Plugin) - yield* add(WebFetchTool.Plugin) - yield* add(WebSearchTool.Plugin) - yield* add(WriteTool.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - for (const item of ProviderPlugins) yield* add(item) - 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 })) - }), -) - -export const node = makeLocationNode({ - name: "plugin-internal", - layer, - deps: [ - Catalog.node, - CommandV2.node, - PluginV2.node, - Integration.node, - AgentV2.node, - Config.node, - Location.node, - LocationMutation.node, - FileMutation.node, - Image.node, - ModelsDev.node, - Npm.node, - EventV2.node, - FSUtil.node, - FileSystem.node, - Global.node, - httpClient, - PermissionV2.node, - QuestionV2.node, - ReadToolFileSystem.node, - SessionInstructions.node, - SessionTodo.node, - SkillV2.node, - Reference.node, - Ripgrep.node, - Shell.node, - ToolRegistry.toolsNode, - PluginRuntime.node, - SdkPlugins.node, - WebSearchTool.configNode, - ], +const services = Effect.fn("PluginInternal.services")(function* () { + const agent = yield* AgentV2.Service + const catalog = yield* Catalog.Service + const command = yield* CommandV2.Service + const config = yield* Config.Service + const events = yield* EventV2.Service + const mutation = yield* FileMutation.Service + const filesystem = yield* FileSystem.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const http = yield* HttpClient.HttpClient + const image = yield* Image.Service + const integration = yield* Integration.Service + const location = yield* Location.Service + const locationMutation = yield* LocationMutation.Service + const models = yield* ModelsDev.Service + const npm = yield* Npm.Service + const permission = yield* PermissionV2.Service + const runtime = yield* PluginRuntime.Service + const question = yield* QuestionV2.Service + const read = yield* ReadToolFileSystem.Service + const reference = yield* Reference.Service + const ripgrep = yield* Ripgrep.Service + const instructions = yield* SessionInstructions.Service + const todo = yield* SessionTodo.Service + const shell = yield* Shell.Service + const skill = yield* SkillV2.Service + const tools = yield* Tools.Service + const websearch = yield* WebSearchTool.ConfigService + return Context.mergeAll( + Context.make(AgentV2.Service, agent), + Context.make(Catalog.Service, catalog), + Context.make(CommandV2.Service, command), + Context.make(Config.Service, config), + Context.make(EventV2.Service, events), + Context.make(FileMutation.Service, mutation), + Context.make(FileSystem.Service, filesystem), + Context.make(FSUtil.Service, fs), + Context.make(Global.Service, global), + Context.make(HttpClient.HttpClient, http), + Context.make(Image.Service, image), + Context.make(Integration.Service, integration), + Context.make(Location.Service, location), + Context.make(LocationMutation.Service, locationMutation), + Context.make(ModelsDev.Service, models), + Context.make(Npm.Service, npm), + Context.make(PermissionV2.Service, permission), + Context.make(PluginRuntime.Service, runtime), + Context.make(QuestionV2.Service, question), + Context.make(ReadToolFileSystem.Service, read), + Context.make(Reference.Service, reference), + Context.make(Ripgrep.Service, ripgrep), + Context.make(SessionInstructions.Service, instructions), + Context.make(SessionTodo.Service, todo), + Context.make(Shell.Service, shell), + Context.make(SkillV2.Service, skill), + Context.make(Tools.Service, tools), + Context.make(WebSearchTool.ConfigService, websearch), + ) +}) + +type ContextServices = A extends Context.Context ? R : never + +export type Requirements = ContextServices>> + +export type InternalPlugin = Plugin + +const pre = [ + AgentPlugin.Plugin, + CommandPlugin.Plugin, + SkillPlugin.Plugin, + ModelsDevPlugin, + ...ProviderPlugins, + ApplyPatchTool.Plugin, + EditTool.Plugin, + GlobTool.Plugin, + GrepTool.Plugin, + QuestionTool.Plugin, + ReadTool.Plugin, + ShellTool.Plugin, + SkillTool.Plugin, + SubagentTool.Plugin, + TodoWriteTool.Plugin, + WebFetchTool.Plugin, + WebSearchTool.Plugin, + WriteTool.Plugin, +] as const satisfies readonly InternalPlugin[] + +const post = [ + ConfigReferencePlugin.Plugin, + ConfigAgentPlugin.Plugin, + ConfigCommandPlugin.Plugin, + ConfigSkillPlugin.Plugin, + ConfigProviderPlugin.Plugin, + VariantPlugin.Plugin, +] as const satisfies readonly InternalPlugin[] + +export const list = Effect.fn("PluginInternal.list")(function* () { + const context = yield* services() + const resolve = (plugins: readonly InternalPlugin[]) => + plugins.map( + (plugin): Plugin => ({ + id: plugin.id, + effect: (host) => plugin.effect(host).pipe(Effect.provide(context)), + }), + ) + return { + pre: resolve(pre), + post: resolve(post), + } }) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 27c176ed67..23a166e267 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,4 +1,4 @@ -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" @@ -197,7 +197,7 @@ function applyModel( } export const ModelsDevPlugin = define({ - id: "models-dev", + id: "opencode.models-dev", effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index b315d3214a..bcd3db1323 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -9,7 +9,7 @@ type Registration = { readonly dispose: () => Promise } /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only - * loader (`PluginV2` / `PluginInternal`) can run it unchanged. + * loader (`PluginV2` / `PluginSupervisor`) can run it unchanged. * * Hook registrations created during the async `setup` attach to the plugin's * scope, so unloading the plugin disposes them. The captured fiber context @@ -93,11 +93,6 @@ export function fromPromise(plugin: Plugin) { }, plugin: { list: (input) => run(host.plugin.list(input)), - add: (input) => { - const child = fromPromise(input) - return run(host.plugin.add(child)) - }, - remove: (id) => run(host.plugin.remove(id)), }, reference: { list: (input) => run(host.reference.list(input)), diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 1749b474ed..7c17bcc4c7 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -31,9 +31,8 @@ import { VenicePlugin } from "./provider/venice" import { XAIPlugin } from "./provider/xai" import { ZenmuxPlugin } from "./provider/zenmux" import type { PluginInternal } from "./internal" -import type { Scope } from "effect" -export const ProviderPlugins: PluginInternal.Plugin[] = [ +export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index c5c4be0d0b..607e565d4a 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const AlibabaPlugin = define({ - id: "alibaba", + id: "opencode.provider.alibaba", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index cbf9defc09..0bc3ced462 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) { } export const AmazonBedrockPlugin = define({ - id: "amazon-bedrock", + id: "opencode.provider.amazon-bedrock", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index efbd50b818..3a8a7c8659 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const AnthropicPlugin = define({ - id: "anthropic", + id: "opencode.provider.anthropic", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 2e73e620bf..3269c7d2d9 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -11,7 +11,7 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) { } export const AzurePlugin = define({ - id: "azure", + id: "opencode.provider.azure", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { @@ -54,7 +54,7 @@ export const AzurePlugin = define({ }) export const AzureCognitiveServicesPlugin = define({ - id: "azure-cognitive-services", + id: "opencode.provider.azure-cognitive-services", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 57029cfba7..20e724d707 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CerebrasPlugin = define({ - id: "cerebras", + id: "opencode.provider.cerebras", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index d416f6f19d..afab0c36df 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,10 +1,10 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CloudflareAIGatewayPlugin = define({ - id: "cloudflare-ai-gateway", + id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 762ceb4d96..b2db49e4db 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,13 +1,13 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") export const CloudflareWorkersAIPlugin = define({ - id: "cloudflare-workers-ai", + id: "opencode.provider.cloudflare-workers-ai", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(providerID) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 0ca0708577..8b0831604a 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CoherePlugin = define({ - id: "cohere", + id: "opencode.provider.cohere", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index 1b23e08ba4..e8316012ad 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const DeepInfraPlugin = define({ - id: "deepinfra", + id: "opencode.provider.deepinfra", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index c84a6ed51f..2e51674ba0 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,10 +1,10 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Npm } from "../../npm" export const DynamicProviderPlugin = define({ - id: "dynamic-provider", + id: "opencode.provider.dynamic", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service yield* ctx.aisdk.sdk( diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index f097dcaca3..07249391a0 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GatewayPlugin = define({ - id: "gateway", + id: "opencode.provider.gateway", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index d116058493..2a4881edc0 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -12,7 +12,7 @@ function shouldUseResponses(modelID: string) { } export const GithubCopilotPlugin = define({ - id: "github-copilot", + id: "opencode.provider.github-copilot", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(ProviderV2.ID.githubCopilot) diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 8723cdaac2..5cd4b94858 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,11 +1,11 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" export const GitLabPlugin = define({ - id: "gitlab", + id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index b87fe61a32..630e353ef0 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) { } export const GoogleVertexPlugin = define({ - id: "google-vertex", + id: "opencode.provider.google-vertex", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { @@ -111,7 +111,7 @@ export const GoogleVertexPlugin = define({ }) export const GoogleVertexAnthropicPlugin = define({ - id: "google-vertex-anthropic", + id: "opencode.provider.google-vertex-anthropic", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 476af5b912..3d2013a523 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GooglePlugin = define({ - id: "google", + id: "opencode.provider.google", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index 0bddb44309..d84ece2151 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GroqPlugin = define({ - id: "groq", + id: "opencode.provider.groq", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 5d4c0d65d8..d901474ef2 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const KiloPlugin = define({ - id: "kilo", + id: "opencode.provider.kilo", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 005880c6f3..68c319b673 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Integration } from "../../integration" export const LLMGatewayPlugin = define({ - id: "llmgateway", + id: "opencode.provider.llmgateway", effect: Effect.fn(function* (ctx) { const integrations = yield* Integration.Service const configured = new Set((yield* integrations.list()).map((integration) => integration.id)) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index a731975659..92bc09abec 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const MistralPlugin = define({ - id: "mistral", + id: "opencode.provider.mistral", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index b1b9c9b117..4597f3070a 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const NvidiaPlugin = define({ - id: "nvidia", + id: "opencode.provider.nvidia", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index d602ed0ff9..3854bdcde2 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenAICompatiblePlugin = define({ - id: "openai-compatible", + id: "opencode.provider.openai-compatible", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 990a82554e..1b27511e49 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -2,7 +2,6 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" -import type { Scope } from "effect" import { Credential } from "../../credential" import { EventV2 } from "../../event" import { InstallationVersion } from "../../installation/version" @@ -159,7 +158,7 @@ const headless = { } satisfies IntegrationOAuthMethodRegistration export const OpenAIPlugin = define({ - id: "openai", + id: "opencode.provider.openai", effect: Effect.fn(function* (ctx) { const events = yield* EventV2.Service const loading = Semaphore.makeUnsafe(1) @@ -225,7 +224,7 @@ export const OpenAIPlugin = define({ }), ) }), -} satisfies PluginInternal.Plugin) +} satisfies PluginInternal.InternalPlugin) function headers(contentType: string) { return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index bfa84ecd78..1316c3e4d0 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -75,7 +75,7 @@ function oauth(http: HttpClient.HttpClient) { } export const OpencodePlugin = define({ - id: "opencode", + id: "opencode.provider.opencode", effect: Effect.fn(function* (ctx) { const events = yield* EventV2.Service const http = yield* HttpClient.HttpClient diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 458ea1cd9b..a255844fdb 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenRouterPlugin = define({ - id: "openrouter", + id: "opencode.provider.openrouter", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 44c1ef2fc0..9eb5b1e246 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const PerplexityPlugin = define({ - id: "perplexity", + id: "opencode.provider.perplexity", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index b6c3a540e6..ede4e31d40 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,11 +1,11 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" export const SapAICorePlugin = define({ - id: "sap-ai-core", + id: "opencode.provider.sap-ai-core", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service yield* ctx.aisdk.sdk( diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 788ac63eb0..2e6cc9f9b4 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) { } export const SnowflakeCortexPlugin = define({ - id: "snowflake-cortex", + id: "opencode.provider.snowflake-cortex", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index 8022e0de66..d9454cfd24 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const TogetherAIPlugin = define({ - id: "togetherai", + id: "opencode.provider.togetherai", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 1a602ffd50..c9d5b163ae 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const VenicePlugin = define({ - id: "venice", + id: "opencode.provider.venice", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 8ff4d14098..0df949b1be 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const VercelPlugin = define({ - id: "vercel", + id: "opencode.provider.vercel", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 8145a3480a..76ec1df1a7 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" export const XAIPlugin = define({ - id: "xai", + id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 4562d6bd2e..099a3affca 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" -import { define } from "../internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const ZenmuxPlugin = define({ - id: "zenmux", + id: "opencode.provider.zenmux", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((evt) => { for (const item of evt.provider.list()) { diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index 78173a50a9..221fb5fae9 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -14,9 +14,9 @@ const defaultStore = makeStore() /** * 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 + * so `PluginSupervisor` can add them on every Location boot through the ordinary + * generation path that `PluginSupervisor` 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. * diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index c9eca99118..f6ca2ef4af 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,7 +2,7 @@ export * as SkillPlugin from "./skill" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" @@ -25,7 +25,7 @@ const REPORT_DESCRIPTION = "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI." export const Plugin = define({ - id: "skill", + id: "opencode.skill", effect: Effect.fn(function* (ctx) { const reportContent = yield* reportContentWithDiagnostics() yield* ctx.skill.transform((draft) => { diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts new file mode 100644 index 0000000000..19410e5001 --- /dev/null +++ b/packages/core/src/plugin/supervisor.ts @@ -0,0 +1,270 @@ +export * as PluginSupervisor from "./supervisor" + +import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import { Event } from "@opencode-ai/schema/config" +import { Context, Effect, Fiber, Layer, Schema, Semaphore, Stream } from "effect" +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import { Config } from "../config" +import { ConfigPlugin } from "../config/plugin" +import { EventV2 } from "../event" +import { FSUtil } from "../fs-util" +import { Location } from "../location" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { PluginPromise } from "../plugin/promise" +import { PluginInternal } from "./internal" +import { SdkPlugins } from "./sdk" + +const PluginModule = Schema.Struct({ + default: Schema.Union([ + Schema.Struct({ + id: Schema.String, + effect: Schema.declare((input): input is Plugin["effect"] => typeof input === "function"), + }), + Schema.Struct({ + id: Schema.String, + setup: Schema.declare[0]["setup"]>( + (input): input is Parameters[0]["setup"] => typeof input === "function", + ), + }), + ]), +}) + +const PluginPackage = Schema.Struct({ + exports: Schema.optional(Schema.Unknown), + main: Schema.optional(Schema.String), + module: Schema.optional(Schema.String), +}) + +type Operation = + | { + readonly type: "add" + readonly target: string + readonly options: Record + } + | { + readonly type: "remove" + readonly target: string + } + +type Candidate = + | { + readonly type: "definition" + readonly definition: Plugin + } + | { + readonly type: "package" + readonly specifier: string + readonly options: Record + } + +type ConfiguredPackage = { + readonly operation: Extract + enabled: boolean +} + +function parse(input: ConfigPlugin.Plugin): Operation { + if (typeof input !== "string") { + return { type: "add", target: input.package, options: input.options ?? {} } + } + if (!input.startsWith("-")) return { type: "add", target: input, options: {} } + if (input.length === 1) throw new Error("Plugin remove operation requires a target") + return { type: "remove", target: input.slice(1) } +} + +const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) { + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const discovered = yield* Effect.forEach( + entries.filter((entry): entry is Config.Directory => entry.type === "directory"), + (entry) => discoverDirectory(fs, entry.path), + ).pipe(Effect.map((items) => items.flat())) + const configured = entries + .filter((entry): entry is Config.Document => entry.type === "document") + .flatMap((entry) => + (entry.info.plugins ?? []).map(parse).map((operation) => { + const directory = entry.path ? path.dirname(entry.path) : location.directory + const target = operation.target.startsWith("file://") + ? fileURLToPath(operation.target) + : operation.target.startsWith("./") || operation.target.startsWith("../") + ? path.resolve(directory, operation.target) + : operation.target + return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target } + }), + ) + // Explicit config is applied last so it can remove auto-discovered packages. + return [...discovered, ...configured] +}) + +const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( + pre: readonly Plugin[], + post: readonly Plugin[], + operations: readonly Operation[], +) { + const plan = apply(pre, post, operations) + return yield* load(plan) +}) + +function apply(pre: readonly Plugin[], post: readonly Plugin[], operations: readonly Operation[]) { + const matches = (selector: string, target: string) => + selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target) + const plugins = [...pre, ...post] + const enabled = new Set(plugins.map((plugin) => plugin.id)) + const packages = new Map() + + for (const operation of operations) { + if (operation.type === "remove") { + plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id)) + packages.forEach((item, target) => { + if (matches(operation.target, target)) item.enabled = false + }) + continue + } + + const matched = plugins.filter((plugin) => matches(operation.target, plugin.id)) + const selectsDefinitions = + matched.length > 0 || + operation.target === "*" || + operation.target.endsWith(".*") || + operation.target.startsWith("opencode.") + if (selectsDefinitions) { + matched.forEach((plugin) => enabled.add(plugin.id)) + packages.forEach((item, target) => { + if (matches(operation.target, target)) item.enabled = true + }) + continue + } + + packages.set(operation.target, { operation, enabled: true }) + } + + const definitions: Candidate[] = pre.flatMap((definition) => + enabled.has(definition.id) ? [{ type: "definition", definition }] : [], + ) + const configured: Candidate[] = Array.from(packages.values()).flatMap((item) => + item.enabled ? [{ type: "package", specifier: item.operation.target, options: item.operation.options }] : [], + ) + const posts: Candidate[] = post.flatMap((definition) => + enabled.has(definition.id) ? [{ type: "definition", definition }] : [], + ) + return [...definitions, ...configured, ...posts] +} + +const load = Effect.fn("PluginSupervisor.load")(function* (plan: readonly Candidate[]) { + return yield* Effect.forEach(plan, (candidate) => { + if (candidate.type === "definition") return Effect.succeed(candidate.definition) + return Effect.gen(function* () { + const npm = yield* Npm.Service + const entrypoint = path.isAbsolute(candidate.specifier) + ? pathToFileURL(candidate.specifier).href + : (yield* npm.add(candidate.specifier)).entrypoint + if (!entrypoint) return + yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint }) + const mod = yield* Effect.promise(() => import(entrypoint)) + const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default + const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) + return { + id: plugin.id, + effect: (host) => plugin.effect({ ...host, options: candidate.options }), + } satisfies Plugin + }).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + }).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined))) +}) + +function discoverDirectory(fs: FSUtil.Interface, directory: string) { + return Effect.gen(function* () { + const files = yield* fs + .glob("{plugin,plugins}/*.{ts,js}", { + cwd: directory, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + const directories = yield* fs + .glob("{plugin,plugins}/*", { + cwd: directory, + absolute: true, + include: "all", + dot: true, + symlink: true, + }) + .pipe( + Effect.flatMap((items) => Effect.filter(items, (item) => fs.isDir(item), { concurrency: "unbounded" })), + Effect.orElseSucceed(() => []), + ) + const packages = yield* Effect.forEach(directories.sort(), (directory) => resolvePackageEntrypoint(fs, directory), { + concurrency: "unbounded", + }).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined))) + return [...files.sort(), ...packages].map((target): Operation => ({ type: "add", target, options: {} })) + }) +} + +const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) { + const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)), + Effect.catch(() => Effect.succeed(undefined)), + ) + const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined + const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"] + + return yield* Effect.forEach(entries, (entry) => { + if (!entry) return Effect.succeed(undefined) + const file = path.resolve(directory, entry) + return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined))) + }).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined))) +}) + +export interface Interface { + readonly ready: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PluginSupervisor") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const registry = yield* PluginV2.Service + const sdk = yield* SdkPlugins.Service + const config = yield* Config.Service + const events = yield* EventV2.Service + const lock = Semaphore.makeUnsafe(1) + let applied: string | undefined + const reload = Effect.fn("PluginSupervisor.reload")(() => + lock.withPermit( + Effect.gen(function* () { + // Resolve OpenCode's internal plugins with their privileged Location services. + const internal = yield* PluginInternal.list() + // Combine internal plugins with host-contributed SDK plugins in boot order. + const pre = [...internal.pre, ...sdk.all()] + // Read the current layered config before resolving plugin directives and packages. + const entries = yield* config.entries() + // Skip duplicate watcher notifications and config edits unrelated to plugins. + const operations = yield* scan(entries) + const version = JSON.stringify(operations) + if (version === applied) return + // Apply config operations and load enabled package plugins into one ordered generation. + const plugins = yield* resolve(pre, internal.post, operations) + // Replace the active generation in one scoped, batched activation. + yield* registry.activate(plugins) + applied = version + }), + ), + ) + yield* events.subscribe(Event.Updated).pipe( + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), + ), + Effect.forkScoped({ startImmediately: true }), + ) + const fiber = yield* reload().pipe( + Effect.withSpan("PluginSupervisor.boot"), + Effect.forkScoped({ startImmediately: true }), + ) + return Service.of({ ready: Fiber.join(fiber) }) + }), +) + +export { layer } diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 7c6e688399..499a2a32bf 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -2,10 +2,10 @@ export * as VariantPlugin from "./variant" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect } from "effect" -import { define } from "./internal" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const Plugin = define({ - id: "variant", + id: "opencode.variant", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((catalog) => { for (const record of catalog.provider.list()) { diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index ab3457fc18..93dcdb4e5d 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -26,21 +26,32 @@ export interface Transformable { readonly reload: Reload } -const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { +type Batch = { + active: boolean + readonly reloads: Set +} + +const CurrentBatch = Context.Reference("@opencode/State/CurrentBatch", { defaultValue: () => undefined, }) export function batch(effect: Effect.Effect) { return Effect.gen(function* () { const current = yield* CurrentBatch - if (current) return yield* effect - const reloads = new Set() - const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads)) - yield* Effect.forEach(reloads, (reload) => reload(), { discard: true }) - return result + if (current?.active) return yield* effect + const batch: Batch = { active: true, reloads: new Set() } + const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit) + batch.active = false + yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true }) + return yield* exit }) } +export const inherit = Effect.fnUntraced(function* () { + const batch = yield* CurrentBatch + return (effect: Effect.Effect) => Effect.provideService(effect, CurrentBatch, batch) +}) + export interface Options { /** Creates the base value for initial state and every scoped-transform reload. */ readonly initial: () => State @@ -100,8 +111,8 @@ export function create(options: Options): Inte transforms = transforms.filter((item) => item !== transform) return Effect.gen(function* () { const batch = yield* CurrentBatch - if (batch) { - batch.add(reload) + if (batch?.active) { + batch.reloads.add(reload) return } yield* materialize() @@ -116,7 +127,7 @@ export function create(options: Options): Inte ) yield* Scope.addFinalizer(scope, dispose) const batch = yield* CurrentBatch - if (batch) batch.add(reload) + if (batch?.active) batch.reloads.add(reload) else yield* reload() return { dispose } }), diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 2f481d8a0d..6700e6e1ba 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -55,7 +55,7 @@ type Prepared = }) export const Plugin = { - id: "core-apply-patch-tool", + id: "opencode.tool.apply-patch", effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 17ff28cfc1..bb9bb4dee9 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -86,7 +86,7 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. export const Plugin = { - id: "core-edit-tool", + id: "opencode.tool.edit", effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index dcea52120f..753e36cab5 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -35,7 +35,7 @@ export const toModelOutput = (output: ModelOutput) => { /** Glob leaf that defaults its filesystem root to the active Location. */ export const Plugin = { - id: "core-glob-tool", + id: "opencode.tool.glob", effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 35ac537c91..685925aa04 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -49,7 +49,7 @@ export const toModelOutput = (output: ModelOutput) => { /** Grep leaf that defaults its filesystem root to the active Location. */ export const Plugin = { - id: "core-grep-tool", + id: "opencode.tool.grep", effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 218edb57e9..9bf4249add 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -43,7 +43,7 @@ export const toModelOutput = ( } export const Plugin = { - id: "core-question-tool", + id: "opencode.tool.question", effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) { const question = yield* QuestionV2.Service const permission = yield* PermissionV2.Service diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 16e229d97d..de03a97974 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -31,7 +31,7 @@ const Input = LocationInput const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) export const Plugin = { - id: "core-read-tool", + id: "opencode.tool.read", effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) { const reader = yield* ReadToolFileSystem.Service const mutation = yield* LocationMutation.Service diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index c3644e4810..cd0c428a61 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -99,7 +99,7 @@ const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectori }) export const Plugin = { - id: "core-shell-tool", + id: "opencode.tool.shell", effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const scope = yield* Scope.Scope diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 1c5d23a047..669e36c4c8 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -53,7 +53,7 @@ const unableToLoad = (name: string, error?: unknown) => new ToolFailure({ message: `Unable to load skill ${name}`, error }) export const Plugin = { - id: "core-skill-tool", + id: "opencode.tool.skill", effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) { const fs = yield* FSUtil.Service const skills = yield* SkillV2.Service diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65f..50db674ef2 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -38,7 +38,7 @@ export const description = [ ].join("\n") export const Plugin = { - id: "core-subagent-tool", + id: "opencode.tool.subagent", effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 5f34ebd5aa..279792e38a 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -21,7 +21,7 @@ export type Output = typeof Output.Type export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2) export const Plugin = { - id: "core-todowrite-tool", + id: "opencode.tool.todowrite", effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) { const todos = yield* SessionTodo.Service const permission = yield* PermissionV2.Service diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index efac4a2a75..b729fb43f7 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -113,7 +113,7 @@ const convert = (content: string, contentType: string, format: Format) => { } export const Plugin = { - id: "core-webfetch-tool", + id: "opencode.tool.webfetch", effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) { const http = yield* HttpClient.HttpClient const permission = yield* PermissionV2.Service diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 80a2422cee..d0c984b89a 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -188,7 +188,7 @@ const Output = Schema.Struct({ }) export const Plugin = { - id: "core-websearch-tool", + id: "opencode.tool.websearch", effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { const http = yield* HttpClient.HttpClient const config = yield* ConfigService diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 73885b2187..ca89665be4 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -43,7 +43,7 @@ export const toModelOutput = (output: Output) => // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. export const Plugin = { - id: "core-write-tool", + id: "opencode.tool.write", effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index b72a62df8a..c3c84d8101 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -1,252 +1,225 @@ +import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" -import { Config } from "@opencode-ai/core/config" -import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Catalog } from "@opencode-ai/core/catalog" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" -import { Npm } from "@opencode-ai/core/npm" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" +import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Effect } from "effect" +import { Database } from "../../src/database/database" +import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "../plugin/fixture" -const it = testEffect(PluginTestLayer) -const decode = Schema.decodeUnknownSync(Config.Info) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])), +) -describe("ConfigExternalPlugin", () => { - it.live("resolves and loads a configured Promise plugin with options", () => +describe("PluginSupervisor config", () => { + it.live("applies selectors in order", () => + withLocation( + { plugins: ["-opencode.provider.*", "opencode.provider.openai"] }, + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + yield* ready() + expect( + (yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")), + ).toEqual([PluginV2.ID.make("opencode.provider.openai")]) + }), + ), + ) + + it.live("loads configured Promise plugins with options", () => + withLocation( + { + plugins: [ + "-*", + { + package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + options: { description: "Loaded from config" }, + }, + ], + }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({ + description: "Loaded from config", + mode: "subagent", + }) + }), + ), + ) + + it.live("loads configured Effect plugins with options", () => + withLocation( + { + plugins: [ + "-*", + { + package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"), + options: { description: "Effect plugin from config" }, + }, + ], + }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("effect-configured"))).toMatchObject({ + description: "Effect plugin from config", + mode: "subagent", + }) + }), + ), + ) + + it.live("ignores invalid packages and continues loading", () => + withLocation( + { + plugins: [ + "-*", + path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"), + path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"), + { + package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + options: { description: "Loaded after invalid plugins" }, + }, + ], + }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({ + description: "Loaded after invalid plugins", + }) + }), + ), + ) + + it.live("loads auto-discovered plugin files and packages", () => + withLocation( + undefined, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({ + description: "Loaded from plugin directory", + }) + expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({ + description: "Loaded from plugin folder", + }) + }), + true, + ), + ) + + it.live("applies explicit removals after auto-discovery", () => + withLocation( + { plugins: ["-*"] }, + Effect.gen(function* () { + yield* ready() + const agents = yield* AgentV2.Service + expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined() + expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined() + }), + true, + ), + ) + + it.live("loads user plugins before internal post plugins", () => Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) - const document = path.join(import.meta.dir, "opencode.json") + const sdk = yield* SdkPlugins.Service + yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void })) + yield* withLocation( + { + plugins: [ + path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), + ], + }, + Effect.gen(function* () { + yield* ready() + const registry = yield* PluginV2.Service + const ids = (yield* registry.list()).map((plugin) => String(plugin.id)) + expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order")) + expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin")) + expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source")) + expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider")) + expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant")) - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - path: document, - info: decode({ - plugins: [ - { - package: "../plugin/fixtures/config-promise-plugin.ts", - options: { description: "Loaded from config" }, - }, - ], - }), - }), - ]), - }), - ), + const catalog = yield* Catalog.Service + expect( + (yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants, + ).toEqual([ + expect.objectContaining({ id: "high", headers: { custom: "true" } }), + expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), + ]) + }), ) - - expect(yield* waitForAgent(agents, "configured")).toMatchObject({ - description: "Loaded from config", - mode: "subagent", - }) }), ) - it.live("loads a configured Effect plugin with options", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) + it.live("allows variant generation to be disabled", () => + withLocation( + { + plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"], + }, + Effect.gen(function* () { + yield* ready() + const registry = yield* PluginV2.Service + expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant") - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - path: path.join(import.meta.dir, "opencode.json"), - info: decode({ - plugins: [ - { - package: "../plugin/fixtures/config-effect-plugin.ts", - options: { description: "Effect plugin from config" }, - }, - ], - }), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({ - description: "Effect plugin from config", - mode: "subagent", - }) - }), - ) - - it.live("ignores invalid plugins and continues loading", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) - - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - path: path.join(import.meta.dir, "opencode.json"), - info: decode({ - plugins: [ - "../plugin/fixtures/missing-plugin.ts", - "../plugin/fixtures/invalid-plugin.ts", - { - package: "../plugin/fixtures/config-promise-plugin.ts", - options: { description: "Loaded after invalid plugins" }, - }, - ], - }), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "configured")).toMatchObject({ - description: "Loaded after invalid plugins", - }) - }), - ) - - it.live("installs and resolves npm plugin packages", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const host = yield* PluginHost.make(plugins) - let installed: string | undefined - const npm = Npm.Service.of({ - add: (spec) => - Effect.sync(() => { - installed = spec - return { - directory: import.meta.dir, - entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), - } - }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }) - - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - info: decode({ - plugins: [ - { - package: "example-plugin@1.0.0", - options: { description: "Installed from npm" }, - }, - ], - }), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "configured")).toMatchObject({ - description: "Installed from npm", - }) - expect(installed).toBe("example-plugin@1.0.0") - }), - ) - - it.live("loads plugin files from config directories", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const agents = yield* AgentV2.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const host = yield* PluginHost.make(plugins) - - yield* ConfigExternalPlugin.Plugin.effect(host).pipe( - Effect.provideService(PluginV2.Service, plugins), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(Location.Service, location), - Effect.provideService(Npm.Service, npm), - Effect.provideService( - Config.Service, - Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Directory({ - type: "directory", - path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")), - }), - ]), - }), - ), - ) - - expect(yield* waitForAgent(agents, "directory")).toMatchObject({ - description: "Loaded from plugin directory", - mode: "subagent", - }) - expect(yield* waitForAgent(agents, "folder")).toMatchObject({ - description: "Loaded from plugin folder", - mode: "subagent", - }) - }), + const catalog = yield* Catalog.Service + expect( + (yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants, + ).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })]) + }), + ), ) }) -const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { - for (let attempt = 0; attempt < 100; attempt++) { - const agent = yield* agents.get(AgentV2.ID.make(id)) - if (agent) return agent - yield* Effect.sleep("10 millis") - } - return yield* Effect.die(`Timed out waiting for agent ${id}`) +const ready = Effect.fnUntraced(function* () { + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.ready }) + +function withLocation(config: unknown, effect: Effect.Effect, fixtures = false) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.tap((tmp) => + Effect.promise(async () => { + if (fixtures) { + const directory = path.join(tmp.path, ".opencode") + await fs.mkdir(directory, { recursive: true }) + await Promise.all( + ["plugin", "plugins"].map((name) => + fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"), + ), + ) + } + if (config !== undefined) { + const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config)) + } + }), + ), + Effect.flatMap((tmp) => + effect.pipe( + Effect.scoped, + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))), + ), + ), + ) +} diff --git a/packages/core/test/config/reload.test.ts b/packages/core/test/config/reload.test.ts index 3550bddb95..e5f8bd10fc 100644 --- a/packages/core/test/config/reload.test.ts +++ b/packages/core/test/config/reload.test.ts @@ -7,7 +7,6 @@ import { CommandV2 } from "@opencode-ai/core/command" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" -import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference" import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" @@ -37,7 +36,7 @@ describe("config plugin reloads", () => { const references = yield* Reference.Service const skills = yield* SkillV2.Service const host = yield* PluginHost.make(plugins) - let entries: Config.Entry[] = [config("first", "First plugin")] + let entries: Config.Entry[] = [config("first")] const service = Config.Service.of({ entries: () => Effect.sync(() => entries) }) const setup = (effect: Effect.Effect) => effect.pipe(Effect.provideService(Config.Service, service)) @@ -47,7 +46,6 @@ describe("config plugin reloads", () => { yield* setup(ConfigSkillPlugin.Plugin.effect(host)) yield* setup(ConfigReferencePlugin.Plugin.effect(host)) yield* setup(ConfigProviderPlugin.Plugin.effect(host)) - yield* setup(ConfigExternalPlugin.Plugin.effect(host)) expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent") expect((yield* commands.get("first"))?.description).toBe("First command") @@ -56,9 +54,8 @@ describe("config plugin reloads", () => { ).toBe(true) expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"]) expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined() - expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin") - entries = [config("second", "Second plugin")] + entries = [config("second")] yield* events.publish(ConfigSchema.Event.Updated, {}) yield* waitUntil( Effect.gen(function* () { @@ -80,12 +77,11 @@ describe("config plugin reloads", () => { expect( (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"), ).toBe(true) - expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin") }).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))), ) }) -function config(name: string, pluginDescription?: string) { +function config(name: string) { return new Config.Document({ type: "document", path: document, @@ -95,15 +91,6 @@ function config(name: string, pluginDescription?: string) { skills: [`/skills/${name}`], references: { [name]: `/references/${name}` }, providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } }, - plugins: - pluginDescription === undefined - ? [] - : [ - { - package: "../plugin/fixtures/config-promise-plugin.ts", - options: { description: pluginDescription }, - }, - ], }), }) } diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 0f17b0dd2b..be6e150680 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,7 +1,8 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Effect, Equal, Hash, Schema } from "effect" +import { Config } from "@opencode-ai/schema/config" +import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" @@ -10,6 +11,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -27,6 +29,124 @@ import { ToolRegistry } from "../src/tool/registry" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]))) describe("LocationServiceMap", () => { + it.live("applies ordered plugin config operations during boot", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })), + ) + const plugins = yield* Effect.gen(function* () { + const plugins = yield* PluginV2.Service + yield* (yield* PluginSupervisor.Service).ready + return yield* plugins.list() + }).pipe( + Effect.scoped, + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })), + ), + ) + + expect(plugins.map((plugin) => plugin.id)).toEqual([PluginV2.ID.make("opencode.agent")]) + }), + ), + ), + ) + + it.live("reloads the plugin generation after config updates", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const file = path.join(dir.path, "opencode.json") + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] }))) + yield* Effect.gen(function* () { + const registry = yield* PluginV2.Service + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.ready + expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"]) + + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] }))) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break + yield* Effect.sleep("20 millis") + } + + expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"]) + + yield* Effect.promise(() => + fs.writeFile( + file, + JSON.stringify({ + plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")], + }), + ), + ) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* registry.list()).length === 0) break + yield* Effect.sleep("20 millis") + } + expect(yield* registry.list()).toEqual([]) + + yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] }))) + for (let attempt = 0; attempt < 100; attempt++) { + if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break + yield* Effect.sleep("20 millis") + } + expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"]) + }).pipe( + Effect.scoped, + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })), + ), + ) + }), + ), + ), + ) + + it.live("routes located events only to their location", () => + Effect.acquireRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)), + ).pipe( + Effect.flatMap(([first, second]) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const events = yield* EventV2.Service + const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) }) + const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) }) + const firstContext = yield* locations.contextEffect(firstRef) + const secondContext = yield* locations.contextEffect(secondRef) + const received = { first: 0, second: 0 } + yield* events.subscribe(Config.Event.Updated).pipe( + Stream.runForEach(() => Effect.sync(() => received.first++)), + Effect.provideContext(firstContext), + Effect.forkScoped({ startImmediately: true }), + ) + yield* events.subscribe(Config.Event.Updated).pipe( + Stream.runForEach(() => Effect.sync(() => received.second++)), + Effect.provideContext(secondContext), + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.sleep("10 millis") + + yield* events.publish(Config.Event.Updated, {}, { location: firstRef }) + yield* Effect.sleep("10 millis") + + expect(received).toEqual({ first: 1, second: 0 }) + }), + ), + ), + ), + ) + it.live("reuses cached services for constructed and decoded location refs", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -64,7 +184,7 @@ describe("LocationServiceMap", () => { const catalog = yield* Catalog.Service yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) const registry = yield* ToolRegistry.Service - // Tool plugins register during the forked PluginInternal boot; wait for + // Tool plugins register during the forked PluginSupervisor boot; wait for // every expected tool rather than relying on batch ordering. yield* Effect.forEach( [ @@ -257,7 +377,7 @@ describe("LocationServiceMap", () => { }) .pipe(Effect.asVoid), }) - yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect) + yield* plugins.activate([{ id: PluginV2.ID.make(reviewer.id), effect: reviewer.effect }]) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 266d487862..8917171929 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Fiber, Schema, Stream } from "effect" +import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { AgentV2 } from "@opencode-ai/core/agent" @@ -16,6 +16,8 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) +class Secret extends Context.Service()("@opencode/test/PluginSecret") {} + describe("PluginV2", () => { it.live("exposes public events through the plugin context", () => Effect.gen(function* () { @@ -35,43 +37,15 @@ describe("PluginV2", () => { }), ) - it.effect("waits for a plugin and returns immediately once active", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const id = PluginV2.ID.make("waited") - const waiting = yield* plugins.wait(id).pipe(Effect.forkChild) - - yield* plugins.add(id, () => Effect.void) - yield* Fiber.join(waiting) - yield* plugins.wait(id) - }), - ) - - it.effect("propagates plugin activation defects to waiters", () => - Effect.gen(function* () { - const plugins = yield* PluginV2.Service - const id = PluginV2.ID.make("failed") - const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild) - - const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit) - const pending = yield* Fiber.join(waiting) - const later = yield* plugins.wait(id).pipe(Effect.exit) - - expect(Exit.isFailure(added)).toBe(true) - expect(Exit.isFailure(pending)).toBe(true) - expect(Exit.isFailure(later)).toBe(true) - }), - ) - - it.effect("adds, replaces, and removes plugins", () => + it.effect("skips identical generations and replaces changed plugin IDs", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const agents = yield* AgentV2.Service let description = "first" - const managed = () => + const managed = (id: string) => define({ - id: "managed", + id, effect: (ctx) => ctx.agent .transform((agents) => @@ -82,19 +56,101 @@ describe("PluginV2", () => { .pipe(Effect.asVoid), }) - yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) + yield* plugins.activate([managed("managed")]) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") description = "second" - yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) + yield* plugins.activate([managed("managed")]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") + + yield* plugins.activate([managed("managed-next")]) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") - yield* plugins.remove(PluginV2.ID.make("managed")) + yield* plugins.activate([]) expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) + it.effect("rejects duplicate IDs before replacing the active generation", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const active = PluginV2.ID.make("active") + const duplicate = PluginV2.ID.make("duplicate") + yield* plugins.activate([{ id: active, effect: () => Effect.void }]) + + const result = yield* plugins + .activate([ + { id: duplicate, effect: () => Effect.void }, + { id: duplicate, effect: () => Effect.void }, + ]) + .pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + expect(yield* plugins.list()).toEqual([{ id: active }]) + }), + ) + + it.effect("retries the same generation after materialization fails", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + let fail = true + const plugin = define({ + id: "retry", + effect: (ctx) => + ctx.agent + .transform(() => { + if (fail) throw new Error("materialization failed") + }) + .pipe(Effect.asVoid), + }) + + expect(Exit.isFailure(yield* plugins.activate([plugin]).pipe(Effect.exit))).toBe(true) + fail = false + yield* plugins.activate([plugin]) + + expect(yield* plugins.list()).toEqual([{ id: PluginV2.ID.make("retry") }]) + }), + ) + + it.effect("closes the previous generation in reverse order", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const closed: string[] = [] + yield* plugins.activate( + ["first", "second"].map((id) => ({ + id: PluginV2.ID.make(id), + effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))), + })), + ) + + yield* plugins.activate([]) + + expect(closed).toEqual(["second", "first"]) + }), + ) + + it.effect("isolates plugins from ambient services", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + let visible = true + const plugin = define({ + id: "isolated", + effect: () => + Effect.serviceOption(Secret).pipe( + Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))), + Effect.asVoid, + ), + }) + + yield* plugins + .activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }]) + .pipe(Effect.provideService(Secret, "secret")) + + expect(visible).toBe(false) + }), + ) + it.effect("registers location tools through the plugin context", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service @@ -114,12 +170,12 @@ describe("PluginV2", () => { .pipe(Effect.orDie), }) - yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect) + yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }]) expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain( "plugin_tool", ) - yield* plugins.remove(PluginV2.ID.make(plugin.id)) + yield* plugins.activate([]) expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain( "plugin_tool", ) @@ -149,7 +205,7 @@ describe("PluginV2", () => { }), }) - yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect) + yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }]) expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([ "plain", @@ -201,7 +257,7 @@ describe("PluginV2", () => { }), }) - yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect) + yield* plugins.activate([{ id: PluginV2.ID.make(plugin.id), effect: plugin.effect }]) const materialized = yield* registry.materialize({ model: testModel }) const settlement = yield* materialized.settle({ diff --git a/packages/core/test/plugin/fixtures/failing-plugin.ts b/packages/core/test/plugin/fixtures/failing-plugin.ts new file mode 100644 index 0000000000..4daac0acd0 --- /dev/null +++ b/packages/core/test/plugin/fixtures/failing-plugin.ts @@ -0,0 +1,7 @@ +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "failing-plugin", + effect: () => Effect.die("plugin failed"), +}) diff --git a/packages/core/test/plugin/fixtures/variant-source-plugin.ts b/packages/core/test/plugin/fixtures/variant-source-plugin.ts new file mode 100644 index 0000000000..087a714227 --- /dev/null +++ b/packages/core/test/plugin/fixtures/variant-source-plugin.ts @@ -0,0 +1,29 @@ +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "variant-source", + effect: (ctx) => + ctx.catalog + .transform((catalog) => { + catalog.provider.update("configured", (provider) => { + provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" } + }) + catalog.model.update("configured", "glm-5.2", (model) => { + model.api = { + id: "glm-5.2", + type: "aisdk", + package: "@ai-sdk/openai-compatible", + } + model.variants = [ + { + id: "high", + settings: {}, + headers: { custom: "true" }, + body: {}, + }, + ] + }) + }) + .pipe(Effect.asVoid), +}) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 63ac18e31e..8ef98c6530 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -59,8 +59,6 @@ export function host(overrides: Overrides = {}): PluginContext { }, plugin: overrides.plugin ?? { list: () => Effect.die("unused plugin.list"), - add: () => Effect.die("unused plugin.add"), - remove: () => Effect.die("unused plugin.remove"), }, reference: overrides.reference ?? { list: () => Effect.die("unused reference.list"), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index b34ceb2d5b..bec50272ef 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () { describe("KiloPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))), + Effect.sync(() => + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.kilo")), + ), ) it.effect("applies legacy referer headers only to kilo", () => diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 5dce7cdcf7..8cc45c6027 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -21,7 +21,9 @@ const addPlugin = Effect.fn(function* () { describe("LLMGatewayPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))), + Effect.sync(() => + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.llmgateway")), + ), ) it.effect("applies legacy referer headers only to enabled llmgateway", () => diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index 260ffff689..baf763c8d1 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () { describe("NvidiaPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))), + Effect.sync(() => + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.nvidia")), + ), ) it.effect("applies NVIDIA tracking headers only to nvidia", () => diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index d611f40363..e0be8b5fa7 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -22,7 +22,9 @@ const addPlugin = Effect.fn(function* () { describe("OpenRouterPlugin", () => { it.effect("is registered so legacy OpenRouter behavior can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))), + Effect.sync(() => + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.openrouter")), + ), ) it.effect("applies legacy referer headers only to openrouter", () => diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index cd67feb40e..a1265b6a8f 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -43,9 +43,11 @@ function withEnv(vars: Record, effect: () = describe("SnowflakeCortexPlugin", () => { it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => Effect.sync(() => { - expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex")) + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.snowflake-cortex")) const ids = ProviderPlugins.map((p) => p.id) - expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible")) + expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan( + ids.indexOf("opencode.provider.openai-compatible"), + ) }), ) diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index b9d34a1f5b..0ce286d612 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -24,7 +24,9 @@ function required(value: T | undefined): T { describe("ZenmuxPlugin", () => { it.effect("is registered so legacy referer headers can be applied", () => - Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))), + Effect.sync(() => + expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("opencode.provider.zenmux")), + ), ) it.effect("applies the exact legacy Zenmux headers", () => diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 536a642fe4..cd0eb32f7c 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -29,8 +29,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { Location } from "@opencode-ai/core/location" -import { PluginV2 } from "@opencode-ai/core/plugin" export const Info = Schema.Struct({ name: Schema.String, @@ -101,7 +101,7 @@ const layer = Layer.effect( const skillDirs = yield* skill.dirs() const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length ? yield* Effect.gen(function* () { - yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + yield* (yield* PluginSupervisor.Service).ready return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) : [] diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 77629bdfca..66f57caf60 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -11,7 +11,4 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain extends PluginApi { - readonly add: (plugin: Plugin) => Effect.Effect - readonly remove: (id: string) => Effect.Effect -} +export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index afb6c16f0e..eb91b9df53 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -10,7 +10,4 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginDomain extends PluginApi { - readonly add: (plugin: Plugin) => Promise - readonly remove: (id: string) => Promise -} +export interface PluginDomain extends PluginApi {}