fix(plugin): select plugins by id

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

View file

@ -72,23 +72,6 @@ type Operation =
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 {
if (typeof input !== "string") {
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")
.flatMap((entry) =>
(entry.info.plugins ?? []).map(parse).map((operation) => {
if (operation.type === "remove") return 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 }
return { ...operation, target }
}),
)
// 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[],
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>()
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin>()
const plugins = () => [...definitions, ...packages.values()]
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
})
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
continue
}
const matched = plugins.filter((plugin) => matches(operation.target, plugin.id))
const selectsDefinitions =
const matched = plugins().filter((plugin) => matches(operation.target, plugin.id))
const selectsPlugins =
matched.length > 0 ||
operation.target === "*" ||
operation.target.endsWith(".*") ||
operation.target.startsWith("opencode.")
if (selectsDefinitions) {
if (selectsPlugins) {
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 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) =>
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,
...(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]
}
return [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
]
})
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 undefined
// Bun currently ignores query parameters when caching file:// imports.
const source =
candidate.mtime === undefined
? entrypoint
: `${candidate.specifier.replaceAll("\\", "/")}?mtime=${candidate.mtime}`
yield* Effect.log({ msg: "loading plugin", id: candidate.specifier, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
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)))
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target)).entrypoint
if (!entrypoint) return
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
? entrypoint
: `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => import(source))
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: operation.options }),
} satisfies Plugin
})
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", () =>
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
directories. Each module must have one default export containing a unique
plugin `id` and either a Promise `setup` function or an Effect `effect`
function.
plugin `id` and a `setup` function.
### Configuration
@ -75,26 +74,25 @@ relative config entry.
### Enable and disable
A string beginning with `-` removes a previously selected target. `*` matches
everything, and a suffix of `.*` matches an ID or target prefix. Directives are
A string beginning with `-` disables plugins by their exported `id`. `*`
matches every ID, and a suffix of `.*` matches an ID prefix. Directives are
applied in order:
```jsonc title="opencode.jsonc"
{
"plugins": [
"./plugins/reviewer.ts",
"-acme.reviewer",
"-opencode.provider.*",
"opencode.provider.openai",
"-./plugins/old.ts",
"-*",
"./plugins/only-this-one.ts"
"opencode.provider.openai"
]
}
```
Use the same package specifier or resolved local target to remove an external
plugin. Built-in and embedded plugins can be selected by their plugin ID.
Explicit config directives run after local auto-discovery, so they can disable
discovered plugins.
Package specifiers and local paths locate plugin modules; they are not disable
selectors. Use the `id` from the plugin's default export to disable it. A later
ID entry re-enables a loaded or built-in plugin. Explicit config directives run
after local auto-discovery, so they can disable discovered plugins by ID.
User plugins are activated in configured order between OpenCode's internal
plugin phases. Hooks run sequentially in registration order, and later hooks
@ -114,12 +112,10 @@ visible from the plugin file, for example:
```sh
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
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.
Match the plugin package version to the OpenCode release you target.
Configuration and discovered plugin files under watched config directories are
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
The Promise API is the simplest option. Export the result of `Plugin.define`
as the module default:
Export the result of `Plugin.define` as the module default:
```ts title=".opencode/plugins/reviewer.ts"
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
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
Promise methods return Promises; the equivalent Effect methods return
`Effect`. Read and action methods use the same inputs and location-aware
responses as the V2 client APIs.
Context methods return Promises. Read and action methods use the same inputs
and location-aware responses as the V2 client APIs.
| Capability | Available operations |
| --- | --- |
@ -271,12 +238,11 @@ handle expected errors inside the callback.
## Add a tool
Pass a plain object with Effect schemas to `tools.add`. Promise tools use async
executors:
Pass a tool declaration to `tools.add`. Define its input with JSON Schema and
use an async executor:
```ts title=".opencode/plugins/greeting.ts"
```js title=".opencode/plugins/greeting.js"
import { Plugin } from "@opencode-ai/plugin/v2"
import { Schema } from "effect"
export default Plugin.define({
id: "acme.greeting",
@ -285,9 +251,21 @@ export default Plugin.define({
tools.add({
name: "greeting",
description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }) => `Hello, ${name}!`,
jsonSchema: {
type: "object",
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.
The executor receives a second context argument containing `sessionID`,
`agent`, `assistantMessageID`, and `toolCallID`. Effect plugins import their
tool contracts from `@opencode-ai/plugin/v2/effect/tool` and return an `Effect`
from `execute`.
`agent`, `assistantMessageID`, and `toolCallID`.
## 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`,
`Integration`, `Model`, `Provider`, `Reference`, and `Skill` schema namespaces.
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"
```
Effect equivalents live below `@opencode-ai/plugin/v2/effect`, such as
`@opencode-ai/plugin/v2/effect/plugin` and
`@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.
Avoid importing types or runtime values from `@opencode-ai/core` or
`@opencode-ai/server`; those are private host implementation details.
## Publish a package
@ -340,8 +313,7 @@ manifest is:
"type": "module",
"exports": "./src/index.ts",
"dependencies": {
"@opencode-ai/plugin": "1.17.15",
"effect": "4.0.0-beta.83"
"@opencode-ai/plugin": "1.17.18"
}
}
```
@ -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
logged; one failing package does not prevent unrelated valid packages from
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
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
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
use the same discovery and location-scoped activation path as configured
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

View file

@ -49,7 +49,7 @@
"groups": [
{
"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.
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