Close Hermes on removal instead of asking the user to close it

Remove > AI refused whenever anything had Hermes's files open and told the user to close it and try again, and the thing open was Hermes: the agent in the terminal that choosing it as the default agent leaves running, the desktop app, a gateway. Those are the removal's to close. It now stops the gateway unit that upstream's `hermes gateway install` wrote, since that unit starts the runtime about to be deleted and would start it again the moment it was killed, then ends every process whose program lives in that runtime or in the package, and only then refuses over whatever is left, which is somebody else's: an editor on a skill, a shell sitting in ~/.hermes, a writer on the state database. Both are judged by the program, never by a later argument or by the home served, so an editor opened on a runtime file is the user's and a unit running a Hermes kept elsewhere is left alone whatever home it serves; for an interpreter the program is the script it runs, so a gateway started by hand as `python .../hermes gateway run` is found too. The refusal comes before anything is touched, so a run that stops leaves Hermes running as it was. A unit that will not stop still aborts the removal, as dropping the package would strand a live gateway on deleted code.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
committed 2026-09-24 21:35:46 -05:00
1 parent f3989092b2
commit 14eb9430c3
3 files changed
+290 -42

No files matched your search

+139 -33
View File
@@ -6,57 +6,163 @@
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail
ensure_hermes_stopped() {
python3 - "$HOME" <<'PY'
import os
from pathlib import Path
import sys
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
home = Path(sys.argv[1])
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
# also covers an unreachable user manager, which says nothing about whether
# the process is alive.
unit_stopped() {
local state
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
[[ $state == "inactive" || $state == "failed" ]]
}
# Hermes is closed here rather than asked to be: the agent in its terminal,
# the desktop app and its helpers, a gateway run by hand -- every process
# whose program, by its executable or by what it was started as, lives in the
# runtime about to be deleted or in the package; for an interpreter that is
# the script it runs, so a gateway started by hand as `python .../hermes` is
# found too. Judged by the program alone, never by a later argument: an
# editor opened on a file in the runtime is the user's. `check` refuses first if anything else has Hermes's files open, by
# its executable, its working directory or a descriptor, that editor or a
# shell sitting in ~/.hermes, so a refusal touches nothing; that
# one is the user's to close, because removing an open SQLite WAL leaves a
# live writer on a deleted generation. `stop` then ends Hermes's own and
# refuses again over whatever is left.
hermes_holders() {
python3 - "$1" "$HOME" <<'PY'
import os
import re
import signal
import sys
import time
from pathlib import Path
mode, home = sys.argv[1], Path(sys.argv[2])
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
roots.append('/opt/hermes-desktop')
runtime = [str((home / relative).resolve()) for relative in ('.hermes/hermes-agent', '.hermes/node', '.hermes/bin')]
runtime.append('/opt/hermes-desktop')
def belongs_to_hermes(target):
def under(target, directories):
target = target.removesuffix(' (deleted)')
return any(target == root or target.startswith(root + '/') for root in roots)
return any(target == directory or target.startswith(directory + '/') for directory in directories)
holders = []
for process in Path('/proc').iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
continue
try:
if process.stat().st_uid != os.getuid():
def processes():
found = []
for process in Path('/proc').iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
continue
targets = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
entries = [process / 'exe', process / 'cwd']
try:
entries.extend((process / 'fd').iterdir())
except PermissionError:
pass
for entry in entries:
if process.stat().st_uid != os.getuid():
continue
argv = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
program = []
try:
targets.append(os.readlink(entry))
program.append(os.readlink(process / 'exe'))
except OSError:
pass
if any(belongs_to_hermes(target) for target in targets):
holders.append(process.name)
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
touched = list(program)
try:
touched.append(os.readlink(process / 'cwd'))
except OSError:
pass
try:
for entry in (process / 'fd').iterdir():
try:
touched.append(os.readlink(entry))
except OSError:
pass
except PermissionError:
pass
started_as = argv[:1]
if started_as and re.fullmatch(r'(python[0-9.]*|node|bash|sh)', os.path.basename(started_as[0])):
started_as = argv[:2]
ours = any(under(target, runtime) for target in started_as + program)
if ours or any(under(target, roots) for target in touched):
try:
name = (process / 'comm').read_text().strip()
except OSError:
name = '?'
found.append((int(process.name), ours, name))
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
return found
if holders:
print('Close Hermes and processes using its files before removing it (PIDs: '
+ ', '.join(holders) + '). Then try again.', file=sys.stderr)
def named(entries):
return ', '.join(f'{pid} {name}' for pid, ours, name in entries)
def refuse(entries):
print('Something other than Hermes still has its files open (PIDs: ' + named(entries)
+ '). Close it, then run the removal again.', file=sys.stderr)
sys.exit(1)
strangers = [entry for entry in processes() if not entry[1]]
if strangers:
refuse(strangers)
if mode == 'check':
sys.exit(0)
mine = [entry for entry in processes() if entry[1]]
if mine:
print('Stopping Hermes (PIDs: ' + named(mine) + ')...')
for signum, patience in ((signal.SIGTERM, 10), (signal.SIGKILL, 3)):
for pid, ours, name in mine:
try:
os.kill(pid, signum)
except ProcessLookupError:
pass
deadline = time.monotonic() + patience
while mine and time.monotonic() < deadline:
time.sleep(0.2)
mine = [entry for entry in processes() if entry[1]]
if not mine:
break
if mine:
print('Hermes did not stop (PIDs: ' + named(mine) + '). Close it, then run the removal again.', file=sys.stderr)
sys.exit(1)
left = processes()
if left:
refuse(left)
PY
}
# Removing an open SQLite WAL leaves a live writer on a deleted generation.
ensure_hermes_stopped
omarchy-pkg-drop hermes-desktop
hermes_holders check
# The installer leaves a unit waiting to hand the app the Omarchy theme.
# The gateway unit that upstream's `hermes gateway install` writes starts the
# runtime about to be deleted, so it goes with it, and it is stopped before
# the processes are, or systemd would start the gateway again the moment it
# was killed. Judged by what the unit starts, as the processes are: a unit
# whose ExecStart runs this runtime is its own whatever home it serves, and
# one that runs a Hermes kept elsewhere is somebody else's arrangement even
# when its home sits under ~/.hermes. A unit that will not stop aborts the
# removal: dropping the package would strand a live gateway on deleted code
# with no way to restart it cleanly.
for unit_file in "$unit_dir"/hermes-gateway*.service; do
[[ -f $unit_file ]] || continue
grep '^ExecStart=' "$unit_file" | grep -qF "$HOME/.hermes/hermes-agent/" || continue
unit=${unit_file##*/}
if systemctl --user disable --now "$unit" 2>/dev/null || unit_stopped "$unit"; then
# .bak is what `gateway install --force` leaves behind when it rewrites a
# unit, so it goes with the unit.
rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit"
systemctl --user daemon-reload 2>/dev/null || true
# A unit that had been failing stays listed as "not-found failed" after
# its file is gone until its failed state is reset.
systemctl --user reset-failed "$unit" 2>/dev/null || true
else
echo "Could not stop $unit; Hermes was not removed." >&2
exit 1
fi
done
# The installer leaves a unit waiting to hand the app the Omarchy theme; it
# runs Hermes itself to do so, so it is stopped before the sweep reaches it.
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
hermes_holders stop
omarchy-pkg-drop hermes-desktop
# Upstream's installer writes this when the runtime under ~/.hermes has landed,
# whether Omarchy ran it for the app or the app's own first launch did, and it
# is the only thing that tells that runtime apart from one the user installed
@@ -115,7 +221,7 @@ if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cm
# turn that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then
ensure_hermes_stopped
hermes_holders stop
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
data_removed=true
fi