Merge pull request #325 from spencerbull/hermes-native-updates

Let Hermes Desktop update its native installation
This commit is contained in:
Ryan Hughes authored and GitHub committed 2026-09-07 02:22:49 -04:00
commit a5d95385ca
5 files changed
+640 -106

No files matched your search

+33 -76
View File
@@ -1,38 +1,24 @@
# Maintainer: David Heinemeier Hansson <david@hey.com>
# Nous builds Hermes Desktop for macOS and Windows only -- their download page
# offers a .dmg and an .exe and tells Linux users to install from a terminal --
# so there is no vendor binary to repackage. Their electron-builder config does
# carry a Linux target, though, and it works; this builds it.
#
# The app only runs against a Hermes runtime built from its own commit, so it
# provisions one itself under ~/.hermes on first launch and the package stays
# on the newest tag. Pairing it with the mise CLI instead was tried and does
# not work: PyPI trails the tags, and the version gap fails the readiness probe
# with a 401. Pinning back to the tag behind PyPI's release does not rescue it
# either -- v2026.7.20's desktop hangs after "backend is ready" without ever
# opening a window, against its own matching runtime.
#
# The app is only a shell: it runs `hermes serve` against a Hermes CLI it does
# not ship, and clones its own copy with the upstream install script when it
# finds none. /usr/bin/hermes-desktop heads that off. See hermes-desktop.sh.
pkgname=hermes-desktop
pkgver=2026.8.18
pkgrel=2
pkgdesc='Native desktop shell for Hermes Agent'
pkgver=2026.8.31
pkgrel=1
pkgdesc='Installer and launcher for the self-updating Hermes desktop'
arch=('x86_64')
url='https://github.com/NousResearch/hermes-agent'
license=('MIT')
# Hermes builds its desktop under the user's runtime so both the app and CLI
# can update it. The compiler and Electron libraries must survive installation.
depends=(
'alsa-lib'
'at-spi2-core'
'bash'
'cairo'
'curl'
'dbus'
'expat'
'curl'
'gcc-libs'
'gcc'
'gdk-pixbuf2'
'git'
'glib2'
@@ -52,65 +38,49 @@ depends=(
'libxfixes'
'libxkbcommon'
'libxrandr'
'make'
'mesa'
'nodejs'
'npm'
'nspr'
'nss'
'pango'
'python'
'systemd-libs'
'util-linux'
'xdg-terminal-exec'
'xdg-utils'
)
optdepends=('omarchy: installs the Hermes CLI the app needs on first launch')
makedepends=('imagemagick')
# The build runs the repo's own npm workspace install, which fetches Electron
# and rebuilds node-pty against it.
makedepends=('git' 'imagemagick' 'nodejs' 'npm' 'python')
# Electron bundles prebuilt binaries that stripping corrupts.
options=('!strip' '!debug')
# The tag's commit. apps/desktop/scripts/write-build-stamp.mjs pins the app's
# first-launch bootstrap to a Hermes commit, and resolves it from $GITHUB_SHA
# before falling back to `git rev-parse`. That fallback is wrong here: makepkg
# builds inside this repository, so git ascends out of srcdir and stamps the
# app with an omarchy-pkgs commit that means nothing upstream.
_commit=e624e9fde561e1add9388384012b295fde669ade
_srcdir="hermes-agent-${pkgver}"
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
# Snapshot the bootstrap installer, including upstream's Linux sandbox fixes.
# Only installation uses it; updates belong to the native runtime on main.
_commit=245e48008fa814b3251f50755eb656bd9fb86cb1
source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/scripts/install.sh"
"LICENSE-${_commit}::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/LICENSE"
'hermes-desktop.sh'
'hermes-desktop.desktop'
'hermes-desktop.png')
sha256sums=('1e3d39d3638ec15fa9d31af262568a953e9272090deb1c50c44cd401175f5b80'
'f5833b969ce451aadee9f08d92db55cce3c7c9213175080590bf37444854d676'
'3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4'
sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968'
'821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6'
'27040084e691421dc1748e7646ae7ee79d5777e8ee1e1ec7581a68432d4a29c4'
'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8'
'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52')
build() {
cd "${srcdir}/${_srcdir}"
export GITHUB_SHA="${_commit}"
export GITHUB_REF_NAME="v${pkgver}"
# The desktop workspace resolves against the repo root, so the install has to
# happen there rather than in apps/desktop.
npm ci
cd apps/desktop
npm run pack
}
package() {
cd "${srcdir}/${_srcdir}/apps/desktop/release/linux-unpacked"
install -dm755 "${pkgdir}/opt/${pkgname}"
cp -a . "${pkgdir}/opt/${pkgname}/"
install -Dm644 "${srcdir}/install-${_commit}.sh" \
"${pkgdir}/usr/share/${pkgname}/install.sh"
install -Dm644 "${srcdir}/LICENSE-${_commit}" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
install -Dm755 "${srcdir}/hermes-desktop.sh" "${pkgdir}/usr/bin/${pkgname}"
# Native registration uses hermes.desktop but omits URI handling. Keep the
# original handler ID hidden, and let the native entry replace the visible one.
install -Dm644 "${srcdir}/hermes-desktop.desktop" \
"${pkgdir}/usr/share/applications/${pkgname}.desktop"
"${pkgdir}/usr/share/applications/hermes-desktop.desktop"
printf 'NoDisplay=true\n' >>"${pkgdir}/usr/share/applications/hermes-desktop.desktop"
sed '/^MimeType=/d' "${srcdir}/hermes-desktop.desktop" >"${pkgdir}/usr/share/applications/hermes.desktop"
install -Dm644 "${srcdir}/hermes-desktop.png" \
"${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png"
local size
@@ -119,17 +89,4 @@ package() {
install -Dm644 "${srcdir}/icon-${size}.png" \
"${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png"
done
install -Dm644 "${srcdir}/${_srcdir}/LICENSE" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
install -Dm644 "${pkgdir}/opt/${pkgname}/LICENSE.electron.txt" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE.electron.txt"
# Chromium's setuid sandbox is only needed where unprivileged user namespaces
# are unavailable; where they work, setuid root is the worse of the two.
if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then
chmod 4755 "${pkgdir}/opt/${pkgname}/chrome-sandbox"
else
chmod 0755 "${pkgdir}/opt/${pkgname}/chrome-sandbox"
fi
}
@@ -4,7 +4,7 @@ Type=Application
Name=Hermes
GenericName=AI Agent
Comment=The self-improving AI agent that grows with you
Exec=hermes-desktop %U
Exec=/usr/bin/hermes-desktop %U
Icon=hermes-desktop
Terminal=false
Categories=Development;
+254 -29
View File
@@ -1,41 +1,266 @@
#!/bin/bash
set -euo pipefail
# Hermes Desktop is a shell around a Hermes runtime, and it only works against
# one built from its own commit. A CLI from PyPI is always a different release
# -- PyPI trails the tags -- and the mismatch fails the app's readiness probe
# with 401 Unauthorized. So keep it away from whatever `hermes` is on PATH,
# which on Omarchy is the mise CLI installed for the terminal agent, and let
# the app provision and manage its own runtime under ~/.hermes. That is the
# arrangement upstream ships, and the only one that starts.
export HERMES_DESKTOP_IGNORE_EXISTING=1
die() {
echo "Hermes: $*" >&2
exit 1
}
# Chromium cannot reliably infer the Secret Service password-store backend
# from a Hyprland session, even when GNOME Keyring is already providing it.
# Keep an explicit user choice (such as KWallet), otherwise select the
# libsecret backend that this package depends on.
export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}"
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
# A profile session shares its parent home's installation and launchers.
home_parent=${HERMES_HOME%/*}
if [[ ${home_parent##*/} == "profiles" ]]; then
HERMES_HOME=${home_parent%/*}
fi
[[ $HERMES_HOME == /* && $HERMES_HOME != "/" ]] || die "Use a Hermes data directory other than /."
export HERMES_HOME
root="$HERMES_HOME/hermes-agent"
marker="$root/.omarchy-hermes-desktop"
installer=/usr/share/hermes-desktop/install.sh
cli=("$root/venv/bin/python" "$root/hermes")
# Reconcile every launch rather than trusting whatever installed us. A plain
# `pacman -S hermes-desktop`, or an install interrupted partway, leaves any
# Hermes the terminal agent had built still sitting there, and by then the
# menu entry that would have tidied it up is disabled because we are present.
if command -v omarchy-install-hermes-cli >/dev/null 2>&1; then
omarchy-install-hermes-cli >/dev/null 2>&1 || true
desktop_executable() {
local executable
for executable in "$root/apps/desktop/release/linux-unpacked/"{Hermes,hermes}; do
if [[ -f $executable && -x $executable ]]; then
printf '%s\n' "$executable"
return 0
fi
done
return 1
}
native_wrapper() {
local wrapper=$1 entry=$2 suffix=${3:-}
[[ -f $wrapper && ! -L $wrapper ]] && cmp -s "$wrapper" <(
printf '#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "%s/venv/bin/python" "%s/%s"%s "$@"\n' "$root" "$root" "$entry" "$suffix"
)
}
runtime_ready() {
[[ -f $root/.hermes-bootstrap-complete && -x ${cli[0]} ]] &&
[[ -x $HOME/.local/bin/hermes ]] &&
native_wrapper "$HOME/.local/bin/hermes" hermes &&
desktop_executable >/dev/null &&
timeout 15 env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" --version >/dev/null 2>&1
}
ready() {
if [[ -f $marker ]]; then
grep -qxF ready "$marker" || return 1
fi
runtime_ready
}
restore_launchers() {
local command wrapper entry
for command in hermes hermes-agent hermes-acp; do
[[ -f $install_backup/$command ]] || continue
wrapper="$HOME/.local/bin/$command"
entry=hermes
[[ $command == "hermes-agent" ]] && entry=run_agent.py
if [[ ! -e $wrapper && ! -L $wrapper ]] || native_wrapper "$wrapper" "$entry" ||
{ [[ -f $wrapper && ! -L $wrapper ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; } ||
{ [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; }; then
cp -p "$install_backup/$command" "$wrapper"
else
echo "Keeping changed $wrapper; its original is in $install_backup." >&2
return
fi
done
rm -rf "$install_backup"
}
stage() {
bash "$installer" --dir "${2:-$root}" --branch main --non-interactive --stage "$1"
}
install_desktop() {
(( EUID != 0 )) || die "Run this installer as your desktop user, without sudo."
mkdir -p "$HERMES_HOME"
exec 9>"$HERMES_HOME/.omarchy-hermes-desktop.lock"
flock -n 9 || die "Another Hermes installation is already running."
if ready; then
exec 9>&-
return
fi
# Upstream replaces these launchers. Admit its exact shims or Omarchy's
# marked predecessor before starting work, never a user's custom command.
local command wrapper entry
for command in hermes hermes-agent hermes-acp; do
wrapper="$HOME/.local/bin/$command"
[[ -e $wrapper || -L $wrapper ]] || continue
entry=hermes
[[ $command == "hermes-agent" ]] && entry=run_agent.py
if native_wrapper "$wrapper" "$entry"; then continue; fi
if [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; then continue; fi
if [[ $command == "hermes" && -f $wrapper && ! -L $wrapper ]] &&
grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; then continue; fi
die "Keeping the existing $wrapper. Move or update that installation before installing Hermes Desktop."
done
# The native prerequisite stage may install Node's three user symlinks.
for command in node npm npx; do
wrapper="$HOME/.local/bin/$command"
if [[ -e $wrapper || -L $wrapper ]] &&
[[ ! -L $wrapper || $(readlink "$wrapper") != "$HERMES_HOME/node/bin/$command" ]]; then
die "Keeping the existing $wrapper. Move it before installing Hermes Desktop."
fi
done
local legacy=false
if [[ -e $root || -L $root ]]; then
[[ -d $root/.git && ! -L $root && ! -L $root/.git ]] || die "Keeping $root; it is not a managed checkout."
if [[ -f $marker ]] && grep -qxF updating "$marker"; then
legacy=true
elif [[ ! -f $marker ]]; then
python -I - "$root/.hermes-bootstrap-complete" <<'PY' || die "Keeping the existing checkout; install its desktop with 'hermes desktop'."
import json, sys
try:
stamp = json.load(open(sys.argv[1]))
assert stamp.get('desktopVersion') and stamp.get('pinnedCommit') in {
'e624e9fde561e1add9388384012b295fde669ade',
'29112bef099274229cadff79cdff7bf7b99c4b77',
}
except (OSError, ValueError, AssertionError, AttributeError):
sys.exit(1)
PY
legacy=true
fi
[[ -z $(git -C "$root" status --porcelain) ]] || die "Keeping local changes in $root. Save them before installing the desktop."
case "$(git -C "$root" remote get-url origin)" in
https://github.com/NousResearch/hermes-agent.git | git@github.com:NousResearch/hermes-agent.git) ;;
*) die "Keeping the checkout's custom origin. Install its desktop with 'hermes desktop'." ;;
esac
if [[ $legacy == true ]]; then
case "$(git -C "$root" rev-parse HEAD)" in
e624e9fde561e1add9388384012b295fde669ade | 29112bef099274229cadff79cdff7bf7b99c4b77) ;;
*)
if [[ $(git -C "$root" symbolic-ref --short -q HEAD) != "main" ]] ||
! git -C "$root" merge-base --is-ancestor HEAD refs/remotes/origin/main; then
die "The legacy checkout has moved. Run 'hermes update', then 'hermes desktop'."
fi
;;
esac
fi
fi
install_backup=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-launchers.XXXXXX")
for command in hermes hermes-agent hermes-acp; do
wrapper="$HOME/.local/bin/$command"
if [[ -f $wrapper ]]; then cp -p "$wrapper" "$install_backup/$command"; fi
done
trap restore_launchers EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
local mise_predecessor=false
if [[ -f $install_backup/hermes ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$install_backup/hermes"; then
mise_predecessor=true
fi
if [[ $legacy == true ]]; then
if [[ $mise_predecessor == true ]]; then
printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor"
fi
if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then
printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude"
fi
printf 'updating\n' >"$marker"
local refspec='+refs/heads/main:refs/remotes/origin/main'
if ! git -C "$root" config --get-all remote.origin.fetch | grep -qxF "$refspec"; then
git -C "$root" config --add remote.origin.fetch "$refspec"
fi
git -C "$root" fetch origin "$refspec"
env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" update --yes --branch main
elif [[ ! -e $root ]]; then
stage prerequisites
# Publish only a complete clone with its ownership marker. An interrupted
# clone stays aside for inspection and cannot block the next installation.
local repository_dir
repository_dir=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-repository.XXXXXX")
echo "Preparing the Hermes checkout in $repository_dir"
stage repository "$repository_dir/hermes-agent"
printf '/.omarchy-hermes-desktop\n' >>"$repository_dir/hermes-agent/.git/info/exclude"
printf 'pending\n' >"$repository_dir/hermes-agent/.omarchy-hermes-desktop"
mv -T --no-clobber "$repository_dir/hermes-agent" "$root"
[[ ! -e $repository_dir/hermes-agent ]] || die "Keeping the checkout that appeared at $root during installation."
rmdir "$repository_dir"
fi
if [[ $mise_predecessor == true ]]; then
printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor"
fi
if [[ $legacy == false ]]; then
for command in venv python-deps node-deps config; do stage "$command"; done
fi
# The native builder also handles updates and supports the namespace
# sandbox. The shell installer's desktop stage still requires a sudo chown.
# The build registers a desktop entry, too. Keep it private until the new
# CLI is published so a different hermes on PATH cannot become its target.
env -u PYTHONPATH -u PYTHONHOME XDG_DATA_HOME="$install_backup/desktop" "${cli[@]}" desktop --build-only
# Build first: a failed download leaves the old terminal CLI usable.
stage complete
stage path
runtime_ready || die "The CLI or desktop is not ready. See the installer output above."
env -u PYTHONPATH -u PYTHONHOME PATH="$HOME/.local/bin:$PATH" "${cli[@]}" desktop --skip-build --build-only
if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then
printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude"
fi
printf 'ready\n' >"$marker"
trap - EXIT INT TERM
rm -rf "$install_backup"
exec 9>&-
if [[ -f $root/.git/omarchy-mise-predecessor ]] && command -v omarchy-install-hermes-cli >/dev/null 2>&1; then
omarchy-install-hermes-cli || echo 'Run omarchy-install-hermes-cli again to finish the old CLI cleanup.' >&2
fi
echo "Hermes is ready. Update it in the app or with 'hermes update'."
}
case "${1:-}" in
--check) ready; exit ;;
--install) install_desktop; exit ;;
--setup)
shift
if /usr/bin/hermes-desktop --install; then
setsid --fork /usr/bin/hermes-desktop "$@" >/dev/null 2>&1
exit
else
status=$?
echo "Hermes installation failed. Retry with: hermes-desktop --install" >&2
if [[ -t 0 ]]; then read -r -p 'Press Enter to close.'; fi
exit "$status"
fi
;;
esac
if ! ready; then
if [[ -t 0 && -t 1 ]]; then
install_desktop
else
exec xdg-terminal-exec /usr/bin/hermes-desktop --setup "$@"
fi
fi
# Chromium's own Ozone detection falls back to XWayland often enough to matter,
# and the result is a blurry window on every scaled display. Ask for Wayland
# directly, unless the user has already picked a platform themselves.
platform_flags=()
if [[ -n "${WAYLAND_DISPLAY:-}" || ${XDG_SESSION_TYPE:-} == wayland ]]; then
platform_flags=(--ozone-platform=wayland)
export HERMES_DESKTOP_HERMES_ROOT="$root"
export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}"
# Upstream desktop registration resolves its launcher through PATH.
export PATH="$HOME/.local/bin:$PATH"
unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME
flags=()
if [[ -n ${WAYLAND_DISPLAY:-} || ${XDG_SESSION_TYPE:-} == "wayland" ]]; then
flags=(--ozone-platform=wayland)
for flag in "$@"; do
case "$flag" in
--ozone-platform=* | --ozone-platform-hint=*) platform_flags=() ;;
esac
case "$flag" in --ozone-platform=* | --ozone-platform-hint=*) flags=() ;; esac
done
fi
exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@"
# Disable only the unusable setuid helper; Chromium keeps its namespace sandbox.
if unshare --user --map-root-user true 2>/dev/null; then
flags+=(--disable-setuid-sandbox)
fi
exec "$(desktop_executable)" "${flags[@]}" "$@"