diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 57a66f89a5c4..214ff46ec09b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -23,6 +23,7 @@ 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" @@ -93,6 +94,7 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { + yield* plugin.init() const cfg = yield* config.get() const skillDirs = yield* skill.dirs() const whitelistedDirs = [ @@ -457,7 +459,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), diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 6ef2ab780dc2..5eecb7959264 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -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" @@ -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 = {} @@ -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), ) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 478114209207..b2ee6ba3ad6d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -30,6 +30,8 @@ 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" const log = Log.create({ service: "plugin" }) @@ -37,6 +39,84 @@ type State = { hooks: Hooks[] } +type BunServeOptions = { + hostname?: string + port?: number + fetch: (request: Request) => Response | Promise +} + +type BunServeResult = { + url: URL + stop(force?: boolean): void +} + +type BunCompat = { + 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 ensureBunCompat() { + const runtime = globalThis as unknown as { Bun?: BunCompat } + if (runtime.Bun?.serve) return + + runtime.Bun = { + ...runtime.Bun, + which: runtime.Bun?.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() + }, + } + }, + } +} + // Hook names that follow the (input, output) => Promise trigger pattern type TriggerName = { [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never @@ -179,6 +259,8 @@ export const layer = Layer.effect( } if (plugins.length) yield* config.waitForDependencies() + ensureBunCompat() + const loaded = yield* Effect.promise(() => PluginLoader.loadExternal({ items: plugins,