从工作树运行 TUI 与桌面应用

Python 核心可以从任何 git worktree 正常运行——cd 进去,hermes 直接可用。两个 TypeScript 表面则不然:ui-tui/ 和 apps/desktop/ 各自需要一份填好的 node_modules,而每个 worktree 全新 npm ci 很慢,还会在你 checkout 的每个分支上重复占用数 GB。

htui 和 hgui 是两个 shell 辅助函数,补上这个缺口。它们都从当前 worktree 启动各自表面,同时从一个规范 checkout 借用 node_modules——因此一个临时分支的代价只是一个符号链接,而非一次安装。

它们是开发者便利工具,不是随发布的命令。把它们放进 ~/.zshrc;路径按需调整。

依赖共享模型 {#the-deps-sharing-model}

一个 checkout 是依赖 checkout——你唯一真正跑 npm install 的地方。其他 worktree 都链接到它,只在自己的 lockfile 不一致时才本地重装(提升依赖的分支绝不能悄悄对着陈旧包运行)。

flowchart TD
    A[worktree 中的 htui / hgui] --> B{package-lock.json<br/>与依赖 checkout 一致?}
    B -- 是 --> C[从依赖 checkout 符号链接 node_modules]
    B -- 否 --> D[在本 worktree 本地 npm ci]
    C --> E[启动表面]
    D --> E

两个环境变量命名规范 checkout:

变量含义
HERMES_MAIN_CHECKOUT依赖 checkout——node_modules 真正所在之处,其后端用 .venv/bin/python 运行。
HERMES_GUI_DEPS_CHECKOUT桌面依赖(apps/desktop/node_modules)所在之处。默认为 HERMES_MAIN_CHECKOUT;仅当你把桌面依赖放在别处时才覆盖。

Hermes 本身都不读它们——它们是这些辅助函数私有的。Hermes 确实读取的变量见环境变量。

htui——从 worktree 运行 TUI {#htui--tui-from-the-worktree}

Ink TUI 已有一条 dev 路径:hermes --tui --dev 用 tsx 运行 TypeScript 源码,而非预构建 bundle。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,否则本地安装(见共享助手)。

`--dev` 与 `HERMES_TUI_DIR` 互斥

HERMES_TUI_DIR 把 Hermes 指向一个预构建 bundle(Nix、系统包),它没有可热重载的源码。若你的 shell 里设置了它,hermes --tui --dev 会报错退出。在 htui 之前运行 unset HERMES_TUI_DIR。

hgui——从 worktree 运行桌面应用 {#hgui--desktop-app-from-the-worktree}

桌面应用在仓库根和 apps/desktop/ 两处都需要依赖,外加一个 Vite 服务器和一个 Python 后端。默认 npm run dev 把 Vite 钉在 5174;Electron 还默认 CDP 端口 9222,并在其 user-data 目录上加单实例锁。只改 Vite 端口不足以同时跑两个桌面。

这个 zsh 示例给每次启动分配一个显式槽位(HGUI_SLOT,默认 0)。每个终端用不同槽位。它使用下面的共享助手,并需要 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"
  # 否则 userData 覆盖还会顺带迁移智能体的 home。
  export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
  export XCURSOR_SIZE=24

  # 镜像 dev 脚本,替换其固定端口。无需改仓库。
  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 ."
)

例如,设置好 HERMES_MAIN_CHECKOUT 并 source 助手后:

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

# 终端 2:一个既有 worktree
cd /path/to/hermes-worktree
HGUI_SLOT=1 hgui

槽位 0 用端口 5174/9222;槽位 1 用 5175/9223。槽位由调用方分配,不是原子预留:同时启动务必用不同槽位。被占用的端口被拒绝,绝不抢占。不同构建用不同 checkout,因为同一 checkout 中的启动仍共享构建产物。

变量在 hgui 中的作用
HGUI_SLOT仅辅助函数用的槽位号,0–9;不是 Hermes 设置。
HERMES_DESKTOP_HERMES_ROOT从本 worktree 运行后端,而非打包/PATH 运行时。
HERMES_DESKTOP_PYTHON复用 main checkout 的 Python 环境。对用 venv 而非 .venv 的安装请调整。
HERMES_DESKTOP_CWD把新桌面工作的根设在 worktree。
HERMES_DESKTOP_DEV_SERVER把 Electron 指向本实例的 Vite 服务器。
HERMES_DESKTOP_CDP_PORT给每个实例自己的渲染进程调试端口。
HERMES_DESKTOP_USER_DATA_DIR分开 Electron 的单实例锁、浏览器存储和桌面偏好。
HERMES_HOME尽管有 Electron user-data 覆盖,仍显式保留智能体 home。

每个槽位以全新桌面偏好起步,并在后续启动时记住。本示例不从运行中的应用复制浏览器存储、已保存导航或后端归属。

多个桌面不是多份智能体数据

默认 HERMES_HOME 是共享的:会话、配置、凭据和 profile 保持相同。避免从两个实例编辑同一会话。做破坏性测试或不兼容数据库迁移时,传一个单独的临时 HERMES_HOME,独立配置那个沙箱。

正常退出应用,或在其启动终端按 Ctrl-C。concurrently -k 管理自己的子命令,Electron 负责其后端关闭。不要加全局 killport、pkill electron 或扫荡所有 serve/dashboard --port 0 进程:那些可能终止另一个实例。若替换本助手的旧版本,移除旧的 _hermes_gui_cleanup 陷阱。

共享助手 {#shared-helpers}

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

# 所在 worktree,已验证为真实 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"
}

# 从依赖 checkout 符号链接 node_modules——绝不覆盖既有目录。
_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"
}
为何只在锁一致时链接

指向分叉 node_modules 的符号链接比不安装更糟——worktree 会对着自己 lockfile 从未声明的包构建。逐字节比较 package-lock.json 是廉价而精确的守卫:同锁 ⇒ 可安全借用;异锁 ⇒ 本地 npm ci。Vite 在执行 server.fs.allow 前会 realpath 符号链接,这正是 apps/desktop/vite.config.ts 把真实 node_modules 位置加入白名单的原因。

另见 {#see-also}