Settings → Model while a chat is open flips the composer source to
'default' but leaves the live session's model painted. Sending that
value on session.create pinned every new chat and skipped model.default.
Only a manual composer pick is a per-session override.
Co-authored-by: Tharanee <tharanee@tharanee.net>
Desktop sent /btw through the slash worker, which printed the answer after
process_command returned, so only the acknowledgement ever showed. Use the
TUI's prompt.btw RPC and persist btw.complete on the originating session.
Co-authored-by: SsSs <w-kwan@hotmail.com>
Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
visibleClarifyCard() bottomed out in queryVisible(), which only drops
[data-pane-hidden] and then returns the first remaining DOM match. That is
enough to tell a foreground tab from a background one, but a split layout
has two chat surfaces on screen at once: both cards survive the hidden-pane
filter, so the winner is decided by document order. The earlier zone owns
Enter and 1..N permanently and the other visible card can never receive its
own shortcut — worse than the mount-order behaviour it replaced, because
mount order at least changed as panes came and went.
Break the tie on the ladder the app already has instead of inventing a
second notion of which surface is "the" one: tree/store's tabTargetGroup
walks hovered zone, then focused zone, and every tab verb (Cmd+1..9,
Ctrl+Tab, the Cmd+W family) targets through it; composerTargetInHoveredZone
(#74447) mirrors it for the model hotkey. visibleClarifyCard now does the
same, matching each visible card's closest [data-tree-group] against those
rungs. The single-card path short-circuits before any store read, so the
common case costs nothing extra.
The final fallback stays document order rather than null on purpose. When
neither rung names a zone holding a card — pointer off every zone, nothing
interacted with yet — returning null would leave Enter doing nothing at all,
a strictly worse regression than answering the first visible card.
Regression coverage pins both halves: the resolver unit tests assert the
active zone selects either card (not just the later one), that hover
overrides focus, that a hovered zone with no card falls through to the
focused one, and that neither rung resolving still yields a card; the
ClarifyTool integration tests render two visible cards in two zones and
assert exactly one clarify.respond fires, carrying whichever zone is
focused. All four order-sensitive assertions fail against the previous
resolver.
A pending clarify card binds Enter / 1-9 / A-Z / arrows on `window`, and
inactive tabs stay MOUNTED, so every parked clarify keeps a live listener.
Nothing in the handler asked whether the card was on screen: the first
listener registered won, called preventDefault(), and the rest bailed on
defaultPrevented. Registration order is mount order, not visibility.
With two chats waiting on a question, answering the one in front of you
sent `clarify.respond` for a background session's request instead —
silently answering a question the user never saw and resuming that turn.
The invariant is already stated one file over, in composer-focus-keys.ts:
"a clarify card waiting in a background thread must not take the
foreground composer's letter keys". `clarifyCardOwnsKey` honours it via
queryVisible(); the card's own listener did not. That asymmetry is the
bug — the key-ownership resolver and the key handler disagreed about
which card is live.
Export that lookup as `visibleClarifyCard()` and have both sides use it,
so they cannot drift apart again. The card bails unless it IS the visible
card, which also leaves the keystroke unprevented for the composer when
the only pending card is hidden.
Scoped by visible-card identity rather than `usePaneVisible()`: split
zones each render their own active pane, so two cards can be visible at
once and a per-pane visible flag would not disambiguate them.
Window X calls app.quit(); backendShutdown.finally() calls it again.
teardownSshConnection deletes the map entry before SSH exec kill, so
the second before-quit saw an empty map and exited while disconnect
was still running. Latch the in-flight teardown so the re-entrant quit
still waits.
reconcileRegistryDrift only healed remote/cloud v1 routes. A v1 global
mode:'ssh' route (host, no url) written by Settings after the one-shot
migration had no registry identity: resolvedConnectionId returned null,
primary stayed 'local', and every launch re-homed the window onto a
fresh local backend. Because the heal skipped SSH entirely, the two
config files re-drifted after every update relaunch instead of
converging once.
Normalize the v1 SSH descriptor into a v2 kind:'ssh' entry (via the
same validated normalizeConnectionInput path the editor uses) and align
primary/lastUsed, with the same narrow-heal rules as remote: already-
registered targets and deliberate primary picks are left alone, and
unusable hosts never touch the registry.
Diagnosis credit: mgallmur-glitch (root cause) and jakewvincent
(re-drift after update relaunch) on #93888.
A no-mux tunnel is a single persistent `ssh -N -L` child. On main, ANY
death of that child after readiness immediately set tunnel.alive=false,
which poisons SshConnection.isAlive() forever; upstream lifecycle probes
then treat the whole SSH connection as dead, tear down the scope, and
SIGTERM a perfectly healthy backend (~10s after HERMES_BACKEND_READY in
the #96266 logs: '[ssh] connection closed (no-mux tunnels killed)' ->
'Ignoring stale Hermes backend exit (SIGTERM)' -> 90s port-announcement
timeout, with retry/repair looping the same failure).
Now a post-readiness child death is a tunnel FLAP: the child is
restarted with a bounded budget (5 attempts, 1s delay by default,
injectable for tests) and only an exhausted budget marks the tunnel —
and thus the connection — unhealthy. Deliberate teardown (cancelForward
/ close) sets tunnel.stopping, cancels any pending restart timer, and
never restarts. Pre-readiness deaths keep failing fast with classified
stderr (auth/bind errors unchanged).
Fixes the kill chain of #96266.
Untagged session rows come from the ambient primary backend. Do not synthesize a local owner for them, and clear stale explicit hints before issuing an id-only resume.
findElectron() probed exactly one path, and got three things wrong at
once for anyone not on a hoisted POSIX install:
* It looked only under the REPO ROOT. This is an npm workspaces repo and
npm hoists a dependency only when nothing conflicts, so `electron`
installing into apps/desktop/node_modules is an ordinary outcome, not a
broken tree.
* It joined a bare `electron`. On Windows the dist file is
`electron.exe`, so the probe could never match there.
* Its PATH fallback spawned `which`, which is not a command on Windows,
so the fallback failed for a reason unrelated to whether electron is on
PATH.
The three combine into a misleading error: the suite refuses to start
with 'Run "npm install" from the repo root' on a tree that has electron
installed. Reproduced on Windows 11 against this repo, where
apps/desktop/node_modules/electron/dist/electron.exe exists and the old
body throws that message; the reporter on #88036 hit the same thing on
Linux and had to hand-symlink the package before the suite would run.
Resolution now asks the installed `electron` package for its own path
first (its main export IS the absolute executable, resolved from
path.txt and honouring ELECTRON_OVERRIDE_DIST_PATH), then falls back to
explicit dist probes for each root, then to PATH with the platform's
lookup command. The error message lists what was searched.
The rules live in e2e/electron-binary.ts so they can be unit-tested
without importing the Playwright runner, with the platform passed in
rather than read from process.platform: reading it would leave every
Windows rule untested on the Linux CI runner.
Wiring: the vitest `electron` project picks up e2e/**/*.unit.test.ts and
Playwright ignores the same pattern, so helper unit tests run in exactly
one runner and the specs are untouched.
Verified: 5 unit tests pass; mutation-checked one rule at a time
(hardcoding the binary name fails 2, reversing the probe order fails 1,
hardcoding `which` fails 1). tsc -p . and tsc -p tsconfig.e2e.json
clean.
This is the environment blocker called out in #88036, not its rendering
bug, so it is deliberately a subset.
Refs #88036
The packaged app crashed at launch with 'No QueryClient set, use
QueryClientProvider to set one': useQuery in a lazy chunk (session-list-density)
read a second @tanstack/react-query runtime whose QueryClientContext was never
populated by the entry's QueryClientProvider. The source tree was correct — the
duplication happened at build time, because react-query was the one
context-bearing runtime not pinned to a shared vendor chunk, and rolldown's
merge heuristics inline the spare copy into a lazy chunk depending on toolchain
version.
- vite.config.ts: add @tanstack/react-query to the vendor-react
advancedChunks group + dev dedupe list, mirroring the react-router fix.
- assert-dist-built.mjs: fail the build when the 'No QueryClient set'
invariant appears in more than one JS asset (launch-smoke guard).
- assert-dist-built.test.mjs: unit tests for the new invariant check.
- launch-packaged-app.spec.ts: e2e smoke test asserting the packaged app
boots to real UI, not the QueryClient error boundary.
The salvaged tests mocked @/store/settings-scope from before
$settingsRequestProfile landed (c942cd9ea1); the page now reads it, so
the mock needs the export.
Without this, diffConfig kept comparing against the page-load snapshot
forever, so reverting a field to its original value produced an empty
patch and left the earlier (now-stale) save on disk. Saves are now
queued so an older in-flight request can't resolve after a newer one
and re-advance the baseline with stale data.
ConfigSettingsInner seeds its local draft once from the config record and
never re-seeds it while the page stays open, but every autosave PUT still
sent the entire draft. Since PUT /api/config deep-merges onto disk, that
degenerates into a full overwrite for every field the UI's schema knows
about: if `hermes config set` (or another profile/session) changes a
schema-known key like fallback_providers while Settings is open, the next
autosave — triggered by editing any unrelated field — writes the stale
seed-time value back over it.
Diff the draft against the seed-time baseline and send only the changed
branches, so an untouched key is never resent and the backend's deep-merge
actually protects it.
Renders the real CommandCenterView + ConfirmDialog: trash click alone must
not call onDeleteSession, delete fires only after explicit confirm, and
cancel closes without deleting. All three fail against the unguarded
pre-fix Command Center (verified by A/B against origin/main).
The Command Center -> Sessions delete button fired instantly on click,
hard-deleting the session (row + messages + request_dump files) with no
confirm and no undo. e6708af1f confirmed the sidebar rows, tab menus and
chat header, but missed the Command Center's independent entry point in
command-center/index.tsx.
Gate the row's delete button behind the same ConfirmDialog used by the
sidebar path, reusing t.sidebar.row copy and t.common.delete, so every
delete entry point is confirmed as e6708af1f intended.
The 5s waitFor deadline trips on loaded CI runners: on 2026-08-31 the same
UI-shard flake hit a plugins-only main push and two unrelated PRs, always as
'expected vi.fn() to be called at least once' in gateway-settings /
messaging / session-unread-tile / toolset-config-panel. Success still
resolves the moment the assertion holds; 12s only absorbs starvation and
stays under the 15s testTimeout so real hangs keep their distinct failure
shape.
The roster click's fronted-tab shortcut trusted the persisted session-tile
bucket (Local Storage 'hermes.desktop.sessionTiles.v2') unconditionally: a
persisted 'Bot Chat' tile naming a session the canonical registry no longer
resolves to — a superseded row from the retired ui_meta pointer design, a
re-minted canonical chat, a stale finished (often hidden) session — was
fronted on every click and remembered as the zone's active pane, so the row's
click target stuck to that stale session forever while its preview/age
described the live one, and clearing Local Storage only healed until the next
click re-persisted the same tile.
Per the Desktop guide the backend is authoritative for session state and the
renderer copy is a cache that must reconcile. focusWorkspaceOwnerSessionTile
now takes an optional staleness probe: tiles the probe rejects are discarded
(same no-undo rationale as discardSessionTile — resurrecting one would just
front the stale session again) and never fronted. The roster click supplies
the probe: a canonical-titled tile whose stored id matches neither the
server-resolved canonical_session registry row nor its compression-lineage
tip is stale, so the click falls through to the authoritative name-registry
open. Side-chat tabs carry no registry identity and are never judged; older
gateways without canonical_session (and shells without the probe) keep the
previous behavior unchanged.
Independent review fold: a getConnectionFor timeout must not resurrect the
ghost secondary. ensureGatewayForAgent reports false when the primary socket
is closed; openGatewayForAgent throws instead of no-op. Tests cover probe
failure, open/ensure no-dial, and closed-primary. Relay retain still pins
isolated SSH routes on the same connection id.
Bot Mode group turns on a Windows Desktop attached to a remote gateway
dialed a registry secondary per member profile. That second WebSocket
accept/closed in ~30ms and never ran session.create. When the window
primary is already that one-host-many-profiles source (sharedRemote),
request/retain/open/ensure now reuse the primary socket and scope RPCs
with profile=. Isolated SSH/pooled backends are unchanged.
Session mutations from the desktop sidebar silently no-op against the wrong
profile's state.db and reappear on the next refresh, unless the mutated
session happens to belong to the serving (primary) profile. Most visible in
the "All Profiles" view and on a remote-primary desktop (a registered remote
gateway as primary, every profile served from it): deleting a session flashes
away optimistically, then comes back after a profile switch.
Root cause: the mutation helpers passed the owning profile ONLY as
request.profile, which the Electron main process consumes for backend ROUTING
but which does not scope the request the backend actually receives. The
backend selects its target DB from ?profile= (DELETE) or body.profile (PATCH).
Reads (getSession, getSessionMessages) and renameSession already scope
correctly; the other mutations regressed / never did:
- deleteSession -> no ?profile= in the URL
- setSessionArchived -> no profile in the PATCH body
- setSessionPinnedRemote -> no profile in the PATCH body
- setSessionUnreadRemote -> no profile in the PATCH body
On a remote gateway whose connection has no remoteProfile alias, the main
process leaves such requests unscoped, so the backend opens its own (default)
state.db, cannot find another profile's row, and returns
{ok:true, already_absent:true} (DELETE) or no-ops (PATCH) — a fake success the
UI treats as done. deleteSession lost its ?profile= scope in the api/ module
split (it was present via PR #44138 / the pre-split hermes.ts path).
Fix: scope all four mutations the same way the working endpoints do —
deleteSession appends sessionScopeQuery(profile) to the URL; setSessionArchived
/ setSessionPinnedRemote / setSessionUnreadRemote include profile in the PATCH
body (mirroring renameSession). request.profile stays for per-profile
remote-override and global-remote routing. Single-profile / selected-profile
users are unaffected (the serving profile already matched).
Verified against a live remote-primary desktop: the DELETE now goes out as
/api/sessions/<id>?profile=<owner> and the row is actually removed from the
owning profile's state.db (confirmed server-side) instead of returning
already_absent.
Adds api/sessions.test.ts coverage: delete scopes ?profile= in the URL for
object and bare-string owners and omits it when no owner is known; archive /
pin / unread carry body.profile when owned and omit it otherwise.
Fixes#78836
ensureRegistryBackend() reuses the ambient primary descriptor for a
non-local, non-ssh registry primary, but the reused descriptor was
missing sharedRemote: true. The request router only appends
?profile=<profile> for sharedRemote backends, so Capabilities/Skills
requests went out unscoped and the gateway served the default profile
while a named profile was selected.
Add the flag and a regression test asserting the scoped URL.
Render durable per-member hold state in Group Chat with canonical resume guidance and accessible, theme-safe status copy.\n\nVerified by independent pre-commit review.
Resume was attaching an unused store as {todos: [], revision: 0} and the desktop rejected tool.start updates that have no revision. A merge:true start after reconnect never patched the list until complete.
Skip unused empty snapshots. Apply unversioned updates without moving the watermark so a later todo.updated can still win.
The gateway's session-backed MCP OAuth flow (mcp.servers.oauth.start) binds
its browser-callback listener on the BACKEND machine's 127.0.0.1. When the
Desktop app connects to a remote backend (SSH/Tailscale), the user's browser
resolves that loopback to the user's machine, the redirect dies, and every
OAuth catalog server (ClickUp, Hospitable, ...) fails in-app with no working
path — the exact topology from the 'MCP Recurring erros' support thread.
Fix mirrors the Desktop's native gateway login (native-oauth-login.ts):
- gateway: mcp.servers.oauth.start accepts client_redirect_uri (loopback-only,
RFC 8252-style validation); when supplied no gateway listener is bound and
the OAuth redirect_uri pins to the client's listener.
- gateway: new mcp.servers.oauth.callback RPC relays the client-captured
code/state into the flow; state verification stays in
DashboardOAuthFlow.deliver_callback (constant-time compare, replay-safe).
- desktop: mcp-oauth-callback-ipc.ts hosts a one-shot 127.0.0.1 listener in
the main process (hermes:mcp-oauth:listen/wait/cancel via preload bridge).
- desktop: hermes-bots mcp-setup.tsx prefers the client listener for local
AND remote backends, falling back to the legacy gateway-listener flow on
older gateways (feature-detect via start rejection).
- docs: remote-host MCP OAuth section documents the automatic Desktop path.
Validation: 19 new gateway tests (validator allowlist, listener skip, relay
accept/reject/replay) — sabotage-verified; 5 new desktop tests against a real
ephemeral listener; E2E through the real session registry + flow bridge with
a stubbed provider probe; tsc electron+renderer builds clean.
Users reported no GUI switch for browser.use_real_profile — the only
desktop home was the generic Settings → Config editor, which nobody
found. The Browser toolset detail pane now renders a 'Use My Real
Browser Profile' ToggleRow above the backend/provider matrix.
- new BrowserRealProfilePanel: reads the shared profile-scoped config
record cache, optimistic write-through, rollback on failure
- saveHermesConfigRecord: capability-scoped PUT /api/config counterpart
of getHermesConfigRecord, so the Capabilities scope selector writes
the profile it points at (possibly another gateway)
- i18n: en/ja/zh/zh-hant keys (ar inherits en via defineLocale)
- docs: browser.md desktop pointer corrected to the real location
Live E2E on the built app over CDP: clicking the switch flipped
browser.use_real_profile true→false→true in the sandbox HERMES_HOME
config.yaml, GET reflected it, no layout glitches (screenshots in PR).
Salvaged from PR #97815 by @itsflownium, slimmed to the schema-free core:
- TodoStore gains a monotonic in-memory revision; the todo tool result
returns it so clients can reject stale updates
- tui_gateway emits a dedicated todo.updated full-snapshot event that
bypasses optional tool-progress display settings
- session resume/activate responses attach the authoritative todo
snapshot; renderer restores it with revision arbitration
- desktop store tracks per-session revisions and rejects regressions
The session_todo_state DB table from the original PR is intentionally
dropped: canonical todo tool results already persist in conversation
history, so resume paths derive the snapshot from the stored transcript
instead of a parallel store.
tool.start for a merge:true todo write used args.todos as a full replace. A one-item status patch became Tasks 1/1, or vanished if content was omitted, so the panel looked stuck at 0/5 until the final complete result. Apply merge by id on start, keep replace for the full result, and show the in-progress spinner on the expanded header too.
Review follow-up on #93911: the previous constant was set to 1_320_000 ms,
which is exactly the backend's maximum work budget (120s turn-lock wait plus a
600s attempt and its policy-gated re-run) rather than something greater than
it. After those bounded waits the handler still classifies the failure, builds
and runs the retry, serializes the terminal result, unwinds the temp-file and
lock scopes, and returns through the event loop -- so a turn that consumes
nearly the whole budget could still lose the race to the client timer and
resurface #93911 at the upper boundary, with the backend holding a typed
reason while Desktop reported its generic timeout.
The deadline is now composed from the three mirrored backend values plus an
explicit settlement/transport margin, so the arithmetic is visible instead of
being a magic number, and bot-relay-deliver-budget.test.mjs reads
config_defaults.py and methods_bot_relay.py to fail when a mirror drifts or
the margin stops being positive. Nothing in the type system links a JS
constant to a Python default; that test is the seam.
Also adds an adversarial virtual-clock regression: a gateway that answers only
after the full ceiling plus settlement is rejected by a deadline set at the
ceiling and accepted by one with margin.
host.requestProfile() had no way to express a per-call timeout, so every
routed plugin RPC fell to the gateway pool's generic 30s deadline. The
bot_relay.deliver contract is much longer: the backend holds the turn lock
(bot_mode.turn_wait_seconds, default 120s) and then runs a 600s turn, doubled
when the retry policy grants one bounded re-run, so methods_bot_relay.py
documents ~1320s as the bound a client must tolerate. Long turns (Computer
Use, deep research) were therefore killed at 30s and reported back as
unclassified failures rather than the typed reason the backend had classified.
requestGatewayForAgent()/requestGatewayForProfile() already accept timeoutMs;
only the two SDK layers above them dropped it. Thread it through and pass the
documented bound at the bot_relay.deliver call site. The argument is omitted
entirely when unset, so every other caller stays on the pool default.
Extends the real-profile machinery (PR #95620) to Brave Origin — Brave's
standalone paid build with a fully separate install identity:
- new canonical key 'brave-origin' in _CHROMIUM_BROWSERS
- Windows: BraveOHTML ProgId -> brave-origin; channel ProgIds BraveOBHTML/
BraveODHTML/BraveOSHTM fail closed (identifiers from brave-core
install_static)
- macOS: com.brave.Browser.origin bundle id (exact match); .beta/.dev/
.nightly channel bundles fail closed; /Applications/Brave Origin.app
- Linux: brave-origin.desktop matched BEFORE the bare 'brave' fragment
(substring scan would otherwise resolve an Origin default to stable
Brave and drive the wrong profile — #95549 wrong-principal invariant);
brave-origin-{beta,nightly,dev} fail closed
- profile dirs: BraveSoftware/Brave-Origin on all three OSes (per
brave-core kProductPathName + Homebrew cask zap paths)
- /browser connect launch tables: Brave Origin split into its OWN group
so a 'brave' executable lookup can never resolve to the Origin binary
- user-facing strings/docs/desktop tooltip updated
Tests: progid/bundle/desktop map params + data-dir resolution for all
three OSes; 125 passed in the three browser test files.