Files
hermes-agent/cli.py
Teknium 9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales

* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter

ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.

* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs

* chore(tui): split en catalog siblings by lane (slash sibling)

* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports

* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts

* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list

* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes

* feat(plugins): report language-pack layers in the mid-run activation summary

* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n

StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.

* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter

- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
  the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
  layers partial packs (nested or flat dotted) over bundled/en via
  mergeTranslations; a string over a function-valued en entry becomes a
  positional {0}/{1} formatter; $appLocaleVersion bumps so translators
  re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
  registry as source 'backend' (method-not-found is silent); re-synced on
  socket open, display.language change and profile switch. A saved pack-only
  language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
  tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
  from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).

* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)

Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.

* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()

- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
  ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
  accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
  properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces

* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)

* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()

Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.

locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).

* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()

- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions

* i18n(platforms): route Google Chat and Teams user-facing text through t()

Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.

Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.

* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()

LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.

* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)

Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.

* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)

_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.

* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)

RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.

* i18n(cli): live-work dock, subagent monitor and render copy through t()

cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.

* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()

* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)

get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.

* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output

* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time

Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).

* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)

cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.

* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()

- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
  at call time; category labels via slash.category.*; help/alias/usage suffixes
  via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
  bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
  lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
  status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
  approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
  bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
  heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
  sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
  keeps its English constant; callers use history_unreadable() ->
  gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.

* i18n(telegram): route adapter chat copy through t()

Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.

Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.

* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()

- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor

* i18n(discord): route adapter chat copy through t()

Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().

* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)

* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()

- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
  existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
  via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
  _DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
  Updating/Generating) are one full template per variant; plurals use
  <key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
  translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).

* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English

* test(i18n): pin Telegram/Discord adapter catalog wiring

Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.

* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge

* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint

* i18n(tr): translate bundled catalog + tui pack

* i18n(ja): translate bundled catalog + tui pack

* i18n(ko): translate bundled catalog + tui pack

* i18n(zh): translate bundled catalog + tui pack

* i18n(fr): translate bundled catalog + tui pack

* i18n(af): translate bundled catalog + tui pack

* i18n(uk): translate bundled catalog + tui pack

* i18n(ar): translate bundled catalog + tui pack

* i18n(pt): translate bundled catalog + tui pack

* i18n(it): translate bundled catalog + tui pack

* i18n(es): translate bundled catalog + tui pack

* i18n(zh-hant): translate bundled catalog + tui pack

* i18n(ru): translate bundled catalog + tui pack

* i18n(hu): translate bundled catalog + tui pack

* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)

* i18n(de): translate bundled catalog + tui pack

* i18n(ga): translate bundled catalog + tui pack

* test(i18n): fixture matches _normalize_lang(lang, home) signature

* i18n(tui): scaffold userMessages/slashCmd en siblings

* i18n(tui): wire secure prompts + content tables

* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays

Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.

* i18n(tui): wire slash ops/wake replies

* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)

* i18n(tui): wire slash core/debug/setup replies

* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)

* i18n(tui): wire slash session/topup/subscription replies

* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)

* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys

* i18n(tui): wire userMessages copy through the userMessages namespace

* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json

* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)

Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).

* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)

* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)

* tui: i18n-export-en script (English templates for pack translators)

* docs(i18n): bundled TUI packs are the bottom layer of the tui surface

* i18n(ru): translate TUI pack

* i18n(ar): translate TUI pack

* i18n(es): translate TUI pack

* i18n(pt): translate TUI pack

* i18n(ko): translate TUI pack

* i18n(de): translate TUI pack

* i18n(ja): translate TUI pack

* i18n(fr): translate TUI pack

* i18n(tr): translate TUI pack

* i18n(it): translate TUI pack

* i18n(zh): translate TUI pack

* i18n(zh-hant): translate TUI pack

* i18n(hu): translate TUI pack

* i18n(uk): translate TUI pack

1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.

Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).

* i18n(ga): translate TUI pack

* i18n(af): translate TUI pack

* plugin_guard: locale catalogs in language packs step down the agent-config family

A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.

* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)

* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header

Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.

* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel

- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
  and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
  agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)

* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})

* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property

* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal

* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export

The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.

* test: unbreak two main-red timing tests the PR merge-ref inherits

- test_local_runtime racing fake publishes the modern state record (legacy pid-only
  records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
  a fixed 0.35s, which a loaded CI runner does not always meet

* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)

* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak

SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.

* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings

* chore(i18n): regenerate desktop key catalog after main sync

---------

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-09-28 14:16:18 -07:00

1797 lines
78 KiB
Python

#!/usr/bin/env python3
"""Hermes Agent CLI — interactive terminal interface (``python cli.py --help`` for usage)."""
# Must be the very first import (UTF-8 stdio on Windows). Missing only mid-``hermes update``.
try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError as exc:
if exc.name != "hermes_bootstrap":
raise # the bootstrap exists but cannot load: skipping it would skip PM activation
import logging
import os
import functools
import shutil # noqa: F401 — tests patch shutil/time through the cli facade
import sys
import re
import atexit
import errno
import time # noqa: F401 — see shutil
from collections import deque
from dataclasses import dataclass
from contextlib import contextmanager, suppress
from pathlib import Path
from datetime import datetime # noqa: F401 — siblings import it lazily through cli
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
os.environ["HERMES_QUIET"] = "1" # suppress our modules' startup chatter
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
from hermes_cli.cli_commands_mixin import CLICommandsMixin
from hermes_cli.cli_billing_mixin import CLIBillingMixin
from hermes_cli.cli_loops_mixin import CLILoopsMixin
from hermes_cli.cli_info_mixin import CLIInfoMixin
from hermes_cli.cli_terminal_mixin import CLITerminalMixin
from hermes_cli.cli_modal_mixin import CLIModalMixin
from hermes_cli.cli_stream_mixin import CLIStreamMixin
from hermes_cli.cli_session_mixin import CLISessionMixin
from hermes_cli.cli_model_switch_mixin import CLIModelSwitchMixin
from hermes_cli.cli_voice_mixin import CLIVoiceMixin
from hermes_cli.cli_status_bar_mixin import CLIStatusBarMixin
from hermes_cli.cli_tui_mixin import CLITuiMixin
from hermes_cli.cli_process_notifications import CLIProcessNotificationsMixin
from hermes_cli.cli_init_mixin import CLIInitMixin
from hermes_cli.cli_tui_runtime_mixin import CLITuiRuntimeMixin
# Extracted clusters (mechanical split, #116911); re-exported here so `cli.<name>` stays the seam.
from hermes_cli.cli_shutdown import ( # noqa: F401,E402
_CLEANUP_STEPS,
_arm_exit_watchdog,
_emit_interrupted_session_end,
_exit_watchdog_timeout,
_finalize_single_query,
_float_env,
_flush_logging_and_stdio,
_flush_one_shot_session_store,
_interrupt_async_delegations,
_invoke_interrupted_session_end,
_notify_session_finalize,
_notify_single_query_session_finalize,
_oneshot_agent_and_session,
_should_emit_cleanup_session_finalize,
_shutdown_agent_memory_provider,
_shutdown_cached_aux_clients,
_shutdown_mcp_servers,
_stop_cli_wake_word,
_sync_process_session_id,
_wait_for_oneshot_background_completions,
)
from hermes_cli.cli_auto_maintenance import ( # noqa: F401,E402
_run_checkpoint_auto_maintenance,
_run_state_db_auto_maintenance,
)
from hermes_cli.cli_render import ( # noqa: F401,E402
ChatConsole,
_ACCENT,
_ACCENT_ANSI_DEFAULT,
_BOLD,
_DA1_REPLY_RE,
_DIM,
_FALSE_RE,
_LIGHT_DEFAULT_TERM_PROGRAMS,
_LIGHT_MODE_REMAP,
_LIGHT_MODE_REMAP_UPPER,
_REASONING_TAGS,
_RST,
_STREAM_PAD,
_STREAM_PARTIAL_PREVIEW_LEN,
_SkinAwareAnsi,
_TOOL_CALL_TAGS,
_TRUE_RE,
_WINDOWS_PATH_WITH_DOT_SEGMENT_RE,
_accent_hex,
_add_suspect_rows,
_append_blank_panel_line,
_append_panel_line,
_assistant_content_as_text,
_assistant_copy_text,
_b,
_build_compact_banner,
_clear_output_history,
_cli_visible_print,
_coerce_output_history_limit,
_cprint,
_d,
_detect_light_mode_uncached,
_heal_cooked_mode_drift,
_hex_to_ansi,
_install_skin_light_mode_hook,
_line_rows,
_luminance_from_hex,
_maybe_remap_for_light_mode,
_output_history_lines,
_output_history_recording,
_output_history_rows,
_output_tail_fitting,
_painted_columns,
_PaintedLine,
_panel_box_width,
_post_stream_transform_output,
_prepend_note_to_message,
_preserve_windows_dot_segments_for_markdown,
_pt_app_is_running,
_pt_print_ansi,
_query_osc11_background,
_record_output_history,
_record_output_history_entry,
_release_paints,
_render_final_assistant_content,
_rich_text_from_ansi,
_set_chrome_floor,
_strip_markdown_syntax,
_strip_reasoning_tags,
_terminal_columns,
_terminal_reflows,
_terminal_width_for_streaming,
_tty_wrap,
_wrap_panel_text,
_wrap_panel_text_keep_ws,
)
from hermes_cli.cli_config_load import ( # noqa: F401,E402
_AUXILIARY_TASK_ENV,
_CWD_PLACEHOLDERS,
_TERMINAL_ENV_MAPPINGS,
_cli_config_defaults,
_init_logging_and_display_from_config,
_load_prefill_messages,
_merge_file_config,
_mirror_config_to_env,
_parse_reasoning_config,
_parse_service_tier_config,
_resolve_prefill_messages_file,
load_cli_config,
)
from hermes_cli.cli_terminal_input import ( # noqa: F401,E402
_BACKSLASH_LINE_CONTINUATION_RE,
_DSR_CPR_ESC_RE,
_DSR_CPR_VISIBLE_RE,
_EXTENDED_ENTER_KEYS_SEQ,
_IMAGE_EXTENSIONS,
_KITTY_KEYBOARD_PUSH_SEQ,
_MODIFY_OTHER_KEYS_SEQ,
_SGR_MOUSE_BARE_RE,
_SGR_MOUSE_ESC_RE,
_SGR_MOUSE_VISIBLE_RE,
_TERMINAL_INPUT_MODE_RESET_SEQ,
_apply_backslash_line_continuation,
_apply_bracketed_paste_timeout_patch,
_bind_prompt_submit_keys,
_build_cpr_disabled_output,
_cli_multiline_shortcuts_enabled,
_collect_query_images,
_detect_file_drop,
_disable_prompt_toolkit_cpr_warning,
_enable_extended_enter_keys,
_estimate_tui_input_height,
_file_drop_result,
_format_image_attachment_badges,
_hermes_call_output_screen_diff,
_is_backslash_line_continuation,
_is_ghostty_terminal,
_preserve_ctrl_enter_newline,
_resolve_attachment_path,
_select_classic_cli_pt_output,
_should_auto_attach_clipboard_image_on_paste,
_split_path_input,
_status_bar_visible_from_display_config,
_strip_leaked_terminal_responses_with_meta,
_terminal_may_leak_cpr,
_terminal_supports_extended_enter_keys,
_termux_example_image_path,
)
from hermes_cli.cli_single_query import ( # noqa: F401,E402
_TERMINAL_PROVIDER_REASONS,
_TRANSIENT_PROVIDER_REASONS,
_collect_kanban_task_images,
_configure_quiet_agent,
_install_single_query_signal_handlers,
_int_or,
_interrupt_agent_for_signal,
_route_single_query_images,
_run_kanban_goal_loop_chat,
_run_kanban_goal_loop_q,
_run_quiet_single_query,
_run_single_query_mode,
_single_query_exit_code,
_sync_cli_session_id_from_agent,
)
from prompt_toolkit.patch_stdout import patch_stdout
try:
from prompt_toolkit.enums import EditingMode
except ImportError: # partial prompt_toolkit stubs in tests
EditingMode = None
from prompt_toolkit import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
try:
from prompt_toolkit.cursor_shapes import CursorShape
_STEADY_CURSOR = CursorShape.BLOCK
except (ImportError, AttributeError):
_STEADY_CURSOR = None
try:
from hermes_cli import pt_input_extras as _pt_extras
_pt_extras.install_shift_enter_alias()
_pt_extras.install_ctrl_enter_alias()
_pt_extras.install_cmd_backspace_alias()
_pt_extras.install_modify_other_keys_aliases()
_pt_extras.install_keypress_data_normalization()
_pt_extras.install_ignored_terminal_sequences()
del _pt_extras
except Exception:
pass
import threading
import queue
def _lazy_shim(module: str, name: str, alias: str | None = None):
"""Import ``module.name`` on first call; keeps heavy imports off startup while ``cli.<name>`` stays patchable."""
import importlib
def shim(*args, **kwargs):
return getattr(importlib.import_module(module), name)(*args, **kwargs)
shim.__name__ = shim.__qualname__ = alias or name
return shim
def format_duration_compact(*args, **kwargs):
seconds = float(args[0] if args else kwargs.get("seconds", 0.0))
if seconds < 60:
return f"{seconds:.0f}s"
minutes = seconds / 60
if minutes < 60:
return f"{minutes:.0f}m"
hours = minutes / 60
if hours < 24:
remaining_min = int(minutes % 60)
return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h"
days = hours / 24
return f"{days:.1f}d"
# model id -> shortest configured alias (process-lifetime cache; config is read once).
_REVERSE_ALIAS_CACHE: dict[str, str] | None = None
def _reverse_alias_for_display(model_name: str) -> str:
"""Shortest alias for ``model_name`` from ``model_aliases:`` or ``model.aliases:``, else ``model_name``."""
global _REVERSE_ALIAS_CACHE
if not model_name:
return model_name
if _REVERSE_ALIAS_CACHE is None:
rmap: dict[str, str] = {}
def _put(m: str, alias: str) -> None:
if m and (m not in rmap or len(alias) < len(rmap[m])):
rmap[m] = alias
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
ma = cfg.get("model_aliases")
if isinstance(ma, dict):
for alias, entry in ma.items():
if isinstance(entry, dict):
_put(str(entry.get("model", "") or "").strip(), alias)
mdl = cfg.get("model", {}) or {}
if isinstance(mdl, dict):
simple = mdl.get("aliases")
if isinstance(simple, dict):
for alias, val in simple.items():
if isinstance(val, str) and val.strip():
v = val.strip()
_put(v.split("/", 1)[1] if "/" in v else v, alias)
except Exception:
pass
_REVERSE_ALIAS_CACHE = rmap
return _REVERSE_ALIAS_CACHE.get(model_name, model_name)
def format_token_count_compact(*args, **kwargs):
value = int(args[0] if args else kwargs.get("value", 0))
abs_value = abs(value)
if abs_value < 1_000:
return str(value)
sign = "-" if value < 0 else ""
units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K"))
for threshold, suffix in units:
if abs_value >= threshold:
scaled = abs_value / threshold
text = f"{scaled:.{2 if scaled < 10 else 1 if scaled < 100 else 0}f}"
if "." in text:
text = text.rstrip("0").rstrip(".")
return f"{sign}{text}{suffix}"
return f"{value:,}"
realign_markdown_tables = _lazy_shim("agent.markdown_tables", "realign_markdown_tables")
_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
# ~/.hermes/.env first, project .env as dev fallback; user env files override stale shell exports.
from hermes_constants import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
from agent.i18n import t as _t # noqa: E402
_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
CLI_CONFIG = load_cli_config()
_init_logging_and_display_from_config()
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI client exists: it
# schedules aclose() on the running loop (prompt_toolkit's, during idle), closing
# transports bound to dead worker loops ("Event loop is closed" / "Press ENTER to
# continue..."). A meta_path finder patches ``openai._base_client`` at first import —
# eager import costs ~166ms/30MB cold, and the patch is guaranteed to land before
# instantiation. See ``agent.auxiliary_client.neuter_async_httpx_del``.
try:
import sys as _httpx_neuter_sys
import importlib.util as _httpx_neuter_imp_util
class _AsyncHttpxDelNeuter:
"""Patch ``AsyncHttpxClientWrapper.__del__`` to a no-op when ``openai._base_client`` loads."""
_armed = True
def find_spec(self, fullname, path=None, target=None):
if not self._armed or fullname != "openai._base_client":
return None
# Disarm before delegating so the recursive find_spec doesn't loop through us.
self._armed = False
try:
_httpx_neuter_sys.meta_path.remove(self)
except ValueError:
pass
spec = _httpx_neuter_imp_util.find_spec(fullname)
if spec is None or spec.loader is None:
return None
_orig_exec = spec.loader.exec_module
def _patched_exec(module):
_orig_exec(module)
try:
cls = getattr(module, "AsyncHttpxClientWrapper", None)
if cls is not None:
cls.__del__ = lambda self: None # type: ignore[assignment]
except Exception:
pass
spec.loader.exec_module = _patched_exec # type: ignore[method-assign]
return spec
_httpx_neuter_sys.meta_path.insert(0, _AsyncHttpxDelNeuter())
except Exception:
pass
# Agent/tool systems load lazily: bare startup only needs the prompt.
def get_tool_definitions(*args, **kwargs):
from hermes_cli.mcp_startup import wait_for_mcp_discovery
from model_tools import get_tool_definitions as _get_tool_definitions
wait_for_mcp_discovery()
return _get_tool_definitions(*args, **kwargs)
validate_toolset = _lazy_shim("toolsets", "validate_toolset")
_cleanup_all_terminals = _lazy_shim("tools.terminal_tool", "cleanup_all_environments", "_cleanup_all_terminals")
set_sudo_password_callback = _lazy_shim("tools.terminal_tool", "set_sudo_password_callback")
set_approval_callback = _lazy_shim("tools.terminal_tool", "set_approval_callback")
set_secret_capture_callback = _lazy_shim("tools.skills_tool", "set_secret_capture_callback")
_cleanup_all_browsers = _lazy_shim("tools.browser_tool_lifecycle", "_emergency_cleanup_all_sessions", "_cleanup_all_browsers")
_cleanup_done = False # _run_cleanup runs exactly once
_cleanup_in_progress = False
_cli_wake_owner = None
# One-shot finalization runs before process cleanup (plugins see the boundary while the
# agent is attached); atexit cleanup must not finalize those sessions again.
_single_query_finalize_attempted_session_ids: set[str | None] = set()
# /handoff sessions belong to the gateway: finalizing them here would stamp end_reason on
# a row the gateway just reopened, making the handoff leg vanish from history.
# Session IDs that were handed off to the gateway via /handoff. The CLI process exits after a successful
# handoff, but the gateway now owns the session lifecycle — _run_cleanup must NOT call finalize_session on
# these, because doing so sets end_reason on a row the gateway just reopened and is actively writing to
# (#88234). The race made the handoff leg vanish from session history and broke session_search recall for
# the handed-off session.
_handed_off_session_ids: set[str | None] = set()
_active_agent_ref = None # active AIAgent, for memory-provider shutdown at exit
_deferred_agent_startup_done = False
# Set once the TUI app starts (focus reporting + mouse tracking on); gates the on-exit
# terminal reset so non-TUI one-shot runs never emit codes for modes they never enabled.
_tui_input_modes_active = False
# Set True once the TUI's prompt_toolkit app starts (which enables focus reporting + mouse tracking). Gates
# the on-exit terminal reset so non-TUI one-shot CLI runs — which also register _run_cleanup via atexit —
# don't emit escape codes for modes they never enabled (#36823).
def _mark_tui_input_modes_active() -> None:
"""Record that the TUI app started, so _run_cleanup resets input modes."""
global _tui_input_modes_active
_tui_input_modes_active = True
def _prepare_deferred_agent_startup() -> None:
"""Run Termux-deferred agent discovery before the first real agent turn."""
global _deferred_agent_startup_done
if _deferred_agent_startup_done:
return
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
return
_deferred_agent_startup_done = True
_accept_hooks = os.environ.get("HERMES_ACCEPT_HOOKS", "").lower() in {"1", "true", "yes", "on"}
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception:
logger.warning("plugin discovery failed at deferred CLI startup", exc_info=True)
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(logger=logger, thread_name="termux-cli-mcp-discovery")
except Exception:
logger.debug("MCP tool discovery failed at deferred CLI startup", exc_info=True)
try:
from agent.shell_hooks import register_from_config
from agent.outbound_webhooks import register_from_config as register_outbound_webhooks
from hermes_cli.config import load_config
_hooks_cfg = load_config()
register_from_config(_hooks_cfg, accept_hooks=_accept_hooks)
register_outbound_webhooks(_hooks_cfg)
except Exception:
logger.debug("shell-hook registration failed at deferred CLI startup", exc_info=True)
_signal_watchdog_armed = False
def _arm_exit_watchdog_on_shutdown_signal() -> None:
"""Arm the exit backstop the moment a termination signal arrives (idempotent; never raises).
The graceful unwind has wedge points BEFORE ``_run_cleanup`` arms its own watchdog
(main thread in a syscall, prompt_toolkit teardown never returning). Leash is 2x
the cleanup timeout so a progressing cleanup is never cut short. Never arm at
startup: the timer exits unconditionally.
SIGTERM/SIGHUP establish unambiguous shutdown intent, but the graceful path from signal →
``agent.interrupt()`` → ``app.exit()`` / ``KeyboardInterrupt`` → ``finally`` → ``_run_cleanup`` has
several wedge points BEFORE ``_run_cleanup`` arms the normal watchdog: a main thread parked in a syscall
that never observes the unwind, a prompt_toolkit teardown that never returns, or an agent worker
blocking the ``finally``. When that happens the process has NO backstop and a "dead" CLI lingers
(observed: ``hermes --tui`` alive ~47 min at 4% CPU after terminal close — the #65998 class).
"""
global _signal_watchdog_armed
if _signal_watchdog_armed:
return
_signal_watchdog_armed = True
base = _exit_watchdog_timeout()
if base <= 0:
return # explicitly disabled
with suppress(Exception): # never let the backstop break signal handling
_arm_exit_watchdog(timeout_s=base * 2, from_signal=True)
def _run_cleanup(*, notify_session_finalize: bool = True):
"""Run resource cleanup exactly once."""
global _cleanup_done, _cleanup_in_progress
if _cleanup_done:
return
_cleanup_done = True
_cleanup_in_progress = True
try:
_arm_exit_watchdog()
# Reset terminal input modes FIRST: teardown below can take seconds and a later
# step raising must not skip the reset. No-op unless the TUI ran.
# See #36823.
_reset_terminal_input_modes_on_exit()
for step, swallow in _CLEANUP_STEPS:
with suppress(swallow):
globals()[step]()
if notify_session_finalize:
cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None
if _should_emit_cleanup_session_finalize(cleanup_session_id):
_notify_session_finalize(session_id=cleanup_session_id, platform="cli", reason="shutdown")
try:
_shutdown_agent_memory_provider(_active_agent_ref)
except Exception as e:
logger.warning("CLI cleanup memory shutdown failed: %s", e, exc_info=True)
finally:
_cleanup_in_progress = False
def _reset_terminal_input_modes_on_exit() -> None:
"""Disable focus reporting + mouse tracking on TUI exit (best-effort).
Ctrl+C / SIGTERM / crashes bypass prompt_toolkit's unwind, leaving focus events and
mouse reports as visible text in the next shell. Writes to stdout when it is the
terminal, else /dev/tty (the TUI may have run with stdout redirected).
Called from ``_run_cleanup`` (atexit-registered + invoked on the normal / EOF / interrupt exit paths)
this covers normal quit, Ctrl+C and SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
``os._exit(0)`` path bypasses ``atexit``; neither runs this — but both are non-TTY / non-TUI, so there
is nothing to reset there. See #36823.
"""
global _tui_input_modes_active
if not _tui_input_modes_active:
return
# Clear first so a re-armed _run_cleanup doesn't re-emit.
_tui_input_modes_active = False
try:
stream = sys.stdout
if stream is not None and stream.isatty():
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
stream.flush()
return
except Exception:
pass
with suppress(Exception), open("/dev/tty", "w", encoding="ascii") as tty:
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
tty.flush()
from hermes_cli.worktree_ops import (
_git_quiet,
_git_repo_root,
_maintain_pack_health,
_prune_stale_worktrees,
_repo_is_shallow,
_setup_worktree,
_worktree_has_unpushed_commits,
release_lsp_clients,
)
# ============================================================================= Git Worktree Isolation
# (#652) =============================================================================
_active_worktree: Optional[Dict[str, str]] = None
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
"""Remove a worktree and its branch on exit; kept only when it has unpushed commits."""
global _active_worktree
info = info or _active_worktree
if not info:
return
wt_path, branch, repo_root = info["path"], info["branch"], info["repo_root"]
if not Path(wt_path).exists():
return
if _worktree_has_unpushed_commits(wt_path, timeout=10):
if _repo_is_shallow(repo_root):
# Shallow boundary makes the unpushed verdict unreliable; the startup pruner reaps later.
_cprint(f"\n\033[33m{_t('cli.worktree.shallow_clone_keeping', path=wt_path)}\033[0m")
print(f" {_t('cli.worktree.next_session_deepens')}")
else:
_cprint(f"\n\033[33m{_t('cli.worktree.unpushed_keeping', path=wt_path)}\033[0m")
print(f" {_t('cli.worktree.clean_up_manually', path=wt_path)}")
_active_worktree = None
return
# Release the tree's language servers while the path still exists, then unlock so `remove`
# isn't blocked by the lock placed at creation. Fail-soft.
release_lsp_clients(wt_path)
_git_quiet(["worktree", "unlock", wt_path], repo_root, log="git worktree unlock failed (non-fatal)")
_git_quiet(["worktree", "remove", wt_path, "--force"], repo_root, timeout=15, log="Failed to remove worktree")
_git_quiet(["branch", "-D", branch], repo_root, log=f"Failed to delete branch {branch}")
_active_worktree = None
_cprint(f"\033[32m{_t('cli.worktree.cleaned_up', path=wt_path)}\033[0m")
# Light/dark terminal detection (mirrors ui-tui/src/theme.ts detectLightMode()). Priority:
# HERMES_LIGHT/HERMES_TUI_LIGHT env, HERMES_TUI_THEME, HERMES_TUI_BACKGROUND, COLORFGBG
# (bg slot 7/15 = light), OSC 11 query, default dark. Cached so the terminal is queried once.
_LIGHT_MODE_CACHE: bool | None = None
def _detect_light_mode() -> bool:
global _LIGHT_MODE_CACHE
if _LIGHT_MODE_CACHE is not None:
return _LIGHT_MODE_CACHE
try:
result = _detect_light_mode_uncached()
except Exception:
result = False
_LIGHT_MODE_CACHE = result
return result
_install_skin_light_mode_hook()
# Prime the light-mode cache when interactive so OSC 11 happens before prompt_toolkit owns the tty.
with suppress(Exception):
if sys.stdin.isatty() and sys.stdout.isatty():
_detect_light_mode()
_OUTPUT_HISTORY_ENABLED = True
_OUTPUT_HISTORY_REPLAYING = False
_OUTPUT_HISTORY_SUPPRESSED = False
_OUTPUT_HISTORY_MAX_LINES = 200
_OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
def _configure_output_history(enabled: bool, max_lines=200) -> None:
"""Configure recent CLI output replayed after terminal redraws."""
global _OUTPUT_HISTORY_ENABLED, _OUTPUT_HISTORY_MAX_LINES, _OUTPUT_HISTORY
_OUTPUT_HISTORY_ENABLED = bool(enabled)
_OUTPUT_HISTORY_MAX_LINES = _coerce_output_history_limit(max_lines)
_OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
@contextmanager
def _suspend_output_history():
global _OUTPUT_HISTORY_SUPPRESSED
old_value = _OUTPUT_HISTORY_SUPPRESSED
_OUTPUT_HISTORY_SUPPRESSED = True
try:
yield
finally:
_OUTPUT_HISTORY_SUPPRESSED = old_value
def _replay_output_history(fit=None, output=None) -> None:
"""Repaint recent output above the prompt after a full screen clear.
``fit=(rows, columns, painted, top)`` replays only the newest lines whose wrapped height
fits ``rows`` (see ``_output_tail_fitting``) — the older ones are still in scrollback
(#95375) — from screen row ``top`` when known (``_set_chrome_floor``). ``output``: paint
now, straight to this prompt_toolkit output, where the caller just erased the viewport and
reset the renderer — ``run_in_terminal`` would first erase below the top row, which
scroll-on-clear terminals (tmux) take as a clear and copy the blank screen into scrollback.
"""
global _OUTPUT_HISTORY_REPLAYING
if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY:
return
_OUTPUT_HISTORY_REPLAYING = True
try:
rendered_lines = _output_history_lines()
top = None
if fit is not None:
rows, columns, painted, top = fit
rendered_lines = _output_tail_fitting(rendered_lines, rows, columns, painted)
if rendered_lines:
# One payload: per-line pt prints each force a sync redraw (a waterfall of old output).
if output is None:
_pt_print(_PT_ANSI("\n".join(rendered_lines)))
else:
from prompt_toolkit.renderer import print_formatted_text as _paint_formatted_text
from prompt_toolkit.styles import Style
_paint_formatted_text(output, _PT_ANSI("\n".join(rendered_lines) + "\n"), Style([]))
size = output.get_size()
if top is not None: # the chrome's top is now this many rows down
top += sum(_line_rows(line, columns) for line in rendered_lines)
_set_chrome_floor(max(0, size.rows - top))
if size.columns != columns:
_add_suspect_rows(top + 1 - size.rows)
width = _painted_columns() if fit is None else columns
for line in rendered_lines: # repainted: they wrap at today's width from now on
if isinstance(line, _PaintedLine):
line.width = width
except Exception:
pass
finally:
_OUTPUT_HISTORY_REPLAYING = False
_strip_leaked_bracketed_paste_wrappers = _lazy_shim(
"hermes_cli.input_sanitize", "strip_leaked_bracketed_paste_wrappers", "_strip_leaked_bracketed_paste_wrappers"
)
# OSC sequences (e.g. OSC-8 links): pt's ANSI parser strips the ESC but leaks the payload as text.
_OSC_ESCAPE_RE = re.compile(r"\x1b\][\s\S]*?(?:\x07|\x1b\\)")
def _looks_like_slash_command(text: str) -> bool:
"""``/help`` yes, ``/Users/x/file.md`` no: a command's first word has no further ``/``."""
if not text or not text.startswith("/"):
return False
return "/" not in text.split()[0][1:]
_skill_commands = None
_skill_bundles = None
def _slash_args(cmd: str) -> str:
"""Text after the slash-command word, stripped ("" when absent)."""
parts = cmd.split(None, 1)
return parts[1].strip() if len(parts) > 1 else ""
def _ensure_skill_commands() -> dict:
global _skill_commands
if _skill_commands is None:
from agent.skill_commands import scan_skill_commands
_skill_commands = scan_skill_commands()
return _skill_commands
def get_skill_commands() -> dict:
return _ensure_skill_commands()
build_skill_invocation_message = _lazy_shim("agent.skill_commands", "build_skill_invocation_message")
build_preloaded_skills_prompt = _lazy_shim("agent.skill_commands", "build_preloaded_skills_prompt")
def get_skill_bundles() -> dict:
global _skill_bundles
if _skill_bundles is None:
from agent.skill_bundles import get_skill_bundles as _impl
_skill_bundles = _impl()
return _skill_bundles
build_bundle_invocation_message = _lazy_shim("agent.skill_bundles", "build_bundle_invocation_message")
def _get_plugin_cmd_handler_names() -> set:
"""Return plugin command names (without slash prefix) for dispatch matching."""
try:
from hermes_cli.plugins import get_plugin_commands
return set(get_plugin_commands().keys())
except Exception:
return set()
def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) -> list[str]:
"""Normalize a CLI skills flag into a deduplicated list of skill identifiers."""
if not skills:
return []
raw_values = [str(item) for item in skills if item is not None] if isinstance(skills, (list, tuple)) else [str(skills)]
parts = (p.strip() for raw in raw_values for p in raw.split(","))
return list(dict.fromkeys(p for p in parts if p))
def save_config_value(key_path: str, value: any) -> bool:
"""Persist dot-separated ``key_path`` = value into HERMES_HOME/config.yaml; True on success.
Never the repo's cli-config.yaml: no config reader loads it, so the value would vanish.
"""
config_path = get_hermes_home() / 'config.yaml'
try:
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(config_path.parent)
from utils import atomic_roundtrip_yaml_update
atomic_roundtrip_yaml_update(config_path, key_path, value)
try: # owner-only: config files contain API keys
os.chmod(config_path, 0o600)
except (OSError, NotImplementedError):
pass
return True
except Exception as e:
logger.error("Failed to save config: %s", e)
return False
def _normalize_moa_model(model: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""``moa:<preset>`` -> ``("moa", preset)`` (same routing as ``/moa``); anything else -> ``(None, model)``.
Returns ``("moa", "<preset>")`` when *model* selects the MoA virtual provider, otherwise ``(None,
model)`` unchanged. This gives non-interactive ``hermes chat -Q -m moa:<preset>`` the same routing the
interactive ``/moa`` command and the model picker already use: ``resolve_runtime_provider`` handles
``requested_provider == "moa"`` and ``agent_init`` builds the MoAClient off ``provider == "moa"``.
Without this the raw ``moa:<preset>`` string is sent to the real provider and rejected with a 401/400
"model not supported" (#56828).
"""
if isinstance(model, str) and model.strip().lower().startswith("moa:"):
preset = model.strip().split(":", 1)[1].strip()
if preset:
return "moa", preset
return None, model
_split_model_config_default = _lazy_shim("hermes_cli.config", "split_model_config_default", "_split_model_config_default")
class _VoiceInputMessage:
"""Sentinel for voice-transcribed input so the concise voice prefix never applies to typed text.
Distinguishes STT output from manually typed text while voice mode is active, so the
concise-voice-response prefix is applied only to messages that actually came from the microphone
(#65827).
"""
__slots__ = ("text",)
def __init__(self, text: str):
self.text = text
def __str__(self) -> str:
return self.text
class _SeededQueryMessage:
"""Sentinel for a ``-q`` prompt seeded into an interactive session; treated LITERALLY (no slash/!/file-drop)."""
__slots__ = ("text", "images")
def __init__(self, text: str, images=None):
self.text = text or ""
self.images = list(images or [])
def __str__(self) -> str:
return self.text
def _should_seed_interactive(query, image, quiet: bool, oneshot: bool) -> bool:
"""``-q`` seeds an interactive session only on a real TTY without ``--oneshot``/``-Q`` (automation answers and exits)."""
if not (query or image) or oneshot or quiet:
return False
try:
return bool(sys.stdin.isatty() and sys.stdout.isatty())
except Exception:
return False
@dataclass
class _ChatTurn:
"""Per-turn state shared by the ``chat()`` phases and the agent worker thread.
``result`` is written by the worker and read after the join; ``tts_normal_exit`` is
set only when the TTS worker drained on its own so the last sentence is never cut.
"""
result: Optional[dict] = None
mute_notification_reply: bool = False
use_streaming_tts: bool = False
box_opened: bool = False
thinking_started: bool = False
text_queue: Optional[queue.Queue] = None
tts_thread: Optional[threading.Thread] = None
stream_callback: Optional[Any] = None
stop_event: Optional[threading.Event] = None
tts_normal_exit: bool = False
voice_prefix: str = ""
from hermes_cli.cli_chat_turn_mixin import CLIChatTurnMixin
_PASTE_REF_RE = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMixin, CLIStatusBarMixin, CLIVoiceMixin, CLIModelSwitchMixin, CLISessionMixin, CLIStreamMixin, CLIModalMixin, CLITerminalMixin, CLIInfoMixin, CLILoopsMixin, CLIChatTurnMixin):
"""Interactive REPL for the Hermes Agent."""
# Seeded -q first message (see _should_seed_interactive); run() re-creates
# _pending_input, so it is enqueued only after the fresh queue exists.
_seeded_first_message: Optional["_SeededQueryMessage"] = None
# Inspection surfaces (banner, /tools, status line) read this on partially built instances too.
disabled_toolsets: Optional[List[str]] = None
def __init__(
self,
model: str = None,
toolsets: List[str] = None,
provider: str = None,
reasoning: str = None,
api_key: str = None,
base_url: str = None,
max_turns: int = None,
run_budget: float = None,
verbose: Optional[bool] = None,
compact: bool = False,
resume: str = None,
checkpoints: bool = False,
pass_session_id: bool = False,
ignore_rules: bool = False,
):
"""CLI args win over config; ``reasoning`` is per-run only; ``resume`` restores history from SQLite."""
self._init_display_options(verbose, compact)
self._init_model_routing(model, toolsets, provider, reasoning, api_key, base_url, max_turns, run_budget,
checkpoints, pass_session_id, ignore_rules)
self._init_runtime_state(resume)
def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -> bool:
"""Claim a global active-session slot for this CLI process."""
if self._active_session_lease is not None:
return True
try:
from hermes_cli.active_sessions import format_refusal_stderr, try_acquire_active_session
lease, message = try_acquire_active_session(
session_id=self.session_id,
surface=surface,
config=self.config,
# Writer identity: a re-claim by this process replaces its own entry.
# See #94595.
metadata={"live_session_id": str(self.session_id)},
)
except Exception as exc:
logger.warning("Failed to claim active session slot: %s", exc)
return True
if message:
print(format_refusal_stderr(message), file=sys.stderr) if stderr else self._console_print(f"[bold red]{message}[/]")
return False
self._active_session_lease = lease
with suppress(Exception):
atexit.register(self._release_active_session)
return True
def _release_active_session(self) -> None:
lease = getattr(self, "_active_session_lease", None)
if lease is None:
return
try:
lease.release()
except Exception:
logger.debug("Failed to release active session slot", exc_info=True)
finally:
self._active_session_lease = None
_PET_FRAME_INTERVAL = 0.16
_PET_CFG_INTERVAL = 2.5
def _install_tool_callbacks(self) -> None:
"""Install tool callbacks that need the live prompt UI."""
if self._tool_callbacks_installed:
return
set_sudo_password_callback(self._sudo_password_callback)
set_approval_callback(self._approval_callback)
set_secret_capture_callback(self._secret_capture_callback)
from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback
set_unlock_prompt_callback(self._vault_unlock_callback)
set_save_login_prompt_callback(self._vault_save_login_callback)
set_code_prompt_callback(self._vault_code_callback)
self._tool_callbacks_installed = True
def _ensure_tirith_security(self) -> None:
"""Check tirith availability once before tools can run terminal commands."""
if self._tirith_security_checked:
return
self._tirith_security_checked = True
try:
from tools.tirith_security import ensure_installed, is_platform_supported, missing_is_expected
if (
ensure_installed(log_failures=False) is None and is_platform_supported()
and (self.config.get("security", {}) or {}).get("tirith_enabled", True)
):
# First launch after install downloads tirith in the background;
# warning then would report a fault that resolves itself.
if missing_is_expected():
logger.info("tirith not ready (downloading or lazy installs off); pattern matching only")
else:
_cprint(f" {_DIM}{_t('cli.startup.tirith_unavailable')}{_RST}")
except Exception as exc:
logger.debug("tirith availability check failed: %s", exc)
def _show_security_advisories(self):
"""Startup banner for unacked security advisories, on stderr (piped stdout stays clean); 24h rate-limited."""
try:
from hermes_cli.security_advisories import detect_compromised, startup_banner
banner = startup_banner(detect_compromised())
if banner:
print(banner, file=sys.stderr, flush=True)
except Exception:
pass # never block startup
def _show_browser_backend_notice(self):
"""Once-per-24h hint when the default Browser Use backend silently fell back to built-in tools."""
try:
from tools.browser_use_cli import default_downgrade_notice
notice = default_downgrade_notice()
if notice:
from gateway.warning_notifications import render_notification
render_notification(lambda: self._console_print(f"[yellow]⚠ {notice}[/yellow]"), platform="cli")
except Exception:
logger.debug("browser backend notice failed", exc_info=True)
def finalize_preloaded_skills(self) -> None:
"""Join the background --skills preload and fold it into the prompt (idempotent).
Raises ``ValueError`` only when EVERY requested skill was unknown.
"""
if getattr(self, "_preload_skills_finalized", False):
return
thread = getattr(self, "_preload_skills_thread", None)
if thread is None:
self._preload_skills_finalized = True
return
thread.join(timeout=120)
self._preload_skills_finalized = True
err = getattr(self, "_preload_skills_error", None)
if err is not None:
raise err
auto_result = getattr(self, "_auto_load_skills_result", None)
if auto_result and auto_result[2]:
logger.warning("skills.auto_load: skill(s) not found or disabled, skipped: %s", ", ".join(auto_result[2]))
# auto_load names first, then explicit -s names that were not already pinned.
self.preloaded_skills = list(auto_result[1]) if auto_result else []
result = getattr(self, "_preload_skills_result", None)
if not result:
return
skills_prompt, loaded_skills, missing_skills = result
if missing_skills:
missing_display = ", ".join(missing_skills)
# A typo'd name must not crash a kanban worker; only a fully-missing set fails loudly.
if loaded_skills:
logger.warning(
"Unknown skill(s) requested, skipping: %s. "
"Continuing with: %s. "
"List available skills with `hermes skills list`.",
missing_display,
", ".join(loaded_skills),
)
else:
raise ValueError(f"Unknown skill(s): {missing_display}")
if skills_prompt:
self.system_prompt = "\n\n".join(p for p in (self.system_prompt, skills_prompt) if p).strip()
self.preloaded_skills += [name for name in loaded_skills if name not in self.preloaded_skills]
def _show_tool_availability_warnings(self):
"""Warn about toolsets switched off at startup (missing API keys, unusable terminal backend)."""
try:
# Runs on a daemon thread on the snapshot fast path: keep the imports to modules the
# registry walk already loaded plus the pure notices module (a heavy import here races
# importlib's module locks against the main thread).
from model_tools import check_tool_availability
from hermes_cli.tool_availability_notices import (
current_terminal_backend, filter_to_enabled_toolsets, tool_availability_warning_lines,
)
from tools.terminal_tool import terminal_backend_unavailable_reason
from toolsets import resolve_toolset
_, unavailable = check_tool_availability()
# Only toolsets this CLI session actually has. The selection is usually a composite bundle
# (``hermes-cli``), so expand it to tool names before matching — a raw name comparison
# matched nothing on a default install and silently dropped the terminal notice.
unavailable = filter_to_enabled_toolsets(unavailable, self.enabled_toolsets or [], resolve_toolset)
lines = tool_availability_warning_lines(
unavailable, terminal_reason=terminal_backend_unavailable_reason(),
terminal_backend=current_terminal_backend())
if lines:
self._console_print()
for line in lines:
self._console_print(line)
except Exception:
pass
def show_config(self):
"""Display current configuration with kawaii ASCII art."""
terminal_env = os.getenv("TERMINAL_ENV", "local")
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
config_path = _hermes_home / 'config.yaml'
if not config_path.exists():
config_path = Path(__file__).parent / 'cli-config.yaml'
config_status = _t("cli.config.loaded") if config_path.exists() else _t("cli.config.not_found")
# ``api_key`` may be a callable (Entra ID bearer provider): never invoke it. Prefer the
# LIVE agent's key: the constructor seeds self.api_key from env before provider
# resolution, so on non-OpenAI providers it can be another vendor's key.
from agent.azure_identity_adapter import is_token_provider
display_key = self.api_key
if self.agent is not None and getattr(self.agent, "api_key", None):
display_key = self.agent.api_key
if is_token_provider(display_key):
api_key_display = _t("cli.config.microsoft_entra_id")
elif isinstance(display_key, str) and len(display_key) > 12:
api_key_display = f"{display_key[:8]}...{display_key[-4:]}"
else:
api_key_display = _t("cli.config.api_key_not_set")
title = _t("cli.config.title")
width = 50
pad = width - len(title)
_unset = _t("cli.config.value_not_set")
ssh_target = (
f"{os.getenv('TERMINAL_SSH_USER', _unset)}@{os.getenv('TERMINAL_SSH_HOST', _unset)}"
f":{os.getenv('TERMINAL_SSH_PORT', '22')}"
) if terminal_env == "ssh" else None
# (section key, ((label key, value), ...)); labels are re-padded at print time so
# translated widths still line up instead of carrying alignment spaces in the catalog.
sections = (
("cli.config.section_model", (
("cli.config.label_model", self.model),
("cli.config.label_base_url", self.base_url),
("cli.config.label_api_key", api_key_display))),
("cli.config.section_terminal", (
("cli.config.label_environment", terminal_env),
*((("cli.config.label_ssh_target", ssh_target),) if ssh_target else ()),
("cli.config.label_working_dir", terminal_cwd),
("cli.config.label_timeout", f"{terminal_timeout}s"),
)),
("cli.config.section_agent", (
("cli.config.label_max_turns", self.max_turns),
("cli.config.label_toolsets", ", ".join(self.enabled_toolsets) if self.enabled_toolsets else "all"),
("cli.config.label_verbose", self.verbose),
)),
("cli.config.section_session", (
("cli.config.label_started", self.session_start.strftime("%Y-%m-%d %H:%M:%S")),
("cli.config.label_config_file", f"{config_path} {config_status}"),
)),
)
print()
print("+" + "-" * width + "+")
print("|" + " " * (pad // 2) + title + " " * (pad - pad // 2) + "|")
print("+" + "-" * width + "+")
for section_key, rows in sections:
print()
print(f" -- {_t(section_key)} --")
label_width = max(len(_t(label_key)) for label_key, _ in rows)
for label_key, value in rows:
print(f" {_t(label_key):<{label_width}} {value}")
print()
# canonical command -> (method name, pass cmd_original?). Absent commands resolve to
# ``_handle_<name>_command(cmd)``. Looked up via getattr at dispatch time so
# monkeypatching works. A handler returning False exits the REPL.
_SLASH_DISPATCH: dict[str, tuple[str, bool]] = {
"exit": ("_cmd_exit", True), "quit": ("_cmd_exit", True), "help": ("_cmd_help", True),
"palette": ("_open_command_palette", False), "whoami": ("_handle_whoami_command", False),
"profile": ("_handle_profile_command", False), "toolsets": ("show_toolsets", False),
"config": ("show_config", False), "redraw": ("_cmd_redraw", True), "clear": ("_cmd_clear", True),
"history": ("show_history", False), "title": ("_cmd_title", True), "new": ("_cmd_new", True),
"model": ("_handle_model_switch", True), "codex-runtime": ("_handle_codex_runtime", True),
"retry": ("_cmd_retry", True), "prompt": ("_handle_prompt_compose_command", True),
"undo": ("_cmd_undo", True), "save": ("save_conversation", True), "skills": ("_cmd_skills", True),
"platforms": ("_show_gateway_status", False), "status": ("_show_session_status", False),
"context": ("_show_context_breakdown", True), "egress": ("_cmd_egress", True),
"statusbar": ("_cmd_statusbar", True), "verbose": ("_toggle_verbose", False), "yolo": ("_toggle_yolo", False),
"compress": ("_manual_compress", True), "subscription": ("_show_subscription", False),
"topup": ("_show_billing", True), "insights": ("_show_insights", True), "update": ("_cmd_update", True),
"version": ("_cmd_version", True), "paste": ("_handle_paste_command", False), "reload": ("_cmd_reload", True),
"reload-mcp": ("_confirm_and_reload_mcp", True), "reload-skills": ("_cmd_reload_skills", True),
"plugins": ("_cmd_plugins", True), "stop": ("_handle_stop_command", False),
"agents": ("_handle_agents_command", False), "bg": ("_handle_background_command", True),
"queue": ("_cmd_queue", True), "steer": ("_cmd_steer", True), "moa": ("_cmd_moa", True),
}
@classmethod
def _slash_handler(cls, canonical: str) -> tuple[str, bool] | None:
"""(method name, pass cmd_original?) for a registered command, else None."""
entry = cls._SLASH_DISPATCH.get(canonical)
if entry is None:
name = f"_handle_{canonical.replace('-', '_')}_command"
if callable(getattr(cls, name, None)):
entry = (name, True)
return entry
# Shared-metrics surface for user-typed commands; None where another process owns the count
# (the TUI slash worker: tui_gateway records the command it forwards).
_slash_metrics_surface: str | None = "cli"
def process_command(self, command: str, *, redispatch: bool = False) -> bool:
"""Dispatch a slash command; returns False to exit the REPL. ``redispatch`` marks an internal
re-entry (quick-command alias, prefix expansion) so the user's command is counted once."""
cmd_lower = command.lower().strip() # lowercase only for matching; args keep their case
cmd_original = command.strip()
# Aliases resolve via the central registry (hermes_cli/commands.py).
from hermes_cli.commands import resolve_command as _resolve_cmd
_base_word = cmd_lower.split()[0].lstrip("/")
_cmd_def = _resolve_cmd(_base_word)
canonical = _cmd_def.name if _cmd_def else _base_word
if not redispatch and self._slash_metrics_surface:
from hermes_cli.observability.shared_metrics_events import record_slash_command
record_slash_command(command=canonical, surface=self._slash_metrics_surface)
# Observer-only pre_command plugin hook (return values ignored; never raises).
if _cmd_def is not None:
from hermes_cli.plugins import fire_pre_command_hook
fire_pre_command_hook(
surface="cli", command=canonical, alias_used=_base_word, args_raw=_slash_args(cmd_original),
session_key=getattr(self, "session_id", None), platform="cli",
)
# A bare `/resume` prompt is one-shot: any other command disarms it so a later
# number isn't swallowed as a stale selection.
# See #34584.
if canonical not in {"resume", "sessions"}:
# Armed when a bare `/resume` prints the recent-sessions list so the very next bare numeric
# input (e.g. `3`) resolves to that session. Holds the exact list used for index resolution;
# one-shot (cleared on the next submitted input, whether it's the selection or anything else).
# See #34584.
self._pending_resume_sessions = None
entry = self._slash_handler(canonical)
if entry is None:
return self._process_unregistered_slash(cmd_original, cmd_lower)
method_name, pass_arg = entry
handler = getattr(self, method_name)
result = handler(cmd_original) if pass_arg else handler()
return result is not False
def _process_unregistered_slash(self, cmd_original: str, cmd_lower: str) -> bool:
"""Slash input with no built-in handler; precedence: quick_commands -> plugins -> bundles -> skills -> prefix expansion."""
base_cmd = cmd_lower.split()[0]
bare = base_cmd.lstrip("/")
skill_commands = _ensure_skill_commands()
skill_bundles = get_skill_bundles()
quick_commands = self.config.get("quick_commands", {})
user_args = cmd_original[len(base_cmd):].strip()
if bare in quick_commands:
return self._run_quick_command(base_cmd, quick_commands[bare], user_args)
if bare in _get_plugin_cmd_handler_names():
self._run_plugin_slash_command(base_cmd, user_args)
elif base_cmd in skill_bundles:
self._run_skill_bundle_command(base_cmd, skill_bundles[base_cmd], user_args)
elif base_cmd in skill_commands:
self._run_skill_slash_command(base_cmd, skill_commands[base_cmd], user_args)
else:
return self._expand_slash_prefix(cmd_original, cmd_lower, skill_commands, skill_bundles)
return True
def _run_quick_command(self, base_cmd: str, qcmd: dict, user_args: str) -> bool:
"""User-defined quick command (config.yaml): ``exec`` runs a shell snippet, ``alias`` re-dispatches."""
qtype = qcmd.get("type")
if qtype == "alias":
target = qcmd.get("target", "").strip()
if target:
target = target if target.startswith("/") else f"/{target}"
return self.process_command(f"{target} {user_args}".strip(), redispatch=True)
self._console_print(f"[bold red]{_t('cli.quick.no_target', command=base_cmd)}[/]")
return True
if qtype != "exec":
self._console_print(f"[bold red]{_t('cli.quick.unsupported_type', command=base_cmd)}[/]")
return True
import subprocess
exec_cmd = qcmd.get("command", "")
if not exec_cmd:
self._console_print(f"[bold red]{_t('cli.quick.no_command', command=base_cmd)}[/]")
return True
try:
# shell=True is intentional (user-authored config snippets, never LLM controlled);
# the env is sanitized because this process holds every API key.
from tools.environments.local import build_subprocess_env
from hermes_cli._subprocess_compat import windows_hide_flags
result = subprocess.run(
exec_cmd, shell=True, capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=30, env=build_subprocess_env(),
creationflags=windows_hide_flags(), # no console flash on Windows (#56747)
)
# See #56747.
output = result.stdout.strip() or result.stderr.strip()
if output:
from agent.redact import redact_sensitive_text
self._console_print(_rich_text_from_ansi(redact_sensitive_text(output)))
else:
self._console_print(f"[dim]{_t('cli.quick.no_output')}[/]")
except subprocess.TimeoutExpired:
self._console_print(f"[bold red]{_t('cli.quick.timed_out')}[/]")
except Exception as e:
self._console_print(f"[bold red]{_t('cli.quick.error', error=str(e))}[/]")
return True
def _run_plugin_slash_command(self, base_cmd: str, user_args: str) -> None:
from hermes_cli.plugins import get_plugin_command_handler, resolve_plugin_command_result
plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
if not plugin_handler:
return
try:
result = resolve_plugin_command_result(plugin_handler(user_args))
if result:
_cprint(str(result))
except Exception as e:
_cprint(f"\033[1;31m{_t('cli.plugin.command_error', error=str(e))}{_RST}")
def _queue_skill_message(self, msg) -> None:
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
def _run_skill_bundle_command(self, base_cmd: str, bundle_info: dict, user_instruction: str) -> None:
"""``/<bundle>`` loads several skills at once (bundles win over same-named skills)."""
bundle_result = build_bundle_invocation_message(base_cmd, user_instruction, task_id=self.session_id)
if not bundle_result:
ChatConsole().print(f"[bold red]{_t('cli.skills.bundle_load_failed', command=base_cmd)}[/]")
return
msg, loaded_names, missing = bundle_result
self._queue_loaded_skills(
msg, _t("cli.skills.loading_bundle", name=bundle_info['name'], count=str(len(loaded_names))), missing)
def _queue_loaded_skills(self, msg, label: str, missing) -> None:
print(f"\n⚡ {label}")
if missing:
ChatConsole().print(f"[yellow]{_t('cli.skills.skipped_missing', names=', '.join(missing))}[/]")
self._queue_skill_message(msg)
def _run_skill_slash_command(self, base_cmd: str, skill_info: dict, rest: str) -> None:
"""``/<skill> ...``; stacked ``/skill-a /skill-b do XYZ`` loads every leading skill (up to 5)."""
from agent.skill_commands import build_stacked_skill_invocation_message, split_stacked_skill_commands
extra_keys, user_instruction = split_stacked_skill_commands(rest)
if extra_keys:
stacked_result = build_stacked_skill_invocation_message(
[base_cmd, *extra_keys], user_instruction, task_id=self.session_id,
)
if not stacked_result:
ChatConsole().print(f"[bold red]{_t('cli.skills.stacked_load_failed', command=base_cmd)}[/]")
return
msg, loaded_names, missing = stacked_result
self._queue_loaded_skills(
msg, _t("cli.skills.loading_stacked", count=str(len(loaded_names)), names=', '.join(loaded_names)),
missing,
)
return
msg = build_skill_invocation_message(base_cmd, rest, task_id=self.session_id)
if msg:
self._queue_loaded_skills(msg, _t("cli.skills.loading_skill", name=skill_info['name']), None)
else:
ChatConsole().print(f"[bold red]{_t('cli.skills.skill_load_failed', command=base_cmd)}[/]")
def _expand_slash_prefix(self, cmd_original: str, cmd_lower: str, skill_commands, skill_bundles) -> bool:
"""Unique-prefix expansion against built-in COMMANDS + skill commands/bundles (agrees with tab-completion)."""
from hermes_cli.commands import COMMANDS
typed_base = cmd_lower.split()[0]
all_known = set(COMMANDS) | set(skill_commands) | set(skill_bundles)
matches = [c for c in all_known if c.startswith(typed_base)]
if len(matches) > 1:
if typed_base in matches:
matches = [typed_base]
else:
# Unique shortest match wins: /qui -> /quit (5) over /quint-pipeline (15)
min_len = min(len(c) for c in matches)
shortest = [c for c in matches if len(c) == min_len]
if len(shortest) == 1:
matches = shortest
if len(matches) == 1 and matches[0] != typed_base:
# Expand to the full name, preserving arguments.
return self.process_command(matches[0] + cmd_original.strip()[len(typed_base):], redispatch=True)
if len(matches) > 1:
_cprint(f"{_ACCENT}{_t('cli.command.ambiguous', command=cmd_lower)}{_RST}")
_cprint(f"{_DIM}{_t('cli.command.did_you_mean_many', candidates=', '.join(sorted(matches)))}{_RST}")
else:
# Exact token with no handler (never re-dispatch the same token: recursion), or no match.
from hermes_cli.cli_unknown_command import unknown_command_lines
lead, pointer = unknown_command_lines(cmd_lower, all_known)
_cprint(f"\033[1;31m{lead}{_RST}")
_cprint(f"{_DIM}{_ACCENT}{pointer}{_RST}")
return True
def _drain_interrupt_queue_to_pending_input(self) -> None:
"""Move stray ``_interrupt_queue`` messages into ``_pending_input`` after every turn.
Busy-time input lands in ``_interrupt_queue`` and is only drained by the explicit
interrupt path; a turn that finishes naturally would otherwise strand it and the
CLI appears to hang. Never raises.
Called once at the end of every turn from ``process_loop``'s ``finally`` block. Catches and swallows
``Exception`` because the drain must never break the main loop. (#20271)
"""
try:
while not self._interrupt_queue.empty():
stray = self._interrupt_queue.get_nowait()
if stray:
self._pending_input.put(stray)
except Exception:
pass
def _on_reasoning(self, reasoning_text: str):
"""Callback for intermediate reasoning display during tool-call loops."""
if not reasoning_text:
return
self._reasoning_preview_buf = getattr(self, "_reasoning_preview_buf", "") + reasoning_text
self._flush_reasoning_preview(force=False)
# Inline tokens that bypass the destructive-slash confirmation modal (scripting, or
# when the modal can't be marshaled onto the app loop).
# A general escape hatch for non-interactive use (scripting/automation) and for the degraded path where
# the modal can't be marshaled onto the app loop — lets users self-serve without flipping
# approvals.destructive_slash_confirm in config. (Native Windows now drives the modal normally — see
# #33961.)
_DESTRUCTIVE_SKIP_TOKENS = frozenset({"now", "--yes", "-y"})
def run(self):
"""Run the interactive CLI loop with persistent input at bottom."""
if not self._claim_active_session("cli"):
return
self._tui_print_startup()
self._tui_init_run_state()
kb = self._tui_build_key_bindings()
layout, style = self._tui_build_layout(kb)
app = self._tui_build_application(layout, kb, style)
_disable_prompt_toolkit_cpr_warning(app)
app.after_render += self._pet_flush_kitty_frame
from hermes_cli.observability.shared_metrics_startup import cli_prompt_ready_handler
app.after_render += cli_prompt_ready_handler()
self._app = app
# Ghost status-bar lines on resize: pt's renderer scrolls the terminal after each
# paint, pushing chrome into scrollback where a column-shrink reflows it into
# duplicates. Wrapping _output_screen_diff keeps its reserve-space branch from firing.
try:
# Background: prompt_toolkit's renderer (renderer.py L232-242) explicitly moves the cursor to
# the bottom of the canvas after painting "to make sure the terminal scrolls up, even when the
# lower lines of the canvas just contain whitespace". In non-fullscreen mode this scrolls chrome
# content (status bar, input rules) into terminal scrollback on every render. When the terminal
# column-shrinks, the emulator reflows the previously rendered full-width rows into multiple
# narrower rows that get pushed up — leaving ghost duplicates AND polluting scrollback. Same
# issue as pt #29 (open since 2014), #1675, #1933. Surgical fix: wrap _output_screen_diff so
# that when its internal `if current_height > previous_screen.height` branch fires (the one that
# does the bottom-cursor-move), we make it fall through by inflating previous_screen.height
# first.
import prompt_toolkit.renderer as _pt_renderer
from prompt_toolkit.renderer import _output_screen_diff as _orig_osd
if not getattr(_pt_renderer, "_hermes_osd_patched", False):
_pt_renderer._output_screen_diff = functools.partial(
_hermes_call_output_screen_diff, _orig_osd
)
_pt_renderer._hermes_osd_patched = True
except Exception:
pass
_apply_bracketed_paste_timeout_patch()
self._install_resize_recovery(app)
threading.Thread(target=self._tui_spinner_loop, daemon=True).start()
threading.Thread(target=self._tui_process_loop, daemon=True).start()
# Wake word listener off-thread so a first-run engine install never blocks the prompt.
threading.Thread(target=self._tui_wake_startup, daemon=True, name="wake-startup").start()
atexit.register(_run_cleanup)
self._tui_install_signal_handlers()
if not self._tui_stdin_usable():
_run_cleanup()
self._print_exit_summary()
return
try:
with patch_stdout():
try:
# run_in_terminal() may return either: • a coroutine / Future (prompt_toolkit ≥ 3.0) —
# must be scheduled via ensure_future so the coroutine is actually awaited; calling it
# bare would leave it unawaited and silently drop the output (fixes #23185 Bug A). •
# None (some mocks / older PT builds) — just call the inner function directly since PT
# already executed it synchronously. Do NOT fall back to a bare _pt_print when
# ensure_future raises, because run_in_terminal already invoked the lambda in that case
# (the mock path), which would double-print the line.
import asyncio as _aio
_aio.get_running_loop().set_exception_handler(self._tui_suppress_closed_loop_errors)
except Exception:
pass # no running loop -- nothing to patch
# Record that the app enables focus reporting + mouse tracking so _run_cleanup
# resets them; extended key modes are popped by the same reset.
# When multiline shortcuts are on, also ask supported terminals (e.g. iTerm2) to report
# modified keys distinctly (kitty protocol + modifyOtherKeys); the cleanup reset pops both
# modes. See #36823.
_mark_tui_input_modes_active()
if self._tui_multiline_shortcuts:
_enable_extended_enter_keys(app.output)
self._pet_start_anim()
app.run()
except (EOFError, KeyboardInterrupt, BrokenPipeError):
pass
except (KeyError, OSError) as _stdin_err:
# Selector registration failures from broken stdin and I/O errors from a
# broken stdout during interrupt (EIO is suppressed).
_errno = getattr(_stdin_err, "errno", None) if isinstance(_stdin_err, OSError) else None
_msg = str(_stdin_err)
if _errno == errno.EIO:
pass
elif _errno in {errno.EINVAL, errno.EBADF} or any(
s in _msg for s in ("is not registered", "Bad file descriptor", "Invalid argument")
):
print(_t("cli.startup.stdin_unusable", error=str(_stdin_err)))
else:
raise
finally:
# A resize right before exit leaves its recovery (and the paints it held) unrun.
_release_paints()
self._tui_shutdown()
# /update relaunch happens here, after prompt_toolkit restored terminal modes, on the
# main thread (the process_loop thread would skip cleanup / only exit itself on Windows).
if self._pending_relaunch:
from hermes_cli.relaunch import relaunch
relaunch(self._pending_relaunch, preserve_inherited=False)
def _build_cli_from_args(model, toolsets, provider, reasoning, api_key, base_url, max_turns, run_budget, verbose, compact, resume, checkpoints, pass_session_id, ignore_rules, skills):
"""Resolve the toolset list (explicit / coding posture / platform default), construct HermesCLI, and start the background skills preload."""
toolsets_list = None
if isinstance(toolsets, str) and toolsets:
toolsets_list = [t.strip() for t in toolsets.split(",")]
elif isinstance(toolsets, (list, tuple)) and toolsets:
# Fire may pass multiple --toolsets as a tuple
toolsets_list = []
for t in toolsets:
toolsets_list.extend([x.strip() for x in t.split(",")] if isinstance(t, str) else [str(t)])
elif not toolsets:
# Coding posture inside a code workspace, else the shared platform resolver.
try:
from agent.coding_context import coding_selection
toolsets_list = coding_selection(platform="cli", config=CLI_CONFIG)
except Exception:
toolsets_list = None
if toolsets_list is None:
from hermes_cli.tools_config import _get_platform_tools
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
parsed_skills = _parse_skills_argument(skills)
try:
cli = HermesCLI(
model=model,
toolsets=toolsets_list,
provider=provider,
reasoning=reasoning,
api_key=api_key,
base_url=base_url,
max_turns=max_turns,
run_budget=run_budget,
verbose=verbose,
compact=compact,
resume=resume,
checkpoints=checkpoints,
pass_session_id=pass_session_id,
ignore_rules=ignore_rules,
)
except ImportError as e:
# Direct `python cli.py` bypasses cmd_chat's partial-update ImportError handler.
from hermes_constants import emit_partial_update_hint
if emit_partial_update_hint(e):
sys.exit(1)
raise
# skills.auto_load rides the same background preload as -s; --ignore-rules skips it with
# the rest of the auto-injected context. Resolved here (not lazily in the agent) so the
# session id is real for ${HERMES_SESSION_ID} and -s can dedupe against it.
from agent.skill_commands import build_auto_load_prompt, resolve_auto_load_skills
auto_load_names = [] if getattr(cli, "ignore_rules", ignore_rules) else resolve_auto_load_skills(CLI_CONFIG)
if not auto_load_names:
cli._auto_load_skills_result = ("", [], [])
if parsed_skills or auto_load_names:
# Load the skill payloads in the background: skill_view walks the full skills
# tree per skill (~0.5s for a large library) and the result is only consumed
# at agent init, not by the banner. finalize_preloaded_skills() joins the
# thread before any consumer reads cli.system_prompt.
def _load_preloaded_skills() -> None:
try:
if auto_load_names:
cli._auto_load_skills_result = build_auto_load_prompt(task_id=cli.session_id, user_config=CLI_CONFIG)
if parsed_skills:
cli._preload_skills_result = build_preloaded_skills_prompt(
parsed_skills, task_id=cli.session_id, excluded_loaded_names=set(cli._auto_load_skills_result[1]))
except Exception as exc: # surfaced by finalize
cli._preload_skills_error = exc
cli._preload_skills_requested = [*auto_load_names, *(s for s in parsed_skills if s not in auto_load_names)]
cli._preload_skills_thread = threading.Thread(target=_load_preloaded_skills, name="skills-preload", daemon=True)
cli._preload_skills_thread.start()
return cli
def _run_legacy_gateway():
"""Legacy `cli.py --gateway` entry: arm the startup watchdog (before importing the gateway graph), then run it."""
import asyncio
with suppress(Exception):
from hermes_startup_watchdog import arm_startup_watchdog
arm_startup_watchdog()
from gateway.run import start_gateway
print(_t("cli.gateway.starting"))
asyncio.run(start_gateway())
def _start_worktree_setup(list_tools, list_toolsets, worktree, w):
"""Start isolated-worktree creation (+ tool prewarm) in the background.
Returns a join callable that publishes ``_active_worktree``/TERMINAL_CWD and
schedules stale-worktree GC, or None when no worktree is wanted.
"""
if list_tools or list_toolsets or not (worktree or w or CLI_CONFIG.get("worktree", False)):
return None
# Overlap tool discovery with the I/O-bound worktree setup so show_banner() hits a warm
# cache (~0.4s). Only on the -w path: plain `hermes` has no I/O wait to hide.
def _prewarm_tools() -> None:
try:
import model_tools as _mt
_mt.get_tool_definitions(quiet_mode=True)
except Exception:
logger.debug("tool prewarm failed", exc_info=True)
threading.Thread(target=_prewarm_tools, name="tool-prewarm", daemon=True).start()
_sync_base = CLI_CONFIG.get("worktree_sync", True)
_wt_result: dict = {}
def _create_worktree() -> None:
try:
_wt_result["info"] = _setup_worktree(sync_base=_sync_base)
except Exception:
logger.debug("worktree setup failed", exc_info=True)
_wt_result["info"] = None
_wt_thread = threading.Thread(target=_create_worktree, name="worktree-setup", daemon=True)
_wt_thread.start()
def _worktree_maintenance(repo: str) -> None:
_prune_stale_worktrees(repo)
_maintain_pack_health(repo)
def _join_worktree() -> Optional[Dict[str, str]]:
_wt_thread.join(timeout=120)
info = _wt_result.get("info")
if not info:
return info
global _active_worktree
_active_worktree = info
os.environ["TERMINAL_CWD"] = info["path"]
atexit.register(_cleanup_worktree, info)
# GC stale worktrees AFTER _setup_worktree so they never race on git's worktree
# metadata (the new tree is immune: <24h age gate + live pid lock); then repack
# once refs are final so lookups stay fast on multi-agent boxes.
_repo = _git_repo_root()
if _repo:
threading.Thread(target=_worktree_maintenance, args=(_repo,), name="worktree-prune", daemon=True).start()
return info
return _join_worktree
def main(
query: str = None,
q: str = None,
oneshot: bool = False,
image: str = None,
toolsets: str = None,
skills: str | list[str] | tuple[str, ...] = None,
model: str = None,
provider: str = None,
reasoning: str = None,
api_key: str = None,
base_url: str = None,
max_turns: int = None,
run_budget: float = None,
verbose: Optional[bool] = None,
quiet: bool = False,
compact: bool = False,
list_tools: bool = False,
list_toolsets: bool = False,
gateway: bool = False,
resume: str = None,
worktree: bool = False,
w: bool = False,
checkpoints: bool = False,
pass_session_id: bool = False,
output_format: str = "text",
ignore_user_config: bool = False,
ignore_rules: bool = False,
):
"""
Hermes Agent CLI - Interactive AI Assistant
Args:
query: Query to run. On a real TTY this seeds an interactive session
(submitted literally as the first turn); with --oneshot/-Q or a
non-TTY it answers and exits. Alias: -q
q: Shorthand for --query
oneshot: With -q: force the legacy answer-and-exit single-query mode
even on a TTY.
image: Optional local image path to attach to a single query
toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal")
skills: Comma-separated or repeated list of skills to preload for the session
model: Model to use (default: anthropic/claude-opus-4-20250514)
provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn")
reasoning: Reasoning effort for this run (none|minimal|low|medium|high|xhigh|max|ultra). Overrides agent.reasoning_effort.
api_key: API key for authentication
base_url: Base URL for the API
max_turns: Maximum tool-calling iterations (default: 60)
verbose: Enable verbose logging
compact: Use compact display mode
list_tools: List available tools and exit
list_toolsets: List available toolsets and exit
resume: Resume a previous session by its ID (e.g., 20260225_143052_a1b2c3)
worktree: Run in an isolated git worktree (for parallel agents). Alias: -w
w: Shorthand for --worktree
Examples:
python cli.py # Start interactive mode
python cli.py --toolsets web,terminal # Use specific toolsets
python cli.py --skills hermes-agent-dev,github-auth
python cli.py -q "What is Python?" # Single query mode
python cli.py -q "Describe this" --image ~/storage/shared/Pictures/cat.png
python cli.py --list-tools # List tools and exit
python cli.py --resume 20260225_143052_a1b2c3 # Resume session
python cli.py -w # Start in isolated git worktree
python cli.py -w -q "Fix issue #123" # Single query in worktree
"""
# UTF-8 stdio on Windows before any print (Rich box-drawing would UnicodeEncodeError on cp1252).
with suppress(Exception):
from hermes_cli.stdio import configure_windows_stdio
configure_windows_stdio()
os.environ["HERMES_INTERACTIVE"] = "1" # terminal_tool: interactive sudo prompts with timeout
if gateway:
_run_legacy_gateway()
return
if not (list_tools or list_toolsets):
from hermes_cli.process_identity import register_self
from hermes_cli.shared_profile_warning import shared_profile_warning
register_self("cli")
warning = shared_profile_warning()
if warning:
print(_t("cli.startup.warning", warning=warning), file=sys.stderr)
_join_worktree = _start_worktree_setup(list_tools, list_toolsets, worktree, w)
query = query or q
# ``hermes chat`` already validated this; the direct Fire entry point gets the same contract.
if output_format == "stream-json":
if not query:
raise ValueError("--format stream-json requires -q/--query")
quiet = True
cli = _build_cli_from_args(model, toolsets, provider, reasoning, api_key, base_url, max_turns, run_budget,
verbose, compact, resume, checkpoints, pass_session_id, ignore_rules, skills)
# Join the background worktree creation before anything consumes TERMINAL_CWD.
# A requested worktree whose setup failed aborts: never silently run without isolation.
wt_info = _join_worktree() if _join_worktree is not None else None
if _join_worktree is not None and not wt_info:
return
# Inject worktree context into agent's system prompt
if wt_info:
wt_note = (
f"\n\n[System note: You are working in an isolated git worktree at "
f"{wt_info['path']}. Your branch is `{wt_info['branch']}`. "
f"Changes here do not affect the main working tree or other agents. "
f"Remember to commit and push your changes, and create a PR if appropriate. "
f"The original repo is at {wt_info['repo_root']}.]"
)
cli.system_prompt = (cli.system_prompt or "") + wt_note
if list_tools or list_toolsets:
cli.show_banner()
(cli.show_tools if list_tools else cli.show_toolsets)()
sys.exit(0)
atexit.register(_run_cleanup) # interactive mode registers again in run() (idempotent)
_install_single_query_signal_handlers(cli)
if query or image:
_run_single_query_mode(cli, query, image, quiet, oneshot, stream_json=output_format == "stream-json")
return
cli.run()
if __name__ == "__main__":
import fire
fire.Fire(main)