Compare commits

...
Author SHA1 Message Date
Kit Langton 9f06708d0e fix(plugin): load Effect from the host for plugins 2026-10-04 23:14:49 -07:00
22 changed files with 1042 additions and 16 deletions

No files matched your search

+31 -1
View File
@@ -3,6 +3,7 @@
import { $ } from "bun"
import { mkdir, rm } from "fs/promises"
import path from "path"
import { discoverEffectSpecifiers } from "@opencode/plugin/runtime-modules.bun"
import { Script } from "@opencode/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
@@ -78,6 +79,28 @@ const appAssetsPlugin: BunPlugin = {
}))
},
}
const pluginRuntimeSpecifiers = [
"@opencode/plugin",
"@opencode/plugin/effect",
"@opencode/plugin/effect/plugin",
"@opencode/plugin/effect/tool",
"@opencode/plugin/promise/plugin",
"@opencode/plugin/promise/tool",
"@opencode/plugin/rpc",
...discoverEffectSpecifiers().map(([specifier]) => specifier),
]
const pluginRuntimeModulesSource = `const modules = {\n${pluginRuntimeSpecifiers
.map((specifier) => ` ${JSON.stringify(specifier)}: () => require(${JSON.stringify(specifier)}),`)
.join("\n")}\n}\nexport function pluginRuntimeModules() {\n return modules\n}\n`
const pluginRuntimeModulesPlugin: BunPlugin = {
name: "opencode-plugin-runtime-modules",
setup(build) {
build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.bun\.ts$/ }, () => ({
contents: pluginRuntimeModulesSource,
loader: "js",
}))
},
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
@@ -124,7 +147,14 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
plugins: [
appAssetsPlugin,
solidPlugin,
parcelWatcherPlugin,
opencodePtyPlugin,
pluginRuntimeModulesPlugin,
simulationGraphPlugin,
],
external: ["node-gyp"],
format: "esm",
minify: true,
+11 -3
View File
@@ -61,9 +61,17 @@ const layer = Layer.effect(
release: holdUnsafe(),
})
})
const exit = yield* Effect.suspend(() =>
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
const exit = yield* Effect.suspend(() => {
const effect = plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) })
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
return Effect.die(
new Error(
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
),
)
}
return effect
}).pipe(
grouped,
inherit,
Effect.updateContext((context: Context.Context<never>) =>
+12 -1
View File
@@ -128,6 +128,17 @@ const load = Effect.fn("PluginModule.load")(function* (
target: operation.target,
...(installed?.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
effect: (host) =>
Effect.suspend(() => {
const effect = plugin.effect({ ...host, options: operation.options })
if (!Effect.isEffect(effect) || effect.pipe !== Effect.void.pipe) {
return Effect.die(
new Error(
`Plugin "${plugin.id}" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.`,
),
)
}
return effect
}),
} satisfies Generation
})
+261 -2
View File
@@ -1,17 +1,27 @@
import { expect } from "bun:test"
import { cp, mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
import { Brand, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Plugin } from "@opencode/core/plugin"
import { PluginModule } from "@opencode/core/plugin/module"
import { Rpc } from "@opencode/core/rpc"
import { Tool } from "@opencode/core/tool"
import { execute } from "@opencode/core/tool/runtime"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(
Layer.merge(
Layer.mergeAll(
PluginTestLayer,
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
),
@@ -90,3 +100,252 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
yield* Effect.promise(() => Bun.sleep(50))
}),
)
it.live("loads plugins and their transitive dependencies against the host's Effect instance", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "plugin")
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
yield* Effect.promise(async () => {
await mkdir(path.join(pluginDir, "node_modules"), { recursive: true })
await cp(hostEffectDir, pluginEffectDir, { recursive: true })
const pkgPath = path.join(pluginEffectDir, "package.json")
const pkg = { ...(await Bun.file(pkgPath).json()), version: "4.0.0-rc.111" }
await writeFile(pkgPath, JSON.stringify(pkg, null, 2))
// Reproduce the version-skew failure modes on the plugin's own Effect copy so loading it would crash:
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
const internalEffect = (await readFile(internalEffectPath, "utf8"))
.replace(
"const logLevel = level ?? fiber.currentLogLevel;\n if (isLogLevelGreaterThan(fiber.minimumLogLevel, logLevel)) {",
"const logLevel = level ?? fiber.cache?.logLevel;\n if (isLogLevelGreaterThan(fiber.cache?.minimumLogLevel, logLevel)) {",
)
.replace(
"export const runPromiseWith = context => {",
"export const runPromiseWith = context => {\n return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });",
)
await writeFile(internalEffectPath, internalEffect)
const depDir = path.join(pluginDir, "node_modules/transitive-dep")
await mkdir(depDir, { recursive: true })
await writeFile(
path.join(depDir, "package.json"),
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
)
await writeFile(
path.join(depDir, "index.js"),
`import { Effect as DepEffect, Schema as DepSchema } from "effect"
import { some as depSome } from "effect/Option"
export const depToolInput = DepSchema.Struct({
mode: DepSchema.String.pipe(DepSchema.withDecodingDefault(DepEffect.succeed("from-dep"))),
})
export { DepEffect, DepSchema, depSome }`,
)
const foreignPluginPkgDir = path.join(pluginDir, "node_modules/@opencode/plugin")
await mkdir(foreignPluginPkgDir, { recursive: true })
await writeFile(
path.join(foreignPluginPkgDir, "package.json"),
JSON.stringify({
name: "@opencode/plugin",
type: "module",
exports: { "./effect": "./effect.js", "./rpc": "./rpc.js" },
}),
)
await writeFile(
path.join(foreignPluginPkgDir, "effect.js"),
"export const Plugin = { define: (p) => p }",
)
await writeFile(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
await writeFile(
path.join(pluginDir, "index.ts"),
`import { Plugin } from "@opencode/plugin/effect"
import { Rpc } from "@opencode/plugin/rpc"
import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
import { some as pluginSome } from "effect/Option"
import { nominal as pluginNominal } from "effect/Brand"
import { DepEffect, DepSchema, depSome, depToolInput } from "transitive-dep"
export const captured = {
PluginEffect,
PluginSchema,
pluginSome,
pluginNominal,
DepEffect,
DepSchema,
depSome,
pluginCount: -1,
}
const Contract = Rpc.define({
id: "host-effect-rpc",
methods: {
check: {
input: PluginSchema.Struct({
count: PluginSchema.Int.pipe(PluginSchema.withDecodingDefault(PluginEffect.succeed(5))),
tag: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
}),
output: PluginSchema.Struct({
value: PluginSchema.String,
}),
},
},
events: {},
})
export default Plugin.define({
id: "host-effect-fixture",
effect: (ctx) =>
PluginEffect.gen(function* () {
yield* PluginEffect.log("setup log from plugin")
const listed = yield* PluginEffect.promise(() =>
PluginEffect.runPromise(ctx.plugin.list().pipe(PluginEffect.orDie)),
)
captured.pluginCount = listed.data.length
yield* ctx.tool.transform((editor) => {
editor.add({
name: "default_tool",
description: "Tool with decoding default from transitive dependency",
input: depToolInput,
output: PluginSchema.Struct({ mode: PluginSchema.String }),
execute: ({ mode }) =>
PluginEffect.log("executing default_tool").pipe(
PluginEffect.as({ output: { mode }, content: mode }),
),
})
editor.add({
name: "check_tool",
description: "Tool with Int, Trim, and isPattern checks",
input: PluginSchema.Struct({
count: PluginSchema.Int,
code: PluginSchema.Trim.check(PluginSchema.isPattern(/^v[0-9]+$/)),
}),
output: PluginSchema.Struct({ formatted: PluginSchema.String }),
execute: ({ count, code }) =>
PluginEffect.succeed({
output: { formatted: \`\${code}:\${count}\` },
content: \`\${code}:\${count}\`,
}),
})
})
yield* ctx.rpc.register(Contract, {
check: ({ count, tag }) =>
PluginEffect.log("executing rpc check").pipe(
PluginEffect.as({ value: \`\${tag}#\${count}\` }),
),
}).pipe(PluginEffect.orDie)
}),
})`,
)
})
const modules = yield* PluginModule.make()
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const rpc = yield* Rpc.Service
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
yield* plugins.activate([definition])
yield* plugins.awaitActivation
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
const imported = (yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))) as {
captured: {
PluginEffect: unknown
PluginSchema: unknown
pluginSome: unknown
pluginNominal: unknown
DepEffect: unknown
DepSchema: unknown
depSome: unknown
pluginCount: number
}
}
expect(imported.captured.PluginEffect).toBe(Effect)
expect(imported.captured.PluginSchema).toBe(Schema)
expect(imported.captured.pluginSome).toBe(Option.some)
expect(imported.captured.pluginNominal).toBe(Brand.nominal)
expect(imported.captured.DepEffect).toBe(Effect)
expect(imported.captured.DepSchema).toBe(Schema)
expect(imported.captured.depSome).toBe(Option.some)
expect(imported.captured.pluginCount).toBe(0)
const registeredTools = yield* tools.list()
const defaultTool = registeredTools.find((tool) => tool.id === "default_tool")
const checkTool = registeredTools.find((tool) => tool.id === "check_tool")
expect(defaultTool).toBeDefined()
expect(checkTool).toBeDefined()
if (!defaultTool || !checkTool) return
const context = {
sessionID: Session.ID.make("ses_host_effect"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_host_effect"),
id: Tool.CallID.make("call_host_effect"),
progress: () => Effect.void,
}
expect(yield* execute(defaultTool, {}, context)).toEqual({
output: { mode: "from-dep" },
content: [{ type: "text", text: "from-dep" }],
})
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
output: { formatted: "v42:3" },
content: [{ type: "text", text: "v42:3" }],
})
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
}),
)
it.live("fails activation when a plugin returns an Effect from a bundled copy of effect", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "bundled-plugin")
const bundledEffectDir = path.join(pluginDir, "bundled-effect")
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
yield* Effect.promise(async () => {
await mkdir(pluginDir, { recursive: true })
await cp(hostEffectDir, bundledEffectDir, { recursive: true })
await writeFile(
path.join(pluginDir, "index.ts"),
`import { Effect as BundledEffect } from "./bundled-effect/dist/index.js"
export default {
id: "bundled-effect-plugin",
effect: () => BundledEffect.void,
}`,
)
})
const modules = yield* PluginModule.make()
const plugins = yield* Plugin.Service
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
yield* plugins.activate([definition])
yield* plugins.awaitActivation
expect(yield* plugins.list()).toMatchObject([
{
id: "bundled-effect-plugin",
state: {
status: "failed",
error: expect.stringContaining(
'Plugin "bundled-effect-plugin" returned an Effect from a bundled copy of effect. Declare "effect" as a peerDependency and do not bundle it.',
),
},
},
])
}),
)
+16
View File
@@ -0,0 +1,16 @@
# `@opencode/plugin`
Authoring interfaces and runtime loader support for OpenCode V2 plugins:
- `@opencode/plugin` — [Promise plugin API](./src/README.md)
- `@opencode/plugin/effect` — [Effect plugin API](./src/effect/README.md)
- `@opencode/plugin/rpc` — portable RPC contract definitions
- `@opencode/plugin/tui` — terminal UI plugin API
## Packaging And Runtime `effect`
OpenCode resolves imports of `effect`, every exported `effect/*` subpath, and `@opencode/plugin` entrypoints (including transitive imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances.
- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking/testing) rather than bundling it.
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. OpenCode detects and rejects Effect plugins whose returned `Effect` comes from a bundled copy.
- At runtime, plugins execute against the host OpenCode release's `effect` version, so plugins must use `effect` APIs and module paths compatible with the OpenCode release they target.
+6
View File
@@ -17,6 +17,12 @@
"./*": "./src/*.ts"
},
"imports": {
"#plugin-runtime": {
"workerd": "./src/runtime.workerd.ts",
"bun": "./src/runtime.bun.ts",
"node": "./src/runtime.node.ts",
"default": "./src/runtime.node.ts"
},
"#plugin-source": {
"bun": "./src/source.bun.ts",
"node": "./src/source.node.ts",
+2
View File
@@ -140,6 +140,8 @@ await ctx.tool.transform((tools) => {
})
```
When using `effect` schemas in a plugin, declare `effect` as a `peerDependency` and do not bundle it; OpenCode resolves `effect` and `effect/*` imports to the host's runtime instance.
## Reloading A Domain
When data captured by a transform changes, reload the affected domain:
+8
View File
@@ -29,6 +29,14 @@ 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`.
## Packaging And Runtime `effect`
OpenCode redirects imports of `effect`, `effect/*` subpaths, and `@opencode/plugin` (including transitive imports from packages in the plugin's `node_modules`) to the host's runtime instances so fibers, loggers, and `Schema` parsers share one runtime copy.
- Declare `effect` in `peerDependencies` (and `devDependencies` for local development) rather than bundling it.
- Do not bundle `effect` into the published plugin artifact; if using a bundler, mark `effect` and `effect/*` external. OpenCode rejects Effect plugins whose returned `Effect` comes from a bundled copy.
- Plugins always execute against the host OpenCode release's `effect` instance, so use `effect` APIs and module paths compatible with the OpenCode release you target.
## Transform Hooks
Transform hooks contribute to stateful domains. Their editor callbacks are
+1
View File
@@ -14,6 +14,7 @@ export { Provider } from "@opencode/schema/provider"
export { Reference } from "@opencode/schema/reference"
export { Rpc } from "@opencode/schema/rpc"
export { Skill } from "@opencode/schema/skill"
export { Tool } from "@opencode/schema/tool"
export { Vcs } from "@opencode/schema/vcs"
export { WebSearch } from "@opencode/schema/websearch"
export { Worktree } from "@opencode/schema/worktree"
+3
View File
@@ -1,3 +1,6 @@
export { CallID, Error, Tool } from "@opencode/schema/tool"
export type { Metadata, Options, Result } from "@opencode/schema/tool"
import { Tool } from "@opencode/schema/tool"
import type { Agent } from "@opencode/schema/agent"
import type { Session } from "@opencode/schema/session"
+3 -1
View File
@@ -2,6 +2,7 @@ export * as Host from "./host.js"
import path from "node:path"
import { importModule, resolveModule } from "@opencode/util/runtime-import"
import { ensurePluginRuntime } from "#plugin-runtime"
export interface Target {
readonly directory: string
@@ -43,6 +44,7 @@ export function resolve(target: Target): Entrypoints {
return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
}
export function load(entrypoint: string): Promise<unknown> {
export async function load(entrypoint: string): Promise<unknown> {
await ensurePluginRuntime(entrypoint)
return importModule(entrypoint)
}
+1
View File
@@ -15,6 +15,7 @@ export { Provider } from "@opencode/schema/provider"
export { Reference } from "@opencode/schema/reference"
export { Rpc } from "@opencode/schema/rpc"
export { Skill } from "@opencode/schema/skill"
export { Tool } from "@opencode/schema/tool"
export { Vcs } from "@opencode/schema/vcs"
export { WebSearch } from "@opencode/schema/websearch"
export { Worktree } from "@opencode/schema/worktree"
@@ -0,0 +1,46 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
export type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
export type RuntimeModuleEntry = Record<string, unknown> | RuntimeModuleLoader
export function discoverEffectSpecifiers(from = import.meta.dir): ReadonlyArray<readonly [string, string]> {
const pkg = Bun.resolveSync("effect/package.json", from)
const dir = path.dirname(pkg)
const dist = path.join(dir, "dist")
const entries: Array<readonly [string, string]> = [["effect", Bun.resolveSync("effect", dir)]]
for (const file of new Bun.Glob("**/*.js").scanSync({ cwd: dist })) {
const normalized = file.replaceAll("\\", "/")
if (normalized === "index.js" || normalized.startsWith("internal/") || normalized.includes("/internal/")) continue
const subpath = normalized.endsWith("/index.js")
? normalized.slice(0, -"/index.js".length)
: normalized.slice(0, -".js".length)
const specifier = `effect/${subpath}`
try {
entries.push([specifier, Bun.resolveSync(specifier, dir)])
} catch {}
}
return entries.toSorted(([left], [right]) => left.localeCompare(right))
}
let cached: Readonly<Record<string, RuntimeModuleLoader>> | undefined
export function pluginRuntimeModules(): Readonly<Record<string, RuntimeModuleLoader>> {
if (cached) return cached
cached = {
"@opencode/plugin": () => import("./promise/index.js"),
"@opencode/plugin/effect": () => import("./effect/index.js"),
"@opencode/plugin/effect/plugin": () => import("./effect/plugin.js"),
"@opencode/plugin/effect/tool": () => import("./effect/tool.js"),
"@opencode/plugin/promise/plugin": () => import("./promise/plugin.js"),
"@opencode/plugin/promise/tool": () => import("./promise/tool.js"),
"@opencode/plugin/rpc": () => import("./rpc.js"),
...Object.fromEntries(
discoverEffectSpecifiers().map(([specifier, resolved]) => [
specifier,
() => import(pathToFileURL(resolved).href) as Promise<Record<string, unknown>>,
]),
),
}
return cached
}
+306
View File
@@ -0,0 +1,306 @@
import { existsSync, readFileSync, realpathSync } from "node:fs"
import { dirname, isAbsolute, join } from "node:path"
import { fileURLToPath } from "node:url"
import { plugin, type PluginBuilder } from "bun"
import { pluginRuntimeModules, type RuntimeModuleEntry } from "./runtime-modules.bun.js"
export { pluginRuntimeModules } from "./runtime-modules.bun.js"
const RUNTIME_MODULE_PREFIX = "opentui:runtime-module:"
const opentuiInstalledKey = Symbol.for("opentui.solid.runtime-plugin-support")
const solidTransformKey = Symbol.for("opentui.solid.transform")
const pluginRuntimeStateKey = Symbol.for("opencode.plugin.runtime-plugin-support")
type RuntimeState = {
readonly prepare: (entrypoint?: string) => void
}
type GlobalState = typeof globalThis & {
[opentuiInstalledKey]?: { readonly specifiers?: ReadonlySet<string> }
[solidTransformKey]?: { readonly installed?: boolean }
[pluginRuntimeStateKey]?: RuntimeState
}
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const runtimeModuleIdForSpecifier = (specifier: string) => `${RUNTIME_MODULE_PREFIX}${encodeURIComponent(specifier)}`
const sourcePath = (value: string) => {
const search = value.indexOf("?")
const hash = value.indexOf("#")
const end = [search, hash].filter((index) => index >= 0).sort((a, b) => a - b)[0]
return end === undefined ? value : value.slice(0, end)
}
const normalizedPaths = new Map<string, string>()
const normalizeSourcePath = (value: string) => {
const clean = sourcePath(value)
const cached = normalizedPaths.get(clean)
if (cached !== undefined) return cached
const normalized = (() => {
try {
return realpathSync(clean)
} catch {
return clean
}
})()
normalizedPaths.set(clean, normalized)
return normalized
}
const exactPathFilter = (paths: readonly string[]) => {
const candidates = [...new Set(paths.map(sourcePath))]
return new RegExp(`^(?:${candidates.map(escapeRegExp).join("|")})(?:[?#].*)?$`)
}
const isNodeModulesPath = (value: string) => /(?:^|[/\\])node_modules(?:[/\\])/.test(value)
const runtimeLoaderForPath = (value: string) => {
const clean = sourcePath(value)
if (clean.endsWith(".tsx")) return "tsx" as const
if (clean.endsWith(".jsx")) return "jsx" as const
if (clean.endsWith(".ts") || clean.endsWith(".mts") || clean.endsWith(".cts")) return "ts" as const
if (clean.endsWith(".js") || clean.endsWith(".mjs") || clean.endsWith(".cjs")) return "js" as const
return null
}
const resolveImportSpecifierPatterns = [
/(\bfrom\s*["'])([^"']+)(["'])/g,
/(\bimport\s*["'])([^"']+)(["'])/g,
/(\bimport\s*\(\s*["'])([^"']+)(["']\s*\))/g,
/(\brequire\s*\(\s*["'])([^"']+)(["']\s*\))/g,
]
const isImportLikeMatchInsideQuotes = (code: string, offset: number) => {
const previous = code[offset - 1]
return previous === '"' || previous === "'" || previous === "`"
}
const collectImportSpecifiers = (code: string) => {
const specifiers = new Set<string>()
for (const pattern of resolveImportSpecifierPatterns) {
code.replace(pattern, (full, _prefix, specifier: string, _suffix, offset: number) => {
if (!isImportLikeMatchInsideQuotes(code, offset)) specifiers.add(specifier)
return full
})
}
return [...specifiers]
}
const resolveSourcePathFromSpecifier = (specifier: string, importer?: string) => {
if (
specifier.startsWith("node:") ||
specifier.startsWith("bun:") ||
specifier.startsWith("http:") ||
specifier.startsWith("https:") ||
specifier.startsWith("data:") ||
specifier.startsWith(RUNTIME_MODULE_PREFIX)
)
return null
const candidate = specifier.startsWith("file:") ? sourcePath(fileURLToPath(specifier)) : sourcePath(specifier)
if (isAbsolute(candidate) && existsSync(candidate) && runtimeLoaderForPath(candidate)) return candidate
const baseDir = importer
? dirname(importer.startsWith("file:") ? fileURLToPath(importer) : sourcePath(importer))
: process.cwd()
try {
const resolved = Bun.resolveSync(candidate, baseDir)
if (resolved.startsWith("node:") || resolved.startsWith("bun:")) return null
return sourcePath(resolved)
} catch {
return null
}
}
export function ensurePluginRuntime(entrypoint?: string) {
const state = globalThis as GlobalState
if (state[opentuiInstalledKey]) return
if (!state[pluginRuntimeStateKey]) {
state[pluginRuntimeStateKey] = install(state)
}
state[pluginRuntimeStateKey].prepare(entrypoint)
}
function install(state: GlobalState): RuntimeState {
const modules: Readonly<Record<string, RuntimeModuleEntry>> = pluginRuntimeModules()
const runtimeModuleIds = new Map<string, string>()
for (const specifier of Object.keys(modules)) {
runtimeModuleIds.set(specifier, runtimeModuleIdForSpecifier(specifier))
}
const resolveRuntimeId = (specifier: string) => {
const known = runtimeModuleIds.get(specifier)
if (known) return known
if (
specifier === "@opentui/core" ||
specifier === "@opentui/core/testing" ||
state[opentuiInstalledKey]?.specifiers?.has(specifier)
)
return runtimeModuleIdForSpecifier(specifier)
return undefined
}
const isSolidTransformActiveFor = (file: string) =>
Boolean(state[solidTransformKey]?.installed) && /\.[cm]?[jt]sx$/.test(sourcePath(file))
const installedLoaders = new Set<string>()
const packageTypes = new Map<string, "module" | "commonjs">()
const nodeModulesAnalysis = new Map<
string,
{ readonly importSpecifiers: readonly string[]; readonly needsRewrite: boolean; readonly isEsm: boolean }
>()
const nodeModulesRewritePaths = new Map<string, readonly string[]>()
let activeBuild: PluginBuilder | undefined
const packageTypeForPath = (file: string) => {
let current = dirname(file)
while (true) {
const pkgJson = join(current, "package.json")
if (existsSync(pkgJson)) {
const cached = packageTypes.get(pkgJson)
if (cached) return cached
const type = (() => {
try {
return JSON.parse(readFileSync(pkgJson, "utf8")).type === "module"
? ("module" as const)
: ("commonjs" as const)
} catch {
return "commonjs" as const
}
})()
packageTypes.set(pkgJson, type)
return type
}
const parent = dirname(current)
if (parent === current) return "commonjs"
current = parent
}
}
const analyzeFile = (file: string, cacheable: boolean) => {
const normalized = normalizeSourcePath(file)
if (cacheable) {
const cached = nodeModulesAnalysis.get(normalized)
if (cached) return cached
}
const contents = (() => {
try {
return readFileSync(normalized, "utf8")
} catch {
return undefined
}
})()
if (contents === undefined) return { importSpecifiers: [], needsRewrite: false, isEsm: false }
const importSpecifiers = collectImportSpecifiers(contents)
const analysis = {
importSpecifiers,
needsRewrite: importSpecifiers.some((specifier) => resolveRuntimeId(specifier) !== undefined),
isEsm: /\b(?:import|export)\b/.test(contents),
}
if (cacheable) nodeModulesAnalysis.set(normalized, analysis)
return analysis
}
const isNodeModulesEsm = (file: string) => {
const normalized = normalizeSourcePath(file)
if (!isNodeModulesPath(normalized)) return false
if (/\.(?:mjs|mts|ts|tsx|jsx)$/.test(normalized)) return true
if (/\.(?:cjs|cts)$/.test(normalized) || !normalized.endsWith(".js")) return false
return packageTypeForPath(normalized) === "module" || analyzeFile(normalized, true).isEsm
}
const collectNodeModulesRewrites = (file: string, visiting = new Set<string>()): readonly string[] => {
const normalized = normalizeSourcePath(file)
if (!isNodeModulesEsm(normalized)) return []
const cached = nodeModulesRewritePaths.get(normalized)
if (cached) return cached
if (visiting.has(normalized)) return []
visiting.add(normalized)
const rewrites = new Set<string>()
const analysis = analyzeFile(normalized, true)
if (analysis.needsRewrite) rewrites.add(normalized)
for (const specifier of analysis.importSpecifiers) {
if (resolveRuntimeId(specifier) !== undefined) continue
const resolved = resolveSourcePathFromSpecifier(specifier, normalized)
if (!resolved || !isNodeModulesEsm(resolved)) continue
for (const nested of collectNodeModulesRewrites(resolved, visiting)) rewrites.add(nested)
}
visiting.delete(normalized)
const result = [...rewrites]
nodeModulesRewritePaths.set(normalized, result)
return result
}
const installRewriteLoader = (file: string) => {
if (!activeBuild) return
const resolvedTarget = sourcePath(file)
if (!isNodeModulesPath(resolvedTarget) && isSolidTransformActiveFor(resolvedTarget)) return
const canonicalTarget = normalizeSourcePath(resolvedTarget)
if (installedLoaders.has(canonicalTarget)) return
installedLoaders.add(canonicalTarget)
const build = activeBuild
build.onLoad({ filter: exactPathFilter([resolvedTarget, canonicalTarget]) }, (args) => {
const loaded = normalizeSourcePath(args.path)
if (loaded !== canonicalTarget) return undefined as never
const loader = runtimeLoaderForPath(args.path)
if (!loader) throw new Error(`Unable to determine runtime loader for path: ${args.path}`)
let code = readFileSync(loaded, "utf8")
for (const pattern of resolveImportSpecifierPatterns) {
code = code.replace(pattern, (full, prefix: string, specifier: string, suffix: string, offset: number) => {
if (isImportLikeMatchInsideQuotes(code, offset)) return full
const replacement = resolveRuntimeId(specifier)
return replacement ? `${prefix}${replacement}${suffix}` : full
})
}
return { contents: code, loader }
})
}
const prescanGraph = (entryFile: string, visiting = new Set<string>()) => {
const normalized = normalizeSourcePath(entryFile)
if (visiting.has(normalized)) return
visiting.add(normalized)
const analysis = analyzeFile(normalized, false)
if (analysis.needsRewrite) installRewriteLoader(entryFile)
for (const specifier of analysis.importSpecifiers) {
if (resolveRuntimeId(specifier) !== undefined) continue
const resolved = resolveSourcePathFromSpecifier(specifier, normalized)
if (!resolved || !runtimeLoaderForPath(resolved)) continue
if (isNodeModulesPath(resolved)) {
for (const rewrite of collectNodeModulesRewrites(resolved)) installRewriteLoader(rewrite)
continue
}
prescanGraph(resolved, visiting)
}
}
plugin({
name: "opencode-plugin-runtime-modules",
setup(build) {
activeBuild = build
if (!state[opentuiInstalledKey]) {
for (const [specifier, entry] of Object.entries(modules)) {
const moduleId = runtimeModuleIds.get(specifier)
if (!moduleId) continue
build.module(moduleId, async () => ({
exports: typeof entry === "function" ? await entry() : entry,
loader: "object",
}))
}
}
build.onResolve({ filter: /^(?:effect(?:\/|$)|@opencode\/plugin(?:\/|$))/ }, (args) => {
const direct = resolveRuntimeId(args.path)
return direct ? { path: direct } : undefined
})
},
})
return {
prepare(entrypoint?: string) {
if (!entrypoint) return
const resolved = resolveSourcePathFromSpecifier(entrypoint)
if (!resolved || !runtimeLoaderForPath(resolved)) return
prescanGraph(resolved)
},
}
}
+99
View File
@@ -0,0 +1,99 @@
import { createRequire, registerHooks } from "node:module"
import path from "node:path"
import { pathToFileURL } from "node:url"
const PREFIX = "opencode:runtime-module:"
const modulesKey = Symbol.for("opencode.plugin.runtime.modules")
type GlobalState = typeof globalThis & {
[modulesKey]?: Map<string, Record<string, unknown>>
}
let pending: Promise<void> | undefined
export function ensurePluginRuntime(_entrypoint?: string): Promise<void> {
if (pending) return pending
pending = setup()
return pending
}
async function setup() {
const state = globalThis as GlobalState
if (state[modulesKey]) return
const [effect, promiseIndex, promisePlugin, promiseTool, effectIndex, effectPlugin, effectTool, rpc] =
await Promise.all([
import("effect") as Promise<Record<string, unknown>>,
import("./promise/index.js") as Promise<Record<string, unknown>>,
import("./promise/plugin.js") as Promise<Record<string, unknown>>,
import("./promise/tool.js") as Promise<Record<string, unknown>>,
import("./effect/index.js") as Promise<Record<string, unknown>>,
import("./effect/plugin.js") as Promise<Record<string, unknown>>,
import("./effect/tool.js") as Promise<Record<string, unknown>>,
import("./rpc.js") as Promise<Record<string, unknown>>,
])
const modules = new Map<string, Record<string, unknown>>([
["effect", effect],
["@opencode/plugin", promiseIndex],
["@opencode/plugin/promise/plugin", promisePlugin],
["@opencode/plugin/promise/tool", promiseTool],
["@opencode/plugin/effect", effectIndex],
["@opencode/plugin/effect/plugin", effectPlugin],
["@opencode/plugin/effect/tool", effectTool],
["@opencode/plugin/rpc", rpc],
])
for (const [key, value] of Object.entries(effect)) {
if (typeof value === "object" && value !== null) {
modules.set(`effect/${key}`, value as Record<string, unknown>)
}
}
state[modulesKey] = modules
const loadEffectSubpath = (specifier: string) => {
const existing = modules.get(specifier)
if (existing) return existing
for (const base of [import.meta.url, pathToFileURL(path.join(process.cwd(), "package.json")).href]) {
try {
const loaded = createRequire(base)(specifier) as Record<string, unknown>
if (typeof loaded === "object" && loaded !== null) {
modules.set(specifier, loaded)
return loaded
}
} catch {}
}
return undefined
}
registerHooks({
resolve(specifier, context, nextResolve) {
if (modules.has(specifier) || (specifier.startsWith("effect/") && loadEffectSubpath(specifier) !== undefined)) {
return { url: `${PREFIX}${encodeURIComponent(specifier)}`, shortCircuit: true }
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url.startsWith(PREFIX)) {
const specifier = decodeURIComponent(url.slice(PREFIX.length))
const exports = modules.get(specifier)
if (exports) {
return { format: "module", source: createModuleSource(specifier, exports), shortCircuit: true }
}
}
return nextLoad(url, context)
},
})
}
function createModuleSource(specifier: string, exports: Record<string, unknown>) {
const keys = Object.keys(exports).filter((key) => key !== "default" && /^[A-Za-z_$][\w$]*$/.test(key))
const lines = [
`const mod = globalThis[Symbol.for("opencode.plugin.runtime.modules")].get(${JSON.stringify(specifier)})`,
...keys.map((key, index) => `const e${index} = mod[${JSON.stringify(key)}]`),
...(keys.length > 0 ? [`export { ${keys.map((key, index) => `e${index} as ${key}`).join(", ")} }`] : []),
]
if ("default" in exports) lines.push("export default mod.default")
return lines.join("\n")
}
+1
View File
@@ -0,0 +1 @@
export function ensurePluginRuntime(_entrypoint?: string) {}
+2
View File
@@ -3,12 +3,14 @@ import { readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Host } from "./host.js"
import { ensurePluginRuntime } from "./runtime.bun.js"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"
let generation = Date.now()
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
ensurePluginRuntime(entrypoint)
const root = fileURLToPath(entrypoint)
const files = new Set<string>()
const visit = (file: string, search = "") => {
+2
View File
@@ -4,10 +4,12 @@ import { fileURLToPath } from "node:url"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"
import { Host } from "./host.js"
import { ensurePluginRuntime } from "./runtime.node.js"
let generation = Date.now()
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
await ensurePluginRuntime()
const version = String(++generation)
const fresh = (specifier: string) => {
const url = new URL(specifier)
+172 -2
View File
@@ -1,9 +1,14 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
import { pathToFileURL } from "node:url"
import { Effect, Option, Schema } from "effect"
import { Brand } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Plugin, Tool } from "../src/effect/index.js"
import { Rpc } from "../src/rpc.js"
import { Host } from "../src/host.js"
// Every entrypoint throws if evaluated: resolution must never execute plugins.
@@ -11,7 +16,7 @@ const source = 'throw new Error("Plugin code must not run during resolution")'
const name = "@fixture/plugin"
async function fixture(files: Record<string, string>, installed = false) {
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-")))
const directory = installed ? path.join(root, "node_modules", name) : root
await Promise.all(
Object.entries(files).map(async ([file, content]) => {
@@ -154,3 +159,168 @@ describe("Host.resolve", () => {
assert.deepEqual(Host.resolve(plugin.target), { server: undefined, tui: undefined, rpc: undefined })
})
})
describe("Host.load", () => {
it("resolves effect, effect/* subpaths, and @opencode/plugin/* to the host across plugin node_modules", async () => {
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-load-")))
try {
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
const pluginEffectDir = path.join(root, "node_modules/effect")
await mkdir(path.join(root, "node_modules"), { recursive: true })
await cp(hostEffectDir, pluginEffectDir, { recursive: true })
const manifest = { ...(await Bun.file(path.join(pluginEffectDir, "package.json")).json()), version: "4.0.0-rc.111" }
await writeFile(path.join(pluginEffectDir, "package.json"), JSON.stringify(manifest, null, 2))
const depDir = path.join(root, "node_modules/transitive-dep")
await mkdir(depDir, { recursive: true })
await writeFile(
path.join(depDir, "package.json"),
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
)
await writeFile(
path.join(depDir, "index.js"),
`import { Effect as DepEffect, Schema as DepSchema } from "effect"
import { some as depSome } from "effect/Option"
export { DepEffect, DepSchema, depSome }`,
)
const foreignPluginPkgDir = path.join(root, "node_modules/@opencode/plugin")
await mkdir(foreignPluginPkgDir, { recursive: true })
await writeFile(
path.join(foreignPluginPkgDir, "package.json"),
JSON.stringify({
name: "@opencode/plugin",
type: "module",
exports: { "./effect": "./effect.js", "./rpc": "./rpc.js" },
}),
)
await writeFile(
path.join(foreignPluginPkgDir, "effect.js"),
"export const Plugin = { define: (p) => p }; export const Tool = {}",
)
await writeFile(path.join(foreignPluginPkgDir, "rpc.js"), "export const Rpc = { define: (d) => d }")
await writeFile(
path.join(root, "helper.ts"),
`import { nominal } from "effect/Brand"
import { HttpClient as HelperHttpClient } from "effect/unstable/http"
import { DepEffect, DepSchema, depSome } from "transitive-dep"
export { nominal, HelperHttpClient, DepEffect, DepSchema, depSome }`,
)
const entry = path.join(root, "index.ts")
await writeFile(
entry,
`import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
import { some as pluginSome } from "effect/Option"
import { Plugin as PluginDef, Tool as PluginTool } from "@opencode/plugin/effect"
import { Rpc as PluginRpc } from "@opencode/plugin/rpc"
import { nominal, HelperHttpClient, DepEffect, DepSchema, depSome } from "./helper"
export { PluginEffect, PluginSchema, pluginSome, PluginDef, PluginTool, PluginRpc, nominal, HelperHttpClient, DepEffect, DepSchema, depSome }`,
)
const loaded = (await Host.load(pathToFileURL(entry).href)) as Record<string, unknown>
assert.equal(loaded.PluginEffect, Effect)
assert.equal(loaded.PluginSchema, Schema)
assert.equal(loaded.pluginSome, Option.some)
assert.equal(loaded.nominal, Brand.nominal)
assert.equal(loaded.HelperHttpClient, HttpClient)
assert.equal(loaded.PluginDef, Plugin)
assert.equal(loaded.PluginTool, Tool)
assert.equal(loaded.PluginRpc, Rpc)
assert.equal(loaded.DepEffect, Effect)
assert.equal(loaded.DepSchema, Schema)
assert.equal(loaded.depSome, Option.some)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it("resolves effect, effect/* subpaths, and @opencode/plugin/effect to the host under Node", async () => {
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-node-")))
try {
const pluginDir = path.join(root, "plugin")
await mkdir(path.join(pluginDir, "node_modules/effect"), { recursive: true })
await writeFile(
path.join(pluginDir, "node_modules/effect/package.json"),
JSON.stringify({
name: "effect",
version: "4.0.0-rc.111",
type: "module",
exports: { ".": "./index.js", "./Option": "./Option.js" },
}),
)
await writeFile(path.join(pluginDir, "node_modules/effect/index.js"), "export const Effect = { foreign: true }")
await writeFile(path.join(pluginDir, "node_modules/effect/Option.js"), "export const some = () => null")
await mkdir(path.join(pluginDir, "node_modules/transitive-dep"), { recursive: true })
await writeFile(
path.join(pluginDir, "node_modules/transitive-dep/package.json"),
JSON.stringify({ name: "transitive-dep", type: "module", exports: { ".": "./index.js" } }),
)
await writeFile(
path.join(pluginDir, "node_modules/transitive-dep/index.js"),
'import { Effect as DepEffect } from "effect"; import { some as depSome } from "effect/Option"; export { DepEffect, depSome }',
)
await mkdir(path.join(pluginDir, "node_modules/@opencode/plugin"), { recursive: true })
await writeFile(
path.join(pluginDir, "node_modules/@opencode/plugin/package.json"),
JSON.stringify({ name: "@opencode/plugin", type: "module", exports: { "./effect": "./effect.js" } }),
)
await writeFile(
path.join(pluginDir, "node_modules/@opencode/plugin/effect.js"),
"export const Plugin = { define: (p) => p }",
)
const entry = path.join(pluginDir, "index.mjs")
await writeFile(
entry,
`import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
import { some as pluginSome } from "effect/Option"
import { Plugin as PluginDef } from "@opencode/plugin/effect"
import { DepEffect, depSome } from "transitive-dep"
export { PluginEffect, PluginSchema, pluginSome, PluginDef, DepEffect, depSome }`,
)
const script = path.join(root, "probe.ts")
await writeFile(
script,
`import assert from "node:assert/strict"
import { Effect, Option, Schema } from ${JSON.stringify(Bun.resolveSync("effect", import.meta.dir))}
import { Plugin } from ${JSON.stringify(path.join(import.meta.dir, "../src/effect/index.ts"))}
import { Host } from ${JSON.stringify(path.join(import.meta.dir, "../src/host.ts"))}
const loaded = await Host.load(${JSON.stringify(pathToFileURL(entry).href)}) as Record<string, unknown>
assert.equal(loaded.PluginEffect, Effect)
assert.equal(loaded.PluginSchema, Schema)
assert.equal(loaded.pluginSome, Option.some)
assert.equal(loaded.PluginDef, Plugin)
assert.equal(loaded.DepEffect, Effect)
assert.equal(loaded.depSome, Option.some)
console.log("node host load passed")`,
)
const build = await Bun.build({
entrypoints: [script],
target: "node",
format: "esm",
outdir: root,
naming: "probe.mjs",
})
assert.equal(build.success, true)
const child = Bun.spawn(["node", "--no-warnings", path.join(root, "probe.mjs")], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exit] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
assert.deepEqual({ stdout, stderr, exit }, { stdout: "node host load passed\n", stderr: "", exit: 0 })
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
@@ -1,8 +1,11 @@
import { ensurePluginRuntime, pluginRuntimeModules } from "@opencode/plugin/runtime.bun"
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
ensureRuntimePluginSupport({
additional: {
...pluginRuntimeModules(),
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
},
})
ensurePluginRuntime()
+37
View File
@@ -7,6 +7,7 @@ import "../src/plugin/runtime-plugin-support.bun"
import { createPluginSources } from "../src/plugin/source"
import { createSourceWatcher } from "../src/plugin/watch"
import { createSignal } from "solid-js"
import { Effect, Option, Schema } from "effect"
import { Plugin } from "@opencode/plugin/tui"
import { tmpdir } from "./fixture/fixture"
@@ -138,6 +139,42 @@ test("shared runtime and ordinary package identities survive plugin generations"
}
})
test("TUI plugins and their dependencies resolve effect and effect/* to the host copy", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
await Bun.write(
new URL("node_modules/effect/package.json", sources.url),
'{"name":"effect","type":"module","exports":{".":"./index.js","./Option":"./Option.js"}}',
)
await Bun.write(
new URL("node_modules/effect/index.js", sources.url),
"export const Effect = { foreign: true }; export const Schema = { foreign: true }",
)
await Bun.write(new URL("node_modules/effect/Option.js", sources.url), "export const some = () => null")
await Bun.write(
new URL("node_modules/effect-helper/package.json", sources.url),
'{"name":"effect-helper","type":"module","exports":{".":"./index.js"}}',
)
await Bun.write(
new URL("node_modules/effect-helper/index.js", sources.url),
'import { Effect as HelperEffect, Schema as HelperSchema } from "effect"; import { some as helperSome } from "effect/Option"; export { HelperEffect, HelperSchema, helperSome }',
)
await Bun.write(
entry,
`import { Effect as PluginEffect, Schema as PluginSchema } from "effect"
import { some as pluginSome } from "effect/Option"
import { HelperEffect, HelperSchema, helperSome } from "effect-helper"
export { PluginEffect, PluginSchema, pluginSome, HelperEffect, HelperSchema, helperSome }`,
)
const loaded = (await sources.read(entry.href)).module as Record<string, unknown>
expect(loaded.PluginEffect).toBe(Effect)
expect(loaded.PluginSchema).toBe(Schema)
expect(loaded.pluginSome).toBe(Option.some)
expect(loaded.HelperEffect).toBe(Effect)
expect(loaded.HelperSchema).toBe(Schema)
expect(loaded.helperSome).toBe(Option.some)
})
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
@@ -3,11 +3,13 @@ title: "Effect"
---
`@opencode/plugin/effect` is the Effect-native version of the OpenCode plugin API. Its context operations return
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`. Install `effect` with the
plugin package.
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`.
OpenCode provides `effect` (including `effect/*` subpaths) and `@opencode/plugin` at runtime when loading plugins.
Install them as peer/dev dependencies for local type-checking and testing rather than bundling them into your plugin.
```sh
bun add @opencode/plugin effect
bun add --peer @opencode/plugin effect
```
Export an Effect plugin from `.opencode/plugins/` to load it automatically.
@@ -1500,7 +1502,7 @@ type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Regi
## Publish
A package plugin uses the same default export as a local Effect plugin. Export the Effect implementation from the main
entrypoint and declare both runtime dependencies.
entrypoint and declare `effect` as a `peerDependency`.
```json title="package.json"
{
@@ -1511,12 +1513,23 @@ entrypoint and declare both runtime dependencies.
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "latest",
"effect": "4.0.0-rc.111"
"@opencode/plugin": "latest"
},
"peerDependencies": {
"effect": ">=4.0.0-rc.112"
}
}
```
OpenCode redirects imports of `effect`, `effect/*` subpaths, and `@opencode/plugin` (including from dependencies in the
plugin's `node_modules`) to the host's runtime instances so fibers, loggers, and `Schema` parsers share one runtime
copy.
- Do not bundle `effect` into the published plugin artifact. If you build with a bundler, mark `effect` and `effect/*`
external; OpenCode rejects Effect plugins whose returned `Effect` comes from a bundled copy.
- Because the plugin always runs against the host OpenCode release's `effect` instance, use `effect` APIs and module
paths compatible with the OpenCode release you target.
The package entrypoint exports `Plugin.define` with an `effect` function.
```ts title="src/index.ts"