From a1075ddd2f2715f2f718f6b93266f6eb5666ca00 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:41:00 -0700 Subject: [PATCH 01/22] refactor(hermes_cli): split /handoff into phase helpers, dict-route /snapshot /worktree /cron, unify print helpers in cli_commands_mixin --- hermes_cli/cli_commands_mixin.py | 2867 +++++++++++++----------------- 1 file changed, 1229 insertions(+), 1638 deletions(-) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index ebf499e8a5..255864f0fa 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -14,6 +14,7 @@ import sys import threading import time import uuid +from contextlib import suppress from datetime import datetime from urllib.parse import urlparse @@ -34,38 +35,113 @@ from hermes_cli.browser_connect import ( ) -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.""" - try: - from tools.browser_tool import lightpanda_engine_status, _using_lightpanda_engine +# --------------------------------------------------------------------------------------- +# Output helpers. Slash-command text is user-visible: every literal below is load-bearing. +# --------------------------------------------------------------------------------------- - if not _using_lightpanda_engine(): - return - used, reason = lightpanda_engine_status() - except Exception: - return - if not used: - print(f" ⚠ browser.engine is 'lightpanda' but it is NOT in use: {reason}") - return - print(f" Engine: Lightpanda — {reason} (no screenshots)") - try: - from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary +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) - lightpanda_bin = find_lightpanda_binary() - except Exception: - return - if lightpanda_bin: - print(f" Binary: {lightpanda_bin}") + +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 f" {_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 _say_block(*lines: str) -> None: + """print() the lines framed by a blank line above and below (the /browser output style).""" + print() + for line in lines: + print(line) + print() + + +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.""" + import shlex + try: + return shlex.split(cmd)[1:] if cmd else [] + except ValueError: + return (cmd or "").split()[1:] + + +def _take_flag(parts: list, flag: str): + """Pop ``flag VALUE`` out of ``parts``: ``(rest, value, ok)``; ok=False when the value is + missing (caller prints usage).""" + if flag not in parts: + return parts, None, True + idx = parts.index(flag) + if idx + 1 >= len(parts): + return parts, None, False + return parts[:idx] + parts[idx + 2:], parts[idx + 1], True + + +def _summarize_paths(paths, limit: int = 5) -> str: + """``a, b, c (+N more)`` for a list of paths.""" + more = f" (+{len(paths) - limit} more)" if len(paths) > limit else "" + return ", ".join(paths[:limit]) + more + + +def _ellipsize(text: str, limit: int) -> str: + """``text[:limit]`` plus ``...`` when truncated.""" + return f"{text[:limit]}{'...' if len(text) > limit else ''}" + + +def _plural(n: int, word: str) -> str: + return f"{n} {word}{'s' if n != 1 else ''}" + + +def _print_capped(lines_text: str, limit: int, tail: str, emit) -> None: + """Emit ``lines_text`` through ``emit``, capped at ``limit`` lines with ``tail`` after.""" + lines = lines_text.splitlines() + if len(lines) > limit: + emit("\n".join(lines[:limit])) + print(f"\n ... ({len(lines) - limit} more lines{tail})") else: - print(f" ⚠ lightpanda binary not found — {LIGHTPANDA_INSTALL_HINT}") + emit(lines_text) -# /cron flag tables: flag -> opts key. Order-sensitive in _parse_flags: bool flags never consume -# a value; --repeat is int-validated separately. +# --------------------------------------------------------------------------------------- +# 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"} @@ -82,7 +158,6 @@ _BUSY_MODE_LONG = { "interrupt": "Enter will redirect the current run while Hermes is busy; /stop still cancels it.", } - # /fast argument -> (service_tier value, persisted config value) _FAST_TIERS = { "fast": ("priority", "fast"), "on": ("priority", "fast"), @@ -90,6 +165,13 @@ _FAST_TIERS = { "auto": ("auto", "auto"), "cold": ("cold", "cold"), } +# /diff argument -> mode (anything else is a path; --stat/stat is the stat flag). +_DIFF_MODES = { + "staged": "staged", "--staged": "staged", "cached": "staged", "--cached": "staged", + "all": "all", "--all": "all", "head": "all", "session": "session", +} +_DIFF_LABELS = {"working": "Unstaged", "staged": "Staged", "all": "All (vs HEAD)"} + def _split_scope_flags(raw: str): """``(arg, explicit_global)`` for /reasoning + /fast: session scope by default, ``--global`` @@ -121,19 +203,6 @@ def _toggle_target(arg: str, current: bool): return None -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 _summarize_paths(paths, limit: int = 5) -> str: - """``a, b, c (+N more)`` for a list of paths.""" - more = f" (+{len(paths) - limit} more)" if len(paths) > limit else "" - return ", ".join(paths[:limit]) + more - - def _cron_api(**kwargs) -> dict: """Call the cronjob model tool and decode its JSON reply.""" from tools.cronjob_tools import cronjob as cronjob_tool @@ -183,32 +252,20 @@ def _parse_cron_flags(tokens): return opts -# /cron subcommand -> CLICommandsMixin handler (bound late, after the class body). -_CRON_SUBCOMMANDS: dict = {} - - -def _say_block(*lines: str) -> None: - """print() the lines framed by a blank line above and below (the /browser output style).""" - print() - for line in lines: - print(line) - print() - +# --------------------------------------------------------------------------------------- +# 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: - try: + with suppress(Exception): cli.agent._flush_messages_to_session_db( cli.conversation_history, conversation_history=cli.conversation_history, ) - except Exception: - pass - try: + with suppress(Exception): cli._session_db.end_session(cli.session_id, reason) - except Exception: - pass def _sync_agent_to_session(cli, session_id: str, *, parent_session_id: str, reason: str) -> None: @@ -224,21 +281,22 @@ def _sync_agent_to_session(cli, session_id: str, *, parent_session_id: str, reas if hasattr(cli.agent, "_last_flushed_db_idx"): cli.agent._last_flushed_db_idx = len(cli.conversation_history) if hasattr(cli.agent, "_todo_store"): - try: + with suppress(Exception): from tools.todo_tool import TodoStore cli.agent._todo_store = TodoStore() - except Exception: - pass if hasattr(cli.agent, "_invalidate_system_prompt"): cli.agent._invalidate_system_prompt() - try: + with suppress(Exception): _mm = getattr(cli.agent, "_memory_manager", None) if _mm is not None: _mm.on_session_switch( session_id, parent_session_id=parent_session_id or "", reset=False, reason=reason, ) - except Exception: - pass + + +def _db_unavailable_line() -> str: + from hermes_state import format_session_db_unavailable + return f" {format_session_db_unavailable()}" def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_note) -> None: @@ -246,10 +304,7 @@ def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_not ``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, _cprint, _maybe_remap_for_light_mode, _render_final_assistant_content - if cli._app: - cli._app.invalidate() - time.sleep(0.05) # brief pause for refresh - print() + _refresh_tui_before_print(cli) ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") for line in header_lines: _cprint(line) @@ -277,6 +332,46 @@ def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_not )) +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.""" + try: + from tools.browser_tool import lightpanda_engine_status, _using_lightpanda_engine + + if not _using_lightpanda_engine(): + return + used, reason = lightpanda_engine_status() + except Exception: + return + if not used: + print(f" ⚠ browser.engine is 'lightpanda' but it is NOT in use: {reason}") + return + print(f" Engine: Lightpanda — {reason} (no screenshots)") + try: + from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary + + lightpanda_bin = find_lightpanda_binary() + except Exception: + return + if lightpanda_bin: + print(f" Binary: {lightpanda_bin}") + else: + print(f" ⚠ lightpanda binary not found — {LIGHTPANDA_INSTALL_HINT}") + + def _browser_use(cli, arg: str) -> None: """/browser use [off] — toggle Browser Use mode (browser.backend); resets the session.""" from hermes_cli.config import load_config, save_config @@ -302,99 +397,110 @@ def _browser_use(cli, arg: str) -> None: cli.new_session() _say_block(headline, " Session reset. New tool configuration is active.") + +def _normalize_cdp_url(cdp_url: str): + """Validate a /browser connect URL: ``(cdp_url, port)`` or None after printing the error. + A ``/devtools/browser/`` path is kept verbatim; anything else is reduced to the origin.""" + parsed = urlparse(cdp_url if "://" in cdp_url else f"http://{cdp_url}") + if parsed.scheme not in {"http", "https", "ws", "wss"}: + _say_block( + f" ⚠ Unsupported browser url scheme: {parsed.scheme or '(missing)'} " + "(expected one of: http, https, ws, wss)" + ) + return None + try: + port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80) + except ValueError: + _say_block(f" ⚠ Invalid port in browser url: {cdp_url}") + return None + if not parsed.hostname: + _say_block(f" ⚠ Missing host in browser url: {cdp_url}") + return None + if parsed.path.startswith("/devtools/browser/"): + return parsed.geturl(), port + return parsed._replace(path="", params="", query="", fragment="").geturl(), port + + +def _launch_default_cdp_browser(port: int): + """Launch a local debug browser for the default CDP URL; returns the discovered CDP url or + None (after printing what went wrong / how to launch manually).""" + import platform as _plat + + launch_port = port + if local_port_in_use(port): + launch_port = find_free_debug_port(port) + print(f" ⚠ Port {port} is occupied by another application that isn't a CDP browser") + print(f" (an IDE debugger or dev server may be using it) — launching on port {launch_port} instead...") + else: + print(" Chromium-family browser isn't running with remote debugging — attempting to launch...") + launch = launch_chrome_debug(launch_port, _plat.system()) + if not launch.launched: + print(" ⚠ Could not auto-launch a Chromium-family browser") + if launch.hint: + print(f" {launch.hint}") + chrome_cmd = manual_chrome_debug_command(launch_port, _plat.system()) + if chrome_cmd: + print(" Launch a Chromium-family browser manually:") + print(f" {chrome_cmd}") + else: + print(" No supported Chromium-family browser executable found in this environment") + return None + for _wait in range(10): # wait for the DevTools discovery endpoint to come up + found = discover_local_cdp_url(launch_port, timeout=1.0) + if found: + print(f" ✓ Chromium-family browser launched and listening on port {launch_port}") + return found + time.sleep(0.5) + print(f" ⚠ Browser launched but port {launch_port} isn't responding yet") + print(" Try again in a few seconds — the debug instance may still be starting") + return None + + def _browser_connect(cli, cdp_url: str) -> None: """/browser connect [url] — validate the CDP URL, find or launch a debug browser, then point the browser tools at it (BROWSER_CDP_URL) and tell the model.""" - import platform as _plat - - parsed_cdp = urlparse(cdp_url if "://" in cdp_url else f"http://{cdp_url}") - if parsed_cdp.scheme not in {"http", "https", "ws", "wss"}: - _say_block( - f" ⚠ Unsupported browser url scheme: {parsed_cdp.scheme or '(missing)'} " - "(expected one of: http, https, ws, wss)" - ) + normalized = _normalize_cdp_url(cdp_url) + if normalized is None: return - try: - _port = parsed_cdp.port or (443 if parsed_cdp.scheme in {"https", "wss"} else 80) - except ValueError: - _say_block(f" ⚠ Invalid port in browser url: {cdp_url}") - return - if not parsed_cdp.hostname: - _say_block(f" ⚠ Missing host in browser url: {cdp_url}") - return - if parsed_cdp.path.startswith("/devtools/browser/"): - cdp_url = parsed_cdp.geturl() - else: - cdp_url = parsed_cdp._replace(path="", params="", query="", fragment="").geturl() + cdp_url, port = normalized # Clear any existing browser sessions so the next tool call uses the new backend - try: + with suppress(Exception): from tools.browser_tool import cleanup_all_browsers cleanup_all_browsers() - except Exception: - pass print() # Already serving CDP? For the default-local URL probe both loopbacks: a squatter # on 127.0.0.1: (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) - _already_open = _found is not None - if _found: - cdp_url = _found + is_default = cdp_url == DEFAULT_BROWSER_CDP_URL + if is_default: + found = discover_local_cdp_url(port, timeout=1.0) + already_open = found is not None + if found: + cdp_url = found else: - _already_open = is_browser_debug_ready(cdp_url, timeout=1.0) + already_open = is_browser_debug_ready(cdp_url, timeout=1.0) - if _already_open: + if already_open: print(f" ✓ Chromium-family browser is already listening at {cdp_url}") - elif _is_default: - _launch_port = _port - if local_port_in_use(_port): - _launch_port = find_free_debug_port(_port) - print(f" ⚠ Port {_port} is occupied by another application that isn't a CDP browser") - print(f" (an IDE debugger or dev server may be using it) — launching on port {_launch_port} instead...") - else: - print(" Chromium-family browser isn't running with remote debugging — attempting to launch...") - _launch = launch_chrome_debug(_launch_port, _plat.system()) - if _launch.launched: - 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: - cdp_url = _found - _already_open = True - break - time.sleep(0.5) - if _already_open: - print(f" ✓ Chromium-family browser launched and listening on port {_launch_port}") - else: - print(f" ⚠ Browser launched but port {_launch_port} isn't responding yet") - print(" Try again in a few seconds — the debug instance may still be starting") - else: - print(" ⚠ Could not auto-launch a Chromium-family browser") - if _launch.hint: - print(f" {_launch.hint}") - chrome_cmd = manual_chrome_debug_command(_launch_port, _plat.system()) - if chrome_cmd: - print(" Launch a Chromium-family browser manually:") - print(f" {chrome_cmd}") - else: - print(" No supported Chromium-family browser executable found in this environment") + elif is_default: + found = _launch_default_cdp_browser(port) + already_open = found is not None + if found: + cdp_url = found else: - print(f" ⚠ Port {_port} is not reachable at {cdp_url}") + print(f" ⚠ Port {port} is not reachable at {cdp_url}") - if not _already_open: + if not already_open: _say_block("Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect") return os.environ["BROWSER_CDP_URL"] = cdp_url # Eagerly start the CDP supervisor so pending_dialogs + frame_tree show up in the next snapshot. - try: + with suppress(Exception): from tools.browser_tool import _ensure_cdp_supervisor # type: ignore[import-not-found] _ensure_cdp_supervisor("default") - except Exception: - pass _say_block("🌐 Browser connected to live Chromium-family browser via CDP", f" Endpoint: {cdp_url}") # Tell the model the CDP browser was made available on purpose. @@ -411,17 +517,16 @@ def _browser_connect(cli, cdp_url: str) -> None: "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(): _say_block("Browser is not connected to a live Chromium-family browser (already using default mode)") return os.environ.pop("BROWSER_CDP_URL", None) - try: + with suppress(Exception): from tools.browser_tool import cleanup_all_browsers, _stop_cdp_supervisor _stop_cdp_supervisor("default") cleanup_all_browsers() - except Exception: - pass _say_block( "🌐 Browser disconnected from live Chromium-family browser", " Browser tools reverted to default mode (local headless or cloud provider)", @@ -432,6 +537,16 @@ def _browser_disconnect(cli) -> None: "Browser tools are back to default mode (headless local browser or cloud provider).]" ) + +def _browser_tool_attr(name: str, default): + """``tools.browser_tool.()`` or ``default`` when the import/call fails.""" + try: + import tools.browser_tool as bt + return getattr(bt, name)() + except Exception: + return default + + def _browser_status() -> None: current = os.environ.get("BROWSER_CDP_URL", "").strip() print() @@ -451,10 +566,8 @@ def _browser_status() -> None: print(f" Endpoint: {current}") _print_lightpanda_engine_status() _port = 9222 - try: + with suppress(ValueError, IndexError): _port = int(current.rsplit(":", 1)[-1].split("/")[0]) - except (ValueError, IndexError): - pass try: import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -462,23 +575,15 @@ def _browser_status() -> None: s.connect(("127.0.0.1", _port)) s.close() print(" Status: ✓ reachable") - except (OSError, Exception): + except Exception: print(" Status: ⚠ not reachable (browser may not be running)") else: - try: - from tools.browser_tool import _get_cloud_provider - provider = _get_cloud_provider() - except Exception: - provider = None + provider = _browser_tool_attr("_get_cloud_provider", None) if provider is not None: print(f"🌐 Browser: {provider.provider_name()} (cloud)") _print_lightpanda_engine_status() else: - try: - from tools.browser_tool import _get_browser_engine - engine = _get_browser_engine() - except Exception: - engine = "auto" + engine = _browser_tool_attr("_get_browser_engine", "auto") if engine == "lightpanda": print("🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)") print(" ⚡ Lightpanda: faster navigation, no screenshot support") @@ -497,42 +602,41 @@ def _browser_status() -> None: 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: + print(" No active agent session.") + return None + mgr = self.agent._checkpoint_mgr + if not mgr.enabled: + for line in disabled_lines: + print(line) + return None + return mgr + def _handle_rollback_command(self, command: str): """Handle /rollback — list, diff, or restore filesystem checkpoints. - Syntax: - /rollback — list checkpoints - /rollback — restore checkpoint N, preserving user hand-edits - (also undoes last chat turn) - /rollback --all — classic full restore (may overwrite your later edits) - /rollback diff — preview changes since checkpoint N - /rollback — restore a single file from checkpoint N""" + ``/rollback`` lists; ```` restores N preserving user hand-edits (and undoes the last + chat turn); `` --all`` is the classic full restore; ``diff `` previews changes since + N; `` `` restores a single file.""" from tools.checkpoint_manager import format_checkpoint_list - if not hasattr(self, 'agent') or not self.agent: - print(" No active agent session.") - return - - mgr = self.agent._checkpoint_mgr - if not mgr.enabled: - print(" Checkpoints are not enabled.") - print(" Enable with: hermes --checkpoints") - print(" Or in config.yaml: checkpoints: { enabled: true }") + mgr = self._checkpoint_manager(( + " Checkpoints are not enabled.", + " Enable with: hermes --checkpoints", + " Or in config.yaml: checkpoints: { enabled: true }", + )) + if mgr is None: return cwd = os.getenv("TERMINAL_CWD", os.getcwd()) - parts = command.split() - args = parts[1:] if len(parts) > 1 else [] - + args = command.split()[1:] # --all / --force: classic full restore, overwriting user edits too. - restore_all = False - filtered = [] - for a in args: - if a.lower() in ("--all", "--force"): - restore_all = True - else: - filtered.append(a) - args = filtered + 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 not args: # List checkpoints — fall back to the cross-project view when the current @@ -547,111 +651,85 @@ class CLICommandsMixin: print(format_checkpoint_list(checkpoints, cwd)) return - # Handle /rollback diff - if args[0].lower() == "diff": - if len(args) < 2: - print(" Usage: /rollback diff ") - return - checkpoints = mgr.list_checkpoints(cwd) - if not checkpoints: - print(f" No checkpoints found for {cwd}") - return - target_hash = self._resolve_checkpoint_ref(args[1], checkpoints) - if not target_hash: - return - result = mgr.diff(cwd, target_hash) - if result["success"]: - stat = result.get("stat", "") - diff = result.get("diff", "") - if not stat and not diff: - print(" No changes since this checkpoint.") - else: - if stat: - print(f"\n{stat}") - if diff: - # Limit diff output to avoid terminal flood - diff_lines = diff.splitlines() - if len(diff_lines) > 80: - print("\n".join(diff_lines[:80])) - print(f"\n ... ({len(diff_lines) - 80} more lines, showing first 80)") - else: - print(f"\n{diff}") - else: - print(f" ❌ {result['error']}") + is_diff = args[0].lower() == "diff" + if is_diff and len(args) < 2: + print(" Usage: /rollback diff ") return - - # Resolve checkpoint reference (number or hash) checkpoints = mgr.list_checkpoints(cwd) if not checkpoints: print(f" No checkpoints found for {cwd}") return - - target_hash = self._resolve_checkpoint_ref(args[0], checkpoints) + target_hash = self._resolve_checkpoint_ref(args[1 if is_diff else 0], checkpoints) if not target_hash: return - - # Check for file-level restore: /rollback - file_path = args[1] if len(args) > 1 else None - - result = mgr.restore( - cwd, target_hash, file_path=file_path, - safe=not restore_all and not file_path, - ) - if result["success"]: - if file_path: - print(f" ✅ Restored {file_path} from checkpoint {result['restored_to']}: {result['reason']}") - else: - print(f" ✅ Restored to checkpoint {result['restored_to']}: {result['reason']}") - skipped = result.get("skipped_user_edits") or [] - if skipped: - print(f" ↷ Kept your hand-edits: {_summarize_paths(skipped)}") - print(" Use /rollback --all to restore those too.") - oversize = result.get("skipped_oversize") or [] - if oversize: - print(f" ↷ Kept (too large for checkpoints, no stored copy to revert to): {_summarize_paths(oversize)}") - failed = result.get("failed_deletes") or [] - if failed: - print(f" ⚠️ Could not remove (left in place): {_summarize_paths(failed)}") - print(" A pre-rollback snapshot was saved automatically.") - - # Also undo the last conversation turn so the agent's context - # matches the restored filesystem state - if self.conversation_history: - self.undo_last(prefill=False) - print(" Chat turn undone to match restored file state.") + if is_diff: + self._rollback_diff(mgr, cwd, target_hash) else: + self._rollback_restore(mgr, cwd, target_hash, args[1] if len(args) > 1 else None, restore_all) + + def _rollback_diff(self, mgr, cwd: str, target_hash: str) -> None: + result = mgr.diff(cwd, target_hash) + if not result["success"]: print(f" ❌ {result['error']}") + return + stat, diff = result.get("stat", ""), result.get("diff", "") + if not stat and not diff: + print(" No changes since this checkpoint.") + return + if stat: + print(f"\n{stat}") + if diff: + # Limit diff output to avoid terminal flood + diff_lines = diff.splitlines() + if len(diff_lines) > 80: + print("\n".join(diff_lines[:80])) + print(f"\n ... ({len(diff_lines) - 80} more lines, showing first 80)") + else: + print(f"\n{diff}") + + def _rollback_restore(self, mgr, cwd: str, 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"]: + print(f" ❌ {result['error']}") + return + what = f"{file_path} from checkpoint" if file_path else "to checkpoint" + print(f" ✅ Restored {what} {result['restored_to']}: {result['reason']}") + skipped = result.get("skipped_user_edits") or [] + if skipped: + print(f" ↷ Kept your hand-edits: {_summarize_paths(skipped)}") + print(" Use /rollback --all to restore those too.") + oversize = result.get("skipped_oversize") or [] + if oversize: + print(f" ↷ Kept (too large for checkpoints, no stored copy to revert to): {_summarize_paths(oversize)}") + failed = result.get("failed_deletes") or [] + if failed: + print(f" ⚠️ Could not remove (left in place): {_summarize_paths(failed)}") + print(" A pre-rollback snapshot was saved automatically.") + # Also undo the last conversation turn so the agent's context matches the restored files. + if self.conversation_history: + self.undo_last(prefill=False) + print(" Chat turn undone to match restored file state.") + + # ---- /diff ---------------------------------------------------------------------------- def _handle_diff_command(self, command: str): """Handle /diff — show git changes in the working directory. Syntax: ``/diff`` (unstaged + untracked), ``staged``, ``all`` (vs HEAD), ``session`` (everything Hermes changed since the checkpoint baseline), ``[mode] --stat``, ``[mode] ``.""" - import shlex - - try: - parts = shlex.split(command)[1:] # preserves quoted paths - except ValueError: - parts = command.split()[1:] - stat_only = False mode = "working" paths: list[str] = [] - for arg in parts: + for arg in _shlex_args(command): # shlex preserves quoted paths low = arg.lower() if low in ("--stat", "stat"): stat_only = True - elif low in ("staged", "--staged", "cached", "--cached"): - mode = "staged" - elif low in ("all", "--all", "head"): - mode = "all" - elif low == "session": - mode = "session" + elif low in _DIFF_MODES: + mode = _DIFF_MODES[low] else: paths.append(arg) cwd = os.getenv("TERMINAL_CWD", os.getcwd()) - if mode == "session": self._print_session_diff(cwd, stat_only) return @@ -662,17 +740,13 @@ class CLICommandsMixin: if not result.get("success"): print(f" {result.get('error', 'Could not generate diff')}") return - - stat = result.get("stat", "") - diff = result.get("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): print(" No changes.") return - - label = {"working": "Unstaged", "staged": "Staged", "all": "All (vs HEAD)"}[mode] if stat: - print(f"\n {label}:") + print(f"\n {_DIFF_LABELS[mode]}:") self._print_diff_text(stat) if untracked and mode in ("working", "all"): print("\n Untracked:") @@ -686,39 +760,27 @@ class CLICommandsMixin: 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.""" - diff_lines = diff.splitlines() print("") - if len(diff_lines) > limit: - self._print_diff_text("\n".join(diff_lines[:limit])) - print(f"\n ... ({len(diff_lines) - limit} more lines — {stat_hint})") - else: - self._print_diff_text(diff) + _print_capped(diff, limit, f" — {stat_hint}", self._print_diff_text) def _print_session_diff(self, cwd: str, stat_only: bool): """Print the cumulative checkpoint-baseline diff (/diff session).""" - if not hasattr(self, 'agent') or not self.agent: - print(" No active agent session.") + mgr = self._checkpoint_manager(( + " Checkpoints are not enabled, so there's no session baseline.", + " Enable with: hermes --checkpoints", + " Or in config.yaml: checkpoints: { enabled: true }", + " (Plain /diff still works — it uses git directly.)", + )) + if mgr is None: return - - mgr = self.agent._checkpoint_mgr - if not mgr.enabled: - print(" Checkpoints are not enabled, so there's no session baseline.") - print(" Enable with: hermes --checkpoints") - print(" Or in config.yaml: checkpoints: { enabled: true }") - print(" (Plain /diff still works — it uses git directly.)") - return - result = mgr.session_diff(cwd) if not result.get("success"): print(f" {result.get('error', 'Could not generate diff')}") return - - stat = result.get("stat", "") - diff = result.get("diff", "") + stat, diff = result.get("stat", ""), result.get("diff", "") if result.get("empty") or (not stat and not diff): print(" No changes — Hermes hasn't edited any files here yet.") return - if stat: self._print_diff_text(f"\n{stat}") if stat_only or not diff: @@ -738,180 +800,148 @@ class CLICommandsMixin: pass print(text) + # ---- /snapshot ------------------------------------------------------------------------ + def _handle_snapshot_command(self, command: str): """Handle /snapshot — lightweight state snapshots for Hermes config/state. Syntax: ``/snapshot`` (list), ``create [label]``, ``restore ``, ``prune [N]`` (default 20).""" - from hermes_cli.backup import ( - create_quick_snapshot, list_quick_snapshots, - restore_quick_snapshot, prune_quick_snapshots, - ) - from hermes_constants import display_hermes_home - parts = command.split() subcmd = parts[1].lower() if len(parts) > 1 else "list" - - if subcmd in {"list", "ls"}: - snaps = list_quick_snapshots() - if not snaps: - print(" No state snapshots yet.") - print(" Create one: /snapshot create [label]") - return - print(f" State snapshots ({display_hermes_home()}/state-snapshots/):\n") - print(f" {'#':>3} {'ID':<35} {'Files':>5} {'Size':>10} {'Label'}") - print(f" {'─'*3} {'─'*35} {'─'*5} {'─'*10} {'─'*20}") - for i, s in enumerate(snaps, 1): - size = s.get("total_size", 0) - if size < 1024: - size_str = f"{size} B" - elif size < 1024 * 1024: - size_str = f"{size / 1024:.0f} KB" - else: - size_str = 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}") - - elif subcmd == "create": - label = " ".join(parts[2:]) if len(parts) > 2 else None - snap_id = create_quick_snapshot(label=label) - if snap_id: - print(f" Snapshot created: {snap_id}") - else: - print(" No state files found to snapshot.") - - elif subcmd in {"restore", "rewind"}: - if len(parts) < 3: - print(" Usage: /snapshot restore ") - snaps = list_quick_snapshots(limit=1) - if snaps: - print(f" Most recent: {snaps[0]['id']}") - return - snap_id = parts[2] - # Allow restore by number (1-indexed) - try: - idx = int(snap_id) - snaps = list_quick_snapshots() - if 1 <= idx <= len(snaps): - snap_id = snaps[idx - 1]["id"] - else: - print(f" Invalid snapshot number. Use 1-{len(snaps)}.") - return - except ValueError: - pass - - # 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: - try: - local_session_db.close() - self._session_db = None - except Exception: - pass - - if restore_quick_snapshot(snap_id): - print(f" Restored state from: {snap_id}") - print( - " Restart recommended for gateway/dashboard processes " - "to pick up state.db changes." - ) - else: - print(f" Snapshot not found: {snap_id}") - - elif subcmd == "prune": - keep = 20 - if len(parts) > 2: - try: - keep = int(parts[2]) - except ValueError: - print(" Usage: /snapshot prune [keep-count]") - return - deleted = prune_quick_snapshots(keep=keep) - print(f" Pruned {deleted} old snapshot(s) (keeping {keep}).") - - else: + 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: print(f" Unknown subcommand: {subcmd}") print(" Usage: /snapshot [list|create [label]|restore |prune [N]]") + return + handler(parts) + + def _snapshot_list(self, parts) -> None: + from hermes_cli.backup import list_quick_snapshots + + snaps = list_quick_snapshots() + if not snaps: + print(" No state snapshots yet.") + print(" Create one: /snapshot create [label]") + return + print(f" State snapshots ({display_hermes_home()}/state-snapshots/):\n") + print(f" {'#':>3} {'ID':<35} {'Files':>5} {'Size':>10} {'Label'}") + print(f" {'─'*3} {'─'*35} {'─'*5} {'─'*10} {'─'*20}") + for i, s in enumerate(snaps, 1): + size = s.get("total_size", 0) + if size < 1024: + size_str = f"{size} B" + elif size < 1024 * 1024: + size_str = f"{size / 1024:.0f} KB" + else: + size_str = f"{size / 1024 / 1024:.1f} MB" + label = s.get("label") or "" + print(f" {i:3} {s['id']:<35} {s.get('file_count', 0):>5} {size_str:>10} {label}") + + def _snapshot_create(self, parts) -> None: + from hermes_cli.backup import create_quick_snapshot + + snap_id = create_quick_snapshot(label=" ".join(parts[2:]) if len(parts) > 2 else None) + print(f" Snapshot created: {snap_id}" if snap_id else " No state files found to snapshot.") + + def _snapshot_restore(self, parts) -> None: + from hermes_cli.backup import list_quick_snapshots, restore_quick_snapshot + + if len(parts) < 3: + print(" Usage: /snapshot restore ") + snaps = list_quick_snapshots(limit=1) + if snaps: + print(f" Most recent: {snaps[0]['id']}") + return + snap_id = parts[2] + try: + idx = int(snap_id) # restore by number (1-indexed) + except ValueError: + idx = None + if idx is not None: + snaps = list_quick_snapshots() + if not 1 <= idx <= len(snaps): + print(f" Invalid snapshot number. Use 1-{len(snaps)}.") + return + 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): + print(f" Restored state from: {snap_id}") + print(" Restart recommended for gateway/dashboard processes to pick up state.db changes.") + else: + print(f" Snapshot not found: {snap_id}") + + def _snapshot_prune(self, parts) -> None: + from hermes_cli.backup import prune_quick_snapshots + + keep = 20 + if len(parts) > 2: + try: + keep = int(parts[2]) + except ValueError: + print(" Usage: /snapshot prune [keep-count]") + return + deleted = prune_quick_snapshots(keep=keep) + print(f" Pruned {deleted} old snapshot(s) (keeping {keep}).") + + # ---- /export, /import ----------------------------------------------------------------- def _handle_export_command(self, command: str): """Handle /export [profile] [-o path] — export a profile to a shareable .tar.gz archive.""" - from hermes_cli.profiles import ( - export_profile, - get_active_profile_name, - get_profile_export_path, - ) - - parts = command.split()[1:] - output = None - if "-o" in parts: - idx = parts.index("-o") - if idx + 1 >= len(parts): - print(" Usage: /export [profile] [-o output.tar.gz]") - return - output = parts[idx + 1] - parts = parts[:idx] + parts[idx + 2:] + 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: + print(" Usage: /export [profile] [-o output.tar.gz]") + return name = parts[0] if parts else (get_active_profile_name() or "default") - try: - if not output: - output = str(get_profile_export_path(name)) - result = export_profile(name, output) + result = export_profile(name, output or str(get_profile_export_path(name))) print(f" ✓ Exported '{name}' to {result}") print(" Share it: the other user runs /import or `hermes profile import `.") except (ValueError, FileNotFoundError, OSError) as e: print(f" Error: {e}") def _handle_import_command(self, command: str): - """Handle /import — import a shared profile archive as a new profile. + """Handle /import [--name ] — import a shared profile archive as a + new profile.""" + from hermes_cli.profiles import check_alias_collision, create_wrapper_script, import_profile - Syntax: - /import [--name ] - """ - from hermes_cli.profiles import ( - check_alias_collision, create_wrapper_script, import_profile, - ) - - parts = command.split()[1:] - name = None - if "--name" in parts: - idx = parts.index("--name") - if idx + 1 >= len(parts): - print(" Usage: /import [--name ]") - return - name = parts[idx + 1] - parts = parts[:idx] + parts[idx + 2:] - - if not parts: + parts, name, ok = _take_flag(command.split()[1:], "--name") + if not ok or not parts: print(" Usage: /import [--name ]") return - - archive = " ".join(parts) # paths may contain spaces - try: - profile_dir = import_profile(archive, name=name) + profile_dir = import_profile(" ".join(parts), name=name) # paths may contain spaces except (ValueError, FileExistsError, FileNotFoundError) as e: print(f" Error: {e}") return - imported = profile_dir.name print(f" ✓ Imported profile '{imported}' at {profile_dir}") - try: + with suppress(Exception): if not check_alias_collision(imported): wrapper_path = create_wrapper_script(imported) if wrapper_path: print(f" Wrapper created: {wrapper_path}") - except Exception: - pass print(f" Use it: hermes -p {imported}") + # ---- /stop, /agents ------------------------------------------------------------------- + def _handle_stop_command(self): """Handle /stop — kill all running background processes and background (async) delegations. Separate from interrupt (stop the current turn), as in Codex.""" from tools.process_registry import process_registry - processes = process_registry.list_sessions() - running = [p for p in processes if p.get("status") == "running"] - + 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. try: from tools.async_delegation import active_count, interrupt_all @@ -923,14 +953,11 @@ class CLICommandsMixin: if not running and not n_async: print(" No running background processes.") return - if running: print(f" Stopping {len(running)} background process(es)...") - killed = process_registry.kill_all() - print(f" ✅ Stopped {killed} process(es).") + print(f" ✅ Stopped {process_registry.kill_all()} process(es).") if n_async and interrupt_all is not None: - stopped = interrupt_all(reason="/stop") - print(f" ✅ Interrupted {stopped} background delegation(s).") + print(f" ✅ Interrupted {interrupt_all(reason='/stop')} background delegation(s).") def _handle_agents_command(self): """Handle /agents — show background processes and agent status.""" @@ -943,10 +970,8 @@ class CLICommandsMixin: _cprint(f" Running processes: {len(running)}") for p in running: - cmd = p.get("command", "")[:80] up = format_uptime_short(p.get("uptime_seconds", 0)) - _cprint(f" {p.get('session_id', '?')} · {up} · {cmd}") - + _cprint(f" {p.get('session_id', '?')} · {up} · {p.get('command', '')[:80]}") if finished: _cprint(f" Recently finished: {len(finished)}") @@ -956,27 +981,18 @@ class CLICommandsMixin: delegations = list_async_delegations() except Exception: delegations = [] - running_d = [ - d for d in delegations - if d.get("status") in ("running", "stalling") - ] if delegations: + running_d = [d for d in delegations if d.get("status") in ("running", "stalling")] _cprint(f" Background delegations: {len(running_d)} running") for d in delegations: - goal = (d.get("goal") or "")[:60] status = d.get("status", "?") - line = ( - f" {d.get('delegation_id', '?')} · " - f"{status} · {goal}" - ) - # Live-status detail for in-flight delegations (#51690). + line = f" {d.get('delegation_id', '?')} · {status} · {(d.get('goal') or '')[:60]}" + # Live-status detail for in-flight delegations. if status == "stalling": quiet = d.get("stalled_after_quiet_seconds") if quiet is not None: - line += ( - f" · no progress {quiet:.0f}s — interrupting" - ) - elif status in ("running",): + line += f" · no progress {quiet:.0f}s — interrupting" + elif status == "running": quiet = d.get("seconds_since_progress") if quiet is not None and quiet >= 60: line += f" · quiet {quiet:.0f}s" @@ -986,10 +1002,7 @@ class CLICommandsMixin: continue tool = child.get("current_tool") doing = f"in {tool}" if tool else "between turns" - part = ( - f" └ child {i + 1}: " - f"{child.get('api_calls', '?')} api calls · {doing}" - ) + part = f" └ child {i + 1}: {child.get('api_calls', '?')} api calls · {doing}" idle = child.get("seconds_since_activity") if idle is not None: part += f" · last activity {idle:.0f}s ago" @@ -998,6 +1011,8 @@ class CLICommandsMixin: agent_running = getattr(self, "_agent_running", False) _cprint(f" Agent: {'running' if agent_running else 'idle'}") + # ---- /journey, /paste, /copy, /image -------------------------------------------------- + def _handle_journey_command(self, cmd_original: str) -> None: """Handle /journey — the learning timeline (see `hermes journey`). Read-only views render Rich color that patch_stdout would swallow, so capture with forced ANSI and re-emit via @@ -1012,15 +1027,13 @@ class CLICommandsMixin: parser = argparse.ArgumentParser(prog="/journey", add_help=False) register_cli(parser) - rest = cmd_original.split(None, 1) try: - args = parser.parse_args(shlex.split(rest[1]) if len(rest) > 1 else []) + args = parser.parse_args(shlex.split(cmd_original)[1:]) except SystemExit: return - interactive = getattr(args, "journey_action", None) in ("delete", "edit") try: - if interactive: + if getattr(args, "journey_action", None) in ("delete", "edit"): args.func(args) return args.force_color = True @@ -1034,124 +1047,107 @@ class CLICommandsMixin: def _handle_paste_command(self): """Handle /paste — explicitly check the clipboard for an image; the reliable fallback where BracketedPaste doesn't fire for image-only clipboards (VSCode terminal, Windows Terminal/WSL2).""" - from cli import _DIM, _RST, _cprint, _termux_example_image_path + from cli import _termux_example_image_path if _is_termux_environment(): - _cprint( - f" {_DIM}Clipboard image paste is not available on Termux — " - f"use /image or paste a local image path like " - f"{_termux_example_image_path()}{_RST}" - ) + _cp(_dim_line("Clipboard image paste is not available on Termux — use /image or " + f"paste a local image path like {_termux_example_image_path()}")) return from hermes_cli.clipboard import has_clipboard_image - if has_clipboard_image(): - if self._try_attach_clipboard_image(): - n = len(self._attached_images) - _cprint(f" 📎 Image #{n} attached from clipboard") - else: - _cprint(f" {_DIM}(>_<) Clipboard has an image but extraction failed{_RST}") + if not has_clipboard_image(): + _cp(_dim_line('(._.) No image found in clipboard')) + elif self._try_attach_clipboard_image(): + _cp(f" 📎 Image #{len(self._attached_images)} attached from clipboard") else: - _cprint(f" {_DIM}(._.) No image found in clipboard{_RST}") + _cp(_dim_line('(>_<) Clipboard has an image but extraction failed')) def _handle_copy_command(self, cmd_original: str) -> None: """Handle /copy [number] — copy assistant output to clipboard.""" - from cli import _assistant_copy_text, _cprint - parts = cmd_original.split(maxsplit=1) - arg = parts[1].strip() if len(parts) > 1 else "" + 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: - _cprint(" Nothing to copy yet.") + _cp(" Nothing to copy yet.") return if arg: try: idx = int(arg) - 1 except ValueError: - _cprint(" Usage: /copy [number]") + _cp(" Usage: /copy [number]") return if idx < 0 or idx >= len(assistant): - _cprint(f" Invalid response number. Use 1-{len(assistant)}.") + _cp(f" Invalid response number. Use 1-{len(assistant)}.") return else: idx = len(assistant) - 1 while idx >= 0 and not _assistant_copy_text(assistant[idx].get("content")): idx -= 1 if idx < 0: - _cprint(" Nothing to copy in assistant responses yet.") + _cp(" Nothing to copy in assistant responses yet.") return text = _assistant_copy_text(assistant[idx].get("content")) if not text: - _cprint(" Nothing to copy in that assistant response.") + _cp(" Nothing to copy in that assistant response.") return try: - from hermes_cli.clipboard import ( - is_remote_shell_session, - write_clipboard_text, - ) - if is_remote_shell_session(): - # Over SSH native tools write the REMOTE clipboard; OSC 52 reaches the user's terminal. + 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 via the emulator). + if is_remote_shell_session() or not write_clipboard_text(text): self._write_osc52_clipboard(text) - _cprint( - f" Copied assistant response #{idx + 1} via OSC 52 " - "(terminal support required)" - ) - return - if write_clipboard_text(text): - _cprint(f" Copied assistant response #{idx + 1} to clipboard") - return - # Native tools unavailable/failed — OSC 52 fallback (SSH/tmux via the emulator). - self._write_osc52_clipboard(text) - _cprint( - f" Copied assistant response #{idx + 1} via OSC 52 " - "(terminal support required)" - ) + _cp(f" Copied assistant response #{idx + 1} via OSC 52 (terminal support required)") + else: + _cp(f" Copied assistant response #{idx + 1} to clipboard") except Exception as e: - _cprint(f" Clipboard copy failed: {e}") + _cp(f" Clipboard copy failed: {e}") def _handle_image_command(self, cmd_original: str): """Handle /image — attach a local image file for the next prompt.""" - from cli import _DIM, _IMAGE_EXTENSIONS, _RST, _cprint, _resolve_attachment_path, _split_path_input, _termux_example_image_path + from cli import _IMAGE_EXTENSIONS, _resolve_attachment_path, _split_path_input, _termux_example_image_path raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "") if not raw_args: hint = _termux_example_image_path() if _is_termux_environment() else "/path/to/image.png" - _cprint(f" {_DIM}Usage: /image e.g. /image {hint}{_RST}") + _cp(_dim_line(f'Usage: /image e.g. /image {hint}')) return path_token, _remainder = _split_path_input(raw_args) image_path = _resolve_attachment_path(path_token) if image_path is None: - _cprint(f" {_DIM}(>_<) File not found: {path_token}{_RST}") + _cp(_dim_line(f'(>_<) File not found: {path_token}')) return if image_path.suffix.lower() not in _IMAGE_EXTENSIONS: - _cprint(f" {_DIM}(._.) Not a supported image file: {image_path.name}{_RST}") + _cp(_dim_line(f'(._.) Not a supported image file: {image_path.name}')) return self._attached_images.append(image_path) - _cprint(f" 📎 Attached image: {image_path.name}") + _cp(f" 📎 Attached image: {image_path.name}") if _remainder: - _cprint(f" {_DIM}Now type your prompt (or use --image in single-query mode): {_remainder}{_RST}") + _cp(_dim_line(f'Now type your prompt (or use --image in single-query mode): {_remainder}')) elif _is_termux_environment(): - _cprint(f" {_DIM}Tip: type your next message, or run hermes chat -q --image {_termux_example_image_path(image_path.name)} \"What do you see?\"{_RST}") + example = _termux_example_image_path(image_path.name) + tip = f'Tip: type your next message, or run hermes chat -q --image {example} "What do you see?"' + _cp(_dim_line(tip)) + + # ---- /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).""" - from cli import _ACCENT, _DIM, _RST, _cprint - import shlex + from cli import _cprint from argparse import Namespace from contextlib import redirect_stdout from io import StringIO from hermes_cli.tools_config import tools_disable_enable_command def _run_capture(ns: Namespace) -> None: - """Run tools_disable_enable_command, routing its ANSI print() output through _cprint - inside the interactive TUI so patch_stdout's StdoutProxy doesn't garble the escapes. - Standalone/tests call straight through so real stdout / pytest capture works.""" - # Standalone/tests, run as usual + """Inside the interactive TUI, route the ANSI print() output through _cprint so + patch_stdout's StdoutProxy doesn't garble the escapes; standalone/tests call straight + through so real stdout / pytest capture works.""" if getattr(self, "_app", None) is None: tools_disable_enable_command(ns) return @@ -1167,21 +1163,16 @@ class CLICommandsMixin: for line in buf.getvalue().splitlines(): _cprint(line) - try: - parts = shlex.split(cmd) - except ValueError: - parts = cmd.split() - - subcommand = parts[1] if len(parts) > 1 else "" + parts = _shlex_args(cmd) + subcommand = parts[0] if parts else "" if subcommand not in {"list", "disable", "enable"}: self.show_tools() return - if subcommand == "list": _run_capture(Namespace(tools_action="list", platform="cli")) return - names = parts[2:] + names = parts[1:] if not names: print(f"(._.) Usage: /tools {subcommand} [name ...]") print(f" Built-in toolset: /tools {subcommand} web") @@ -1190,29 +1181,27 @@ class CLICommandsMixin: # Typing the command is consent. Do NOT use input() — it hangs in prompt_toolkit's loop. verb = "Disabling" if subcommand == "disable" else "Enabling" - label = ", ".join(names) - _cprint(f"{_ACCENT}{verb} {label}...{_RST}") - + _cprint(_accent(f"{verb} {', '.join(names)}...")) _run_capture(Namespace(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() - _cprint(f"{_DIM}Session reset. New tool configuration is active.{_RST}") + _cprint(_dim("Session reset. New tool configuration is active.")) 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")) - profile_name = reply.data["profile"] - display = reply.data["home"] + _say_block(f" Profile: {reply.data['profile']}", f" Home: {reply.data['home']}") - print() - print(f" Profile: {profile_name}") - print(f" Home: {display}") - print() + # ---- /handoff ------------------------------------------------------------------------- + + _HANDOFF_PENDING_TIMEOUT = 60.0 + _HANDOFF_RUNNING_TIMEOUT = 900.0 # full synthetic agent turn + delivery + _HANDOFF_HEARTBEAT_EVERY = 30.0 def _handle_handoff_command(self, cmd_original: str) -> bool: """Handle ``/handoff `` — transfer this CLI session to a gateway platform. @@ -1223,36 +1212,43 @@ class CLICommandsMixin: dispatch to finish. ``completed`` → resume hint + return False (caller exits like /quit). ``failed`` / pending-timeout → error + True (session kept). A running-timeout leaves the row untouched (the gateway owns it) and returns True.""" - from cli import _cprint - from hermes_state import format_session_db_unavailable - - parts = cmd_original.split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - _cprint(" Usage: /handoff ") - _cprint(" Hands the current session off to that platform's home channel.") - _cprint(" The CLI session ends here; resume it later with /resume.") + platform_name = _command_arg(cmd_original).lower() + if not platform_name: + _cp(" Usage: /handoff ", + " Hands the current session off to that platform's home channel.", + " The CLI session ends here; resume it later with /resume.") return True + 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): + _cp(" Session is already in flight for handoff. Wait for it to settle, then retry.") + return True + _cp(f" Queued handoff of '{session_title}' → {platform_name} (home: {home.name}).", + " Waiting for the gateway to pick it up...") + return self._handoff_wait(platform_name, session_title) - platform_name = parts[1].strip().lower() - - # Validate platform name + home channel via the live gateway config. + 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 - _cprint(f" Could not load gateway config: {exc}") - return True - + _cp(f" Could not load gateway config: {exc}") + return None try: platform = Platform(platform_name) except (ValueError, KeyError): - _cprint(f" Unknown platform '{platform_name}'.") - return True - + _cp(f" Unknown platform '{platform_name}'.") + return None try: gw_config = load_gateway_config() except Exception as exc: - _cprint(f" Could not load gateway config: {exc}") - return True + _cp(f" Could not load gateway config: {exc}") + return None pcfg = gw_config.platforms.get(platform) if not pcfg or not pcfg.enabled: @@ -1264,80 +1260,58 @@ class CLICommandsMixin: from gateway.relay import relay_platform_identities relay_cfg = gw_config.platforms.get(Platform.RELAY) if relay_cfg and relay_cfg.enabled: - fronted = {p for p, _ in relay_platform_identities()} - relay_fronts = platform_name in fronted + relay_fronts = platform_name in {p for p, _ in relay_platform_identities()} except Exception: relay_fronts = False if not relay_fronts: - _cprint(f" Platform '{platform_name}' is not configured/enabled in the gateway.") - return True + _cp(f" Platform '{platform_name}' is not configured/enabled in the gateway.") + return None home = gw_config.get_home_channel(platform) if not home or not home.chat_id: - _cprint(f" No home channel configured for {platform_name}.") - _cprint(" Set one with /sethome on the destination chat first.") - return True + _cp(f" No home channel configured for {platform_name}.", + " Set one with /sethome on the destination chat first.") + return None + return home - # Refuse mid-turn: an in-flight agent run would race with the - # gateway's switch_session and the synthetic turn dispatch. + 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): - _cprint(" Agent is busy. Wait for the current turn to finish, then retry /handoff.") - return True - - # Make sure we have a SessionDB handle. + _cp(" Agent is busy. Wait for the current turn to finish, then retry /handoff.") + return None if not self._session_db: - try: + with suppress(Exception): from hermes_state import SessionDB self._session_db = SessionDB() - except Exception: - pass if not self._session_db: - _cprint(f" {format_session_db_unavailable()}") - return True - - # 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. + _cp(_db_unavailable_line()) + return None + # 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: - row = self._session_db.get_session(self.session_id) - if not row: - placeholder_title = f"handoff-{self.session_id[:8]}" - self._session_db.set_session_title(self.session_id, placeholder_title) + 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: - _cprint(f" Could not ensure session row in state.db: {exc}") - return True - - # Display title for messaging. + _cp(f" Could not ensure session row in state.db: {exc}") + return None session_title = "" - try: + with suppress(Exception): row = self._session_db.get_session(self.session_id) if row: session_title = row.get("title") or "" - except Exception: - pass - if not session_title: - session_title = self.session_id[:8] + return session_title or self.session_id[:8] - # Mark pending — gateway watcher will pick this up. - ok = self._session_db.request_handoff(self.session_id, platform_name) - if not ok: - _cprint(" Session is already in flight for handoff. Wait for it to settle, then retry.") - return True - - _cprint(f" Queued handoff of '{session_title}' → {platform_name} (home: {home.name}).") - _cprint(" Waiting for the gateway to pick it up...") - - # Two-phase poll, tick every 0.5s. PENDING (unclaimed): 60s — a timeout means no - # gateway watcher is looking at this state.db, and the CAS fail can't stomp a claim - # landing the same instant. RUNNING (claimed): the gateway replays the full - # transcript via a synthetic turn (routinely >60s), so wait much longer with a - # heartbeat and on timeout do NOT touch the row — failing it here was the - # split-brain bug (CLI said "failed" while the gateway completed the switch). + def _handoff_wait(self, platform_name: str, session_title: str) -> bool: + """Two-phase poll, tick every 0.5s. PENDING (unclaimed): 60s — a timeout means no gateway + watcher is looking at this state.db, and the CAS fail can't stomp a claim landing the same + instant. RUNNING (claimed): the gateway replays the full transcript via a synthetic turn + (routinely >60s), so wait much longer with a heartbeat and on timeout do NOT touch the row + — failing it here was the split-brain bug (CLI said "failed" while the gateway completed + the switch).""" import time as _time - _PENDING_TIMEOUT = 60.0 - _RUNNING_TIMEOUT = 900.0 # full synthetic agent turn + delivery - _HEARTBEAT_EVERY = 30.0 - pending_deadline = _time.time() + _PENDING_TIMEOUT + pending_deadline = _time.time() + self._HANDOFF_PENDING_TIMEOUT running_deadline = None next_heartbeat = None last_state = "pending" @@ -1349,27 +1323,24 @@ class CLICommandsMixin: current = (state_row or {}).get("state") or "pending" if current != last_state: if current == "running": - _cprint(" Gateway picked it up; transferring...") - running_deadline = _time.time() + _RUNNING_TIMEOUT - next_heartbeat = _time.time() + _HEARTBEAT_EVERY + _cp(" Gateway picked it up; transferring...") + running_deadline = _time.time() + self._HANDOFF_RUNNING_TIMEOUT + next_heartbeat = _time.time() + self._HANDOFF_HEARTBEAT_EVERY last_state = current if current == "completed": - _cprint("") - _cprint(f" ↻ Handoff complete. The session is now active on {platform_name}.") - _cprint(f" Resume it on this CLI later with: /resume {session_title}") - _cprint("") - # Mark handed off so _run_cleanup does NOT finalize it on exit: the gateway - # owns the row now, and setting end_reason under it would drop the handoff - # leg from session history / session_search. + _cp("", f" ↻ Handoff complete. The session is now active on {platform_name}.", + f" Resume it on this CLI later with: /resume {session_title}", "") + # Mark handed off so _run_cleanup does NOT finalize it on exit: the gateway owns + # the row now, and setting end_reason under it would drop the handoff leg from + # session history / session_search. from cli import _handed_off_session_ids _handed_off_session_ids.add(self.session_id) - # End the CLI cleanly — same exit semantics as /quit. - self._should_exit = True + self._should_exit = True # same exit semantics as /quit return False if current == "failed": err = (state_row or {}).get("error") or "unknown error" - _cprint(f" Handoff failed: {err}") - _cprint(" Your CLI session is intact. Try /handoff again, or /resume on the platform manually.") + _cp(f" Handoff failed: {err}", + " Your CLI session is intact. Try /handoff again, or /resume on the platform manually.") return True now = _time.time() if current == "pending": @@ -1377,14 +1348,14 @@ class CLICommandsMixin: break else: # running if next_heartbeat is not None and now >= next_heartbeat: - _cprint(" Still transferring (the agent is replaying your session on the destination)...") - next_heartbeat = now + _HEARTBEAT_EVERY + _cp(" Still transferring (the agent is replaying your session on the destination)...") + next_heartbeat = now + self._HANDOFF_HEARTBEAT_EVERY if running_deadline is not None and now >= running_deadline: # Do NOT fail the row: the gateway owns it (split-brain bug otherwise). - _cprint(" The gateway is taking unusually long to finish the transfer.") - _cprint(f" Check {platform_name} — the session may still arrive there.") - _cprint(" This CLI is no longer waiting. Avoid continuing this session here;") - _cprint(" if nothing arrives, retry /handoff once the state settles.") + _cp(" The gateway is taking unusually long to finish the transfer.", + f" Check {platform_name} — the session may still arrive there.", + " This CLI is no longer waiting. Avoid continuing this session here;", + " if nothing arrives, retry /handoff once the state settles.") return True _time.sleep(0.5) @@ -1392,50 +1363,44 @@ class CLICommandsMixin: # instant wins; we lose only the retry convenience, never the handoff). try: self._session_db.fail_handoff( - self.session_id, - "timed out waiting for gateway", - only_states=("pending",), + self.session_id, "timed out waiting for gateway", only_states=("pending",), ) except TypeError: # Older SessionDB without only_states (mixed installs): legacy unconditional fail. - try: + with suppress(Exception): self._session_db.fail_handoff(self.session_id, "timed out waiting for gateway") - except Exception: - pass except Exception: pass - _cprint(" Timed out waiting for the gateway. Is `hermes gateway` running?") - _cprint(" Your CLI session is intact.") + _cp(" Timed out waiting for the gateway. Is `hermes gateway` running?", + " Your CLI session is intact.") return True + # ---- /resume, /sessions, /branch ------------------------------------------------------ + def _handle_resume_command(self, cmd_original: str) -> None: """Handle /resume — switch to a previous session mid-conversation.""" - from cli import _cprint, _sync_process_session_id - parts = cmd_original.split(None, 1) - target = parts[1].strip() if len(parts) > 1 else "" + from cli import _sync_process_session_id + target = _command_arg(cmd_original) # Users copy the help text's placeholder brackets/quotes verbatim (``/resume ``). if len(target) >= 2 and (target[0], target[-1]) in {("<", ">"), ("[", "]"), ('"', '"'), ("'", "'")}: target = target[1:-1].strip() if not target: - _cprint(" Usage: /resume ") + _cp(" Usage: /resume ") if self._show_recent_sessions(reason="resume"): # Arm a one-shot pending-resume selection so a bare number on the next line # works. Must be the same list _show_recent_sessions showed and the numbered # branch below resolves — all three use _list_recent_sessions(limit=10). self._pending_resume_sessions = self._list_recent_sessions(limit=10) return - _cprint(" Tip: Use /history or `hermes sessions list` to find sessions.") + _cp(" Tip: Use /history or `hermes sessions list` to find sessions.") return - # Any explicit /resume supersedes a previously-armed bare - # numbered prompt. + # Any explicit /resume supersedes a previously-armed bare numbered prompt. self._pending_resume_sessions = None - if not self._session_db: - from hermes_state import format_session_db_unavailable - _cprint(f" {format_session_db_unavailable()}") + _cp(_db_unavailable_line()) return # Resolve numbered selection, title, or ID @@ -1443,87 +1408,72 @@ class CLICommandsMixin: sessions = self._list_recent_sessions(limit=10) index = int(target) if index < 1 or index > len(sessions): - _cprint(f" Resume index {index} is out of range.") - _cprint(" Use /resume with no arguments to see available sessions.") + _cp(f" Resume index {index} is out of range.", + " Use /resume with no arguments to see available sessions.") return - selected = sessions[index - 1] - target_id = selected["id"] + target_id = sessions[index - 1]["id"] else: from hermes_cli.main import _resolve_session_by_name_or_id - resolved = _resolve_session_by_name_or_id(target) - target_id = resolved or target + target_id = _resolve_session_by_name_or_id(target) or target session_meta = self._session_db.get_session(target_id) if not session_meta: - _cprint(f" Session not found: {target}") - _cprint(" Use /sessions or `hermes sessions list` to see available sessions.") + _cp(f" Session not found: {target}", + " Use /sessions or `hermes sessions list` to see available sessions.") return - # If the target is the empty head of a compression chain, redirect to - # the descendant that actually holds the transcript. See #15000. + # If the target is the empty head of a compression chain, redirect to the descendant + # that actually holds the transcript. try: 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: - _cprint( - f" Session {target_id} was compressed into {resolved_id}; " - f"resuming the descendant with your transcript." - ) + _cp(f" Session {target_id} was compressed into {resolved_id}; " + f"resuming the descendant with your transcript.") target_id = resolved_id - resolved_meta = self._session_db.get_session(target_id) - if resolved_meta: - session_meta = resolved_meta + session_meta = self._session_db.get_session(target_id) or session_meta if target_id == self.session_id: - _cprint(" Already on that session.") + _cp(" Already on that session.") return old_session_id = self.session_id _end_current_session(self, "resumed_other") - - # Switch to the target session self.session_id = target_id self._resumed = True self._pending_title = None _sync_process_session_id(target_id) - # Both projections come from one lineage SELECT: model_history is - # alternation-repaired for LIVE REPLAY (heals a durable ``user;user`` violation - # once instead of on every request); display_history is the full lineage - # verbatim for _display_resumed_history() (matches startup --resume). + # Both projections come from one lineage SELECT: model_history is alternation-repaired + # for LIVE REPLAY (heals a durable ``user;user`` violation once instead of on every + # request); display_history is the full lineage verbatim for _display_resumed_history() + # (matches startup --resume). model_history, display_history = self._session_db.get_resume_conversations(target_id) self.conversation_history = [m for m in (model_history or []) if m.get("role") != "session_meta"] self._resume_display_history = [m for m in (display_history or []) if m.get("role") != "session_meta"] - # Re-open the target session so it's not marked as ended - try: + with suppress(Exception): # re-open the target session so it's not marked as ended self._session_db.reopen_session(target_id) - except Exception: - pass - _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. + # 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: - _cprint( - f" ↻ Resumed session {target_id}{title_part}" - f" ({msg_count} user message{'s' if msg_count != 1 else ''}," - f" {len(self.conversation_history)} total)" - ) + _cp(f" ↻ Resumed session {target_id}{title_part}" + f" ({_plural(msg_count, 'user message')}, {len(self.conversation_history)} total)") self._display_resumed_history() else: - _cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") + _cp(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") - # Same contract as a startup --resume: retarget the process/tool cwd (else the - # terminal tools keep operating in the wrong repo), restore the target session's - # persisted YOLO bypass (the previous session's stops applying — the approval - # session key changed), and its model/provider (else it reverts to config default). + # Same contract as a startup --resume: retarget the process/tool cwd (else the terminal + # tools keep operating in the wrong repo), restore the target session's persisted YOLO + # bypass (the approval session key changed), and its model/provider (else it reverts to + # config default). self._restore_session_cwd(session_meta) self._restore_session_yolo(session_meta) self._restore_session_model(session_meta) @@ -1534,186 +1484,34 @@ class CLICommandsMixin: Bare/``list`` prints the same recent-sessions table /resume shows; an explicit target delegates to the resume flow so ``/sessions `` and ``/resume `` behave identically. (The TUI has a picker overlay; the classic CLI prints inline.)""" - from cli import _cprint - parts = cmd_original.split(None, 1) - arg = parts[1].strip() if len(parts) > 1 else "" - sub = arg.lower() - - # Bare /sessions or /sessions list — show recent sessions inline. - if not arg or sub in {"list", "ls", "browse"}: - if not self._session_db: - from hermes_state import format_session_db_unavailable - _cprint(f" {format_session_db_unavailable()}") - return - if not self._show_recent_sessions(reason="sessions"): - _cprint(" (._.) No previous sessions yet.") - return - - # /sessions behaves the same as /resume . - self._handle_resume_command(f"/resume {arg}") - - def _handle_worktree_command(self, cmd_original: str) -> None: - """Handle /worktree — inspect, create, or reclaim isolated git worktrees. - - Syntax: - /worktree — show the active worktree (if any) - /worktree new [name] — create a worktree and move this session into it - /worktree list — list worktrees under the repo's .worktrees/ - /worktree prune [--dry-run] — reclaim safe trees + merged branches - - ``new`` retargets the terminal/file tools (``TERMINAL_CWD`` + process cwd) at the new - tree; the launcher's exit cleanup applies (kept only with unpushed commits, as ``hermes -w``). - ``prune`` is the attended reclaim from hermes_cli/worktree_gc.py: never deletes tracked - changes, unique unpushed commits, or in-use trees.""" - import subprocess - - import cli as _cli - - parts = cmd_original.split(None, 2) - sub = parts[1].lower() if len(parts) > 1 else "" - - repo_root = _cli._git_repo_root() - - if not sub or sub in {"status", "show"}: - active = _cli._active_worktree - if active: - print(f" Active worktree: {active['path']}") - print(f" Branch: {active['branch']}") - else: - print(" No active worktree for this session.") - if repo_root: - print(" /worktree new [name] — create one and move this session into it") - print(" /worktree prune — reclaim stale trees and merged branches") - else: - print(" (not inside a git repository)") - return - - if sub in {"prune", "gc", "clean"}: - if not repo_root: - print(" Not inside a git repository.") - return - rest = parts[2].strip().lower() if len(parts) > 2 else "" - dry_run = "--dry-run" in rest or "-n" in rest.split() - from hermes_cli import worktree_gc - - 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: - for line in actions: - print(f" {line}") - print(f" {len(actions)} action(s) {'planned' if dry_run else 'done'}.") - else: - print(" Nothing to reclaim — remaining trees/branches carry real work.") - kept = [ - record for record in tree_records - if record.verdict == "keep" - and "kanban" not in record.reason and "in use" not in record.reason - ] - if kept: - print(f" Preserved {len(kept)} tree(s) with real work:") - for record in kept: - print(f" {record.name}: {record.reason}") - return - - if sub in {"list", "ls"}: - if not repo_root: - print(" Not inside a git repository.") - return - 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 = "" - if out: - for line in out.splitlines(): - print(f" {line}") - else: - print(" Could not list worktrees.") - return - - if sub in {"new", "add", "create"}: - if not repo_root: - print(" ❌ /worktree new requires being inside a git repository.") - return - name = parts[2].strip() if len(parts) > 2 else None - 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=name, - ) - if not wt_info: - return # _setup_worktree already printed the failure - # Retarget the session's terminal/file tools at the new tree (as `hermes -w` does). - try: - os.chdir(wt_info["path"]) - except OSError as e: - print(f" ⚠ Created worktree but could not enter it: {e}") - os.environ["TERMINAL_CWD"] = wt_info["path"] - # Same keep-if-unpushed cleanup as `hermes -w`. Only one tree is "active" per - # process; an earlier one keeps its own atexit registration (explicit info arg). - import atexit - _cli._active_worktree = wt_info - atexit.register(_cli._cleanup_worktree, wt_info) - print(f" ✅ Worktree ready: {wt_info['path']}") - print(f" Branch: {wt_info['branch']}") - print(" Terminal and file tools now operate in the worktree.") - return - - print(f" Unknown /worktree subcommand: {sub}") - print(" Usage: /worktree [new [name] | list]") + arg = _command_arg(cmd_original) + if arg and arg.lower() not in {"list", "ls", "browse"}: + self._handle_resume_command(f"/resume {arg}") + elif not self._session_db: + _cp(_db_unavailable_line()) + elif not self._show_recent_sessions(reason="sessions"): + _cp(" (._.) No previous sessions yet.") def _handle_branch_command(self, cmd_original: str) -> None: """Handle /branch [name] — fork the current session into a new independent copy of the full history so a different approach can be explored without losing the original.""" - from cli import _cprint, _sync_process_session_id + from cli import _sync_process_session_id if not self.conversation_history: - _cprint(" No conversation to branch — send a message first.") + _cp(" No conversation to branch — send a message first.") return - if not self._session_db: - from hermes_state import format_session_db_unavailable - _cprint(f" {format_session_db_unavailable()}") + _cp(_db_unavailable_line()) return - parts = cmd_original.split(None, 1) - branch_name = parts[1].strip() if len(parts) > 1 else "" - - # Generate the new session ID + branch_name = _command_arg(cmd_original) now = datetime.now() - timestamp_str = now.strftime("%Y%m%d_%H%M%S") - short_uuid = uuid.uuid4().hex[:6] - new_session_id = f"{timestamp_str}_{short_uuid}" - - # Determine branch title + new_session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" if branch_name: branch_title = branch_name - else: - # Auto-generate from the current session title - current_title = None - if self._session_db: - current_title = self._session_db.get_session_title(self.session_id) - base = current_title or "branch" + else: # auto-generate from the current session title + base = self._session_db.get_session_title(self.session_id) or "branch" branch_title = self._session_db.get_next_title_in_lineage(base) - # Save the current session's state before branching parent_session_id = self.session_id _end_current_session(self, "branched") @@ -1733,11 +1531,13 @@ class CLICommandsMixin: parent_session_id=parent_session_id, ) except Exception as e: - _cprint(f" Failed to create branch session: {e}") + _cp(f" Failed to create branch session: {e}") return - # Copy history in bounded-chunk transactions. Best-effort: a failed copy still yields a usable branch. - try: + # Copy history in bounded-chunk transactions. Best-effort: a failed copy still yields a + # usable branch. api_content sidecar: the branch's first turn replays the parent's exact + # wire bytes (warm prompt cache) instead of a cold prefill. + with suppress(Exception): self._session_db.append_messages_batch( new_session_id, [ @@ -1751,8 +1551,6 @@ class CLICommandsMixin: "reasoning_details": msg.get("reasoning_details"), "codex_reasoning_items": msg.get("codex_reasoning_items"), "codex_message_items": msg.get("codex_message_items"), - # api_content sidecar: the branch's first turn replays the parent's exact - # wire bytes (warm prompt cache) instead of a cold prefill. "api_content": extract_api_content_sidecar(msg), "timestamp": msg.get("timestamp"), } @@ -1760,14 +1558,8 @@ class CLICommandsMixin: ], chunk_rows=500, ) - except Exception: - pass # Best-effort copy - - # Set title on the branch - try: + with suppress(Exception): self._session_db.set_session_title(new_session_id, branch_title) - except Exception: - pass # Switch to the new session self._transfer_session_yolo(self.session_id, new_session_id) @@ -1776,78 +1568,145 @@ class CLICommandsMixin: self._pending_title = None self._resumed = True # Prevents auto-title generation _sync_process_session_id(new_session_id) - - # Sync the agent 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"]) - _cprint( - f" ⑂ Branched session \"{branch_title}\"" - f" ({msg_count} user message{'s' if msg_count != 1 else ''})" - ) - _cprint(f" Original session: {parent_session_id}") - _cprint(f" Branch session: {new_session_id}") + _cp(f" ⑂ Branched session \"{branch_title}\" ({_plural(msg_count, 'user message')})", + f" Original session: {parent_session_id}", + f" Branch session: {new_session_id}") + + # ---- /worktree ------------------------------------------------------------------------ + + def _handle_worktree_command(self, cmd_original: str) -> None: + """Handle /worktree — inspect, create, or reclaim isolated git worktrees. + + ``/worktree`` shows the active worktree; ``new [name]`` creates one and moves this session + into it (retargets ``TERMINAL_CWD`` + process cwd; the launcher's exit cleanup applies — + kept only with unpushed commits, as ``hermes -w``); ``list`` lists trees under the repo's + .worktrees/; ``prune [--dry-run]`` is the attended reclaim from hermes_cli/worktree_gc.py + (never deletes tracked changes, unique unpushed 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: + print(f" Active worktree: {active['path']}") + print(f" Branch: {active['branch']}") + else: + print(" No active worktree for this session.") + if repo_root: + print(" /worktree new [name] — create one and move this session into it") + print(" /worktree prune — reclaim stale trees and merged branches") + else: + print(" (not inside a git repository)") + return + + handler = { + "prune": self._worktree_prune, "gc": self._worktree_prune, "clean": self._worktree_prune, + "list": self._worktree_list, "ls": self._worktree_list, + "new": self._worktree_new, "add": self._worktree_new, "create": self._worktree_new, + }.get(sub) + if handler is None: + print(f" Unknown /worktree subcommand: {sub}") + print(" Usage: /worktree [new [name] | list]") + return + if not repo_root: + print(" ❌ /worktree new requires being inside a git repository." if handler is self._worktree_new + else " Not inside a git repository.") + return + 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: + for line in actions: + print(f" {line}") + print(f" {len(actions)} action(s) {'planned' if dry_run else 'done'}.") + else: + print(" Nothing to reclaim — remaining trees/branches carry real work.") + kept = [ + record for record in tree_records + if record.verdict == "keep" and "kanban" not in record.reason and "in use" not in record.reason + ] + if kept: + print(f" Preserved {len(kept)} tree(s) with real work:") + for record in kept: + print(f" {record.name}: {record.reason}") + + def _worktree_list(self, repo_root: str, rest: str) -> None: + import subprocess + 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 = "" + if out: + for line in out.splitlines(): + print(f" {line}") + else: + print(" Could not list worktrees.") + + def _worktree_new(self, repo_root: str, rest: str) -> None: + import atexit + import cli as _cli + from hermes_cli.config import load_config + + try: + sync_base = bool(load_config().get("worktree_sync", True)) + except Exception: + sync_base = True + wt_info = _cli._setup_worktree(repo_root=repo_root, sync_base=sync_base, name=rest or None) + if not wt_info: + return # _setup_worktree already printed the failure + # Retarget the session's terminal/file tools at the new tree (as `hermes -w` does). + try: + os.chdir(wt_info["path"]) + except OSError as e: + print(f" ⚠ Created worktree but could not enter it: {e}") + os.environ["TERMINAL_CWD"] = wt_info["path"] + # Same keep-if-unpushed cleanup as `hermes -w`. Only one tree is "active" per process; + # an earlier one keeps its own atexit registration (explicit info arg). + _cli._active_worktree = wt_info + atexit.register(_cli._cleanup_worktree, wt_info) + print(f" ✅ Worktree ready: {wt_info['path']}") + print(f" Branch: {wt_info['branch']}") + print(" Terminal and file tools now operate in the worktree.") + + # ---- /personality, /pet, /hatch ------------------------------------------------------- def _handle_personality_command(self, cmd: str): - """Handle the /personality command to set predefined personalities. - - All resolution/persistence goes through hermes_cli.personality — - the single owner of personality state on every surface. - """ + """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, + describe_personality, normalize_personality_name, persist_personality, prompt_text, resolve_personality, ) - parts = cmd.split(maxsplit=1) - - if len(parts) > 1: - # Set personality - personality_name = parts[1].strip() - - try: - name, personality_prompt = resolve_personality( - personality_name, getattr(self, "config", None) - ) - except ValueError: - print(f"(._.) Unknown personality: {personality_name.lower()}") - print(f" Available: none, {', '.join(self.personalities.keys())}") - return - - saved = persist_personality(name) - 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 = "" - self.agent = None # Force re-init - if saved: - print("(^_^)b Personality cleared (saved to config)") - else: - print("(^_^) Personality cleared (session only)") - print(" No personality overlay — using base agent behavior.") - else: - self.system_prompt = personality_prompt - self.agent = None # Force re-init - if saved: - print(f"(^_^)b Personality set to '{name}' (saved to config)") - else: - print(f"(^_^) Personality set to '{name}' (session only)") - print(f" \"{personality_prompt[:60]}{'...' if len(personality_prompt) > 60 else ''}\"") - else: - # Show available personalities + 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", "") ) @@ -1866,6 +1725,33 @@ class CLICommandsMixin: print() print(" Usage: /personality (* = active)") print() + return + + try: + name, personality_prompt = resolve_personality(personality_name, getattr(self, "config", None)) + except ValueError: + print(f"(._.) Unknown personality: {personality_name.lower()}") + print(f" Available: none, {', '.join(self.personalities.keys())}") + return + + saved = persist_personality(name) + scope = "(saved to config)" if saved else "(session only)" + face = "(^_^)b" if saved else "(^_^)" + if not name: + # Neutral reset — fall back to the user-owned manual prompt. + try: + from hermes_cli.config import cfg_get, read_raw_config + self.system_prompt = prompt_text(cfg_get(read_raw_config(), "agent", "system_prompt", default="")) + except Exception: + self.system_prompt = "" + self.agent = None # Force re-init + print(f"{face} Personality cleared {scope}") + print(" No personality overlay — using base agent behavior.") + else: + self.system_prompt = personality_prompt + self.agent = None # Force re-init + print(f"{face} Personality set to '{name}' {scope}") + print(f" \"{_ellipsize(personality_prompt, 60)}\"") def _handle_pet_command(self, cmd: str): """Toggle, browse, or adopt a petdex mascot. @@ -1877,47 +1763,37 @@ class CLICommandsMixin: from agent.pet.manifest import ManifestError from hermes_cli.pets import _set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display - parts = cmd.split(maxsplit=1) - arg = parts[1].strip() if len(parts) > 1 else "" + arg = _command_arg(cmd) low = arg.lower() - if not arg or low == "toggle": enabled, name, err = toggle_pet_display() if err: print(f"(x_x) {err}") - return - if enabled: + elif enabled: print(f"(^_^)b {name} is out — it'll pop in shortly.") else: print(f"(-_-)zzZ {name} put away." if name else "(-_-)zzZ Pet put away.") - return - - if low in ("list", "gallery", "browse", "all"): + elif low in ("list", "gallery", "browse", "all"): print_pet_gallery() - return - - if low == "scale" or low.startswith("scale "): + elif low == "scale" or low.startswith("scale "): value = arg[len("scale"):].strip() if not value: print("(o_o) Usage: /pet scale (e.g. /pet scale 0.5)") return scale, err = set_pet_scale(value) print(f"(x_x) {err}" if err else f"(^_^) Pet scale → {scale:g}.") - return - - if low == "off": + elif low == "off": _set_enabled(False) print("(-_-)zzZ Pet put away.") - return - - print(f"(o_o) Fetching '{arg}' from petdex…") - try: - pet = store.install_pet(arg) - except (store.PetStoreError, ManifestError) as exc: - print(f"(x_x) Couldn't adopt '{arg}': {exc}") - return - _set_active(arg) - print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.") + else: + print(f"(o_o) Fetching '{arg}' from petdex…") + try: + pet = store.install_pet(arg) + except (store.PetStoreError, ManifestError) as exc: + print(f"(x_x) Couldn't adopt '{arg}': {exc}") + return + _set_active(arg) + print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.") def _handle_hatch_command(self, cmd: str): """Generate ("hatch") a new petdex pet from a description: base look, one animation row @@ -1928,13 +1804,11 @@ class CLICommandsMixin: from agent.pet.generate.imagegen import GenerationError from hermes_cli.pets import _set_active - parts = cmd.split(maxsplit=1) - concept = parts[1].strip() if len(parts) > 1 else "" - + concept = _command_arg(cmd) if not concept: - # Dispatched from the process_loop daemon thread while prompt_toolkit owns - # stdin — a raw input() here never renders and eats keystrokes. Use the - # thread-aware prompt helper (run_in_terminal; None when prompting isn't safe). + # Dispatched from the process_loop daemon thread while prompt_toolkit owns stdin — a + # raw input() here never renders and eats keystrokes. Use the thread-aware prompt + # helper (run_in_terminal; None when prompting isn't safe). prompt_helper = getattr(self, "_prompt_text_input", None) if callable(prompt_helper): concept = (prompt_helper("(o_o) Describe your pet: ") or "").strip() @@ -1944,7 +1818,6 @@ class CLICommandsMixin: except (EOFError, KeyboardInterrupt): print() return - if not concept: print("(o_o) Usage: /hatch (e.g. /hatch a tiny cyber fox)") return @@ -1959,16 +1832,13 @@ class CLICommandsMixin: except GenerationError as exc: print(f"(x_x) Couldn't generate a base look: {exc}") return - if not drafts: print("(x_x) No base draft came back — try again.") return def _progress(event: str, detail: str) -> None: - if event == "row": - # detail is "::"; show the state name. - state = detail.split(":", 1)[0] - print(f" ┊ drawing {state}…") + if event == "row": # detail is "::"; show the state name. + print(f" ┊ drawing {detail.split(':', 1)[0]}…") elif event == "compose": print(" ┊ composing spritesheet…") elif event == "save": @@ -1976,19 +1846,17 @@ class CLICommandsMixin: try: result = orchestrate.hatch_pet( - base_image=drafts[0], - slug=slug, - display_name=display_name, - concept=concept, + base_image=drafts[0], slug=slug, display_name=display_name, concept=concept, on_progress=_progress, ) except GenerationError as exc: print(f"(x_x) Hatch failed: {exc}") return - _set_active(result.slug) print(f"(^_^)b {result.display_name} hatched and adopted — it'll pop in shortly!") + # ---- /cron ---------------------------------------------------------------------------- + def _handle_cron_command(self, cmd: str): """Handle the /cron command to manage scheduled tasks.""" import shlex @@ -2006,7 +1874,7 @@ class CLICommandsMixin: print(f"(._.) Unknown cron command: {subcommand}") print(" Available: list, add, edit, pause, resume, run, remove") return - handler(self, subcommand, opts) + getattr(self, handler)(subcommand, opts) def _cron_overview(self) -> None: print() @@ -2032,8 +1900,7 @@ class CLICommandsMixin: print(" Current Jobs:") print(" " + "-" * 63) for job in jobs: - repeat_str = job.get("repeat", "?") - print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {repeat_str:<8}") + print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {job.get('repeat', '?'):<8}") if job.get("skills"): print(f" Skills: {', '.join(job['skills'])}") print(f" {job.get('prompt_preview', '')}") @@ -2086,14 +1953,14 @@ class CLICommandsMixin: action="create", schedule=schedule, prompt=prompt or None, name=opts["name"], deliver=opts["deliver"], repeat=opts["repeat"], skills=skills or None, ) - if result.get("success"): - print(f"(^_^)b Created job: {result['job_id']}") - print(f" Schedule: {result['schedule']}") - if result.get("skills"): - print(f" Skills: {', '.join(result['skills'])}") - print(f" Next run: {result['next_run_at']}") - else: + if not result.get("success"): print(f"(x_x) Failed to create job: {result.get('error')}") + return + print(f"(^_^)b Created job: {result['job_id']}") + print(f" Schedule: {result['schedule']}") + if result.get("skills"): + print(f" Skills: {', '.join(result['skills'])}") + print(f" Next run: {result['next_run_at']}") def _cron_edit(self, subcommand: str, opts: dict) -> None: from cli import get_job @@ -2113,31 +1980,26 @@ class CLICommandsMixin: replacement_skills = _normalize_skills(opts["skills"]) add_skills = _normalize_skills(opts["add_skills"]) remove_skills = set(_normalize_skills(opts["remove_skills"])) - existing_skills = list(existing.get("skills") or ([] if not existing.get("skill") else [existing.get("skill")])) 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] - for skill in add_skills: - if skill not in final_skills: - final_skills.append(skill) + 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 result.get("success"): - job = result["job"] - print(f"(^_^)b Updated job: {job['job_id']}") - print(f" Schedule: {job['schedule']}") - if job.get("skills"): - print(f" Skills: {', '.join(job['skills'])}") - else: - print(" Skills: none") - else: + if not result.get("success"): print(f"(x_x) Failed to update job: {result.get('error')}") + return + job = result["job"] + print(f"(^_^)b Updated job: {job['job_id']}") + print(f" Schedule: {job['schedule']}") + print(f" Skills: {', '.join(job['skills'])}" if job.get("skills") else " Skills: none") def _cron_job_action(self, subcommand: str, opts: dict) -> None: """pause / resume / run / remove (aliases rm, delete) on one job id.""" @@ -2151,28 +2013,23 @@ class CLICommandsMixin: if not result.get("success"): print(f"(x_x) Failed to {action} job: {result.get('error')}") return - if action == "pause": - print(f"(^_^)b Paused job: {result['job']['name']} ({job_id})") - elif action == "resume": - print(f"(^_^)b Resumed job: {result['job']['name']} ({job_id})") - print(f" Next run: {result['job'].get('next_run_at')}") - elif action == "run": - print(f"(^_^)b Triggered job: {result['job']['name']} ({job_id})") - print(" It will run on the next scheduler tick.") - else: + if action == "remove": removed = result.get("removed_job", {}) print(f"(^_^)b Removed job: {removed.get('name', job_id)} ({job_id})") + return + verb = {"pause": "Paused", "resume": "Resumed", "run": "Triggered"}[action] + print(f"(^_^)b {verb} job: {result['job']['name']} ({job_id})") + if action == "resume": + print(f" Next run: {result['job'].get('next_run_at')}") + elif action == "run": + print(" It will run on the next scheduler tick.") + + # ---- 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.""" - import shlex - - try: - tokens = shlex.split(cmd)[1:] if cmd else [] - except ValueError: - tokens = (cmd or "").split()[1:] - args = " ".join(tokens) + args = " ".join(_shlex_args(cmd)) try: from hermes_cli.suggestions_cmd import handle_suggestions_command output = handle_suggestions_command(args) @@ -2186,11 +2043,7 @@ class CLICommandsMixin: (``agent_seed``, run as the next turn); `` slot=val …`` creates the job directly.""" import shlex - try: - tokens = shlex.split(cmd)[1:] if cmd else [] - except ValueError: - tokens = (cmd or "").split()[1:] - args = " ".join(shlex.quote(t) for t in tokens) + 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) @@ -2200,29 +2053,21 @@ class CLICommandsMixin: 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. + # 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 slash command. - - Delegates to hermes_cli.curator so the CLI and the `hermes curator` - subcommand share the same handler set. - """ + """Handle /curator — delegates to hermes_cli.curator so the CLI and the `hermes curator` + subcommand share the same handler set.""" import shlex tokens = shlex.split(cmd)[1:] if cmd else [] - if not tokens: - tokens = ["status"] - try: from hermes_cli.curator import cli_main - cli_main(tokens) + cli_main(tokens or ["status"]) except SystemExit: - # argparse calls sys.exit() on --help or errors; swallow so we - # don't kill the interactive session. - pass + pass # argparse exits on --help/errors; don't kill the interactive session except Exception as exc: print(f"(._.) curator: {exc}") @@ -2230,9 +2075,7 @@ class CLICommandsMixin: """Handle /kanban — strip the leading ``/kanban`` and hand the rest to ``kanban.run_slash``.""" from hermes_cli.kanban import run_slash - rest = cmd.strip() - if rest.startswith("/"): - rest = rest.lstrip("/") + rest = cmd.strip().lstrip("/") if rest.startswith("kanban"): rest = rest[len("kanban"):].lstrip() try: @@ -2243,19 +2086,15 @@ class CLICommandsMixin: print(output) def _handle_skills_command(self, cmd: str): - """Handle /skills slash command — delegates to hermes_cli.skills_hub.""" + """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 - # Intercept write-approval review subcommands first (pending/approve/ - # reject/diff/mode); everything else goes to the skills hub. - parts = cmd.strip().split() - args = parts[1:] if len(parts) > 1 else [] - if args and args[0].lower() in {"pending", "approve", "apply", "reject", - "deny", "drop", "diff", "approval", "mode"}: + args = cmd.strip().split()[1:] + if args and args[0].lower() in {"pending", "approve", "apply", "reject", "deny", "drop", "diff", "approval", "mode"}: 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), + wa.SKILLS, args, set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled), ) if out is not None: print(out) @@ -2263,6 +2102,32 @@ class CLICommandsMixin: 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. /memory approve from the Desktop GUI): apply against a + # freshly loaded on-disk store, mirroring the gateway path. It persists to the same + # MEMORY/USER.md and honors the user's configured char limits. + from tools.memory_tool import load_on_disk_store + store = load_on_disk_store() + out = handle_pending_subcommand( + wa.MEMORY, args, memory_store=store, + set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled), + ) + print(out if out is not None else + "Unknown /memory subcommand. Use: pending, approve , reject , approval .") + + def _save_write_approval(self, subsystem: str, enabled: bool): + """Persist .write_approval to config (for /memory|/skills approval).""" + from cli import save_config_value + save_config_value(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).""" @@ -2272,18 +2137,14 @@ class CLICommandsMixin: print(f" {command} needs an active chat session to run.") def _handle_learn_command(self, cmd: str): - """Handle /learn — distill a reusable skill from anything the user describes. - - Open-ended: the argument is free text describing the source(s) — 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``.""" + """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) - if user_request: - print("\n⚡ Learning a skill from what you described...") - else: - print("\n⚡ Learning a skill from this conversation...") + print("\n⚡ Learning a skill from what you described..." if user_request + else "\n⚡ Learning a skill from this conversation...") self._queue_prompt_turn(build_learn_prompt(user_request), "/learn") def _handle_plan_command(self, cmd: str): @@ -2292,10 +2153,7 @@ class CLICommandsMixin: from agent.plan_prompt import build_plan_prompt task = _command_arg(cmd) # optional — empty infers the task from conversation context - if task: - print(f"\n📋 Planning: {task[:80]}{'...' if len(task) > 80 else ''}") - else: - print("\n📋 Planning from this conversation's context...") + print(f"\n📋 Planning: {_ellipsize(task, 80)}" if task else "\n📋 Planning from this conversation's context...") self._queue_prompt_turn(build_plan_prompt(task), "/plan") def _handle_init_command(self, cmd: str): @@ -2304,85 +2162,53 @@ class CLICommandsMixin: from hermes_cli.init_command import build_init_prompt_for_cwd msg = build_init_prompt_for_cwd(extra=_command_arg(cmd)) # optional user emphasis - if "UPDATE the existing AGENTS.md" in msg: - print("\n⚡ Updating AGENTS.md from a project scan...") - else: - print("\n⚡ Generating AGENTS.md from a project scan...") + verb = "Updating" if "UPDATE the existing AGENTS.md" in msg else "Generating" + print(f"\n⚡ {verb} AGENTS.md from a project scan...") self._queue_prompt_turn(msg, "/init") - 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 - parts = cmd.strip().split() - args = parts[1:] if len(parts) > 1 else [] - store = getattr(self.agent, "_memory_store", None) if getattr(self, "agent", None) else None - if store is None: - # No live agent store (e.g. /memory approve from the Desktop GUI): apply against - # a freshly loaded on-disk store, mirroring the gateway path. It persists to the - # same MEMORY/USER.md and honors the user's configured char limits. - 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), - ) - if out is None: - out = ("Unknown /memory subcommand. " - "Use: pending, approve , reject , approval .") - print(out) - - def _save_write_approval(self, subsystem: str, enabled: bool): - """Persist .write_approval to config (for /memory|/skills approval).""" - from cli import save_config_value - save_config_value(f"{subsystem}.write_approval", bool(enabled)) + # ---- side-session handlers: /bg, /btw ------------------------------------------------- def _handle_background_command(self, cmd: str): """Handle /bg — 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 AIAgent, _cprint, set_approval_callback, set_secret_capture_callback, set_sudo_password_callback - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - _cprint(" Usage: /bg ") - _cprint(" Example: /bg Summarize the top HN stories today") - _cprint(" (For a side question about this conversation, use /btw .)") - _cprint(" The task runs in a separate session and results display here when done.") + prompt = _command_arg(cmd) + if not prompt: + _cp(" Usage: /bg ", + " Example: /bg Summarize the top HN stories today", + " (For a side question about this conversation, use /btw .)", + " The task runs in a separate session and results display here when done.") return - prompt = parts[1].strip() 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]}" - - # Make sure we have valid credentials if not self._ensure_runtime_credentials(): - _cprint(" (>_<) Cannot start background task: no valid credentials.") + _cp(" (>_<) Cannot start background task: no valid credentials.") return - - _cprint(f" 🔄 Background task #{task_num} started: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"") - _cprint(f" Task ID: {task_id}") - _cprint(" You can continue chatting — results will appear when done.\n") + preview = _ellipsize(prompt, 60) + _cp(f" 🔄 Background task #{task_num} started: \"{preview}\"", + f" Task ID: {task_id}", + " You can continue chatting — results will appear when done.\n") turn_route = self._resolve_turn_agent_config(prompt) + runtime = turn_route["runtime"] def run_background(): set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) - try: + with suppress(Exception): set_secret_capture_callback(self._secret_capture_callback) - except Exception: - pass try: bg_agent = AIAgent( model=turn_route["model"], - api_key=turn_route["runtime"].get("api_key"), - base_url=turn_route["runtime"].get("base_url"), - provider=turn_route["runtime"].get("provider"), - api_mode=turn_route["runtime"].get("api_mode"), - acp_command=turn_route["runtime"].get("command"), - acp_args=turn_route["runtime"].get("args"), - max_tokens=turn_route["runtime"].get("max_tokens"), + api_key=runtime.get("api_key"), + base_url=runtime.get("base_url"), + provider=runtime.get("provider"), + api_mode=runtime.get("api_mode"), + acp_command=runtime.get("command"), + acp_args=runtime.get("args"), + max_tokens=runtime.get("max_tokens"), max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, quiet_mode=True, @@ -2413,49 +2239,30 @@ class CLICommandsMixin: self._app.invalidate() bg_agent.thinking_callback = _bg_thinking - - result = bg_agent.run_conversation( - user_message=prompt, - task_id=task_id, - ) - + result = bg_agent.run_conversation(user_message=prompt, task_id=task_id) response = result.get("final_response", "") if result else "" if not response and result and result.get("error"): response = f"Error: {result['error']}" - _print_side_result_panel( self, - header_lines=[ - f" ✅ Background task #{task_num} complete", - f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"", - ], + header_lines=[f" ✅ Background task #{task_num} complete", f" Prompt: \"{preview}\""], body=response, title_suffix=f"(background #{task_num})", empty_note=" (No response generated)", ) - - # Play bell if enabled if self.bell_on_complete: sys.stdout.write("\a") sys.stdout.flush() - except Exception as e: - # Same TUI refresh pattern as success path - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() + _refresh_tui_before_print(self) _cprint(f" ❌ Background task #{task_num} failed: {e}") finally: - try: + with suppress(Exception): set_sudo_password_callback(None) set_approval_callback(None) set_secret_capture_callback(None) - except Exception: - pass self._background_tasks.pop(task_id, None) - # Clear spinner only if no foreground agent owns it - if not self._agent_running: + if not self._agent_running: # clear spinner only if no foreground agent owns it self._spinner_text = "" if self._app: self._invalidate(min_interval=0) @@ -2470,59 +2277,44 @@ class CLICommandsMixin: (no history mutation, no role-alternation risk, no cache invalidation).""" from cli import _cprint - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - _cprint(" Usage: /btw ") - _cprint(" Example: /btw which file was that error in?") - _cprint(" Answers a quick question about this conversation without interrupting it.") - _cprint(" (For an independent background task, use /bg .)") + question = _command_arg(cmd) + if not question: + _cp(" Usage: /btw ", + " Example: /btw which file was that error in?", + " Answers a quick question about this conversation without interrupting it.", + " (For an independent background task, use /bg .)") return - - question = parts[1].strip() - if not self._ensure_runtime_credentials(): - _cprint(" (>_<) Cannot answer side question: no valid credentials.") + _cp(" (>_<) Cannot answer side question: no valid credentials.") return - # Snapshot NOW, on the UI thread — the foreground turn keeps appending - # to conversation_history while the worker runs. + # 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"], - "provider": turn_route["runtime"].get("provider"), - "base_url": turn_route["runtime"].get("base_url"), - "api_key": turn_route["runtime"].get("api_key"), - "api_mode": turn_route["runtime"].get("api_mode"), + **{k: runtime.get(k) for k in ("provider", "base_url", "api_key", "api_mode")}, } - - preview = question[:60] + ("..." if len(question) > 60 else "") - _cprint(f" 💬 Side question: \"{preview}\"") - _cprint(" Answering from a snapshot of this conversation — the current work continues.\n") + preview = _ellipsize(question, 60) + _cp(f" 💬 Side question: \"{preview}\"", + " Answering from a snapshot of this conversation — the current work continues.\n") def run_side_question(): try: from agent.side_question import answer_side_question answer = answer_side_question( - question, - history_snapshot, - parent_agent=parent_agent, - main_runtime=main_runtime, + question, history_snapshot, parent_agent=parent_agent, main_runtime=main_runtime, ) _print_side_result_panel( - self, - header_lines=[f" 💬 /btw: \"{preview}\""], - body=answer, - title_suffix="(btw)", - empty_note=" (No answer generated)", + self, header_lines=[f" 💬 /btw: \"{preview}\""], body=answer, + title_suffix="(btw)", empty_note=" (No answer generated)", ) except Exception as e: - if self._app: - self._app.invalidate() - time.sleep(0.05) - print() + _refresh_tui_before_print(self) _cprint(f" ❌ /btw failed: {e}") finally: if self._app: @@ -2530,28 +2322,27 @@ class CLICommandsMixin: threading.Thread(target=run_side_question, daemon=True, name="btw-side-question").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 ``/``.""" - from cli import ChatConsole, _BOLD, _DIM, _RST, _accent_hex, _cprint + 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: - _cprint(f"\033[1;31mBundle subsystem unavailable: {reply.data['error']}{_RST}") + _cp(f"\033[1;31mBundle subsystem unavailable: {reply.data['error']}{_RST}") return bundles = reply.data["bundles"] if not bundles: - _cprint(" No skill bundles installed.") - _cprint( - f" {_DIM}Create one with: hermes bundles create " - f" --skill --skill {_RST}" - ) - _cprint(f" {_DIM}Directory: {reply.data['dir']}{_RST}") + _cp(" No skill bundles installed.", + _dim_line('Create one with: hermes bundles create --skill --skill '), + _dim_line(f"Directory: {reply.data['dir']}")) return - _cprint(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(bundles)} installed):") + _cp(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(bundles)} installed):") for info in bundles: skill_count = len(info.get("skills", [])) desc = info.get("description") or f"Load {skill_count} skills" @@ -2561,15 +2352,11 @@ class CLICommandsMixin: ) for s in info.get("skills", []): ChatConsole().print(f" [dim]· {_escape(s)}[/]") - _cprint( - f"\n {_DIM}Invoke a bundle with /. " - f"Manage with `hermes bundles`.{_RST}" - ) + _cp("\n" + _dim_line("Invoke a bundle with /. Manage with `hermes bundles`.")) def _handle_browser_command(self, cmd: str): """Handle /browser connect|disconnect|status|use — manage the live Chromium-family CDP connection.""" - parts = cmd.strip().split(None, 1) - sub = parts[1].lower().strip() if len(parts) > 1 else "status" + sub = _command_arg(cmd).lower() or "status" if sub == "use" or sub.startswith("use "): _browser_use(self, sub.split(None, 1)[1].strip() if " " in sub else "on") elif sub.startswith("connect"): @@ -2589,51 +2376,40 @@ class CLICommandsMixin: " use [off] Switch to Browser Use mode (CLI 3.0) / back to built-in tools", ) + # ---- /heartbeat, /refine, /review ----------------------------------------------------- + def _handle_heartbeat_command(self, cmd: str) -> None: """Dispatch /heartbeat: set / status / pause / resume / clear. ``/heartbeat every 10m `` 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 cli import _DIM, _RST, _cprint - from hermes_cli.heartbeat import parse_interval, format_interval + from hermes_cli.heartbeat import format_interval - parts = (cmd or "").strip().split(None, 1) - arg = parts[1].strip() if len(parts) > 1 else "" + arg = _command_arg(cmd) lower = arg.lower() - mgr = self._get_heartbeat_manager() if mgr is None: - _cprint(f" {_DIM}Heartbeats unavailable (no active session).{_RST}") - return - - if not arg or lower == "status": - _cprint(f" {mgr.status_line()}") - return - - if lower == "pause": + _cp(_dim_line('Heartbeats unavailable (no active session).')) + elif not arg or lower == "status": + _cp(f" {mgr.status_line()}") + elif lower == "pause": state = mgr.pause() - if state is None: - _cprint(f" {_DIM}No heartbeat set.{_RST}") - else: - _cprint(f" ⏸ Heartbeat paused: {state.prompt}") - return - - if lower == "resume": + _cp(f" ⏸ Heartbeat paused: {state.prompt}" if state else _dim_line('No heartbeat set.')) + elif lower == "resume": state = mgr.resume() if state is None: - _cprint(f" {_DIM}No heartbeat to resume.{_RST}") + _cp(_dim_line('No heartbeat to resume.')) else: self._start_heartbeat_watchdog() - _cprint(f" ▶ Heartbeat resumed (every {format_interval(state.interval_seconds)}): {state.prompt}") - return + _cp(f" ▶ Heartbeat resumed (every {format_interval(state.interval_seconds)}): {state.prompt}") + elif lower in {"clear", "stop", "off"}: + _cp(" ✓ Heartbeat cleared." if mgr.clear() else _dim_line('No heartbeat set.')) + else: + self._heartbeat_set(mgr, arg) - if lower in {"clear", "stop", "off"}: - if mgr.clear(): - _cprint(" ✓ Heartbeat cleared.") - else: - _cprint(f" {_DIM}No heartbeat set.{_RST}") - return + def _heartbeat_set(self, mgr, arg: str) -> None: + """Set: ``/heartbeat every 10m `` (also accepts ``10m ``).""" + from hermes_cli.heartbeat import parse_interval, format_interval - # Set: `/heartbeat every 10m ` (also accepts `10m `). tokens = arg.split(None, 2) interval = None prompt = "" @@ -2645,103 +2421,80 @@ class CLICommandsMixin: prompt = arg[len(tokens[0]):].strip() if interval and interval > 0 else "" if interval is None: - _cprint(" Usage: /heartbeat every (e.g. /heartbeat every 10m Check CI)") - _cprint(f" {_DIM}Also: /heartbeat status | pause | resume | clear{_RST}") + _cp(" Usage: /heartbeat every (e.g. /heartbeat every 10m Check CI)", + _dim_line('Also: /heartbeat status | pause | resume | clear')) return if interval < 0: from hermes_cli.heartbeat import MIN_INTERVAL_SECONDS - _cprint(f" Interval too small — minimum is {MIN_INTERVAL_SECONDS}s.") + _cp(f" Interval too small — minimum is {MIN_INTERVAL_SECONDS}s.") return if not prompt.strip(): - _cprint(" Usage: /heartbeat every — the prompt is required.") + _cp(" Usage: /heartbeat every — the prompt is required.") return - try: state = mgr.set(prompt, interval) except ValueError as exc: - _cprint(f" Invalid heartbeat: {exc}") + _cp(f" Invalid heartbeat: {exc}") return self._start_heartbeat_watchdog() - _cprint(f" ♥ Heartbeat set (every {format_interval(state.interval_seconds)}): {state.prompt}") - _cprint( - f" {_DIM}Fires as a normal turn whenever the session is idle and the " - f"interval has elapsed. /heartbeat pause | resume | clear to manage; " - f"lives only while this Hermes process runs — use `hermes cron` for " - f"durable schedules.{_RST}" - ) + _cp(f" ♥ Heartbeat set (every {format_interval(state.interval_seconds)}): {state.prompt}", + _dim_line('Fires as a normal turn whenever the session is idle and the interval has elapsed. /heartbeat pause | resume | clear to manage; lives only while this Hermes process runs — use `hermes cron` for durable schedules.')) def _handle_refine_command(self, cmd: str) -> None: """Dispatch /refine — run the memory/skill review fork on demand (same machinery as the automatic post-turn ``_spawn_background_review``), with optional focus text. Background fork; the live conversation and prompt cache are never touched.""" - from cli import _DIM, _RST, _cprint - - parts = (cmd or "").strip().split(None, 1) - focus = parts[1].strip() if len(parts) > 1 else "" - + focus = _command_arg(cmd) agent = getattr(self, "agent", None) if agent is None: - _cprint(f" {_DIM}Nothing to refine yet — send a message first.{_RST}") + _cp(_dim_line('Nothing to refine yet — send a message first.')) return - snapshot = list(getattr(self, "conversation_history", None) or []) if not snapshot: - _cprint(f" {_DIM}Nothing to refine yet — the conversation is empty.{_RST}") + _cp(_dim_line('Nothing to refine yet — the conversation is empty.')) return - - review_skills = "skill_manage" in getattr(agent, "valid_tool_names", set()) try: agent._spawn_background_review( - messages_snapshot=snapshot, - review_memory=True, - review_skills=review_skills, - focus=focus or None, - explicit=True, + 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: - _cprint(f" /refine failed to start: {exc}") + _cp(f" /refine failed to start: {exc}") return tail = f" (focus: {focus})" if focus else "" - _cprint( - f" ⚗ Reviewing this conversation in the background{tail} — " - f"any memory/skill updates will be reported when done." - ) + _cp(f" ⚗ Reviewing this conversation in the background{tail} — " + f"any memory/skill updates will be reported when done.") def _handle_review_command(self, cmd: str) -> None: """Dispatch /review — snapshot the last N messages (+ argument text as instructions) and spawn an independent reviewer subagent via async delegation; the review re-enters this session as a normal delegation completion.""" - from cli import _DIM, _RST, _cprint - - parts = (cmd or "").strip().split(None, 1) - prompt = parts[1].strip() if len(parts) > 1 else "" - + prompt = _command_arg(cmd) agent = getattr(self, "agent", None) if agent is None: - _cprint(f" {_DIM}Nothing to review yet — send a message first.{_RST}") + _cp(_dim_line('Nothing to review yet — send a message first.')) return - 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: - _cprint(f" {_DIM}{exc}{_RST}") + _cp(_dim_line(str(exc))) return except Exception as exc: - _cprint(f" /review failed to start: {exc}") + _cp(f" /review failed to start: {exc}") return - _cprint(f" {format_dispatch_note(result, prompt)}") + _cp(f" {format_dispatch_note(result, prompt)}") + + # ---- /goal, /loop, /subgoal ----------------------------------------------------------- def _handle_goal_command(self, cmd: str) -> None: """Dispatch /goal subcommands: set / draft / show / gate / wait / status / pause / resume / clear.""" - from cli import _DIM, _RST, _cprint arg = _command_arg(cmd) - mgr = self._get_goal_manager() if mgr is None: - _cprint(f" {_DIM}Goals unavailable (no active session).{_RST}") + _cp(_dim_line('Goals unavailable (no active session).')) return lower = arg.lower() @@ -2750,296 +2503,240 @@ class CLICommandsMixin: rest = rest.strip() if not arg or lower == "status": - _cprint(f" {mgr.status_line()}") + _cp(f" {mgr.status_line()}") elif lower == "show": - _cprint(f" {mgr.status_line()}") - _cprint(f" {mgr.render_contract()}") + _cp(f" {mgr.status_line()}", f" {mgr.render_contract()}") elif lower.startswith("draft"): - # Expand plain text into a structured completion contract so "done" is - # evidence-based instead of a vibe check. + # Expand plain text into a structured completion contract so "done" is evidence-based + # instead of a vibe check. objective = arg[len("draft"):].strip() if not objective: - _cprint(" Usage: /goal draft ") + _cp(" Usage: /goal draft ") return self._handle_goal_draft(objective) elif lower == "pause": state = mgr.pause(reason="user-paused") - _cprint(f" ⏸ Goal paused: {state.goal}" if state else f" {_DIM}No goal set.{_RST}") + _cp(f" ⏸ Goal paused: {state.goal}" if state else _dim_line('No goal set.')) elif lower == "resume": self._goal_resume(mgr) elif lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() - _cprint(" ✓ Goal cleared." if had else f" {_DIM}No active goal.{_RST}") + _cp(" ✓ Goal cleared." if had else _dim_line('No active goal.')) elif verb == "wait": self._goal_wait(mgr, rest) elif lower == "unwait": - if mgr.stop_waiting(): - _cprint(" ▶ Wait barrier cleared — goal loop resumes.") - else: - _cprint(f" {_DIM}No wait barrier set.{_RST}") + _cp(" ▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting() + else _dim_line('No wait barrier set.')) elif verb == "gate": self._goal_gate(mgr, rest) else: self._goal_set(mgr, arg) + def _kick_goal(self, prompt: str) -> bool: + """Queue ``prompt`` as the next turn so the loop starts without a separate message.""" + try: + self._pending_input.put(prompt) + return True + except Exception: + return False + def _goal_resume(self, mgr) -> None: - from cli import _DIM, _RST, _cprint state = mgr.resume() if state is None: - _cprint(f" {_DIM}No goal to resume.{_RST}") + _cp(_dim_line('No goal to resume.')) return - _cprint(f" ▶ Goal resumed: {state.goal}") - # Resume must restart work, not just flip state: queue the continuation prompt - # the same way /goal queues its kickoff. + _cp(f" ▶ Goal resumed: {state.goal}") + # Resume must restart work, not just flip state: queue the continuation prompt the same + # way /goal queues its kickoff. prompt = mgr.next_continuation_prompt() - queued = False - if prompt: - try: - self._pending_input.put(prompt) - queued = True - except Exception: - pass - if queued: - _cprint(f" {_DIM}Continuing now — taking the next step.{_RST}") + if prompt and self._kick_goal(prompt): + _cp(_dim_line('Continuing now — taking the next step.')) else: - _cprint(f" {_DIM}Send any message to kick off the next step.{_RST}") + _cp(_dim_line('Send any message to kick off the next step.')) def _goal_wait(self, mgr, wait_arg: str) -> None: """/goal wait [reason] — park the loop on a background process (CI / build); the barrier auto-clears when the PID exits.""" - from cli import _cprint if not wait_arg: - _cprint(" Usage: /goal wait [reason]") + _cp(" Usage: /goal wait [reason]") return wtokens = wait_arg.split(None, 1) try: pid = int(wtokens[0]) except ValueError: - _cprint(" /goal wait: must be an integer process id.") + _cp(" /goal wait: must be an integer process id.") return reason = wtokens[1].strip() if len(wtokens) > 1 else "" try: mgr.wait_on(pid, reason=reason) except (RuntimeError, ValueError) as exc: - _cprint(f" /goal wait: {exc}") + _cp(f" /goal wait: {exc}") return rtxt = f" ({reason})" if reason else "" - _cprint(f" ⏳ Goal parked on pid {pid}{rtxt}. Loop pauses until it exits.") + _cp(f" ⏳ Goal parked on pid {pid}{rtxt}. Loop pauses until it exits.") def _goal_gate(self, mgr, gate_arg: str) -> None: """/goal gate [list | add | remove | clear] — shell commands that must pass before the judge may declare the goal done; a failing gate's output becomes the continuation prompt.""" - from cli import _cprint gate_lower = gate_arg.lower() if not gate_arg or gate_lower == "list": for line in mgr.render_gates().splitlines(): - _cprint(f" {line}") - return - if gate_lower.startswith("add "): - command = gate_arg[len("add"):].strip() + _cp(f" {line}") + elif gate_lower.startswith("add "): try: - gate = mgr.add_gate(command) + gate = mgr.add_gate(gate_arg[len("add"):].strip()) except (RuntimeError, ValueError) as exc: - _cprint(f" /goal gate add: {exc}") + _cp(f" /goal gate add: {exc}") return - _cprint( - f" ⚿ Gate added: $ {gate.command} " + _cp(f" ⚿ Gate added: $ {gate.command} " f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). " - f"It must pass before the goal can complete." - ) - return - if gate_lower.startswith("remove ") or gate_lower.startswith("rm "): - idx_text = gate_arg.split(None, 1)[1].strip() + f"It must pass before the goal can complete.") + elif gate_lower.startswith("remove ") or gate_lower.startswith("rm "): try: - removed = mgr.remove_gate(int(idx_text)) + removed = mgr.remove_gate(int(gate_arg.split(None, 1)[1].strip())) except (RuntimeError, ValueError, IndexError) as exc: - _cprint(f" /goal gate remove: {exc}") + _cp(f" /goal gate remove: {exc}") return - _cprint(f" ✓ Gate removed: $ {removed}") - return - if gate_lower == "clear": + _cp(f" ✓ Gate removed: $ {removed}") + elif gate_lower == "clear": try: prev = mgr.clear_gates() except RuntimeError as exc: - _cprint(f" /goal gate clear: {exc}") + _cp(f" /goal gate clear: {exc}") return - _cprint(f" ✓ Cleared {prev} gate{'s' if prev != 1 else ''}.") - return - _cprint(" Usage: /goal gate [list | add | remove | clear]") + _cp(f" ✓ Cleared {_plural(prev, 'gate')}.") + else: + _cp(" Usage: /goal gate [list | add | remove | clear]") def _goal_set(self, mgr, arg: str) -> None: """Set the goal from free text; inline `verify:`/`constraints:`/`boundaries:`/`stop when:` lines become a completion contract, the remaining prose the headline. Kicks the loop off.""" - from cli import _DIM, _RST, _cprint from hermes_cli.goals import parse_contract headline, contract = parse_contract(arg) - goal_text = headline or arg try: - state = mgr.set(goal_text, contract=contract if not contract.is_empty() else None) + state = mgr.set(headline or arg, contract=contract if not contract.is_empty() else None) except ValueError as exc: - _cprint(f" Invalid goal: {exc}") + _cp(f" Invalid goal: {exc}") return + self._print_goal_set(state, "Completion contract:") + against = " against the contract above" if state.has_contract() else "" + _cp(_dim_line(f"After each turn, a judge model checks if the goal is done{against}. " + "Hermes keeps working until it is, you pause/clear it, or the budget is " + "exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.")) + self._kick_goal(state.goal) - _cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}") + def _print_goal_set(self, state, contract_label: str) -> None: + _cp(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}") if state.has_contract(): - _cprint(f" {_DIM}Completion contract:{_RST}") + _cp(_dim_line(contract_label)) for line in state.contract.render_block().splitlines(): - _cprint(f" {line}") - _cprint( - f" {_DIM}After each turn, a judge model checks if the goal is done" - f"{' against the contract above' if state.has_contract() else ''}. " - f"Hermes keeps working until it is, you pause/clear it, or the budget is " - f"exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.{_RST}" - ) - # Kick the loop off immediately so the user doesn't have to send a separate message. - try: - self._pending_input.put(state.goal) - except Exception: - pass + _cp(f" {line}") def _handle_goal_draft(self, objective: str) -> None: - """Draft a structured completion contract from a plain objective and - set it as the active goal. Falls back to a bare goal if the aux model - can't produce a contract.""" - from cli import _DIM, _RST, _cprint + """Draft a structured completion contract from a plain objective and set it as the active + goal. Falls back to a bare goal if the aux model can't produce a contract.""" from hermes_cli.goals import draft_contract mgr = self._get_goal_manager() if mgr is None: - _cprint(f" {_DIM}Goals unavailable (no active session).{_RST}") + _cp(_dim_line('Goals unavailable (no active session).')) return - - _cprint(f" {_DIM}Drafting completion contract…{_RST}") + _cp(_dim_line('Drafting completion contract…')) try: contract = draft_contract(objective) except Exception as exc: import logging as _logging _logging.getLogger(__name__).debug("goal draft failed: %s", exc) contract = None - try: state = mgr.set(objective, contract=contract) except ValueError as exc: - _cprint(f" Invalid goal: {exc}") + _cp(f" Invalid goal: {exc}") return - - _cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}") + self._print_goal_set(state, "Drafted completion contract:") if state.has_contract(): - _cprint(f" {_DIM}Drafted completion contract:{_RST}") - for line in state.contract.render_block().splitlines(): - _cprint(f" {line}") - _cprint( - f" {_DIM}Tighten any field by re-setting the goal with inline " - f"lines (e.g. verify: ), then /goal resume. " - f"Use /goal show to review.{_RST}" - ) + _cp(_dim_line('Tighten any field by re-setting the goal with inline lines (e.g. verify: ), then /goal resume. Use /goal show to review.')) else: - _cprint( - f" {_DIM}Couldn't draft a contract (aux model unavailable) — " - f"running as a free-form goal. The per-turn judge still applies.{_RST}" - ) - try: - self._pending_input.put(state.goal) - except Exception: - pass + _cp(_dim_line("Couldn't draft a contract (aux model unavailable) — running as a free-form goal. The per-turn judge still applies.")) + self._kick_goal(state.goal) def _handle_loop_command(self, cmd: str) -> None: """Dispatch /loop — recurring in-session wakeups: ``/loop [interval] [--times N] [--until ]`` starts one; ``status | pause | resume | stop`` control it.""" - from cli import _DIM, _RST, _cprint - parts = (cmd or "").strip().split(None, 1) - arg = parts[1].strip() if len(parts) > 1 else "" - + arg = _command_arg(cmd) mgr = self._get_loop_manager() if mgr is None: - _cprint(f" {_DIM}Loops unavailable (no active session).{_RST}") + _cp(_dim_line('Loops unavailable (no active session).')) return from hermes_cli.loops import dispatch_loop_command result = dispatch_loop_command(mgr, arg) for line in (result.get("output") or "").splitlines(): - _cprint(f" {line}") + _cp(f" {line}") if result.get("created"): - try: + with suppress(Exception): from hermes_cli.loops import goal_blocks_loop_tick - if goal_blocks_loop_tick(mgr.session_id): - _cprint( - f" {_DIM}Note: an active /goal is driving this session — " - f"loop wakeups defer until the goal finishes, pauses, or parks.{_RST}" - ) - except Exception: - pass + _cp(_dim_line('Note: an active /goal is driving this session — loop wakeups defer until the goal finishes, pauses, or parks.')) def _handle_subgoal_command(self, cmd: str) -> None: """Dispatch /subgoal: bare → show, ```` → append, ``remove `` (1-based), ``clear``. Subgoals are extra criteria added mid-loop; they join both the judge prompt and the continuation prompt at the next turn boundary (no special kick).""" - from cli import _DIM, _RST, _cprint parts = (cmd or "").strip().split(None, 2) arg = " ".join(parts[1:]).strip() if len(parts) > 1 else "" mgr = self._get_goal_manager() if mgr is None: - _cprint(f" {_DIM}Goals unavailable (no active session).{_RST}") + _cp(_dim_line('Goals unavailable (no active session).')) return - if not mgr.has_goal(): - _cprint(f" {_DIM}No active goal. Set one with /goal .{_RST}") + _cp(_dim_line('No active goal. Set one with /goal .')) return - - # No args → list current subgoals. - if not arg: - _cprint(f" {mgr.status_line()}") - _cprint(f" {mgr.render_subgoals()}") + if not arg: # list current subgoals + _cp(f" {mgr.status_line()}", f" {mgr.render_subgoals()}") return tokens = arg.split(None, 1) verb = tokens[0].lower() rest = tokens[1].strip() if len(tokens) > 1 else "" - if verb == "remove": if not rest: - _cprint(" Usage: /subgoal remove ") + _cp(" Usage: /subgoal remove ") return try: idx = int(rest.split()[0]) except ValueError: - _cprint(" /subgoal remove: must be an integer (1-based index).") + _cp(" /subgoal remove: must be an integer (1-based index).") return try: removed = mgr.remove_subgoal(idx) except (IndexError, RuntimeError) as exc: - _cprint(f" /subgoal remove: {exc}") + _cp(f" /subgoal remove: {exc}") return - _cprint(f" ✓ Removed subgoal {idx}: {removed}") - return - - if verb == "clear": + _cp(f" ✓ Removed subgoal {idx}: {removed}") + elif verb == "clear": try: prev = mgr.clear_subgoals() except RuntimeError as exc: - _cprint(f" /subgoal clear: {exc}") + _cp(f" /subgoal clear: {exc}") return - if prev: - _cprint(f" ✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}.") - else: - _cprint(f" {_DIM}No subgoals to clear.{_RST}") - return + _cp(f" ✓ Cleared {_plural(prev, 'subgoal')}." if prev else _dim_line('No subgoals to clear.')) + else: # append the whole arg as a new subgoal + try: + text = mgr.add_subgoal(arg) + except (ValueError, RuntimeError) as exc: + _cp(f" /subgoal: {exc}") + return + idx = len(mgr.state.subgoals) if mgr.state else 0 + _cp(f" ✓ Added subgoal {idx}: {text}") - # Otherwise — append the whole arg as a new subgoal. - try: - text = mgr.add_subgoal(arg) - except (ValueError, RuntimeError) as exc: - _cprint(f" /subgoal: {exc}") - return - idx = len(mgr.state.subgoals) if mgr.state else 0 - _cprint(f" ✓ Added subgoal {idx}: {text}") + # ---- /skin, /prompt ------------------------------------------------------------------- def _handle_skin_command(self, cmd: str): """Handle /skin [name] — show or change the display skin.""" @@ -3050,14 +2747,12 @@ class CLICommandsMixin: print("Skin engine not available.") return - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or not parts[1].strip(): - # Show current skin and list available + new_skin = _command_arg(cmd).lower() + if not new_skin: # show current skin and list available current = get_active_skin_name() - skins = list_skins() print(f"\n Current skin: {current}") print(" Available skins:") - for s in skins: + for s in list_skins(): marker = " ●" if s["name"] == current else " " source = f" ({s['source']})" if s["source"] == "user" else "" print(f" {marker} {s['name']}{source} — {s['description']}") @@ -3065,7 +2760,6 @@ class CLICommandsMixin: print(f" Custom skins: drop a YAML file in {display_hermes_home()}/skins/\n") return - new_skin = parts[1].strip().lower() available = {s["name"] for s in list_skins()} if new_skin not in available: print(f" Unknown skin: {new_skin}") @@ -3073,13 +2767,9 @@ class CLICommandsMixin: return set_active_skin(new_skin) - _ACCENT.reset() # Re-resolve ANSI color for the new skin - # _DIM is now a fixed dim+italic ANSI escape (terminal-default fg) - # so it doesn't need re-resolving on skin switch. - if save_config_value("display.skin", new_skin): - print(f" Skin set to: {new_skin} (saved)") - else: - print(f" Skin set to: {new_skin}") + _ACCENT.reset() # re-resolve ANSI color for the new skin (_DIM is a fixed escape) + saved = " (saved)" if save_config_value("display.skin", new_skin) else "" + print(f" Skin set to: {new_skin}{saved}") print(" Note: banner colors will update on next session start.") if self._apply_tui_skin_style(): print(" Prompt + TUI colors updated.") @@ -3088,7 +2778,6 @@ class CLICommandsMixin: """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.""" - import os import shlex import subprocess import tempfile @@ -3110,44 +2799,34 @@ class CLICommandsMixin: try: subprocess.call([*shlex.split(editor), path]) except Exception: - # Fall back to a bare invocation (editor value may not be a - # simple argv-splittable string on some platforms). + # Fall back to a bare invocation (editor value may not be argv-splittable everywhere). subprocess.call(f"{editor} {shlex.quote(path)}", shell=True) with open(path, "r", encoding="utf-8") as fh: raw = fh.read() finally: - try: + with suppress(OSError): os.unlink(path) - except OSError: - pass - lines = [ln for ln in raw.splitlines() if not ln.startswith("#!")] - return "\n".join(lines).strip() + 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).""" - from cli import _DIM, _RST, _cprint - - initial = "" parts = (cmd_original or "").strip().split(None, 1) - if len(parts) > 1: - initial = parts[1] - try: - composed = self._compose_in_editor(initial) + composed = self._compose_in_editor(parts[1] if len(parts) > 1 else "") except Exception as exc: - _cprint(f" {_DIM}(>_<) Could not open editor: {exc}{_RST}") + _cp(_dim_line(f'(>_<) Could not open editor: {exc}')) return - if not composed: - _cprint(f" {_DIM}(._.) Empty prompt — nothing sent.{_RST}") + _cp(_dim_line('(._.) Empty prompt — nothing sent.')) return - - # One-shot seed: the interactive loop runs this as the next agent turn - # right after process_command() returns (see cli.py main loop). + # 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: """Toggle or inspect focus view (``/focus [on|off|status]``) — a DISPLAY-ONLY reduced-output mode. @@ -3156,73 +2835,56 @@ class CLICommandsMixin: ``agent/tool_executor.py`` gates on); turning it off restores the stash verbatim. Adds a per-turn hidden-line count with a recovery hint and a ``focus`` status-bar segment. Never touches history, system prompt, or request payloads.""" - from cli import _cprint, save_config_value + from cli import save_config_value 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, + FOCUS_CONFIG_KEY, FOCUS_TOOL_PROGRESS_MODE, format_focus_status, + format_focus_toggle_message, normalize_tool_progress_mode, resolve_focus_arg, ) - arg = _command_arg(cmd_original) current = bool(getattr(self, "_focus_view_enabled", False)) - action, target = resolve_focus_arg(arg, current) - + action, target = resolve_focus_arg(_command_arg(cmd_original), current) if action == "usage": - _cprint(" Usage: /focus [on|off|status]") + _cp(" Usage: /focus [on|off|status]") return # 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 + getattr(self, "_focus_saved_tool_progress", None) if current else getattr(self, "tool_progress_mode", "all") ) - if action == "status": - body = format_focus_status(current, restore_mode) - head, _, tail = body.partition("\n") + head, _, tail = format_focus_status(current, restore_mode).partition("\n") label, _, rest = head.partition(":") state_color = _Colors.GREEN if current else _Colors.DIM - _cprint( - f" {_Colors.BOLD}{label}:{_Colors.RESET}" - f"{state_color}{rest}{_Colors.RESET}" - + (f"\n{_Colors.DIM} {tail.strip()}{_Colors.RESET}" if tail else "") - ) + _cp(f" {_Colors.BOLD}{label}:{_Colors.RESET}{state_color}{rest}{_Colors.RESET}" + + (f"\n{_Colors.DIM} {tail.strip()}{_Colors.RESET}" if tail else "")) return - - if target == current: - # Idempotent explicit set — report without rewriting config. - _cprint(f" {format_focus_toggle_message(current, restore_mode)}") + if target == current: # idempotent explicit set — report without rewriting config + _cp(f" {format_focus_toggle_message(current, restore_mode)}") return if target: - # Stash the user's configured mode, then reuse the EXISTING - # suppression path by snapping to "off". + # 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_config_value(FOCUS_CONFIG_KEY, bool(target)) - state = ( - f"{_Colors.GREEN}enabled{_Colors.RESET}" if target - else f"{_Colors.DIM}disabled{_Colors.RESET}" - ) + state = (f"{_Colors.GREEN}enabled{_Colors.RESET}" if target + else f"{_Colors.DIM}disabled{_Colors.RESET}") 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: message = message.replace(word, state, 1) break - _cprint(f" {message}") + _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 @@ -3233,10 +2895,8 @@ class CLICommandsMixin: self.tool_progress_mode = normalized agent = getattr(self, "agent", None) if agent is not None: - try: + with suppress(Exception): agent.tool_progress_mode = normalized - except Exception: - pass 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 @@ -3262,100 +2922,80 @@ class CLICommandsMixin: from hermes_cli.focus_view import format_hidden_line line = format_hidden_line(count) - if not line: - return - try: - from cli import _DIM, _RST, _cprint + if line: + with suppress(Exception): + _cp(_dim_line(line)) - _cprint(f" {_DIM}{line}{_RST}") - except Exception: - pass + # ---- 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 cli import _cprint from hermes_cli.approval_mode import run_approval_mode_command parts = (cmd_original or "").strip().split(None, 1) - requested = parts[1] if len(parts) > 1 else None - result = run_approval_mode_command(requested) - _cprint(f" {result.message}") + result = run_approval_mode_command(parts[1] if len(parts) > 1 else None) + _cp(f" {result.message}") - def _handle_footer_command(self, cmd_original: str) -> None: - """Toggle or inspect ``display.runtime_footer.enabled`` (``/footer [on|off|status]``).""" - from cli import _cprint, save_config_value - from hermes_cli.config import load_config + 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 cli import save_config_value from hermes_cli.colors import Colors as _Colors - arg = _command_arg(cmd_original, lower=True) - cfg = load_config() or {} - footer_cfg = ((cfg.get("display") or {}).get("runtime_footer") or {}) - current = bool(footer_cfg.get("enabled", False)) - fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"] - new_state = _toggle_target(arg, current) if new_state == "status": - state = "ON" if current else "OFF" - _cprint( - f" {_Colors.BOLD}Runtime footer:{_Colors.RESET} {state}\n" - f" Fields: {', '.join(fields)}" - ) - return + _cp(f" {_Colors.BOLD}{label}:{_Colors.RESET} {'ON' if current else 'OFF'}{status_line}") + return None if new_state is None: - _cprint(" Usage: /footer [on|off|status]") - return - - if save_config_value("display.runtime_footer.enabled", new_state): - state = ( - f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state - else f"{_Colors.DIM}OFF{_Colors.RESET}" - ) - _cprint(f" Runtime footer: {state}") + _cp(f" Usage: {usage}") + return None + if save_config_value(config_key, new_state): + state = (f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state + else f"{_Colors.DIM}OFF{_Colors.RESET}") + _cp(f" {label}: {state}") else: - _cprint(" Failed to save runtime_footer setting to config.yaml") + _cp(f" Failed to save {failed} setting to config.yaml") + return new_state + + def _handle_footer_command(self, cmd_original: str) -> None: + """Toggle or inspect ``display.runtime_footer.enabled`` (``/footer [on|off|status]``).""" + from hermes_cli.config import load_config + + footer_cfg = (((load_config() or {}).get("display") or {}).get("runtime_footer") or {}) + fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"] + self._toggle_setting( + _command_arg(cmd_original, lower=True), bool(footer_cfg.get("enabled", False)), + usage="/footer [on|off|status]", status_line=f"\n Fields: {', '.join(fields)}", + config_key="display.runtime_footer.enabled", label="Runtime footer", failed="runtime_footer", + ) def _handle_timestamps_command(self, cmd_original: str) -> None: """Toggle or inspect ``display.timestamps`` (``/timestamps [on|off|status]``). When on, message labels carry an ``[HH:MM]`` suffix and ``/history`` prefixes stored-timestamp turns.""" - from cli import _cprint, save_config_value - from hermes_cli.colors import Colors as _Colors - arg = _command_arg(cmd_original, lower=True) current = bool(getattr(self, "show_timestamps", False)) - new_state = _toggle_target(arg, current) - if new_state == "status": - state = "ON" if current else "OFF" - _cprint(f" {_Colors.BOLD}Message timestamps:{_Colors.RESET} {state}") - return - if new_state is None: - _cprint(" Usage: /timestamps [on|off|status]") - return + if isinstance(new_state, bool): + self.show_timestamps = new_state + self._toggle_setting( + arg, current, usage="/timestamps [on|off|status]", status_line="", + config_key="display.timestamps", label="Message timestamps", failed="timestamps", + ) - self.show_timestamps = new_state - if save_config_value("display.timestamps", new_state): - state = ( - f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state - else f"{_Colors.DIM}OFF{_Colors.RESET}" - ) - _cprint(f" Message timestamps: {state}") - else: - _cprint(" Failed to save timestamps setting to config.yaml") + # ---- model-behaviour settings: /reasoning, /busy, /indicator, /fast ------------------- def _handle_reasoning_command(self, cmd: str): """Handle /reasoning — manage effort level and display toggle. - Usage: - /reasoning Show effort level and display state - /reasoning [--global] Set effort for this session (none, minimal, low, medium, - high, xhigh, max, ultra); --global persists to config.yaml - /reasoning show|hide Show / hide model thinking in output - /reasoning full|clamp Complete thinking vs. first-10-lines clamp""" - from cli import CLI_CONFIG, _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value - parts = cmd.strip().split(maxsplit=1) + ``/reasoning`` shows the effort level and display state; `` [--global]`` sets the + effort for this session (none, minimal, low, medium, high, xhigh, max, ultra; --global + persists to config.yaml); ``show|hide`` toggles thinking in the output; ``full|clamp`` + picks complete thinking vs. the first-10-lines clamp.""" + from cli import CLI_CONFIG, _parse_reasoning_config, save_config_value + raw = _command_arg(cmd) - if len(parts) < 2: - # Show current state + if not raw: # show current state rc = self.reasoning_config if rc is None: level = "medium (default)" @@ -3365,57 +3005,52 @@ class CLICommandsMixin: level = rc.get("effort", "medium") display_state = "on ✓" if self.show_reasoning else "off" full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines" - _cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}") - _cprint(f" {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}") - _cprint(f" {_DIM}Usage: /reasoning [--global]{_RST}") + _cp(_accent_line(f"Reasoning effort: {level}"), + _accent_line(f"Reasoning display: {display_state} ({full_state})"), + _dim_line('Usage: /reasoning [--global]')) return - arg, explicit_global = _split_scope_flags(parts[1]) + arg, explicit_global = _split_scope_flags(raw) # Display toggle - if arg in {"show", "on"}: - self.show_reasoning = True + if arg in {"show", "on"} or arg in {"hide", "off"}: + self.show_reasoning = arg in {"show", "on"} if self.agent: self.agent.reasoning_callback = self._current_reasoning_callback() - save_config_value("display.show_reasoning", True) - _cprint(f" {_ACCENT}✓ Reasoning display: ON (saved){_RST}") - _cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}") - return - if arg in {"hide", "off"}: - self.show_reasoning = False - if self.agent: - self.agent.reasoning_callback = self._current_reasoning_callback() - save_config_value("display.show_reasoning", False) - _cprint(f" {_ACCENT}✓ Reasoning display: OFF (saved){_RST}") + save_config_value("display.show_reasoning", self.show_reasoning) + if self.show_reasoning: + _cp(_accent_line("✓ Reasoning display: ON (saved)"), + _dim_line(' Model thinking will be shown during and after each response.')) + else: + _cp(_accent_line("✓ Reasoning display: OFF (saved)")) return # Full / clamped recap toggle if arg in {"full", "all"}: self.reasoning_full = True save_config_value("display.reasoning_full", True) - _cprint(f" {_ACCENT}✓ Reasoning display: FULL (saved){_RST}") - _cprint(f" {_DIM} The post-response recap box will print complete thinking.{_RST}") + _cp(_accent_line("✓ Reasoning display: FULL (saved)"), + _dim_line(' The post-response recap box will print complete thinking.')) if not self.show_reasoning: - _cprint(f" {_DIM} Note: reasoning display is OFF — run /reasoning show to see it.{_RST}") + _cp(_dim_line(' Note: reasoning display is OFF — run /reasoning show to see it.')) return if arg in {"clamp", "collapse", "short"}: self.reasoning_full = False save_config_value("display.reasoning_full", False) - _cprint(f" {_ACCENT}✓ Reasoning display: CLAMPED to 10 lines (saved){_RST}") + _cp(_accent_line("✓ Reasoning display: CLAMPED to 10 lines (saved)")) return # Effort level change parsed = _parse_reasoning_config(arg) if parsed is None: - _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh, max, ultra{_RST}") - _cprint(f" {_DIM}Display: show, hide{_RST}") - _cprint(f" {_DIM}Scope: session-scoped by default, --global to persist{_RST}") + _cp(_dim_line(f'(._.) Unknown argument: {arg}'), + _dim_line('Valid levels: none, minimal, low, medium, high, xhigh, max, ultra'), + _dim_line('Display: show, hide'), + _dim_line('Scope: session-scoped by default, --global to persist')) return self.reasoning_config = parsed self.agent = None # Force agent re-init with new reasoning config - saved = explicit_global and save_config_value("agent.reasoning_effort", arg) if saved: agent_cfg = CLI_CONFIG.get("agent") @@ -3423,99 +3058,82 @@ class CLICommandsMixin: agent_cfg = {} CLI_CONFIG["agent"] = agent_cfg agent_cfg["reasoning_effort"] = arg - _cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' {_scope_outcome(explicit_global, saved)}{_RST}") + _cp(_accent_line(f"✓ Reasoning effort set to '{arg}' {_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.""" - from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or parts[1].strip().lower() == "status": - _cprint(f" {_ACCENT}Busy input mode: {self.busy_input_mode}{_RST}") - _behavior = _BUSY_MODE_SHORT.get(self.busy_input_mode, _BUSY_MODE_SHORT["interrupt"]) - _cprint(f" {_DIM}Enter while busy: {_behavior}{_RST}") - _cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}") + from cli import save_config_value + arg = _command_arg(cmd, lower=True) + usage = _dim_line('Usage: /busy [queue|steer|interrupt|status]') + if not arg or arg == "status": + behavior = _BUSY_MODE_SHORT.get(self.busy_input_mode, _BUSY_MODE_SHORT["interrupt"]) + _cp(_accent_line(f"Busy input mode: {self.busy_input_mode}"), + _dim_line(f'Enter while busy: {behavior}'), usage) return - - arg = parts[1].strip().lower() if arg not in _BUSY_MODE_LONG: - _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}") + _cp(_dim_line(f'(._.) Unknown argument: {arg}'), usage) return - self.busy_input_mode = arg if save_config_value("display.busy_input_mode", arg): - _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (saved to config){_RST}") - _cprint(f" {_DIM}{_BUSY_MODE_LONG[arg]}{_RST}") + _cp(_accent_line(f"✓ Busy input mode set to '{arg}' (saved to config)"), _dim_line(_BUSY_MODE_LONG[arg])) else: - _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (session only){_RST}") + _cp(_accent_line(f"✓ Busy input mode set to '{arg}' (session only)")) 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 cli import _ACCENT, _DIM, _RST, _cprint, save_config_value + from cli import save_config_value from hermes_constants import DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES - styles = INDICATOR_STYLES - current = ( - (self.config.get("display") or {}).get("tui_status_indicator", DEFAULT_INDICATOR_STYLE) - ) - - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or parts[1].strip().lower() == "status": - _cprint(f" {_ACCENT}Busy-indicator style: {current}{_RST}") - _cprint(f" {_DIM}Usage: /indicator [{'|'.join(styles)}]{_RST}") + current = (self.config.get("display") or {}).get("tui_status_indicator", DEFAULT_INDICATOR_STYLE) + arg = _command_arg(cmd, lower=True) + usage = _dim_line(f"Usage: /indicator [{'|'.join(INDICATOR_STYLES)}]") + if not arg or arg == "status": + _cp(_accent_line(f"Busy-indicator style: {current}"), usage) return - - arg = parts[1].strip().lower() - if arg not in styles: - _cprint(f" {_DIM}(._.) Unknown indicator style: {arg}{_RST}") - _cprint(f" {_DIM}Usage: /indicator [{'|'.join(styles)}]{_RST}") + if arg not in INDICATOR_STYLES: + _cp(_dim_line(f'(._.) Unknown indicator style: {arg}'), usage) return - self.config.setdefault("display", {})["tui_status_indicator"] = arg if save_config_value("display.tui_status_indicator", arg): - _cprint(f" {_ACCENT}✓ Busy-indicator style set to '{arg}' (saved to config){_RST}") - _cprint(f" {_DIM}The TUI picks up the new style on its next render.{_RST}") + _cp(_accent_line(f"✓ Busy-indicator style set to '{arg}' (saved to config)"), + _dim_line('The TUI picks up the new style on its next render.')) else: - _cprint(f" {_ACCENT}✓ Busy-indicator style set to '{arg}' (session only){_RST}") + _cp(_accent_line(f"✓ Busy-indicator style set to '{arg}' (session only)")) 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). - """ - from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value + Session-scoped by default; ``--global`` persists agent.service_tier to config.yaml + (parity with /model and /reasoning).""" + from cli import save_config_value if not self._fast_command_available(): - _cprint(" (._.) /fast is only available for models that support fast mode (OpenAI Priority Processing or Anthropic Fast Mode).") + _cp(" (._.) /fast is only available for models that support fast mode (OpenAI Priority Processing or Anthropic Fast Mode).") return # Determine the branding for the current model try: from hermes_cli.models import _is_anthropic_fast_model - agent = getattr(self, "agent", None) - model = getattr(agent, "model", None) or getattr(self, "model", None) + model = getattr(getattr(self, "agent", None), "model", None) or getattr(self, "model", None) feature_name = "Anthropic Fast Mode" if _is_anthropic_fast_model(model) else "Priority Processing" except Exception: feature_name = "Fast mode" - parts = cmd.strip().split(maxsplit=1) - if len(parts) < 2 or parts[1].strip().lower() == "status": + raw = _command_arg(cmd) + usage = _dim_line('Usage: /fast [normal|fast|auto|cold|status] [--global]') + if not raw or raw.lower() == "status": status = {"priority": "fast", None: "normal"}.get(self.service_tier, self.service_tier) - _cprint(f" {_ACCENT}{feature_name}: {status}{_RST}") - _cprint(f" {_DIM}Usage: /fast [normal|fast|auto|cold|status] [--global]{_RST}") + _cp(_accent_line(f"{feature_name}: {status}"), usage) return - arg, explicit_global = _split_scope_flags(parts[1]) + arg, explicit_global = _split_scope_flags(raw) if arg not in _FAST_TIERS: - _cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}") - _cprint(f" {_DIM}Usage: /fast [normal|fast|auto|cold|status] [--global]{_RST}") + _cp(_dim_line(f'(._.) Unknown argument: {arg}'), usage) return self.service_tier, saved_value = _FAST_TIERS[arg] - label = saved_value.upper() - self.agent = None # Force agent re-init with new service-tier config saved = explicit_global and save_config_value("agent.service_tier", saved_value) - _cprint(f" {_ACCENT}✓ {feature_name} set to {label} {_scope_outcome(explicit_global, saved)}{_RST}") + _cp(_accent_line(f"✓ {feature_name} set to {saved_value.upper()} {_scope_outcome(explicit_global, saved)}")) + + # ---- /debug, /update, /voice, /wake --------------------------------------------------- def _handle_debug_command(self, cmd_original: str = ""): """Handle /debug [nous|local] — upload debug report + logs and print share URLs. @@ -3526,12 +3144,8 @@ class CLICommandsMixin: words = {w.lower() for w in cmd_original.split()[1:]} local = "local" in words - nous = "nous" in words and not local # Typing /debug is the upload consent (yes=True); input() would hang in prompt_toolkit anyway. - args = SimpleNamespace( - lines=200, expire=7, local=local, nous=nous, yes=True - ) - run_debug_share(args) + 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``. @@ -3554,27 +3168,19 @@ class CLICommandsMixin: detail="This will exit the current session and run `hermes update`.", choices=choices, ) - if raw is None: - print(" 🟡 /update cancelled.") - return False - choice = self._normalize_slash_confirm_choice(raw, choices) - if choice != "once": + if raw is None or self._normalize_slash_confirm_choice(raw, choices) != "once": print(" 🟡 /update cancelled.") return False - print() - print(" ⚕ Launching update...") - print() - - # run() execs this from the main thread after prompt_toolkit restores terminal - # modes. Relaunching here (daemon thread) would skip terminal cleanup on POSIX - # and, on Windows, sys.exit would only end the worker thread. + _say_block(" ⚕ Launching update...") + # run() execs this from the main thread after prompt_toolkit restores terminal modes. + # Relaunching here (daemon thread) would skip terminal cleanup on POSIX and, on Windows, + # sys.exit would only end the worker thread. self._pending_relaunch = ["update"] return True def _handle_voice_command(self, command: str): """Handle /voice [on|off|tts|status] command.""" - from cli import _cprint subcommand = _command_arg(command, lower=True) if subcommand == "": # bare /voice toggles subcommand = "off" if self._voice_mode else "on" @@ -3587,14 +3193,12 @@ class CLICommandsMixin: if subcommand in actions: actions[subcommand]() else: - _cprint(f"Unknown voice subcommand: {subcommand}") - _cprint("Usage: /voice [on|off|tts|status]") + _cp(f"Unknown voice subcommand: {subcommand}", "Usage: /voice [on|off|tts|status]") def _handle_wake_command(self, command: str): """Handle /wake [on|off|status] — the 'Hey Hermes' hotword listener. The toggle IS the config: on/off also writes ``wake_word.enabled`` so the choice persists; startup auto-arm only reads it.""" - from cli import _cprint subcommand = _command_arg(command, lower=True) if subcommand == "": # bare /wake toggles subcommand = "off" if getattr(self, "_wake_word_active", False) else "on" @@ -3607,29 +3211,16 @@ class CLICommandsMixin: elif subcommand == "status": self._show_wake_word_status() else: - _cprint(f"Unknown wake subcommand: {subcommand}") - _cprint("Usage: /wake [on|off|status]") + _cp(f"Unknown wake subcommand: {subcommand}", "Usage: /wake [on|off|status]") def _persist_wake_word_enabled(self, enabled: bool): """Save ``wake_word.enabled`` so the /wake toggle sticks for future sessions.""" - from cli import _cprint, _DIM, _RST, save_config_value + from cli import save_config_value - try: + with suppress(Exception): from tools.wake_word import load_wake_word_config - if bool(load_wake_word_config().get("enabled")) == enabled: return # already persisted — don't rewrite config or re-announce - except Exception: - pass if save_config_value("wake_word.enabled", enabled): - _cprint(f"{_DIM}Wake word {'enabled' if enabled else 'disabled'} in config " - f"(wake_word.enabled: {str(enabled).lower()}).{_RST}") - - -_CRON_SUBCOMMANDS.update({ - "list": CLICommandsMixin._cron_list, - "add": CLICommandsMixin._cron_add, - "create": CLICommandsMixin._cron_add, - "edit": CLICommandsMixin._cron_edit, - **{k: CLICommandsMixin._cron_job_action for k in ("pause", "resume", "run", "remove", "rm", "delete")}, -}) + _cp(_dim(f"Wake word {'enabled' if enabled else 'disabled'} in config " + f"(wake_word.enabled: {str(enabled).lower()}).")) From b494c03d5ecfb2571ee133b1e0c759f0762a285d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:57:05 -0700 Subject: [PATCH 02/22] refactor(hermes_cli): merge consecutive print runs, compact layout in cli_commands_mixin --- hermes_cli/cli_commands_mixin.py | 489 +++++++++++-------------------- 1 file changed, 172 insertions(+), 317 deletions(-) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 255864f0fa..8aca152a63 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -31,8 +31,7 @@ from hermes_cli.browser_connect import ( is_browser_debug_ready, launch_chrome_debug, local_port_in_use, - manual_chrome_debug_command, -) + manual_chrome_debug_command) # --------------------------------------------------------------------------------------- @@ -46,6 +45,12 @@ def _cp(*lines: str) -> None: _cprint(line) +def _pr(*lines: str) -> None: + """print() each line.""" + for line in lines: + print(line) + + def _dim(text: str) -> str: """Wrap ``text`` in the dim ANSI escape.""" from cli import _DIM, _RST @@ -140,8 +145,7 @@ _CRON_VALUE_FLAGS = {"--name": "name", "--deliver": "deliver", "--prompt": "prom # /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")}, -} + **{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"} @@ -150,8 +154,7 @@ _OFF_WORDS = {"off", "disable", "false", "0"} _BUSY_MODE_SHORT = { "queue": "queues for next turn", "steer": "steers into current run (after next tool call)", - "interrupt": "redirects current run immediately", -} + "interrupt": "redirects current run immediately"} _BUSY_MODE_LONG = { "queue": "Enter will queue follow-up input while Hermes is busy.", "steer": "Enter will steer your message into the current run (after the next tool call).", @@ -162,14 +165,12 @@ _BUSY_MODE_LONG = { _FAST_TIERS = { "fast": ("priority", "fast"), "on": ("priority", "fast"), "normal": (None, "normal"), "off": (None, "normal"), - "auto": ("auto", "auto"), "cold": ("cold", "cold"), -} + "auto": ("auto", "auto"), "cold": ("cold", "cold")} # /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", -} + "all": "all", "--all": "all", "head": "all", "session": "session"} _DIFF_LABELS = {"working": "Unstaged", "staged": "Staged", "all": "All (vs HEAD)"} @@ -224,8 +225,7 @@ def _parse_cron_flags(tokens): opts = { "name": None, "deliver": None, "repeat": None, "prompt": None, "schedule": None, "skills": [], "add_skills": [], "remove_skills": [], - "clear_skills": False, "all": False, "positionals": [], - } + "clear_skills": False, "all": False, "positionals": []} i = 0 while i < len(tokens): token = tokens[i] @@ -262,8 +262,7 @@ def _end_current_session(cli, reason: str) -> None: if cli.agent: with suppress(Exception): cli.agent._flush_messages_to_session_db( - cli.conversation_history, conversation_history=cli.conversation_history, - ) + cli.conversation_history, conversation_history=cli.conversation_history) with suppress(Exception): cli._session_db.end_session(cli.session_id, reason) @@ -290,8 +289,7 @@ def _sync_agent_to_session(cli, session_id: str, *, parent_session_id: str, reas _mm = getattr(cli.agent, "_memory_manager", None) if _mm is not None: _mm.on_session_switch( - session_id, parent_session_id=parent_session_id or "", reset=False, reason=reason, - ) + session_id, parent_session_id=parent_session_id or "", reset=False, reason=reason) def _db_unavailable_line() -> str: @@ -303,7 +301,8 @@ def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_not """Print a worker-thread result (/bg, /btw) 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, _cprint, _maybe_remap_for_light_mode, _render_final_assistant_content + from cli import ( + ChatConsole, _accent_hex, _cprint, _maybe_remap_for_light_mode, _render_final_assistant_content) _refresh_tui_before_print(cli) ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") for line in header_lines: @@ -328,8 +327,7 @@ def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_not style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4), - width=cli._scrollback_box_width(), - )) + width=cli._scrollback_box_width())) def _refresh_tui_before_print(cli) -> None: @@ -350,7 +348,6 @@ def _print_lightpanda_engine_status() -> None: says whether it is in use or which higher-precedence setting shadows it.""" try: from tools.browser_tool import lightpanda_engine_status, _using_lightpanda_engine - if not _using_lightpanda_engine(): return used, reason = lightpanda_engine_status() @@ -362,7 +359,6 @@ def _print_lightpanda_engine_status() -> None: print(f" Engine: Lightpanda — {reason} (no screenshots)") try: from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary - lightpanda_bin = find_lightpanda_binary() except Exception: return @@ -376,13 +372,11 @@ def _browser_use(cli, arg: str) -> None: """/browser use [off] — toggle Browser Use mode (browser.backend); resets the session.""" from hermes_cli.config import load_config, save_config from tools.registry import invalidate_check_fn_cache - if arg not in {"on", "off"}: _say_block( "Usage: /browser use [off]", " /browser use — switch to Browser Use mode (browser_exec via CLI 3.0)", - " /browser use off — revert to the built-in browser tools", - ) + " /browser use off — revert to the built-in browser tools") return config = load_config() if arg == "on": @@ -405,8 +399,7 @@ def _normalize_cdp_url(cdp_url: str): if parsed.scheme not in {"http", "https", "ws", "wss"}: _say_block( f" ⚠ Unsupported browser url scheme: {parsed.scheme or '(missing)'} " - "(expected one of: http, https, ws, wss)" - ) + "(expected one of: http, https, ws, wss)") return None try: port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80) @@ -425,12 +418,11 @@ 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) - print(f" ⚠ Port {port} is occupied by another application that isn't a CDP browser") - print(f" (an IDE debugger or dev server may be using it) — launching on port {launch_port} instead...") + _pr(f" ⚠ Port {port} is occupied by another application that isn't a CDP browser", + f" (an IDE debugger or dev server may be using it) — launching on port {launch_port} instead...") else: print(" Chromium-family browser isn't running with remote debugging — attempting to launch...") launch = launch_chrome_debug(launch_port, _plat.system()) @@ -440,8 +432,7 @@ def _launch_default_cdp_browser(port: int): print(f" {launch.hint}") chrome_cmd = manual_chrome_debug_command(launch_port, _plat.system()) if chrome_cmd: - print(" Launch a Chromium-family browser manually:") - print(f" {chrome_cmd}") + _pr(" Launch a Chromium-family browser manually:", f" {chrome_cmd}") else: print(" No supported Chromium-family browser executable found in this environment") return None @@ -451,8 +442,8 @@ def _launch_default_cdp_browser(port: int): print(f" ✓ Chromium-family browser launched and listening on port {launch_port}") return found time.sleep(0.5) - print(f" ⚠ Browser launched but port {launch_port} isn't responding yet") - print(" Try again in a few seconds — the debug instance may still be starting") + _pr(f" ⚠ Browser launched but port {launch_port} isn't responding yet", + " Try again in a few seconds — the debug instance may still be starting") return None @@ -468,7 +459,6 @@ def _browser_connect(cli, cdp_url: str) -> None: with suppress(Exception): from tools.browser_tool import cleanup_all_browsers cleanup_all_browsers() - print() # Already serving CDP? For the default-local URL probe both loopbacks: a squatter @@ -481,7 +471,6 @@ def _browser_connect(cli, cdp_url: str) -> None: cdp_url = found else: already_open = is_browser_debug_ready(cdp_url, timeout=1.0) - if already_open: print(f" ✓ Chromium-family browser is already listening at {cdp_url}") elif is_default: @@ -491,11 +480,9 @@ def _browser_connect(cli, cdp_url: str) -> None: cdp_url = found else: print(f" ⚠ Port {port} is not reachable at {cdp_url}") - if not already_open: _say_block("Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect") return - os.environ["BROWSER_CDP_URL"] = cdp_url # Eagerly start the CDP supervisor so pending_dialogs + frame_tree show up in the next snapshot. with suppress(Exception): @@ -514,8 +501,7 @@ def _browser_connect(cli, cdp_url: str) -> None: "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.]" - ) + "closing tabs, or navigating away unless the user's task calls for it.]") def _browser_disconnect(cli) -> None: @@ -529,13 +515,11 @@ def _browser_disconnect(cli) -> None: cleanup_all_browsers() _say_block( "🌐 Browser disconnected from live Chromium-family browser", - " Browser tools reverted to default mode (local headless or cloud provider)", - ) + " Browser tools reverted to default mode (local headless or cloud provider)") if hasattr(cli, '_pending_input'): cli._pending_input.put( "[System note: The user has disconnected the browser tools from their live Chromium-family browser. " - "Browser tools are back to default mode (headless local browser or cloud provider).]" - ) + "Browser tools are back to default mode (headless local browser or cloud provider).]") def _browser_tool_attr(name: str, default): @@ -556,14 +540,14 @@ def _browser_status() -> None: except Exception: _bu_mode = False if _bu_mode: - print("🌐 Browser: Browser Use mode (browser_exec via the Browser Use CLI 3.0)") - print(" Local Chrome via CDP, or Browser Use cloud browsers") + _pr("🌐 Browser: Browser Use mode (browser_exec via the Browser Use CLI 3.0)", + " Local Chrome via CDP, or Browser Use cloud browsers") _print_lightpanda_engine_status() _say_block(" /browser use off — revert to the built-in browser tools") return if current: - print("🌐 Browser: connected to live Chromium-family browser via CDP") - print(f" Endpoint: {current}") + _pr("🌐 Browser: connected to live Chromium-family browser via CDP", + f" Endpoint: {current}") _print_lightpanda_engine_status() _port = 9222 with suppress(ValueError, IndexError): @@ -585,9 +569,9 @@ def _browser_status() -> None: else: engine = _browser_tool_attr("_get_browser_engine", "auto") if engine == "lightpanda": - print("🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)") - print(" ⚡ Lightpanda: faster navigation, no screenshot support") - print(" Automatic Chromium fallback for screenshots and failed commands") + _pr("🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)", + " ⚡ Lightpanda: faster navigation, no screenshot support", + " Automatic Chromium fallback for screenshots and failed commands") _print_lightpanda_engine_status() elif engine == "chrome": print("🌐 Browser: local headless Chromium (agent-browser --engine chrome)") @@ -595,8 +579,7 @@ def _browser_status() -> None: print("🌐 Browser: local headless Chromium (agent-browser)") _say_block( " /browser connect — connect to your live Chromium-family browser", - " /browser disconnect — revert to default", - ) + " /browser disconnect — revert to default") class CLICommandsMixin: @@ -623,21 +606,17 @@ class CLICommandsMixin: chat turn); `` --all`` is the classic full restore; ``diff `` previews changes since N; `` `` restores a single file.""" from tools.checkpoint_manager import format_checkpoint_list - mgr = self._checkpoint_manager(( " Checkpoints are not enabled.", " Enable with: hermes --checkpoints", - " Or in config.yaml: checkpoints: { enabled: true }", - )) + " Or in config.yaml: checkpoints: { enabled: true }")) if mgr is None: return - cwd = os.getenv("TERMINAL_CWD", os.getcwd()) args = command.split()[1:] # --all / --force: classic full restore, overwriting user edits too. restore_all = any(a.lower() in ("--all", "--force") for a in args) args = [a for a in args if a.lower() not in ("--all", "--force")] - if not args: # List checkpoints — fall back to the cross-project view when the current # directory has none (writes may have landed under the session cwd, not TERMINAL_CWD). @@ -650,7 +629,6 @@ class CLICommandsMixin: return print(format_checkpoint_list(checkpoints, cwd)) return - is_diff = args[0].lower() == "diff" if is_diff and len(args) < 2: print(" Usage: /rollback diff ") @@ -665,7 +643,8 @@ class CLICommandsMixin: if is_diff: self._rollback_diff(mgr, cwd, target_hash) else: - self._rollback_restore(mgr, cwd, target_hash, args[1] if len(args) > 1 else None, restore_all) + 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) @@ -682,13 +661,14 @@ class CLICommandsMixin: # Limit diff output to avoid terminal flood diff_lines = diff.splitlines() if len(diff_lines) > 80: - print("\n".join(diff_lines[:80])) - print(f"\n ... ({len(diff_lines) - 80} more lines, showing first 80)") + _pr("\n".join(diff_lines[:80]), + f"\n ... ({len(diff_lines) - 80} more lines, showing first 80)") else: print(f"\n{diff}") - def _rollback_restore(self, mgr, cwd: str, 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) + 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"]: print(f" ❌ {result['error']}") return @@ -696,11 +676,12 @@ class CLICommandsMixin: print(f" ✅ Restored {what} {result['restored_to']}: {result['reason']}") skipped = result.get("skipped_user_edits") or [] if skipped: - print(f" ↷ Kept your hand-edits: {_summarize_paths(skipped)}") - print(" Use /rollback --all to restore those too.") + _pr(f" ↷ Kept your hand-edits: {_summarize_paths(skipped)}", + " Use /rollback --all to restore those too.") oversize = result.get("skipped_oversize") or [] if oversize: - print(f" ↷ Kept (too large for checkpoints, no stored copy to revert to): {_summarize_paths(oversize)}") + print(" ↷ Kept (too large for checkpoints, no stored copy to revert to): " + f"{_summarize_paths(oversize)}") failed = result.get("failed_deletes") or [] if failed: print(f" ⚠️ Could not remove (left in place): {_summarize_paths(failed)}") @@ -728,14 +709,11 @@ class CLICommandsMixin: mode = _DIFF_MODES[low] else: paths.append(arg) - cwd = os.getenv("TERMINAL_CWD", os.getcwd()) if mode == "session": self._print_session_diff(cwd, stat_only) return - from tools.working_diff import collect_working_diff - result = collect_working_diff(cwd, mode=mode, paths=paths or None) if not result.get("success"): print(f" {result.get('error', 'Could not generate diff')}") @@ -769,8 +747,7 @@ class CLICommandsMixin: " Checkpoints are not enabled, so there's no session baseline.", " Enable with: hermes --checkpoints", " Or in config.yaml: checkpoints: { enabled: true }", - " (Plain /diff still works — it uses git directly.)", - )) + " (Plain /diff still works — it uses git directly.)")) if mgr is None: return result = mgr.session_diff(cwd) @@ -810,25 +787,24 @@ class CLICommandsMixin: 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, + "restore": self._snapshot_restore, "rewind": self._snapshot_restore, + "prune": self._snapshot_prune, }.get(subcmd) if handler is None: - print(f" Unknown subcommand: {subcmd}") - print(" Usage: /snapshot [list|create [label]|restore |prune [N]]") + _pr(f" Unknown subcommand: {subcmd}", + " Usage: /snapshot [list|create [label]|restore |prune [N]]") return handler(parts) def _snapshot_list(self, parts) -> None: from hermes_cli.backup import list_quick_snapshots - snaps = list_quick_snapshots() if not snaps: - print(" No state snapshots yet.") - print(" Create one: /snapshot create [label]") + _pr(" No state snapshots yet.", " Create one: /snapshot create [label]") return print(f" State snapshots ({display_hermes_home()}/state-snapshots/):\n") - print(f" {'#':>3} {'ID':<35} {'Files':>5} {'Size':>10} {'Label'}") - print(f" {'─'*3} {'─'*35} {'─'*5} {'─'*10} {'─'*20}") + _pr(f" {'#':>3} {'ID':<35} {'Files':>5} {'Size':>10} {'Label'}", + f" {'─'*3} {'─'*35} {'─'*5} {'─'*10} {'─'*20}") for i, s in enumerate(snaps, 1): size = s.get("total_size", 0) if size < 1024: @@ -842,13 +818,11 @@ class CLICommandsMixin: def _snapshot_create(self, parts) -> None: from hermes_cli.backup import create_quick_snapshot - snap_id = create_quick_snapshot(label=" ".join(parts[2:]) if len(parts) > 2 else None) print(f" Snapshot created: {snap_id}" if snap_id else " No state files found to snapshot.") def _snapshot_restore(self, parts) -> None: from hermes_cli.backup import list_quick_snapshots, restore_quick_snapshot - if len(parts) < 3: print(" Usage: /snapshot restore ") snaps = list_quick_snapshots(limit=1) @@ -873,16 +847,14 @@ class CLICommandsMixin: with suppress(Exception): local_session_db.close() self._session_db = None - if restore_quick_snapshot(snap_id): - print(f" Restored state from: {snap_id}") - print(" Restart recommended for gateway/dashboard processes to pick up state.db changes.") + _pr(f" Restored state from: {snap_id}", + " Restart recommended for gateway/dashboard processes to pick up state.db changes.") else: print(f" Snapshot not found: {snap_id}") def _snapshot_prune(self, parts) -> None: from hermes_cli.backup import prune_quick_snapshots - keep = 20 if len(parts) > 2: try: @@ -898,7 +870,6 @@ class CLICommandsMixin: 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: print(" Usage: /export [profile] [-o output.tar.gz]") @@ -906,8 +877,8 @@ class CLICommandsMixin: 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))) - print(f" ✓ Exported '{name}' to {result}") - print(" Share it: the other user runs /import or `hermes profile import `.") + _pr(f" ✓ Exported '{name}' to {result}", + " Share it: the other user runs /import or `hermes profile import `.") except (ValueError, FileNotFoundError, OSError) as e: print(f" Error: {e}") @@ -915,7 +886,6 @@ class CLICommandsMixin: """Handle /import [--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: print(" Usage: /import [--name ]") @@ -940,7 +910,6 @@ class CLICommandsMixin: """Handle /stop — kill all running background processes and background (async) delegations. Separate from interrupt (stop the current turn), as in Codex.""" 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. try: @@ -949,7 +918,6 @@ class CLICommandsMixin: except Exception: n_async = 0 interrupt_all = None - if not running and not n_async: print(" No running background processes.") return @@ -963,11 +931,9 @@ class CLICommandsMixin: """Handle /agents — show background processes and agent status.""" from cli import _cprint 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"] - _cprint(f" Running processes: {len(running)}") for p in running: up = format_uptime_short(p.get("uptime_seconds", 0)) @@ -1007,7 +973,6 @@ class CLICommandsMixin: if idle is not None: part += f" · last activity {idle:.0f}s ago" _cprint(part) - agent_running = getattr(self, "_agent_running", False) _cprint(f" Agent: {'running' if agent_running else 'idle'}") @@ -1021,17 +986,14 @@ class CLICommandsMixin: import io import shlex from contextlib import redirect_stdout - from cli import _cprint 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) @@ -1052,7 +1014,6 @@ class CLICommandsMixin: _cp(_dim_line("Clipboard image paste is not available on Termux — use /image or " f"paste a local image path like {_termux_example_image_path()}")) return - from hermes_cli.clipboard import has_clipboard_image if not has_clipboard_image(): _cp(_dim_line('(._.) No image found in clipboard')) @@ -1065,12 +1026,10 @@ class CLICommandsMixin: """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: _cp(" Nothing to copy yet.") return - if arg: try: idx = int(arg) - 1 @@ -1087,16 +1046,14 @@ class CLICommandsMixin: if idx < 0: _cp(" Nothing to copy in assistant responses yet.") return - text = _assistant_copy_text(assistant[idx].get("content")) if not text: _cp(" Nothing to copy in that assistant response.") return - 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 via the emulator). + # 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): self._write_osc52_clipboard(text) _cp(f" Copied assistant response #{idx + 1} via OSC 52 (terminal support required)") @@ -1107,13 +1064,13 @@ class CLICommandsMixin: def _handle_image_command(self, cmd_original: str): """Handle /image — attach a local image file for the next prompt.""" - from cli import _IMAGE_EXTENSIONS, _resolve_attachment_path, _split_path_input, _termux_example_image_path + from cli import ( + _IMAGE_EXTENSIONS, _resolve_attachment_path, _split_path_input, _termux_example_image_path) raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "") if not raw_args: hint = _termux_example_image_path() if _is_termux_environment() else "/path/to/image.png" _cp(_dim_line(f'Usage: /image e.g. /image {hint}')) return - path_token, _remainder = _split_path_input(raw_args) image_path = _resolve_attachment_path(path_token) if image_path is None: @@ -1122,7 +1079,6 @@ class CLICommandsMixin: if image_path.suffix.lower() not in _IMAGE_EXTENSIONS: _cp(_dim_line(f'(._.) Not a supported image file: {image_path.name}')) return - self._attached_images.append(image_path) _cp(f" 📎 Attached image: {image_path.name}") if _remainder: @@ -1156,13 +1112,11 @@ class CLICommandsMixin: class _TTYBuf(StringIO): def isatty(self) -> bool: return True - buf = _TTYBuf() with redirect_stdout(buf): tools_disable_enable_command(ns) for line in buf.getvalue().splitlines(): _cprint(line) - parts = _shlex_args(cmd) subcommand = parts[0] if parts else "" if subcommand not in {"list", "disable", "enable"}: @@ -1171,19 +1125,17 @@ class CLICommandsMixin: if subcommand == "list": _run_capture(Namespace(tools_action="list", platform="cli")) return - names = parts[1:] if not names: - print(f"(._.) Usage: /tools {subcommand} [name ...]") - print(f" Built-in toolset: /tools {subcommand} web") - print(f" MCP tool: /tools {subcommand} github:create_issue") + _pr(f"(._.) Usage: /tools {subcommand} [name ...]", + f" Built-in toolset: /tools {subcommand} web", + f" MCP tool: /tools {subcommand} github:create_issue") return # Typing the command is consent. Do NOT use input() — it hangs in prompt_toolkit's loop. verb = "Disabling" if subcommand == "disable" else "Enabling" _cprint(_accent(f"{verb} {', '.join(names)}...")) _run_capture(Namespace(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") @@ -1193,7 +1145,6 @@ class CLICommandsMixin: def _handle_profile_command(self): """Display active profile name and home directory.""" from hermes_cli.slash_exec import CommandContext, execute_command - reply = execute_command("profile", CommandContext(surface="cli")) _say_block(f" Profile: {reply.data['profile']}", f" Home: {reply.data['home']}") @@ -1249,7 +1200,6 @@ class CLICommandsMixin: except Exception as exc: _cp(f" Could not load gateway config: {exc}") return None - pcfg = gw_config.platforms.get(platform) if not pcfg or not pcfg.enabled: # Relay aliasing: a relay-fronted gateway has only a RELAY config block, yet @@ -1266,7 +1216,6 @@ class CLICommandsMixin: if not relay_fronts: _cp(f" Platform '{platform_name}' is not configured/enabled in the gateway.") return None - home = gw_config.get_home_channel(platform) if not home or not home.chat_id: _cp(f" No home channel configured for {platform_name}.", @@ -1363,8 +1312,7 @@ class CLICommandsMixin: # instant wins; we lose only the retry convenience, never the handoff). try: self._session_db.fail_handoff( - self.session_id, "timed out waiting for gateway", only_states=("pending",), - ) + 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): @@ -1385,7 +1333,6 @@ class CLICommandsMixin: # Users copy the help text's placeholder brackets/quotes verbatim (``/resume ``). if len(target) >= 2 and (target[0], target[-1]) in {("<", ">"), ("[", "]"), ('"', '"'), ("'", "'")}: target = target[1:-1].strip() - if not target: _cp(" Usage: /resume ") if self._show_recent_sessions(reason="resume"): @@ -1415,7 +1362,6 @@ class CLICommandsMixin: 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: _cp(f" Session not found: {target}", @@ -1433,11 +1379,9 @@ class CLICommandsMixin: f"resuming the descendant with your transcript.") target_id = resolved_id session_meta = self._session_db.get_session(target_id) or session_meta - if target_id == self.session_id: _cp(" Already on that session.") return - old_session_id = self.session_id _end_current_session(self, "resumed_other") self.session_id = target_id @@ -1452,11 +1396,9 @@ class CLICommandsMixin: model_history, display_history = self._session_db.get_resume_conversations(target_id) self.conversation_history = [m for m in (model_history or []) if m.get("role") != "session_meta"] self._resume_display_history = [m for m in (display_history or []) if m.get("role") != "session_meta"] - 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 @@ -1502,7 +1444,6 @@ class CLICommandsMixin: if not self._session_db: _cp(_db_unavailable_line()) return - branch_name = _command_arg(cmd_original) now = datetime.now() new_session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" @@ -1511,7 +1452,6 @@ class CLICommandsMixin: else: # auto-generate from the current session title base = self._session_db.get_session_title(self.session_id) or "branch" branch_title = self._session_db.get_next_title_in_lineage(base) - parent_session_id = self.session_id _end_current_session(self, "branched") @@ -1526,10 +1466,8 @@ class CLICommandsMixin: model_config={ "max_iterations": self.max_turns, "reasoning_config": self.reasoning_config, - "_branched_from": parent_session_id, - }, - parent_session_id=parent_session_id, - ) + "_branched_from": parent_session_id}, + parent_session_id=parent_session_id) except Exception as e: _cp(f" Failed to create branch session: {e}") return @@ -1552,12 +1490,9 @@ class CLICommandsMixin: "codex_reasoning_items": msg.get("codex_reasoning_items"), "codex_message_items": msg.get("codex_message_items"), "api_content": extract_api_content_sidecar(msg), - "timestamp": msg.get("timestamp"), - } - for msg in self.conversation_history - ], - chunk_rows=500, - ) + "timestamp": msg.get("timestamp")} + for msg in self.conversation_history], + chunk_rows=500) with suppress(Exception): self._session_db.set_session_title(new_session_id, branch_title) @@ -1571,7 +1506,6 @@ class CLICommandsMixin: if self.agent: self.agent.session_start = now _sync_agent_to_session(self, new_session_id, parent_session_id=parent_session_id, reason="branch") - msg_count = len([m for m in self.conversation_history if m.get("role") == "user"]) _cp(f" ⑂ Branched session \"{branch_title}\" ({_plural(msg_count, 'user message')})", f" Original session: {parent_session_id}", @@ -1588,45 +1522,39 @@ class CLICommandsMixin: .worktrees/; ``prune [--dry-run]`` is the attended reclaim from hermes_cli/worktree_gc.py (never deletes tracked changes, unique unpushed 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: - print(f" Active worktree: {active['path']}") - print(f" Branch: {active['branch']}") + _pr(f" Active worktree: {active['path']}", f" Branch: {active['branch']}") else: print(" No active worktree for this session.") if repo_root: - print(" /worktree new [name] — create one and move this session into it") - print(" /worktree prune — reclaim stale trees and merged branches") + _pr(" /worktree new [name] — create one and move this session into it", + " /worktree prune — reclaim stale trees and merged branches") else: print(" (not inside a git repository)") return - handler = { "prune": self._worktree_prune, "gc": self._worktree_prune, "clean": self._worktree_prune, "list": self._worktree_list, "ls": self._worktree_list, "new": self._worktree_new, "add": self._worktree_new, "create": self._worktree_new, }.get(sub) if handler is None: - print(f" Unknown /worktree subcommand: {sub}") - print(" Usage: /worktree [new [name] | list]") + _pr(f" Unknown /worktree subcommand: {sub}", " Usage: /worktree [new [name] | list]") return if not repo_root: - print(" ❌ /worktree new requires being inside a git repository." if handler is self._worktree_new - else " Not inside a git repository.") + print(" ❌ /worktree new requires being inside a git repository." + if handler is self._worktree_new else " Not inside a git repository.") return 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 @@ -1657,8 +1585,7 @@ class CLICommandsMixin: try: result = subprocess.run( ["git", "worktree", "list"], capture_output=True, text=True, encoding="utf-8", - errors="replace", timeout=10, cwd=repo_root, - ) + errors="replace", timeout=10, cwd=repo_root) out = result.stdout.strip() if result.returncode == 0 else "" except Exception: out = "" @@ -1672,7 +1599,6 @@ class CLICommandsMixin: import atexit import cli as _cli from hermes_cli.config import load_config - try: sync_base = bool(load_config().get("worktree_sync", True)) except Exception: @@ -1690,9 +1616,8 @@ class CLICommandsMixin: # an earlier one keeps its own atexit registration (explicit info arg). _cli._active_worktree = wt_info atexit.register(_cli._cleanup_worktree, wt_info) - print(f" ✅ Worktree ready: {wt_info['path']}") - print(f" Branch: {wt_info['branch']}") - print(" Terminal and file tools now operate in the worktree.") + _pr(f" ✅ Worktree ready: {wt_info['path']}", f" Branch: {wt_info['branch']}", + " Terminal and file tools now operate in the worktree.") # ---- /personality, /pet, /hatch ------------------------------------------------------- @@ -1701,21 +1626,18 @@ class CLICommandsMixin: 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, - ) + 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", "") - ) + (read_raw_config().get("display") or {}).get("personality", "")) except Exception: current = "" print() - print("+" + "-" * 50 + "+") - print("|" + " " * 12 + "(^o^)/ Personalities" + " " * 15 + "|") - print("+" + "-" * 50 + "+") + _pr("+" + "-" * 50 + "+", "|" + " " * 12 + "(^o^)/ Personalities" + " " * 15 + "|", + "+" + "-" * 50 + "+") print() marker = " *" if not current else " " print(f" {marker}{'none':<12} - (no personality overlay)") @@ -1726,14 +1648,12 @@ class CLICommandsMixin: print(" Usage: /personality (* = active)") print() return - try: name, personality_prompt = resolve_personality(personality_name, getattr(self, "config", None)) except ValueError: print(f"(._.) Unknown personality: {personality_name.lower()}") print(f" Available: none, {', '.join(self.personalities.keys())}") return - saved = persist_personality(name) scope = "(saved to config)" if saved else "(session only)" face = "(^_^)b" if saved else "(^_^)" @@ -1741,17 +1661,18 @@ class CLICommandsMixin: # 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="")) + self.system_prompt = prompt_text( + cfg_get(read_raw_config(), "agent", "system_prompt", default="")) except Exception: self.system_prompt = "" self.agent = None # Force re-init - print(f"{face} Personality cleared {scope}") - print(" No personality overlay — using base agent behavior.") + _pr(f"{face} Personality cleared {scope}", + " No personality overlay — using base agent behavior.") else: self.system_prompt = personality_prompt self.agent = None # Force re-init - print(f"{face} Personality set to '{name}' {scope}") - print(f" \"{_ellipsize(personality_prompt, 60)}\"") + _pr(f"{face} Personality set to '{name}' {scope}", + f" \"{_ellipsize(personality_prompt, 60)}\"") def _handle_pet_command(self, cmd: str): """Toggle, browse, or adopt a petdex mascot. @@ -1761,8 +1682,8 @@ class CLICommandsMixin: 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 - + 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": @@ -1803,7 +1724,6 @@ class CLICommandsMixin: 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: # Dispatched from the process_loop daemon thread while prompt_toolkit owns stdin — a @@ -1825,7 +1745,6 @@ class CLICommandsMixin: # A short, friendly display name from the first few words of the concept. display_name = " ".join(w.capitalize() for w in concept.split()[:3])[:28].strip() or "Pet" slug = store.slugify(display_name) or store.slugify(concept) or "pet" - print(f"(o_o) Designing '{concept}'… (a minute of image-model calls)") try: drafts = orchestrate.generate_base_drafts(concept, n=1) @@ -1843,12 +1762,10 @@ class CLICommandsMixin: print(" ┊ composing spritesheet…") elif event == "save": print(" ┊ saving…") - try: result = orchestrate.hatch_pet( base_image=drafts[0], slug=slug, display_name=display_name, concept=concept, - on_progress=_progress, - ) + on_progress=_progress) except GenerationError as exc: print(f"(x_x) Hatch failed: {exc}") return @@ -1860,7 +1777,6 @@ class CLICommandsMixin: def _handle_cron_command(self, cmd: str): """Handle the /cron command to manage scheduled tasks.""" import shlex - tokens = shlex.split(cmd) if len(tokens) == 1: self._cron_overview() @@ -1871,34 +1787,28 @@ class CLICommandsMixin: return handler = _CRON_SUBCOMMANDS.get(subcommand) if handler is None: - print(f"(._.) Unknown cron command: {subcommand}") - print(" Available: list, add, edit, pause, resume, run, remove") + _pr(f"(._.) Unknown cron command: {subcommand}", + " Available: list, add, edit, pause, resume, run, remove") return getattr(self, handler)(subcommand, opts) def _cron_overview(self) -> None: print() - print("+" + "-" * 68 + "+") - print("|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|") - print("+" + "-" * 68 + "+") + _pr("+" + "-" * 68 + "+", "|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|", + "+" + "-" * 68 + "+") print() - print(" Commands:") - print(" /cron list") - print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]') - print(' /cron edit --schedule "every 4h" --prompt "New task"') - print(" /cron edit --skill blogwatcher --skill maps") - print(" /cron edit --remove-skill blogwatcher") - print(" /cron edit --clear-skills") - print(" /cron pause ") - print(" /cron resume ") - print(" /cron run ") - print(" /cron remove ") + _pr(" Commands:", " /cron list", + ' /cron add "every 2h" "Check server status" [--skill blogwatcher]', + ' /cron edit --schedule "every 4h" --prompt "New task"', + " /cron edit --skill blogwatcher --skill maps", + " /cron edit --remove-skill blogwatcher", + " /cron edit --clear-skills", " /cron pause ", + " /cron resume ", " /cron run ", " /cron remove ") print() result = _cron_api(action="list") jobs = result.get("jobs", []) if result.get("success") else [] if jobs: - print(" Current Jobs:") - print(" " + "-" * 63) + _pr(" Current Jobs:", " " + "-" * 63) for job in jobs: print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {job.get('repeat', '?'):<8}") if job.get("skills"): @@ -1918,14 +1828,12 @@ class CLICommandsMixin: print("(._.) No scheduled jobs.") return print() - print("Scheduled Jobs:") - print("-" * 80) + _pr("Scheduled Jobs:", "-" * 80) for job in jobs: - print(f" ID: {job['job_id']}") - print(f" Name: {job['name']}") - print(f" State: {job.get('state', '?')}") - print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})") - print(f" Next run: {job.get('next_run_at', 'N/A')}") + _pr(f" ID: {job['job_id']}", f" Name: {job['name']}", + f" State: {job.get('state', '?')}", + f" Schedule: {job['schedule']} ({job.get('repeat', '?')})", + f" Next run: {job.get('next_run_at', 'N/A')}") if job.get("skills"): print(f" Skills: {', '.join(job['skills'])}") print(f" Prompt: {job.get('prompt_preview', '')}") @@ -1951,20 +1859,17 @@ class CLICommandsMixin: return result = _cron_api( action="create", schedule=schedule, prompt=prompt or None, name=opts["name"], - deliver=opts["deliver"], repeat=opts["repeat"], skills=skills or None, - ) + deliver=opts["deliver"], repeat=opts["repeat"], skills=skills or None) if not result.get("success"): print(f"(x_x) Failed to create job: {result.get('error')}") return - print(f"(^_^)b Created job: {result['job_id']}") - print(f" Schedule: {result['schedule']}") + _pr(f"(^_^)b Created job: {result['job_id']}", f" Schedule: {result['schedule']}") if result.get("skills"): print(f" Skills: {', '.join(result['skills'])}") print(f" Next run: {result['next_run_at']}") def _cron_edit(self, subcommand: str, opts: dict) -> None: from cli import get_job - positionals = opts["positionals"] if not positionals: print("(._.) Usage: /cron edit [--schedule ...] [--prompt ...] [--skill ...]") @@ -1985,21 +1890,19 @@ class CLICommandsMixin: 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 [])) + 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, - ) + name=opts["name"], deliver=opts["deliver"], repeat=opts["repeat"], skills=final_skills) if not result.get("success"): print(f"(x_x) Failed to update job: {result.get('error')}") return job = result["job"] - print(f"(^_^)b Updated job: {job['job_id']}") - print(f" Schedule: {job['schedule']}") - print(f" Skills: {', '.join(job['skills'])}" if job.get("skills") else " Skills: none") + _pr(f"(^_^)b Updated job: {job['job_id']}", f" Schedule: {job['schedule']}", + f" Skills: {', '.join(job['skills'])}" if job.get("skills") else " Skills: none") def _cron_job_action(self, subcommand: str, opts: dict) -> None: """pause / resume / run / remove (aliases rm, delete) on one job id.""" @@ -2009,7 +1912,8 @@ class CLICommandsMixin: return 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) + result = _cron_api(action=action, job_id=job_id, + reason="paused from /cron" if action == "pause" else None) if not result.get("success"): print(f"(x_x) Failed to {action} job: {result.get('error')}") return @@ -2042,7 +1946,6 @@ class CLICommandsMixin: Bare lists the catalog; ```` seeds the agent to ask for each value conversationally (``agent_seed``, run as the next turn); `` slot=val …`` creates the job directly.""" import shlex - args = " ".join(shlex.quote(t) for t in _shlex_args(cmd)) try: from hermes_cli.blueprint_cmd import handle_blueprint_command @@ -2061,7 +1964,6 @@ class CLICommandsMixin: """Handle /curator — delegates to hermes_cli.curator so the CLI and the `hermes curator` subcommand share the same handler set.""" import shlex - tokens = shlex.split(cmd)[1:] if cmd else [] try: from hermes_cli.curator import cli_main @@ -2074,7 +1976,6 @@ class CLICommandsMixin: 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() @@ -2090,7 +1991,8 @@ class CLICommandsMixin: write-approval review subcommands (pending/approve/reject/diff/mode).""" from cli import ChatConsole args = cmd.strip().split()[1:] - if args and args[0].lower() in {"pending", "approve", "apply", "reject", "deny", "drop", "diff", "approval", "mode"}: + 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( @@ -2116,8 +2018,7 @@ class CLICommandsMixin: 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), - ) + set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled)) print(out if out is not None else "Unknown /memory subcommand. Use: pending, approve , reject , approval .") @@ -2141,7 +2042,6 @@ class CLICommandsMixin: a URL, "what we just did", pasted notes). The live agent gathers the material with the tools it already has and authors the skill via ``skill_manage``.""" from agent.learn_prompt import build_learn_prompt - user_request = _command_arg(cmd) print("\n⚡ Learning a skill from what you described..." if user_request else "\n⚡ Learning a skill from this conversation...") @@ -2151,16 +2051,15 @@ class CLICommandsMixin: """Handle /plan — write a markdown implementation plan, no execution. The live agent inspects the workspace read-only and saves the plan under ``.hermes/plans/``.""" from agent.plan_prompt import build_plan_prompt - task = _command_arg(cmd) # optional — empty infers the task from conversation context - print(f"\n📋 Planning: {_ellipsize(task, 80)}" if task else "\n📋 Planning from this conversation's context...") + print(f"\n📋 Planning: {_ellipsize(task, 80)}" if task + else "\n📋 Planning from this conversation's context...") self._queue_prompt_turn(build_plan_prompt(task), "/plan") def _handle_init_command(self, cmd: str): """Handle /init — generate or update AGENTS.md from a project scan performed by the live agent with its own read-only tools.""" from hermes_cli.init_command import build_init_prompt_for_cwd - msg = build_init_prompt_for_cwd(extra=_command_arg(cmd)) # optional user emphasis verb = "Updating" if "UPDATE the existing AGENTS.md" in msg else "Generating" print(f"\n⚡ {verb} AGENTS.md from a project scan...") @@ -2171,7 +2070,9 @@ class CLICommandsMixin: def _handle_background_command(self, cmd: str): """Handle /bg — 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 AIAgent, _cprint, set_approval_callback, set_secret_capture_callback, set_sudo_password_callback + from cli import ( + AIAgent, _cprint, set_approval_callback, set_secret_capture_callback, + set_sudo_password_callback) prompt = _command_arg(cmd) if not prompt: _cp(" Usage: /bg ", @@ -2179,7 +2080,6 @@ class CLICommandsMixin: " (For a side question about this conversation, use /btw .)", " The task runs in a separate session and results display here when done.") return - 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]}" @@ -2190,7 +2090,6 @@ class CLICommandsMixin: _cp(f" 🔄 Background task #{task_num} started: \"{preview}\"", f" Task ID: {task_id}", " You can continue chatting — results will appear when done.\n") - turn_route = self._resolve_turn_agent_config(prompt) runtime = turn_route["runtime"] @@ -2226,8 +2125,7 @@ class CLICommandsMixin: provider_require_parameters=self._provider_require_params, provider_data_collection=self._provider_data_collection, openrouter_min_coding_score=self._openrouter_min_coding_score, - fallback_model=self._fallback_model, - ) + fallback_model=self._fallback_model) # Silence raw spinner; route thinking through TUI widget when no foreground agent is active. bg_agent._print_fn = lambda *_a, **_kw: None @@ -2237,7 +2135,6 @@ class CLICommandsMixin: self._spinner_text = text if self._app: self._app.invalidate() - bg_agent.thinking_callback = _bg_thinking result = bg_agent.run_conversation(user_message=prompt, task_id=task_id) response = result.get("final_response", "") if result else "" @@ -2245,11 +2142,11 @@ class CLICommandsMixin: response = f"Error: {result['error']}" _print_side_result_panel( self, - header_lines=[f" ✅ Background task #{task_num} complete", f" Prompt: \"{preview}\""], + header_lines=[f" ✅ Background task #{task_num} complete", + f" Prompt: \"{preview}\""], body=response, title_suffix=f"(background #{task_num})", - empty_note=" (No response generated)", - ) + empty_note=" (No response generated)") if self.bell_on_complete: sys.stdout.write("\a") sys.stdout.flush() @@ -2266,7 +2163,6 @@ class CLICommandsMixin: self._spinner_text = "" if self._app: self._invalidate(min_interval=0) - thread = threading.Thread(target=run_background, daemon=True, name=f"bg-task-{task_id}") self._background_tasks[task_id] = thread thread.start() @@ -2276,7 +2172,6 @@ class CLICommandsMixin: history snapshot via a one-shot auxiliary call. The live session is never touched (no history mutation, no role-alternation risk, no cache invalidation).""" from cli import _cprint - question = _command_arg(cmd) if not question: _cp(" Usage: /btw ", @@ -2297,8 +2192,7 @@ class CLICommandsMixin: runtime = turn_route["runtime"] main_runtime = { "model": turn_route["model"], - **{k: runtime.get(k) for k in ("provider", "base_url", "api_key", "api_mode")}, - } + **{k: runtime.get(k) for k in ("provider", "base_url", "api_key", "api_mode")}} preview = _ellipsize(question, 60) _cp(f" 💬 Side question: \"{preview}\"", " Answering from a snapshot of this conversation — the current work continues.\n") @@ -2311,15 +2205,13 @@ class CLICommandsMixin: ) _print_side_result_panel( self, header_lines=[f" 💬 /btw: \"{preview}\""], body=answer, - title_suffix="(btw)", empty_note=" (No answer generated)", - ) + title_suffix="(btw)", empty_note=" (No answer generated)") except Exception as e: _refresh_tui_before_print(self) _cprint(f" ❌ /btw failed: {e}") finally: if self._app: self._invalidate(min_interval=0) - threading.Thread(target=run_side_question, daemon=True, name="btw-side-question").start() # ---- /bundles, /browser --------------------------------------------------------------- @@ -2329,27 +2221,23 @@ class CLICommandsMixin: inside the running CLI). Bundles are loaded via ``/``.""" 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: _cp(f"\033[1;31mBundle subsystem unavailable: {reply.data['error']}{_RST}") return - bundles = reply.data["bundles"] if not bundles: _cp(" No skill bundles installed.", _dim_line('Create one with: hermes bundles create --skill --skill '), _dim_line(f"Directory: {reply.data['dir']}")) return - _cp(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(bundles)} installed):") for info in bundles: skill_count = len(info.get("skills", [])) desc = info.get("description") or f"Load {skill_count} skills" ChatConsole().print( f" [bold {_accent_hex()}]/{info['slug']:<20}[/] " - f"[dim]-[/] {_escape(desc)} [dim]({skill_count} skills)[/]" - ) + f"[dim]-[/] {_escape(desc)} [dim]({skill_count} skills)[/]") for s in info.get("skills", []): ChatConsole().print(f" [dim]· {_escape(s)}[/]") _cp("\n" + _dim_line("Invoke a bundle with /. Manage with `hermes bundles`.")) @@ -2361,7 +2249,8 @@ class CLICommandsMixin: _browser_use(self, sub.split(None, 1)[1].strip() if " " in sub else "on") elif sub.startswith("connect"): connect_parts = cmd.strip().split(None, 2) # ["/browser", "connect", "ws://..."] - _browser_connect(self, connect_parts[2].strip() if len(connect_parts) > 2 else DEFAULT_BROWSER_CDP_URL) + url = connect_parts[2].strip() if len(connect_parts) > 2 else DEFAULT_BROWSER_CDP_URL + _browser_connect(self, url) elif sub == "disconnect": _browser_disconnect(self) elif sub == "status": @@ -2373,8 +2262,7 @@ class CLICommandsMixin: " connect Connect browser tools to your live Chromium-family browser session", " disconnect Revert to default browser backend", " status Show current browser mode", - " use [off] Switch to Browser Use mode (CLI 3.0) / back to built-in tools", - ) + " use [off] Switch to Browser Use mode (CLI 3.0) / back to built-in tools") # ---- /heartbeat, /refine, /review ----------------------------------------------------- @@ -2383,7 +2271,6 @@ class CLICommandsMixin: 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._get_heartbeat_manager() @@ -2409,7 +2296,6 @@ class CLICommandsMixin: def _heartbeat_set(self, mgr, arg: str) -> None: """Set: ``/heartbeat every 10m `` (also accepts ``10m ``).""" from hermes_cli.heartbeat import parse_interval, format_interval - tokens = arg.split(None, 2) interval = None prompt = "" @@ -2419,7 +2305,6 @@ class CLICommandsMixin: elif tokens: interval = parse_interval(tokens[0]) prompt = arg[len(tokens[0]):].strip() if interval and interval > 0 else "" - if interval is None: _cp(" Usage: /heartbeat every (e.g. /heartbeat every 10m Check CI)", _dim_line('Also: /heartbeat status | pause | resume | clear')) @@ -2438,7 +2323,9 @@ class CLICommandsMixin: return self._start_heartbeat_watchdog() _cp(f" ♥ Heartbeat set (every {format_interval(state.interval_seconds)}): {state.prompt}", - _dim_line('Fires as a normal turn whenever the session is idle and the interval has elapsed. /heartbeat pause | resume | clear to manage; lives only while this Hermes process runs — use `hermes cron` for durable schedules.')) + _dim_line("Fires as a normal turn whenever the session is idle and the interval has " + "elapsed. /heartbeat pause | resume | clear to manage; lives only while this " + "Hermes process runs — use `hermes cron` for durable schedules.")) def _handle_refine_command(self, cmd: str) -> None: """Dispatch /refine — run the memory/skill review fork on demand (same machinery as the @@ -2457,8 +2344,7 @@ class CLICommandsMixin: 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, - ) + focus=focus or None, explicit=True) except Exception as exc: _cp(f" /refine failed to start: {exc}") return @@ -2496,16 +2382,15 @@ class CLICommandsMixin: if mgr is None: _cp(_dim_line('Goals unavailable (no active session).')) return - lower = arg.lower() verb, _, rest = arg.partition(" ") verb = verb.lower() rest = rest.strip() - if not arg or lower == "status": _cp(f" {mgr.status_line()}") elif lower == "show": - _cp(f" {mgr.status_line()}", f" {mgr.render_contract()}") + _cp(f" {mgr.status_line()}") + _cp(f" {mgr.render_contract()}") elif lower.startswith("draft"): # Expand plain text into a structured completion contract so "done" is evidence-based # instead of a vibe check. @@ -2614,7 +2499,6 @@ class CLICommandsMixin: """Set the goal from free text; inline `verify:`/`constraints:`/`boundaries:`/`stop when:` lines become a completion contract, the remaining prose the headline. Kicks the loop off.""" from hermes_cli.goals import parse_contract - headline, contract = parse_contract(arg) try: state = mgr.set(headline or arg, contract=contract if not contract.is_empty() else None) @@ -2639,7 +2523,6 @@ class CLICommandsMixin: """Draft a structured completion contract from a plain objective and set it as the active goal. Falls back to a bare goal if the aux model can't produce a contract.""" from hermes_cli.goals import draft_contract - mgr = self._get_goal_manager() if mgr is None: _cp(_dim_line('Goals unavailable (no active session).')) @@ -2658,9 +2541,11 @@ class CLICommandsMixin: return self._print_goal_set(state, "Drafted completion contract:") if state.has_contract(): - _cp(_dim_line('Tighten any field by re-setting the goal with inline lines (e.g. verify: ), then /goal resume. Use /goal show to review.')) + _cp(_dim_line("Tighten any field by re-setting the goal with inline lines " + "(e.g. verify: ), then /goal resume. Use /goal show to review.")) else: - _cp(_dim_line("Couldn't draft a contract (aux model unavailable) — running as a free-form goal. The per-turn judge still applies.")) + _cp(_dim_line("Couldn't draft a contract (aux model unavailable) — running as a " + "free-form goal. The per-turn judge still applies.")) self._kick_goal(state.goal) def _handle_loop_command(self, cmd: str) -> None: @@ -2671,9 +2556,7 @@ class CLICommandsMixin: if mgr is None: _cp(_dim_line('Loops unavailable (no active session).')) 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}") @@ -2681,7 +2564,8 @@ class CLICommandsMixin: with suppress(Exception): from hermes_cli.loops import goal_blocks_loop_tick if goal_blocks_loop_tick(mgr.session_id): - _cp(_dim_line('Note: an active /goal is driving this session — loop wakeups defer until the goal finishes, pauses, or parks.')) + _cp(_dim_line("Note: an active /goal is driving this session — loop wakeups " + "defer until the goal finishes, pauses, or parks.")) def _handle_subgoal_command(self, cmd: str) -> None: """Dispatch /subgoal: bare → show, ```` → append, ``remove `` (1-based), ``clear``. @@ -2690,7 +2574,6 @@ class CLICommandsMixin: continuation prompt at the next turn boundary (no special kick).""" parts = (cmd or "").strip().split(None, 2) arg = " ".join(parts[1:]).strip() if len(parts) > 1 else "" - mgr = self._get_goal_manager() if mgr is None: _cp(_dim_line('Goals unavailable (no active session).')) @@ -2699,9 +2582,9 @@ class CLICommandsMixin: _cp(_dim_line('No active goal. Set one with /goal .')) return if not arg: # list current subgoals - _cp(f" {mgr.status_line()}", f" {mgr.render_subgoals()}") + _cp(f" {mgr.status_line()}") + _cp(f" {mgr.render_subgoals()}") return - tokens = arg.split(None, 1) verb = tokens[0].lower() rest = tokens[1].strip() if len(tokens) > 1 else "" @@ -2746,12 +2629,10 @@ class CLICommandsMixin: except ImportError: print("Skin engine not available.") return - new_skin = _command_arg(cmd).lower() if not new_skin: # show current skin and list available current = get_active_skin_name() - print(f"\n Current skin: {current}") - print(" Available skins:") + _pr(f"\n Current skin: {current}", " Available skins:") for s in list_skins(): marker = " ●" if s["name"] == current else " " source = f" ({s['source']})" if s["source"] == "user" else "" @@ -2759,18 +2640,15 @@ class CLICommandsMixin: print("\n Usage: /skin ") print(f" Custom skins: drop a YAML file in {display_hermes_home()}/skins/\n") return - available = {s["name"] for s in list_skins()} if new_skin not in available: - print(f" Unknown skin: {new_skin}") - print(f" Available: {', '.join(sorted(available))}") + _pr(f" Unknown skin: {new_skin}", f" Available: {', '.join(sorted(available))}") return - set_active_skin(new_skin) _ACCENT.reset() # re-resolve ANSI color for the new skin (_DIM is a fixed escape) saved = " (saved)" if save_config_value("display.skin", new_skin) else "" - print(f" Skin set to: {new_skin}{saved}") - print(" Note: banner colors will update on next session start.") + _pr(f" Skin set to: {new_skin}{saved}", + " Note: banner colors will update on next session start.") if self._apply_tui_skin_style(): print(" Prompt + TUI colors updated.") @@ -2781,15 +2659,12 @@ class CLICommandsMixin: import shlex import subprocess import tempfile - editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") if not editor: editor = "notepad" if os.name == "nt" else "nano" - header = ( "#! Compose your prompt below. Lines starting with '#!' are ignored.\n" - "#! Save and quit to send; leave empty to cancel.\n\n" - ) + "#! Save and quit to send; leave empty to cancel.\n\n") fd, path = tempfile.mkstemp(suffix=".md", prefix="hermes_prompt_") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: @@ -2806,7 +2681,6 @@ class CLICommandsMixin: 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: @@ -2839,9 +2713,7 @@ class CLICommandsMixin: 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, - ) - + 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": @@ -2851,8 +2723,7 @@ class CLICommandsMixin: # 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") - ) + else getattr(self, "tool_progress_mode", "all")) if action == "status": head, _, tail = format_focus_status(current, restore_mode).partition("\n") label, _, rest = head.partition(":") @@ -2863,7 +2734,6 @@ class CLICommandsMixin: if target == current: # idempotent explicit set — report without rewriting config _cp(f" {format_focus_toggle_message(current, restore_mode)}") return - if target: # Stash the user's configured mode, then reuse the EXISTING suppression path by # snapping to "off". @@ -2875,7 +2745,6 @@ class CLICommandsMixin: self._focus_view_enabled = bool(target) self._focus_hidden_lines = 0 save_config_value(FOCUS_CONFIG_KEY, bool(target)) - state = (f"{_Colors.GREEN}enabled{_Colors.RESET}" if target else f"{_Colors.DIM}disabled{_Colors.RESET}") message = format_focus_toggle_message(bool(target), restore_mode) @@ -2890,7 +2759,6 @@ class CLICommandsMixin: """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) @@ -2904,7 +2772,6 @@ class CLICommandsMixin: 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): @@ -2920,7 +2787,6 @@ class CLICommandsMixin: 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): @@ -2931,7 +2797,6 @@ class CLICommandsMixin: 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}") @@ -2942,7 +2807,6 @@ class CLICommandsMixin: Returns the new bool state when it was saved (None otherwise).""" from cli import save_config_value from hermes_cli.colors import Colors as _Colors - new_state = _toggle_target(arg, current) if new_state == "status": _cp(f" {_Colors.BOLD}{label}:{_Colors.RESET} {'ON' if current else 'OFF'}{status_line}") @@ -2961,7 +2825,6 @@ class CLICommandsMixin: 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( @@ -2980,8 +2843,7 @@ class CLICommandsMixin: self.show_timestamps = new_state self._toggle_setting( arg, current, usage="/timestamps [on|off|status]", status_line="", - config_key="display.timestamps", label="Message timestamps", failed="timestamps", - ) + config_key="display.timestamps", label="Message timestamps", failed="timestamps") # ---- model-behaviour settings: /reasoning, /busy, /indicator, /fast ------------------- @@ -2994,7 +2856,6 @@ class CLICommandsMixin: picks complete thinking vs. the first-10-lines clamp.""" from cli import CLI_CONFIG, _parse_reasoning_config, save_config_value raw = _command_arg(cmd) - if not raw: # show current state rc = self.reasoning_config if rc is None: @@ -3007,9 +2868,9 @@ class CLICommandsMixin: full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines" _cp(_accent_line(f"Reasoning effort: {level}"), _accent_line(f"Reasoning display: {display_state} ({full_state})"), - _dim_line('Usage: /reasoning [--global]')) + _dim_line("Usage: /reasoning [--global]")) return - arg, explicit_global = _split_scope_flags(raw) # Display toggle @@ -3048,7 +2909,6 @@ class CLICommandsMixin: _dim_line('Display: show, hide'), _dim_line('Scope: session-scoped by default, --global to persist')) return - self.reasoning_config = parsed self.agent = None # Force agent re-init with new reasoning config saved = explicit_global and save_config_value("agent.reasoning_effort", arg) @@ -3075,7 +2935,8 @@ class CLICommandsMixin: return self.busy_input_mode = arg if save_config_value("display.busy_input_mode", arg): - _cp(_accent_line(f"✓ Busy input mode set to '{arg}' (saved to config)"), _dim_line(_BUSY_MODE_LONG[arg])) + _cp(_accent_line(f"✓ Busy input mode set to '{arg}' (saved to config)"), + _dim_line(_BUSY_MODE_LONG[arg])) else: _cp(_accent_line(f"✓ Busy input mode set to '{arg}' (session only)")) @@ -3106,7 +2967,8 @@ class CLICommandsMixin: (parity with /model and /reasoning).""" from cli import save_config_value if not self._fast_command_available(): - _cp(" (._.) /fast is only available for models that support fast mode (OpenAI Priority Processing or Anthropic Fast Mode).") + _cp(" (._.) /fast is only available for models that support fast mode " + "(OpenAI Priority Processing or Anthropic Fast Mode).") return # Determine the branding for the current model @@ -3116,14 +2978,12 @@ class CLICommandsMixin: feature_name = "Anthropic Fast Mode" if _is_anthropic_fast_model(model) else "Priority Processing" except Exception: feature_name = "Fast mode" - raw = _command_arg(cmd) usage = _dim_line('Usage: /fast [normal|fast|auto|cold|status] [--global]') if not raw or raw.lower() == "status": status = {"priority": "fast", None: "normal"}.get(self.service_tier, self.service_tier) _cp(_accent_line(f"{feature_name}: {status}"), usage) return - arg, explicit_global = _split_scope_flags(raw) if arg not in _FAST_TIERS: _cp(_dim_line(f'(._.) Unknown argument: {arg}'), usage) @@ -3131,7 +2991,8 @@ class CLICommandsMixin: self.service_tier, saved_value = _FAST_TIERS[arg] self.agent = None # Force agent re-init with new service-tier config saved = explicit_global and save_config_value("agent.service_tier", saved_value) - _cp(_accent_line(f"✓ {feature_name} set to {saved_value.upper()} {_scope_outcome(explicit_global, saved)}")) + outcome = _scope_outcome(explicit_global, saved) + _cp(_accent_line(f"✓ {feature_name} set to {saved_value.upper()} {outcome}")) # ---- /debug, /update, /voice, /wake --------------------------------------------------- @@ -3141,11 +3002,11 @@ class CLICommandsMixin: 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)) + 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``. @@ -3153,7 +3014,6 @@ class CLICommandsMixin: Returns True when confirmed: the caller triggers app exit so the relaunch happens on the main thread after prompt_toolkit restores terminal modes. False when cancelled.""" from hermes_cli.config import is_managed, format_managed_message - if is_managed(): print(f" ✗ {format_managed_message('update Hermes Agent')}") return False @@ -3161,17 +3021,14 @@ class CLICommandsMixin: # prompt_toolkit-native modal: renders above the composer, no raw input() races. choices = [ ("once", "Update Now", "exit the current session and update Hermes Agent"), - ("cancel", "Cancel", "keep the current session"), - ] + ("cancel", "Cancel", "keep the current session")] raw = self._prompt_text_input_modal( title="⚕ Update Hermes Agent", detail="This will exit the current session and run `hermes update`.", - choices=choices, - ) + choices=choices) if raw is None or self._normalize_slash_confirm_choice(raw, choices) != "once": print(" 🟡 /update cancelled.") return False - _say_block(" ⚕ Launching update...") # run() execs this from the main thread after prompt_toolkit restores terminal modes. # Relaunching here (daemon thread) would skip terminal cleanup on POSIX and, on Windows, @@ -3188,8 +3045,7 @@ class CLICommandsMixin: "on": self._enable_voice_mode, "off": self._disable_voice_mode, "tts": self._toggle_voice_tts, - "status": self._show_voice_status, - } + "status": self._show_voice_status} if subcommand in actions: actions[subcommand]() else: @@ -3216,7 +3072,6 @@ class CLICommandsMixin: def _persist_wake_word_enabled(self, enabled: bool): """Save ``wake_word.enabled`` so the /wake toggle sticks for future sessions.""" from cli import save_config_value - with suppress(Exception): from tools.wake_word import load_wake_word_config if bool(load_wake_word_config().get("enabled")) == enabled: From da375f4cf43403f954079c37f470ed87b0a12d86 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:13:02 -0700 Subject: [PATCH 03/22] refactor(hermes_cli): table /reasoning toggles and /bg provider kwargs, shared manager/persist helpers in cli_commands_mixin --- hermes_cli/cli_commands_mixin.py | 205 +++++++++++++------------------ 1 file changed, 85 insertions(+), 120 deletions(-) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 8aca152a63..0c6bd57289 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -25,13 +25,8 @@ from rich.panel import Panel from hermes_constants import display_hermes_home, is_termux as _is_termux_environment from agent.turn_context import extract_api_content_sidecar 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) + 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) # --------------------------------------------------------------------------------------- @@ -152,8 +147,7 @@ _OFF_WORDS = {"off", "disable", "false", "0"} # /busy mode -> what Enter does while Hermes is working (status line / post-set explanation). _BUSY_MODE_SHORT = { - "queue": "queues for next turn", - "steer": "steers into current run (after next tool call)", + "queue": "queues for next turn", "steer": "steers into current run (after next tool call)", "interrupt": "redirects current run immediately"} _BUSY_MODE_LONG = { "queue": "Enter will queue follow-up input while Hermes is busy.", @@ -163,9 +157,26 @@ _BUSY_MODE_LONG = { # /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")} + "fast": ("priority", "fast"), "on": ("priority", "fast"), "normal": (None, "normal"), + "off": (None, "normal"), "auto": ("auto", "auto"), "cold": ("cold", "cold")} + +# /reasoning display toggles: arg -> (attr, value, headline, follow-up note) +_REASONING_TOGGLES = { + **dict.fromkeys(("show", "on"), ("show_reasoning", True, "ON", + "Model thinking will be shown during and after each response.")), + **dict.fromkeys(("hide", "off"), ("show_reasoning", False, "OFF", "")), + **dict.fromkeys(("full", "all"), ("reasoning_full", True, "FULL", + "The post-response recap box will print complete thinking.")), + **dict.fromkeys(("clamp", "collapse", "short"), ("reasoning_full", False, "CLAMPED to 10 lines", "")), +} + +# /bg AIAgent provider-routing kwargs -> HermesCLI attribute carrying the value. +_BG_PROVIDER_KWARGS = { + "providers_allowed": "_providers_only", "providers_ignored": "_providers_ignore", + "providers_order": "_providers_order", "provider_sort": "_provider_sort", + "provider_require_parameters": "_provider_require_params", + "provider_data_collection": "_provider_data_collection", + "openrouter_min_coding_score": "_openrouter_min_coding_score", "fallback_model": "_fallback_model"} # /diff argument -> mode (anything else is a path; --stat/stat is the stat flag). _DIFF_MODES = { @@ -321,12 +332,8 @@ def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_not label, _resp_color, _resp_text = "⚕ Hermes", "#CD7F32", "#FFF8DC" ChatConsole().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), + 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())) @@ -513,9 +520,8 @@ def _browser_disconnect(cli) -> None: from tools.browser_tool import cleanup_all_browsers, _stop_cdp_supervisor _stop_cdp_supervisor("default") cleanup_all_browsers() - _say_block( - "🌐 Browser disconnected from live Chromium-family browser", - " Browser tools reverted to default mode (local headless or cloud provider)") + _say_block("🌐 Browser disconnected from live Chromium-family browser", + " Browser tools reverted to default mode (local headless or cloud provider)") if hasattr(cli, '_pending_input'): cli._pending_input.put( "[System note: The user has disconnected the browser tools from their live Chromium-family browser. " @@ -577,9 +583,8 @@ def _browser_status() -> None: print("🌐 Browser: local headless Chromium (agent-browser --engine chrome)") else: print("🌐 Browser: local headless Chromium (agent-browser)") - _say_block( - " /browser connect — connect to your live Chromium-family browser", - " /browser disconnect — revert to default") + _say_block(" /browser connect — connect to your live Chromium-family browser", + " /browser disconnect — revert to default") class CLICommandsMixin: @@ -607,8 +612,7 @@ class CLICommandsMixin: N; `` `` restores a single file.""" from tools.checkpoint_manager import format_checkpoint_list mgr = self._checkpoint_manager(( - " Checkpoints are not enabled.", - " Enable with: hermes --checkpoints", + " Checkpoints are not enabled.", " Enable with: hermes --checkpoints", " Or in config.yaml: checkpoints: { enabled: true }")) if mgr is None: return @@ -788,8 +792,7 @@ class CLICommandsMixin: 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) + "prune": self._snapshot_prune}.get(subcmd) if handler is None: _pr(f" Unknown subcommand: {subcmd}", " Usage: /snapshot [list|create [label]|restore |prune [N]]") @@ -1464,8 +1467,7 @@ class CLICommandsMixin: source=os.environ.get("HERMES_SESSION_SOURCE", "cli"), model=self.model, model_config={ - "max_iterations": self.max_turns, - "reasoning_config": self.reasoning_config, + "max_iterations": self.max_turns, "reasoning_config": self.reasoning_config, "_branched_from": parent_session_id}, parent_session_id=parent_session_id) except Exception as e: @@ -1508,8 +1510,7 @@ class CLICommandsMixin: _sync_agent_to_session(self, new_session_id, parent_session_id=parent_session_id, reason="branch") msg_count = len([m for m in self.conversation_history if m.get("role") == "user"]) _cp(f" ⑂ Branched session \"{branch_title}\" ({_plural(msg_count, 'user message')})", - f" Original session: {parent_session_id}", - f" Branch session: {new_session_id}") + f" Original session: {parent_session_id}", f" Branch session: {new_session_id}") # ---- /worktree ------------------------------------------------------------------------ @@ -1644,9 +1645,7 @@ class CLICommandsMixin: for name, prompt in self.personalities.items(): marker = " *" if name == current else " " print(f" {marker}{name:<12} - {describe_personality(prompt)}") - print() - print(" Usage: /personality (* = active)") - print() + _pr("", " Usage: /personality (* = active)", "") return try: name, personality_prompt = resolve_personality(personality_name, getattr(self, "config", None)) @@ -2075,8 +2074,7 @@ class CLICommandsMixin: set_sudo_password_callback) prompt = _command_arg(cmd) if not prompt: - _cp(" Usage: /bg ", - " Example: /bg Summarize the top HN stories today", + _cp(" Usage: /bg ", " Example: /bg Summarize the top HN stories today", " (For a side question about this conversation, use /btw .)", " The task runs in a separate session and results display here when done.") return @@ -2087,8 +2085,7 @@ class CLICommandsMixin: _cp(" (>_<) Cannot start background task: no valid credentials.") return preview = _ellipsize(prompt, 60) - _cp(f" 🔄 Background task #{task_num} started: \"{preview}\"", - f" Task ID: {task_id}", + _cp(f" 🔄 Background task #{task_num} started: \"{preview}\"", f" Task ID: {task_id}", " You can continue chatting — results will appear when done.\n") turn_route = self._resolve_turn_agent_config(prompt) runtime = turn_route["runtime"] @@ -2100,32 +2097,16 @@ class CLICommandsMixin: set_secret_capture_callback(self._secret_capture_callback) try: bg_agent = AIAgent( - model=turn_route["model"], - api_key=runtime.get("api_key"), - base_url=runtime.get("base_url"), - provider=runtime.get("provider"), - api_mode=runtime.get("api_mode"), - acp_command=runtime.get("command"), - acp_args=runtime.get("args"), - max_tokens=runtime.get("max_tokens"), - max_iterations=self.max_turns, - enabled_toolsets=self.enabled_toolsets, - quiet_mode=True, - verbose_logging=False, - session_id=task_id, - platform="cli", - session_db=self._session_db, - reasoning_config=self.reasoning_config, + model=turn_route["model"], api_key=runtime.get("api_key"), + base_url=runtime.get("base_url"), provider=runtime.get("provider"), + api_mode=runtime.get("api_mode"), acp_command=runtime.get("command"), + acp_args=runtime.get("args"), max_tokens=runtime.get("max_tokens"), + max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, + quiet_mode=True, verbose_logging=False, session_id=task_id, platform="cli", + session_db=self._session_db, reasoning_config=self.reasoning_config, service_tier=self.service_tier, request_overrides=turn_route.get("request_overrides"), - providers_allowed=self._providers_only, - providers_ignored=self._providers_ignore, - providers_order=self._providers_order, - provider_sort=self._provider_sort, - provider_require_parameters=self._provider_require_params, - provider_data_collection=self._provider_data_collection, - openrouter_min_coding_score=self._openrouter_min_coding_score, - fallback_model=self._fallback_model) + **{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 @@ -2174,8 +2155,7 @@ class CLICommandsMixin: from cli import _cprint question = _command_arg(cmd) if not question: - _cp(" Usage: /btw ", - " Example: /btw which file was that error in?", + _cp(" Usage: /btw ", " Example: /btw which file was that error in?", " Answers a quick question about this conversation without interrupting it.", " (For an independent background task, use /bg .)") return @@ -2257,8 +2237,7 @@ class CLICommandsMixin: _browser_status() else: _say_block( - "Usage: /browser connect|disconnect|status|use", - "", + "Usage: /browser connect|disconnect|status|use", "", " connect Connect browser tools to your live Chromium-family browser session", " disconnect Revert to default browser backend", " status Show current browser mode", @@ -2266,6 +2245,14 @@ class CLICommandsMixin: # ---- /heartbeat, /refine, /review ----------------------------------------------------- + def _session_manager(self, getter, label: str): + """The session-scoped manager from ``getter()``, or None after the standard dim + "