restore_quick_snapshot now returns False when the snapshot's auth.json is refused or skipped, even if other files were restored. /snapshot restore mapped every False to "Snapshot not found", so a restore whose files came back but whose corrupt auth.json was refused told the user the snapshot did not exist. When the restore returns False but the snapshot id is listed, print a new snapshot.restore_incomplete message instead (added to every locale carrying snapshot.not_found). The kept corrupt-auth test now drives the caller too and asserts the live auth.json stays untouched.
2733 lines
144 KiB
Python
2733 lines
144 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.i18n import t
|
|
from agent.message_metadata import message_identity
|
|
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 _t(key: str, **kwargs) -> str:
|
|
"""Catalog text for this module: ``cli.commands.<key>`` in the active language."""
|
|
return t(f"cli.commands.{key}", **kwargs)
|
|
|
|
|
|
def _tn(key: str, count: int, **kwargs) -> str:
|
|
"""Plural-aware ``_t``: ``<key>_one`` when ``count == 1``, else ``<key>_other``."""
|
|
return _t(f"{key}_{'one' if count == 1 else 'other'}", count=count, **kwargs)
|
|
|
|
|
|
def _gt(key: str, **kwargs) -> str:
|
|
"""A ``gateway.<key>`` catalog entry the CLI shares verbatim with the messaging gateway."""
|
|
return t(f"gateway.{key}", **kwargs)
|
|
|
|
|
|
def _lines(text: str, pad: str = " ") -> list:
|
|
"""Each line of a (possibly multi-line) catalog value prefixed with ``pad`` (blank lines kept)."""
|
|
return [f"{pad}{line}" if line else "" for line in text.splitlines()]
|
|
|
|
|
|
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 = _t("shared.more_suffix", count=len(paths) - limit) 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 ''}"
|
|
|
|
|
|
# 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 modes; what Enter does while Hermes is working lives in the catalog as
|
|
# ``cli.commands.busy.short_<mode>`` (status line) / ``long_<mode>`` (post-set explanation).
|
|
_BUSY_MODES = ("queue", "steer", "interrupt")
|
|
|
|
# /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"),
|
|
"ultrafast": ("ultrafast", "ultrafast")}
|
|
|
|
# /reasoning display toggles: arg -> (attr, value, headline key, follow-up note key or None);
|
|
# the keys resolve under ``cli.commands.reasoning.*`` at call time.
|
|
_REASONING_TOGGLES = {
|
|
**dict.fromkeys(("show", "on"), ("show_reasoning", True, "headline_on", "note_show")),
|
|
**dict.fromkeys(("hide", "off"), ("show_reasoning", False, "headline_off", None)),
|
|
**dict.fromkeys(("full", "all"), ("reasoning_full", True, "headline_full", "note_full")),
|
|
**dict.fromkeys(("clamp", "collapse", "short"), ("reasoning_full", False, "headline_clamped", None)),
|
|
}
|
|
|
|
# /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 event -> catalog key (``cli.commands.hatch.*``).
|
|
_HATCH_PROGRESS = {"compose": "hatch.progress_compose", "save": "hatch.progress_save"}
|
|
|
|
# /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 mode -> stat headline catalog key (``cli.commands.diff.*``).
|
|
_DIFF_LABELS = {"working": "diff.label_working", "staged": "diff.label_staged", "all": "diff.label_all"}
|
|
|
|
|
|
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(_t("shared.set_saved", label=label, value=value)), _dim_line(note))
|
|
else:
|
|
_cp(_accent_line(_t("shared.set_session_only", label=label, value=value)))
|
|
|
|
|
|
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 _t("shared.scope_saved")
|
|
if explicit_global:
|
|
return _t("shared.scope_save_failed")
|
|
return _t("shared.scope_session")
|
|
|
|
|
|
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(_t("cron.repeat_must_be_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" {_t('browser.engine_not_in_use', reason=reason)}")
|
|
print(f" {_t('browser.engine_lightpanda', reason=reason)}")
|
|
try:
|
|
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
|
|
lightpanda_bin = find_lightpanda_binary()
|
|
except Exception:
|
|
return
|
|
print(f" {_t('browser.binary', path=lightpanda_bin)}" if lightpanda_bin
|
|
else f" {_t('browser.binary_not_found', hint=LIGHTPANDA_INSTALL_HINT)}")
|
|
|
|
|
|
def _browser_use(cli, arg: str) -> None:
|
|
"""/browser use [off] — toggle Browser Use mode (browser.backend); resets the session."""
|
|
from tools.browser_use_cli import set_browser_use_mode
|
|
if arg not in {"on", "off"}:
|
|
return _say_block(
|
|
_t("browser.use_usage"),
|
|
f" {_t('browser.use_on_hint')}", f" {_t('browser.use_off_hint')}")
|
|
set_browser_use_mode(arg == "on")
|
|
headline = _t("browser.use_enabled" if arg == "on" else "browser.use_disabled")
|
|
cli.new_session()
|
|
_say_block(headline, f" {_t('browser.session_reset')}")
|
|
|
|
|
|
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" {_t('browser.unsupported_scheme', scheme=parsed.scheme or _t('browser.scheme_missing'))}")
|
|
return None
|
|
try:
|
|
port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80)
|
|
except ValueError:
|
|
_say_block(f" {_t('browser.invalid_port', url=cdp_url)}")
|
|
return None
|
|
if not parsed.hostname:
|
|
_say_block(f" {_t('browser.missing_host', 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" {_t('browser.port_occupied', port=port)}",
|
|
f" {_t('browser.port_occupied_detail', launch_port=launch_port)}")
|
|
else:
|
|
print(f" {_t('browser.launching')}")
|
|
launch = launch_chrome_debug(launch_port, _plat.system())
|
|
if not launch.launched:
|
|
print(f" {_t('browser.launch_failed')}")
|
|
if launch.hint:
|
|
print(f" {launch.hint}")
|
|
chrome_cmd = manual_chrome_debug_command(launch_port, _plat.system())
|
|
if chrome_cmd:
|
|
_pr(f" {_t('browser.launch_manually')}", f" {chrome_cmd}")
|
|
else:
|
|
print(f" {_t('browser.no_executable')}")
|
|
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" {_t('browser.launched', port=launch_port)}")
|
|
return found
|
|
time.sleep(0.5)
|
|
_pr(f" {_t('browser.not_responding', port=launch_port)}", f" {_t('browser.try_again')}")
|
|
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" {_t('browser.already_listening', url=found)}")
|
|
elif is_default:
|
|
found = _launch_default_cdp_browser(port)
|
|
else:
|
|
print(f" {_t('browser.port_unreachable', port=port, url=cdp_url)}")
|
|
if not found:
|
|
return _say_block(_t("browser.not_connected"))
|
|
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(_t("browser.connected"), f" {_t('browser.endpoint', url=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(_t("browser.already_default"))
|
|
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(_t("browser.disconnected"), f" {_t('browser.reverted')}")
|
|
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: (headline key, *detail keys) under
|
|
# ``cli.commands.browser.*``; detail lines are indented at print time.
|
|
_LOCAL_ENGINE_LINES = {
|
|
"lightpanda": ("status_lightpanda", "status_lightpanda_note", "status_lightpanda_fallback"),
|
|
"chrome": ("status_chrome",),
|
|
"auto": ("status_auto",)}
|
|
|
|
|
|
def _local_engine_lines(engine: str) -> list:
|
|
head, *details = _LOCAL_ENGINE_LINES.get(engine, _LOCAL_ENGINE_LINES["auto"])
|
|
return [_t(f"browser.{head}"), *(f" {_t(f'browser.{key}')}" for key in details)]
|
|
|
|
|
|
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(_t("browser.status_browser_use"), f" {_t('browser.status_browser_use_note')}")
|
|
_print_lightpanda_engine_status()
|
|
return _say_block(f" {_t('browser.status_use_off_hint')}")
|
|
if current:
|
|
_pr(_t("browser.status_connected"), f" {_t('browser.endpoint', url=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(f" {_t('browser.status_reachable')}")
|
|
except Exception:
|
|
print(f" {_t('browser.status_unreachable')}")
|
|
else:
|
|
provider = _probe("tools.browser_tool_cloud", "_get_cloud_provider", None)
|
|
if provider is not None:
|
|
print(_t("browser.status_cloud", provider=provider.display_name))
|
|
_print_lightpanda_engine_status()
|
|
else:
|
|
engine = _probe("tools.browser_tool_cloud", "_get_browser_engine", "auto")
|
|
_pr(*_local_engine_lines(engine))
|
|
if engine == "lightpanda":
|
|
_print_lightpanda_engine_status()
|
|
_say_block(f" {_t('browser.status_connect_hint')}", f" {_t('browser.status_disconnect_hint')}")
|
|
|
|
|
|
# /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(f" {_t('shared.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(_lines(_t("rollback.not_enabled")))
|
|
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" {_t('rollback.none_here_showing_all', cwd=cwd)}")
|
|
return print(format_checkpoint_list(all_checkpoints, _t("rollback.all_directories")))
|
|
return print(format_checkpoint_list(checkpoints, cwd))
|
|
is_diff = args[0].lower() == "diff"
|
|
if is_diff and len(args) < 2:
|
|
return print(f" {_t('rollback.usage_diff')}")
|
|
checkpoints = mgr.list_checkpoints(cwd)
|
|
if not checkpoints:
|
|
return print(f" {_gt('rollback.none_found', cwd=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(f" {_t('rollback.no_changes_since')}")
|
|
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 {_t('rollback.more_lines', count=len(diff_lines) - 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']}")
|
|
restored = dict(hash=result["restored_to"], reason=result["reason"])
|
|
print(f" {_t('rollback.restored_file', file=file_path, **restored)}" if file_path
|
|
else f" {_t('rollback.restored_all', **restored)}")
|
|
skipped = result.get("skipped_user_edits") or []
|
|
if skipped:
|
|
_pr(*_lines(_gt("rollback.kept_user_edits", files=_summarize_paths(skipped))))
|
|
oversize = result.get("skipped_oversize") or []
|
|
if oversize:
|
|
print(f" {_gt('rollback.kept_oversize', files=_summarize_paths(oversize))}")
|
|
failed = result.get("failed_deletes") or []
|
|
if failed:
|
|
print(f" {_gt('rollback.failed_deletes', files=_summarize_paths(failed))}")
|
|
print(f" {_t('rollback.snapshot_saved')}")
|
|
# 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(f" {_t('rollback.chat_turn_undone')}")
|
|
|
|
# ---- /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') or _t('shared.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(f" {_gt('diff.no_changes')}")
|
|
if stat:
|
|
print(f"\n {_t(_DIFF_LABELS[mode])}:")
|
|
self._print_diff_text(stat)
|
|
if untracked and mode in ("working", "all"):
|
|
_pr(f"\n {_t('diff.untracked')}", *(f" + {rel}" for rel in untracked[:20]))
|
|
if len(untracked) > 20:
|
|
print(f" {_t('diff.and_more', count=len(untracked) - 20)}")
|
|
if diff and not stat_only:
|
|
self._print_diff_body(diff, _t("diff.stat_hint"))
|
|
|
|
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 {_t('diff.more_lines', count=len(diff_lines) - limit, hint=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(_lines(_t("diff.not_enabled")))
|
|
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') or _t('shared.could_not_generate_diff')}")
|
|
stat, diff = result.get("stat", ""), result.get("diff", "")
|
|
if result.get("empty") or (not stat and not diff):
|
|
return print(f" {_t('diff.no_session_changes')}")
|
|
if stat:
|
|
self._print_diff_text(f"\n{stat}")
|
|
if diff and not stat_only:
|
|
self._print_diff_body(diff, _t("diff.stat_hint_session"))
|
|
|
|
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" {_t('snapshot.unknown_subcommand', subcommand=subcmd)}",
|
|
f" {_t('snapshot.usage')}")
|
|
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(f" {_t('snapshot.none_yet')}", f" {_t('snapshot.create_hint')}")
|
|
print(f" {_t('snapshot.list_header', dir=display_hermes_home())}\n")
|
|
# Column labels are translated values re-padded here (never padded in the catalog).
|
|
_pr(f" {_t('snapshot.col_num'):>3} {_t('snapshot.col_id'):<35} {_t('snapshot.col_files'):>5} "
|
|
f"{_t('snapshot.col_size'):>10} {_t('snapshot.col_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" {_t('snapshot.created', snapshot_id=snap_id)}" if snap_id
|
|
else f" {_t('snapshot.nothing_to_snapshot')}")
|
|
|
|
def _snapshot_restore(self, parts) -> None:
|
|
from hermes_cli.backup import list_quick_snapshots, restore_quick_snapshot
|
|
if len(parts) < 3:
|
|
print(f" {_t('snapshot.usage_restore')}")
|
|
snaps = list_quick_snapshots(limit=1)
|
|
if snaps:
|
|
print(f" {_t('snapshot.most_recent', snapshot_id=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" {_t('snapshot.invalid_number', max=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" {_t('snapshot.restored', snapshot_id=snap_id)}",
|
|
f" {_t('snapshot.restart_recommended')}")
|
|
elif snap_id in {s.get("id") for s in list_quick_snapshots(limit=10**6)}:
|
|
# False also means the auth.json merge was refused; don't call an existing snapshot missing.
|
|
print(f" {_t('snapshot.restore_incomplete', snapshot_id=snap_id)}")
|
|
else:
|
|
print(f" {_t('snapshot.not_found', snapshot_id=snap_id)}")
|
|
|
|
def _snapshot_prune(self, parts) -> None:
|
|
from hermes_cli.backup import prune_quick_snapshots
|
|
keep = 20
|
|
if len(parts) > 2:
|
|
# isdecimal() also rejects "-1": a negative keep would slice away the
|
|
# newest snapshots instead of the oldest.
|
|
if not parts[2].isdecimal():
|
|
return print(f" {_t('snapshot.usage_prune')}")
|
|
keep = int(parts[2])
|
|
deleted = prune_quick_snapshots(keep=keep)
|
|
print(f" {_t('snapshot.pruned', deleted=deleted, keep=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(f" {_t('export.usage')}")
|
|
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" {_t('export.exported', name=name, path=result)}", f" {_t('export.share_hint')}")
|
|
except (ValueError, FileNotFoundError, OSError) as e:
|
|
print(f" {_t('shared.error', 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(f" {_t('import.usage')}")
|
|
try:
|
|
profile_dir = import_profile(" ".join(parts), name=name) # paths may contain spaces
|
|
except (ValueError, FileExistsError, FileNotFoundError) as e:
|
|
return print(f" {_t('shared.error', error=e)}")
|
|
imported = profile_dir.name
|
|
print(f" {_t('import.imported', name=imported, path=profile_dir)}")
|
|
with suppress(Exception):
|
|
if not check_alias_collision(imported):
|
|
wrapper_path = create_wrapper_script(imported)
|
|
if wrapper_path:
|
|
print(f" {_t('import.wrapper_created', path=wrapper_path)}")
|
|
print(f" {_t('import.use_it', name=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(f" {_t('stop.none_running')}")
|
|
if running:
|
|
print(f" {_t('stop.stopping', count=len(running))}")
|
|
print(f" {_t('stop.stopped', count=process_registry.kill_all(source='cli.stop'))}")
|
|
if n_async:
|
|
from tools.async_delegation import interrupt_all
|
|
print(f" {_t('stop.interrupted_delegations', count=interrupt_all(reason='/stop'))}")
|
|
|
|
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" {_t('agents.running_processes', count=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" {_t('agents.recently_finished', count=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" {_t('agents.background_delegations', count=len(running_d))}")
|
|
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 += _t("agents.no_progress", seconds=f"{quiet:.0f}")
|
|
elif status == "running":
|
|
quiet = d.get("seconds_since_progress")
|
|
if quiet is not None and quiet >= 60:
|
|
line += _t("agents.quiet", seconds=f"{quiet:.0f}")
|
|
_cp(line)
|
|
for i, child in enumerate(d.get("children_activity") or []):
|
|
if not isinstance(child, dict):
|
|
continue
|
|
tool = child.get("current_tool")
|
|
doing = _t("agents.in_tool", tool=tool) if tool else _t("agents.between_turns")
|
|
part = " " + _t("agents.child_line", index=i + 1,
|
|
api_calls=child.get("api_calls", "?"), doing=doing)
|
|
idle = child.get("seconds_since_activity")
|
|
if idle is not None:
|
|
part += _t("agents.last_activity", seconds=f"{idle:.0f}")
|
|
_cp(part)
|
|
agent_running = getattr(self, "_agent_running", False)
|
|
_cp(f" {_t('agents.agent_running') if agent_running else _t('agents.agent_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" {_t('journey.failed', error=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(_t("paste.no_image")))
|
|
elif self._try_attach_clipboard_image():
|
|
_cp(f" {_t('paste.attached', index=len(self._attached_images))}")
|
|
else:
|
|
_cp(_dim_line(_t("paste.extract_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(f" {_t('copy.nothing_yet')}")
|
|
if arg:
|
|
try:
|
|
idx = int(arg) - 1
|
|
except ValueError:
|
|
return _cp(f" {_t('copy.usage')}")
|
|
if idx < 0 or idx >= len(assistant):
|
|
return _cp(f" {_t('copy.invalid_number', max=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(f" {_t('copy.nothing_in_responses')}")
|
|
text = _assistant_copy_text(assistant[idx].get("content"))
|
|
if not text:
|
|
return _cp(f" {_t('copy.nothing_in_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" {_t('copy.copied_osc52', index=idx + 1)}")
|
|
else:
|
|
_cp(f" {_t('copy.copied', index=idx + 1)}")
|
|
except Exception as e:
|
|
_cp(f" {_t('copy.failed', error=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}{_t('image.usage', example=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(_t("image.not_found", path=path_token)))
|
|
if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
|
|
return _cp(_dim_line(_t("image.unsupported", name=image_path.name)))
|
|
self._attached_images.append(image_path)
|
|
_cp(f" {_t('image.attached', name=image_path.name)}")
|
|
if _remainder:
|
|
_cprint(f" {_DIM}{_t('image.now_type_prompt', text=_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(_t("tools.usage", subcommand=subcommand),
|
|
f" {_t('tools.example_toolset', subcommand=subcommand)}",
|
|
f" {_t('tools.example_mcp', subcommand=subcommand)}")
|
|
# Typing the command is consent. Do NOT use input() — it hangs in prompt_toolkit's loop.
|
|
_cp(_accent(_t("tools.disabling" if subcommand == "disable" else "tools.enabling",
|
|
names=", ".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(_t("tools.session_reset")))
|
|
|
|
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" {_t('profile.profile', profile=reply.data['profile'])}",
|
|
f" {_t('profile.home', 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(
|
|
f" {_t('handoff.usage')}", f" {_t('handoff.usage_detail_1')}",
|
|
f" {_t('handoff.usage_detail_2')}")
|
|
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(f" {_t('handoff.already_in_flight')}")
|
|
_cp(f" {_t('handoff.queued', title=session_title, platform=platform_name, home=home.name)}",
|
|
f" {_t('handoff.waiting')}")
|
|
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" {_t('handoff.config_load_failed', error=exc)}")
|
|
try:
|
|
platform = Platform(platform_name)
|
|
except (ValueError, KeyError):
|
|
return _cp(f" {_t('handoff.unknown_platform', platform=platform_name)}")
|
|
try:
|
|
gw_config = load_gateway_config()
|
|
except Exception as exc:
|
|
return _cp(f" {_t('handoff.config_load_failed', error=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" {_t('handoff.platform_not_enabled', platform=platform_name)}")
|
|
home = gw_config.get_home_channel(platform)
|
|
if not home or not home.chat_id:
|
|
return _cp(f" {_t('handoff.no_home_channel', platform=platform_name)}",
|
|
f" {_t('handoff.set_home_hint')}")
|
|
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(f" {_t('shared.agent_busy', command='/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" {_t('handoff.session_row_failed', error=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(f" {_t('handoff.picked_up')}")
|
|
running_deadline = time.time() + self._HANDOFF_RUNNING_TIMEOUT
|
|
next_heartbeat = time.time() + self._HANDOFF_HEARTBEAT_EVERY
|
|
last_state = current
|
|
if current == "completed":
|
|
_cp("", f" {_t('handoff.complete', platform=platform_name)}",
|
|
f" {_t('handoff.resume_hint', title=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 _t("handoff.unknown_error")
|
|
return self._handoff_keep(
|
|
f" {_t('handoff.failed', error=err)}", f" {_t('handoff.session_intact_retry')}")
|
|
now = time.time()
|
|
if current == "pending":
|
|
if now >= pending_deadline:
|
|
break
|
|
else: # running
|
|
if next_heartbeat is not None and now >= next_heartbeat:
|
|
_cp(f" {_t('handoff.still_transferring')}")
|
|
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(
|
|
f" {_t('handoff.taking_long')}",
|
|
f" {_t('handoff.check_platform', platform=platform_name)}",
|
|
f" {_t('handoff.no_longer_waiting')}", f" {_t('handoff.retry_when_settled')}")
|
|
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(f" {_t('handoff.timed_out')}", f" {_t('handoff.session_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(f" {_t('shared.agent_busy', command='/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(f" {_t('resume.usage')}")
|
|
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(f" {_t('resume.tip_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(f" {_t('resume.already_on')}")
|
|
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(" " + _tn("resume.resumed", msg_count, session_id=target_id, title=title_part,
|
|
total=len(self.conversation_history)))
|
|
self._display_resumed_history()
|
|
else:
|
|
_cp(f" {_t('resume.resumed_empty', session_id=target_id, title=title_part)}")
|
|
# 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(*_lines(_gt("resume.out_of_range", index=index)))
|
|
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" {_t('resume.not_found', target=target)}", f" {_t('resume.not_found_hint')}")
|
|
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" {_t('resume.compressed_redirect', session_id=target_id, resolved_id=resolved_id)}")
|
|
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(f" {_t('sessions.none_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(f" {_t('shared.agent_busy', command='/branch')}")
|
|
from cli import _sync_process_session_id
|
|
if not self.conversation_history:
|
|
return _cp(f" {_gt('branch.no_conversation')}")
|
|
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" {_gt('branch.create_failed', error=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}, **message_identity(msg, with_tool_uids=True)}
|
|
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(" " + _tn("branch.branched", msg_count, title=branch_title),
|
|
f" {_t('branch.original_session', session_id=parent_session_id)}",
|
|
f" {_t('branch.branch_session', session_id=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" {_t('worktree.active', path=active['path'])}",
|
|
f" {_t('worktree.branch', branch=active['branch'])}")
|
|
else:
|
|
print(f" {_t('worktree.none_active')}")
|
|
if repo_root:
|
|
_pr(f" {_t('worktree.hint_new')}", f" {_t('worktree.hint_prune')}")
|
|
else:
|
|
print(f" {_t('worktree.not_in_repo_note')}")
|
|
return
|
|
handler = _WORKTREE_SUBCOMMANDS.get(sub)
|
|
if handler is None:
|
|
return _pr(f" {_t('worktree.unknown_subcommand', subcommand=sub)}", f" {_t('worktree.usage')}")
|
|
if not repo_root:
|
|
print(f" {_t('worktree.new_requires_repo')}" if handler == "_worktree_new"
|
|
else f" {_t('worktree.not_in_repo')}")
|
|
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),
|
|
" " + _t("worktree.actions_planned" if dry_run else "worktree.actions_done", count=len(actions)))
|
|
else:
|
|
print(f" {_t('worktree.nothing_to_reclaim')}")
|
|
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" {_t('worktree.preserved', count=len(kept))}",
|
|
*(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 (f" {_t('worktree.list_failed')}",))
|
|
|
|
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" {_t('worktree.enter_failed', error=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" {_t('worktree.ready', path=wt_info['path'])}",
|
|
f" {_t('worktree.branch', branch=wt_info['branch'])}", f" {_t('worktree.tools_in_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 + "+", "|" + _t("personality.title").center(50) + "|",
|
|
"+" + "-" * 50 + "+", "",
|
|
f" {' *' if not current else ' '}{'none':<12} - {_t('personality.none_option')}")
|
|
for name, prompt in self.personalities.items():
|
|
marker = " *" if name == current else " "
|
|
print(f" {marker}{name:<12} - {describe_personality(prompt)}")
|
|
return _pr("", f" {_t('personality.usage')}", "")
|
|
try:
|
|
name, personality_prompt = resolve_personality(personality_name, getattr(self, "config", None))
|
|
except ValueError:
|
|
print(_t("personality.unknown", name=personality_name.lower()))
|
|
return print(f" {_t('personality.available', available=', '.join(self.personalities.keys()))}")
|
|
saved = persist_personality(name)
|
|
scope = _t("personality.scope_saved") if saved else _t("personality.scope_session")
|
|
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(_t("personality.cleared", face=face, scope=scope), f" {_t('personality.cleared_note')}")
|
|
else:
|
|
self.system_prompt = personality_prompt
|
|
_retire_agent(self) # Force re-init
|
|
_pr(_t("personality.set_to", face=face, name=name, scope=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(_t("pet.error", error=err) if err else _t("pet.is_out", name=name) if enabled
|
|
else _t("pet.put_away_named", name=name) if name else _t("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(_t("pet.usage_scale"))
|
|
scale, err = set_pet_scale(value)
|
|
print(_t("pet.error", error=err) if err else _t("pet.scale_set", scale=f"{scale:g}"))
|
|
elif low == "off":
|
|
_set_enabled(False)
|
|
print(_t("pet.put_away"))
|
|
else:
|
|
print(_t("pet.fetching", name=arg))
|
|
try:
|
|
pet = store.install_pet(arg)
|
|
except (store.PetStoreError, ManifestError) as exc:
|
|
return print(_t("pet.adopt_failed", name=arg, error=exc))
|
|
_set_active(arg)
|
|
print(_t("pet.is_out", name=pet.display_name))
|
|
|
|
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)(_t("hatch.describe_prompt")) or "").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
return print()
|
|
if not concept:
|
|
return print(_t("hatch.usage"))
|
|
# 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 _t("hatch.default_name"))
|
|
slug = store.slugify(display_name) or store.slugify(concept) or "pet"
|
|
print(_t("hatch.designing", concept=concept))
|
|
try:
|
|
drafts = orchestrate.generate_base_drafts(concept, n=1)
|
|
except GenerationError as exc:
|
|
return print(_t("hatch.base_failed", error=exc))
|
|
if not drafts:
|
|
return print(_t("hatch.no_base_draft"))
|
|
|
|
def _progress(event: str, detail: str) -> None:
|
|
if event == "row": # detail is "<state>:<done>:<total>"; show the state name.
|
|
print(f" {_t('hatch.progress_drawing', state=detail.split(':', 1)[0])}")
|
|
elif event in _HATCH_PROGRESS:
|
|
print(f" {_t(_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(_t("hatch.failed", error=exc))
|
|
_set_active(result.slug)
|
|
print(_t("hatch.hatched", name=result.display_name))
|
|
|
|
# ---- /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(_t("cron.unknown_command", subcommand=subcommand), f" {_t('cron.available')}")
|
|
getattr(self, handler)(subcommand, opts)
|
|
|
|
def _cron_overview(self) -> None:
|
|
_pr("", "+" + "-" * 68 + "+", "|" + _t("cron.overview_title").center(68) + "|",
|
|
"+" + "-" * 68 + "+", "", f" {_t('cron.overview_commands')}",
|
|
*_lines(_t("cron.overview_usage"), pad=" "), "")
|
|
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(f" {_t('cron.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" {_t('cron.skills', 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" {_t('cron.next_short') if label == 'Next run' else label}: {value}")
|
|
print()
|
|
else:
|
|
print(f" {_t('cron.no_jobs_hint')}")
|
|
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(_t("cron.no_jobs"))
|
|
from hermes_cli.cron import _next_run_row
|
|
print()
|
|
_pr(_t("cron.list_header"), "-" * 80)
|
|
for job in jobs:
|
|
_pr(f" {_t('cron.id', job_id=job['job_id'])}", f" {_t('cron.name', name=job['name'])}",
|
|
f" {_t('cron.state', state=job.get('state', '?'))}",
|
|
f" {_t('cron.schedule_repeat', schedule=job['schedule'], repeat=job.get('repeat', '?'))}",
|
|
" %s: %s" % _next_run_row(job) if job.get("next_run_at") else f" {_t('cron.next_run_na')}")
|
|
if job.get("skills"):
|
|
print(f" {_t('cron.skills', skills=', '.join(job['skills']))}")
|
|
print(f" {_t('cron.prompt', 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 = _t("cron.status_delivery_failed", error=job["last_delivery_error"])
|
|
elif status == "error" and job.get("last_error"):
|
|
status = _t("cron.status_error", error=job["last_error"])
|
|
print(f" {_t('cron.last_run', when=job['last_run_at'], status=status)}")
|
|
print()
|
|
|
|
def _cron_add(self, subcommand: str, opts: dict) -> None:
|
|
positionals = opts["positionals"]
|
|
if not positionals:
|
|
return print(_t("cron.usage_add"))
|
|
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(_t("cron.need_prompt_or_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(_t("cron.create_failed", error=result.get("error")))
|
|
_pr(_t("cron.created", job_id=result["job_id"]),
|
|
f" {_t('cron.schedule', schedule=result['schedule'])}")
|
|
if result.get("skills"):
|
|
print(f" {_t('cron.skills', skills=', '.join(result['skills']))}")
|
|
print(f" {_t('cron.next_run', value=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(_t("cron.usage_edit"))
|
|
job_id = positionals[0]
|
|
existing = get_job(job_id)
|
|
if not existing:
|
|
return print(_t("cron.job_not_found", job_id=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(_t("cron.update_failed", error=result.get("error")))
|
|
job = result["job"]
|
|
_pr(_t("cron.updated", job_id=job["job_id"]), f" {_t('cron.schedule', schedule=job['schedule'])}",
|
|
f" {_t('cron.skills', skills=', '.join(job['skills']))}" if job.get("skills")
|
|
else f" {_t('cron.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(_t("cron.usage_action", subcommand=subcommand))
|
|
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(_t(f"cron.{action}_failed", error=result.get("error")))
|
|
if action == "remove":
|
|
removed = result.get("removed_job", {})
|
|
return print(_t("cron.removed", name=removed.get("name", job_id), 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(_t("cron.did_not_run", name=job["name"], job_id=job_id, reason=job["execution_skipped"]))
|
|
# One full template per action verb (cron.paused / cron.resumed / cron.triggered).
|
|
print(_t({"pause": "cron.paused", "resume": "cron.resumed", "run": "cron.triggered"}[action],
|
|
name=job["name"], job_id=job_id))
|
|
if action == "resume":
|
|
print(f" {_t('cron.next_run', value=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 = _t("suggestions.failed", error=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(_t("blueprint.failed", error=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(_t("curator.failed", error=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 = _t("kanban.failed", 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 _t("memory.unknown_subcommand"))
|
|
|
|
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" {_t('shared.needs_active_session', command=command)}")
|
|
|
|
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" + _t("learn.from_description" if user_request else "learn.from_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("\n" + (_t("plan.planning", task=_ellipsize(task, 80)) if task else _t("plan.from_conversation")))
|
|
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
|
|
# session_key="" targets the single-session CLI's "default" cwd record, which tracks
|
|
# `cd` and workspace switches, so /init follows the directory the user works in.
|
|
msg = build_init_prompt_for_cwd(extra=_command_arg(cmd), session_key="") # optional user emphasis
|
|
print("\n" + _t("init.updating" if "UPDATE the existing AGENTS.md" in msg else "init.generating"))
|
|
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(*_lines(_t("background.usage")))
|
|
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(f" {_t('background.no_credentials')}")
|
|
preview = _ellipsize(prompt, 60)
|
|
_cp(f" {_t('background.started', number=task_num, preview=preview)}",
|
|
f" {_t('background.task_id', task_id=task_id)}", f" {_t('background.keep_chatting')}\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 = _gt("model.error_prefix", 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=_t("background.label", number=task_num),
|
|
header_lines=[f" {_t('background.complete', number=task_num)}",
|
|
f" {_t('background.prompt', preview=preview)}"],
|
|
title_suffix=_t("background.title_suffix", number=task_num),
|
|
empty_note=f" {_t('background.no_response')}", 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=_t("side_result.complete", label=fail_label))
|
|
except Exception as e:
|
|
_refresh_tui_before_print(self)
|
|
line = f" {_t('side_result.failed', label=fail_label, error=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=_t("login.label"), header_lines=[f" {_t('login.label')}"],
|
|
title_suffix=_t("login.title_suffix"), empty_note=f" {_t('login.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(*_lines(_t("btw.usage")))
|
|
if not self._ensure_runtime_credentials():
|
|
return _cp(f" {_t('btw.no_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(*_lines(_gt("btw.started", preview=preview)), "")
|
|
|
|
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" {_t('btw.header', preview=preview)}"],
|
|
title_suffix=_t("btw.title_suffix"),
|
|
empty_note=f" {_t('btw.no_answer')}").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;31m{_t('bundles.unavailable', error=reply.data['error'])}{_RST}")
|
|
bundles = reply.data["bundles"]
|
|
if not bundles:
|
|
return _cp(f" {_t('bundles.none_installed')}", _dim_line(_t("bundles.create_hint")),
|
|
_dim_line(_t("bundles.directory", dir=reply.data["dir"])))
|
|
_cp(f"\n ▣ {_BOLD}{_t('bundles.title')}{_RST} {_t('bundles.installed_count', count=len(bundles))}")
|
|
for info in bundles:
|
|
skill_count = len(info.get("skills", []))
|
|
desc = info.get("description") or _t("bundles.default_desc", count=skill_count)
|
|
ChatConsole().print(
|
|
f" [bold {_accent_hex()}]/{info['slug']:<20}[/] "
|
|
f"[dim]-[/] {_escape(desc)} [dim]{_escape(_t('bundles.skills_count', count=skill_count))}[/]")
|
|
for s in info.get("skills", []):
|
|
ChatConsole().print(f" [dim]· {_escape(s)}[/]")
|
|
_cp("\n" + _dim_line(_t("bundles.invoke_hint")))
|
|
|
|
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(
|
|
_t("browser.usage"), "",
|
|
*(f" {_t(f'browser.usage_{sub}')}" for sub in ("connect", "disconnect", "status", "use")))
|
|
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(_t("shared.unavailable_no_session", label=label)))
|
|
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, _t("heartbeat.label"))
|
|
if mgr is None:
|
|
return
|
|
if not arg or lower == "status":
|
|
_cp(f" {mgr.status_line()}")
|
|
elif lower == "pause":
|
|
state = mgr.pause()
|
|
_cp(f" {_t('heartbeat.paused', prompt=state.prompt)}" if state
|
|
else _dim_line(_t("heartbeat.none_set")))
|
|
elif lower == "resume":
|
|
state = mgr.resume()
|
|
if state is None:
|
|
_cp(_dim_line(_t("heartbeat.none_to_resume")))
|
|
else:
|
|
self._start_heartbeat_watchdog()
|
|
_cp(f" {_t('heartbeat.resumed', interval=format_interval(state.interval_seconds), prompt=state.prompt)}")
|
|
elif lower in {"clear", "stop", "off"}:
|
|
_cp(f" {_t('heartbeat.cleared')}" if mgr.clear() else _dim_line(_t("heartbeat.none_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(f" {_t('heartbeat.usage')}", _dim_line(_t("heartbeat.usage_also")))
|
|
if interval < 0:
|
|
from hermes_cli.heartbeat import MIN_INTERVAL_SECONDS
|
|
return _cp(f" {_t('heartbeat.interval_too_small', min_seconds=MIN_INTERVAL_SECONDS)}")
|
|
if not prompt.strip():
|
|
return _cp(f" {_t('heartbeat.prompt_required')}")
|
|
state = _attempt(_t("heartbeat.invalid_label"), ValueError, mgr.set, prompt, interval)
|
|
if state is _FAILED:
|
|
return
|
|
self._start_heartbeat_watchdog()
|
|
_cp(f" {_t('heartbeat.set', interval=format_interval(state.interval_seconds), prompt=state.prompt)}",
|
|
_dim_line(_t("heartbeat.set_note")))
|
|
|
|
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(_t("refine.nothing_yet")))
|
|
snapshot = list(getattr(self, "conversation_history", None) or [])
|
|
if not snapshot:
|
|
return _cp(_dim_line(_t("refine.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" {_t('refine.failed', error=exc)}")
|
|
tail = _t("refine.focus_suffix", focus=focus) if focus else ""
|
|
_cp(f" {_t('refine.started', focus=tail)}")
|
|
|
|
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(_t("review.nothing_yet")))
|
|
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" {_t('review.failed', error=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, _t("goal.label"))
|
|
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(_t("goal.continuing") if queued else _t("goal.send_to_kick")))
|
|
|
|
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, _t("loop.label"))
|
|
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(_t("loop.goal_defers_note")))
|
|
|
|
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, _t("goal.label"))
|
|
if mgr is None:
|
|
return
|
|
if not mgr.has_goal():
|
|
return _cp(_dim_line(_t("subgoal.no_goal")))
|
|
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(f" {_t('subgoal.usage_remove')}")
|
|
try:
|
|
idx = int(rest.split()[0])
|
|
except ValueError:
|
|
return _cp(f" {_t('subgoal.remove_needs_int')}")
|
|
removed = _attempt("/subgoal remove", (IndexError, RuntimeError), mgr.remove_subgoal, idx)
|
|
if removed is not _FAILED:
|
|
_cp(f" {_t('subgoal.removed', index=idx, text=removed)}")
|
|
elif verb == "clear":
|
|
prev = _attempt("/subgoal clear", RuntimeError, mgr.clear_subgoals)
|
|
if prev is not _FAILED:
|
|
_cp(" " + _tn("subgoal.cleared", prev) if prev else _dim_line(_t("subgoal.none_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" {_t('subgoal.added', index=idx, text=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(_t("skin.unavailable"))
|
|
new_skin = _command_arg(cmd).lower()
|
|
if not new_skin: # show current skin and list available
|
|
current = get_active_skin_name()
|
|
_pr(f"\n {_t('skin.current', name=current)}", f" {_t('skin.available_header')}")
|
|
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(f"\n {_t('skin.usage')}",
|
|
f" {_t('skin.custom_hint', dir=display_hermes_home())}\n")
|
|
available = {s["name"] for s in list_skins()}
|
|
if new_skin not in available:
|
|
return _pr(f" {_t('skin.unknown', name=new_skin)}",
|
|
f" {_t('skin.available', names=', '.join(sorted(available)))}")
|
|
set_active_skin(new_skin)
|
|
_ACCENT.reset() # re-resolve ANSI color for the new skin (_DIM is a fixed escape)
|
|
saved = _t("skin.saved_suffix") if _save("display.skin", new_skin) else ""
|
|
_pr(f" {_t('skin.set_to', name=new_skin, saved=saved)}", f" {_t('skin.banner_note')}")
|
|
if self._apply_tui_skin_style():
|
|
print(f" {_t('skin.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(_t("prompt_compose.editor_header") + "\n\n")
|
|
if initial_text:
|
|
fh.write(initial_text)
|
|
try:
|
|
editor_argv = [*shlex.split(editor), path]
|
|
except ValueError:
|
|
return "" # unbalanced quotes in $EDITOR: cancel, never retry through a shell
|
|
try:
|
|
status = subprocess.call(editor_argv)
|
|
except OSError:
|
|
return "" # editor not runnable: cancel the compose (#81364)
|
|
# A failed editor may leave seeded or abandoned text in the buffer.
|
|
if status != 0:
|
|
return ""
|
|
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(_t("prompt_compose.editor_failed", error=exc)))
|
|
if not composed:
|
|
return _cp(_dim_line(_t("prompt_compose.empty")))
|
|
# 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(f" {_t('focus.usage')}")
|
|
# 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 = _t("shared.state_on") if current else _t("shared.state_off")
|
|
return _cp(f" {_Colors.BOLD}{label}:{_Colors.RESET} {state}{status_line}")
|
|
if new_state is None:
|
|
return _cp(f" {_t('shared.usage', usage=usage)}")
|
|
if _save(config_key, new_state):
|
|
colour = _Colors.GREEN if new_state else _Colors.DIM
|
|
state = _t("shared.state_on") if new_state else _t("shared.state_off")
|
|
_cp(f" {label}: {colour}{state}{_Colors.RESET}")
|
|
else:
|
|
_cp(f" {_t('toggle.save_failed', setting=failed)}")
|
|
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=_t("footer.usage"), status_line=f"\n {_t('footer.fields', fields=', '.join(fields))}",
|
|
config_key="display.runtime_footer.enabled", label=_t("footer.label"), 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=_t("timestamps.usage"), status_line="",
|
|
config_key="display.timestamps", label=_t("timestamps.label"), 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)
|
|
from hermes_cli.codex_runtime_switch import get_current_runtime
|
|
# The live agent's api_mode, else the configured runtime: ``ultra`` is verbatim on the Codex app-server.
|
|
_route = (getattr(self, "provider", None), getattr(self, "model", None),
|
|
getattr(getattr(self, "agent", None), "api_mode", None) or get_current_runtime(CLI_CONFIG))
|
|
if not raw: # show current state
|
|
rc = self.reasoning_config
|
|
level = (_gt("reasoning.level_default") if rc is None else _gt("reasoning.level_disabled")
|
|
if rc.get("enabled") is False else effort_display_label(rc.get("effort", "medium"), *_route))
|
|
display_state = _t("reasoning.display_on") if self.show_reasoning else _t("reasoning.display_off")
|
|
full_state = (_t("reasoning.full_state_full") if getattr(self, "reasoning_full", False)
|
|
else _t("reasoning.full_state_clamped"))
|
|
return _cp(_accent_line(_t("reasoning.effort_line", level=level)),
|
|
_accent_line(_t("reasoning.display_line", display=display_state, full=full_state)),
|
|
_dim_line(_t("reasoning.usage")))
|
|
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_key, note_key = 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(_t("reasoning.display_saved", headline=_t(f"reasoning.{headline_key}"))))
|
|
if note_key:
|
|
_cp(_dim_line(f" {_t(f'reasoning.{note_key}')}"))
|
|
if attr == "reasoning_full" and value and not self.show_reasoning:
|
|
_cp(_dim_line(f" {_t('reasoning.display_off_note')}"))
|
|
return
|
|
# Effort level change
|
|
parsed = _parse_reasoning_config(arg)
|
|
if parsed is None:
|
|
return _cp(_dim_line(_t("shared.unknown_argument", arg=arg)),
|
|
_dim_line(_t("reasoning.valid_levels")), _dim_line(_t("reasoning.valid_display")),
|
|
_dim_line(_t("reasoning.valid_scope")))
|
|
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(_t("reasoning.effort_set", effort=effort_display_label(arg, *_route),
|
|
scope=_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(_t("busy.usage"))
|
|
if not arg or arg == "status":
|
|
mode = self.busy_input_mode if self.busy_input_mode in _BUSY_MODES else "interrupt"
|
|
return _cp(_accent_line(_t("busy.status", mode=self.busy_input_mode)),
|
|
_dim_line(_t("busy.enter_while_busy", behavior=_t(f"busy.short_{mode}"))), usage)
|
|
if arg not in _BUSY_MODES:
|
|
return _cp(_dim_line(_t("shared.unknown_argument", arg=arg)), usage)
|
|
self.busy_input_mode = arg
|
|
_persist_display_choice("display.busy_input_mode", arg, _t("busy.label"), _t(f"busy.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(_t("indicator.usage", styles="|".join(INDICATOR_STYLES)))
|
|
if not arg or arg == "status":
|
|
return _cp(_accent_line(_t("indicator.status", style=current)), usage)
|
|
if arg not in INDICATOR_STYLES:
|
|
return _cp(_dim_line(_t("indicator.unknown_style", arg=arg)), usage)
|
|
self.config.setdefault("display", {})["tui_status_indicator"] = arg
|
|
_persist_display_choice("display.tui_status_indicator", arg, _t("indicator.label"), _t("indicator.note"))
|
|
|
|
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(f" {_t('fast.not_supported')}")
|
|
# 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 = _t("fast.feature_generic" if anthropic is None
|
|
else "fast.feature_anthropic" if anthropic else "fast.feature_openai")
|
|
raw = _command_arg(cmd)
|
|
usage = _dim_line(_t("fast.usage"))
|
|
if not raw or raw.lower() == "status":
|
|
from agent.fast_mode import service_tier_word
|
|
status = service_tier_word(self.service_tier)
|
|
return _cp(_accent_line(_t("fast.status", feature=feature_name, status=status)), usage)
|
|
arg, explicit_global = _split_scope_flags(raw)
|
|
if arg not in _FAST_TIERS:
|
|
return _cp(_dim_line(_t("shared.unknown_argument", arg=arg)), usage)
|
|
if arg == "ultrafast":
|
|
if not _probe("hermes_cli.models", "model_supports_ultrafast", False, model):
|
|
return _cp(_dim_line(_t("fast.ultrafast_not_supported", model=model or "?")), usage)
|
|
feature_name = _t("fast.feature_ultrafast")
|
|
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(_t("fast.set_to", feature=feature_name, value=saved_value.upper(), scope=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(_t('update.managed_action'))}")
|
|
return False
|
|
# prompt_toolkit-native modal: renders above the composer, no raw input() races.
|
|
choices = [("once", _t("update.choice_update"), _t("update.choice_update_desc")),
|
|
("cancel", _t("update.choice_cancel"), _t("update.choice_cancel_desc"))]
|
|
raw = self._prompt_text_input_modal(
|
|
title=_t("update.title"), detail=_t("update.detail"), choices=choices)
|
|
if raw is None or self._normalize_slash_confirm_choice(raw, choices) != "once":
|
|
print(f" {_t('update.cancelled')}")
|
|
return False
|
|
_say_block(f" {_t('update.launching')}")
|
|
# 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(_t("voice.unknown_subcommand", subcommand=subcommand), _t("voice.usage"))
|
|
|
|
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(_t("wake.unknown_subcommand", subcommand=subcommand), _t("wake.usage"))
|
|
|
|
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(_t("wake.enabled_in_config" if enabled else "wake.disabled_in_config")))
|