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:
1 parent
f3989092b2
commit
14eb9430c3
3 files changed
+290
-42
No files matched your search
+139
-33
@@ -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
|
||||
|
||||
Reference in new issue
Block a user