From db94e69ff92c032ae433e1c6b2a23cf7cd0c917a Mon Sep 17 00:00:00 2001 From: Thomas Goette Date: Fri, 11 Sep 2026 17:58:32 +0200 Subject: [PATCH] fix(tui): route console output to log file to protect TUI display Library console output (Ajv compiling third-party MCP schemas, plugin dependencies, ...) wrote raw lines into the TUI's alternate screen and corrupted the display. In TUI mode the console methods are now redirected into the opencode log file in all TUI entrypoints and the server worker, respecting OPENCODE_LOG_LEVEL. https://github.com/anomalyco/opencode/issues/31002 --- packages/opencode/src/cli/cmd/attach.ts | 3 + packages/opencode/src/cli/cmd/run.ts | 3 + packages/opencode/src/cli/cmd/tui.ts | 3 + packages/opencode/src/cli/tui/worker.ts | 4 ++ packages/opencode/src/util/console-guard.ts | 44 +++++++++++++ .../opencode/test/util/console-guard.test.ts | 61 +++++++++++++++++++ 6 files changed, 118 insertions(+) create mode 100644 packages/opencode/src/util/console-guard.ts create mode 100644 packages/opencode/test/util/console-guard.test.ts 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 +}