fix(plugin): select plugins by id

This commit is contained in:
Dax Raad 2026-07-10 00:44:43 -04:00
commit fbb0fdf88a
8 changed files with 149 additions and 152 deletions

View file

@ -72,23 +72,6 @@ type Operation =
readonly target: string readonly target: string
} }
type Candidate =
| {
readonly type: "definition"
readonly definition: Plugin
}
| {
readonly type: "package"
readonly specifier: string
readonly options: Record<string, unknown>
readonly mtime?: number
}
type ConfiguredPackage = {
readonly operation: Extract<Operation, { type: "add" }>
enabled: boolean
}
function parse(input: ConfigPlugin.Plugin): Operation { function parse(input: ConfigPlugin.Plugin): Operation {
if (typeof input !== "string") { if (typeof input !== "string") {
return { type: "add", target: input.package, options: input.options ?? {} } return { type: "add", target: input.package, options: input.options ?? {} }
@ -109,13 +92,14 @@ const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Con
.filter((entry): entry is Config.Document => entry.type === "document") .filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => .flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => { (entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return operation
const directory = entry.path ? path.dirname(entry.path) : location.directory const directory = entry.path ? path.dirname(entry.path) : location.directory
const target = operation.target.startsWith("file://") const target = operation.target.startsWith("file://")
? fileURLToPath(operation.target) ? fileURLToPath(operation.target)
: operation.target.startsWith("./") || operation.target.startsWith("../") : operation.target.startsWith("./") || operation.target.startsWith("../")
? path.resolve(directory, operation.target) ? path.resolve(directory, operation.target)
: operation.target : operation.target
return operation.type === "add" ? { ...operation, target } : { type: "remove" as const, target } return { ...operation, target }
}), }),
) )
// Explicit config is applied last so it can remove auto-discovered packages. // Explicit config is applied last so it can remove auto-discovered packages.
@ -136,88 +120,66 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
post: readonly Plugin[], post: readonly Plugin[],
operations: readonly Operation[], 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) => const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target) selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const plugins = [...pre, ...post] const definitions = [...pre, ...post]
const enabled = new Set(plugins.map((plugin) => plugin.id)) const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, ConfiguredPackage>() const packages = new Map<string, Plugin>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) { for (const operation of operations) {
if (operation.type === "remove") { if (operation.type === "remove") {
plugins.filter((plugin) => matches(operation.target, plugin.id)).forEach((plugin) => enabled.delete(plugin.id)) plugins()
packages.forEach((item, target) => { .filter((plugin) => matches(operation.target, plugin.id))
if (matches(operation.target, target)) item.enabled = false .forEach((plugin) => enabled.delete(plugin.id))
})
continue continue
} }
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id)) const matched = plugins().filter((plugin) => matches(operation.target, plugin.id))
const selectsDefinitions = const selectsPlugins =
matched.length > 0 || matched.length > 0 ||
operation.target === "*" || operation.target === "*" ||
operation.target.endsWith(".*") || operation.target.endsWith(".*") ||
operation.target.startsWith("opencode.") operation.target.startsWith("opencode.")
if (selectsDefinitions) { if (selectsPlugins) {
matched.forEach((plugin) => enabled.add(plugin.id)) matched.forEach((plugin) => enabled.add(plugin.id))
packages.forEach((item, target) => {
if (matches(operation.target, target)) item.enabled = true
})
continue continue
} }
packages.set(operation.target, { operation, enabled: true }) const plugin = yield* load(operation).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
if (!plugin) continue
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
enabled.add(plugin.id)
} }
const definitions: Candidate[] = pre.flatMap((definition) => return [
enabled.has(definition.id) ? [{ type: "definition", definition }] : [], ...pre.filter((plugin) => enabled.has(plugin.id)),
) ...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
const configured: Candidate[] = Array.from(packages.values()).flatMap((item) => ...post.filter((plugin) => enabled.has(plugin.id)),
item.enabled ]
? [ })
{
type: "package",
specifier: item.operation.target,
options: item.operation.options,
...(item.operation.mtime === undefined ? {} : { mtime: item.operation.mtime }),
},
]
: [],
)
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[]) { const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
return yield* Effect.forEach(plan, (candidate) => { const npm = yield* Npm.Service
if (candidate.type === "definition") return Effect.succeed(candidate.definition) const entrypoint = path.isAbsolute(operation.target)
return Effect.gen(function* () { ? pathToFileURL(operation.target).href
const npm = yield* Npm.Service : (yield* npm.add(operation.target)).entrypoint
const entrypoint = path.isAbsolute(candidate.specifier) if (!entrypoint) return
? pathToFileURL(candidate.specifier).href // Bun currently ignores query parameters when caching file:// imports.
: (yield* npm.add(candidate.specifier)).entrypoint const source =
if (!entrypoint) return undefined operation.mtime === undefined
// Bun currently ignores query parameters when caching file:// imports. ? entrypoint
const source = : `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
candidate.mtime === undefined yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
? entrypoint const mod = yield* Effect.promise(() => import(source))
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}` const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source }) const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const mod = yield* Effect.promise(() => import(source)) return {
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default id: plugin.id,
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) effect: (host) => plugin.effect({ ...host, options: operation.options }),
return { } satisfies Plugin
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) { function discoverDirectory(fs: FSUtil.Interface, directory: string) {

View file

@ -63,6 +63,32 @@ describe("PluginSupervisor config", () => {
), ),
) )
it.live("disables configured plugins by exported ID", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
return withLocation(
{ plugins: [plugin, "-config-promise-plugin"] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
expect((yield* plugins.list()).map((item) => String(item.id))).not.toContain("config-promise-plugin")
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
}),
)
})
it.live("does not disable configured plugins by package target", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
return withLocation(
{ plugins: [plugin, `-${plugin}`] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* PluginV2.Service
expect((yield* plugins.list()).map((item) => String(item.id))).toContain("config-promise-plugin")
}),
)
})
it.live("loads configured Effect plugins with options", () => it.live("loads configured Effect plugins with options", () =>
withLocation( withLocation(
{ {

View file

@ -17,8 +17,7 @@ execution; and call a location-scoped subset of the V2 client.
Plugins can be loaded from npm packages, explicit local paths, or config Plugins can be loaded from npm packages, explicit local paths, or config
directories. Each module must have one default export containing a unique directories. Each module must have one default export containing a unique
plugin `id` and either a Promise `setup` function or an Effect `effect` plugin `id` and a `setup` function.
function.
### Configuration ### Configuration
@ -75,26 +74,25 @@ relative config entry.
### Enable and disable ### Enable and disable
A string beginning with `-` removes a previously selected target. `*` matches A string beginning with `-` disables plugins by their exported `id`. `*`
everything, and a suffix of `.*` matches an ID or target prefix. Directives are matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
applied in order: applied in order:
```jsonc title="opencode.jsonc" ```jsonc title="opencode.jsonc"
{ {
"plugins": [ "plugins": [
"./plugins/reviewer.ts",
"-acme.reviewer",
"-opencode.provider.*", "-opencode.provider.*",
"opencode.provider.openai", "opencode.provider.openai"
"-./plugins/old.ts",
"-*",
"./plugins/only-this-one.ts"
] ]
} }
``` ```
Use the same package specifier or resolved local target to remove an external Package specifiers and local paths locate plugin modules; they are not disable
plugin. Built-in and embedded plugins can be selected by their plugin ID. selectors. Use the `id` from the plugin's default export to disable it. A later
Explicit config directives run after local auto-discovery, so they can disable ID entry re-enables a loaded or built-in plugin. Explicit config directives run
discovered plugins. after local auto-discovery, so they can disable discovered plugins by ID.
User plugins are activated in configured order between OpenCode's internal User plugins are activated in configured order between OpenCode's internal
plugin phases. Hooks run sequentially in registration order, and later hooks plugin phases. Hooks run sequentially in registration order, and later hooks
@ -114,12 +112,10 @@ visible from the plugin file, for example:
```sh ```sh
cd .opencode cd .opencode
bun add @opencode-ai/plugin@1.17.15 effect@4.0.0-beta.83 bun add @opencode-ai/plugin
``` ```
`effect` is required for Effect plugins and for the `Schema` values used by Match the plugin package version to the OpenCode release you target.
typed tools. A Promise plugin that does not define tools may only need
`@opencode-ai/plugin`. Match these versions to the OpenCode release you target.
Configuration and discovered plugin files under watched config directories are Configuration and discovered plugin files under watched config directories are
reloaded when they change. Reloading replaces the active plugin generation and reloaded when they change. Reloading replaces the active plugin generation and
@ -128,8 +124,7 @@ package version or a local dependency when no watched file changed.
## Create a plugin ## Create a plugin
The Promise API is the simplest option. Export the result of `Plugin.define` Export the result of `Plugin.define` as the module default:
as the module default:
```ts title=".opencode/plugins/reviewer.ts" ```ts title=".opencode/plugins/reviewer.ts"
import { Plugin } from "@opencode-ai/plugin/v2" import { Plugin } from "@opencode-ai/plugin/v2"
@ -156,38 +151,10 @@ export default Plugin.define({
long-lived behavior during setup; do not wait there on an infinite event long-lived behavior during setup; do not wait there on an infinite event
stream. stream.
### Effect plugins
Use the Effect entrypoint when the implementation benefits from Effect
composition, fibers, or scoped resources:
```ts title=".opencode/plugins/reviewer-effect.ts"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "acme.reviewer-effect",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = "Reviews code for regressions"
agent.mode = "subagent"
})
})
}),
})
```
The plugin effect is scoped. Finalizers, scoped fibers, and registrations are
released when the plugin reloads or unloads. OpenCode deliberately isolates the
effect from its private Core services; use only the public `ctx` capabilities.
## Context ## Context
Promise methods return Promises; the equivalent Effect methods return Context methods return Promises. Read and action methods use the same inputs
`Effect`. Read and action methods use the same inputs and location-aware and location-aware responses as the V2 client APIs.
responses as the V2 client APIs.
| Capability | Available operations | | Capability | Available operations |
| --- | --- | | --- | --- |
@ -271,12 +238,11 @@ handle expected errors inside the callback.
## Add a tool ## Add a tool
Pass a plain object with Effect schemas to `tools.add`. Promise tools use async Pass a tool declaration to `tools.add`. Define its input with JSON Schema and
executors: use an async executor:
```ts title=".opencode/plugins/greeting.ts" ```js title=".opencode/plugins/greeting.js"
import { Plugin } from "@opencode-ai/plugin/v2" import { Plugin } from "@opencode-ai/plugin/v2"
import { Schema } from "effect"
export default Plugin.define({ export default Plugin.define({
id: "acme.greeting", id: "acme.greeting",
@ -285,9 +251,21 @@ export default Plugin.define({
tools.add({ tools.add({
name: "greeting", name: "greeting",
description: "Create a greeting", description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }), jsonSchema: {
output: Schema.String, type: "object",
execute: async ({ name }) => `Hello, ${name}!`, properties: {
name: { type: "string" },
},
required: ["name"],
additionalProperties: false,
},
execute: async ({ name }) => {
const text = `Hello, ${name}!`
return {
structured: { greeting: text },
content: [{ type: "text", text }],
}
},
}) })
}) })
}, },
@ -304,13 +282,11 @@ configure registration with `{ group, deferred }`:
tool instead of exposing it directly. tool instead of exposing it directly.
The executor receives a second context argument containing `sessionID`, The executor receives a second context argument containing `sessionID`,
`agent`, `assistantMessageID`, and `toolCallID`. Effect plugins import their `agent`, `assistantMessageID`, and `toolCallID`.
tool contracts from `@opencode-ai/plugin/v2/effect/tool` and return an `Effect`
from `execute`.
## Types ## Types
`Plugin.define` infers the context and callbacks. The Promise root also `Plugin.define` infers the context and callbacks. The package also
re-exports the canonical `Agent`, `Command`, `Connection`, `Credential`, re-exports the canonical `Agent`, `Command`, `Connection`, `Credential`,
`Integration`, `Model`, `Provider`, `Reference`, and `Skill` schema namespaces. `Integration`, `Model`, `Provider`, `Reference`, and `Skill` schema namespaces.
Import narrower API types from their public subpaths when needed: Import narrower API types from their public subpaths when needed:
@ -322,11 +298,8 @@ import type { AgentDraft } from "@opencode-ai/plugin/v2/agent"
import type { ToolExecuteBeforeEvent } from "@opencode-ai/plugin/v2/tool" import type { ToolExecuteBeforeEvent } from "@opencode-ai/plugin/v2/tool"
``` ```
Effect equivalents live below `@opencode-ai/plugin/v2/effect`, such as Avoid importing types or runtime values from `@opencode-ai/core` or
`@opencode-ai/plugin/v2/effect/plugin` and `@opencode-ai/server`; those are private host implementation details.
`@opencode-ai/plugin/v2/effect/tool`. Avoid importing types or runtime values
from `@opencode-ai/core` or `@opencode-ai/server`; those are private host
implementation details.
## Publish a package ## Publish a package
@ -340,8 +313,7 @@ manifest is:
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": "./src/index.ts",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.17.15", "@opencode-ai/plugin": "1.17.18"
"effect": "4.0.0-beta.83"
} }
} }
``` ```
@ -363,3 +335,40 @@ If a plugin is absent, check the server log described in
[Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are [Troubleshooting](/troubleshooting#read-logs). Invalid modules and setup failures are
logged; one failing package does not prevent unrelated valid packages from logged; one failing package does not prevent unrelated valid packages from
being resolved. being resolved.
## Effect
Plugins built with Effect use the `@opencode-ai/plugin/v2/effect` entrypoint.
Install `effect` alongside the plugin package and export an `effect` function
instead of `setup`:
```sh
bun add @opencode-ai/plugin effect
```
```ts title=".opencode/plugins/reviewer-effect.ts"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "acme.reviewer-effect",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.agent.transform((agents) => {
agents.update("reviewer", (agent) => {
agent.description = "Reviews code for regressions"
agent.mode = "subagent"
})
})
}),
})
```
Context operations return Effects. The plugin effect is scoped, so finalizers,
fibers, and registrations are released when the plugin reloads or unloads.
OpenCode does not expose its private Core services to the plugin; use the
capabilities on `ctx`.
Typed tools can use `Schema` from `effect` and the contracts exported from
`@opencode-ai/plugin/v2/effect/tool`. Their executors return an Effect and may
fail with the typed tool failure channel.

View file

@ -4,7 +4,7 @@ description: "Embed an OpenCode host in an Effect application."
--- ---
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to `@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
host OpenCode in-process. Unlike the [network client](/client), it assembles the host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
OpenCode server and routes API calls through its HTTP router in memory. It opens OpenCode server and routes API calls through its HTTP router in memory. It opens
no HTTP listener and adds no network hop between the client and server. no HTTP listener and adds no network hop between the client and server.
@ -72,4 +72,4 @@ const active = await Effect.runPromise(
Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins Call `opencode.plugin(...)` to register an embedded V2 plugin. Embedded plugins
use the same discovery and location-scoped activation path as configured use the same discovery and location-scoped activation path as configured
plugins. The SDK also exports `Tool` for plugin-defined tools. See the plugins. The SDK also exports `Tool` for plugin-defined tools. See the
[Plugins guide](/plugins) for the plugin shape and available hooks. [Plugins guide](/build/plugins) for the plugin shape and available hooks.

View file

@ -421,7 +421,7 @@ accepts options.
} }
``` ```
See the [plugins guide](/plugins) for plugin development and configuration. See the [plugins guide](/build/plugins) for plugin development and configuration.
### Providers ### Providers

View file

@ -49,7 +49,7 @@
"groups": [ "groups": [
{ {
"group": "Build with OpenCode", "group": "Build with OpenCode",
"pages": ["plugins", "client", "sdk/index"] "pages": ["build/plugins", "build/client", "build/sdk"]
} }
] ]
}, },

View file

@ -501,7 +501,7 @@ plugin API is still being finalized during beta, and detailed plugin migration g
ready. ready.
Once the V2 plugin API is finalized, OpenCode should be able to migrate the majority of V1 plugins while keeping related Once the V2 plugin API is finalized, OpenCode should be able to migrate the majority of V1 plugins while keeping related
local modules and dependencies together. See the current beta [Plugins guide](/plugins). local modules and dependencies together. See the current beta [Plugins guide](/build/plugins).
## Server API and clients ## Server API and clients