Compare commits

...
Author SHA1 Message Date
Kit Langton 2bc9f6ea09 refactor(plugin): isolate effect authoring runtime 2026-08-24 13:44:14 -04:00
103 changed files with 857 additions and 215 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Compile Effect-authored plugins to the runtime-neutral Promise and Standard Schema plugin contract.
+3 -1
View File
@@ -579,7 +579,6 @@
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -590,6 +589,7 @@
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
},
@@ -597,12 +597,14 @@
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"effect": "catalog:",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opencode-ai/theme",
"@opentui/core",
"@opentui/solid",
"effect",
"solid-js",
],
},
-1
View File
@@ -1,5 +1,4 @@
import "./plugin-runtime.promise"
import "./plugin-runtime.effect"
process.stdout.on("error", (error) => {
if ("code" in error && error.code === "EPIPE") return
@@ -1,28 +0,0 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
Tool: { Error: Tool.Error },
}
-16
View File
@@ -132,20 +132,10 @@ export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `export {}`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Error = sdk.Tool.Error
`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@@ -160,17 +150,11 @@ const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin": "opencode:plugin-v2",
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
})}
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-promise-plugin": promisePluginModule,
"opencode:plugin-promise-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
})}
__cjs_mod__.registerHooks({
resolve(__ocSpecifier, __ocContext, __ocNextResolve) {
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigAgentPlugin from "./agent.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import path from "path"
@@ -1,6 +1,6 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigImagePlugin from "./image.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Image } from "../../image.js"
@@ -1,6 +1,6 @@
export * as ConfigInstructionPlugin from "./instruction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { dirname, join } from "path"
@@ -1,6 +1,6 @@
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigMCPPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { Config } from "../../config.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigProviderPlugin from "./provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigReferencePlugin from "./reference.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document } from "@opencode-ai/schema/config"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ShellSelect } from "../../shell/select.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigSkillPlugin from "./skill.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
@@ -1,6 +1,6 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigWebSearchPlugin from "./websearch.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ConfigEntryObserver } from "./entry-observer.js"
+1 -1
View File
@@ -2,7 +2,7 @@ export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/core/plugin/definition"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as AgentPlugin from "./agent.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as CommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Location } from "../location.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
+15
View File
@@ -0,0 +1,15 @@
import type { Context as PluginContext, Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import type { Scope } from "effect"
export type Context = PluginContext
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
export function define<R>(plugin: PluginDefinition<R>) {
return plugin
}
export namespace Plugin {
export type Context = PluginContext
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
export const define = <R>(plugin: PluginDefinition<R>) => plugin
}
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Plugin } from "@opencode-ai/core/plugin/definition"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginInternal from "./internal.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin } from "@opencode-ai/core/plugin/definition"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
@@ -1,6 +1,6 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Integration } from "@opencode-ai/schema/integration"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as PlanPlugin from "./plan.js"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
type MantleSDK = {
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const AnthropicPlugin = define({
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const CerebrasPlugin = define({
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import { Effect } from "effect"
@@ -8,7 +8,7 @@ import { App } from "../../app.js"
import { Agent } from "../../agent.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
+1 -1
View File
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const GitLabPlugin = define({
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
function resolveProject(options: Record<string, any>) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const KiloPlugin = define({
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const NvidiaPlugin = define({
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai.compatible",
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
@@ -1,7 +1,7 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
export const OpenRouterPlugin = define({
id: "opencode.provider.openrouter",
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Npm } from "@opencode-ai/util/npm"
import { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const VercelPlugin = define({
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Clock, Effect, Option, Schema } from "effect"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const ZenmuxPlugin = define({
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SdkPlugins from "./sdk.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin } from "@opencode-ai/core/plugin/definition"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
+1 -1
View File
@@ -2,7 +2,7 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { define, type Context } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
+19 -19
View File
@@ -1,9 +1,8 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Predicate, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -18,22 +17,13 @@ import { importModule } from "@opencode-ai/util/runtime-import"
import { Service } from "./supervisor-service.js"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
default: Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
@@ -119,8 +109,18 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
: `${entrypoint}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
if (
Predicate.isObject(mod) &&
Predicate.isObject(mod.default) &&
"effect" in mod.default &&
!("setup" in mod.default)
) {
return yield* Effect.fail(
new Error("Effect plugins must be exported through Plugin.define from @opencode-ai/plugin/effect"),
)
}
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const plugin = PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SystemPromptPlugin from "./system-prompt.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as VariantPlugin from "./variant.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WarmingPlugin from "./warming.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Clock, Duration, Effect, Scope } from "effect"
import { Config } from "../config.js"
import { SessionSchema } from "../session/schema.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebSearchExa from "./exa.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { WebSearchMcp } from "./mcp.js"
@@ -1,6 +1,6 @@
export * as WebSearchFirecrawl from "./firecrawl.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app.js"
@@ -1,6 +1,6 @@
export * as WebSearchParallel from "./parallel.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebSearchTavily from "./tavily.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Duration, Effect, Schema, Scope } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { App } from "../../app.js"
+1 -1
View File
@@ -6,7 +6,7 @@
*/
export * as EditTool from "./edit.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as GlobTool from "./glob.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment/index.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as GrepTool from "./grep.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PatchTool from "./patch.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Effect, Result, Schema } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as QuestionTool from "./question.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Form } from "../../form.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ReadTool from "./read.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { basename, dirname, join } from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ShellTool from "./shell.js"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SkillTool from "./skill.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { Effect, Schema, Scope } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebFetchTool from "./webfetch.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { ToolFailure } from "@opencode-ai/ai"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WebSearchTool from "./websearch.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
+1 -1
View File
@@ -6,7 +6,7 @@
*/
export * as WriteTool from "./write.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as WellKnownPlugin from "./plugin.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { WellKnown } from "../wellknown.js"
+4 -1
View File
@@ -2,7 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/core/plugin/definition"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
@@ -146,6 +146,7 @@ describe("PluginSupervisor config", () => {
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/raw-effect-plugin.ts"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
options: { description: "Loaded after invalid plugins" },
@@ -162,12 +163,14 @@ describe("PluginSupervisor config", () => {
expect(output).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/raw-effect-plugin.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/raw-effect-plugin.ts") },
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
+1 -1
View File
@@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import type { SessionError } from "@opencode-ai/schema/session-error"
import { Tool } from "@opencode-ai/core/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/core/plugin/definition"
import { Effect, type Scope } from "effect"
import { host } from "../plugin/host"
+1 -1
View File
@@ -18,7 +18,7 @@ import {
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/core/plugin/definition"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Plugin as EffectPlugin } from "@opencode-ai/core/plugin/definition"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
@@ -0,0 +1,6 @@
import { Effect } from "effect"
export default {
id: "raw-effect-plugin",
effect: () => Effect.void,
}
+76 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { DateTime, Effect, Fiber, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
@@ -25,6 +25,7 @@ import { PluginTestLayer } from "./fixture"
import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
const EffectPlugin = await import("@opencode-ai/plugin/effect")
describe("fromPromise", () => {
it.effect("adapts plugin storage methods", () =>
@@ -469,11 +470,11 @@ describe("fromPromise", () => {
const events: string[] = []
const promisePlugin = define({
id: "promise-cleanup",
setup: async () => {
setup: async (context) => {
events.push("setup")
return async () => {
await Promise.resolve()
events.push("cleanup")
events.push(context.signal.aborted ? "cleanup-aborted" : "cleanup-active")
}
},
})
@@ -485,7 +486,7 @@ describe("fromPromise", () => {
}),
)
expect(events).toEqual(["setup", "cleanup"])
expect(events).toEqual(["setup", "cleanup-aborted"])
}),
)
@@ -534,6 +535,77 @@ describe("fromPromise", () => {
}),
)
it.effect("runs Effect-authored tool codecs through the Promise boundary", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const interrupted: string[] = []
const effectPlugin = EffectPlugin.Plugin.define({
id: "effect-tool",
effect: (context) =>
context.tool.transform((tools) =>
tools.add({
name: "increment",
options: { codemode: false },
description: "Increment",
input: Schema.FiniteFromString,
output: Schema.FiniteFromString,
execute: (input) => {
if (input === 42) {
return Effect.fail(
new Tool.Error({ message: "cannot increment 42", metadata: { reason: "reserved" } }),
)
}
if (input === 43) {
return Effect.sync(() => interrupted.push("started")).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Effect.sync(() => interrupted.push("finalized"))),
)
}
return Effect.succeed({ output: input + 1 })
},
}),
),
})
yield* PluginPromise.fromPromise(effectPlugin).effect(host)
expect(
yield* (yield* registry.snapshot()).execute({
sessionID: Session.ID.make("ses_effect_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_effect_tool"),
progress: () => Effect.void,
call: { type: "tool-call", id: "call_effect_tool", name: "increment", input: "41" },
}),
).toMatchObject({ output: "42" })
expect(
yield* Effect.flip(
(yield* registry.snapshot()).execute({
sessionID: Session.ID.make("ses_effect_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_effect_tool_error"),
progress: () => Effect.void,
call: { type: "tool-call", id: "call_effect_tool_error", name: "increment", input: "42" },
}),
),
).toMatchObject({ _tag: "Tool.Error", message: "cannot increment 42", metadata: { reason: "reserved" } })
const execution = yield* (yield* registry.snapshot())
.execute({
sessionID: Session.ID.make("ses_effect_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_effect_tool_interrupt"),
progress: () => Effect.void,
call: { type: "tool-call", id: "call_effect_tool_interrupt", name: "increment", input: "43" },
})
.pipe(Effect.forkScoped({ startImmediately: true }))
while (interrupted.length === 0) yield* Effect.yieldNow
yield* Fiber.interrupt(execution)
expect(interrupted).toEqual(["started", "finalized"])
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+5 -1
View File
@@ -25,13 +25,13 @@
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:"
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"effect": "catalog:",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
@@ -44,6 +44,9 @@
"@opentui/solid": {
"optional": true
},
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
@@ -55,6 +58,7 @@
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
+11 -2
View File
@@ -1,11 +1,15 @@
# OpenCode V2 Promise Plugin API
The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of `@opencode-ai/plugin/effect`. It grants plugins the same two in-process capabilities:
The Promise plugin API at `@opencode-ai/plugin` is OpenCode's runtime-neutral
plugin contract. The Effect entrypoint compiles its authoring API to this same
contract before the plugin reaches OpenCode. It grants plugins two in-process
capabilities:
- `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain.
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
Hook callbacks, hook registration, `reload`, and `Registration.dispose` use
Promises. No Effect runtime values are part of this boundary.
## Defining A Plugin
@@ -57,6 +61,11 @@ await ctx.agent.transform((agent) => {
})
```
`ctx.signal` is aborted when setup is interrupted or the plugin unloads. Runtime
hook callbacks receive the invocation signal as their second argument, and tool
executors receive it on their context, so asynchronous work can stop with the
host operation.
Available transform hooks are namespaced by domain:
```ts
+3 -1
View File
@@ -2,7 +2,9 @@
## Status
This document describes the agreed target design for the V2 plugin system. It is an implementation plan, not documentation for the current API.
This is a historical implementation plan. It was superseded by the
Promise/Standard Schema host boundary documented in `../README.md` and
`README.md`; do not treat the direction or API names below as current design.
## Goals
+10
View File
@@ -23,12 +23,22 @@ export default Plugin.define({
})
```
`Plugin.define` compiles the definition to OpenCode's Promise plugin contract.
The adapter runs every Effect, schema conversion, callback, and finalizer with
the plugin's installed `effect` peer; OpenCode never executes a plugin-created
Effect with the host runtime.
Plugin setup registers hooks imperatively through each domain's `hook` method.
Configuration supplied for the plugin is available as `ctx.options`.
Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`.
Tool schemas are converted to detached Standard Schema capabilities before
registration. Inputs decode from model JSON into the executor type, while
outputs encode from the executor type back to JSON. The host does not inspect
or transfer Effect schema ASTs.
## Transform Hooks
Transform hooks contribute to stateful domains:
+328
View File
@@ -0,0 +1,328 @@
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Exit, Schema, Scope, Stream } from "effect"
import { HttpApiEndpoint } from "effect/unstable/httpapi"
import type { Context as PromiseContext, Plugin as PromisePlugin } from "../promise/plugin.js"
import type { Info as PromiseTool } from "../promise/tool.js"
import type { Context, Plugin } from "./plugin.js"
import { toStandardSchema } from "./tool-schema.js"
import { endpointSchemas } from "./endpoint-schema.js"
export type { Context, Plugin } from "./plugin.js"
type PromiseRegistration = { readonly dispose: () => Promise<void> }
type EffectRegistration = { readonly dispose: Effect.Effect<void> }
type PromiseEvent = ReturnType<PromiseContext["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
interface CompiledEndpoint {
readonly encode: ReadonlyArray<(input: unknown) => Effect.Effect<unknown, Schema.SchemaError>>
readonly decode: (output: unknown) => Effect.Effect<unknown, Schema.SchemaError>
readonly noContent: boolean
}
const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
export function define(plugin: Plugin<Scope.Scope>): PromisePlugin {
return {
id: plugin.id,
tui: plugin.tui,
setup: (host) =>
Effect.runPromise(
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
Promise.all([import("@opencode-ai/protocol/client"), import("@opencode-ai/protocol/groups/event")]),
)
const scope = yield* Scope.make()
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const run = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>, signal?: AbortSignal) =>
Effect.runPromise(Scope.provide(scope)(effect), { signal })
const runFailure = <A, E>(effect: Effect.Effect<A, E, Scope.Scope>, signal?: AbortSignal) =>
run(
Effect.match(effect, {
onFailure: (error) => ({ _tag: "Failure" as const, error }),
onSuccess: (value) => ({ _tag: "Success" as const, value }),
}),
signal,
).then((result) => (result._tag === "Success" ? result.value : Promise.reject(result.error)))
const register = (acquire: () => Promise<PromiseRegistration>) =>
Effect.acquireRelease(Effect.promise(acquire), (registration) =>
Effect.promise(() => registration.dispose()),
).pipe(
Effect.map(
(registration): EffectRegistration => ({
dispose: Effect.promise(() => registration.dispose()),
}),
),
)
const adaptApiMethod = <EffectMethod>(
endpoint: HttpApiEndpoint.Top,
method: (input: never, options?: never) => Promise<unknown>,
) => {
const compiled = compileEndpoint(endpoint)
return ((input?: unknown) =>
Effect.gen(function* () {
const encoded = yield* Effect.forEach(compiled.encode, (encode) => encode(input ?? {}))
const output = yield* Effect.tryPromise({
try: (signal) => method(Object.assign({}, ...encoded) as never, { signal } as never),
catch: (cause) => cause,
})
if (compiled.noContent) return undefined
return yield* compiled.decode(output)
})) as EffectMethod
}
const transform = <EffectTransform>(domain: {
transform: (callback: never) => Promise<PromiseRegistration>
}) => ((callback: never) => register(() => domain.transform(callback))) as EffectTransform
const context: Context = {
app: host.app,
options: host.options,
agent: {
get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
transform: transform<Context["agent"]["transform"]>(host.agent),
reload: () => Effect.promise(() => host.agent.reload()),
},
aisdk: {
hook: (name, callback, options) =>
register(() =>
host.aisdk.hook(name, (event, invocation) => run(callback(event), invocation.signal), options),
),
},
catalog: {
provider: {
list: adaptApiMethod(ProviderEndpoints["provider.list"], host.catalog.provider.list),
get: adaptApiMethod(ProviderEndpoints["provider.get"], host.catalog.provider.get),
},
model: {
list: adaptApiMethod(ModelEndpoints["model.list"], host.catalog.model.list),
default: adaptApiMethod(ModelEndpoints["model.default"], host.catalog.model.default),
},
transform: transform<Context["catalog"]["transform"]>(host.catalog),
reload: () => Effect.promise(() => host.catalog.reload()),
},
command: {
list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list),
transform: transform<Context["command"]["transform"]>(host.command),
reload: () => Effect.promise(() => host.command.reload()),
},
event: {
subscribe: () =>
Stream.fromAsyncIterable(host.event.subscribe(), (cause) => cause).pipe(
Stream.mapEffect((event) => Schema.decodeUnknownEffect(OpenCodeEvent)(event as PromiseEvent)),
),
},
integration: {
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
connect: {
key: adaptApiMethod(IntegrationEndpoints["integration.connect.key"], host.integration.connect.key),
},
oauth: {
connect: adaptApiMethod(
IntegrationEndpoints["integration.oauth.connect"],
host.integration.oauth.connect,
),
status: adaptApiMethod(IntegrationEndpoints["integration.oauth.status"], host.integration.oauth.status),
complete: adaptApiMethod(
IntegrationEndpoints["integration.oauth.complete"],
host.integration.oauth.complete,
),
cancel: adaptApiMethod(IntegrationEndpoints["integration.oauth.cancel"], host.integration.oauth.cancel),
},
command: {
connect: adaptApiMethod(
IntegrationEndpoints["integration.command.connect"],
host.integration.command.connect,
),
status: adaptApiMethod(
IntegrationEndpoints["integration.command.status"],
host.integration.command.status,
),
cancel: adaptApiMethod(
IntegrationEndpoints["integration.command.cancel"],
host.integration.command.cancel,
),
},
transform: (callback) =>
register(() =>
host.integration.transform((draft) =>
callback({
list: draft.list,
get: draft.get,
update: draft.update,
remove: draft.remove,
method: {
list: draft.method.list,
update: (input) => {
if (!("authorize" in input)) return draft.method.update(input)
const refresh = input.refresh
draft.method.update({
...input,
authorize: (answer) =>
run(input.authorize(answer)).then((authorization) =>
authorization.mode === "auto"
? {
...authorization,
callback: run(authorization.callback),
}
: {
...authorization,
callback: (code) => run(authorization.callback(code)),
},
),
refresh: refresh === undefined ? undefined : (credential) => run(refresh(credential)),
})
},
remove: draft.method.remove,
},
}),
),
),
reload: () => Effect.promise(() => host.integration.reload()),
connection: {
active: (id) => Effect.promise(() => host.integration.connection.active(id)),
resolve: (connection) =>
Effect.tryPromise({
try: () => host.integration.connection.resolve(connection),
catch: (cause) => cause,
}),
},
},
mcp: {
list: adaptApiMethod(McpEndpoints["mcp.list"], host.mcp.list),
add: adaptApiMethod(McpEndpoints["mcp.add"], host.mcp.add),
remove: adaptApiMethod(McpEndpoints["mcp.remove"], host.mcp.remove),
connect: adaptApiMethod(McpEndpoints["mcp.connect"], host.mcp.connect),
disconnect: adaptApiMethod(McpEndpoints["mcp.disconnect"], host.mcp.disconnect),
transform: transform<Context["mcp"]["transform"]>(host.mcp),
reload: () => Effect.promise(() => host.mcp.reload()),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
},
reference: {
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
transform: transform<Context["reference"]["transform"]>(host.reference),
reload: () => Effect.promise(() => host.reference.reload()),
},
skill: {
list: adaptApiMethod(SkillEndpoints["skill.list"], host.skill.list),
transform: transform<Context["skill"]["transform"]>(host.skill),
reload: () => Effect.promise(() => host.skill.reload()),
},
storage: {
get: (key) => Effect.promise(() => host.storage.get(key)),
set: (key, value) => Effect.promise(() => host.storage.set(key, value)),
remove: (key) => Effect.promise(() => host.storage.remove(key)),
scan: (options) => Effect.promise(() => host.storage.scan(options)),
},
tool: {
transform: (callback) =>
register(() =>
host.tool.transform((draft) =>
callback({
add: (tool) => draft.add(adaptTool(tool, runFailure)),
}),
),
),
hook: (name, callback) =>
register(() =>
host.tool.hook(name, (event, invocation) => runFailure(callback(event), invocation.signal)),
),
},
websearch: {
providers: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers),
query: adaptApiMethod(WebSearchEndpoints["websearch.query"], host.websearch.query),
reload: () => Effect.promise(() => host.websearch.reload()),
transform: (callback) =>
register(() =>
host.websearch.transform((draft) =>
callback({
add: (definition) =>
draft.add({
id: definition.id,
name: definition.name,
execute: (input, context) => run(definition.execute(input), context.signal),
}),
default: draft.default,
}),
),
),
},
session: {
hook: (name, callback, options) =>
register(() =>
host.session.hook(name, (event, invocation) => run(callback(event), invocation.signal), options),
),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
switchAgent: adaptApiMethod(SessionEndpoints["session.switchAgent"], host.session.switchAgent),
switchModel: adaptApiMethod(SessionEndpoints["session.switchModel"], host.session.switchModel),
prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate),
command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
},
shell: {
hook: (name, callback) =>
register(() => host.shell.hook(name, (event, invocation) => run(callback(event), invocation.signal))),
},
}
const setup = yield* Effect.exit(Scope.provide(scope)(plugin.effect(context)))
if (Exit.isFailure(setup)) {
yield* Scope.close(scope, setup)
return yield* Effect.failCause(setup.cause)
}
return () => Effect.runPromise(Scope.close(scope, Exit.void))
}),
{ signal: host.signal },
),
}
}
function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
const cached = compiledEndpoints.get(endpoint)
if (cached) return cached
const schemas = endpointSchemas(endpoint)
const compiled = {
encode: schemas.inputs.map((schema) => Schema.encodeUnknownEffect(schema)),
decode: Schema.decodeUnknownEffect(schemas.output),
noContent: schemas.noContent,
} satisfies CompiledEndpoint
compiledEndpoints.set(endpoint, compiled)
return compiled
}
function adaptTool(
tool: Tool.Info<any, any>,
runFailure: <A, E>(effect: Effect.Effect<A, E, Scope.Scope>, signal?: AbortSignal) => Promise<A>,
): PromiseTool {
const input = toStandardSchema(tool.input, "input")
const output = tool.output === undefined ? undefined : toStandardSchema(tool.output, "output")
return {
...tool,
input,
...(output === undefined ? {} : { output }),
execute: (value, context) =>
runFailure(
tool.execute(value, {
...context,
progress: (update) => Effect.promise(() => context.progress(update)),
}),
context.signal,
),
}
}
@@ -0,0 +1,36 @@
import { Schema, SchemaAST } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
type RuntimeSchema = Schema.Codec<unknown, unknown>
export function endpointSchemas(endpoint: HttpApiEndpoint.Top) {
const payload = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas)
const success = Array.from(endpoint.success)
if (payload.length > 1 || success.length > 1) {
throw new Error(`Unsupported API schema cardinality: ${endpoint.identifier}`)
}
const inputs = [
endpoint.params,
endpoint.query === undefined ? undefined : Schema.toType(endpoint.query),
endpoint.headers,
...payload,
].filter((schema): schema is Schema.Top => schema !== undefined) as Array<RuntimeSchema>
const output = (success[0] ?? HttpApiSchema.NoContent) as RuntimeSchema
const type = Schema.toType(output).ast
const data = SchemaAST.isObjects(output.ast)
? output.ast.propertySignatures.find((property) => property.name === "data")
: undefined
return {
inputs,
output:
!HttpApiSchema.isNoContent(output.ast) &&
SchemaAST.isObjects(type) &&
type.indexSignatures.length === 0 &&
type.propertySignatures.length === 1 &&
type.propertySignatures[0]?.name === "data" &&
data !== undefined
? (Schema.make<Schema.Top>(data.type) as RuntimeSchema)
: output,
noContent: HttpApiSchema.isNoContent(output.ast),
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
export * as Plugin from "./plugin.js"
export * as Plugin from "./adapter.js"
export type { StorageEntry, StorageScanOptions, StorageScanResult } from "../storage.js"
export { Agent } from "@opencode-ai/schema/agent"
+1 -3
View File
@@ -43,6 +43,4 @@ export interface Plugin<R = Scope.Scope> {
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
export function define<R = Scope.Scope>(plugin: Plugin<R>) {
return plugin
}
export { define } from "./adapter.js"
+18
View File
@@ -0,0 +1,18 @@
import type { Tool } from "@opencode-ai/schema/tool"
import { Schema, SchemaAST } from "effect"
export function toStandardSchema(schema: Tool.ValueSchema<any>, direction: "input" | "output"): Tool.ValueSchema<any> {
if (typeof schema === "object" && schema !== null && "~standard" in schema) {
return { "~standard": schema["~standard"] } as Tool.ValueSchema<any>
}
if (!Schema.isSchema(schema)) return schema
if (!(schema.ast instanceof SchemaAST.Base)) {
throw new Error(
"Effect tool schemas must use the plugin's Effect peer or be converted to Standard Schema by their authoring runtime",
)
}
const codec = schema as Schema.Codec<unknown, unknown>
const oriented = direction === "input" ? codec : Schema.flip(codec)
const standard = Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(oriented))
return { "~standard": standard["~standard"] } as Tool.ValueSchema<any>
}
+68 -51
View File
@@ -1,9 +1,10 @@
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
import { define } from "../effect/plugin.js"
import { Effect, Option, Schema, Scope, Stream } from "effect"
import { HttpApiEndpoint } from "effect/unstable/httpapi"
import type { Plugin as EffectPlugin } from "../effect/plugin.js"
import { endpointSchemas } from "../effect/endpoint-schema.js"
import type { Context, Plugin } from "./plugin.js"
import type { Info } from "./tool.js"
import type { BeforeHook, Info } from "./tool.js"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
@@ -20,52 +21,27 @@ const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
const cached = compiledEndpoints.get(endpoint)
if (cached) return cached
const payloadSchemas = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas)
const successSchemas = Array.from(endpoint.success)
if (payloadSchemas.length > 1 || successSchemas.length > 1) {
throw new Error(`Unsupported API schema cardinality: ${endpoint.identifier}`)
}
const inputs = [
endpoint.params,
endpoint.query === undefined ? undefined : Schema.toType(endpoint.query),
endpoint.headers,
...payloadSchemas,
].filter((schema): schema is Schema.Top => schema !== undefined) as Array<RuntimeSchema>
const success = (successSchemas[0] ?? HttpApiSchema.NoContent) as RuntimeSchema
const noContent = HttpApiSchema.isNoContent(success.ast)
const type = Schema.toType(success).ast
const data = SchemaAST.isObjects(success.ast)
? success.ast.propertySignatures.find((property) => property.name === "data")
: undefined
const output =
!noContent &&
SchemaAST.isObjects(type) &&
type.indexSignatures.length === 0 &&
type.propertySignatures.length === 1 &&
type.propertySignatures[0]?.name === "data" &&
data !== undefined
? (Schema.make<Schema.Top>(data.type) as RuntimeSchema)
: success
const schemas = endpointSchemas(endpoint)
const compiled = {
decode: inputs.map((schema) => Schema.decodeUnknownEffect(schema)),
encode: Schema.encodeUnknownEffect(output),
noContent,
decode: schemas.inputs.map((schema) => Schema.decodeUnknownEffect(schema)),
encode: Schema.encodeUnknownEffect(schemas.output),
noContent: schemas.noContent,
} satisfies CompiledEndpoint
compiledEndpoints.set(endpoint, compiled)
return compiled
}
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* loader (`Plugin` / `PluginSupervisor`) can run it unchanged.
* Adapts the runtime-neutral Promise contract into Core's internal Effect
* plugin representation.
*
* Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context
* preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain.
*/
export function fromPromise(plugin: Plugin) {
return define({
export function fromPromise(plugin: Plugin): EffectPlugin {
return {
id: plugin.id,
tui: plugin.tui,
effect: (host) =>
@@ -100,13 +76,15 @@ export function fromPromise(plugin: Plugin) {
method: (input: never) => Effect.Effect<unknown, unknown>,
) => {
const compiled = compileEndpoint(endpoint)
return ((input?: unknown) =>
return ((input?: unknown, requestOptions?: { readonly signal?: AbortSignal }) =>
Effect.gen(function* () {
const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
const result = yield* method(Object.assign({}, ...decoded) as never)
if (compiled.noContent) return undefined
return yield* compiled.encode(result)
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
}).pipe((effect) =>
Effect.runPromiseWith(context)(effect, { signal: requestOptions?.signal }),
)) as PromiseMethod
}
const transform =
@@ -120,7 +98,7 @@ export function fromPromise(plugin: Plugin) {
}),
)
const context2: Context = {
const context2: Omit<Context, "signal"> = {
app: host.app,
options: host.options,
agent: {
@@ -132,7 +110,11 @@ export function fromPromise(plugin: Plugin) {
aisdk: {
hook: (name, callback, options) =>
register(
host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
host.aisdk.hook(
name,
(event) => attempt((signal) => Promise.resolve(callback(event, { signal }))).pipe(Effect.orDie),
options,
),
),
},
catalog: {
@@ -280,8 +262,21 @@ export function fromPromise(plugin: Plugin) {
}),
),
),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
hook: (name, callback) => {
if (name === "execute.before") {
const before = callback as BeforeHook
return register(
host.tool.hook("execute.before", (event) =>
attemptTool((signal) => Promise.resolve(before(event, { signal }))),
),
)
}
return register(
host.tool.hook(name, (event) =>
attempt((signal) => Promise.resolve(callback(event, { signal }))).pipe(Effect.orDie),
),
)
},
},
websearch: {
providers: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers),
@@ -305,7 +300,11 @@ export function fromPromise(plugin: Plugin) {
session: {
hook: (name, callback, options) =>
register(
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
host.session.hook(
name,
(event) => attempt((signal) => Promise.resolve(callback(event, { signal }))).pipe(Effect.orDie),
options,
),
),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
@@ -321,27 +320,45 @@ export function fromPromise(plugin: Plugin) {
},
shell: {
hook: (name, callback) =>
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
register(
host.shell.hook(name, (event) =>
attempt((signal) => Promise.resolve(callback(event, { signal }))).pipe(Effect.orDie),
),
),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
if (!cleanup) return
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
const controller = new AbortController()
const cleanup = yield* attempt((signal) => {
signal.addEventListener("abort", () => controller.abort(), { once: true })
return Promise.resolve(plugin.setup({ ...context2, signal: controller.signal }))
}).pipe(Effect.orDie)
if (cleanup) yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
}),
})
}
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
}
type RuntimeSchema = Schema.Codec<unknown, unknown>
function attemptTool<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({
try: evaluate,
catch: (cause) =>
Option.getOrElse(
Schema.decodeUnknownOption(Tool.Error)(cause),
() => new Tool.Error({ message: cause instanceof Error ? cause.message : String(cause), error: cause }),
),
})
}
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
Effect.promise(() =>
attemptTool((signal) =>
tool.execute(input, {
...context,
signal,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
)
+1
View File
@@ -17,6 +17,7 @@ import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
export interface Context {
readonly signal: AbortSignal
readonly app: App
readonly options: PluginOptions
readonly agent: AgentDomain
+6 -2
View File
@@ -7,14 +7,18 @@ export interface ModelHookOptions {
readonly providerID?: string
}
export interface InvocationContext {
readonly signal: AbortSignal
}
export type Hooks<Spec> = <Name extends keyof Spec>(
name: Name,
callback: (input: Spec[Name]) => Promise<void> | void,
callback: (input: Spec[Name], context: InvocationContext) => Promise<void> | void,
) => Promise<Registration>
export type ModelHooks<Spec> = <Name extends keyof Spec>(
name: Name,
callback: (input: Spec[Name]) => Promise<void> | void,
callback: (input: Spec[Name], context: InvocationContext) => Promise<void> | void,
options?: ModelHookOptions,
) => Promise<Registration>
+5 -2
View File
@@ -6,9 +6,10 @@ import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { Hooks, Transform } from "./registration.js"
import type { Hooks, InvocationContext, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
readonly signal: AbortSignal
readonly progress: (update: Tool.Metadata) => Promise<void>
}
@@ -28,7 +29,7 @@ interface ToolDraft {
): void
}
interface ToolHooks {
export interface ToolHooks {
readonly "execute.before": {
readonly tool: string
readonly inputSchema: JsonSchema.JsonSchema
@@ -57,6 +58,8 @@ interface ToolHooks {
)
}
export type BeforeHook = (input: ToolHooks["execute.before"], context: InvocationContext) => Promise<void> | void
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly hook: Hooks<ToolHooks>
@@ -10,8 +10,10 @@ import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Effect } from "effect"
const Plugin = await import("../src/effect/index")
const EffectPlugin = await import("../src/effect/plugin")
const PromisePlugin = await import("../src/promise/index")
const TuiPlugin = await import("../src/tui/index")
@@ -46,6 +48,15 @@ test.each([
])
})
test("effect definitions compile to the Promise plugin ABI", () => {
const plugin = Plugin.Plugin.define({ id: "effect", effect: () => Effect.void })
const subpath = EffectPlugin.define({ id: "effect-subpath", effect: () => Effect.void })
expect(plugin).toHaveProperty("setup")
expect(plugin).not.toHaveProperty("effect")
expect(subpath).toHaveProperty("setup")
expect(subpath).not.toHaveProperty("effect")
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
+134
View File
@@ -0,0 +1,134 @@
import { afterAll, expect, test } from "bun:test"
import { cp, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, Schema } from "effect"
import { Plugin } from "../src/effect/index.js"
import type { Context } from "../src/promise/plugin.js"
const fixture = prepareAdapter()
afterAll(async () => rm((await fixture).directory, { recursive: true, force: true }))
test("owns Effect callbacks and finalizers inside the Promise plugin lifecycle", async () => {
const events: string[] = []
const host = {
signal: new AbortController().signal,
app: {},
options: {},
agent: {},
aisdk: {
hook: async (_name: string, callback: () => Promise<void>) => {
events.push("registered")
await callback(undefined, { signal: new AbortController().signal })
return { dispose: async () => void events.push("disposed") }
},
},
catalog: { provider: {}, model: {} },
command: {},
event: {},
integration: { connect: {}, oauth: {}, command: {}, connection: {} },
mcp: {},
plugin: {},
reference: {},
session: {},
shell: {},
skill: {},
storage: {},
tool: {},
websearch: {},
} as unknown as Context
const plugin = Plugin.define({
id: "lifecycle",
effect: (context) =>
Effect.gen(function* () {
yield* context.aisdk.hook("sdk", () => Effect.sync(() => events.push("callback")))
yield* Effect.addFinalizer(() => Effect.sync(() => events.push("finalized")))
}),
})
const cleanup = await plugin.setup(host)
expect(events).toEqual(["registered", "callback"])
expect(cleanup).toBeFunction()
if (cleanup) await cleanup()
expect(events).toEqual(["registered", "callback", "finalized", "disposed"])
})
test("interrupts Effect setup through the Promise setup signal", async () => {
const controller = new AbortController()
const events: string[] = []
const plugin = Plugin.define({
id: "interrupt-setup",
effect: () =>
Effect.sync(() => events.push("started")).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Effect.sync(() => events.push("finalized"))),
),
})
const setup = plugin.setup({
signal: controller.signal,
app: {},
options: {},
agent: {},
aisdk: {},
catalog: { provider: {}, model: {} },
command: {},
event: {},
integration: { connect: {}, oauth: {}, command: {}, connection: {} },
mcp: {},
plugin: {},
reference: {},
session: {},
shell: {},
skill: {},
storage: {},
tool: {},
websearch: {},
} as unknown as Context)
while (events.length === 0) await Bun.sleep(0)
controller.abort()
await expect(setup).rejects.toBeDefined()
expect(events).toEqual(["started", "finalized"])
})
test("converts schemas entirely inside the plugin Effect runtime", async () => {
const prepared = await fixture
const input = prepared.toStandardSchema(prepared.Schema.FiniteFromString, "input") as StandardSchemaV1 &
StandardJSONSchemaV1
const output = prepared.toStandardSchema(prepared.Schema.FiniteFromString, "output") as StandardSchemaV1 &
StandardJSONSchemaV1
expect(Schema.isSchema(input)).toBe(false)
expect(await input["~standard"].validate("42")).toEqual({ value: 42 })
expect(input["~standard"].jsonSchema.input({ target: "draft-2020-12" })).toMatchObject({ type: "string" })
expect(await output["~standard"].validate(42)).toEqual({ value: "42" })
expect(output["~standard"].jsonSchema.output({ target: "draft-2020-12" })).toMatchObject({ type: "string" })
})
test("rejects schemas from a different plugin Effect runtime", async () => {
const prepared = await fixture
expect(() => prepared.toStandardSchema(Schema.String, "input")).toThrow("must use the plugin's Effect peer")
})
async function prepareAdapter() {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-effect-adapter-"))
await cp(
path.dirname(fileURLToPath(import.meta.resolve("effect/package.json"))),
path.join(directory, "node_modules", "effect"),
{ recursive: true },
)
await cp(new URL("../src/effect/tool-schema.ts", import.meta.url), path.join(directory, "tool-schema.ts"))
const adapter = (await import(pathToFileURL(path.join(directory, "tool-schema.ts")).href)) as {
toStandardSchema: (schema: Tool.ValueSchema, direction: "input" | "output") => Tool.ValueSchema
}
const runtime = (await import(
pathToFileURL(path.join(directory, "node_modules", "effect", "dist", "index.js")).href
)) as {
Schema: typeof Schema
}
return { directory, toStandardSchema: adapter.toStandardSchema, Schema: runtime.Schema }
}

Some files were not shown because too many files have changed in this diff Show More