fix(desktop+gateway): full multi-profile support over one global-remote dashboard (#39921)

* fix(desktop): cross-profile session history in app-global remote mode

#39894 made remote-profile sessions first-class for PER-PROFILE remote
overrides. But the common setup — Settings → Gateway → "All profiles" → Remote
— writes app-GLOBAL remote mode (connection.json top-level mode:'remote', empty
profiles map), which the intercept didn't recognize. Switching to a non-launch
profile then 404'd every session read, so no history showed for it.

In global remote mode a SINGLE backend serves every profile via ?profile= (it
reads each profile's state.db off the remote host's own disk — verified: one
dashboard returns /api/profiles and /api/profiles/sessions?profile=all across
all profiles). The fix: when no per-profile override matches but global remote
mode is active, route per-session reads/mutations to that one backend and KEEP
the ?profile= param so it opens the right state.db (instead of bailing to the
local path and dropping the profile scope).

- new globalRemoteActive() — true for connection.json mode:'remote' or the
  HERMES_DESKTOP_REMOTE_URL env override.
- per-session branch: per-profile override → route sans profile (own db);
  global mode → route to the single backend WITH ?profile= preserved.
- unified list is unchanged in global mode: it already passes through to the one
  backend, which aggregates all profiles natively.

Verified live against a one-dashboard / multi-profile remote (Austin's topology):
cross-profile transcript reads load (was 404), rename/delete route to the right
profile, unified list spans both profiles.

Known limitation (architectural, not fixed here): LIVE chat as a non-launch
profile still needs a per-profile dashboard on the remote — the dashboard binds
HERMES_HOME once at process start, so one global backend can't run an agent
turn as another profile. Session history/read/mutate now work regardless.

* fix(gateway): resume + chat any profile over one global-remote dashboard

The REST half of this branch made cross-profile session history visible in
app-global remote mode, but resume + chat still went over the WebSocket gateway,
which was hard-bound to the dashboard's launch profile. Resuming a non-launch
profile's session 404'd ("session not found") and sending spawned a new session
— because session.resume/prompt.submit had no profile concept and the live
agent + state.db were process-global to the launch profile's HERMES_HOME.

Make the WS gateway per-session profile-aware so ONE dashboard can serve every
local profile on its host (the app-global remote topology):

- session.resume accepts an optional `profile`. _profile_home() resolves that
  profile's home on this host; resume opens THAT profile's state.db, binds its
  HERMES_HOME (ContextVar override) while building the agent so config/skills/
  model resolve to it, and passes the profile db to the agent so turns persist
  to the right state.db. The owning profile_home is stored on the session.
- prompt.submit re-binds the stored profile_home for the turn thread (mid-turn
  home reads — memory, skills — resolve to the resumed profile), reset in finally.
- _make_agent gains an optional session_db param (defaults to _get_db()).
- _load_cfg honors the home override (falls back to _hermes_home) so a resumed
  profile loads its own config; cache keyed on resolved path.
- desktop: session.resume now sends the owning profile.

Omitted/launch profile → unchanged (single-profile and per-profile-remote setups
are byte-for-byte the same path). Verified live against a one-dashboard /
multi-profile remote: resuming a non-launch profile's session loads its history,
runs a real turn against THAT profile's home/env, and persists to its state.db.

tests/tui_gateway/test_protocol.py: _make_agent mocks updated for the new param.
This commit is contained in:
brooklyn!
2026-06-05 12:22:55 -05:00
committed by GitHub
parent e837856ecd
commit 02d6bf1c39
4 changed files with 119 additions and 20 deletions
+34 -8
View File
@@ -3922,6 +3922,16 @@ function configuredRemoteProfileNames() {
return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))
}
// True when the app is in app-global remote mode (Settings → "All profiles" →
// Remote, or the env override): a SINGLE remote backend serves every profile via
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
function globalRemoteActive() {
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return true
}
return readDesktopConnectionConfig().mode === 'remote'
}
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
async function fetchJsonForProfile(profile, path) {
return requestJsonForProfile(profile, path, 'GET')
@@ -4762,19 +4772,35 @@ async function interceptSessionRequestForRemote(request) {
}
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
// (mutations); route to the remote sans profile param — it serves its own
// state.db, with no cross-profile semantics.
// (mutations). Two remote shapes:
// - per-profile override: route to that profile's own remote, sans profile
// param (it serves its own state.db natively).
// - global remote mode: ONE backend serves every profile via ?profile=, so
// route there and KEEP the profile param so it opens the right state.db.
if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) {
const profile = (searchParams.get('profile') || request.profile || '').trim()
if (!profile || !profileHasRemoteOverride(profile)) {
if (!profile) {
return undefined
}
if (method === 'GET') {
return fetchJsonForProfile(profile, pathname)
if (profileHasRemoteOverride(profile)) {
if (method === 'GET') {
return fetchJsonForProfile(profile, pathname)
}
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
if (body) delete body.profile
return requestJsonForProfile(profile, pathname, method, body)
}
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
if (body) delete body.profile
return requestJsonForProfile(profile, pathname, method, body)
if (globalRemoteActive()) {
// Single global backend: keep ?profile= so it opens the right state.db.
const sep = pathname.includes('?') ? '&' : '?'
const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}`
if (method === 'GET') {
return fetchJsonForProfile(null, path)
}
const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile }
return requestJsonForProfile(null, path, method, body)
}
return undefined
}
return undefined
@@ -529,7 +529,11 @@ export function useSessionActions({
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96
cols: 96,
// Owning profile: in app-global remote mode one backend serves every
// profile, so the gateway opens this profile's state.db + home to
// resume + persist the right session (no-op for single/launch profile).
...(sessionProfile ? { profile: sessionProfile } : {})
})
if (!isCurrentResume()) {