-
Notifications
You must be signed in to change notification settings - Fork 815
Description
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
- Start happy-coder in local mode:
happy - Send a message from the mobile app (triggers automatic switch to remote mode)
- Press double-space to switch back to local mode
- Note: Often requires pressing space 3-5+ times before the switch actually works
- 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
- Multiple spaces required: Double-space often doesn't work; need 3-5+ presses
- Missing chat history: Terminal doesn't refresh properly, missing conversation context
- Character loss: Typed characters are dropped/eaten intermittently
- Status line flashing: Duration and context window values flash back and forth
- Slash command crash: Pressing
/causes Claude Code CPU to spike to 100% - Complete unresponsiveness: Terminal becomes frozen, requiring
killof 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" modesetEncoding("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 runningThis 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
- Ink stdin handling: Check if Ink fully releases stdin control on unmount
- Claude Code terminal setup: How does Claude Code initialize terminal when resuming a session with inherited stdio?
- Stream encoding: The
setEncoding("utf8")call may affect how Claude Code reads raw terminal input - 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.