From fa46040eff5ce389ca2764c4f665d3266806717e Mon Sep 17 00:00:00 2001 From: Test User Date: Sat, 18 Jul 2026 00:31:31 +0200 Subject: [PATCH] feat(plugin): expose active listener URL --- packages/opencode/src/plugin/discovery.ts | 75 ++++ packages/opencode/src/plugin/index.ts | 190 +---------- packages/opencode/src/plugin/input.ts | 38 +++ packages/opencode/src/plugin/loading.ts | 80 +++++ .../opencode/test/plugin/listener-url.test.ts | 115 +++++++ packages/plugin/src/auth.ts | 133 ++++++++ packages/plugin/src/config.ts | 7 + packages/plugin/src/hooks.ts | 135 ++++++++ packages/plugin/src/index.ts | 322 +----------------- packages/plugin/src/workspace.ts | 29 ++ 10 files changed, 630 insertions(+), 494 deletions(-) create mode 100644 packages/opencode/src/plugin/discovery.ts create mode 100644 packages/opencode/src/plugin/input.ts create mode 100644 packages/opencode/src/plugin/loading.ts create mode 100644 packages/opencode/test/plugin/listener-url.test.ts create mode 100644 packages/plugin/src/auth.ts create mode 100644 packages/plugin/src/config.ts create mode 100644 packages/plugin/src/hooks.ts create mode 100644 packages/plugin/src/workspace.ts diff --git a/packages/opencode/src/plugin/discovery.ts b/packages/opencode/src/plugin/discovery.ts new file mode 100644 index 000000000000..648340fc7980 --- /dev/null +++ b/packages/opencode/src/plugin/discovery.ts @@ -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) { + const seen = new Set() + 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)) + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1558c707c3f7..a02cba789e31 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -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 trigger pattern type TriggerName = { [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never }[keyof Hooks] @@ -57,68 +36,7 @@ export interface Interface { export class Service extends Context.Service()("@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) { - const seen = new Set() - 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, @@ -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)), diff --git a/packages/opencode/src/plugin/input.ts b/packages/opencode/src/plugin/input.ts new file mode 100644 index 000000000000..c7e428063284 --- /dev/null +++ b/packages/opencode/src/plugin/input.ts @@ -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[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 +}) diff --git a/packages/opencode/src/plugin/loading.ts b/packages/opencode/src/plugin/loading.ts new file mode 100644 index 000000000000..07114d125e7b --- /dev/null +++ b/packages/opencode/src/plugin/loading.ts @@ -0,0 +1,80 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { Effect } from "effect" +import type { ConfigPlugin } from "@/config/plugin" +import type { RuntimeFlags } from "@/effect/runtime-flags" +import { errorMessage } from "@/util/error" +import { applyPlugin, internalPlugins } from "./discovery" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier } from "./shared" + +export const loadInternalPlugins = Effect.fnUntraced(function* ( + flags: RuntimeFlags.Info, + input: PluginInput, + hooks: Hooks[], +) { + 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) + } +}) + +type ExternalLoadInput = { + readonly origins: ConfigPlugin.Origin[] + readonly pluginInput: PluginInput + readonly hooks: Hooks[] + readonly publishPluginError: (message: string) => void +} + +export const loadExternalPlugins = Effect.fnUntraced(function* (input: ExternalLoadInput) { + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: input.origins, + 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) + input.publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + return + } + + if (stage === "compatibility") { + input.publishPluginError(`Plugin ${spec} skipped: ${message}`) + return + } + + if (stage === "entry") { + input.publishPluginError(`Failed to load plugin ${spec}: ${message}`) + return + } + + input.publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), + ) + + for (const load of loaded) { + if (!load) continue + + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input.pluginInput, input.hooks), + catch: errorMessage, + }).pipe( + Effect.tapError((error) => Effect.logError("failed to load plugin", { path: load.spec, error })), + Effect.catch(() => Effect.void), + ) + } +}) diff --git a/packages/opencode/test/plugin/listener-url.test.ts b/packages/opencode/test/plugin/listener-url.test.ts new file mode 100644 index 000000000000..e7adcf7f5165 --- /dev/null +++ b/packages/opencode/test/plugin/listener-url.test.ts @@ -0,0 +1,115 @@ +import { describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect, Layer } from "effect" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Plugin } from "@/plugin/index" +import { Server } from "@/server/server" +import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer), +) + +describe("plugin listener URL", () => { + it.live("tracks the actual listener lifecycle on one captured plugin input", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const file = path.join(directory, "listener-plugin.ts") + const spec = pathToFileURL(file).href + const source = path.join(directory, "opencode.json") + yield* Effect.promise(() => + Bun.write( + file, + [ + "export default async function plugin(input) {", + " return {", + ' "shell.env": async (_input, output) => {', + ' output.env.LISTENER_URL = input.listenerUrl?.href ?? "undefined"', + " output.env.SERVER_URL = input.serverUrl.href", + " },", + " }", + "}", + "", + ].join("\n"), + ), + ) + + const program = Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.init() + const observe = () => + Effect.gen(function* () { + const output = { env: {} } + yield* plugin.trigger("shell.env", { cwd: directory }, output) + return output.env + }) + + expect(yield* observe()).toEqual({ + LISTENER_URL: "undefined", + SERVER_URL: "http://localhost:4096/", + }) + + yield* Effect.acquireUseRelease( + Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0 })), + (listener) => + Effect.gen(function* () { + expect(yield* observe()).toEqual({ + LISTENER_URL: listener.url.href, + SERVER_URL: listener.url.href, + }) + }), + (listener) => Effect.promise(() => listener.stop(true)), + ) + + expect(yield* observe()).toEqual({ + LISTENER_URL: "undefined", + SERVER_URL: "http://localhost:4096/", + }) + + yield* Effect.acquireUseRelease( + Effect.promise(() => Server.listen({ hostname: "localhost", port: 0 })), + (listener) => + Effect.gen(function* () { + expect(yield* observe()).toEqual({ + LISTENER_URL: listener.url.href, + SERVER_URL: listener.url.href, + }) + }), + (listener) => Effect.promise(() => listener.stop(true)), + ) + + expect(yield* observe()).toEqual({ + LISTENER_URL: "undefined", + SERVER_URL: "http://localhost:4096/", + }) + }) + + yield* program.pipe( + Effect.provide( + LayerNode.compile(Plugin.node, [ + [ + Config.node, + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: [spec], + plugin_origins: [{ spec, source, scope: "local" as const }], + }), + directories: () => Effect.succeed([directory]), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), + ), + provideInstance(directory), + ) + }), + ) +}) diff --git a/packages/plugin/src/auth.ts b/packages/plugin/src/auth.ts new file mode 100644 index 000000000000..1a19c224b238 --- /dev/null +++ b/packages/plugin/src/auth.ts @@ -0,0 +1,133 @@ +import type { Provider } from "@opencode-ai/sdk" +import type { Auth } from "@opencode-ai/sdk/v2" + +type Rule = { + key: string + op: "eq" | "neq" + value: string +} + +export type AuthHook = { + provider: string + loader?: (auth: () => Promise, provider: Provider) => Promise> + methods: ( + | { + type: "oauth" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + validate?: (value: string) => string | undefined + /** @deprecated Use `when` instead */ + condition?: (inputs: Record) => boolean + when?: Rule + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + /** @deprecated Use `when` instead */ + condition?: (inputs: Record) => boolean + when?: Rule + } + > + authorize(inputs?: Record): Promise + } + | { + type: "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + validate?: (value: string) => string | undefined + /** @deprecated Use `when` instead */ + condition?: (inputs: Record) => boolean + when?: Rule + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + /** @deprecated Use `when` instead */ + condition?: (inputs: Record) => boolean + when?: Rule + } + > + authorize?(inputs?: Record): Promise< + | { + type: "success" + key: string + provider?: string + metadata?: Record + } + | { + type: "failed" + } + > + } + )[] +} + +export type AuthOAuthResult = { url: string; instructions: string } & ( + | { + method: "auto" + callback(): Promise< + | ({ + type: "success" + provider?: string + } & ( + | { + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string + } + | { key: string; metadata?: Record } + )) + | { + type: "failed" + } + > + } + | { + method: "code" + callback(code: string): Promise< + | ({ + type: "success" + provider?: string + } & ( + | { + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string + } + | { key: string; metadata?: Record } + )) + | { + type: "failed" + } + > + } +) + +/** @deprecated Use AuthOAuthResult instead. */ +export type AuthOuathResult = AuthOAuthResult diff --git a/packages/plugin/src/config.ts b/packages/plugin/src/config.ts new file mode 100644 index 000000000000..fe059f981320 --- /dev/null +++ b/packages/plugin/src/config.ts @@ -0,0 +1,7 @@ +import type { Config as SDKConfig } from "@opencode-ai/sdk" + +export type PluginOptions = Record + +export type Config = Omit & { + plugin?: Array +} diff --git a/packages/plugin/src/hooks.ts b/packages/plugin/src/hooks.ts new file mode 100644 index 000000000000..1c42e91bf365 --- /dev/null +++ b/packages/plugin/src/hooks.ts @@ -0,0 +1,135 @@ +import type { Event, Model, Provider, Permission, UserMessage, Message, Part } from "@opencode-ai/sdk" +import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2" +import type { AuthHook } from "./auth.js" +import type { Config } from "./config.js" +import type { ToolDefinition } from "./tool.js" + +export type ProviderContext = { + source: "env" | "config" | "custom" | "api" + info: Provider + options: Record +} + +export type ProviderHookContext = { + auth?: Auth +} + +export type ProviderHook = { + id: string + models?: (provider: ProviderV2, ctx: ProviderHookContext) => Promise> +} + +export interface Hooks { + dispose?: () => Promise + event?: (input: { event: Event }) => Promise + config?: (input: Config) => Promise + tool?: { + [key: string]: ToolDefinition + } + auth?: AuthHook + provider?: ProviderHook + /** + * Called when a new message is received + */ + "chat.message"?: ( + input: { + sessionID: string + agent?: string + model?: { providerID: string; modelID: string } + messageID?: string + variant?: string + }, + output: { message: UserMessage; parts: Part[] }, + ) => Promise + /** + * Modify parameters sent to LLM + */ + "chat.params"?: ( + input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, + output: { + temperature: number + topP: number + topK: number + maxOutputTokens: number | undefined + options: Record + }, + ) => Promise + "chat.headers"?: ( + input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, + output: { headers: Record }, + ) => Promise + "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise + "command.execute.before"?: ( + input: { command: string; sessionID: string; arguments: string }, + output: { parts: Part[] }, + ) => Promise + "tool.execute.before"?: ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: any }, + ) => Promise + "shell.env"?: ( + input: { cwd: string; sessionID?: string; callID?: string }, + output: { env: Record }, + ) => Promise + "tool.execute.after"?: ( + input: { tool: string; sessionID: string; callID: string; args: any }, + output: { + title: string + output: string + metadata: any + }, + ) => Promise + "experimental.chat.messages.transform"?: ( + input: {}, + output: { + messages: { + info: Message + parts: Part[] + }[] + }, + ) => Promise + "experimental.chat.system.transform"?: ( + input: { sessionID?: string; model: Model }, + output: { + system: string[] + }, + ) => Promise + "experimental.provider.small_model"?: (input: { provider: ProviderV2 }, output: { model?: ModelV2 }) => Promise + /** + * Called before session compaction starts. Allows plugins to customize + * the compaction prompt. + * + * - `context`: Additional context strings appended to the default prompt + * - `prompt`: If set, replaces the default compaction prompt entirely + */ + "experimental.session.compacting"?: ( + input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ) => Promise + /** + * Called after compaction succeeds and before a synthetic user + * auto-continue message is added. + * + * - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic + * user "continue" turn. + */ + "experimental.compaction.autocontinue"?: ( + input: { + sessionID: string + agent: string + model: Model + provider: ProviderContext + message: UserMessage + overflow: boolean + }, + output: { enabled: boolean }, + ) => Promise + "experimental.text.complete"?: ( + input: { sessionID: string; messageID: string; partID: string }, + output: { text: string }, + ) => Promise + /** + * Modify tool definitions (description and parameters) sent to LLM + */ + "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..8b939ddd6675 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,57 +1,15 @@ -import type { - Event, - createOpencodeClient, - Project, - Model, - Provider, - Permission, - UserMessage, - Message, - Part, - Config as SDKConfig, -} from "@opencode-ai/sdk" -import type { Provider as ProviderV2, Model as ModelV2, Auth } from "@opencode-ai/sdk/v2" - +import type { Project, createOpencodeClient } from "@opencode-ai/sdk" +import type { AuthHook, AuthOAuthResult, AuthOuathResult } from "./auth.js" +import type { Config, PluginOptions } from "./config.js" +import type { Hooks, ProviderContext, ProviderHook, ProviderHookContext } from "./hooks.js" import type { BunShell } from "./shell.js" -import { type ToolDefinition } from "./tool.js" +import type { WorkspaceAdapter, WorkspaceInfo, WorkspaceTarget } from "./workspace.js" export * from "./tool.js" - -export type ProviderContext = { - source: "env" | "config" | "custom" | "api" - info: Provider - options: Record -} - -export type WorkspaceInfo = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - -export type WorkspaceTarget = - | { - type: "local" - directory: string - } - | { - type: "remote" - url: string | URL - headers?: HeadersInit - } - -export type WorkspaceAdapter = { - name: string - description: string - configure(config: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise - remove(config: WorkspaceInfo): Promise - target(config: WorkspaceInfo): WorkspaceTarget | Promise -} +export type { AuthHook, AuthOAuthResult, AuthOuathResult } +export type { Config, PluginOptions } +export type { Hooks, ProviderContext, ProviderHook, ProviderHookContext } +export type { WorkspaceAdapter, WorkspaceInfo, WorkspaceTarget } export type PluginInput = { client: ReturnType @@ -61,16 +19,11 @@ export type PluginInput = { experimental_workspace: { register(type: string, adapter: WorkspaceAdapter): void } + readonly listenerUrl?: URL serverUrl: URL $: BunShell } -export type PluginOptions = Record - -export type Config = Omit & { - plugin?: Array -} - export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise export type PluginModule = { @@ -78,258 +31,3 @@ export type PluginModule = { server: Plugin tui?: never } - -type Rule = { - key: string - op: "eq" | "neq" - value: string -} - -export type AuthHook = { - provider: string - loader?: (auth: () => Promise, provider: Provider) => Promise> - methods: ( - | { - type: "oauth" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - validate?: (value: string) => string | undefined - /** @deprecated Use `when` instead */ - condition?: (inputs: Record) => boolean - when?: Rule - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - /** @deprecated Use `when` instead */ - condition?: (inputs: Record) => boolean - when?: Rule - } - > - authorize(inputs?: Record): Promise - } - | { - type: "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - validate?: (value: string) => string | undefined - /** @deprecated Use `when` instead */ - condition?: (inputs: Record) => boolean - when?: Rule - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - /** @deprecated Use `when` instead */ - condition?: (inputs: Record) => boolean - when?: Rule - } - > - authorize?(inputs?: Record): Promise< - | { - type: "success" - key: string - provider?: string - metadata?: Record - } - | { - type: "failed" - } - > - } - )[] -} - -export type AuthOAuthResult = { url: string; instructions: string } & ( - | { - method: "auto" - callback(): Promise< - | ({ - type: "success" - provider?: string - } & ( - | { - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string - } - | { key: string; metadata?: Record } - )) - | { - type: "failed" - } - > - } - | { - method: "code" - callback(code: string): Promise< - | ({ - type: "success" - provider?: string - } & ( - | { - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string - } - | { key: string; metadata?: Record } - )) - | { - type: "failed" - } - > - } -) - -export type ProviderHookContext = { - auth?: Auth -} - -export type ProviderHook = { - id: string - models?: (provider: ProviderV2, ctx: ProviderHookContext) => Promise> -} - -/** @deprecated Use AuthOAuthResult instead. */ -export type AuthOuathResult = AuthOAuthResult - -export interface Hooks { - dispose?: () => Promise - event?: (input: { event: Event }) => Promise - config?: (input: Config) => Promise - tool?: { - [key: string]: ToolDefinition - } - auth?: AuthHook - provider?: ProviderHook - /** - * Called when a new message is received - */ - "chat.message"?: ( - input: { - sessionID: string - agent?: string - model?: { providerID: string; modelID: string } - messageID?: string - variant?: string - }, - output: { message: UserMessage; parts: Part[] }, - ) => Promise - /** - * Modify parameters sent to LLM - */ - "chat.params"?: ( - input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, - output: { - temperature: number - topP: number - topK: number - maxOutputTokens: number | undefined - options: Record - }, - ) => Promise - "chat.headers"?: ( - input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, - output: { headers: Record }, - ) => Promise - "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise - "command.execute.before"?: ( - input: { command: string; sessionID: string; arguments: string }, - output: { parts: Part[] }, - ) => Promise - "tool.execute.before"?: ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: any }, - ) => Promise - "shell.env"?: ( - input: { cwd: string; sessionID?: string; callID?: string }, - output: { env: Record }, - ) => Promise - "tool.execute.after"?: ( - input: { tool: string; sessionID: string; callID: string; args: any }, - output: { - title: string - output: string - metadata: any - }, - ) => Promise - "experimental.chat.messages.transform"?: ( - input: {}, - output: { - messages: { - info: Message - parts: Part[] - }[] - }, - ) => Promise - "experimental.chat.system.transform"?: ( - input: { sessionID?: string; model: Model }, - output: { - system: string[] - }, - ) => Promise - "experimental.provider.small_model"?: (input: { provider: ProviderV2 }, output: { model?: ModelV2 }) => Promise - /** - * Called before session compaction starts. Allows plugins to customize - * the compaction prompt. - * - * - `context`: Additional context strings appended to the default prompt - * - `prompt`: If set, replaces the default compaction prompt entirely - */ - "experimental.session.compacting"?: ( - input: { sessionID: string }, - output: { context: string[]; prompt?: string }, - ) => Promise - /** - * Called after compaction succeeds and before a synthetic user - * auto-continue message is added. - * - * - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic - * user "continue" turn. - */ - "experimental.compaction.autocontinue"?: ( - input: { - sessionID: string - agent: string - model: Model - provider: ProviderContext - message: UserMessage - overflow: boolean - }, - output: { enabled: boolean }, - ) => Promise - "experimental.text.complete"?: ( - input: { sessionID: string; messageID: string; partID: string }, - output: { text: string }, - ) => Promise - /** - * Modify tool definitions (description and parameters) sent to LLM - */ - "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise -} diff --git a/packages/plugin/src/workspace.ts b/packages/plugin/src/workspace.ts new file mode 100644 index 000000000000..01498e717834 --- /dev/null +++ b/packages/plugin/src/workspace.ts @@ -0,0 +1,29 @@ +export type WorkspaceInfo = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type WorkspaceTarget = + | { + type: "local" + directory: string + } + | { + type: "remote" + url: string | URL + headers?: HeadersInit + } + +export type WorkspaceAdapter = { + name: string + description: string + configure(config: WorkspaceInfo): WorkspaceInfo | Promise + create(config: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise + remove(config: WorkspaceInfo): Promise + target(config: WorkspaceInfo): WorkspaceTarget | Promise +}