Skip to content
Draft
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
75 changes: 75 additions & 0 deletions packages/opencode/src/plugin/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Hooks, Plugin, PluginInput, PluginModule } from "@opencode-ai/plugin"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
import { CodexAuthPlugin } from "./openai/codex"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex"
import type { RuntimeFlags } from "@/effect/runtime-flags"
import { PluginLoader } from "./loader"
import { readPluginId, readV1Plugin, resolvePluginId } from "./shared"

export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
}

export function internalPlugins(flags: RuntimeFlags.Info): Plugin[] {
return [
(input) =>
CodexAuthPlugin(input, {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
gitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
SnowflakeCortexAuthPlugin,
XaiAuthPlugin,
]
}

function isServerPlugin(value: unknown): value is Plugin {
return typeof value === "function"
}

function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin(value.server)) return
return value.server
}

function getLegacyPlugins(mod: Record<string, unknown>) {
const seen = new Set<unknown>()
const result: Plugin[] = []

for (const entry of Object.values(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
const plugin = getServerPlugin(entry)
if (!plugin) throw new TypeError("Plugin export is not a function")
result.push(plugin)
}

return result
}

export async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
hooks.push(await (plugin as PluginModule).server(input, load.options))
return
}

for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, load.options))
}
}
190 changes: 8 additions & 182 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,21 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import type {
Hooks,
PluginInput,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdapter as PluginWorkspaceAdapter,
} from "@opencode-ai/plugin"
import type { Hooks } from "@opencode-ai/plugin"
import { Config } from "@/config/config"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./openai/codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters"
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 { errorMessage } from "@/util/error"
import { makeInput } from "./input"
import { loadExternalPlugins, loadInternalPlugins } from "./loading"

type State = {
hooks: Hooks[]
}

// 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
}[keyof Hooks]
Expand All @@ -57,68 +36,7 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}

export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
}

// Built-in plugins that are directly imported (not installed from npm)
function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
return [
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
(input) =>
CodexAuthPlugin(input, {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
SnowflakeCortexAuthPlugin,
XaiAuthPlugin,
]
}

function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
}

function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin(value.server)) return
return value.server
}

function getLegacyPlugins(mod: Record<string, unknown>) {
const seen = new Set<unknown>()
const result: PluginInstance[] = []

for (const entry of Object.values(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
const plugin = getServerPlugin(entry)
if (!plugin) throw new TypeError("Plugin export is not a function")
result.push(plugin)
}

return result
}

async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
hooks.push(await (plugin as PluginModule).server(input, load.options))
return
}

for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, load.options))
}
}
export { experimentalWebSocketsEnabled } from "./discovery"

const layer = Layer.effect(
Service,
Expand All @@ -136,108 +54,16 @@ const layer = Layer.effect(
bridge.fork(events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
}

const { Server } = yield* Effect.promise(() => import("../server/server"))

const serverUrl = Server.url
const client = createOpencodeClient({
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
})
const input = yield* makeInput(ctx)
const cfg = yield* config.get()
const input: PluginInput = {
client,
project: ctx.project,
worktree: ctx.worktree,
directory: ctx.directory,
experimental_workspace: {
register(type: string, adapter: PluginWorkspaceAdapter) {
registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)
},
},
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}

for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
const init = yield* Effect.tryPromise({
try: () => plugin(input),
catch: errorMessage,
}).pipe(
Effect.tapError((error) => Effect.logError("failed to load internal plugin", { name: plugin.name, error })),
Effect.option,
)
if (init._tag === "Some") hooks.push(init.value)
}
yield* loadInternalPlugins(flags, input, hooks)

const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
if (flags.pure && cfg.plugin_origins?.length) {
}
if (plugins.length) yield* config.waitForDependencies()
yield* loadExternalPlugins({ origins: plugins, pluginInput: input, hooks, publishPluginError })

const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: plugins,
kind: "server",
report: {
start(candidate) {},
missing(candidate, _retry, message) {},
error(candidate, _retry, stage, error, resolved) {
const spec = candidate.plan.spec
const cause = error instanceof Error ? (error.cause ?? error) : error
const message = stage === "load" ? errorMessage(error) : errorMessage(cause)

if (stage === "install") {
const parsed = parsePluginSpecifier(spec)
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
return
}

if (stage === "compatibility") {
publishPluginError(`Plugin ${spec} skipped: ${message}`)
return
}

if (stage === "entry") {
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
return
}

publishPluginError(`Failed to load plugin ${spec}: ${message}`)
},
},
}),
)
for (const load of loaded) {
if (!load) continue

// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
yield* Effect.tryPromise({
try: () => applyPlugin(load, input, hooks),
catch: (err) => {
const message = errorMessage(err)
return message
},
}).pipe(
Effect.tapError((error) => Effect.logError("failed to load plugin", { path: load.spec, error })),
Effect.catch(() => {
// TODO: make proper events for this
// events.publish(Session.Event.Error, {
// error: new NamedError.Unknown({
// message: `Failed to load plugin ${load.spec}: ${message}`,
// }).toObject(),
// })
return Effect.void
}),
)
}

// Notify plugins of current config
for (const hook of hooks) {
yield* Effect.tryPromise({
try: () => Promise.resolve((hook as any).config?.(cfg)),
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/src/plugin/input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { PluginInput, WorkspaceAdapter } from "@opencode-ai/plugin"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Effect } from "effect"
import { registerAdapter } from "@/control-plane/adapters"
import { ServerAuth } from "@/server/auth"
import type { InstanceContext } from "@/project/instance-context"

export const makeInput = Effect.fnUntraced(function* (ctx: InstanceContext) {
const { Server } = yield* Effect.promise(() => import("../server/server"))
const serverUrl = Server.url
const client = createOpencodeClient({
baseUrl: serverUrl?.toString() ?? "http://localhost:4096",
directory: ctx.directory,
headers: ServerAuth.headers(),
...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }),
})

const input: PluginInput = {
client,
project: ctx.project,
worktree: ctx.worktree,
directory: ctx.directory,
experimental_workspace: {
register(type: string, adapter: WorkspaceAdapter) {
registerAdapter(ctx.project.id, type, adapter as Parameters<typeof registerAdapter>[2])
},
},
get listenerUrl(): URL | undefined {
return Server.url
},
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
return input
})
Loading
Loading