{"repo":"NousResearch/hermes-agent","public_id":"40490","display_label":"CLI deadlock on lazy-dep install prompt under prompt_toolkit","summary":"A bug where using bare input() for lazy-dependency install prompts causes an unrecoverable lockup when prompt_toolkit owns the terminal. Multiple fix attempts address this by skipping or properly routing the interactive prompt in that context.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40901},"resolution":{"state":"resolved","by":"pr-40901"},"freshness":{"state":"verified","last_verified_at":"2026-06-07T01:44:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":40490,"pr_number":40901,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":40490,"title":"[Bug]: CLI input locks up unrecoverably on lazy-dep install prompt (lazy_deps.py uses bare input() under prompt_toolkit)","url":null,"state":"closed"},{"kind":"pr","number":40506,"title":"fix(cli): skip interactive lazy-install prompt when under prompt_toolkit (#40490)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40561","number":40561},{"kind":"pr","value":"#40490","number":40490}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40520,"title":"fix(cli): use prompt_toolkit run_in_terminal for lazy-dep install prompt (#40490)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40561","number":40561},{"kind":"pr","value":"#40490","number":40490}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":40561,"title":"fix(lazy-deps): don't deadlock the lazy-install prompt under the TUI (#40490)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d47f919ef","number":0},{"kind":"commit","value":"c3d750c1a","number":0},{"kind":"pr","value":"#40490","number":40490},{"kind":"pr","value":"#40490","number":40490}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40579,"title":"fix(cli): skip lazy-dep prompt when prompt_toolkit owns terminal (#40490)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d47f919ef","number":0},{"kind":"pr","value":"#40901","number":40901}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40901,"title":"fix(cli): never deadlock on lazy-dep prompt + make Pillow a core dep (#40490)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["pyproject.toml","tools/lazy_deps.py","tools/transcription_tools.py","tools/vision_tools.py"],"fix_hunks":[{"file":"pyproject.toml","hunks":["@@ -91,6 +91,14 @@ dependencies = [\n   \"uvicorn[standard]>=0.24.0,<1\",\n   \"ptyprocess>=0.7.0,<1; sys_platform != 'win32'\",\n   \"pywinpty>=2.0.0,<3; sys_platform == 'win32'\",\n+  # Image resize recovery for the vision tools. Pillow shrinks oversized images\n+  # (>5 MB or >8000px) at embed time; without it the byte AND pixel-dimension\n+  # shrink paths no-op, so an oversized image bakes into immutable history and\n+  # bricks the session on Anthropic's non-retryable 400. Pure-wheel, no system\n+  # libs required for the codecs we use, so it's safe to ship in the base\n+  # install rather than gating it behind an extra + a mid-session lazy install\n+  # (which deadlocked the CLI under prompt_toolkit — see #40490).\n+  \"Pillow==12.2.0\",\n ]\n \n [project.optional-dependencies]","@@ -137,14 +145,12 @@ pty = [\n   # without pulling in extra packages.\n ]\n honcho = [\"honcho-ai==2.0.1\"]\n-# Image resize recovery for the vision tools.  Pillow is a soft dependency:\n-# vision_tools / conversation_compression degrade gracefully without it (they\n-# log and skip the resize), but without it the byte AND pixel-dimension shrink\n-# paths silently no-op, so an oversized image (>5 MB or >8000px) bakes into\n-# immutable history and bricks the session on Anthropic's non-retryable 400.\n-# Declared here so packagers (Nix, Homebrew) ship it with [all] and so\n-# `pip install hermes-agent[vision]` / the lazy-install path can resolve it.\n-vision = [\"Pillow==12.2.0\"]\n+# Image resize recovery for the vision tools. Pillow is now a CORE dependency\n+# (see the main `dependencies` list) since the byte/pixel shrink paths are on\n+# the default vision-embed path and the mid-session lazy install deadlocked the\n+# CLI under prompt_toolkit (#40490). This extra is kept as a no-op back-compat\n+# alias so existing `pip install hermes-agent[vision]` invocations still resolve.\n+vision = []\n # CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's\n # sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before\n # 1.0.1, a malformed Host header makes `request.url.path` desync from the path"]},{"file":"tools/lazy_deps.py","hunks":["@@ -174,11 +174,11 @@\n         \"uvicorn[standard]==0.41.0\",\n         \"starlette==1.0.1\",  # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]\n     ),\n-    # Vision image-resize recovery (Pillow). Soft dependency: vision_tools and\n-    # conversation_compression degrade gracefully without it, but the byte AND\n-    # pixel-dimension shrink paths no-op when it's absent, so an oversized\n-    # image can brick a session on Anthropic's non-retryable 400. Keep in sync\n-    # with pyproject [vision].\n+    # Vision image-resize recovery (Pillow). Pillow is now a CORE dependency\n+    # (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback\n+    # for stripped/source-build installs that somehow dropped it. The vision\n+    # call site uses prompt=False so it can never raise a blocking input()\n+    # prompt mid-session (#40490).\n     \"tool.vision\": (\"Pillow==12.2.0\",),\n }\n ","@@ -456,7 +456,23 @@ def ensure(feature: str, *, prompt: bool = True) -> None:\n             \"lazy installs disabled (security.allow_lazy_installs=false)\"\n         )\n \n-    if prompt and sys.stdin.isatty() and sys.stdout.isatty():\n+    # Only show the interactive confirmation when we own a TTY and\n+    # prompt_toolkit isn't running.  A bare input() deadlocks when a\n+    # prompt_toolkit app owns the terminal because keystrokes route to\n+    # its event loop rather than stdin, so the prompt blocks forever.\n+    # Under the TUI we skip the prompt and proceed — lazy installs are\n+    # gated by security.allow_lazy_installs, so reaching here is\n+    # already user opt-in.\n+    _pt_active = False\n+    if \"prompt_toolkit.application.current\" in sys.modules:\n+        try:\n+            from prompt_toolkit.application.current import get_app_or_none\n+            _app = get_app_or_none()\n+            _pt_active = _app is not None and getattr(_app, \"is_running\", False)\n+        except Exception:\n+            _pt_active = False\n+\n+    if prompt and not _pt_active and sys.stdin.isatty() and sys.stdout.isatty():\n         spec_list = \", \".join(missing)\n         try:\n             answer = input("]},{"file":"tools/transcription_tools.py","hunks":["@@ -214,7 +214,11 @@ def _try_lazy_install_stt() -> bool:\n     \"\"\"\n     try:\n         from tools.lazy_deps import ensure\n-        ensure(\"stt.faster_whisper\")\n+        # prompt=False: never raise a blocking input() prompt mid-session.\n+        # Under the interactive CLI prompt_toolkit owns stdin, so a bare\n+        # input() deadlocks the terminal (#40490). The install is already\n+        # gated by security.allow_lazy_installs, so reaching here is opt-in.\n+        ensure(\"stt.faster_whisper\", prompt=False)\n         # Re-check dynamically after install\n         import importlib.util as _iu\n         if _iu.find_spec(\"faster_whisper\"):"]},{"file":"tools/vision_tools.py","hunks":["@@ -422,7 +422,11 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None,\n         # the raw bytes and let the caller raise the size error.\n         try:\n             from tools.lazy_deps import ensure as _ensure_dep\n-            _ensure_dep(\"tool.vision\")\n+            # prompt=False: never raise a blocking input() prompt mid-session.\n+            # Under the interactive CLI prompt_toolkit owns stdin, so a bare\n+            # input() deadlocks the terminal (#40490). The install is already\n+            # gated by security.allow_lazy_installs, so reaching here is opt-in.\n+            _ensure_dep(\"tool.vision\", prompt=False)\n             from PIL import Image\n             import io as _io\n         except Exception:"]},{"file":"uv.lock","hunks":["@@ -1401,6 +1401,7 @@ dependencies = [\n     { name = \"markdown\" },\n     { name = \"openai\" },\n     { name = \"pathspec\" },\n+    { name = \"pillow\" },\n     { name = \"prompt-toolkit\" },\n     { name = \"psutil\" },\n     { name = \"ptyprocess\", marker = \"sys_platform != 'win32'\" },","@@ -1563,9 +1564,6 @@ termux-all = [\n tts-premium = [\n     { name = \"elevenlabs\" },\n ]\n-vision = [\n-    { name = \"pillow\" },\n-]\n voice = [\n     { name = \"faster-whisper\" },\n     { name = \"numpy\" },","@@ -1654,7 +1652,7 @@ requires-dist = [\n     { name = \"openai\", specifier = \"==2.24.0\" },\n     { name = \"parallel-web\", marker = \"extra == 'parallel-web'\", specifier = \"==0.4.2\" },\n     { name = \"pathspec\", specifier = \"==1.1.1\" },\n-    { name = \"pillow\", marker = \"extra == 'vision'\", specifier = \"==12.2.0\" },\n+    { name = \"pillow\", specifier = \"==12.2.0\" },\n     { name = \"prompt-toolkit\", specifier = \"==3.0.52\" },\n     { name = \"psutil\", specifier = \"==7.2.2\" },\n     { name = \"ptyprocess\", marker = \"sys_platform != 'win32'\", specifier = \">=0.7.0,<1\" },\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}