Skip to content

Instantly share code, notes, and snippets.

@OutThisLife
Last active September 16, 2026 11:07
Show Gist options
  • Select an option

  • Save OutThisLife/0c7407c203fee74e9b9bd2ab38e762d1 to your computer and use it in GitHub Desktop.

Select an option

Save OutThisLife/0c7407c203fee74e9b9bd2ab38e762d1 to your computer and use it in GitHub Desktop.
Hermes worktree-aware launchers (hermes / htui / hgui) — run Hermes from any git worktree without a full per-worktree reinstall

Hermes worktree launchers (hermes / htui / hgui)

Run the CLI/TUI or parallel Electron desktops against main and worktrees without changing Hermes itself. Share dependencies only when package locks match; otherwise install locally.

Install

Save hermes-launchers.zsh in ~/.zsh.d/55-hermes.zsh, or source it from .zshrc. This gist combines the two modules managed separately by chezmoi. Do not keep an older separate 55-hermes-hgui.zsh overriding this combined file.

Set DEV_ROOT to your repo parent before sourcing (for example $HOME/Developer). Defaults use the installer runtime at $HOME/.hermes/hermes-agent:

  • HERMES_MAIN_CHECKOUT: $DEV_ROOT/hermes-agent (contribution checkout)
  • HERMES_INSTALL_CHECKOUT: $HOME/.hermes/hermes-agent
  • HERMES_VENV: $HERMES_INSTALL_CHECKOUT/venv
  • HERMES_GUI_DEPS_CHECKOUT: $HERMES_INSTALL_CHECKOUT

Override runtime/dependency variables before sourcing if your install differs. Requires zsh, Git, Node/npm, lsof, rsync, an installed Hermes Python environment, and desktop npm dependencies in the donor checkout (npm ci at its root).

hermes … runs the current checkout's CLI/TUI; htui … runs hermes --tui --dev. Matching npm locks share dependencies via symlinks. Divergent locks get a local, lock-stamped install; Python reuses HERMES_VENV/bin/python.

Parallel Hermes desktops (macOS / zsh)

Run hgui in separate terminals inside main and your worktrees, or use hgui /path/to/worktree. No Hermes source changes are needed.

  • Free slots 0–9 use renderer port 5174+n and debugging port 9222+n.
  • HGUI_SLOT=2 hgui /path/to/worktree pins a slot; busy/invalid pins fail without killing anything.
  • Slot 0 uses standard Electron settings; extra slots use Hermes-dev-n userData folders. Settings are seeded locally once, without copying session navigation or backend ownership.
  • HERMES_HOME stays explicit (default ~/.hermes): sessions/config/profiles are shared, not sandboxes. Avoid editing the same conversation from two instances or testing incompatible database migrations.
  • Quit an instance normally or Ctrl-C its terminal. The launcher delegates child shutdown to concurrently; it never kills arbitrary processes by port.
  • Start instances sequentially, once the first has printed its ready ports. Slots are selected by listening ports, not an atomic reservation. Use distinct pinned slots for simultaneous starts.
  • Use different checkouts when running different code; builds in the same checkout share output files.
  • The launcher adds apps/desktop/node_modules/.bin before the root node_modules/.bin on PATH, so workspace-local Electron is found alongside hoisted Vite/concurrently. If an older launcher ends with electron: command not found, update it and reload the shell helper.

On another configured machine: dots pull (or chezmoi update), then open a new terminal. The managed files are ~/.zsh.d/55-hermes.zsh and ~/.zsh.d/55-hermes-hgui.zsh. For an existing terminal, source both in that order; sourcing .zshrc again is guarded.

The standalone launcher gist contains a combined sourceable file. macOS is runtime-tested; Linux uses ${XDG_CONFIG_HOME:-$HOME/.config} for Electron settings but is not runtime-tested. This is not a native PowerShell/Git Bash launcher.

# Hermes worktree-aware launcher.
# Contrib clone for source/worktrees; installer venv + node_modules for runtime.
export HERMES_MAIN_CHECKOUT="$DEV_ROOT/hermes-agent"
export HERMES_INSTALL_CHECKOUT="${HERMES_INSTALL_CHECKOUT:-$HOME/.hermes/hermes-agent}"
export HERMES_VENV="${HERMES_VENV:-$HERMES_INSTALL_CHECKOUT/venv}"
export HERMES_GUI_DEPS_CHECKOUT="${HERMES_GUI_DEPS_CHECKOUT:-$HERMES_INSTALL_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" ]] || return 1
print -r "$root"
}
hermes() {
local root arg use_tui_dir
use_tui_dir=1
root="$(_hermes_root)" || {
command hermes "$@"
return
}
for arg in "$@"; do
case "$arg" in
--dev|--dev=*)
use_tui_dir=0
break
;;
esac
done
# ui-tui is a root workspace, so its deps track the root lock. Link main's
# tree only when locks match; a stale link to divergent deps is repaired.
if [[ -L "$root/ui-tui/node_modules" ]] && ! _hermes_locks_match "$root" "$HERMES_MAIN_CHECKOUT"; then
rm -f "$root/ui-tui/node_modules"
fi
if [[ ! -e "$root/ui-tui/node_modules" ]]; then
if _hermes_locks_match "$root" "$HERMES_MAIN_CHECKOUT" && [[ -d "$HERMES_MAIN_CHECKOUT/ui-tui/node_modules" ]]; then
ln -s "$HERMES_MAIN_CHECKOUT/ui-tui/node_modules" "$root/ui-tui/node_modules"
else
_hermes_local_install "$root" || true
fi
fi
if (( use_tui_dir )); then
PYTHONPATH="$root" \
HERMES_TUI_DIR="$root/ui-tui" \
"$HERMES_VENV/bin/python" -m hermes_cli.main "$@"
else
# Force-disable prebuilt override for --dev flow.
env -u HERMES_TUI_DIR \
PYTHONPATH="$root" \
"$HERMES_VENV/bin/python" -m hermes_cli.main "$@"
fi
}
htui() {
hermes --tui --dev "$@"
}
_hermes_npm_root_ok() {
local root="$1"
[[ -f "${root%/}/node_modules/vite/package.json" ]]
}
# Symlink node_modules from deps checkout when missing or a broken partial install.
_hermes_link_node_modules() {
local target="$1" source="$2"
local dest="${target%/}/node_modules" src="${source%/}/node_modules"
[[ -d "$src" ]] || return 1
if [[ -L "$dest" ]]; then
return 0
fi
if [[ -e "$dest" ]]; then
if _hermes_npm_root_ok "$target"; then
return 0
fi
if _hermes_npm_root_ok "$source"; then
rm -rf "$dest"
else
return 1
fi
fi
ln -s "$src" "$dest"
}
# A deps checkout only stands in for another when their locks are byte-identical.
_hermes_locks_match() {
cmp -s "${1%/}/package-lock.json" "${2%/}/package-lock.json"
}
# Real (non-symlinked) install in the worktree, stamped against its own lock so
# we reinstall only when the lock actually changes.
_hermes_local_install() {
local root="${1%/}" stamp hash
stamp="$root/node_modules/.hgui-lock"
hash="${$(shasum "$root/package-lock.json" 2>/dev/null)%% *}"
if [[ -d "$root/node_modules" && ! -L "$root/node_modules" \
&& -f "$stamp" && "$(<$stamp)" == "$hash" ]]; then
return 0
fi
echo "hgui: worktree deps diverge from deps checkout; installing locally (npm ci)…" >&2
[[ -L "$root/node_modules" ]] && rm -f "$root/node_modules"
[[ -L "$root/apps/desktop/node_modules" ]] && rm -f "$root/apps/desktop/node_modules"
[[ -L "$root/ui-tui/node_modules" ]] && rm -f "$root/ui-tui/node_modules"
( cd "$root" && npm ci ) || return 1
print -r -- "$hash" > "$stamp"
}
_hermes_resolve_checkout() {
local arg="$1" root
if [[ -n "$arg" ]]; then
# :A — no cd. chpwd hooks (nvm use) print to stdout and poison $(cd && pwd).
[[ -d "$arg" ]] || return 1
root="${arg:A}"
[[ -f "$root/hermes_cli/main.py" ]] || return 1
print -r "$root"
return 0
fi
_hermes_root
}
# hgui — Hermes desktop dev launcher, multi-instance.
#
# Each running hgui owns a "slot": vite port 5174+n, CDP port 9222+n, and (for
# n>0) its own Electron userData dir so requestSingleInstanceLock() doesn't
# bounce the second launch. Slot 0 is the stock `npm run dev` layout.
# All slots share the real ~/.hermes (sessions, config, profiles).
#
# hgui [path] launch against a checkout (default: cwd's). Picks the
# first free slot, so `hgui` in two worktrees just works.
# HGUI_SLOT=n hgui pin a slot.
_hgui_pick_slot() {
local slot
if [[ -n "$HGUI_SLOT" ]]; then
[[ "$HGUI_SLOT" == [0-9] ]] || { print -u2 'hgui: HGUI_SLOT must be 0-9'; return 1; }
for slot in $((5174 + HGUI_SLOT)) $((9222 + HGUI_SLOT)); do
if lsof -nP -t -iTCP:$slot -sTCP:LISTEN >/dev/null 2>&1; then
print -u2 "hgui: slot $HGUI_SLOT is busy (port $slot); leaving it alone"
return 1
fi
done
print -r "$HGUI_SLOT"; return 0
fi
for slot in {0..9}; do
lsof -nP -t -iTCP:$((5174 + slot)) -sTCP:LISTEN >/dev/null 2>&1 && continue
lsof -nP -t -iTCP:$((9222 + slot)) -sTCP:LISTEN >/dev/null 2>&1 && continue
print -r "$slot"; return 0
done
return 1
}
# Extra slots copy main settings, excluding caches, locks and session navigation.
# They then drift independently; agent data still comes from HERMES_HOME.
_hgui_user_data_dir() {
local slot="$1" base dir
case "$OSTYPE" in
darwin*) base="$HOME/Library/Application Support/Hermes" ;;
*) base="${XDG_CONFIG_HOME:-$HOME/.config}/Hermes" ;;
esac
(( slot == 0 )) && { print -r "$base"; return 0; }
dir="$base-dev-$slot"
if [[ ! -d "$dir" && -d "$base" ]]; then
echo "hgui: seeding ${dir:t} from main app settings" >&2
rsync -a \
--exclude 'Singleton*' --exclude 'Cache' --exclude 'Code Cache' \
--exclude 'GPUCache' --exclude 'Dawn*Cache' --exclude 'blob_storage' \
--exclude 'DevToolsActivePort' --exclude 'backend-ownership.json' \
--exclude 'Local Storage' --exclude 'Session Storage' \
"$base/" "$dir/" || return 1
fi
print -r "$dir"
}
hgui() (
local root deps desktop deps_desktop py arg slot vite_port cdp_port user_data
arg="$1"
root="$(_hermes_resolve_checkout "$arg")" || {
if [[ -n "$arg" ]]; then
echo "hgui: $arg is not a Hermes checkout" >&2
else
echo "hgui: not inside a Hermes checkout (usage: hgui [path])" >&2
fi
return 1
}
deps="${HERMES_GUI_DEPS_CHECKOUT:-$HERMES_MAIN_CHECKOUT}"
desktop="$root/apps/desktop"
deps_desktop="$deps/apps/desktop"
if [[ ! -d "$desktop" ]]; then
echo "hgui: $root does not have apps/desktop" >&2
return 1
fi
if [[ ! -d "$deps_desktop" ]]; then
echo "hgui: set HERMES_GUI_DEPS_CHECKOUT to a checkout with apps/desktop deps" >&2
return 1
fi
# Link main's tree only when this worktree's lock matches; otherwise a branch
# that bumps a dep would silently run against stale packages. On divergence,
# install locally instead.
if _hermes_locks_match "$root" "$deps"; then
_hermes_link_node_modules "$desktop" "$deps_desktop" || true
_hermes_link_node_modules "$root" "$deps" || true
elif ! _hermes_local_install "$root"; then
echo "hgui: dependency install failed" >&2
return 1
fi
if ! _hermes_npm_root_ok "$root"; then
echo "hgui: run once: cd $deps && npm ci" >&2
return 1
fi
py="$HERMES_VENV/bin/python"
[[ -x "$py" ]] || py="$(command -v python3)"
slot="$(_hgui_pick_slot)" || { echo "hgui: could not reserve a free slot" >&2; return 1; }
vite_port=$((5174 + slot))
cdp_port=$((9222 + slot))
user_data="$(_hgui_user_data_dir "$slot")" || return 1
echo "hgui: slot $slot → ${root:t} vite :$vite_port cdp :$cdp_port" >&2
# concurrently owns its children; never kill processes by shared ports.
# Mirrors `npm run dev` (dev:renderer + dev:electron) with the ports and
# userData made per-slot. HERMES_HOME is pinned explicitly because a custom
# userData dir would otherwise relocate it to <userData>/hermes-home.
(
cd "$desktop" || exit 1
export PATH="$desktop/node_modules/.bin:$root/node_modules/.bin:$PATH"
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export HERMES_DESKTOP_HERMES_ROOT="$root"
export HERMES_DESKTOP_PYTHON="$py"
export HERMES_DESKTOP_IGNORE_EXISTING=1
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="$user_data"
export XCURSOR_SIZE=24
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 ."
)
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment