跳到主要内容

从 Worktree 运行 TUI 与桌面端

Python 核心可以从任意 git worktree 正常运行——cd 进去,hermes 就能直接工作。但两个 TypeScript 界面不行:ui-tui/ 和 apps/desktop/ 各自都需要一个内容完整的 node_modules,而为每个 worktree 重新运行一次 npm ci 既慢又会在你检出的每个分支上重复占用数 GB 的磁盘空间。

htui 和 hgui 就是用来填补这一空缺的两个 shell 辅助函数。它们各自从当前 worktree 启动对应的界面,同时从某个规范检出借用 node_modules——这样,一个用完即弃的分支只有一个符号链接的代价,而不是一次安装的代价。

它们是开发者便利工具,并非随产品发布的命令。把它们放进 ~/.zshrc 中,路径可按需调整。

依赖共享模型​

有一个检出充当依赖检出——也就是你实际运行 npm install 的那个唯一位置。其他所有 worktree 都链接到它,只有当自身的 lockfile 与它出现分叉时才在本地重新安装(冻结了某个依赖的分支,绝不能悄悄用旧的包继续运行)。

两个环境变量用于指定规范检出:

变量含义
HERMES_MAIN_CHECKOUT依赖检出——node_modules 真正所在的位置,其 .venv/bin/python 用于运行后端。
HERMES_GUI_DEPS_CHECKOUT桌面端依赖(apps/desktop/node_modules)所在的位置。默认值为 HERMES_MAIN_CHECKOUT;只有当你把桌面端依赖放在别处时才需要覆盖它。

Hermes 本身不会读取这两个变量——它们仅属于这些辅助函数。Hermes 确实会读取的环境变量详见环境变量。

htui — 从 worktree 运行 TUI​

Ink TUI 本身已经有一条开发路径:hermes --tui --dev 通过 tsx 运行 TypeScript 源码,而不是使用预构建的 dist 包。htui 就是在它之上的一行封装,同时把运行目标指向当前 worktree 的 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 会从源码编译,所以当根 lockfile 匹配时它会从 HERMES_MAIN_CHECKOUT 链接 ui-tui/node_modules,否则就在本地安装(参见 _hermes_root / 链接辅助函数)。

--dev 与 HERMES_TUI_DIR 互斥

HERMES_TUI_DIR 会将 Hermes 指向一个预构建的 dist 包(Nix、系统包),它没有可热重载的源码。如果你的 shell 中设置了该变量,hermes --tui --dev 会以错误退出。在运行 htui 之前请先执行 unset HERMES_TUI_DIR。

hgui — 从 worktree 运行桌面应用​

桌面应用更重:它需要仓库根目录和 apps/desktop/ 下都有 node_modules,一个绑定到端口 5174 的 Vite 开发服务器,以及一个 Python 后端。hgui 将这些全部针对当前 worktree 串联起来:

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 )
}

它设置的桌面端环境变量都是真正的后端解析开关:

变量在 hgui 中的作用
HERMES_DESKTOP_HERMES_ROOT从当前 worktree 运行后端,而不是使用打包的或 PATH 中的 hermes。
HERMES_DESKTOP_PYTHON复用依赖检出的 venv,而不是重新解析一个 Python。
HERMES_DESKTOP_IGNORE_EXISTING忽略 PATH 上任何 hermes,使其无法遮蔽 worktree。
HERMES_DESKTOP_CWD以 worktree 为根目录打开桌面端聊天。

裸跑 npm run dev 不会处理而 hgui 会处理的两个坑点:

  • 端口 5174 是固定的。 第二个 hgui 会与第一个的 Vite 服务器冲突;该辅助函数会先杀掉那个残留的进程。
  • 失控的子进程。 Electron 经常能在 Ctrl+C 后幸存,让短暂运行的 dashboard --port 0 后端或 Vite 进程变成孤儿。EXIT/INT/TERM trap 会执行清理,终止 Electron 外壳、:5174 监听者,以及它派生出的任何 --port 0 dashboard。

共享辅助函数​

两个函数以相同方式解析所在的检出并链接依赖:

# 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 是你自己的一个小辅助函数(lsof -ti:$1 | xargs kill);换成你偏好的做法即可。

为什么只在 lockfile 匹配时链接

指向分叉的 node_modules 的符号链接比不安装还糟——worktree 会针对其自身 lockfile 从未声明过的包进行构建。逐字节比较 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 — 开发服务器、沙箱脚本与打包
  • 环境变量 — Hermes 会读取的每一个 HERMES_* 变量