# NousResearch/hermes-agent · Install and CLI stdin/TTY crash fixes

## Open now

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

## TL;DR

- 25 member(s): 0 open issue(s), 9 closed issue(s), 0 open PR(s), 4 merged PR(s), 12 PR(s) closed without merging.
- A fix was merged to main in PR #8560. Verify it addresses your case.
- **Recommended:** read PR #8560 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-8560,pr-25734,pr-26077`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #5884: \[Setup\]: OSError: \[Errno 22\] Invalid argument:\[prompt_toolkit crash\]
- #6393: \[Bug\]: after install it, startup failed
- #8641: setup.py: hermes chat fails to launch after setup in non-TTY environments
- #12074: \[Bug\]: Installing, configuring fully and launching chat yields a crash
- #21332: \[Bug\]: Hermes macOS install/startup regression report
- #21454: Bug: Re-running install.sh on prior install creates infinite-loop shim (CLI hangs)
- #21802: \[Bug\]: venv/bin/hermes entrypoint is a self-referencing bash wrapper, causing infinite   recursion
- #24834: install.sh corrupts venv \`hermes\` entry-point when ~/.local/bin/hermes is a symlink
- #26328: \[Setup\]: Killed when Starting setup wizard

## Prior & competing PRs

- **#8560: fix: handle broken stdin in prompt_toolkit startup (#6393)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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():
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `scripts/install.sh`, `tests/test_install_sh_symlink_stomp.py`
  - fix hunks:
  - `scripts/install.sh`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `scripts/release.py`
- **#6401: fix: fix start failed after config it**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `704488b2074399d1f49ef60e324aa28ca98f0b4f`, `#6393`
- **#6415: fix: relaunch chat in a fresh process when setup leaves stdin dirty**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#8420: Fix non-TTY interactive chat launch handling**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#8668: fix(setup): skip auto-launch without interactive stdin**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `256bedb63`, `#25067`
- **#8796: fix: catch OSError 'Invalid argument' in stdin selector guard**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
- **#13251: fix(cli): also suppress OSError EINVAL from kqueue stdin register (#6393)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
- **#13454: fix(setup): reattach tty before launching chat**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#25067`
- **#20948: fix(cli): fall back to SelectSelector when kqueue can't watch stdin on macOS**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`
- **#21513: fix(install): preserve pip entry point when re-running on symlinked install**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#22388: fix(install): detect non-Python venv/bin/hermes and regenerate via pip**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `c75e1a03f`, `#25734`
- **#24850: fix(install): rm symlink before writing hermes launcher to prevent venv corruption**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `c75e1a03f`, `#25734`, `#21513`
- **#26421: fix(install): use Python.app fallback on macOS**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`

## Fix status

- Merged to main in PR #8560 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-15`.
- Subsystem: `hermes_cli`
