diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 9f4796a46246..ded844ebec8d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -481,6 +481,11 @@ const backendPool = new Map() // profile -> { process, port, token, connectionPr // exist while a non-primary profile is actively being chatted through. const POOL_MAX_BACKENDS = Math.max(1, Number(process.env.HERMES_DESKTOP_POOL_MAX) || 3) const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || 10 * 60_000) +// A backend touched within this window has a live renderer socket (the keepalive +// pings every 60s for every open profile). LRU eviction must spare these — a +// concurrent multi-profile session keeps several backends "fresh" at once, and +// killing one to honor the soft cap would abort a running agent. +const POOL_KEEPALIVE_FRESH_MS = 90_000 let poolIdleReaper = null // Auto-reload budget for renderer crashes. A deterministic startup crash would // otherwise loop forever (reload → crash → reload), pinning CPU and spamming @@ -3860,16 +3865,22 @@ function touchPoolBackend(profile) { if (entry) entry.lastActiveAt = Date.now() } -// Evict least-recently-used pool backends until at most `keep` remain. +// Evict least-recently-used pool backends until at most `keep` remain — but only +// ever evict backends without a live renderer socket (stale beyond the keepalive +// window). When every backend is actively kept alive we let the pool exceed the +// soft cap rather than kill a running session. function evictLruPoolBackends(keep) { if (backendPool.size <= keep) return - const ordered = [...backendPool.entries()].sort( - (a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0) - ) - while (ordered.length > Math.max(0, keep)) { - const [profile] = ordered.shift() + const now = Date.now() + const evictable = [...backendPool.entries()] + .filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS) + .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) + let removable = backendPool.size - Math.max(0, keep) + for (const [profile] of evictable) { + if (removable <= 0) break rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) stopPoolBackend(profile) + removable -= 1 } } diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index d8458a93186d..2647968ad6b1 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -10,10 +10,27 @@ import { failDesktopBoot, setDesktopBootStep } from '@/store/boot' -import { setGateway } from '@/store/gateway' +import { + $gateway, + closeSecondaryGateways, + configureGatewayRegistry, + ensureGatewayForProfile, + pruneSecondaryGateways, + reconnectSecondaryGateways, + reportPrimaryGatewayState, + setPrimaryGateway, + touchSecondaryGateways +} from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' -import { $activeGatewayProfile, touchActiveGatewayBackend } from '@/store/profile' -import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session' +import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile' +import { + $attentionSessionIds, + $connection, + $sessions, + $workingSessionIds, + setConnection, + setSessionsLoading +} from '@/store/session' import type { RpcEvent } from '@/types/hermes' interface GatewayBootOptions { @@ -166,6 +183,7 @@ export function useGatewayBoot({ clearReconnectTimer() reconnectAttempt = 0 + reconnectSecondaryGateways() if (!gatewayOpen()) { void attemptReconnect() @@ -186,10 +204,14 @@ export function useGatewayBoot({ const gateway = new HermesGateway() callbacksRef.current.onGatewayReady(gateway) - setGateway(gateway) + setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get())) + // Secondary (background-profile) sockets funnel into the same handler. + configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) }) const offState = gateway.onState(st => { - setGatewayState(st) + // Mirror to the composer only while the primary is the active profile — + // a background secondary reconnect mustn't flip the foreground state. + reportPrimaryGatewayState(st) if (st === 'open') { reconnectAttempt = 0 @@ -219,10 +241,33 @@ export function useGatewayBoot({ window.addEventListener('online', onOnline) document.addEventListener('visibilitychange', onVisible) - // Keep the active pool backend alive while this window is open (the main - // process can't observe the direct renderer↔backend WS). No-op for the - // primary backend. - const keepaliveTimer = setInterval(() => touchActiveGatewayBackend(), 60_000) + // Keep live pool backends alive while this window is open (the main process + // can't observe the direct renderer↔backend WS). No-op for the primary. + const keepaliveTimer = setInterval(() => { + touchActiveGatewayBackend() + touchSecondaryGateways() + }, 60_000) + + // Bound concurrency cost to live work: keep a background socket only while + // its profile has a running (working) or blocked (needs-input) session. + // Once that profile goes idle its socket is dropped and its backend is free + // to idle-reap. The active profile is always spared. + const recomputeKeptGateways = () => { + const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()]) + const keep = new Set() + + for (const session of $sessions.get()) { + if (live.has(session.id)) { + keep.add(normalizeProfileKey(session.profile)) + } + } + + pruneSecondaryGateways(keep) + } + + const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways()) + const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways()) + const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways()) const offWindowState = desktop.onWindowStateChanged?.(payload => { const current = $connection.get() @@ -276,7 +321,10 @@ export function useGatewayBoot({ // right backend. Best-effort: a missing preference means "default". try { const pref = await desktop.profile?.get?.() - $activeGatewayProfile.set((pref?.profile ?? '').trim() || 'default') + const profileKey = (pref?.profile ?? '').trim() || 'default' + $activeGatewayProfile.set(profileKey) + setPrimaryGateway(gateway, profileKey) + void ensureGatewayForProfile(profileKey) } catch { $activeGatewayProfile.set('default') } @@ -316,6 +364,9 @@ export function useGatewayBoot({ cancelled = true clearReconnectTimer() clearInterval(keepaliveTimer) + offWorking() + offAttention() + offActiveProfile() window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisible) offPowerResume?.() @@ -324,10 +375,12 @@ export function useGatewayBoot({ offExit() offWindowState?.() offBootProgress() + closeSecondaryGateways() gateway.close() publish(null) callbacksRef.current.onGatewayReady(null) - setGateway(null) + setPrimaryGateway(null) + $gateway.set(null) } }, []) } diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 92e7e732b426..1addd3c1e7d3 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { HermesGateway } from '@/hermes' import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' +import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway' import { $activeGatewayProfile } from '@/store/profile' import { $gatewayState, setConnection } from '@/store/session' @@ -25,6 +26,16 @@ export function useGatewayRequest() { gatewayStateRef.current = gatewayState }, [gatewayState]) + // Track the active gateway (primary or a background profile's socket) so + // outbound requests and overlay props always target the focused profile. + useEffect( + () => + $gateway.subscribe(gateway => { + gatewayRef.current = gateway as HermesGateway | null + }), + [] + ) + const ensureGatewayOpen = useCallback(async () => { const existing = gatewayRef.current @@ -99,7 +110,10 @@ export function useGatewayRequest() { throw error } - const recovered = await ensureGatewayOpen() + // Primary keeps the OAuth-aware reconnect (remote gateways re-mint a + // single-use ticket); background profiles are always local pool + // backends, so the registry handles their reconnect with no reauth. + const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen() if (!recovered) { // Prefer the reauth error from the failed reconnect (OAuth session diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index d7820434eff3..4bf06549bbd0 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -752,12 +752,11 @@ export function useMessageStream({ return } - // Turn ended — drop any blocking prompt that's still open (e.g. the - // agent was interrupted, or the approval already resolved). Prevents a - // stale overlay from outliving the turn that raised it. - if (isActiveEvent) { - clearAllPrompts() - } + // Turn ended — drop any blocking prompt still open for THIS session + // (e.g. interrupted, or the approval already resolved). Scoped to the + // session so a background turn finishing can't wipe the active chat's + // prompt, and vice versa. + clearAllPrompts(sessionId) flushQueuedDeltas(sessionId) @@ -842,37 +841,34 @@ export function useMessageStream({ } } } else if (event.type === 'approval.request') { - if (!isActiveEvent) { - return - } - - // Dangerous-command / execute_code approval. The Python side is - // blocked in _await_gateway_decision() until approval.respond lands; - // without this the agent stalls until its 5-min timeout and the tool - // is BLOCKED. Approval is session-keyed (no request_id) — the overlay - // sends back {choice, session_id}. + // Dangerous-command / execute_code approval. The Python side is blocked + // in _await_gateway_decision() until approval.respond lands; without + // this the agent stalls until its 5-min timeout and the tool is BLOCKED. + // Park it per-session (like clarify) so a *background* profile's turn can + // raise it and wait — the sidebar flags "needs input" and the inline bar + // surfaces once the user focuses that chat. setApprovalRequest({ command: typeof payload?.command === 'string' ? payload.command : '', description: typeof payload?.description === 'string' ? payload.description : 'dangerous command', sessionId: sessionId ?? null }) - } else if (event.type === 'sudo.request') { - if (!isActiveEvent) { - return - } + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } + } else if (event.type === 'sudo.request') { // Sudo password capture (tools/terminal_tool.py). Blocked on // sudo.respond {request_id, password}. const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' if (requestId) { - setSudoRequest({ requestId }) + setSudoRequest({ requestId, sessionId: sessionId ?? null }) + + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } } } else if (event.type === 'secret.request') { - if (!isActiveEvent) { - return - } - // Skill credential capture (tools/skills_tool.py). Blocked on // secret.respond {request_id, value}. const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' @@ -881,18 +877,23 @@ export function useMessageStream({ setSecretRequest({ requestId, envVar: typeof payload?.env_var === 'string' ? payload.env_var : '', - prompt: typeof payload?.prompt === 'string' ? payload.prompt : '' + prompt: typeof payload?.prompt === 'string' ? payload.prompt : '', + sessionId: sessionId ?? null }) + + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } } } else if (event.type === 'error') { const errorMessage = payload?.message || 'Hermes reported an error' const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage) - // A turn that errors out has also ended — drop any open blocking - // prompt so an approval/sudo/secret overlay can't linger past the - // failed turn (same intent as the message.complete clear). - if (isActiveEvent) { - clearAllPrompts() + // A turn that errors out has also ended — drop any open blocking prompt + // for this session so an approval/sudo/secret overlay can't linger past + // the failed turn (same intent as the message.complete clear). + if (sessionId) { + clearAllPrompts(sessionId) } if (looksLikeProviderSetup) { diff --git a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx index a615b9c4ffcd..b3dfff2e9286 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx @@ -2,7 +2,8 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { $approvalRequest } from '@/store/prompts' +import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' +import { $activeSessionId } from '@/store/session' import { $toolDisclosureStates } from '@/store/tool-view' import { Thread } from './thread' @@ -120,13 +121,15 @@ function GroupHarness({ message }: { message: ThreadMessage }) { } beforeEach(() => { - $approvalRequest.set(null) + clearAllPrompts() + $activeSessionId.set('sess-1') $toolDisclosureStates.set({}) }) afterEach(() => { cleanup() - $approvalRequest.set(null) + clearAllPrompts() + $activeSessionId.set(null) }) describe('ToolGroupSlot approval surfacing', () => { @@ -143,7 +146,7 @@ describe('ToolGroupSlot approval surfacing', () => { }) it('force-opens the group body so the approval surfaces without expanding', async () => { - $approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) + setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) const { container } = render() diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx index 3574ad0da66c..fb6c71f6b308 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx @@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { HermesGateway } from '@/hermes' import { $gateway } from '@/store/gateway' -import { $approvalRequest } from '@/store/prompts' +import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts' +import { $activeSessionId } from '@/store/session' import { PendingToolApproval } from './tool-approval' import type { ToolPart } from './tool-fallback-model' @@ -13,7 +14,8 @@ function part(toolName: string): ToolPart { } function setRequest(command = 'rm -rf /tmp/x') { - $approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' }) + $activeSessionId.set('sess-1') + setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' }) } function mockGateway() { @@ -25,7 +27,8 @@ function mockGateway() { afterEach(() => { cleanup() - $approvalRequest.set(null) + clearAllPrompts() + $activeSessionId.set(null) $gateway.set(null) }) diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx index 999a994e9e57..ad3a1cdbe33d 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx @@ -81,7 +81,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => { session_id: request.sessionId ?? undefined }) triggerHaptic(choice === 'deny' ? 'cancel' : 'submit') - clearApprovalRequest() + clearApprovalRequest(request.sessionId) } catch (error) { notifyError(error, 'Could not send approval response') setSubmitting(null) diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index 69b7cae4e32d..551bbf047ea6 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -64,7 +64,7 @@ function SudoDialog() { request_id: request.requestId }) triggerHaptic('submit') - clearSudoRequest(request.requestId) + clearSudoRequest(request.sessionId, request.requestId) } catch (error) { notifyError(error, 'Could not send sudo password') setSubmitting(false) @@ -163,7 +163,7 @@ function SecretDialog() { value: secret }) triggerHaptic('submit') - clearSecretRequest(request.requestId) + clearSecretRequest(request.sessionId, request.requestId) } catch (error) { notifyError(error, 'Could not send secret') setSubmitting(false) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 44cf0de0a061..8cc8efedd4a2 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -1,16 +1,290 @@ +import type { ConnectionState, GatewayEvent } from '@hermes/shared' import { atom } from 'nanostores' -import type { HermesGateway } from '@/hermes' +import { HermesGateway } from '@/hermes' +import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' +import { setGatewayState } from '@/store/session' + +// ── Multi-profile gateway routing ────────────────────────────────────────── +// Concurrent sessions across profiles need concurrent sockets: the renderer's +// event handler is already session-keyed, so the only thing stopping two +// profiles streaming at once was the single swapping socket. We keep that one +// socket as the PRIMARY (window) backend — owned by use-gateway-boot, with all +// its boot-progress / sleep-wake machinery — and add one persistent SECONDARY +// socket per *other* profile that has live work. Every socket feeds the same +// handleGatewayEvent, so background sessions keep painting. Single-profile users +// only ever have the primary, so their path is byte-for-byte unchanged. + +const normKey = (profile: string | null | undefined): string => (profile ?? '').trim() || 'default' + +// Read connection state through a call so TS control-flow analysis doesn't +// narrow the getter to a constant across guards (it genuinely changes). +const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open' // The active gateway instance, exposed for inline message-stream components -// (e.g. inline ClarifyTool) that need to call gateway methods without having -// the instance threaded down through props from `ChatView`. +// (e.g. inline ClarifyTool, model overlays) that call gateway methods without +// the instance threaded down through props. export const $gateway = atom(null) -export function setGateway(gateway: HermesGateway | null): void { - if ($gateway.get() === gateway) { +interface RegistryConfig { + onEvent: (event: GatewayEvent) => void +} + +let config: RegistryConfig | null = null + +export function configureGatewayRegistry(cfg: RegistryConfig): void { + config = cfg +} + +// ── Primary (window) backend ─────────────────────────────────────────────── +let primaryGateway: HermesGateway | null = null +let primaryProfile = 'default' + +export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void { + primaryGateway = gateway + primaryProfile = normKey(profile) +} + +// ── Secondary (pool) backends ────────────────────────────────────────────── +interface Secondary { + profile: string + gateway: HermesGateway + offEvent: () => void + offState: () => void + reconnectTimer: ReturnType | null + reconnectAttempt: number + reconnecting: boolean + // While true the entry auto-reconnects on drop; pruning flips it off so a + // deliberate close doesn't trigger the backoff loop. + wantOpen: boolean +} + +const secondaries = new Map() + +let activeKey = 'default' + +export function isActivePrimary(): boolean { + return activeKey === primaryProfile +} + +export function activeGateway(): HermesGateway | null { + if (activeKey === primaryProfile) { + return primaryGateway + } + + return secondaries.get(activeKey)?.gateway ?? primaryGateway +} + +// Mirror a backend's connection state into the global composer state, but only +// when that backend is the one the user is currently looking at. Lets the +// composer reflect the active profile's socket without a background reconnect +// flipping the foreground enabled/disabled state. +function reportGatewayState(profile: string, state: ConnectionState): void { + if (normKey(profile) === activeKey) { + setGatewayState(state) + } +} + +export function reportPrimaryGatewayState(state: ConnectionState): void { + reportGatewayState(primaryProfile, state) +} + +function setActive(profile: string): void { + activeKey = normKey(profile) + const gateway = activeGateway() + $gateway.set(gateway) + setGatewayState(gateway?.connectionState ?? 'closed') +} + +function clearTimer(entry: Secondary): void { + if (entry.reconnectTimer !== null) { + clearTimeout(entry.reconnectTimer) + entry.reconnectTimer = null + } +} + +async function openSecondary(entry: Secondary): Promise { + const desktop = window.hermesDesktop + + if (!desktop) { return } - $gateway.set(gateway) + const conn = await desktop.getConnection(entry.profile) + const wsUrl = await resolveGatewayWsUrl(desktop, conn) + await entry.gateway.connect(wsUrl) + void desktop.touchBackend?.(entry.profile).catch(() => undefined) +} + +function scheduleReconnect(entry: Secondary): void { + if (entry.reconnecting || entry.reconnectTimer !== null || !entry.wantOpen) { + return + } + + // 1s, 2s, 4s … capped at 15s — same backoff shape as the primary. + const delay = Math.min(15_000, 1_000 * 2 ** Math.min(entry.reconnectAttempt, 4)) + entry.reconnectAttempt += 1 + entry.reconnectTimer = setTimeout(() => { + entry.reconnectTimer = null + void reconnectSecondary(entry) + }, delay) +} + +async function reconnectSecondary(entry: Secondary): Promise { + if (entry.reconnecting || !entry.wantOpen || isOpen(entry.gateway)) { + return + } + + entry.reconnecting = true + + try { + await openSecondary(entry) + entry.reconnectAttempt = 0 + } catch { + // Transport failure → fall through to the backoff below. + } finally { + entry.reconnecting = false + + if (entry.wantOpen && !isOpen(entry.gateway)) { + scheduleReconnect(entry) + } + } +} + +function createSecondary(profile: string): Secondary { + const gateway = new HermesGateway() + + const entry: Secondary = { + profile, + gateway, + offEvent: () => {}, + offState: () => {}, + reconnectTimer: null, + reconnectAttempt: 0, + reconnecting: false, + wantOpen: true + } + + entry.offEvent = gateway.onEvent(event => config?.onEvent(event)) + entry.offState = gateway.onState(state => { + reportGatewayState(profile, state) + + if (state === 'open') { + entry.reconnectAttempt = 0 + clearTimer(entry) + } else if ((state === 'closed' || state === 'error') && entry.wantOpen) { + scheduleReconnect(entry) + } + }) + + secondaries.set(profile, entry) + + return entry +} + +// Make `profile` the active gateway, lazily opening its socket if needed. The +// primary is a no-op fast path. Background sockets are never closed here. +export async function ensureGatewayForProfile(profile: string): Promise { + const key = normKey(profile) + + if (key === primaryProfile) { + setActive(key) + + return + } + + let entry = secondaries.get(key) + + if (!entry) { + entry = createSecondary(key) + } + + entry.wantOpen = true + + if (!isOpen(entry.gateway)) { + clearTimer(entry) + entry.reconnectAttempt = 0 + + try { + await openSecondary(entry) + } catch { + scheduleReconnect(entry) + } + } + + setActive(key) +} + +// Reconnect the active gateway after a transient request failure. Primary +// reconnects are owned by use-gateway-boot, so we only drive secondaries here. +export async function ensureActiveGatewayOpen(): Promise { + if (activeKey === primaryProfile) { + return primaryGateway + } + + const entry = secondaries.get(activeKey) + + if (!entry) { + return null + } + + if (!isOpen(entry.gateway)) { + await reconnectSecondary(entry) + } + + return isOpen(entry.gateway) ? entry.gateway : null +} + +// Wake signal (sleep/network/visibility): nudge every live secondary back open. +export function reconnectSecondaryGateways(): void { + for (const entry of secondaries.values()) { + if (!entry.wantOpen || isOpen(entry.gateway)) { + continue + } + + entry.reconnectAttempt = 0 + clearTimer(entry) + void reconnectSecondary(entry) + } +} + +// Keep the idle reaper from killing a backend we still need: ping every live +// secondary. The active one is pinged separately (touchActiveGatewayBackend). +export function touchSecondaryGateways(): void { + const desktop = window.hermesDesktop + + for (const entry of secondaries.values()) { + if (entry.wantOpen) { + void desktop?.touchBackend?.(entry.profile).catch(() => undefined) + } + } +} + +// Close + evict secondaries whose profile is neither active nor in `keep` +// (profiles with a running / needs-input session). Bounds cost to live work. +export function pruneSecondaryGateways(keep: Set): void { + for (const [key, entry] of [...secondaries]) { + if (key === activeKey || keep.has(key)) { + continue + } + + entry.wantOpen = false + clearTimer(entry) + entry.offEvent() + entry.offState() + entry.gateway.close() + secondaries.delete(key) + } +} + +export function closeSecondaryGateways(): void { + for (const entry of secondaries.values()) { + entry.wantOpen = false + clearTimer(entry) + entry.offEvent() + entry.offState() + entry.gateway.close() + } + + secondaries.clear() } diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index f89d0dae1f5a..e9342109d372 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -1,7 +1,6 @@ import { atom, computed } from 'nanostores' import { getProfiles, setApiRequestProfile } from '@/hermes' -import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { queryClient } from '@/lib/query-client' import { arraysEqual, @@ -12,8 +11,7 @@ import { storedStringArray, storedStringRecord } from '@/lib/storage' -import { $gateway } from '@/store/gateway' -import { setConnection } from '@/store/session' +import { $gateway, ensureGatewayForProfile } from '@/store/gateway' import type { ProfileInfo } from '@/types/hermes' // Canonical key for a profile: trimmed, empty → "default". Used everywhere we @@ -180,10 +178,11 @@ export const $gatewaySwapTarget = atom(null) let gatewaySwitch: Promise | null = null -// Reconnect the single live gateway to `profile`'s backend if it isn't already -// there. A null/empty target means "no explicit profile" → keep the gateway on -// whatever profile it's currently on (so a plain new chat stays put and a -// single-profile user never swaps). No-op fast path when already on target. +// Make `profile`'s backend the active gateway, lazily opening its socket if it +// isn't live yet. Unlike the old single-socket swap, background profiles keep +// their sockets — so their sessions keep streaming concurrently. A null/empty +// target means "no explicit profile" → keep the current gateway (a plain new +// chat stays put; single-profile users never leave the primary). export async function ensureGatewayProfile(profile: string | null | undefined): Promise { if (profile == null || !String(profile).trim()) { // "No explicit profile" = use the current gateway. But if an explicit swap @@ -199,44 +198,26 @@ export async function ensureGatewayProfile(profile: string | null | undefined): const target = normalizeProfileKey(profile) - if (normalizeProfileKey($activeGatewayProfile.get()) === target) { + if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()) { return } - // Serialize concurrent swaps so two rapid session switches don't fight over - // the single socket. + // Serialize concurrent activations so two rapid session switches don't race + // the active pointer. if (gatewaySwitch) { await gatewaySwitch.catch(() => undefined) - if (normalizeProfileKey($activeGatewayProfile.get()) === target) { + if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()) { return } } $gatewaySwapTarget.set(target) gatewaySwitch = (async () => { - const desktop = window.hermesDesktop - const gateway = $gateway.get() - - if (!desktop || !gateway) { - return - } - - // getConnection lazily spawns/reuses the profile's pool backend (or returns - // the primary when target is the primary's profile). - const conn = await desktop.getConnection(target) - setConnection(conn) - const wsUrl = await resolveGatewayWsUrl(desktop, conn) - // The single socket is still OPEN to the *previous* profile's backend, and - // gateway.connect() no-ops on an already-open socket. Drop it first so the - // reconnect actually re-points at the target profile's backend — otherwise - // the swap silently stays on the old backend (session.create writes to the - // wrong profile's DB). close() nulls the socket without emitting a 'closed' - // state, so it doesn't trip the boot auto-reconnect. - gateway.close() - await gateway.connect(wsUrl) + // ensureGatewayForProfile opens (or reuses) the target's socket and points + // the active gateway at it — without closing the profile you came from. + await ensureGatewayForProfile(target) $activeGatewayProfile.set(target) - void desktop.touchBackend?.(target).catch(() => undefined) })() try { @@ -290,6 +271,19 @@ export function selectProfile(name: string): void { void ensureGatewayProfile(target) } +// Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse +// view. Unlike selectProfile, it leaves $showAllProfiles untouched, so the +// unified sidebar stays put — used by the per-profile "+" in the all-profiles +// session list, where switching scope would throw away the browse state the user +// is in. Points new chats at the profile and opens its backend so the next +// message lands in the right place. +export function newSessionInProfile(name: string): void { + const target = normalizeProfileKey(name) + $newChatProfile.set(target) + requestFreshSession() + void ensureGatewayProfile(target) +} + export function setShowAllProfiles(value: boolean): void { $showAllProfiles.set(value) } diff --git a/apps/desktop/src/store/prompts.test.ts b/apps/desktop/src/store/prompts.test.ts index 3ed8ade8ad53..7e454639acea 100644 --- a/apps/desktop/src/store/prompts.test.ts +++ b/apps/desktop/src/store/prompts.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $approvalRequest, @@ -12,13 +12,21 @@ import { setSecretRequest, setSudoRequest } from './prompts' +import { $activeSessionId } from './session' + +// Prompts are parked per-session; the exported $*Request views are scoped to the +// active session, so each test focuses the session it's asserting on. +beforeEach(() => { + $activeSessionId.set('s1') +}) afterEach(() => { clearAllPrompts() + $activeSessionId.set(null) }) describe('approval prompt store', () => { - it('holds the most recent session-keyed approval request', () => { + it('holds the active session-keyed approval request', () => { setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'recursive delete', sessionId: 's1' }) expect($approvalRequest.get()).toEqual({ @@ -28,9 +36,20 @@ describe('approval prompt store', () => { }) }) - it('clears unconditionally (approval is session-keyed, no request id)', () => { + it('parks a background session prompt out of the active view', () => { + setApprovalRequest({ command: 'x', description: 'd', sessionId: 's2' }) + + // Not visible while s1 is focused … + expect($approvalRequest.get()).toBeNull() + + // … but surfaces once the user switches to the session that raised it. + $activeSessionId.set('s2') + expect($approvalRequest.get()?.sessionId).toBe('s2') + }) + + it('clears the active session prompt', () => { setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' }) - clearApprovalRequest() + clearApprovalRequest('s1') expect($approvalRequest.get()).toBeNull() }) @@ -38,21 +57,21 @@ describe('approval prompt store', () => { describe('sudo prompt store', () => { it('clears only when the request id matches the in-flight prompt', () => { - setSudoRequest({ requestId: 'abc' }) + setSudoRequest({ requestId: 'abc', sessionId: 's1' }) // A stale clear for a different request must NOT drop the live prompt — // otherwise a late response to a prior sudo ask would dismiss the current // one and leave the agent blocked. - clearSudoRequest('stale') - expect($sudoRequest.get()).toEqual({ requestId: 'abc' }) + clearSudoRequest('s1', 'stale') + expect($sudoRequest.get()).toEqual({ requestId: 'abc', sessionId: 's1' }) - clearSudoRequest('abc') + clearSudoRequest('s1', 'abc') expect($sudoRequest.get()).toBeNull() }) it('clears unconditionally when no request id is given', () => { - setSudoRequest({ requestId: 'abc' }) - clearSudoRequest() + setSudoRequest({ requestId: 'abc', sessionId: 's1' }) + clearSudoRequest('s1') expect($sudoRequest.get()).toBeNull() }) @@ -60,32 +79,43 @@ describe('sudo prompt store', () => { describe('secret prompt store', () => { it('carries env var and prompt, and clears on id match', () => { - setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key' }) + setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key', sessionId: 's1' }) expect($secretRequest.get()).toEqual({ requestId: 'r1', envVar: 'OPENAI_API_KEY', - prompt: 'Paste your key' + prompt: 'Paste your key', + sessionId: 's1' }) - clearSecretRequest('mismatch') + clearSecretRequest('s1', 'mismatch') expect($secretRequest.get()).not.toBeNull() - clearSecretRequest('r1') + clearSecretRequest('s1', 'r1') expect($secretRequest.get()).toBeNull() }) }) describe('clearAllPrompts', () => { - it('drops every in-flight prompt at once (turn end / interrupt)', () => { + it('drops every kind for one session at once (turn end / interrupt)', () => { setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' }) - setSudoRequest({ requestId: 'abc' }) - setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p' }) + setSudoRequest({ requestId: 'abc', sessionId: 's1' }) + setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p', sessionId: 's1' }) - clearAllPrompts() + clearAllPrompts('s1') expect($approvalRequest.get()).toBeNull() expect($sudoRequest.get()).toBeNull() expect($secretRequest.get()).toBeNull() }) + + it('leaves other sessions parked prompts intact', () => { + setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' }) + setApprovalRequest({ command: 'y', description: 'e', sessionId: 's2' }) + + clearAllPrompts('s1') + + $activeSessionId.set('s2') + expect($approvalRequest.get()?.command).toBe('y') + }) }) diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index 11ba3a0bb6c2..76780a3b6dc3 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -1,86 +1,115 @@ -import { atom } from 'nanostores' +import { atom, computed, type ReadableAtom } from 'nanostores' + +import { $activeSessionId } from './session' // Blocking interactive prompts the gateway raises mid-turn. Each maps to a // `*.request` event the Python side emits while it blocks the agent thread // waiting for a `*.respond` RPC. Without a renderer for these, the agent -// silently stalls until its timeout (default 5 min) and the tool is BLOCKED -// — the desktop app previously handled clarify.request but not these three, -// so dangerous-command approval, sudo, and secret prompts never surfaced. +// silently stalls until its timeout (default 5 min) and the tool is BLOCKED. +// +// Like clarify, every prompt is parked under the runtime session id that raised +// it (not one shared slot), so a *background* session running concurrently can +// raise an approval/sudo/secret prompt and have it wait — surfaced via the +// sidebar "needs input" badge — until the user switches to that chat. The +// exported $*Request view is scoped to the active session, so a background +// prompt never hijacks the foreground. -export interface ApprovalRequest { - command: string - description: string +const keyFor = (sessionId: string | null | undefined): string => sessionId ?? '' + +interface KeyedPrompt { sessionId: string | null } -// Approval is session-keyed on the backend (one in-flight approval per -// session, resolved via approval.respond {choice, session_id}). It carries -// no request_id, unlike sudo/secret which are _block()-style request/response. -export const $approvalRequest = atom(null) - -export function setApprovalRequest(request: ApprovalRequest): void { - $approvalRequest.set(request) +interface PromptStore { + $active: ReadableAtom + clear: (sessionId?: string | null, requestId?: string) => void + reset: () => void + set: (request: T) => void } -export function clearApprovalRequest(): void { - $approvalRequest.set(null) +// One per-session prompt kind: a map keyed by session, plus an active-session +// view for the overlays. `clear` drops one session's entry (a request-id +// mismatch is a no-op so a stale resolve can't wipe a newer prompt); with no +// session hint it drops every entry, optionally filtered by request id. +function keyedPromptStore(): PromptStore { + const $all = atom>({}) + const idOf = (value: T): string | undefined => (value as { requestId?: string }).requestId + + return { + $active: computed([$all, $activeSessionId], (all, activeId) => all[keyFor(activeId)] ?? null), + reset: () => $all.set({}), + set: request => $all.set({ ...$all.get(), [keyFor(request.sessionId)]: request }), + clear(sessionId, requestId) { + const all = $all.get() + + if (sessionId !== undefined) { + const key = keyFor(sessionId) + const current = all[key] + + if (current && !(requestId && idOf(current) !== requestId)) { + const next = { ...all } + delete next[key] + $all.set(next) + } + + return + } + + const next = Object.fromEntries(Object.entries(all).filter(([, v]) => requestId && idOf(v) !== requestId)) + + if (Object.keys(next).length !== Object.keys(all).length) { + $all.set(next as Record) + } + } + } } -export interface SudoRequest { +// Approval is session-keyed on the backend (one in-flight approval per session, +// resolved via approval.respond {choice, session_id}). It carries no request_id, +// unlike sudo/secret which are _block()-style request/response. +export interface ApprovalRequest extends KeyedPrompt { + command: string + description: string +} + +export interface SudoRequest extends KeyedPrompt { requestId: string } -export const $sudoRequest = atom(null) - -export function setSudoRequest(request: SudoRequest): void { - $sudoRequest.set(request) -} - -export function clearSudoRequest(requestId?: string): void { - const current = $sudoRequest.get() - - if (!current) { - return - } - - if (requestId && current.requestId !== requestId) { - return - } - - $sudoRequest.set(null) -} - -export interface SecretRequest { - requestId: string +export interface SecretRequest extends KeyedPrompt { envVar: string prompt: string + requestId: string } -export const $secretRequest = atom(null) +const approval = keyedPromptStore() +const sudo = keyedPromptStore() +const secret = keyedPromptStore() -export function setSecretRequest(request: SecretRequest): void { - $secretRequest.set(request) -} +export const $approvalRequest = approval.$active +export const setApprovalRequest = approval.set +export const clearApprovalRequest = approval.clear -export function clearSecretRequest(requestId?: string): void { - const current = $secretRequest.get() +export const $sudoRequest = sudo.$active +export const setSudoRequest = sudo.set +export const clearSudoRequest = sudo.clear + +export const $secretRequest = secret.$active +export const setSecretRequest = secret.set +export const clearSecretRequest = secret.clear + +// Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds — +// or every parked prompt when no session is given (global reset / tests). +export function clearAllPrompts(sessionId?: string | null): void { + if (sessionId === undefined) { + approval.reset() + sudo.reset() + secret.reset() - if (!current) { return } - if (requestId && current.requestId !== requestId) { - return - } - - $secretRequest.set(null) -} - -// Drop every in-flight prompt. Called when a turn ends (message.complete / -// error) so a stale overlay can't linger past the turn that raised it — e.g. -// if the agent was interrupted while a prompt was open. -export function clearAllPrompts(): void { - $approvalRequest.set(null) - $sudoRequest.set(null) - $secretRequest.set(null) + approval.clear(sessionId) + sudo.clear(sessionId) + secret.clear(sessionId) }