Внутреннее устройство 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*/ LaunchAgentai.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-upcompletionс заново взведённым хвостом обновления исходников и код выхода 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-updependencies(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_text | banner.py |
| Рамка блока с ответом | colors.response_border | cli.py |
| Мордочки спиннера (ожидание / размышление) | spinner.waiting_faces, spinner.thinking_faces | display.py |
| Глаголы и «крылья» спиннера (необязательно) | spinner.thinking_verbs, spinner.wings | display.py |
| Префикс вывода инструмента / эмодзи по инструментам | tool_prefix, tool_emojis | display.py → get_tool_emoji() |
| Имя агента / приветствие / подпись ответа / символ приглашения | branding.agent_name, welcome, response_label, prompt_symbol | banner.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.