Skip to content

Bug Report: Terminal input corruption after switching from remote to local mode #301

Closed

Description

@YujiShen

Environment

Component Version
happy-coder 0.13.0
Claude Code 2.0.76
Node.js v22.19.0
macOS 26.2 (Build 25C56)
Terminal xterm-24bit
Shell zsh

Summary

After switching from remote mode (mobile) back to local mode, terminal input becomes severely corrupted. Characters are dropped/eaten, the status line flashes erratically, and using slash commands (/) causes Claude Code CPU to spike to 100%, making the terminal completely unresponsive.

Steps to Reproduce

  1. Start happy-coder in local mode: happy
  2. Send a message from the mobile app (triggers automatic switch to remote mode)
  3. Press double-space to switch back to local mode
    • Note: Often requires pressing space 3-5+ times before the switch actually works
  4. Attempt to type in local mode

Expected Behavior

  • Double-space switches cleanly to local mode
  • Terminal displays chat history normally
  • Typing works normally with all characters appearing

Actual Behavior

  1. Multiple spaces required: Double-space often doesn't work; need 3-5+ presses
  2. Missing chat history: Terminal doesn't refresh properly, missing conversation context
  3. Character loss: Typed characters are dropped/eaten intermittently
  4. Status line flashing: Duration and context window values flash back and forth
  5. Slash command crash: Pressing / causes Claude Code CPU to spike to 100%
  6. Complete unresponsiveness: Terminal becomes frozen, requiring kill of both Claude Code and happy-coder processes

Additional Symptom

On exit, an EIO error is emitted:

EIO: i/o error, read
      fd: 11,
 syscall: "read",
   errno: -5,
    code: "EIO"

Root Cause Analysis

After investigating the source code, several issues were identified in the remote→local mode transition:

1. stdin state not properly reset after remote mode

In claudeRemoteLauncher finally block (~line 2980-2992 in index-B3gQr6vs.mjs):

} finally {
  permissionHandler.reset();
  process.stdin.off("data", abort);
  if (process.stdin.isTTY) {
    process.stdin.setRawMode(false);
  }
  if (inkInstance) {
    inkInstance.unmount();
  }
  // ...
}

Problems:

  • stdin.pause() is never called - stdin remains in "flowing" mode
  • setEncoding("utf8") set during remote mode setup (~line 2693) is never reset
  • Keypress listeners may not be fully removed

Compare to remote mode setup (~line 2689-2695):

if (hasTTY) {
  process.stdin.resume();
  if (process.stdin.isTTY) {
    process.stdin.setRawMode(true);
  }
  process.stdin.setEncoding("utf8");  // This persists!
}

2. Race condition with Ink unmount

When inkInstance.unmount() is called, Ink's internal stdin handling may not fully clean up before claudeLocal spawns the new Claude Code process with stdio: ["inherit"].

3. Async cleanup not awaited

In the main cleanup handler (~line 5210-5215):

stopCaffeinate();
happyServer.stop();    // Not awaited
hookServer.stop();     // Not awaited
cleanupHookSettingsFile(hookSettingsPath);
logger.debug("[START] Cleanup complete, exiting");
process.exit(0);       // Exits while async operations still running

This causes the EIO error on exit.

Attempted Fixes (Unsuccessful)

Added to the finally block:

process.stdin.removeAllListeners("keypress");
process.stdin.pause();

This did not resolve the issue, suggesting the problem may be deeper - possibly in how Claude Code handles inherited stdin when the terminal is in an inconsistent state, or a race condition with Ink.

Suggested Investigation Areas

  1. Ink stdin handling: Check if Ink fully releases stdin control on unmount
  2. Claude Code terminal setup: How does Claude Code initialize terminal when resuming a session with inherited stdio?
  3. Stream encoding: The setEncoding("utf8") call may affect how Claude Code reads raw terminal input
  4. Timing: Add delays or proper awaits between Ink unmount and Claude spawn

Workaround

Currently none - the remote→local switch is essentially unusable. Users must avoid using mobile mode or restart happy-coder after each mobile interaction.

Log Reference

Relevant log entries showing the mode switch:

[01:02:37.816] [loop] Iteration with mode: remote
[01:02:58.064] [remote]: Switching to local mode via double space
[01:02:58.065] [remote]: doSwitch
[01:02:58.066] [remote]: launch finally
[01:02:58.076] [loop] Iteration with mode: local
[01:02:58.079] [local]: launch
[01:02:58.079] [ClaudeLocal] Will resume existing session: ...

The switch appears to complete in logs, but terminal state is corrupted.

Activity

  1. tonydehnke commented on Jan 4, 2026

    @tonydehnke

    Experiencing this same issue. After switching from iPhone back to Mac, cursor input becomes extremely slow/laggy - to the point of needing to force quit the terminal session entirely.

    The double-space switch often doesn't register on first try (need 3-5 presses as described). Once back in local mode, typing is nearly unusable.

    Environment

    Component Version
    happy-coder 0.13.0
    Claude Code 2.0.76
    Node.js v24.12.0
    macOS 26.2 (Build 25C56)
    Terminal xterm-256color
    Shell zsh

    This is a blocker for the mobile→desktop workflow. Would love to see this prioritized.

  2. sstraus commented on Jan 6, 2026

    @sstraus
    Contributor

    This is becoming really annoying. Someone found a solution?

  3. gabimoncha commented on Jan 6, 2026

    @gabimoncha

    i just installed the app and experienced too

  4. JoshuaSeidel commented on Jan 9, 2026

    @JoshuaSeidel

    upvote

  5. ShareOut commented on Jan 12, 2026

    @ShareOut

    Hi,

    I ran into an issue where Claude Code processes can remain alive as orphans when happy switches modes (remote/local). These detached processes keep stdin inherited, which leads to corrupted terminal behavior (e.g. characters being randomly swallowed due to multiple processes reading from stdin).

    On my system, this happens because termination signals are not propagated to the spawned Claude binary. As a result, killing the launcher does not terminate the child process.

    I applied the following fix locally, which resolves the issue for me: propagate termination signals (SIGTERM, SIGINT, SIGHUP) to the spawned child process.

    Location:
    happy-cli/scripts/claude_version_utils.cjs (around line 362), inside the binary spawn branch.

    After this code :

    const isJsFile = cliPath.endsWith('.js') || cliPath.endsWith('.cjs');
    
    if (isJsFile) {
        // JavaScript file - use import to keep interceptors working
        const importUrl = pathToFileURL(cliPath).href;
        import(importUrl);
    } else {
        // Binary file (e.g., Homebrew installation) - spawn directly
        // Note: Interceptors won't work with binary files, but that's acceptable
        // as binary files are self-contained and don't need interception
        const args = process.argv.slice(2);
        const child = spawn(cliPath, args, {
            stdio: 'inherit',
            env: process.env
        });
    
    

    Put :

    
    // Propagate termination signals to child process
    // Without this, when the launcher is killed (e.g., during mode switch),
    // the Claude binary survives as an orphan and keeps stdin inherited,
    // causing race conditions with other processes reading from the terminal.
    ['SIGTERM', 'SIGINT', 'SIGHUP'].forEach(signal => {
        process.on(signal, () => {
            if (child && !child.killed) {
                child.kill(signal);
            }
        });
    });
    

    This works in practice because happy already recreates a fresh Claude instance on each remote/local switch.

    I haven’t yet verified whether this fully aligns with the rest of the codebase or any intended lifecycle management, so feedback is very welcome.

    Let me know what you think.

  6. materemias commented on Jan 13, 2026

    @materemias

    Hi,

    I ran into an issue where Claude Code processes can remain alive as orphans when happy switches modes (remote/local). These detached processes keep stdin inherited, which leads to corrupted terminal behavior (e.g. characters being randomly swallowed due to multiple processes reading from stdin).

    On my system, this happens because termination signals are not propagated to the spawned Claude binary. As a result, killing the launcher does not terminate the child process.

    I applied the following fix locally, which resolves the issue for me: propagate termination signals (SIGTERM, SIGINT, SIGHUP) to the spawned child process.

    Location: happy-cli/scripts/claude_version_utils.cjs (around line 362), inside the binary spawn branch.

    After this code :

    const isJsFile = cliPath.endsWith('.js') || cliPath.endsWith('.cjs');
    
    if (isJsFile) {
        // JavaScript file - use import to keep interceptors working
        const importUrl = pathToFileURL(cliPath).href;
        import(importUrl);
    } else {
        // Binary file (e.g., Homebrew installation) - spawn directly
        // Note: Interceptors won't work with binary files, but that's acceptable
        // as binary files are self-contained and don't need interception
        const args = process.argv.slice(2);
        const child = spawn(cliPath, args, {
            stdio: 'inherit',
            env: process.env
        });
    

    Put :

    
    // Propagate termination signals to child process
    // Without this, when the launcher is killed (e.g., during mode switch),
    // the Claude binary survives as an orphan and keeps stdin inherited,
    // causing race conditions with other processes reading from the terminal.
    ['SIGTERM', 'SIGINT', 'SIGHUP'].forEach(signal => {
        process.on(signal, () => {
            if (child && !child.killed) {
                child.kill(signal);
            }
        });
    });
    

    This works in practice because happy already recreates a fresh Claude instance on each remote/local switch.

    I haven’t yet verified whether this fully aligns with the rest of the codebase or any intended lifecycle management, so feedback is very welcome.

    Let me know what you think.

    This worked for me. Do you plan to submit a PR in happy-cli repo?

  7. sferich888 commented on Jan 20, 2026

    @sferich888

    Confirming on Linux (Fedora 43)

    Experiencing the same terminal input corruption issues on Linux.

    Environment

    Component Version
    OS Fedora Linux 43 (Workstation Edition)
    Kernel 6.17.11-300.fc43.x86_64
    happy 0.13.0
    Claude Code 2.1.12
    Node.js v24.12.0
    npm 11.6.2
    Shell bash
    Terminal GNOME Terminal
    TERM xterm-256color
    COLORTERM truecolor

    Notes

    • Issue persists on Claude Code 2.1.12 (newer than original report's 2.0.76)
    • Cross-platform: reproduces on Linux, not just macOS
  8. materemias commented on Jan 20, 2026

    @materemias

    this PR fixes it

  9. sferich888 commented on Jan 22, 2026

    @sferich888

    Is this then a duplicate of #430 ?

  10. added a commit that references this issue on Feb 16, 2026
    4f10167
  11. added 2 commits that reference this issue on Feb 25, 2026
    5c5b193
    abdf854
  12. moved this from Backlog to In progress in roadmapon Apr 13, 2026
  13. bra1nDump commented on Apr 14, 2026

    @bra1nDump
    Contributor

    tracking this in #613 - same root cause (input handler persists after remote→local switch). closing here to keep discussion in one place.

  14. moved this from In progress to Done in roadmapon Apr 14, 2026
  15. added a commit that references this issue on May 1, 2026
    7e5729e
  16. added a commit that references this issue on May 8, 2026
    c4e1c23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions