Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion packages/cli/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Script } from "@opencode/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@opencode/plugin/runtime-modules"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
Expand Down Expand Up @@ -82,6 +83,30 @@ export default () => readFileSync(archive)`,
}))
},
}
const pluginRuntimeEntries = discoverPluginRuntimeSpecifiers()
const pluginRuntimeModulesSource = [
"export const resolveHostPackageRoots = () => []",
"const modules = {",
...[...pluginRuntimeEntries.keys()].map(
(specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier, pluginRuntimeEntries)},`,
),
"}",
"export const loadRuntimeModules = () => modules",
].join("\n")
const pluginRuntimePlugin: BunPlugin = {
name: "opencode-plugin-runtime",
setup(build) {
build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.ts$/ }, () => ({
contents: pluginRuntimeModulesSource,
loader: "ts",
}))
build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({
contents:
'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'',
loader: "js",
}))
},
}

for (const item of targets) {
const opencodePty = await resolveOpencodePty({
Expand Down Expand Up @@ -128,7 +153,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,
pluginRuntimePlugin,
simulationGraphPlugin,
],
external: ["node-gyp"],
format: "esm",
minify: true,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env bun

import { ensurePluginRuntime } from "@opencode/plugin/runtime"
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
Expand All @@ -17,6 +18,8 @@ import { EffectFlock } from "@opencode/util/effect-flock"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"

ensurePluginRuntime()

if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
const { askpass } = await import("./ssh-askpass")
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
Expand Down
310 changes: 306 additions & 4 deletions packages/core/test/plugin/module.test.ts

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions packages/plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# `@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`

When the OpenCode CLI loads server or TUI plugins, it resolves imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` entrypoints (including imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances so fibers, loggers, and `Schema` parsers share one copy.

- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking and testing) rather than a bundled runtime dependency.
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. Two copies of `effect` do not share fiber, logger, or `Schema` internals.
- Plugins and their `node_modules` dependencies always receive OpenCode's host `effect` instance. Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another `effect` major (such as Effect 3) are not supported.
- Only public `effect` subpaths are provided. A plugin that imports one of Effect's private `internal` modules fails to load with an error naming the path.
- The compiled OpenCode binary does not include the Scalar and Swagger UI assets used by Effect's HTTP API docs pages; serving those pages from a plugin shows a notice instead.
1 change: 1 addition & 0 deletions packages/plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
".": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts",
"./host": "./src/host.ts",
"./runtime": "./src/runtime.ts",
"./tui": "./src/tui/index.ts",
"./*": "./src/*.ts"
},
Expand Down
113 changes: 113 additions & 0 deletions packages/plugin/src/runtime-modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { existsSync, realpathSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"

export type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>

const runtimePackages = ["effect", "@opencode/plugin"] as const

export function resolveHostPackageRoots(from = import.meta.dir, packages: readonly string[] = runtimePackages) {
return packages.flatMap((pkgName) => {
const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from))
return [dir, findNodeModulesDir(pkgName, from, dir)]
})
}

export function loadRuntimeModules(): Readonly<Record<string, RuntimeModuleLoader>> {
const entries = discoverPluginRuntimeSpecifiers()
const effectEntry = entries.get("effect")
if (effectEntry) require(effectEntry)
return Object.fromEntries(
[...entries.entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]),
)
}

export function discoverPluginRuntimeSpecifiers(
from = import.meta.dir,
packages: readonly string[] = runtimePackages,
): ReadonlyMap<string, string> {
const entries = new Map<string, string>()
for (const pkgName of packages) {
const realDir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from))
// Resolve workspace @opencode/plugin paths via node_modules so OpenTUI does not wrap host files in async rewrite loaders.
const loadDir = findNodeModulesDir(pkgName, from, realDir)
const toLoadPath = (resolved: string) =>
loadDir === realDir ? resolved : path.join(loadDir, path.relative(realDir, resolved))
const rootEntry = Bun.resolveSync(pkgName, from)
const relParts = path.relative(realDir, rootEntry).replaceAll("\\", "/").split("/")
const scanDir = relParts.length > 1 ? path.join(realDir, relParts[0]) : realDir
const ext = path.extname(rootEntry) || ".js"
entries.set(pkgName, toLoadPath(rootEntry))
for (const file of new Bun.Glob(`**/*${ext}`).scanSync({ cwd: scanDir })) {
const normalized = file.replaceAll("\\", "/")
if (
normalized.startsWith("internal/") ||
normalized.includes("/internal/") ||
normalized.startsWith("source.") ||
normalized.startsWith("runtime")
) {
continue
}
const base = normalized.slice(0, -ext.length)
if (base === "index") continue
const candidates = base.endsWith("/index")
? [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`]
: [`${pkgName}/${base}`]
for (const specifier of candidates) {
if (entries.has(specifier)) continue
try {
entries.set(specifier, toLoadPath(Bun.resolveSync(specifier, from)))
} catch {}
}
}
}
return entries
}

export function pluginRuntimeLoaderCode(specifier: string, entries: ReadonlyMap<string, string>) {
if (specifier.startsWith("effect/")) {
const slash = specifier.lastIndexOf("/")
const parent = specifier.slice(0, slash)
const member = specifier.slice(slash + 1)
const parentResolved = entries.get(parent)
const resolved = entries.get(specifier)
if (
member !== "index" &&
parentResolved &&
resolved &&
(require(parentResolved) as Record<string, unknown>)[member] === require(resolved)
) {
return `() => require(${JSON.stringify(parent)})[${JSON.stringify(member)}]`
}
}
return `() => require(${JSON.stringify(specifier)})`
}

export function createLoader(resolved: string): RuntimeModuleLoader {
let cached: Record<string, unknown> | undefined
let pending: Promise<Record<string, unknown>> | undefined
return () => {
if (cached) return cached
if (pending) return pending
try {
return (cached = require(resolved) as Record<string, unknown>)
} catch {
return (pending = import(pathToFileURL(resolved).href).then(
(mod: Record<string, unknown>) => (cached = mod),
(error) => {
pending = undefined
throw error
},
))
}
}
}

function findNodeModulesDir(pkgName: string, from: string, realDir: string) {
if (/[/\\]node_modules[/\\]/.test(realDir)) return realDir
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", pkgName)
if (existsSync(candidate) && realpathSync(candidate) === realDir) return candidate
if (path.dirname(dir) === dir) return realDir
}
}
52 changes: 52 additions & 0 deletions packages/plugin/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { loadRuntimeModules, resolveHostPackageRoots, type RuntimeModuleLoader } from "./runtime-modules.js"

const foreignPkgSuffix = String.raw`(?:node_modules[/\\](@opencode[/\\]plugin|effect)|(@opencode[/\\]plugin|effect)@[^/\\]+@@@\d+)`
const foreignPkgPattern = new RegExp(String.raw`^(.*[/\\]${foreignPkgSuffix})[/\\](.+)$`)

let installed: Readonly<Record<string, RuntimeModuleLoader>> | undefined

export function provides(specifier: string) {
return installed !== undefined && Object.hasOwn(installed, specifier)
}

export function ensurePluginRuntime() {
if (typeof Bun === "undefined") return {}
if (installed) return installed
const modules = loadRuntimeModules()
installed = modules
Bun.plugin({
name: "opencode-plugin-runtime",
setup(build) {
for (const [specifier, load] of Object.entries(modules)) {
build.module(specifier, () => {
const exports = load()
return exports instanceof Promise
? exports.then((value) => ({ exports: value, loader: "object" as const }))
: { exports, loader: "object" as const }
})
}
build.onLoad({ filter: createForeignPackageFilter() }, (args) => {
const match = args.path.match(foreignPkgPattern)
const target = match ? `${match[2] ?? match[3]}/${match[4]}`.replaceAll("\\", "/") : args.path
throw new Error(
`Cannot load "${target}" from plugin node_modules: "${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.`,
)
})
},
})
return modules
}

export function createForeignPackageFilter(rootsInput: Iterable<string> = resolveHostPackageRoots()) {
const suffix = String.raw`[/\\]${foreignPkgSuffix}[/\\].*\.[cm]?[jt]sx?(?:[?#].*)?$`
const roots = [...new Set(rootsInput)]
if (roots.length === 0) return new RegExp(suffix)
const escaped = roots
.map((value) =>
value
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/(?:\\\/|\\\\|\/)+/g, "[/\\\\]"),
)
.join("|")
return new RegExp(`^(?!(?:${escaped})[/\\\\]).*${suffix}`)
}
2 changes: 2 additions & 0 deletions packages/plugin/src/source.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Host } from "./host.js"
import { provides } from "./runtime.js"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"

Expand Down Expand Up @@ -39,6 +40,7 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
? new URL(item.path, pathToFileURL(file))
: localSource(item.path, path.dirname(file))
if (!local) {
if (provides(item.path)) continue
try {
Bun.resolveSync(item.path, path.dirname(file))
} catch {
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin/test/host.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { 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"
Expand All @@ -11,7 +11,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]) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/plugin/runtime-plugin-support.bun.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { ensurePluginRuntime, provides } from "@opencode/plugin/runtime"
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"

ensurePluginRuntime()
ensureRuntimePluginSupport({
additional: {
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
},
preserve: provides,
})
Loading
Loading