Внутреннее устройство CLI

Дополнение к hermes_cli/AGENTS.md (там правила) — здесь развёрнутые объяснения.

Конвейер обновления

Поведение, видимое пользователю (квитанции, --plan, режимы снапшота), описано в разделе Обновление; краткая форма правил лежит в hermes_cli/AGENTS.md.

Кампания обновления флота #91277 (август 2026). PR, который ослабляет какую-либо стадию, должен ответить за класс сбоя, от которого она страхует. plan → snapshot → apply → restart-per-kind → verify → report

  • Plan (update_inventory.py, hermes update --plan): инвентаризация только для чтения — тип установки, все профили, каждый живой шлюз с супервизором и версией работающего кода. Типы развёртывания — полноправные: git обновляется на месте; docker/nix/apt на месте обновить НЕЛЬЗЯ, и апдейтер сообщает правильную внешнюю команду, а не борется с моделью развёртывания.
  • Snapshot (backup.py): быстрый снапшот перед обновлением для КАЖДОГО профиля (замена кода и рестарт флота затрагивают все), каждый в свой state-snapshots/, одинаковый набор файлов, лимит 1 GiB на файл, keep=1. Никогда не вводите частичный или многоуровневый набор снапшотов: неравное покрытие порождает рассинхронизированные восстановления между поколениями схемы. Быстрые снапшоты — это ВОССТАНОВЛЕНИЕ ПОСЛЕ ПОТЕРИ ФАЙЛОВ (из них восстанавливается страховочная сетка cron-заданий каждого профиля), а НЕ страховка отката кода; откатом занимается полный режим --backup.
  • Apply: git pull или запасной путь через ZIP в Windows. Он срабатывает ТОЛЬКО когда упал сам git (_should_zip_fallback_on_update_error, классификация по argv; сбой установки зависимостей никогда не должен запускать повторную загрузку, затирающую дерево), ОТКАЗЫВАЕТСЯ работать с грязным рабочим деревом (-uall плюс повторная проверка TOCTOU перед заменой, но строку !! классифицирует по тому, уничтожит ли её замена: допускается игнорируемый путь под корневой записью, которой нет в ZIP (.bytecode-fingerprint, .hermes-bootstrap-complete, hermes_agent.egg-info/; до загрузки вместо набора из ZIP выступают отслеживаемые корневые записи, а повторная проверка получает настоящий), вложенный __pycache__/node_modules или вывод из _ZIP_PRESERVED_NESTED; прочие игнорируемые файлы в поставляемых каталогах по-прежнему блокируют) и прививает живые вложенные результаты сборки (_ZIP_PRESERVED_NESTED: apps/desktop/{release,dist,node_modules,build}, hermes_cli/web_dist, ui-tui/{dist,node_modules,packages/hermes-ink/dist}, web/node_modules, scripts/whatsapp-bridge/node_modules) в подготовленную замену жёсткими ссылками (в исходном ZIP с GitHub их нет; без прививки замена их удаляет). После замены решение о пересборке Desktop учитывает ещё и штамп сборки в HERMES_HOME, так что установка, которая уже потеряла свои артефакты при прошлом обновлении, пересобирается, а не «забывается» (#90495).
  • Restart-per-kind: рестарты systemd и launchd ОХВАТЫВАЮТ ВЕСЬ ФЛОТ обновляемой установки (каждый юнит hermes-gateway* / LaunchAgent ai.hermes.gateway*, чей home — корень обновляемой установки или один из её profiles/<name>), сначала со сливом (SIGUSR1), с изоляцией сбоев по юниту и метке. Если перезапустить только сервис вызвавшего профиля, соседи остаются на устаревшем sys.modules, пока не упадут: самый большой кластер дублирующихся PR в истории репозитория вырос из этой ошибки. Границы флота задаёт HOME, а не пространство имён: hermes_cli/update_fleet_scope.py судит каждый юнит, метку и процесс по тому home, на котором они реально работают (живое окружение, Environment= юнита, HERMES_HOME в plist), а рантайм другого HERMES_HOME на том же аккаунте (соседняя установка, настоящий hermes-gateway.service, увиденный из временного home) называется по имени и остаётся нетронутым, никогда не перезапускаясь (#93349).
  • Verify: шлюзы записывают code_sha/code_version в gateway_state.json при каждой записи статуса рантайма (gateway/status.py); апдейтер сравнивает каждый живой шлюз со свежим чекаутом и печатает матрицу версий флота. После точки фиксации (дерево сдвинулось) hermes update уже ничем не падает (код выхода 0, пока код не откатили): заведомо устаревший или остановленный шлюз, неудачные сборка, лаунчер, миграция конфига, зачистка байткода, переход на канал или возобновление в Windows печатают ⚠, попадают в квитанцию как outcome: "success" с followups: [{step, reason}] и сохраняют своё обязательство (source-completion-pending для хвоста, обязательство рестарта флота для шлюзов), так что следующий запуск или hermes update, в том числе с «Already up to date», повторяет их. Предупреждение при запуске CLI о неисполненном рестарте флота не даёт флоту с разными версиями когда-либо выглядеть здоровым. Процесс завершения, который не удалось запустить (временный каталог, файл запроса, spawn) или который умер без согласованного результата (OOM, SIGKILL), — долг того же рода: неисполненный follow-up completion с заново взведённым хвостом обновления исходников и код выхода 0. Код 2 (отказ или конкурентный запуск) и код 1 (ничего не зафиксировано или откат) сохраняют свой смысл; единственный зафиксированный запуск с кодом 1 — autostash, восстановление которого дало конфликт и остался припаркованным: квитанция получает partial с user_action, потому что то, что может заново применить только пользователь, никто не повторит (#122557).
  • Report: каждый запуск пишет машиночитаемую квитанцию в logs/update_receipts/ КОРНЕВОГО home (никогда не «липкого» профиля; update.log тоже) — указатель latest.json; шаги, пропуски С ПРИЧИНАМИ, исход рестарта, план, снимок флота, followups. Квитанция лежит на диске как outcome: "running" с самого начала и обновляется на границе каждой стадии; следующее обновление помечает запись running, чьих процессов уже нет, как interrupted и сообщает об этом. Читатели (hermes logs update, debug-бандл, статус дашборда, квитанции pm) тоже берут корневой home. Сбой синхронизации зависимостей в bootstrap завершения — это follow-up dependencies (A6), а Ctrl-C после точки фиксации завершает запись как interrupted (код 130), но не failed. Перед заменой исходников родитель сохраняет план, снимки, квитанцию и свой токен паузы в Windows. update_completion.py запускает подготовку PM из нового кода с отключённой инициализацией site, затем сборки выбранным Python, обслуживание, сканирование/рестарты и проверку. Git, current и ZIP делят этого владельца; никогда не перезагружайте и не вычищайте модули, чтобы продолжить в старом интерпретаторе. Родитель держит блокировку, ждёт и принимает только согласованный финальный результат. cmd_update по-прежнему закрывает ранние сбои и исходы с отсутствующим или убитым дочерним процессом; данные об отказе PM переживают передачу управления. См. website/docs/developer-guide/source-update-completion.md. Начатая, но не записанная квитанция — это баг.
  • Ничто не выполняет подтянутый код в интерпретаторе, запущенном до pull. Это дерево завершает обновления через update_completion.run_completion / _update_takeover.py. hermes_cli/update_handoff.py и hermes_cli/update_serve_obligations.py — ЗАМОРОЖЕННАЯ ПОВЕРХНОСТЬ СОВМЕСТИМОСТИ для релизов, которые лениво импортируют эти модули по имени из НОВОГО дерева после замены чекаута. Их публичные имена должны оставаться импортируемыми, а поведение — прежним. Они вызывают _old_updater.stop_for_relaunch → _run_child. Удаление любого имени «кирпичит» каждый релиз посреди обновления. См. tests/compat/old_updater_surface.json.

Координацию сканирования процессов между апдейтером, serve/дашбордом и шлюзом заменяет управляющий сокет, которым владеет шлюз (#92091); сканирование остаётся запасным слоем для старых и упавших процессов, так что прочитайте #92091, прежде чем добавлять любую эвристику. Правила определения процесса (никогда не подстроки argv; канонические матчеры; наборы флагов, выведенные из парсера; никогда не исключать предков-шлюзов огульно, #87594) описаны в корневом AGENTS.md и в website/docs/developer-guide/cli-internals.md.

Грубый рестарт через systemd ждёт TimeoutStopUSec юнита плюс TimeoutStartUSec, добавляя 15 секунд запаса на стороне клиента. Целевой юнит читается в той же области менеджера, в которой идёт рестарт; этот бюджет действует и на первую попытку, и на повторную — включая догоняющий рестарт после прерванного обновления. Запуск после мягкого слива берёт только бюджет старта плюс запас. Если лимит фазы отсутствует, не разбирается или бесконечен, для неё берутся 90 секунд — так автоматические обновления остаются ограниченными по времени. Таймаут клиента systemctl транзакцию менеджера не отменяет. Самодельные цепочки из нескольких команд остановки или EXTEND_TIMEOUT_USEC всё ещё способны пережить эту оценку; настоящий таймаут по-прежнему означает незавершённый рестарт, а успешные команды всё так же требуют прежней проверки здоровья сервиса и версий по парку. Сырые числовые значения *USec — это микросекунды, а отформатированные идут в фиксированных единицах systemd, включая дни, недели, месяцы и годы. Суммарный таймаут ограничен снизу от нативного предела опроса в миллисекундах для знакового 32-битного числа (с запасом на округление), поэтому исключительно долгие лимиты юнита опрос подпроцесса не переполнят. Нулевые, неизвестные и бесконечные лимиты фаз работают по ограниченному запасному значению. Настройки слива активного хода это не меняет.

Вход в бесплатный тариф Nous

Завершение входа — одна функция, settle_after_upgrade; её вызывает каждый, кто сохраняет аккаунт поверх идентичности бесплатного тарифа (CLI upgrade_guest, поллер десктопа): она переводит конфиг с приветственного маршрута на хост аккаунта и рекомендованную модель по умолчанию для этого тарифа (models.recommended_nous_default_model, общую с GET /api/model/recommended-default).

Общий поток, состояния и тексты лежат в anon_sign_in.py; вывод в CLI — в anon_sign_in_cli.py. anon_auth.py хранит идентичность, опрос повышения и расчёт, а существующий API входа реэкспортирует. Поток получает коллабораторы идентичности и сохранения через anon_auth в момент вызова, чтобы не ломались точки подмены атрибутов модуля через monkeypatch.

Сам вход — это одна композиция: anon_auth.run_sign_in() выдаёт состояния SignInState (Code, Waiting, Completed, Declined, Superseded, TimedOut, Retired, Failed, AlreadySignedIn, Unavailable). Она сама читает текущее состояние, держит один абсолютный дедлайн на оба ожидания, сохраняет только после завершённого повышения и выдачи токена, вызывает settle_after_upgrade ровно один раз на завершение и никогда не выпускает сбой сохранения или расчёта исключением — он превращается в Failed. Каждое состояние несёт свои .copy (форма для чата, в которой никогда нет сырого исключения, URL или команды hermes) и .copy_terminal, так что ни один вызывающий не сопоставляет причину со строкой. cancelled() останавливает попытку; cancel_wins_after_promotion решает, что делать, если сервер уже завершил перенос: десктоп оставляет True (DELETE означает «не на этой машине»), шлюз передаёт False (замена не должна отбрасывать перенос, который пользователь действительно одобрил). scope открывается только вокруг блоков предусловия и сохранения, никогда не через yield или сетевое ожидание, потому что run_in_executor не переносит contextvars. upgrade_guest (hermes auth upgrade), обработчик /login в CLI и поллер повышения в десктопе — это рендереры поверх неё; поверхность, которой нужна атомарность проверки отмены и сохранения, передаёт persist_guard. Простой вход десктопа «подключить ещё один аккаунт Nous» по коду устройства — отдельный путь (_nous_plain_poller), и он должен таким остаться.

Определение процесса: никогда не выводить его из подстрок argv

Класс ошибок, породивший около десятка проблем с обновлением флота (#90778, #87594, #78089, #76129, #91964, …): классификация процесса по условию вида "serve" in cmdline. В kanban --preserve-cache есть «serve»; ЗНАЧЕНИЕ флага может совпадать с подкомандой (-m dashboard serve); обрезанные командные строки прячут настоящую подкоманду. Правила:

  • Пользуйтесь каноническими матчерами: gateway.status.looks_like_gateway_command_line (запуск шлюза), hermes_cli.update_cmd._hermes_holder_subcommand (подкоманда верхнего уровня в любом argv Hermes). Самодельные переборы токенов недопустимы.
  • Наборы флагов ВЫВОДЯТСЯ из парсера (_holder_value_flags() интроспектирует build_top_level_parser()), а не выписываются руками — рукописные списки разъезжаются с реальностью.
  • Не исключайте предков из сканирования процессов огульно: когда /update запущен дочерним процессом шлюза, предок-шлюз обязан оставаться видимым для механики паузы (#87594). Исключайте интерактивное происхождение, оставляя предков, похожих на шлюз.
  • Сопоставляйте ПОЛНЫЕ командные строки; обрезайте только при выводе на экран (#78089).
  • Прежде чем добавлять новую эвристику сканирования, прочитайте #92091: основным механизмом координации становится управляющий сокет шлюза, а сканирование остаётся запасным слоем для старых и упавших процессов.

Движок скинов — что именно настраивается

ЭлементКлюч скинаКто использует
Рамка панели баннера / заголовок / заголовки секций / приглушённый текст / телоcolors.banner_border, banner_title, banner_accent, banner_dim, banner_textbanner.py
Рамка блока с ответомcolors.response_bordercli.py
Мордочки спиннера (ожидание / размышление)spinner.waiting_faces, spinner.thinking_facesdisplay.py
Глаголы и «крылья» спиннера (необязательно)spinner.thinking_verbs, spinner.wingsdisplay.py
Префикс вывода инструмента / эмодзи по инструментамtool_prefix, tool_emojisdisplay.py → get_tool_emoji()
Имя агента / приветствие / подпись ответа / символ приглашенияbranding.agent_name, welcome, response_label, prompt_symbolbanner.py, cli.py

Встроенные скины (_BUILTIN_SKINS в hermes_cli/skin_engine.py): default (классическое золото в стиле kawaii), ares (багрянец с бронзой и своими «крыльями» спиннера), mono (оттенки серого), slate (холодный синий). Встроенный скин добавляется словарём {"name", "description", "colors", "spinner", "branding", "tool_prefix"}. Пользовательские скины лежат в ~/.hermes/skins/<name>.yaml с теми же ключами и включаются командой /skin <name> или настройкой display.skin: <name>; полный YAML-шаблон приведён в руководстве Скины и темы.

Профили: несколько экземпляров сразу

Hermes поддерживает профили — полностью изолированные экземпляры, у каждого свой HERMES_HOME (конфигурация, API-ключи, память, сессии, навыки, шлюз). Для команд с одним профилем (hermes -p x <cmd>) функция _apply_profile_override() в hermes_cli/main.py выставляет HERMES_HOME до импорта любых модулей, поэтому каждое обращение к get_hermes_home() попадает в область активного профиля. Мультиплексирующий шлюз и бэкенд serve для десктопа и дашборда устроены иначе: они обслуживают несколько профилей из одного процесса. Активный профиль там — переопределение через contextvar, привязанное к каждой отдельной активности; os.environ["HERMES_HOME"] остаётся у профиля запуска, а константа уровня модуля, выведенная из домашнего каталога, замораживается на этом профиле запуска (см. Внутреннее устройство шлюза, раздел «Мультиплексированные профили»). Операции с профилями привязаны к домашнему каталогу пользователя (_get_profiles_root() возвращает Path.home() / ".hermes" / "profiles", а не get_hermes_home() / "profiles"), так что hermes -p coder profile list видит все профили независимо от активного — это сделано намеренно. Правила безопасного для профилей кода — в корневом AGENTS.md, правила областей видимости секретов при мультиплексе — в gateway/AGENTS.md.

перевод и редактура:

обновлено

⌕ ESC