File size: 7,456 Bytes
6aa76ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | ---
sidebar_position: 5
title: "TUI & Desktop from Worktrees"
description: "Run the Ink TUI and Electron desktop app from a git worktree without a full npm install per checkout"
---
# TUI & Desktop from Worktrees
The Python core runs fine from any [git worktree](../user-guide/git-worktrees.md) β `cd` 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).
```mermaid
flowchart TD
A[htui / hgui in a worktree] --> B{package-lock.json<br/>matches deps checkout?}
B -- yes --> C[symlink node_modules<br/>from deps checkout]
B -- no --> D[local npm ci<br/>in this worktree]
C --> E[launch surface]
D --> E
```
Two env vars name the canonical checkout:
| Variable | Meaning |
|----------|---------|
| `HERMES_MAIN_CHECKOUT` | The deps checkout β where `node_modules` really lives, and whose `.venv/bin/python` runs the backend. |
| `HERMES_GUI_DEPS_CHECKOUT` | Where 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](../reference/environment-variables.md).
## `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/`:
```bash
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](#shared-helpers)).
:::warning `--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 is heavier: it needs `node_modules` at both the repo root and `apps/desktop/`, a Vite dev server pinned to port `5174`, and a Python backend. `hgui` wires all of it against the current worktree:
```bash
hgui() {
local root deps desktop
root="$(_hermes_root)" || { echo "hgui: not in a Hermes checkout" >&2; return 1; }
deps="${HERMES_GUI_DEPS_CHECKOUT:-$HERMES_MAIN_CHECKOUT}"
desktop="$root/apps/desktop"
# Borrow deps when locks match; otherwise install locally in the worktree.
if cmp -s "$root/package-lock.json" "$deps/package-lock.json"; then
_hermes_link_deps "$desktop" "$deps/apps/desktop"
_hermes_link_deps "$root" "$deps"
else
( cd "$root" && npm ci ) || return 1
fi
# Vite is fixed at 5174 β evict a stale session from another hgui.
lsof -t -i:5174 >/dev/null 2>&1 && killport 5174
# Electron often survives Ctrl+C without reaping its ephemeral backends.
trap '_hermes_gui_cleanup "$root"' INT TERM EXIT
( cd "$desktop"
export PATH="$root/node_modules/.bin:$PATH"
HERMES_DESKTOP_HERMES_ROOT="$root" \
HERMES_DESKTOP_PYTHON="$HERMES_MAIN_CHECKOUT/.venv/bin/python" \
HERMES_DESKTOP_IGNORE_EXISTING=1 \
HERMES_DESKTOP_CWD="$root" \
npm run dev )
}
```
The desktop env vars it sets are all real backend-resolution knobs:
| Variable | Role in `hgui` |
|----------|----------------|
| `HERMES_DESKTOP_HERMES_ROOT` | Runs the backend from **this worktree**, not the packaged/PATH `hermes`. |
| `HERMES_DESKTOP_PYTHON` | Reuses the deps checkout's venv instead of re-resolving a Python. |
| `HERMES_DESKTOP_IGNORE_EXISTING` | Ignores any `hermes` on `PATH` so it can't shadow the worktree. |
| `HERMES_DESKTOP_CWD` | Opens the desktop chat rooted at the worktree. |
Two footguns `hgui` handles that a bare `npm run dev` does not:
- **Port `5174` is fixed.** A second `hgui` collides with the first's Vite server; the helper kills the stale one first.
- **Orphaned children.** Electron frequently survives `Ctrl+C` through `concurrently` without reaping the ephemeral `dashboard --port 0` backend or the Vite process. The `EXIT`/`INT`/`TERM` trap runs a cleanup that terminates the Electron shell, the `:5174` listener, and any `--port 0` dashboard it spawned.
## Shared helpers
Both functions resolve the enclosing checkout and link deps the same way:
```bash
# 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"
}
# Reap ephemeral backends Electron leaves behind on exit.
_hermes_gui_cleanup() {
local root="$1"
[[ -n "$root" ]] && pkill -TERM -f "${root}/apps/desktop/node_modules/electron" 2>/dev/null
lsof -t -i:5174 >/dev/null 2>&1 && killport 5174
pgrep -f 'hermes_cli\.main.*dashboard.*--port 0' 2>/dev/null | xargs -r kill -TERM 2>/dev/null
}
```
`killport` is a small helper of your own (`lsof -ti:$1 | xargs kill`); substitute your preferred incantation.
:::info 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
- [Git Worktrees](../user-guide/git-worktrees.md) β the isolation model these helpers build on
- [TUI](../user-guide/tui.md) β `hermes --tui --dev` and the `HERMES_TUI_DIR` prebuild path
- [Desktop App](../user-guide/desktop.md) β building from source and the backend resolution ladder
- [`apps/desktop/README.md`](https://github.com/NousResearch/hermes-agent/blob/main/apps/desktop/README.md) β dev server, sandbox script, and packaging
- [Environment Variables](../reference/environment-variables.md) β every `HERMES_*` variable Hermes reads
|