CLI slash commands freezing and blocking issues

NousResearch/hermes-agent · cluster 21348 · hermes cli

Data as of

3 open items. Fix merged in #23454. Check it covers your case.

The short answer

2open issues
1open PR
47items grouped
Known?
Yes. 47 related items grouped here, 20 issues already closed.
Fix event
A fix was merged to main in PR #23454. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #23907, #31704, #33241. That is separate from the fix event.
Still open
2 issues and 1 PR.

Multiple bugs and fixes related to slash commands (/new, /clear, /reset, /reload-mcp) causing terminal freezes, deadlocks, and blocking behavior, particularly around threading, event loop scheduling, and prompt handling across platforms.

Open issues2

  • Issue#39418/reload-mcp freezes the CLI terminal when triggered by user — session becomes unresponsive
  • Issue#66258hermes-mcp-reload-bug-issue

Open PRs1

  • PR#39446fix(cli): keep /reload-mcp off the input thread

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #23454 fix(cli): drive _prompt_text_input directly when off main thread (#23185)

    merged to main

    files: cli.py, tests/cli/test_prompt_text_input_thread_safety.py

    Show the diff
    • cli.py
      @@ -6008,7 +6008,17 @@ def _pick():
               return result[0]
       
           def _prompt_text_input(self, prompt_text: str) -> str | None:
      -        """Prompt for free-text input safely inside or outside prompt_toolkit."""
      +        """Prompt for free-text input safely inside or outside prompt_toolkit.
      +
      +        Mirrors the thread-aware guard in ``_run_curses_picker``: ``run_in_terminal``
      +        returns a coroutine that must be awaited by the prompt_toolkit event loop,
      +        which only exists on the main thread.  Slash commands are dispatched from
      +        the ``process_loop`` daemon thread (see issue #23185), so calling
      +        ``run_in_terminal`` from there orphans the coroutine — ``_ask`` never runs,
      +        and user keystrokes leak into the composer instead.  Fall back to a direct
      +        ``input()`` when we're off the main thread.
      +        """
      +        import threading
               result = [None]
       
               def _ask():
      @@ -6017,13 +6027,23 @@ def _ask():
                   except (KeyboardInterrupt, EOFError):
                       pass
       
      -        if self._app:
      +        in_main_thread = threading.current_thread() is threading.main_thread()
      +
      +        if self._app and in_main_thread:
                   from prompt_toolkit.application import run_in_terminal
                   was_visible = self._status_bar_visible
                   self._status_bar_visible = False
                   self._app.invalidate()
                   try:
                       run_in_terminal(_ask)
      +            except Exception:
      +                # WSL / Warp / certain terminal emulators silently drop the
      +                # scheduled coroutine.  Fall back to a direct input() so the
      +                # user's keystrokes don't leak into the agent buffer.
      +                try:
      +                    _ask()
      +                except Exception:
      +                    pass
                   finally:
                       self._status_bar_visible = was_visible
                       self._app.invalidate()
    • tests/cli/test_prompt_text_input_thread_safety.py
      @@ -0,0 +1,109 @@
      +"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
      +
      +Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched
      +from the ``process_loop`` daemon thread.  ``prompt_toolkit.run_in_terminal``
      +returns a coroutine that only the main-thread event loop can drive; calling it
      +from a daemon thread orphans the coroutine, ``_ask`` never runs, and user
      +keystrokes leak into the composer instead of the confirmation prompt
      +(see issue #23185).
      +
      +The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to
      +a direct ``input()`` call so the prompt actually renders and consumes
      +keystrokes.
      +"""
      +
      +import threading
      +from unittest.mock import MagicMock, patch
      +
      +
      +def _make_cli():
      +    """Minimal HermesCLI shell exposing ``_prompt_text_input``."""
      +    import cli as cli_mod
      +
      +    obj = object.__new__(cli_mod.HermesCLI)
      +    obj._app = MagicMock()
      +    obj._status_bar_visible = True
      +    return obj
      +
      +
      +class TestPromptTextInputThreadSafety:
      +    def test_main_thread_uses_run_in_terminal(self):
      +        """On the main thread with an active app, route through run_in_terminal."""
      +        cli = _make_cli()
      +
      +        with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
      +             patch("builtins.input", return_value="2"):
      +            result = cli._prompt_text_input("Choice: ")
      +
      +        # run_in_terminal was invoked; the _ask closure passed to it would
      +        # call input() when driven by the event loop.  We assert dispatch path,
      +        # not the orphaned-coroutine result.
      +        assert mock_rit.called
      +
      +    def test_background_thread_falls_back_to_direct_input(self):
      +        """On a daemon thread, skip run_in_terminal and call input() directly.
      +
      +        This is the bug from issue #23185: process_loop dispatches slash
      +        commands on a daemon thread, so run_in_terminal's coroutine is
      +        orphaned.  The fallback must drive input() itself so user keystrokes
      +        don't leak into the agent buffer.
      +        """
      +        cli = _make_cli()
      +        captured = {}
      +
      +        def fake_input(prompt):
      +            captured["prompt"] = prompt
      +            return "1"
      +
      +        result_holder = {}
      +
      +        def run_on_daemon():
      +            with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
      +                 patch("builtins.input", side_effect=fake_input):
      +                result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
      +                result_holder["rit_called"] = mock_rit.called
      +
      +        t = threading.Thread(target=run_on_daemon, daemon=True)
      +        t.start()
      +        t.join(timeout=2.0)
      +        assert not t.is_alive(), "daemon thread hung — input() was not driven"
      +
      +        # run_in_terminal was bypassed entirely on the background thread.
      +        assert result_holder["rit_called"] is False
      +        # input() was invoked with the prompt and its return value was captured.
      +        assert captured.get("prompt") == "Choice [1/2/3]: "
      +        assert result_holder["value"] == "1"
      +
      +    def test_no_app_uses_direct_input(self):
      +        """Without an active prompt_toolkit app, always call input() directly."""
      +        cli = _make_cli()
      +        cli._app = None
      +
      +        with patch("builtins.input", return_value="cancel") as mock_input:
      +            result = cli._prompt_text_input("Choice: ")
      +
      +        assert mock_input.called
      +        assert result == "cancel"
      +
      +    def test_run_in_terminal_exception_falls_back(self):
      +        """If run_in_terminal raises (WSL / Warp edge cases), fall back to input()."""
      +        cli = _make_cli()
      +
      +        with patch(
      +            "prompt_toolkit.application.run_in_terminal",
      +            side_effect=RuntimeError("event loop dropped the coroutine"),
      +        ), patch("builtins.input", return_value="3") as mock_input:
      +            result = cli._prompt_text_input("Choice: ")
      +
      +        assert mock_input.called
      +        assert result == "3"
      +
      +    def test_eof_returns_none(self):
      +        """EOFError from input() yields None, not an unhandled exception."""
      +        cli = _make_cli()
      +        cli._app = None
      +
      +        with patch("builtins.input", side_effect=EOFError()):
      +            result = cli._prompt_text_input("Choice: ")
      +
      +        assert result is None
  • #23907 fix(cli): use prompt_toolkit modal for destructive slash confirmations

    merged to main

    files: cli.py, scripts/release.py, tests/cli/test_destructive_slash_confirm.py, tests/cli/test_prompt_text_input_thread_safety.py

    Show the diff
    • cli.py
      @@ -2558,6 +2558,8 @@ def __init__(
               self._approval_state = None
               self._approval_deadline = 0
               self._approval_lock = threading.Lock()
      +        self._slash_confirm_state = None
      +        self._slash_confirm_deadline = 0
               self._model_picker_state = None
               self._secret_state = None
               self._secret_deadline = 0
      @@ -3715,7 +3717,7 @@ def _open_external_editor(self, buffer=None) -> bool:
               if self._command_running:
                   _cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}")
                   return False
      -        if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state:
      +        if self._sudo_state or self._secret_state or self._approval_state or getattr(self, "_slash_confirm_state", None) or self._clarify_state:
                   _cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}")
                   return False
               target_buffer = buffer or getattr(app, "current_buffer", None)
      @@ -6059,6 +6061,194 @@ def _ask():
                   _ask()
               return result[0]
       
      +    def _prompt_text_input_modal(
      +        self,
      +        *,
      +        title: str,
      +        detail: str,
      +        choices: list[tuple[str, str, str]],
      +        timeout: float = 120,
      +    ) -> str | None:
      +        """Prompt through the prompt_toolkit composer instead of raw input().
      +
      +        This is for CLI slash-command confirmations.  The old raw input() path
      +        fought prompt_toolkit's active stdin ownership: in some terminals the
      +        prompt appeared above the TUI, choices were redrawn later, and Enter
      +        could be interpreted as EOF/exit.  A first-class modal state keeps the
      +        choices visible and lets the normal Enter key binding submit the typed
      +        or highlighted choice.
      +        """
      +        import time as _time
      +
      +        if not choices:
      +            return None
      +
      +        # If prompt_toolkit is not running (unit tests / non-interactive calls),
      +        # keep the simple stdin fallback.
      +        if not getattr(self, "_app", None):
      +            return self._prompt_text_input("Choice [1/2/3]: ")
      +
      +        response_queue = queue.Queue()
      +        self._capture_modal_input_snapshot()
      +        self._slash_confirm_state = {
      +            "title": title,
      +            "detail": detail,
      +            "choices": choices,
      +            "selected": 0,
      +            "response_queue": response_queue,
      +        }
      +        self._slash_confirm_deadline = _time.monotonic() + timeout
      +        self._invalidate()
      +
      +        _last_countdown_refresh = _time.monotonic()
      +        try:
      +            while True:
      +                try:
      +                    result = response_queue.get(timeout=1)
      +                    self._slash_confirm_state = None
      +                    self._slash_confirm_deadline = 0
      +                    self._restore_modal_input_snapshot()
      +                    self._invalidate()
      +                    return result
      +                except queue.Empty:
      +                    remaining = self._slash_confirm_deadline - _time.monotonic()
      +                    if remaining <= 0:
      +                        break
      +                    now = _time.monotonic()
      +                    if now - _last_countdown_refresh >= 5.0:
      +                        _last_countdown_refresh = now
      +                        self._invalidate()
      +        finally:
      +            if self._slash_confirm_state is not None:
      +                self._slash_confirm_state = None
      +                self._slash_confirm_deadline = 0
      +                self._restore_modal_input_snapshot()
      +                self._invalidate()
      +        return None
      +
      +    def _submit_slash_confirm_response(self, value: str | None) -> None:
      +        state = self._slash_confirm_state
      +        if not state:
      +            return
      +        state["response_queue"].put(value)
      +        self._slash_confirm_state = None
      +        self._slash_confirm_deadline = 0
      +        self._invalidate()
      +
      +    def _normalize_slash_confirm_choice(
      +        self,
      +        raw: str | None,
      +        choices: list[tuple[str, str, str]],
      +    ) -> str | None:
      +        if raw is None:
      +            return None
      +        choice_raw = raw.strip().lower()
      +        if not choice_raw:
      +            return None
      +        aliases = {
      +            "1": "once",
      +            "once": "once",
      +            "approve": "once",
      +            "yes": "once",
      +            "y": "once",
      +            "ok": "once",
      +            "2": "always",
      +            "always": "always",
      +            "remember": "always",
      +            "3": "cancel",
      +            "cancel": "cancel",
      +            "nevermind": "cancel",
      +            "no": "cancel",
      +            "n": "cancel",
      +        }
      +        allowed = {choice[0] for choice in choices}
      +        normalized = aliases.get(choice_raw)
      +        if normalized in allowed:
      +            return normalized
      +        if choice_raw in allowed:
      +            return choice_raw
      +        return None
      +
      +    def _get_slash_confirm_display_fragments(self):
      +        """Render the /new-/clear-style confirmation panel."""
      +        state = self._slash_confirm_state
      +        if not state:
      +            return []
      +
      +        title = state.get("title") or "Confirm action"
      +        detail = state.get("detail") or ""
      +        choices = state.get("choices") or []
      +        selected = state.get("selected", 0)
      +
      +        def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int:
      +            term_cols = shutil.get_terminal_size((100, 20)).columns
      +            longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4])
      +            inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
      +            return inner + 2
      +
      +        def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]:
      +            wrapped = textwrap.wrap(
      +                text,
      +                width=max(8, width),
      +                replace_whitespace=False,
      +                drop_whitespace=False,
      +                subsequent_indent=subsequent_indent,
      +            )
      +            return wrapped or [""]
      +
      +        def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
      +            inner_width = max(0, box_width - 2)
      +            lines.append((border_style, "│ "))
      +            lines.append((content_style, text.ljust(inner_width)))
      +            lines.append((border_style, " │\n"))
      +
      +        def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
      +            lines.append((border_style, "│" + (" " * box_width) + "│\n"))
      +
      +        preview_lines = []
      +        for line in detail.splitlines():
      +            preview_lines.extend(_wrap_panel_text(line, 72))
      +        for idx, (_value, label, desc) in enumerate(choices):
      +            marker = "❯" if idx == selected else " "
      +            preview_lines.extend(_wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", 72, subsequent_indent="    "))
      +        preview_lines.append("Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.")
      +
      +        box_width = _panel_box_width(title, preview_lines)
      +        inner_text_width = max(8, box_width - 2)
      +        detail_wrapped = []
      +        for line in detail.splitlines():
      +            detail_wrapped.extend(_wrap_panel_text(line, inner_text_width))
      +        choice_wrapped: list[tuple[int, str]] = []
      +        for idx, (_value, label, desc) in enumerate(choices):
      +            marker = "❯" if idx == selected else " "
      +            for wrapped in _wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", inner_text_width, subsequent_indent="    "):
      +                choice_wrapped.append((idx, wrapped))
      +
      +        term_rows = shutil.get_terminal_size((100, 24)).lines
      +        reserved_below = 6
      +        chrome_full = 6
      +        available = max(0, term_rows - reserved_below)
      +        max_detail_rows = max(1, available - chrome_full - len(choice_wrapped))
      +        max_detail_rows = min(max_detail_rows, 8)
      +        if len(detail_wrapped) > max_detail_rows:
      +            keep = max(1, max_detail_rows - 1)
      +            detail_wrapped = detail_wrapped[:keep] + ["… (detail truncated)"]
      +
      +        lines = []
      +        lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\n'))
      +        _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width)
      +        _append_blank_panel_line(lines, 'class:approval-border', box_width)
      +        for wrapped in detail_wrapped:
      +            _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
      +        _append_blank_panel_line(lines, 'class:approval-border', box_width)
      +        for idx, wrapped in choice_wrapped:
      +            style = 'class:approval-selected' if idx == selected else 'class:approval-choice'
      +            _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
      +        _append_blank_panel_line(lines, 'class:approval-border', box_width)
      +        _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width)
      +        lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n'))
      +        return lines
      +
           def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
               """Open prompt_toolkit-native /model picker modal."""
               self._capture_modal_input_snapshot()
      @@ -8577,30 +8767,24 @@ def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]
               if not confirm_required:
                   return "once"
       
      -        # Render warning + prompt — single-line composer prompt, mirrors
      -        # ``_confirm_and_reload_mcp``.
      -        print()
      -        print(f"⚠️  /{command} — destroys conversation state")
      -        print()
      -        for line in detail.splitlines():
      -            print(f"  {line}")
      -        print()
      -        print("  [1] Approve Once   — proceed this time only")
      -        print("  [2] Always Approve — proceed and silence this prompt permanently")
      -        print("  [3] Cancel         — keep current conversation")
      -        print()
      -        raw = self._prompt_text_input("Choice [1/2/3]: ")
      +        # Render a prompt_toolkit-native confirmation panel.  This keeps option
      +        # labels visible above the composer and avoids raw input()/EOF races with
      +        # the running TUI.
      +        choices = [
      +            ("once", "Approve Once", "proceed this time only"),
      +            ("always", "Always Approve", "proceed and silence this prompt permanently"),
      +            ("cancel", "Cancel", "keep current conversation"),
      +        ]
      +        raw = self._prompt_text_input_modal(
      +            title=f"⚠️  /{command} — destroys conversation state",
      +            detail=detail,
      +            choices=choices,
      +        )
               if raw is None:
                   print(f"🟡 /{command} cancelled (no input).")
                   return None
      -        choice_raw = raw.strip().lower()
      -        if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
      -            choice = "once"
      -        elif choice_raw in ("2", "always", "remember"):
      -            choice = "always"
      -        elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
      -            choice = "cancel"
      -        else:
      +        choice = self._normalize_slash_confirm_choice(raw, choices)
      +        if choice is None:
                   print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.")
                   return None
       
      @@ -8645,32 +8829,28 @@ def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
                       self._reload_mcp()
                   return
       
      -        # Render warning + prompt.  Use a single-line prompt so the user
      -        # sees the warning as output and types a response into the composer.
      -        print()
      -        print("⚠️  /reload-mcp — Prompt cache invalidation warning")
      -        print()
      -        print("  Reloading MCP servers rebuilds the tool set for this session and")
      -        print("  invalidates the provider prompt cache.  The next message will")
      -        print("  re-send full input tokens (can be expensive on long-context or")
      -        print("  high-reasoning models).")
      -        print()
      -        print("  [1] Approve Once   — reload now")
      -        print("  [2] Always Approve — reload now and silence this prompt permanently")
      -        print("  [3] Cancel         — leave MCP tools unchanged")
      -        print()
      -        raw = self._prompt_text_input("Choice [1/2/3]: ")
      +        # Render warning + prompt.  Use the same prompt_toolkit-native composer
      +        # modal as destructive slash confirmations so choices stay visible.
      +        choices = [
      +            ("once", "Approve Once", "reload now"),
      +            ("always", "Always Approve", "reload now and silence this prompt permanently"),
      +            ("cancel", "Cancel", "leave MCP tools unchanged"),
      +        ]
      +        raw = self._prompt_text_input_modal(
      +            title="⚠️  /reload-mcp — Prompt cache invalidation warning",
      +            detail=(
      +                "Reloading MCP servers rebuilds the tool set for this session and\n"
      +                "invalidates the provider prompt cache. The next message will\n"
      +                "re-send full input tokens (can be expensive on long-context or\n"
      +                "high-reasoning models)."
      +            ),
      +            choices=choices,
      +        )
               if raw is None:
                   print("🟡 /reload-mcp cancelled (no input).")
                   return
      -        choice_raw = raw.strip().lower()
      -        if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
      -            choice = "once"
      -        elif choice_raw in ("2", "always", "remember"):
      -            choice = "always"
      -        elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
      -            choice = "cancel"
      -        else:
      +        choice = self._normalize_slash_confirm_choice(raw, choices)
      +        if choice is None:
                   print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
                   return
       
      @@ -10594,6 +10774,8 @@ def _state_fragment(style: str, icon: str, extra: str = ""):
                   return _state_fragment("class:sudo-prompt", "🔑")
               if self._approval_state:
                   return _state_fragment("class:prompt-working", "⚠")
      +        if getattr(self, "_slash_confirm_state", None):
      +            return _state_fragment("class:prompt-working", "⚠")
               if self._clarify_freetext:
                   return _state_fragment("class:clarify-selected", "✎")
               if self._clarify_state:
      @@ -10660,6 +10842,7 @@ def _build_tui_layout_children(
               sudo_widget,
               secret_widget,
               approval_widget,
      +        slash_confirm_widget=None,
               clarify_widget,
               model_picker_widget=None,
               spinner_widget=None,
      @@ -10684,6 +10867,7 @@ def _build_tui_layout_children(
                       sudo_widget,
                       secret_widget,
                       approval_widget,
      +                slash_confirm_widget,
                       clarify_widget,
                       model_picker_widget,
                       spinner_widget,
      @@ -10846,6 +11030,13 @@ def run(self):
               self._approval_deadline = 0
               self._approval_lock = threading.Lock()  # serialize concurrent approval prompts (delegation race fix)
       
      +        # Destructive slash-command confirmation state (/new, /clear, /undo).
      +        # These prompts are answered through the prompt_toolkit composer, not
      +        # raw input(), so the option labels stay visible and Enter does not EOF
      +        # the whole app.
      +        self._slash_confirm_state = None
      +        self._slash_confirm_deadline = 0
      +
               # Slash command loading state
               self._command_running = False
               self._command_status = ""
      @@ -10937,6 +11128,20 @@ def handle_enter(event):
                       event.app.invalidate()
                       return
       
      +            # --- Slash-command confirmation: submit typed or highlighted choice ---
      +            if self._slash_confirm_state:
      +                text = event.app.current_buffer.text.strip()
      +                choices = self._slash_confirm_state.get("choices") or []
      +                choice = self._normalize_slash_confirm_choice(text, choices) if text else None
      +                if choice is None:
      +                    selected = self._slash_confirm_state.get("selected", 0)
      +                    if 0 <= selected < len(choices):
      +                        choice = choices[selected][0]
      +                self._submit_slash_confirm_response(choice or "cancel")
      +                event.app.current_buffer.reset()
      +                event.app.invalidate()
      +                return
      +
                   # --- /model picker modal ---
                   if self._model_picker_state:
                       try:
      @@ -11197,6 +11402,20 @@ def approval_down(event):
                       self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
                       event.app.invalidate()
       
      +        # --- Slash-command confirmation: arrow-key navigation ---
      +        @kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state)))
      +        def slash_confirm_up(event):
      +            if self._slash_confirm_state:
      +                self._slash_confirm_state["selected"] = max(0, self._slash_confirm_state.get("selected", 0) - 1)
      +                event.app.invalidate()
      +
      +        @kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state)))
      +        def slash_confirm_down(event):
      +            if self._slash_confirm_state:
      +                max_idx = len(self._slash_confirm_state.get("choices") or []) - 1
      +                self._slash_confirm_state["selected"] = min(max_idx, self._slash_confirm_state.get("selected", 0) + 1)
      +                event.app.invalidate()
      +
               # --- /model picker: arrow-key navigation ---
               @kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
               def model_picker_up(event):
      @@ -11237,12 +11456,26 @@ def handler(event):
                   _idx = 9 if _num == 0 else _num - 1
                   kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx))
       
      +        # Number keys for quick slash-confirm selection (1-9, 0 for 10th item)
      +        def _make_slash_confirm_number_handler(idx):
      +            def handler(event):
      +                if self._slash_confirm_state and idx < len(self._slash_confirm_state.get("choices") or []):
      +                    choice = self._slash_confirm_state["choices"][idx][0]
      +                    self._submit_slash_confirm_response(choice)
      +                    event.app.current_buffer.reset()
      +                    event.app.invalidate()
      +            return handler
      +
      +        for _num in range(10):
      +            _idx = 9 if _num == 0 else _num - 1
      +            kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(_make_slash_confirm_number_handler(_idx))
      +
               # --- History navigation: up/down browse history in normal input mode ---
               # The TextArea is multiline, so by default up/down only move the cursor.
               # Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
               # history browsing when on the first/last line (or single-line input).
               _normal_input = Condition(
      -            lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
      +            lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
               )
       
               @kb.add('up', filter=_normal_input)
      @@ -11318,6 +11551,13 @@ def handle_ctrl_c(event):
                       event.app.invalidate()
                       return
       
      +            # Cancel slash confirmation prompt
      +            if self._slash_confirm_state:
      +                self._submit_slash_confirm_response("cancel")
      +                event.app.current_buffer.reset()
      +                event.app.invalidate()
      +                return
      +
                   # Cancel /model picker
                   if self._model_picker_state:
                       self._close_model_picker()
      @@ -11412,6 +11652,13 @@ def handle_ctrl_q(event):
                       event.app.invalidate()
                       return
       
      +            # Cancel slash confirmation prompt
      +            if self._slash_confirm_state:
      +                self._submit_slash_confirm_response("cancel")
      +                event.app.current_buffer.reset()
      +                event.app.invalidate()
      +                return
      +
                   # Cancel /model picker
                   if self._model_picker_state:
                       self._close_model_picker()
      @@ -11460,7 +11707,7 @@ def handle_ctrl_d(event):
                       event.app.exit()
       
               _modal_prompt_active = Condition(
      -            lambda: bool(self._secret_state or self._sudo_state)
      +            lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state)
               )
       
               @kb.add('escape', filter=_modal_prompt_active, eager=True)
      @@ -11476,6 +11723,11 @@ def handle_escape_modal(event):
                       self._sudo_state = None
                       event.app.invalidate()
                       return
      +            if self._slash_confirm_state:
      +                self._submit_slash_confirm_response("cancel")
      +                event.app.current_buffer.reset()
      +                event.app.invalidate()
      +                return
       
               @kb.add('c-z')
               def handle_ctrl_z(event):
      @@ -11558,7 +11810,7 @@ def handle_voice_record(event):
                       # Guard: don't START recording during agent run or interactive prompts
                       if cli_ref._agent_running:
                           return
      -                if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state:
      +                if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state:
                           return
                       # Guard: don't start while a previous stop/transcribe cycle is
                       # still running — recorder.stop() holds AudioRecorder._lock and
      @@ -11845,6 +12097,8 @@ def _get_placeholder():
                       return "type secret (hidden), Enter to submit · ESC to skip"
                   if cli_ref._approval_state:
                       return ""
      +            if cli_ref._slash_confirm_state:
      +                return "type 1/2/3, or use ↑/↓ then Enter"
                   if cli_ref._clarify_freetext:
                       return "type your answer here and press Enter"
                   if cli_ref._clarify_state:
      @@ -11887,6 +12141,13 @@ def get_hint_text():
                           ('class:clarify-countdown', f'  ({remaining}s)'),
                       ]
       
      +            if cli_ref._slash_confirm_state:
      +                remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic()))
      +                return [
      +                    ('class:hint', '  type 1/2/3, or ↑/↓ to select, Enter to confirm'),
      +                    ('class:clarify-countdown', f'  ({remaining}s)'),
      +                ]
      +
                   if cli_ref._clarify_state:
                       remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic()))
                       countdown = f'  ({remaining}s)' if cli_ref._clarify_deadline else ''
      @@ -11909,7 +12170,7 @@ def get_hint_text():
                   return []
       
               def get_hint_height():
      -            if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running:
      +            if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running:
                       return 1
                   # Keep a spacer while the agent runs on roomy terminals, but reclaim
                   # the row on narrow/mobile screens where every line matters.
      @@ -12213,6 +12474,17 @@ def _get_approval_display():
                   filter=Condition(lambda: cli_ref._approval_state is not None),
               )
       
      +        def _get_slash_confirm_display():
      +            return cli_ref._get_slash_confirm_display_fragments()
      +
      +        slash_confirm_widget = ConditionalContainer(
      +            Window(
      +                FormattedTextControl(_get_slash_confirm_display),
      +                wrap_lines=True,
      +            ),
      +            filter=Condition(lambda: cli_ref._slash_confirm_state is not None),
      +        )
      +
               # --- /model picker: display widget ---
               def _get_model_picker_display():
                   state = cli_ref._model_picker_state
      @@ -12358,6 +12630,7 @@ def _get_voice_status():
                           sudo_widget=sudo_widget,
                           secret_widget=[redacted:secret-assignment]
                           approval_widget=approval_widget,
      +                    slash_confirm_widget=slash_confirm_widget,
                           clarify_widget=clarify_widget,
                           model_picker_widget=model_picker_widget,
                           spinner_widget=spinner_widget,
    • scripts/release.py
      @@ -51,6 +51,7 @@
           "[redacted:email]": "teknium1",
           "[redacted:email]": "thelumiereguy",
           "[redacted:email]": "counterposition",
      +    "[redacted:email]": "zhengyn0001",
           "[redacted:email]": "curiouscleo",
           "[redacted:email]": "hrkzogw",
           "[redacted:email]": "dandacompany",
    • tests/cli/test_destructive_slash_confirm.py
      @@ -6,6 +6,7 @@
       
       from __future__ import annotations
       
      +import queue
       from types import SimpleNamespace
       from unittest.mock import patch
       
      @@ -17,10 +18,17 @@ def _bound(fn, instance):
       
       def _make_self(prompt_response):
           """Build a minimal stand-in 'self' for _confirm_destructive_slash."""
      -    return SimpleNamespace(
      +    from cli import HermesCLI
      +
      +    self_ = SimpleNamespace(
               _app=None,
               _prompt_text_input=lambda _prompt: prompt_response,
      +        _prompt_text_input_modal=lambda **_kw: prompt_response,
      +    )
      +    self_._normalize_slash_confirm_choice = _bound(
      +        HermesCLI._normalize_slash_confirm_choice, self_,
           )
      +    return self_
       
       
       def test_gate_off_returns_once_without_prompting():
      @@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always():
           self_ = _make_self(prompt_response="2")
       
           saves = []
      -
           def _fake_save(key, value):
               saves.append((key, value))
               return True
      @@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing():
           # treated as on despite the config error.  If the gate had been off
           # this would have returned 'once' without consulting the prompt.
           assert result is None
      +
      +
      +def test_slash_confirm_modal_number_selection_submits_without_raw_input():
      +    """Pressing 2 in the TUI modal should resolve to Always Approve directly."""
      +    from cli import HermesCLI
      +
      +    q = queue.Queue()
      +    self_ = SimpleNamespace(
      +        _slash_confirm_state={
      +            "choices": [
      +                ("once", "Approve Once", "proceed once"),
      +                ("always", "Always Approve", "persist opt-out"),
      +                ("cancel", "Cancel", "abort"),
      +            ],
      +            "selected": 0,
      +            "response_queue": q,
      +        },
      +        _slash_confirm_deadline=123,
      +        _invalidate=lambda: None,
      +    )
      +
      +    _bound(HermesCLI._submit_slash_confirm_response, self_)("always")
      +
      +    assert q.get_nowait() == "always"
      +    assert self_._slash_confirm_state is None
      +    assert self_._slash_confirm_deadline == 0
      +
      +
      +def test_slash_confirm_display_fragments_include_choice_mapping():
      +    """The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'."""
      +    from cli import HermesCLI
      +
      +    self_ = SimpleNamespace(
      +        _slash_confirm_state={
      +            "title": "⚠️  /new — destroys conversation state",
      +            "detail": "This starts a fresh session.",
      +            "choices": [
      +                ("once", "Approve Once", "proceed once"),
      +                ("always", "Always Approve", "persist opt-out"),
      +                ("cancel", "Cancel", "abort"),
      +            ],
      +            "selected": 1,
      +        },
      +    )
      +
      +    fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()
      +    rendered = "".join(fragment for _style, fragment in fragments)
      +
      +    assert "[1] Approve Once" in rendered
      +    assert "[2] Always Approve" in rendered
      +    assert "[3] Cancel" in rendered
      +    assert "Type 1/2/3" in rendered
    • tests/cli/test_prompt_text_input_thread_safety.py
      @@ -1,15 +1,9 @@
       """Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
       
      -Slash commands (``/clear``, ``/new``, ``/undo``, ``/reload-mcp``) are dispatched
      -from the ``process_loop`` daemon thread.  ``prompt_toolkit.run_in_terminal``
      -returns a coroutine that only the main-thread event loop can drive; calling it
      -from a daemon thread orphans the coroutine, ``_ask`` never runs, and user
      -keystrokes leak into the composer instead of the confirmation prompt
      -(see issue #23185).
      -
      -The fix mirrors ``_run_curses_picker``: when off the main thread, fall back to
      -a direct ``input()`` call so the prompt actually renders and consumes
      -keystrokes.
      +Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
      +The normal slash confirmations now use a prompt_toolkit-native modal, but
      +``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
      +cases.
       """
       
       import threading
      @@ -17,7 +11,7 @@
       
       
       def _make_cli():
      -    """Minimal HermesCLI shell exposing ``_prompt_text_input``."""
      +    """Minimal HermesCLI shell exposing prompt fallback helpers."""
           import cli as cli_mod
       
           obj = object.__new__(cli_mod.HermesCLI)
      @@ -33,7 +27,7 @@ def test_main_thread_uses_run_in_terminal(self):
       
               with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
                    patch("builtins.input", return_value="2"):
      -            result = cli._prompt_text_input("Choice: ")
      +            cli._prompt_text_input("Choice: ")
       
               # run_in_terminal was invoked; the _ask closure passed to it would
               # call input() when driven by the event loop.  We assert dispatch path,
      @@ -43,10 +37,8 @@ def test_main_thread_uses_run_in_terminal(self):
           def test_background_thread_falls_back_to_direct_input(self):
               """On a daemon thread, skip run_in_terminal and call input() directly.
       
      -        This is the bug from issue #23185: process_loop dispatches slash
      -        commands on a daemon thread, so run_in_terminal's coroutine is
      -        orphaned.  The fallback must drive input() itself so user keystrokes
      -        don't leak into the agent buffer.
      +        This preserves the fallback for any prompt that still runs off the main
      +        UI thread: run_in_terminal's coroutine would otherwise be orphaned.
               """
               cli = _make_cli()
               captured = {}
  • #31704 fix(cli): add inline --yes/now skip for destructive slash commands (#30768)

    merged to main

    files: cli.py, tests/cli/test_destructive_slash_confirm.py, tests/cli/test_destructive_slash_inline_skip_e2e.py, website/docs/reference/slash-commands.md

    Show the diff
    • cli.py
      @@ -8101,6 +8101,7 @@ def process_command(self, command: str) -> bool:
                       "clear",
                       "This clears the screen and starts a new session.\n"
                       "The current conversation history will be discarded.",
      +                cmd_original=cmd_original,
                   ) is None:
                       return
                   self.new_session(silent=True)
      @@ -8225,12 +8226,16 @@ def process_command(self, command: str) -> bool:
                   if not self._handle_handoff_command(cmd_original):
                       return False
               elif canonical == "new":
      -            parts = cmd_original.split(maxsplit=1)
      -            title = parts[1].strip() if len(parts) > 1 else None
      +            # Strip inline-skip tokens (now/--yes/-y) before deriving the title
      +            # so "/new now My Session" yields title="My Session" instead of
      +            # title="now My Session". See _split_destructive_skip.
      +            _new_args, _ = self._split_destructive_skip(cmd_original)
      +            title = _new_args.strip() or None
                   if self._confirm_destructive_slash(
                       "new",
                       "This starts a fresh session.\n"
                       "The current conversation history will be discarded.",
      +                cmd_original=cmd_original,
                   ) is None:
                       return
                   self.new_session(title=title)
      @@ -8257,6 +8262,7 @@ def process_command(self, command: str) -> bool:
                   if self._confirm_destructive_slash(
                       "undo",
                       "This removes the last user/assistant exchange from history.",
      +                cmd_original=cmd_original,
                   ) is None:
                       return
                   self.undo_last()
      @@ -9908,7 +9914,49 @@ def _check_config_mcp_changes(self) -> None:
               if _reload_thread.is_alive():
                   print("  ⚠️  MCP reload timed out (30s). Some servers may not have reconnected.")
       
      -    def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]:
      +    # Inline-skip tokens that bypass the destructive-slash confirmation modal.
      +    # Matches the escape-hatch pattern users on broken modal platforms
      +    # (currently native Windows PowerShell — issue #30768) need to self-serve
      +    # without having to flip approvals.destructive_slash_confirm in config.
      +    _DESTRUCTIVE_SKIP_TOKENS = [redacted:secret-assignment]"now", "--yes", "-y"})
      +
      +    @classmethod
      +    def _split_destructive_skip(cls, cmd_text: Optional[str]) -> tuple[str, bool]:
      +        """Split inline-skip tokens out of a destructive slash command.
      +
      +        Returns ``(remainder, skip)`` where ``remainder`` is the original
      +        text with the command word and any recognized skip tokens removed,
      +        and ``skip`` is True iff at least one skip token was found.
      +
      +        Examples:
      +            "/reset now"            -> ("", True)
      +            "/reset --yes My title" -> ("My title", True)
      +            "/new My title"         -> ("My title", False)
      +            "/clear"                -> ("", False)
      +        """
      +        if not cmd_text:
      +            return "", False
      +        tokens = [redacted:secret-assignment]
      +        if not tokens:
      +            return "", False
      +        # Drop leading "/cmd" word — callers pass the full command text.
      +        if tokens[0].startswith("/"):
      +            tokens = [redacted:secret-assignment]
      +        skip = False
      +        kept: list[str] = []
      +        for tok in tokens:
      +            if tok.lower() in cls._DESTRUCTIVE_SKIP_TOKENS:
      +                skip = True
      +                continue
      +            kept.append(tok)
      +        return " ".join(kept), skip
      +
      +    def _confirm_destructive_slash(
      +        self,
      +        command: str,
      +        detail: str,
      +        cmd_original: Optional[str] = None,
      +    ) -> Optional[str]:
               """Prompt the user to confirm a destructive session slash command.
       
               Used by ``/clear``, ``/new``/``/reset``, and ``/undo`` before they
      @@ -9924,9 +9972,24 @@ def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]
               gate is off the function returns ``"once"`` immediately without
               prompting.
       
      +        Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or
      +        ``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``),
      +        the modal is bypassed and ``"once"`` is returned immediately. This is
      +        an escape hatch for platforms where the prompt_toolkit modal hangs
      +        (issue #30768 — native Windows PowerShell). Callers are responsible
      +        for stripping the skip tokens from any remaining argument parsing
      +        (see :meth:`_split_destructive_skip`).
      +
               Returns ``"once"``, ``"always"``, or ``None`` (cancelled).  Callers
               proceed with the destructive action when the result is non-None.
               """
      +        # Inline-skip escape hatch — works regardless of platform/modal state.
      +        # See class-level _DESTRUCTIVE_SKIP_TOKENS for the accepted tokens.
      +        if cmd_original:
      +            _, _skip = self._split_destructive_skip(cmd_original)
      +            if _skip:
      +                return "once"
      +
               # Gate check — respects prior "Always Approve" clicks.
               try:
                   cfg = load_cli_config()
    • tests/cli/test_destructive_slash_confirm.py
      @@ -209,3 +209,123 @@ def test_slash_confirm_display_fragments_include_choice_mapping():
           assert "[2] Always Approve" in rendered
           assert "[3] Cancel" in rendered
           assert "Type 1/2/3" in rendered
      +
      +
      +# ---------------------------------------------------------------------------
      +# Inline-skip escape hatch (issue #30768)
      +#
      +# Users on platforms where the prompt_toolkit modal doesn't dispatch keys
      +# (currently native Windows PowerShell) need a way to bypass the confirmation
      +# without flipping the config gate.  ``/reset now``, ``/new --yes``, ``/clear
      +# -y`` all skip the modal and return "once" immediately.
      +# ---------------------------------------------------------------------------
      +
      +
      +def test_split_destructive_skip_recognized_tokens():
      +    """``now``, ``--yes``, and ``-y`` are recognized as skip tokens."""
      +    from cli import HermesCLI
      +
      +    assert HermesCLI._split_destructive_skip("/reset now") == ("", True)
      +    assert HermesCLI._split_destructive_skip("/clear --yes") == ("", True)
      +    assert HermesCLI._split_destructive_skip("/undo -y") == ("", True)
      +
      +
      +def test_split_destructive_skip_strips_command_word():
      +    """Leading ``/cmd`` token is stripped; remaining args survive."""
      +    from cli import HermesCLI
      +
      +    assert HermesCLI._split_destructive_skip("/new My title") == ("My title", False)
      +    assert HermesCLI._split_destructive_skip("/new --yes My title") == ("My title", True)
      +
      +
      +def test_split_destructive_skip_case_insensitive():
      +    """Token matching is case-insensitive but not a substring match."""
      +    from cli import HermesCLI
      +
      +    assert HermesCLI._split_destructive_skip("/new NOW") == ("", True)
      +    # Substring match must NOT trigger — "Now-Title" is a literal title token.
      +    assert HermesCLI._split_destructive_skip("/new Now-Title") == ("Now-Title", False)
      +
      +
      +def test_split_destructive_skip_handles_empty_and_none():
      +    """Defensive against missing/empty input."""
      +    from cli import HermesCLI
      +
      +    assert HermesCLI._split_destructive_skip(None) == ("", False)
      +    assert HermesCLI._split_destructive_skip("") == ("", False)
      +    assert HermesCLI._split_destructive_skip("   ") == ("", False)
      +
      +
      +def test_confirm_destructive_slash_now_skips_modal():
      +    """``/reset now`` skips the modal even when the gate is on."""
      +    from cli import HermesCLI
      +
      +    # Build a prompt stub that fails the test if invoked — proving the modal
      +    # was never reached.
      +    def _explode(**_kw):
      +        raise AssertionError("modal must not be invoked when inline-skip present")
      +
      +    self_ = SimpleNamespace(
      +        _app=None,
      +        _prompt_text_input_modal=_explode,
      +    )
      +    self_._normalize_slash_confirm_choice = _bound(
      +        HermesCLI._normalize_slash_confirm_choice, self_,
      +    )
      +    self_._split_destructive_skip = HermesCLI._split_destructive_skip  # classmethod
      +
      +    with patch(
      +        "cli.load_cli_config",
      +        return_value={"approvals": {"destructive_slash_confirm": True}},
      +    ):
      +        result = _bound(HermesCLI._confirm_destructive_slash, self_)(
      +            "new", "detail", cmd_original="/reset now",
      +        )
      +
      +    assert result == "once"
      +
      +
      +def test_confirm_destructive_slash_yes_flag_skips_modal():
      +    """``--yes`` flag is equivalent to ``now``."""
      +    from cli import HermesCLI
      +
      +    def _explode(**_kw):
      +        raise AssertionError("modal must not be invoked when --yes present")
      +
      +    self_ = SimpleNamespace(
      +        _app=None,
      +        _prompt_text_input_modal=_explode,
      +    )
      +    self_._normalize_slash_confirm_choice = _bound(
      +        HermesCLI._normalize_slash_confirm_choice, self_,
      +    )
      +    self_._split_destructive_skip = HermesCLI._split_destructive_skip
      +
      +    with patch(
      +        "cli.load_cli_config",
      +        return_value={"approvals": {"destructive_slash_confirm": True}},
      +    ):
      +        result = _bound(HermesCLI._confirm_destructive_slash, self_)(
      +            "new", "detail", cmd_original="/new --yes My Session",
      +        )
      +
      +    assert result == "once"
      +
      +
      +def test_confirm_destructive_slash_no_skip_token_still_prompts():
      +    """Without a skip token the gate-on path still consults the modal."""
      +    from cli import HermesCLI
      +
      +    self_ = _make_self(prompt_response="3")  # cancel
      +    self_._split_destructive_skip = HermesCLI._split_destructive_skip
      +
      +    with patch(
      +        "cli.load_cli_config",
      +        return_value={"approvals": {"destructive_slash_confirm": True}},
      +    ):
      +        result = _bound(HermesCLI._confirm_destructive_slash, self_)(
      +            "new", "detail", cmd_original="/new My Session",
      +        )
      +
      +    # Prompt was reached and returned cancel → None.
      +    assert result is None
    • tests/cli/test_destructive_slash_inline_skip_e2e.py
      @@ -0,0 +1,129 @@
      +"""End-to-end integration test for the destructive-slash inline-skip path.
      +
      +Drives ``HermesCLI.process_command("/reset now")`` against a minimal stand-in
      +and verifies:
      +
      +1. ``new_session`` was invoked (the command actually ran)
      +2. ``_prompt_text_input_modal`` was NOT invoked (modal bypassed)
      +3. The skip token did not leak into the session title
      +
      +This is the regression test for issue #30768 — the inline-skip escape hatch
      +must work without ever touching the modal, on every platform.
      +"""
      +
      +from __future__ import annotations
      +
      +from types import SimpleNamespace
      +from unittest.mock import patch
      +
      +
      +def _make_cli_stub():
      +    """Build a minimal HermesCLI-shaped object that can run ``process_command``
      +    for the destructive-slash branches without spinning up a real TUI."""
      +    from cli import HermesCLI
      +
      +    new_session_calls = []
      +
      +    def _capture_new_session(self_, title=None, silent=False):
      +        new_session_calls.append({"title": title, "silent": silent})
      +
      +    self_ = SimpleNamespace(
      +        _app=None,
      +        _prompt_text_input_modal=lambda **_kw: (_ for _ in ()).throw(
      +            AssertionError("modal must not be invoked when inline-skip token present")
      +        ),
      +        new_session=lambda **kw: _capture_new_session(self_, **kw),
      +        # Stub out side-effects the destructive-slash branches reach for.
      +        console=SimpleNamespace(clear=lambda: None),
      +        compact=False,
      +        model="stub-model",
      +        session_id="stub-session",
      +        enabled_toolsets=[],
      +        _pending_title=None,
      +        _session_db=None,
      +    )
      +    # Bind the methods we need under test.
      +    self_._split_destructive_skip = HermesCLI._split_destructive_skip
      +    self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
      +        self_, type(self_)
      +    )
      +    self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
      +    return self_, new_session_calls
      +
      +
      +def test_reset_now_invokes_new_session_without_modal():
      +    """``/reset now`` runs ``new_session`` and never touches the modal."""
      +    self_, calls = _make_cli_stub()
      +
      +    with patch(
      +        "cli.load_cli_config",
      +        return_value={"approvals": {"destructive_slash_confirm": True}},
      +    ):
      +        self_.process_command("/reset now")
      +
      +    assert calls, "new_session was never invoked"
      +    # The /new branch passes title=None when there's no non-skip remainder.
      +    assert calls[0]["title"] is None
      +
      +
      +def test_new_yes_with_title_preserves_title():
      +    """``/new --yes My Session`` runs ``new_session(title='My Session')``."""
      +    self_, calls = _make_cli_stub()
      +
      +    with patch(
      +        "cli.load_cli_config",
      +        return_value={"approvals": {"destructive_slash_confirm": True}},
      +    ):
      +        self_.process_command("/new --yes My Session")
      +
      +    assert calls, "new_session was never invoked"
      +    assert calls[0]["title"] == "My Session"
      +
      +
      +def test_new_without_skip_token_still_consults_modal():
      +    """``/new My Session`` (no skip token) must reach the modal.
      +
      +    Sanity check that we haven't accidentally short-circuited the normal path.
      +    """
      +    from cli import HermesCLI
      +
      +    new_session_calls = []
      +    modal_calls = []
      +
      +    def _capture_new_session(self_, title=None, silent=False):
      +        new_session_calls.append({"title": title, "silent": silent})
      +
      +    def _record_modal(**kw):
      +        modal_calls.append(kw)
      +        # Simulate user cancelling so new_session is not called.
      +        return "3"
      +
      +    self_ = SimpleNamespace(
      +        _app=None,
      +        _prompt_text_input_modal=_record_modal,
      +        new_session=lambda **kw: _capture_new_session(self_, **kw),
      +        console=SimpleNamespace(clear=lambda: None),
      +        compact=False,
      +        model="stub-model",
      +        session_id="stub-session",
      +        enabled_toolsets=[],
      +        _pending_title=None,
      +        _session_db=None,
      +    )
      +    self_._split_destructive_skip = HermesCLI._split_destructive_skip
      +    self_._normalize_slash_confirm_choice = HermesCLI._normalize_slash_confirm_choice.__get__(
      +        self_, type(self_)
      +    )
      +    self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
      +        self_, type(self_)
      +    )
      +    self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
      +
      +    with patch(
      +        "cli.load_cli_config",
      +        return_value={"approvals": {"destructive_slash_confirm": True}},
      +    ):
      +        self_.process_command("/new My Session")
      +
      +    assert modal_calls, "modal must be reached when no skip token is present"
      +    assert not new_session_calls, "user cancelled — new_session must not run"
    • website/docs/reference/slash-commands.md
      @@ -36,7 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
       
       | Command | Description |
       |---------|-------------|
      -| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. |
      +| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. Append `now`, `--yes`, or `-y` to skip the confirmation modal — e.g. `/reset now`, `/new --yes my-experiment`. |
       | `/clear` | Clear screen and start a new session |
       | `/history` | Show conversation history |
       | `/save` | Save the current conversation |
      @@ -252,4 +252,6 @@ The CLI prompts before running slash commands that throw away unsaved session st
       
       For each of these the CLI opens a three-choice modal: **Approve Once** (proceed this time), **Always Approve** (proceed and persist `approvals.destructive_slash_confirm: false` so future destructive commands run without prompting), or **Cancel**.
       
      +**Inline skip:** append `now`, `--yes`, or `-y` to bypass the modal for a single invocation — e.g. `/reset now`, `/new --yes my-session`, `/clear -y`, `/undo -y`. Useful when the modal doesn't render correctly on your terminal (see [issue #30768](https://github.com/NousResearch/hermes-agent/issues/30768) for native Windows PowerShell) or when scripting against the CLI.
      +
       Set `approvals.destructive_slash_confirm: false` in `~/.hermes/config.yaml` to disable the prompts globally; set it back to `true` to re-enable. See [Security — Destructive slash command confirmation](../user-guide/security.md#dangerous-command-approval) for context.
  • #32013 fix(cli): prevent /reset and /new freeze on Windows by falling back to stdin prompt (#30768)

    merged to main

    files: cli.py, scripts/release.py, tests/cli/test_slash_confirm_windows.py

    Show the diff
    • cli.py
      @@ -7034,7 +7034,28 @@ def _prompt_text_input_modal(
               could be interpreted as EOF/exit.  A first-class modal state keeps the
               choices visible and lets the normal Enter key binding submit the typed
               or highlighted choice.
      +
      +        **Platform note (Windows dead-lock — issue #30768):**
      +        The queue-based modal relies on prompt_toolkit key bindings receiving
      +        keyboard events and calling ``_submit_slash_confirm_response``.  On
      +        Windows (PowerShell / Windows Terminal) the prompt_toolkit input
      +        channel can become unresponsive when the modal is entered from the
      +        ``process_loop`` daemon thread, causing a dead-lock: the user sees the
      +        confirmation panel but keystrokes never reach the key bindings and the
      +        ``response_queue.get()`` blocks until the 120-second timeout expires.
      +
      +        To avoid this, we fall back to ``_prompt_text_input`` (a simple
      +        ``input()``-based prompt) when any of these conditions hold:
      +
      +        * ``sys.platform == "win32"`` — native Windows console (ConPTY /
      +          win32_input) does not support the modal reliably.
      +        * Called from a non-main thread — the prompt_toolkit event loop only
      +          runs on the main thread; key bindings can't fire from a daemon
      +          thread (same rationale as the ``_prompt_text_input`` thread guard
      +          in PR #23454).
      +        * ``self._app`` is not set — unit tests / non-interactive contexts.
               """
      +        import threading
               import time as _time
       
               if not choices:
      @@ -7045,6 +7066,20 @@ def _prompt_text_input_modal(
               if not getattr(self, "_app", None):
                   return self._prompt_text_input("Choice [1/2/3]: ")
       
      +        # On Windows the prompt_toolkit input channel can deadlock when the
      +        # modal is entered from the process_loop daemon thread — keystrokes
      +        # never reach the key bindings, so response_queue.get() blocks for
      +        # the full timeout (issue #30768).  Fall back to the simpler
      +        # stdin-based prompt which works reliably on Windows.
      +        if sys.platform == "win32":
      +            return self._prompt_text_input("Choice [1/2/3]: ")
      +
      +        # Mirror the thread-aware guard from _prompt_text_input (PR #23454):
      +        # run_in_terminal and the modal queue both depend on the main-thread
      +        # event loop.  From a daemon thread the modal key bindings never fire.
      +        if threading.current_thread() is not threading.main_thread():
      +            return self._prompt_text_input("Choice [1/2/3]: ")
      +
               response_queue = queue.Queue()
               self._capture_modal_input_snapshot()
               self._slash_confirm_state = {
    • scripts/release.py
      @@ -1301,6 +1301,7 @@
           "[redacted:email]": "SamuelZ12",  # PR #7480 (show recap after in-session resume)
           "[redacted:email]": "daizhonggeng",  # PR #9020 (numbered /resume selection)
           "[redacted:email]": "wanwan2qq",  # PR #10215 (strip brackets/quotes from /resume; gateway session-ID lookup)
      +    "[redacted:email]": "SimoKiihamaki",  # PR #30773 (Windows /reset+/new freeze; stdin fallback for modal)
       }
       
       
    • tests/cli/test_slash_confirm_windows.py
      @@ -0,0 +1,259 @@
      +"""Regression tests for issue #30768: /reset and /new freeze on Windows.
      +
      +``_prompt_text_input_modal`` uses a queue-based modal that relies on
      +prompt_toolkit key bindings receiving keyboard events.  On Windows the
      +prompt_toolkit input channel can deadlock when the modal is entered from
      +the ``process_loop`` daemon thread.  The fix falls back to the simpler
      +``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads.
      +
      +These tests verify:
      +1. Windows detection triggers the stdin fallback
      +2. Non-main thread detection triggers the stdin fallback
      +3. macOS/Linux main-thread path still uses the modal (no regression)
      +4. No-app path still uses the stdin fallback (existing behavior)
      +5. Empty choices returns None (existing behavior)
      +"""
      +
      +import queue
      +import sys
      +import threading
      +import time
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +
      +def _make_cli():
      +    """Minimal HermesCLI shell exposing prompt/modal helpers."""
      +    import cli as cli_mod
      +
      +    obj = object.__new__(cli_mod.HermesCLI)
      +    obj._app = MagicMock()
      +    obj._status_bar_visible = True
      +    obj._last_invalidate = 0.0
      +    obj._modal_input_snapshot = None
      +    obj._slash_confirm_state = None
      +    obj._slash_confirm_deadline = 0
      +    return obj
      +
      +
      +# ---------------------------------------------------------------------------
      +# Sample choices used across tests
      +# ---------------------------------------------------------------------------
      +_SAMPLE_CHOICES = [
      +    ("once", "Approve Once", "proceed this time only"),
      +    ("always", "Always Approve", "proceed and silence this prompt permanently"),
      +    ("cancel", "Cancel", "keep current conversation"),
      +]
      +
      +
      +class TestModalWindowsFallback:
      +    """Windows dead-lock regression tests for _prompt_text_input_modal."""
      +
      +    def test_windows_falls_back_to_stdin(self):
      +        """On Windows, _prompt_text_input_modal should use _prompt_text_input."""
      +        cli = _make_cli()
      +
      +        with patch.object(sys, "platform", "win32"), \
      +             patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
      +            result = cli._prompt_text_input_modal(
      +                title="⚠️  /new — destroys conversation state",
      +                detail="This starts a fresh session.",
      +                choices=_SAMPLE_CHOICES,
      +            )
      +
      +        # The stdin-based fallback was used, not the modal queue path.
      +        mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
      +        assert result == "1"
      +
      +    def test_non_main_thread_falls_back_to_stdin(self):
      +        """Off the main thread, _prompt_text_input_modal should use stdin fallback."""
      +        cli = _make_cli()
      +        result_holder = {}
      +
      +        def run_on_daemon():
      +            # Patch platform to "linux" so the Windows check doesn't short-circuit.
      +            with patch.object(sys, "platform", "linux"), \
      +                 patch.object(cli, "_prompt_text_input", return_value="2") as mock_stdin:
      +                result_holder["result"] = cli._prompt_text_input_modal(
      +                    title="⚠️  /reset",
      +                    detail="This starts a fresh session.",
      +                    choices=_SAMPLE_CHOICES,
      +                )
      +                result_holder["stdin_called"] = mock_stdin.called
      +
      +        t = threading.Thread(target=run_on_daemon, daemon=True)
      +        t.start()
      +        t.join(timeout=2.0)
      +        assert not t.is_alive(), "daemon thread hung — modal deadlocked"
      +        assert result_holder["stdin_called"] is True
      +        assert result_holder["result"] == "2"
      +
      +    def test_main_thread_non_windows_uses_modal(self):
      +        """On macOS/Linux main thread, the queue-based modal is still used."""
      +        cli = _make_cli()
      +
      +        # We need to simulate the modal receiving a response. We'll patch
      +        # the response_queue to immediately return a value.
      +        with patch.object(sys, "platform", "darwin"), \
      +             patch.object(cli, "_capture_modal_input_snapshot"), \
      +             patch.object(cli, "_restore_modal_input_snapshot"), \
      +             patch.object(cli, "_invalidate"):
      +            # Start the modal in a way that it will receive a response
      +            # immediately via the queue.
      +            original_queue = queue.Queue
      +            original_time = time.monotonic
      +
      +            def _fake_modal_flow(*args, **kwargs):
      +                """Simulate the modal flow: set state, put response, return."""
      +                # We'll directly test that the modal path is entered by
      +                # checking that _slash_confirm_state was set.
      +                pass
      +
      +            # Since we can't easily mock the internal queue, let's test
      +            # that the modal path is entered by checking that
      +            # _prompt_text_input was NOT called.
      +            with patch.object(cli, "_prompt_text_input") as mock_stdin:
      +                # Set up a response that will be put into the queue
      +                # after the modal starts waiting.
      +                def _submit_after_delay():
      +                    time.sleep(0.2)
      +                    state = cli._slash_confirm_state
      +                    if state and "response_queue" in state:
      +                        state["response_queue"].put("once")
      +
      +                submitter = threading.Thread(target=_submit_after_delay, daemon=True)
      +                submitter.start()
      +
      +                result = cli._prompt_text_input_modal(
      +                    title="⚠️  /new",
      +                    detail="This starts a fresh session.",
      +                    choices=_SAMPLE_CHOICES,
      +                    timeout=5,
      +                )
      +
      +                submitter.join(timeout=2.0)
      +
      +            # The stdin fallback should NOT have been called.
      +            mock_stdin.assert_not_called()
      +            # The result should be "once" from the simulated modal response.
      +            assert result == "once"
      +
      +    def test_no_app_falls_back_to_stdin(self):
      +        """Without a prompt_toolkit app, always use stdin fallback."""
      +        cli = _make_cli()
      +        cli._app = None
      +
      +        with patch.object(cli, "_prompt_text_input", return_value="3") as mock_stdin:
      +            result = cli._prompt_text_input_modal(
      +                title="⚠️  /clear",
      +                detail="This clears the screen.",
      +                choices=_SAMPLE_CHOICES,
      +            )
      +
      +        mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
      +        assert result == "3"
      +
      +    def test_empty_choices_returns_none(self):
      +        """Empty choices list should return None without prompting."""
      +        cli = _make_cli()
      +
      +        with patch.object(cli, "_prompt_text_input") as mock_stdin:
      +            result = cli._prompt_text_input_modal(
      +                title="Test",
      +                detail="Test",
      +                choices=[],
      +            )
      +
      +        mock_stdin.assert_not_called()
      +        assert result is None
      +
      +    def test_windows_fallback_does_not_set_modal_state(self):
      +        """Verify Windows fallback doesn't leave _slash_confirm_state set."""
      +        cli = _make_cli()
      +
      +        with patch.object(sys, "platform", "win32"), \
      +             patch.object(cli, "_prompt_text_input", return_value="1"):
      +            cli._prompt_text_input_modal(
      +                title="⚠️  /reset",
      +                detail="This starts a fresh session.",
      +                choices=_SAMPLE_CHOICES,
      +            )
      +
      +        assert cli._slash_confirm_state is None
      +
      +    def test_non_main_thread_fallback_does_not_set_modal_state(self):
      +        """Verify daemon-thread fallback doesn't leave modal state set."""
      +        cli = _make_cli()
      +        errors = []
      +
      +        def run_on_daemon():
      +            try:
      +                with patch.object(sys, "platform", "linux"), \
      +                     patch.object(cli, "_prompt_text_input", return_value="1"):
      +                    cli._prompt_text_input_modal(
      +                        title="⚠️  /new",
      +                        detail="This starts a fresh session.",
      +                        choices=_SAMPLE_CHOICES,
      +                    )
      +                if cli._slash_confirm_state is not None:
      +                    errors.append("_slash_confirm_state should be None")
      +            except Exception as exc:
      +                errors.append(str(exc))
      +
      +        t = threading.Thread(target=run_on_daemon, daemon=True)
      +        t.start()
      +        t.join(timeout=2.0)
      +        assert not errors, f"unexpected errors: {errors}"
      +        assert cli._slash_confirm_state is None
      +
      +
      +class TestConfirmDestructiveSlashWindows:
      +    """Integration-level tests for _confirm_destructive_slash on Windows."""
      +
      +    def test_confirm_destructive_slash_bypasses_modal_on_windows(self):
      +        """_confirm_destructive_slash should work on Windows via stdin fallback."""
      +        cli = _make_cli()
      +        cli.model = "test-model"
      +        cli._agent_running = False
      +        cli._spinner_text = ""
      +        cli._should_exit = False
      +        cli._command_running = False
      +        cli.session_id = "test-session"
      +        cli._pending_tool_info = {}
      +        cli._tool_start_time = 0.0
      +        cli._last_scrollback_tool = ""
      +
      +        with patch.object(sys, "platform", "win32"), \
      +             patch.object(cli, "_prompt_text_input", return_value="1"), \
      +             patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
      +            result = cli._confirm_destructive_slash(
      +                "new",
      +                "This starts a fresh session.\nThe current conversation history will be discarded.",
      +            )
      +
      +        assert result == "once"
      +
      +    def test_confirm_destructive_slash_cancelled_on_windows(self):
      +        """Cancellation via stdin fallback works on Windows."""
      +        cli = _make_cli()
      +        cli.model = "test-model"
      +        cli._agent_running = False
      +        cli._spinner_text = ""
      +        cli._should_exit = False
      +        cli._command_running = False
      +        cli.session_id = "test-session"
      +        cli._pending_tool_info = {}
      +        cli._tool_start_time = 0.0
      +        cli._last_scrollback_tool = ""
      +
      +        with patch.object(sys, "platform", "win32"), \
      +             patch.object(cli, "_prompt_text_input", return_value="3"), \
      +             patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
      +            result = cli._confirm_destructive_slash(
      +                "reset",
      +                "This starts a fresh session.\nThe current conversation history will be discarded.",
      +            )
      +
      +        # Choice "3" normalizes to "cancel", which returns None.
      +        assert result is None
  • #33241 fix(cli): keep destructive slash modal on Linux/macOS daemon thread

    merged to main

    files: cli.py, tests/cli/test_slash_confirm_windows.py

    Show the diff
    • cli.py
      @@ -7154,11 +7154,13 @@ def _prompt_text_input_modal(
       
               * ``sys.platform == "win32"`` — native Windows console (ConPTY /
                 win32_input) does not support the modal reliably.
      -        * Called from a non-main thread — the prompt_toolkit event loop only
      -          runs on the main thread; key bindings can't fire from a daemon
      -          thread (same rationale as the ``_prompt_text_input`` thread guard
      -          in PR #23454).
               * ``self._app`` is not set — unit tests / non-interactive contexts.
      +
      +        On non-Windows platforms the modal itself is still safe from the
      +        ``process_loop`` daemon thread as long as the main-thread event loop
      +        owns the prompt_toolkit buffer mutations.  When we are off the main
      +        thread, schedule the modal snapshot / restore work on ``self._app.loop``
      +        via ``call_soon_threadsafe`` and keep the queue-based response path.
               """
               import threading
               import time as _time
      @@ -7179,33 +7181,62 @@ def _prompt_text_input_modal(
               if sys.platform == "win32":
                   return self._prompt_text_input("Choice [1/2/3]: ")
       
      -        # Mirror the thread-aware guard from _prompt_text_input (PR #23454):
      -        # run_in_terminal and the modal queue both depend on the main-thread
      -        # event loop.  From a daemon thread the modal key bindings never fire.
      -        if threading.current_thread() is not threading.main_thread():
      +        try:
      +            app_loop = self._app.loop
      +        except Exception:
      +            app_loop = None
      +
      +        in_main_thread = threading.current_thread() is threading.main_thread()
      +        if not in_main_thread and app_loop is None:
                   return self._prompt_text_input("Choice [1/2/3]: ")
       
               response_queue = queue.Queue()
      -        self._capture_modal_input_snapshot()
      -        self._slash_confirm_state = {
      -            "title": title,
      -            "detail": detail,
      -            "choices": choices,
      -            "selected": 0,
      -            "response_queue": response_queue,
      -        }
      -        self._slash_confirm_deadline = _time.monotonic() + timeout
      -        self._invalidate()
      +
      +        def _setup_modal() -> None:
      +            self._capture_modal_input_snapshot()
      +            self._slash_confirm_state = {
      +                "title": title,
      +                "detail": detail,
      +                "choices": choices,
      +                "selected": 0,
      +                "response_queue": response_queue,
      +            }
      +            self._slash_confirm_deadline = _time.monotonic() + timeout
      +            self._invalidate()
      +
      +        def _teardown_modal() -> None:
      +            self._slash_confirm_state = None
      +            self._slash_confirm_deadline = 0
      +            self._restore_modal_input_snapshot()
      +            self._invalidate()
      +
      +        def _run_on_app_loop(fn) -> bool:
      +            if in_main_thread or app_loop is None:
      +                fn()
      +                return True
      +            ready = threading.Event()
      +
      +            def _wrapped() -> None:
      +                try:
      +                    fn()
      +                finally:
      +                    ready.set()
      +
      +            try:
      +                app_loop.call_soon_threadsafe(_wrapped)
      +            except Exception:
      +                return False
      +            return ready.wait(timeout=5)
      +
      +        if not _run_on_app_loop(_setup_modal):
      +            return self._prompt_text_input("Choice [1/2/3]: ")
       
               _last_countdown_refresh = _time.monotonic()
               try:
                   while True:
                       try:
                           result = response_queue.get(timeout=1)
      -                    self._slash_confirm_state = None
      -                    self._slash_confirm_deadline = 0
      -                    self._restore_modal_input_snapshot()
      -                    self._invalidate()
      +                    _run_on_app_loop(_teardown_modal)
                           return result
                       except queue.Empty:
                           remaining = self._slash_confirm_deadline - _time.monotonic()
      @@ -7217,10 +7248,7 @@ def _prompt_text_input_modal(
                               self._invalidate()
               finally:
                   if self._slash_confirm_state is not None:
      -                self._slash_confirm_state = None
      -                self._slash_confirm_deadline = 0
      -                self._restore_modal_input_snapshot()
      -                self._invalidate()
      +                _run_on_app_loop(_teardown_modal)
               return None
       
           def _submit_slash_confirm_response(self, value: str | None) -> None:
    • tests/cli/test_slash_confirm_windows.py
      @@ -1,14 +1,14 @@
      -"""Regression tests for issue #30768: /reset and /new freeze on Windows.
      +"""Regression tests for issue #30768 and #32383.
       
       ``_prompt_text_input_modal`` uses a queue-based modal that relies on
       prompt_toolkit key bindings receiving keyboard events.  On Windows the
       prompt_toolkit input channel can deadlock when the modal is entered from
       the ``process_loop`` daemon thread.  The fix falls back to the simpler
      -``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads.
      +``_prompt_text_input`` (stdin-based) prompt on Windows.
       
       These tests verify:
       1. Windows detection triggers the stdin fallback
      -2. Non-main thread detection triggers the stdin fallback
      +2. Non-Windows daemon threads still use the modal via the app loop
       3. macOS/Linux main-thread path still uses the modal (no regression)
       4. No-app path still uses the stdin fallback (existing behavior)
       5. Empty choices returns None (existing behavior)
      @@ -29,6 +29,7 @@ def _make_cli():
       
           obj = object.__new__(cli_mod.HermesCLI)
           obj._app = MagicMock()
      +    obj._app.loop = MagicMock()
           obj._status_bar_visible = True
           obj._last_invalidate = 0.0
           obj._modal_input_snapshot = None
      @@ -66,28 +67,47 @@ def test_windows_falls_back_to_stdin(self):
               mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
               assert result == "1"
       
      -    def test_non_main_thread_falls_back_to_stdin(self):
      -        """Off the main thread, _prompt_text_input_modal should use stdin fallback."""
      +    def test_non_main_thread_uses_modal_via_app_loop(self):
      +        """Off the main thread on Linux, keep the modal path via app-loop setup."""
               cli = _make_cli()
               result_holder = {}
      +        setup_calls = []
      +        teardown_calls = []
      +
      +        def _call_soon_threadsafe(callback):
      +            callback()
       
               def run_on_daemon():
      -            # Patch platform to "linux" so the Windows check doesn't short-circuit.
                   with patch.object(sys, "platform", "linux"), \
      -                 patch.object(cli, "_prompt_text_input", return_value="2") as mock_stdin:
      +                 patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe), \
      +                 patch.object(cli, "_prompt_text_input") as mock_stdin, \
      +                 patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: setup_calls.append("capture")), \
      +                 patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: teardown_calls.append("restore")):
                       result_holder["result"] = cli._prompt_text_input_modal(
                           title="⚠️  /reset",
                           detail="This starts a fresh session.",
                           choices=_SAMPLE_CHOICES,
      +                    timeout=5,
                       )
                       result_holder["stdin_called"] = mock_stdin.called
       
      +        def _submit_after_delay():
      +            time.sleep(0.2)
      +            state = cli._slash_confirm_state
      +            if state and "response_queue" in state:
      +                state["response_queue"].put("once")
      +
      +        submitter = threading.Thread(target=_submit_after_delay, daemon=True)
               t = threading.Thread(target=run_on_daemon, daemon=True)
      +        submitter.start()
               t.start()
               t.join(timeout=2.0)
      +        submitter.join(timeout=2.0)
               assert not t.is_alive(), "daemon thread hung — modal deadlocked"
      -        assert result_holder["stdin_called"] is True
      -        assert result_holder["result"] == "2"
      +        assert result_holder["stdin_called"] is False
      +        assert result_holder["result"] == "once"
      +        assert setup_calls == ["capture"]
      +        assert teardown_calls == ["restore"]
       
           def test_main_thread_non_windows_uses_modal(self):
               """On macOS/Linux main thread, the queue-based modal is still used."""
      @@ -182,20 +202,33 @@ def test_windows_fallback_does_not_set_modal_state(self):
       
               assert cli._slash_confirm_state is None
       
      -    def test_non_main_thread_fallback_does_not_set_modal_state(self):
      -        """Verify daemon-thread fallback doesn't leave modal state set."""
      +    def test_non_main_thread_modal_clears_state(self):
      +        """Verify daemon-thread modal teardown does not leave state behind."""
               cli = _make_cli()
               errors = []
       
      +        def _call_soon_threadsafe(callback):
      +            callback()
      +
               def run_on_daemon():
                   try:
                       with patch.object(sys, "platform", "linux"), \
      -                     patch.object(cli, "_prompt_text_input", return_value="1"):
      +                     patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe):
      +                    def _submit_after_delay():
      +                        time.sleep(0.2)
      +                        state = cli._slash_confirm_state
      +                        if state and "response_queue" in state:
      +                            state["response_queue"].put("cancel")
      +
      +                    submitter = threading.Thread(target=_submit_after_delay, daemon=True)
      +                    submitter.start()
                           cli._prompt_text_input_modal(
                               title="⚠️  /new",
                               detail="This starts a fresh session.",
                               choices=_SAMPLE_CHOICES,
      +                        timeout=5,
                           )
      +                    submitter.join(timeout=2.0)
                       if cli._slash_confirm_state is not None:
                           errors.append("_slash_confirm_state should be None")
                   except Exception as exc:

19 PRs closed without merging

From the close comments: done elsewhere 5 · duplicate 4 · superseded by another PR 4 · merged via another change 3 · stale or abandoned 1 · other 2

  • #22972 fix(cli): prevent RuntimeWarning in _prompt_text_input from background thread · done elsewhere
  • #22989 fix(cli): use prompt_toolkit.prompt() for TUI confirmation prompts (Fixes #22958) · done elsewhere → #22958
  • #23078 fix(cli): add main-thread guard to _prompt_text_input — prevent RuntimeWarning (#22970) · duplicate → #22972
  • #23257 fix(cli): avoid run_in_terminal from worker threads · done elsewhere
  • #23317 fix(cli): suppress RuntimeWarning from unawaited run_in_terminal in prompt_toolkit 3.0.52+ · superseded by another PR → #23454
Show 14 more
  • #23348 fix(cli): use prompt_toolkit prompt for confirmation input · duplicate → #22989, #22958
  • #23571 fix: use TUI modal for slash confirmations · merged via another change → 054f568, #23907, #23694, #23853
  • #23749 fix(cli): schedule destructive prompts on app loop · superseded by another PR → #23907, #23571, #23694
  • #23886 fix(cli): route destructive slash confirmations through main thread e… · superseded by another PR → #23907, #23571, #23694, #23853
  • #24336 fix(cli): schedule /new /clear /undo confirm prompt on PT event loop from daemon thread · superseded by another PR → #22958, #22970
  • #24722 fix(cli): defer destructive slash resets until active turns exit · done elsewhere → 458a94e, #24701, #36833
  • #26617 fix(cli): await run_in_terminal futures to prevent RuntimeWarning · merged via another change → c5f1f86, 365da2d, #23454, #23302
  • #30773 fix(cli): prevent /reset and /new freeze on Windows by falling back to stdin prompt · merged via another change → #32013, #30768
  • #32387 fix(cli): keep destructive slash modal on Linux · other → #33241, #33165, #32383
  • #32657 fix: add timeout protection to /new and /clear commands (Issue #32383) · duplicate → #32387
  • #32808 fix(cli): keep slash confirmation modal on app loop · duplicate → #32387, #32013, #32383, #22958
  • #37237 fix(cli): restore cooked mode before input() on MSYS2/Git Bash to prevent deadlock · other → ab98818
  • #37297 fix(cli): restore cooked terminal mode on Windows/MSYS2 to prevent input() deadlock (#37230) · stale or abandoned → #37230, #36844
  • #75084 fix(cli): stop /reload-mcp from blocking the command processor (supersedes #39446) · done elsewhere → 972aa33
Closed issues · 20
  • Closed#21348RuntimeWarning: coroutine 'run_in_terminal.<locals>.run' was never awaited during /reload-mcp
  • Closed#22241/reload-mcp confirmation prompt is broken — async coroutine never awaited
  • Closed#22958[Bug]: /clear, /new, /reset, /undo confirmation prompt cannot be answered — keystrokes leak into chat composer
  • Closed#22970fix(cli): RuntimeWarning in /new, /clear, /undo, /reload-mcp — coroutine was never awaited
  • Closed#23009bug: /reload-mcp triggers RuntimeWarning from unawaited run_in_terminal coroutine
  • Closed#23185run_in_terminal() coroutines not awaited — silent message loss (output) + broken input on WSL
  • Closed#23297[Bug]: RuntimeWarning “run_in_terminal... was never awaited” when confirming /clear or /new in CLI
  • Closed#23567[Bug]: When using the Hermes Agent in WSL, running the /clear command will cause the program to freeze.
  • Closed#23694[Bug]: Terminal hang when prompting for confirmation on destructive slash commands (/new, /clear) (windows beta)
  • Closed#23853/reload-mcp deadlock: _prompt_text_input input() blocks daemon thread in raw mode
  • Closed#23920[Bug]: '/new' confirmation freezes session
  • Closed#24343/new and /clear hang on confirmation prompt — daemon-thread input() competes with prompt_toolkit stdin
  • Closed#24701CLI /new can stall at destructive confirmation and repeat prior task
  • Closed#30768Bug: /reset and /new slash commands freeze on Windows — routes to interactive CLI menu
  • Closed#32207[Bug]: /clear command freezes or bugs the terminal
  • Closed#32383fix(cli): /new, /clear, /reset freeze permanently on Linux after fae815a (PR #30773)
  • Closed#33165[Bug]: macOS:When Hermes is connected to DeepSeek, using the /new command causes it to freeze completely.
  • Closed#33961[Bug]: /new, /clear, and /reset commands freeze the terminal session
  • Closed#34140[Bug]: CLI freezes after using /new command (WSL)
  • Closed#37230[Bug]: /new and /clear hang/freeze the Hermes CLI process on Windows 11 (Git Bash MSYS2)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.05: 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

Subsystem
hermes cli
Merged PRs
#23454, #23907, #31704, #32013, #33241
Closed issues
20
Attempts
19 PRs closed without merging
Fix link checked
27 May 2026

For your agent

The same facts, typed.