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:
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<string>()
|
||||
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<HermesGateway | null>(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<typeof setTimeout> | 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<string, Secondary>()
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<HermesGateway | null> {
|
||||
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<string>): 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()
|
||||
}
|
||||
|
||||
@ -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<string | null>(null)
|
||||
|
||||
let gatewaySwitch: Promise<void> | 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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<ApprovalRequest | null>(null)
|
||||
|
||||
export function setApprovalRequest(request: ApprovalRequest): void {
|
||||
$approvalRequest.set(request)
|
||||
interface PromptStore<T extends KeyedPrompt> {
|
||||
$active: ReadableAtom<null | T>
|
||||
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<T extends KeyedPrompt>(): PromptStore<T> {
|
||||
const $all = atom<Record<string, T>>({})
|
||||
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<string, T>)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<SudoRequest | null>(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<SecretRequest | null>(null)
|
||||
const approval = keyedPromptStore<ApprovalRequest>()
|
||||
const sudo = keyedPromptStore<SudoRequest>()
|
||||
const secret = keyedPromptStore<SecretRequest>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user