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

0open issues
0open PRs
25items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #8560 fix: handle broken stdin in prompt_toolkit startup (#6393)

    merged to main

    files: cli.py

    Show 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

    merged to main

    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

    merged to main

    files: scripts/install.sh, tests/test_install_sh_symlink_stomp.py

    Show 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

    merged to main

    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.00 means 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

At a glance

Subsystem
hermes cli
Merged PRs
#8560, #25067, #25734, #26077
Closed issues
9
Attempts
12 PRs closed without merging
Fix link checked
15 May 2026

For your agent

The same facts, typed.