refactor(hermes_cli): compact docstrings/comments, table /worktree routing in cli_commands_mixin

This commit is contained in:
Teknium
2026-09-02 22:01:37 -07:00
parent a7e39e1d8e
commit 3b438c40bb
+73 -120
View File
@@ -184,6 +184,12 @@ _BG_PROVIDER_KWARGS = {
"provider_data_collection": "_provider_data_collection",
"openrouter_min_coding_score": "_openrouter_min_coding_score", "fallback_model": "_fallback_model"}
# /worktree subcommand -> CLICommandsMixin method name (all need a repo root).
_WORKTREE_SUBCOMMANDS = {
**dict.fromkeys(("prune", "gc", "clean"), "_worktree_prune"),
**dict.fromkeys(("list", "ls"), "_worktree_list"),
**dict.fromkeys(("new", "add", "create"), "_worktree_new")}
# /diff argument -> mode (anything else is a path; --stat/stat is the stat flag).
_DIFF_MODES = {
"staged": "staged", "--staged": "staged", "cached": "staged", "--cached": "staged",
@@ -191,6 +197,14 @@ _DIFF_MODES = {
_DIFF_LABELS = {"working": "Unstaged", "staged": "Staged", "all": "All (vs HEAD)"}
def _persist_display_choice(key: str, value: str, label: str, note: str) -> None:
"""Save a /busy-style choice to config and report saved vs session-only."""
if _save(key, value):
_cp(_accent_line(f"✓ {label} set to '{value}' (saved to config)"), _dim_line(note))
else:
_cp(_accent_line(f"✓ {label} set to '{value}' (session only)"))
def _split_scope_flags(raw: str):
"""``(arg, explicit_global)`` for /reasoning + /fast: session scope by default, ``--global``
persists to config.yaml, ``--session`` is an explicit no-op (parity with /model)."""
@@ -285,11 +299,9 @@ def _end_current_session(cli, reason: str) -> None:
def _sync_agent_to_session(cli, session_id: str, *, parent_session_id: str, reason: str) -> None:
"""Point an already-built agent at ``session_id`` after a /resume or /branch switch.
Resets per-session agent state, re-anchors the DB flush index to the loaded history,
and notifies memory providers with reset=False — their accumulated state stays valid
and just targets the new session_id (the parent link keeps the lineage)."""
"""Point an already-built agent at ``session_id`` after a /resume or /branch switch: reset
per-session state, re-anchor the DB flush index, and notify memory providers with
reset=False (their state stays valid and just targets the new id; parent keeps lineage)."""
if not cli.agent:
return
cli.agent.session_id = session_id
@@ -609,11 +621,8 @@ class CLICommandsMixin:
return mgr
def _handle_rollback_command(self, command: str):
"""Handle /rollback — list, diff, or restore filesystem checkpoints.
``/rollback`` lists; ``<N>`` restores N preserving user hand-edits (and undoes the last
chat turn); ``<N> --all`` is the classic full restore; ``diff <N>`` previews changes since
N; ``<N> <file>`` restores a single file."""
"""Handle /rollback [diff] <N> [<file>|--all] — list, diff, or restore checkpoints.
A restore also undoes the last chat turn; ``--all`` overwrites user hand-edits too."""
from tools.checkpoint_manager import format_checkpoint_list
mgr = self._checkpoint_manager((
" Checkpoints are not enabled.", " Enable with: hermes --checkpoints",
@@ -626,8 +635,7 @@ class CLICommandsMixin:
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).
# No checkpoints for this dir → cross-project view (writes may sit under the session cwd).
checkpoints = mgr.list_checkpoints(cwd)
if not checkpoints:
all_checkpoints = mgr.list_all_checkpoints()
@@ -702,10 +710,8 @@ class CLICommandsMixin:
# ---- /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] <path...>``."""
"""Handle /diff [working|staged|all|session] [--stat] [<path>...] — git changes in the
cwd; ``session`` is everything Hermes changed since the checkpoint baseline."""
stat_only = False
mode = "working"
paths: list[str] = []
@@ -788,9 +794,7 @@ class CLICommandsMixin:
# ---- /snapshot ------------------------------------------------------------------------
def _handle_snapshot_command(self, command: str):
"""Handle /snapshot — lightweight state snapshots for Hermes config/state.
Syntax: ``/snapshot`` (list), ``create [label]``, ``restore <id>``, ``prune [N]`` (default 20)."""
"""Handle /snapshot [list|create [label]|restore <id>|prune [N]] — state snapshots."""
parts = command.split()
subcmd = parts[1].lower() if len(parts) > 1 else "list"
handler = {
@@ -1161,12 +1165,8 @@ class CLICommandsMixin:
def _handle_handoff_command(self, cmd_original: str) -> bool:
"""Handle ``/handoff <platform>`` — transfer this CLI session to a gateway platform.
Validates the platform + home channel, refuses mid-turn (an in-flight run would race the
gateway's switch_session), writes ``handoff_state='pending'``, then block-polls state.db:
60s for the gateway to CLAIM the row, then up to 15 min (with heartbeats) for the claimed
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."""
Validate target → prepare session row → mark pending → block-poll (see ``_handoff_wait``).
Returns False only on ``completed`` (caller exits like /quit); True keeps the session."""
platform_name = _command_arg(cmd_original).lower()
if not platform_name:
_cp(" Usage: /handoff <platform>",
@@ -1206,9 +1206,8 @@ class CLICommandsMixin:
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
# /handoff discord is deliverable when the relay fronts it. UX pre-check only —
# the gateway watcher re-checks against the authenticated transport before dispatch.
# Relay aliasing: a relay-fronted gateway has only a RELAY block yet /handoff discord
# is deliverable. UX pre-check only — the gateway watcher re-checks before dispatch.
relay_fronts = False
try:
from gateway.relay import relay_platform_identities
@@ -1257,12 +1256,10 @@ class CLICommandsMixin:
return session_title or self.session_id[:8]
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)."""
"""Two-phase 0.5s poll. PENDING (unclaimed): 60s, then CAS-fail the row so the user can
retry (a claim racing this instant wins). RUNNING (claimed): the gateway replays the
transcript via a synthetic turn (routinely >60s) — wait 15 min with heartbeats and on
timeout do NOT touch the row; failing it here was the split-brain bug."""
import time as _time
pending_deadline = _time.time() + self._HANDOFF_PENDING_TIMEOUT
running_deadline = None
@@ -1283,9 +1280,8 @@ class CLICommandsMixin:
if current == "completed":
_cp("", f" ↻ Handoff complete. The session is now active on {platform_name}.",
f" Resume it on this CLI later with: /resume {session_title}", "")
# 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.
# _run_cleanup must NOT finalize the row on exit: the gateway owns it now, and an
# end_reason set under it would drop the handoff leg from session history/search.
from cli import _handed_off_session_ids
_handed_off_session_ids.add(self.session_id)
self._should_exit = True # same exit semantics as /quit
@@ -1312,9 +1308,7 @@ class CLICommandsMixin:
return True
_time.sleep(0.5)
# Pending timed out: clear the flag so the user can retry (CAS — a claim racing this
# instant wins; we lose only the retry convenience, never the handoff).
try:
try: # pending timed out: CAS-clear so the user can retry
self._session_db.fail_handoff(
self.session_id, "timed out waiting for gateway", only_states=("pending",))
except TypeError:
@@ -1340,9 +1334,8 @@ class CLICommandsMixin:
if not target:
_cp(" Usage: /resume <number|session_id_or_title>")
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).
# Arm a one-shot bare-number selection; must be the same list the table showed
# and the numbered branch resolves (all use _list_recent_sessions(limit=10)).
self._pending_resume_sessions = self._list_recent_sessions(limit=10)
return
_cp(" Tip: Use /history or `hermes sessions list` to find sessions.")
@@ -1393,10 +1386,8 @@ class CLICommandsMixin:
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).
# One lineage SELECT, two projections: model_history is alternation-repaired for live
# replay (heals a durable user;user once); display_history is verbatim (as startup --resume).
model_history, display_history = self._session_db.get_resume_conversations(target_id)
self.conversation_history = [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"]
@@ -1416,20 +1407,15 @@ class CLICommandsMixin:
else:
_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 approval session key changed), and its model/provider (else it reverts to
# config default).
# Same contract as startup --resume: retarget the tool cwd, restore the persisted YOLO
# bypass (approval session key changed) and the model/provider (else config default).
self._restore_session_cwd(session_meta)
self._restore_session_yolo(session_meta)
self._restore_session_model(session_meta)
def _handle_sessions_command(self, cmd_original: str) -> None:
"""Handle /sessions [list|<id_or_title>] — browse or resume previous sessions.
Bare/``list`` prints the same recent-sessions table /resume shows; an explicit target
delegates to the resume flow so ``/sessions <id>`` and ``/resume <id>`` behave identically.
(The TUI has a picker overlay; the classic CLI prints inline.)"""
"""Handle /sessions [list|<id_or_title>] — bare/``list`` prints the recent-sessions table;
an explicit target delegates to /resume so both spellings behave identically."""
arg = _command_arg(cmd_original)
if arg and arg.lower() not in {"list", "ls", "browse"}:
self._handle_resume_command(f"/resume {arg}")
@@ -1459,9 +1445,8 @@ class CLICommandsMixin:
parent_session_id = self.session_id
_end_current_session(self, "branched")
# Create the new session with parent link. The stable ``_branched_from`` marker keeps
# the branch visible in /resume + /sessions even after the parent is re-ended with a
# different end_reason.
# The stable ``_branched_from`` marker keeps the branch visible in /resume + /sessions
# even after the parent is re-ended with a different end_reason.
try:
self._session_db.create_session(
session_id=new_session_id,
@@ -1475,9 +1460,8 @@ class CLICommandsMixin:
_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. api_content sidecar: the branch's first turn replays the parent's exact
# wire bytes (warm prompt cache) instead of a cold prefill.
# Best-effort chunked copy (a failed copy still yields a usable branch); the api_content
# sidecar lets the branch's first turn replay the parent's exact wire bytes (warm cache).
with suppress(Exception):
self._session_db.append_messages_batch(
new_session_id,
@@ -1516,13 +1500,9 @@ class CLICommandsMixin:
# ---- /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)."""
"""Handle /worktree [new [name]|list|prune [--dry-run]] — isolated git worktrees.
``new`` moves this session into the tree (as ``hermes -w``: kept on exit only with
unpushed commits); ``prune`` never deletes tracked changes, unique commits, or in-use trees."""
import cli as _cli
parts = cmd_original.split(None, 2)
sub = parts[1].lower() if len(parts) > 1 else ""
@@ -1540,19 +1520,15 @@ class CLICommandsMixin:
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)
handler = _WORKTREE_SUBCOMMANDS.get(sub)
if handler is None:
_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.")
if handler == "_worktree_new" else " Not inside a git repository.")
return
handler(repo_root, rest)
getattr(self, handler)(repo_root, rest)
def _worktree_prune(self, repo_root: str, rest: str) -> None:
import cli as _cli
@@ -1675,11 +1651,8 @@ class CLICommandsMixin:
f" \"{_ellipsize(personality_prompt, 60)}\"")
def _handle_pet_command(self, cmd: str):
"""Toggle, browse, or adopt a petdex mascot.
``/pet`` / ``/pet toggle`` flips ``display.pet.enabled``; ``list`` browses the gallery;
``scale <n>`` resizes; ``<slug>`` adopts (installs if needed); ``off`` disables.
Writes ``display.pet.*`` to config; pet surfaces pick it up on their next poll."""
"""Handle /pet [toggle|list|scale <n>|off|<slug>] — the petdex mascot. Writes
``display.pet.*`` to config; pet surfaces pick it up on their next poll."""
from agent.pet import store
from agent.pet.manifest import ManifestError
from hermes_cli.pets import (
@@ -1726,9 +1699,8 @@ class CLICommandsMixin:
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
# raw input() here never renders and eats keystrokes. Use the thread-aware prompt
# helper (run_in_terminal; None when prompting isn't safe).
# prompt_toolkit owns stdin on this daemon thread — raw input() never renders and eats
# keystrokes; prefer the thread-aware helper (None when prompting isn't safe).
prompt_helper = getattr(self, "_prompt_text_input", None)
if callable(prompt_helper):
concept = (prompt_helper("(o_o) Describe your pet: ") or "").strip()
@@ -2011,9 +1983,8 @@ class CLICommandsMixin:
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.
# No live agent store (e.g. Desktop GUI): use a fresh on-disk store, as the gateway
# does — same MEMORY/USER.md, same configured char limits.
from tools.memory_tool import load_on_disk_store
store = load_on_disk_store()
out = handle_pending_subcommand(
@@ -2551,9 +2522,7 @@ class CLICommandsMixin:
def _handle_subgoal_command(self, cmd: str) -> None:
"""Dispatch /subgoal: bare → show, ``<text>`` → append, ``remove <n>`` (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)."""
Subgoals join the judge + continuation prompts at the next turn boundary (no kick)."""
parts = (cmd or "").strip().split(None, 2)
arg = " ".join(parts[1:]).strip() if len(parts) > 1 else ""
mgr = self._session_manager(self._get_goal_manager, "Goals")
@@ -2683,13 +2652,10 @@ class CLICommandsMixin:
# ---- /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.
Composes with the existing ``/verbose`` machinery instead of adding a second suppression
path: turning it on stashes the user's tool_progress_mode and snaps it to "off" (the value
``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."""
"""``/focus [on|off|status]`` — DISPLAY-ONLY reduced output. Reuses the ``/verbose``
suppression path: on stashes tool_progress_mode and snaps it to "off" (what
``agent/tool_executor.py`` gates on); off restores the stash verbatim. Never touches
history, system prompt, or request payloads."""
from hermes_cli.colors import Colors as _Colors
from hermes_cli.focus_view import (
FOCUS_CONFIG_KEY, FOCUS_TOOL_PROGRESS_MODE, format_focus_status,
@@ -2827,12 +2793,8 @@ class CLICommandsMixin:
# ---- model-behaviour settings: /reasoning, /busy, /indicator, /fast -------------------
def _handle_reasoning_command(self, cmd: str):
"""Handle /reasoning — manage effort level and display toggle.
``/reasoning`` shows the effort level and display state; ``<level> [--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."""
"""Handle /reasoning [<level> [--global]|show|hide|full|clamp] — effort level (session
scope unless --global) and thinking display toggles (always saved)."""
from cli import CLI_CONFIG, _parse_reasoning_config
raw = _command_arg(cmd)
if not raw: # show current state
@@ -2898,14 +2860,7 @@ class CLICommandsMixin:
_cp(_dim_line(f'(._.) Unknown argument: {arg}'), usage)
return
self.busy_input_mode = arg
self._persist_display_choice("display.busy_input_mode", arg, "Busy input mode", _BUSY_MODE_LONG[arg])
def _persist_display_choice(self, key: str, value: str, label: str, note: str) -> None:
"""Save a /busy-style choice to config and report saved vs session-only."""
if _save(key, value):
_cp(_accent_line(f"✓ {label} set to '{value}' (saved to config)"), _dim_line(note))
else:
_cp(_accent_line(f"✓ {label} set to '{value}' (session only)"))
_persist_display_choice("display.busy_input_mode", arg, "Busy input mode", _BUSY_MODE_LONG[arg])
def _handle_indicator_command(self, cmd: str):
"""Handle /indicator [status|kaomoji|emoji|unicode|ascii] — pick the TUI busy-indicator style.
@@ -2921,8 +2876,8 @@ class CLICommandsMixin:
_cp(_dim_line(f'(._.) Unknown indicator style: {arg}'), usage)
return
self.config.setdefault("display", {})["tui_status_indicator"] = arg
self._persist_display_choice("display.tui_status_indicator", arg, "Busy-indicator style",
"The TUI picks up the new style on its next render.")
_persist_display_choice("display.tui_status_indicator", arg, "Busy-indicator style",
"The TUI picks up the new style on its next render.")
def _handle_fast_command(self, cmd: str):
"""Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).
@@ -2971,10 +2926,9 @@ class CLICommandsMixin:
lines=200, expire=7, local=local, nous="nous" in words and not local, yes=True))
def _handle_update_command(self) -> bool:
"""Handle /update — exit the session and relaunch as ``hermes update``.
Returns True when confirmed: the caller triggers app exit so the relaunch happens on the
main thread after prompt_toolkit restores terminal modes. False when cancelled."""
"""Handle /update — exit the session and relaunch as ``hermes update``. Returns True when
confirmed (the caller exits the app; the relaunch runs on the main thread after
prompt_toolkit restores terminal modes), False when cancelled."""
from hermes_cli.config import is_managed, format_managed_message
if is_managed():
print(f" ✗ {format_managed_message('update Hermes Agent')}")
@@ -2991,9 +2945,8 @@ class CLICommandsMixin:
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,
# sys.exit would only end the worker thread.
# run() execs this on the main thread after prompt_toolkit restores terminal modes;
# relaunching from this daemon thread would skip cleanup (POSIX) / only end the thread (Windows).
self._pending_relaunch = ["update"]
return True