diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e59fefe08060..bf22bce2e0c1 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,6 +1,7 @@ import type { Hooks, PluginInput, + PluginLogger, Plugin as PluginInstance, PluginModule, WorkspaceAdapter as PluginWorkspaceAdapter, @@ -96,6 +97,28 @@ function getLegacyPlugins(mod: Record) { return result } +/** + * Creates a namespaced logger for a plugin that writes to OpenCode's log system. + * @param pluginId - Identifier for the plugin (function name for internal, package name or file basename for external) + */ +function createPluginLogger(pluginId: string): PluginLogger { + return Log.create({ service: `plugin.${pluginId}` }) +} + +function pluginIdFromLoad(load: PluginLoader.Loaded): string { + if (load.pkg?.pkg) return load.pkg.pkg + if (!load.spec.startsWith("file://")) return load.spec + try { + const url = new URL(load.spec) + let path = url.pathname.replace(/\.[jt]sx?$/i, "") + if (path.endsWith("/index")) path = path.slice(0, -"/index".length) + const last = path.split("/").filter(Boolean).pop() + return last ?? load.spec + } catch { + return load.spec + } +} + async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") if (plugin) { @@ -134,7 +157,9 @@ export const layer = Layer.effect( fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() - const input: PluginInput = { + + // Base input without logger - logger is added per-plugin + const baseInput = { client, project: ctx.project, worktree: ctx.worktree, @@ -147,14 +172,23 @@ export const layer = Layer.effect( get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, - // @ts-expect-error $: typeof Bun === "undefined" ? undefined : Bun.$, } + // Helper to create plugin input with namespaced logger + function createPluginInput(pluginId: string): PluginInput { + return { + ...baseInput, + log: createPluginLogger(pluginId), + } as PluginInput + } + for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) { + const pluginId = plugin.name || "internal" + const pluginInput = createPluginInput(pluginId) log.info("loading internal plugin", { name: plugin.name }) const init = yield* Effect.tryPromise({ - try: () => plugin(input), + try: () => plugin(pluginInput), catch: (err) => { log.error("failed to load internal plugin", { name: plugin.name, error: err }) }, @@ -212,10 +246,13 @@ export const layer = Layer.effect( for (const load of loaded) { if (!load) continue + // Create plugin-specific input with namespaced logger + const pluginInput = createPluginInput(pluginIdFromLoad(load)) + // Keep plugin execution sequential so hook registration and execution // order remains deterministic across plugin runs. yield* Effect.tryPromise({ - try: () => applyPlugin(load, input, hooks), + try: () => applyPlugin(load, pluginInput, hooks), catch: (err) => { const message = errorMessage(err) log.error("failed to load plugin", { path: load.spec, error: message }) diff --git a/packages/opencode/test/plugin/cloudflare.test.ts b/packages/opencode/test/plugin/cloudflare.test.ts index 5fa410683582..1351427016cb 100644 --- a/packages/opencode/test/plugin/cloudflare.test.ts +++ b/packages/opencode/test/plugin/cloudflare.test.ts @@ -11,6 +11,7 @@ const pluginInput = { }, serverUrl: new URL("https://example.com"), $: {} as never, + log: { debug() {}, info() {}, warn() {}, error() {} }, } function makeHookInput(overrides: { providerID?: string; apiId?: string; reasoning?: boolean }) { diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 271bcde99b23..075e20a2c088 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -191,6 +191,7 @@ describe("plugin.codex", () => { }, serverUrl: new URL("https://example.com"), $: {} as never, + log: { debug() {}, info() {}, warn() {}, error() {} }, }, { issuer: server.url.origin, diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 939247f09b4e..21c14ff68cdb 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -228,6 +228,7 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { }, serverUrl: new URL("https://example.com"), $: {} as never, + log: { debug() {}, info() {}, warn() {}, error() {} }, }) const models = await hooks.provider!.models!( diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 6156477be216..731cf974a2fe 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -53,6 +53,17 @@ export type WorkspaceAdapter = { target(config: WorkspaceInfo): WorkspaceTarget | Promise } +/** + * Structured logger for plugins to write to OpenCode's log system. + * Messages are persisted to OpenCode's log files with proper namespace tagging. + */ +export type PluginLogger = { + debug(message?: any, extra?: Record): void + info(message?: any, extra?: Record): void + warn(message?: any, extra?: Record): void + error(message?: any, extra?: Record): void +} + export type PluginInput = { client: ReturnType project: Project @@ -63,6 +74,11 @@ export type PluginInput = { } serverUrl: URL $: BunShell + /** + * Structured logger for writing to OpenCode's log system. + * Use this instead of console.log to ensure logs are captured in log files. + */ + log: PluginLogger } export type PluginOptions = Record