From b3c1a1579b84ce28da9dfc15939d809180abf342 Mon Sep 17 00:00:00 2001 From: Alex Ramsay Date: Sat, 29 Aug 2026 15:49:59 -0400 Subject: [PATCH] feat(plugin): let server plugins emit custom bus events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server plugins can only receive events via the event hook, forcing client/server plugin pairs into loopback HTTP workarounds. Add event.emit(type, properties) to PluginInput, publishing arbitrary namespaced event types (e.g. plugin..) on the Bus so clients can subscribe. TUI plugins subscribe with api.event.on(...) — loosen its typing so custom types filter through with a PluginEvent fallback shape while known SDK event types still narrow via Extract. --- bun.lock | 8 --- .../opencode/src/cli/cmd/tui/context/event.ts | 6 +- packages/opencode/src/plugin/index.ts | 7 ++ packages/opencode/test/plugin/event.test.ts | 72 +++++++++++++++++++ .../test/plugin/github-copilot-models.test.ts | 3 + packages/plugin/src/index.ts | 7 ++ packages/plugin/src/tui.ts | 16 ++++- 7 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/plugin/event.test.ts diff --git a/bun.lock b/bun.lock index 7ff8a3072fd4..ea216dfa8301 100644 --- a/bun.lock +++ b/bun.lock @@ -5512,10 +5512,6 @@ "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="], - "@standard-community/standard-json/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], - - "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], - "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], @@ -6444,10 +6440,6 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index da073f6e9202..77fcba114df5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -1,4 +1,5 @@ import type { Event } from "@opencode-ai/sdk/v2" +import type { PluginEvent, PluginEventHandler } from "@opencode-ai/plugin/tui" import { useProject } from "./project" import { useSDK } from "./sdk" @@ -27,10 +28,11 @@ export function useEvent() { }) } - function on(type: T, handler: (event: Extract) => void) { + function on(type: T, handler: PluginEventHandler) { + const fn = handler as (event: PluginEvent) => void return subscribe((event) => { if (event.type !== type) return - handler(event as Extract) + fn(event as PluginEvent) }) } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 60942915a37c..e0ab1fedbb21 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -5,8 +5,10 @@ import type { PluginModule, WorkspaceAdaptor as PluginWorkspaceAdaptor, } from "@opencode-ai/plugin" +import z from "zod" import { Config } from "../config/config" import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Flag } from "../flag/flag" @@ -148,6 +150,11 @@ export namespace Plugin { get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, + event: { + emit(type, properties) { + void Bus.publish(BusEvent.define(type, z.unknown()), properties) + }, + }, // @ts-expect-error $: typeof Bun === "undefined" ? undefined : Bun.$, } diff --git a/packages/opencode/test/plugin/event.test.ts b/packages/opencode/test/plugin/event.test.ts new file mode 100644 index 000000000000..3cc20915b07c --- /dev/null +++ b/packages/opencode/test/plugin/event.test.ts @@ -0,0 +1,72 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../fixture/fixture" + +const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS +process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + +const { Plugin } = await import("../../src/plugin/index") +const { Bus } = await import("../../src/bus") +const { Instance } = await import("../../src/project/instance") + +afterEach(async () => { + await Instance.disposeAll() +}) + +afterAll(() => { + if (disableDefault === undefined) { + delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + return + } + process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault +}) + +async function project(source: string) { + return tmpdir({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + await Bun.write(file, source) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + }, + }) +} + +describe("plugin.event", () => { + test("emits custom bus events reachable via subscribeAll", async () => { + await using tmp = await project( + [ + "export default async (input) => ({", + " config() {", + ' input.event.emit("plugin.demo.tick", { n: 1 })', + " },", + "})", + "", + ].join("\n"), + ) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const received: Array<{ type: string; properties: unknown }> = [] + const off = Bus.subscribeAll((evt) => { + received.push(evt) + }) + await Plugin.init() + await Bun.sleep(10) + off() + expect(received).toContainEqual({ type: "plugin.demo.tick", properties: { n: 1 } }) + }, + }) + }) +}) diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 33ddef5ddfac..547992588f2a 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -129,6 +129,9 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { register() {}, }, serverUrl: new URL("https://example.com"), + event: { + emit() {}, + }, $: {} as never, }) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 49d995c6f7b9..4d7530edac98 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -63,6 +63,13 @@ export type PluginInput = { register(type: string, adaptor: WorkspaceAdaptor): void } serverUrl: URL + /** + * Emit custom events on the bus. TUI plugins subscribe via + * `api.event.on(...)`. Namespace types as `plugin..`. + */ + event: { + emit(type: string, properties: unknown): void + } $: BunShell } diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index e6f832f7e1ea..e5babf6f6e04 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -385,8 +385,22 @@ export type TuiSlots = { } } +export type PluginEvent = { + type: string + properties: unknown +} + +/** + * Event handler for `api.event.on`. Known server event types narrow to their + * SDK shape; custom types emitted by server plugins (e.g. `plugin..`) + * arrive as {@link PluginEvent}. + */ +export type PluginEventHandler = ( + event: Type extends Event["type"] ? Extract : PluginEvent, +) => void + export type TuiEventBus = { - on: (type: Type, handler: (event: Extract) => void) => () => void + on: (type: Type, handler: PluginEventHandler) => () => void } export type TuiDispose = () => void | Promise