Install and CLI stdin/TTY crash fixes
NousResearch/hermes-agent · cluster 5884 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #8560. Check it covers your case.
The short answer
- Known?
- Yes. 25 related items grouped here, 9 issues already closed.
- Fix event
- A fix was merged to main in PR #8560. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #8560, #25734, #26077. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes address crashes and infinite loops caused by broken symlinks during install, self-referencing entrypoint wrappers, and OSError/EINVAL failures when registering stdin with kqueue on macOS. A recurring theme is handling non-TTY or broken stdin contexts in the CLI and setup flow.
Open issues0
Open PRs0
Fixes on record
-
#8560 fix: handle broken stdin in prompt_toolkit startup (#6393)
files:
cli.pyShow the diff
-
cli.py@@ -9569,17 +9569,37 @@ def _signal_handler(signum, frame): pass # Signal handlers may fail in restricted environments # Install a custom asyncio exception handler that suppresses the - # "Event loop is closed" RuntimeError from httpx transport cleanup. - # This is defense-in-depth — the primary fix is neuter_async_httpx_del - # which disables __del__ entirely, but older clients or SDK upgrades - # could bypass it. + # "Event loop is closed" RuntimeError from httpx transport cleanup + # and the "0 is not registered" KeyError from broken stdin (#6393). + # The RuntimeError fix is defense-in-depth — the primary fix is + # neuter_async_httpx_del which disables __del__ entirely. The + # KeyError fix handles macOS + uv-managed Python environments where + # fd 0 is not reliably available to the asyncio selector. def _suppress_closed_loop_errors(loop, context): exc = context.get("exception") if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc): return # silently suppress + if isinstance(exc, KeyError) and "is not registered" in str(exc): + return # suppress selector registration failures (#6393) # Fall back to default handler for everything else loop.default_exception_handler(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). + try: + import os as _os + _os.fstat(0) + except OSError: + print( + "Error: stdin (fd 0) is not available.\n" + "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" + "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + ) + _run_cleanup() + self._print_exit_summary() + return + # Run the application with patch_stdout for proper output handling try: with patch_stdout(): @@ -9593,6 +9613,17 @@ def _suppress_closed_loop_errors(loop, context): app.run() except (EOFError, KeyboardInterrupt, BrokenPipeError): pass + 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): + 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" + "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + ) + else: + raise finally: self._should_exit = True # Flush memories before exit (only for substantial conversations)
-
-
#25067 fix(setup): drop post-setup chat handoff
files:
hermes_cli/setup.py,tests/hermes_cli/test_setup.py,tests/hermes_cli/test_setup_openclaw_migration.py,tests/hermes_cli/test_setup_reconfigure.py -
#25734 fix(install): preserve pip entry point when re-running on symlinked install
files:
scripts/install.sh,tests/test_install_sh_symlink_stomp.pyShow the diff
-
scripts/install.sh@@ -1281,6 +1281,10 @@ setup_path() { # We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars # can't make this launcher import modules from another checkout. mkdir -p "$command_link_dir" + # Older installs created this path as a symlink to $HERMES_BIN. Without + # the rm, `cat >` follows the symlink and overwrites the venv pip entry + # point with this shim — making `exec "$HERMES_BIN"` self-recurse. (#21454) + rm -f "$command_link_dir/hermes" cat > "$command_link_dir/hermes" <<EOF #!/usr/bin/env bash unset PYTHONPATH -
tests/test_install_sh_symlink_stomp.py@@ -0,0 +1,123 @@ +"""Regression for #21454: re-running install.sh on a symlinked prior install. + +Older versions of ``install.sh`` created ``$command_link_dir/hermes`` as a +symlink to the pip-generated entry point at ``$HERMES_BIN`` (i.e. +``venv/bin/hermes``). When ``setup_path()`` later switched to writing a bash +shim with ``cat > "$command_link_dir/hermes" <<EOF``, the redirect followed +the existing symlink and overwrote the pip entry point with the shim. The +shim's ``exec "$HERMES_BIN" "$@"`` then self-recursed and ``hermes`` hung on +every invocation. + +These tests pin the fix: ``setup_path()`` must remove ``$command_link_dir/hermes`` +before writing through the redirect, so the shim is created as a regular file +in ``command_link_dir`` and the venv entry point is left intact. +""" + +from __future__ import annotations + +import re +import stat +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" + + +def _extract_setup_path_shim_block() -> str: + """Return the install.sh shim-write block used by setup_path().""" + text = INSTALL_SH.read_text() + match = re.search( + r"(?P<block>mkdir -p \"\$command_link_dir\".*?chmod \+x \"\$command_link_dir/hermes\")", + text, + re.DOTALL, + ) + assert match is not None, ( + "Could not locate the setup_path shim-write block in scripts/install.sh" + ) + return match["block"] + + +def test_setup_path_shim_block_removes_old_link_before_writing() -> None: + """Static guard: the rm must precede the cat heredoc, not follow it.""" + block = _extract_setup_path_shim_block() + rm_idx = block.find('rm -f "$command_link_dir/hermes"') + cat_idx = block.find('cat > "$command_link_dir/hermes" <<EOF') + assert rm_idx != -1, ( + "setup_path() must `rm -f` $command_link_dir/hermes before the " + "`cat >` heredoc, otherwise an existing symlink (left by older " + "installs) will be followed and the pip entry point overwritten. " + "See #21454." + ) + assert cat_idx != -1, "expected `cat >` heredoc still present" + assert rm_idx < cat_idx, ( + "`rm -f` must come *before* the `cat >` heredoc, not after." + ) + + +def test_re_running_setup_path_block_preserves_pip_entry_point(tmp_path: Path) -> None: + """Behavioral repro: simulate prior-install symlink + new-install heredoc. + + Layout mirrors a real install: + + tmp/ + venv/bin/hermes <- pip entry point (the one we must preserve) + local_bin/hermes <- symlink → ../venv/bin/hermes (old install) + + Then we run the exact shim-write block from setup_path() with + ``HERMES_BIN`` and ``command_link_dir`` pointed at this fixture. The fix + requires that, after the run: + + * ``venv/bin/hermes`` still contains its original pip-script body + * ``local_bin/hermes`` is a regular file (not a symlink) holding the shim + """ + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + pip_entry = venv_bin / "hermes" + pip_marker = "#!/usr/bin/env python\n# pip-generated entry point — must not be overwritten\n" + pip_entry.write_text(pip_marker) + pip_entry.chmod(pip_entry.stat().st_mode | stat.S_IXUSR) + + command_link_dir = tmp_path / "local_bin" + command_link_dir.mkdir() + shim_path = command_link_dir / "hermes" + # Reproduce the prior-install state: shim path is a symlink to the + # pip-generated entry point. + shim_path.symlink_to(pip_entry) + assert shim_path.is_symlink() + + block = _extract_setup_path_shim_block() + # Drive the block with the real env vars setup_path() sets. + script = f'set -e\nHERMES_BIN={pip_entry!s}\ncommand_link_dir={command_link_dir!s}\n{block}\n' + result = subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert result.returncode == 0, ( + f"shim-write block failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + # The pip entry point must still be the original pip script — not a + # re-written self-recursing bash shim. + assert pip_entry.read_text() == pip_marker, ( + "venv/bin/hermes was overwritten by setup_path() — symlink-stomp " + "regression (#21454)." + ) + + # The shim path itself must now be a regular file holding the launcher. + assert shim_path.exists() + assert not shim_path.is_symlink(), ( + "command_link_dir/hermes must be replaced with a regular file, not " + "left as a symlink — otherwise the next install will stomp again." + ) + shim_text = shim_path.read_text() + assert "unset PYTHONPATH" in shim_text + assert "unset PYTHONHOME" in shim_text + assert f'exec "{pip_entry}"' in shim_text + shim_mode = shim_path.stat().st_mode + assert shim_mode & stat.S_IXUSR, "shim must be user-executable"
-
-
#26077 fix(cli): fall back to SelectSelector when kqueue can't watch stdin on macOS
files:
cli.py,scripts/release.py
12 PRs closed without merging
From the close comments: superseded by another PR 4 · done elsewhere 2 · stale or abandoned 2 · closed without comment 1 · merged via another change 1 · other 2
-
#6401 fix: fix start failed after config it · done elsewhere →
704488b,#6393 - #6415 fix: relaunch chat in a fresh process when setup leaves stdin dirty · stale or abandoned
- #8420 Fix non-TTY interactive chat launch handling · other
-
#8668 fix(setup): skip auto-launch without interactive stdin · done elsewhere →
256bedb,#25067 - #8796 fix: catch OSError 'Invalid argument' in stdin selector guard · superseded by another PR
Show 7 more
- #13251 fix(cli): also suppress OSError EINVAL from kqueue stdin register (#6393) · superseded by another PR
-
#13454 fix(setup): reattach tty before launching chat · stale or abandoned →
#25067 - #20948 fix(cli): fall back to SelectSelector when kqueue can't watch stdin on macOS · merged via another change
- #21513 fix(install): preserve pip entry point when re-running on symlinked install · closed without comment
-
#22388 fix(install): detect non-Python venv/bin/hermes and regenerate via pip · superseded by another PR →
c75e1a0,#25734 -
#24850 fix(install): rm symlink before writing hermes launcher to prevent venv corruption · superseded by another PR →
c75e1a0,#25734,#21513 - #26421 fix(install): use Python.app fallback on macOS · other
Closed issues · 9
- Closed#5884[Setup]: OSError: [Errno 22] Invalid argument:[prompt_toolkit crash]
- Closed#6393[Bug]: after install it, startup failed
- Closed#8641setup.py: hermes chat fails to launch after setup in non-TTY environments
- Closed#12074[Bug]: Installing, configuring fully and launching chat yields a crash
- Closed#21332[Bug]: Hermes macOS install/startup regression report
- Closed#21454Bug: Re-running install.sh on prior install creates infinite-loop shim (CLI hangs)
- Closed#21802[Bug]: venv/bin/hermes entrypoint is a self-referencing bash wrapper, causing infinite recursion
- Closed#24834install.sh corrupts venv `hermes` entry-point when ~/.local/bin/hermes is a symlink
- Closed#26328[Setup]: Killed when Starting setup wizard
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.08: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab