From 756b7749356f4b2e8a887668e30ad57df1761e76 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 4 May 2026 12:07:16 +0200 Subject: [PATCH] fix(coding-agent): exit on lost terminal Fixes #4144. --- .../src/modes/interactive/interactive-mode.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3b2adb5d..2fab6f56 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -163,6 +163,16 @@ type CompactionQueuedMessage = { mode: "steer" | "followUp"; }; +const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]); + +function isDeadTerminalError(error: unknown): boolean { + if (!error || typeof error !== "object" || !("code" in error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); +} + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage."; @@ -3223,6 +3233,15 @@ export class InteractiveMode { process.exit(0); } + private emergencyTerminalExit(): never { + this.isShuttingDown = true; + this.unregisterSignalHandlers(); + killTrackedDetachedChildren(); + // The terminal is gone. Do not run normal shutdown because TUI and + // extension cleanup can write restore sequences and re-trigger EIO. + process.exit(129); + } + /** * Check if shutdown was requested and perform shutdown if so. */ @@ -3241,12 +3260,26 @@ export class InteractiveMode { for (const signal of signals) { const handler = () => { + if (signal === "SIGHUP") { + this.emergencyTerminalExit(); + } killTrackedDetachedChildren(); void this.shutdown(); }; - process.on(signal, handler); + process.prependListener(signal, handler); this.signalCleanupHandlers.push(() => process.off(signal, handler)); } + + const terminalErrorHandler = (error: Error) => { + if (isDeadTerminalError(error)) { + this.emergencyTerminalExit(); + } + throw error; + }; + process.stdout.on("error", terminalErrorHandler); + process.stderr.on("error", terminalErrorHandler); + this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler)); + this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler)); } private unregisterSignalHandlers(): void {