Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { type DeepMutable } from "@opencode-ai/core/schema"
Expand Down Expand Up @@ -91,6 +93,7 @@ export const layer = Layer.effect(

const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
yield* plugin.init()
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [
Expand Down Expand Up @@ -423,7 +426,7 @@ export const layer = Layer.effect(
)

export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Plugin.layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer))),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { Effect, Layer, Context, Schema } from "effect"
import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import { Plugin } from "@/plugin"
import { EventV2Bridge } from "@/event-v2-bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2 } from "@opencode-ai/core/event"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
Expand Down Expand Up @@ -67,9 +70,11 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service

const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
yield* plugin.init()
const cfg = yield* config.get()
const bridge = yield* EffectBridge.make()
const commands: Record<string, Info> = {}
Expand Down Expand Up @@ -173,8 +178,9 @@ export const layer = Layer.effect(
)

export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Plugin.layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer))),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)

Expand Down
101 changes: 100 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,112 @@ import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { spawnSync } from "node:child_process"
import { createServer } from "node:http"
import { readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"

const log = Log.create({ service: "plugin" })

type State = {
hooks: Hooks[]
}

type BunServeOptions = {
hostname?: string
port?: number
fetch: (request: Request) => Response | Promise<Response>
}

type BunServeResult = {
url: URL
stop(force?: boolean): void
}

type BunServeCompat = {
serve?: (options: BunServeOptions) => BunServeResult
which?: (command: string) => string | null
}

function which(command: string) {
const result = spawnSync(
process.platform === "win32" ? "where.exe" : "command",
process.platform === "win32" ? [command] : ["-v", command],
{
encoding: "utf8",
shell: process.platform !== "win32",
windowsHide: true,
},
)
if (result.status !== 0) return null
return result.stdout.split(/\r?\n/).find((line) => line.trim())?.trim() ?? null
}

function port(input: number | undefined) {
if (input && input > 0) return input
return 20_000 + Math.floor(Math.random() * 40_000)
}

function withBunServeCompat<T>(enabled: boolean, callback: () => Promise<T>) {
if (!enabled) return callback()

const runtime = globalThis as unknown as { Bun?: BunServeCompat }
const original = runtime.Bun
if (original?.serve) return callback()

runtime.Bun = {
...original,
which: original?.which ?? which,
serve(options: BunServeOptions) {
const hostname = options.hostname ?? "127.0.0.1"
const listenPort = port(options.port)
const server = createServer(async (incoming, outgoing) => {
try {
const response = await options.fetch(
new Request(new URL(incoming.url ?? "/", `http://${hostname}:${listenPort}`), {
method: incoming.method,
headers: incoming.headers as HeadersInit,
body:
incoming.method === "GET" || incoming.method === "HEAD"
? undefined
: (incoming as unknown as BodyInit),
}),
)
outgoing.statusCode = response.status
response.headers.forEach((value, key) => outgoing.setHeader(key, value))
outgoing.end(Buffer.from(await response.arrayBuffer()))
} catch (error) {
log.error("Bun.serve compatibility handler failed", { error })
outgoing.statusCode = 500
outgoing.end("Internal Server Error")
}
})
server.listen(listenPort, hostname)

return {
url: new URL(`http://${hostname}:${listenPort}`),
stop() {
server.close()
},
}
},
}

return Promise.resolve()
.then(callback)
.finally(() => {
if (original) runtime.Bun = original
else delete runtime.Bun
})
}

export async function needsBunServeCompat(load: Pick<PluginLoader.Loaded, "entry">) {
const source = await readFile(load.entry.startsWith("file://") ? fileURLToPath(load.entry) : load.entry, "utf8").catch(
() => "",
)
return /\b[Bb]un\.serve\s*\(/.test(source) || /\b[Bb]un\.which\s*\(/.test(source) || source.includes("requires Bun.serve")
}

// Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
Expand Down Expand Up @@ -226,7 +325,7 @@ export const layer = Layer.effect(
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
yield* Effect.tryPromise({
try: () => applyPlugin(load, input, hooks),
try: async () => withBunServeCompat(await needsBunServeCompat(load), () => applyPlugin(load, input, hooks)),
catch: (err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: load.spec, error: message })
Expand Down
55 changes: 55 additions & 0 deletions packages/opencode/test/cli/tui/plugin-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,67 @@ import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Filesystem } from "@/util/filesystem"
import { PluginLoader } from "../../../src/plugin/loader"
import { needsBunServeCompat } from "../../../src/plugin"

const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")

type Row = Record<string, unknown>

test("detects Bun serve compatibility from plugin entry source", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const bunServePlugin = path.join(dir, "bun-serve-plugin.js")
const contextModePlugin = path.join(dir, "context-mode-plugin.js")
await Bun.write(
bunServePlugin,
`const bun = runtime.Bun
if (!bun) throw new Error("Runtime skill source server requires Bun.serve")
bun.serve({ fetch() { return new Response("ok") } })
`,
)
await Bun.write(
contextModePlugin,
`if (globalThis.Bun) {
require(["bun", "sqlite"].join(":")).Database
} else {
require(["node", "sqlite"].join(":")).DatabaseSync
}
`,
)
return { bunServePlugin, contextModePlugin }
},
})

await expect(needsBunServeCompat({ entry: tmp.extra.bunServePlugin })).resolves.toBe(true)
await expect(needsBunServeCompat({ entry: tmp.extra.contextModePlugin })).resolves.toBe(false)
})

test("detects Bun serve compatibility without a Bun global", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "bun-serve-plugin.js")
await Bun.write(file, `const server = bun.serve({ fetch() { return new Response("ok") } })\n`)
return { file }
},
})

const runtime = globalThis as { Bun?: unknown }
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Bun")
if (descriptor?.configurable === false) {
await expect(needsBunServeCompat({ entry: tmp.extra.file })).resolves.toBe(true)
return
}

try {
delete runtime.Bun
await expect(needsBunServeCompat({ entry: tmp.extra.file })).resolves.toBe(true)
} finally {
if (descriptor) Object.defineProperty(globalThis, "Bun", descriptor)
else delete runtime.Bun
}
})

test("does not retry permanent file plugin load errors", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
Expand Down
Loading