Files
hermes-agent/hermes_cli/cli_commands_mixin.py
Hermes Agent ec5c9c738a fix(processes): persist_on_release keeps background jobs alive across lifecycle kill sweeps (#41225)
Background processes spawned with terminal(background=true) are killed from
three agent-lifecycle sweeps: agent release()'s kill_all, a gateway turn
timeout's kill_started_since, and agent close's owned-process loop. Jobs the
user explicitly wants to outlive the session (overnight batches, watchful
daemons) had no way to opt out.

Add terminal(background=true, persist_on_release=true):
- ProcessSession.persist_on_release, stamped by spawn_local/spawn_via_env,
  carried in crash-recovery checkpoints and exposed via list_sessions()
- kill_all skips persisted sessions only for lifecycle sources
  (_LIFECYCLE_KILL_SOURCES: kill_all, gateway_turn_timeout, agent_close);
  explicit operator stops (process_manage kill, /stop slash + RPC mirror,
  CLI /stop) now pass distinct sources so they still reach persisted jobs
- the agent_close owned-process loop in _close_task_resources skips
  persisted sessions the same way
- gateway shutdown keeps killing persisted jobs (source=gateway_shutdown):
  the host is going away and survivors would become PPID=1 orphans

Co-authored-by: salvaged from #109846 (persist_on_release plumbing) and
extended to the turn-timeout and agent_close paths.
2026-09-25 13:49:31 -05:00

2755 lines
146 KiB
Python

"""Slash-command handlers for the interactive CLI (``HermesCLI`` inherits ``CLICommandsMixin``).
cli.py-internal symbols (``_cprint``/``_ACCENT``/``save_config_value``…) are imported LAZILY inside
the helpers/handlers via ``from cli import ...`` — cli.py imports this module (cycle otherwise).
"""
from __future__ import annotations
import argparse
import atexit
import importlib
import io
import json
import os
import shlex
import subprocess
import tempfile
import threading
import time
import uuid
from contextlib import redirect_stdout, suppress
from io import StringIO
from datetime import datetime
from urllib.parse import urlparse
from rich import box as rich_box
from rich.markup import escape as _escape
from rich.panel import Panel
from hermes_constants import display_hermes_home
from hermes_state_ids import new_session_id as mint_session_id
from agent.turn_context import extract_api_content_sidecar
from hermes_cli.cli_agent_setup_mixin import _retire_agent
from hermes_cli.browser_connect import (
DEFAULT_BROWSER_CDP_URL, discover_local_cdp_url, find_free_debug_port, is_browser_debug_ready,
launch_chrome_debug, local_port_in_use, manual_chrome_debug_command)
# Output helpers. Slash-command text is user-visible: every literal below is load-bearing.
def _cp(*lines: str) -> None:
"""``_cprint`` each line (lazy import: cli.py imports this module)."""
from cli import _cprint
for line in lines:
_cprint(line)
def _pr(*lines: str) -> None:
"""print() each line."""
for line in lines:
print(line)
def _save(key: str, value) -> bool:
"""cli.save_config_value, resolved lazily (cli.py imports this module)."""
from cli import save_config_value
return save_config_value(key, value)
def _dim(text: str) -> str:
"""Wrap ``text`` in the dim ANSI escape."""
from cli import _DIM, _RST
return f"{_DIM}{text}{_RST}"
def _dim_line(text: str) -> str:
"""Two-space indented dim line (the standard slash-command hint shape)."""
return " " + _dim(text)
def _accent(text: str) -> str:
"""Wrap ``text`` in the skin accent ANSI escape."""
from cli import _ACCENT, _RST
return f"{_ACCENT}{text}{_RST}"
def _accent_line(text: str) -> str:
"""Two-space indented accent line (the standard slash-command headline shape)."""
return f" {_accent(text)}"
def _probe(module: str, name: str, default, *args):
"""``<module>.<name>(*args)`` or ``default`` when the import or the call fails (optional
subsystems: browser backends, async delegations, wake word, ...)."""
try:
return getattr(importlib.import_module(module), name)(*args)
except Exception:
return default
class _TTYBuf(StringIO):
"""StringIO that claims to be a TTY so ``hermes_cli.colors.color()`` still emits ANSI escapes."""
def isatty(self) -> bool:
return True
_FAILED = object()
def _attempt(label: str, errors, fn, *args, **kwargs):
"""``fn(*args, **kwargs)``, or ``_FAILED`` after printing `` <label>: <exc>`` for ``errors``."""
try:
return fn(*args, **kwargs)
except errors as exc:
_cp(f" {label}: {exc}")
return _FAILED
def _say_block(*lines: str) -> None:
"""print() the lines framed by a blank line above and below (the /browser output style)."""
_pr("", *lines, "")
def _command_arg(cmd: str, *, lower: bool = False) -> str:
"""Everything after the slash-command word, stripped (optionally lowercased)."""
parts = (cmd or "").strip().split(None, 1)
arg = parts[1].strip() if len(parts) > 1 else ""
return arg.lower() if lower else arg
def _shlex_args(cmd: str) -> list:
"""Tokens after the command word; falls back to whitespace split on unbalanced quotes."""
try:
return shlex.split(cmd)[1:] if cmd else []
except ValueError:
return (cmd or "").split()[1:]
def _take_flag(parts: list, flag: str):
"""Pop ``flag VALUE`` out of ``parts``: ``(rest, value, ok)``; ok=False when the value is
missing (caller prints usage)."""
if flag not in parts:
return parts, None, True
idx = parts.index(flag)
if idx + 1 >= len(parts):
return parts, None, False
return parts[:idx] + parts[idx + 2:], parts[idx + 1], True
def _summarize_paths(paths, limit: int = 5) -> str:
"""``a, b, c (+N more)`` for a list of paths."""
more = f" (+{len(paths) - limit} more)" if len(paths) > limit else ""
return ", ".join(paths[:limit]) + more
def _ellipsize(text: str, limit: int) -> str:
"""``text[:limit]`` plus ``...`` when truncated."""
return f"{text[:limit]}{'...' if len(text) > limit else ''}"
def _plural(n: int, word: str) -> str:
return f"{n} {word}{'s' if n != 1 else ''}"
# Small data tables.
# /cron flag tables: flag -> opts key. Order-sensitive in _parse_cron_flags: bool flags never
# consume a value; --repeat is int-validated separately.
_CRON_BOOL_FLAGS = {"--clear-skills": "clear_skills", "--all": "all"}
_CRON_LIST_FLAGS = {"--skill": "skills", "--add-skill": "add_skills", "--remove-skill": "remove_skills"}
_CRON_VALUE_FLAGS = {"--name": "name", "--deliver": "deliver", "--prompt": "prompt", "--schedule": "schedule"}
# /cron subcommand -> CLICommandsMixin method name.
_CRON_SUBCOMMANDS = {
"list": "_cron_list", "add": "_cron_add", "create": "_cron_add", "edit": "_cron_edit",
**{k: "_cron_job_action" for k in ("pause", "resume", "run", "remove", "rm", "delete")}}
_ON_WORDS = {"on", "enable", "true", "1"}
_OFF_WORDS = {"off", "disable", "false", "0"}
# /busy mode -> what Enter does while Hermes is working (status line / post-set explanation).
_BUSY_MODE_SHORT = {
"queue": "queues for next turn", "steer": "steers into current run (after next tool call)",
"interrupt": "redirects current run immediately"}
_BUSY_MODE_LONG = {
"queue": "Enter will queue follow-up input while Hermes is busy.",
"steer": "Enter will steer your message into the current run (after the next tool call).",
"interrupt": "Enter will redirect the current run while Hermes is busy; /stop still cancels it.",
}
# /fast argument -> (service_tier value, persisted config value)
_FAST_TIERS = {
"fast": ("priority", "fast"), "on": ("priority", "fast"), "normal": (None, "normal"),
"off": (None, "normal"), "auto": ("auto", "auto"), "cold": ("cold", "cold")}
# /reasoning display toggles: arg -> (attr, value, headline, follow-up note)
_REASONING_TOGGLES = {
**dict.fromkeys(("show", "on"), ("show_reasoning", True, "ON",
"Model thinking will be shown during and after each response.")),
**dict.fromkeys(("hide", "off"), ("show_reasoning", False, "OFF", "")),
**dict.fromkeys(("full", "all"), ("reasoning_full", True, "FULL",
"The post-response recap box will print complete thinking.")),
**dict.fromkeys(("clamp", "collapse", "short"), ("reasoning_full", False, "CLAMPED to 10 lines", "")),
}
# /bg AIAgent provider-routing kwargs -> HermesCLI attribute carrying the value.
_BG_PROVIDER_KWARGS = {
"providers_allowed": "_providers_only", "providers_ignored": "_providers_ignore",
"providers_order": "_providers_order", "provider_sort": "_provider_sort",
"provider_require_parameters": "_provider_require_params",
"provider_data_collection": "_provider_data_collection",
"openrouter_min_coding_score": "_openrouter_min_coding_score", "fallback_model": "_fallback_model"}
# /worktree subcommand -> CLICommandsMixin method name (all need a repo root).
_WORKTREE_SUBCOMMANDS = {
**dict.fromkeys(("prune", "gc", "clean"), "_worktree_prune"),
**dict.fromkeys(("list", "ls"), "_worktree_list"),
**dict.fromkeys(("new", "add", "create"), "_worktree_new")}
# Message fields copied verbatim onto a /branch row (plus role / tool_name / api_content).
_BRANCH_COPY_KEYS = ("content", "tool_calls", "tool_call_id", "reasoning", "reasoning_details",
"codex_reasoning_items", "codex_message_items", "timestamp")
_HATCH_PROGRESS = {"compose": " ┊ composing spritesheet…", "save": " ┊ saving…"}
# /diff argument -> mode (anything else is a path; --stat/stat is the stat flag).
_DIFF_MODES = {
"staged": "staged", "--staged": "staged", "cached": "staged", "--cached": "staged",
"all": "all", "--all": "all", "head": "all", "session": "session"}
_DIFF_LABELS = {"working": "Unstaged", "staged": "Staged", "all": "All (vs HEAD)"}
def _persist_display_choice(key: str, value: str, label: str, note: str) -> None:
"""Save a /busy-style choice to config and report saved vs session-only."""
if _save(key, value):
_cp(_accent_line(f"✓ {label} set to '{value}' (saved to config)"), _dim_line(note))
else:
_cp(_accent_line(f"✓ {label} set to '{value}' (session only)"))
def _split_scope_flags(raw: str):
"""``(arg, explicit_global)`` for /reasoning + /fast: session scope by default, ``--global``
persists to config.yaml, ``--session`` is an explicit no-op (parity with /model)."""
tokens = raw.strip().lower().split()
return " ".join(t for t in tokens if t not in ("--global", "--session")), "--global" in tokens
def _scope_outcome(explicit_global: bool, saved: bool) -> str:
"""Parenthetical tail for a scoped setting change."""
if saved:
return "(saved to config)"
if explicit_global:
return "(session only; config save failed)"
return "(this session — use --global to persist)"
def _toggle_target(arg: str, current: bool):
"""Resolve a ``/x [on|off|status]`` argument: "status" for a status query, a bool for the
new state (bare arg toggles), or None when the argument is unrecognized."""
if arg in {"status", "?"}:
return "status"
if arg in _ON_WORDS:
return True
if arg in _OFF_WORDS:
return False
if arg == "":
return not current
return None
def _cron_api(**kwargs) -> dict:
"""Call the cronjob model tool and decode its JSON reply."""
from tools.cronjob_tools import cronjob as cronjob_tool
return json.loads(cronjob_tool(**kwargs))
def _normalize_skills(values) -> list:
"""Strip, drop empties, and dedupe (order-preserving)."""
normalized = []
for value in values:
text = str(value or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _parse_cron_flags(tokens):
"""Parse /cron flags into an opts dict (None after printing an error for a bad --repeat)."""
opts = {
"name": None, "deliver": None, "repeat": None, "prompt": None, "schedule": None,
"skills": [], "add_skills": [], "remove_skills": [],
"clear_skills": False, "all": False, "positionals": []}
i = 0
while i < len(tokens):
token = tokens[i]
has_value = i + 1 < len(tokens)
if token in _CRON_BOOL_FLAGS:
opts[_CRON_BOOL_FLAGS[token]] = True
i += 1
elif token in _CRON_LIST_FLAGS and has_value:
opts[_CRON_LIST_FLAGS[token]].append(tokens[i + 1])
i += 2
elif token == "--repeat" and has_value:
try:
opts["repeat"] = int(tokens[i + 1])
except ValueError:
return print("(._.) --repeat must be an integer")
i += 2
elif token in _CRON_VALUE_FLAGS and has_value:
opts[_CRON_VALUE_FLAGS[token]] = tokens[i + 1]
i += 2
else:
opts["positionals"].append(token)
i += 1
return opts
# Session-switch plumbing shared by /resume and /branch.
def _end_current_session(cli, reason: str) -> None:
"""Flush un-persisted messages, then end the current session row with ``reason``.
Best-effort on both steps (the switch proceeds even if the DB write fails)."""
if cli.agent:
with suppress(Exception):
cli.agent._flush_messages_to_session_db(
cli.conversation_history, conversation_history=cli.conversation_history)
with suppress(Exception):
cli._session_db.end_session(cli.session_id, reason)
def _sync_agent_to_session(cli, session_id: str, *, parent_session_id: str, reason: str) -> None:
"""Point an already-built agent at ``session_id`` after a /resume or /branch switch: reset
per-session state, re-anchor the DB flush index, and notify memory providers with
reset=False (their state stays valid and just targets the new id; parent keeps lineage)."""
if not cli.agent:
return
cli.agent.session_id = session_id
cli.agent.reset_session_state()
if hasattr(cli.agent, "_last_flushed_db_idx"):
cli.agent._last_flushed_db_idx = len(cli.conversation_history)
if hasattr(cli.agent, "_todo_store"):
with suppress(Exception):
from tools.todo_tool import TodoStore
cli.agent._todo_store = TodoStore()
if hasattr(cli.agent, "_invalidate_system_prompt"):
cli.agent._invalidate_system_prompt()
with suppress(Exception):
_mm = getattr(cli.agent, "_memory_manager", None)
# Notify memory providers that session_id rotated to a fresh conversation. reset=True signals
# providers to flush accumulated per-session state (_session_turns, _turn_counter, _document_id).
# Fires BEFORE the plugin on_session_reset hook (shell hooks only see the new id; Python providers
# see the transition). See #6672. When the old session has history, end-of-session extraction
# (LLM-bound, seconds) and this switch are queued as ONE task on the memory manager's serialized
# worker — end strictly before switch, without blocking /new (#16454). With no history there is
# nothing to extract; switch inline as before.
# Notify memory providers that session_id rotated to a resumed session. reset=False — the provider's
# accumulated state is still valid; it just needs to target the new session_id for subsequent
# writes. See #6672.
# Notify memory providers that session_id forked to a new branch. reset=False — the branched session
# carries the transcript forward, so provider state tracks the lineage. parent_session_id links the
# branch back to the original. See #6672.
if _mm is not None:
_mm.on_session_switch(
session_id, parent_session_id=parent_session_id or "", reset=False, reason=reason)
def _without_session_meta(messages) -> list:
return [m for m in (messages or []) if m.get("role") != "session_meta"]
def _db_unavailable_line() -> str:
from hermes_state import format_session_db_unavailable
return f" {format_session_db_unavailable(details=True)}"
def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_note, console=None) -> None:
"""Print a worker-thread result (/bg, /btw, /login) into the scrollback: accent rules around
``header_lines``, then ``body`` in a skinned Rich panel (or ``empty_note``).
Forces a TUI refresh first so the spinner/status bar don't overlap the output."""
from cli import ChatConsole, _accent_hex, _maybe_remap_for_light_mode, _render_final_assistant_content
_refresh_tui_before_print(cli)
rich_console = console or ChatConsole()
rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
if console is None:
_cp(*header_lines)
else:
for line in header_lines:
console.print(line)
rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
if not body:
return _cp(empty_note) if console is None else console.print(empty_note)
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "☤ Hermes")
_resp_color = _maybe_remap_for_light_mode(_skin.get_color("response_border", "#CD7F32"))
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
except Exception:
label, _resp_color, _resp_text = "☤ Hermes", "#CD7F32", "#FFF8DC"
rich_console.print(Panel(
_render_final_assistant_content(body, mode=cli.final_response_markdown),
title=f"[{_resp_color} bold]{label} {title_suffix}[/]", title_align="left",
border_style=_resp_color, style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4),
width=cli._scrollback_box_width()))
def _refresh_tui_before_print(cli) -> None:
"""Invalidate the running TUI (brief pause for the redraw) then print a blank separator, so
worker-thread output doesn't overlap the spinner/status bar."""
if cli._app:
cli._app.invalidate()
time.sleep(0.05)
print()
# /browser sub-handlers.
def _print_lightpanda_engine_status() -> None:
"""``/browser status`` line(s) about ``browser.engine: lightpanda`` — silent unless set;
says whether it is in use or which higher-precedence setting shadows it."""
if not _probe("tools.browser_tool_lightpanda_fallback", "_using_lightpanda_engine", False):
return
used, reason = _probe("tools.browser_tool_lightpanda_fallback", "lightpanda_engine_status", (None, None))
if reason is None:
return
if not used:
return print(f" ⚠ browser.engine is 'lightpanda' but it is NOT in use: {reason}")
print(f" Engine: Lightpanda — {reason} (no screenshots)")
try:
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
lightpanda_bin = find_lightpanda_binary()
except Exception:
return
print(f" Binary: {lightpanda_bin}" if lightpanda_bin
else f" ⚠ lightpanda binary not found — {LIGHTPANDA_INSTALL_HINT}")
def _browser_use(cli, arg: str) -> None:
"""/browser use [off] — toggle Browser Use mode (browser.backend); resets the session."""
from hermes_cli.config import load_config, save_config
from tools.registry import invalidate_check_fn_cache
if arg not in {"on", "off"}:
return _say_block(
"Usage: /browser use [off]",
" /browser use — switch to Browser Use mode (browser_exec via CLI 3.0)",
" /browser use off — revert to the built-in browser tools")
config = load_config()
if arg == "on":
config.setdefault("browser", {})["backend"] = "browser-use"
headline = "🌐 Browser Use mode enabled — browser_exec via the Browser Use CLI 3.0"
else:
from tools.browser_use_cli import BACKEND_DISABLED
config.setdefault("browser", {})["backend"] = BACKEND_DISABLED
headline = "🌐 Browser Use mode disabled — built-in browser tools restored"
save_config(config)
invalidate_check_fn_cache()
cli.new_session()
_say_block(headline, " Session reset. New tool configuration is active.")
def _normalize_cdp_url(cdp_url: str):
"""Validate a /browser connect URL: ``(cdp_url, port)`` or None after printing the error.
A ``/devtools/browser/<id>`` path is kept verbatim; anything else is reduced to the origin."""
parsed = urlparse(cdp_url if "://" in cdp_url else f"http://{cdp_url}")
if parsed.scheme not in {"http", "https", "ws", "wss"}:
_say_block(
f" ⚠ Unsupported browser url scheme: {parsed.scheme or '(missing)'} "
"(expected one of: http, https, ws, wss)")
return None
try:
port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80)
except ValueError:
_say_block(f" ⚠ Invalid port in browser url: {cdp_url}")
return None
if not parsed.hostname:
_say_block(f" ⚠ Missing host in browser url: {cdp_url}")
return None
if parsed.path.startswith("/devtools/browser/"):
return parsed.geturl(), port
return parsed._replace(path="", params="", query="", fragment="").geturl(), port
def _launch_default_cdp_browser(port: int):
"""Launch a local debug browser for the default CDP URL; returns the discovered CDP url or
None (after printing what went wrong / how to launch manually)."""
import platform as _plat
launch_port = port
if local_port_in_use(port):
launch_port = find_free_debug_port(port)
_pr(f" ⚠ Port {port} is occupied by another application that isn't a CDP browser",
f" (an IDE debugger or dev server may be using it) — launching on port {launch_port} instead...")
else:
print(" Chromium-family browser isn't running with remote debugging — attempting to launch...")
launch = launch_chrome_debug(launch_port, _plat.system())
if not launch.launched:
print(" ⚠ Could not auto-launch a Chromium-family browser")
if launch.hint:
print(f" {launch.hint}")
chrome_cmd = manual_chrome_debug_command(launch_port, _plat.system())
if chrome_cmd:
_pr(" Launch a Chromium-family browser manually:", f" {chrome_cmd}")
else:
print(" No supported Chromium-family browser executable found in this environment")
return None
for _wait in range(10): # wait for the DevTools discovery endpoint to come up
found = discover_local_cdp_url(launch_port, timeout=1.0)
if found:
print(f" ✓ Chromium-family browser launched and listening on port {launch_port}")
return found
time.sleep(0.5)
_pr(f" ⚠ Browser launched but port {launch_port} isn't responding yet",
" Try again in a few seconds — the debug instance may still be starting")
return None
def _browser_connect(cli, cdp_url: str) -> None:
"""/browser connect [url] — validate the CDP URL, find or launch a debug browser, then
point the browser tools at it (BROWSER_CDP_URL) and tell the model."""
normalized = _normalize_cdp_url(cdp_url)
if normalized is None:
return
cdp_url, port = normalized
# Clear any existing browser sessions so the next tool call uses the new backend
_probe("tools.browser_tool_lifecycle", "cleanup_all_browsers", None)
print()
# Already serving CDP? For the default-local URL probe both loopbacks: a squatter
# on 127.0.0.1:<port> (e.g. an IDE debugger) can push the browser to bind [::1] only.
is_default = cdp_url == DEFAULT_BROWSER_CDP_URL
if is_default:
found = discover_local_cdp_url(port, timeout=1.0)
else:
found = cdp_url if is_browser_debug_ready(cdp_url, timeout=1.0) else None
if found:
print(f" ✓ Chromium-family browser is already listening at {found}")
elif is_default:
found = _launch_default_cdp_browser(port)
else:
print(f" ⚠ Port {port} is not reachable at {cdp_url}")
if not found:
return _say_block("Browser not connected — start a Chromium-family browser with remote "
"debugging and retry /browser connect")
os.environ["BROWSER_CDP_URL"] = found
# Eagerly start the CDP supervisor so pending_dialogs + frame_tree show up in the next snapshot.
_probe("tools.browser_tool_cdp", "_ensure_cdp_supervisor", None, "default")
_say_block("🌐 Browser connected to live Chromium-family browser via CDP", f" Endpoint: {found}")
# Tell the model the CDP browser was made available on purpose.
if hasattr(cli, '_pending_input'):
cli._pending_input.put(
"[System note: The user invoked /browser connect and connected your browser tools to "
"a Chromium-family dev/debug browser via Chrome DevTools Protocol. "
"Your browser_navigate, browser_snapshot, browser_click, and other browser tools now "
"control that CDP browser. The command itself is a signal that using browser tools for "
"their current browser-related request is expected; do not wait for separate permission "
"just because CDP is connected. This is typically a Hermes-managed isolated debug "
"profile, not the user's main everyday browser. It is still user-visible and may contain "
"pages, logged-in sessions, or cookies in that debug profile, so avoid destructive actions, "
"closing tabs, or navigating away unless the user's task calls for it.]")
def _browser_disconnect(cli) -> None:
if not os.environ.get("BROWSER_CDP_URL", "").strip():
return _say_block("Browser is not connected to a live Chromium-family browser "
"(already using default mode)")
os.environ.pop("BROWSER_CDP_URL", None)
with suppress(Exception):
from tools.browser_tool_lifecycle import cleanup_all_browsers
from tools.browser_tool_cdp import _stop_cdp_supervisor
_stop_cdp_supervisor("default")
cleanup_all_browsers()
_say_block("🌐 Browser disconnected from live Chromium-family browser",
" Browser tools reverted to default mode (local headless or cloud provider)")
if hasattr(cli, '_pending_input'):
cli._pending_input.put(
"[System note: The user has disconnected the browser tools from their live Chromium-family browser. "
"Browser tools are back to default mode (headless local browser or cloud provider).]")
# /browser status headline per local browser.engine value.
_LOCAL_ENGINE_LINES = {
"lightpanda": ("🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)",
" ⚡ Lightpanda: faster navigation, no screenshot support",
" Automatic Chromium fallback for screenshots and failed commands"),
"chrome": ("🌐 Browser: local headless Chromium (agent-browser --engine chrome)",),
"auto": ("🌐 Browser: local headless Chromium (agent-browser)",)}
def _browser_status() -> None:
current = os.environ.get("BROWSER_CDP_URL", "").strip()
print()
if _probe("tools.browser_use_cli", "is_browser_use_cli_mode", False):
_pr("🌐 Browser: Browser Use mode (browser_exec via the Browser Use CLI 3.0)",
" Local Chrome via CDP, or Browser Use cloud browsers")
_print_lightpanda_engine_status()
return _say_block(" /browser use off — revert to the built-in browser tools")
if current:
_pr("🌐 Browser: connected to live Chromium-family browser via CDP",
f" Endpoint: {current}")
_print_lightpanda_engine_status()
_port = 9222
with suppress(ValueError, IndexError):
_port = int(current.rsplit(":", 1)[-1].split("/")[0])
try:
import socket
socket.create_connection(("127.0.0.1", _port), timeout=1).close()
print(" Status: ✓ reachable")
except Exception:
print(" Status: ⚠ not reachable (browser may not be running)")
else:
provider = _probe("tools.browser_tool_cloud", "_get_cloud_provider", None)
if provider is not None:
print(f"🌐 Browser: {provider.display_name} (cloud)")
_print_lightpanda_engine_status()
else:
engine = _probe("tools.browser_tool_cloud", "_get_browser_engine", "auto")
_pr(*_LOCAL_ENGINE_LINES.get(engine, _LOCAL_ENGINE_LINES["auto"]))
if engine == "lightpanda":
_print_lightpanda_engine_status()
_say_block(" /browser connect — connect to your live Chromium-family browser",
" /browser disconnect — revert to default")
# /browser subcommand word → handler(cli, rest); ``rest`` is the raw (case-preserved)
# remainder of the line. Adding a subcommand is one row here plus a usage line.
_BROWSER_SUBCOMMANDS = {
"use": lambda cli, rest: _browser_use(cli, rest.lower() or "on"),
"connect": lambda cli, rest: _browser_connect(cli, rest or DEFAULT_BROWSER_CDP_URL),
"disconnect": lambda cli, rest: _browser_disconnect(cli),
"status": lambda cli, rest: _browser_status(),
}
class CLICommandsMixin:
"""Mixin holding the interactive-CLI slash-command handlers."""
# ---- /rollback ------------------------------------------------------------------------
def _checkpoint_manager(self, disabled_lines):
"""The agent's checkpoint manager, or None after printing why it is unavailable."""
if not hasattr(self, 'agent') or not self.agent:
return print(" No active agent session.")
mgr = self.agent._checkpoint_mgr
if not mgr.enabled:
return _pr(*disabled_lines)
return mgr
def _handle_rollback_command(self, command: str):
"""Handle /rollback [diff] <N> [<file>|--all] — list, diff, or restore checkpoints.
A restore also undoes the last chat turn; ``--all`` overwrites user hand-edits too."""
from tools.checkpoint_manager import format_checkpoint_list
mgr = self._checkpoint_manager((
" Checkpoints are not enabled.", " Enable with: hermes --checkpoints",
" Or in config.yaml: checkpoints: { enabled: true }"))
if mgr is None:
return
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
args = command.split()[1:]
# --all / --force: classic full restore, overwriting user edits too.
restore_all = any(a.lower() in ("--all", "--force") for a in args)
args = [a for a in args if a.lower() not in ("--all", "--force")]
if reason := mgr.unsupported_backend_reason(): # CLI: no session key, the "default" container
# Container-backed session: any host checkpoint listed here belongs to another tree,
# so diff/restore are refused; the list stays visible for local administration.
print(f" {reason}")
if args:
return
if not args:
# No checkpoints for this dir → cross-project view (writes may sit under the session cwd).
checkpoints = mgr.list_checkpoints(cwd)
if not checkpoints:
# List checkpoints — fall back to the cross-project view when the current directory has none
# (#10505, reapply of PR #10633 by @nightq). The Aug 2026 QA sweep hit this live: writes
# landed checkpoints under the session cwd (/tmp/qa-repo) while bare /rollback searched only
# TERMINAL_CWD's project and reported "No checkpoints found" despite fresh checkpoints
# existing.
all_checkpoints = mgr.list_all_checkpoints()
if all_checkpoints:
print(f" No checkpoints for {cwd} — showing all directories.")
return print(format_checkpoint_list(all_checkpoints, "all directories"))
return print(format_checkpoint_list(checkpoints, cwd))
is_diff = args[0].lower() == "diff"
if is_diff and len(args) < 2:
return print(" Usage: /rollback diff <N>")
checkpoints = mgr.list_checkpoints(cwd)
if not checkpoints:
return print(f" No checkpoints found for {cwd}")
target_hash = self._resolve_checkpoint_ref(args[1 if is_diff else 0], checkpoints)
if not target_hash:
return
if is_diff:
self._rollback_diff(mgr, cwd, target_hash)
else:
file_path = args[1] if len(args) > 1 else None
self._rollback_restore(mgr, cwd, target_hash, file_path, restore_all)
def _rollback_diff(self, mgr, cwd: str, target_hash: str) -> None:
result = mgr.diff(cwd, target_hash)
if not result["success"]:
return print(f" ❌ {result['error']}")
stat, diff = result.get("stat", ""), result.get("diff", "")
if not stat and not diff:
return print(" No changes since this checkpoint.")
if stat:
print(f"\n{stat}")
if diff:
# Limit diff output to avoid terminal flood
diff_lines = diff.splitlines()
if len(diff_lines) > 80:
_pr("\n".join(diff_lines[:80]),
f"\n ... ({len(diff_lines) - 80} more lines, showing first 80)")
else:
print(f"\n{diff}")
def _rollback_restore(self, mgr, cwd, target_hash: str, file_path, restore_all: bool) -> None:
result = mgr.restore(cwd, target_hash, file_path=file_path,
safe=not restore_all and not file_path)
if not result["success"]:
return print(f" ❌ {result['error']}")
what = f"{file_path} from checkpoint" if file_path else "to checkpoint"
print(f" ✅ Restored {what} {result['restored_to']}: {result['reason']}")
skipped = result.get("skipped_user_edits") or []
if skipped:
_pr(f" ↷ Kept your hand-edits: {_summarize_paths(skipped)}",
" Use /rollback <N> --all to restore those too.")
oversize = result.get("skipped_oversize") or []
if oversize:
print(" ↷ Kept (too large for checkpoints, no stored copy to revert to): "
f"{_summarize_paths(oversize)}")
failed = result.get("failed_deletes") or []
if failed:
print(f" ⚠️ Could not remove (left in place): {_summarize_paths(failed)}")
print(" A pre-rollback snapshot was saved automatically.")
# Also undo the last conversation turn so the agent's context matches the restored files.
if self.conversation_history:
self.undo_last(prefill=False)
print(" Chat turn undone to match restored file state.")
# ---- /diff ----------------------------------------------------------------------------
def _handle_diff_command(self, command: str):
"""Handle /diff [working|staged|all|session] [--stat] [<path>...] — git changes in the
cwd; ``session`` is everything Hermes changed since the checkpoint baseline."""
stat_only = False
mode = "working"
paths: list[str] = []
for arg in _shlex_args(command): # shlex preserves quoted paths
low = arg.lower()
if low in ("--stat", "stat"):
stat_only = True
elif low in _DIFF_MODES:
mode = _DIFF_MODES[low]
else:
paths.append(arg)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
if mode == "session":
return self._print_session_diff(cwd, stat_only)
from tools.working_diff import collect_working_diff
result = collect_working_diff(cwd, mode=mode, paths=paths or None)
if not result.get("success"):
return print(f" {result.get('error', 'Could not generate diff')}")
stat, diff = result.get("stat", ""), result.get("diff", "")
untracked = result.get("untracked", [])
if result.get("empty") or (not stat and not diff and not untracked):
return print(" No changes.")
if stat:
print(f"\n {_DIFF_LABELS[mode]}:")
self._print_diff_text(stat)
if untracked and mode in ("working", "all"):
_pr("\n Untracked:", *(f" + {rel}" for rel in untracked[:20]))
if len(untracked) > 20:
print(f" ... and {len(untracked) - 20} more")
if diff and not stat_only:
self._print_diff_body(diff, "run /diff --stat for a summary")
def _print_diff_body(self, diff: str, stat_hint: str, limit: int = 400) -> None:
"""Print a diff, capped at ``limit`` lines with a pointer to the --stat form."""
print("")
diff_lines = diff.splitlines()
if len(diff_lines) > limit:
self._print_diff_text("\n".join(diff_lines[:limit]))
print(f"\n ... ({len(diff_lines) - limit} more lines — {stat_hint})")
else:
self._print_diff_text(diff)
def _print_session_diff(self, cwd: str, stat_only: bool):
"""Print the cumulative checkpoint-baseline diff (/diff session)."""
mgr = self._checkpoint_manager((
" Checkpoints are not enabled, so there's no session baseline.",
" Enable with: hermes --checkpoints",
" Or in config.yaml: checkpoints: { enabled: true }",
" (Plain /diff still works — it uses git directly.)"))
if mgr is None:
return
if reason := mgr.unsupported_backend_reason(): # host baseline is not this session's tree
return print(f" {reason}")
result = mgr.session_diff(cwd)
if not result.get("success"):
return print(f" {result.get('error', 'Could not generate diff')}")
stat, diff = result.get("stat", ""), result.get("diff", "")
if result.get("empty") or (not stat and not diff):
return print(" No changes — Hermes hasn't edited any files here yet.")
if stat:
self._print_diff_text(f"\n{stat}")
if diff and not stat_only:
self._print_diff_body(diff, "run /diff session --stat for a summary")
def _print_diff_text(self, text: str) -> None:
"""Render diff/stat text with color when a rich console is present; plain print otherwise
(e.g. unit tests instantiating the mixin standalone)."""
console = getattr(self, "console", None)
if console is not None:
try:
from cli import _rich_text_from_ansi
console.print(_rich_text_from_ansi(text))
return
except Exception:
pass
print(text)
# ---- /snapshot ------------------------------------------------------------------------
def _handle_snapshot_command(self, command: str):
"""Handle /snapshot [list|create [label]|restore <id>|prune [N]] — state snapshots."""
parts = command.split()
subcmd = parts[1].lower() if len(parts) > 1 else "list"
handler = {
"list": self._snapshot_list, "ls": self._snapshot_list, "create": self._snapshot_create,
"restore": self._snapshot_restore, "rewind": self._snapshot_restore,
"prune": self._snapshot_prune}.get(subcmd)
if handler is None:
return _pr(f" Unknown subcommand: {subcmd}",
" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
handler(parts)
def _snapshot_list(self, parts) -> None:
from hermes_cli.backup import list_quick_snapshots
snaps = list_quick_snapshots()
if not snaps:
return _pr(" No state snapshots yet.", " Create one: /snapshot create [label]")
print(f" State snapshots ({display_hermes_home()}/state-snapshots/):\n")
_pr(f" {'#':>3} {'ID':<35} {'Files':>5} {'Size':>10} {'Label'}",
f" {'─'*3} {'─'*35} {'─'*5} {'─'*10} {'─'*20}")
for i, s in enumerate(snaps, 1):
size = s.get("total_size", 0)
size_str = (f"{size} B" if size < 1024 else f"{size / 1024:.0f} KB"
if size < 1024 * 1024 else f"{size / 1024 / 1024:.1f} MB")
label = s.get("label") or ""
print(f" {i:3} {s['id']:<35} {s.get('file_count', 0):>5} {size_str:>10} {label}")
def _snapshot_create(self, parts) -> None:
from hermes_cli.backup import create_quick_snapshot
snap_id = create_quick_snapshot(label=" ".join(parts[2:]) if len(parts) > 2 else None)
print(f" Snapshot created: {snap_id}" if snap_id else " No state files found to snapshot.")
def _snapshot_restore(self, parts) -> None:
from hermes_cli.backup import list_quick_snapshots, restore_quick_snapshot
if len(parts) < 3:
print(" Usage: /snapshot restore <snapshot-id>")
snaps = list_quick_snapshots(limit=1)
if snaps:
print(f" Most recent: {snaps[0]['id']}")
return
snap_id = parts[2]
try:
idx = int(snap_id) # restore by number (1-indexed)
except ValueError:
idx = None
if idx is not None:
snaps = list_quick_snapshots()
if not 1 <= idx <= len(snaps):
return print(f" Invalid snapshot number. Use 1-{len(snaps)}.")
snap_id = snaps[idx - 1]["id"]
# Close our SessionDB first so the restore doesn't contend with this process's live connection.
local_session_db = getattr(self, "_session_db", None)
if local_session_db is not None:
with suppress(Exception):
local_session_db.close()
self._session_db = None
if restore_quick_snapshot(snap_id):
_pr(f" Restored state from: {snap_id}",
" Restart recommended for gateway/dashboard processes to pick up state.db changes.")
else:
print(f" Snapshot not found: {snap_id}")
def _snapshot_prune(self, parts) -> None:
from hermes_cli.backup import prune_quick_snapshots
keep = 20
if len(parts) > 2:
try:
keep = int(parts[2])
except ValueError:
return print(" Usage: /snapshot prune [keep-count]")
deleted = prune_quick_snapshots(keep=keep)
print(f" Pruned {deleted} old snapshot(s) (keeping {keep}).")
# ---- /export, /import -----------------------------------------------------------------
def _handle_export_command(self, command: str):
"""Handle /export [profile] [-o path] — export a profile to a shareable .tar.gz archive."""
from hermes_cli.profiles import export_profile, get_active_profile_name, get_profile_export_path
parts, output, ok = _take_flag(command.split()[1:], "-o")
if not ok:
return print(" Usage: /export [profile] [-o output.tar.gz]")
name = parts[0] if parts else (get_active_profile_name() or "default")
try:
result = export_profile(name, output or str(get_profile_export_path(name)))
_pr(f" ✓ Exported '{name}' to {result}",
" Share it: the other user runs /import or `hermes profile import <archive>`.")
except (ValueError, FileNotFoundError, OSError) as e:
print(f" Error: {e}")
def _handle_import_command(self, command: str):
"""Handle /import <archive.tar.gz> [--name <name>] — import a shared profile archive as a
new profile."""
from hermes_cli.profiles import check_alias_collision, create_wrapper_script, import_profile
parts, name, ok = _take_flag(command.split()[1:], "--name")
if not ok or not parts:
return print(" Usage: /import <archive.tar.gz> [--name <name>]")
try:
profile_dir = import_profile(" ".join(parts), name=name) # paths may contain spaces
except (ValueError, FileExistsError, FileNotFoundError) as e:
return print(f" Error: {e}")
imported = profile_dir.name
print(f" ✓ Imported profile '{imported}' at {profile_dir}")
with suppress(Exception):
if not check_alias_collision(imported):
wrapper_path = create_wrapper_script(imported)
if wrapper_path:
print(f" Wrapper created: {wrapper_path}")
print(f" Use it: hermes -p {imported}")
# ---- /stop, /agents -------------------------------------------------------------------
def _handle_stop_command(self):
"""Handle /stop — kill all running background processes and background (async) delegations.
Separate from interrupt (stop the current turn), as in Codex.
See #14602.
"""
from tools.process_registry import process_registry
running = [p for p in process_registry.list_sessions() if p.get("status") == "running"]
# Background subagents live in their own registry, not the process registry.
n_async = _probe("tools.async_delegation", "active_count", 0)
if not running and not n_async:
return print(" No running background processes.")
if running:
print(f" Stopping {len(running)} background process(es)...")
print(f" ✅ Stopped {process_registry.kill_all(source='cli.stop')} process(es).")
if n_async:
from tools.async_delegation import interrupt_all
print(f" ✅ Interrupted {interrupt_all(reason='/stop')} background delegation(s).")
def _handle_agents_command(self):
"""Handle /agents — show background processes and agent status."""
from tools.process_registry import format_uptime_short, process_registry
processes = process_registry.list_sessions()
running = [p for p in processes if p.get("status") == "running"]
finished = [p for p in processes if p.get("status") != "running"]
_cp(f" Running processes: {len(running)}")
for p in running:
up = format_uptime_short(p.get("uptime_seconds", 0))
_cp(f" {p.get('session_id', '?')} · {up} · {p.get('command', '')[:80]}")
if finished:
_cp(f" Recently finished: {len(finished)}")
# Background (async) delegations — delegate_task(background=true)
delegations = _probe("tools.async_delegation", "list_async_delegations", [])
if delegations:
running_d = [d for d in delegations if d.get("status") in ("running", "stalling")]
_cp(f" Background delegations: {len(running_d)} running")
for d in delegations:
status = d.get("status", "?")
line = f" {d.get('delegation_id', '?')} · {status} · {(d.get('goal') or '')[:60]}"
# Live-status detail for in-flight delegations.
# See #51690.
if status == "stalling":
quiet = d.get("stalled_after_quiet_seconds")
if quiet is not None:
line += f" · no progress {quiet:.0f}s — interrupting"
elif status == "running":
quiet = d.get("seconds_since_progress")
if quiet is not None and quiet >= 60:
line += f" · quiet {quiet:.0f}s"
_cp(line)
for i, child in enumerate(d.get("children_activity") or []):
if not isinstance(child, dict):
continue
tool = child.get("current_tool")
doing = f"in {tool}" if tool else "between turns"
part = f" └ child {i + 1}: {child.get('api_calls', '?')} api calls · {doing}"
idle = child.get("seconds_since_activity")
if idle is not None:
part += f" · last activity {idle:.0f}s ago"
_cp(part)
agent_running = getattr(self, "_agent_running", False)
_cp(f" Agent: {'running' if agent_running else 'idle'}")
# ---- /journey, /paste, /copy, /image --------------------------------------------------
def _handle_journey_command(self, cmd_original: str) -> None:
"""Handle /journey — the learning timeline (see `hermes journey`). Read-only views render
Rich color that patch_stdout would swallow, so capture with forced ANSI and re-emit via
``_cprint``; ``delete``/``edit`` are interactive and keep real stdio."""
from hermes_cli.journey import register_cli
parser = argparse.ArgumentParser(prog="/journey", add_help=False)
register_cli(parser)
try:
args = parser.parse_args(shlex.split(cmd_original)[1:])
except SystemExit:
return
try:
if getattr(args, "journey_action", None) in ("delete", "edit"):
args.func(args)
return
args.force_color = True
buf = io.StringIO()
with redirect_stdout(buf):
args.func(args)
_cp(buf.getvalue().rstrip("\n"))
except Exception as exc:
_cp(f" /journey failed: {exc}")
def _handle_paste_command(self):
"""Handle /paste — explicitly check clipboard for an image.
This is the reliable fallback for terminals where BracketedPaste
doesn't fire for image-only clipboard content (e.g., VSCode terminal,
Windows Terminal with WSL2).
"""
from hermes_cli.clipboard import has_clipboard_image
if not has_clipboard_image():
_cp(_dim_line('(._.) No image found in clipboard'))
elif self._try_attach_clipboard_image():
_cp(f" 📎 Image #{len(self._attached_images)} attached from clipboard")
else:
_cp(_dim_line('(>_<) Clipboard has an image but extraction failed'))
def _handle_copy_command(self, cmd_original: str) -> None:
"""Handle /copy [number] — copy assistant output to clipboard."""
from cli import _assistant_copy_text
arg = _command_arg(cmd_original)
assistant = [m for m in self.conversation_history if m.get("role") == "assistant"]
if not assistant:
return _cp(" Nothing to copy yet.")
if arg:
try:
idx = int(arg) - 1
except ValueError:
return _cp(" Usage: /copy [number]")
if idx < 0 or idx >= len(assistant):
return _cp(f" Invalid response number. Use 1-{len(assistant)}.")
else: # latest response that has copyable text
idx = next((i for i in range(len(assistant) - 1, -1, -1)
if _assistant_copy_text(assistant[i].get("content"))), -1)
if idx < 0:
return _cp(" Nothing to copy in assistant responses yet.")
text = _assistant_copy_text(assistant[idx].get("content"))
if not text:
return _cp(" Nothing to copy in that assistant response.")
try:
from hermes_cli.clipboard import is_remote_shell_session, write_clipboard_text
# Over SSH native tools write the REMOTE clipboard; OSC 52 reaches the user's terminal.
# Locally, OSC 52 is the fallback when native tools are unavailable/fail (SSH/tmux).
if is_remote_shell_session() or not write_clipboard_text(text):
# Fixes #31528.
self._write_osc52_clipboard(text)
_cp(f" Copied assistant response #{idx + 1} via OSC 52 (terminal support required)")
else:
_cp(f" Copied assistant response #{idx + 1} to clipboard")
except Exception as e:
_cp(f" Clipboard copy failed: {e}")
def _handle_image_command(self, cmd_original: str):
"""Handle /image <path> — attach a local image file for the next prompt."""
from cli import _DIM, _IMAGE_EXTENSIONS, _RST, _cprint, _resolve_attachment_path, _split_path_input
raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
if not raw_args:
hint = "/path/to/image.png"
_cprint(f" {_DIM}Usage: /image <path> e.g. /image {hint}{_RST}")
return
path_token, _remainder = _split_path_input(raw_args)
image_path = _resolve_attachment_path(path_token)
if image_path is None:
return _cp(_dim_line(f'(>_<) File not found: {path_token}'))
if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
return _cp(_dim_line(f'(._.) Not a supported image file: {image_path.name}'))
self._attached_images.append(image_path)
_cp(f" 📎 Attached image: {image_path.name}")
if _remainder:
_cprint(f" {_DIM}Now type your prompt (or use --image in single-query mode): {_remainder}{_RST}")
# ---- /tools, /profile -----------------------------------------------------------------
def _handle_tools_command(self, cmd: str):
"""Handle /tools [list|disable|enable]. Bare shows the tool list; ``list`` shows per-toolset
status; disable/enable save to config and reset the session so the new tool set takes
effect cleanly (no prompt-cache breakage mid-conversation)."""
parts = _shlex_args(cmd)
subcommand = parts[0] if parts else ""
if subcommand not in {"list", "disable", "enable"}:
return self.show_tools()
if subcommand == "list":
return self._run_tools_config(tools_action="list", platform="cli")
names = parts[1:]
if not names:
return _pr(f"(._.) Usage: /tools {subcommand} <name> [name ...]",
f" Built-in toolset: /tools {subcommand} web",
f" MCP tool: /tools {subcommand} github:create_issue")
# Typing the command is consent. Do NOT use input() — it hangs in prompt_toolkit's loop.
verb = "Disabling" if subcommand == "disable" else "Enabling"
_cp(_accent(f"{verb} {', '.join(names)}..."))
self._run_tools_config(tools_action=subcommand, names=names, platform="cli")
from hermes_cli.tools_config import _get_platform_tools
from hermes_cli.config import load_config
self.enabled_toolsets = _get_platform_tools(load_config(), "cli")
self.new_session()
_cp(_dim("Session reset. New tool configuration is active."))
def _run_tools_config(self, **ns) -> None:
"""Run ``tools_disable_enable_command``. Inside the interactive TUI its ANSI print() output
is captured (isatty=True so colors still render) and re-emitted through _cprint so
patch_stdout's StdoutProxy doesn't garble the escapes; standalone/tests call straight through."""
from argparse import Namespace
from hermes_cli.tools_config import tools_disable_enable_command
if getattr(self, "_app", None) is None:
return tools_disable_enable_command(Namespace(**ns))
buf = _TTYBuf()
with redirect_stdout(buf):
tools_disable_enable_command(Namespace(**ns))
_cp(*buf.getvalue().splitlines())
def _handle_profile_command(self):
"""Display active profile name and home directory."""
from hermes_cli.slash_exec import CommandContext, execute_command
reply = execute_command("profile", CommandContext(surface="cli"))
_say_block(f" Profile: {reply.data['profile']}", f" Home: {reply.data['home']}")
# ---- /handoff -------------------------------------------------------------------------
_HANDOFF_PENDING_TIMEOUT = 60.0
_HANDOFF_RUNNING_TIMEOUT = 900.0 # full synthetic agent turn + delivery
_HANDOFF_HEARTBEAT_EVERY = 30.0
@staticmethod
def _handoff_keep(*lines: str) -> bool:
"""Print ``lines`` and keep the CLI session (the True verdict of /handoff)."""
_cp(*lines)
return True
def _handle_handoff_command(self, cmd_original: str) -> bool:
"""Handle ``/handoff <platform>`` — transfer this CLI session to a gateway platform.
Validate target → prepare session row → mark pending → block-poll (see ``_handoff_wait``).
Returns False only on ``completed`` (caller exits like /quit); True keeps the session."""
platform_name = _command_arg(cmd_original).lower()
if not platform_name:
return self._handoff_keep(
" Usage: /handoff <platform>",
" Hands the current session off to that platform's home channel.",
" The CLI session ends here; resume it later with /resume.")
home = self._handoff_validate_target(platform_name)
if home is None:
return True
session_title = self._handoff_prepare_session()
if session_title is None:
return True
if not self._session_db.request_handoff(self.session_id, platform_name):
return self._handoff_keep(
" Session is already in flight for handoff. Wait for it to settle, then retry.")
_cp(f" Queued handoff of '{session_title}' → {platform_name} (home: {home.name}).",
" Waiting for the gateway to pick it up...")
return self._handoff_wait(platform_name, session_title)
def _handoff_validate_target(self, platform_name: str):
"""Resolve the destination home channel via the live gateway config; None (after printing
the reason) when the platform is unknown, disabled, or has no home channel."""
try:
from gateway.config import load_gateway_config, Platform
except Exception as exc: # pragma: no cover — gateway pkg always shipped
return _cp(f" Could not load gateway config: {exc}")
try:
platform = Platform(platform_name)
except (ValueError, KeyError):
return _cp(f" Unknown platform '{platform_name}'.")
try:
gw_config = load_gateway_config()
except Exception as exc:
return _cp(f" Could not load gateway config: {exc}")
pcfg = gw_config.platforms.get(platform)
if not pcfg or not pcfg.enabled:
# Relay aliasing: a relay-fronted gateway has only a RELAY block yet /handoff discord
# is deliverable. UX pre-check only — the gateway watcher re-checks before dispatch.
relay_fronts = False
with suppress(Exception):
from gateway.relay import relay_platform_identities
relay_cfg = gw_config.platforms.get(Platform.RELAY)
if relay_cfg and relay_cfg.enabled:
relay_fronts = platform_name in {p for p, _ in relay_platform_identities()}
if not relay_fronts:
return _cp(f" Platform '{platform_name}' is not configured/enabled in the "
"gateway.")
home = gw_config.get_home_channel(platform)
if not home or not home.chat_id:
return _cp(f" No home channel configured for {platform_name}.",
" Set one with /sethome on the destination chat first.")
return home
def _handoff_prepare_session(self):
"""Refuse mid-turn, make sure a SessionDB handle + session row exist, and return the
display title (None after printing why the handoff cannot start)."""
# An in-flight agent run would race the gateway's switch_session and the synthetic turn.
if getattr(self, "_agent_running", False):
return _cp(" Agent is busy. Wait for the current turn to finish, then retry /handoff.")
if not self._session_db:
with suppress(Exception):
from hermes_state_registry import acquire
self._session_db = acquire()
if not self._session_db:
return _cp(_db_unavailable_line())
# Ensure the session row exists (an empty session has flushed nothing yet): the gateway
# needs a row to switch_session onto; set_session_title's INSERT OR IGNORE creates it.
try:
if not self._session_db.get_session(self.session_id):
self._session_db.set_session_title(self.session_id, f"handoff-{self.session_id[:8]}")
except Exception as exc:
return _cp(f" Could not ensure session row in state.db: {exc}")
session_title = ""
with suppress(Exception):
session_title = (self._session_db.get_session(self.session_id) or {}).get("title") or ""
return session_title or self.session_id[:8]
def _handoff_wait(self, platform_name: str, session_title: str) -> bool:
"""Two-phase 0.5s poll. PENDING (unclaimed): 60s, then CAS-fail the row so the user can
retry (a claim racing this instant wins). RUNNING (claimed): the gateway replays the
transcript via a synthetic turn (routinely >60s) — wait 15 min with heartbeats and on
timeout do NOT touch the row; failing it here was the split-brain bug."""
pending_deadline = time.time() + self._HANDOFF_PENDING_TIMEOUT
running_deadline = None
next_heartbeat = None
last_state = "pending"
while True:
try:
state_row = self._session_db.get_handoff_state(self.session_id)
except Exception:
state_row = None
current = (state_row or {}).get("state") or "pending"
if current != last_state:
if current == "running":
_cp(" Gateway picked it up; transferring...")
running_deadline = time.time() + self._HANDOFF_RUNNING_TIMEOUT
next_heartbeat = time.time() + self._HANDOFF_HEARTBEAT_EVERY
last_state = current
if current == "completed":
_cp("", f" ↻ Handoff complete. The session is now active on {platform_name}.",
f" Resume it on this CLI later with: /resume {session_title}", "")
# _run_cleanup must NOT finalize the row on exit: the gateway owns it now, and an
# end_reason set under it would drop the handoff leg from session history/search.
# See #88234.
from cli import _handed_off_session_ids
_handed_off_session_ids.add(self.session_id)
self._should_exit = True # same exit semantics as /quit
return False
if current == "failed":
err = (state_row or {}).get("error") or "unknown error"
return self._handoff_keep(
f" Handoff failed: {err}",
" Your CLI session is intact. Try /handoff again, or /resume on the platform manually.")
now = time.time()
if current == "pending":
if now >= pending_deadline:
break
else: # running
if next_heartbeat is not None and now >= next_heartbeat:
_cp(" Still transferring (the agent is replaying your session on the destination)...")
next_heartbeat = now + self._HANDOFF_HEARTBEAT_EVERY
if running_deadline is not None and now >= running_deadline:
# Do NOT fail the row: the gateway owns it (split-brain bug otherwise).
return self._handoff_keep(
" The gateway is taking unusually long to finish the transfer.",
f" Check {platform_name} — the session may still arrive there.",
" This CLI is no longer waiting. Avoid continuing this session here;",
" if nothing arrives, retry /handoff once the state settles.")
time.sleep(0.5)
try: # pending timed out: CAS-clear so the user can retry
self._session_db.fail_handoff(
self.session_id, "timed out waiting for gateway", only_states=("pending",))
except TypeError:
# Older SessionDB without only_states (mixed installs): legacy unconditional fail.
with suppress(Exception):
self._session_db.fail_handoff(self.session_id, "timed out waiting for gateway")
except Exception:
pass
return self._handoff_keep(
" Timed out waiting for the gateway. Is `hermes gateway` running?",
" Your CLI session is intact.")
# ---- /resume, /sessions, /branch ------------------------------------------------------
def _handle_resume_command(self, cmd_original: str) -> None:
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
if getattr(self, "_agent_running", False):
return _cp(" Agent is busy. Wait for the current turn to finish, then retry /resume.")
from cli import _sync_process_session_id
target = _command_arg(cmd_original)
# Users copy the help text's placeholder brackets/quotes verbatim (``/resume <abc123>``).
if len(target) >= 2 and target[0] + target[-1] in {"<>", "[]", '""', "''"}:
target = target[1:-1].strip()
if not target:
_cp(" Usage: /resume <number|session_id_or_title>")
if self._show_recent_sessions(reason="resume"):
# Arm a one-shot bare-number selection; must be the same list the table showed
# and the numbered branch resolves (all use _list_recent_sessions(limit=10)).
# Arm a one-shot pending-resume selection so the user can type just the number (`3`) on the
# next line instead of having to retype `/resume 3`. The list here must match the one shown
# by _show_recent_sessions and used for index resolution below — all three go through
# _list_recent_sessions(limit=10). See #34584.
self._pending_resume_sessions = self._list_recent_sessions(limit=10)
return
return _cp(" Tip: Use /history or `hermes sessions list` to find sessions.")
# Any explicit /resume <target> supersedes a previously-armed bare numbered prompt.
self._pending_resume_sessions = None
if not self._session_db:
return _cp(_db_unavailable_line())
resolved = self._resolve_resume_target(target)
if resolved is None:
return
target_id, session_meta = resolved
if target_id == self.session_id:
return _cp(" Already on that session.")
old_session_id = self.session_id
_end_current_session(self, "resumed_other")
self.session_id, self._resumed, self._pending_title = target_id, True, None
_sync_process_session_id(target_id)
# One lineage SELECT, two projections: model_history is alternation-repaired for live
# replay (heals a durable user;user once); display_history is verbatim (as startup --resume).
model_history, display_history = self._session_db.get_resume_conversations(target_id)
self.conversation_history = _without_session_meta(model_history)
self._resume_display_history = _without_session_meta(display_history)
with suppress(Exception): # re-open the target session so it's not marked as ended
self._session_db.reopen_session(target_id)
_sync_agent_to_session(self, target_id, parent_session_id=old_session_id, reason="resume")
title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else ""
from agent.context_compressor import is_user_originated_turn
# Count only user-originated turns: legacy compaction handoffs are durable role=user rows
# without display_kind.
msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)])
if self.conversation_history:
_cp(f" ↻ Resumed session {target_id}{title_part}"
f" ({_plural(msg_count, 'user message')}, {len(self.conversation_history)} total)")
self._display_resumed_history()
else:
_cp(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
# Same contract as startup --resume: retarget the tool cwd, restore the persisted YOLO
# bypass (approval session key changed) and the model/provider (else config default).
# Retarget the process + tool cwd to where the session was started, so a mid-chat /resume (and
# /sessions <id>, which delegates here) lands in the same directory as a startup `hermes
# -c`/`--resume`. The startup resume paths already call this; without it, the terminal/code-exec
# tools and relative-path resolution keep operating in the wrong repo. Idempotent and a no-op when
# the session recorded no cwd. See #38562.
self._restore_session_cwd(session_meta)
self._restore_session_yolo(session_meta)
self._restore_session_model(session_meta)
def _resolve_resume_target(self, target: str):
"""``(session_id, meta)`` for a numbered selection, title, or id; None after printing why
it could not be resolved. An empty compression-chain head redirects to the descendant
that actually holds the transcript."""
if target.isdigit():
sessions = self._list_recent_sessions(limit=10)
index = int(target)
if index < 1 or index > len(sessions):
return _cp(f" Resume index {index} is out of range.",
" Use /resume with no arguments to see available sessions.")
target_id = sessions[index - 1]["id"]
else:
from hermes_cli.main import _resolve_session_by_name_or_id
target_id = _resolve_session_by_name_or_id(target) or target
session_meta = self._session_db.get_session(target_id)
if not session_meta:
return _cp(f" Session not found: {target}",
" Use /sessions or `hermes sessions list` to see available sessions.")
try:
# If the target is the empty head of a compression chain, redirect to the descendant that
# actually holds the transcript. See #15000.
resolved_id = self._session_db.resolve_resume_session_id(target_id)
except Exception:
resolved_id = target_id
if resolved_id and resolved_id != target_id:
_cp(f" Session {target_id} was compressed into {resolved_id}; "
f"resuming the descendant with your transcript.")
target_id = resolved_id
session_meta = self._session_db.get_session(target_id) or session_meta
return target_id, session_meta
def _handle_sessions_command(self, cmd_original: str) -> None:
"""Handle /sessions [list|<id_or_title>] — bare/``list`` prints the recent-sessions table;
an explicit target delegates to /resume so both spellings behave identically."""
arg = _command_arg(cmd_original)
if arg and arg.lower() not in {"list", "ls", "browse"}:
self._handle_resume_command(f"/resume {arg}")
elif not self._session_db:
_cp(_db_unavailable_line())
elif not self._show_recent_sessions(reason="sessions"):
_cp(" (._.) No previous sessions yet.")
def _handle_branch_command(self, cmd_original: str) -> None:
"""Handle /branch [name] — fork the current session into a new independent copy of the
full history so a different approach can be explored without losing the original."""
# An in-flight agent run would flush through the rotating session identity: the branch
# ends the parent row and repoints agent.session_id (_sync_agent_to_session), so the
# turn's remaining messages land on the branch. Refuse mid-turn like /handoff does.
if getattr(self, "_agent_running", False):
return _cp(" Agent is busy. Wait for the current turn to finish, then retry /branch.")
from cli import _sync_process_session_id
if not self.conversation_history:
return _cp(" No conversation to branch — send a message first.")
if not self._session_db:
return _cp(_db_unavailable_line())
# CLI has no threads: always in place; strip the gateway's ``--here`` so it is never a title.
from gateway.slash_commands_branch_thread import parse_branch_args
_, branch_name = parse_branch_args(_command_arg(cmd_original))
now = datetime.now()
new_session_id = mint_session_id(now)
branch_title = branch_name or self._session_db.get_next_title_in_lineage(
self._session_db.get_session_title(self.session_id) or "branch")
parent_session_id = self.session_id
# Create the child BEFORE ending the parent: a failed create_session must leave the session the
# user is still on open, not ended with end_reason="branched" and no branch (#11030).
# The stable ``_branched_from`` marker keeps the branch visible in /resume + /sessions
# even after the parent is re-ended with a different end_reason.
# The child sends the parent's exact system prompt: a row without one makes the branch's first
# turn rebuild (re-probing the workspace), so the warm cache the copied transcript buys is
# lost at byte 0 whenever the repo moved since the parent's session start.
parent_prompt = getattr(self.agent, "_cached_system_prompt", None)
if not isinstance(parent_prompt, str) or not parent_prompt:
with suppress(Exception):
parent_prompt = (self._session_db.get_session(parent_session_id) or {}).get("system_prompt")
try:
self._session_db.create_session(
session_id=new_session_id, source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=self.model, parent_session_id=parent_session_id, system_prompt=parent_prompt or None,
model_config={"max_iterations": self.max_turns, "reasoning_config": self.reasoning_config,
"_branched_from": parent_session_id})
except Exception as e:
return _cp(f" Failed to create branch session: {e}")
_end_current_session(self, "branched")
# Best-effort chunked copy (a failed copy still yields a usable branch); the api_content
# sidecar lets the branch's first turn replay the parent's exact wire bytes (warm cache).
with suppress(Exception):
self._session_db.append_messages_batch(new_session_id, [
{"role": msg.get("role", "user"), "tool_name": msg.get("tool_name") or msg.get("name"),
"api_content": extract_api_content_sidecar(msg),
**{k: msg.get(k) for k in _BRANCH_COPY_KEYS}}
for msg in self.conversation_history], chunk_rows=500)
with suppress(Exception):
self._session_db.set_session_title(new_session_id, branch_title)
# Switch to the new session
self._transfer_session_yolo(self.session_id, new_session_id)
self.session_id, self.session_start, self._pending_title = new_session_id, now, None
self._resumed = True # Prevents auto-title generation
_sync_process_session_id(new_session_id)
if self.agent:
self.agent.session_start = now
_sync_agent_to_session(self, new_session_id, parent_session_id=parent_session_id, reason="branch")
msg_count = len([m for m in self.conversation_history if m.get("role") == "user"])
_cp(f" ⑂ Branched session \"{branch_title}\" ({_plural(msg_count, 'user message')})",
f" Original session: {parent_session_id}", f" Branch session: {new_session_id}")
# ---- /worktree ------------------------------------------------------------------------
def _handle_worktree_command(self, cmd_original: str) -> None:
"""Handle /worktree [new [name]|list|prune [--dry-run]] — isolated git worktrees.
``new`` moves this session into the tree (as ``hermes -w``: kept on exit only with
unpushed commits); ``prune`` never deletes tracked changes, unique commits, or in-use trees."""
import cli as _cli
parts = cmd_original.split(None, 2)
sub = parts[1].lower() if len(parts) > 1 else ""
rest = parts[2].strip() if len(parts) > 2 else ""
repo_root = _cli._git_repo_root()
if not sub or sub in {"status", "show"}:
active = _cli._active_worktree
if active:
_pr(f" Active worktree: {active['path']}", f" Branch: {active['branch']}")
else:
print(" No active worktree for this session.")
if repo_root:
_pr(" /worktree new [name] — create one and move this session into it",
" /worktree prune — reclaim stale trees and merged branches")
else:
print(" (not inside a git repository)")
return
handler = _WORKTREE_SUBCOMMANDS.get(sub)
if handler is None:
return _pr(f" Unknown /worktree subcommand: {sub}",
" Usage: /worktree [new [name] | list]")
if not repo_root:
print(" ❌ /worktree new requires being inside a git repository."
if handler == "_worktree_new" else " Not inside a git repository.")
return
getattr(self, handler)(repo_root, rest)
def _worktree_prune(self, repo_root: str, rest: str) -> None:
import cli as _cli
from hermes_cli import worktree_gc
rest = rest.lower()
dry_run = "--dry-run" in rest or "-n" in rest.split()
active = _cli._active_worktree
tree_records = worktree_gc.audit_worktrees(repo_root, with_sizes=False)
if active:
# Never reap the tree this session is sitting in, even if judged clean+merged.
active_path = str(active.get("path") or "")
tree_records = [record for record in tree_records if record.path != active_path]
actions = worktree_gc.reclaim_worktrees(repo_root, dry_run=dry_run, records=tree_records)
actions += worktree_gc.reclaim_branches(repo_root, dry_run=dry_run)
if actions:
_pr(*(f" {line}" for line in actions),
f" {len(actions)} action(s) {'planned' if dry_run else 'done'}.")
else:
print(" Nothing to reclaim — remaining trees/branches carry real work.")
kept = [r for r in tree_records
if r.verdict == "keep" and "kanban" not in r.reason and "in use" not in r.reason]
if kept:
_pr(f" Preserved {len(kept)} tree(s) with real work:",
*(f" {record.name}: {record.reason}" for record in kept))
def _worktree_list(self, repo_root: str, rest: str) -> None:
try:
result = subprocess.run(
["git", "worktree", "list"], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=10, cwd=repo_root)
out = result.stdout.strip() if result.returncode == 0 else ""
except Exception:
out = ""
_pr(*(f" {line}" for line in out.splitlines()) if out else (" Could not list worktrees.",))
def _worktree_new(self, repo_root: str, rest: str) -> None:
import cli as _cli
from hermes_cli.config import load_config
try:
sync_base = bool(load_config().get("worktree_sync", True))
except Exception:
sync_base = True
wt_info = _cli._setup_worktree(repo_root=repo_root, sync_base=sync_base, name=rest or None)
if not wt_info:
return # _setup_worktree already printed the failure
# Retarget the session's terminal/file tools at the new tree (as `hermes -w` does).
try:
os.chdir(wt_info["path"])
except OSError as e:
print(f" ⚠ Created worktree but could not enter it: {e}")
os.environ["TERMINAL_CWD"] = wt_info["path"]
# Same keep-if-unpushed cleanup as `hermes -w`. Only one tree is "active" per process;
# an earlier one keeps its own atexit registration (explicit info arg).
_cli._active_worktree = wt_info
atexit.register(_cli._cleanup_worktree, wt_info)
_pr(f" ✅ Worktree ready: {wt_info['path']}", f" Branch: {wt_info['branch']}",
" Terminal and file tools now operate in the worktree.")
# ---- /personality, /pet, /hatch -------------------------------------------------------
def _handle_personality_command(self, cmd: str):
"""Handle /personality [name] — list or set a predefined personality. All resolution and
persistence goes through hermes_cli.personality, the single owner of personality state."""
from hermes_cli.personality import (
describe_personality, normalize_personality_name, persist_personality, prompt_text,
resolve_personality)
personality_name = _command_arg(cmd)
if not personality_name:
try:
from hermes_cli.config import read_raw_config
current = normalize_personality_name(
(read_raw_config().get("display") or {}).get("personality", ""))
except Exception:
current = ""
_pr("", "+" + "-" * 50 + "+", "|" + " " * 12 + "(^o^)/ Personalities" + " " * 15 + "|",
"+" + "-" * 50 + "+", "",
f" {' *' if not current else ' '}{'none':<12} - (no personality overlay)")
for name, prompt in self.personalities.items():
marker = " *" if name == current else " "
print(f" {marker}{name:<12} - {describe_personality(prompt)}")
return _pr("", " Usage: /personality <name> (* = active)", "")
try:
name, personality_prompt = resolve_personality(personality_name, getattr(self, "config", None))
except ValueError:
print(f"(._.) Unknown personality: {personality_name.lower()}")
return print(f" Available: none, {', '.join(self.personalities.keys())}")
saved = persist_personality(name)
scope = "(saved to config)" if saved else "(session only)"
face = "(^_^)b" if saved else "(^_^)"
if not name:
# Neutral reset — fall back to the user-owned manual prompt.
try:
from hermes_cli.config import cfg_get, read_raw_config
self.system_prompt = prompt_text(
cfg_get(read_raw_config(), "agent", "system_prompt", default=""))
except Exception:
self.system_prompt = ""
_retire_agent(self) # Force re-init
_pr(f"{face} Personality cleared {scope}",
" No personality overlay — using base agent behavior.")
else:
self.system_prompt = personality_prompt
_retire_agent(self) # Force re-init
_pr(f"{face} Personality set to '{name}' {scope}",
f" \"{_ellipsize(personality_prompt, 60)}\"")
def _handle_pet_command(self, cmd: str):
"""Handle /pet [toggle|list|scale <n>|off|<slug>] — the petdex mascot. Writes
``display.pet.*`` to config; pet surfaces pick it up on their next poll."""
from agent.pet import store
from agent.pet.manifest import ManifestError
from hermes_cli.pets import (
_set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display)
arg = _command_arg(cmd)
low = arg.lower()
if not arg or low == "toggle":
enabled, name, err = toggle_pet_display()
print(f"(x_x) {err}" if err else f"(^_^)b {name} is out — it'll pop in shortly." if enabled
else f"(-_-)zzZ {name} put away." if name else "(-_-)zzZ Pet put away.")
elif low in ("list", "gallery", "browse", "all"):
print_pet_gallery()
elif low == "scale" or low.startswith("scale "):
value = arg[len("scale"):].strip()
if not value:
return print("(o_o) Usage: /pet scale <factor> (e.g. /pet scale 0.5)")
scale, err = set_pet_scale(value)
print(f"(x_x) {err}" if err else f"(^_^) Pet scale → {scale:g}.")
elif low == "off":
_set_enabled(False)
print("(-_-)zzZ Pet put away.")
else:
print(f"(o_o) Fetching '{arg}' from petdex…")
try:
pet = store.install_pet(arg)
except (store.PetStoreError, ManifestError) as exc:
return print(f"(x_x) Couldn't adopt '{arg}': {exc}")
_set_active(arg)
print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.")
def _handle_hatch_command(self, cmd: str):
"""Generate ("hatch") a new petdex pet from a description: base look, one animation row
per state, spritesheet, then adopt. Progress streams inline (~a minute of image calls).
The desktop app opens a richer overlay for this command instead."""
from agent.pet import store
from agent.pet.generate import orchestrate
from agent.pet.generate.imagegen import GenerationError
from hermes_cli.pets import _set_active
concept = _command_arg(cmd)
if not concept:
# prompt_toolkit owns stdin on this daemon thread — raw input() never renders and eats
# keystrokes; prefer the thread-aware helper (None when prompting isn't safe).
# Bare /hatch is dispatched from the process_loop daemon thread while prompt_toolkit owns stdin
# — a raw input() here types into a prompt that never renders and swallows the next keystrokes
# (same class as #23185; found in the Aug 2026 full-surface CLI QA sweep: bare /hatch left the
# session eating input until Ctrl+C). Route through the thread-aware prompt helper, which uses
# run_in_terminal on the main thread and cancels cleanly (None) when prompting isn't safe.
prompt_helper = getattr(self, "_prompt_text_input", None)
try:
concept = ((prompt_helper or input)("(o_o) Describe your pet: ") or "").strip()
except (EOFError, KeyboardInterrupt):
return print()
if not concept:
return print("(o_o) Usage: /hatch <description> (e.g. /hatch a tiny cyber fox)")
# A short, friendly display name from the first few words of the concept.
display_name = " ".join(w.capitalize() for w in concept.split()[:3])[:28].strip() or "Pet"
slug = store.slugify(display_name) or store.slugify(concept) or "pet"
print(f"(o_o) Designing '{concept}'… (a minute of image-model calls)")
try:
drafts = orchestrate.generate_base_drafts(concept, n=1)
except GenerationError as exc:
return print(f"(x_x) Couldn't generate a base look: {exc}")
if not drafts:
return print("(x_x) No base draft came back — try again.")
def _progress(event: str, detail: str) -> None:
if event == "row": # detail is "<state>:<done>:<total>"; show the state name.
print(f" ┊ drawing {detail.split(':', 1)[0]}…")
elif event in _HATCH_PROGRESS:
print(_HATCH_PROGRESS[event])
try:
result = orchestrate.hatch_pet(
base_image=drafts[0], slug=slug, display_name=display_name, concept=concept,
on_progress=_progress)
except GenerationError as exc:
return print(f"(x_x) Hatch failed: {exc}")
_set_active(result.slug)
print(f"(^_^)b {result.display_name} hatched and adopted — it'll pop in shortly!")
# ---- /cron ----------------------------------------------------------------------------
def _handle_cron_command(self, cmd: str):
"""Handle the /cron command to manage scheduled tasks."""
tokens = shlex.split(cmd)
if len(tokens) == 1:
return self._cron_overview()
subcommand = tokens[1].lower()
opts = _parse_cron_flags(tokens[2:])
if opts is None:
return
handler = _CRON_SUBCOMMANDS.get(subcommand)
if handler is None:
return _pr(f"(._.) Unknown cron command: {subcommand}",
" Available: list, add, edit, pause, resume, run, remove")
getattr(self, handler)(subcommand, opts)
def _cron_overview(self) -> None:
_pr("", "+" + "-" * 68 + "+", "|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|",
"+" + "-" * 68 + "+", "", " Commands:", " /cron list",
' /cron add "every 2h" "Check server status" [--skill blogwatcher]',
' /cron edit <job_id> --schedule "every 4h" --prompt "New task"',
" /cron edit <job_id> --skill blogwatcher --skill maps",
" /cron edit <job_id> --remove-skill blogwatcher",
" /cron edit <job_id> --clear-skills", " /cron pause <job_id>",
" /cron resume <job_id>", " /cron run <job_id>", " /cron remove <job_id>", "")
result = _cron_api(action="list")
jobs = result.get("jobs", []) if result.get("success") else []
if jobs:
from hermes_cli.cron import _next_run_row
_pr(" Current Jobs:", " " + "-" * 63)
for job in jobs:
print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {job.get('repeat', '?'):<8}")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
print(f" {job.get('prompt_preview', '')}")
if job.get("next_run_at"):
# A stamp parked past the scheduler grace must not read as upcoming (#114309).
label, value = _next_run_row(job)
print(f" {'Next' if label == 'Next run' else label}: {value}")
print()
else:
print(" No scheduled jobs. Use '/cron add' to create one.")
print()
def _cron_list(self, subcommand: str, opts: dict) -> None:
result = _cron_api(action="list", include_disabled=opts["all"])
jobs = result.get("jobs", []) if result.get("success") else []
if not jobs:
return print("(._.) No scheduled jobs.")
from hermes_cli.cron import _next_run_row
print()
_pr("Scheduled Jobs:", "-" * 80)
for job in jobs:
_pr(f" ID: {job['job_id']}", f" Name: {job['name']}",
f" State: {job.get('state', '?')}",
f" Schedule: {job['schedule']} ({job.get('repeat', '?')})",
" %s: %s" % _next_run_row(job) if job.get("next_run_at") else " Next run: N/A")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
print(f" Prompt: {job.get('prompt_preview', '')}")
if job.get("last_run_at"):
status = job.get("last_status") or "?"
# delivery_failed: the run succeeded but delivery didn't — the reason lives
# in last_delivery_error (last_error is None).
if status == "delivery_failed" and job.get("last_delivery_error"):
status = f"delivery_failed: {job['last_delivery_error']}"
elif status == "error" and job.get("last_error"):
status = f"error: {job['last_error']}"
print(f" Last run: {job['last_run_at']} ({status})")
print()
def _cron_add(self, subcommand: str, opts: dict) -> None:
positionals = opts["positionals"]
if not positionals:
return print("(._.) Usage: /cron add <schedule> <prompt>")
schedule = opts["schedule"] or positionals[0]
prompt = opts["prompt"] or " ".join(positionals[1:])
skills = _normalize_skills(opts["skills"])
if not prompt and not skills:
return print("(._.) Please provide a prompt or at least one skill")
result = _cron_api(
action="create", schedule=schedule, prompt=prompt or None, name=opts["name"],
deliver=opts["deliver"], repeat=opts["repeat"], skills=skills or None)
if not result.get("success"):
return print(f"(x_x) Failed to create job: {result.get('error')}")
_pr(f"(^_^)b Created job: {result['job_id']}", f" Schedule: {result['schedule']}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
print(f" Next run: {result['next_run_at']}")
def _cron_edit(self, subcommand: str, opts: dict) -> None:
from cron import get_job
positionals = opts["positionals"]
if not positionals:
return print("(._.) Usage: /cron edit <job_id> "
"[--schedule ...] [--prompt ...] [--skill ...]")
job_id = positionals[0]
existing = get_job(job_id)
if not existing:
return print(f"(._.) Job not found: {job_id}")
# Skill edit precedence: --clear-skills > --skill (replace) > --add/--remove (merge) > untouched.
final_skills = None
replacement_skills = _normalize_skills(opts["skills"])
add_skills = _normalize_skills(opts["add_skills"])
remove_skills = set(_normalize_skills(opts["remove_skills"]))
if opts["clear_skills"]:
final_skills = []
elif replacement_skills:
final_skills = replacement_skills
elif add_skills or remove_skills:
existing_skills = list(
existing.get("skills") or ([existing["skill"]] if existing.get("skill") else []))
final_skills = [skill for skill in existing_skills if skill not in remove_skills]
final_skills += [skill for skill in add_skills if skill not in final_skills]
result = _cron_api(
action="update", job_id=job_id, schedule=opts["schedule"], prompt=opts["prompt"],
name=opts["name"], deliver=opts["deliver"], repeat=opts["repeat"], skills=final_skills)
if not result.get("success"):
return print(f"(x_x) Failed to update job: {result.get('error')}")
job = result["job"]
_pr(f"(^_^)b Updated job: {job['job_id']}", f" Schedule: {job['schedule']}",
f" Skills: {', '.join(job['skills'])}" if job.get("skills") else " Skills: none")
def _cron_job_action(self, subcommand: str, opts: dict) -> None:
"""pause / resume / run / remove (aliases rm, delete) on one job id."""
positionals = opts["positionals"]
if not positionals:
return print(f"(._.) Usage: /cron {subcommand} <job_id>")
job_id = positionals[0]
action = "remove" if subcommand in {"remove", "rm", "delete"} else subcommand
result = _cron_api(action=action, job_id=job_id,
reason="paused from /cron" if action == "pause" else None)
if not result.get("success"):
return print(f"(x_x) Failed to {action} job: {result.get('error')}")
if action == "remove":
removed = result.get("removed_job", {})
return print(f"(^_^)b Removed job: {removed.get('name', job_id)} ({job_id})")
job = result["job"]
if action == "run" and job.get("execution_skipped"):
# A refused run-now (claim lost, paused, gone) must not read as accepted.
return print(f"(x_x) Did not run job: {job['name']} ({job_id})\n {job['execution_skipped']}")
verb = {"pause": "Paused", "resume": "Resumed", "run": "Triggered"}[action]
print(f"(^_^)b {verb} job: {job['name']} ({job_id})")
if action == "resume":
print(f" Next run: {job.get('next_run_at')}")
elif action == "run":
from hermes_cli.cron import _run_outcome
print(f" {_run_outcome(job)}")
# ---- delegating handlers: /suggestions, /blueprint, /curator, /kanban, /skills, /memory --
def _handle_suggestions_command(self, cmd: str):
"""Handle /suggestions — review/accept/dismiss suggested automations via the shared handler.
CLI origin is the local platform so an accepted job's "origin" delivery resolves to a home channel."""
args = " ".join(_shlex_args(cmd))
try:
from hermes_cli.suggestions_cmd import handle_suggestions_command
output = handle_suggestions_command(args)
except Exception as e:
output = f"Suggestions command failed: {e}"
self._console_print(output)
def _handle_blueprint_command(self, cmd: str):
"""Handle /blueprint — set up an automation from a blueprint template (shared handler).
Bare lists the catalog; ``<name>`` seeds the agent to ask for each value conversationally
(``agent_seed``, run as the next turn); ``<name> slot=val …`` creates the job directly."""
args = " ".join(shlex.quote(t) for t in _shlex_args(cmd))
try:
from hermes_cli.blueprint_cmd import handle_blueprint_command
result = handle_blueprint_command(args)
except Exception as e:
self._console_print(f"Cron blueprint command failed: {e}")
return
self._console_print(result.text)
seed = getattr(result, "agent_seed", None)
if seed:
# One-shot: the interactive loop picks this up right after the slash command
# returns and runs it as a normal agent turn.
self._pending_agent_seed = seed
def _handle_curator_command(self, cmd: str):
"""Handle /curator — delegates to hermes_cli.curator so the CLI and the `hermes curator`
subcommand share the same handler set."""
tokens = shlex.split(cmd)[1:] if cmd else []
try:
from hermes_cli.curator import cli_main
cli_main(tokens or ["status"])
except SystemExit:
pass # argparse exits on --help/errors; don't kill the interactive session
except Exception as exc:
print(f"(._.) curator: {exc}")
def _handle_kanban_command(self, cmd: str):
"""Handle /kanban — strip the leading ``/kanban`` and hand the rest to ``kanban.run_slash``."""
from hermes_cli.kanban import run_slash
rest = cmd.strip().lstrip("/")
if rest.startswith("kanban"):
rest = rest[len("kanban"):].lstrip()
try:
output = run_slash(rest)
except Exception as exc: # pragma: no cover - defensive
output = f"(._.) kanban error: {exc}"
if output:
print(output)
def _handle_skills_command(self, cmd: str):
"""Handle /skills slash command — delegates to hermes_cli.skills_hub, after intercepting the
write-approval review subcommands (pending/approve/reject/diff/mode)."""
from cli import ChatConsole
args = cmd.strip().split()[1:]
review_words = {"pending", "approve", "apply", "reject", "deny", "drop", "diff", "approval", "mode"}
if args and args[0].lower() in review_words:
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
out = handle_pending_subcommand(
wa.SKILLS, args, set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
)
if out is not None:
return print(out)
from hermes_cli.skills_hub import handle_skills_slash
handle_skills_slash(cmd, ChatConsole())
def _handle_memory_command(self, cmd: str):
"""Handle /memory slash command — pending review + approval-gate toggle."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
args = cmd.strip().split()[1:]
store = getattr(self.agent, "_memory_store", None) if getattr(self, "agent", None) else None
if store is None:
# No live agent store (e.g. Desktop GUI): use a fresh on-disk store, as the gateway
# does — same MEMORY/USER.md, same configured char limits.
# Apply against a freshly loaded on-disk store, mirroring the gateway path
# (gateway/slash_commands.py): it persists to the same MEMORY/USER.md and creates MEMORY.md on
# the first approved write. Without this the shared handler returns "memory store unavailable".
# See #46783.
from tools.memory_tool import load_on_disk_store
store = load_on_disk_store()
out = handle_pending_subcommand(
wa.MEMORY, args, memory_store=store,
set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled))
print(out if out is not None else
"Unknown /memory subcommand. Use: pending, approve <id>, reject <id>, approval <on|off>.")
def _save_write_approval(self, subsystem: str, enabled: bool):
"""Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
_save(f"{subsystem}.write_approval", bool(enabled))
# ---- prompt-queueing handlers: /learn, /plan, /init -----------------------------------
def _queue_prompt_turn(self, msg: str, command: str) -> None:
"""Inject ``msg`` onto the agent's input queue as the next normal user turn (the
/learn, /plan, /init pattern: no engine, no model-tool footprint, prompt-cache safe)."""
if hasattr(self, "_pending_input"):
self._pending_input.put(msg)
else: # pragma: no cover - defensive (no live input loop)
print(f" {command} needs an active chat session to run.")
def _handle_learn_command(self, cmd: str):
"""Handle /learn — distill a reusable skill from anything the user describes (a directory,
a URL, "what we just did", pasted notes). The live agent gathers the material with the
tools it already has and authors the skill via ``skill_manage``."""
from agent.learn_prompt import build_learn_prompt
user_request = _command_arg(cmd)
print("\n⚡ Learning a skill from what you described..." if user_request
else "\n⚡ Learning a skill from this conversation...")
self._queue_prompt_turn(build_learn_prompt(user_request), "/learn")
def _handle_plan_command(self, cmd: str):
"""Handle /plan — write a markdown implementation plan, no execution. The live agent
inspects the workspace read-only and saves the plan under ``.hermes/plans/``."""
from agent.plan_prompt import build_plan_prompt
task = _command_arg(cmd) # optional — empty infers the task from conversation context
print(f"\n📋 Planning: {_ellipsize(task, 80)}" if task
else "\n📋 Planning from this conversation's context...")
self._queue_prompt_turn(build_plan_prompt(task), "/plan")
def _handle_init_command(self, cmd: str):
"""Handle /init — generate or update AGENTS.md from a project scan performed by the
live agent with its own read-only tools."""
from hermes_cli.init_command import build_init_prompt_for_cwd
msg = build_init_prompt_for_cwd(extra=_command_arg(cmd)) # optional user emphasis
verb = "Updating" if "UPDATE the existing AGENTS.md" in msg else "Generating"
print(f"\n⚡ {verb} AGENTS.md from a project scan...")
self._queue_prompt_turn(msg, "/init")
# ---- side-session handlers: /bg, /btw -------------------------------------------------
def _handle_background_command(self, cmd: str):
"""Handle /bg <prompt> — run a prompt in a separate background session (its own AIAgent
on a thread); the result prints here without touching the active history."""
from cli import set_approval_callback, set_secret_capture_callback, set_sudo_password_callback
from run_agent import AIAgent
prompt = _command_arg(cmd)
if not prompt:
return _cp(" Usage: /bg <prompt>", " Example: /bg Summarize the top HN stories today",
" (For a side question about this conversation, use /btw <question>.)",
" The task runs in a separate session and results display here when done.")
self._background_task_counter += 1
task_num = self._background_task_counter
task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
if not self._ensure_runtime_credentials():
return _cp(" (>_<) Cannot start background task: no valid credentials.")
preview = _ellipsize(prompt, 60)
_cp(f" 🔄 Background task #{task_num} started: \"{preview}\"", f" Task ID: {task_id}",
" You can continue chatting — results will appear when done.\n")
turn_route = self._resolve_turn_agent_config(prompt)
runtime = turn_route["runtime"]
def produce():
from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
set_unlock_prompt_callback(self._vault_unlock_callback)
set_save_login_prompt_callback(self._vault_save_login_callback)
set_code_prompt_callback(self._vault_code_callback)
with suppress(Exception):
set_secret_capture_callback(self._secret_capture_callback)
try:
bg_agent = AIAgent(
model=turn_route["model"], acp_command=runtime.get("command"),
acp_args=runtime.get("args"), max_iterations=self.max_turns,
**{k: runtime.get(k) for k in ("api_key", "base_url", "provider", "api_mode",
"max_tokens")}, enabled_toolsets=self.enabled_toolsets,
quiet_mode=True, verbose_logging=False, session_id=task_id, platform="cli",
side_agent=True,
session_db=self._session_db, reasoning_config=self.reasoning_config,
service_tier=self.service_tier,
request_overrides=turn_route.get("request_overrides"),
**{kw: getattr(self, attr) for kw, attr in _BG_PROVIDER_KWARGS.items()})
# Silence raw spinner; route thinking through TUI widget when no foreground agent is active.
bg_agent._print_fn = lambda *_a, **_kw: None
def _bg_thinking(text: str) -> None:
# Concurrent bg tasks may race on _spinner_text; acceptable for best-effort UI.
if not self._agent_running:
self._spinner_text = text
if self._app:
# Display result in the CLI (thread-safe via patch_stdout). Force a TUI refresh
# first so spinner/status bar don't overlap with the output (fixes #2718).
# Same TUI refresh pattern as success path (#2718)
self._app.invalidate()
bg_agent.thinking_callback = _bg_thinking
try:
result = bg_agent.run_conversation(user_message=prompt, task_id=task_id)
response = result.get("final_response", "") if result else ""
if not response and result and result.get("error"):
response = f"Error: {result['error']}"
return response
finally:
# One agent per /bg task in a long-lived CLI process: close()
# is the owner boundary (memory shutdown, tool subprocesses,
# httpx clients); an unclosed side agent leaks all of them
# until the CLI exits (#50197).
with suppress(Exception):
bg_agent.close()
finally:
with suppress(Exception):
set_sudo_password_callback(None)
set_approval_callback(None)
set_secret_capture_callback(None)
set_unlock_prompt_callback(None)
set_save_login_prompt_callback(None)
set_code_prompt_callback(None)
def done():
self._background_tasks.pop(task_id, None)
if not self._agent_running: # clear spinner only if no foreground agent owns it
self._spinner_text = ""
thread = self._side_worker(
produce, name=f"bg-task-{task_id}", fail_label=f"Background task #{task_num}",
header_lines=[f" ✅ Background task #{task_num} complete", f" Prompt: \"{preview}\""],
title_suffix=f"(background #{task_num})", empty_note=" (No response generated)",
bell=True, on_done=done)
self._background_tasks[task_id] = thread
thread.start()
def _side_worker(self, produce, *, name, fail_label, header_lines, title_suffix, empty_note,
bell=False, on_done=None, console=None) -> threading.Thread:
"""Daemon thread for /bg, /btw and /login: ``produce()`` returns the body to print in a side-result
panel; failures print ``fail_label`` failed; the TUI is always re-invalidated afterwards."""
def run():
try:
body = produce()
_print_side_result_panel(self, header_lines=header_lines, body=body,
title_suffix=title_suffix, empty_note=empty_note,
console=console)
if bell:
self._ring_bell(context=f"{fail_label} complete")
except Exception as e:
_refresh_tui_before_print(self)
line = f" ❌ {fail_label} failed: {e}"
# Same console the caller captured, so a late failure can't splice into a later command.
if console is not None:
console.print(line, markup=False)
else:
_cp(line)
finally:
if on_done is not None:
on_done()
if self._app:
self._invalidate(min_interval=0)
return threading.Thread(target=run, daemon=True, name=name)
def _handle_login_command(self, cmd_original: str) -> None:
"""Start an in-chat sign-in without blocking the input loop while approval is pending."""
from hermes_cli import anon_auth
# Pin the output target now. Under the live TUI ``self.console`` writes straight to
# patch_stdout's StdoutProxy, which mangles Rich's escapes — there ``None`` keeps the
# panel on the ``_cprint`` path. Only the slash worker (``_app`` is None) swaps the console.
console = None if getattr(self, "_app", None) else getattr(self, "console", None)
_cp(f" {anon_auth.LOGIN_STARTING}")
gen = anon_auth.run_sign_in(timeout_seconds=8.0)
try:
first = next(gen, None)
except KeyboardInterrupt:
with suppress(Exception):
gen.close()
return _cp(anon_auth.UPGRADE_CANCELLED)
if first is None:
return
if first.terminal:
return _cp(f" {first.copy}")
anon_auth.render_sign_in_cli_code(first, chat=True, printer=_cp)
def _settle_session_model(state) -> None:
"""A completed sign-in moved this profile onto the account: the welcome host is gone and
the portal serves ``nous/welcome`` as a paid model, so a session still carrying it must
move too — the CLI counterpart of the gateway's on-``Completed`` sweep. Only the free
tier's own model is replaced; a model the user picked while the sign-in was pending
stands. Writing ``self.model`` is enough: ``chat()`` compares the turn-route signature
and rebuilds the agent on the next turn, so a turn already in flight keeps the agent it
started with. ``getattr``: tests drive this handler with minimal shells.
"""
if state.kind != "completed" or not getattr(state, "model_changed", False):
return
if str(getattr(self, "model", "") or "") == anon_auth.GUEST_MODEL:
# "" when the settle cleared the default: _ensure_runtime_credentials then applies
# the provider's silent default, which is what settle_after_upgrade documents.
self.model = state.model or ""
thread = self._side_worker(
lambda: anon_auth.drain_sign_in_copy(gen, chat=True, on_terminal=_settle_session_model),
name="login", fail_label="Sign-in", header_lines=[" Sign-in"],
title_suffix="(sign-in)", empty_note=" (No result)", console=console)
thread.start()
def _handle_btw_command(self, cmd: str):
"""Handle /btw <question> — answer a side question about this conversation from a
history snapshot via a one-shot auxiliary call. The live session is never touched
(no history mutation, no role-alternation risk, no cache invalidation)."""
question = _command_arg(cmd)
if not question:
return _cp(" Usage: /btw <question>", " Example: /btw which file was that error in?",
" Answers a quick question about this conversation without interrupting it.",
" (For an independent background task, use /bg <prompt>.)")
if not self._ensure_runtime_credentials():
return _cp(" (>_<) Cannot answer side question: no valid credentials.")
# Snapshot NOW, on the UI thread — the foreground turn keeps appending to
# conversation_history while the worker runs.
history_snapshot = list(self.conversation_history or [])
# Live agent → cache-parity fork (full context, warm cache reads).
parent_agent = self.agent
turn_route = self._resolve_turn_agent_config(question)
runtime = turn_route["runtime"]
main_runtime = {
"model": turn_route["model"],
**{k: runtime.get(k) for k in ("provider", "base_url", "api_key", "api_mode")},
"session_id": getattr(parent_agent, "session_id", None),
}
preview = _ellipsize(question, 60)
_cp(f" 💬 Side question: \"{preview}\"",
" Answering from a snapshot of this conversation — the current work continues.\n")
def produce():
from agent.side_question import answer_side_question
return answer_side_question(
question, history_snapshot, parent_agent=parent_agent, main_runtime=main_runtime)
self._side_worker(produce, name="btw-side-question", fail_label="/btw",
header_lines=[f" 💬 /btw: \"{preview}\""], title_suffix="(btw)",
empty_note=" (No answer generated)").start()
# ---- /bundles, /browser ---------------------------------------------------------------
def _handle_bundles_command(self, cmd: str) -> None:
"""In-session ``/bundles`` — show installed skill bundles (``hermes bundles list`` rendered
inside the running CLI). Bundles are loaded via ``/<bundle-name>``."""
from cli import ChatConsole, _BOLD, _RST, _accent_hex
from hermes_cli.slash_exec import CommandContext, execute_command
reply = execute_command("bundles", CommandContext(surface="cli"))
if "error" in reply.data:
return _cp(f"\033[1;31mBundle subsystem unavailable: {reply.data['error']}{_RST}")
bundles = reply.data["bundles"]
if not bundles:
return _cp(" No skill bundles installed.",
_dim_line('Create one with: hermes bundles create <name> --skill <s1> --skill <s2>'),
_dim_line(f"Directory: {reply.data['dir']}"))
_cp(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(bundles)} installed):")
for info in bundles:
skill_count = len(info.get("skills", []))
desc = info.get("description") or f"Load {skill_count} skills"
ChatConsole().print(
f" [bold {_accent_hex()}]/{info['slug']:<20}[/] "
f"[dim]-[/] {_escape(desc)} [dim]({skill_count} skills)[/]")
for s in info.get("skills", []):
ChatConsole().print(f" [dim]· {_escape(s)}[/]")
_cp("\n" + _dim_line("Invoke a bundle with /<slug>. Manage with `hermes bundles`."))
def _handle_browser_command(self, cmd: str):
"""Handle /browser connect|disconnect|status|use — manage the live Chromium-family CDP connection."""
# The subcommand word is matched case-insensitively; the raw argument keeps
# its case because a CDP URL's path segment is case-sensitive.
parts = _command_arg(cmd).split(None, 1)
word = parts[0].lower() if parts else "status"
rest = parts[1].strip() if len(parts) > 1 else ""
handler = _BROWSER_SUBCOMMANDS.get(word)
if handler is None:
_say_block(
"Usage: /browser connect|disconnect|status|use", "",
" connect Connect browser tools to your live Chromium-family browser session",
" disconnect Revert to default browser backend",
" status Show current browser mode",
" use [off] Switch to Browser Use mode (CLI 3.0) / back to built-in tools")
return
handler(self, rest.strip())
# ---- /heartbeat, /refine, /review -----------------------------------------------------
def _session_manager(self, getter, label: str):
"""The session-scoped manager from ``getter()``, or None after the standard dim
"<label> unavailable (no active session)." line."""
mgr = getter()
if mgr is None:
_cp(_dim_line(f"{label} unavailable (no active session)."))
return mgr
def _handle_heartbeat_command(self, cmd: str) -> None:
"""Dispatch /heartbeat: set / status / pause / resume / clear. ``/heartbeat every 10m <prompt>``
sets the session's one recurring instruction, injected as a normal user turn when due.
Session-scoped and in-process — use `hermes cron` for durable schedules."""
from hermes_cli.heartbeat import format_interval
arg = _command_arg(cmd)
lower = arg.lower()
mgr = self._session_manager(self._get_heartbeat_manager, "Heartbeats")
if mgr is None:
return
if not arg or lower == "status":
_cp(f" {mgr.status_line()}")
elif lower == "pause":
state = mgr.pause()
_cp(f" ⏸ Heartbeat paused: {state.prompt}" if state else _dim_line('No heartbeat set.'))
elif lower == "resume":
state = mgr.resume()
if state is None:
_cp(_dim_line('No heartbeat to resume.'))
else:
self._start_heartbeat_watchdog()
_cp(f" ▶ Heartbeat resumed (every {format_interval(state.interval_seconds)}): {state.prompt}")
elif lower in {"clear", "stop", "off"}:
_cp(" ✓ Heartbeat cleared." if mgr.clear() else _dim_line('No heartbeat set.'))
else:
self._heartbeat_set(mgr, arg)
def _heartbeat_set(self, mgr, arg: str) -> None:
"""Set: ``/heartbeat every 10m <prompt>`` (also accepts ``10m <prompt>``)."""
from hermes_cli.heartbeat import parse_interval, format_interval
tokens = arg.split(None, 2)
interval = None
prompt = ""
if tokens and tokens[0].lower() == "every" and len(tokens) >= 2:
interval = parse_interval(f"every {tokens[1]}")
prompt = tokens[2] if len(tokens) > 2 else ""
elif tokens:
interval = parse_interval(tokens[0])
prompt = arg[len(tokens[0]):].strip() if interval and interval > 0 else ""
if interval is None:
return _cp(
" Usage: /heartbeat every <interval> <prompt> (e.g. /heartbeat every 10m Check CI)",
_dim_line('Also: /heartbeat status | pause | resume | clear'))
if interval < 0:
from hermes_cli.heartbeat import MIN_INTERVAL_SECONDS
return _cp(f" Interval too small — minimum is {MIN_INTERVAL_SECONDS}s.")
if not prompt.strip():
return _cp(" Usage: /heartbeat every <interval> <prompt> — the prompt is required.")
state = _attempt("Invalid heartbeat", ValueError, mgr.set, prompt, interval)
if state is _FAILED:
return
self._start_heartbeat_watchdog()
_cp(f" ♥ Heartbeat set (every {format_interval(state.interval_seconds)}): {state.prompt}",
_dim_line("Fires as a normal turn whenever the session is idle and the interval has "
"elapsed. /heartbeat pause | resume | clear to manage; lives only while this "
"Hermes process runs — use `hermes cron` for durable schedules."))
def _handle_refine_command(self, cmd: str) -> None:
"""Dispatch /refine — run the memory/skill review fork on demand (same machinery as the
automatic post-turn ``_spawn_background_review``), with optional focus text. Background
fork; the live conversation and prompt cache are never touched."""
focus = _command_arg(cmd)
agent = getattr(self, "agent", None)
if agent is None:
return _cp(_dim_line('Nothing to refine yet — send a message first.'))
snapshot = list(getattr(self, "conversation_history", None) or [])
if not snapshot:
return _cp(_dim_line('Nothing to refine yet — the conversation is empty.'))
try:
agent._spawn_background_review(
messages_snapshot=snapshot, review_memory=True,
review_skills="skill_manage" in getattr(agent, "valid_tool_names", set()),
focus=focus or None, explicit=True)
except Exception as exc:
return _cp(f" /refine failed to start: {exc}")
tail = f" (focus: {focus})" if focus else ""
_cp(f" ⚗ Reviewing this conversation in the background{tail} — "
f"any memory/skill updates will be reported when done.")
def _handle_review_command(self, cmd: str) -> None:
"""Dispatch /review — snapshot the last N messages (+ argument text as instructions) and
spawn an independent reviewer subagent via async delegation; the review re-enters this
session as a normal delegation completion."""
prompt = _command_arg(cmd)
agent = getattr(self, "agent", None)
if agent is None:
return _cp(_dim_line('Nothing to review yet — send a message first.'))
snapshot = list(getattr(self, "conversation_history", None) or [])
try:
from agent.review_engine import format_dispatch_note, start_review
result = start_review(agent, snapshot, prompt)
except ValueError as exc:
return _cp(_dim_line(str(exc)))
except Exception as exc:
return _cp(f" /review failed to start: {exc}")
_cp(f" {format_dispatch_note(result, prompt)}")
# ---- /goal, /loop, /subgoal -----------------------------------------------------------
def _handle_goal_command(self, cmd: str) -> None:
from hermes_cli.goal_command import dispatch_goal_command
from hermes_cli.goals import last_user_message_content
mgr = self._session_manager(self._get_goal_manager, "Goals")
if mgr is None:
return
result = dispatch_goal_command(
mgr, _command_arg(cmd), authorize_gate=lambda: None,
progress=lambda text: _cp(_dim_line(text)),
last_user_message=last_user_message_content(getattr(self, "conversation_history", None)),
)
for line in result.output.splitlines():
_cp(f" {line}")
if result.prompt:
queued = self._kick_goal(result.prompt)
if not result.kickoff:
_cp(_dim_line('Continuing now — taking the next step.' if queued else
'Send any message to kick off the next step.'))
def _kick_goal(self, prompt: str) -> bool:
"""Queue the next turn without mutating cached conversation history."""
try:
self._pending_input.put(prompt)
return True
except Exception:
return False
def _handle_loop_command(self, cmd: str) -> None:
"""Dispatch /loop — recurring in-session wakeups: ``/loop [interval] <prompt> [--times N]
[--until <cond>]`` starts one; ``status | pause | resume | stop`` control it."""
arg = _command_arg(cmd)
mgr = self._session_manager(self._get_loop_manager, "Loops")
if mgr is None:
return
from hermes_cli.loops import dispatch_loop_command
result = dispatch_loop_command(mgr, arg)
for line in (result.get("output") or "").splitlines():
_cp(f" {line}")
if result.get("created"):
with suppress(Exception):
from hermes_cli.loops import goal_blocks_loop_tick
if goal_blocks_loop_tick(mgr.session_id):
_cp(_dim_line("Note: an active /goal is driving this session — loop wakeups "
"defer until the goal finishes, pauses, or parks."))
def _handle_subgoal_command(self, cmd: str) -> None:
"""Dispatch /subgoal: bare → show, ``<text>`` → append, ``remove <n>`` (1-based), ``clear``.
Subgoals join the judge + continuation prompts at the next turn boundary (no kick)."""
parts = (cmd or "").strip().split(None, 2)
arg = " ".join(parts[1:]).strip() if len(parts) > 1 else ""
mgr = self._session_manager(self._get_goal_manager, "Goals")
if mgr is None:
return
if not mgr.has_goal():
return _cp(_dim_line('No active goal. Set one with /goal <text>.'))
if not arg: # list current subgoals
_cp(f" {mgr.status_line()}")
return _cp(f" {mgr.render_subgoals()}")
tokens = arg.split(None, 1)
verb = tokens[0].lower()
rest = tokens[1].strip() if len(tokens) > 1 else ""
if verb == "remove":
if not rest:
return _cp(" Usage: /subgoal remove <n>")
try:
idx = int(rest.split()[0])
except ValueError:
return _cp(" /subgoal remove: <n> must be an integer (1-based index).")
removed = _attempt("/subgoal remove", (IndexError, RuntimeError), mgr.remove_subgoal, idx)
if removed is not _FAILED:
_cp(f" ✓ Removed subgoal {idx}: {removed}")
elif verb == "clear":
prev = _attempt("/subgoal clear", RuntimeError, mgr.clear_subgoals)
if prev is not _FAILED:
_cp(f" ✓ Cleared {_plural(prev, 'subgoal')}." if prev
else _dim_line('No subgoals to clear.'))
else: # append the whole arg as a new subgoal
text = _attempt("/subgoal", (ValueError, RuntimeError), mgr.add_subgoal, arg)
if text is not _FAILED:
idx = len(mgr.state.subgoals) if mgr.state else 0
_cp(f" ✓ Added subgoal {idx}: {text}")
# ---- /skin, /prompt -------------------------------------------------------------------
def _handle_skin_command(self, cmd: str):
"""Handle /skin [name] — show or change the display skin."""
from cli import _ACCENT
try:
from hermes_cli.skin_engine import list_skins, set_active_skin, get_active_skin_name
except ImportError:
return print("Skin engine not available.")
new_skin = _command_arg(cmd).lower()
if not new_skin: # show current skin and list available
current = get_active_skin_name()
_pr(f"\n Current skin: {current}", " Available skins:")
for s in list_skins():
marker = " ●" if s["name"] == current else " "
source = f" ({s['source']})" if s["source"] == "user" else ""
print(f" {marker} {s['name']}{source} — {s['description']}")
return _pr("\n Usage: /skin <name>",
f" Custom skins: drop a YAML file in {display_hermes_home()}/skins/\n")
available = {s["name"] for s in list_skins()}
if new_skin not in available:
return _pr(f" Unknown skin: {new_skin}",
f" Available: {', '.join(sorted(available))}")
set_active_skin(new_skin)
_ACCENT.reset() # re-resolve ANSI color for the new skin (_DIM is a fixed escape)
saved = " (saved)" if _save("display.skin", new_skin) else ""
_pr(f" Skin set to: {new_skin}{saved}",
" Note: banner colors will update on next session start.")
if self._apply_tui_skin_style():
print(" Prompt + TUI colors updated.")
def _compose_in_editor(self, initial_text: str = "") -> str:
"""Open ``$VISUAL``/``$EDITOR`` on a temp markdown file and return the saved buffer with
``#!`` comment lines stripped; "" if the editor failed or the buffer was left empty.
Factored out so the read-back/strip logic is unit-testable."""
editor = (os.environ.get("VISUAL") or os.environ.get("EDITOR")
or ("notepad" if os.name == "nt" else "nano"))
fd, path = tempfile.mkstemp(suffix=".md", prefix="hermes_prompt_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write("#! Compose your prompt below. Lines starting with '#!' are ignored.\n"
"#! Save and quit to send; leave empty to cancel.\n\n")
if initial_text:
fh.write(initial_text)
try:
subprocess.call([*shlex.split(editor), path])
except Exception:
# Fall back to a bare invocation (editor value may not be argv-splittable everywhere).
subprocess.call(f"{editor} {shlex.quote(path)}", shell=True)
with open(path, "r", encoding="utf-8-sig") as fh:
raw = fh.read()
finally:
with suppress(OSError):
os.unlink(path)
return "\n".join(ln for ln in raw.splitlines() if not ln.startswith("#!")).strip()
def _handle_prompt_compose_command(self, cmd_original: str) -> None:
"""Handle /prompt — compose the next prompt in $EDITOR (optionally seeded with the argument)
and queue it as the next agent turn via the one-shot ``_pending_agent_seed`` (as /blueprint)."""
parts = (cmd_original or "").strip().split(None, 1)
try:
composed = self._compose_in_editor(parts[1] if len(parts) > 1 else "")
except Exception as exc:
return _cp(_dim_line(f'(>_<) Could not open editor: {exc}'))
if not composed:
return _cp(_dim_line('(._.) Empty prompt — nothing sent.'))
# One-shot seed: the interactive loop runs this as the next agent turn right after
# process_command() returns (see cli.py main loop).
self._pending_agent_seed = composed
# ---- /focus and its display hooks -----------------------------------------------------
def _handle_focus_command(self, cmd_original: str) -> None:
"""``/focus [on|off|status]`` — DISPLAY-ONLY reduced output. Reuses the ``/verbose``
suppression path: on stashes tool_progress_mode and snaps it to "off" (what
``agent/tool_executor.py`` gates on); off restores the stash verbatim. Never touches
history, system prompt, or request payloads."""
from hermes_cli.colors import Colors as _Colors
from hermes_cli.focus_view import (
FOCUS_CONFIG_KEY, FOCUS_TOOL_PROGRESS_MODE, format_focus_status,
format_focus_toggle_message, normalize_tool_progress_mode, resolve_focus_arg)
current = bool(getattr(self, "_focus_view_enabled", False))
action, target = resolve_focus_arg(_command_arg(cmd_original), current)
if action == "usage":
return _cp(" Usage: /focus [on|off|status]")
# The mode /focus off restores: while focus is ON the live mode is "off", so use the stash.
restore_mode = normalize_tool_progress_mode(
getattr(self, "_focus_saved_tool_progress", None) if current
else getattr(self, "tool_progress_mode", "all"))
if action == "status":
head, _, tail = format_focus_status(current, restore_mode).partition("\n")
label, _, rest = head.partition(":")
state_color = _Colors.GREEN if current else _Colors.DIM
return _cp(f" {_Colors.BOLD}{label}:{_Colors.RESET}{state_color}{rest}{_Colors.RESET}"
+ (f"\n{_Colors.DIM} {tail.strip()}{_Colors.RESET}" if tail else ""))
if target == current: # idempotent explicit set — report without rewriting config
return _cp(f" {format_focus_toggle_message(current, restore_mode)}")
if target:
# Stash the user's configured mode, then reuse the EXISTING suppression path by
# snapping to "off".
self._focus_saved_tool_progress = restore_mode
self._set_tool_progress_mode(FOCUS_TOOL_PROGRESS_MODE)
else:
self._set_tool_progress_mode(restore_mode)
self._focus_saved_tool_progress = None
self._focus_view_enabled = bool(target)
self._focus_hidden_lines = 0
_save(FOCUS_CONFIG_KEY, bool(target))
message = format_focus_toggle_message(bool(target), restore_mode)
# Re-colour just the enabled/disabled word so the line matches siblings.
for word in ("enabled", "disabled"):
if word in message:
colour = _Colors.GREEN if target else _Colors.DIM
message = message.replace(word, f"{colour}{word}{_Colors.RESET}", 1)
break
_cp(f" {message}")
def _set_tool_progress_mode(self, mode: str) -> None:
"""Set the live tool-progress mode on both the CLI and the agent (one write path for
/focus and /verbose — the agent copy is what ``agent/tool_executor.py`` gates on)."""
from hermes_cli.focus_view import normalize_tool_progress_mode
normalized = normalize_tool_progress_mode(mode)
self.tool_progress_mode = normalized
agent = getattr(self, "agent", None)
if agent is not None:
with suppress(Exception):
agent.tool_progress_mode = normalized
def _note_focus_hidden_line(self, function_name: str) -> None:
"""Count one tool line focus view is suppressing this turn — against the mode the user had
BEFORE focus snapped to "off", so a prior ``/verbose off`` user is never told focus hid lines."""
if not getattr(self, "_focus_view_enabled", False):
return
from hermes_cli.focus_view import would_display_tool_line
saved = getattr(self, "_focus_saved_tool_progress", None)
last = getattr(self, "_focus_last_counted_tool", None)
if not would_display_tool_line(saved, function_name, last):
return
self._focus_last_counted_tool = function_name
self._focus_hidden_lines = int(getattr(self, "_focus_hidden_lines", 0)) + 1
def _emit_focus_recovery_line(self) -> None:
"""Print the dim post-turn recovery line and reset the counter."""
count = int(getattr(self, "_focus_hidden_lines", 0) or 0)
self._focus_hidden_lines = 0
self._focus_last_counted_tool = None
if not getattr(self, "_focus_view_enabled", False):
return
from hermes_cli.focus_view import format_hidden_line
line = format_hidden_line(count)
if line:
with suppress(Exception):
_cp(_dim_line(line))
# ---- persisted display toggles: /approvals, /footer, /timestamps ----------------------
def _handle_approvals_command(self, cmd_original: str) -> None:
"""Show or persist the profile-wide dangerous-command approval mode."""
from hermes_cli.approval_mode import run_approval_mode_command
parts = (cmd_original or "").strip().split(None, 1)
result = run_approval_mode_command(parts[1] if len(parts) > 1 else None)
_cp(f" {result.message}")
def _toggle_setting(self, arg: str, current: bool, *, usage: str, status_line: str,
config_key: str, label: str, failed: str):
"""Shared /footer + /timestamps flow: status query, usage error, or save + report.
Returns the new bool state when it was saved (None otherwise)."""
from hermes_cli.colors import Colors as _Colors
new_state = _toggle_target(arg, current)
if new_state == "status":
state = "ON" if current else "OFF"
return _cp(f" {_Colors.BOLD}{label}:{_Colors.RESET} {state}{status_line}")
if new_state is None:
return _cp(f" Usage: {usage}")
if _save(config_key, new_state):
colour = _Colors.GREEN if new_state else _Colors.DIM
_cp(f" {label}: {colour}{'ON' if new_state else 'OFF'}{_Colors.RESET}")
else:
_cp(f" Failed to save {failed} setting to config.yaml")
return new_state
def _handle_footer_command(self, cmd_original: str) -> None:
"""Toggle or inspect ``display.runtime_footer.enabled`` (``/footer [on|off|status]``)."""
from hermes_cli.config import load_config
footer_cfg = (((load_config() or {}).get("display") or {}).get("runtime_footer") or {})
fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"]
self._toggle_setting(
_command_arg(cmd_original, lower=True), bool(footer_cfg.get("enabled", False)),
usage="/footer [on|off|status]", status_line=f"\n Fields: {', '.join(fields)}",
config_key="display.runtime_footer.enabled", label="Runtime footer", failed="runtime_footer",
)
def _handle_timestamps_command(self, cmd_original: str) -> None:
"""Toggle or inspect ``display.timestamps`` (``/timestamps [on|off|status]``). When on,
message labels carry an ``[HH:MM]`` suffix and ``/history`` prefixes stored-timestamp turns."""
arg = _command_arg(cmd_original, lower=True)
current = bool(getattr(self, "show_timestamps", False))
new_state = _toggle_target(arg, current)
if isinstance(new_state, bool):
self.show_timestamps = new_state
self._toggle_setting(
arg, current, usage="/timestamps [on|off|status]", status_line="",
config_key="display.timestamps", label="Message timestamps", failed="timestamps")
# ---- model-behaviour settings: /reasoning, /busy, /indicator, /fast -------------------
def _handle_reasoning_command(self, cmd: str):
"""Handle /reasoning [<level> [--global]|show|hide|full|clamp] — effort level (session
scope unless --global) and thinking display toggles (always saved)."""
from cli import CLI_CONFIG, _parse_reasoning_config
from agent.reasoning_effort import effort_display_label
raw = _command_arg(cmd)
_route = (getattr(self, "provider", None), getattr(self, "model", None))
if not raw: # show current state
rc = self.reasoning_config
level = ("medium (default)" if rc is None else "none (disabled)"
if rc.get("enabled") is False else effort_display_label(rc.get("effort", "medium"), *_route))
display_state = "on ✓" if self.show_reasoning else "off"
full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines"
return _cp(_accent_line(f"Reasoning effort: {level}"),
_accent_line(f"Reasoning display: {display_state} ({full_state})"),
_dim_line("Usage: /reasoning <none|minimal|low|medium|high|xhigh|max|ultra"
"|show|hide|full|clamp> [--global]"))
arg, explicit_global = _split_scope_flags(raw)
toggle = _REASONING_TOGGLES.get(arg)
if toggle is not None: # display show/hide or full/clamp recap toggle
attr, value, headline, note = toggle
setattr(self, attr, value)
if attr == "show_reasoning" and self.agent:
self.agent.reasoning_callback = self._current_reasoning_callback()
_save(f"display.{attr}", value)
_cp(_accent_line(f"✓ Reasoning display: {headline} (saved)"))
if note:
_cp(_dim_line(f" {note}"))
if attr == "reasoning_full" and value and not self.show_reasoning:
_cp(_dim_line(" Note: reasoning display is OFF — run /reasoning show to see it."))
return
# Effort level change
parsed = _parse_reasoning_config(arg)
if parsed is None:
return _cp(_dim_line(f'(._.) Unknown argument: {arg}'),
_dim_line('Valid levels: none, minimal, low, medium, high, xhigh, max, ultra'),
_dim_line('Display: show, hide'),
_dim_line('Scope: session-scoped by default, --global to persist'))
self.reasoning_config = parsed
_retire_agent(self) # Force agent re-init with new reasoning config
saved = explicit_global and _save("agent.reasoning_effort", arg)
if saved:
if not isinstance(CLI_CONFIG.get("agent"), dict):
CLI_CONFIG["agent"] = {}
CLI_CONFIG["agent"]["reasoning_effort"] = arg
_cp(_accent_line(f"✓ Reasoning effort set to '{effort_display_label(arg, *_route)}' "
f"{_scope_outcome(explicit_global, saved)}"))
def _handle_busy_command(self, cmd: str):
"""Handle /busy [status|queue|steer|interrupt] — what Enter does while Hermes is working."""
arg = _command_arg(cmd, lower=True)
usage = _dim_line('Usage: /busy [queue|steer|interrupt|status]')
if not arg or arg == "status":
behavior = _BUSY_MODE_SHORT.get(self.busy_input_mode, _BUSY_MODE_SHORT["interrupt"])
return _cp(_accent_line(f"Busy input mode: {self.busy_input_mode}"),
_dim_line(f'Enter while busy: {behavior}'), usage)
if arg not in _BUSY_MODE_LONG:
return _cp(_dim_line(f'(._.) Unknown argument: {arg}'), usage)
self.busy_input_mode = arg
_persist_display_choice("display.busy_input_mode", arg, "Busy input mode", _BUSY_MODE_LONG[arg])
def _handle_indicator_command(self, cmd: str):
"""Handle /indicator [status|kaomoji|emoji|unicode|ascii] — pick the TUI busy-indicator style.
Persists to ``display.tui_status_indicator`` (the key the TUI reads) for its next render."""
from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES
current = (self.config.get("display") or {}).get("tui_status_indicator", DEFAULT_INDICATOR_STYLE)
arg = _command_arg(cmd, lower=True)
usage = _dim_line(f"Usage: /indicator [{'|'.join(INDICATOR_STYLES)}]")
if not arg or arg == "status":
return _cp(_accent_line(f"Busy-indicator style: {current}"), usage)
if arg not in INDICATOR_STYLES:
return _cp(_dim_line(f'(._.) Unknown indicator style: {arg}'), usage)
self.config.setdefault("display", {})["tui_status_indicator"] = arg
_persist_display_choice("display.tui_status_indicator", arg, "Busy-indicator style",
"The TUI picks up the new style on its next render.")
def _handle_fast_command(self, cmd: str):
"""Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).
Session-scoped by default; ``--global`` persists agent.service_tier to config.yaml
(parity with /model and /reasoning)."""
if not self._fast_command_available():
return _cp(" (._.) /fast is only available for models that support fast mode "
"(OpenAI Priority Processing or Anthropic Fast Mode).")
# Determine the branding for the current model
model = getattr(getattr(self, "agent", None), "model", None) or getattr(self, "model", None)
anthropic = _probe("hermes_cli.models", "_is_anthropic_fast_model", None, model)
feature_name = ("Fast mode" if anthropic is None
else "Anthropic Fast Mode" if anthropic else "Priority Processing")
raw = _command_arg(cmd)
usage = _dim_line('Usage: /fast [normal|fast|auto|cold|status] [--global]')
if not raw or raw.lower() == "status":
status = {"priority": "fast", None: "normal"}.get(self.service_tier, self.service_tier)
return _cp(_accent_line(f"{feature_name}: {status}"), usage)
arg, explicit_global = _split_scope_flags(raw)
if arg not in _FAST_TIERS:
return _cp(_dim_line(f'(._.) Unknown argument: {arg}'), usage)
self.service_tier, saved_value = _FAST_TIERS[arg]
_retire_agent(self) # Force agent re-init with new service-tier config
saved = explicit_global and _save("agent.service_tier", saved_value)
outcome = _scope_outcome(explicit_global, saved)
_cp(_accent_line(f"✓ {feature_name} set to {saved_value.upper()} {outcome}"))
# ---- /debug, /update, /voice, /wake ---------------------------------------------------
def _handle_debug_command(self, cmd_original: str = ""):
"""Handle /debug [nous|local] — upload debug report + logs and print share URLs.
Default: public paste service; ``nous``: Nous-internal (staff-only); ``local``: render to
stdout, no upload. ``local`` wins if both are given (never touches the network)."""
from hermes_cli.debug import run_debug_share
from types import SimpleNamespace
words = {w.lower() for w in cmd_original.split()[1:]}
local = "local" in words
# Typing /debug is the upload consent (yes=True); input() would hang in prompt_toolkit anyway.
run_debug_share(SimpleNamespace(
lines=200, expire=7, local=local, nous="nous" in words and not local, yes=True))
def _handle_update_command(self) -> bool:
"""Handle /update — exit the session and relaunch as ``hermes update``. Returns True when
confirmed (the caller exits the app; the relaunch runs on the main thread after
prompt_toolkit restores terminal modes), False when cancelled."""
from hermes_cli.config import is_managed, format_managed_message
if is_managed():
print(f" ✗ {format_managed_message('update Hermes Agent')}")
return False
# prompt_toolkit-native modal: renders above the composer, no raw input() races.
choices = [("once", "Update Now", "exit the current session and update Hermes Agent"),
("cancel", "Cancel", "keep the current session")]
raw = self._prompt_text_input_modal(
title="☤ Update Hermes Agent",
detail="This will exit the current session and run `hermes update`.", choices=choices)
if raw is None or self._normalize_slash_confirm_choice(raw, choices) != "once":
print(" 🟡 /update cancelled.")
return False
_say_block(" ☤ Launching update...")
# run() execs this on the main thread after prompt_toolkit restores terminal modes;
# relaunching from this daemon thread would skip cleanup (POSIX) / only end the thread (Windows).
self._pending_relaunch = ["update"]
return True
def _handle_voice_command(self, command: str):
"""Handle /voice [on|off|tts|status] command."""
subcommand = _command_arg(command, lower=True) or ("off" if self._voice_mode else "on")
actions = {"on": self._enable_voice_mode, "off": self._disable_voice_mode,
"tts": self._toggle_voice_tts, "status": self._show_voice_status}
if subcommand in actions:
actions[subcommand]()
else:
_cp(f"Unknown voice subcommand: {subcommand}", "Usage: /voice [on|off|tts|status]")
def _handle_wake_command(self, command: str):
"""Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener. The toggle IS the
config: on/off also writes ``wake_word.enabled`` so the choice persists; startup
auto-arm only reads it."""
subcommand = _command_arg(command, lower=True) or (
"off" if getattr(self, "_wake_word_active", False) else "on") # bare /wake toggles
if subcommand == "on":
if self._start_wake_word_listener(announce=True):
self._persist_wake_word_enabled(True)
elif subcommand == "off":
self._stop_wake_word_listener(announce=True)
self._persist_wake_word_enabled(False)
elif subcommand == "status":
self._show_wake_word_status()
else:
_cp(f"Unknown wake subcommand: {subcommand}", "Usage: /wake [on|off|status]")
def _persist_wake_word_enabled(self, enabled: bool):
"""Save ``wake_word.enabled`` so the /wake toggle sticks for future sessions."""
persisted = _probe("tools.wake_word", "load_wake_word_config", None)
if isinstance(persisted, dict) and bool(persisted.get("enabled")) == enabled:
return # already persisted — don't rewrite config or re-announce
if _save("wake_word.enabled", enabled):
_cp(_dim(f"Wake word {'enabled' if enabled else 'disabled'} in config "
f"(wake_word.enabled: {str(enabled).lower()})."))