|
# 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 ." |
|
) |
|
) |