Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 77f7642d09 fix(plugin): serve bundled SDK to loose plugin files on Bun
Loose plugin files under .opencode/plugins or configured by path have no
node_modules, so the compiled Bun executable failed to resolve
@opencode/plugin. Register the host's own SDK modules as Bun virtual modules
when a plugin loads, matching the module hooks the Node executable already
registers in its bundle prelude.
2026-09-14 22:02:14 -05:00
8 changed files with 79 additions and 4 deletions
+3 -1
View File
@@ -143,8 +143,10 @@ function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
}
// A loose plugin file has no node_modules: the compiled executable must serve
// its bundled SDK to this import.
function pluginSource() {
return 'export default { id: "smoke", setup: async () => {} }\n'
return 'import { Plugin } from "@opencode/plugin"\nexport default Plugin.define({ id: "smoke", setup: async () => {} })\n'
}
async function pluginIDs(url: string, headers: HeadersInit) {
+5
View File
@@ -21,6 +21,11 @@
"bun": "./src/source.bun.ts",
"node": "./src/source.node.ts",
"default": "./src/source.node.ts"
},
"#plugin-runtime": {
"bun": "./src/runtime.bun.ts",
"node": "./src/runtime.node.ts",
"default": "./src/runtime.node.ts"
}
},
"files": [
+3 -1
View File
@@ -43,6 +43,8 @@ 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> {
const { ensureRuntimeModules } = await import("#plugin-runtime")
ensureRuntimeModules()
return importModule(entrypoint)
}
+29
View File
@@ -0,0 +1,29 @@
import { plugin } from "bun"
// Loose plugin files have no node_modules, so Bun cannot find the SDK the host
// already bundles. Serve the host's own copy so plugins and OpenCode share one
// module identity, matching the Node executable's module hooks. The TUI shims
// `@opencode/plugin/tui` separately with the OpenTUI runtime modules it needs.
const modules: Record<string, () => Promise<object>> = {
"@opencode/plugin": () => import("./promise/index.js"),
"@opencode/plugin/promise/plugin": () => import("./promise/plugin.js"),
"@opencode/plugin/promise/tool": () => import("./promise/tool.js"),
"@opencode/plugin/effect": () => import("./effect/index.js"),
"@opencode/plugin/effect/plugin": () => import("./effect/plugin.js"),
"@opencode/plugin/effect/tool": () => import("./effect/tool.js"),
}
let installed = false
export function ensureRuntimeModules() {
if (installed) return
installed = true
plugin({
name: "opencode-plugin-runtime",
setup(build) {
Object.entries(modules).forEach(([specifier, load]) => {
build.module(specifier, async () => ({ exports: { ...(await load()) }, loader: "object" }))
})
},
})
}
+3
View File
@@ -0,0 +1,3 @@
// The Node executable serves `@opencode/plugin` to loaded plugins through the
// module hooks registered in its bundle prelude (packages/cli/vite.node.config.ts).
export function ensureRuntimeModules() {}
+28
View File
@@ -154,3 +154,31 @@ describe("Host.resolve", () => {
assert.deepEqual(Host.resolve(plugin.target), { server: undefined, tui: undefined, rpc: undefined })
})
})
describe("Host.load", () => {
it("serves the host's own SDK to loose plugin files without node_modules", async () => {
await using plugin = await fixture({
"plugin.ts": [
'import { Plugin, Provider } from "@opencode/plugin"',
'import { Plugin as EffectPlugin } from "@opencode/plugin/effect"',
'import { define } from "@opencode/plugin/promise/plugin"',
"export const sdk = { Plugin, Provider, EffectPlugin, define }",
'export default Plugin.define({ id: "loose", setup() {} })',
].join("\n"),
})
const [promise, effect, define] = await Promise.all([
import("../src/promise/index.js"),
import("../src/effect/index.js"),
import("../src/promise/plugin.js"),
])
const loaded = (await Host.load(plugin.url("plugin.ts"))) as {
default: { id: string }
sdk: Record<string, unknown>
}
assert.equal(loaded.default.id, "loose")
assert.equal(loaded.sdk.Plugin, promise.Plugin)
assert.equal(loaded.sdk.Provider, promise.Provider)
assert.equal(loaded.sdk.EffectPlugin, effect.Plugin)
assert.equal(loaded.sdk.define, define.define)
})
})
@@ -18,8 +18,11 @@ export default Plugin.define({
})
```
Plugins under `.opencode/plugins/` are loaded automatically, like the local example above. To load published packages
or plugin directories from other locations, add them to `plugins` in `opencode.json(c)`:
Plugins under `.opencode/plugins/` are loaded automatically, like the local example above. Import `@opencode/plugin`
directly: OpenCode resolves it at runtime, so local plugins do not need a `package.json` or an install step. Add
`@opencode/plugin` as a development dependency only when you want editor types.
To load published packages or plugin directories from other locations, add them to `plugins` in `opencode.json(c)`:
```jsonc title="opencode.jsonc"
{
@@ -50,6 +50,9 @@ OpenCode also loads direct `.ts` and `.js` files and immediate plugin package di
└── acme-package/
```
Local files can import `@opencode/plugin` without installing it; OpenCode resolves the package at runtime. Other
dependencies must be installed next to the plugin.
Global plugins use the same discovery layout under the OpenCode config directory.
```text