diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 6f5aea6a1248..80ae1283cc57 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -3,6 +3,7 @@ import { UI } from "@/cli/ui" import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" +import { installConsoleGuard } from "@/util/console-guard" export const AttachCommand = cmd({ command: "attach ", @@ -60,6 +61,8 @@ export const AttachCommand = cmd({ describe: "cap visible mini replay to the newest N messages", }), handler: async (args) => { + // The TUI renders on the alternate screen; console output must go to the log file. + installConsoleGuard() if (args.replay === true) { UI.error("--replay is not supported; replay is enabled by default") process.exitCode = 1 diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index cccc2550179f..39ee26104cf4 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -261,6 +261,9 @@ export const RunCommand = effectCmd({ describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { + const { installConsoleGuard } = yield* Effect.promise(() => import("@/util/console-guard")) + // The TUI renders on the alternate screen; console output must go to the log file. + installConsoleGuard() const { Agent } = yield* Effect.promise(() => import("@/agent/agent")) const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags")) const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 95ffac7ea51d..022a45ac3ef6 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -14,6 +14,7 @@ import { writeHeapSnapshot } from "v8" import { ServerAuth } from "@/server/auth" import { validateSession } from "../tui/validate-session" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" +import { installConsoleGuard } from "@/util/console-guard" declare global { const OPENCODE_WORKER_PATH: string @@ -142,6 +143,8 @@ export const TuiThreadCommand = cmd({ hidden: true, }), handler: async (args) => { + // The TUI renders on the alternate screen; console output must go to the log file. + installConsoleGuard() if (args.replay === true) { UI.error("--replay is not supported; replay is enabled by default") process.exitCode = 1 diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4cf6b2d446b3..f800b643d755 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -10,9 +10,13 @@ import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { installConsoleGuard } from "@/util/console-guard" Heap.start() +// The worker shares the TUI terminal; library console output must never reach it. +installConsoleGuard() + const onUnhandledRejection = (_error: unknown) => {} const onUncaughtException = (_error: Error) => {} diff --git a/packages/opencode/src/util/console-guard.ts b/packages/opencode/src/util/console-guard.ts new file mode 100644 index 000000000000..62b799f87348 --- /dev/null +++ b/packages/opencode/src/util/console-guard.ts @@ -0,0 +1,44 @@ +import path from "node:path" +import { appendFile } from "node:fs/promises" +import { inspect } from "node:util" +import { Global } from "@opencode-ai/core/global" +import { Logging } from "@opencode-ai/core/observability/logging" + +// The TUI renders on the alternate screen while the server worker shares the +// same terminal. Any library that calls console.* (Ajv compiling third-party +// MCP schemas, plugin dependencies, ...) would write raw lines straight into +// the TUI and corrupt it. In TUI mode the console methods are redirected into +// the opencode log file instead of the terminal, so no output can ever break +// the display. Non-TUI entrypoints (serve, mcp list, ...) never install this. +// https://github.com/anomalyco/opencode/issues/31002 + +const LEVELS = { Debug: 0, Info: 1, Warn: 2, Error: 3 } as const + +function installConsole() { + const format = (...args: unknown[]) => { + try { + return args.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 3 }))).join(" ") + } catch { + return "" + } + } + + const write = (level: keyof typeof LEVELS, args: unknown[]) => { + if (LEVELS[level] < LEVELS[Logging.minimumLogLevel()]) return + const line = `timestamp=${new Date().toISOString()} level=${level.toLowerCase()} service=console message=${JSON.stringify(format(...args))}\n` + void appendFile(path.join(Global.Path.log, "opencode.log"), line).catch(() => {}) + } + + console.debug = (...args: unknown[]) => write("Debug", args) + console.info = (...args: unknown[]) => write("Info", args) + console.log = (...args: unknown[]) => write("Info", args) + console.warn = (...args: unknown[]) => write("Warn", args) + console.error = (...args: unknown[]) => write("Error", args) +} + +/** Redirect all console output to the log file. For TUI mode only. */ +export function installConsoleGuard() { + installConsole() +} + +export * as ConsoleGuard from "./console-guard" diff --git a/packages/opencode/test/util/console-guard.test.ts b/packages/opencode/test/util/console-guard.test.ts new file mode 100644 index 000000000000..84ec61f436dc --- /dev/null +++ b/packages/opencode/test/util/console-guard.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import { Global } from "@opencode-ai/core/global" +import { ConsoleGuard } from "../../src/util/console-guard" + +function logFile() { + return path.join(Global.Path.log, "opencode.log") +} + +async function readLog() { + const file = Bun.file(logFile()) + return (await file.exists()) ? await file.text() : "" +} + +const original = { + debug: console.debug, + info: console.info, + log: console.log, + warn: console.warn, + error: console.error, +} + +describe("ConsoleGuard.installConsoleGuard", () => { + test("routes console output to the log file instead of the terminal", async () => { + try { + ConsoleGuard.installConsoleGuard() + console.warn("unknown format ignored in schema at path", { format: "uint64" }) + await new Promise((resolve) => setTimeout(resolve, 100)) + const log = await readLog() + expect(log).toContain('service=console message="unknown format ignored in schema at path') + expect(log).toContain("level=warn") + } finally { + restoreConsole() + } + }) + + test("suppresses entries below the configured minimum level", async () => { + const previous = process.env.OPENCODE_LOG_LEVEL + process.env.OPENCODE_LOG_LEVEL = "ERROR" + try { + ConsoleGuard.installConsoleGuard() + console.warn("guard should suppress this warn") + console.error("guard should keep this error") + await new Promise((resolve) => setTimeout(resolve, 100)) + const log = await readLog() + expect(log).toContain("guard should keep this error") + expect(log).not.toContain("guard should suppress this warn") + } finally { + restoreConsole() + process.env.OPENCODE_LOG_LEVEL = previous + } + }) +}) + +function restoreConsole() { + console.debug = original.debug + console.info = original.info + console.log = original.log + console.warn = original.warn + console.error = original.error +}