{"repo":"NousResearch/hermes-agent","public_id":"13617","display_label":"CLI approval prompt freezes, deadlocks, and input fixes","summary":"Multiple bugs and fixes addressing approval prompt unresponsiveness, deadlocks between prompt_toolkit and background threads, and terminal output flooding that blocks user interaction during approval dialogs.","subsystem":"hermes_cli","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":13697},"resolution":{"state":"partially_resolved","by":"pr-13697,pr-16574"},"freshness":{"state":"verified","last_verified_at":"2026-04-27T13:42:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2419047619047619},"fix_edges":[{"issue_number":13617,"pr_number":13697,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":13618,"pr_number":13697,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15216,"pr_number":16574,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":13617,"title":"Bug: Terminal approval prompt freezes input area, preventing user interaction","url":null,"state":"closed"},{"kind":"issue","number":13951,"title":"[Bug]: CLI: Approval prompt becomes unresponsive due to background output flooding the terminal","url":null,"state":"closed"},{"kind":"issue","number":15216,"title":"[Bug]: approval prompt get_input deadlocks against prompt_toolkit MainThread (regression of #13617)","url":null,"state":"closed"},{"kind":"pr","number":13697,"title":"fix: approval prompt freeze — CLI thread-local + TUI blind-keystroke","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/cli/test_cli_approval_ui.py","ui-tui/src/app/useInputHandlers.ts"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -8370,6 +8370,17 @@ def stream_callback(delta: str):\n \n             def run_agent():\n                 nonlocal result\n+                # Set callbacks inside the agent thread so thread-local storage\n+                # in terminal_tool is populated for this thread.  The main thread\n+                # registration (run() line ~9046) is invisible here because\n+                # _callback_tls is threading.local().  Matches the pattern used\n+                # by acp_adapter/server.py for ACP sessions.\n+                set_sudo_password_callback(self._sudo_password_callback)\n+                set_approval_callback(self._approval_callback)\n+                try:\n+                    set_secret_capture_callback(self._secret_capture_callback)\n+                except Exception:\n+                    pass\n                 agent_message = _voice_prefix + message if _voice_prefix else message\n                 # Prepend pending model switch note so the model knows about the switch\n                 _msn = getattr(self, '_pending_model_switch_note', None)","@@ -8395,6 +8406,15 @@ def run_agent():\n                         \"failed\": True,\n                         \"error\": _summary,\n                     }\n+                finally:\n+                    # Clear thread-local callbacks so a reused thread doesn't\n+                    # hold stale references to a disposed CLI instance.\n+                    try:\n+                        set_sudo_password_callback(None)\n+                        set_approval_callback(None)\n+                        set_secret_capture_callback(None)\n+                    except Exception:\n+                        pass\n \n             # Start agent in background thread (daemon so it cannot keep the\n             # process alive when the user closes the terminal tab — SIGHUP"]},{"file":"tests/cli/test_cli_approval_ui.py","hunks":["@@ -254,3 +254,88 @@ def test_approval_display_truncates_giant_command_in_view_mode(self):\n \n         # Command got truncated with a marker.\n         assert \"(command truncated\" in rendered\n+\n+\n+class TestApprovalCallbackThreadLocalWiring:\n+    \"\"\"Regression guard for the thread-local callback freeze (#13617 / #13618).\n+\n+    After 62348cff made _approval_callback / _sudo_password_callback thread-local\n+    (ACP GHSA-qg5c-hvr5-hjgr), the CLI agent thread could no longer see callbacks\n+    registered in the main thread — the dangerous-command prompt silently fell\n+    back to stdin input() and deadlocked against prompt_toolkit. The fix is to\n+    register the callbacks INSIDE the agent worker thread (matching the ACP\n+    pattern). These tests lock in that invariant.\n+    \"\"\"\n+\n+    def test_main_thread_registration_is_invisible_to_child_thread(self):\n+        \"\"\"Confirms the underlying threading.local semantics that drove the bug.\n+\n+        If this ever starts passing as \"visible\", the thread-local isolation\n+        is gone and the ACP race GHSA-qg5c-hvr5-hjgr may be back.\n+        \"\"\"\n+        from tools.terminal_tool import (\n+            set_approval_callback,\n+            _get_approval_callback,\n+        )\n+\n+        def main_cb(_cmd, _desc):\n+            return \"once\"\n+\n+        set_approval_callback(main_cb)\n+        try:\n+            seen = {}\n+\n+            def _child():\n+                seen[\"value\"] = _get_approval_callback()\n+\n+            t = threading.Thread(target=_child, daemon=True)\n+            t.start()\n+            t.join(timeout=2)\n+            assert seen[\"value\"] is None\n+        finally:\n+            set_approval_callback(None)\n+\n+    def test_child_thread_registration_is_visible_and_cleared_in_finally(self):\n+        \"\"\"The fix pattern: register INSIDE the worker thread, clear in finally.\n+\n+        This is exactly what cli.py's run_agent() closure does. If this test\n+        fails, the CLI approval prompt freeze (#13617) has regressed.\n+        \"\"\"\n+        from tools.terminal_tool import (\n+            set_approval_callback,\n+            set_sudo_password_callback,\n+            _get_approval_callback,\n+            _get_sudo_password_callback,\n+        )\n+\n+        def approval_cb(_cmd, _desc):\n+            return \"once\"\n+\n+        def sudo_cb():\n+            return \"hunter2\"\n+\n+        seen = {}\n+\n+        def _worker():\n+            # Mimic cli.py's run_agent() thread target.\n+            set_approval_callback(approval_cb)\n+            set_sudo_password_callback(sudo_cb)\n+            try:\n+                seen[\"approval\"] = _get_approval_callback()\n+                seen[\"sudo\"] = _get_sudo_password_callback()\n+            finally:\n+                set_approval_callback(None)\n+                set_sudo_password_callback(None)\n+                seen[\"approval_after\"] = _get_approval_callback()\n+                seen[\"sudo_after\"] = _get_sudo_password_callback()\n+\n+        t = threading.Thread(target=_worker, daemon=True)\n+        t.start()\n+        t.join(timeout=2)\n+\n+        assert seen[\"approval\"] is approval_cb\n+        assert seen[\"sudo\"] is sudo_cb\n+        # Finally block must clear both slots — otherwise a reused thread\n+        # would hold a stale reference to a disposed CLI instance.\n+        assert seen[\"approval_after\"] is None\n+        assert seen[\"sudo_after\"] is None"]},{"file":"ui-tui/src/app/useInputHandlers.ts","hunks":["@@ -172,6 +172,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {\n     const live = getUiState()\n \n     if (isBlocked) {\n+      // When approval/clarify/confirm overlays are active, their own useInput\n+      // handlers must receive keystrokes (arrow keys, numbers, Enter).  Only\n+      // intercept Ctrl+C here so the user can deny/dismiss — all other keys\n+      // fall through to the component-level handlers.\n+      if (overlay.approval || overlay.clarify || overlay.confirm) {\n+        if (isCtrl(key, ch, 'c')) {\n+          cancelOverlayFromCtrlC()\n+        }\n+        return\n+      }\n+\n       if (overlay.pager) {\n         if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') {\n           return patchOverlayState({ pager: null })\n"]}]},{"kind":"pr","number":13991,"title":"fix(cli): block streaming output while dangerous-command approval prompt is visible","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16477,"title":"fix(approval): route fallback input() through prompt_toolkit run_in_terminal to fix deadlock","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":16574,"title":"fix(approval): close prompt_toolkit deadlock vectors (#15216)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_background_review.py","tests/tools/test_approval.py","tools/approval.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -74,6 +74,12 @@\n     check_toolset_requirements,\n )\n from tools.terminal_tool import cleanup_vm, get_active_env, is_persistent_env\n+from tools.terminal_tool import (\n+    set_approval_callback as _set_approval_callback,\n+    set_sudo_password_callback as _set_sudo_password_callback,\n+    _get_approval_callback,\n+    _get_sudo_password_callback,\n+)\n from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget\n from tools.interrupt import set_interrupt as _set_interrupt\n from tools.browser_tool import cleanup_browser","@@ -3241,6 +3247,21 @@ def _spawn_background_review(\n \n         def _run_review():\n             import contextlib\n+            # Install a non-interactive approval callback on this worker\n+            # thread so any dangerous-command guard the review agent trips\n+            # resolves to \"deny\" instead of falling back to input() -- which\n+            # deadlocks against the parent's prompt_toolkit TUI (#15216).\n+            # Same pattern as _subagent_auto_deny in tools/delegate_tool.py.\n+            def _bg_review_auto_deny(command, description, **kwargs):\n+                logger.warning(\n+                    \"Background review auto-denied dangerous command: %s (%s)\",\n+                    command, description,\n+                )\n+                return \"deny\"\n+            try:\n+                _set_approval_callback(_bg_review_auto_deny)\n+            except Exception:\n+                pass\n             review_agent = None\n             try:\n                 with open(os.devnull, \"w\") as _devnull, \\","@@ -3322,6 +3343,12 @@ def _run_review():\n                         review_agent.close()\n                     except Exception:\n                         pass\n+                # Clear the approval callback on this bg-review thread so a\n+                # recycled thread-id doesn't inherit a stale reference.\n+                try:\n+                    _set_approval_callback(None)\n+                except Exception:\n+                    pass\n \n         t = threading.Thread(target=_run_review, daemon=True, name=\"bg-review\")\n         t.start()","@@ -8469,6 +8496,14 @@ def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effe\n         self._current_tool = tool_names_str\n         self._touch_activity(f\"executing {num_tools} tools concurrently: {tool_names_str}\")\n \n+        # Capture CLI callbacks from the agent thread so worker threads can\n+        # register them locally.  Without this, _get_approval_callback() in\n+        # terminal_tool returns None in ThreadPoolExecutor workers, causing\n+        # the dangerous-command prompt to fall back to input() — which\n+        # deadlocks against prompt_toolkit's raw terminal mode (#13617).\n+        _parent_approval_cb = _get_approval_callback()\n+        _parent_sudo_cb = _get_sudo_password_callback()\n+\n         def _run_tool(index, tool_call, function_name, function_args):\n             \"\"\"Worker function executed in a thread.\"\"\"\n             # Register this worker tid so the agent can fan out an interrupt","@@ -8495,6 +8530,18 @@ def _run_tool(index, tool_call, function_name, function_args):\n                 set_activity_callback(self._touch_activity)\n             except Exception:\n                 pass\n+            # Propagate approval/sudo callbacks to this worker thread.\n+            # Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr).\n+            if _parent_approval_cb is not None:\n+                try:\n+                    _set_approval_callback(_parent_approval_cb)\n+                except Exception:\n+                    pass\n+            if _parent_sudo_cb is not None:\n+                try:\n+                    _set_sudo_password_callback(_parent_sudo_cb)\n+                except Exception:\n+                    pass\n             start = time.time()\n             try:\n                 result = self._invoke_tool(function_name, function_args, effective_task_id, tool_call.id, messages=messages)","@@ -8517,6 +8564,13 @@ def _run_tool(index, tool_call, function_name, function_args):\n                 _set_interrupt(False, _worker_tid)\n             except Exception:\n                 pass\n+            # Clear thread-local callbacks so a recycled worker thread\n+            # doesn't hold stale references to a disposed CLI instance.\n+            try:\n+                _set_approval_callback(None)\n+                _set_sudo_password_callback(None)\n+            except Exception:\n+                pass\n \n         # Start spinner for CLI mode (skip when TUI handles tool progress)\n         spinner = None"]},{"file":"scripts/release.py","hunks":["@@ -553,6 +553,7 @@\n     \"[redacted:email]\": \"chen1749144759\",\n     \"[redacted:email]\": \"MorAlekss\",\n     \"[redacted:email]\": \"ash\",\n+    \"[redacted:email]\": \"andrewhosf\",\n }\n \n "]},{"file":"tests/run_agent/test_background_review.py","hunks":["@@ -71,3 +71,59 @@ def close(self):\n         \"shutdown_memory_provider\",\n         \"close\",\n     ]\n+\n+\n+def test_background_review_installs_auto_deny_approval_callback(monkeypatch):\n+    \"\"\"Regression guard for #15216.\n+\n+    The background review thread must install a non-interactive approval\n+    callback. If it doesn't, any dangerous-command guard the review agent\n+    trips falls back to input() on a daemon thread, which deadlocks against\n+    the parent's prompt_toolkit TUI.\n+    \"\"\"\n+    import tools.terminal_tool as tt\n+\n+    observed: dict = {\"during_run\": \"<unread>\", \"after_finally\": \"<unread>\"}\n+\n+    class FakeReviewAgent:\n+        def __init__(self, **kwargs):\n+            self._session_messages = []\n+\n+        def run_conversation(self, **kwargs):\n+            # Capture what the callback looks like mid-run. It must be\n+            # a callable (the auto-deny) -- not None.\n+            observed[\"during_run\"] = tt._get_approval_callback()\n+\n+        def shutdown_memory_provider(self):\n+            pass\n+\n+        def close(self):\n+            pass\n+\n+    monkeypatch.setattr(run_agent_module, \"AIAgent\", FakeReviewAgent)\n+    monkeypatch.setattr(run_agent_module.threading, \"Thread\", ImmediateThread)\n+\n+    # Start from a clean slot.\n+    tt.set_approval_callback(None)\n+    agent = _bare_agent()\n+\n+    AIAgent._spawn_background_review(\n+        agent,\n+        messages_snapshot=[{\"role\": \"user\", \"content\": \"hello\"}],\n+        review_memory=True,\n+    )\n+\n+    observed[\"after_finally\"] = tt._get_approval_callback()\n+\n+    assert callable(observed[\"during_run\"]), (\n+        \"Background review did not install an approval callback on its \"\n+        \"worker thread; dangerous-command prompts will deadlock against \"\n+        \"the parent TUI (#15216).\"\n+    )\n+    # The installed callback must deny (it's a safety gate, not a prompt).\n+    assert observed[\"during_run\"](\"rm -rf /\", \"test\") == \"deny\"\n+\n+    assert observed[\"after_finally\"] is None, (\n+        \"Background review leaked its approval callback into the worker \"\n+        \"thread's TLS slot; a recycled thread-id could reuse it.\"\n+    )"]},{"file":"tests/tools/test_approval.py","hunks":["@@ -906,3 +906,62 @@ def test_safe_chmod_without_execute_not_flagged(self):\n         cmd = \"chmod +x script.sh\"\n         dangerous, _, _ = detect_dangerous_command(cmd)\n         assert dangerous is False\n+\n+\n+class TestFailClosedUnderPromptToolkit:\n+    \"\"\"Regression guard for #15216.\n+\n+    When prompt_toolkit owns the terminal and no approval callback is\n+    registered on the calling thread, prompt_dangerous_approval() must\n+    deny fast instead of falling through to the input() fallback -- which\n+    deadlocks because the user's keystrokes go to prompt_toolkit's raw-mode\n+    stdin capture, not to input().\n+    \"\"\"\n+\n+    def test_denies_when_prompt_toolkit_active_and_no_callback(self):\n+        import threading\n+        import prompt_toolkit.application.current as ptc\n+\n+        orig = ptc.get_app_or_none\n+        ptc.get_app_or_none = lambda: object()  # pretend a pt app is running\n+        result = []\n+        try:\n+            def run():\n+                result.append(\n+                    prompt_dangerous_approval(\n+                        \"rm -rf /\",\n+                        \"test danger\",\n+                        timeout_seconds=30,\n+                        approval_callback=None,\n+                    )\n+                )\n+\n+            t = threading.Thread(target=run, daemon=True)\n+            t.start()\n+            t.join(timeout=3)\n+            assert not t.is_alive(), (\n+                \"prompt_dangerous_approval deadlocked under prompt_toolkit \"\n+                \"with no callback -- fail-closed guard is broken\"\n+            )\n+            assert result == [\"deny\"]\n+        finally:\n+            ptc.get_app_or_none = orig\n+\n+    def test_callback_path_still_wins_over_guard(self):\n+        \"\"\"Guard must not short-circuit a valid callback.\"\"\"\n+        import prompt_toolkit.application.current as ptc\n+\n+        orig = ptc.get_app_or_none\n+        ptc.get_app_or_none = lambda: object()\n+        try:\n+            def cb(command, description, **kwargs):\n+                return \"once\"\n+\n+            result = prompt_dangerous_approval(\n+                \"rm -rf /\",\n+                \"test danger\",\n+                approval_callback=cb,\n+            )\n+            assert result == \"once\"\n+        finally:\n+            ptc.get_app_or_none = orig"]},{"file":"tools/approval.py","hunks":["@@ -536,6 +536,33 @@ def prompt_dangerous_approval(command: str, description: str,\n             logger.error(\"Approval callback failed: %s\", e, exc_info=True)\n             return \"deny\"\n \n+    # Fail-closed guard: if prompt_toolkit owns the terminal (interactive\n+    # CLI session) and no approval callback is registered on this thread,\n+    # the input() fallback below would spawn a daemon thread whose read\n+    # can never see Enter -- the user's keystrokes go to prompt_toolkit,\n+    # not input(), producing an invisible 60s deadlock (issue #15216).\n+    # Deny fast and log loudly instead so the caller can surface a real\n+    # error to the agent. Any thread that needs interactive approval must\n+    # install a callback via tools.terminal_tool.set_approval_callback()\n+    # before reaching this point (see delegate_tool.py, run_agent.py\n+    # _execute_tool_calls_concurrent / _spawn_background_review for the\n+    # established pattern).\n+    try:\n+        from prompt_toolkit.application.current import get_app_or_none\n+        if get_app_or_none() is not None:\n+            logger.warning(\n+                \"Dangerous-command approval requested on a thread with no \"\n+                \"approval callback while prompt_toolkit is active; denying \"\n+                \"to avoid stdin deadlock. command=%r description=%r\",\n+                command, description,\n+            )\n+            return \"deny\"\n+    except Exception:\n+        # prompt_toolkit not installed, or detection failed -- fall through\n+        # to the legacy input() path (safe in non-TUI contexts: scripts,\n+        # tests, sshd, etc.).\n+        pass\n+\n     os.environ[\"HERMES_SPINNER_PAUSE\"] = \"1\"\n     try:\n         while True:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}