hermes - 💡(How to fix) Fix [Bug]: approval prompt get_input deadlocks against prompt_toolkit MainThread (regression of #13617) [4 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#15216Fetched 2026-04-25 06:23:45
View on GitHub
Comments
4
Participants
2
Timeline
10
Reactions
0
Author
Participants
Timeline (top)
labeled ×5commented ×4cross-referenced ×1

The interactive CLI deadlocks when tools/approval.py's get_input is invoked from a background thread while prompt_toolkit's Application.run() is already running on the MainThread. Both compete for stdin; the user's keystrokes go to prompt_toolkit, never reach the approval thread, and the approval thread's read blocks indefinitely. The CLI displays the prompt and keeps the timer ticking, but accepts no keyboard input. Ctrl+C is ignored (approval thread is blocked at a C-level stdin read, not interruptible by Python signal handlers). Only kill -TERM (and sometimes -KILL) on the agent process recovers.

This looks like a regression of closed issue #13617 ("Bug: Terminal approval prompt freezes input area, preventing user interaction") — same observable symptom, possibly a different code path (tools/approval.py:463 get_input + a concurrent terminal_tool._cleanup_thread_worker) not covered by that fix.

Filed in place of #15194, which I filed earlier based on log evidence alone and misdiagnosed as an auxiliary_client timeout bug. A py-spy dump (see below) made it clear the hang is in the approval path, not in an HTTP await. Will close #15194 as duplicate of this one.

Root Cause

Suspected root cause

Fix Action

Fix / Workaround

Thread <BG> (idle): "patch-stdout-flush-thread" wait (threading.py:327) get (queue.py:171) _write_thread (prompt_toolkit/patch_stdout.py:153) ...

Key frames:

  • tools/approval.py:463 get_input — the blocking read.
  • prompt_toolkit/application/application.py:1002 run on MainThread — already owns the terminal's input path via patch_stdout.
  • tools/terminal_tool.py:1115 _cleanup_thread_worker — actively running when the deadlock occurred, so the approval likely fired right at / after terminal-tool cleanup.

tools/approval.py:get_input appears to call a blocking input() / sys.stdin.readline() / low-level read(0) from a non-main thread while prompt_toolkit.Application.run() is active on the main thread. prompt_toolkit's patch_stdout / input capture already owns the TTY, so the background read never sees a complete line. Additionally, the C-level blocking read is not interruptible by Ctrl+C in Python (signal only delivered to main thread), so the only recovery is SIGKILL.

Code Example

Process <PID>: <python> <home>/.local/bin/hermes
Python v3.11.14

Thread <MAIN> (idle): "MainThread"
    select (selectors.py:566)
    _run_once (asyncio/base_events.py:1898)
    run_forever (asyncio/base_events.py:608)
    run_until_complete (asyncio/base_events.py:641)
    run (asyncio/runners.py:118)
    run (asyncio/runners.py:190)
    run (prompt_toolkit/application/application.py:1002)
    run (cli.py:10718)
    main (cli.py:11074)
    cmd_chat (hermes_cli/main.py:1204)
    main (hermes_cli/main.py:9118)
    <module> (hermes:10)

Thread <BG> (idle): "mcp-event-loop"
    select (selectors.py:566)
    _run_once (asyncio/base_events.py:1898)
    run_forever (asyncio/base_events.py:608)
    run (threading.py:982)
    _bootstrap_inner (threading.py:1045)
    _bootstrap (threading.py:1002)

Thread <BG> (active): "asyncio-waitpid-0"     # x4 of these
    _do_waitpid (asyncio/unix_events.py:1404)
    run (threading.py:982)
    _bootstrap_inner (threading.py:1045)
    _bootstrap (threading.py:1002)

Thread <BG> (active): "Thread-2 (spinner_loop)"
    spinner_loop (cli.py:10498)
    ...

Thread <BG> (idle): "Thread-3 (process_loop)"
    wait (threading.py:331)
    get (queue.py:180)
    process_loop (cli.py:10509)
    ...

Thread <BG> (idle): "patch-stdout-flush-thread"
    wait (threading.py:327)
    get (queue.py:171)
    _write_thread (prompt_toolkit/patch_stdout.py:153)
    ...

Thread <BG> (active): "Thread-7 (_cleanup_thread_worker)"
    _cleanup_thread_worker (tools/terminal_tool.py:1115)
    ...

Thread <BG> (active): "Thread-31 (get_input)"        # <-- BLOCKED HERE
    get_input (tools/approval.py:463)
    run (threading.py:982)
    _bootstrap_inner (threading.py:1045)
    _bootstrap (threading.py:1002)

---

hermes --yolo
RAW_BUFFERClick to expand / collapse

Summary

The interactive CLI deadlocks when tools/approval.py's get_input is invoked from a background thread while prompt_toolkit's Application.run() is already running on the MainThread. Both compete for stdin; the user's keystrokes go to prompt_toolkit, never reach the approval thread, and the approval thread's read blocks indefinitely. The CLI displays the prompt and keeps the timer ticking, but accepts no keyboard input. Ctrl+C is ignored (approval thread is blocked at a C-level stdin read, not interruptible by Python signal handlers). Only kill -TERM (and sometimes -KILL) on the agent process recovers.

This looks like a regression of closed issue #13617 ("Bug: Terminal approval prompt freezes input area, preventing user interaction") — same observable symptom, possibly a different code path (tools/approval.py:463 get_input + a concurrent terminal_tool._cleanup_thread_worker) not covered by that fix.

Filed in place of #15194, which I filed earlier based on log evidence alone and misdiagnosed as an auxiliary_client timeout bug. A py-spy dump (see below) made it clear the hang is in the approval path, not in an HTTP await. Will close #15194 as duplicate of this one.

Environment

  • OS: macOS (Apple Silicon)
  • Hermes: v0.11.0 (2026.4.23) + ~70 commits pulled today (most recent hermes update completed successfully)
  • Python: 3.11.14
  • Provider: Anthropic (claude-opus-4-6)
  • MCP servers active at hang: github, atlassian, slack, plus one internal stdio server (~73 tools total after removing a 221-tool server that was unrelated).

Reproduction (observed, not yet minimized)

  1. Start a session with several MCP servers registered and terminal_tool enabled.
  2. Send a prompt that causes Hermes to run at least one shell-terminal tool call early in the turn.
  3. After the terminal tool finishes and _cleanup_thread_worker is active, the next tool invocation (or the subsequent approval prompt) fires tools/approval.py:get_input on a background thread.
  4. MainThread is in prompt_toolkit.application.Application.runasyncio run_until_completeselectors.select (normal idle state waiting for input).
  5. Input area displays but accepts nothing. Ctrl+C is ignored. Timer continues counting. No new log lines appear in ~/.hermes/logs/agent.log.
  6. Process is alive, ~0% CPU, status S — parked on get_input.

py-spy dump (sanitized, full process)

Process <PID>: <python> <home>/.local/bin/hermes
Python v3.11.14

Thread <MAIN> (idle): "MainThread"
    select (selectors.py:566)
    _run_once (asyncio/base_events.py:1898)
    run_forever (asyncio/base_events.py:608)
    run_until_complete (asyncio/base_events.py:641)
    run (asyncio/runners.py:118)
    run (asyncio/runners.py:190)
    run (prompt_toolkit/application/application.py:1002)
    run (cli.py:10718)
    main (cli.py:11074)
    cmd_chat (hermes_cli/main.py:1204)
    main (hermes_cli/main.py:9118)
    <module> (hermes:10)

Thread <BG> (idle): "mcp-event-loop"
    select (selectors.py:566)
    _run_once (asyncio/base_events.py:1898)
    run_forever (asyncio/base_events.py:608)
    run (threading.py:982)
    _bootstrap_inner (threading.py:1045)
    _bootstrap (threading.py:1002)

Thread <BG> (active): "asyncio-waitpid-0"     # x4 of these
    _do_waitpid (asyncio/unix_events.py:1404)
    run (threading.py:982)
    _bootstrap_inner (threading.py:1045)
    _bootstrap (threading.py:1002)

Thread <BG> (active): "Thread-2 (spinner_loop)"
    spinner_loop (cli.py:10498)
    ...

Thread <BG> (idle): "Thread-3 (process_loop)"
    wait (threading.py:331)
    get (queue.py:180)
    process_loop (cli.py:10509)
    ...

Thread <BG> (idle): "patch-stdout-flush-thread"
    wait (threading.py:327)
    get (queue.py:171)
    _write_thread (prompt_toolkit/patch_stdout.py:153)
    ...

Thread <BG> (active): "Thread-7 (_cleanup_thread_worker)"
    _cleanup_thread_worker (tools/terminal_tool.py:1115)
    ...

Thread <BG> (active): "Thread-31 (get_input)"        # <-- BLOCKED HERE
    get_input (tools/approval.py:463)
    run (threading.py:982)
    _bootstrap_inner (threading.py:1045)
    _bootstrap (threading.py:1002)

Key frames:

  • tools/approval.py:463 get_input — the blocking read.
  • prompt_toolkit/application/application.py:1002 run on MainThread — already owns the terminal's input path via patch_stdout.
  • tools/terminal_tool.py:1115 _cleanup_thread_worker — actively running when the deadlock occurred, so the approval likely fired right at / after terminal-tool cleanup.

Suspected root cause

tools/approval.py:get_input appears to call a blocking input() / sys.stdin.readline() / low-level read(0) from a non-main thread while prompt_toolkit.Application.run() is active on the main thread. prompt_toolkit's patch_stdout / input capture already owns the TTY, so the background read never sees a complete line. Additionally, the C-level blocking read is not interruptible by Ctrl+C in Python (signal only delivered to main thread), so the only recovery is SIGKILL.

Possible fixes:

  1. Route all approval prompts through the same prompt_toolkit app (e.g. Application.create_background_task + a modal dialog / float) rather than a raw input() on a side thread.
  2. If a side-thread prompt is unavoidable, temporarily suspend prompt_toolkit (app.output.flush() + app.exit(...) / run_in_terminal) before reading stdin, and restore afterward.
  3. As a minimum, add a timeout + cancellation token to get_input so the CLI is recoverable without SIGKILL.

Expected behavior

  1. Approval prompts must not deadlock the main input loop. Whatever thread owns stdin, keystrokes should reach the approval consumer.
  2. Ctrl+C during an approval wait should cancel the approval (treated as "deny" or "abort"), return control to the prompt, not require SIGKILL.
  3. A visible indicator (banner / toast) should tell the user an approval is pending — the current failure mode is invisible (just the normal with a ticking timer).

Workaround (for users hitting this)

  • Run with --yolo to bypass approval prompts entirely:
    hermes --yolo
  • Or disable the specific tool that's triggering approvals (e.g. hermes tools → disable terminal_tool).
  • If already stuck: kill -TERM <pid>; if that fails (stdin read not interruptible), kill -KILL <pid>. The gateway (launchd-managed) will auto-respawn — safe to ignore.

Cross-references

  • Closes / duplicates my own prior issue #15194 (logs-only diagnosis pointed at auxiliary_client, turned out to be this deadlock in disguise).
  • Appears to be a regression of closed #13617 — same observable symptom (approval prompt freezes input), may be a code path that #13617's fix didn't cover.

Additional context I can provide on request

  • Full un-truncated py-spy dump output
  • agent.log tail around the freeze (trailing auxiliary_client lines that are NOT the cause — they're just the last logs written before the deadlock)
  • Reproduce with verbose tracing if maintainers can share a debug flag

extent analysis

TL;DR

The most likely fix is to route all approval prompts through the same prompt_toolkit app to avoid deadlocks when tools/approval.py:get_input is invoked from a background thread.

Guidance

  • Identify and review all instances where tools/approval.py:get_input is called from a non-main thread to ensure they are properly handled.
  • Consider using Application.create_background_task to integrate approval prompts with the main prompt_toolkit application.
  • If side-thread prompts are unavoidable, implement a mechanism to temporarily suspend prompt_toolkit before reading stdin and restore it afterward.
  • Add a timeout and cancellation token to get_input to make the CLI recoverable without requiring SIGKILL.

Example

# Example of using Application.create_background_task
from prompt_toolkit.application import Application

def get_input():
    # Create a background task to handle input
    app = Application()
    future = app.create_background_task(get_user_input)
    # Wait for the task to complete
    result = future.result()
    return result

def get_user_input():
    # Handle user input here
    user_input = input("Please enter your input: ")
    return user_input

Notes

The provided py-spy dump and description suggest a complex interaction between threads and prompt_toolkit. The solution may require careful consideration of thread safety and synchronization.

Recommendation

Apply a workaround by running with --yolo to bypass approval prompts entirely, or disable the specific tool triggering approvals, until a permanent fix can be implemented.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

  1. Approval prompts must not deadlock the main input loop. Whatever thread owns stdin, keystrokes should reach the approval consumer.
  2. Ctrl+C during an approval wait should cancel the approval (treated as "deny" or "abort"), return control to the prompt, not require SIGKILL.
  3. A visible indicator (banner / toast) should tell the user an approval is pending — the current failure mode is invisible (just the normal with a ticking timer).

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - 💡(How to fix) Fix [Bug]: approval prompt get_input deadlocks against prompt_toolkit MainThread (regression of #13617) [4 comments, 2 participants]