Merge Settings → Connections into Settings → Gateway as a single
"Gateways" page:
- One nav entry ("Gateways"); the Connections nav entry is removed. The
legacy `?tab=connections` deep link stays in the route enum and
redirects to the unified page, and renders it in the interim frame so
saved routes/bookmarks don't break or flash.
- The connections registry UI (list, add, edit, delete, test, make
primary, update-all) moves into its own component
(connections-registry.tsx) composed at the bottom of the Gateways
page; connections-settings.tsx is deleted (nothing else imported it).
- The Add flow now offers ALL kinds: Local, Hermes Cloud, Remote
gateway, and SSH (previously only remote/ssh). The Local kind button
is disabled while the managed local entry exists; a hint points cloud
adds at the sign-in/discovery flow above.
- Duplicate prevention, enforced in the save path on both sides of the
IPC boundary (renderer inline error + main-process
normalizeConnectionInput throw): only one 'local' entry ever;
remote/cloud dupes keyed on the normalized URL (trim, strip trailing
slashes, lowercase) across both kinds; ssh dupes keyed on normalized
user@host:port + remote profile.
- The page-level "Applies to" ScopeChip row is gone. Per-profile
gateway overrides are now an explicit "Per-profile overrides"
subsection listing the default connection and each named profile,
with an Edit affordance that drives the same scope state machinery.
- Command palette and profile-rail deep links retarget the unified
page; i18n nav rename + new strings across types, en, ja, zh,
zh-hant, ar.
Tests: new connections-registry.test.tsx (registry UI + dedupe helper
units, including the inline duplicate rejection), electron
connection-registry.test.ts dedupe cases, and updated
profile-rail-connect test.
The BotFace catchlight dots were hardcoded white. On dark bodies
(maroon/ink/oxblood) isDarkColor() flips the pupils to light cream
(eyeFill), so a white catchlight on a cream pupil is invisible — those
avatars looked like they had no dots in their eyes (image14 report).
Catchlight contrast now follows the pupil, not the body: dark pupils
keep the white sparkle, light pupils get a dark one (rgba(0,0,0,0.6)).
The animation clock only moves the hb-hl elements (cx/cy) and never
touches fill, so the initial-render fill carries through every frame.
Verified with a rendered before/after/control strip: BEFORE maroon has
no visible dots, AFTER shows clear dark dots in the cream eyes, light
control body unchanged with white catchlights. Plugin suite green
incl. new face-catchlight.test.mjs.
Two bugs found by a REAL two-gateway live test (two isolated HERMES_HOMEs,
bravo running the api_server platform, alpha's agent autonomously running
`hermes peer dm` from its Bot Chat protocol; reply relayed correctly and
persisted in bravo's canonical Bot Chat):
1. peer dm parsed the session-create response flat, but api_server wraps
the row: {"object": "hermes.session", "session": {...}} — every first DM
to a fresh peer failed with "Peer did not return a session id" (and the
orphaned Bot Chat then 400'd retries with duplicate-title). Parse the
wrapped shape; the test fake now mirrors the real response shape so this
class can't pass green again.
2. api_server: a session created with no model persists the advertised
virtual model ("hermes-agent") on the row; session chat then replayed it
as a REAL model id and the provider 400'd ("hermes-agent is not a valid
model ID"). _request_agent_overrides already filters the virtual model
for per-request bodies — apply the same filter to the stored session
model at both chat sites (sync + stream), so it means "gateway default"
exactly like the request-body path.
Live E2E transcript (bravo's Bot Chat, via /api/sessions/{id}/messages):
user: Message from 🤖 alpha (@alpha): What is your callsign?
assistant: CALLSIGN-BRAVO-7
peer cmd unit suite 10/10 with the corrected fake.
Follow-up to the salvaged #88632 (Jack Lau) fix for #88532, extending the
same repair to the sibling frozen-at-init handle and closing the handle
lifecycle gap the per-path cache introduces.
1. GatewayRunner._session_db had the identical bug class: bound once as
AsyncSessionDB(SessionDB()) in __init__ on the root home, while /resume,
/title, /history and session search all execute inside
_profile_runtime_scope on a multiplexed gateway. Convert it to the same
property-with-pin pattern: per-access resolution of _default_db_path(),
one cached AsyncSessionDB per resolved path under a lock, and assignment
preserved as an explicit pin (many suites install fakes or None).
Construction-time priming keeps the #88235 init-failure broadcast at
startup.
2. Handle lifecycle: the per-path caches accumulate one open SessionDB per
profile served, but the shutdown path closed only store._db /
runner._session_db - which now resolve just the shutdown task's own
(root) scope. Secondary profiles' handles would strand their WAL write
locks until process exit, recreating the abandoned-handle leak
b454e4da76 fixed and breaking --replace restarts with 'database is
locked'. Add close_all_db_handles() / close_all_session_db_handles()
sweeps and call both from the gateway teardown path. SessionDB.close()
is idempotent, so the root handle being closed by both the legacy loop
and the sweep is safe.
Tests: sweep coverage plus a runner-property scope/pin/cache test in
tests/gateway/test_multiplex_session_db_profile_scope.py.
Fixes#88532.
A multiplexed gateway serves every profile from one process, but
SessionStore bound a single SessionDB during __init__:
self._db = SessionDB()
SessionDB(db_path=None) resolves _default_db_path() at call time and
does follow the context-local HERMES_HOME override, so the path
machinery was already correct. The problem was when it ran: at
construction, on the process's own root home, long before any inbound
event enters _profile_runtime_scope. Every profile's rows therefore
landed in the root state.db, even though the scope had redirected
get_hermes_home() correctly for the turn (that helper's own docstring
lists "sessions" among what it scopes).
The rows still carry the right profile_name, stamped from
source.profile by the same handler, so nothing in the data looks wrong.
The only visible symptom is the desktop listing a profile's session
under the default bot: _open_session_db_for_profile opens
profiles/<name>/state.db, which never received the write.
Look the handle up through a property instead, resolving the active
scope per access and caching one handle per resolved path so a hot
inbound path opens SQLite once per profile rather than once per
message. Construction stays under the cache lock so a concurrent first
message on a profile cannot open and then leak a second handle.
Assignment is preserved as an explicit pin, which is what the existing
suites rely on when they install a fake handle or disable the DB with
store._db = None, and a pin keeps winning across scope changes.
Behavior is unchanged when no profile scope is active, so single-profile
gateways resolve exactly the path they did before. This does not migrate
rows that already landed in the root store; those stay where they are.
Partial salvage of PR #88561 (routines-filter-hint half only; the pet
gallery pagination half is out of scope for #88263 and was skipped).
When the cron store has jobs but none surface for the active bot (older
gateway without profile scoping, no [bot:<name>] tags matching), the
Routines pane now explains that cronjobs exist but are hidden, instead
of showing the generic empty state — so users don't believe their
scheduled jobs disappeared.
Port of NousResearch/Hermes-Bot-Mode#95 (repo archived, ported upstream).
Follow-up to the #88347 salvage: after release_all_under() force-closes a
profile's shared connection, a store re-created on the same path registers
a fresh entry under the same key. A stale holder that later calls close()
would pop that fresh entry (its refs were transferred nowhere), letting a
third store open a second connection to the same database — exactly the
multi-writer contention the shared registry exists to prevent. close()
now evicts the registry entry only when it is still its own.
The desktop's main serve process opens memory_store.db for every known
profile and nothing closed those connections before delete_profile's
rmtree — on Windows the open SQLite handles make the removal fail with
WinError 32 for both the CLI and the DELETE /api/profiles/<name> route
(#88347). POSIX unlinking of open files hid the same leak.
MemoryStore.close() is refcount-driven, so a live holder keeps the
handle forever; add MemoryStore.release_all_under(directory) to
force-close every shared connection under a directory, and call it in
delete_profile after stopping the profile backends. Inside serve the
handles live in that very process and get released; from the CLI it is
a no-op.
Fixes#88347
Three bugs in apps/desktop/src/plugins/hermes-bots/plugin.js:
1. register() subscribed host.state.profile / host.state.gateway listeners
without capturing the unbind functions, so a plugin disable -> re-enable
cycle stacked a duplicate listener per cycle that kept firing until app
quit (same survives-disable class as the face clock before its
onDispose hook). The unbinds are now captured and released via
ctx.onDispose.
2. CreateRoutineDialog passed `key: createTarget` INSIDE the jsx() props
object. The react/jsx-runtime silently ignores a `key` prop there (key
is the third jsx() argument), so switching the routine owner never
remounted the dialog and it kept stale per-bot form state. Moved the
key to the third argument; the source-shape test now pins the correct
shape and rejects the prop form.
3. beginOAuth() overwrote pollRef.current without clearing an existing
interval, so a retry / double-click while a poll was live orphaned a
2s poller that ran until unmount and could flip the phase from a stale
OAuth session. It now clears any live poll before starting a new one.
Community report: group chats showed neither profile pictures nor proper
names for members (names fixed in #88721; this adds the avatars).
- Every bot message in a group room now carries the speaker's avatar,
resolved through the same botAppearance pipeline as the roster: custom
uploaded/generated/pet images honored, backfilled PNGs dropped so the
animated math face renders, deterministic shape+color for bots with no
customization (including remote speakers with no local meta).
- The room header shows the member roster as overlapping faces (capped
at 6) with a display-name tooltip, alongside the existing count.
Follow-ups on the salvaged bounded-teardown fix:
- Replace the source-grep regression test (backend-lifecycle.test.ts asserted
main.ts source text) with behavior-contract tests against the extracted
createPoolStopper(): handle retention until bounded exit, stop dedup,
stopAll completeness, and the respawn-awaits-dying-backend gate.
- Gate the registry-local spawn path on the in-flight stop too (sibling site
the original PR predates).
- Route teardownPoolBackendAndWait through the shared stopper so profile
delete/rename teardown covers both local pool keys with the same bounded
semantics.
Follow-ups on top of the salvaged rename lifecycle:
- Retire both local renderer socket scopes for the OLD profile name before
the rename PATCH (rename-profile-dialog), mirroring the delete-path fix
from #88638 — a retained socket otherwise treats the rename's backend
teardown as a transient drop and its reconnect respawns the old-name
backend, whose ensure_hermes_home() recreates the directory the rename
just moved (#45474).
- Hold the profile deletion gate for rename requests too, so a concurrent
renderer reconnect entering ensureBackend() mid-rename cannot respawn the
old-name backend.
- Regression tests for retire-before-rename ordering and the
validation-rejected path.
Community report: in a Bot Mode group chat with the primary agent, Hermes
lost its name and rendered as @default in the room.
- Workspace speaker labels now render the roster displayName (default →
Hermes, titles respected) instead of the raw @profile id. Clicking a
speaker reveals the full disambiguated form — Display-gatewayname
(@handle) — so same-named agents on two connections stay tellable
apart on demand; naturally they just show their display name.
- Room transcripts fed to members render the default profile as Hermes
(new groupSpeakerLabel helper).
- The per-turn prompt addresses members by @handle (@hermes for the
primary profile) instead of @default, and parseGroupChatMentions now
resolves @hermes back to the default member via botHandle.
Bots could message teammates on their own machine (hermes -p <bot> chat) and
the desktop could relay user mentions over Connections, but a bot had NO
transport to a bot on another gateway. This adds one, with zero new server
surface: the peer's existing api_server platform is the wire.
- hermes_cli/subcommands/peer.py: `hermes peer add/list/remove/dm`.
`dm <peer>[/<agent>]` resolves the remote agent's canonical "Bot Chat"
(list by title, create when missing), runs one synchronous agent turn via
POST /api/sessions/{id}/chat, and prints the reply on stdout — the exact
cross-machine twin of the local bot-messaging command, so the Bot Mode
protocol composes over it unchanged. Named profiles route via the peer's
/p/<profile>/ multiplex mirror. Peer URLs live in config.yaml
(`bot_peers`); the peer's API_SERVER_KEY is a credential and lives in
~/.hermes/.env as HERMES_PEER_<NAME>_KEY.
- hermes_cli/main.py: parser wiring + fast-path/session-flag command sets.
- tools/bot_mode_probe.py: when peers are registered, the injected Bot Chat
messaging protocol gains a cross-machine paragraph (peer roster +
`hermes peer dm` pattern) so agents discover remote teammates on their
own; peers join the capability fingerprint so registering/removing one
refreshes eternal Bot Chat prompts on the next message (loud, one-time,
user-initiated — no per-turn cache drift).
- Docs: Bot Mode guide (bot-initiated DMs across machines) + cli-commands
reference (`hermes peer` section + summary row).
Tests: tests/hermes_cli/test_peer_cmd.py (target parsing, /p/ scoping,
registry round-trip in isolated config, real-loopback-HTTP dm flow incl.
Bot Chat create-vs-reuse and bearer auth), bot_mode_probe peer-paragraph +
epoch tests. E2E: real `python -m hermes_cli.main peer ...` against a live
fake peer over HTTP with isolated HERMES_HOME (config/.env persistence,
bare + /p/<profile> routing, stdin, --json). 23 passed; ruff clean.
Bot Mode's group chats spawned one per-member session per room, and those
"Group: ..." rows (plus canonical Bot Chats when the old eye-toggle pref was
off) flooded the global Sessions sidebar — a 6-bot room dumped six identical
rows into recents (reported with screenshot, Aug 17).
Plugin (apps/desktop/src/plugins/hermes-bots/plugin.js):
- session.create now passes hidden:true UNCONDITIONALLY for both canonical
Bot Chats and group-room member sessions; the $hideBotChats pref, its eye
toggle, and its storage hydrate are removed (Bot Mode sessions are plumbing
or plugin-owned forever-chats, never scratch conversations).
- hideOwnedBotSessions(): idempotent reconciliation sweep over every owned
session id (bot meta canonical chats + each room's member sessions) via
session.set_hidden, run on plugin load and on each gateway reconnect, so
rows born visible under the old pref get cleaned up.
- The Bots session browser and canonical-chat recovery scan pass
include_hidden:true so they still see the rows they own.
Gateway (tui_gateway/methods_session.py):
- session.list honors an include_hidden param (default off — the resume
picker and all global callers keep dropping hidden rows).
- session.set_hidden gains a durable fallback: when no LIVE runtime session
matches, resolve the stored session id in the target profile's state.db
(via resolve_session_id) and flip the flag there. The sweep holds stored
ids for chats that aren't live; the old live-only lookup 4001'd them.
Validated E2E with real imports against a temp HERMES_HOME: born-hidden row
(hidden=1), profile-scoped session.list default vs include_hidden (0 vs 1),
and stored-id sweep on a non-live legacy row (hidden=1). Plugin suite
167/167; new RPC regression tests in tests/tui_gateway/test_session_hidden_rpc.py.
Covers the precise pinned-session resolver added in PR #88690: request
param shape, the preferred_session response field, hidden-row and
compression-lineage resolution, and older-gateway behavior.
Reconstructs the 204-run benchmark battery behind #81958 (Browser Use CLI
3.0 mode) as a rerunnable eval under evals/browser-use/, following the
toolperf_abeval / evals-compaction pattern.
- tasks/easy.json + tasks/hard.json: the oracle-checked toscrape task
batteries (5 easy, 6 hard) exactly as run for the PR
- single_run.py: one cell = task x arm (base | pr | prns) x model x rep;
throwaway HERMES_HOME, web-fetch creds stripped, arms pinned to separate
trees via BUBENCH_BASE_TREE / BUBENCH_PR_TREE
- orchestrate.py: resume-safe local-CDP battery driver
- orchestrate_cloud.py: backend matrix (nous-cloud via the browser_use
provider plugin, browserbase via REST) with per-cell session lifecycle
- report.py: scorecard aggregation with vs-base token deltas
- README.md: design, run instructions, and the recovered Aug 8-10 2026
baseline scorecards (hard battery, backend matrix, easy round 1,
digest ablation)
The original /tmp/bu-bench workspace was lost to a tmpfs reboot; harness
and readouts were recovered verbatim from the benchmark session's tool-call
history in state.db, with hardcoded paths parameterized. Smoke-verified
live: report.py aggregation, and single-cell runs (pr + base arms) against
a real headless Chrome CDP with sonnet-5 driving browser_exec, oracle pass.
Follow-up to #88664. The remote @mention delivery path had three gaps that
made cross-machine DMs half-work:
1. No reply relay: deliverRemoteRosterMentions submitted the prompt and
toasted, but never polled — the handoff note promised a relay that never
came. Now a bounded poll (same shape as a group member turn: new
assistant message after the baseline, 180s cap) relays the recipient's
reply as a notification, or says it's still pending.
2. No sender attribution: the raw user text was submitted, so the
recipient's messaging protocol never recognized an agent-to-agent
message. Deliveries now carry the standard
"Message from 🤖 <sender> (@handle):" prefix.
3. Duplicate Bot Chats: every mention minted a fresh "Bot Chat" session.
ensureRemoteCanonicalChat now resolves the recipient's pinned canonical
chat from its profile ui_meta, falls back to resume-by-title, and only
creates when neither exists — mirroring ensureGroupChatSession.
Tests: remote-dm-delivery.test.mjs (pin-resume without create, attribution
prefix + reply relay via vm-run behavior tests, source contract for the
bounded poll). Plugin suite 187/187.
A resume racing a profile/connection swap can 404 on a backend that
does not own the session; the terminal-failure branch then dropped the
window to the blank new-chat route while the target session was alive.
goneSessionVerdict() now gates the draft fallback: a session created
this run, still listed on some profile, or looked up while a gateway
swap is in flight arms the bounded auto-retry latch instead of
discarding the route. Draft remains the calm-conditions path for
verifiably dead ids.
The BOTS sidebar previewed each profile's most recently active session
(last_session) but clicking the row opened the pinned canonical chat —
two different session identities, so the preview described one
conversation and the click landed in another.
- profiles.list gains an optional preferred_session_ids param
({profile: session_id}): an exact, existence-checked per-profile
lookup that resolves hidden rows and compression lineages to the
live tip (the same resolver session.resume uses) and returns a
preferred_session summary alongside the unchanged last_session.
- The hermes-bots plugin sends its canonical-chat pins with each
roster poll and previews preferred_session ?? last_session.
- openBotCanonicalChat verifies pins through the precise resolver
instead of a paginated, hidden-excluding session.list window that
misjudged real hidden pins as gone; transient lookup failures no
longer clear the pin or mint a replacement chat.
- Grandfathering: a bot with history but no pin adopts the previewed
session on first open instead of minting a new empty chat — the
behavior the design comment already promised.
Closes#88200
A failed intro used to clear the pin even though the chat was already made, so the next click opened a second one. A missing pin grabbed the newest session, even when a real Bot Chat was in the list.
Failed intros now keep the pin. A missing pin looks for a session titled Bot Chat. If that is not there, we try the stored pin instead of the newest row.
Ported from NousResearch/Hermes-Bot-Mode#59 after Bot Mode moved in-tree.
Community asks (Discord, Aug 17): unclear what happens across cloud vs
desktop, whether every bot replies in group chats, and how to persist
connections to multiple gateways.
Extends the Bot Mode user-guide page (landed on main today) with the new
cross-connection features:
- "Create on" picker: creating an agent on another registered machine, with
the remote-target caveats (clone source, staged capability checklists,
draft discard).
- Group chats: explicit "not every bot replies" explanation of the
round-robin/pass model, and rooms spanning machines with device badges.
- @mentions across machines via the Connections registry (no gateway switch).
- Bots-across-machines section: persistent SSH inventory, last-known rows,
and the stay-in-your-chat interaction model; cloud+desktop recipe.
- desktop.md Bot Mode section links to the full guide; multi-connection
page's Bot Mode reference points at the docs page instead of the old
standalone repo.
Builds on the salvaged #88598 multi-source roster work:
- New Agent "Create on" picker (multi-connection registries only): the
profiles.create/configure/describe/mcp.catalog calls route to the picked
connection's backend via host.requestProfile route descriptors — the
window's active gateway never switches. Remote-target drafts discard via
the remote CLI; appearance/title write into the remote profile's ui_meta
and asset store; the taken-name check is scoped to the target machine's
roster; the live SkillsView Capabilities tab (active-gateway-bound) falls
back to the staged checklists for remote targets.
- Group chats can seat bots from other registered connections: member turns
(session.create/resume, prompt.submit, reply polling) route to each
member's own source through the new requestForBot helper. Remote member
descriptors persist on the room record (bot-meta is active-gateway-scoped
by design); watermarks/sessions key by source-qualified member keys so
same-named agents on two machines never share state; @name-device handles
resolve in room mentions; room lines and turn prompts badge cross-machine
speakers with their device.
- pidIsOurDashboard: a dead remote PID (ps exits non-zero) now reads as
FOREIGN instead of throwing "Could not verify SSH backend process
ownership" — the misleading error from #88625's secondary report.
Tests: cross-connection-bots.test.mjs (route descriptors, requestForBot
routing, member keys, disambiguated mentions, device badges, source
contracts); plugin suite 180/180; electron vitest 220/220.
Stay on the default gateway. Bot Mode still lists every Connections
agent. Clicking a remote row no longer hops the window onto SSH —
@dixie / @bob-spark in this chat resolve against the roster and
Desktop delivers in the background via requestProfile.
Clicking Mac Mini / Spark (the device default row) passed the desktop
pool key as the remote Hermes profile. That profile does not exist, so
the chat never opened. Named profiles (bob, dixie) already sent a real
name and worked.
Also refuse to fall back to this-device's default chat pin when the
remote source did not actually become active.
A leftover sshConnections key made roster polling call
ensureRegistryBackend for every SSH source every ~5s. That spawned
remote dashboards, the mux died, and the renderer hit hermes:api
ECONNRESET / liveness-probe drops.
SSH inventory stays on the cached ls path only. Clicking a bot still
dials that one source.
A first inventory miss used to stick forever as a seeded default until
the user hit Test. Retry after 60s; a successful cache still never
re-probes, and Test still forces an immediate refresh.
Remembered remotes were restored whenever the union omitted their
connection id, so a deleted registry source could keep resurrecting
until remount. Only restore rows that still belong to a registered
source.
Clicking the local agent left connectionId null, so the roster treated
the registry primary (often an SSH box) as active and dropped its
profiles while inventing a "This device" shadow of default.
Inventory undialed SSH sources with a cached ls of ~/.hermes/profiles
instead of requiring the window to switch onto that machine. Hostile
HERMES_HOME values are rejected before the listing command runs.
Group chats could be created but never deleted — the only way out was
manually ungrouping every member, which still left the shared room log
in plugin storage forever.
The group-chat workspace header now has a trash button behind a
ConfirmDialog. Disband is soft: it clears every member's group
assignment (syncs cross-machine via ui_meta), drops the room log from
the atom and the persisted group-chats map, clears the needs-you badge,
and closes the room view. The members' per-group gateway sessions
("Group: <name>") are intentionally kept and remain reachable from each
bot's session browser. A room with a drive still in flight leaves a
runtime-only epoch-bumped tombstone so the round-robin loop bails at its
next member boundary; the tombstone is never persisted.
The skew banner told users to run the in-app update, but on machines where
the local desktop pack is the broken step (e.g. the get-windows win32
binding staging failure in #88251), rebuilding in place cannot succeed.
Reinstalling from the packaged installer sidesteps the local build
entirely, so offer it as the escape hatch:
- Settings > About skew banner gains a "Get the installer" button opening
https://hermes-agent.nousresearch.com/ via openExternal
- banner copy now mentions reinstalling when the update doesn't clear the
warning
- all 5 locales updated (new bundleOutOfSyncAction key)
Bot Mode ships built into the desktop app (default on) but only had a
short section in desktop.md. This adds a dedicated user-guide page
covering the Bots roster, creating and editing Bots, avatars, routines,
group chats, bot-to-bot messaging (agent.bot_mode_protocol), the
multi-connection roster, and CLI parity.