feat(busy): add 'steer' as a third display.busy_input_mode option (#16279)

Enter while the agent is busy can now inject the typed text via /steer —
arriving at the agent after the next tool call — instead of interrupting
(current default) or queueing for the next turn.

Changes:
- cli.py: keybinding honors busy_input_mode='steer' by calling
  agent.steer(text) on the UI thread (thread-safe), with automatic
  fallback to 'queue' when the agent is missing, steer() is unavailable,
  images are attached, or steer() rejects the payload. /busy accepts
  'steer' as a fourth argument alongside queue/interrupt/status.
- gateway/run.py: busy-message handler and the PRIORITY running-agent
  path both route through running_agent.steer() when the mode is 'steer',
  with the same fallback-to-queue safety net. Ack wording tells users
  their message was steered into the current run. Restart-drain queueing
  now also activates for 'steer' so messages aren't lost across restarts.
- agent/onboarding.py: first-touch hint has a steer branch for both
  CLI and gateway.
- hermes_cli/commands.py: /busy args_hint updated to include steer,
  and 'steer' is registered as a subcommand (completions).
- hermes_cli/web_server.py: dashboard select widget offers steer.
- hermes_cli/config.py, cli-config.yaml.example, hermes_cli/tips.py:
  inline docs updated.
- website/docs/user-guide/cli.md + messaging/index.md: documented.
- Tests: steer set/status path for /busy; onboarding hints;
  _load_busy_input_mode accepts steer; busy-session ack exercises
  steer success + two fallback-to-queue branches.

Requested on X by @CodingAcct.

Default is unchanged (interrupt).
This commit is contained in:
Teknium
2026-04-26 18:21:29 -07:00
committed by GitHub
parent 87477756fd
commit 635253b918
14 changed files with 308 additions and 38 deletions

View File

@ -43,10 +43,18 @@ def busy_input_hint_gateway(mode: str) -> str:
"Send `/busy interrupt` to make new messages stop the current task "
"immediately, or `/busy status` to check. This notice won't appear again."
)
if mode == "steer":
return (
"💡 First-time tip — I steered your message into the current run; "
"it will arrive after the next tool call instead of interrupting. "
"Send `/busy interrupt` or `/busy queue` to change this, or "
"`/busy status` to check. This notice won't appear again."
)
return (
"💡 First-time tip — I just interrupted my current task to answer you. "
"Send `/busy queue` to queue follow-ups for after the current task instead, "
"or `/busy status` to check. This notice won't appear again."
"`/busy steer` to inject them mid-run without interrupting, or "
"`/busy status` to check. This notice won't appear again."
)
@ -55,13 +63,19 @@ def busy_input_hint_cli(mode: str) -> str:
if mode == "queue":
return (
"(tip) Your message was queued for the next turn. "
"Use /busy interrupt to make Enter stop the current run instead. "
"This tip only shows once."
"Use /busy interrupt to make Enter stop the current run instead, "
"or /busy steer to inject mid-run. This tip only shows once."
)
if mode == "steer":
return (
"(tip) Your message was steered into the current run; it arrives "
"after the next tool call. Use /busy interrupt or /busy queue to "
"change this. This tip only shows once."
)
return (
"(tip) Your message interrupted the current run. "
"Use /busy queue to queue messages for the next turn instead. "
"This tip only shows once."
"Use /busy queue to queue messages for the next turn instead, "
"or /busy steer to inject mid-run. This tip only shows once."
)

View File

@ -847,8 +847,12 @@ display:
# What Enter does when Hermes is already busy (CLI and gateway platforms).
# interrupt: Interrupt the current run and redirect Hermes (default)
# queue: Queue your message for the next turn
# steer: Inject your message mid-run via /steer, arriving at the agent
# after the next tool call — no interrupt, no role violation.
# Falls back to 'queue' if the agent isn't running yet or if
# images are attached (steer only carries text).
# Ctrl+C (or /stop in gateway) always interrupts regardless of this setting.
# Toggle at runtime with /busy_input_mode <interrupt|queue>.
# Toggle at runtime with /busy <interrupt|queue|steer>.
busy_input_mode: interrupt
# Background process notifications (gateway/messaging only).

61
cli.py
View File

@ -1848,9 +1848,16 @@ class HermesCLI:
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
# show_reasoning: display model thinking/reasoning before the response
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
# busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn)
_bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
# busy_input_mode: "interrupt" (Enter interrupts current run),
# "queue" (Enter queues for next turn), or "steer" (Enter injects
# mid-run via /steer, arriving after the next tool call).
_bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower()
if _bim == "queue":
self.busy_input_mode = "queue"
elif _bim == "steer":
self.busy_input_mode = "steer"
else:
self.busy_input_mode = "interrupt"
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
@ -6816,24 +6823,36 @@ class HermesCLI:
/busy Show current busy input mode
/busy status Show current busy input mode
/busy queue Queue input for the next turn instead of interrupting
/busy steer Inject Enter mid-run via /steer (after next tool call)
/busy interrupt Interrupt the current run on Enter (default)
"""
parts = cmd.strip().split(maxsplit=1)
if len(parts) < 2 or parts[1].strip().lower() == "status":
_cprint(f" {_ACCENT}Busy input mode: {self.busy_input_mode}{_RST}")
_cprint(f" {_DIM}Enter while busy: {'queues for next turn' if self.busy_input_mode == 'queue' else 'interrupts current run'}{_RST}")
_cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}")
if self.busy_input_mode == "queue":
_behavior = "queues for next turn"
elif self.busy_input_mode == "steer":
_behavior = "steers into current run (after next tool call)"
else:
_behavior = "interrupts current run"
_cprint(f" {_DIM}Enter while busy: {_behavior}{_RST}")
_cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}")
return
arg = parts[1].strip().lower()
if arg not in {"queue", "interrupt"}:
if arg not in {"queue", "interrupt", "steer"}:
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
_cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}")
_cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}")
return
self.busy_input_mode = arg
if save_config_value("display.busy_input_mode", arg):
behavior = "Enter will queue follow-up input while Hermes is busy." if arg == "queue" else "Enter will interrupt the current run while Hermes is busy."
if arg == "queue":
behavior = "Enter will queue follow-up input while Hermes is busy."
elif arg == "steer":
behavior = "Enter will steer your message into the current run (after the next tool call)."
else:
behavior = "Enter will interrupt the current run while Hermes is busy."
_cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (saved to config){_RST}")
_cprint(f" {_DIM}{behavior}{_RST}")
else:
@ -9210,12 +9229,34 @@ class HermesCLI:
# Bundle text + images as a tuple when images are present
payload = (text, images) if images else text
if self._agent_running and not (text and _looks_like_slash_command(text)):
if self.busy_input_mode == "queue":
_effective_mode = self.busy_input_mode
if _effective_mode == "steer":
# Route Enter through /steer — inject mid-run after the
# next tool call. Images can't ride along (steer only
# appends text), so fall back to queue when images are
# attached. If the agent lacks steer() or rejects the
# payload, also fall back to queue so nothing is lost.
if images or not text:
_effective_mode = "queue"
else:
accepted = False
try:
if self.agent is not None and hasattr(self.agent, "steer"):
accepted = bool(self.agent.steer(text))
except Exception as exc:
_cprint(f" {_DIM}Steer failed ({exc}) — queued for next turn.{_RST}")
accepted = False
if accepted:
preview = text[:80] + ("..." if len(text) > 80 else "")
_cprint(f" {_ACCENT}⏩ Steered: '{preview}'{_RST}")
else:
_effective_mode = "queue"
if _effective_mode == "queue":
# Queue for the next turn instead of interrupting
self._pending_input.put(payload)
preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
_cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
else:
elif _effective_mode == "interrupt":
self._interrupt_queue.put(payload)
# Debug: log to file when message enters interrupt queue
try:

View File

@ -1212,7 +1212,10 @@ class GatewayRunner:
return "restarting" if self._restart_requested else "shutting down"
def _queue_during_drain_enabled(self) -> bool:
return self._restart_requested and self._busy_input_mode == "queue"
# Both "queue" and "steer" modes imply the user doesn't want messages
# to be lost during restart — queue them for the newly-spawned gateway
# process to pick up. "interrupt" mode drops them (current behaviour).
return self._restart_requested and self._busy_input_mode in ("queue", "steer")
# -------- /queue FIFO helpers --------------------------------------
# /queue must produce one full agent turn per invocation, in FIFO
@ -1513,7 +1516,11 @@ class GatewayRunner:
mode = str(cfg.get("display", {}).get("busy_input_mode", "") or "").strip().lower()
except Exception:
pass
return "queue" if mode == "queue" else "interrupt"
if mode == "queue":
return "queue"
if mode == "steer":
return "steer"
return "interrupt"
@staticmethod
def _load_restart_drain_timeout() -> float:
@ -1651,18 +1658,46 @@ class GatewayRunner:
if not adapter:
return False # let default path handle it
running_agent = self._running_agents.get(session_key)
# Steer mode: inject mid-run via running_agent.steer() instead of
# queueing + interrupting. If the agent isn't running yet
# (sentinel) or lacks steer(), or the payload is empty, fall back
# to queue semantics so nothing is lost.
effective_mode = self._busy_input_mode
steered = False
if effective_mode == "steer":
steer_text = (event.text or "").strip()
can_steer = (
steer_text
and running_agent is not None
and running_agent is not _AGENT_PENDING_SENTINEL
and hasattr(running_agent, "steer")
)
if can_steer:
try:
steered = bool(running_agent.steer(steer_text))
except Exception as exc:
logger.warning("Gateway steer failed for session %s: %s", session_key, exc)
steered = False
if not steered:
# Fall back to queue (merge into pending messages, no interrupt)
effective_mode = "queue"
# Store the message so it's processed as the next turn after the
# current run finishes (or is interrupted).
from gateway.platforms.base import merge_pending_message_event
merge_pending_message_event(adapter._pending_messages, session_key, event)
# current run finishes (or is interrupted). Skip this for a
# successful steer — the text already landed inside the run and
# must NOT also be replayed as a next-turn user message.
if not steered:
merge_pending_message_event(adapter._pending_messages, session_key, event)
is_queue_mode = self._busy_input_mode == "queue"
is_queue_mode = effective_mode == "queue"
is_steer_mode = effective_mode == "steer"
# If not in queue mode, interrupt the running agent immediately.
# If not in queue/steer mode, interrupt the running agent immediately.
# This aborts in-flight tool calls and causes the agent loop to exit
# at the next check point.
running_agent = self._running_agents.get(session_key)
if not is_queue_mode and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
if effective_mode == "interrupt" and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
try:
running_agent.interrupt(event.text)
except Exception:
@ -1699,7 +1734,12 @@ class GatewayRunner:
pass
status_detail = f" ({', '.join(status_parts)})" if status_parts else ""
if is_queue_mode:
if is_steer_mode:
message = (
f"⏩ Steered into current run{status_detail}. "
f"Your message arrives after the next tool call."
)
elif is_queue_mode:
message = (
f"⏳ Queued for the next turn{status_detail}. "
f"I'll respond once the current task finishes."
@ -1723,9 +1763,15 @@ class GatewayRunner:
)
_user_cfg = _load_gateway_config()
if not is_seen(_user_cfg, BUSY_INPUT_FLAG):
if is_steer_mode:
_hint_mode = "steer"
elif is_queue_mode:
_hint_mode = "queue"
else:
_hint_mode = "interrupt"
message = (
f"{message}\n\n"
f"{busy_input_hint_gateway('queue' if is_queue_mode else 'interrupt')}"
f"{busy_input_hint_gateway(_hint_mode)}"
)
mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG)
except Exception as _onb_err:
@ -3711,6 +3757,24 @@ class GatewayRunner:
logger.debug("PRIORITY queue follow-up for session %s", _quick_key)
self._queue_or_replace_pending_event(_quick_key, event)
return None
if self._busy_input_mode == "steer":
# Steer mode: inject text into the running agent mid-run via
# agent.steer(). Falls back to queue semantics if the payload
# is empty, the agent lacks steer(), or steer() rejects.
steer_text = (event.text or "").strip()
steered = False
if steer_text and hasattr(running_agent, "steer"):
try:
steered = bool(running_agent.steer(steer_text))
except Exception as exc:
logger.warning("PRIORITY steer failed for session %s: %s", _quick_key, exc)
steered = False
if steered:
logger.debug("PRIORITY steer for session %s", _quick_key)
return None
logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key)
self._queue_or_replace_pending_event(_quick_key, event)
return None
logger.debug("PRIORITY interrupt for session %s", _quick_key)
running_agent.interrupt(event.text)
if _quick_key in self._pending_messages:

View File

@ -126,8 +126,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
cli_only=True, args_hint="[queue|interrupt|status]",
subcommands=("queue", "interrupt", "status")),
cli_only=True, args_hint="[queue|steer|interrupt|status]",
subcommands=("queue", "steer", "interrupt", "status")),
# Tools & Skills
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",

View File

@ -627,7 +627,7 @@ DEFAULT_CONFIG = {
"compact": False,
"personality": "kawaii",
"resume_display": "full",
"busy_input_mode": "interrupt",
"busy_input_mode": "interrupt", # interrupt | queue | steer
"bell_on_complete": False,
"show_reasoning": False,
"streaming": False,

View File

@ -106,7 +106,7 @@ TIPS = [
"Set display.streaming: true to see tokens appear in real time as the model generates.",
"Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.",
"Set display.compact: true to reduce whitespace in output for denser information.",
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.",
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent, or steer to inject them mid-run via /steer.",
"Set display.resume_display: minimal to skip the full conversation recap on session resume.",
"Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).",
"Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.",

View File

@ -287,7 +287,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"display.busy_input_mode": {
"type": "select",
"description": "Input behavior while agent is running",
"options": ["interrupt", "queue"],
"options": ["interrupt", "queue", "steer"],
},
"memory.provider": {
"type": "select",

View File

@ -117,6 +117,12 @@ class TestHintMessages:
assert "/busy interrupt" in msg
assert "queued" in msg.lower()
def test_busy_input_hint_gateway_steer(self):
msg = busy_input_hint_gateway("steer")
assert "/busy interrupt" in msg
assert "/busy queue" in msg
assert "steer" in msg.lower()
def test_busy_input_hint_cli_interrupt(self):
msg = busy_input_hint_cli("interrupt")
assert "/busy queue" in msg
@ -125,6 +131,12 @@ class TestHintMessages:
msg = busy_input_hint_cli("queue")
assert "/busy interrupt" in msg
def test_busy_input_hint_cli_steer(self):
msg = busy_input_hint_cli("steer")
assert "/busy interrupt" in msg
assert "/busy queue" in msg
assert "steer" in msg.lower()
def test_tool_progress_hints_mention_verbose(self):
assert "/verbose" in tool_progress_hint_gateway()
assert "/verbose" in tool_progress_hint_cli()
@ -133,8 +145,10 @@ class TestHintMessages:
for hint in (
busy_input_hint_gateway("queue"),
busy_input_hint_gateway("interrupt"),
busy_input_hint_gateway("steer"),
busy_input_hint_cli("queue"),
busy_input_hint_cli("interrupt"),
busy_input_hint_cli("steer"),
tool_progress_hint_gateway(),
tool_progress_hint_cli(),
):

View File

@ -65,6 +65,35 @@ class TestHandleBusyCommand(unittest.TestCase):
self.assertEqual(stub.busy_input_mode, "interrupt")
mock_save.assert_called_once_with("display.busy_input_mode", "interrupt")
def test_steer_argument_sets_steer_mode_and_saves(self):
cli_mod = _import_cli()
stub = self._make_cli("interrupt")
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
):
cli_mod.HermesCLI._handle_busy_command(stub, "/busy steer")
self.assertEqual(stub.busy_input_mode, "steer")
mock_save.assert_called_once_with("display.busy_input_mode", "steer")
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("steer", printed.lower())
def test_status_reports_steer_behavior(self):
cli_mod = _import_cli()
stub = self._make_cli("steer")
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_busy_command(stub, "/busy status")
mock_save.assert_not_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("steer", printed.lower())
# The usage line should also advertise the steer option
self.assertIn("steer", printed)
def test_invalid_argument_prints_usage(self):
cli_mod = _import_cli()
stub = self._make_cli()
@ -90,5 +119,5 @@ class TestBusyCommandRegistry(unittest.TestCase):
from hermes_cli.commands import COMMAND_REGISTRY
busy = next(c for c in COMMAND_REGISTRY if c.name == "busy")
assert busy.args_hint == "[queue|interrupt|status]"
assert busy.args_hint == "[queue|steer|interrupt|status]"
assert busy.category == "Configuration"

View File

@ -186,6 +186,91 @@ class TestBusySessionAck:
assert "respond once the current task finishes" in content
assert "Interrupting" not in content
@pytest.mark.asyncio
async def test_steer_mode_calls_agent_steer_no_interrupt_no_queue(self):
"""busy_input_mode='steer' injects via agent.steer() and skips queueing."""
runner, sentinel = _make_runner()
runner._busy_input_mode = "steer"
adapter = _make_adapter()
event = _make_event(text="also check the tests")
sk = build_session_key(event.source)
runner.adapters[event.source.platform] = adapter
agent = MagicMock()
agent.steer = MagicMock(return_value=True)
runner._running_agents[sk] = agent
with patch("gateway.run.merge_pending_message_event") as mock_merge:
await runner._handle_active_session_busy_message(event, sk)
# VERIFY: Agent was steered, NOT interrupted
agent.steer.assert_called_once_with("also check the tests")
agent.interrupt.assert_not_called()
# VERIFY: No queueing — successful steer must NOT replay as next turn
mock_merge.assert_not_called()
# VERIFY: Ack mentions steer wording
adapter._send_with_retry.assert_called_once()
call_kwargs = adapter._send_with_retry.call_args
content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
assert "Steered" in content or "steer" in content.lower()
assert "Interrupting" not in content
@pytest.mark.asyncio
async def test_steer_mode_falls_back_to_queue_when_agent_rejects(self):
"""If agent.steer() returns False, fall back to queue behavior."""
runner, sentinel = _make_runner()
runner._busy_input_mode = "steer"
adapter = _make_adapter()
event = _make_event(text="empty or rejected")
sk = build_session_key(event.source)
runner.adapters[event.source.platform] = adapter
agent = MagicMock()
agent.steer = MagicMock(return_value=False) # rejected
runner._running_agents[sk] = agent
with patch("gateway.run.merge_pending_message_event") as mock_merge:
await runner._handle_active_session_busy_message(event, sk)
agent.steer.assert_called_once()
agent.interrupt.assert_not_called()
# Fell back to queue semantics: event was merged into pending messages
mock_merge.assert_called_once()
# Ack uses queue-mode wording (not steer, not interrupt)
call_kwargs = adapter._send_with_retry.call_args
content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
assert "Queued for the next turn" in content
assert "Steered" not in content
@pytest.mark.asyncio
async def test_steer_mode_falls_back_to_queue_when_agent_pending(self):
"""If agent is still starting (sentinel), steer mode falls back to queue."""
runner, sentinel = _make_runner()
runner._busy_input_mode = "steer"
adapter = _make_adapter()
event = _make_event(text="arrived too early")
sk = build_session_key(event.source)
runner.adapters[event.source.platform] = adapter
# Agent is still being set up — sentinel in place
runner._running_agents[sk] = sentinel
with patch("gateway.run.merge_pending_message_event") as mock_merge:
await runner._handle_active_session_busy_message(event, sk)
# Event was queued instead of steered
mock_merge.assert_called_once()
call_kwargs = adapter._send_with_retry.call_args
content = call_kwargs.kwargs.get("content") or call_kwargs[1].get("content", "")
assert "Queued for the next turn" in content
@pytest.mark.asyncio
async def test_debounce_suppresses_rapid_acks(self):
"""Second message within 30s should NOT send another ack."""

View File

@ -90,9 +90,21 @@ def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, mon
)
assert gateway_run.GatewayRunner._load_busy_input_mode() == "queue"
(tmp_path / "config.yaml").write_text(
"display:\n busy_input_mode: steer\n", encoding="utf-8"
)
assert gateway_run.GatewayRunner._load_busy_input_mode() == "steer"
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "interrupt")
assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "steer")
assert gateway_run.GatewayRunner._load_busy_input_mode() == "steer"
# Unknown values fall through to the safe default
monkeypatch.setenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "bogus")
assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
def test_load_restart_drain_timeout_prefers_env_then_config_then_default(
tmp_path, monkeypatch, caplog

View File

@ -225,19 +225,23 @@ The `display.busy_input_mode` config key controls what happens when you press En
|------|----------|
| `"interrupt"` (default) | Your message interrupts the current operation and is processed immediately |
| `"queue"` | Your message is silently queued and sent as the next turn after the agent finishes |
| `"steer"` | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call no interrupt, no new turn |
```yaml
# ~/.hermes/config.yaml
display:
busy_input_mode: "queue" # or "interrupt" (default)
busy_input_mode: "steer" # or "queue" or "interrupt" (default)
```
Queue mode is useful when you want to prepare follow-up messages without accidentally canceling in-flight work. Unknown values fall back to `"interrupt"`.
`"queue"` mode is useful when you want to prepare follow-up messages without accidentally canceling in-flight work. `"steer"` mode is useful when you want to redirect the agent mid-task without interrupting e.g. "actually, also check the tests" while it's still editing code. Unknown values fall back to `"interrupt"`.
`"steer"` has two automatic fallbacks: if the agent hasn't started yet, or if images are attached, the message falls back to `"queue"` behavior so nothing is lost.
You can also change it inside the CLI:
```text
/busy queue
/busy steer
/busy interrupt
/busy status
```

View File

@ -219,13 +219,16 @@ Send any message while the agent is working to interrupt it. Key behaviors:
- **Multiple messages are combined** — messages sent during interruption are joined into one prompt
- **`/stop` command** — interrupts without queuing a follow-up message
### Queue vs interrupt (busy-input mode)
### Queue vs interrupt vs steer (busy-input mode)
By default, messaging a busy agent interrupts it. To switch the whole install so follow-ups queue behind the current task instead, set:
By default, messaging a busy agent interrupts it. Two other modes are available:
- `queue` — follow-up messages wait and run as the next turn after the current task finishes.
- `steer` — follow-up messages are injected into the current run via `/steer`, arriving at the agent after the next tool call. No interrupt, no new turn. Falls back to `queue` behavior if the agent hasn't started yet.
```yaml
display:
busy_input_mode: queue # default: interrupt
busy_input_mode: steer # or queue, or interrupt (default)
```
The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob (`"💡 First-time tip — …"`). The reminder fires once per install — a flag under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again.