From 4f101673d6df6dec8bedb619b30b2b0055044146 Mon Sep 17 00:00:00 2001 From: tomstetson Date: Wed, 11 Feb 2026 00:20:52 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20restore=20terminal=20state=20after?= =?UTF-8?q?=20remote=E2=86=92local=20mode=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix terminal input corruption (dropped characters, phantom mode switches, unresponsive Claude) when switching from remote mode back to local mode via double-space. Root causes addressed: - Wrong cleanup order: raw mode was disabled BEFORE Ink unmounted, but Ink expects raw mode during teardown. Now Ink unmounts first. - setEncoding("utf8") was never reset: poisons stdin for subsequent Buffer-mode consumers. Now reset via _readableState. - process.exit() in signal handlers bypasses finally blocks: terminal left in raw mode. Now restoreStdin() runs before exit. - No signal forwarding to binary children: orphan Claude processes compete for stdin. Now SIGINT/SIGTERM/SIGHUP forwarded. - stdin left in flowing mode: no pause() after remote cleanup. Now restoreStdin() pauses stdin. Fixes #301, #422, #423, #424, #430, #443, #527 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../scripts/claude_version_utils.cjs | 18 +++++++ packages/happy-cli/src/claude/claudeLocal.ts | 7 ++- .../src/claude/claudeRemoteLauncher.ts | 18 ++++--- packages/happy-cli/src/claude/runClaude.ts | 6 +++ packages/happy-cli/src/utils/restoreStdin.ts | 53 +++++++++++++++++++ 5 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 packages/happy-cli/src/utils/restoreStdin.ts diff --git a/packages/happy-cli/scripts/claude_version_utils.cjs b/packages/happy-cli/scripts/claude_version_utils.cjs index 2184917ae6a..024e9c0a018 100644 --- a/packages/happy-cli/scripts/claude_version_utils.cjs +++ b/packages/happy-cli/scripts/claude_version_utils.cjs @@ -497,7 +497,25 @@ function runClaudeCli(cliPath) { stdio: 'inherit', env: process.env }); + + // Forward termination signals to the child process. + // Without this, the child becomes an orphan competing for stdin + // when the parent is killed (e.g., during remote→local mode switch). + const forwardSignal = (signal) => { + if (!child.killed) { + child.kill(signal); + } + }; + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; + for (const sig of signals) { + process.on(sig, () => forwardSignal(sig)); + } + child.on('exit', (code) => { + // Clean up signal handlers to avoid leaks + for (const sig of signals) { + process.removeAllListeners(sig); + } process.exit(code || 0); }); } diff --git a/packages/happy-cli/src/claude/claudeLocal.ts b/packages/happy-cli/src/claude/claudeLocal.ts index 45d3709890c..7e15bc08bf0 100644 --- a/packages/happy-cli/src/claude/claudeLocal.ts +++ b/packages/happy-cli/src/claude/claudeLocal.ts @@ -11,6 +11,7 @@ import { projectPath } from "@/projectPath"; import { systemPrompt } from "./utils/systemPrompt"; import type { SandboxConfig } from "@/persistence"; import { initializeSandbox, wrapCommand } from "@/sandbox/manager"; +import { restoreStdin } from "@/utils/restoreStdin"; /** * Error thrown when the Claude process exits with a non-zero exit code. @@ -184,8 +185,10 @@ export async function claudeLocal(opts: { // Spawn the process try { - // Start the interactive process - process.stdin.pause(); + // Ensure stdin is fully clean before handing it to the child process. + // This guards against edge cases where remote mode cleanup was incomplete + // (e.g., encoding left as utf8, raw mode still active, flowing mode). + restoreStdin(); await new Promise((r, reject) => { const args: string[] = [] diff --git a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts index b3472475d1e..7bc9a2bca7b 100644 --- a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts +++ b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts @@ -6,6 +6,7 @@ import React from "react"; import { claudeRemote } from "./claudeRemote"; import { PermissionHandler } from "./utils/permissionHandler"; import { Future } from "@/utils/future"; +import { restoreStdin } from "@/utils/restoreStdin"; import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; import { logger } from "@/ui/logger"; @@ -443,14 +444,19 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | // Clean up permission handler permissionHandler.reset(); - // Reset Terminal - process.stdin.off('data', abort); - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - } + // Unmount Ink FIRST — it expects raw mode to still be active during teardown. + // Reversing this order (disabling raw mode first) causes Ink to corrupt the terminal. if (inkInstance) { - inkInstance.unmount(); + try { + inkInstance.unmount(); + } catch { + // Ink unmount can throw if already unmounted + } } + + // Now restore stdin to a clean state for the next consumer (local mode / child process) + restoreStdin(); + messageBuffer.clear(); // Resolve abort future diff --git a/packages/happy-cli/src/claude/runClaude.ts b/packages/happy-cli/src/claude/runClaude.ts index b626c62b61f..d589b0afb7e 100644 --- a/packages/happy-cli/src/claude/runClaude.ts +++ b/packages/happy-cli/src/claude/runClaude.ts @@ -28,6 +28,7 @@ import { claudeLocal } from '@/claude/claudeLocal'; import { createSessionScanner } from '@/claude/utils/sessionScanner'; import { Session } from './session'; import { applySandboxPermissionPolicy, resolveInitialClaudePermissionMode } from './utils/permissionMode'; +import { restoreStdin } from '@/utils/restoreStdin'; /** JavaScript runtime to use for spawning Claude Code */ export type JsRuntime = 'node' | 'bun' @@ -388,6 +389,11 @@ export async function runClaude(credentials: Credentials, options: StartOptions const cleanup = async () => { logger.debug('[START] Received termination signal, cleaning up...'); + // Restore terminal BEFORE any async work or process.exit() — + // signal handlers bypass finally blocks, so this is our only chance + // to prevent leaving the terminal in raw mode. + restoreStdin(); + try { // Update lifecycle state to archived before closing if (session) { diff --git a/packages/happy-cli/src/utils/restoreStdin.ts b/packages/happy-cli/src/utils/restoreStdin.ts new file mode 100644 index 00000000000..10aab4ea062 --- /dev/null +++ b/packages/happy-cli/src/utils/restoreStdin.ts @@ -0,0 +1,53 @@ +/** + * Restores process.stdin to a clean state after raw mode / Ink usage. + * + * When switching from remote mode (Ink UI with raw mode) back to local mode + * (Claude with inherited stdio), stdin must be fully reset: + * 1. Raw mode disabled (so the terminal handles line editing again) + * 2. Paused (exits flowing mode so the child process can read stdin) + * 3. Encoding reset from utf8 back to Buffer (setEncoding is sticky) + * 4. Orphaned "data" listeners removed (prevents phantom keypress handling) + * + * Idempotent — safe to call multiple times or when stdin is already clean. + */ +export function restoreStdin(): void { + try { + // 1. Disable raw mode (only on TTY) + if (process.stdin.isTTY) { + try { + process.stdin.setRawMode(false); + } catch { + // Already not in raw mode, or stdin is destroyed + } + } + + // 2. Pause stdin (exit flowing mode) + try { + process.stdin.pause(); + } catch { + // Already paused or destroyed + } + + // 3. Reset encoding back to Buffer mode + // setEncoding("utf8") is a one-way operation on the public API — + // the only way to undo it is to null out the internal decoder state. + try { + const state = (process.stdin as any)._readableState; + if (state) { + state.encoding = null; + state.decoder = null; + } + } catch { + // Internal state not accessible — non-critical + } + + // 4. Remove orphaned "data" listeners that Ink or remote mode attached + try { + process.stdin.removeAllListeners('data'); + } catch { + // Listeners already gone or stdin destroyed + } + } catch { + // Entire restoration failed — non-critical, best-effort cleanup + } +} From 4272cd08cb4da57602f8a05f1cc0ecfe84fafdfc Mon Sep 17 00:00:00 2001 From: tomstetson Date: Wed, 11 Feb 2026 01:07:07 -0500 Subject: [PATCH 2/2] fix: use exit tracking instead of child.killed for signal forwarding child.killed becomes true after the first kill() call, not when the child actually exits. This means if the child traps the first signal (e.g., SIGINT for graceful shutdown), subsequent signals from mode switches would be silently dropped, leaving the launcher waiting indefinitely. Track exit state with a boolean instead, and wrap kill() in try-catch for the race where the child exits between our check and the kill call. Addresses review feedback from Codex on PR #570. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../happy-cli/scripts/claude_version_utils.cjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/happy-cli/scripts/claude_version_utils.cjs b/packages/happy-cli/scripts/claude_version_utils.cjs index 024e9c0a018..803a96c1ba7 100644 --- a/packages/happy-cli/scripts/claude_version_utils.cjs +++ b/packages/happy-cli/scripts/claude_version_utils.cjs @@ -501,9 +501,18 @@ function runClaudeCli(cliPath) { // Forward termination signals to the child process. // Without this, the child becomes an orphan competing for stdin // when the parent is killed (e.g., during remote→local mode switch). + // Note: we track exit ourselves rather than using child.killed, because + // child.killed becomes true after the first kill() call — not when the + // child actually exits. We need to keep forwarding signals (e.g., a + // second SIGTERM) in case the child trapped the first one. + let childExited = false; const forwardSignal = (signal) => { - if (!child.killed) { - child.kill(signal); + if (!childExited) { + try { + child.kill(signal); + } catch { + // Child already gone + } } }; const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; @@ -512,6 +521,7 @@ function runClaudeCli(cliPath) { } child.on('exit', (code) => { + childExited = true; // Clean up signal handlers to avoid leaks for (const sig of signals) { process.removeAllListeners(sig);