Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/happy-cli/scripts/claude_version_utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
7 changes: 5 additions & 2 deletions packages/happy-cli/src/claude/claudeLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void>((r, reject) => {
const args: string[] = []

Expand Down
18 changes: 12 additions & 6 deletions packages/happy-cli/src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/happy-cli/src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions packages/happy-cli/src/utils/restoreStdin.ts
Original file line number Diff line number Diff line change
@@ -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
}
}