diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 48c380aef39..4bd33ca2402 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 3aebf88fbe5..fa8b61ce182 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 43ba4cd3758..a7babbb5aa7 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 70271dec23f..00000000000 --- 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 d2d6a26029b..320895d1289 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 d33332f92d7..75e0cc10e41 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 ff83c53ff1b..84546b901cf 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 3e352c6e6b0..a5b052b42ff 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 2a4e0d6ef67..bbb60482c15 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 a6ff337c62b..8ffdf93ba10 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 a0e3556ce8d..7549089bf36 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 19896412380..7bb14dad319 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 4c7bb8538c7..2e58c6b1440 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 939bb4e731b..63637c96b08 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 27c176ed671..23a166e2679 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 b315d3214a1..bcd3db13235 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 1749b474ed3..7c17bcc4c74 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 c5c4be0d0be..607e565d4a4 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 cbf9defc09d..0bc3ced4628 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 efbd50b818a..3a8a7c86590 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 2e73e620bf3..3269c7d2d92 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 57029cfba7a..20e724d707c 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 d416f6f19d3..afab0c36dfb 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 762ceb4d963..b2db49e4dbe 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 0ca0708577a..8b0831604ae 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 1b23e08ba4a..e8316012ad4 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 c84a6ed51f8..2e51674ba07 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 f097dcaca3f..07249391a0f 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 d1160584938..2a4881edc0f 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 8723cdaac2e..5cd4b948580 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 b87fe61a325..630e353ef0f 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 476af5b9121..3d2013a5238 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 0bddb443095..d84ece21517 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 5d4c0d65d8a..d901474ef25 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 005880c6f31..68c319b6730 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 a7319756598..92bc09abecf 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 b1b9c9b117c..4597f3070ab 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 d602ed0ff95..3854bdcde2c 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 990a82554e5..1b27511e495 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 bfa84ecd783..1316c3e4d05 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 458ea1cd9b7..a255844fdb0 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 44c1ef2fc0a..9eb5b1e246f 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 b6c3a540e61..ede4e31d400 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 788ac63eb03..2e6cc9f9b44 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 8022e0de668..d9454cfd241 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 1a602ffd50b..c9d5b163aed 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 8ff4d140983..0df949b1bed 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 8145a3480a0..76ec1df1a7e 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 4562d6bd2e5..099a3affca8 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 78173a50a99..221fb5fae9a 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 c9eca991184..f6ca2ef4afc 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 00000000000..19410e5001f --- /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 7c6e6883998..499a2a32bfc 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 ab3457fc181..93dcdb4e5d6 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 2f481d8a0d5..6700e6e1ba3 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 17ff28cfc17..bb9bb4dee97 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 dcea52120f3..753e36cab50 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 35ac537c918..685925aa040 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 218edb57e92..9bf4249add6 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 16e229d97de..de03a97974f 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 c3644e48104..cd0c428a612 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 1c5d23a047f..669e36c4c88 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 517d1ce65fa..50db674ef26 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 5f34ebd5aaa..279792e38a1 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 efac4a2a75c..b729fb43f7b 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 80a2422cee9..d0c984b89a3 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 73885b21873..ca89665be42 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 b72a62df8ae..c3c84d81016 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 3550bddb95a..e5f8bd10fcd 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 0f17b0dd2bd..be6e150680b 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 266d4878623..8917171929f 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 00000000000..4daac0acd09 --- /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 00000000000..087a7142270 --- /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 63ac18e31ed..8ef98c6530f 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 b34ceb2d5b3..bec50272eff 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 5dce7cdcf70..8cc45c6027b 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 260ffff689e..baf763c8d14 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 d611f40363a..e0be8b5fa74 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 cd67feb40e8..a1265b6a8f3 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 b9d34a1f5bb..0ce286d6124 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 536a642fe49..cd0eb32f7c8 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 77629bdfcae..66f57caf607 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 afb6c16f0e8..eb91b9df537 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 {}