Every command waited 6.7 seconds first: how Hermes moved three startup chores off the critical path

A shell script that loops over a dozen hermes sessions list calls should take a couple of seconds; on one machine it took noticeably longer, and the interesting part was that nothing failed. Every call simply paid a toll before doing anything useful. Add the TUI backend, hermes serve, the gateway and every subprocess Hermes spawns, and “this tool feels heavy” stops being a vibe and becomes an accounting problem. On 2026-10-26, three pull requests merged into main that each closed one of those accounts. None of them are in a release yet — more on that at the end.
What “startup cost” actually means
Every Hermes process — the CLI, the backend behind the TUI, hermes serve, the gateway, and each child it derives — runs the same preparatory chores before it reaches your request. Startup cost is everything between exec and the first useful thing: reading config, importing modules, resolving which version of the code is running and, as it turned out, deleting old directories.
One second on a command you run two hundred times a day is three minutes, but you never experience it as one wait. All three chores below sat on the critical path for reasons unrelated to correctness: each was simply the first caller.
Bill one: the scratch cleanup that ran before anything else
Hermes gives every profile a scratch directory at ~/.hermes/cache/scratch and points TMPDIR/TMP/TEMP at it — but only if those three variables were not already set.
The problem was where the sweeping happened. get_scratch_dir() defaults to prune=True, and in nearly every process its first caller is the startup hook: hermes_bootstrap → export_scratch_tmp_env → apply_scratch_tmp_env. So “find the idle entries and rmtree them” was inlined ahead of everything else. The PR author recorded hermes --worktree sitting for 13 seconds while it deleted a 34 GB scratch entry. Nothing was broken; the command was waiting on a rmtree.
The fix keeps the policy and moves the work: the pass still runs once per process and at most once per hour, but a detached child process does it (python -I, re-attached to the user’s session manager after the command exits), so the command returns immediately. Measured with real processes, a fake HOME/HERMES_HOME and two git archive trees, hermes sessions list went from 6.74 s to 0.35 s when one idle entry held 600,000 files.
0.35 s is not a compromise: a control run with no such entry also takes 0.35 s, and the entry was deleted by the detached child 5.8 s after startup. With four idle entries of 15,000 files each and a process that exits immediately, the drop was 0.64 s to 0.05 s — and all four entries were still there when the process exited.
What did not change is the part that protects you. A directory you dropped in or untarred with an old mtime is still kept. An unclaimed sleep inside an idle entry still gets TERM before the directory goes. The directory of the shell that launched Hermes, and its parents, is kept. Background processes, gateways and CLI sessions that Hermes tracks itself are never stopped. And if psutil cannot be imported, nothing is deleted at all — the pass fails closed and logs process check failed …, nothing deleted this pass.
The policy explains why the directory sometimes looks neglected: an entry goes only after 24 hours of idleness, any write anywhere inside in the last day keeps it, and hardlinks do not count as writes — deliberately, so git clone --local sharing pack files with its source does not keep the clone alive forever. Every delete, stop and keep lands in ~/.hermes/logs/scratch-prune.log. Entries you cannot read or write — usually container output written as root into a bind mount — are not deleted blindly; you get a hint and a suggested sudo rm -rf '<path>'. Background on that plumbing: the terminal temp directory walkthrough.
Bill two: a full git version lookup on every single command
Run Hermes from a git checkout and every command got about 1 second faster: hermes sessions list from 1.453 s to 0.435 s (mean of six interleaved runs each way), and git subprocesses per command from 15 to 2.
Two root causes, the same bug in different clothes. Each ran a complete version lookup — seven git calls, one of which, git tag --merged HEAD, costs about 0.35 s on a large repository — on the startup path.
The first is import-time eagerness. import hermes_cli.config enumerates every provider plugin, and several build a HermesAgent/<release> User-Agent at module level: Fireworks, GMI, xAI, router, Kimi, OpenCode. To get the release into that string they resolved the version out of git during import, and auth_constants, models, model_catalog, auxiliary_client, gemini_native_adapter, web_server_messaging, Slack, Yuanbao and Perplexity did the same. One config import, a pile of git subprocesses, before your command has read a single argument. That import-layer cost is the subject of the config cache deep dive.
The second is a surprisingly expensive way to compare one string. The “update pending restart” notice calls _current_checkout_sha(), which goes through get_code_identity(refresh=True) — clearing the cache and re-running the whole lookup, every startup, even when there is no update receipt to compare against.
The fix is three small pieces: providers.base.LazyHeaders, a dict subclass that stores keys at import time and evaluates callable values on first read, so the User-Agent with the release in it is computed only when a request is actually built, while .copy(), |, update, json.dumps, pickle and isinstance(x, dict) still see a real dict; version_info.hermes_user_agent(product), computed at request-build time; and version_info.read_checkout_sha(), which gets the same SHA in one git call instead of seven. hermes --version, the User-Agent and the attribution headers are all unchanged.
Bill three: the commit-graph had been stale for two months
git tag --merged HEAD is used both at startup and by hermes update. On a real installation checkout it went from 0.39 s to 0.05 s.
Three things had gone wrong. Under partial clone, git’s own commit-graph writing is deliberately disabled (upstream #127711). Hermes’s pack maintenance never wrote one either. And a objects/info/commit-graph.lock left behind by a killed git process had been sitting there for two months, because gitlock’s stale-lock sweep did not cover that particular lock. So every tag lookup did the expensive thing, forever, with a lock file guaranteeing it stayed that way.
The fix refreshes the graph in the background after every hermes update and on every hermes -w start, using git commit-graph write --reachable --split --no-changed-paths. The same pass clears commit-graph and multi-pack-index locks and tmp_graph_* temp files left by killed git processes, and shutdown sends SIGTERM to the process tree first, SIGKILL only after a 5 second grace (default behavior for other callers unchanged), so kills leave less debris.
Measured on the real thing: _run_post_update_maintenance dropped from 2.82 s to 2.01 s, the split graph was written within 3 seconds of the phase returning, and commit-graph verify passes. Background maintenance on hermes -w went from doing nothing at all to writing the graph in 3.47 s and clearing that two-month-old lock. Since this now hangs off hermes update, the graceful upgrade guide covers the rest.
The three bills side by side
| What you do | Before | After |
|---|---|---|
hermes sessions list, one idle entry with 600k files |
6.74 s | 0.35 s |
| Four idle entries × 15k files, process exits immediately | 0.64 s | 0.05 s |
Version lookup from a checkout (hermes sessions list) |
1.453 s | 0.435 s |
| git subprocesses per command | 15 | 2 |
git tag --merged HEAD on a real install checkout |
0.39 s | 0.05 s |
_run_post_update_maintenance (includes real tag fetch) |
2.82 s | 2.01 s |
None of this work was wrong or unnecessary. It was just in the wrong place.
A smaller fix: a clock jump no longer evicts a running gateway
The fourth PR is small, but it produces a mystery. After the system clock jumped, the scratch sweep could misjudge a running gateway as abandoned and treat it accordingly; it no longer does. If a long-running background service has ever vanished right after a laptop resumed or NTP corrected the clock, this is a plausible explanation — and it is fixed in the same batch.
What this means for you today
All four PRs merged on 2026-10-26, which is later than v0.22.5 (released 2026-10-23). They are not in any release — not v0.22.5, not anything you can install today. They exist on main only. To get the improvement now, follow main; otherwise it arrives with the next version.
If you are staying on a release for a while, the numbers are still useful as a baseline: if a single hermes sessions list takes six or seven seconds, the cause is almost certainly a large idle entry under ~/.hermes/cache/scratch, worth inspecting before you conclude that Hermes itself got slow. ~/.hermes/logs/scratch-prune.log records every delete, stop and keep, so “did something eat my temp files” has an answer. From a checkout under ~/.hermes/hermes-agent, a leftover objects/info/commit-graph.lock is a real slowdown that nothing on v0.22.5 cleans up. And if you export TMPDIR yourself, note that Hermes only redirects the three temp variables when they are unset — your own temp files never enter the scratch policy.
For a fresh setup, the installation guide covers both the release and the checkout layout, and the distinction matters here: the version-lookup and commit-graph wins apply only to checkout installs, while the scratch pruning win applies to everyone.