Skip to content

fix(cli): give plugins the host's Effect - #53422

Merged
kitlangton merged 15 commits into
v2from
plugin-host-modules
Oct 7, 2026
Merged

kitlangton merged 15 commits into
v2from
plugin-host-modules

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Why

When a server or TUI plugin (or one of its node_modules dependencies) resolves its own copy of effect or @opencode/plugin instead of the host's runtime instances, the two copies cannot share module-private symbols, fiber internals, or Schema AST sentinels. In practice, loading a plugin with its own effect copy produces four concrete runtime failures:

  1. Effect.log from the plugin's copy crashes inside a host fiber (TypeError: undefined is not an object (evaluating 'logLevel.toUpperCase')).
  2. Effect.runPromise of a host Effect crashes (TypeError: fiber.succeedWith is not a function).
  3. Tool input schemas using Schema.withDecodingDefault crash when decoded by the host (TypeError: transformations[i] is not a function).
  4. Even two copies of the exact same effect version reject valid inputs on Schema.Int, Schema.Trim, and Schema.isPattern checks across ToolRuntime.execute and Rpc.call (Expected an integer).

Following the prior art of name-based host-provided module tables in extension hosts (such as VS Code providing vscode and Atom providing atom by module name), OpenCode provides its host effect and @opencode/plugin runtime modules to plugins via Bun's native build.module table. (Once oven-sh/bun#40398 lands so runtime resolution hooks apply uniformly across nested node_modules static imports, the per-specifier build.module table + build.onLoad guard can simplify further.)

Supersedes #53296.

What Changes

Import from plugin or plugin node_modules Before After
effect, exported effect/* subpaths (effect/Option, effect/unstable/http, effect/testing, …) Resolves to plugin's own node_modules/effect when present Resolves to the OpenCode CLI host's effect module instance
@opencode/plugin, exported @opencode/plugin/* subpaths (@opencode/plugin/effect, @opencode/plugin/rpc, @opencode/plugin/tui, …) Resolves to plugin's own node_modules/@opencode/plugin when present Resolves to the OpenCode CLI host's @opencode/plugin module instance
effect/package.json Resolves to JSON manifest Continues to work (onLoad guard excludes .json)
Unprovided or removed effect/* / @opencode/plugin/* module (e.g. effect/RemovedSubpath, effect/internal/*) present in plugin's node_modules Silently falls through to plugin's copy and mixes two Effect runtimes Fails loudly at plugin load with Cannot load "<pkg>/<path>" from plugin node_modules: "<pkg>/<path>" is not provided by OpenCode
HttpApiScalar / HttpApiSwagger inline UI assets in compiled CLI binary Either unbundled or adds ~17 MiB of inline browser bundles Serves a short notice ("Scalar/Swagger UI assets are not bundled in OpenCode") without bundling the 5.1 MiB inline browser scripts
flowchart TD
  A["Plugin or transitive node_modules import"] --> B{"provides(specifier)?<br/>(effect, effect/*, @opencode/plugin, @opencode/plugin/*)"}
  B -- "Yes (preserved by OpenTUI 0.5.15 preserve: provides)" --> C["build.module(specifier)<br/>returns host module instance"]
  B -- "No" --> D{"Resolves to foreign<br/>node_modules/{effect,@opencode/plugin}/*.{js,mjs,cjs,ts,tsx}?"}
  D -- "Yes" --> E["build.onLoad guard throws loud load error<br/>(no silent Effect mixing)"]
  D -- "No (e.g. effect/package.json)" --> F["Native Bun loader"]
Loading

Host Module Redirection

  • @opencode/plugin/runtime (packages/plugin/src/runtime.ts, packages/plugin/src/runtime-modules.ts): Owned by @opencode/plugin (the package that loads plugins via Host.load). ensurePluginRuntime() runs once at CLI startup (packages/cli/src/index.ts) and in TUI plugin support (packages/tui/src/plugin/runtime-plugin-support.bun.ts), registering every host effect/* and @opencode/plugin/* specifier with build.module(specifier, ...) and exporting provides(specifier) for direct use by source.bun.ts and runtime-plugin-support.bun.ts (without any globalThis symbol).
  • Export-validated specifier discovery (packages/plugin/src/runtime-modules.ts): discoverPluginRuntimeSpecifiers() derives the scanned source tree and file extension from the package's resolved root entrypoint (Bun.resolveSync(pkgName, from)), scans that tree (dist/**/*.js or src/**/*.ts, excluding internal/**, source.*, and runtime*), and resolves every candidate through Bun.resolveSync(specifier, from) so unexported files and stale packages/plugin/dist builds are ignored. Workspace @opencode/plugin paths are resolved through their node_modules/@opencode/plugin symlink (findNodeModulesDir) so OpenTUI's isNodeModulesPath check does not wrap host workspace files in async rewrite loaders.
  • OpenTUI 0.5.15 preserve: provides integration (packages/tui/src/plugin/runtime-plugin-support.bun.ts): Bumps @opentui/* to 0.5.15 and passes preserve: provides alongside additional: { "@opencode/plugin/tui": ... } to ensureRuntimePluginSupport. OpenTUI leaves effect and @opencode/plugin bare specifiers untouched and skips following them during node_modules prescanning, so Bun's build.module table resolves static import, dynamic import(), and CJS require() directly without any file:// or absolute-path build.onResolve workaround.
  • Foreign node_modules load guard: Because Bun's runtime resolver does not invoke JS build.onResolve hooks for nested static imports in node_modules, ensurePluginRuntime() installs a narrow build.onLoad guard matching foreign node_modules/{effect,@opencode/plugin} and Bun auto-install cache ({effect,@opencode/plugin}@<ver>@@@<n>) JS/TS files while excluding the host's own package roots (separator-agnostic on Windows) and .json manifests.
  • Skip missing-package watches for host-provided specifiers (packages/plugin/src/source.bun.ts): prepareSource checks provides(item.path) before probing Bun.resolveSync so zero-install local plugins importing effect or @opencode/plugin do not register missing-package watchers on node_modules/effect or node_modules/@opencode/plugin.
  • Whole-module swap in compiled binary build (packages/cli/script/build.ts): At compile time, pluginRuntimePlugin swaps packages/plugin/src/runtime-modules.ts via build.onLoad with the generated loadRuntimeModules table, routing effect/<parent>/<member> lookups through their parent barrel (() => require(parent)[member]) only when require(parentResolved)[member] === require(resolved) (preserving side-effect and non-re-exported subpaths across 4.0.0-rc.112, 4.0.0-rc.118, and 4.0.0), and replaces internal/httpApiScalar.js and internal/httpApiSwagger.js with a 1-line notice stub while keeping effect/testing/FastCheck real.

Scope

  • build.module is process-global and importer-blind, so plugins and all dependencies inside their node_modules always receive OpenCode's host effect instance. Libraries inside a plugin's node_modules built on another effect major (such as Effect 3) also receive the host's Effect 4 and are not supported (pinned by test and documented in packages/plugin/README.md and services/www/src/docs/content/build/plugins/effect.mdx).
  • Removed or unprovided effect/* and @opencode/plugin/* module paths fail loudly at plugin load rather than silently loading a second copy from the plugin's node_modules.
  • Registration is invoked only by the CLI (packages/cli/src/index.ts) and TUI runtime plugin support; SDK and Node embedders are unaffected.

Verification

# Full repo lint, ast-grep, sdk-docs, and 36-package typecheck
bun run check
bun run lint:changed

# Package test suites
bun run --cwd packages/plugin test
bun test --cwd packages/core test/plugin/module.test.ts test/plugin/supervisor-reload.test.ts test/plugin.test.ts test/tool-shell.test.ts
bun test --cwd packages/tui --timeout 30000 test/plugin-source.test.ts test/plugin-discovery.test.ts test/plugin-markdown.test.tsx test/plugin-model.test.tsx test/plugin-select.test.tsx test/plugin-toast.test.tsx
bun run --cwd packages/server test
bun test --cwd packages/cli test/import-boundaries.test.ts test/standalone.test.ts test/mini-host.test.ts

# Single-target compiled binary build and server + TUI e2e verification
bun run --cwd packages/cli script/build.ts --single --skip-install --skip-web-ui
  • Fail-without evidence:
    • In packages/core/test/plugin/module.test.ts, commenting out ensurePluginRuntime() causes the plugin integration tests to fail with TypeError: undefined is not an object (evaluating 'logLevel.toUpperCase'), silent RemovedLegacySubpath.js loading, and { major: 3 } resolution; testing the pre-fix discoverPluginRuntimeSpecifiers with packages/plugin/dist present splits @opencode/plugin/effect (src/effect/index.ts) from @opencode/plugin/effect/plugin (dist/effect/plugin.js); testing pre-fix member in require(parent) misroutes effect/schema/SchemaJITCompiler/enable.
    • In packages/tui/src/plugin/runtime-plugin-support.bun.ts, omitting preserve: provides fails TUI plugin .ts loads with Cannot load "effect/dist/esm/index.js" from plugin node_modules; omitting findNodeModulesDir / toLoadPath fails createRequire(import.meta.url)("@opencode/plugin/effect") with TypeError: require() async module ".../packages/plugin/src/effect/index.ts" is unsupported; omitting the "effect" pre-require fails standalone cold require("effect") with TypeError: require() async module "effect" is unsupported.
  • Compiled binary server + TUI e2e, size, and startup (darwin-arm64):
    • Spawned dist/cli-darwin-arm64/bin/opencode serve --stdio --port=0 AND launched the compiled TUI in a PTY (opencode --server=...) against a fixture plugin containing both index.ts (server) and tui.ts (TUI), a sabotaged node_modules/effect (4.0.0-rc.111), a foreign node_modules/@opencode/plugin, a transitive-dep using Schema.withDecodingDefault, dynamic await import("effect/Option") and await import("@opencode/plugin/effect"), and a local helper.ts importing "zod" from the plugin's node_modules/zod: both server and TUI plugins activated against the host's Effect, Schema, Option.some, FastCheck, HttpApiScalar/HttpApiSwagger, and @opencode/plugin while resolving "zod" from the plugin's own node_modules/zod and failing the unprovided-subpath plugin cleanly at load.
    • Binary size: 154,066,674 bytes (146.93 MiB) on origin/v2 → 162,207,090 bytes (154.69 MiB, +7.76 MiB vs +25.00 MiB in fix(plugin): load Effect from the host for plugins #53296).
    • Startup (opencode --version, 11-run median): 53.69 ms on origin/v2 → 62.03 ms (58.26 ms min).

@kitlangton
kitlangton merged commit c28e9ec into v2 Oct 7, 2026
13 of 14 checks passed
@kitlangton
kitlangton deleted the plugin-host-modules branch October 7, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant