Skip to content
Closed
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
80 changes: 76 additions & 4 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9426,7 +9426,39 @@ def _get_voice_status():
'voice-status-recording': 'bg:#1a1a2e #FF4444 bold',
}
style = PTStyle.from_dict(self._build_tui_style_dict())


# Detect whether stdin is directly usable by the asyncio selector.
# On macOS with uv-managed Python or inside IDE terminals (e.g. Kiro),
# fstat(0) passes but kqueue raises OSError EINVAL when trying to watch
# fd 0. In that case we use a PipeInput so prompt_toolkit never touches
# fd 0 directly, and a feeder thread bridges real stdin → the pipe.
_pipe_input_ctx = None
_pipe_input_obj = None
_stdin_selector_ok = True
try:
import selectors as _sel_check
_ts = _sel_check.DefaultSelector()
try:
_ts.register(0, _sel_check.EVENT_READ)
_ts.unregister(0)
except (OSError, KeyError):
_stdin_selector_ok = False
finally:
_ts.close()
except Exception:
pass

_app_extra_kwargs: dict = {}
if not _stdin_selector_ok:
try:
from prompt_toolkit.input import create_pipe_input
_pipe_input_ctx = create_pipe_input()
_pipe_input_obj = _pipe_input_ctx.__enter__()
_app_extra_kwargs["input"] = _pipe_input_obj
except Exception:
_pipe_input_ctx = None
_pipe_input_obj = None

# Create the application
app = Application(
layout=layout,
Expand All @@ -9435,6 +9467,7 @@ def _get_voice_status():
full_screen=False,
mouse_support=False,
**({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}),
**_app_extra_kwargs,
)
self._app = app # Store reference for clarify_callback

Expand Down Expand Up @@ -9705,19 +9738,52 @@ def _suppress_closed_loop_errors(loop, context):
# Validate stdin before launching prompt_toolkit — on macOS with
# uv-managed Python, fd 0 can be invalid or unregisterable with the
# asyncio selector, causing "KeyError: '0 is not registered'" (#6393).
# Also catches environments (e.g. IDE terminals) where fstat() passes
# but the kqueue/epoll selector raises OSError EINVAL (#6393 follow-up).
# When _pipe_input_obj is set we already have a working fallback, so
# only bail out if stdin is completely unavailable (fstat fails).
_stdin_usable = True
try:
import os as _os
_os.fstat(0)
except OSError:
_stdin_usable = False

if not _stdin_usable and _pipe_input_obj is None:
print(
"Error: stdin (fd 0) is not available.\n"
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n"
"Error: stdin (fd 0) is not usable by the asyncio selector.\n"
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS)\n"
"or when running inside an IDE terminal that doesn't provide a real PTY.\n"
"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup"
)
_run_cleanup()
self._print_exit_summary()
return

# If using pipe input fallback, start a feeder thread that reads real
# stdin and forwards each line into the pipe so prompt_toolkit receives
# keystrokes without needing to watch fd 0 via the asyncio selector.
_stdin_feeder_thread = None
if _pipe_input_obj is not None:
import sys as _sys

def _stdin_feeder():
try:
for _line in _sys.stdin:
if self._should_exit:
break
try:
_pipe_input_obj.send_text(_line)
except Exception:
break
except Exception:
pass

_stdin_feeder_thread = threading.Thread(
target=_stdin_feeder, daemon=True, name="stdin-feeder"
)
_stdin_feeder_thread.start()

# Run the application with patch_stdout for proper output handling
try:
with patch_stdout():
Expand All @@ -9734,7 +9800,7 @@ def _suppress_closed_loop_errors(loop, context):
except (KeyError, OSError) as _stdin_err:
# Catch selector registration failures from broken stdin (#6393).
# This is the fallback for cases that slip past the fstat() guard.
if "is not registered" in str(_stdin_err) or "Bad file descriptor" in str(_stdin_err):
if "is not registered" in str(_stdin_err) or "Bad file descriptor" in str(_stdin_err) or "Invalid argument" in str(_stdin_err):
print(
f"\nError: stdin is not usable ({_stdin_err}).\n"
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n"
Expand All @@ -9744,6 +9810,12 @@ def _suppress_closed_loop_errors(loop, context):
raise
finally:
self._should_exit = True
# Clean up pipe input context manager if we used the fallback
if _pipe_input_ctx is not None:
try:
_pipe_input_ctx.__exit__(None, None, None)
except Exception:
pass
# Interrupt the agent immediately so its daemon thread stops making
# API calls and exits promptly (agent_thread is daemon, so the
# process will exit once the main thread finishes, but interrupting
Expand Down