TUI и десктоп из worktree

Python-ядро прекрасно работает из любого git worktree: зашли туда через cd, набрали hermes — и всё. С двумя TypeScript-интерфейсами так не выйдет: ui-tui/ и apps/desktop/ требуют заполненный node_modules, а свежий npm ci на каждый worktree работает долго и раскладывает гигабайты копий по всем веткам, что у вас выкачаны.

Эту дыру закрывают два шелл-хелпера — htui и hgui. Каждый запускает свой интерфейс из текущего worktree, одалживая node_modules у одного канонического чекаута. В итоге временная ветка стоит символической ссылки, а не установки.

Это удобства для разработчика, а не поставляемые команды. Положите их в ~/.zshrc, пути подгоните под себя.

Модель общих зависимостей

Один чекаут назначается чекаутом зависимостей — единственным местом, где вы реально запускаете npm install. Остальные worktree линкуются на него и переустанавливают пакеты локально только тогда, когда их lock-файл разошёлся: ветка, поднявшая версию зависимости, не должна молча собираться на устаревших пакетах.

flowchart TD
    A[htui / hgui в worktree] --> B{package-lock.json<br/>совпадает с чекаутом зависимостей?}
    B -- да --> C[символическая ссылка на node_modules<br/>из чекаута зависимостей]
    B -- нет --> D[локальный npm ci<br/>в этом worktree]
    C --> E[запуск интерфейса]
    D --> E

Канонический чекаут задаётся двумя переменными окружения:

ПеременнаяЧто означает
HERMES_MAIN_CHECKOUTЧекаут зависимостей: там по-настоящему лежит node_modules, и оттуда же .venv/bin/python запускает бэкенд.
HERMES_GUI_DEPS_CHECKOUTГде лежат зависимости десктопа (apps/desktop/node_modules). По умолчанию совпадает с HERMES_MAIN_CHECKOUT; переопределяйте, только если держите их отдельно.

Сам Hermes ни ту, ни другую не читает — они приватны для этих хелперов. Переменные, которые Hermes действительно читает, описаны в справочнике по переменным окружения.

htui — TUI из worktree

У Ink-TUI уже есть путь для разработки: hermes --tui --dev запускает TypeScript-исходники через tsx, минуя собранный бандл. htui — однострочная обёртка над ним, которая дополнительно нацеливает запуск на ui-tui/ текущего worktree:

htui() {
  local root
  root="$(_hermes_root)" || { echo "htui: not in a Hermes checkout" >&2; return 1; }
  ( cd "$root" && PYTHONPATH="$root" \
      "$HERMES_MAIN_CHECKOUT/.venv/bin/python" -m hermes_cli.main --tui --dev "$@" )
}

Флаг --dev собирает из исходников, поэтому при совпадении корневого lock-файла хелпер линкует ui-tui/node_modules из HERMES_MAIN_CHECKOUT, а иначе ставит пакеты локально (см. _hermes_root и хелперы линковки).

внимание

--dev и HERMES_TUI_DIR несовместимы HERMES_TUI_DIR указывает Hermes на собранный бандл (Nix, системные пакеты), у которого нет исходников для горячей перезагрузки. Если переменная выставлена в вашем шелле, hermes --tui --dev завершится с ошибкой. Сделайте unset HERMES_TUI_DIR перед htui.

hgui — десктопное приложение из worktree

Десктопному приложению нужны зависимости и в корне репозитория, и в apps/desktop/, сервер Vite и Python-бэкенд. Штатный npm run dev прибивает Vite к порту 5174; Electron к тому же по умолчанию берёт порт CDP 9222 и ставит блокировку единственного экземпляра на свой каталог user-data. Сменить один лишь порт Vite для запуска двух десктопов недостаточно.

Этот пример для zsh выдаёт каждому запуску явный слот (HGUI_SLOT, по умолчанию 0). В каждом терминале используйте свой слот. Пример опирается на общие хелперы ниже и требует lsof:

hgui() (
  local root deps desktop slot="${HGUI_SLOT:-0}" vite_port cdp_port port
  [[ "$slot" == [0-9] ]] || { print -u2 'hgui: HGUI_SLOT must be 0-9'; return 1; }
  vite_port=$((5174 + slot))
  cdp_port=$((9222 + slot))
  for port in "$vite_port" "$cdp_port"; do
    if lsof -nP -t -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
      print -u2 "hgui: port $port is busy; choose another HGUI_SLOT"
      return 1
    fi
  done

  root="$(_hermes_root)" || { print -u2 'hgui: not in a Hermes checkout'; return 1; }
  deps="${HERMES_GUI_DEPS_CHECKOUT:-$HERMES_MAIN_CHECKOUT}"
  desktop="$root/apps/desktop"

  if cmp -s "$root/package-lock.json" "$deps/package-lock.json"; then
    _hermes_link_deps "$desktop" "$deps/apps/desktop" || return 1
    _hermes_link_deps "$root" "$deps" || return 1
  else
    ( cd "$root" && npm ci ) || return 1
  fi

  cd "$desktop" || return 1
  export PATH="$desktop/node_modules/.bin:$root/node_modules/.bin:$PATH"
  export HERMES_DESKTOP_HERMES_ROOT="$root"
  export HERMES_DESKTOP_PYTHON="$HERMES_MAIN_CHECKOUT/.venv/bin/python"
  export HERMES_DESKTOP_CWD="$root"
  export HERMES_DESKTOP_DEV_SERVER="http://127.0.0.1:$vite_port"
  export HERMES_DESKTOP_CDP_PORT="$cdp_port"
  export HERMES_DESKTOP_USER_DATA_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/hermes-hgui/slot-$slot"
  # Иначе переопределение userData переселило бы и домашний каталог агента.
  export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
  export XCURSOR_SIZE=24

  # Повторяем dev-скрипты, заменяя их фиксированные порты. Правки в репозитории не нужны.
  concurrently -k -n "vite,electron" \
    "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port $vite_port --strictPort" \
    "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:$vite_port && node scripts/bundle-electron-main.mjs --dev && electron ."
)

Например, задав HERMES_MAIN_CHECKOUT и подключив хелперы через source:

# Терминал 1: основной чекаут
cd "$HERMES_MAIN_CHECKOUT"
HGUI_SLOT=0 hgui

# Терминал 2: существующий worktree
cd /path/to/hermes-worktree
HGUI_SLOT=1 hgui

Слот 0 занимает порты 5174/9222, слот 1 — 5175/9223. Слоты назначает вызывающий, атомарно они не резервируются: для одновременных запусков всегда берите разные слоты. Занятые порты отклоняются, а не освобождаются принудительно. Для разных сборок используйте отдельные чекауты — запуски из одного чекаута всё равно делят результаты сборки.

ПеременнаяРоль в hgui
HGUI_SLOTНомер слота только для хелпера, 0–9; не настройка Hermes.
HERMES_DESKTOP_HERMES_ROOTЗапускает бэкенд из этого worktree, а не из упакованного или найденного в PATH рантайма.
HERMES_DESKTOP_PYTHONПереиспользует Python-окружение основного чекаута. Поправьте для установки, где вместо .venv используется venv.
HERMES_DESKTOP_CWDНовая работа в десктопе начинается с корнем в worktree.
HERMES_DESKTOP_DEV_SERVERУказывает Electron на сервер Vite этого экземпляра.
HERMES_DESKTOP_CDP_PORTДаёт каждому экземпляру свой порт отладки рендерера.
HERMES_DESKTOP_USER_DATA_DIRРазделяет блокировку единственного экземпляра Electron, хранилище браузера и настройки десктопа.
HERMES_HOMEЯвно сохраняет домашний каталог агента, несмотря на переопределение user-data в Electron.

Каждый слот стартует со свежими настройками десктопа и запоминает их для следующих запусков. Хранилище браузера, сохранённую навигацию и владение бэкендом из уже работающего приложения этот пример не копирует.

внимание
Отдельные десктопы — не отдельные данные агента

HERMES_HOME по умолчанию общий: сессии, конфигурация, учётные данные и профили остаются теми же. Не редактируйте один и тот же разговор из обоих экземпляров. Для разрушительных тестов или несовместимых миграций базы передайте отдельный временный HERMES_HOME и настройте эту песочницу независимо.

Выходите из приложения штатно или нажмите Ctrl-C в терминале, из которого оно запущено. concurrently -k сам управляет своими дочерними командами, а Electron сам завершает свой бэкенд. Не добавляйте глобальный killport, pkill electron или зачистку всех процессов serve/dashboard --port 0: они способны убить другой экземпляр. Если заменяете прежнюю версию этого хелпера, уберите старую ловушку _hermes_gui_cleanup.

Общие хелперы

Обе функции одинаково определяют объемлющий чекаут и линкуют зависимости:

# Объемлющий worktree с проверкой, что это настоящий чекаут Hermes.
_hermes_root() {
  local root
  root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
  [[ -f "$root/hermes_cli/main.py" && -d "$root/ui-tui" ]] && print -r "$root"
}

# Символическая ссылка на node_modules из чекаута зависимостей — никогда поверх существующего дерева.
_hermes_link_deps() {
  local target="${1%/}" source="${2%/}"
  [[ -d "$source/node_modules" ]] || return 1
  [[ -e "$target/node_modules" ]] || ln -s "$source/node_modules" "$target/node_modules"
}
инфо
Почему линкуем только при совпадении lock-файлов

Ссылка на разошедшийся node_modules хуже, чем отсутствие установки: worktree собирался бы против пакетов, которых его собственный lock-файл никогда не объявлял. Побайтовое сравнение package-lock.json — дешёвая и точная проверка: lock тот же, значит одалживать безопасно; lock другой — локальный npm ci. Vite разворачивает символические ссылки в реальные пути до применения server.fs.allow, поэтому apps/desktop/vite.config.ts вносит в белый список настоящее расположение node_modules.

Смотрите также

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

обновлено

⌕ ESC