feat(desktop): concurrent multi-profile gateway sockets

Keep one persistent socket per profile with live work instead of closing
the single socket on every profile swap, so background sessions across
profiles keep streaming at once. A gateway registry owns the primary
(window) socket plus lazy secondaries (own backoff/reconnect); all feed
the same session-keyed event handler. Secondaries are pruned to profiles
with a working/needs-input session, the keepalive pings every open
backend, and LRU eviction spares freshly-touched backends so the soft cap
can't abort a running agent. Approval/sudo/secret prompts are parked
per-session (surfaced via the needs-input badge) so a background turn can
block without hijacking the foreground. Single-profile users only ever
have the primary, so their path is unchanged.
This commit is contained in:
Brooklyn Nicholson
2026-06-04 20:44:19 -05:00
parent 89baf02919
commit 4891f9ae78
12 changed files with 586 additions and 174 deletions
@@ -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(<GroupHarness message={groupedPendingMessage()} />)
@@ -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)
})
@@ -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)
@@ -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)