Files
hermes-agent/hermes_cli/AGENTS.md
teknium1 0abfd1105c fix(migrate): hermes update refuses to fold cross-user / cross-scope gateways; auto_multiplex_migration opt-out
Reshape the two salvaged commits onto current main (#109954):

- Move the boundary guard out of the gateway_migrate facade into a new sibling
  hermes_cli/gateway_migrate_guards.py as a table of guard functions
  (_AUTO_MIGRATION_GUARDS: service domain, UNIX user, HERMES_HOME tree) plus the
  identity resolver. The facade grows by ~20 lines only (uid/runtime_home on
  ProfileGateway, one seam, the hook wiring).
- Compare uids, not strings: live pid owner via /proc (ps fallback only on
  macOS, where /proc does not exist), else the system unit's User= via
  _read_systemd_user_from_unit (root when absent), else the home directory's
  owner. None means unknown and never blocks.
- The home-tree guard reads the HERMES_HOME the installed unit pins, not the
  directory the plan enumerated: that is where the gateway really runs and is
  exactly the "stale copies under profiles/" shape from the report.
- When the default is detached, a service-managed secondary is a different
  domain for the AUTO path (it must not elect the secondary's manager); the
  explicit command keeps electing it as before.
- The explicit command surfaces the same findings as notices (dry run shows
  them) and is never blocked by them; only the update hook refuses.
- Rename the opt-out key to gateway.auto_multiplex_migration (nested only, no
  top-level alias) and read it before a plan is built, so false prints nothing
  and touches nothing. The explicit command ignores it.
- Tests trimmed to the invariants: one parametrized boundary test that exercises
  the real hook end to end (refuses, touches nothing, dry run shows the notice),
  one "same user / same scope still migrates" control, one opt-out test.
- Docs: boundary table + renamed opt-out section in multi-profile-gateways.md;
  one line in hermes_cli/AGENTS.md.

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: Athena <athena@olympus.local>
2026-09-13 14:48:09 -07:00

14 KiB

hermes_cli/ + cli.py — CLI, slash commands, config, skins, updater, profiles

Applies on top of the root AGENTS.md. Long-form: website/docs/developer-guide/cli-internals.md.

CLI architecture

cli.py holds HermesCLI (REPL loop, config, slash dispatch); behaviour lives in mixins hermes_cli/cli_commands_mixin.py, cli_stream_mixin.py, cli_status_bar_mixin.py, cli_billing_mixin.py, cli_tui_mixin.py, ... Rich renders banner/panels; prompt_toolkit handles input + autocomplete; KawaiiSpinner (agent/display.py) animates API calls and prints the ┊ activity feed. load_cli_config() in cli.py merges CLI defaults + user YAML. process_command() resolves the canonical name via resolve_command() then dispatches through HermesCLI._SLASH_DISPATCH (canonical -> (method name, pass_arg)), falling back to a _handle_<name>_command method by naming convention. There is no elif ladder — do not add one. Skill slash commands (agent/skill_commands.py) scan ~/.hermes/skills/ and inject as a user message, never into the system prompt (prompt caching).

Rules: all interactive menu-pickers use curses (hermes_cli/curses_ui.py; example hermes_cli/tools_config.py). Never emit \033[K (ANSI erase-to-EOL) in spinner/display code — it leaks as literal ?[K under prompt_toolkit's patch_stdout; space-pad instead: f"\r{line}{' ' * pad}". Wrapper CLIs extend via the protected hooks in cli_tui_mixin.py (website/docs/developer-guide/extending-the-cli.md), not by overriding run().

Slash command registry (hermes_cli/commands.py)

COMMAND_REGISTRY (list of CommandDef) is the single source; everything derives from it: CLI dispatch (resolve_command()), gateway GATEWAY_KNOWN_COMMANDS + dispatch, gateway_help_lines(), telegram_bot_commands() (BotCommand menu), slack_subcommand_map(), COMMANDS (autocomplete), COMMANDS_BY_CATEGORY (show_help()). Fields: name (no slash), description, category (Session | Configuration | Tools & Skills | Info | Exit), aliases tuple, args_hint, cli_only, gateway_only, gateway_config_gate (config dotpath; a cli_only command becomes gateway-available when truthy — GATEWAY_KNOWN_COMMANDS always includes gated commands so the gateway can dispatch them; help/menus show them only when the gate is open).

Adding a command: (1) CommandDef("mycommand", "What it does", "Session", aliases=("mc",), args_hint="[arg]") in COMMAND_REGISTRY; (2) _handle_mycommand_command(self, cmd_original) on the relevant cli_*_mixin.py (picked up by convention) or an explicit _SLASH_DISPATCH entry "mycommand": ("_handle_mycommand", True) when the method name/arg-passing differs; (3) for the gateway, _handle_mycommand_command(self, event) on the matching gateway/slash_commands_*.py mixin and list it in _IDLE_COMMANDS (or _PLAIN_COMMANDS if it must work mid-run) in gateway/run_busy.py — handlers resolve by name via _command_handler_table; (4) persistent settings via save_config_value() in cli.py. Adding an alias = add to aliases; every surface updates automatically. Commands that mutate system-prompt state default to deferred invalidation with --now opt-in (root invariant).

Shared goal commands

hermes_cli/goal_command.py::dispatch_goal_command owns /goal parsing and manager mutations. CLI, messaging gateway, TUI/Desktop/dashboard and Desktop goal controls all delegate there; adapters only resolve sessions, authorize gate creation, render results, and schedule kickoff/continuation prompts. Async callers preserve ContextVars when running dispatch off-loop (drafting uses profile-scoped auxiliary credentials). Do not add a surface-specific goal parser. ACP has no goal command or goal loop yet.

Config system (hermes_cli/config.py)

  • config.yaml option: add to DEFAULT_CONFIG. Bump _config_version ONLY to actively migrate/transform existing config (rename keys, restructure); new keys deep-merge automatically. Top-level sections (non-exhaustive): model, agent, terminal, compression, display, stt, tts, memory, security, delegation, smart_model_routing, checkpoints, auxiliary, curator, skills, gateway, logging, cron, profiles, plugins, honcho. auxiliary = per-task side-LLM overrides (agent/AGENTS.md); curator = enabled, interval_hours, min_idle_hours, stale_after_days, archive_after_days, backup.*.
  • .env = SECRETS ONLY (keys, tokens, passwords): add to OPTIONAL_ENV_VARS with {"description", "prompt", "url", "password": True, "category": provider|tool|messaging|setting}. Non-secret settings go in config.yaml; if internal code needs an env mirror, bridge it in code (gateway_timeout; terminal.cwd → TERMINAL_CWD). MESSAGING_CWD is removed and TERMINAL_CWD in .env is deprecated — the loader warns; canonical is terminal.cwd.
  • Three loaders — know which you're in: load_cli_config() (CLI, cli.py); load_config() (hermes tools/setup, most subcommands, hermes_cli/config.py, merges DEFAULT_CONFIG); hermes_cli/config_effective.py::load_user_config_effective() (gateway runtime via gateway/run.py::_load_gateway_config, TUI gateway _load_cfg, cron, hermes send, doctor, hermes_time/hermes_logging: user file + managed overlay + ${VAR} expansion + model-key canon, NO defaults — for presence-sensitive readers). If the CLI sees a key and the gateway doesn't (or vice versa), you're on the wrong loader — check DEFAULT_CONFIG coverage. Never hand-roll raw-read → overlay → expand; read_user_config_raw is for write-back round-trips only.
  • Working directory: CLI uses os.getcwd(); messaging uses terminal.cwd, bridged to TERMINAL_CWD for child tools.

Skin engine (hermes_cli/skin_engine.py)

Skins are pure data (SkinConfig); no code change to add one. init_skin_from_config() reads display.skin at startup; get_active_skin() (cached), set_active_skin(name) (/skin), load_skin(name) (user ~/.hermes/skins/*.yaml → built-ins → default; missing values inherit from default). Built-ins in _BUILTIN_SKINS: default, ares, mono, slate. Keys: colors.* (banner border/title/accent/dim/text, response_border), spinner.* (waiting/thinking faces, thinking_verbs, wings), tool_prefix, tool_emojis, branding.* (agent_name, welcome, response_label, prompt_symbol). Consumers: banner.py, display.py, cli.py. Key-by-key table and YAML template: website/docs/user-guide/features/skins.md.

Update pipeline (hermes update) — transactional; every stage guards a real field failure

Fleet-update campaign #91277 (Aug 2026). A PR that weakens a stage must answer for the failure class it guards. plan → snapshot → apply → restart-per-kind → verify → report

  • Plan (update_inventory.py, hermes update --plan): read-only inventory — install kind, all profiles, every live gateway with supervisor + running code version. Deployment kinds are first-class: git updates in place; docker/nix/apt are NOT in-place-updatable and the updater reports the correct external command instead of fighting the deployment model.
  • Snapshot (backup.py): pre-update quick snapshot for EVERY profile (the code swap + fleet restart touch all of them), each into its own state-snapshots/, identical file set, 1 GiB per-file cap, keep=1. Never add a partial/tiered snapshot set — mixed coverage creates torn-restore states across schema generations. Quick snapshots are FILE-LOSS RECOVERY (the per-profile cron-jobs safety net restores from them), NOT code-rollback insurance; --backup full mode owns rollback.
  • Apply: git pull, or the Windows ZIP fallback — which fires ONLY when git itself failed (_should_zip_fallback_on_update_error, argv-classified; a dependency-install failure must never trigger a tree-clobbering re-download), REFUSES a dirty working tree (-uall + a pre-swap TOCTOU re-check), and grafts the live apps/desktop/release/ into the staged swap (the GitHub source ZIP has no built desktop app; without the graft the swap deletes it).
  • Restart-per-kind: systemd and launchd restarts are FLEET-WIDE (every hermes-gateway* unit / ai.hermes.gateway* LaunchAgent), drain-first (SIGUSR1), with per-unit/per-label failure isolation. Restarting only the invoking profile's service leaves siblings on stale sys.modules until they crash — the largest dupe-PR cluster in the repo's history came from that bug.
  • Verify: gateways stamp code_sha/code_version into gateway_state.json on every runtime-status write (gateway/status.py); the updater compares each live gateway against the fresh checkout and prints a fleet version matrix. A provably-stale gateway fails the update (exit 1) — automation must never treat a mixed-version fleet as healthy.
  • Report: every run writes a machine-readable receipt to ~/.hermes/logs/update_receipts/ (latest.json pointer; steps, skips WITH reasons, restart outcome, plan, fleet snapshot). Finalization is owned by the cmd_update command boundary — early sys.exit paths (preflight refusals, fetch failures) still persist a receipt with the real exit code. A begun-but-unwritten receipt is a bug: refused/failed runs are the ones receipts exist for.

Process-scan coordination between updater, serve/dashboard, and gateway is being replaced by a gateway-owned control socket (#92091); scans are the fallback layer for old/crashed processes — read #92091 before adding any heuristic. Process identity rules (never argv substrings; canonical matchers; parser-derived flag sets; never blanket-exclude gateway ancestors, #87594): root AGENTS.md and website/docs/developer-guide/cli-internals.md.

Profiles (multi-instance)

_apply_profile_override() in hermes_cli/main.py sets HERMES_HOME before any module import, so every get_hermes_home() scopes to the active profile (rules in root). Profiles are independent islands by design — no live config inheritance; --clone copies at creation, minus messaging channels (profile_channels.py: ownership-based inventory evaluated in the SOURCE's plugin scope — adapter-declared keys + canonical/alias prefixes + GATEWAY_ALLOW*/GATEWAY_RELAY_*; prefixes shared with tools (HASS_/TWILIO_/EMAIL_) are stripped only when the source runs that adapter; never a hand list). --clone-channels opts in and its live-multiplexer refusal lives in create_profile (CLI, REST and TUI all go through it). Clones are built in profiles/.<name>.staging-<pid> (hidden → invisible to _iter_named_profile_dirs and the hot-serve rescan) and published by one os.rename after the strip; symlinked .env/config.yaml are materialized first so a clone never writes through to its source. Multiplex (gateway.multiplex_profiles) secret-scope rules: gateway/AGENTS.md. The served set is profiles.py::profiles_to_serve(multiplex=True) = default + every live (non-tombstoned) dir under profiles/ — there is no allowlist (gateway.multiplex_profile_allowlist was retired in config v43). Enumeration is a pure read: never mkdir a profile home from a served path (SessionDB, logging, cron all go through mkdir_under_hermes_home / _ensure_cron_dir, which refuse a deleted or missing named profile, #94590). Process-global per-profile slots (MCP discovery in mcp_startup.py, tool registry overlays) key on hermes_constants.hermes_home_key(), never a single flag. Migration from per-profile gateways: hermes_cli/gateway_migrate.py (hermes gateway migrate --multiplex|--standalone, table-driven _PREFLIGHT_CHECKS, manifest <default>/gateway_migration.json); update_cmd_fleet._verify_fleet_after_update calls maybe_auto_migrate_after_update on the success path only; gateway_migrate_guards.py holds the auto-path-only refusals (table _AUTO_MIGRATION_GUARDS: other service domain / UNIX user / HERMES_HOME outside profiles/ — notices for the explicit command, blockers for the hook) and the gateway.auto_multiplex_migration opt-out (#109954). Blockers reuse GatewayRunner._adapter_credential_fingerprint and platform_binds_port; "has a /p/<profile>/ ingress" is the adapter class attribute serves_profile_prefix — set it on a new HTTP-inbound adapter when it answers the prefix, never extend a list here.

Nous free tier (hermes_cli/anon_auth.py)

Sign-in completion is one function, settle_after_upgrade, called by every caller that persists an account over a free-tier identity (CLI upgrade_guest, the desktop poller): it moves a config on the welcome route to the account's host and the tier's recommended default (models.recommended_nous_default_model, shared with GET /api/model/recommended-default).

The shared flow, states, and copy live in anon_sign_in.py; CLI rendering lives in anon_sign_in_cli.py. anon_auth.py keeps identity, promotion polling, and settlement, and re-exports the existing sign-in API. The flow resolves identity and persistence collaborators through anon_auth at call time to preserve module-attribute monkeypatch seams.

The sign-in itself is one composition: anon_auth.run_sign_in() yields SignInStates (Code, Waiting, Completed, Declined, Superseded, TimedOut, Retired, Failed, AlreadySignedIn, Unavailable). It reads the current state itself, holds one absolute deadline across both waits, persists only after a completed promotion and a token grant, runs settle_after_upgrade exactly once per completion, and never lets a persist or settle failure escape as an exception — it becomes Failed. Every state carries its own .copy (the chat form, which never contains a raw exception, a URL or a hermes verb) and .copy_terminal, so no caller maps a reason to a string. cancelled() stops an attempt; cancel_wins_after_promotion decides what happens when the server had already completed the transfer — the desktop keeps True (a DELETE means "not on this machine"), the gateway passes False (a supersede must not discard a transfer the user actually approved). scope is entered only around the precondition and persist blocks, never across a yield or a network wait, because run_in_executor does not carry contextvars. upgrade_guest (hermes auth upgrade), the CLI /login handler and the desktop promotion poller are renderers over it; a surface that needs the cancel check and the save to be atomic passes persist_guard. The desktop's plain "connect another Nous account" device-code login is a separate path (_nous_plain_poller) and must stay one.