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
4 changes: 3 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -93,6 +94,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 @@ -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),
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
82 changes: 82 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,93 @@ 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" })

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 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<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 @@ -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,
Expand Down
Loading