Skip to content

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

@YujiShen

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions