Files
Teknium 5819226468 feat(plugins): one switch and one action for unattended plugin updates
main already ships `hermes plugins update` (no name = update all) and the
opt-in `plugins.auto_apply` cadence. This makes both reachable from every
surface and closes the gaps reported on the original PR:

- Catalog installs now ride `plugins.auto_apply` too (it moved only git
  rows, so catalog plugins stayed on their install-day pin). The update
  path still refuses a widened pin or a too-new requires_hermes unattended.
- One updater per plugin: a cross-process lock around update_one and the
  dashboard/Desktop update, so the gateway's auto-apply tick and a manual
  update never run on the same plugin at once; the loser changes nothing.
- `hermes update` names, per profile, the catalog installs its new catalog
  pins left behind and re-arms that profile's plugin check, so an opted-in
  gateway applies them on the next tick.
- New CLI verb `hermes plugins auto-update [on|off]`; `/plugins update` and
  `/plugins auto-update [on|off]` in the classic CLI and the TUI (slash
  worker).
- RPC `plugins.manage` actions `update_all` and `auto_update` (contract +
  regenerated apps/shared outputs) for Desktop.

Off by default (legacy behaviour); all writes are profile-scoped through
get_hermes_home()/the scoped RPC.
2026-11-04 21:58:12 -08:00

892 lines
43 KiB
Python

"""Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI.
Mixin on ``HermesCLI``; cli.py symbols are imported lazily inside methods (import cycle)."""
from __future__ import annotations
import logging
import threading
import time
from agent.i18n import t
from hermes_cli.gateway_client import GatewayClientError
from rich.markup import escape as _escape
def _fresh_start() -> str:
"""Banner line printed after /clear — resolved at call time so the active language applies."""
return f" {t('cli.session.fresh_start')}\n"
def _preview(payload: str) -> str:
return f"{payload[:80]}{'...' if len(payload) > 80 else ''}"
def _queue_usage() -> str:
return t("cli.queue.usage")
def _parse_queue_index(token: str) -> int | None:
"""1-based queue index from one whitespace-free token, or None when it isn't one.
``str.isdigit()`` alone is not enough: it accepts characters ``int()`` rejects
(``²``, ``②``), and ``int()`` also refuses digit strings past Python's
conversion limit. A ValueError here would escape the slash handler and take
down the prompt_toolkit app, so every queue index goes through this.
"""
if not token.isdigit():
return None
try:
return int(token)
except ValueError:
return None
# management verb -> (handler, takes a leading item index). Verbs without an index are
# only management when they stand alone; index verbs when a number follows or when the
# verb stands alone (the handler then prints its usage line).
_QUEUE_VERBS: dict[str, tuple[str, bool]] = {
"list": ("_queue_list", False), "ls": ("_queue_list", False), "show": ("_queue_list", False),
"clear": ("_queue_clear", False),
"edit": ("_queue_edit", True), "set": ("_queue_edit", True),
"rm": ("_queue_remove", True), "remove": ("_queue_remove", True), "delete": ("_queue_remove", True),
"del": ("_queue_remove", True), "pop": ("_queue_remove", True),
"move": ("_queue_move", True),
}
def _print_decision_message(decision: dict) -> bool:
"""Print a manager decision's ``message`` (if any) via _cprint; True when one was printed."""
from cli import _cprint
msg = decision.get("message") or ""
if msg:
_cprint(f" {msg}")
return bool(msg)
def _goal_blocks_loop_tick(mgr) -> bool:
"""``loops.goal_blocks_loop_tick`` for this loop's session: asked of the gateway when the manager
is the gateway's (it reads the goal row and its barrier where they live)."""
if hasattr(mgr, "goal_blocks_tick"):
return mgr.goal_blocks_tick()
from hermes_cli.loops import goal_blocks_loop_tick
return goal_blocks_loop_tick(mgr.session_id)
class CLILoopsMixin:
"""Simple slash-command wrappers plus goal/heartbeat/loop manager hooks for the interactive CLI"""
def _cmd_exit(self, cmd_original: str):
# /exit --delete also removes the session's transcripts + SQLite history.
from cli import _DIM, _RST, _cprint, _slash_args
_args = _slash_args(cmd_original).lower()
# Ported from google-gemini/gemini-cli#19332.
if _args in {"--delete", "-d"}:
self._delete_session_on_exit = True
elif _args:
_cprint(f" {_DIM}{t('cli.exit.unknown_argument', argument=_escape(_args))}{_RST}")
return True
return False
def _cmd_help(self, cmd_original: str):
from cli import _slash_args
self.show_help(_slash_args(cmd_original))
def _cmd_redraw(self, cmd_original: str):
# Manual recovery for terminal buffer drift (mux tab switches, subshell ``clear``,
# SSH restores; #8688). Ctrl+L is bound to the same helper.
from cli import _DIM, _RST, _cprint
self._force_full_redraw()
_cprint(f" {_DIM}{t('cli.display.ui_redrawn')}{_RST}")
def _cmd_clear(self, cmd_original: str):
from cli import ChatConsole, _build_compact_banner, _clear_output_history, _cprint, get_tool_definitions
from hermes_cli.banner import build_welcome_banner
if self._confirm_destructive_slash(
"clear",
"This clears the screen and starts a new session.\n"
"The current conversation history will be discarded.",
cmd_original=cmd_original,
) is None:
return True # confirmation cancelled — command handled, keep REPL alive
self.new_session(silent=True)
_clear_output_history()
if not self._app:
self.console.clear()
self.show_banner()
print(_fresh_start())
self._print_random_tip()
return
# Inside the TUI, Rich's console.clear() and self.console both go through
# patch_stdout's StdoutProxy, which swallows the clear escapes / mangles raw
# output: clear via prompt_toolkit's output and print through ChatConsole.
out = self._app.output
out.erase_screen()
out.cursor_goto(0, 0)
out.flush()
cc = ChatConsole()
# The banner plan startup uses (show_banner): a gateway session's banner is the new
# session's snapshot (its tools, MCP included, and main's short label), never this
# process's registry.
from hermes_cli.cli_info_mixin import banner_plan
plan = banner_plan(self, cc)
if plan.banner_kw is None:
cc.print(_build_compact_banner())
elif plan.inventory is not None:
self._show_gateway_banner(build_welcome_banner, plan.inventory, plan.banner_kw)
else:
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets,
disabled_toolsets=self.disabled_toolsets, quiet_mode=True)
build_welcome_banner(tools=tools, **plan.banner_kw)
_cprint(_fresh_start())
self._print_random_tip()
def _cmd_title(self, cmd_original: str):
from cli import _cprint
from hermes_state import format_session_db_unavailable
parts = cmd_original.split(maxsplit=1)
if len(parts) == 1:
# No argument: show current title and session ID.
if not self._session_db:
_cprint(f" {format_session_db_unavailable(details=True)}")
return
_cprint(f" {t('cli.title.session_id', session_id=self.session_id)}")
session = self._session_db.get_session(self.session_id)
if session and session.get("title"):
_cprint(f" {t('cli.title.current', title=session['title'])}")
elif self._pending_title:
_cprint(f" {t('cli.title.pending', title=self._pending_title)}")
else:
_cprint(f" {t('cli.title.none_set_usage')}")
return
raw_title = parts[1].strip()
if not raw_title:
_cprint(f" {t('cli.title.usage')}")
return
if not self._session_db:
_cprint(f" {format_session_db_unavailable(details=True)}")
return
# Sanitize early so feedback matches what gets stored. A rejection (e.g. too
# long) prints that one reason and stops — never a second, contradictory
# "empty after cleanup" error (SC-05).
try:
from hermes_state import SessionDB
new_title = SessionDB.sanitize_title(raw_title)
except ValueError as e:
_cprint(f" {e}")
return True
if not new_title:
_cprint(f" {t('cli.title.empty_after_cleanup')}")
elif self._session_db.get_session(self.session_id):
try:
if self._session_db.set_session_title(self.session_id, new_title):
self._status_bar_title_checked_at = 0.0
_cprint(f" {t('cli.title.set', title=new_title)}")
else:
_cprint(f" {t('gateway.shared.session_not_found')}")
except ValueError as e:
_cprint(f" {e}")
else:
# Session not created yet — check uniqueness now, defer the title.
existing = self._session_db.get_session_by_title(new_title)
if existing:
_cprint(f" {t('cli.title.already_in_use', title=new_title, session_id=existing['id'])}")
else:
self._pending_title = new_title
_cprint(f" {t('cli.title.queued', title=new_title)}")
def _cmd_new(self, cmd_original: str):
# Strip inline-skip tokens (now/--yes/-y) before deriving the title so
# "/new now My Session" yields title="My Session". See _split_destructive_skip.
_new_args, _ = self._split_destructive_skip(cmd_original)
title = _new_args.strip() or None
if self._confirm_destructive_slash(
"new",
"This starts a fresh session.\n"
"The current conversation history will be discarded.",
cmd_original=cmd_original,
) is None:
return True # confirmation cancelled — command handled, keep REPL alive
self.new_session(title=title)
def _record_model_friction(self, signal: str, turns: int = 1) -> None:
# The TUI slash worker's shadow CLI has no metrics surface: tui_gateway counts what it executes.
if self._slash_metrics_surface:
from hermes_cli.observability.shared_metrics_model import record_model_friction
record_model_friction(
signal, session_id=getattr(self, "session_id", None), agent=getattr(self, "agent", None),
provider=getattr(self, "provider", None), model=getattr(self, "model", None), turns=turns)
def _cmd_retry(self, cmd_original: str):
retry_msg = self.retry_last()
if retry_msg:
self._record_model_friction("retry")
if retry_msg and hasattr(self, '_pending_input'):
self._pending_input.put(retry_msg) # process_loop sends it to the agent
def _cmd_undo(self, cmd_original: str):
# "/undo" → 1, "/undo 3" → 3.
_undo_n = 1
_undo_parts = cmd_original.split()
if len(_undo_parts) > 1:
try:
_undo_n = max(1, int(_undo_parts[1]))
except ValueError:
print(t("cli.undo.invalid_count", value=repr(_undo_parts[1])))
return True # bad arg — command handled, keep the REPL alive
# Nothing to undo → say so; no destructive confirmation for a no-op (SC-06).
if not self.conversation_history:
print(t("cli.session.undo_no_messages"))
return True
_undo_desc = (
t("cli.undo.removes_last_exchange")
if _undo_n == 1
else t("cli.undo.removes_last_turns", count=_undo_n))
if self._confirm_destructive_slash("undo", _undo_desc, cmd_original=cmd_original) is None:
return True # confirmation cancelled — command handled, keep REPL alive
turns_undone = self.undo_last(_undo_n)
if turns_undone: # None when nothing was rewound
self._record_model_friction("undo", turns_undone)
def _cmd_skills(self, cmd_original: str):
with self._busy_command(self._slow_command_status(cmd_original)):
self._handle_skills_command(cmd_original)
def _cmd_egress(self, cmd_original: str):
from hermes_cli.slash_exec import CommandContext, execute_command
text = execute_command("egress", CommandContext(surface="cli")).text
self._console_print(text, highlight=False, markup=False)
def _cmd_statusbar(self, cmd_original: str):
self._status_bar_visible = not self._status_bar_visible
self._console_print(
f" {t('cli.display.status_bar_visible') if self._status_bar_visible else t('cli.display.status_bar_hidden')}")
def _cmd_update(self, cmd_original: str) -> bool:
# A truthy result means the process is relaunching — leave the REPL.
return not self._handle_update_command()
def _cmd_version(self, cmd_original: str):
from hermes_cli.main import _print_version_info
_print_version_info(check_updates=True)
def _cmd_reload(self, cmd_original: str):
from hermes_cli.config import reload_env
count = reload_env()
print(f" {t('cli.reload.env_reloaded', count=count)}")
def _cmd_reload_skills(self, cmd_original: str):
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_skills()
def _cmd_plugins(self, cmd_original: str):
from hermes_constants import display_hermes_home
parts = cmd_original.split()[1:]
if parts and parts[0] in ("update", "auto-update"):
from hermes_cli.plugins_auto_update import plugins_slash_action
with self._busy_command(self._slow_command_status(cmd_original)):
for line in plugins_slash_action(parts):
print(line)
return
try:
# Discover from disk (bundled + user) like `hermes plugins list`, so
# installed-but-not-enabled plugins show up; the plugin manager only knows
# *loaded* plugins and made fresh installs look like "nothing installed".
from hermes_cli.plugins_cmd import (
_discover_all_plugins, _get_disabled_set, _get_enabled_set, _plugin_status)
entries = _discover_all_plugins()
enabled = _get_enabled_set()
disabled = _get_disabled_set()
# `/plugins` is a quick glance: user plugins only, bundled ones summarized
# on one line (full catalog behind `hermes plugins list`).
user_entries = [e for e in entries if e[3] != "bundled"]
bundled_count = len(entries) - len(user_entries)
if not user_entries:
print(t("cli.plugins.none_installed"))
print(f" {t('cli.plugins.install_hint')}")
print(f" {t('cli.plugins.drop_dir_hint', path=f'{display_hermes_home()}/plugins/')}")
if bundled_count:
print(f" {t('cli.plugins.bundled_available', count=bundled_count)}")
return
from hermes_cli import gateway_plugin_commands
loaded = gateway_plugin_commands.loaded(self) # the gateway's; None off the gateway
if loaded is None:
try: # loaded-plugin details (tools/hooks/commands counts, errors) by name
from hermes_cli.plugins import get_plugin_manager
loaded = {p["name"]: p for p in get_plugin_manager().list_plugins()}
except Exception:
loaded = {}
print(t("cli.plugins.user_plugins_header", count=len(user_entries)))
for name, version, _desc, source, _dir, key in sorted(user_entries):
state = _plugin_status(name, enabled, disabled, key=key)
info = loaded.get(name) or {}
bits = [f"{info[k]} {t(f'cli.plugins.count_noun_{k}')}" for k in ("tools", "hooks", "commands") if info.get(k)]
glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○")
ver = f" v{version}" if version else ""
detail = f" ({', '.join(bits)})" if bits else ""
label = "" if state == "enabled" else f" [{state}]"
error = f" — {info['error']}" if info.get("error") else ""
print(f" {glyph} {name}{ver}{label}{detail}{error}")
if bundled_count:
print(f" {t('cli.plugins.bundled_more', count=bundled_count)}")
print(f" {t('cli.plugins.enable_disable_hint')}")
except Exception as e:
print(t("cli.plugins.system_error", error=e))
# ── /queue: enqueue, list, edit, rm, move, clear ─────────────────
# A queued next-turn prompt can be inspected and changed before it is sent.
def _pending_input_items(self) -> list:
"""Snapshot of queued next-turn prompts (raw items; may include ``_VoiceInputMessage``)."""
with self._pending_input.mutex:
return list(self._pending_input.queue)
def _mutate_pending_input(self, mutate) -> tuple[list, list]:
"""Apply ``mutate(items) -> items`` to the queued prompts under the queue's own mutex,
so a voice/interrupt ``put`` from another thread can't slip between snapshot and
write-back. Returns ``(before, after)``."""
q = self._pending_input
with q.mutex:
before = list(q.queue)
after = list(mutate(list(before)))
q.queue.clear()
q.queue.extend(after)
q.unfinished_tasks = len(after)
if after:
q.not_empty.notify_all()
return before, after
def _gateway_queue_store(self):
"""The gateway session store when this CLI is a viewer of the gateway's session, else None."""
from hermes_cli.gateway_tools_notify import gateway_store
return gateway_store(self) if getattr(self, "session_id", None) else None
def _queue_items(self) -> list:
"""``[(admission_id | None, item)]``: the owner's queued admissions (run first, in its FIFO
order), then the turns still waiting in ``_pending_input``."""
items: list = []
store = self._gateway_queue_store()
if store is not None:
from hermes_cli import gateway_queue
items = list(gateway_queue.queued_rows(self, store))
return items + [(None, item) for item in self._pending_input_items()]
def _queue_enqueue(self, text: str) -> None:
from cli import _cprint
payload = self._expand_paste_references(text)
store = self._gateway_queue_store() if self._agent_running else None
if store is not None:
# #134889 N5: the gateway's durable FIFO holds it (committed at accept, kept across an owner
# restart, run once after the current turn), never this process.
from hermes_cli import gateway_queue
try:
gateway_queue.submit_queued(self, store, payload)
except gateway_queue.QueueOutcomeUnknown as exc: # sent, no answer: it may be queued
_cprint(f" {t('cli.queue.submit_unknown', error=exc)}")
return
except GatewayClientError as exc: # the owner refused, or it was never sent: nothing is held here
_cprint(f" {t('cli.queue.gateway_refused', error=exc)}")
return
else:
self._pending_input.put(payload)
key = "cli.queue.queued_next_turn" if self._agent_running else "cli.queue.queued"
_cprint(f" {t(key, preview=_preview(payload))}")
def _queue_list(self, rest: str) -> None:
from cli import _VoiceInputMessage, _cprint
items = self._queue_items()
if not items:
_cprint(f" {t('cli.queue.empty')}" + ("" if rest else " " + _queue_usage()))
return
_cprint(f" {t('cli.queue.pending_header', count=len(items))}")
for idx, (_, item) in enumerate(items, 1):
tag = " [voice]" if isinstance(item, _VoiceInputMessage) else ""
_cprint(f" {idx}. {_preview(str(item).replace(chr(10), ' '))}{tag}")
def _queue_gateway_call(self, verb, index, *args):
"""One owner verb on queued item ``index``; its refusal is printed (in its own words), never raised."""
from cli import _cprint
from hermes_cli import gateway_queue
from hermes_cli.gateway_client import GatewayRPCError
try:
return getattr(gateway_queue, verb)(self, self._gateway_queue_store(), *args)
except GatewayRPCError as exc:
if str(exc) in gateway_queue.STARTED_REFUSALS: # the drain claimed it first: it runs as sent
_cprint(f" {t('cli.queue.already_started', index=index)}")
else:
key = "cli.queue.edit_refused" if verb == "replace" else "cli.queue.remove_refused"
_cprint(f" {t(key, index=index, error=exc)}")
except GatewayClientError as exc: # no answer: the owner may have applied it
_cprint(f" {t('cli.queue.change_unknown', index=index, error=exc)}")
return None
def _queue_clear(self, rest: str) -> None:
from cli import _cprint
owned = [admission for admission, _ in self._queue_items() if admission]
cleared = sum(1 for idx, admission in enumerate(owned, 1)
if self._queue_gateway_call("cancel", idx, admission) == "cancelled")
before, _ = self._mutate_pending_input(lambda items: [])
cleared += len(before)
key = "cli.queue.cleared_one" if cleared == 1 else "cli.queue.cleared_other"
_cprint(f" {t(key, count=cleared)}")
def _queue_remove(self, rest: str) -> None:
from cli import _cprint
bits = rest.split()
idx = _parse_queue_index(bits[0]) if len(bits) == 1 else None
if idx is None:
_cprint(f" {t('cli.queue.usage_remove')}")
return
items = self._queue_items()
owned = sum(1 for admission, _ in items if admission)
if 1 <= idx <= owned:
admission, text = items[idx - 1]
verdict = self._queue_gateway_call("cancel", idx, admission)
if verdict == "cancelled":
_cprint(f" {t('cli.queue.removed', index=idx, preview=_preview(str(text)))}")
elif verdict == "stopped": # claimed between the list and the cancel: the owner interrupted it
_cprint(f" {t('cli.queue.started_stopped', index=idx)}")
elif verdict == "started":
_cprint(f" {t('cli.queue.already_started', index=idx)}")
return
local = idx - owned
removed: list = []
before, _ = self._mutate_pending_input(
lambda items: (removed.append(items.pop(local - 1)) or items) if 1 <= local <= len(items) else items)
if removed:
_cprint(f" {t('cli.queue.removed', index=idx, preview=_preview(str(removed[0])))}")
else:
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=owned + len(before))}")
def _queue_edit(self, rest: str) -> None:
from cli import _VoiceInputMessage, _cprint
# Split on any whitespace, like ``_cmd_queue``'s routing check, so
# ``edit 1<TAB>text`` doesn't leave "1\ttext" as the index token.
bits = rest.split(None, 1)
idx = _parse_queue_index(bits[0]) if len(bits) == 2 else None
if idx is None:
_cprint(f" {t('cli.queue.usage_edit')}")
return
new_prompt = bits[1]
new_text = self._expand_paste_references(new_prompt.strip())
items = self._queue_items()
owned = sum(1 for admission, _ in items if admission)
if 1 <= idx <= owned:
# The owner admits the edit as a new admission at the back of its FIFO.
if self._queue_gateway_call("replace", idx, items[idx - 1][0], new_text):
_cprint(f" {t('cli.queue.updated', index=idx, preview=_preview(new_text))}")
return
local = idx - owned
def _edit(items: list) -> list:
if 1 <= local <= len(items):
# A voice-queued item keeps its sentinel so the concise voice-response
# prefix still applies (#65827).
voice = isinstance(items[local - 1], _VoiceInputMessage)
items[local - 1] = _VoiceInputMessage(new_text) if voice else new_text
return items
before, after = self._mutate_pending_input(_edit)
if before == after:
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=owned + len(before))}")
else:
_cprint(f" {t('cli.queue.updated', index=idx, preview=_preview(new_text))}")
def _queue_move(self, rest: str) -> None:
from cli import _cprint
bits = rest.split()
src, dst = (_parse_queue_index(b) for b in bits) if len(bits) == 2 else (None, None)
if src is None or dst is None:
_cprint(f" {t('cli.queue.usage_move')}")
return
owned = sum(1 for admission, _ in self._queue_items() if admission)
if owned and min(src, dst) <= owned and src != dst:
# The owner's FIFO runs admissions in the order they were sent; it has no reorder verb.
_cprint(f" {t('cli.queue.move_gateway_fifo')}")
return
src, dst = src - owned, dst - owned
def _move(items: list) -> list:
if 1 <= src <= len(items) and 1 <= dst <= len(items):
items.insert(dst - 1, items.pop(src - 1))
return items
before, after = self._mutate_pending_input(_move)
if before == after and src != dst:
_cprint(f" {t('cli.queue.move_out_of_range', size=owned + len(before))}")
else:
_cprint(f" {t('cli.queue.moved', source=src + owned, destination=dst + owned)}")
def _cmd_queue(self, cmd_original: str):
"""``/queue <prompt>`` enqueues; a leading management verb whose arguments fit
(``list``/``clear`` alone, ``edit N …``/``rm N``/``move A B``) manages the queue
instead. Anything else — ``clear the logs``, ``edit the config`` — is still a prompt;
``/queue add <prompt>`` forces enqueueing."""
from cli import _cprint, _slash_args
payload = _slash_args(cmd_original)
if not payload:
self._queue_list("")
return
verb, _, rest = payload.partition(" ")
verb, rest = verb.lower(), rest.strip()
if verb == "add":
if rest:
self._queue_enqueue(rest)
else:
_cprint(f" {t('cli.queue.usage_add')}")
return
handler, takes_index = _QUEUE_VERBS.get(verb, (None, False))
is_management = handler is not None and (
rest.split(None, 1)[0].isdigit() if takes_index and rest else not rest
)
if is_management:
getattr(self, handler)(rest)
else:
self._queue_enqueue(payload)
def _cmd_steer(self, cmd_original: str):
# Inject a message after the next tool call without interrupting: while the
# agent runs, push into its pending_steer slot (drained by _execute_tool_calls_*
# into the next tool result); otherwise fall back to /queue semantics.
from cli import _cprint, _slash_args
payload = _slash_args(cmd_original)
if not payload:
_cprint(f" {t('cli.steer.usage')}")
elif self._agent_running and self.agent is not None and hasattr(self.agent, "steer"):
try:
accepted = self.agent.steer(payload)
except Exception as exc:
_cprint(f" {t('cli.steer.failed', error=exc)}")
else:
if accepted == "voice_stopped": # TUI-26: the owner ended the voice chat; consumed
pass
elif accepted:
_cprint(f" {t('cli.steer.queued', preview=_preview(payload))}")
else:
_cprint(f" {t('cli.steer.rejected_empty')}")
else:
self._pending_input.put(payload)
_cprint(f" {t('cli.steer.no_agent_queued', preview=_preview(payload))}")
# ────────────────────────────────────────────────────────────────
# Session-bound managers: /goal (Ralph-style loop), /heartbeat, /loop
# ────────────────────────────────────────────────────────────────
def _session_bound_manager(self, attr: str, label: str, load):
"""Return the manager cached on ``self.<attr>``, rebuilt when ``session_id`` changed
(after /new or a compression-driven session split).
``load()`` does the imports and returns a ``sid -> manager`` factory; an import
failure is logged and yields None, as does an empty session_id.
"""
try:
make = load()
except Exception as exc:
logging.debug("%s unavailable: %s", label, exc)
return None
sid = getattr(self, "session_id", None) or ""
if not sid:
return None
existing = getattr(self, attr, None)
if existing is not None and getattr(existing, "session_id", None) == sid:
return existing
mgr = make(sid)
setattr(self, attr, mgr)
return mgr
def _automation_connection(self):
"""``(gateway, connection)``: on a gateway session store the managers are the gateway's
(``hermes_cli.gateway_automation``), and none exists until the gateway session is open, so this
process never opens state.db read-write nor calls the goal judge. Off the gateway (tests, the
TUI slash worker's shadow CLI) main's in-process managers apply."""
from hermes_cli.gateway_session_store import GatewaySessionStore
store = getattr(self, "_session_db", None)
if not isinstance(store, GatewaySessionStore):
return False, None
return True, store.connection if getattr(self, "_gateway_session_open", False) else None
def _automation_manager(self, attr, label, local, remote):
gateway, connection = self._automation_connection()
if gateway and connection is None:
return None
return self._session_bound_manager(
attr, label, (lambda: lambda sid: remote(sid, connection)) if gateway else local)
def _mark_automation_stale(self) -> None:
"""Agent thread: the gateway published this session's goal/loop state mid-turn (a tool moved it).
The dock re-reads the mirrored managers on its next spinner-thread poll (``cli_session_dock``)."""
self._automation_stale = True
def _get_goal_manager(self):
"""GoalManager bound to the current session_id (see ``_session_bound_manager``)."""
def max_turns():
from hermes_cli.config import load_config
try:
goals_cfg = (load_config() or {}).get("goals") or {}
return int(goals_cfg.get("max_turns", 20) or 20)
except Exception: # health: allow BLE001 -- main's fallback: an unreadable goals config is the 20-turn default
return 20
def load():
from hermes_cli.goals import GoalManager
return lambda sid: GoalManager(session_id=sid, default_max_turns=max_turns())
def remote(sid, connection):
from hermes_cli.gateway_automation import GatewayGoalManager
return GatewayGoalManager(sid, connection)
return self._automation_manager("_goal_manager", "goal manager", load, remote)
def _get_heartbeat_manager(self):
"""HeartbeatManager bound to the current session_id (see ``_session_bound_manager``)."""
def load():
from hermes_cli.heartbeat import HeartbeatManager
return lambda sid: HeartbeatManager(session_id=sid)
def remote(sid, connection):
from hermes_cli.gateway_automation import GatewayHeartbeatManager
return GatewayHeartbeatManager(sid, connection)
return self._automation_manager("_heartbeat_manager", "heartbeat manager", load, remote)
def _get_loop_manager(self):
"""LoopManager bound to the current session_id (see ``_session_bound_manager``)."""
def load():
from hermes_cli.loops import LoopManager
return lambda sid: LoopManager(session_id=sid)
def remote(sid, connection):
from hermes_cli.gateway_automation import GatewayLoopManager
return GatewayLoopManager(sid, connection)
return self._automation_manager("_loop_manager", "loop manager", load, remote)
def _start_heartbeat_watchdog(self):
"""Start the idle-poll daemon that injects a due heartbeat prompt into
``_pending_input`` as a normal user turn when the session is idle. Missed ticks
coalesce (the anchor resets on fire, so a busy hour yields ONE heartbeat turn).
Idempotent; safe to call on every /heartbeat set."""
if getattr(self, "_heartbeat_watchdog_started", False):
return
self._heartbeat_watchdog_started = True
from hermes_cli.gateway_automation import GatewayHeartbeatManager
from hermes_cli.heartbeat import POLL_SECONDS
def _loop():
try:
while not getattr(self, "_should_exit", False):
time.sleep(POLL_SECONDS)
try:
mgr = self._get_heartbeat_manager()
if isinstance(mgr, GatewayHeartbeatManager):
# The gateway poller stands down while this watchdog runs, so a resume,
# set or re-anchor from another viewer must reach this copy.
mgr.refresh()
if mgr is None or not mgr.is_active():
continue
busy = (
self._agent_running
or getattr(self, "_voice_recording", False)
or getattr(self, "_voice_processing", False)
or not self._pending_input.empty())
if busy:
continue
prompt = mgr.due_prompt()
if prompt:
self._pending_input.put(prompt)
except Exception as exc:
logging.debug("heartbeat watchdog tick failed: %s", exc)
finally:
self._heartbeat_watchdog_started = False
threading.Thread(target=_loop, daemon=True, name="heartbeat-watchdog").start()
def _maybe_resume_parked_goal(self) -> None:
"""Idle hook run from process_loop: when a parked /goal's barrier has lifted (the process
exited, the timer elapsed, or the wait aged past its cap), queue the continuation so the
loop resumes WITHOUT waiting for an unrelated turn to re-evaluate it. The barrier used to be
checked only lazily, on the next turn; a session with nothing else arriving stayed parked
indefinitely (one run: 3 h 22 min on a grandchild's poller)."""
now = time.time()
if now - getattr(self, "_last_goal_barrier_check", 0.0) < 5.0:
return
self._last_goal_barrier_check = now
try:
if not self._pending_input.empty():
return
mgr = self._get_goal_manager()
state = getattr(mgr, "state", None) if mgr is not None else None
if state is None or state.status != "active":
return
if not (state.waiting_on_pid is not None or state.waiting_on_session is not None or state.waiting_until):
return # not parked
if mgr.is_waiting():
return # barrier still holds (is_waiting() also applies the age cap)
prompt = mgr.next_continuation_prompt()
if prompt:
from cli import _DIM, _RST, _cprint
_cprint(f" {_DIM}{t('cli.goal.barrier_lifted')}{_RST}")
self._pending_input.put(prompt)
except Exception as exc:
logging.debug("parked-goal resume check failed: %s", exc)
def _maybe_fire_loop_tick(self) -> None:
"""Idle hook run from process_loop: fire a due /loop wakeup.
Only while the agent is idle and nothing is queued — a real user message always
wins the idle boundary, and so does an active (non-parked) /goal, whose
judge-driven continuations own it; the loop defers to the next poll.
"""
from cli import _DIM, _RST, _cprint
mgr = self._get_loop_manager()
if mgr is None or not mgr.is_due():
return
# The idle poll runs at ~10 Hz; a due-but-deferred tick would otherwise hit the
# DB (goal_blocks_loop_tick) on every poll. Throttle the re-check.
now = time.time()
if now - getattr(self, "_last_loop_tick_check", 0.0) < 2.0:
return
self._last_loop_tick_check = now
try:
if not self._pending_input.empty():
return
except Exception:
return
try:
if _goal_blocks_loop_tick(mgr):
return
except Exception:
pass
wakeup = mgr.fire_tick()
if not wakeup:
return
try:
state = mgr.state
tick_no = state.ticks_fired if state else "?"
_cprint(f" {_DIM}{t('cli.loop.wakeup_firing', tick=tick_no)}{_RST}")
self._pending_input.put(wakeup)
except Exception as exc:
logging.debug("loop tick injection failed: %s", exc)
try:
mgr.abandon_tick()
except Exception:
pass
return
# A slash-command loop (`/loop 10m /recap`) is dispatched via process_command and
# never reaches chat()'s post-turn finally, so its tick would never complete and
# the loop would wedge on awaiting_response. Slash ticks have no model reply to
# judge; complete them immediately (caps and scheduling still apply).
if wakeup.lstrip().startswith("/"):
try:
_print_decision_message(mgr.complete_tick(""))
except Exception:
pass
def _last_assistant_response_text(self) -> str:
"""Text of the most recent assistant message ("" when none); multimodal parts are flattened."""
try:
for msg in reversed(self.conversation_history or []):
if msg.get("role") == "assistant":
content = msg.get("content", "")
if isinstance(content, list):
parts = [
p.get("text", "")
for p in content
if isinstance(p, dict) and p.get("type") in {"text", "output_text"}]
return "\n".join(t for t in parts if t)
return str(content or "")
except Exception:
pass
return ""
def _maybe_complete_loop_tick_after_turn(self) -> None:
"""Post-turn hook: evaluate a finished /loop wakeup turn.
No-op unless the turn was a loop wakeup (``awaiting_response`` set by
``fire_tick``). Detects the LOOP_COMPLETE marker, judges --until, applies caps,
and schedules the next tick. Mirrors _maybe_continue_goal_after_turn's shape.
"""
from cli import _DIM, _RST, _cprint
mgr = self._get_loop_manager()
if mgr is None:
return
state = mgr.state
if state is None or not state.awaiting_response:
return
# A user-interrupted wakeup turn pauses the loop (recoverable via /loop resume)
# — same contract as the goal loop's Ctrl+C handling.
if getattr(self, "_last_turn_interrupted", False):
try:
mgr.pause(reason="user-interrupted (Ctrl+C)")
except Exception:
pass
_cprint(f" {_DIM}{t('cli.loop.paused_interrupted')}{_RST}")
return
decision = mgr.complete_tick("" if getattr(self, "_last_turn_failed", False)
else self._last_assistant_response_text())
# With the live-work dock up its loop row carries the countdown; the one-shot line would go stale.
if (not _print_decision_message(decision) and decision.get("status") == "active"
and mgr.state is not None and getattr(self, "_subagent_monitor", None) is None):
_cprint(f" {_DIM}{t('cli.loop.remaining', label=mgr.state.remaining_label())}{_RST}")
def _maybe_continue_goal_after_turn(self) -> None:
"""Post-turn hook: judge the goal and maybe re-queue a continuation. A real user
message already queued preempts judging (re-judged after their turn). Ctrl+C
AUTO-PAUSES instead of judging — the judge on partial output nearly always says
"continue" and would re-queue exactly what was cancelled; pausing is recoverable
via ``/goal resume``. Empty-response skip mirrors ``gateway/run.py``."""
from cli import _DIM, _RST, _cprint, _looks_like_slash_command
mgr = self._get_goal_manager()
if mgr is None or not mgr.is_active():
return
# Slash commands don't count as "real user messages": they're dispatched via
# process_command, not chat(), so a queued /subgoal would consume its slot without
# ever re-firing this hook and the goal loop would silently stall. Peek at every
# queued entry (Queue.queue is the deque; FIFO undisturbed) and defer only on a
# non-slash payload. Bundled payloads are (text, images) tuples.
try:
pending = getattr(self, "_pending_input", None)
if pending is not None and not pending.empty():
try:
entries = [e[0] if isinstance(e, tuple) and e else e for e in list(pending.queue)]
has_real_message = any(
not (isinstance(e, str) and _looks_like_slash_command(e)) for e in entries)
except Exception:
has_real_message = True # can't introspect — defer to be safe
if has_real_message:
return
except Exception:
pass
if getattr(self, "_last_turn_interrupted", False):
try:
mgr.pause(reason="user-interrupted (Ctrl+C)")
except Exception as exc:
logging.debug("goal pause-on-interrupt failed: %s", exc)
_cprint(f" {_DIM}{t('cli.goal.paused_interrupted')}{_RST}")
return
# Empty/whitespace responses are almost always transient failures (API error,
# empty stream): judging would say "continue" and trip the parse-failure backstop.
# A failed turn (a refused one: its folder is gone) never reached the model, so the judge
# would grade the previous reply and burn the whole budget; same rule as the TUI's hook.
if getattr(self, "_last_turn_failed", False):
return
last_response = self._last_assistant_response_text()
if not last_response.strip():
return
_active_deleg = 0
try:
from hermes_cli.goals import count_active_delegations, gather_background_processes as _gather_bg
# Only THIS session's processes: subagents' pollers must not park the parent's goal.
_bg_procs = _gather_bg(owner_task_id=getattr(self, "session_id", None) or None)
_active_deleg = count_active_delegations(getattr(self.agent, "session_id", None))
except Exception:
_bg_procs = None
decision = mgr.evaluate_after_turn(
last_response, user_initiated=True, background_processes=_bg_procs, active_delegations=_active_deleg)
_print_decision_message(decision)
if decision.get("should_continue"):
prompt = decision.get("continuation_prompt")
if prompt:
try:
self._pending_input.put(prompt)
except Exception as exc:
logging.debug("goal continuation enqueue failed: %s", exc)