hermes-agent/apps/desktop/src/lib/gateway-ws-url.ts
Brooklyn Nicholson b94b3622b5 feat(desktop): per-session profile switching + cross-profile sessions
Add first-class profile support to the desktop app without app reloads.

- Swap the single live gateway onto a session's profile lazily (spawned on
  demand by the Electron backend pool), so one backend serves the active
  profile and others stay cold — no OOM with many profiles.
- Aggregate sessions across profiles by reading each profile's state.db
  read-only; unified "All profiles" view groups sessions per profile with
  per-profile pagination, while the default view stays scoped to one profile.
- Add an Arc-style profile rail at the sidebar foot: a default<->all toggle
  pinned left, colored named-profile squares scrolling between, Manage pinned
  right. Profile identity is a deterministic per-name color.
- Route profile-scoped REST (config/env/skills/tools/model) to the active
  gateway profile and invalidate React Query caches on swap. Single-profile
  users never trigger a swap, so their path is unchanged.

Backend:
- web_server: profile-aware active/list endpoints + per-profile session
  totals; hermes_state: session_count(exclude_children); main.py: honor
  --profile over HERMES_HOME env for pooled backends.

UI primitives:
- Add a position-aware Tip tooltip (instant, themed) as a drop-in for native
  title=, and strip redundant tooltips from self-descriptive chrome.
2026-06-04 16:35:34 -05:00

92 lines
3.4 KiB
TypeScript

import type { HermesConnection } from '@/global'
/**
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
* consumed). For local/token gateways the URL carries a long-lived token and
* never needs re-minting.
*
* Resolution rules:
*
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
* back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to
* fail with an opaque "connection closed" error. Instead, let the mint error
* propagate so the caller can surface the gateway's reauth message
* ("session has expired… Sign in again").
*
* - token / local, or when the preload method is genuinely absent (older
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
* the fallback is safe and preserves compatibility.
*
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
* callers can distinguish "the user must re-authenticate" from a generic
* transport failure.
*/
export interface ResolveGatewayWsUrlDeps {
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The
* optional profile selects which backend to mint for — critical when swapping
* to a pooled profile, since the default mint resolves the primary backend. */
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
}
export class GatewayReauthRequiredError extends Error {
readonly needsOauthLogin = true
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'GatewayReauthRequiredError'
}
}
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
return (
error instanceof GatewayReauthRequiredError ||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
)
}
export async function resolveGatewayWsUrl(
desktop: ResolveGatewayWsUrlDeps,
conn: Pick<HermesConnection, 'authMode' | 'profile' | 'wsUrl'>
): Promise<string> {
const mint = desktop.getGatewayWsUrl
// Mint for THIS connection's profile, not the primary. Without it a pooled
// profile swap re-mints the default backend's URL and connects to the wrong
// backend.
const profile = conn.profile ?? null
if (conn.authMode === 'oauth') {
if (!mint) {
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
// dead, so connecting with it cannot succeed. Surface a reauth error
// rather than silently attempting a doomed connect.
throw new GatewayReauthRequiredError(
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
)
}
try {
return await mint(profile)
} catch (error) {
throw new GatewayReauthRequiredError(
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
{ cause: error }
)
}
}
// token / local: the URL carries a long-lived token. Re-mint when available
// (cheap, keeps parity), but the cached URL is a safe fallback.
if (mint) {
const fresh = await mint(profile).catch(() => null)
if (fresh) {
return fresh
}
}
return conn.wsUrl
}