Clicking a bot row moved the layout interaction tracker to the sidebar
group, so $focusedStoredSessionId fell back to the primary selection —
which is null in Bot Mode, because bot chats open as tiles and never set
$selectedStoredSessionId. The Bots plugin reads that null 'focused'
edge as 'the chat lost the center', releases its open claim, and the
Bots home re-asserts over the still-visible chat: the UI jumps to the
list instead of staying in the chat.
$focusedStoredSessionId now answers from the main zone's active tile in
Bot Mode before falling back to the selection, so a chrome-sidebar
click no longer fabricates a null edge; a genuinely closed chat (no
tile in main) still surfaces null and the home returns as before.
Regression tests cover the sidebar-click case (red before the fix),
plus guards for the closed-chat and sessions-mode derivations.
Clicking the Cronjobs tile shifts focus onto the tile itself, momentarily
dropping bot-chat workspace ownership — syncRoutinesPane then unregistered
the pane out from under the user's own click, with no way back. Keep the
tile while Bot Mode is on screen and the tile is the focused surface;
leaving Bot Mode still unregisters as designed. Live-verified.
Hiding the Sessions/Bots strip, or tapping the header of a lone docked
tile (Cronjobs and any plugin pane beside the workspace), left no mouse
path back: the restore menu lived on chrome the gesture just unmounted,
and a row-collapsed rail could size to 0px.
Treat hide-only chrome as stranded so `never` cannot hide those chips.
Stop collapsing on header tap (chevron only). Size a minimized zone to
MINIMIZED_TRACK and keep the horizontal strip when two or more tabs
remain.
Mistral ignores response_format=text and returns the full transcription
object. Desktop was dumping that JSON into the composer; pull .text out
so dictation shows the spoken words instead.
The agent loop writes an internal "(empty)" sentinel when the
nudge/prefill/empties/fallback ladder all fail. The gateway converts it
into a user-friendly notice at delivery, but the desktop group-chat
bridge appended the raw sentinel into the room log (seen posting
"(empty)" in a Bot Mode group room), and it synced to the shared
ui_meta for mobile.
Normalize at the single choke point, appendGroupChatEntry, mirroring
gateway/run.py substitution so group chat and gateway surfaces show the
same text. (pass)/empty silence semantics unchanged. Includes a
regression test proven to fail on the pre-fix code.
Fixes#94308
Address review feedback on #94386: the new tests only exercised
runGroupChatMemberTurn's use of pickGroupTurnReply. Add the analogous
case for harvestStrandedGroupReply (substantive answer -> synthetic
continuation nudge -> (pass) tail) and document the pass-only tie-break
(newest wins) in pickGroupTurnReply's docstring.
runGroupChatMemberTurn (and harvestStrandedGroupReply) selected only the
last assistant message in a finished turn. A Codex intent-ack continuation
nudge can land a complete, substantive room answer and then get a
synthetic "(pass)" reply to the nudge itself — the terminal message picked
by the old scan, which silently discarded the real answer (#94376).
Both call sites now scan the messages appended this turn for the last
substantive (non-pass) assistant reply, falling back to a pass only when
no substantive answer exists in that window.
Source-contract tests (the group-room-ux pattern): the workspace renders
the Stop button only while room.running, wires it to stopGroupThread
(not the #94570 per-member interrupt spray), and carries no hardcoded
CJK label.
Salvaged from #94570 (@ShonnQ): the Activity bar gains a Stop button
while a round is running (room.running), plus an inline Stop on the
expanded 'working' activity row. Rewired from the original per-member
session.interrupt spray onto the stopGroupThread primitive so the round
loop actually stops (epoch bump + holds + on-turn interrupt) instead of
marching to the next member; labels are plain English like the rest of
the plugin's UI strings.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
stopGroupThread(group, thread, members?) is the room's first true
cancellation primitive: it bumps the room epoch (the driving loop bails
at its next member boundary), sets #93129 holds for every member (no
future turns until an explicit release), records a 'stopped' activity
event on the new epoch, and sends session.interrupt to the member
currently on turn via its own route — previously the plugin issued zero
interrupt RPCs, so 'stop' meant waiting out the in-flight model call.
The runGroupChatMemberTurnLeased poll loop now abandons a turn whose
dispatch epoch went stale WHILE its member is held — the stop signature.
An ordinary newer-send epoch bump without a hold still polls to
completion so late work keeps landing (#93127 commit check unchanged).
Follow-up to the #94755 salvage: every runGroupChatRounds exit recorded
'settled', so a room that died at the round/message/continuation cap looked
identical to genuine consensus. Track the exit kind and record 'capped'
(with label + glyph) when a cap ended the drive, and pin the exit-path
wiring with source-contract tests.
- GROUP_CHAT_MAX_CONTINUATIONS=2 caps continuation rounds independently of
the message cap, so pathological @mention chains can't consume the room's
whole budget on handoffs.
- unaddressedGroupMentions now orders by log INDEX instead of entry id:
ids are UUIDs (groupChatEntryId), not monotonic — string comparison could
both re-drive answered members and miss stranded ones.
- New unaddressed-mentions.test.mjs exercises the REAL function via the
vm-slice pattern (replacing concept-only helpers) and pins the ordering
fix with a UUID-vs-log-order case.
In Bot Mode group chats, a member reply that @mentions a teammate never
drove the cited bot when the current round went quiet: the
'spokeThisRound === 0' early exit treated a zero-reply round as 'everyone
passed' and settled the room, even though the reply's @mention was
pending. The same silent settle happened whenever GROUP_CHAT_MAX_ROUNDS
or GROUP_CHAT_MAX_MESSAGES landed between the mention and the next
round (#94478).
Fix:
- unaddressedGroupMentions() detects member-to-member citations in the
thread whose cited member has not posted anything after the citing
entry (self-mentions excluded; user sends re-drive everyone anyway).
- The quiet-round exit now checks for such pending handoffs and runs one
bounded continuation round driving exactly those cited members — same
holds/stranded/epoch/cap machinery as ordinary rounds, so nothing new
is trusted.
- If the continuation also produces nothing (pass, failure, or cap), the
room settles as before; behavior only changes where a bot was actually
called and never answered.
Regression tests in plugins tests family (2 new); full hermes-bots
plugin suite stays green (558/558).
Fixes#94478
The fail-closed owner ladder (#95407) is correct for new sessions, but
legacy unowned rows on registry-topology installs dead-ended in
SessionOwnerResolutionError (reporter's Error B) with their transcripts
fully intact in state.db.
- resolveLegacyOwnerBackfillScope: pick the single-match store for the
server-side owner backfill at enumeration time (serving registered
connection / primary pool); fail closed on multi-candidate topologies.
- maybeBackfillLegacySessionOwners: one-shot per scope per renderer,
fire-and-forget from the #95407 stamp path, logs the stamped count.
- Read-only stored-transcript resume: when session.resume fails closed,
fetch the transcript over id-only REST (ambient first, then registered
backends, read-only probes only) and open the session as a read-only
transcript instead of dead-ending; sends are refused with a notice and
a later successful live resume clears the latch. Wired into the main
pane resume recovery and the session-tile delegate (which now runs the
same fail-closed owner gate as the RPC dispatcher).
Refs #94724
The reconcile-to-guarded-model interaction test's requestGateway mock
must only answer config.set with the confirm handshake — the panel's
model.options read rides the same dispatcher and was eating the
first mocked response.
The Bots editor's model write (profiles.configure) was the one switch
surface that bypassed the data-policy / expensive-model selection guard:
a guarded pick (e.g. muse-spark contributor tier) was applied silently,
with no confirm flow anywhere — the #95293 remainder after the core
picker's confirm handshake landed in use-model-controls.
Gateway: profiles.configure now answers confirm_required +
confirm_message for a guarded model (same handshake as config.set
model) and writes NOTHING until the client resends with
confirm_expensive_model: true. Other sections still apply; the pending
model section is not reported as failed.
Desktop: the confirm flow is extracted out of use-model-controls into
one shared applier (lib/guarded-model-switch.ts, exported through the
plugin SDK) — warning toast, staleness-guarded Confirm, single
confirmed resend, never a retry loop. The core picker and the Bots
editor now consume the SAME handler; the Bots editor's Confirm resends
only the model section with confirm_expensive_model: true.
Fixes#95293 (Bots surface remainder).
Refresh Models only updated the catalog cache, so the composer kept showing a model that was no longer in the new group list.
Co-authored-by: Cursor <cursoragent@cursor.com>
The Bots model picker's catalog read rode the bot's own socket with no
deadline: a wedged dial left the query pending forever, so the picker
spun indefinitely. On top of that every fetch forced refresh:true,
bypassing the staleTime cache, so each Bots view remount (tab re-front,
dialog reopen, pane visibility flip) knocked the picker back into its
loading state and wiped the staged provider/model pick mid-edit.
Bound every attempt to 20s (rejection falls through to the picker's
existing free-text fallback), drop the forced refresh so the read
participates in the cache like every other surface's catalog, and pin
the contract with a red->green regression suite.
Slim renderer UI for the managed SSH remote update engine (#95942),
adapted from #93042's renderer unit with the deferred canary/rollout
scope stripped. Adds a per-connection store (idle/updating/terminal
states, receipt, managed-update-in-progress busy envelope) and a
'Managed updates' section on the Gateways settings page with an Update
button, progress line, and correlated receipt per registered
Desktop-managed SSH connection. Fails closed when the Electron main
lacks connections.updateManaged.
Group rooms persist source-qualified members. After Desktop switches to
the built-in This-device source, a dead loopback row still listed next
to the live profile and looked like a second agent. Collapse only the
sidebar tiles; $lastRoster, group seats, and mentions keep every
(connectionId, profile) identity.
A wake-path liveness-probe timeout force-closed the primary renderer
socket even when the backend was merely busy mid-tool-call; the gateway
then saw its client vanish, ws_orphan_reap expired, and the running turn
died as a bare "Operation interrupted." placeholder.
While any session still reports working, the first inconclusive probe
timeout now defers the teardown behind one bounded re-probe; only an
exhausted consecutive-failure streak (or no in-flight work at all)
rebuilds the transport. The streak resets on a successful ping, a
healthy -32601 answer, a clean open, a gateway switch, and unmount.
The registry's active-connection-invalidated fallback re-dial was the one
getConnection() await in this file the #93454 bound-every-IPC-round-trip
sweep never reached. A wedged main-process round-trip during an eviction
fallback (idle reap, connection removal, profile delete) left $connection
latched on a promise that never settles instead of rejecting into the
existing catch/publish(null) path.
The #92434 mid-handshake pin (added on main after #95343 branched) latches
gatewayMocks.connect on a never-resolving mockImplementation; vi.clearAllMocks()
clears calls, not implementations, so the salvaged wedged-probe test inherited
a dial that never completes and timed out.
20s withTimeout() on the boot() and soft-switch paths (use-gateway-boot.ts),
since these are IPC round-trips into the main process with no timeout of
their own — a wedged main-process round-trip hangs the awaiting caller
forever instead of surfacing a failure.
Every other production call site of the same IPC pair was still unbounded:
- store/gateway.ts's openSecondary() and sharedPrimaryRoute() — the actual
connection-establishment underneath requestGatewayForProfile/Agent,
ensureGatewayForProfile/Agent, and every other exported routing entry
point that opens a non-primary profile's socket.
- use-gateway-request.ts's on-demand reconnect (the primary gateway's
"not connected" retry path hit by every RPC).
- voice-playback.ts's resolveSpeakStreamUrl().
- api/plugins.ts's activeConnection() (pluginSocket's connect()).
Extracted RECONNECT_ATTEMPT_TIMEOUT_MS into the shared lib/with-timeout.ts
(previously local to use-gateway-boot.ts) so every call site uses the same
budget instead of duplicating the constant.
Regression tests mirror the existing use-gateway-boot.test.tsx hang-repro
pattern: wedge getConnection()/getConnectionFor() with a never-resolving
promise, advance fake timers past the 20s bound, assert the caller settles
instead of hanging. Mutation-verified: reverted the production fix (kept
tests) and confirmed the 6 new tests fail — 4 by genuinely timing out at the
vitest level, 2 by TypeError on the not-yet-exported activeConnection —
restored the fix and confirmed all 70 tests across the gateway/voice/boot/
plugins suites pass, with tsc -p . --noEmit clean throughout.
Copy the user's default-Chromium profile (auth state only) into a managed
snapshot, launch Hermes' packaged Chromium on it via agent-browser, and hand
the CDP endpoint to the Browser Use CLI (and built-in tools) to drive. The
snapshot is a non-default dir, so it sidesteps Chrome 136+'s default-profile
remote-debugging block and never contends with the user's running browser;
launched without mock-keychain switches so keyring-encrypted cookies decrypt.
- consent-gated browser_exec 'local' arg (schema only appears with consent)
- fail-closed on non-Chromium default / snapshot failure
- stale-session guard: reuse only when the live session is on our copy dir
- snapshot excludes extensions/service-workers (renderer wedge) + caches
Review follow-ups on the salvage (#94914): the delete path derives its
scope from the removed row while saves derive from ownerRoute — a shape
drift orphaned the entry until LRU eviction. Deletes now sweep every
scope of the stored id (safe: deleted ids are never reused), while the
failed-resume path keeps the exact-scope drop (a same-id twin in another
profile must keep its tail). purgeLegacyV1 latches only after a
completed sweep so a mid-sweep throw retries next touch.
Stored session ids are only unique within one profile's state.db, and
localStorage survives profile switches in the same window. The durable
transcript-tail cache keyed entries by bare stored id, so a tail cached
while working in profile A was painted against profile B's backend after
a switch; that backend never held the session and retried it on every
wake ('session not found'), matching the desktop.log pattern of repeated
session.reclaimed (ws_orphan_reap) events for ids that live in another
profile's state.db (#94828).
Scope every entry by its owner — the same {connectionId, profile} shape
the REST layer already threads through getLatestSessionMessages and the
in-memory twin stores in TranscriptTailState:
- key entries under v2:[connectionId, profile, storedId]; loads only
return a tail saved under the SAME scope
- thread the session's owning scope through all call sites
(resumeSession's warm-path save + cold-paint load/rollback drop,
final save, removeSession's delete drop)
- sweep pre-scoping v1 entries once per window: a bare-id key cannot be
attributed to an owner, so it must never paint again
- dropTranscriptTail with a scope leaves other backends' same-id tails
intact
Re-implements the #93042 main.ts wiring against current main (post-#94724
drift), making the extracted managed-ssh-update engine reachable:
- ManagedConnectionUpdateGate instance + owner-only recovery journal at
DESKTOP_MANAGED_SSH_RECOVERY_PATH (read/write/persist/mark/clear with
strict record validation).
- IPC: hermes:connections:update-managed (requestManagedSshUpdate with
correlation-id claim + in-flight dedupe); update-all's ssh rows now route
through the transactional drain/update/restore lifecycle instead of
POSTing the remote backend updater.
- Gate enforcement at every dial/mutate seam: bootstrapSshConnectionInner
(pre-dial + publication fence with exact-serve rollback via
rollbackSshBootstrapResult), resolveRemoteBackend, ensureRegistryBackend,
saveRegistryConnection dial-field edits, connections:remove, and
primary-routing mutations (set-primary, set-launch-mode,
connection-config save/apply, profile:set) via
assertCanMutateManagedPrimaryRouting.
- Scope capture/drain/restore drivers: captureManagedSshScopes (pool +
primary discovery, bootstrap fence join), drainManagedSshScope (exact
identity-re-proof termination, no-kill forward recovery),
ensureManagedSshBackend(AtKey)/restoreManagedPrimarySshBackend restores,
openManagedSshUpdateTransport for serve-less connections.
- Startup recovery (resumeManagedSshRecoveries before createWindow) and
before-quit join of in-flight update/recovery operations BEFORE the SSH
coordinator is sealed, so restore dials are not refused during quit.
- Extended sshConnections state (spawnNonce/creationTime(Ns)/startedAt/
hermesPath/hermesHome/pythonPath/remoteProfile/registryConnectionId/
primaryRegistryScope) so drain can prove the exact serve it owns;
bootstrap coordinator entries carry metadata for the update fence;
persistSshConnectionToken mirrors tokens per managedSshTokenPersistencePlan.
- preload/global.d.ts: connections.updateManaged +
DesktopManagedConnectionUpdateResult/Receipt types.
Renderer UI (fleet-updates store, about-settings, system.ts ProfileScope
plumbing, i18n) intentionally NOT wired — it belongs to the deferred fleet
rollout UI and follows separately.
Wiring re-implemented against current main; design from #93042 by @andrexibiza
tsc -p apps/desktop clean; electron project 1924/1924 passed (133 files,
incl. 131/131 across the three engine suites); eslint clean on touched files.
Two profiles can hold sessions with the SAME stored id (restored
backups, copied state.dbs, cross-profile imports). mergeSessionPage
keyed rows by bare id, so the twins collapsed into one sidebar row
whose title/activity carry stitched one profile's content onto the
other's route — clicking a row previewing profile A resumed profile B
and wedged on an eternal 'Waking up…' when the mismatched resume never
completed (live-reproduced on main).
- mergeSessionPage: identity + lineage + dedupe keys are now
(profile, id); a kept twin in another profile survives the incoming
page dedupe. Local profile normalize (importing @/store/profile would
be circular).
- Sidebar clicks carry the ROW as the identity: onResumeSession passes
the clicked SessionInfo, and wiring pins the row's own
(connection, profile) as the resume owner via requestSessionResume
before navigating. Untagged rows keep the id-only path.