diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 48c380aef..4bd33ca24 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 3aebf88fb..fa8b61ce1 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 43ba4cd37..a7babbb5a 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 70271dec2..000000000 --- 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 d2d6a2602..320895d12 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 d33332f92..75e0cc10e 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 ff83c53ff..84546b901 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 3e352c6e6..a5b052b42 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 2a4e0d6ef..bbb60482c 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 a6ff337c6..8ffdf93ba 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 a0e3556ce..7549089bf 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 198964123..7bb14dad3 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 4c7bb8538..2e58c6b14 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 939bb4e73..63637c96b 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 27c176ed6..23a166e26 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 b315d3214..bcd3db132 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 1749b474e..7c17bcc4c 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 c5c4be0d0..607e565d4 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 cbf9defc0..0bc3ced46 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 efbd50b81..3a8a7c865 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 2e73e620b..3269c7d2d 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 57029cfba..20e724d70 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 d416f6f19..afab0c36d 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 762ceb4d9..b2db49e4d 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 0ca070857..8b0831604 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 1b23e08ba..e8316012a 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 c84a6ed51..2e51674ba 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 f097dcaca..07249391a 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 d11605849..2a4881edc 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 8723cdaac..5cd4b9485 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 b87fe61a3..630e353ef 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 476af5b91..3d2013a52 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 0bddb4430..d84ece215 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 5d4c0d65d..d901474ef 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 005880c6f..68c319b67 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 a73197565..92bc09abe 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 b1b9c9b11..4597f3070 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 d602ed0ff..3854bdcde 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 990a82554..1b27511e4 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 bfa84ecd7..1316c3e4d 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 458ea1cd9..a255844fd 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 44c1ef2fc..9eb5b1e24 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 b6c3a540e..ede4e31d4 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 788ac63eb..2e6cc9f9b 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 8022e0de6..d9454cfd2 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 1a602ffd5..c9d5b163a 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 8ff4d1409..0df949b1b 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 8145a3480..76ec1df1a 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 4562d6bd2..099a3affc 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 78173a50a..221fb5fae 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 c9eca9911..f6ca2ef4a 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 000000000..19410e500 --- /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 7c6e68839..499a2a32b 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 ab3457fc1..93dcdb4e5 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 2f481d8a0..6700e6e1b 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 17ff28cfc..bb9bb4dee 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 dcea52120..753e36cab 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 35ac537c9..685925aa0 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 218edb57e..9bf4249ad 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 16e229d97..de03a9797 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 c3644e481..cd0c428a6 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 1c5d23a04..669e36c4c 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 517d1ce65..50db674ef 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 5f34ebd5a..279792e38 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 efac4a2a7..b729fb43f 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 80a2422ce..d0c984b89 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 73885b218..ca89665be 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 b72a62df8..c3c84d810 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 3550bddb9..e5f8bd10f 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 0f17b0dd2..be6e15068 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 266d48786..891717192 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 000000000..4daac0acd --- /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 000000000..087a71422 --- /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 63ac18e31..8ef98c653 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 b34ceb2d5..bec50272e 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 5dce7cdcf..8cc45c602 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 260ffff68..baf763c8d 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 d611f4036..e0be8b5fa 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 cd67feb40..a1265b6a8 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 b9d34a1f5..0ce286d61 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 536a642fe..cd0eb32f7 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 77629bdfc..66f57caf6 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 afb6c16f0..eb91b9df5 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 {}