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 {