CLI deadlock on lazy-dep install prompt under prompt_toolkit

NousResearch/hermes-agent · cluster 40490

Data as of

Nothing open on GitHub. Fix merged in #40901. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #40901. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #40901. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

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.

  • #40901 fix(cli): never deadlock on lazy-dep prompt + make Pillow a core dep (#40490)

    merged to main

    files: pyproject.toml, tools/lazy_deps.py, tools/transcription_tools.py, tools/vision_tools.py

    Show the diff
    • pyproject.toml
      @@ -91,6 +91,14 @@ dependencies = [
         "uvicorn[standard]>=0.24.0,<1",
         "ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
         "pywinpty>=2.0.0,<3; sys_platform == 'win32'",
      +  # Image resize recovery for the vision tools. Pillow shrinks oversized images
      +  # (>5 MB or >8000px) at embed time; without it the byte AND pixel-dimension
      +  # shrink paths no-op, so an oversized image bakes into immutable history and
      +  # bricks the session on Anthropic's non-retryable 400. Pure-wheel, no system
      +  # libs required for the codecs we use, so it's safe to ship in the base
      +  # install rather than gating it behind an extra + a mid-session lazy install
      +  # (which deadlocked the CLI under prompt_toolkit — see #40490).
      +  "Pillow==12.2.0",
       ]
       
       [project.optional-dependencies]
      @@ -137,14 +145,12 @@ pty = [
         # without pulling in extra packages.
       ]
       honcho = ["honcho-ai==2.0.1"]
      -# Image resize recovery for the vision tools.  Pillow is a soft dependency:
      -# vision_tools / conversation_compression degrade gracefully without it (they
      -# log and skip the resize), but without it the byte AND pixel-dimension shrink
      -# paths silently no-op, so an oversized image (>5 MB or >8000px) bakes into
      -# immutable history and bricks the session on Anthropic's non-retryable 400.
      -# Declared here so packagers (Nix, Homebrew) ship it with [all] and so
      -# `pip install hermes-agent[vision]` / the lazy-install path can resolve it.
      -vision = ["Pillow==12.2.0"]
      +# Image resize recovery for the vision tools. Pillow is now a CORE dependency
      +# (see the main `dependencies` list) since the byte/pixel shrink paths are on
      +# the default vision-embed path and the mid-session lazy install deadlocked the
      +# CLI under prompt_toolkit (#40490). This extra is kept as a no-op back-compat
      +# alias so existing `pip install hermes-agent[vision]` invocations still resolve.
      +vision = []
       # CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's
       # sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before
       # 1.0.1, a malformed Host header makes `request.url.path` desync from the path
    • tools/lazy_deps.py
      @@ -174,11 +174,11 @@
               "uvicorn[standard]==0.41.0",
               "starlette==1.0.1",  # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
           ),
      -    # Vision image-resize recovery (Pillow). Soft dependency: vision_tools and
      -    # conversation_compression degrade gracefully without it, but the byte AND
      -    # pixel-dimension shrink paths no-op when it's absent, so an oversized
      -    # image can brick a session on Anthropic's non-retryable 400. Keep in sync
      -    # with pyproject [vision].
      +    # Vision image-resize recovery (Pillow). Pillow is now a CORE dependency
      +    # (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback
      +    # for stripped/source-build installs that somehow dropped it. The vision
      +    # call site uses prompt=False so it can never raise a blocking input()
      +    # prompt mid-session (#40490).
           "tool.vision": ("Pillow==12.2.0",),
       }
       
      @@ -456,7 +456,23 @@ def ensure(feature: str, *, prompt: bool = True) -> None:
                   "lazy installs disabled (security.allow_lazy_installs=false)"
               )
       
      -    if prompt and sys.stdin.isatty() and sys.stdout.isatty():
      +    # Only show the interactive confirmation when we own a TTY and
      +    # prompt_toolkit isn't running.  A bare input() deadlocks when a
      +    # prompt_toolkit app owns the terminal because keystrokes route to
      +    # its event loop rather than stdin, so the prompt blocks forever.
      +    # Under the TUI we skip the prompt and proceed — lazy installs are
      +    # gated by security.allow_lazy_installs, so reaching here is
      +    # already user opt-in.
      +    _pt_active = False
      +    if "prompt_toolkit.application.current" in sys.modules:
      +        try:
      +            from prompt_toolkit.application.current import get_app_or_none
      +            _app = get_app_or_none()
      +            _pt_active = _app is not None and getattr(_app, "is_running", False)
      +        except Exception:
      +            _pt_active = False
      +
      +    if prompt and not _pt_active and sys.stdin.isatty() and sys.stdout.isatty():
               spec_list = ", ".join(missing)
               try:
                   answer = input(
    • tools/transcription_tools.py
      @@ -214,7 +214,11 @@ def _try_lazy_install_stt() -> bool:
           """
           try:
               from tools.lazy_deps import ensure
      -        ensure("stt.faster_whisper")
      +        # prompt=False: never raise a blocking input() prompt mid-session.
      +        # Under the interactive CLI prompt_toolkit owns stdin, so a bare
      +        # input() deadlocks the terminal (#40490). The install is already
      +        # gated by security.allow_lazy_installs, so reaching here is opt-in.
      +        ensure("stt.faster_whisper", prompt=False)
               # Re-check dynamically after install
               import importlib.util as _iu
               if _iu.find_spec("faster_whisper"):
    • tools/vision_tools.py
      @@ -422,7 +422,11 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None,
               # the raw bytes and let the caller raise the size error.
               try:
                   from tools.lazy_deps import ensure as _ensure_dep
      -            _ensure_dep("tool.vision")
      +            # prompt=False: never raise a blocking input() prompt mid-session.
      +            # Under the interactive CLI prompt_toolkit owns stdin, so a bare
      +            # input() deadlocks the terminal (#40490). The install is already
      +            # gated by security.allow_lazy_installs, so reaching here is opt-in.
      +            _ensure_dep("tool.vision", prompt=False)
                   from PIL import Image
                   import io as _io
               except Exception:
    • uv.lock
      @@ -1401,6 +1401,7 @@ dependencies = [
           { name = "markdown" },
           { name = "openai" },
           { name = "pathspec" },
      +    { name = "pillow" },
           { name = "prompt-toolkit" },
           { name = "psutil" },
           { name = "ptyprocess", marker = "sys_platform != 'win32'" },
      @@ -1563,9 +1564,6 @@ termux-all = [
       tts-premium = [
           { name = "elevenlabs" },
       ]
      -vision = [
      -    { name = "pillow" },
      -]
       voice = [
           { name = "faster-whisper" },
           { name = "numpy" },
      @@ -1654,7 +1652,7 @@ requires-dist = [
           { name = "openai", specifier = "==2.24.0" },
           { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
           { name = "pathspec", specifier = "==1.1.1" },
      -    { name = "pillow", marker = "extra == 'vision'", specifier = "==12.2.0" },
      +    { name = "pillow", specifier = "==12.2.0" },
           { name = "prompt-toolkit", specifier = "==3.0.52" },
           { name = "psutil", specifier = "==7.2.2" },
           { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" },

4 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · other 2

  • #40506 fix(cli): skip interactive lazy-install prompt when under prompt_toolkit (#40490) · other → #40561, #40490
  • #40520 fix(cli): use prompt_toolkit run_in_terminal for lazy-dep install prompt (#40490) · other → #40561, #40490
  • #40561 fix(lazy-deps): don't deadlock the lazy-install prompt under the TUI (#40490) · done elsewhere → d47f919, c3d750c, #40490
  • #40579 fix(cli): skip lazy-dep prompt when prompt_toolkit owns terminal (#40490) · merged via another change → d47f919, #40901
Closed issues · 1
  • Closed#40490[Bug]: CLI input locks up unrecoverably on lazy-dep install prompt (lazy_deps.py uses bare input() under prompt_toolkit)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.33: 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

Merged PRs
#40901
Closed issues
1
Attempts
4 PRs closed without merging
Fix link checked
7 Jun 2026

For your agent

The same facts, typed.