Files
hermes-agent/hermes_cli/cli_tui_runtime_mixin.py
Teknium 399d956903 feat(bot_desktop): Bot Screen, computer_use and the browser run inside the terminal backend (#121169)
* feat(docker): publish nousresearch/hermes-sandbox:desktop for terminal backends

The terminal backends (docker, modal, daytona, singularity) all default to
nikolaik/python-nodejs:python3.11-nodejs20, a bare Python+Node base. For Bot
Screen, computer_use and the browser to run INSIDE that sandbox instead of on
the gateway host, the sandbox image needs the display stack.

docker/sandbox-desktop.Dockerfile is that base plus:
  - the everyday tools it lacked (jq, ripgrep, fd, tmux, less, nano, vim,
    zip, rsync, tree, procps, htop, sudo for the base's uid-1000 `pn`)
  - the exact package set the Hermes -desktop image installs (TigerVNC,
    Xfce components, dbus, xauth, fonts)
  - Playwright's headed Chromium (same build as the -desktop image)
  - cua-driver 0.28.2 from its pinned release tarball

No Hermes inside; the default user stays root like the base so nothing
changes for people who just switch docker_image. Desktop processes run as
`pn`. 4.27 GB on amd64.

docker.yml gains a `sandbox` variant with its own cache scope and repository
(nousresearch/hermes-sandbox:desktop, :main-desktop, :<release>-desktop);
the docker-integration suite is skipped for it (no Hermes to test) and
docker/sandbox-desktop-smoke.sh runs instead: as `pn`, every launcher and
cua-driver binary resolves, the real launcher.sh publishes :20, the RFB
socket completes the 3.8 handshake relayed over `docker exec -i` stdio, and
a headed Chromium maps a window on that display. hadolint lints the new
Dockerfile in docker-lint.yml.

* feat(docker): sandbox desktop base on python3.13-nodejs26

Matches the Hermes image (Python 3.13 / Node 26) and the top of requires-python;
the default docker_image tag it inherited was Python 3.11 / Node 20. Same pn
uid 1000, Debian 13; smoke (launcher, RFB relay, headed Chromium) passes.

* feat(docker): bake agent-browser into hermes-sandbox:desktop

The browser tools drive the agent-browser CLI; when the browser follows the
terminal backend that CLI has to exist inside the sandbox. Pinned to the same
^0.26.0 range the gateway resolves, --ignore-scripts like the gateway's npx path.

* feat(bot_desktop): the screen, computer_use and the browser follow the terminal backend

A user who sandboxes `terminal` (docker/ssh/singularity) had the agent's
screen, cua-driver and Chromium running on the gateway HOST beside that
sandbox: Bot Screen gave a headless host a display, the Xfce panel carries
xfce4-terminal, and `computer_use` could open a shell outside the boundary
the sandbox exists for.

Now the desktop lives where the terminal lives:

- tools/environments/streams.py: one primitive per spawn-per-call backend, the
  local argv prefix that runs its remainder inside the sandbox with stdio open
  (`docker exec -i`, `ssh`, `apptainer exec`). SDK backends (modal, daytona,
  vercel) have none and report so.
- tools/bot_desktop/sandbox_host.py: launcher.sh runs inside the sandbox as
  the image's `pn`; the pane's RFB bytes ride a 12-line python relay over that
  prefix; `cua-driver mcp` is the prefix + the sandbox image's own driver.
- tools/bot_desktop/placement.py + `bot_desktop.placement` (auto|terminal|
  gateway). `auto` follows the backend; a sandbox that cannot host a screen
  REFUSES with the opt-in named instead of silently using the host.
- runtime.start/stop/status/published_env branch on placement; the pane,
  lease, epoch fencing and CLI are unchanged.
- cua_backend: the MCP invocation is the sandbox one when the screen is
  there; the host driver's runtime contract is irrelevant then; check_fn is
  true under a terminal placement without a host binary.
- browser_tool_session: agent-browser invocations are wrapped in the prefix
  with the daemon, socket dir and profile inside the sandbox; screenshots are
  fetched back so MEDIA: paths keep working; recycle closes the sandbox
  daemon.
- web_routers/display.py: the bridge pumps a relay's stdio when the screen is
  in a sandbox, a unix socket otherwise.

Live on docker with nousresearch/hermes-sandbox:desktop: start/observe/RFB
handshake through the dashboard bridge, human takeover fences the agent
(HumanHasControl) and keystrokes reach the sandbox Xvnc, handback restores,
three start/stop rounds leave zero desktop processes; computer_use capture
and list_windows see only the sandbox's Xfce; browser_navigate/snapshot/
vision run with Chromium and agent-browser inside the container and zero
host processes on the bot profile; modal + auto refuses naming the opt-in.

* feat(desktop): Screen pane shows where a sandbox-placed screen runs; Install is host-only

DesktopStatus gains placement ('gateway' | 'terminal:<backend>'). A sandbox
image lacking the stack is a blocker naming hermes-sandbox:desktop, shown in
place of Start; install_command stays None there because the pane's Install
button runs the package manager on the gateway host, the wrong machine, and
display.install refuses for the same reason. The pane header carries
'Screen runs inside the docker sandbox, with the terminal' (4 locales).

* fix(bot_desktop): "is the screen in the sandbox" is a disk check on hot paths, never a config read

Every browser command and CUA spawn asked in_sandbox(), which resolves placement by
loading config, which initializes HERMES_HOME. Under a test's fake home that raised
HomeInitializationError from _run_browser_command; on a real host it read config per
click. Hot paths now ask sandbox_screen_running(): the start marker on disk, written
only by a sandbox start. Policy (in_sandbox) stays for start/install, where config is
the question. display.observe gates on "an RFB endpoint exists" for either placement.

* test(moa): late-accounting sink test asserts the wedged slot's row, not sink order

Under CI load the poll loop can see the interrupt before collecting the fast slot, so the
fast slot also arrives late and first; the test then failed on sink_calls[0]. The
contract is that the wedged slot's real usage reaches the sink.

* chore: retrigger CI (zero-job dispatch failure, auto-heal)

* fix(bot_desktop): a sandbox that died under a live screen fails loudly, never falls to the host

sandbox_screen_running() drops a start marker whose terminal environment is no longer
registered (stale after a process restart). When the environment object outlives its
container, the browser's sandbox wrap now checks the published DISPLAY and raises
"the screen inside the terminal backend's sandbox is gone; start it again" instead of
KeyError('AGENT_BROWSER_PROFILE'). Live: fresh sandbox navigate ok; docker rm -f the
container; next navigate returns that error; zero host Chromium either way.

* feat(terminal): nousresearch/hermes-sandbox:desktop is the default container sandbox

Every container backend (docker, modal, daytona, singularity) now defaults to the
sandbox image with the desktop stack, so Bot Screen, computer_use and the browser
run inside the sandbox for everyone who never chose an image; Python 3.13 / Node 26
match the Hermes image. One constant (DEFAULT_SANDBOX_IMAGE) replaces six copies of
the old literal. Migration 47 moves saved configs still holding the OLD default and
never touches an image the user pinned. Docker reuse recreates a container built
from another image, or the flip would silently never take effect for anyone with a
persisted container (live: old container removed, new one on 3.13 / Node 26 with
Xvnc, cua-driver, agent-browser present).

* chore: retrigger CI (zero-job dispatch failure)

* chore: retrigger CI (zero-job dispatch failure, auto-heal)

* chore: retrigger CI (zero-job dispatch failure, auto-heal)

* chore(config): template stamps v47 and shows the new default sandbox image

The template is what install.sh / docker / doctor --fix seed; a stamp behind
DEFAULT_CONFIG makes every fresh install migrate on first run.

* feat(sandbox): the default image change is a decision, not a surprise

A persisted Docker sandbox on another image is kept when docker_image is unset;
only a written docker_image (an explicit pin) recreates it. The pin verdict
travels as TERMINAL_DOCKER_IMAGE_PINNED through both terminal bridges (process
env and per-profile scope) and the container-config allowlist.

Approval surfaces, all through hermes_cli.sandbox_image_switch: the interactive
CLI asks once at startup (y = pin the new image, n = pin the current one, Enter =
ask later); the Screen pane shows the same choice with Switch / Keep buttons via
display.switchSandboxImage; `hermes config set terminal.docker_image …` is the
same answer from any shell. Gateways and cron never decide: they keep the sandbox
and log the notice.

Migration 47 now unsets a saved image equal to the OLD default instead of
rewriting it to the new one — that value was the template copied, not a pin, and
rewriting it would have made the runtime recreate existing sandboxes unasked.

Modal restores its snapshot and Daytona reuses its labeled sandbox regardless of
the configured image, so existing sandboxes there were already untouched.

* fix(config): both plain defaults that preceded the desktop sandbox image are template copies

main pinned nikolaik/python-nodejs:python3.14-nodejs22 (cd0f97f833) without a migration;
a saved config holding either literal is unset by migration 47, so it follows the default
and existing sandboxes get the keep-or-switch decision instead of a silent recreate.

* ci(docker): build the sandbox image on release/dispatch, not every main push

Leaves docker.yml exactly as on main. The sandbox image carries no Hermes code,
so two 4 GB multi-arch builds per merge bought nothing. sandbox-image.yml builds
and smokes on a PR that edits its own Dockerfile/smoke, and publishes only on a
release or a manual dispatch with publish=true. Stable tag stays :desktop.

* fix(config): keep main's config.py/config_defaults.py edits under the sandbox-image delta

The rebase resolved both files wholesale with the branch side, dropping main's move to
hermes_yaml (the 3.14 runtime venv has no PyYAML) and the 3.14 base pin. This is main's
version plus exactly the branch's own changes: DEFAULT_SANDBOX_IMAGE, the pin verdict in the
env bridge, placement defaults and the v47 stamp.

* test: sandbox-image tests read config.yaml through hermes_yaml (no PyYAML on the 3.14 runtime)

* fix(bot_desktop): read the sandbox marker BOM-tolerantly (windows footgun lint)

* fix(bot_desktop): placement is the authority; sandbox screen survives restarts

Review findings on the sandbox-hosted Bot Screen, each reproduced live first.

Authority. The browser preflight and the CUA invocation keyed off screen
LIVENESS, so `placement: terminal` with the screen not yet up handed the tool an
unchanged host command. `runtime.tool_placement()` is now the one resolver:
terminal placement starts the sandbox screen on demand (no auto_start opt-in
inside the user's own sandbox), refused placement raises its reason, and neither
ever yields the host. placement.resolve() answers a local backend from env alone
so the common case costs no config load on the spawn path.

Restart. sandbox_screen_running() deleted the marker whenever the process-local
terminal registry was empty, i.e. after every gateway restart, while Xvnc kept
running in the container; stop() then returned False and left it. The marker
now records the owning container; liveness comes from `docker inspect` on it,
stop/status re-attach to the recorded owner (even after the placement setting
moved), and only a container that is gone drops the marker.

SSH. remote_argv emitted `bash -c <script>` as three words; OpenSSH joins them
and the remote login shell ran `bash -c export` and the rest itself. The script
travels as one quoted word for ssh (remote_command knows the backend); docker
and apptainer keep argv.

CDP reach. agent-browser inside the sandbox reports the sandbox's loopback;
the Browser Use harness, browser_exec and the vault supervisor connect from the
host and got connection refused. streams.forward_port() proxies a local port
over the exec stream (same relay as the RFB bridge) and the CDP URL is rewritten
to the local end.

pids limit. --pids-limit 256 counts threads; measured on the desktop image the
desktop stack is 44, one Chromium tab 212, the agent's browser with two tabs
488. Past the cap every further docker exec died with "procReady not received".
Default is 2048 with the measurements in the comment.

Replacement. An approved image switch force-removed the old container before
`docker run` tried the new image; a private tag or registry outage left nothing.
The image is inspected/pulled first and the old container kept on failure.

Desktop integration. The sandbox start never passed the dock's browser launcher
(no Browser icon) and the thumbnail needed a host launcher pid + host ImageGrab
(always None). The dock runs the sandbox's Playwright Chromium on the shared
profile; the thumbnail is grabbed inside the sandbox (Pillow baked into the
image). The browser profile moves from /tmp — a 512 MB tmpfs emptied on every
container stop — to the desktop user's home, so logins follow the container.

Pin provenance. A TERMINAL_DOCKER_IMAGE written in a routed profile's .env is a
pin even when it spells the default; the scope compared values before.

* docs(bot-screen): no literal tmp path in the profile-location note

* fix(bot_desktop): docker inspect liveness probe closes stdin (TUI subprocess guard)

* fix(bot_desktop): adopting a screen the sandbox kept records the marker

Live ssh probe: after the host's state was lost while the sandbox kept its
Xvnc, start() took the idempotent early return (display already published)
and never wrote the host marker, so status/thumbnail/stop lost the screen.
Record the adopted display like a fresh launch.

Docs: what an ssh host of your own must carry, and why a Dockerfile ENV is
not enough for a login session (PLAYWRIGHT_BROWSERS_PATH via /etc/environment).

* docker(sandbox-desktop): login sessions find the browser (PLAYWRIGHT_BROWSERS_PATH via /etc/environment)

* docs(bot-screen): what the Apptainer path inherits from the image and what it does not

* chore(config): sandbox-image migration is 47→48 (main took 47 for compression.threshold_tokens)

* chore: retrigger CI (zero-job dispatch failure, auto-heal)
2026-09-28 03:34:07 -07:00

511 lines
26 KiB
Python

"""Classic-TUI run loop phases: input processing, after-turn bookkeeping, startup banner/prewarm/maintenance, prompt_toolkit application build, signal handlers and shutdown.
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO.
"""
from __future__ import annotations
import errno
import logging
import os
import queue
import shutil
import sys
import threading
import time
from agent.interrupt_compat import request_hard_interrupt
from agent.pet import render as pet_render
from contextlib import suppress
from hermes_constants import get_hermes_home
from prompt_toolkit.application import Application
from rich.markup import escape as _escape
# Log-record parity with the origin module.
logger = logging.getLogger("cli")
class CLITuiRuntimeMixin:
"""Classic-TUI run loop phases: input processing, after-turn bookkeeping, startup banner/prewarm/maintenance, prompt_toolkit application build, signal handlers and shutdown."""
def _tui_process_loop(self):
"""REPL worker thread: drain ``_pending_input``, run idle housekeeping, dispatch each input."""
while not self._should_exit:
try:
try:
user_input = self._pending_input.get(timeout=0.1)
except queue.Empty:
if not self._agent_running:
self._tui_idle_tick()
continue
self._tui_process_one_input(user_input)
except Exception as e:
if isinstance(e, OSError) and e.errno == errno.EIO:
self._mark_terminal_io_broken("process_loop")
logger.warning("process_loop EIO — freezing UI paints (#81521): %s", e)
continue
logger.warning("process_loop unhandled error (msg may be lost): %s", e)
def _tui_idle_tick(self):
"""Idle housekeeping between inputs (agent not running)."""
self._check_config_mcp_changes() # auto-reload MCP on mcp_servers change
# Termios drift heal first: a drifted tty makes the CLI look dead while the loop is healthy.
for step in (
self._check_termios_drift,
lambda: self._drain_process_notifications("cli-idle"),
self._maybe_fire_loop_tick,
self._maybe_resume_parked_goal,
):
with suppress(Exception):
step()
def _tui_process_one_input(self, user_input):
"""Route one submitted input: file drop, /resume pick, ! shell, slash command, or a chat turn."""
from cli import _DIM, _PASTE_REF_RE, _RST, _cprint, _detect_file_drop, _looks_like_slash_command, _strip_leaked_bracketed_paste_wrappers, _strip_leaked_terminal_responses_with_meta
from tools.process_registry_notifications import TimelineNotification
user_input, is_voice_input, is_seeded_query = self._tui_unwrap_input(user_input)
if not user_input:
return
notification_preview = user_input if isinstance(user_input, TimelineNotification) else None
self._status_bar_suppressed_after_resize = False # input ends post-resize suppression
submit_images = []
if isinstance(user_input, tuple):
user_input, submit_images = user_input
if isinstance(user_input, str):
user_input = _strip_leaked_bracketed_paste_wrappers(user_input)
user_input, _had_mouse_reports = _strip_leaked_terminal_responses_with_meta(user_input)
if _had_mouse_reports:
self._recover_terminal_input_modes(reason="mouse reports leaked into submitted input")
# A typed bare stop phrase ends an active voice chat (transcripts are checked earlier).
if not is_voice_input and self._typed_voice_stop(user_input):
return
# File drops are detected before any dispatch; seeded -q prompts are literal text.
_file_drop = _detect_file_drop(user_input) if isinstance(user_input, str) and not is_seeded_query else None
if _file_drop:
_drop_path = _file_drop["path"]
_remainder = _file_drop["remainder"]
if _file_drop["is_image"]:
submit_images.append(_drop_path)
user_input = _remainder or f"[User attached image: {_drop_path.name}]"
_cprint(f" 📎 Auto-attached image: {_drop_path.name}")
else:
_cprint(f" 📄 Detected file: {_drop_path.name}")
user_input = f"[User attached file: {_drop_path}]" + (f"\n{_remainder}" if _remainder else "")
elif isinstance(user_input, str):
# A bare number right after a bare `/resume` selects that session (never sent to the agent).
if self._pending_resume_sessions and self._consume_pending_resume_selection(user_input):
return
if not is_seeded_query:
if self.handle_bang_shell(user_input):
return
if _looks_like_slash_command(user_input):
user_input = self._tui_run_slash_input(user_input)
if user_input is None:
return
if isinstance(user_input, str) and _PASTE_REF_RE.search(user_input):
user_input = self._expand_paste_references(user_input)
_cprint("")
self._print_user_message_preview(notification_preview or user_input)
if submit_images:
n = len(submit_images)
_cprint(f" {_DIM}📎 {n} image{'s' if n > 1 else ''} attached{_RST}")
self._agent_running = self._interactive_turn = True
self._pet_turn_error = self._pet_reasoning = False
self._turn_summary_begin()
self._app.invalidate()
try:
self.chat(notification_preview or user_input, images=submit_images or None, voice_input=is_voice_input)
finally:
self._tui_after_turn()
def _tui_run_slash_input(self, user_input: str):
"""Dispatch a slash command. Returns the pending agent seed to run as a chat turn, else None."""
from cli import _cprint
_cprint(f"\n⚙️ {user_input}")
try:
if not self.process_command(user_input):
self._should_exit = True
if self._app.is_running:
self._app.exit()
except KeyboardInterrupt:
# Ctrl+C during a slow slash command returns to the prompt instead of exiting.
_cprint("\n[dim]Command interrupted.[/dim]")
return None
_seed, self._pending_agent_seed = self._pending_agent_seed, None
return _seed or None
def _tui_after_turn(self):
"""Post-turn bookkeeping after chat() returns (normal, error, or interrupt)."""
from cli import _DIM, _RST, _cprint
self._agent_running = self._pet_reasoning = False
self._spinner_text = self._last_scrollback_tool = ""
self._tool_start_time = 0.0
self._pending_tool_info.clear()
self._pet_react_turn_end()
self._turn_summary_emit()
self._interactive_turn = False
self._app.invalidate()
# After an interrupt the renderer may have drifted (leaked CPR text, VT100 parser
# stalled mid-escape): drain stray bytes and force a clean redraw.
if self._last_turn_interrupted:
self._recover_terminal_after_interrupt()
# Re-queue any messages that arrived in _interrupt_queue while the agent was running and were never
# claimed by the explicit interrupt path. See _drain_interrupt_queue_to_pending_input for the full
# rationale. Regression of #17666 / #18760 — the drain block from the original PR #17939 was
# deferred as "worth its own review" and never re-landed (#20271).
self._drain_interrupt_queue_to_pending_input()
# /goal continuation (queued user input still preempts), then /loop tick completion.
for hook, what in (
(self._maybe_continue_goal_after_turn, "goal continuation"),
(self._maybe_complete_loop_tick_after_turn, "loop completion"),
):
try:
hook()
except Exception as _exc:
logging.debug("%s hook failed: %s", what, _exc)
# Continuous voice: restart recording off-thread (beep + recorder start would block process_loop).
if self._voice_mode and self._voice_continuous and not self._voice_recording:
def _restart_recording():
try:
if self._voice_tts:
self._voice_tts_done.wait(timeout=60)
time.sleep(0.3)
# A barge-in capture already owns the mic and submits the interruption itself.
if self._voice_barge_capture.is_set():
return
self._voice_start_recording()
self._app.invalidate()
except Exception as e:
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()
with suppress(Exception):
self._drain_process_notifications("cli-post-turn")
def _tui_signal_handler(self, signum, frame):
"""SIGHUP/SIGTERM -> graceful shutdown.
The agent is hard-interrupted first so its daemon thread can kill the tool's
setsid subprocess group before the main thread unwinds (else an orphan child).
``logger.debug`` is guarded: logging is not reentrant-safe and a shutdown race
can raise ``KeyError`` inside the handler, bypassing prompt_toolkit's unwind.
"""
from cli import _arm_exit_watchdog_on_shutdown_signal, _interrupt_agent_for_signal
with suppress(Exception):
logger.debug("Received signal %s, triggering graceful shutdown", signum)
# Arm the backstop IMMEDIATELY: if the unwind wedges, _run_cleanup never arms its own.
# Shutdown intent is now unambiguous — arm the exit backstop IMMEDIATELY, before the graceful unwind
# below. If any step of that unwind wedges (main thread parked in a syscall, prompt_toolkit teardown
# never returning), _run_cleanup never runs and would never arm its own watchdog — leaving a "dead"
# CLI alive for minutes (#65998 class).
# Arm the exit backstop now that shutdown intent is unambiguous — covers wedges in the unwind below
# that would otherwise leave the process alive with no watchdog (#65998 class).
_arm_exit_watchdog_on_shutdown_signal()
if self._agent_running:
_interrupt_agent_for_signal(self.agent, signum)
# Prefer app.exit() over raising KeyboardInterrupt: a KBI from a signal handler
# lands in a pt Task ("Unhandled exception in event loop" + "Press ENTER to
# continue..."); call_soon_threadsafe lets the loop unwind normally.
try:
from prompt_toolkit.application.current import get_app_or_none
_app = get_app_or_none()
_loop = getattr(_app, "loop", None)
if _loop is not None:
_loop.call_soon_threadsafe(_app.exit)
return # clean unwind — no traceback, no ENTER pause
except Exception:
pass
raise KeyboardInterrupt() # fallback for non-prompt_toolkit contexts
def _tui_print_startup(self):
"""Startup output: light-mode probe, banner, advisories, resume/welcome lines, tips."""
from cli import _accent_hex, _detect_light_mode
with suppress(Exception): # light-mode probe before pt grabs the tty (cached)
_detect_light_mode()
# Scroll the cursor to the last row so banner, responses and prompt pin to the bottom.
with suppress(Exception):
_term_lines = shutil.get_terminal_size().lines
if _term_lines > 2:
print("\n" * (_term_lines - 1), end="", flush=True)
self.show_banner()
self._show_security_advisories()
self._show_browser_backend_notice()
# First-run: an unconfigured install routes into provider onboarding instead of
# a chat that spins ~30s and fails with a provider-specific error. TTY only. A
# configured profile whose credential is benched or signed out gets the reason instead.
try:
self._maybe_offer_first_run_setup()
except Exception:
logger.debug("first-run setup offer failed", exc_info=True)
# A persisted Docker sandbox on the previous default image is kept until the user says
# so; this TTY is where they can. Either answer pins an image, so it is asked once.
if sys.stdin.isatty():
try:
from hermes_cli.sandbox_image_switch import offer_interactive
from cli import _cprint
offer_interactive(cprint=_cprint)
except Exception:
logger.debug("sandbox image switch offer failed", exc_info=True)
if self._resumed and self._preload_resumed_session():
self._display_resumed_history()
_welcome_skin = None # stays None when the skin engine failed
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
_welcome_color = "#FFF8DC"
try:
from hermes_cli.skin_engine import get_active_skin
_welcome_skin = get_active_skin()
_welcome_text = _welcome_skin.get_branding("welcome", _welcome_text)
_welcome_color = _welcome_skin.get_color("banner_text", _welcome_color)
except Exception:
pass
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
self._tui_startup_prewarm_and_warnings(_welcome_skin)
self._print_random_tip()
self._tui_startup_background_maintenance()
# Before the background preload is folded in (at agent init), show the REQUESTED names.
_skills_for_line = self.preloaded_skills or list(self._preload_skills_requested or [])
if _skills_for_line and not self._startup_skills_line_shown:
self._console_print(f"[bold {_accent_hex()}]Activated skills:[/] {', '.join(_skills_for_line)}")
self._startup_skills_line_shown = True
self._console_print()
def _tui_startup_prewarm_and_warnings(self, _welcome_skin):
"""Idle-window prewarms (picker cache, agent runtime imports) plus the redaction-off and OpenClaw-residue banners."""
# Warm the /model picker cache off-thread (else its first open blocks ~1-2s).
with suppress(Exception):
from hermes_cli.model_switch_providers import prewarm_picker_cache_async
prewarm_picker_cache_async()
# Pre-import the agent runtime (~1.5s: run_agent + OpenAI SDK) off-thread; the import
# lock makes an early submit block on the remaining work rather than redo it.
# Skipped when Termux defers agent startup on purpose.
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
def _prewarm_agent_runtime() -> None:
try:
import run_agent # noqa: F401 (imports model_tools + tool registry)
import openai # noqa: F401
except Exception:
logger.debug("agent runtime pre-import failed", exc_info=True)
threading.Thread(target=_prewarm_agent_runtime, name="agent-runtime-prewarm", daemon=True).start()
# Redaction is ON by default; be loud when the operator turned it off.
with suppress(Exception):
# The redactor snapshots its state at import time so any toggle now won't affect the running
# process — we just want the operator to see that they're running without the safety net. See
# #17691.
_redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true")
if _redact_raw.lower() not in {"1", "true", "yes", "on"}:
self._console_print(
"[bold red]⚠ Secret redaction is DISABLED[/] "
f"(HERMES_REDACT_SECRETS={_redact_raw}). "
"API keys and tokens may appear verbatim in chat output, "
"session JSONs, and logs. Set "
"[cyan]security.redact_secrets: true[/] in config.yaml "
"to re-enable."
)
# One-time banner when ~/.openclaw/ is left over from a migration.
try:
from agent.onboarding import (
OPENCLAW_RESIDUE_FLAG, detect_openclaw_residue, is_seen, mark_seen, openclaw_residue_hint_cli,
)
if not is_seen(self.config, OPENCLAW_RESIDUE_FLAG) and detect_openclaw_residue():
try:
_resid_color = _welcome_skin.get_color("banner_dim", "#B8860B")
except Exception:
_resid_color = "#B8860B"
self._console_print(f"[{_resid_color}]{openclaw_residue_hint_cli()}[/]")
try:
from hermes_cli.config import get_config_path as _get_cfg_path_resid
mark_seen(_get_cfg_path_resid(), OPENCLAW_RESIDUE_FLAG)
except Exception:
pass # banner fires again next session
except Exception:
pass
def _tui_startup_background_maintenance(self):
"""Best-effort startup passes: curator skill maintenance, personal + org skill sync.
Off the main thread: the curator's deterministic pass snapshots and prunes the whole
skills tree (a due weekly pass held the prompt for 6 minutes on a large library), and
the sync pulls can hit the network. The REPL must never wait on housekeeping."""
threading.Thread(target=self._run_startup_maintenance, name="startup-maintenance", daemon=True).start()
def _run_startup_maintenance(self):
with suppress(Exception):
from agent.curator import maybe_run_curator
maybe_run_curator(
idle_for_seconds=float("inf"), # CLI startup = fully idle
on_summary=lambda msg: self._console_print(f"[dim #6b7684]💾 {msg}[/]"),
)
# Skill sync (personal, then org-shared): inert unless the access gate is open
# and a sync base URL is configured. The org pull is gated on a real org role on
# the token (only issued for multi-member orgs), so a solo account never hits
# the network here. Both fail-quiet.
try:
from tools.skills_sync_client import maybe_pull_skills
from tools.skills_sync_client_org import maybe_pull_org_skills
except Exception:
return
for pull in (maybe_pull_skills, maybe_pull_org_skills):
with suppress(Exception):
pull()
def _tui_build_application(self, layout, kb, style):
"""Construct the prompt_toolkit Application for the REPL."""
from cli import CLI_CONFIG, EditingMode, _STEADY_CURSOR, _select_classic_cli_pt_output
_cpr_disabled_output = _select_classic_cli_pt_output(sys.stdout)
# Kitty placeholders encode the image id in exact foreground RGB, so the whole app
# runs 24-bit there. ColorDepth is imported lazily for tests that stub prompt_toolkit.
extra_kw = {}
if pet_render.supports_kitty_placeholders():
from prompt_toolkit.output import ColorDepth
extra_kw["color_depth"] = ColorDepth.DEPTH_24_BIT
if _cpr_disabled_output is not None:
extra_kw["output"] = _cpr_disabled_output
if _STEADY_CURSOR is not None:
extra_kw["cursor"] = _STEADY_CURSOR
if EditingMode is not None:
# Vi editing mode when display.vim_mode is on.
# EMACS is prompt_toolkit's own default, so non-opted-in behaviour is unchanged.
extra_kw["editing_mode"] = EditingMode.VI if self._vim_mode else EditingMode.EMACS
return Application(
layout=layout,
key_bindings=kb,
style=style,
full_screen=False,
mouse_support=False,
# 0 (default) avoids fighting terminal auto-scroll in non-fullscreen mode.
refresh_interval=float(CLI_CONFIG.get("display", {}).get("cli_refresh_interval", 0)),
# Erase the bottom chrome on exit instead of freezing a copy into scrollback.
# Without this, prompt_toolkit's render_as_done teardown repaints the chrome one last time and
# leaves it stranded above the exit summary — so a dead status bar + empty prompt sit between
# the conversation transcript and the "Resume this session" block, and stack with the next
# session's UI on resume (#38252). The actual conversation transcript is printed through
# patch_stdout into normal scrollback and is unaffected; only the managed chrome is erased.
# Applies to every exit path (/exit, /quit, EOF, Ctrl+C).
erase_when_done=True,
**extra_kw,
)
def _tui_install_signal_handlers(self):
"""SIGTERM/SIGHUP -> graceful shutdown; Windows absorbs SIGINT (see body)."""
try:
import signal as _signal
_signal.signal(_signal.SIGTERM, self._tui_signal_handler)
if hasattr(_signal, 'SIGHUP'):
_signal.signal(_signal.SIGHUP, self._tui_signal_handler)
# Windows: absorb SIGINT. Win32 delivers spurious CTRL_C_EVENT when children spawn
# from background threads, which would unwind app.run() mid-turn. Real Ctrl+C is
# bound by prompt_toolkit. Never call agent.interrupt() here (fake user message).
if sys.platform == "win32":
_signal.signal(_signal.SIGINT, lambda signum, frame: None)
except Exception:
pass # restricted environments
def _tui_stdin_usable(self) -> bool:
"""Validate fd 0 before prompt_toolkit starts; on macOS fall back to a select() loop when kqueue can't watch it (uv-managed Python)."""
try:
os.fstat(0)
except OSError:
print(
"Error: stdin (fd 0) is not available.\n"
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n"
"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup"
)
return False
if sys.platform == "darwin":
import selectors as _selectors
try:
if hasattr(_selectors, "KqueueSelector"):
_kq = _selectors.KqueueSelector()
try:
_kq.register(0, _selectors.EVENT_READ)
_kq.unregister(0)
finally:
_kq.close()
except (OSError, ValueError, KeyError):
import asyncio as _aio_probe
class _SelectEventLoopPolicy(_aio_probe.DefaultEventLoopPolicy):
def new_event_loop(self):
return _aio_probe.SelectorEventLoop(_selectors.SelectSelector())
_aio_probe.set_event_loop_policy(_SelectEventLoopPolicy())
return True
def _tui_shutdown(self):
"""Teardown after the app exits: interrupt agent, stop voice/pet, persist + close session, cleanup, exit summary."""
from cli import _DIM, _RST, _cprint, _invoke_interrupted_session_end, _run_cleanup, set_approval_callback, set_secret_capture_callback, set_sudo_password_callback
self._should_exit = True
self._pet_stop_anim()
# Without this line the terminal sits silent through the whole cleanup window.
with suppress(Exception):
print(f"{_DIM}Shutting down… (finalizing session){_RST}", flush=True)
if self.agent and self._agent_running:
with suppress(Exception):
request_hard_interrupt(self.agent)
if self._voice_recorder:
with suppress(Exception):
self._voice_recorder.shutdown()
self._voice_recorder = None
with suppress(Exception):
from tools.voice_mode import cleanup_temp_recordings
cleanup_temp_recordings()
from agent.vault_backends.unlock import (lock as _vault_lock, set_code_prompt_callback,
set_save_login_prompt_callback, set_unlock_prompt_callback)
for _unset in (set_sudo_password_callback, set_approval_callback, set_secret_capture_callback,
set_unlock_prompt_callback, set_save_login_prompt_callback, set_code_prompt_callback):
_unset(None)
_vault_lock() # session tokens for external password managers die with the session
# On SIGHUP/SIGTERM the agent thread may be reaped before its own persistence runs.
self._persist_active_session_before_close()
if self._session_db and self.agent:
try:
self._session_db.end_session(self.agent.session_id, "cli_close")
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not close session in DB: %s", e)
if not self._delete_session_on_exit:
# Drop the empty row of a start-and-quit session so /resume stays clean.
try:
self._discard_session_if_empty(self.agent.session_id)
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not prune empty session: %s", e)
else:
# /exit --delete: remove transcripts + SQLite history.
try:
_sid = self.agent.session_id
if self._session_db.delete_session(_sid, sessions_dir=get_hermes_home() / "sessions"):
_cprint(f" {_DIM}✓ Session {_escape(_sid)} deleted{_RST}")
else:
_cprint(f" {_DIM}✗ Session {_escape(_sid)} not found for deletion{_RST}")
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not delete session on exit: %s", e)
# run_conversation() fires on_session_end on normal completion; only fire here mid-turn.
if self.agent and self._agent_running:
_invoke_interrupted_session_end(self.agent, self.agent.session_id, "shutdown")
_run_cleanup()
self._print_exit_summary()
self._release_active_session()