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
45 changes: 41 additions & 4 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
Hooks,
PluginInput,
PluginLogger,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdapter as PluginWorkspaceAdapter,
Expand Down Expand Up @@ -96,6 +97,28 @@ function getLegacyPlugins(mod: Record<string, unknown>) {
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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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 })
},
Expand Down Expand Up @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/plugin/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/plugin/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ describe("plugin.codex", () => {
},
serverUrl: new URL("https://example.com"),
$: {} as never,
log: { debug() {}, info() {}, warn() {}, error() {} },
},
{
issuer: server.url.origin,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
16 changes: 16 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ export type WorkspaceAdapter = {
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
}

/**
* 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<string, any>): void
info(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
}

export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
project: Project
Expand All @@ -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<string, unknown>
Expand Down
Loading