跳到主要内容

TUI & Desktop from Worktrees

The Python core runs fine from any git worktreecd in and hermes just works. The two TypeScript surfaces do not: ui-tui/ and apps/desktop/ each need a populated node_modules, and a fresh npm ci per worktree is slow and duplicates gigabytes across every branch you have checked out.

htui and hgui are two shell helpers that close that gap. Each launches its surface from the current worktree while borrowing node_modules from one canonical checkout — so a throwaway branch costs a symlink, not an install.

They're developer conveniences, not shipped commands. Drop them in ~/.zshrc; adapt paths to taste.

The deps-sharing model

One checkout is the deps checkout — the one place you actually run npm install. Every other worktree links against it, and only re-installs locally when its lockfile diverges (a branch that bumps a dependency must not silently run against stale packages).

Two env vars name the canonical checkout:

VariableMeaning
HERMES_MAIN_CHECKOUTThe deps checkout — where node_modules really lives, and whose .venv/bin/python runs the backend.
HERMES_GUI_DEPS_CHECKOUTWhere the desktop deps (apps/desktop/node_modules) live. Defaults to HERMES_MAIN_CHECKOUT; override only if you keep desktop deps elsewhere.

Neither is read by Hermes itself — they're private to these helpers. The variables Hermes does read are covered in Environment Variables.

htui — TUI from the worktree

The Ink TUI has a dev path already: hermes --tui --dev runs the TypeScript sources via tsx instead of the prebuilt bundle. htui is a one-liner over it that also points the run at the current worktree's ui-tui/:

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 compiles from source, so it links ui-tui/node_modules from HERMES_MAIN_CHECKOUT when the root lockfile matches and installs locally otherwise (see _hermes_root / linking helpers).

--dev and HERMES_TUI_DIR are mutually exclusive

HERMES_TUI_DIR points Hermes at a prebuilt bundle (Nix, system packages), which has no source to hot-reload. If it's set in your shell, hermes --tui --dev exits with an error. Run unset HERMES_TUI_DIR before htui.

hgui — desktop app from the worktree

The desktop app needs dependencies at both the repo root and apps/desktop/, a Vite server, and a Python backend. The stock npm run dev pins Vite to 5174; Electron also defaults to CDP port 9222 and takes a single-instance lock on its user-data directory. Changing only the Vite port is not enough to run two desktops.

This zsh example gives each launch an explicit slot (HGUI_SLOT, default 0). Use a different slot in each terminal. It uses the shared helpers below and requires 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"
# A userData override would otherwise also relocate the agent's home.
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export XCURSOR_SIZE=24

# Mirror the dev scripts, replacing their fixed ports. No repo edits needed.
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 ."
)

For example, after setting HERMES_MAIN_CHECKOUT and sourcing the helpers:

# Terminal 1: main checkout
cd "$HERMES_MAIN_CHECKOUT"
HGUI_SLOT=0 hgui

# Terminal 2: an existing worktree
cd /path/to/hermes-worktree
HGUI_SLOT=1 hgui

Slot 0 uses ports 5174/9222; slot 1 uses 5175/9223. Slots are caller-assigned, not atomically reserved: always use distinct slots for simultaneous starts. Busy ports are rejected, never evicted. Use separate checkouts for separate builds because launches in the same checkout still share build outputs.

VariableRole in hgui
HGUI_SLOTHelper-only slot number, 09; not a Hermes setting.
HERMES_DESKTOP_HERMES_ROOTRuns the backend from this worktree, not the packaged/PATH runtime.
HERMES_DESKTOP_PYTHONReuses the main checkout's Python environment. Adjust for an installation that uses venv rather than .venv.
HERMES_DESKTOP_CWDRoots new desktop work in the worktree.
HERMES_DESKTOP_DEV_SERVERPoints Electron at this instance's Vite server.
HERMES_DESKTOP_CDP_PORTGives each instance its own renderer debugging port.
HERMES_DESKTOP_USER_DATA_DIRSeparates Electron's single-instance lock, browser storage, and desktop preferences.
HERMES_HOMEExplicitly preserves the agent home despite the Electron user-data override.

Each slot starts with fresh desktop preferences and remembers them on later launches. This example does not copy browser storage, saved navigation, or backend ownership from a running app.

Separate desktops are not separate agent data

The default HERMES_HOME is shared: sessions, configuration, credentials, and profiles remain the same. Avoid editing the same conversation from both instances. For destructive tests or incompatible database migrations, pass a separate temporary HERMES_HOME and configure that sandbox independently.

Quit the app normally or press Ctrl-C in its launching terminal. concurrently -k manages its own child commands, and Electron owns its backend shutdown. Do not add a global killport, pkill electron, or a sweep of all serve/dashboard --port 0 processes: those can terminate another instance. Remove the old _hermes_gui_cleanup trap if replacing an earlier version of this helper.

Shared helpers

Both functions resolve the enclosing checkout and link deps the same way:

# The enclosing worktree, verified as a real Hermes checkout.
_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"
}

# Symlink node_modules from the deps checkout — never over an existing tree.
_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"
}
Why link only when locks match

A symlink to a divergent node_modules is worse than no install — the worktree would build against packages its own lockfile never declared. Byte-comparing package-lock.json is the cheap, exact guard: same lock ⇒ safe to borrow; different lock ⇒ npm ci locally. Vite realpaths symlinks before enforcing server.fs.allow, which is why apps/desktop/vite.config.ts whitelists the real node_modules location.

See also