From 9d84e28692f9483eea5a950ca017174cb641867a Mon Sep 17 00:00:00 2001 From: Omair Ahmed Date: Mon, 11 May 2026 16:27:27 -0400 Subject: [PATCH] fix(coding-agent): restore terminal on uncaught exception When an uncaught exception fires in interactive mode, the process dies with stdin still in raw mode and the cursor hidden, leaving the user with a "borked" terminal that needs `stty sane && reset` to recover. The most common trigger is an extension's async ChildProcess `exit` callback that throws (e.g. from accessing a stale ctx after session replacement), but this affects any uncaught throw from anywhere in pi. Add an uncaughtException handler in registerSignalHandlers that calls ui.stop() before exiting, mirroring the existing emergencyTerminalExit pattern. The handler is registered with prependListener and tracked in signalCleanupHandlers, so it is removed on graceful shutdown the same way the other handlers are. Unlike emergencyTerminalExit (used for SIGHUP / dead-terminal EIO), the terminal is still alive on uncaughtException, so we run the normal ui.stop() to restore cooked mode, the cursor, bracketed paste mode, and Kitty / modifyOtherKeys sequences. --- .../src/modes/interactive/interactive-mode.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 006ee7c5..d03c7f26 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3244,6 +3244,36 @@ export class InteractiveMode { process.exit(129); } + /** + * Last-resort handler for uncaught exceptions. The TUI puts stdin into raw + * mode and hides the cursor; without this handler, an uncaught throw from + * anywhere (e.g. an extension's async `ChildProcess.on("exit")` callback) + * tears down the process while leaving the terminal in raw mode with no + * cursor, requiring `stty sane && reset` to recover. + * + * Unlike emergencyTerminalExit, the terminal is still alive here, so we + * call ui.stop() to restore cooked mode, the cursor, and disable bracketed + * paste / Kitty / modifyOtherKeys sequences. + */ + private uncaughtCrash(error: Error): never { + if (this.isShuttingDown) { + process.exit(1); + } + this.isShuttingDown = true; + try { + this.unregisterSignalHandlers(); + } catch {} + try { + killTrackedDetachedChildren(); + } catch {} + try { + this.ui.stop(); + } catch {} + console.error("pi exiting due to uncaughtException:"); + console.error(error); + process.exit(1); + } + /** * Check if shutdown was requested and perform shutdown if so. */ @@ -3282,6 +3312,13 @@ export class InteractiveMode { process.stderr.on("error", terminalErrorHandler); this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler)); this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler)); + + // Restore the terminal before the process dies on any uncaught throw. + // Without this, an unhandled exception from extension code (or anywhere + // in pi) leaves the terminal in raw mode with no cursor. + const uncaughtExceptionHandler = (error: Error) => this.uncaughtCrash(error); + process.prependListener("uncaughtException", uncaughtExceptionHandler); + this.signalCleanupHandlers.push(() => process.off("uncaughtException", uncaughtExceptionHandler)); } private unregisterSignalHandlers(): void {