# NousResearch/hermes-agent · CLI slash commands freezing and blocking issues

## Open now

- 2 issues, 1 PR open on GitHub.
- #39418: /reload-mcp freezes the CLI terminal when triggered by user — session becomes unresponsive
- #66258: hermes-mcp-reload-bug-issue
- PR #39446: fix(cli): keep /reload-mcp off the input thread

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.

## TL;DR

- 47 member(s): 2 open issue(s), 20 closed issue(s), 1 open PR(s), 5 merged PR(s), 19 PR(s) closed without merging.
- A fix was merged to main in PR #23454. Verify it addresses your case.
- **Recommended:** read PR #23454 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-23907,pr-31704,pr-33241`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Open related issues

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

## Closed / duplicate issues

- #21348: RuntimeWarning: coroutine 'run_in_terminal.<locals>.run' was never awaited during /reload-mcp
- #22241: /reload-mcp confirmation prompt is broken — async coroutine never awaited
- #22958: \[Bug\]: /clear, /new, /reset, /undo confirmation prompt cannot be answered — keystrokes leak into chat composer
- #22970: fix(cli): RuntimeWarning in /new, /clear, /undo, /reload-mcp — coroutine was never awaited
- #23009: bug: /reload-mcp triggers RuntimeWarning from unawaited run_in_terminal coroutine
- #23185: run_in_terminal() coroutines not awaited — silent message loss (output) + broken input on WSL
- #23297: \[Bug\]: RuntimeWarning “run_in_terminal... was never awaited” when confirming /clear or /new in CLI
- #23567: \[Bug\]: When using the Hermes Agent in WSL, running the /clear command will cause the program to freeze.
- #23694: \[Bug\]: Terminal hang when prompting for confirmation on destructive slash commands (/new, /clear) (windows beta)
- #23853: /reload-mcp deadlock: _prompt_text_input input() blocks daemon thread in raw mode
- #23920: \[Bug\]: '/new' confirmation freezes session
- #24343: /new and /clear hang on confirmation prompt — daemon-thread input() competes with prompt_toolkit stdin
- #24701: CLI /new can stall at destructive confirmation and repeat prior task
- #30768: Bug: /reset and /new slash commands freeze on Windows — routes to interactive CLI menu
- #32207: \[Bug\]: /clear command freezes or bugs the terminal
- #32383: fix(cli): /new, /clear, /reset freeze permanently on Linux after fae815a (PR #30773)
- #33165: \[Bug\]: macOS：When Hermes is connected to DeepSeek, using the /new command causes it to freeze completely.
- #33961: \[Bug\]:  /new, /clear, and /reset commands freeze the terminal session
- #34140: \[Bug\]:  CLI freezes after using /new command (WSL)
- #37230: \[Bug\]: /new and /clear hang/freeze the Hermes CLI process on Windows 11 (Git Bash MSYS2)

## Prior & competing PRs

- **#22972: fix(cli): prevent RuntimeWarning in _prompt_text_input from background thread**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#22989: fix(cli): use prompt_toolkit.prompt() for TUI confirmation prompts (Fixes #22958)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#22958`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#23454: fix(cli): drive _prompt_text_input directly when off main thread (#23185)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `tests/cli/test_prompt_text_input_thread_safety.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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():
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `scripts/release.py`, `tests/cli/test_destructive_slash_confirm.py`, `tests/cli/test_prompt_text_input_thread_safety.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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 = ""
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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 ''
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -6,6 +6,7 @@
     
     from __future__ import annotations
     
    +import queue
     from types import SimpleNamespace
     from unittest.mock import patch
     
    ```

    ```diff
    @@ -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():
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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 = {}
    ```
- **#24336: fix(cli): schedule /new /clear /undo confirm prompt on PT event loop from daemon thread**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#22958`, `#22970`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#31704: fix(cli): add inline --yes/now skip for destructive slash commands (#30768)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 |
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `scripts/release.py`, `tests/cli/test_slash_confirm_windows.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `tests/cli/test_slash_confirm_windows.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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."""
    ```

    ```diff
    @@ -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:
    ```
- **#37237: fix(cli): restore cooked mode before input() on MSYS2/Git Bash to prevent deadlock**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `ab98818e5bf3d721d01f1b1899f60ceaeab2a2e9`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#23078: fix(cli): add main-thread guard to _prompt_text_input — prevent RuntimeWarning (#22970)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#22972`
- **#23257: fix(cli): avoid run_in_terminal from worker threads**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#23317: fix(cli): suppress RuntimeWarning from unawaited run_in_terminal in prompt_toolkit 3.0.52+**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#23454`, `#23454`
- **#23348: fix(cli): use prompt_toolkit prompt for confirmation input**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#22989`, `#22958`
- **#23571: fix: use TUI modal for slash confirmations**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `054f56857`, `#23907`, `#23694`, `#23853`
- **#23749: fix(cli): schedule destructive prompts on app loop**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#23907`, `#23571`, `#23694`
- **#23886: fix(cli): route destructive slash confirmations through main thread e…**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#23907`, `#23571`, `#23694`, `#23853`
- **#24722: fix(cli): defer destructive slash resets until active turns exit**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `458a94e42568b332e8794ca8fbb8c8e1279160a3`, `#24701`, `#36833`
- **#26617: fix(cli): await run_in_terminal futures to prevent RuntimeWarning**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `c5f1f863acd49c92d9fbe92550115d51dd5181d0`, `365da2d2dfd92bd16f1993c8fefea05508282db8`, `#23454`, `#23302`
- **#30773: fix(cli): prevent /reset and /new freeze on Windows by falling back to stdin prompt**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#32013`, `#30768`
- **#32387: fix(cli): keep destructive slash modal on Linux**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#33241`, `#33165`, `#32383`
- **#32657: fix: add timeout protection to /new and /clear commands (Issue #32383)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#32387`
- **#32808: fix(cli): keep slash confirmation modal on app loop**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#32387`, `#32013`, `#32383`, `#32387`, `#32383`, `#22958`, `#32013`
- **#37297: fix(cli): restore cooked terminal mode on Windows/MSYS2 to prevent input() deadlock (#37230)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#37230`, `#36844`
- **#39446: fix(cli): keep /reload-mcp off the input thread**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `972aa33d376e8cb308466192ee545c879459fd0d`
  - files: `cli.py`, `tests/cli/test_cli_loading_indicator.py`
- **#75084: fix(cli): stop /reload-mcp from blocking the command processor (supersedes #39446)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `972aa33d37`

## Fix status

- Merged to main in PR #23454 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-27`.
- Subsystem: `hermes_cli`
