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)
- Start a session with several MCP servers registered and
terminal_tool enabled.
- Send a prompt that causes Hermes to run at least one shell-terminal tool call early in the turn.
- 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.
- MainThread is in
prompt_toolkit.application.Application.run → asyncio run_until_complete → selectors.select (normal idle state waiting for input).
- Input area displays
❯ but accepts nothing. Ctrl+C is ignored. Timer continues counting. No new log lines appear in ~/.hermes/logs/agent.log.
- 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:
- 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.
- 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.
- As a minimum, add a timeout + cancellation token to
get_input so the CLI is recoverable without SIGKILL.
Expected behavior
- Approval prompts must not deadlock the main input loop. Whatever thread owns stdin, keystrokes should reach the approval consumer.
- Ctrl+C during an approval wait should cancel the approval (treated as "deny" or "abort"), return control to the prompt, not require SIGKILL.
- 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:
- 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
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
Summary
The interactive CLI deadlocks when
tools/approval.py'sget_inputis invoked from a background thread whileprompt_toolkit'sApplication.run()is already running on the MainThread. Both compete for stdin; the user's keystrokes go toprompt_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). Onlykill -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 concurrentterminal_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_clienttimeout bug. Apy-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
v0.11.0 (2026.4.23)+ ~70 commits pulled today (most recenthermes updatecompleted successfully)claude-opus-4-6)Reproduction (observed, not yet minimized)
terminal_toolenabled._cleanup_thread_workeris active, the next tool invocation (or the subsequent approval prompt) firestools/approval.py:get_inputon a background thread.prompt_toolkit.application.Application.run→asyncio run_until_complete→selectors.select(normal idle state waiting for input).❯but accepts nothing. Ctrl+C is ignored. Timer continues counting. No new log lines appear in~/.hermes/logs/agent.log.S— parked onget_input.py-spy dump (sanitized, full process)
Key frames:
tools/approval.py:463 get_input— the blocking read.prompt_toolkit/application/application.py:1002 runon MainThread — already owns the terminal's input path viapatch_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_inputappears to call a blockinginput()/sys.stdin.readline()/ low-levelread(0)from a non-main thread whileprompt_toolkit.Application.run()is active on the main thread.prompt_toolkit'spatch_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:
prompt_toolkitapp (e.g.Application.create_background_task+ a modal dialog / float) rather than a rawinput()on a side thread.prompt_toolkit(app.output.flush()+app.exit(...)/run_in_terminal) before reading stdin, and restore afterward.get_inputso the CLI is recoverable without SIGKILL.Expected behavior
❯with a ticking timer).Workaround (for users hitting this)
--yoloto bypass approval prompts entirely:hermes tools→ disableterminal_tool).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
auxiliary_client, turned out to be this deadlock in disguise).Additional context I can provide on request
py-spy dumpoutputagent.logtail around the freeze (trailingauxiliary_clientlines that are NOT the cause — they're just the last logs written before the deadlock)