// Effect server plugin — template // // Docs: https://opencode.ai/v2/docs/build/plugins/effect/ // // Copy this file, keep only what you need, delete the rest. // Transcribed from the doc page. Note the differences from the Promise flavor: // entry is `effect`, not `setup`; reads are yieldable; cleanup is via Scope. // --------------------------------------------------------------------------- // PREREQUISITE: the package is not bundled with the CLI. Install it at the // version matching your OpenCode release, then restart the service: // // cd ~/.config/opencode // npm install @opencode/plugin@$(opencode --version | sed 's/^v//') // opencode service restart // // A stale `@opencode-ai/plugin` (the V1-era name) in node_modules is a leftover // dependency, not what the host provides — ignore its export list. // --------------------------------------------------------------------------- // The Effect page imports these from the effect entrypoint; the Transforms // example shows `import { Model, Plugin, Provider } from "@opencode/plugin/effect"`. import { Model, Plugin, Provider } from "@opencode/plugin/effect" import { Effect, Schedule, Schema, Stream } from "effect" export default Plugin.define({ id: "example", // `effect` runs at load. The scope closes on reload or unload, which releases // registrations, scoped fibers, and finalizers together. effect: (ctx) => Effect.gen(function* () { // ---------------------------------------------------------------- storage yield* ctx.storage.set("loaded", true) // ---------------------------------------------------------------- options // Untyped. Narrow before use. const strict = ctx.options.strict === true // -------------------------------------------------------------- logging yield* Effect.logInfo("loaded", { version: ctx.app.version }) // ---------------------------------------------------------------- reads // Effect reads usually return a decoded envelope: result lands on .data const models = yield* ctx.model.list().pipe(Effect.orDie) yield* Effect.logInfo("models", { count: models.data.length }) const active = yield* ctx.plugin .list() .pipe( Effect.orDie, // NOTE: the doc page writes `p.status === "active"`, but the shipped // Plugin.Info nests it as `state.status`. Trust the type. Effect.map((r) => r.data.filter((p) => p.state.status === "active")), ) yield* Effect.logInfo("active plugins", { count: active.length }) // ------------------------------------------------------------- transform // The callback is STILL SYNCHRONOUS, even here. No Effect, no Promise. yield* ctx.model.transform((editor) => { editor .list() .filter((model) => model.cost.some((tier) => tier.output > 20)) .forEach((model) => { editor.remove(model.providerID, model.id) }) }) // ----------------------------------------------------------------- tool // Effect plugins use Effect Schema instead of raw JSON Schema, so input // and output are typed. yield* ctx.tool.transform((editor) => { editor.namespace({ name: "example", description: "Example tools" }) editor.add({ name: "greeting", description: "Create a greeting", input: Schema.Struct({ name: Schema.String }), output: Schema.Struct({ greeting: Schema.String }), options: { namespace: "example", codemode: true }, execute: ({ name }, context) => Effect.gen(function* () { yield* context.progress({ status: "greeting" }) // The page's executor example returns { output } for typed output. return { output: { greeting: `Hello ${name}!` } } }), }) }) // ---------------------------------------------------------------- hooks // Callbacks return Effects. yield* ctx.session.hook("context", (event) => Effect.sync(() => { event.system.push({ type: "text", text: "Keep the review focused on correctness." }) delete event.tools.write }), ) // A hook may fail with Tool.Error to block a tool call. // yield* ctx.tool.hook("execute.before", (event) => // event.tool === "write" // ? Effect.fail(new Tool.Error({ message: "Writes are disabled" })) // : Effect.logDebug("tool input", { tool: event.tool, input: event.input }), // ) // ------------------------------------------------------------- provider // Optional: contribute providers and refresh them from a source. The // page's example loader returns entries shaped // `{ info: Provider.Info, models: readonly Model.Info[] }`. if (strict) { const source = { providers: yield* loadFromSource() } yield* ctx.provider.transform((editor) => { source.providers.forEach((provider) => editor.add(provider)) }) // Captured inputs are not watched: fork a scoped fiber to refresh. yield* Effect.repeat( Effect.gen(function* () { source.providers = yield* loadFromSource() yield* ctx.provider.reload() }), { schedule: Schedule.spaced("1 minute") }, ).pipe(Effect.forkScoped) } // ---------------------------------------------------------------- events // subscribe() is a Stream. forkScoped handles interruption for you. yield* ctx.event.subscribe().pipe( Stream.filter((item) => item.type === "config.updated"), Stream.runForEach(() => Effect.logInfo("configuration changed")), Effect.forkScoped, ) // No explicit cleanup needed: the scope closes it all. }), }) // Stand-in for your own loader, used by the provider block above. The doc // page's example loader returns `{ info: Provider.Info, models: readonly // Model.Info[] }` entries. declare function loadFromSource(): Effect.Effect< readonly { info: Provider.Info; models: readonly Model.Info[] }[] >