Root cause: a draft has no session_id, so use-slash-completions sent commands.catalog /
complete.slash with empty params and tui_gateway's _session_home_scope (session-only
profile_home) scanned the LAUNCH profile's skills; CompleteSlashParams also rejected a
`profile` key outright (extra=forbid -> 4000).
Fix: the hook sends the tile's routed profile when there is no session (cache keyed by
session-or-profile); _session_home_scope resolves a session-less `profile` through
_profile_home; `profile` added to CompleteSlashParams and SkillsReloadParams (same
session_id-or-empty shape in skills.reload) and the TS contract regenerated.
Red-on-base: tests/tui_gateway/test_protocol.py::test_sessionless_slash_palette_follows_profile_param
fails on origin/main (complete.slash -> 4000 "profile: Extra inputs are not permitted");
use-slash-completions.test.tsx "scopes a sessionless catalog and typed lookup to the routed
profile" fails on origin/main (commands.catalog called with {} not { profile }). Both green on head.
tui_gateway/server.py::_stored_session_runtime_overrides read only the top-level model_config
keys and then fell back to billing_provider (frozen at the first accounted call), never the
nested gateway_runtime dict gateway/run_turn.py persists per turn — so a Telegram-driven session
resumed on Desktop paired the current model with the first-call provider and 404'd. Route the
Desktop/TUI reader through the canonical SessionDB.session_gateway_runtime (nested gateway_runtime,
then top-level TUI keys, then billing_provider), the same reader CLI --resume uses; the reader now
fills a missing top-level provider from billing_provider so the pre-existing TUI contract
(top-level base_url/api_mode + billing_provider) is unchanged. Redone slim from #125956, which
kept a second hand-rolled reader and inverted the precedence.
red-on-base: tests/tui_gateway/test_resume_switched_provider_endpoint.py::
test_resume_honors_gateway_runtime_over_first_call_billing_provider and
test_resume_prefers_gateway_runtime_over_stale_top_level_keys fail on origin/main with
`assert ('anthropic', None, None) == ('openai-codex', None, 'codex_responses')`; green-on-head.
hermes_cli/main_tui_launch.py::_print_tui_exit_summary printed `hermes --tui --resume ID` /
`hermes --tui -c "TITLE"` without the `-p <profile>` suffix that the classic CLI sibling
(cli_session_mixin.py::_print_exit_summary) already appends, so a hint copied from a named
profile's TUI resolved against the default profile and failed with "No session found".
Trimmed from #125079: no try/except fallback (get_active_profile_name does not raise), tests
folded into the existing tests/hermes_cli/test_exit_summary_resume_hint.py.
red-on-base: tests/hermes_cli/test_exit_summary_resume_hint.py::TestTuiExitSummaryResumeHint::
test_tui_hints_include_profile_flag_for_named_profile fails on origin/main with
`assert 'hermes --tui --resume 20260524_000001_abc123 -p dev' in '...hermes --tui --resume
20260524_000001_abc123\n hermes --tui -c "My TUI Session"...'`; green-on-head: 7 passed.
Root cause: tui_gateway/methods_session.py session.save built saved_dir from get_hermes_home()
in an unscoped @_session_method handler, so a secondary-profile session's /save landed in the
launch profile's sessions/saved/. Use session["profile_home"] like the other session-bound
handlers (session.history / session.create); the compute-host path is unchanged. No other
handler in methods_session.py builds a session-bound path from get_hermes_home().
Red-on-base: tests/tui_gateway/test_tui_gateway_server.py::
test_session_save_lands_in_the_sessions_own_profile_a_b_a fails on origin/main at
`assert parents == [saved, work_home / "sessions" / "saved", saved]` (index 1 is the launch
home); green on head (3 session_save tests passed).
Root cause: tui_gateway/methods_slash.py::_mirror_slash_side_effects ran mirror(...) off-turn
(slash.exec RPC pool / compute-host control reader) with no profile runtime scope bound, so on a
multiplexed gateway /model's switch_model -> resolve_runtime credential read raised
UnscopedSecretError ("live session sync failed") and the live session kept its old route. The
wrap at this chokepoint covers every mirror (/model, /prompt, /personality, ...) and all callers.
Red-on-base: tests/tui_gateway/test_multi_profile_hosting_fail_closed.py::
test_model_switch_mirror_binds_runtime_scope_under_multiplex fails on origin/main at
`assert invoke(b) == ""` with "live session sync failed: Hermes could not read this profile's
A_ONLY_TOKEN ..."; green on head (1 passed).
Root cause: tui_gateway/session_lifecycle.py::_interrupt_session_turn invoked the
agent_loop_stopped hook with no profile runtime scope bound, so on a pooled serve /
multiplexed gateway an observer's get_hermes_home() named the launch profile instead
of the interrupted session's. Binding _session_profile_runtime_scope(session) inside
the function covers every caller (session.interrupt RPC, WS orphan reaper, idle reaper,
lease takeover, compute-host close); same rule _finalize_session already applies.
Trimmed from #125073: no hook-payload change, no plugins.py doc change, one A->B->A test.
Red-on-base: tests/tui_gateway/test_interrupt_agent_loop_stopped_hook.py::
test_interrupt_hook_observer_sees_the_sessions_profile_a_b_a fails on origin/main with
`assert seen == [launch, routed, launch]` (index 1 is the launch home); green on head (4 passed).
Co-authored-by: Yuan Li <dskwelmcy@163.com>
The full-clone shape (#122353), the second update after the unshallowing
update (#124272) and the --depth pre-fetch (#123346) cells run HEAD code and
now assert plainly. #123254 and #124645 live in N-1's pre-swap fetch and
check/pull code, so those gates stay until a release carrying the fix is N-1;
their reason text now says so.
The upgrade E2E cells are the regression proof; the unit tests keep one
invariant per behaviour: full clone stays full / partial clone keeps its own
filter (#122353), crash recognizer + single promisor-disabled retry (#124272),
heal of a depth-1 clone / of a --depth-grafted full clone (#123254, #123346).
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
The pre-fetch heal (#123254) and the post-update tag fetch now share
fetch_full_commit_graph, so both follow one clone-mode rule: a partial clone
repeats its own filter, a full clone fetches unfiltered (#122353), and only a
depth-limited clone whose history is really missing unshallows as tree:0. A
full clone grafted by a user's `git fetch --depth 1` already has its history
locally, so it unshallows unfiltered and stays full.
Also: the network-timeout message no longer claims "no response from the
remote" (a large transfer on a slow link hits the same cap), without adding a
classifier rule that would assert the opposite.
A depth-1 installer checkout far behind main could never update: the plain
main fetch pulled nearly the full object graph (merge-heavy history drags in
side-branch ancestry past the shallow boundary), hit the 300s network cap
mid-transfer every time, and the unshallow step that makes later fetches
incremental only ran after a successful update. Unshallow the checkout first
(commit-graph-only fetch with the same 900s budget the post-update tag fetch
uses, pulling the update branch along), non-fatally, and stop reporting a
wall-clock kill as 'no response from the remote'.
Fixes#123254
prune_stale_shallow_grafts dropped grafts no live ref points at, but the
remote-tracking reflog still naming a dropped fetch tip made the --reflog
fail-safe walk fail (its parent was never fetched), rolling the prune back
on every run — grafts kept accumulating and the next update fell into
orphan divergence (#124645). Expire exactly the fetch reflogs that pin a
dropped graft before writing the trimmed file; user reflogs (HEAD, local
branches) are never touched, so the fail-safe rollback still guards them.
Fixes#124645
Git for Windows 2.54 reports the same partial-clone pack-objects BUG()
without the POSIX 'pack-objects died of signal 6' line (#124293 field
evidence): the BUG assertion is followed directly by 'fatal: could not
finish pack-objects to repack local links' and 'fatal: index-pack failed'.
The recognizer demanded the signal wording, so the recovery retry missed
this Windows occurrence entirely.
Require the BUG fingerprint plus EITHER terminator of the same abort —
the POSIX signal line or the Windows repack fatal — and pin both stderr
shapes plus a fingerprint-without-terminator negative in the regression.
On a partial clone (clone --filter=tree:0), git 2.53/2.54 crashes every
fetch: index-pack --promisor runs repack_local_links(), which feeds
pack-objects --exclude-promisor-objects-best-effort, and pack-objects
BUG()s (SIGABRT, 'should only be called on existing objects') when the
link traversal hits a legitimately missing promisor object. The crash is
deterministic, so every fetch — and with it the CLI's update/check flows
— stays broken until the user applies the manual workaround themselves.
The recovery is the reporter's verified workaround, wired in: on this
exact crash (all three stderr markers), retry the fetch once with
-c remote.origin.promisor= (per-invocation only; the user's filter
choice stays in their config). Wired into both fetch sites — the update
fetch and the check fetch (which also serves forks via upstream).
Unrelated fetch failures are returned untouched; if the retry still
crashes, the update path prints the manual heal command.
Fixes#124272
Hardcoding tree:0 for partial clones would silently tighten a checkout the
user cloned with --filter=blob:none. Read the existing
remote.origin.partialclonefilter instead and pass it back verbatim; full
clones still fetch unfiltered.
git fetch --filter=tree:0 writes remote.origin.promisor=true and
remote.origin.partialclonefilter=tree:0 even when those keys were
deliberately removed, so every hermes update converted a de-partialised
checkout back into a partial clone and re-armed the should_include_obj
fetch failure (#122353). Pass the filter only when the checkout already
is a partial clone; full clones fetch tags unfiltered.
/proc/locks is host-wide and the kernel serves it over several read() calls,
resuming by record index. When other processes add or drop locks between
those reads the table shifts and entries are skipped, so a lock that is
still held can read as gone.
- tests: the live-DB preview and header-probe lock tests read their own
process's locks from /proc/locks and flaked on busy CI hosts (1 in 12 on
a quiet box; 9 in 12 with six processes churning locks). A shared
tests/posix_lock_probe.own_posix_locks() reads /proc/self/fdinfo instead,
which lists this process's locks per open file in one read.
- hermes_state_lockowners: state_db_write_lock_holders() names the writer
in the "state.db write lock unavailable" warning. One pass could skip the
holder and log "no write-class lock held at the deadline". It now unions
three passes; a skip rarely repeats.
pm/lock.json and the Npm/AgentBrowser templates record registry.npmjs.org, and
pinned_source handed that URL straight to the downloader, so ~/.npmrc /
npm_config_registry were ignored and closed networks could not provision npm or
agent-browser. pinned_source now routes public-npm URLs through the configured
registry (npm's own precedence: npm_config_registry, then the user npmrc); the
lock's SHA256 still verifies the bytes and progress keeps the lockfile URL.
npm_dist_tags reads from the same registry.
The E2E cell test_npm_registry_mirror_serves_pm_npm_download drops its gate.
Co-authored-by: funky-xamarin <30426178+Wenfengcheng@users.noreply.github.com>
The E2E cells (test_update_with_corporate_root_only_in_ssl_cert_file, test_p2_env_lock_refusal_is_not_reported_as_success)
now assert the fixed behaviour directly.
The .env write lock (a package-managed install, or a key the administrator
pins in the managed-scope .env) refuses inside save_env_value /
remove_env_value by printing to stderr and returning. The caller cannot tell
that apart from a successful write, and the four multi-store writers built on
them carried on:
- save_provider_env_credential still rewrote config.yaml api_key mirrors of
the old value, reloaded the pool and returned ok:true.
- remove_provider_env_credential still pruned the env-seeded credential_pool
entry, recorded env:<VAR> in suppressed_sources and dropped config.yaml
mirrors.
- save_env_setting / remove_env_setting still dropped the top-level
config.yaml copy.
So `hermes config set|unset <PINNED_KEY>` printed the refusal, then "✓ Set" /
"✓ Unset", and exited 0. PUT and DELETE /api/env answered 200. With the user's
.env holding an old key and model.api_key mirroring it, a refused set left
.env on the old key and moved config.yaml to the new one.
The lifecycle writers now check the lock before any side effect and raise
ValueError with the refusal (require_env_writable). The CLI set/unset env
routes turn that into exit 1, and _env_write_errors already maps ValueError to
400, so the dashboard and Desktop get the reason. save_env_value and
remove_env_value keep their return shape for their other callers.
model.disconnect (TUI/Desktop) loops over every env var of a provider, so
it asks the lock about all of them (env_write_refusal) before removing any:
a pinned key refuses with nothing touched, where it used to strip the
user's own key first. A locked var that holds nothing is skipped, so
Disconnect keeps clearing the auth store on a package-managed install,
where every .env write is locked but api keys live in auth.json. The CLI
secret prompt prints the refusal instead of only the skill setup's log line.
Removes FALSE_SUCCESS_GATE (#109290) and the N-1 #124778 dashboard gate: the respawn runs in the
updated checkout's code in both columns, and the N-1 column is 7/7 on this branch (5/7 on
origin/main with the gates off). Removes PID_FILE_GATE (#123109): the bootstrap launcher is already
classified by #124965 and the cell passes on origin/main in both columns.
The pre-takeover argv is [<old venv python>, <hermes launcher>, ...]. Rebuilding it on
sys.executable + <repo>/hermes spawns a child that only works if that interpreter already has the
dependencies on sys.path; runtime_command is the launcher every other Hermes spawn uses (store
Python + bootstrap), so the respawn works from the bare takeover interpreter too. Only a
python + hermes-launcher pair is rebuilt.
_respawn_dashboard_processes appended --no-open to the argv before the
Popen call and returned the modified argv in the failed list, so the
receipt-side matching (pid_cmdline.get(p) in failed_cmds) never matched
a dashboard command — the #109290 gap survived for the most common
case. Keep the spawn argv separate and report the caller's original
argv on failure, which is what the cmdline reconciliation compares
against.
When the post-update dashboard respawn raised, the receipt derived the
runtime outcome from the incarnation liveness probe: the pre-update pid
being gone is exactly what a failed respawn looks like from the outside,
so the row read "restarted" and the overall receipt read "success".
The truth already existed on the producer side (the respawn path returns
the argvs that failed to spawn) and was dropped at the call site.
Thread it through: _restart_killed_backends now returns the PIDs whose
argv respawn was attempted and failed, _finish_dashboard_update_cleanup
returns them to both update paths, and match_runtime_outcomes checks
failed_respawn_pids before the incarnation probe — "we watched the spawn
fail" outranks "gone". Reuses the existing "failed" vocabulary (the unit
path already emits it) and escalates the receipt to "partial" like the
unaccounted-tripwire class. See #109290.
A pre-PM-takeover install left ~/.local/bin/hermes as a symlink to a
Python console script, so the kernel recorded a manual backend as
[<old venv python>, <launcher>, dashboard, ...]. After the takeover
rewrote that launcher into a POSIX shell shim, _respawn_dashboard_
processes replayed the captured argv verbatim: the old interpreter was
asked to parse a shell script, the child died at parse time
(SyntaxError in logs/dashboard-restart.log), and the updater still
printed '✓ restarted' (#124778).
Collapse any [interpreter, launcher, ...] pair onto the running
install's sys.executable + repo-root hermes entry before spawning
(other shapes, e.g. python -m, replay unchanged), and treat a child
that exits within a 1s grace window as a failed restart instead of a
success — a respawned backend is a resident server, so an immediate
exit is a startup failure (stale argv, port already bound), never a
restart.
* refactor(desktop): remove the onboarding intro cinematic and the guided-onboarding tests
The guided first run now opens straight into the guide: the gate's
`cinematic` phase becomes `pending`, and OnboardingChatGate owes the guide
on mount (the old HERMES_SKIP_INTRO path, now the only path). Removes the
native intro window, its IPC bridge, the `?win=intro` renderer, its assets,
and the skipIntro launch flag. Deletes the guided-onboarding test files ahead
of the rewrite, plus the reset helpers only they called.
* refactor(onboarding): strip comments from the guided-onboarding modules
Clears comments and docstrings from the guided-onboarding files ahead of
their rewrite, desktop and backend. Lint and type pragmas stay. No code
changes.
* feat(docker): publish nousresearch/hermes-sandbox:desktop for terminal backends
The terminal backends (docker, modal, daytona, singularity) all default to
nikolaik/python-nodejs:python3.11-nodejs20, a bare Python+Node base. For Bot
Screen, computer_use and the browser to run INSIDE that sandbox instead of on
the gateway host, the sandbox image needs the display stack.
docker/sandbox-desktop.Dockerfile is that base plus:
- the everyday tools it lacked (jq, ripgrep, fd, tmux, less, nano, vim,
zip, rsync, tree, procps, htop, sudo for the base's uid-1000 `pn`)
- the exact package set the Hermes -desktop image installs (TigerVNC,
Xfce components, dbus, xauth, fonts)
- Playwright's headed Chromium (same build as the -desktop image)
- cua-driver 0.28.2 from its pinned release tarball
No Hermes inside; the default user stays root like the base so nothing
changes for people who just switch docker_image. Desktop processes run as
`pn`. 4.27 GB on amd64.
docker.yml gains a `sandbox` variant with its own cache scope and repository
(nousresearch/hermes-sandbox:desktop, :main-desktop, :<release>-desktop);
the docker-integration suite is skipped for it (no Hermes to test) and
docker/sandbox-desktop-smoke.sh runs instead: as `pn`, every launcher and
cua-driver binary resolves, the real launcher.sh publishes :20, the RFB
socket completes the 3.8 handshake relayed over `docker exec -i` stdio, and
a headed Chromium maps a window on that display. hadolint lints the new
Dockerfile in docker-lint.yml.
* ci(docker): build the sandbox image on release/dispatch, not every main push
Leaves docker.yml exactly as on main. The sandbox image carries no Hermes code,
so two 4 GB multi-arch builds per merge bought nothing. sandbox-image.yml builds
and smokes on a PR that edits its own Dockerfile/smoke, and publishes only on a
release or a manual dispatch with publish=true. Stable tag stays :desktop.
* feat(docker): sandbox desktop base on python3.13-nodejs26
Matches the Hermes image (Python 3.13 / Node 26) and the top of requires-python;
the default docker_image tag it inherited was Python 3.11 / Node 20. Same pn
uid 1000, Debian 13; smoke (launcher, RFB relay, headed Chromium) passes.
* feat(docker): sandbox desktop image bakes agent-browser and Pillow
The runtime PR wraps the browser tools' agent-browser invocation in the sandbox
and grabs the Screen pane thumbnail inside it (the X socket and its cookie live
there). Both need to be in the image the default tag publishes, or the runtime
finds a tag-compatible image without them. agent-browser is pinned to the range
the gateway resolves (tools/browser_tool.py AGENT_BROWSER_NPX_SPEC), scripts off,
so the first browser_navigate in a fresh sandbox never waits on npm. The smoke
checks both as pn.
* docker(sandbox-desktop): login sessions find the browser (PLAYWRIGHT_BROWSERS_PATH via /etc/environment)
Dockerfile ENV reaches docker exec only. As the target of the ssh backend
(sshd on top of this image) a login session gets its environment from PAM,
so agent-browser reported 'Chrome not found' over ssh while working under
docker exec. Write the browser location to /etc/environment as well; the
smoke test asserts a clean login environment resolves it.
Fold #125784's prompt matrix ("Channel hint.", "", None) into the existing
parametrized continuity test. "Channel hint." is the regression guard; ""
pins the plain pass-through (downstream consumers treat "" and None alike,
so it guards against an `or None` rewrite, not a behaviour difference); None
is the control that passes on main and head and keeps the parent-override
ephemeral invariant honest. Drop the negative-control assertions that could
not fail once the override check passed (a tautological inequality and stub
echoes), and the trailing blank line that tripped `git diff --check`.
Co-authored-by: funky-xamarin <30426178+Wenfengcheng@users.noreply.github.com>
* fix(compression): compaction follows the ratio again; no default token cap
compression.threshold_tokens defaulted to 256000 since #115986, so every
window above ~341K compacted at 256K regardless of compression.threshold
or model_thresholds: a 1M-window model compacted at 25% of its window,
and threshold: 0.8 still compacted at 256K. No single token count suits
windows from 64K to 1M+, so the cap goes back to opt-in (null) and the
ratio decides, as it did before #115986.
The template seeder and `hermes doctor --fix` copied the 256000 default
into config.yaml, where it reads as a user choice and would keep capping
those installs. Config v47 drops threshold_tokens only when it equals
256000; any other explicit cap and an explicit null are preserved.
The rest of #115986 stays: the model-switch warning quoting the real
trigger and the shared _derive_trigger are correct with any default.
Tests that encoded 256K now derive expectations from DEFAULT_CONFIG.
* docs(compression): threshold_tokens is an optional cap, default null
User guide, developer guide and delegation page described the 256K
default cap; they now describe the ratio trigger as the default and
threshold_tokens as an opt-in cost ceiling.
browser-harness sends each CLI event from a detached python child; in the
smoke it outlived the check and held the relocated payload open on Windows,
so the restore rename failed with EPERM. Hermes's own harness env sets
ANONYMIZED_TELEMETRY=false; the smoke now does the same.
The relocated-payload smoke now runs browser_exec's engine the way
tools/browser_use_cli.py launches it (store interpreter, -S, only the
payload site dir on PYTHONPATH), so a payload that cannot start the
default browser driver fails the release build instead of shipping.
The default browser_exec tool ran the `browser-use` CLI from a PM side
environment (browser-use==0.13.10 in <home>/environments/browser-use),
provisioned by the installers and `hermes update`. Sealed Desktop payloads
skip that step, so the Desktop app never had it and silently fell back to
the built-in tools; the side env was also per-profile and 225 MB.
The CLI's execution path is only `browser_harness.run.main()`; the
browser-use agent framework (anthropic/openai/google-api pins, 93 MB of
googleapiclient) is never imported. browser-harness itself is 2.6 MB of
pure Python whose pins (Pillow 12.3.0, websockets 15.0.1) already match
Hermes's own, so it becomes a core dependency and runs on sys.executable:
- pyproject/uv.lock: browser-harness==0.1.13 (+ cdp-use, fetch-use).
- _find_cli() returns [sys.executable, -m, browser_harness.run]; the child
env points PYTHONPATH at the harness site dir (the Desktop store
interpreter boots without a venv and the harness daemon re-runs
sys.executable), replacing whatever the agent inherited.
- The side-env provisioning (install_cli, the update/installer step) goes.
- Bump pin ae81c5b -> f5a880c (docs-only commit: refreshed screenshot,
new 1280x640 social preview, HOW_TO_USE.md guide linked from README)
- Add image: raw.githubusercontent URL of the social preview (2:1, 1280x640)
- hermes plugins validate passes at the pinned commit
restore now holds the memory tool's own lock while it writes MEMORY.md/USER.md and
refuses when a file changed after its plan, so it never replaces a running agent's
memory write. The plan lists the memory entries it brings back and removes.
Importing a Hermes backup put the backup's copy of the history back and cut off
every version recorded after it, the state right before the import included.
Those versions now stay, and the import is recorded as one change restore can undo.
Re-pins AhmetArif0/hermes-memory-rewind to 5f935c2 (1.3.0). A change made outside the
agent's turns was credited to whatever came next: a write approved with /memory approve
showed up as the agent's next memory call, a curator archive as the next session's. Each
turn now starts with a baseline (pre_llm_call, which declares only session_id and
platform), and the session-start baseline names no session. Adds pre_llm_call to hooks.
Re-pins AhmetArif0/hermes-memory-rewind from b747327 to e5b139b, which only redraws
docs/card.png. The plugin page header crops the 2:1 image to rows 110-490, which cut off
the card's title and last line. The plugin directory and version 1.2.0 are unchanged.