Skip to content

fix(desktop): Bot chats speak with the Bot's voice, not the active profile's (#100864) - #36

Open
webtecnica wants to merge 3810 commits into
mainfrom
fix/100864-bot-voice-profile
Open

fix(desktop): Bot chats speak with the Bot's voice, not the active profile's (#100864)#36
webtecnica wants to merge 3810 commits into
mainfrom
fix/100864-bot-voice-profile

Conversation

@webtecnica

Copy link
Copy Markdown
Owner

Fixes NousResearch#100864

Desktop Bots: voice playback used the active profile's TTS voice instead of the Bot's own.

Root cause

The voice playback seam (voice-playback.ts, voice-client-direct.ts, api/system.ts audio routes) resolved its (connection, profile) through getApiRequestProfile()/getApiRequestConnection() — the window's active gateway profile. A Bot chat is a profile too: its model-options/config/transcript requests route through the session's owner route (session-tile.tsx: ownerRoute?.targetProfile || ownerRoute?.profile || activeGatewayProfile). Voice was the only exception, so every Bot replied with the active profile's TTS voice regardless of its own per-profile voice settings.

Fix

Introduce a VoiceRouteScope (connection, profile) context. Chat surfaces owned by a route (Bot chats, routed sessions) provide their owner route; speech synthesis and transcription resolve through that scope instead of the active profile:

  • voice-route-scope.ts (new): VoiceRouteScopeContext + useVoiceRouteScope()
  • chat/index.tsx: ChatRuntimeBoundary provides the scope from the same owner-route resolution session-tile.tsx already uses for model options
  • voice-playback.ts / voice-client-direct.ts / api/system.ts: playSpeechText, startSpeechStream, resolveSpeakStreamUrl, directTtsConfig, fetchVoiceClientConfig, transcribeAudio, transcribeAudioClientDirect and speakText accept an optional scope, threaded through every ladder rung (client-direct → WS relay → data-URL fallback)
  • Auto-speak, voice-conversation and read-aloud pass the chat's scope
  • Routing tests pin both contracts: a passed scope resolves through the owner route (never the active profile), and no scope keeps the pre-Desktop Bots: voice playback uses the active profile's TTS voice instead of the Bot's own profile TTS config NousResearch/hermes-agent#100864 behavior byte-identical

Surfaces with no owner route leave the context null — playback falls back to the active (connection, profile), unchanged.

Tests

  • voice-playback.routing.test.ts: +2 regression tests (owner-route resolution when scoped; active-profile fallback when not)
  • Existing voice suite (routing, client-direct, auto-speak): 26/26 pass
  • tsc --noEmit clean

caya8205-2 and others added 30 commits August 31, 2026 14:54
Review P1. _profile_home_for_key() returned the same None for three
different states — multiplexing off / legacy agent:main namespace, a named
profile whose directory does not exist yet, and a resolution error — and
_db_for_key() collapsed all of them to the ambient store.

That recreated the very split this change removes. The enrollment bridge
provisions profiles/<name>/ at runtime, so a key such as
agent:fitness:telegram:dm:1 can legitimately be seen first: the first lookup
landed in root state.db, and the next one, after provisioning, in
profiles/fitness/state.db. One qualified session identity, two physical
stores. The resolver-exception path fell open the same way.

Ownership is now tri-state:
  - no named owner            -> ambient DB (single-profile behavior intact)
  - named owner + home        -> that profile's DB
  - named owner, unresolvable -> None, and a warning; never root

Callers already treat a missing DB as "skip the mutation", which is the
defer-don't-misroute behavior wanted here. _append_transcript_message is the
one path reached with an id the entry-point guard did not check (the
compression-child id), so it now raises explicitly and lets the caller's
retry queue hold the row instead of relying on an AttributeError.

Tests exercise the effect boundary rather than cache state: a named key
before its profile exists leaves root untouched and lands only in the
profile store once provisioned, and a resolver exception fails closed too.
Both fail against the previous two-state behavior by returning a live
SessionDB where None is required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review P1 #2. _append_to_transcript_serialized() writes the compression
continuation to child_id BEFORE publishing either _transcript_reroutes or
the _entries update — that ordering is load-bearing for backlog order, so it
must not move. At that moment nothing in the routing index points at the
child, so _db_for_session_id(child_id) missed its scan and fell through to
_db_for_key(None), i.e. the ambient store. The fail-closed guard did not fire
because root is a live handle.

The row therefore targeted root rather than the already-proven parent owner.
With no child row there the append is rejected by the FOREIGN KEY constraint,
the pending queue never drains and the reroute cannot advance; against a
split-brain root the message would instead be written cross-profile.

Record ownership before the mutation instead of moving the publication: a
private _session_owner_hints map carries session_id -> owning key for ids
whose owner is proven but not yet published, consulted by the new
_owner_key_for_session_id() after the index scan misses, and dropped as soon
as routing publishes. Signatures are unchanged, so the existing suites that
stub _append_transcript_message keep working untouched; the map is read
through getattr for stores built via object.__new__.

The regression is physical rather than mocked: an ended compression parent
and a live child that exist only in profiles/fitness/state.db, no active
profile scope, append to the parent, then assert all four effects — the row
lands on the child in the profile store, the pending queue drains, the
reroute and the routing entry advance, and root state.db stays untouched.
Without the hint it fails exactly as the review predicted, on
"FOREIGN KEY constraint failed" against root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one

Second half of NousResearch#66887. _entries is a single flat dict holding every
profile's keys, so the index it persists to has to be a single file — but it
was read and written through _db, which resolves whichever profile scope is
active. A whole-index rewrite during one profile's turn copied every other
profile's routing rows into that profile's store, and startup, which runs
unscoped, then loaded a different copy than the last writer produced.

That is why the startup recovery pass never sees a secondary profile's crash
marker, which is the half this issue's title names. mark_turn_active()
persists through the single-entry fast path (state.db only, no sessions.json
mirror), so a marker written during a profile's turn landed in that
profile's store and _recover_unclean_sessions(), running with no scope, read
a store that had never heard of it. The turn was silently never promoted to
resume_pending.

Capture the gateway's own home at construction — the store is built at
startup before any profile scope exists — and route the index through it:
_ensure_loaded_locked, _reconcile_recovered_routing_locked,
_persist_routing_data and _save_entry now use _routing_db. A pinned handle
still wins, so suites that install a fake or disable the DB are unaffected.

_prune_stale_sessions_locked is the mixed case and is split accordingly: it
now asks _db_for_key(key) whether each session ended, because that is a
per-session question, while the index write stays on the single store. One
ambient handle previously answered it for every profile at once, which could
prune a live secondary-profile route on the strength of the root store's
copy of that session.

Regression as requested on the issue: mark a turn active under a secondary
profile's scope, then build a fresh store with no scope and run
recover_interrupted_turns(). It promotes exactly one turn to resume_pending
here and promotes zero against the previous behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The NousResearch#66887 fix pins the routing index to HERMES_HOME state.db; the
fast-path harness still read entries through the ambient store. Point
get_hermes_home at the test tmp so both are the same file, matching
the new single-store contract.
Bare SessionStore instances built without __init__ lack _db_pinned,
_routing_home, and the handle cache behind the _db property. Restore
main's old getattr contract for them: report no DB and fall through to
the sessions.json path instead of raising AttributeError.
Discord interim commentary was replying to the user's trigger message on
every update (66fa6e4 added reply_to unconditionally for Buzz). Discord
uses native thread_id; only Buzz/Slack/Mattermost/Feishu need reply-to
anchoring for threading.

- Stream consumer _send_commentary checks adapter.platform.value
- Passes reply_to only for buzz/slack/mattermost/feishu
- Discord/Telegram commentary posts flat or threads via metadata
PR NousResearch#98424 merged with commits authored as everest.kill1@gmail.com
(anhtahaylove) but the email was never added to contributors/emails/,
so the next release's contributor_audit would fail on the unmapped
address. One-file mapping, same mechanism as every other entry.
…t generic error

When the turn-start fail-closed boundary (NousResearch#98424) raises
PreflightCompressionTimedOut, the exception escaped run_conversation to
the surfaces' generic exception handlers. The gateway deliberately never
exposes raw exception text, so users saw 'Sorry, I encountered an
unexpected error... Try again or use /reset' instead of the boundary's
actionable guidance, and the compression_exhausted clean-session
recovery contract (NousResearch#9893/NousResearch#35809) never engaged.

Catch it at the build_turn_context callsite and convert it into the
same typed recovery dict the in-loop timeout consumers return
(salvaged NousResearch#98741 / PR NousResearch#99710): failed=True, partial=True,
compression_exhausted=True, turn_exit_reason=context_compression_timeout,
with the actionable message in final_response and error.

Regression test proves the exception no longer escapes and the typed
contract fields survive to the caller (mutation-checked: test fails on
main without the handler).
result['error'] and result['final_response'] are independently settable
keys that only coincidentally share _COMPRESSION_TIMEOUT_FINAL_RESPONSE
today; assert the actionable substring instead so a benign prefix or
rewording does not break the terminal-contract test.
The preflight handler surfaces the boundary exception's per-request text
(token count, 'provider call was not sent') rather than the in-loop
_COMPRESSION_TIMEOUT_FINAL_RESPONSE constant, which describes a
different state (compression ran and could not reduce). Document the
divergence so it is not 'fixed' into a single message later.
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.
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
(NousResearch#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.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Windows Get-Process and macOS ps exit 1 on a missing PID, so reapOrphans
kept stale records and the next launch paid another 2-8s spawn each.
Throw ESRCH from the existing isPidAlive helper before any shell-out.

Closes NousResearch#92875

Co-authored-by: Jackal991 <139240222+Jackal991@users.noreply.github.com>
Co-authored-by: jonotonfoto <126111813+jonotonfoto@users.noreply.github.com>
Co-authored-by: foras910521-lab <268267187+foras910521-lab@users.noreply.github.com>
… chat

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>
Pin the RPC route (not slash.exec), bare-/btw usage, older-gateway
fallback, and originating-session answer rendering.

Co-authored-by: SsSs <w-kwan@hotmail.com>
Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
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>
…e focused profile

A shared dashboard's launch HERMES_HOME is not the selected profile. model.options now runs under @_profile_scoped, and global-remote REST keeps ?profile= even for the primary label.

Co-authored-by: fangliquanflq <fangliquan@qq.com>
…nd profile

Sticky composer keys were global, so a provider picked on one remote profile could ride into session.create on another. Persistence now follows an explicit (connectionId, profile) owner published before the active-profile reseed; unresolved legacy owners fail closed.

Co-authored-by: fangliquanflq <fangliquan@qq.com>
…used owner

requestModelOptions now sends the owner profile on the RPC, forced reseeds call getGlobalModelInfo(profile), and picker/cache keys include the registry connection so a tile cannot fall back through the ambient socket.

Co-authored-by: fangliquanflq <fangliquan@qq.com>
…e poll storm (NousResearch#98434)

A boot-restored chat can stay bound to a dead runtime id and remount its
composer status stack repeatedly with no genuine rebind ever occurring.
The stack's mount effect cleared the gone-polling latch on every mount, so
each remount re-armed process.list + slash.exec('goal status') against
the same phantom id forever, churning the composer every ~5s.

Real rebinds already reset the latch at the runtime-mint seams
(use-gateway-boot.ts, store/gateway.ts). Drop the redundant per-mount
reset so the latch actually holds across a remount.
process.list already stopped hammering a reaped id. approval.pending and
goal status did not, and session.info heartbeats republished an equivalent
state object so every tab rerendered. Share the gone-latch from
runtime-gone and keep heartbeat identity when nothing changed.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
session.reclaimed now heals through markRuntimeGone before dropping
cache. A 4001 on the visible session's dispatcher request asks for a
durable resume. prompt.submit uses the window dispatcher so the turn
lease survives the ACK, and a primary route no longer registers a
phantom turn lease.

Co-authored-by: Lester Liang <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: wz-heng <68931789+wz-heng@users.noreply.github.com>
Hidden session panes published stick-to-bottom into window-global atoms
and every mounted list subscribed to the same jump broadcast, so a buried
tab yanked the reader and flashed the composer. Only the visible pane
may publish, scroll requests are keyed by session, and a run start or
same-session refresh leaves a scrolled-up reader where they were.

Co-authored-by: gamewocao <gamewocao@users.noreply.github.com>
Co-authored-by: mor44-AI <andrefmontemor@gmail.com>
Co-authored-by: d4rk pr10r <darkpriorlabs@gmail.com>
Co-authored-by: Jackal991 <lawrence@hydra-flow.co.uk>
Keep-alive tabs remount their composer on transcript/status backstops
and were calling focus() while the user typed in the front tab. Gate
autofocus on pane visibility, and refuse to steal the caret from
another visible composer. A hidden tab that still holds DOM focus
does not block the pane the user just switched to.

Co-authored-by: Dan Bennett <dan@danbennett.me>
Co-authored-by: mor44-AI <andrefmontemor@gmail.com>
Co-authored-by: d4rk pr10r <darkpriorlabs@gmail.com>
…_reasoning

Compression and some OpenAI-compatible proxies hand us a dict-shaped
response or a bare message, not a ChatCompletion. Reuse the existing
helper instead of a second extractor, and bound an optional reasoning
fallback so a chain-of-thought dump cannot become the summary.

Co-authored-by: Chris DePuy <chris@650group.com>
Co-authored-by: chenhm <chenhm@yuancheng.local>
OutThisLife and others added 29 commits September 1, 2026 22:33
detectBundleSkew() trusted `git rev-list --count <stamp>..HEAD -- apps/desktop`
outright, which claims skew in two states where the install is not torn.

Ancestry: `A..HEAD` only measures how far HEAD is ahead of A when A is an
ancestor of HEAD. A ZIP-fallback update rewrites the tree onto a synthetic
root, so the stamp still resolves but is unreachable; the range degenerates to
HEAD's own history and reports a permanent >= 1 while apps/desktop is
byte-identical. Ask `merge-base --is-ancestor` first and go quiet unless it
answers yes.

Scope: the pathspec counted every file under apps/desktop/, so a docs- or
e2e-only commit produced a banner promising missing UI features that do not
exist. Count only the paths that reach the shipped app.

Co-authored-by: jackulau <jackulau@users.noreply.github.com>
Co-authored-by: kokhlo <kokhlo@users.noreply.github.com>
…orn renderer

A user who reopens Hermes while an update is running lands on the boot gate,
which is what it is for. But the updater swaps the packaged bundle on disk
after `hermes update` exits, and its `open` leg only focuses this already-
running process, so nothing ever loads the new build. The parked instance then
passes the gate and boots the new runtime under the old renderer — the "App
build out of date" banner immediately after a fully successful update, over an
Updates card that says "You're on the latest version" and so offers no remedy.

Compare the install stamp this process loaded at boot with the one on disk when
the gate clears. On positive proof of a swap — different commit, or a different
builtAt at the same commit — relaunch instead of starting a backend. Detection
fails quiet like bundle-skew, so a swap that never happened (the Windows
locked-binary case) is unchanged. A one-shot argv flag makes a relaunch loop
impossible and a 15s failsafe falls back to the old behavior.

Co-authored-by: tk-pkm111 <133480534+tk-pkm111@users.noreply.github.com>
Co-authored-by: aeonsong <aeonsong@users.noreply.github.com>
When the bundle was swapped under a running process, the About banner sent the
user to the installer — a download and a reinstall for a state that a plain
restart repairs, and the reason reinstalling never helped these reports.

Report bundleSwapPending on hermes:version and give that case its own copy and
a "Restart Hermes" button. It gets its own headline too: reusing "App build out
of date" over a body that says the app is already installed repeats the
contradiction with the Updates card that the banner is supposed to resolve. The
installer link stays for the genuinely-stale-bundle case.

Packaged builds only — a dev `--build-only` rewrites the stamp under a running
`npm start`, and that is a rebuild the developer asked for, not a torn install.

Co-authored-by: tk-pkm111 <133480534+tk-pkm111@users.noreply.github.com>
The update entry points chose their target from the connection mode, so
every surface in remote mode acted on the backend — including the ones
showing the client's own status.

The macOS "Check for Updates…" app-menu item is the clearest case: it
sits next to "About Hermes" and is the OS-standard way to update THIS
app, but on a Mac connected to a remote Linux backend it checked the
Linux box. The backend was already current, so the action reported
nothing and did nothing, and the desktop app drifted months behind with
no error and no updater log to explain it. The update-available toast
had the same split: a client check raised it, clicking it opened the
backend's overlay, which has no target switcher and no way back.

Surfaces bound to one target now name it; only genuinely generic
commands still take the connection-mode default, so the command
palette's remote-mode backend target and the everything-flow are
unchanged.

Co-authored-by: BerneYue <14088768+yuexiongHNU@users.noreply.github.com>
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
Co-authored-by: clayduncan <234173110+clayduncan@users.noreply.github.com>
Co-authored-by: Dhana <227747512+whoisdhana@users.noreply.github.com>
The everything-flow's client leg read `$updateStatus.get() ?? await
checkUpdates()`, so a cached row always won. That row can be up to a
poll interval (30 minutes) old and is captured before the backend leg
runs, so a cached "already current" skipped the client apply entirely —
the stale-GUI gap the flow exists to close.

Re-check first and fall back to the pre-flow snapshot when the live
check can't answer. `checkUpdates()` resolves with an error status
rather than rejecting and overwrites the atom with it, so the snapshot
is taken before any leg runs.

Co-authored-by: Dhana <227747512+whoisdhana@users.noreply.github.com>
A concurrent WAL checkpoint / reset / frame-flush can surface SQLITE_IOERR
to a reader on a perfectly healthy database: a mode=ro connection cannot
perform the WAL recovery the read needs, because recovery writes the -shm
index and read-only mode refuses. The window is millisecond-scale.

Today that one-shot error escapes the SessionDB read-only constructor, and
GET /api/sessions turns it into a 500 the desktop reads as an authoritative
empty list.

Retry it, bounded, in the constructor so every read-only opener is covered —
the sidebar poll, cross-profile aggregation, recall, browse — rather than at
one route. A persistent IOERR still exhausts the budget and propagates.
Remaining transient failures answer 503, so the client keeps the list it has.

On the write path, BEGIN IMMEDIATE can hit the same transient IOERR before
the callback runs. That one is safe to retry on the same connection because
nothing has been mutated; once the callback starts, settlement is unknown and
the error propagates. Never close()+reopen to heal it — close() cancels this
process's POSIX advisory locks on the file for every sibling connection, and
a list poll's reader must stay disposable so a replaced state.db is observed
and the pre-repair forensic backup stays reachable.

Fixes NousResearch#100436

Co-authored-by: rkfshakti <rkfshakti@users.noreply.github.com>
Co-authored-by: AKAZIK-py <AKAZIK-py@users.noreply.github.com>
The sidebar reports a profile it could not scan as HTTP 200 with an empty
page and errors=[{profile}]. The renderer merges that page keeping only
working, pinned, and selected rows, so every idle Yesterday / This-week
session disappears until a later scan succeeds — and the 5s coalescing cache
then serves the same empty payload back for the rest of its TTL.

Carry the previous rows forward for exactly the profiles named in errors[],
keyed by profile::id so a twin id in another profile is never stitched in.
Profiles that scanned cleanly are still authoritative, so a genuinely empty
page with no errors still clears the list. Per-profile usage and truncation
flags follow the same rule rather than zeroing under a list that was kept.

The legacy per-slice fallback stamps errors on the slice that actually
failed, so a cron read failure can no longer blank recents.

Part of NousResearch#73847
Part of NousResearch#88528

Co-authored-by: AKAZIK-py <AKAZIK-py@users.noreply.github.com>
…ection

Branching a session owned by one connection while another is active created
the child on the wrong backend — or nowhere — while the sidebar still painted
an optimistic row. That row pointed at an id no backend owned, so hydration
retried, exhausted, and armed the stranded-session overlay: "Couldn't load
this session. The connection to this session failed and automatic retries
gave up." Retry re-ran the same mis-route, so it never recovered.

branchStoredSession and branchCurrentSession resolved only the parent's
PROFILE and called ensureGatewayProfile(profile), then dispatched through the
ambient requestGateway. A profile name does not identify a backend once
several connections expose the same name, so both the parent transcript read
and the session.create/session.branch RPC landed on whichever socket happened
to be active. removeSession, twelve lines away, already routed by
(connectionId, profile) via SessionOwnerScope — branch simply never got the
same treatment.

Reuse that existing contract: derive the exact owner from the parent row with
sessionOwnerRouteFromRow, activate it with ensureGatewayAgent, and dispatch
via requestGatewayForAgent. getAllSessionMessages takes the same owner scope
so the transcript read cannot silently come back empty and abort the branch
as "nothing to branch" before any create is attempted. Both arms of forkBranch
(session.branch for the open chat, session.create for a sidebar right-click)
are covered.

An untagged parent row — the single-backend case — keeps the previous
profile-only path exactly, so behaviour is unchanged for users with one
connection.

Tests: three call-site regressions asserting the create rides the owning
(connection, profile) socket, that the transcript read carries the same owner
scope, and that an untagged parent still uses the ambient socket. Plus an
integration test that mocks nothing inside the router — the real
requestGatewayForAgent runs against a fake Electron bridge and transport, so
a regression that re-collapses a registry route onto the ambient socket fails
even if the call-site assertions still pass.
…reate

Routing the branch create to the parent's owning connection was only half the
job. The child then landed in the sidebar as a row that lied about who owned
it, so the chat pane spun forever on "draft: branch #1" and never hydrated —
the create was right, the row was wrong.

upsertOptimisticSession stamps the row's profile from $activeGatewayProfile and
omits connection_id entirely when no owner is passed (utils.ts:1318-1342), and
it also skips setSessionOwnerHint. The branch call site passed no owner, so the
child got NEITHER a row tag NOR a hint. resumeSession's owner ladder starts at
`capturedOwner || getSessionOwnerHint(storedSessionId)` and forkBranch calls it
without a capturedOwner, so the missing hint alone was enough to send the
resume to whichever backend happened to be active. Pass the parent's route as
the owner argument, restoring both mechanisms. The two sibling routed creates
in this file already did exactly this.

The tile path had the same defect one rung further out. A branch of a session
that is not the open chat opens a tile instead of resuming, and
SessionTileChrome resolved its owner from the tile route alone. openSessionTile
is called for a branch child with no workspaceScope, and session-states.ts only
persists a tile ownerRoute in bots mode, so that tile had no owner at all and
its model + composer RPCs fell back to the ambient socket. Use the same
tile-route-then-row ladder its sibling in session-tile-actions.ts already uses,
resolved per render so it cannot go stale against the tile store, the
recents/cron/messaging rows, or the hint map, with only the resulting identity
memoised on primitives.

An untagged parent row still reproduces the previous ambient behaviour exactly,
so single-connection users are unaffected.

Verified end to end against two real gateways: a session owned by a remote
connection, branched through the actual sidebar context menu in a running dev
app. The remote gateway served the create (ws closed ... messages=11
detached_sessions=1) and the resulting row polled stable at connection_id =
the remote for the full 8s window. Before the fix the same gesture produced a
row with no connection_id.
Routing the create and stamping the optimistic row still left a
remote-owned branch child flickering into "Couldn't open this session —
Session keeps losing its backend runtime right after resuming". The RPCs
were right and every resume succeeded; the OWNING SOCKET was the
casualty. Three gaps, one cause — nothing durable named the owner:

- openSessionTile persisted ownerRoute only for workspaceMode==='bots',
  so the branch tile pinned nothing in the gateway keep-set
  (openTileGatewayScopes / foregroundSessionScopes). The pruner closed
  the owner socket, the backend orphan-reaped the draft runtime,
  session.reclaimed unbound the tile, resume re-armed and succeeded on a
  fresh socket the next recompute closed again — until the resume-storm
  breaker (NousResearch#93892) latched the error card at TILE_RESUME_STORM_LIMIT.
  Persist the route for sessions-mode tiles whose opener knows the exact
  owner, and stop a route-less re-scope from clobbering it.

- forkBranch, unlike both sibling routed creates in the same file, never
  called setSessionOwnerHint/holdSessionOwnerUntilForeground — so in the
  gap between session.branch returning and the tile publication landing,
  no keep-set rung named the owner and a prune could reap the just-minted
  draft runtime before the first prompt. Add both, mirroring the
  siblings; the hold retires once the tile's own route covers the scope.

- resetTileRuntimeBindings preserved cross-connection runtimes only for
  bot tabs, so a flapping sibling connection (an SSH source re-dialing)
  dropped the branch tile's healthy binding on every reconnect, re-arming
  resume each time — the same storm by another path. Preserve any
  owner-routed tile; the owner's own reconnect still rebinds.

Also keep open tiles' rows in sessionsToKeep: a branch child is a draft
the aggregator cannot return until its first turn persists it, so the
next background refresh silently dropped the optimistic "draft: branch
#N" row and the sidebar showed no trace of the branch until first send.

Each fix verified RED by reverting its line. Live acceptance against a
remote-owned parent branched from another connection: draft row visible
immediately and stable across refreshes, tile carries the owner route,
first turn accepted and completed by the owning backend, and no
storm/error card through the full 120s storm window — before the fixes
the card latched at ~20s.
…esearch#93959)

Desktop branch creation hung on an infinite spinner and lost the branch
on restart. Root cause: the renderer branches via session.create with
parent_session_id + a seeded transcript, but session.create defers the
DB row to the first prompt (the draft-hygiene contract). The renderer's
post-create resume then re-fetches the fresh child through REST and
defer_history hydration — both read the DB. An unpersisted child 404s
and hydrates empty, the client fail-latch (sessionShouldHaveTranscript +
empty messages) refuses to bind a "transcript-less" session, and the
user sees a spinner forever; on restart the rowless child vanishes and
the optimistic "Draft: Branch N" entry disappears with it.

A seeded branch is explicit user intent, not an abandoned draft.
session.create now persists the child immediately when both
parent_session_id AND seeded history are present:

- Row created in the PARENT's profile-scoped state.db, stamped with
  _branched_from + parent_session_id (same shape as TUI /branch).
- Seeded transcript copied via append_messages_batch so REST prefetch
  and defer_history hydration find it on the first read.
- Title assigned from get_next_title_in_lineage(parent) and cleared
  from pending_title — the branch lands in the parent's lineage instead
  of falling back to a message-preview name.

Persistence is best-effort: a broken DB logs and lets create succeed,
leaving the lazy first-prompt path as fallback. Plain drafts keep the
lazy-row contract unchanged.

Fixes NousResearch#93959
…ilures

Review follow-up on NousResearch#93959:

1. Partial-failure window: if the row commits but the transcript copy or
   title write fails, the durable-but-empty child defeated the lazy
   first-prompt fallback (_ensure_session_db_row is INSERT OR IGNORE), so
   the renderer fail-latched on a transcript-less session again. The seed
   block now compensates: delete just this child so the lazy path can
   retry cleanly. Disk-full is exempt — deleting data on a full disk makes
   things worse.

2. Silent degradation: the best-effort catch now logs at WARNING with
   exc_info instead of DEBUG, so a regression in this user-facing path is
   observable without enabling debug logs.

Tests: compensation deletes the half-written row and preserves
pending_title; disk-full keeps the row and surfaces the WARNING.
A scoped projects.tree / projects.project_sessions response is built from
ONE profile's state.db, so the request scope is authoritative even for
legacy rows whose persisted profile_name is NULL. Without the stamp those
rows reach the renderer ownerless, and every owner lookup off them — the
branch path included — falls back to whichever backend is active.

Co-authored-by: evan-bradford <evan-bradford@users.noreply.github.com>
…mes one

branchStoredSession looked its parent up in $sessions alone, and
branchCurrentSession did the same. A conversation reachable through a
profile-scoped project tree has no row there, and when it appears in both
places the flat Recents copy is the ownerless one — so the lookup returned
the row that cannot route, and the branch created its child on whichever
backend happened to be active.

cachedSessionRow spans Recents, cron, messaging and the project tree, and
prefers the self-describing candidate. One ladder, used by both branch
entry points and by resolveStoredSession.

Co-authored-by: evan-bradford <evan-bradford@users.noreply.github.com>
TileChat re-renders per streamed token, and the owner ladder spread three
session arrays before scanning them on each one. Subscribe to the atoms it
actually reads and memoise the lookup on them, so it recomputes when the
tile store or a session list changes rather than per frame.
The coalescing key now carries the owner, so two backends that both expose a
session called `parent` get their own create instead of the second caller
receiving the first's child. Both creates are held open, which is the only
state the key guards — a sequential version passes even with the owner
stripped out.

Co-authored-by: Ahmett101 <ahmet.tunc@gmail.com>
session-tile-owner-route.test.ts asserted against the TEXT of
session-tile.tsx, so it passed on a broken implementation whose call site
merely looked right, and failed on this refactor, which changed nothing the
tile actually does. AGENTS.md bans the pattern outright.

Extracted the ladder as tileOwnerRoute() and replaced the three regex
assertions with six that call it: tile route wins, row falls back, hint
falls back, targetProfile carries through, a bare profile narrows away, an
untagged session stays ambient. 12s of source-matching becomes 1.1s of
behavior.
… bare if: false

The `${{ false && (...) }}` if-expression on the reusable-workflow
e2e-desktop job made GitHub's workflow parser fail at startup (annotation:
"An unexpected error has occurred"), so every ci.yaml run on main and
every PR dispatched 0 jobs. Discriminator on this branch: pre-24f5a60
blob → 26 jobs; paren-relocated && form → 0; bare if: false → 25.
Lane stays disabled; comment documents the re-enable line.
Collapses the three test-refinement commits from NousResearch#100614 (423f7f8,
157db13, ffa72dd): model recovery pressure by provider-call state,
assert the rebuilt-oversized retry fails closed, keep the compacted
history compressible (user+assistant summary rows).
The Anthropic long-context 429 handler restarts on row count alone,
the same shape NousResearch#100614 fixed in the generic overflow handler. Arm the
same provider-overflow recovery flag there so the rebuilt request is
measured against the reduced window before the provider is retried.

The 413 (byte-scored) and output-cap (max_tokens) handlers are a
different yardstick and are left as-is.
A wall-clock step-back (NTP) can leave the marker's mtime in the future,
which would push the idle window out by the step size. Clamp with
min(mtime, now); _last_inbound_at has the same exposure but is at least
bounded by process uptime. Review comment on NousResearch#100830.
…board-client-activity

fix(scale-to-zero): count an attached dashboard WS client as inbound activity
…no worker

should_use_direct_api_call() contexts (gateway cron turns NousResearch#62151, delegate_task
children NousResearch#60203) were short-circuited onto the NON-streaming wire because the
interrupt worker wedges inside their nested thread pools. That dropped every
liveness property streaming provides: edge proxies kill the silent POST
(z.ai HTTP 524 — three retries later the child dies as "max_iterations"), and
the non-stream stale watchdog cannot tell a reasoning model's thinking phase
from a hung provider, so children die at exactly stale_timeout (NousResearch#100260).

Keep those contexts on interruptible_streaming_api_call. The request now runs
INLINE on the conversation thread (no worker → the deadlock class stays
closed) while the existing poll loop — 30s heartbeat, stale-stream detector,
cross-thread interrupt abort — moves onto a monitor thread that only ever
aborts sockets, never dispatches (same shape as direct_api_call's watchdog
timer). Interactive sessions are unchanged: worker + poll loop as before.

should_use_direct_api_call() itself is untouched; only what it routes to.

Live A/B (real SSE server, real AIAgent.run_conversation):
  before: subagent/cron wire stream=None, request on conversation thread
  after:  subagent/cron wire stream=True, request on conversation thread
          cli unchanged (stream=True, spawned worker)
  inline stale detector kills a one-chunk-then-silence stream at budget;
  AIAgent.interrupt() from another thread unwinds the inline stream in 0.6s.

Co-authored-by: Expri-commits <184641533+Expri-commits@users.noreply.github.com>
…esearch#100881)

Desktop "Read replies aloud" / voice conversation, TUI and CLI /voice tts
now hold a lease on the TTS engine. Acquiring pre-loads the configured
provider (piper/kittentts model into the same LRU slot synthesis reads;
lazily-installed cloud SDKs), so the first spoken reply no longer pays the
model load as dead air. Releasing the last lease across surfaces unloads
resident local models.

- tools/tts_tool.py: warm_tts_provider / release_tts_provider /
  acquire_tts_lease / release_tts_lease over a _LOCAL_TTS_MODEL_CACHES
  registry; piper/kittentts loaders extracted so warm-up and synthesis
  share one resolution path.
- web_server: POST /api/audio/tts-lease (profile-scoped, off-loop,
  failures reported in body never as HTTP errors).
- tui_gateway voice.toggle + cli.py /voice tts|on|off wire the lease.
- desktop: lib/tts-lease.ts (dedupe, per-lease serialization, latest
  intent wins) driven from useComposerVoice; setTtsLease API client.
- docs: features/tts.md section.

Live (real piper, isolated HERMES_HOME): first synthesis 988ms cold →
92ms after the toggle warmed the engine; release drops the model.
…ofile's (NousResearch#100864)

Voice playback dialed getApiRequestProfile() — the window's ACTIVE gateway
profile — so every Bot chat replied with the active profile's TTS/STT config
regardless of the Bot's own per-profile voice settings.

Introduce a VoiceRouteScope context: chat surfaces owned by a route (Bot
chats, routed sessions) provide their owner (connection, profile); speech
synthesis (client-direct, WS relay, data-URL fallback) and transcription
now resolve through that scope. Surfaces with no owner route keep the
pre-NousResearch#100864 behavior (active profile).

- voice-route-scope.ts: VoiceRouteScopeContext + useVoiceRouteScope
- chat/index.tsx: ChatRuntimeBoundary provides the scope from the session's
  owner route (same resolution as session-tile model options)
- voice-playback.ts / voice-client-direct.ts / api/system.ts: play/transcribe
  accept an optional scope, threaded through every ladder rung
- composer auto-speak, voice-conversation, and read-aloud pass the scope
- routing tests pin the owner-route resolution and the unchanged fallback
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 392065d — fix(desktop): Bot chats speak with the Bot's voice, not the

⚠️ Warnings

OSV vulnerability scan · View job

13 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 7m53s (no baseline yet).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop Bots: voice playback uses the active profile's TTS voice instead of the Bot's own profile TTS config