{"repo":"NousResearch/hermes-agent","public_id":"5884","display_label":"Install and CLI stdin/TTY crash fixes","summary":"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.","subsystem":"hermes_cli","counts":{"members":25,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":9,"merged_prs":4,"attempt_prs":12},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":8560},"resolution":{"state":"partially_resolved","by":"pr-8560,pr-25734,pr-26077"},"freshness":{"state":"verified","last_verified_at":"2026-05-15T03:14:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07653333333333333},"fix_edges":[{"issue_number":5884,"pr_number":26077,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":6393,"pr_number":8560,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":8641,"pr_number":25067,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":21454,"pr_number":25734,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":21802,"pr_number":25734,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":24834,"pr_number":25734,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5884,"title":"[Setup]: OSError: [Errno 22] Invalid argument:[prompt_toolkit crash]","url":null,"state":"closed"},{"kind":"issue","number":6393,"title":"[Bug]: after install it, startup failed","url":null,"state":"closed"},{"kind":"issue","number":8641,"title":"setup.py: hermes chat fails to launch after setup in non-TTY environments","url":null,"state":"closed"},{"kind":"issue","number":12074,"title":"[Bug]: Installing, configuring fully and launching chat yields a crash","url":null,"state":"closed"},{"kind":"issue","number":21332,"title":"[Bug]: Hermes macOS install/startup regression report","url":null,"state":"closed"},{"kind":"issue","number":21454,"title":"Bug: Re-running install.sh on prior install creates infinite-loop shim (CLI hangs)","url":null,"state":"closed"},{"kind":"issue","number":21802,"title":"[Bug]: venv/bin/hermes entrypoint is a self-referencing bash wrapper, causing infinite   recursion","url":null,"state":"closed"},{"kind":"issue","number":24834,"title":"install.sh corrupts venv `hermes` entry-point when ~/.local/bin/hermes is a symlink","url":null,"state":"closed"},{"kind":"issue","number":26328,"title":"[Setup]: Killed when Starting setup wizard","url":null,"state":"closed"},{"kind":"pr","number":6401,"title":"fix: fix start failed after config it","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"704488b2074399d1f49ef60e324aa28ca98f0b4f","number":0},{"kind":"pr","value":"#6393","number":6393}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":6415,"title":"fix: relaunch chat in a fresh process when setup leaves stdin dirty","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":8420,"title":"Fix non-TTY interactive chat launch handling","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":8560,"title":"fix: handle broken stdin in prompt_toolkit startup (#6393)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -9569,17 +9569,37 @@ def _signal_handler(signum, frame):\n             pass  # Signal handlers may fail in restricted environments\n         \n         # Install a custom asyncio exception handler that suppresses the\n-        # \"Event loop is closed\" RuntimeError from httpx transport cleanup.\n-        # This is defense-in-depth — the primary fix is neuter_async_httpx_del\n-        # which disables __del__ entirely, but older clients or SDK upgrades\n-        # could bypass it.\n+        # \"Event loop is closed\" RuntimeError from httpx transport cleanup\n+        # and the \"0 is not registered\" KeyError from broken stdin (#6393).\n+        # The RuntimeError fix is defense-in-depth — the primary fix is\n+        # neuter_async_httpx_del which disables __del__ entirely.  The\n+        # KeyError fix handles macOS + uv-managed Python environments where\n+        # fd 0 is not reliably available to the asyncio selector.\n         def _suppress_closed_loop_errors(loop, context):\n             exc = context.get(\"exception\")\n             if isinstance(exc, RuntimeError) and \"Event loop is closed\" in str(exc):\n                 return  # silently suppress\n+            if isinstance(exc, KeyError) and \"is not registered\" in str(exc):\n+                return  # suppress selector registration failures (#6393)\n             # Fall back to default handler for everything else\n             loop.default_exception_handler(context)\n \n+        # Validate stdin before launching prompt_toolkit — on macOS with\n+        # uv-managed Python, fd 0 can be invalid or unregisterable with the\n+        # asyncio selector, causing \"KeyError: '0 is not registered'\" (#6393).\n+        try:\n+            import os as _os\n+            _os.fstat(0)\n+        except OSError:\n+            print(\n+                \"Error: stdin (fd 0) is not available.\\n\"\n+                \"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\\n\"\n+                \"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup\"\n+            )\n+            _run_cleanup()\n+            self._print_exit_summary()\n+            return\n+\n         # Run the application with patch_stdout for proper output handling\n         try:\n             with patch_stdout():","@@ -9593,6 +9613,17 @@ def _suppress_closed_loop_errors(loop, context):\n                 app.run()\n         except (EOFError, KeyboardInterrupt, BrokenPipeError):\n             pass\n+        except (KeyError, OSError) as _stdin_err:\n+            # Catch selector registration failures from broken stdin (#6393).\n+            # This is the fallback for cases that slip past the fstat() guard.\n+            if \"is not registered\" in str(_stdin_err) or \"Bad file descriptor\" in str(_stdin_err):\n+                print(\n+                    f\"\\nError: stdin is not usable ({_stdin_err}).\\n\"\n+                    \"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\\n\"\n+                    \"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup\"\n+                )\n+            else:\n+                raise\n         finally:\n             self._should_exit = True\n             # Flush memories before exit (only for substantial conversations)\n"]}]},{"kind":"pr","number":8668,"title":"fix(setup): skip auto-launch without interactive stdin","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"256bedb63","number":0},{"kind":"pr","value":"#25067","number":25067}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":8796,"title":"fix: catch OSError 'Invalid argument' in stdin selector guard","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":13251,"title":"fix(cli): also suppress OSError EINVAL from kqueue stdin register (#6393)","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":13454,"title":"fix(setup): reattach tty before launching chat","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#25067","number":25067}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":20948,"title":"fix(cli): fall back to SelectSelector when kqueue can't watch stdin on macOS","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21513,"title":"fix(install): preserve pip entry point when re-running on symlinked install","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":22388,"title":"fix(install): detect non-Python venv/bin/hermes and regenerate via pip","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c75e1a03f","number":0},{"kind":"pr","value":"#25734","number":25734}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24850,"title":"fix(install): rm symlink before writing hermes launcher to prevent venv corruption","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c75e1a03f","number":0},{"kind":"pr","value":"#25734","number":25734},{"kind":"pr","value":"#21513","number":21513}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25067,"title":"fix(setup): drop post-setup chat handoff","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]},{"kind":"pr","number":25734,"title":"fix(install): preserve pip entry point when re-running on symlinked install","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/install.sh","tests/test_install_sh_symlink_stomp.py"],"fix_hunks":[{"file":"scripts/install.sh","hunks":["@@ -1281,6 +1281,10 @@ setup_path() {\n     # We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars\n     # can't make this launcher import modules from another checkout.\n     mkdir -p \"$command_link_dir\"\n+    # Older installs created this path as a symlink to $HERMES_BIN. Without\n+    # the rm, `cat >` follows the symlink and overwrites the venv pip entry\n+    # point with this shim — making `exec \"$HERMES_BIN\"` self-recurse. (#21454)\n+    rm -f \"$command_link_dir/hermes\"\n     cat > \"$command_link_dir/hermes\" <<EOF\n #!/usr/bin/env bash\n unset PYTHONPATH"]},{"file":"tests/test_install_sh_symlink_stomp.py","hunks":["@@ -0,0 +1,123 @@\n+\"\"\"Regression for #21454: re-running install.sh on a symlinked prior install.\n+\n+Older versions of ``install.sh`` created ``$command_link_dir/hermes`` as a\n+symlink to the pip-generated entry point at ``$HERMES_BIN`` (i.e.\n+``venv/bin/hermes``). When ``setup_path()`` later switched to writing a bash\n+shim with ``cat > \"$command_link_dir/hermes\" <<EOF``, the redirect followed\n+the existing symlink and overwrote the pip entry point with the shim. The\n+shim's ``exec \"$HERMES_BIN\" \"$@\"`` then self-recursed and ``hermes`` hung on\n+every invocation.\n+\n+These tests pin the fix: ``setup_path()`` must remove ``$command_link_dir/hermes``\n+before writing through the redirect, so the shim is created as a regular file\n+in ``command_link_dir`` and the venv entry point is left intact.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import re\n+import stat\n+import subprocess\n+from pathlib import Path\n+\n+import pytest\n+\n+\n+REPO_ROOT = Path(__file__).resolve().parent.parent\n+INSTALL_SH = REPO_ROOT / \"scripts\" / \"install.sh\"\n+\n+\n+def _extract_setup_path_shim_block() -> str:\n+    \"\"\"Return the install.sh shim-write block used by setup_path().\"\"\"\n+    text = INSTALL_SH.read_text()\n+    match = re.search(\n+        r\"(?P<block>mkdir -p \\\"\\$command_link_dir\\\".*?chmod \\+x \\\"\\$command_link_dir/hermes\\\")\",\n+        text,\n+        re.DOTALL,\n+    )\n+    assert match is not None, (\n+        \"Could not locate the setup_path shim-write block in scripts/install.sh\"\n+    )\n+    return match[\"block\"]\n+\n+\n+def test_setup_path_shim_block_removes_old_link_before_writing() -> None:\n+    \"\"\"Static guard: the rm must precede the cat heredoc, not follow it.\"\"\"\n+    block = _extract_setup_path_shim_block()\n+    rm_idx = block.find('rm -f \"$command_link_dir/hermes\"')\n+    cat_idx = block.find('cat > \"$command_link_dir/hermes\" <<EOF')\n+    assert rm_idx != -1, (\n+        \"setup_path() must `rm -f` $command_link_dir/hermes before the \"\n+        \"`cat >` heredoc, otherwise an existing symlink (left by older \"\n+        \"installs) will be followed and the pip entry point overwritten. \"\n+        \"See #21454.\"\n+    )\n+    assert cat_idx != -1, \"expected `cat >` heredoc still present\"\n+    assert rm_idx < cat_idx, (\n+        \"`rm -f` must come *before* the `cat >` heredoc, not after.\"\n+    )\n+\n+\n+def test_re_running_setup_path_block_preserves_pip_entry_point(tmp_path: Path) -> None:\n+    \"\"\"Behavioral repro: simulate prior-install symlink + new-install heredoc.\n+\n+    Layout mirrors a real install:\n+\n+        tmp/\n+          venv/bin/hermes        <- pip entry point (the one we must preserve)\n+          local_bin/hermes       <- symlink → ../venv/bin/hermes  (old install)\n+\n+    Then we run the exact shim-write block from setup_path() with\n+    ``HERMES_BIN`` and ``command_link_dir`` pointed at this fixture. The fix\n+    requires that, after the run:\n+\n+      * ``venv/bin/hermes`` still contains its original pip-script body\n+      * ``local_bin/hermes`` is a regular file (not a symlink) holding the shim\n+    \"\"\"\n+    venv_bin = tmp_path / \"venv\" / \"bin\"\n+    venv_bin.mkdir(parents=True)\n+    pip_entry = venv_bin / \"hermes\"\n+    pip_marker = \"#!/usr/bin/env python\\n# pip-generated entry point — must not be overwritten\\n\"\n+    pip_entry.write_text(pip_marker)\n+    pip_entry.chmod(pip_entry.stat().st_mode | stat.S_IXUSR)\n+\n+    command_link_dir = tmp_path / \"local_bin\"\n+    command_link_dir.mkdir()\n+    shim_path = command_link_dir / \"hermes\"\n+    # Reproduce the prior-install state: shim path is a symlink to the\n+    # pip-generated entry point.\n+    shim_path.symlink_to(pip_entry)\n+    assert shim_path.is_symlink()\n+\n+    block = _extract_setup_path_shim_block()\n+    # Drive the block with the real env vars setup_path() sets.\n+    script = f'set -e\\nHERMES_BIN={pip_entry!s}\\ncommand_link_dir={command_link_dir!s}\\n{block}\\n'\n+    result = subprocess.run(\n+        [\"bash\", \"-c\", script],\n+        capture_output=True,\n+        text=True,\n+        cwd=tmp_path,\n+    )\n+    assert result.returncode == 0, (\n+        f\"shim-write block failed:\\nstdout={result.stdout}\\nstderr={result.stderr}\"\n+    )\n+\n+    # The pip entry point must still be the original pip script — not a\n+    # re-written self-recursing bash shim.\n+    assert pip_entry.read_text() == pip_marker, (\n+        \"venv/bin/hermes was overwritten by setup_path() — symlink-stomp \"\n+        \"regression (#21454).\"\n+    )\n+\n+    # The shim path itself must now be a regular file holding the launcher.\n+    assert shim_path.exists()\n+    assert not shim_path.is_symlink(), (\n+        \"command_link_dir/hermes must be replaced with a regular file, not \"\n+        \"left as a symlink — otherwise the next install will stomp again.\"\n+    )\n+    shim_text = shim_path.read_text()\n+    assert \"unset PYTHONPATH\" in shim_text\n+    assert \"unset PYTHONHOME\" in shim_text\n+    assert f'exec \"{pip_entry}\"' in shim_text\n+    shim_mode = shim_path.stat().st_mode\n+    assert shim_mode & stat.S_IXUSR, \"shim must be user-executable\"\n"]}]},{"kind":"pr","number":26077,"title":"fix(cli): fall back to SelectSelector when kqueue can't watch stdin on macOS","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","scripts/release.py"]},{"kind":"pr","number":26421,"title":"fix(install): use Python.app fallback on macOS","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}