feat(core): manage configurable plugin generations

This commit is contained in:
Dax Raad 2026-07-05 15:51:15 -04:00
commit a9b7bd9e2f
83 changed files with 1116 additions and 819 deletions

View file

@ -102,7 +102,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
description: "Named local directories or Git repositories available as external context", description: "Named local directories or Git repositories available as external context",
}), }),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({ 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), providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {} }) {}

View file

@ -1,6 +1,6 @@
export * as ConfigAgentPlugin from "./agent" export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path" import path from "path"
import { Effect, Option, Schema, Stream } from "effect" import { Effect, Option, Schema, Stream } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
@ -34,7 +34,7 @@ const agentKeys = new Set([
]) ])
export const Plugin = define({ export const Plugin = define({
id: "config-agent", id: "opencode.config.agent",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service

View file

@ -1,6 +1,6 @@
export * as ConfigCommandPlugin from "./command" export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path" import path from "path"
import { Effect, Option, Schema, Stream } from "effect" import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command" import { CommandV2 } from "../../command"
@ -13,7 +13,7 @@ import { ConfigMarkdown } from "../markdown"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
export const Plugin = define({ export const Plugin = define({
id: "config-command", id: "opencode.config.command",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service

View file

@ -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<EffectPlugin["effect"]>(
(input): input is EffectPlugin["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<PromisePlugin["setup"]>(
(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<string, unknown> }[] = []
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<typeof plugin.effect>[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)))
})

View file

@ -1,12 +1,12 @@
export * as ConfigProviderPlugin from "./provider" 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 { Effect, Stream } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
export const Plugin = define({ export const Plugin = define({
id: "config-provider", id: "opencode.config.provider",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const loaded = { entries: yield* config.entries() } const loaded = { entries: yield* config.entries() }

View file

@ -1,6 +1,6 @@
export * as ConfigReferencePlugin from "./reference" export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path" import path from "path"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
@ -11,7 +11,7 @@ import { Global } from "../../global"
import { Location } from "../../location" import { Location } from "../../location"
export const Plugin = define({ export const Plugin = define({
id: "core/config-reference", id: "opencode.config.reference",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const location = yield* Location.Service const location = yield* Location.Service

View file

@ -1,6 +1,6 @@
export * as ConfigSkillPlugin from "./skill" export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path" import path from "path"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { Config } from "../../config" import { Config } from "../../config"
@ -10,7 +10,7 @@ import { Global } from "../../global"
import { Location } from "../../location" import { Location } from "../../location"
export const Plugin = define({ export const Plugin = define({
id: "config-skill", id: "opencode.config.skill",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service const config = yield* Config.Service
const global = yield* Global.Service const global = yield* Global.Service

View file

@ -583,12 +583,32 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
} }
const local = <A extends Payload>(stream: Stream.Stream<A>) =>
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 = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> => const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
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<D>), Stream.map((event) => event as Payload<D>),
) )
const streamLive = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.live) const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = ( const readAfter = (
aggregateID: string, aggregateID: string,

View file

@ -5,12 +5,16 @@ import { Catalog } from "./catalog"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { Config } from "./config" import { Config } from "./config"
import { LayerNode } from "./effect/layer-node" 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 { FileMutation } from "./file-mutation"
import { FileSystem } from "./filesystem" import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { FSUtil } from "./fs-util"
import { Generate } from "./generate" import { Generate } from "./generate"
import { Form } from "./form" import { Form } from "./form"
import { Global } from "./global"
import { LocationWatcher } from "./filesystem/location-watcher" import { LocationWatcher } from "./filesystem/location-watcher"
import { Image } from "./image" import { Image } from "./image"
import { Integration } from "./integration" import { Integration } from "./integration"
@ -18,15 +22,20 @@ import { Location } from "./location"
import { LocationMutation } from "./location-mutation" import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map" import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index" import { MCP } from "./mcp/index"
import { ModelsDev } from "./models-dev"
import { Npm } from "./npm"
import { PermissionV2 } from "./permission" import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin" 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 { ProjectCopy } from "./project/copy"
import { Pty } from "./pty" import { Pty } from "./pty"
import { QuestionV2 } from "./question" import { QuestionV2 } from "./question"
import { Shell } from "./shell" import { Shell } from "./shell"
import { Reference } from "./reference" import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance" import { ReferenceGuidance } from "./reference/guidance"
import { Ripgrep } from "./ripgrep"
import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model" import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction" import { SessionCompaction } from "./session/compaction"
@ -42,11 +51,49 @@ import { SessionInstructions } from "./session/instructions"
import { McpTool } from "./tool/mcp" import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem" import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry" import { ToolRegistry } from "./tool/registry"
import { WebSearchTool } from "./tool/websearch"
import { ToolOutputStore } from "./tool-output-store" import { ToolOutputStore } from "./tool-output-store"
import { Vcs } from "./vcs" import { Vcs } from "./vcs"
export { LocationServiceMap } from "./location-service-map" 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 = [ const locationServiceNodes = [
Location.node, Location.node,
Config.node, Config.node,
@ -57,12 +104,11 @@ const locationServiceNodes = [
Catalog.node, Catalog.node,
AISDK.node, AISDK.node,
PluginV2.node, PluginV2.node,
PluginInternal.node, pluginSupervisorNode,
ProjectCopy.node, ProjectCopy.node,
ProjectCopy.refreshNode, ProjectCopy.refreshNode,
FileSystemSearch.node, FileSystemSearch.node,
FileSystem.node, FileSystem.node,
LocationWatcher.node,
Pty.node, Pty.node,
Shell.node, Shell.node,
SkillV2.node, SkillV2.node,
@ -92,6 +138,8 @@ const locationServiceNodes = [
Snapshot.node, Snapshot.node,
SessionRunnerLLM.node, SessionRunnerLLM.node,
Vcs.node, Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[] ] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes) export const locationServices = LayerNode.group<typeof locationServiceNodes>(locationServiceNodes)
@ -106,6 +154,7 @@ export function buildLocationServiceMap(
LocationServiceMap.Service, LocationServiceMap.Service,
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can // Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on // introduce new tagged dependencies (Location.boundNode depends on
@ -116,9 +165,10 @@ export function buildLocationServiceMap(
return LayerNode.compile(location.node).pipe( return LayerNode.compile(location.node).pipe(
Layer.fresh, Layer.fresh,
Layer.tap(() => Layer.tap(() =>
Effect.logInfo("booting location services", { Effect.logInfo("location services booted", {
directory: ref.directory, directory: ref.directory,
workspaceID: ref.workspaceID, workspaceID: ref.workspaceID,
durationMs: Math.round(performance.now() - startedAt),
}), }),
), ),
Layer.provide(LayerNode.compile(location.hoisted)), Layer.provide(LayerNode.compile(location.hoisted)),

View file

@ -1,8 +1,7 @@
export * as PluginV2 from "./plugin" export * as PluginV2 from "./plugin"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk" import { AISDK } from "./aisdk"
@ -10,7 +9,6 @@ import { Catalog } from "./catalog"
import { CommandV2 } from "./command" import { CommandV2 } from "./command"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Integration } from "./integration" import { Integration } from "./integration"
import { KeyedMutex } from "./effect/keyed-mutex"
import { Location } from "./location" import { Location } from "./location"
import { PluginHost } from "./plugin/host" import { PluginHost } from "./plugin/host"
import { PluginRuntime } from "./plugin/runtime" import { PluginRuntime } from "./plugin/runtime"
@ -27,9 +25,7 @@ export type Info = Plugin.Info
export const Event = Plugin.Event export const Event = Plugin.Event
export interface Interface { export interface Interface {
readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect<void> readonly activate: (plugins: readonly import("@opencode-ai/plugin/v2/effect").Plugin[]) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
readonly wait: (id: ID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
@ -39,110 +35,73 @@ const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make() const scope = yield* Scope.make()
const active = new Map<ID, Scope.Closeable>() const active = new Map<ID, Scope.Closeable>()
const loading = new Set<ID>() const lock = Semaphore.makeUnsafe(1)
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>() let generation: readonly ID[] | undefined = []
const failures = new Map<ID, Exit.Exit<void, never>>() let host: Parameters<import("@opencode-ai/plugin/v2/effect").Plugin["effect"]>[0]
let host: Parameters<PluginDefinition["effect"]>[0]
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) { const activate = Effect.fn("Plugin.activate")(function* (
if (loading.has(id)) return yield* Effect.die(new Error(`Plugin load cycle detected for ${id}`)) plugins: readonly import("@opencode-ai/plugin/v2/effect").Plugin[],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: ID.make(plugin.id) }))
const ids = new Set<ID>()
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)( yield* lock.withPermit(
Effect.sync(() => { Effect.gen(function* () {
loading.add(id) if (
failures.delete(id) generation !== undefined &&
}).pipe( generation.length === definitions.length &&
Effect.andThen( generation.every((id, index) => id === definitions[index]?.id)
State.batch( ) {
Effect.gen(function* () { return
const existing = active.get(id) }
active.delete(id) generation = undefined
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) 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) const child = yield* Scope.fork(scope)
yield* effect(host).pipe( const loaded = yield* Effect.suspend(() => definition.effect(host)).pipe(
Scope.provide(child), inherit,
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), Effect.updateContext((_context: Context.Context<never>) => 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.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
) )
yield* events.publish(Event.Added, { id }) if (Exit.isFailure(loaded)) return loaded
active.set(id, child) active.set(definition.id, child)
yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { }
discard: true, return Exit.void
})
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<void>()
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(exit)) return yield* exit
generation = definitions.map((definition) => definition.id)
}),
) )
}) })
yield* Effect.addFinalizer((exit) => yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () { Effect.gen(function* () {
active.clear() active.clear()
generation = []
yield* State.batch(Scope.close(scope, exit)) yield* State.batch(Scope.close(scope, exit))
}), }),
) )
const service = Service.of({ const service = Service.of({
add, activate,
remove,
wait,
list: Effect.fn("Plugin.list")(function* () { list: Effect.fn("Plugin.list")(function* () {
return Array.from(active.keys()).map((id) => ({ id })) return Array.from(active.keys()).map((id) => ({ id }))
}), }),

View file

@ -1,7 +1,7 @@
export * as AgentPlugin from "./agent" export * as AgentPlugin from "./agent"
import path from "path" import path from "path"
import { define } from "./internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect" import { Effect } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Global } from "../global" 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` - 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({ export const Plugin = define({
id: "agent", id: "opencode.agent",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service const location = yield* Location.Service
const worktree = location.directory const worktree = location.directory

View file

@ -1,13 +1,13 @@
export * as CommandPlugin from "./command" export * as CommandPlugin from "./command"
import { define } from "./internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect" import { Effect } from "effect"
import { Location } from "../location" import { Location } from "../location"
import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt" import PROMPT_REVIEW from "./command/review.txt"
export const Plugin = define({ export const Plugin = define({
id: "command", id: "opencode.command",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service const location = yield* Location.Service
yield* ctx.command.transform((draft) => { yield* ctx.command.transform((draft) => {

View file

@ -273,8 +273,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}, },
plugin: { plugin: {
list: () => response(plugin.list()), list: () => response(plugin.list()),
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
}, },
reference: { reference: {
list: () => response(reference.list()), list: () => response(reference.list()),

View file

@ -1,16 +1,14 @@
export * as PluginInternal from "./internal" export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/app-node" import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { httpClient } from "../effect/app-node-platform" import { Context, Effect, Scope } from "effect"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { HttpClient } from "effect/unstable/http"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Catalog } from "../catalog" import { Catalog } from "../catalog"
import { CommandV2 } from "../command" import { CommandV2 } from "../command"
import { Config } from "../config" import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigExternalPlugin } from "../config/plugin/external"
import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigSkillPlugin } from "../config/plugin/skill"
@ -25,8 +23,6 @@ import { Location } from "../location"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev" import { ModelsDev } from "../models-dev"
import { Npm } from "../npm" import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question" import { QuestionV2 } from "../question"
import { Reference } from "../reference" import { Reference } from "../reference"
@ -35,177 +31,137 @@ import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo" import { SessionTodo } from "../session/todo"
import { Shell } from "../shell" import { Shell } from "../shell"
import { SkillV2 } from "../skill" 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 { ApplyPatchTool } from "../tool/apply-patch"
import { EditTool } from "../tool/edit" import { EditTool } from "../tool/edit"
import { GlobTool } from "../tool/glob" import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep" import { GrepTool } from "../tool/grep"
import { QuestionTool } from "../tool/question" import { QuestionTool } from "../tool/question"
import { ReadTool } from "../tool/read"
import { ReadToolFileSystem } from "../tool/read-filesystem" import { ReadToolFileSystem } from "../tool/read-filesystem"
import { ReadTool } from "../tool/read"
import { ShellTool } from "../tool/shell" import { ShellTool } from "../tool/shell"
import { SkillTool } from "../tool/skill" import { SkillTool } from "../tool/skill"
import { SubagentTool } from "../tool/subagent" import { SubagentTool } from "../tool/subagent"
import { TodoWriteTool } from "../tool/todowrite" import { TodoWriteTool } from "../tool/todowrite"
import { Tools } from "../tool/tools"
import { WebFetchTool } from "../tool/webfetch" import { WebFetchTool } from "../tool/webfetch"
import { WebSearchTool } from "../tool/websearch" import { WebSearchTool } from "../tool/websearch"
import { WriteTool } from "../tool/write" 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 = const services = Effect.fn("PluginInternal.services")(function* () {
| AgentV2.Service const agent = yield* AgentV2.Service
| Catalog.Service const catalog = yield* Catalog.Service
| CommandV2.Service const command = yield* CommandV2.Service
| Config.Service const config = yield* Config.Service
| EventV2.Service const events = yield* EventV2.Service
| FileMutation.Service const mutation = yield* FileMutation.Service
| FileSystem.Service const filesystem = yield* FileSystem.Service
| FSUtil.Service const fs = yield* FSUtil.Service
| Global.Service const global = yield* Global.Service
| HttpClient.HttpClient const http = yield* HttpClient.HttpClient
| Image.Service const image = yield* Image.Service
| Integration.Service const integration = yield* Integration.Service
| Location.Service const location = yield* Location.Service
| LocationMutation.Service const locationMutation = yield* LocationMutation.Service
| ModelsDev.Service const models = yield* ModelsDev.Service
| Npm.Service const npm = yield* Npm.Service
| PermissionV2.Service const permission = yield* PermissionV2.Service
| PluginRuntime.Service const runtime = yield* PluginRuntime.Service
| QuestionV2.Service const question = yield* QuestionV2.Service
| ReadToolFileSystem.Service const read = yield* ReadToolFileSystem.Service
| Reference.Service const reference = yield* Reference.Service
| Ripgrep.Service const ripgrep = yield* Ripgrep.Service
| SessionInstructions.Service const instructions = yield* SessionInstructions.Service
| SessionTodo.Service const todo = yield* SessionTodo.Service
| Shell.Service const shell = yield* Shell.Service
| SkillV2.Service const skill = yield* SkillV2.Service
| Tools.Service const tools = yield* Tools.Service
| WebSearchTool.ConfigService const websearch = yield* WebSearchTool.ConfigService
return Context.mergeAll(
export interface Plugin<R = never> { Context.make(AgentV2.Service, agent),
readonly id: string Context.make(Catalog.Service, catalog),
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R | Scope.Scope> Context.make(CommandV2.Service, command),
} Context.make(Config.Service, config),
Context.make(EventV2.Service, events),
export function define<R>(plugin: Plugin<R>) { Context.make(FileMutation.Service, mutation),
return plugin Context.make(FileSystem.Service, filesystem),
} Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
const layer = Layer.effectDiscard( Context.make(HttpClient.HttpClient, http),
Effect.gen(function* () { Context.make(Image.Service, image),
const plugin = yield* PluginV2.Service Context.make(Integration.Service, integration),
const sdkPlugins = yield* SdkPlugins.Service Context.make(Location.Service, location),
const services = Context.mergeAll( Context.make(LocationMutation.Service, locationMutation),
Context.make(Catalog.Service, yield* Catalog.Service), Context.make(ModelsDev.Service, models),
Context.make(CommandV2.Service, yield* CommandV2.Service), Context.make(Npm.Service, npm),
Context.make(Integration.Service, yield* Integration.Service), Context.make(PermissionV2.Service, permission),
Context.make(AgentV2.Service, yield* AgentV2.Service), Context.make(PluginRuntime.Service, runtime),
Context.make(Config.Service, yield* Config.Service), Context.make(QuestionV2.Service, question),
Context.make(Location.Service, yield* Location.Service), Context.make(ReadToolFileSystem.Service, read),
Context.make(ModelsDev.Service, yield* ModelsDev.Service), Context.make(Reference.Service, reference),
Context.make(Npm.Service, yield* Npm.Service), Context.make(Ripgrep.Service, ripgrep),
Context.make(EventV2.Service, yield* EventV2.Service), Context.make(SessionInstructions.Service, instructions),
Context.make(FSUtil.Service, yield* FSUtil.Service), Context.make(SessionTodo.Service, todo),
Context.make(FileSystem.Service, yield* FileSystem.Service), Context.make(Shell.Service, shell),
Context.make(Global.Service, yield* Global.Service), Context.make(SkillV2.Service, skill),
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient), Context.make(Tools.Service, tools),
Context.make(LocationMutation.Service, yield* LocationMutation.Service), Context.make(WebSearchTool.ConfigService, websearch),
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), type ContextServices<A> = A extends Context.Context<infer R> ? R : never
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service), export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
Context.make(SessionTodo.Service, yield* SessionTodo.Service),
Context.make(SkillV2.Service, yield* SkillV2.Service), export type InternalPlugin = Plugin<Requirements | Scope.Scope>
Context.make(Reference.Service, yield* Reference.Service),
Context.make(Ripgrep.Service, yield* Ripgrep.Service), const pre = [
Context.make(Shell.Service, yield* Shell.Service), AgentPlugin.Plugin,
Context.make(Tools.Service, yield* Tools.Service), CommandPlugin.Plugin,
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service), SkillPlugin.Plugin,
Context.make(WebSearchTool.ConfigService, yield* WebSearchTool.ConfigService), ModelsDevPlugin,
) ...ProviderPlugins,
const add = (input: Plugin<Requirements | Scope.Scope>) => ApplyPatchTool.Plugin,
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) => EditTool.Plugin,
input.effect(context).pipe(Effect.provide(services)), GlobTool.Plugin,
) GrepTool.Plugin,
QuestionTool.Plugin,
yield* State.batch( ReadTool.Plugin,
Effect.gen(function* () { ShellTool.Plugin,
yield* add(ConfigReferencePlugin.Plugin) SkillTool.Plugin,
yield* add(AgentPlugin.Plugin) SubagentTool.Plugin,
yield* add(CommandPlugin.Plugin) TodoWriteTool.Plugin,
yield* add(SkillPlugin.Plugin) WebFetchTool.Plugin,
yield* add(ModelsDevPlugin) WebSearchTool.Plugin,
yield* add(ConfigExternalPlugin.Plugin) WriteTool.Plugin,
yield* add(ApplyPatchTool.Plugin) ] as const satisfies readonly InternalPlugin[]
yield* add(EditTool.Plugin)
yield* add(GlobTool.Plugin) const post = [
yield* add(GrepTool.Plugin) ConfigReferencePlugin.Plugin,
yield* add(QuestionTool.Plugin) ConfigAgentPlugin.Plugin,
yield* add(ReadTool.Plugin) ConfigCommandPlugin.Plugin,
yield* add(ShellTool.Plugin) ConfigSkillPlugin.Plugin,
yield* add(SkillTool.Plugin) ConfigProviderPlugin.Plugin,
yield* add(SubagentTool.Plugin) VariantPlugin.Plugin,
yield* add(TodoWriteTool.Plugin) ] as const satisfies readonly InternalPlugin[]
yield* add(WebFetchTool.Plugin)
yield* add(WebSearchTool.Plugin) export const list = Effect.fn("PluginInternal.list")(function* () {
yield* add(WriteTool.Plugin) const context = yield* services()
yield* add(ConfigAgentPlugin.Plugin) const resolve = (plugins: readonly InternalPlugin[]) =>
yield* add(ConfigCommandPlugin.Plugin) plugins.map(
yield* add(ConfigSkillPlugin.Plugin) (plugin): Plugin => ({
for (const item of ProviderPlugins) yield* add(item) id: plugin.id,
yield* add(ConfigProviderPlugin.Plugin) effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
yield* add(VariantPlugin.Plugin) }),
// Embedder-contributed plugins are added last so they layer over config. )
for (const plugin of sdkPlugins.all()) yield* add(plugin) return {
}), pre: resolve(pre),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) post: resolve(post),
}), }
)
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,
],
}) })

View file

@ -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 type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { EventV2 } from "../event" import { EventV2 } from "../event"
@ -197,7 +197,7 @@ function applyModel(
} }
export const ModelsDevPlugin = define({ export const ModelsDevPlugin = define({
id: "models-dev", id: "opencode.models-dev",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service

View file

@ -9,7 +9,7 @@ type Registration = { readonly dispose: () => Promise<void> }
/** /**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only * 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 * Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context * scope, so unloading the plugin disposes them. The captured fiber context
@ -93,11 +93,6 @@ export function fromPromise(plugin: Plugin) {
}, },
plugin: { plugin: {
list: (input) => run(host.plugin.list(input)), 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: { reference: {
list: (input) => run(host.reference.list(input)), list: (input) => run(host.reference.list(input)),

View file

@ -31,9 +31,8 @@ import { VenicePlugin } from "./provider/venice"
import { XAIPlugin } from "./provider/xai" import { XAIPlugin } from "./provider/xai"
import { ZenmuxPlugin } from "./provider/zenmux" import { ZenmuxPlugin } from "./provider/zenmux"
import type { PluginInternal } from "./internal" import type { PluginInternal } from "./internal"
import type { Scope } from "effect"
export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>[] = [ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
AlibabaPlugin, AlibabaPlugin,
AmazonBedrockPlugin, AmazonBedrockPlugin,
AnthropicPlugin, AnthropicPlugin,

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AlibabaPlugin = define({ export const AlibabaPlugin = define({
id: "alibaba", id: "opencode.provider.alibaba",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,6 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
type MantleSDK = { type MantleSDK = {
@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
} }
export const AmazonBedrockPlugin = define({ export const AmazonBedrockPlugin = define({
id: "amazon-bedrock", id: "opencode.provider.amazon-bedrock",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AnthropicPlugin = define({ export const AnthropicPlugin = define({
id: "anthropic", id: "opencode.provider.anthropic",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function selectLanguage(sdk: any, modelID: string, useChat: boolean) { function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
@ -11,7 +11,7 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
} }
export const AzurePlugin = define({ export const AzurePlugin = define({
id: "azure", id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
@ -54,7 +54,7 @@ export const AzurePlugin = define({
}) })
export const AzureCognitiveServicesPlugin = define({ export const AzureCognitiveServicesPlugin = define({
id: "azure-cognitive-services", id: "opencode.provider.azure-cognitive-services",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CerebrasPlugin = define({ export const CerebrasPlugin = define({
id: "cerebras", id: "opencode.provider.cerebras",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,10 +1,10 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CloudflareAIGatewayPlugin = define({ export const CloudflareAIGatewayPlugin = define({
id: "cloudflare-ai-gateway", id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,13 +1,13 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
const providerID = ProviderV2.ID.make("cloudflare-workers-ai") const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({ export const CloudflareWorkersAIPlugin = define({
id: "cloudflare-workers-ai", id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID) const item = evt.provider.get(providerID)

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CoherePlugin = define({ export const CoherePlugin = define({
id: "cohere", id: "opencode.provider.cohere",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const DeepInfraPlugin = define({ export const DeepInfraPlugin = define({
id: "deepinfra", id: "opencode.provider.deepinfra",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,10 +1,10 @@
import { Effect } from "effect" import { Effect } from "effect"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm" import { Npm } from "../../npm"
export const DynamicProviderPlugin = define({ export const DynamicProviderPlugin = define({
id: "dynamic-provider", id: "opencode.provider.dynamic",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GatewayPlugin = define({ export const GatewayPlugin = define({
id: "gateway", id: "opencode.provider.gateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,6 +1,6 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function shouldUseResponses(modelID: string) { function shouldUseResponses(modelID: string) {
@ -12,7 +12,7 @@ function shouldUseResponses(modelID: string) {
} }
export const GithubCopilotPlugin = define({ export const GithubCopilotPlugin = define({
id: "github-copilot", id: "opencode.provider.github-copilot",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(ProviderV2.ID.githubCopilot) const item = evt.provider.get(ProviderV2.ID.githubCopilot)

View file

@ -1,11 +1,11 @@
import os from "os" import os from "os"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({ export const GitLabPlugin = define({
id: "gitlab", id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
function resolveProject(options: Record<string, any>) { function resolveProject(options: Record<string, any>) {
@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
} }
export const GoogleVertexPlugin = define({ export const GoogleVertexPlugin = define({
id: "google-vertex", id: "opencode.provider.google-vertex",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
@ -111,7 +111,7 @@ export const GoogleVertexPlugin = define({
}) })
export const GoogleVertexAnthropicPlugin = define({ export const GoogleVertexAnthropicPlugin = define({
id: "google-vertex-anthropic", id: "opencode.provider.google-vertex-anthropic",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GooglePlugin = define({ export const GooglePlugin = define({
id: "google", id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GroqPlugin = define({ export const GroqPlugin = define({
id: "groq", id: "opencode.provider.groq",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const KiloPlugin = define({ export const KiloPlugin = define({
id: "kilo", id: "opencode.provider.kilo",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,9 +1,9 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Integration } from "../../integration" import { Integration } from "../../integration"
export const LLMGatewayPlugin = define({ export const LLMGatewayPlugin = define({
id: "llmgateway", id: "opencode.provider.llmgateway",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const configured = new Set((yield* integrations.list()).map((integration) => integration.id)) const configured = new Set((yield* integrations.list()).map((integration) => integration.id))

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const MistralPlugin = define({ export const MistralPlugin = define({
id: "mistral", id: "opencode.provider.mistral",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const NvidiaPlugin = define({ export const NvidiaPlugin = define({
id: "nvidia", id: "opencode.provider.nvidia",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenAICompatiblePlugin = define({ export const OpenAICompatiblePlugin = define({
id: "openai-compatible", id: "opencode.provider.openai-compatible",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -2,7 +2,6 @@ import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import { Credential } from "../../credential" import { Credential } from "../../credential"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
@ -159,7 +158,7 @@ const headless = {
} satisfies IntegrationOAuthMethodRegistration } satisfies IntegrationOAuthMethodRegistration
export const OpenAIPlugin = define({ export const OpenAIPlugin = define({
id: "openai", id: "opencode.provider.openai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1) const loading = Semaphore.makeUnsafe(1)
@ -225,7 +224,7 @@ export const OpenAIPlugin = define({
}), }),
) )
}), }),
} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>) } satisfies PluginInternal.InternalPlugin)
function headers(contentType: string) { function headers(contentType: string) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }

View file

@ -75,7 +75,7 @@ function oauth(http: HttpClient.HttpClient) {
} }
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
id: "opencode", id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient

View file

@ -1,9 +1,9 @@
import { Effect } from "effect" import { Effect } from "effect"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenRouterPlugin = define({ export const OpenRouterPlugin = define({
id: "openrouter", id: "opencode.provider.openrouter",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const PerplexityPlugin = define({ export const PerplexityPlugin = define({
id: "perplexity", id: "opencode.provider.perplexity",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,11 +1,11 @@
import { Effect } from "effect" import { Effect } from "effect"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Npm } from "../../npm" import { Npm } from "../../npm"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const SapAICorePlugin = define({ export const SapAICorePlugin = define({
id: "sap-ai-core", id: "opencode.provider.sap-ai-core",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service const npm = yield* Npm.Service
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(

View file

@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response> type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
} }
export const SnowflakeCortexPlugin = define({ export const SnowflakeCortexPlugin = define({
id: "snowflake-cortex", id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const TogetherAIPlugin = define({ export const TogetherAIPlugin = define({
id: "togetherai", id: "opencode.provider.togetherai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VenicePlugin = define({ export const VenicePlugin = define({
id: "venice", id: "opencode.provider.venice",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VercelPlugin = define({ export const VercelPlugin = define({
id: "vercel", id: "opencode.provider.vercel",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -1,9 +1,9 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({ export const XAIPlugin = define({
id: "xai", id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.sdk( yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) { Effect.fn(function* (evt) {

View file

@ -1,8 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "../internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const ZenmuxPlugin = define({ export const ZenmuxPlugin = define({
id: "zenmux", id: "opencode.provider.zenmux",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {

View file

@ -14,9 +14,9 @@ const defaultStore = makeStore()
/** /**
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* so `PluginInternal` can add them on every Location boot through the ordinary * so `PluginSupervisor` can add them on every Location boot through the ordinary
* `ctx.plugin.add` seam the same path `ConfigExternalPlugin` uses for plugins * generation path that `PluginSupervisor` uses for plugins discovered from
* discovered from config. A plugin registered after a Location has booted only * config. A plugin registered after a Location has booted only
* applies to Locations booted afterward, matching config-plugin timing; * applies to Locations booted afterward, matching config-plugin timing;
* embedders register at startup before creating Sessions. * embedders register at startup before creating Sessions.
* *

View file

@ -2,7 +2,7 @@
export * as SkillPlugin from "./skill" export * as SkillPlugin from "./skill"
import { define } from "./internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect } from "effect" import { Effect } from "effect"
import { AbsolutePath } from "../schema" import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill" 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." "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({ export const Plugin = define({
id: "skill", id: "opencode.skill",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics() const reportContent = yield* reportContentWithDiagnostics()
yield* ctx.skill.transform((draft) => { yield* ctx.skill.transform((draft) => {

View file

@ -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<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[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<string, unknown>
}
| {
readonly type: "remove"
readonly target: string
}
type Candidate =
| {
readonly type: "definition"
readonly definition: Plugin
}
| {
readonly type: "package"
readonly specifier: string
readonly options: Record<string, unknown>
}
type ConfiguredPackage = {
readonly operation: Extract<Operation, { type: "add" }>
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<string, ConfiguredPackage>()
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<void>
}
export class Service extends Context.Service<Service, Interface>()("@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 }

View file

@ -2,10 +2,10 @@ export * as VariantPlugin from "./variant"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect } from "effect" import { Effect } from "effect"
import { define } from "./internal" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const Plugin = define({ export const Plugin = define({
id: "variant", id: "opencode.variant",
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((catalog) => { yield* ctx.catalog.transform((catalog) => {
for (const record of catalog.provider.list()) { for (const record of catalog.provider.list()) {

View file

@ -26,21 +26,32 @@ export interface Transformable<DraftApi> {
readonly reload: Reload readonly reload: Reload
} }
const CurrentBatch = Context.Reference<Set<Reload> | undefined>("@opencode/State/CurrentBatch", { type Batch = {
active: boolean
readonly reloads: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined, defaultValue: () => undefined,
}) })
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) { export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () { return Effect.gen(function* () {
const current = yield* CurrentBatch const current = yield* CurrentBatch
if (current) return yield* effect if (current?.active) return yield* effect
const reloads = new Set<Reload>() const batch: Batch = { active: true, reloads: new Set() }
const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads)) const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
yield* Effect.forEach(reloads, (reload) => reload(), { discard: true }) batch.active = false
return result yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
}) })
} }
export const inherit = Effect.fnUntraced(function* () {
const batch = yield* CurrentBatch
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentBatch, batch)
})
export interface Options<State, DraftApi> { export interface Options<State, DraftApi> {
/** Creates the base value for initial state and every scoped-transform reload. */ /** Creates the base value for initial state and every scoped-transform reload. */
readonly initial: () => State readonly initial: () => State
@ -100,8 +111,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
transforms = transforms.filter((item) => item !== transform) transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () { return Effect.gen(function* () {
const batch = yield* CurrentBatch const batch = yield* CurrentBatch
if (batch) { if (batch?.active) {
batch.add(reload) batch.reloads.add(reload)
return return
} }
yield* materialize() yield* materialize()
@ -116,7 +127,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
) )
yield* Scope.addFinalizer(scope, dispose) yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch const batch = yield* CurrentBatch
if (batch) batch.add(reload) if (batch?.active) batch.reloads.add(reload)
else yield* reload() else yield* reload()
return { dispose } return { dispose }
}), }),

View file

@ -55,7 +55,7 @@ type Prepared =
}) })
export const Plugin = { export const Plugin = {
id: "core-apply-patch-tool", id: "opencode.tool.apply-patch",
effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service

View file

@ -86,7 +86,7 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
export const Plugin = { export const Plugin = {
id: "core-edit-tool", id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service

View file

@ -35,7 +35,7 @@ export const toModelOutput = (output: ModelOutput) => {
/** Glob leaf that defaults its filesystem root to the active Location. */ /** Glob leaf that defaults its filesystem root to the active Location. */
export const Plugin = { export const Plugin = {
id: "core-glob-tool", id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service

View file

@ -49,7 +49,7 @@ export const toModelOutput = (output: ModelOutput) => {
/** Grep leaf that defaults its filesystem root to the active Location. */ /** Grep leaf that defaults its filesystem root to the active Location. */
export const Plugin = { export const Plugin = {
id: "core-grep-tool", id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service

View file

@ -43,7 +43,7 @@ export const toModelOutput = (
} }
export const Plugin = { export const Plugin = {
id: "core-question-tool", id: "opencode.tool.question",
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
const question = yield* QuestionV2.Service const question = yield* QuestionV2.Service
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service

View file

@ -31,7 +31,7 @@ const Input = LocationInput
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
export const Plugin = { export const Plugin = {
id: "core-read-tool", id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service

View file

@ -99,7 +99,7 @@ const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectori
}) })
export const Plugin = { export const Plugin = {
id: "core-shell-tool", id: "opencode.tool.shell",
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope

View file

@ -53,7 +53,7 @@ const unableToLoad = (name: string, error?: unknown) =>
new ToolFailure({ message: `Unable to load skill ${name}`, error }) new ToolFailure({ message: `Unable to load skill ${name}`, error })
export const Plugin = { export const Plugin = {
id: "core-skill-tool", id: "opencode.tool.skill",
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const skills = yield* SkillV2.Service const skills = yield* SkillV2.Service

View file

@ -38,7 +38,7 @@ export const description = [
].join("\n") ].join("\n")
export const Plugin = { export const Plugin = {
id: "core-subagent-tool", id: "opencode.tool.subagent",
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service

View file

@ -21,7 +21,7 @@ export type Output = typeof Output.Type
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2) export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
export const Plugin = { export const Plugin = {
id: "core-todowrite-tool", id: "opencode.tool.todowrite",
effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("TodoWriteTool.Plugin")(function* (ctx: PluginContext) {
const todos = yield* SessionTodo.Service const todos = yield* SessionTodo.Service
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service

View file

@ -113,7 +113,7 @@ const convert = (content: string, contentType: string, format: Format) => {
} }
export const Plugin = { export const Plugin = {
id: "core-webfetch-tool", id: "opencode.tool.webfetch",
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const permission = yield* PermissionV2.Service const permission = yield* PermissionV2.Service

View file

@ -188,7 +188,7 @@ const Output = Schema.Struct({
}) })
export const Plugin = { export const Plugin = {
id: "core-websearch-tool", id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const config = yield* ConfigService const config = yield* ConfigService

View file

@ -43,7 +43,7 @@ export const toModelOutput = (output: Output) =>
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists. // TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
export const Plugin = { export const Plugin = {
id: "core-write-tool", id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service

View file

@ -1,252 +1,225 @@
import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" 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 { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config" import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FSUtil } from "@opencode-ai/core/fs-util" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" 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 { 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 { 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 { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(
const decode = Schema.decodeUnknownSync(Config.Info) AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
)
describe("ConfigExternalPlugin", () => { describe("PluginSupervisor config", () => {
it.live("resolves and loads a configured Promise plugin with options", () => 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* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const sdk = yield* SdkPlugins.Service
const agents = yield* AgentV2.Service yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
const fs = yield* FSUtil.Service yield* withLocation(
const location = yield* Location.Service {
const npm = yield* Npm.Service plugins: [
const host = yield* PluginHost.make(plugins) path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
const document = path.join(import.meta.dir, "opencode.json") 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( const catalog = yield* Catalog.Service
Effect.provideService(PluginV2.Service, plugins), expect(
Effect.provideService(FSUtil.Service, fs), (yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
Effect.provideService(Location.Service, location), ).toEqual([
Effect.provideService(Npm.Service, npm), expect.objectContaining({ id: "high", headers: { custom: "true" } }),
Effect.provideService( expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
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" },
},
],
}),
}),
]),
}),
),
) )
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
}), }),
) )
it.live("loads a configured Effect plugin with options", () => it.live("allows variant generation to be disabled", () =>
Effect.gen(function* () { withLocation(
const plugins = yield* PluginV2.Service {
const agents = yield* AgentV2.Service plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
const fs = yield* FSUtil.Service },
const location = yield* Location.Service Effect.gen(function* () {
const npm = yield* Npm.Service yield* ready()
const host = yield* PluginHost.make(plugins) const registry = yield* PluginV2.Service
expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
yield* ConfigExternalPlugin.Plugin.effect(host).pipe( const catalog = yield* Catalog.Service
Effect.provideService(PluginV2.Service, plugins), expect(
Effect.provideService(FSUtil.Service, fs), (yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
Effect.provideService(Location.Service, location), ).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })])
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 waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { const ready = Effect.fnUntraced(function* () {
for (let attempt = 0; attempt < 100; attempt++) { const supervisor = yield* PluginSupervisor.Service
const agent = yield* agents.get(AgentV2.ID.make(id)) yield* supervisor.ready
if (agent) return agent
yield* Effect.sleep("10 millis")
}
return yield* Effect.die(`Timed out waiting for agent ${id}`)
}) })
function withLocation<A, E, R>(config: unknown, effect: Effect.Effect<A, E, R>, 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) }))),
),
),
)
}

View file

@ -7,7 +7,6 @@ import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" 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 { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference" import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill" import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
@ -37,7 +36,7 @@ describe("config plugin reloads", () => {
const references = yield* Reference.Service const references = yield* Reference.Service
const skills = yield* SkillV2.Service const skills = yield* SkillV2.Service
const host = yield* PluginHost.make(plugins) 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 service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
const setup = <R>(effect: Effect.Effect<void, never, R>) => const setup = <R>(effect: Effect.Effect<void, never, R>) =>
effect.pipe(Effect.provideService(Config.Service, service)) effect.pipe(Effect.provideService(Config.Service, service))
@ -47,7 +46,6 @@ describe("config plugin reloads", () => {
yield* setup(ConfigSkillPlugin.Plugin.effect(host)) yield* setup(ConfigSkillPlugin.Plugin.effect(host))
yield* setup(ConfigReferencePlugin.Plugin.effect(host)) yield* setup(ConfigReferencePlugin.Plugin.effect(host))
yield* setup(ConfigProviderPlugin.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* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command") expect((yield* commands.get("first"))?.description).toBe("First command")
@ -56,9 +54,8 @@ describe("config plugin reloads", () => {
).toBe(true) ).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"]) expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined() 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* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil( yield* waitUntil(
Effect.gen(function* () { Effect.gen(function* () {
@ -80,12 +77,11 @@ describe("config plugin reloads", () => {
expect( expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"), (yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true) ).toBe(true)
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))), }).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
) )
}) })
function config(name: string, pluginDescription?: string) { function config(name: string) {
return new Config.Document({ return new Config.Document({
type: "document", type: "document",
path: document, path: document,
@ -95,15 +91,6 @@ function config(name: string, pluginDescription?: string) {
skills: [`/skills/${name}`], skills: [`/skills/${name}`],
references: { [name]: `/references/${name}` }, references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } }, providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
plugins:
pluginDescription === undefined
? []
: [
{
package: "../plugin/fixtures/config-promise-plugin.ts",
options: { description: pluginDescription },
},
],
}), }),
}) })
} }

View file

@ -1,7 +1,8 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" 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 { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" 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 { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider" 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]))) const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
describe("LocationServiceMap", () => { 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", () => it.live("reuses cached services for constructed and decoded location refs", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
@ -64,7 +184,7 @@ describe("LocationServiceMap", () => {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {})) yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const registry = yield* ToolRegistry.Service 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. // every expected tool rather than relying on batch ordering.
yield* Effect.forEach( yield* Effect.forEach(
[ [
@ -257,7 +377,7 @@ describe("LocationServiceMap", () => {
}) })
.pipe(Effect.asVoid), .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({ expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
description: "Reviews code", description: "Reviews code",

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" 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 { define } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@ -16,6 +16,8 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
describe("PluginV2", () => { describe("PluginV2", () => {
it.live("exposes public events through the plugin context", () => it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () { Effect.gen(function* () {
@ -35,43 +37,15 @@ describe("PluginV2", () => {
}), }),
) )
it.effect("waits for a plugin and returns immediately once active", () => it.effect("skips identical generations and replaces changed plugin IDs", () =>
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", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
let description = "first" let description = "first"
const managed = () => const managed = (id: string) =>
define({ define({
id: "managed", id,
effect: (ctx) => effect: (ctx) =>
ctx.agent ctx.agent
.transform((agents) => .transform((agents) =>
@ -82,19 +56,101 @@ describe("PluginV2", () => {
.pipe(Effect.asVoid), .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") expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
description = "second" 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") 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() 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", () => it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
@ -114,12 +170,12 @@ describe("PluginV2", () => {
.pipe(Effect.orDie), .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( expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
"plugin_tool", "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( expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
"plugin_tool", "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([ expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
"plain", "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 materialized = yield* registry.materialize({ model: testModel })
const settlement = yield* materialized.settle({ const settlement = yield* materialized.settle({

View file

@ -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"),
})

View file

@ -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),
})

View file

@ -59,8 +59,6 @@ export function host(overrides: Overrides = {}): PluginContext {
}, },
plugin: overrides.plugin ?? { plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"), list: () => Effect.die("unused plugin.list"),
add: () => Effect.die("unused plugin.add"),
remove: () => Effect.die("unused plugin.remove"),
}, },
reference: overrides.reference ?? { reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"), list: () => Effect.die("unused reference.list"),

View file

@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () {
describe("KiloPlugin", () => { describe("KiloPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => 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", () => it.effect("applies legacy referer headers only to kilo", () =>

View file

@ -21,7 +21,9 @@ const addPlugin = Effect.fn(function* () {
describe("LLMGatewayPlugin", () => { describe("LLMGatewayPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => 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", () => it.effect("applies legacy referer headers only to enabled llmgateway", () =>

View file

@ -19,7 +19,9 @@ const addPlugin = Effect.fn(function* () {
describe("NvidiaPlugin", () => { describe("NvidiaPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => 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", () => it.effect("applies NVIDIA tracking headers only to nvidia", () =>

View file

@ -22,7 +22,9 @@ const addPlugin = Effect.fn(function* () {
describe("OpenRouterPlugin", () => { describe("OpenRouterPlugin", () => {
it.effect("is registered so legacy OpenRouter behavior can be applied", () => 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", () => it.effect("applies legacy referer headers only to openrouter", () =>

View file

@ -43,9 +43,11 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
describe("SnowflakeCortexPlugin", () => { describe("SnowflakeCortexPlugin", () => {
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () => it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
Effect.sync(() => { 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) 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"),
)
}), }),
) )

View file

@ -24,7 +24,9 @@ function required<T>(value: T | undefined): T {
describe("ZenmuxPlugin", () => { describe("ZenmuxPlugin", () => {
it.effect("is registered so legacy referer headers can be applied", () => 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", () => it.effect("applies the exact legacy Zenmux headers", () =>

View file

@ -29,8 +29,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Reference } from "@opencode-ai/core/reference" import { Reference } from "@opencode-ai/core/reference"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
export const Info = Schema.Struct({ export const Info = Schema.Struct({
name: Schema.String, name: Schema.String,
@ -101,7 +101,7 @@ const layer = Layer.effect(
const skillDirs = yield* skill.dirs() const skillDirs = yield* skill.dirs()
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
? yield* Effect.gen(function* () { ? 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) return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
: [] : []

View file

@ -11,7 +11,4 @@ export function define<R = Scope.Scope>(plugin: Plugin<R>) {
return plugin return plugin
} }
export interface PluginDomain extends PluginApi<unknown> { export interface PluginDomain extends PluginApi<unknown> {}
readonly add: (plugin: Plugin) => Effect.Effect<void>
readonly remove: (id: string) => Effect.Effect<void>
}

View file

@ -10,7 +10,4 @@ export function define(plugin: Plugin) {
return plugin return plugin
} }
export interface PluginDomain extends PluginApi { export interface PluginDomain extends PluginApi {}
readonly add: (plugin: Plugin) => Promise<void>
readonly remove: (id: string) => Promise<void>
}