* 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>
1797 lines
78 KiB
Python
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)
|