Files
hermes-agent/hermes_cli/cli_session_dock.py
T
Teknium 85188e61c3 Merge origin/main into fix/pkg13-ink-leftovers
Resolves the hermes_cli/cli_tui_mixin.py conflict from #141501 landing: main moved _Panel to
hermes_cli/cli_panel.py and this branch moved the wrap helpers to hermes_cli/cli_tui_wrap.py.
Keep both moves: import _Panel and the wrap helpers, define neither here.

Refs #134889 (N8)
2026-10-19 12:55:27 -07:00

102 lines
4.7 KiB
Python

"""Goal, loop + queued-prompt rows for the classic CLI live-work dock.
Sibling of ``cli_process_dock``: the dock paints an active ``/goal``, a standing ``/loop`` with its
countdown, and the prompts waiting in ``/queue`` around the subagent and process blocks, so they are
visible while the agent works instead of only through ``/goal status``, ``/loop status`` and
``/queue list``.
"""
from __future__ import annotations
import math
import time
# Queued prompts drain one per turn; past this many the dock shows a ``+N more`` line.
QUEUE_ROWS = 3
def sync_automation(cli) -> None:
"""Re-read the goal/loop managers' mirrored state once after the gateway flagged it changed mid-turn
(``_mark_automation_stale``): one ``state`` round trip per change, none while nothing moves."""
if not getattr(cli, "_automation_stale", False):
return
cli._automation_stale = False
for attr in ("_goal_manager", "_loop_manager"):
mgr = getattr(cli, attr, None)
if mgr is not None and mgr.session_id == getattr(cli, "session_id", None):
mgr.refresh()
def goal_line(cli) -> str:
"""``GoalManager.status_line()`` for an active or paused goal, else ``''``.
Reads only the manager the CLI already holds for its current session: the dock refreshes on
the spinner thread every second and must never be the thing that opens state.db.
"""
mgr = getattr(cli, "_goal_manager", None)
if mgr is None or mgr.session_id != getattr(cli, "session_id", None) or not mgr.has_goal():
return ""
return mgr.status_line()
def countdown(seconds: float) -> str:
"""Time left until the next wakeup, rounded UP: whole minutes until the last one, then seconds.
Minute steps keep an idle prompt's dock to one repaint a minute (the spinner thread repaints only
when the dock text changes); per-second repaints of a 45-minute wait would fight terminal viewport
restoration for nothing.
"""
left = max(0, math.ceil(seconds))
if left < 60:
return f"{left}s"
hours, minutes = divmod(math.ceil(left / 60), 60)
return f"{hours}h{minutes:02d}m" if hours else f"{minutes}m"
def loop_row(cli, now: float | None = None) -> tuple[str, str, str]:
"""``(badge, line, prompt)`` for an active or paused /loop, else ``('', '', '')``.
``line`` is the expanded dock row (``↻ loop · every 5m · 2 runs · next in 4m · <prompt>``),
``badge`` the collapsed heading's count fragment (``↻ loop next in 4m``). Same rule as :func:`goal_line`:
only the manager the CLI already holds is read, never state.db.
"""
mgr = getattr(cli, "_loop_manager", None)
# One read of ``state``: the input thread's ``/loop stop`` sets it to None under this spinner-thread poll.
s = mgr.state if mgr is not None and mgr.session_id == getattr(cli, "session_id", None) else None
if s is None or s.status not in {"active", "paused"}:
return "", "", ""
runs = f"{s.ticks_fired}/{s.times} runs" if s.times else f"{s.ticks_fired} run{'s' if s.ticks_fired != 1 else ''}"
if s.status == "paused":
reason = f" · {s.paused_reason}" if s.paused_reason else ""
return "⏸ loop paused", f"⏸ loop paused{reason} · {runs} · {s.prompt}", s.prompt
if s.awaiting_response:
when = "running now"
else:
remaining = s.next_due_at - (time.time() if now is None else now)
when = f"next in {countdown(remaining)}" if remaining > 0 else "due, waiting for idle"
return f"↻ loop {when}", f"↻ loop · {s.cadence_label()} · {runs} · {when} · {s.prompt}", s.prompt
def _prompt_text(item) -> str:
if isinstance(item, tuple): # (text, images) from an image-attached submit
item = item[0]
# TimelineNotification carries a compact title; voice/seeded sentinels wrap their text.
text = getattr(item, "display_text", None) or getattr(item, "text", None) or str(item)
return " ".join(str(text).split())
def queued_prompts(cli) -> list[str]:
"""One-line previews of the turns waiting in ``_pending_input``, oldest first."""
from hermes_cli.gateway_tools_notify import gateway_store
owned: list = []
store = gateway_store(cli) if getattr(cli, "session_id", None) else None
if store is not None:
# The gateway's FIFO runs first (#134889 N5): its queued admissions as the owner last published them.
from hermes_cli.gateway_queue import published_queue
owned = published_queue(cli, store)
pending = getattr(cli, "_pending_input", None)
if pending is None:
return [text for text in map(_prompt_text, owned) if text]
with pending.mutex:
items = list(pending.queue)
return [text for text in map(_prompt_text, owned + items) if text]