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"
}
Ссылка на разошедшийся node_modules хуже, чем отсутствие установки: worktree собирался бы против пакетов, которых его собственный lock-файл никогда не объявлял. Побайтовое сравнение package-lock.json — дешёвая и точная проверка: lock тот же, значит одалживать безопасно; lock другой — локальный npm ci. Vite разворачивает символические ссылки в реальные пути до применения server.fs.allow, поэтому apps/desktop/vite.config.ts вносит в белый список настоящее расположение node_modules.
Смотрите также
- Git worktrees — модель изоляции, на которой стоят эти хелперы
- TUI —
hermes --tui --devи путь через собранный бандлHERMES_TUI_DIR - Десктопное приложение — сборка из исходников и лестница разрешения бэкенда
apps/desktop/README.md— dev-сервер, скрипт песочницы и упаковка- Переменные окружения — все переменные
HERMES_*, которые читает Hermes