diff --git a/packages/happy-cli/scripts/claude_version_utils.cjs b/packages/happy-cli/scripts/claude_version_utils.cjs index 2184917ae..803a96c1b 100644 --- a/packages/happy-cli/scripts/claude_version_utils.cjs +++ b/packages/happy-cli/scripts/claude_version_utils.cjs @@ -497,7 +497,35 @@ 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). + // 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 (!childExited) { + try { + child.kill(signal); + } catch { + // Child already gone + } + } + }; + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']; + for (const sig of signals) { + process.on(sig, () => forwardSignal(sig)); + } + child.on('exit', (code) => { + childExited = true; + // 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 d1dc7d11d..e5c8a8a07 100644 --- a/packages/happy-cli/src/claude/claudeLocal.ts +++ b/packages/happy-cli/src/claude/claudeLocal.ts @@ -9,6 +9,7 @@ import { claudeFindLastSession } from "./utils/claudeFindLastSession"; import { getProjectPath } from "./utils/path"; import { projectPath } from "@/projectPath"; import { systemPrompt } from "./utils/systemPrompt"; +import { restoreStdin } from "@/utils/restoreStdin"; /** * Error thrown when the Claude process exits with a non-zero exit code. @@ -177,8 +178,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 81e6454ab..5af37d04e 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"; @@ -441,14 +442,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 5e157bd45..3724167a1 100644 --- a/packages/happy-cli/src/claude/runClaude.ts +++ b/packages/happy-cli/src/claude/runClaude.ts @@ -27,6 +27,7 @@ import { startOfflineReconnection, connectionState } from '@/utils/serverConnect import { claudeLocal } from '@/claude/claudeLocal'; import { createSessionScanner } from '@/claude/utils/sessionScanner'; import { Session } from './session'; +import { restoreStdin } from '@/utils/restoreStdin'; /** JavaScript runtime to use for spawning Claude Code */ export type JsRuntime = 'node' | 'bun' @@ -372,6 +373,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 000000000..10aab4ea0 --- /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 + } +}