fix(cli): add inline --yes/now skip for destructive slash commands (#30768)

Issue #30768 reports that on native Windows PowerShell the destructive-slash
confirmation modal renders but never registers keypresses, leaving the user
unable to confirm or cancel /reset, /new, /clear, or /undo. The modal works
on macOS, Linux, and WSL; PR #23907 (merged May 11) replaced the
daemon-thread input() pattern with a prompt_toolkit-native keybinding modal
but the win32 input pipeline apparently doesn't dispatch keys to the
filter-conditioned handlers. The modal investigation is ongoing.

This change ships the immediate escape hatch: append `now`, `--yes`, or `-y`
to any destructive slash command to bypass the modal and run the action
immediately. Works on every platform without touching the broken Windows
code path.

  /reset now            -> reset, no modal
  /new --yes my-session -> new session titled "my-session", no modal
  /clear -y             -> clear, no modal
  /undo -y              -> undo, no modal

The default behavior (modal prompts when approvals.destructive_slash_confirm
is True) is unchanged for users who don't pass a skip token.

Implementation:

- New classmethod HermesCLI._split_destructive_skip(text) -> (remainder, skip)
  parses a destructive-slash command string, strips the leading "/cmd" word
  and any recognized skip tokens (case-insensitive exact match, not substring),
  and reports whether a skip was requested.
- HermesCLI._confirm_destructive_slash gains an optional cmd_original= arg.
  When the arg contains a skip token, it returns "once" immediately —
  before the gate check and before any modal rendering.
- The /clear, /new, /undo handlers in process_command pass cmd_original
  through. /new additionally uses _split_destructive_skip to strip skip
  tokens from the remaining text before deriving the session title, so
  "/new now My Session" yields title="My Session" (not "now My Session").

Tests:

- 7 new unit tests in tests/cli/test_destructive_slash_confirm.py covering
  the helper (recognized tokens, command-word stripping, case-insensitive
  exact match, None/empty input) and the modal bypass (now and --yes both
  skip; no-skip-token still consults the modal).
- 3 new integration tests in tests/cli/test_destructive_slash_inline_skip_e2e.py
  driving HermesCLI.process_command end-to-end and asserting (a) new_session
  is invoked, (b) the modal is never reached, (c) the skip token does not
  leak into the session title, and (d) the no-skip-token path still reaches
  the modal as a sanity check that we haven't accidentally short-circuited
  the normal flow.

All 31 tests across the destructive-slash test surface pass.

Docs:

- website/docs/reference/slash-commands.md documents the new flags both in
  the destructive-commands table and the dedicated approval section, with a
  link back to issue #30768 explaining why the escape hatch exists.
This commit is contained in:
Teknium 2026-05-24 15:28:15 -07:00
parent 99a7ecc335
commit 8e68426981
4 changed files with 318 additions and 4 deletions

69
cli.py
View file

@ -8115,6 +8115,7 @@ class HermesCLI:
"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)
@ -8239,12 +8240,16 @@ class HermesCLI:
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)
@ -8271,6 +8276,7 @@ class HermesCLI:
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()
@ -9922,7 +9928,49 @@ class HermesCLI:
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 = frozenset({"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 = cmd_text.strip().split()
if not tokens:
return "", False
# Drop leading "/cmd" word — callers pass the full command text.
if tokens[0].startswith("/"):
tokens = tokens[1:]
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
@ -9938,9 +9986,24 @@ class HermesCLI:
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()

View file

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

View file

@ -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"

View file

@ -36,7 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| Command | Description |
|---------|-------------|
| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. |
| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. Append `now`, `--yes`, or `-y` to skip the confirmation modal — e.g. `/reset now`, `/new --yes my-experiment`. |
| `/clear` | Clear screen and start a new session |
| `/history` | Show conversation history |
| `/save` | Save the current conversation |
@ -252,4 +252,6 @@ The CLI prompts before running slash commands that throw away unsaved session st
For each of these the CLI opens a three-choice modal: **Approve Once** (proceed this time), **Always Approve** (proceed and persist `approvals.destructive_slash_confirm: false` so future destructive commands run without prompting), or **Cancel**.
**Inline skip:** append `now`, `--yes`, or `-y` to bypass the modal for a single invocation — e.g. `/reset now`, `/new --yes my-session`, `/clear -y`, `/undo -y`. Useful when the modal doesn't render correctly on your terminal (see [issue #30768](https://github.com/NousResearch/hermes-agent/issues/30768) for native Windows PowerShell) or when scripting against the CLI.
Set `approvals.destructive_slash_confirm: false` in `~/.hermes/config.yaml` to disable the prompts globally; set it back to `true` to re-enable. See [Security — Destructive slash command confirmation](../user-guide/security.md#dangerous-command-approval) for context.