diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 1bc240bf78f..9b056c994e3 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -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 - readonly mtime?: number - } - -type ConfiguredPackage = { - readonly operation: Extract - enabled: boolean -} - function parse(input: ConfigPlugin.Plugin): Operation { if (typeof input !== "string") { return { type: "add", target: input.package, options: input.options ?? {} } @@ -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() + const definitions = [...pre, ...post] + const enabled = new Set(definitions.map((plugin) => plugin.id)) + const packages = new Map() + 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) { + 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) { diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 75faeef99d7..4c83d3bb4b1 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -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( { diff --git a/packages/docs/client.mdx b/packages/docs/build/client.mdx similarity index 100% rename from packages/docs/client.mdx rename to packages/docs/build/client.mdx diff --git a/packages/docs/plugins.mdx b/packages/docs/build/plugins.mdx similarity index 81% rename from packages/docs/plugins.mdx rename to packages/docs/build/plugins.mdx index 880b65cbac1..f6c1bccf1bd 100644 --- a/packages/docs/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -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. diff --git a/packages/docs/sdk/index.mdx b/packages/docs/build/sdk.mdx similarity index 92% rename from packages/docs/sdk/index.mdx rename to packages/docs/build/sdk.mdx index a1611e842cb..b72aac9c097 100644 --- a/packages/docs/sdk/index.mdx +++ b/packages/docs/build/sdk.mdx @@ -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. diff --git a/packages/docs/config.mdx b/packages/docs/config.mdx index 2350313dec0..f0ae18f7c79 100644 --- a/packages/docs/config.mdx +++ b/packages/docs/config.mdx @@ -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 diff --git a/packages/docs/docs.json b/packages/docs/docs.json index fa9a97c30c0..7efec8459ec 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -49,7 +49,7 @@ "groups": [ { "group": "Build with OpenCode", - "pages": ["plugins", "client", "sdk/index"] + "pages": ["build/plugins", "build/client", "build/sdk"] } ] }, diff --git a/packages/docs/migrate-v1.mdx b/packages/docs/migrate-v1.mdx index 49042d7664d..44b59cd08c2 100644 --- a/packages/docs/migrate-v1.mdx +++ b/packages/docs/migrate-v1.mdx @@ -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