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
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>",
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {}
Expand Down
44 changes: 44 additions & 0 deletions packages/opencode/src/util/console-guard.ts
Original file line number Diff line number Diff line change
@@ -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 "<unformattable console output>"
}
}

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"
61 changes: 61 additions & 0 deletions packages/opencode/test/util/console-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading