Desktop session approval status indicators and notifications

NousResearch/hermes-agent · cluster 86565 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #86772. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #86772. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items address how blocked/approval-waiting sessions are surfaced to users: fixing incorrect status dots, adding named OS notifications and sidebar badges for sessions needing input, and updating gateway to correctly report approval-blocked sessions as waiting. One item covers a gateway JSON-RPC transport change for server-to-client requests.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #86772 fix(desktop): unread-dot read-state overhaul — correct clears, restart persistence, manual mark-unread (salvages #74162 #74293 #76504 #82824 #84822)

    merged to main

    files: apps/desktop/e2e/unread-dot-restart.spec.ts, apps/desktop/src/app/chat/sidebar/index.tsx, apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx, apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx, apps/desktop/src/app/chat/sidebar/session-row.test.tsx, apps/desktop/src/app/chat/sidebar/session-row.tsx, apps/desktop/src/app/chat/sidebar/sessions-section.test.tsx, apps/desktop/src/app/chat/sidebar/sessions-section.tsx, apps/desktop/src/app/chat/sidebar/virtual-session-list.test.tsx, apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx, apps/desktop/src/app/contrib/controller.tsx, apps/desktop/src/app/open-session.ts, apps/desktop/src/app/session/hooks/use-session-actions/index.ts, apps/desktop/src/app/settings/sessions-settings.tsx, apps/desktop/src/hermes.ts, apps/desktop/src/i18n/ar.ts, apps/desktop/src/i18n/en.ts, apps/desktop/src/i18n/ja.ts, apps/desktop/src/i18n/types.ts, apps/desktop/src/i18n/zh-hant.ts (+23 more)

  • #110677 feat(gateway): server→client JSON-RPC requests replace the *.request/*.respond event pairs (#110521)

    merged to main

    files: apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx, apps/desktop/src/app/contrib/wiring.tsx, apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx, apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts, apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx, apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.test.ts, apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts, apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts, apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts, apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts, apps/desktop/src/app/session/hooks/use-message-stream/index.ts, apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx, apps/desktop/src/app/session/hooks/use-session-actions.test.tsx, apps/desktop/src/app/session/hooks/use-session-actions/index.ts, apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.test.ts, apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts, apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx, apps/desktop/src/components/assistant-ui/clarify-tool.tsx, apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx, apps/desktop/src/components/assistant-ui/tool/approval.test.tsx (+66 more)

    Show the diff
    • apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx
      @@ -6,7 +6,6 @@ import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
       import { $clarifyRequests } from '@/store/clarify'
       import type { ComposerAttachment } from '@/store/composer'
       import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue'
      -import { $gateway } from '@/store/gateway'
       import {
         clearAllPrompts,
         hasBlockingPromptRequest,
      @@ -14,6 +13,7 @@ import {
         setSecretRequest,
         setSudoRequest
       } from '@/store/prompts'
      +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
       
       import { type ComposerTarget, requestComposerSubmit } from '../focus'
       import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'
      @@ -457,26 +457,30 @@ describe('useComposerSubmit busy-turn routing', () => {
       })
       
       describe('useComposerSubmit with a clarify parked on the session', () => {
      -  const gatewayRequest = vi.fn(async () => ({ ok: true }))
      +  // The clarify is a live server→client request: skipping it answers that
      +  // request frame (`{ answer: '' }`), not a `clarify.respond` RPC.
      +  const respond = vi.fn()
       
         const parkClarify = (sessionId: string) => {
      +    const requestId = `req-${sessionId}`
      +
      +    rememberServerRequest({ fail: vi.fn(), id: requestId, method: 'clarify', params: {}, respond })
           $clarifyRequests.set({
             [sessionId]: {
      -        requestId: `req-${sessionId}`,
      +        requestId,
               question: 'which one?',
               choices: ['a', 'b'],
               multiSelect: false,
               sessionId
             }
           })
      -    $gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)
         }
       
         afterEach(() => {
           cleanup()
      -    gatewayRequest.mockClear()
      +    respond.mockClear()
      +    resetServerRequestsForTests()
           $clarifyRequests.set({})
      -    $gateway.set(null)
           vi.restoreAllMocks()
         })
       
      @@ -488,16 +492,12 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
             hook.result.current.submitDraft()
           })
       
      -    await waitFor(() =>
      -      expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {
      -        request_id: 'req-runtime-session',
      -        answer: ''
      -      })
      -    )
      +    await waitFor(() => expect(respond).toHaveBeenCalledWith({ answer: '' }))
           await waitFor(() =>
             expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))
           )
           expect($clarifyRequests.get()['runtime-session']).toBeUndefined()
      +    expect(hasOpenServerRequest('req-runtime-session')).toBe(false)
         })
       
         it('skips the question before steering a busy turn', async () => {
      @@ -509,7 +509,7 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
           })
       
           await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
      -    expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })
      +    expect(respond).toHaveBeenCalledWith({ answer: '' })
         })
       
         it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {
      @@ -520,7 +520,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
             hook.result.current.submitDraft()
           })
       
      -    expect(gatewayRequest).not.toHaveBeenCalled()
      +    expect(respond).not.toHaveBeenCalled()
      +    expect(hasOpenServerRequest('req-runtime-session')).toBe(true)
           expect($clarifyRequests.get()['runtime-session']).toBeDefined()
           expect(onCancel).toHaveBeenCalledTimes(1)
         })
      @@ -534,7 +535,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
           })
       
           await waitFor(() => expect(onSubmit).toHaveBeenCalled())
      -    expect(gatewayRequest).not.toHaveBeenCalled()
      +    expect(respond).not.toHaveBeenCalled()
      +    expect(hasOpenServerRequest('req-other-session')).toBe(true)
           expect($clarifyRequests.get()['other-session']).toBeDefined()
         })
       })
    • apps/desktop/src/app/contrib/wiring.tsx
      @@ -497,7 +497,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
           [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]
         )
       
      -  const { handleGatewayEvent } = useMessageStream({
      +  const { handleGatewayEvent, handleServerRequest } = useMessageStream({
           activeGatewayProfile,
           activeSessionIdRef,
           hydrateFromStoredSession,
      @@ -898,6 +898,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
             closeAllTerminals()
           },
           handleGatewayEvent: handleGatewayEventWithPlugins,
      +    handleServerRequest,
           onConnectionReady: c => {
             connectionRef.current = c
           },
    • apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
      @@ -258,6 +258,7 @@ function Harness({
         useGatewayBoot({
           beforeConnectionSwitch,
           handleGatewayEvent: () => undefined,
      +    handleServerRequest: () => false,
           onConnectionReady: () => undefined,
           onGatewayReady: () => undefined,
           refreshHermesConfig,
    • apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
      @@ -2,6 +2,7 @@ import {
         type GatewayEvent,
         isGatewayReauthRequired,
         isGatewayWebSocketUrl,
      +  JSON_RPC_METHOD_NOT_FOUND,
         JsonRpcGatewayError,
         reconnectBackoffDelayMs,
         resolveGatewayWsUrl
      @@ -31,6 +32,7 @@ import {
         closeLegacySecondaryGateways,
         closeSecondaryGateways,
         configureGatewayRegistry,
      +  dispatchPrimaryServerRequest,
         disposeSecondariesForConnection,
         ensureActiveGatewayOpen,
         ensureGatewayForProfile,
      @@ -40,6 +42,7 @@ import {
         pruneSecondaryGateways,
         reconnectSecondaryGateways,
         reportPrimaryGatewayState,
      +  type ScopedServerRequest,
         setPrimaryGateway,
         setPrimaryGatewayConnection,
         touchSecondaryGateways
      @@ -148,6 +151,8 @@ export function primaryRuntimeConnectionId(connection: Pick<HermesConnection, 'c
       interface GatewayBootOptions {
         beforeConnectionSwitch: () => void
         handleGatewayEvent: (event: GatewayEvent) => void
      +  /** Server→client request from any registry socket; false = no handler (the channel answers -32601). */
      +  handleServerRequest: (request: ScopedServerRequest) => boolean
         onConnectionReady: (
           connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
         ) => void
      @@ -159,6 +164,7 @@ interface GatewayBootOptions {
       export function useGatewayBoot({
         beforeConnectionSwitch,
         handleGatewayEvent,
      +  handleServerRequest,
         onConnectionReady,
         onGatewayReady,
         refreshHermesConfig,
      @@ -167,6 +173,7 @@ export function useGatewayBoot({
         const callbacksRef = useRef({
           beforeConnectionSwitch,
           handleGatewayEvent,
      +    handleServerRequest,
           onConnectionReady,
           onGatewayReady,
           refreshHermesConfig,
      @@ -176,6 +183,7 @@ export function useGatewayBoot({
         callbacksRef.current = {
           beforeConnectionSwitch,
           handleGatewayEvent,
      +    handleServerRequest,
           onConnectionReady,
           onGatewayReady,
           refreshHermesConfig,
      @@ -805,6 +813,11 @@ export function useGatewayBoot({
           // (connectionId, profile) keep-set so two sources exposing the same
           // profile name (every source has a 'default') can't collide.
           configureGatewayRegistry({
      +      onServerRequest: request => {
      +        if (!callbacksRef.current.handleServerRequest(request)) {
      +          request.fail(JSON_RPC_METHOD_NOT_FOUND, `Hermes Desktop cannot answer ${request.method}`)
      +        }
      +      },
             // The primary socket has no secondary entry to carry registry identity.
             // Electron's published active descriptor is authoritative after boot;
             // a true legacy primary has no connectionId and remains unqualified.
      @@ -910,6 +923,9 @@ export function useGatewayBoot({
             callbacksRef.current.handleGatewayEvent(scopedEvent)
           })
       
      +    // Secondary sockets reach the same handler through the registry's onServerRequest.
      +    const offRequest = gateway.onRequest(request => dispatchPrimaryServerRequest(request, sourceProfile))
      +
           // Wake signals: power resume (macOS/Windows), network coming back, and the
           // window regaining focus/visibility. Each nudges an immediate reconnect.
           const forceReconnectNow = () => reconnectNow({ forceOpenSocket: true })
      @@ -1274,6 +1290,7 @@ export function useGatewayBoot({
             offActiveStateReauth()
             offState()
             offEvent()
      +      offRequest()
             offExit()
             offWindowState?.()
             offBootProgress()
    • apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx
      @@ -8,7 +8,7 @@ import { onScrollToBottomRequest } from '@/store/thread-scroll'
       
       import { type MessageStreamHarness, renderMessageStream } from './test-harness'
       
      -// A `clarify.request` must leave an answerable inline row even when the
      +// A `clarify` server request must leave an answerable inline row even when the
       // `tool.start` that normally mounts it was missed (stream reconnect /
       // hydration race). Without it the sidebar says "needs input" but the
       // transcript has nowhere to render the choices, so the agent blocks forever.
      @@ -24,8 +24,8 @@ function mountStream() {
         stream = renderMessageStream(SID)
       }
       
      -const clarifyRequest = (payload: Record<string, unknown>) =>
      -  act(() => stream.handleEvent({ payload, session_id: SID, type: 'clarify.request' }))
      +const clarifyRequest = ({ request_id, ...params }: Record<string, unknown>) =>
      +  act(() => void stream.handleRequest('clarify', { ...params, session_id: SID }, request_id as string))
       
       const toolStart = (payload: Record<string, unknown>) =>
         act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.start' }))
      @@ -34,7 +34,13 @@ const toolComplete = (payload: Record<string, unknown>) =>
         act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.complete' }))
       
       const clarifyExpire = (requestId: string) =>
      -  act(() => stream.handleEvent({ payload: { request_id: requestId }, session_id: SID, type: 'clarify.expire' }))
      +  act(() =>
      +    stream.handleEvent({
      +      payload: { id: requestId, method: 'clarify', reason: 'timeout' },
      +      session_id: SID,
      +      type: 'request.cancel'
      +    })
      +  )
       
       function clarifyParts() {
         const messages = stream.state().messages ?? []
      @@ -49,7 +55,7 @@ function seedHydratedMessages(messages: ChatMessage[]) {
         stream.states.set(SID, state)
       }
       
      -describe('clarify.request stream hydration', () => {
      +describe('clarify request stream hydration', () => {
         beforeEach(() => {
           clearClarifyRequest()
           scrollToBottom.mockClear()
      @@ -89,12 +95,13 @@ describe('clarify.request stream hydration', () => {
         it('does not move the active thread for a background session clarify', () => {
           mountStream()
       
      -    act(() =>
      -      stream.handleEvent({
      -        payload: { choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-background' },
      -        session_id: 'session-background',
      -        type: 'clarify.request'
      -      })
      +    act(
      +      () =>
      +        void stream.handleRequest(
      +          'clarify',
      +          { choices: ['yes', 'no'], question: 'Ship it?', session_id: 'session-background' },
      +          'req-background'
      +        )
           )
       
           expect(scrollToBottom).not.toHaveBeenCalled()
      @@ -129,7 +136,7 @@ describe('clarify.request stream hydration', () => {
         it('merges with the real tool.start row even though its id differs from the request id', () => {
           mountStream()
       
      -    // Reality: tool.start carries the model's tool_call_id, clarify.request a
      +    // Reality: tool.start carries the model's tool_call_id, the clarify request a
           // separately-generated request_id. They must still collapse to ONE card
           // (correlated by question), not two.
           toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' })
    • /dev/null
      @@ -1,95 +0,0 @@
      -import { afterEach, describe, expect, it, vi } from 'vitest'
      -
      -import { $gateway } from '@/store/gateway'
      -import { $toursEnabled } from '@/store/tours'
      -
      -import { handleDesktopBridgeEvent } from './desktop-bridge'
      -import type { GatewayEventContext } from './types'
      -
      -function previewActContext({
      -  explicitSid,
      -  isActiveEvent
      -}: {
      -  explicitSid: string
      -  isActiveEvent: boolean
      -}): GatewayEventContext {
      -  return {
      -    event: { session_id: explicitSid || undefined, type: 'preview.act.request' },
      -    explicitSid,
      -    isActiveEvent,
      -    payload: { action: 'elements', request_id: 'request-1' }
      -  } as GatewayEventContext
      -}
      -
      -describe('preview action bridge routing', () => {
      -  afterEach(() => {
      -    $gateway.set(null)
      -  })
      -
      -  it('leaves a scoped action request unanswered in a window showing another session', () => {
      -    const request = vi.fn()
      -    $gateway.set({ request } as never)
      -
      -    expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true)
      -    expect(request).not.toHaveBeenCalled()
      -  })
      -
      -  it('keeps the legacy fail-fast response for an unscoped inactive request', () => {
      -    const request = vi.fn()
      -    $gateway.set({ request } as never)
      -
      -    expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: '', isActiveEvent: false }))).toBe(true)
      -    expect(request).toHaveBeenCalledWith('preview.act.respond', {
      -      request_id: 'request-1',
      -      text: JSON.stringify({
      -        error: 'The in-app browser only takes actions in the session the user is looking at.',
      -        success: false
      -      })
      -    })
      -  })
      -})
      -
      -function tourContext({
      -  explicitSid,
      -  isActiveEvent
      -}: {
      -  explicitSid: string
      -  isActiveEvent: boolean
      -}): GatewayEventContext {
      -  return {
      -    event: { session_id: explicitSid || undefined, type: 'tour.request' },
      -    explicitSid,
      -    isActiveEvent,
      -    payload: { action: 'discover', request_id: 'tour-request-1' }
      -  } as GatewayEventContext
      -}
      -
      -describe('tour bridge routing', () => {
      -  afterEach(() => {
      -    $gateway.set(null)
      -    $toursEnabled.set(true)
      -  })
      -
      -  it('leaves a scoped request unanswered in another session even when tours are disabled', () => {
      -    const request = vi.fn()
      -    $gateway.set({ request } as never)
      -    $toursEnabled.set(false)
      -
      -    expect(handleDesktopBridgeEvent(tourContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true)
      -    expect(request).not.toHaveBeenCalled()
      -  })
      -
      -  it('keeps the legacy fail-fast response for an unscoped inactive request', () => {
      -    const request = vi.fn()
      -    $gateway.set({ request } as never)
      -
      -    expect(handleDesktopBridgeEvent(tourContext({ explicitSid: '', isActiveEvent: false }))).toBe(true)
      -    expect(request).toHaveBeenCalledWith('tour.respond', {
      -      request_id: 'tour-request-1',
      -      text: JSON.stringify({
      -        error: 'Tours only run in the session the user is looking at.',
      -        success: false
      -      })
      -    })
      -  })
      -})
    • apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts
      @@ -1,165 +1,17 @@
      -import { readActivePreview } from '@/app/chat/right-rail/preview-reader'
       import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
      -import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
       import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
      -import type { PreviewActAction } from '@/lib/preview-act/act-in-page'
      -import type { TourAction, TourStep } from '@/lib/tour'
      -import { $gateway } from '@/store/gateway'
       import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus'
       import { recordAgentReaction } from '@/store/reactions-local'
       import { setMessages } from '@/store/session'
       import { $tipsEnabled, type ActiveTip, showTip } from '@/store/tips'
      -import { $toursEnabled } from '@/store/tours'
       
       import type { GatewayEventContext } from './types'
       
      -/** The preview engine, loaded on demand so ~25KB of page-injectable source stays
      - *  off the boot path.
      - *
      - *  In dev that lazy chunk is also a trap. The browser caches a dynamic import by
      - *  URL for the life of the page, so this bridge would hand every action to
      - *  whichever build of the engine loaded first, and no edit to it — or to the
      - *  overlay whose source it stringifies into the page — would reach the guest
      - *  until the whole window reloaded. Asking for a fresh copy is more reliable
      - *  than trusting hot-update propagation to reach a module nothing statically
      - *  imports; the dev server stamps the dependency URLs it has invalidated, so a
      - *  fresh engine pulls a fresh overlay down with it.
      - *
      - *  The literal path is what a bare specifier can't be here, and it has to track
      - *  this module's real location — hence the fall back to the static import, which
      - *  is also the only branch production keeps, `import.meta.hot` being stripped
      - *  there along with everything it guards. */
      -const loadPreviewEngine = () => {
      -  const stable = () => import('@/app/chat/right-rail/preview-act')
      -
      -  if (!import.meta.hot) {
      -    return stable().then(mod => mod.actOnActivePreview)
      -  }
      -
      -  return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now())
      -    .catch(stable)
      -    .then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview'])
      -}
      -
      -/** Desktop-surface bridge events: read-back requests the agent blocks on
      - *  (terminal/preview/window), agent terminal streaming, pane reveal, and
      - *  message reactions. */
      +/** Desktop-surface bridge events: agent terminal streaming, tips, pane
      + *  reveal, layouts and message reactions. The read-back REQUESTS the agent
      + *  blocks on (terminal/preview/window/tour) live in `server-requests.ts`. */
       export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {
      -  const { event, payload, explicitSid, isActiveEvent } = ctx
      -
      -  if (event.type === 'terminal.read.request') {
      -    // read_terminal tool: serialize the renderer's xterm buffer and answer
      -    // immediately (Python blocks on the respond). Empty text = no live pane.
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      const start = typeof payload?.start === 'number' ? payload.start : undefined
      -      const count = typeof payload?.count === 'number' ? payload.count : undefined
      -      const result = readActiveTerminal({ start, count })
      -
      -      void $gateway.get()?.request('terminal.read.respond', {
      -        request_id: requestId,
      -        text: result ? JSON.stringify(result) : ''
      -      })
      -    }
      -
      -    return true
      -  }
      -
      -  if (event.type === 'preview.read.request') {
      -    // read_preview tool: serialize the active preview tab (a Browser
      -    // webview's page text is async) and answer. Empty text = nothing open.
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      const start = typeof payload?.start === 'number' ? payload.start : undefined
      -      const count = typeof payload?.count === 'number' ? payload.count : undefined
      -
      -      void readActivePreview({ count, start }).then(result => {
      -        void $gateway.get()?.request('preview.read.respond', {
      -          request_id: requestId,
      -          text: result ? JSON.stringify(result) : ''
      -        })
      -      })
      -    }
      -
      -    return true
      -  }
      -
      -  if (event.type === 'preview.act.request') {
      -    // drive_preview tool: click/type/scroll/press inside the guest page, or
      -    // drive the pane's history. Dynamic import keeps the injected engine off
      -    // the boot path. Active session only: a background turn must never reach
      -    // into the page the user is working in (desktop AGENTS.md: offer, don't
      -    // hijack).
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      // Every mounted desktop window can observe the same gateway event. A
      -      // scoped mismatch belongs to another window, so answering here would race
      -      // the owning window and could make this refusal win before its real result.
      -      if (explicitSid && !isActiveEvent) {
      -        return true
      -      }
      -
      -      const answer = (result: unknown) =>
      -        $gateway.get()?.request('preview.act.respond', {
      -          request_id: requestId,
      -          text: result ? JSON.stringify(result) : ''
      -        })
      -
      -      if (isActiveEvent) {
      -        void loadPreviewEngine()
      -          .then(run =>
      -            run({
      -              amount: payload?.amount,
      -              key: payload?.key,
      -              kind: payload?.action ?? '',
      -              max: payload?.max,
      -              ref: payload?.ref,
      -              selector: payload?.selector,
      -              submit: payload?.submit,
      -              text: payload?.text,
      -              to: payload?.to as PreviewActAction['to']
      -            })
      -          )
      -          .then(answer, error =>
      -            answer({ error: error instanceof Error ? error.message : String(error), success: false })
      -          )
      -      } else {
      -        void answer({
      -          error: 'The in-app browser only takes actions in the session the user is looking at.',
      -          success: false
      -        })
      -      }
      -    }
      -
      -    return true
      -  }
      -
      -  if (event.type === 'window.read.request') {
      -    // read_window_below tool: main owns native window enumeration, so ask
      -    // it over IPC and answer. Empty text = unavailable (no bridge, or
      -    // enumeration unsupported on this system e.g. Wayland).
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      const read = window.hermesDesktop?.readWindowBelow
      -
      -      const answer = (result: unknown) =>
      -        $gateway.get()?.request('window.read.respond', {
      -          request_id: requestId,
      -          text: result ? JSON.stringify(result) : ''
      -        })
      -
      -      // .catch: ipcRenderer.invoke rejects on an older shell without the
      -      // handler or a main-side throw — without an empty answer the tool
      -      // would stall its full 30s timeout.
      -      void Promise.resolve(read ? read() : null).then(answer, () => answer(null))
      -    }
      -
      -    return true
      -  }
      +  const { event, payload, isActiveEvent } = ctx
       
         if (event.type === 'agent.terminal.output') {
           // Live chunk from a background process → its read-only agent terminal tab.
      @@ -176,64 +28,6 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {
           return true
         }
       
      -  if (event.type === 'tour.request') {
      -    // tour tool: run one guided-tour action (highlight/step/discover) via
      -    // driver.js — on the app's own DOM or inside the preview pane's guest
      -    // page — and answer with the outcome. Dynamic import keeps driver.js
      -    // and the preview injection payload off the boot path. Active session
      -    // only: a background turn must never paint overlays on the user's
      -    // screen (desktop AGENTS.md: offer, don't hijack).
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      // As with preview actions, only the renderer that owns an explicitly
      -      // scoped request may answer. Inactive windows must stay silent even when
      -      // tours are disabled locally, or their refusal can beat the owner.
      -      if (explicitSid && !isActiveEvent) {
      -        return true
      -      }
      -
      -      const answer = (result: unknown) =>
      -        $gateway.get()?.request('tour.respond', {
      -          request_id: requestId,
      -          text: result ? JSON.stringify(result) : ''
      -        })
      -
      -      if (!$toursEnabled.get()) {
      -        // Refused in words, not silently dropped: the agent asked for a
      -        // walkthrough it isn't getting, and a no-op would leave it narrating
      -        // a spotlight the user can't see.
      -        void answer({ error: 'The user has turned guided tours off.', success: false })
      -      } else if (isActiveEvent) {
      -        void import('@/lib/tour')
      -          .then(({ runTour }) =>
      -            runTour(
      -              {
      -                kind: (payload?.action ?? 'stop') as TourAction['kind'],
      -                selector: payload?.selector,
      -                side: payload?.side as TourStep['side'],
      -                startAt: payload?.step_index,
      -                steps: payload?.steps as TourStep[] | undefined,
      -                text: payload?.text,
      -                title: payload?.title
      -              },
      -              payload?.surface === 'preview' ? 'preview' : 'app'
      -            )
      -          )
      -          .then(answer, error =>
      -            answer({ error: error instanceof Error ? error.message : String(error), success: false })
      -          )
      -      } else {
      -        void answer({
      -          error: 'Tours only run in the session the user is looking at.',
      -          success: false
      -        })
      -      }
      -    }
      -
      -    return true
      -  }
      -
         if (event.type === 'tip.show') {
           // tip tool: point the accent bubble at something and say one line about
           // it. Fire-and-forget — a tip is not a question, and blocking the turn on
    • apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts
      @@ -1,448 +1,89 @@
       import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'
      -import { translateNow } from '@/i18n'
      -import { restorePendingClarifyToolCall, settlePendingClarifyToolCall } from '@/lib/chat-messages'
      -import {
      -  $clarifyRequests,
      -  clearClarifyRequest,
      -  normalizeChoices,
      -  normalizeQuestions,
      -  setClarifyRequest,
      -  warnDroppedChoices
      -} from '@/store/clarify'
      -import { $gateway } from '@/store/gateway'
      -import { setMcpSetupRequest } from '@/store/mcp-setup'
      -import { dispatchNativeNotification } from '@/store/native-notifications'
      +import { settlePendingClarifyToolCall } from '@/lib/chat-messages'
      +import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify'
      +import { $mcpSetupRequests, clearMcpSetupRequest } from '@/store/mcp-setup'
       import {
      +  $approvalRequests,
      +  $secretRequests,
      +  $sudoRequests,
         $vaultCodeRequests,
         $vaultSaveLoginRequests,
         $vaultUnlockRequests,
      +  clearApprovalRequest,
      +  clearSecretRequest,
      +  clearSudoRequest,
         clearVaultCodeRequest,
         clearVaultSaveLoginRequest,
      -  clearVaultUnlockRequest,
      -  receiveApprovalRequest,
      -  setSecretRequest,
      -  setSudoRequest,
      -  setVaultCodeRequest,
      -  setVaultSaveLoginRequest,
      -  setVaultUnlockRequest
      +  clearVaultUnlockRequest
       } from '@/store/prompts'
      -import { requestScrollToBottom } from '@/store/thread-scroll'
      +import { forgetServerRequest } from '@/store/server-requests'
       
       import type { GatewayEventContext } from './types'
       
      -/** The blocking-input family: clarify / MCP setup consent / approval / sudo /
      - *  secret requests. The Python side is blocked on the matching *.respond, so
      - *  each of these must be parked per-session and surfaced. */
      +/** The blocking-input family arrives as server→client REQUESTS (see
      + *  `server-requests.ts`); the one EVENT in the family is `request.cancel`, the
      + *  backend withdrawing an open request (timeout / interrupt / session close):
      + *  tear down whichever parked card carries that id. Cancel is request-correlated:
      + *  a delayed cancel for an older prompt must not erase a newer one the same
      + *  session raised. */
       export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
         const { deps, event, payload, sessionId, occurredAt } = ctx
      -  const { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall } = deps
      -
      -  if (event.type === 'clarify.request') {
      -    // Surface the clarify tool's overlay. The Python side is blocked on
      -    // `clarify.respond`, so without this handler the agent would hang
      -    // forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
      -    //
      -    // Store the request for whichever session raised it — even a background
      -    // one. clarify.request is a one-shot event; if we dropped it for an
      -    // unfocused session, that session would block on `clarify.respond`
      -    // indefinitely and re-focusing it could never recover (the event is
      -    // gone). Parking it per-session lets the user answer once they switch
      -    // over; the inline ClarifyTool reads the active session's entry.
      -    if (sessionId && sessionInterrupted(sessionId)) {
      -      return true
      -    }
      -
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -    const question = typeof payload?.question === 'string' ? payload.question : ''
      -    const rawChoices = payload?.choices
      -    const choices = normalizeChoices(rawChoices)
      -    const multiSelect = payload?.multi_select === true
      -    // Batch (multi-question) clarify: `questions` replaces question/choices
      -    // on the wire. `answers` rides along only on reconnect replay, carrying
      -    // the per-question locks the server already accepted.
      -    const questions = normalizeQuestions(payload?.questions)
      -
      -    const lockedAnswers =
      -      typeof payload?.answers === 'object' && payload?.answers !== null
      -        ? Object.fromEntries(
      -            Object.entries(payload.answers as Record<string, unknown>).filter(
      -              (entry): entry is [string, string] => typeof entry[1] === 'string'
      -            )
      -          )
      -        : undefined
      -
      -    if (requestId && questions.length > 0) {
      -      const request = {
      -        choices: null,
      -        lockedAnswers,
      -        multiSelect: false,
      -        question: '',
      -        questions,
      -        receivedAt: Date.now() / 1000,
      -        requestId,
      -        sessionId: sessionId ?? null
      -      }
      -
      -      setClarifyRequest(request)
      -
      -      if (sessionId) {
      -        // A resumed/hydrated transcript may already contain this provider's
      -        // clarify call while carrying no live streamId. Re-arm that exact row
      -        // instead of letting the generic stream mutator append a second card.
      -        updateSessionState(sessionId, state => {
      -          const projection = restorePendingClarifyToolCall(
      -            state.messages,
      -            pendingClarifyToolPayload(request),
      -            occurredAt
      -          )
      -
      -          return {
      -            ...state,
      -            messages: projection.messages,
      -            streamId: projection.streamId,
      -            sawAssistantPayload: true,
      -            awaitingResponse: false,
      -            needsInput: true
      -          }
      -        })
      -
      -        if (sessionId === activeSessionIdRef.current) {
      -          requestScrollToBottom(sessionId)
      -        }
      -      }
      -
      -      dispatchNativeNotification({
      -        body: questions.map(q => q.question).join(' · '),
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      -      })
      -    } else if (requestId && question) {
      -      if (rawChoices != null && choices.length === 0) {
      -        warnDroppedChoices('gateway', question, rawChoices)
      -      }
       
      -      const request = {
      -        requestId,
      -        question,
      -        choices: choices.length > 0 ? choices : null,
      -        multiSelect,
      -        receivedAt: Date.now() / 1000,
      -        sessionId: sessionId ?? null
      -      }
      -
      -      setClarifyRequest(request)
      -
      -      if (sessionId) {
      -        // Same provider-shape-aware repair as the batch path above. This keeps
      -        // the original hydrated row and provider tool id, while still seeding
      -        // a row when tool.start was genuinely missed.
      -        updateSessionState(sessionId, state => {
      -          const projection = restorePendingClarifyToolCall(
      -            state.messages,
      -            pendingClarifyToolPayload(request),
      -            occurredAt
      -          )
      -
      -          return {
      -            ...state,
      -            messages: projection.messages,
      -            streamId: projection.streamId,
      -            sawAssistantPayload: true,
      -            awaitingResponse: false,
      -            needsInput: true
      -          }
      -        })
      -
      -        if (sessionId === activeSessionIdRef.current) {
      -          requestScrollToBottom(sessionId)
      -        }
      -      }
      -
      -      dispatchNativeNotification({
      -        body: question,
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      -      })
      -    }
      -
      -    return true
      +  if (event.type !== 'request.cancel') {
      +    return false
         }
       
      -  if (event.type === 'vault.code.expire') {
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -    const request = sessionId ? $vaultCodeRequests.get()[sessionId] : undefined
      -
      -    if (requestId && request && request.requestId === requestId) {
      -      clearVaultCodeRequest(sessionId, requestId)
      -    }
      +  const id = typeof payload?.id === 'string' ? payload.id : ''
       
      +  if (!id) {
           return true
         }
       
      -  if (event.type === 'vault.save_login.expire') {
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -    const request = sessionId ? $vaultSaveLoginRequests.get()[sessionId] : undefined
      +  forgetServerRequest(id)
       
      -    if (requestId && request && request.requestId === requestId) {
      -      clearVaultSaveLoginRequest(sessionId, requestId)
      -    }
      +  const key = sessionId ?? ''
       
      -    return true
      -  }
      -
      -  if (event.type === 'vault.unlock.expire') {
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -    const request = sessionId ? $vaultUnlockRequests.get()[sessionId] : undefined
      -
      -    if (requestId && request && request.requestId === requestId) {
      -      clearVaultUnlockRequest(sessionId, requestId)
      -    }
      -
      -    return true
      -  }
      -
      -  if (event.type === 'clarify.expire') {
      -    if (!sessionId) {
      -      return true
      -    }
      -
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -    const request = $clarifyRequests.get()[sessionId]
      -
      -    // Expiry is request-correlated: a delayed event from an older prompt must
      -    // not erase a newer clarify raised by the same session.
      -    if (!requestId || !request || request.requestId !== requestId) {
      -      return true
      -    }
      +  if ($clarifyRequests.get()[key]?.requestId === id) {
      +    const request = $clarifyRequests.get()[key]
       
      -    clearClarifyRequest(requestId, sessionId)
      -    updateSessionState(sessionId, state => {
      -      const projection = settlePendingClarifyToolCall(
      -        state.messages,
      -        pendingClarifyToolPayload(request),
      -        state.busy,
      -        occurredAt
      -      )
      +    clearClarifyRequest(id, sessionId)
       
      -      return {
      -        ...state,
      -        messages: projection.messages,
      -        needsInput: false,
      -        streamId: state.busy ? (projection.streamId ?? state.streamId) : null
      -      }
      -    })
      -
      -    return true
      -  }
      -
      -  if (event.type === 'mcp.setup.request') {
      -    // setup_mcp tool (desktop GUI): the agent proposed an MCP server and
      -    // the Python side is blocked on mcp.setup.respond. Park the request
      -    // per-session (like clarify) and upsert a stable pending tool row so
      -    // the inline consent card has somewhere to render even when the
      -    // tool.start event was missed (stream reconnect / hydration race).
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -    const server = typeof payload?.server === 'string' ? payload.server : ''
      -    const rawAction = typeof payload?.action === 'string' ? payload.action : 'install'
      -    const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install'
      -    const reason = typeof payload?.reason === 'string' ? payload.reason : ''
      -
      -    if (requestId && server) {
      -      setMcpSetupRequest({ action, reason, requestId, server, sessionId: sessionId ?? null })
      -
      -      if (sessionId) {
      -        upsertToolCall(
      -          sessionId,
      -          { args: { action, reason, server }, name: 'setup_mcp', tool_id: requestId },
      -          'running'
      +    if (sessionId && request) {
      +      deps.updateSessionState(sessionId, state => {
      +        const projection = settlePendingClarifyToolCall(
      +          state.messages,
      +          pendingClarifyToolPayload(request),
      +          state.busy,
      +          occurredAt
               )
      -        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
      -      }
      -
      -      dispatchNativeNotification({
      -        body: reason || server,
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      -      })
      -    }
      -
      -    return true
      -  }
       
      -  if (event.type === 'approval.request') {
      -    // 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.
      -    const command = typeof payload?.command === 'string' ? payload.command : ''
      -    const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
      -
      -    void receiveApprovalRequest($gateway.get(), {
      -      // false only when a tirith warning forbids it; backend omits the field otherwise.
      -      allowPermanent: payload?.allow_permanent !== false,
      -      choices: Array.isArray(payload?.choices)
      -        ? payload.choices.filter(choice => typeof choice === 'string')
      -        : undefined,
      -      command,
      -      description,
      -      requestId: typeof payload?.request_id === 'string' ? payload.request_id : undefined,
      -      sessionId: sessionId ?? null,
      -      smartDenied: payload?.smart_denied === true
      -    }).catch(() => undefined)
      -
      -    if (sessionId) {
      -      updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
      -    }
      -
      -    dispatchNativeNotification({
      -      actions: [
      -        { id: 'approve', text: translateNow('notifications.native.approveAction') },
      -        { id: 'reject', text: translateNow('notifications.native.rejectAction') }
      -      ],
      -      body: command || description,
      -      kind: 'approval',
      -      sessionId,
      -      title: translateNow('notifications.native.approvalTitle')
      -    })
      -
      -    return true
      -  }
      -
      -  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, sessionId: sessionId ?? null })
      -
      -      if (sessionId) {
      -        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
      -      }
      -
      -      dispatchNativeNotification({
      -        body: translateNow('notifications.native.inputBody'),
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      -      })
      -    }
      -
      -    return true
      -  }
      -
      -  if (event.type === 'secret.request') {
      -    // Skill credential capture (tools/skills_tool.py). Blocked on
      -    // secret.respond {request_id, value}.
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
      -      const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
      -
      -      setSecretRequest({
      -        requestId,
      -        envVar,
      -        prompt: promptText,
      -        sessionId: sessionId ?? null
      -      })
      -
      -      if (sessionId) {
      -        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
      -      }
      -
      -      dispatchNativeNotification({
      -        body: promptText || envVar || translateNow('notifications.native.inputBody'),
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      -      })
      -    }
      -
      -    return true
      -  }
      -
      -  if (event.type === 'vault.code.request') {
      -    // Second factor: the site asked for a one-time code and no authenticator key is saved for the login.
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      const site = typeof payload?.site === 'string' ? payload.site : ''
      -      const hint = typeof payload?.hint === 'string' ? payload.hint : ''
      -
      -      setVaultCodeRequest({ hint, requestId, sessionId: sessionId ?? null, site })
      -
      -      if (sessionId) {
      -        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
      -      }
      -
      -      dispatchNativeNotification({
      -        body: translateNow('prompts.vaultCodeTitle', site),
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      -      })
      -    }
      -
      -    return true
      -  }
      -
      -  if (event.type === 'vault.save_login.request') {
      -    // The agent is on a sign-in page with no saved login: identifier + masked password card; the
      -    // answer is stored in the encrypted vault by the backend and filled at once (never shown to the model).
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      const origin = typeof payload?.origin === 'string' ? payload.origin : ''
      -      const site = typeof payload?.site === 'string' ? payload.site : origin
      -
      -      setVaultSaveLoginRequest({ origin, requestId, sessionId: sessionId ?? null, site })
      -
      -      if (sessionId) {
      -        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
      -      }
      -
      -      dispatchNativeNotification({
      -        body: translateNow('prompts.vaultSaveTitle', site),
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      +        return {
      +          ...state,
      +          messages: projection.messages,
      +          needsInput: false,
      +          streamId: state.busy ? (projection.streamId ?? state.streamId) : null
      +        }
             })
           }
       
           return true
         }
       
      -  if (event.type === 'vault.unlock.request') {
      -    // External password-manager unlock (agent/vault_backends). Blocked on
      -    // vault.unlock.respond {request_id, password}; "" keeps it locked.
      -    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
      -
      -    if (requestId) {
      -      const backend = typeof payload?.backend === 'string' ? payload.backend : ''
      -      const displayName = typeof payload?.display_name === 'string' ? payload.display_name : backend
      -
      -      setVaultUnlockRequest({ backend, displayName, requestId, sessionId: sessionId ?? null })
      -
      -      if (sessionId) {
      -        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
      -      }
      -
      -      dispatchNativeNotification({
      -        body: translateNow('prompts.vaultUnlockTitle', displayName),
      -        kind: 'input',
      -        sessionId,
      -        title: translateNow('notifications.native.inputTitle')
      -      })
      -    }
      -
      -    return true
      +  if ($approvalRequests.get()[key]?.serverRequestId === id) {
      +    clearApprovalRequest(sessionId, $approvalRequests.get()[key]?.requestId)
      +  } else if ($sudoRequests.get()[key]?.requestId === id) {
      +    clearSudoRequest(sessionId, id)
      +  } else if ($secretRequests.get()[key]?.requestId === id) {
      +    clearSecretRequest(sessionId, id)
      +  } else if ($vaultCodeRequests.get()[key]?.requestId === id) {
      +    clearVaultCodeRequest(sessionId, id)
      +  } else if ($vaultSaveLoginRequests.get()[key]?.requestId === id) {
      +    clearVaultSaveLoginRequest(sessionId, id)
      +  } else if ($vaultUnlockRequests.get()[key]?.requestId === id) {
      +    clearVaultUnlockRequest(sessionId, id)
      +  } else if ($mcpSetupRequests.get()[key]?.requestId === id) {
      +    clearMcpSetupRequest(id, sessionId)
         }
       
      -  return false
      +  return true
       }
    • apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts
      @@ -0,0 +1,59 @@
      +import { afterEach, describe, expect, it, vi } from 'vitest'
      +
      +import { $toursEnabled } from '@/store/tours'
      +
      +import { handleServerRequest } from './server-requests'
      +import type { ServerRequestContext } from './server-requests'
      +
      +const deps = {} as ServerRequestContext['deps']
      +
      +function deliver(method: string, params: Record<string, unknown>, activeSessionId: null | string) {
      +  const respond = vi.fn()
      +  const fail = vi.fn()
      +  const handled = handleServerRequest({ fail, id: 'srq-1', method, params, profile: 'default', respond }, deps, activeSessionId)
      +
      +  return { fail, handled, respond }
      +}
      +
      +describe('preview action request routing', () => {
      +  it('leaves a scoped action request unanswered in a window showing another session', () => {
      +    const { handled, respond, fail } = deliver('preview.act', { action: 'elements', session_id: 'session-a' }, 'session-b')
      +
      +    expect(handled).toBe(true)
      +    expect(respond).not.toHaveBeenCalled()
      +    expect(fail).not.toHaveBeenCalled()
      +  })
      +
      +  it('fails fast for an unscoped request with no session in view', () => {
      +    const { respond } = deliver('preview.act', { action: 'elements' }, null)
      +
      +    expect(respond).toHaveBeenCalledWith({
      +      value: JSON.stringify({
      +        error: 'The in-app browser only takes actions in the session the user is looking at.',
      +        success: false
      +      })
      +    })
      +  })
      +})
      +
      +describe('tour request routing', () => {
      +  afterEach(() => {
      +    $toursEnabled.set(true)
      +  })
      +
      +  it('leaves a scoped request unanswered in another session even when tours are disabled', () => {
      +    $toursEnabled.set(false)
      +    const { handled, respond } = deliver('tour', { action: 'discover', session_id: 'session-a' }, 'session-b')
      +
      +    expect(handled).toBe(true)
      +    expect(respond).not.toHaveBeenCalled()
      +  })
      +
      +  it('fails fast for an unscoped request with no session in view', () => {
      +    const { respond } = deliver('tour', { action: 'discover' }, null)
      +
      +    expect(respond).toHaveBeenCalledWith({
      +      value: JSON.stringify({ error: 'Tours only run in the session the user is looking at.', success: false })
      +    })
      +  })
      +})
    • apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts
      @@ -0,0 +1,434 @@
      +import { readActivePreview } from '@/app/chat/right-rail/preview-reader'
      +import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
      +import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'
      +import { translateNow } from '@/i18n'
      +import { restorePendingClarifyToolCall } from '@/lib/chat-messages'
      +import type { PreviewActAction } from '@/lib/preview-act/act-in-page'
      +import type { TourAction, TourStep } from '@/lib/tour'
      +import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
      +import type { ScopedServerRequest } from '@/store/gateway'
      +import { setMcpSetupRequest } from '@/store/mcp-setup'
      +import { dispatchNativeNotification } from '@/store/native-notifications'
      +import {
      +  receiveApprovalRequest,
      +  setSecretRequest,
      +  setSudoRequest,
      +  setVaultCodeRequest,
      +  setVaultSaveLoginRequest,
      +  setVaultUnlockRequest
      +} from '@/store/prompts'
      +import { rememberServerRequest } from '@/store/server-requests'
      +import { requestScrollToBottom } from '@/store/thread-scroll'
      +import { $toursEnabled } from '@/store/tours'
      +
      +import type { GatewayEventDeps } from './types'
      +
      +/** The preview engine, loaded on demand so ~25KB of page-injectable source stays
      + *  off the boot path (dev: a fresh copy per action so edits reach the guest — see
      + *  the previous home of this loader in desktop-bridge.ts for the full story). */
      +const loadPreviewEngine = () => {
      +  const stable = () => import('@/app/chat/right-rail/preview-act')
      +
      +  if (!import.meta.hot) {
      +    return stable().then(mod => mod.actOnActivePreview)
      +  }
      +
      +  return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now())
      +    .catch(stable)
      +    .then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview'])
      +}
      +
      +const str = (v: unknown): string => (typeof v === 'string' ? v : '')
      +const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
      +
      +/** Answer a string-valued request with a JSON-encoded result ('' = nothing / unavailable). */
      +const answerValue = (request: ScopedServerRequest, result: unknown) =>
      +  request.respond({ value: result ? JSON.stringify(result) : '' })
      +
      +export interface ServerRequestContext {
      +  deps: Pick<GatewayEventDeps, 'activeSessionIdRef' | 'sessionInterrupted' | 'updateSessionState' | 'upsertToolCall'>
      +  request: ScopedServerRequest
      +  /** The session the request names ('' when unscoped). */
      +  sessionId: string
      +  /** The named session is the one on screen. */
      +  isActiveSession: boolean
      +}
      +
      +type Handler = (ctx: ServerRequestContext) => void
      +
      +const markNeedsInput = (ctx: ServerRequestContext) => {
      +  if (ctx.sessionId) {
      +    ctx.deps.updateSessionState(ctx.sessionId, state => ({ ...state, needsInput: true }))
      +  }
      +}
      +
      +const notifyInput = (ctx: ServerRequestContext, body: string) => {
      +  if (!ctx.request.replayed) {
      +    dispatchNativeNotification({
      +      body,
      +      kind: 'input',
      +      sessionId: ctx.sessionId || null,
      +      title: translateNow('notifications.native.inputTitle')
      +    })
      +  }
      +}
      +
      +// ── Blocking-input family (clarify / approval / sudo / secret / vault / MCP setup) ──
      +// Every one is parked per-session (like clarify) so a BACKGROUND session's turn can
      +// raise it and wait — the sidebar flags "needs input" and the card surfaces once the
      +// user focuses that chat. The Python side blocks on the response frame; without a
      +// handler the channel answers -32601 and the tool fails fast instead of stalling.
      +
      +const clarify: Handler = ctx => {
      +  const { deps, request, sessionId } = ctx
      +  const p = request.params
      +
      +  if (sessionId && deps.sessionInterrupted(sessionId)) {
      +    request.respond({ answer: '' })
      +
      +    return
      +  }
      +
      +  const question = str(p.question)
      +  const rawChoices = p.choices
      +  const choices = normalizeChoices(rawChoices)
      +  const multiSelect = p.multi_select === true
      +  // Batch (multi-question) clarify: `questions` replaces question/choices on the
      +  // wire. `answers` rides along only on a reconnect replay (locks the server
      +  // already accepted).
      +  const questions = normalizeQuestions(p.questions)
      +
      +  const lockedAnswers =
      +    typeof p.answers === 'object' && p.answers !== null
      +      ? Object.fromEntries(
      +          Object.entries(p.answers as Record<string, unknown>).filter(
      +            (entry): entry is [string, string] => typeof entry[1] === 'string'
      +          )
      +        )
      +      : undefined
      +
      +  if (questions.length === 0 && !question) {
      +    request.respond({ answer: '' })
      +
      +    return
      +  }
      +
      +  if (questions.length === 0 && rawChoices != null && choices.length === 0) {
      +    warnDroppedChoices('gateway', question, rawChoices)
      +  }
      +
      +  const clarifyRequest =
      +    questions.length > 0
      +      ? {
      +          choices: null,
      +          lockedAnswers,
      +          multiSelect: false,
      +          question: '',
      +          questions,
      +          receivedAt: Date.now() / 1000,
      +          requestId: request.id,
      +          sessionId: sessionId || null
      +        }
      +      : {
      +          choices: choices.length > 0 ? choices : null,
      +          multiSelect,
      +          question,
      +          receivedAt: Date.now() / 1000,
      +          requestId: request.id,
      +          sessionId: sessionId || null
      +        }
      +
      +  rememberServerRequest(request)
      +  setClarifyRequest(clarifyRequest)
      +
      +  if (sessionId) {
      +    // A resumed/hydrated transcript may already contain this provider's clarify
      +    // call while carrying no live streamId. Re-arm that exact row instead of
      +    // letting the generic stream mutator append a second card.
      +    const occurredAt = Date.now() / 1000
      +
      +    deps.updateSessionState(sessionId, state => {
      +      const projection = restorePendingClarifyToolCall(
      +        state.messages,
      +        pendingClarifyToolPayload(clarifyRequest),
      +        occurredAt
      +      )
      +
      +      return {
      +        ...state,
      +        messages: projection.messages,
      +        streamId: projection.streamId,
      +        sawAssistantPayload: true,
      +        awaitingResponse: false,
      +        needsInput: true
      +      }
      +    })
      +
      +    if (sessionId === deps.activeSessionIdRef.current) {
      +      requestScrollToBottom(sessionId)
      +    }
      +  }
      +
      +  notifyInput(ctx, questions.length > 0 ? questions.map(q => q.question).join(' · ') : question)
      +}
      +
      +const approval: Handler = ctx => {
      +  const { request, sessionId } = ctx
      +  const p = request.params
      +  const command = str(p.command)
      +  const description = str(p.description) || 'dangerous command'
      +
      +  rememberServerRequest(request)
      +  void receiveApprovalRequest(null, {
      +    // false only when a tirith warning forbids it; backend omits the field otherwise.
      +    allowPermanent: p.allow_permanent !== false,
      +    choices: Array.isArray(p.choices) ? p.choices.filter((choice): choice is string => typeof choice === 'string') : undefined,
      +    command,
      +    description,
      +    // The approval queue's own id — `approval.pending` / `approval.received` / `approval.respond` key on it.
      +    requestId: str(p.request_id) || undefined,
      +    serverRequestId: request.id,
      +    sessionId: sessionId || null,
      +    smartDenied: p.smart_denied === true
      +  }).catch(() => undefined)
      +  markNeedsInput(ctx)
      +
      +  if (!request.replayed) {
      +    dispatchNativeNotification({
      +      actions: [
      +        { id: 'approve', text: translateNow('notifications.native.approveAction') },
      +        { id: 'reject', text: translateNow('notifications.native.rejectAction') }
      +      ],
      +      body: command || description,
      +      kind: 'approval',
      +      sessionId: sessionId || null,
      +      title: translateNow('notifications.native.approvalTitle')
      +    })
      +  }
      +}
      +
      +const sudo: Handler = ctx => {
      +  rememberServerRequest(ctx.request)
      +  setSudoRequest({ requestId: ctx.request.id, sessionId: ctx.sessionId || null })
      +  markNeedsInput(ctx)
      +  notifyInput(ctx, translateNow('notifications.native.inputBody'))
      +}
      +
      +const secret: Handler = ctx => {
      +  const p = ctx.request.params
      +  const envVar = str(p.env_var)
      +  const promptText = str(p.prompt)
      +
      +  rememberServerRequest(ctx.request)
      +  setSecretRequest({ envVar, prompt: promptText, requestId: ctx.request.id, sessionId: ctx.sessionId || null })
      +  markNeedsInput(ctx)
      +  notifyInput(ctx, promptText || envVar || translateNow('notifications.native.inputBody'))
      +}
      +
      +const vaultCode: Handler = ctx => {
      +  const p = ctx.request.params
      +  const site = str(p.site)
      +
      +  rememberServerRequest(ctx.request)
      +  setVaultCodeRequest({ hint: str(p.hint), requestId: ctx.request.id, sessionId: ctx.sessionId || null, site })
      +  markNeedsInput(ctx)
      +  notifyInput(ctx, translateNow('prompts.vaultCodeTitle', site))
      +}
      +
      +const vaultSaveLogin: Handler = ctx => {
      +  const p = ctx.request.params
      +  const origin = str(p.origin)
      +  const site = str(p.site) || origin
      +
      +  rememberServerRequest(ctx.request)
      +  setVaultSaveLoginRequest({ origin, requestId: ctx.request.id, sessionId: ctx.sessionId || null, site })
      +  markNeedsInput(ctx)
      +  notifyInput(ctx, translateNow('prompts.vaultSaveTitle', site))
      +}
      +
      +const vaultUnlockPrompt: Handler = ctx => {
      +  const p = ctx.request.params
      +  const backend = str(p.backend)
      +  const displayName = str(p.display_name) || backend
      +
      +  rememberServerRequest(ctx.request)
      +  setVaultUnlockRequest({ backend, displayName, requestId: ctx.request.id, sessionId: ctx.sessionId || null })
      +  markNeedsInput(ctx)
      +  notifyInput(ctx, translateNow('prompts.vaultUnlockTitle', displayName))
      +}
      +
      +const mcpSetup: Handler = ctx => {
      +  // setup_mcp tool (desktop GUI): the agent proposed an MCP server. Park the
      +  // request per-session (like clarify) and upsert a stable pending tool row so
      +  // the inline consent card has somewhere to render even when the tool.start
      +  // event was missed (stream reconnect / hydration race).
      +  const { deps, request, sessionId } = ctx
      +  const p = request.params
      +  const server = str(p.server)
      +  const rawAction = str(p.action) || 'install'
      +  const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install'
      +  const reason = str(p.reason)
      +
      +  if (!server) {
      +    request.respond({ value: '' })
      +
      +    return
      +  }
      +
      +  rememberServerRequest(request)
      +  setMcpSetupRequest({ action, reason, requestId: request.id, server, sessionId: sessionId || null })
      +
      +  if (sessionId) {
      +    deps.upsertToolCall(sessionId, { args: { action, reason, server }, name: 'setup_mcp', tool_id: request.id }, 'running')
      +  }
      +
      +  markNeedsInput(ctx)
      +  notifyInput(ctx, reason || server)
      +}
      +
      +// ── Desktop-surface bridges (answered immediately, no card) ─────────────────
      +
      +const terminalRead: Handler = ({ request }) => {
      +  // read_terminal tool: serialize the renderer's xterm buffer. Empty = no live pane.
      +  answerValue(request, readActiveTerminal({ count: num(request.params.count), start: num(request.params.start) }))
      +}
      +
      +const previewRead: Handler = ({ request }) => {
      +  // read_preview tool: the active preview tab's page text is async. Empty = nothing open.
      +  void readActivePreview({ count: num(request.params.count), start: num(request.params.start) }).then(result =>
      +    answerValue(request, result)
      +  )
      +}
      +
      +const previewAct: Handler = ({ isActiveSession, request, sessionId }) => {
      +  // drive_preview tool: click/type/scroll/press inside the guest page. Active
      +  // session only: a background turn must never reach into the page the user is
      +  // working in (desktop AGENTS.md: offer, don't hijack). Every mounted window can
      +  // observe the same request; a scoped mismatch belongs to another window, so
      +  // answering here would race the owner — stay silent.
      +  if (sessionId && !isActiveSession) {
      +    return
      +  }
      +
      +  const p = request.params
      +
      +  if (!isActiveSession) {
      +    answerValue(request, {
      +      error: 'The in-app browser only takes actions in the session the user is looking at.',
      +      success: false
      +    })
      +
      +    return
      +  }
      +
      +  void loadPreviewEngine()
      +    .then(run =>
      +      run({
      +        amount: p.amount as never,
      +        key: p.key as never,
      +        kind: (str(p.action) || '') as never,
      +        max: p.max as never,
      +        ref: p.ref as never,
      +        selector: p.selector as never,
      +        submit: p.submit as never,
      +        text: p.text as never,
      +        to: p.to as PreviewActAction['to']
      +      })
      +    )
      +    .then(
      +      result => answerValue(request, result),
      +      error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false })
      +    )
      +}
      +
      +const windowRead: Handler = ({ request }) => {
      +  // read_window_below tool: main owns native window enumeration. Empty =
      +  // unavailable (older shell without the handler, Wayland, …) — without an
      +  // answer the tool would stall its full 30s deadline.
      +  const read = window.hermesDesktop?.readWindowBelow
      +
      +  void Promise.resolve(read ? read() : null).then(
      +    result => answerValue(request, result),
      +    () => answerValue(request, null)
      +  )
      +}
      +
      +const tour: Handler = ({ isActiveSession, request, sessionId }) => {
      +  // tour tool: one guided-tour action via driver.js, app DOM or preview guest
      +  // page. Active session only, same window-ownership rule as preview.act.
      +  if (sessionId && !isActiveSession) {
      +    return
      +  }
      +
      +  const p = request.params
      +
      +  if (!$toursEnabled.get()) {
      +    // Refused in words, not silently dropped: a no-op would leave the agent
      +    // narrating a spotlight the user can't see.
      +    answerValue(request, { error: 'The user has turned guided tours off.', success: false })
      +
      +    return
      +  }
      +
      +  if (!isActiveSession) {
      +    answerValue(request, { error: 'Tours only run in the session the user is looking at.', success: false })
      +
      +    return
      +  }
      +
      +  void import('@/lib/tour')
      +    .then(({ runTour }) =>
      +      runTour(
      +        {
      +          kind: (str(p.action) || 'stop') as TourAction['kind'],
      +          selector: p.selector as never,
      +          side: p.side as TourStep['side'],
      +          startAt: p.step_index as never,
      +          steps: p.steps as TourStep[] | undefined,
      +          text: p.text as never,
      +          title: p.title as never
      +        },
      +        p.surface === 'preview' ? 'preview' : 'app'
      +      )
      +    )
      +    .then(
      +      result => answerValue(request, result),
      +      error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false })
      +    )
      +}
      +
      +/** Method → handler. Every `ServerRequestMap` key the desktop answers. */
      +export const SERVER_REQUEST_HANDLERS: Record<string, Handler> = {
      +  approval,
      +  clarify,
      +  'mcp.setup': mcpSetup,
      +  'preview.act': previewAct,
      +  'preview.read': previewRead,
      +  secret,
      +  sudo,
      +  'terminal.read': terminalRead,
      +  tour,
      +  'vault.code': vaultCode,
      +  'vault.save_login': vaultSaveLogin,
      +  'vault.unlock_prompt': vaultUnlockPrompt,
      +  'window.read': windowRead
      +}
      +
      +/** Dispatch one server→client request; false when the desktop has no handler for its method. */
      +export function handleServerRequest(
      +  request: ScopedServerRequest,
      +  deps: ServerRequestContext['deps'],
      +  activeSessionId: null | string
      +): boolean {
      +  const handler = SERVER_REQUEST_HANDLERS[request.method]
      +
      +  if (!handler) {
      +    return false
      +  }
      +
      +  const sessionId = str(request.params.session_id)
      +
      +  handler({ deps, request, sessionId, isActiveSession: Boolean(sessionId) && sessionId === activeSessionId })
      +
      +  return true
      +}
    • apps/desktop/src/app/session/hooks/use-message-stream/index.ts
      @@ -24,6 +24,7 @@ import {
         stripGeneratedImageEchoes
       } from '@/lib/generated-images'
       import { isTodoToolName, nextTodosFromToolEvent, parseTodoRevision } from '@/lib/todos'
      +import type { ScopedServerRequest } from '@/store/gateway'
       import { dispatchNativeNotification } from '@/store/native-notifications'
       import { isDiskFullErrorMessage, notifyError } from '@/store/notifications'
       import { broadcastSessionsChanged } from '@/store/session-sync'
      @@ -33,6 +34,7 @@ import { $todosBySession, setSessionTodos } from '@/store/todos'
       import type { ClientSessionState } from '../../../types'
       
       import { useGatewayEventHandler } from './gateway-event'
      +import { handleServerRequest as dispatchServerRequest } from './gateway-event/server-requests'
       import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils'
       
       interface MessageStreamOptions {
      @@ -880,11 +882,25 @@ export function useMessageStream({
           upsertToolCall
         })
       
      +  // Server→client requests (clarify, approval, sudo, …) from every socket the
      +  // registry owns. The request answers itself over the socket it arrived on,
      +  // so no owner routing is involved here — only which card to show.
      +  const handleServerRequest = useCallback(
      +    (request: ScopedServerRequest): boolean =>
      +      dispatchServerRequest(
      +        request,
      +        { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall },
      +        activeSessionIdRef.current
      +      ),
      +    [activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall]
      +  )
      +
         return {
           appendAssistantDelta,
           appendReasoningDelta,
           completeAssistantMessage,
           handleGatewayEvent,
      +    handleServerRequest,
           finalizeInterimAssistantMessage,
           upsertToolCall
         }
    • apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx
      @@ -6,6 +6,7 @@ import { vi } from 'vitest'
       
       import type { ClientSessionState } from '@/app/types'
       import { createClientSessionState } from '@/lib/chat-runtime'
      +import type { ScopedServerRequest } from '@/store/gateway'
       
       import { useMessageStream } from './index'
       
      @@ -17,6 +18,9 @@ export interface MessageStreamHarnessOptions extends Partial<Parameters<typeof u
       export interface MessageStreamHarness {
         /** Feed a gateway event into the mounted hook. */
         handleEvent: (event: GatewayEvent) => void
      +  /** Feed a server→client request (clarify, approval, …) into the mounted hook;
      +   *  returns the `respond` spy so a test can assert the answer frame. */
      +  handleRequest: (method: string, params: Record<string, unknown>, id?: string) => ReturnType<typeof vi.fn>
         /** Push streaming assistant text, bypassing the event envelope. For the specs
          *  about flush scheduling rather than about a particular event. */
         appendDelta: (sessionId: string, delta: string) => void
      @@ -48,6 +52,7 @@ export function renderMessageStream(
         { states = new Map<string, ClientSessionState>(), ...overrides }: MessageStreamHarnessOptions = {}
       ): MessageStreamHarness {
         let dispatch: ((event: GatewayEvent) => void) | null = null
      +  let dispatchRequest: ((request: ScopedServerRequest) => boolean) | null = null
         let appendDelta: ((sessionId: string, delta: string) => void) | null = null
         let latest: ClientSessionState | null = null
       
      @@ -75,8 +80,9 @@ export function renderMessageStream(
       
           useEffect(() => {
             dispatch = stream.handleGatewayEvent
      +      dispatchRequest = stream.handleServerRequest
             appendDelta = stream.appendAssistantDelta
      -    }, [stream.appendAssistantDelta, stream.handleGatewayEvent])
      +    }, [stream.appendAssistantDelta, stream.handleGatewayEvent, stream.handleServerRequest])
       
           return null
         }
      @@ -93,6 +99,17 @@ export function renderMessageStream(
       
             dispatch(event)
           },
      +    handleRequest: (method, params, id = `srq-${method}`) => {
      +      const respond = vi.fn()
      +
      +      if (!dispatchRequest) {
      +        throw new Error('renderMessageStream: the hook never mounted')
      +      }
      +
      +      dispatchRequest({ fail: vi.fn(), id, method, params, profile: 'default', respond })
      +
      +      return respond
      +    },
           appendDelta: (id, delta) => {
             if (!appendDelta) {
               throw new Error('renderMessageStream: the hook never mounted')
    • apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
      @@ -1136,16 +1136,26 @@ describe('resumeSession failure recovery', () => {
       
           const requestGateway = vi.fn(async (method: string) => {
             if (method === 'session.resume') {
      +        // The channel re-delivers `open_requests` to the request handler BEFORE
      +        // the caller sees the result; that handler parks the card. Mirror that
      +        // ordering here (no channel in this harness).
      +        setClarifyRequest({
      +          choices: ['safe', 'fast'],
      +          multiSelect: false,
      +          question: 'Which path?',
      +          receivedAt: Date.now(),
      +          requestId: 'req-resumed',
      +          sessionId: 'runtime-1'
      +        })
      +
               return {
                 info: {},
                 message_count: 2,
                 messages: [],
                 messages_omitted: true,
      -          pending_clarify: {
      -            choices: ['safe', 'fast'],
      -            question: 'Which path?',
      -            request_id: 'req-resumed'
      -          },
      +          open_requests: [
      +            { id: 'req-resumed', method: 'clarify', params: { choices: ['safe', 'fast'], question: 'Which path?' } }
      +          ],
                 resumed: 'stored-1',
                 running: true,
                 session_id: 'runtime-1',
      @@ -1196,21 +1206,34 @@ describe('resumeSession failure recovery', () => {
             session_id: 'stored-1'
           } as never)
       
      +    const questions = [
      +      { choices: ['Blue', 'Red'], qid: 'q0', question: 'Color?' },
      +      { choices: ['Small', 'Large'], qid: 'q1', question: 'Size?' }
      +    ]
      +
           const requestGateway = vi.fn(async (method: string) => {
             if (method === 'session.resume') {
      +        // Request handler parks the batch card (with the server-locked answer)
      +        // from the re-delivered open request before the result lands.
      +        setClarifyRequest({
      +          choices: null,
      +          lockedAnswers: { q0: 'Blue' },
      +          multiSelect: false,
      +          question: '',
      +          questions: questions.map(q => ({ ...q, multiSelect: false })),
      +          receivedAt: Date.now(),
      +          requestId: 'req-batch-resumed',
      +          sessionId: 'runtime-1'
      +        })
      +
               return {
                 info: {},
                 message_count: 2,
                 messages: [],
                 messages_omitted: true,
      -          pending_clarify: {
      -            answers: { q0: 'Blue' },
      -            questions: [
      -              { choices: ['Blue', 'Red'], qid: 'q0', question: 'Color?' },
      -              { choices: ['Small', 'Large'], qid: 'q1', question: 'Size?' }
      -            ],
      -            request_id: 'req-batch-resumed'
      -          },
      +          open_requests: [
      +            { id: 'req-batch-resumed', method: 'clarify', params: { answers: { q0: 'Blue' }, questions } }
      +          ],
                 resumed: 'stored-1',
                 running: true,
                 session_id: 'runtime-1',
      @@ -2900,16 +2923,23 @@ describe('resumeSession warm-cache mapping integrity', () => {
       
           const requestGateway = vi.fn(async (method: string) => {
             if (method === 'session.activate') {
      +        setClarifyRequest({
      +          choices: ['safe', 'fast'],
      +          multiSelect: false,
      +          question: 'Which path?',
      +          receivedAt: Date.now(),
      +          requestId: 'req-warm',
      +          sessionId: 'rt-A'
      +        })
      +
               return {
                 info: {},
                 message_count: 2,
                 messages: [],
                 messages_omitted: true,
      -          pending_clarify: {
      -            choices: ['safe', 'fast'],
      -            question: 'Which path?',
      -            request_id: 'req-warm'
      -          },
      +          open_requests: [
      +            { id: 'req-warm', method: 'clarify', params: { choices: ['safe', 'fast'], question: 'Which path?' } }
      +          ],
                 resumed: 'stored-A',
                 running: true,
                 session_id: 'rt-A',
    • apps/desktop/src/app/session/hooks/use-session-actions/index.ts
      @@ -50,7 +50,7 @@ import {
         resolveNewChatOwnerRoute
       } from '@/store/profile'
       import { $projectScope, resolveNewSessionCwd } from '@/store/projects'
      -import { setApprovalRequest } from '@/store/prompts'
      +import { receiveApprovalRequest } from '@/store/prompts'
       import { clearStoredTranscriptReadOnly, markStoredTranscriptReadOnly } from '@/store/read-only-transcript'
       import {
         $activeSessionStoredIdRotation,
      @@ -345,7 +345,10 @@ function restorePendingApproval(response: SessionResumeResponse, sessionId: stri
           return false
         }
       
      -  setApprovalRequest({
      +  // The live `approval` server request (re-delivered from `open_requests`
      +  // before this ran) already parked itself with the same queue id; don't
      +  // clobber it with a copy that can only answer through the RPC fallback.
      +  void receiveApprovalRequest(null, {
           allowPermanent: pending.allow_permanent !== false,
           choices: pending.choices,
           command: pending.command ?? '',
    • apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.test.ts
      @@ -30,102 +30,41 @@ describe('restorePendingClarifyFromSnapshot', () => {
           $clarifyRequests.set({})
         })
       
      -  it('restores a batch clarify snapshot (questions, no top-level question)', () => {
      -    const state = restorePendingClarifyFromSnapshot(
      -      {
      -        pending_clarify: {
      -          request_id: 'rid1',
      -          questions: [
      -            { choices: ['Yes', 'No'], multi_select: false, qid: 'q0', question: 'Proceed?' },
      -            { qid: 'q1', question: 'Which region?' }
      -          ]
      -        }
      -      },
      -      'sess-1',
      -      resumeStartedAt
      -    )
      -
      -    expect(state.request).not.toBeNull()
      -    expect(setClarifyRequestMock).toHaveBeenCalledWith(
      -      expect.objectContaining({
      +  it('hands back the card the request handler already parked for a replayed open clarify request', () => {
      +    $clarifyRequests.set({
      +      'sess-1': {
      +        choices: null,
               multiSelect: false,
      -        question: '',
      +        question: 'Proceed?',
      +        receivedAt: resumeStartedAt + 5,
               requestId: 'rid1',
      -        sessionId: 'sess-1',
      -        questions: [
      -          { choices: ['Yes', 'No'], multiSelect: false, qid: 'q0', question: 'Proceed?' },
      -          { multiSelect: false, qid: 'q1', question: 'Which region?', choices: null }
      -        ]
      -      })
      -    )
      -  })
      -
      -  it('carries server-locked answers into the replayed batch card', () => {
      -    restorePendingClarifyFromSnapshot(
      -      {
      -        pending_clarify: {
      -          answers: { q0: 'Yes', junk: 42 },
      -          request_id: 'rid2',
      -          questions: [{ qid: 'q0', question: 'Proceed?' }]
      -        }
      -      },
      -      'sess-2',
      -      resumeStartedAt
      -    )
      -
      -    expect(setClarifyRequestMock).toHaveBeenCalledWith(
      -      expect.objectContaining({ lockedAnswers: { q0: 'Yes' }, requestId: 'rid2' })
      -    )
      -  })
      +        sessionId: 'sess-1'
      +      }
      +    })
       
      -  it('still restores the single-question form', () => {
           const state = restorePendingClarifyFromSnapshot(
      -      {
      -        pending_clarify: {
      -          choices: ['A', 'B'],
      -          multi_select: true,
      -          question: 'Pick one',
      -          request_id: 'rid3'
      -        }
      -      },
      -      'sess-3',
      +      { open_requests: [{ id: 'rid1', method: 'clarify', params: { question: 'Proceed?' } }] },
      +      'sess-1',
             resumeStartedAt
           )
       
      -    expect(state.request).not.toBeNull()
      -    expect(setClarifyRequestMock).toHaveBeenCalledWith(
      -      expect.objectContaining({
      -        choices: ['A', 'B'],
      -        multiSelect: true,
      -        question: 'Pick one',
      -        requestId: 'rid3'
      -      })
      -    )
      +    expect(state.authoritativeAbsent).toBe(false)
      +    expect(state.request?.requestId).toBe('rid1')
      +    expect(clearClarifyRequestMock).not.toHaveBeenCalled()
         })
       
      -  it('rejects a payload with neither form (no request restored)', () => {
      +  it('reports an open request the handler declined (no card parked) without inventing one', () => {
           const state = restorePendingClarifyFromSnapshot(
      -      { pending_clarify: { request_id: 'rid4' } },
      +      { open_requests: [{ id: 'rid4', method: 'clarify', params: {} }] },
             'sess-4',
             resumeStartedAt
           )
       
      +    expect(state.authoritativeAbsent).toBe(false)
           expect(state.request).toBeNull()
           expect(setClarifyRequestMock).not.toHaveBeenCalled()
         })
       
      -  it('rejects a payload with no request id', () => {
      -    const state = restorePendingClarifyFromSnapshot(
      -      { pending_clarify: { question: 'Orphaned prompt' } },
      -      'sess-5',
      -      resumeStartedAt
      -    )
      -
      -    expect(state.request).toBeNull()
      -    expect(state.authoritativeAbsent).toBe(true)
      -    expect(setClarifyRequestMock).not.toHaveBeenCalled()
      -  })
      -
         it('clears a stale local request when the snapshot has none, and leaves a newer in-flight one', () => {
           $clarifyRequests.set({
             'sess-6': {
    • apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts
      @@ -1,12 +1,5 @@
       import type { GatewayEventPayload } from '@/lib/chat-messages'
      -import {
      -  $clarifyRequests,
      -  type ClarifyRequest,
      -  clearClarifyRequest,
      -  normalizeChoices,
      -  normalizeQuestions,
      -  setClarifyRequest
      -} from '@/store/clarify'
      +import { $clarifyRequests, type ClarifyRequest, clearClarifyRequest } from '@/store/clarify'
       import type { SessionResumeResponse } from '@/types/hermes'
       
       export interface PendingClarifyResumeState {
      @@ -16,28 +9,26 @@ export interface PendingClarifyResumeState {
       }
       
       /**
      - * Restore a pending clarify from a resume/activate snapshot onto `sessionId`.
      + * Reconcile the parked clarify for `sessionId` against a resume/activate
      + * snapshot.
        *
      - * The snapshot mirrors the live clarify.request wire shape: single-question
      - * payloads carry `question`/`choices`/`multi_select`; batch (multi-question)
      - * ones carry `questions` (+ any answers already locked server-side) and no
      - * top-level `question`. Multi-select locks arrive as JSON-encoded arrays
      - * inside a string — never a bare array — so `lockedAnswers` keeps string
      - * values only.
      - *
      - * A missing snapshot is authoritative only for requests that already existed
      - * when the RPC began. A newer clarify.request that arrives while the response
      - * is in flight is left alone.
      + * The snapshot's `open_requests` names every server→client request still
      + * blocking the session. The shared channel has ALREADY re-delivered those to
      + * the request handlers (which parked the clarify card) before the caller sees
      + * the response, so this only has to (a) report the parked request when the
      + * snapshot confirms it and (b) treat a snapshot WITHOUT a clarify as
      + * authoritative for requests that already existed when the RPC began — a
      + * newer request that arrived while the response was in flight is left alone.
        */
       export function restorePendingClarifyFromSnapshot(
      -  response: Pick<SessionResumeResponse, 'pending_clarify'>,
      +  response: Pick<SessionResumeResponse, 'open_requests'>,
         sessionId: string,
         resumeStartedAt: number,
         requestIdAtStart?: string
       ): PendingClarifyResumeState {
      -  const pending = response.pending_clarify
      +  const pending = (response.open_requests ?? []).find(entry => entry.method === 'clarify')
       
      -  if (!pending || typeof pending.request_id !== 'string') {
      +  if (!pending) {
           const current = $clarifyRequests.get()[sessionId]
       
           const existedAtStart = Boolean(current && requestIdAtStart && current.requestId === requestIdAtStart)
      @@ -53,36 +44,12 @@ export function restorePendingClarifyFromSnapshot(
           return { authoritativeAbsent: true, cleared: null, request: null }
         }
       
      -  const questions = normalizeQuestions(pending.questions)
      -  const question = typeof pending.question === 'string' ? pending.question : ''
      -
      -  if (!question && questions.length === 0) {
      -    return { authoritativeAbsent: false, cleared: null, request: null }
      -  }
      -
      -  const choices = normalizeChoices(pending.choices)
      -
      -  const lockedAnswers =
      -    typeof pending.answers === 'object' && pending.answers !== null
      -      ? Object.fromEntries(
      -          Object.entries(pending.answers).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
      -        )
      -      : undefined
      -
      -  const request: ClarifyRequest = {
      -    choices: choices.length > 0 ? choices : null,
      -    lockedAnswers,
      -    multiSelect: pending.multi_select === true,
      -    question,
      -    receivedAt: Date.now() / 1000,
      -    requestId: pending.request_id,
      -    sessionId,
      -    ...(questions.length > 0 ? { questions } : {})
      -  }
      -
      -  setClarifyRequest(request)
      +  // The request handler parked it under this session when the channel
      +  // re-delivered `open_requests`; a card the handler declined (empty
      +  // question) is simply not there.
      +  const parked = $clarifyRequests.get()[sessionId]
       
      -  return { authoritativeAbsent: false, cleared: null, request }
      +  return { authoritativeAbsent: false, cleared: null, request: parked?.requestId === pending.id ? parked : null }
       }
       
       export function pendingClarifyToolPayload(request: ClarifyRequest): GatewayEventPayload {
    • apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx
      @@ -2,7 +2,7 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react'
       import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
       import { atom } from 'nanostores'
       import type { ReactNode } from 'react'
      -import { afterEach, describe, expect, it, vi } from 'vitest'
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
       
       import { onComposerInsertRequest } from '@/app/chat/composer/focus'
       import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
      @@ -12,6 +12,7 @@ import { I18nProvider } from '@/i18n'
       import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
       import { $gateway } from '@/store/gateway'
       import { $profiles } from '@/store/profile'
      +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
       import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
       
       import { ClarifyTool, readClarifyBatchResult, readClarifyResult } from './clarify-tool'
      @@ -38,9 +39,23 @@ vi.mock('@assistant-ui/react', () => ({
         useAuiState: () => messageRunning
       }))
       
      +/** Seed a live `clarify` server request; the card answers it synchronously with
      + *  `respondToServerRequest`, so the returned spy sees the response frame. */
      +function liveServerRequest(id: string) {
      +  const respond = vi.fn()
      +  rememberServerRequest({ fail: vi.fn(), id, method: 'clarify', params: {}, respond })
      +
      +  return respond
      +}
      +
      +beforeEach(() => {
      +  resetServerRequestsForTests()
      +})
      +
       afterEach(() => {
         cleanup()
         clearClarifyRequest()
      +  resetServerRequestsForTests()
         $activeSessionId.set(null)
         $gateway.set(null)
         messageRunning = true
      @@ -99,6 +114,7 @@ function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessageP
       
       function renderLiveClarify({ multiSelect = false }: { multiSelect?: boolean } = {}) {
         const request = vi.fn().mockResolvedValue({ ok: true })
      +  const respond = liveServerRequest('request-1')
       
         $activeSessionId.set('session-1')
         $gateway.set({ request } as never)
      @@ -111,7 +127,7 @@ function renderLiveClarify({ multiSelect = false }: { multiSelect?: boolean } =
         })
         const { rerender } = renderClarify(<ClarifyTool {...liveClarifyProps()} />)
       
      -  return { request, rerender }
      +  return { request, rerender, respond }
       }
       
       describe('ClarifyTool live card stays mounted across settle', () => {
      @@ -135,13 +151,13 @@ describe('ClarifyTool live card stays mounted across settle', () => {
         })
       
         it('holds the card through the gap between answering and the settled result', async () => {
      -    const { request, rerender } = renderLiveClarify()
      +    const { rerender, respond } = renderLiveClarify()
       
           fireEvent.click(screen.getByRole('button', { name: /staging/ }))
           fireEvent.click(screen.getByRole('button', { name: /Continue/ }))
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalled()
      +      expect(respond).toHaveBeenCalled()
           })
       
           // tool.complete is what swaps in the settled card; the turn can already
      @@ -177,7 +193,7 @@ describe('ClarifyTool live card stays mounted across settle', () => {
       
       describe('ClarifyTool choice selection', () => {
         it('selects independently, deselects and submits multi-select choices as a JSON array', async () => {
      -    const { request } = renderLiveClarify({ multiSelect: true })
      +    const { respond } = renderLiveClarify({ multiSelect: true })
           const staging = screen.getByRole('button', { name: /staging/ })
           const production = screen.getByRole('button', { name: /production/ })
       
      @@ -197,15 +213,12 @@ describe('ClarifyTool choice selection', () => {
           fireEvent.click(screen.getByRole('button', { name: /Continue/ }))
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('clarify.respond', {
      -        answer: JSON.stringify(['production', 'staging']),
      -        request_id: 'request-1'
      -      })
      +      expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production', 'staging']) })
           })
         })
       
         it('keeps single-select replacement and plain-string submission', async () => {
      -    const { request } = renderLiveClarify()
      +    const { respond } = renderLiveClarify()
           const staging = screen.getByRole('button', { name: /staging/ })
           const production = screen.getByRole('button', { name: /production/ })
       
      @@ -218,11 +231,9 @@ describe('ClarifyTool choice selection', () => {
           fireEvent.click(screen.getByRole('button', { name: /Continue/ }))
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('clarify.respond', {
      -        answer: 'production',
      -        request_id: 'request-1'
      -      })
      +      expect(respond).toHaveBeenCalledWith({ answer: 'production' })
           })
      +    expect(hasOpenServerRequest('request-1')).toBe(false)
         })
       })
       
      @@ -397,36 +408,30 @@ describe('ClarifyTool keyboard navigation', () => {
         })
       
         it('selects by number and confirms the answer with Enter', async () => {
      -    const { request } = renderLiveClarify()
      +    const { respond } = renderLiveClarify()
       
           fireEvent.keyDown(window, { key: '2' })
           fireEvent.keyDown(window, { key: 'Enter' })
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('clarify.respond', {
      -        answer: 'production',
      -        request_id: 'request-1'
      -      })
      +      expect(respond).toHaveBeenCalledWith({ answer: 'production' })
           })
         })
       
         it('stages a highlighted multi-select choice with Enter and submits it with Continue', async () => {
      -    const { request } = renderLiveClarify({ multiSelect: true })
      +    const { respond } = renderLiveClarify({ multiSelect: true })
           const production = screen.getByRole('button', { name: /production/ })
       
           fireEvent.keyDown(window, { key: 'ArrowDown' })
           fireEvent.keyDown(window, { key: 'Enter' })
       
           expect(production.getAttribute('aria-pressed')).toBe('true')
      -    expect(request).not.toHaveBeenCalled()
      +    expect(respond).not.toHaveBeenCalled()
       
           fireEvent.click(screen.getByRole('button', { name: /Continue/ }))
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('clarify.respond', {
      -        answer: JSON.stringify(['production']),
      -        request_id: 'request-1'
      -      })
      +      expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production']) })
           })
         })
       
      @@ -445,23 +450,23 @@ describe('ClarifyTool keyboard navigation', () => {
         })
       
         it('does not intercept keyboard events while an action button has focus', () => {
      -    const { request } = renderLiveClarify()
      +    const { respond } = renderLiveClarify()
           const skip = screen.getByRole('button', { name: 'Skip' })
       
           skip.focus()
       
           expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)
           expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true)
      -    expect(request).not.toHaveBeenCalled()
      +    expect(respond).not.toHaveBeenCalled()
         })
       })
       
       describe('ClarifyTool recommended option', () => {
         it('dims the (Recommended) label and answers with the choice the backend sent', async () => {
      -    const request = vi.fn().mockResolvedValue({ ok: true })
      +    const respond = liveServerRequest('request-1')
       
           $activeSessionId.set('session-1')
      -    $gateway.set({ request } as never)
      +    $gateway.set({ request: vi.fn() } as never)
           setClarifyRequest({
             choices: ['staging (Recommended)', 'production'],
             multiSelect: false,
      @@ -482,10 +487,7 @@ describe('ClarifyTool recommended option', () => {
           // The decorated string goes back verbatim; the tool strips the label before
           // the agent ever sees the answer.
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('clarify.respond', {
      -        answer: 'staging (Recommended)',
      -        request_id: 'request-1'
      -      })
      +      expect(respond).toHaveBeenCalledWith({ answer: 'staging (Recommended)' })
           })
         })
       })
      @@ -564,6 +566,7 @@ function liveBatchProps(): ToolCallMessagePartProps {
       
       function renderLiveBatch(lockedAnswers?: Record<string, string>, multiSelect = false) {
         const request = vi.fn().mockResolvedValue({ ok: true, remaining: [] })
      +  const respond = liveServerRequest('request-batch')
       
         $activeSessionId.set('session-1')
         $gateway.set({ request } as never)
      @@ -581,7 +584,7 @@ function renderLiveBatch(lockedAnswers?: Record<string, string>, multiSelect = f
         })
         renderClarify(<ClarifyTool {...liveBatchProps()} />)
       
      -  return request
      +  return { request, respond }
       }
       
       describe('readClarifyBatchResult', () => {
      @@ -611,23 +614,20 @@ describe('readClarifyBatchResult', () => {
       describe('ClarifyTool submit shortcut', () => {
         it('submits selected choices with Cmd/Ctrl+Enter without toggling the focused option', async () => {
           for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) {
      -      const { request } = renderLiveClarify({ multiSelect: true })
      +      const { respond } = renderLiveClarify({ multiSelect: true })
             const choice = screen.getByRole('button', { name: /staging/ })
             fireEvent.click(choice)
             choice.focus()
             fireEvent.keyDown(choice, { key: 'Enter', ...modifier })
      -      await waitFor(() => expect(request).toHaveBeenCalledTimes(1))
      -      expect(request).toHaveBeenCalledWith('clarify.respond', {
      -        request_id: 'request-1',
      -        answer: '["staging"]'
      -      })
      +      await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))
      +      expect(respond).toHaveBeenCalledWith({ answer: '["staging"]' })
             cleanup()
           }
         })
       
         it('submits a complete batch from its text field while preserving incomplete and multiline input', async () => {
           for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) {
      -      const request = renderLiveBatch()
      +      const { request } = renderLiveBatch()
             const field = screen.getByPlaceholderText('Type your answer…')
             fireEvent.change(field, { target: { value: 'packet' } })
             field.focus()
      @@ -639,7 +639,7 @@ describe('ClarifyTool submit shortcut', () => {
             expect(request).not.toHaveBeenCalled()
             fireEvent.keyDown(field, { key: 'Enter', ...modifier })
             await waitFor(() => expect(request).toHaveBeenCalledTimes(2))
      -      expect(request).toHaveBeenNthCalledWith(2, 'clarify.respond', {
      +      expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', {
               answer: 'packet',
               question_id: 'q1',
               request_id: 'request-batch'
      @@ -659,7 +659,7 @@ describe('ClarifyTool batch card', () => {
         })
       
         it('stages locally and keeps the single confirm disabled until all answered', async () => {
      -    const request = renderLiveBatch()
      +    const { request } = renderLiveBatch()
           const confirm = screen.getByRole('button', { name: /Confirm and continue/ })
       
           expect((confirm as HTMLButtonElement).disabled).toBe(true)
      @@ -676,8 +676,8 @@ describe('ClarifyTool batch card', () => {
           expect((confirm as HTMLButtonElement).disabled).toBe(false)
         })
       
      -  it('confirm sends every per-question lock in order and completes the batch', async () => {
      -    const request = renderLiveBatch()
      +  it('confirm sends every per-question clarify.lock in order and completes the batch', async () => {
      +    const { request } = renderLiveBatch()
       
           fireEvent.click(screen.getByRole('button', { name: /red/ }))
           fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } })
      @@ -686,20 +686,22 @@ describe('ClarifyTool batch card', () => {
           await waitFor(() => {
             expect(request).toHaveBeenCalledTimes(2)
           })
      -    expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', {
      +    expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', {
             answer: 'red',
             question_id: 'q0',
             request_id: 'request-batch'
           })
      -    expect(request).toHaveBeenNthCalledWith(2, 'clarify.respond', {
      +    expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', {
             answer: 'packet',
             question_id: 'q1',
             request_id: 'request-batch'
           })
      +    // The last lock resolves the server request; the card forgets it locally.
      +    await waitFor(() => expect(hasOpenServerRequest('request-batch')).toBe(false))
         })
       
         it('a staged answer stays editable before confirm', async () => {
      -    const request = renderLiveBatch()
      +    const { request } = renderLiveBatch()
       
           fireEvent.click(screen.getByRole('button', { name: /red/ }))
           fireEvent.click(screen.getByRole('button', { name: /blue/ }))
      @@ -710,7 +712,7 @@ describe('ClarifyTool batch card', () => {
             expect(request).toHaveBeenCalledTimes(2)
           })
           // The re-pick won: blue, not red.
      -    expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', {
      +    expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', {
             answer: 'blue',
             question_id: 'q0',
             request_id: 'request-batch'
      @@ -732,17 +734,15 @@ describe('ClarifyTool batch card', () => {
           expect(screen.getByText('1 of 2 answered')).toBeTruthy()
         })
       
      -  it('Skip cancels the whole batch without a question_id', async () => {
      -    const request = renderLiveBatch()
      +  it('Skip cancels the whole batch with an empty response (no answers)', async () => {
      +    const { request, respond } = renderLiveBatch()
       
           fireEvent.click(screen.getByRole('button', { name: 'Skip' }))
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('clarify.respond', {
      -        answer: '',
      -        request_id: 'request-batch'
      -      })
      +      expect(respond).toHaveBeenCalledWith({})
           })
      +    expect(request).not.toHaveBeenCalled()
         })
       
         it('renders the settled batch with all questions and answers', () => {
      @@ -773,7 +773,10 @@ describe('ClarifyTool batch card', () => {
       // foreground focus, so after a profile / Bot Chat switch it can be profile B
       // while the blocking clarify belongs to profile A — the response lands on a
       // backend that never held the request and the owner stays blocked until the
      -// tool times out. Every live clarify.respond now routes by request.sessionId.
      +// tool times out. A clarify answer is now the server request's own response
      +// frame — it rides the socket the request arrived on, so no routing decision is
      +// left to make. Only `clarify.lock` (a real client→server RPC) still dials a
      +// socket, and it must route by request.sessionId.
       
       const OWNER_CONNECTION_ID = 'conn-profile-a'
       const OWNER_PROFILE = 'profile-a'
      @@ -794,12 +797,12 @@ function armCrossProfileOwner() {
         return ambient
       }
       
      -function expectOwnerCall(nth: number, params: Record<string, unknown>) {
      +function expectOwnerLock(nth: number, params: Record<string, unknown>) {
         expect(gatewayMocks.requestGatewayForAgent).toHaveBeenNthCalledWith(
           nth,
           OWNER_CONNECTION_ID,
           OWNER_PROFILE,
      -    'clarify.respond',
      +    'clarify.lock',
           params
         )
       }
      @@ -811,8 +814,9 @@ describe('ClarifyTool owner routing', () => {
           gatewayMocks.requestGatewayForAgent.mockClear()
         })
       
      -  it('answers a single clarify on the owner socket, never profile B ambient', async () => {
      +  it('answers a single clarify through its server request, never profile B ambient', async () => {
           const ambient = armCrossProfileOwner()
      +    const respond = liveServerRequest('request-1')
       
           setClarifyRequest({
             choices: ['staging', 'production'],
      @@ -827,14 +831,15 @@ describe('ClarifyTool owner routing', () => {
           fireEvent.click(screen.getByRole('button', { name: /Continue/ }))
       
           await waitFor(() => {
      -      expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)
      +      expect(respond).toHaveBeenCalledWith({ answer: 'staging' })
           })
      -    expectOwnerCall(1, { answer: 'staging', request_id: 'request-1' })
      +    expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled()
           expect(ambient).not.toHaveBeenCalled()
         })
       
         it('sends both sequential batch locks on the owner socket, in order', async () => {
           const ambient = armCrossProfileOwner()
      +    liveServerRequest('request-batch')
       
           setClarifyRequest({
             choices: null,
      @@ -857,13 +862,14 @@ describe('ClarifyTool owner routing', () => {
             expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(2)
           })
           // The LAST lock resolves the blocked tool, so order is load-bearing.
      -    expectOwnerCall(1, { answer: 'red', question_id: 'q0', request_id: 'request-batch' })
      -    expectOwnerCall(2, { answer: 'packet', question_id: 'q1', request_id: 'request-batch' })
      +    expectOwnerLock(1, { answer: 'red', question_id: 'q0', request_id: 'request-batch' })
      +    expectOwnerLock(2, { answer: 'packet', question_id: 'q1', request_id: 'request-batch' })
           expect(ambient).not.toHaveBeenCalled()
         })
       
      -  it('sends a batch skip/cancel on the owner socket', async () => {
      +  it('answers a batch skip/cancel through its server request, never profile B ambient', async () => {
           const ambient = armCrossProfileOwner()
      +    const respond = liveServerRequest('request-batch')
       
           setClarifyRequest({
             choices: null,
      @@ -881,9 +887,9 @@ describe('ClarifyTool owner routing', () => {
           fireEvent.click(screen.getByRole('button', { name: 'Skip' }))
       
           await waitFor(() => {
      -      expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)
      +      expect(respond).toHaveBeenCalledWith({})
           })
      -    expectOwnerCall(1, { answer: '', request_id: 'request-batch' })
      +    expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled()
           expect(ambient).not.toHaveBeenCalled()
         })
       })
      @@ -915,7 +921,11 @@ describe('ClarifyTool visible-card scoping', () => {
           return { ...liveClarifyProps(), args, argsText: JSON.stringify(args), toolCallId }
         }
       
      +  /** Park a clarify on `sessionId` with its own live server request; the
      +   *  returned spy sees that request's (and only that request's) answer. */
         function parkClarify(requestId: string, sessionId: string) {
      +    const respond = liveServerRequest(requestId)
      +
           setClarifyRequest({
             choices: ['staging', 'production'],
             multiSelect: false,
      @@ -923,6 +933,8 @@ describe('ClarifyTool visible-card scoping', () => {
             requestId,
             sessionId
           })
      +
      +    return respond
         }
       
         /** A card inside an inactive tab layer — mounted and live, just not on screen. */
      @@ -937,11 +949,9 @@ describe('ClarifyTool visible-card scoping', () => {
         }
       
         it('answers the visible card, not a background one that mounted first', async () => {
      -    const request = vi.fn().mockResolvedValue({ ok: true })
      -
      -    $gateway.set({ request } as never)
      -    parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)
      -    parkClarify(FOREGROUND_REQUEST, FOREGROUND_SESSION)
      +    $gateway.set({ request: vi.fn() } as never)
      +    const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)
      +    const foreground = parkClarify(FOREGROUND_REQUEST, FOREGROUND_SESSION)
       
           // The background card is rendered FIRST, so its window listener registers
           // first. Registration order used to decide the winner, which meant the card
      @@ -958,29 +968,25 @@ describe('ClarifyTool visible-card scoping', () => {
           fireEvent.keyDown(window, { key: 'Enter' })
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledTimes(1)
      +      expect(foreground).toHaveBeenCalledTimes(1)
           })
       
      -    // Exactly one answer, carrying the FOREGROUND request id — the background
      -    // session's turn must not be resumed by a keystroke aimed at this one.
      -    expect(request).toHaveBeenCalledWith('clarify.respond', {
      -      answer: 'staging',
      -      request_id: FOREGROUND_REQUEST
      -    })
      +    // Exactly one answer, on the FOREGROUND request — the background session's
      +    // turn must not be resumed by a keystroke aimed at this one.
      +    expect(foreground).toHaveBeenCalledWith({ answer: 'staging' })
      +    expect(background).not.toHaveBeenCalled()
         })
       
         it('leaves the key alone when the only pending card is hidden', () => {
      -    const request = vi.fn().mockResolvedValue({ ok: true })
      -
      -    $gateway.set({ request } as never)
      -    parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)
      +    $gateway.set({ request: vi.fn() } as never)
      +    const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)
       
           renderClarify(backgroundCard())
       
           // Untouched (no preventDefault) ⇒ the keystroke stays available to the
           // composer, matching what `clarifyCardOwnsKey` reports with no visible card.
           expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)
      -    expect(request).not.toHaveBeenCalled()
      +    expect(background).not.toHaveBeenCalled()
         })
       
         /** A card in its own split zone — unlike `backgroundCard` this one IS on
      @@ -998,11 +1004,9 @@ describe('ClarifyTool visible-card scoping', () => {
       
         /** Both zones visible, zone-a first in document order. */
         function renderSplit() {
      -    const request = vi.fn().mockResolvedValue({ ok: true })
      -
      -    $gateway.set({ request } as never)
      -    parkClarify(ZONE_A_REQUEST, ZONE_A_SESSION)
      -    parkClarify(ZONE_B_REQUEST, ZONE_B_SESSION)
      +    $gateway.set({ request: vi.fn() } as never)
      +    const zoneA = parkClarify(ZONE_A_REQUEST, ZONE_A_SESSION)
      +    const zoneB = parkClarify(ZONE_B_REQUEST, ZONE_B_SESSION)
       
           renderClarify(
             <>
      @@ -1011,42 +1015,38 @@ describe('ClarifyTool visible-card scoping', () => {
             </>
           )
       
      -    return request
      +    return { zoneA, zoneB }
         }
       
         it('answers the later-in-document card when its zone is the focused one', async () => {
      -    const request = renderSplit()
      +    const { zoneA, zoneB } = renderSplit()
       
           $activeTreeGroup.set('zone-b')
           fireEvent.keyDown(window, { key: 'Enter' })
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledTimes(1)
      +      expect(zoneB).toHaveBeenCalledTimes(1)
           })
       
           // Both cards are visible and both hold a live window listener, so this is
           // the case document order gets wrong: it would answer zone-a's question.
      -    expect(request).toHaveBeenCalledWith('clarify.respond', {
      -      answer: 'staging',
      -      request_id: ZONE_B_REQUEST
      -    })
      +    expect(zoneB).toHaveBeenCalledWith({ answer: 'staging' })
      +    expect(zoneA).not.toHaveBeenCalled()
         })
       
         it('answers the other visible card once the focus moves to its zone', async () => {
      -    const request = renderSplit()
      +    const { zoneA, zoneB } = renderSplit()
       
           $activeTreeGroup.set('zone-a')
           fireEvent.keyDown(window, { key: 'Enter' })
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledTimes(1)
      +      expect(zoneA).toHaveBeenCalledTimes(1)
           })
       
           // The direct pin for "the other visible card then cannot receive its
           // shortcut": neither zone may be permanently starved of its own keys.
      -    expect(request).toHaveBeenCalledWith('clarify.respond', {
      -      answer: 'staging',
      -      request_id: ZONE_A_REQUEST
      -    })
      +    expect(zoneA).toHaveBeenCalledWith({ answer: 'staging' })
      +    expect(zoneB).not.toHaveBeenCalled()
         })
       })
    • apps/desktop/src/components/assistant-ui/clarify-tool.tsx
      @@ -39,6 +39,7 @@ import {
       } from '@/store/clarify'
       import { $gateway } from '@/store/gateway'
       import { notifyError } from '@/store/notifications'
      +import { forgetServerRequest, respondToServerRequest } from '@/store/server-requests'
       import { requestForOwnedSession } from '@/store/session-states'
       
       import { handleClarifySubmitShortcut } from './clarify-submit-shortcut'
      @@ -476,22 +477,9 @@ function ClarifyToolSinglePending({
             setSubmitting(true)
       
             try {
      -        // Route through the session's OWNER (tile route → hint → tagged row);
      -        // legacy ambient is allowed only when it is provably the sole backend.
      -        // The ambient socket follows foreground focus, so after a profile / Bot
      -        // Chat switch it can point at a backend that never held this clarify —
      -        // and the owner stays blocked (#91684 client half, like approval.respond).
      -        await requestForOwnedSession<{ ok?: boolean }>(
      -          matchingRequest.sessionId,
      -          // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg
      -          // call shape gateway.request callers assert on.
      -          gateway.request.bind(gateway) as typeof gateway.request,
      -          'clarify.respond',
      -          {
      -            request_id: matchingRequest.requestId,
      -            answer
      -          }
      -        )
      +        // The response frame goes back over the socket the request arrived on —
      +        // the owner backend by construction (#91684's class cannot recur).
      +        respondToServerRequest(matchingRequest.requestId, { answer })
               triggerHaptic('submit')
               onAnswered()
               clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
      @@ -1036,21 +1024,18 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo
           setSubmitting(true)
       
           try {
      -      // Sequential, not Promise.all: the LAST lock resolves the blocked tool
      -      // server-side, so every earlier lock must already be accepted when it
      -      // lands — a reordered burst could complete the batch with a missing
      -      // answer.
      -      //
      -      // Each lock rides the session's OWNER socket, not the ambient one: a
      -      // profile / Bot Chat switch re-points ambient at a backend that never
      -      // held this batch, which would leave the owner blocked.
      +      // Sequential, not Promise.all: the LAST lock resolves the blocked
      +      // server request, so every earlier lock must already be accepted when
      +      // it lands — a reordered burst could complete the batch with a missing
      +      // answer. `clarify.lock` is a normal RPC; it rides the session's OWNER
      +      // socket (a profile / Bot Chat switch re-points ambient elsewhere).
             for (const question of questions) {
               const answer = stagedAnswer(question)
       
      -        await requestForOwnedSession<{ ok?: boolean }>(
      +        await requestForOwnedSession<{ remaining?: string[]; status?: string }>(
                 request.sessionId,
                 gateway.request.bind(gateway) as typeof gateway.request,
      -          'clarify.respond',
      +          'clarify.lock',
                 {
                   answer: answer ?? '',
                   question_id: question.qid,
      @@ -1059,6 +1044,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo
               )
             }
       
      +      forgetServerRequest(request.requestId)
      +
             triggerHaptic('submit')
             onAnswered()
             // tool.complete lands next → ClarifyToolBatchSettled.
      @@ -1095,20 +1082,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo
           onAnswered()
           clearClarifyRequest(request.requestId, request.sessionId)
       
      -    try {
      -      if (gateway) {
      -        // Owner-routed like the locks above — a skip sent to the wrong backend
      -        // is a silent no-op that leaves the agent waiting out its timeout.
      -        await requestForOwnedSession(
      -          request.sessionId,
      -          gateway.request.bind(gateway) as typeof gateway.request,
      -          'clarify.respond',
      -          { answer: '', request_id: request.requestId }
      -        )
      -      }
      -    } catch {
      -      // The tool times out on its own; a failed skip must never block the UI.
      -    }
      +    // A response with no `answers` is the cancel-all (the plain Esc path).
      +    respondToServerRequest(request.requestId, {})
         }, [gateway, onAnswered, request])
       
         const handleSubmit = useCallback(
    • apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx
      @@ -29,6 +29,7 @@ import { cn } from '@/lib/utils'
       import { $gateway } from '@/store/gateway'
       import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup'
       import { notifyError } from '@/store/notifications'
      +import { respondToServerRequest } from '@/store/server-requests'
       import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp'
       
       import { selectMessageRunning } from './tool/fallback-model'
      @@ -229,15 +230,8 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
               invalidateMcpSuggestionIndex()
             }
       
      -      try {
      -        await gateway.request<{ status?: string }>('mcp.setup.respond', {
      -          request_id: request.requestId,
      -          result: JSON.stringify(outcome)
      -        })
      -        // tool.complete lands next → McpSetupSettled.
      -      } catch (error) {
      -        notifyError(error, copy.sendFailed)
      -      }
      +      respondToServerRequest(request.requestId, { value: JSON.stringify(outcome) })
      +      // tool.complete lands next → McpSetupSettled.
           },
           [copy.gatewayDisconnected, copy.reloadFailed, copy.sendFailed, gateway, request]
         )
    • apps/desktop/src/components/assistant-ui/tool/approval.test.tsx
      @@ -1,9 +1,10 @@
       import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
      -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
      +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
       
       import type { HermesGateway } from '@/hermes'
       import { $gateway } from '@/store/gateway'
       import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts'
      +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
       import { $activeSessionId } from '@/store/session'
       
       import { PendingApprovalFallback, PendingToolApproval } from './approval'
      @@ -33,12 +34,20 @@ function part(toolName: string): ToolPart {
       function setRequest(
         command = 'rm -rf /tmp/x',
         allowPermanent?: boolean,
      -  extra: { choices?: string[]; smartDenied?: boolean } = {}
      +  extra: { choices?: string[]; requestId?: string; serverRequestId?: string; smartDenied?: boolean } = {}
       ) {
         $activeSessionId.set('sess-1')
         setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1', ...extra })
       }
       
      +/** A live `approval` server request the card answers synchronously. */
      +function liveApproval(id = 'srq-approval') {
      +  const respond = vi.fn()
      +  rememberServerRequest({ fail: vi.fn(), id, method: 'approval', params: {}, respond })
      +
      +  return respond
      +}
      +
       function mockGateway() {
         const request = vi.fn().mockResolvedValue({ resolved: true })
         $gateway.set({ request } as unknown as HermesGateway)
      @@ -46,9 +55,14 @@ function mockGateway() {
         return request
       }
       
      +beforeEach(() => {
      +  resetServerRequestsForTests()
      +})
      +
       afterEach(() => {
         cleanup()
         clearAllPrompts()
      +  resetServerRequestsForTests()
         $activeSessionId.set(null)
         $gateway.set(null)
       })
      @@ -83,15 +97,37 @@ describe('PendingToolApproval', () => {
           expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy()
         })
       
      -  it('sends approval.respond {choice: "once"} and clears the request on Run', async () => {
      +  it('answers the live approval request with {choice: "once"} and clears the request on Run', async () => {
           const request = mockGateway()
      -    setRequest()
      +    const respond = liveApproval()
      +    setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1', serverRequestId: 'srq-approval' })
      +    render(<PendingToolApproval part={part('terminal')} />)
      +
      +    fireEvent.click(screen.getByRole('button', { name: /Run/ }))
      +
      +    await waitFor(() => {
      +      expect(respond).toHaveBeenCalledWith({ choice: 'once' })
      +    })
      +    expect(hasOpenServerRequest('srq-approval')).toBe(false)
      +    expect(request).not.toHaveBeenCalledWith('approval.respond', expect.anything())
      +    expect($approvalRequest.get()).toBeNull()
      +  })
      +
      +  it('falls back to the approval.respond RPC when no live server request is registered', async () => {
      +    // A prompt restored from `approval.pending` (no socket carried the frame):
      +    // the queue-level RPC is the only way to answer it.
      +    const request = mockGateway()
      +    setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1' })
           render(<PendingToolApproval part={part('terminal')} />)
       
           fireEvent.click(screen.getByRole('button', { name: /Run/ }))
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' })
      +      expect(request).toHaveBeenCalledWith('approval.respond', {
      +        choice: 'once',
      +        request_id: 'apr-1',
      +        session_id: 'sess-1'
      +      })
           })
           expect($approvalRequest.get()).toBeNull()
         })
      @@ -109,15 +145,16 @@ describe('PendingToolApproval', () => {
           expect(screen.getByText(longCommand)).toBeTruthy()
         })
       
      -  it('sends choice "deny" on Reject', async () => {
      -    const request = mockGateway()
      -    setRequest()
      +  it('answers the live approval request with {choice: "deny"} on Reject', async () => {
      +    mockGateway()
      +    const respond = liveApproval()
      +    setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1', serverRequestId: 'srq-approval' })
           render(<PendingToolApproval part={part('terminal')} />)
       
           fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
       
           await waitFor(() => {
      -      expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
      +      expect(respond).toHaveBeenCalledWith({ choice: 'deny' })
           })
         })
       
    • apps/desktop/src/components/assistant-ui/tool/approval.tsx
      @@ -21,6 +21,7 @@ import { isSubmitEnter } from '@/lib/ime'
       import { cn } from '@/lib/utils'
       import { $gateway } from '@/store/gateway'
       import { notifyError } from '@/store/notifications'
      +import { answerApproval } from '@/store/prompts'
       import {
         type ApprovalRequest,
         clearApprovalRequest,
      @@ -29,7 +30,6 @@ import {
         sessionApprovalInlineVisible,
         sessionApprovalRequest
       } from '@/store/prompts'
      -import { requestForOwnedSession } from '@/store/session-states'
       
       import type { ToolPart } from './fallback-model'
       
      @@ -145,22 +145,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
             setSubmitting(choice)
       
             try {
      -        // Route through the session's OWNER (tile route → known profile);
      -        // ambient only when no owner is known. The ambient socket follows
      -        // foreground focus, and for a cross-profile session it points at a
      -        // backend that never held this approval (#91684 client half).
      -        await requestForOwnedSession<{ resolved?: boolean }>(
      -          request.sessionId,
      -          // Bound (not wrapped) so the ambient fallback keeps the exact
      -          // 2-arg call shape gateway.request callers assert on.
      -          gateway.request.bind(gateway) as typeof gateway.request,
      -          'approval.respond',
      -          {
      -            choice,
      -            request_id: request.requestId,
      -            session_id: request.sessionId ?? undefined
      -          }
      -        )
      +        // Live prompt: the response frame rides the socket the request came on
      +        // (the owner backend by construction). Restored prompt: queue-level
      +        // `approval.respond`, owner-routed (#91684 client half).
      +        await answerApproval(gateway, request, choice)
               triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
               clearApprovalRequest(request.sessionId, request.requestId)
               void replayPendingApproval(gateway, request.sessionId).catch(() => undefined)
      @@ -169,7 +157,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
               setSubmitting(null)
             }
           },
      -    [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request.requestId, request.sessionId]
      +    [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request]
         )
       
         // ⌘/Ctrl+Enter → Run, Esc → Reject.
    • apps/desktop/src/components/prompt-overlays.test.tsx
      @@ -1,10 +1,11 @@
       import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
      -import { afterEach, describe, expect, it, vi } from 'vitest'
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
       
       import { I18nProvider } from '@/i18n'
       import { $gateway } from '@/store/gateway'
       import { notifyError } from '@/store/notifications'
       import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts'
      +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
       import { $activeSessionId } from '@/store/session'
       
       import { PromptOverlays } from './prompt-overlays'
      @@ -20,20 +21,48 @@ function renderPrompts(sessionId: string | null = 's1') {
         )
       }
       
      +beforeEach(() => {
      +  resetServerRequestsForTests()
      +})
      +
       afterEach(() => {
         cleanup()
         clearAllPrompts()
      +  resetServerRequestsForTests()
         $activeSessionId.set(null)
         $gateway.set(null)
         vi.clearAllMocks()
       })
       
       describe('PromptOverlays', () => {
      -  it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => {
      -    const request = vi.fn().mockRejectedValue(new Error('no pending password request'))
      +  it('answers the live sudo request with an empty value on Cancel and clears the dialog', async () => {
      +    const respond = vi.fn()
      +    const request = vi.fn()
      +
      +    $activeSessionId.set('s1')
      +    $gateway.set({ request } as never)
      +    rememberServerRequest({ fail: vi.fn(), id: 'sudo-1', method: 'sudo', params: {}, respond })
      +    setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' })
      +
      +    renderPrompts()
      +
      +    expect(screen.getByText('Administrator password')).toBeTruthy()
      +
      +    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
      +
      +    await waitFor(() => expect($sudoRequest.get()).toBeNull())
      +    expect(respond).toHaveBeenCalledWith({ value: '' })
      +    expect(hasOpenServerRequest('sudo-1')).toBe(false)
      +    expect(request).not.toHaveBeenCalled()
      +    expect(notifyError).not.toHaveBeenCalled()
      +  })
      +
      +  it('dismisses a stale sudo dialog when the gateway no longer has the request open', async () => {
      +    const request = vi.fn()
       
           $activeSessionId.set('s1')
           $gateway.set({ request } as never)
      +    // No server request registered under this id: it expired / was cancelled.
           setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' })
       
           renderPrompts()
      @@ -43,12 +72,34 @@ describe('PromptOverlays', () => {
           fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
       
           await waitFor(() => expect($sudoRequest.get()).toBeNull())
      -    expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
      +    expect(request).not.toHaveBeenCalled()
      +    expect(notifyError).not.toHaveBeenCalled()
      +  })
      +
      +  it('answers the live secret request with an empty value on Cancel and clears the dialog', async () => {
      +    const respond = vi.fn()
      +    const request = vi.fn()
      +
      +    $activeSessionId.set('s1')
      +    $gateway.set({ request } as never)
      +    rememberServerRequest({ fail: vi.fn(), id: 'secret-1', method: 'secret', params: {}, respond })
      +    setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' })
      +
      +    renderPrompts()
      +
      +    expect(screen.getByText('TEST_SECRET')).toBeTruthy()
      +
      +    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
      +
      +    await waitFor(() => expect($secretRequest.get()).toBeNull())
      +    expect(respond).toHaveBeenCalledWith({ value: '' })
      +    expect(hasOpenServerRequest('secret-1')).toBe(false)
      +    expect(request).not.toHaveBeenCalled()
           expect(notifyError).not.toHaveBeenCalled()
         })
       
      -  it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => {
      -    const request = vi.fn().mockRejectedValue(new Error('no pending value request'))
      +  it('dismisses a stale secret dialog when the gateway no longer has the request open', async () => {
      +    const request = vi.fn()
       
           $activeSessionId.set('s1')
           $gateway.set({ request } as never)
      @@ -61,7 +112,7 @@ describe('PromptOverlays', () => {
           fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
       
           await waitFor(() => expect($secretRequest.get()).toBeNull())
      -    expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
      +    expect(request).not.toHaveBeenCalled()
           expect(notifyError).not.toHaveBeenCalled()
         })
       })
    • apps/desktop/src/components/prompt-overlays.tsx
      @@ -33,8 +33,7 @@ import {
         sessionVaultSaveLoginRequest,
         sessionVaultUnlockRequest
       } from '@/store/prompts'
      -import { ambientRequestFor } from '@/store/session-gone-latch'
      -import { requestForOwnedSession } from '@/store/session-states'
      +import { respondToServerRequest } from '@/store/server-requests'
       
       // Renders the modal mid-turn prompts the gateway raises and waits on: sudo
       // password and skill secret capture. Dangerous-command / execute_code approval
      @@ -78,10 +77,7 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) {
             setSubmitting(true)
       
             try {
      -        await gateway.request<{ status?: string }>('sudo.respond', {
      -          password: [redacted:secret-assignment]
      -          request_id: request.requestId
      -        })
      +        respondToServerRequest(request.requestId, { value })
               triggerHaptic('submit')
               clearSudoRequest(request.sessionId, request.requestId)
             } catch (error) {
      @@ -181,10 +177,7 @@ function SecretDialog({ sessionId }: { sessionId: string | null }) {
             setSubmitting(true)
       
             try {
      -        await gateway.request<{ status?: string }>('secret.respond', {
      -          request_id: request.requestId,
      -          value: secret
      -        })
      +        respondToServerRequest(request.requestId, { value: secret })
               triggerHaptic('submit')
               clearSecretRequest(request.sessionId, request.requestId)
             } catch (error) {
      @@ -285,14 +278,9 @@ function VaultUnlockDialog({ sessionId }: { sessionId: string | null }) {
             setSubmitting(true)
       
             try {
      -        // A master password must reach the backend that raised the prompt, not whatever
      -        // gateway is foreground right now (background profile tiles have their own socket).
      -        await requestForOwnedSession<{ status?: string }>(
      -          request.sessionId,
      -          ambientRequestFor(gateway),
      -          'vault.unlock.respond',
      -          { request_id: request.requestId, password }
      -        )
      +        // The response frame goes back over the socket the request arrived on — the
      +        // backend that raised the prompt, never whatever gateway is foreground.
      +        respondToServerRequest(request.requestId, { value: password })
               triggerHaptic('submit')
               clearVaultUnlockRequest(request.sessionId, request.requestId)
             } catch (error) {
      @@ -387,12 +375,7 @@ function VaultSaveLoginDialog({ sessionId }: { sessionId: string | null }) {
             setSubmitting(true)
       
             try {
      -        await requestForOwnedSession<{ status?: string }>(
      -          request.sessionId,
      -          ambientRequestFor(gateway),
      -          'vault.save_login.respond',
      -          { login, request_id: request.requestId }
      -        )
      +        respondToServerRequest(request.requestId, { value: login })
               triggerHaptic('submit')
               clearVaultSaveLoginRequest(request.sessionId, request.requestId)
             } catch (error) {
      @@ -504,12 +487,7 @@ function VaultCodeDialog({ sessionId }: { sessionId: string | null }) {
             setSubmitting(true)
       
             try {
      -        await requestForOwnedSession<{ status?: string }>(
      -          request.sessionId,
      -          ambientRequestFor(gateway),
      -          'vault.code.respond',
      -          { code: value, request_id: request.requestId }
      -        )
      +        respondToServerRequest(request.requestId, { value })
               triggerHaptic('submit')
               clearVaultCodeRequest(request.sessionId, request.requestId)
             } catch (error) {
    • apps/desktop/src/components/prompt-overlays.vault-code.test.tsx
      @@ -1,16 +1,8 @@
       import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
      -import { afterEach, expect, it, vi } from 'vitest'
      +import { afterEach, beforeEach, expect, it, vi } from 'vitest'
       
       import { stubResizeObserver } from '@/test/jsdom'
       
      -const gatewayMocks = vi.hoisted(() => ({
      -  requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' }))
      -}))
      -
      -vi.mock('@/store/gateway', async importActual => ({
      -  ...(await importActual<Record<string, unknown>>()),
      -  requestGatewayForAgent: gatewayMocks.requestGatewayForAgent
      -}))
       vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
       vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
       
      @@ -18,26 +10,35 @@ import { PromptOverlays } from '@/components/prompt-overlays'
       import { $gateway } from '@/store/gateway'
       import { $profiles } from '@/store/profile'
       import { clearAllPrompts, sessionVaultCodeRequest, setVaultCodeRequest } from '@/store/prompts'
      +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
       import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
       
       stubResizeObserver()
       
      +beforeEach(() => {
      +  resetServerRequestsForTests()
      +})
      +
       afterEach(() => {
         cleanup()
         clearAllPrompts()
      +  resetServerRequestsForTests()
         _resetSessionOwnerHintsForTests()
         $gateway.set(null)
         vi.clearAllMocks()
       })
       
      -// The 2FA code goes to the OWNING profile socket as vault.code.respond, whitespace/dashes stripped
      -// (users paste "246 810" from an SMS); Skip answers "".
      -it('sends the trimmed code to the owning profile socket', async () => {
      +// The 2FA code answers the `vault.code` server request (the frame rides the socket the
      +// request arrived on, never the ambient gateway), whitespace/dashes stripped (users paste
      +// "246 810" from an SMS); Skip answers "".
      +it('answers the vault.code server request with the trimmed code, never the ambient gateway', async () => {
         $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never)
         setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })
         const ambient = vi.fn().mockResolvedValue({ status: 'ok' })
      +  const respond = vi.fn()
         $activeSessionId.set('session-b')
         $gateway.set({ request: ambient } as never)
      +  rememberServerRequest({ fail: vi.fn(), id: 'req-c', method: 'vault.code', params: {}, respond })
         setVaultCodeRequest({ hint: '', requestId: 'req-c', sessionId: 'session-a', site: 'github.com' })
       
         render(<PromptOverlays sessionId="session-a" />)
      @@ -49,13 +50,9 @@ it('sends the trimmed code to the owning profile socket', async () => {
         expect(submit.disabled).toBe(false)
         fireEvent.submit(input.closest('form')!)
       
      -  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))
      -  expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[]).slice(0, 4)).toEqual([
      -    'conn-1',
      -    'owner',
      -    'vault.code.respond',
      -    { code: '246810', request_id: 'req-c' }
      -  ])
      +  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))
      +  expect(respond).toHaveBeenCalledWith({ value: '246810' })
      +  expect(hasOpenServerRequest('req-c')).toBe(false)
         expect(ambient).not.toHaveBeenCalled()
         await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull())
       })
      @@ -63,13 +60,15 @@ it('sends the trimmed code to the owning profile socket', async () => {
       it('Skip answers an empty code and clears the card', async () => {
         $profiles.set([{ name: 'owner' }] as never)
         setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })
      +  const respond = vi.fn()
         $gateway.set({ request: vi.fn() } as never)
      +  rememberServerRequest({ fail: vi.fn(), id: 'req-d', method: 'vault.code', params: {}, respond })
         setVaultCodeRequest({ hint: '', requestId: 'req-d', sessionId: 'session-a', site: 'github.com' })
       
         render(<PromptOverlays sessionId="session-a" />)
         fireEvent.click(Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Skip')!)
       
      -  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))
      -  expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ code: '', request_id: 'req-d' })
      +  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))
      +  expect(respond).toHaveBeenCalledWith({ value: '' })
         await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull())
       })
    • apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx
      @@ -1,16 +1,8 @@
       import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
      -import { afterEach, expect, it, vi } from 'vitest'
      +import { afterEach, beforeEach, expect, it, vi } from 'vitest'
       
       import { stubResizeObserver } from '@/test/jsdom'
       
      -const gatewayMocks = vi.hoisted(() => ({
      -  requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' }))
      -}))
      -
      -vi.mock('@/store/gateway', async importActual => ({
      -  ...(await importActual<Record<string, unknown>>()),
      -  requestGatewayForAgent: gatewayMocks.requestGatewayForAgent
      -}))
       vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
       vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
       
      @@ -18,26 +10,36 @@ import { PromptOverlays } from '@/components/prompt-overlays'
       import { $gateway } from '@/store/gateway'
       import { $profiles } from '@/store/profile'
       import { clearAllPrompts, sessionVaultSaveLoginRequest, setVaultSaveLoginRequest } from '@/store/prompts'
      +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
       import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
       
       stubResizeObserver()
       
      +beforeEach(() => {
      +  resetServerRequestsForTests()
      +})
      +
       afterEach(() => {
         cleanup()
         clearAllPrompts()
      +  resetServerRequestsForTests()
         _resetSessionOwnerHintsForTests()
         $gateway.set(null)
         vi.clearAllMocks()
       })
       
      -// The "save this login" card is the zero-setup path: the pair goes to the OWNING profile's socket as
      -// one JSON answer, the password field is masked, and Save is disabled until both fields are filled.
      -it('sends identifier + password as one vault.save_login.respond to the owning profile socket', async () => {
      +// The "save this login" card is the zero-setup path: the pair answers the `vault.save_login`
      +// server request as one JSON value (the frame rides the socket the request arrived on, never
      +// the ambient gateway), the password field is masked, and Save is disabled until both fields
      +// are filled.
      +it('answers the vault.save_login server request with identifier + password as one JSON value', async () => {
         $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never)
         setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })
         const ambient = vi.fn().mockResolvedValue({ status: 'ok' })
      +  const respond = vi.fn()
         $activeSessionId.set('session-b')
         $gateway.set({ request: ambient } as never)
      +  rememberServerRequest({ fail: vi.fn(), id: 'req-s', method: 'vault.save_login', params: {}, respond })
         setVaultSaveLoginRequest({
           origin: 'https://github.com',
           requestId: 'req-s',
      @@ -57,13 +59,13 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr
         expect(submit.disabled).toBe(false)
         fireEvent.submit(password.closest('form')!)
       
      -  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))
      -  const [conn, profile, method, params] = gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[]
      -  expect([conn, profile, method]).toEqual(['conn-1', 'owner', 'vault.save_login.respond'])
      -  expect(JSON.parse((params as { login: string }).login)).toEqual({
      +  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))
      +  const [result] = respond.mock.calls[0] as [{ value: string }]
      +  expect(JSON.parse(result.value)).toEqual({
           identifier: '[redacted:email]',
           password: '[redacted:secret-assignment]'
         })
      +  expect(hasOpenServerRequest('req-s')).toBe(false)
         expect(ambient).not.toHaveBeenCalled()
         await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull())
       })
      @@ -71,7 +73,9 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr
       it("Don't save answers an empty login and clears the card", async () => {
         $profiles.set([{ name: 'owner' }] as never)
         setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })
      +  const respond = vi.fn()
         $gateway.set({ request: vi.fn() } as never)
      +  rememberServerRequest({ fail: vi.fn(), id: 'req-d', method: 'vault.save_login', params: {}, respond })
         setVaultSaveLoginRequest({
           origin: 'https://github.com',
           requestId: 'req-d',
      @@ -83,10 +87,7 @@ it("Don't save answers an empty login and clears the card", async () => {
         const decline = Array.from(document.querySelectorAll('button')).find(b => b.textContent === "Don't save")!
         fireEvent.click(decline)
       
      -  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))
      -  expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({
      -    login: '',
      -    request_id: 'req-d'
      -  })
      +  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))
      +  expect(respond).toHaveBeenCalledWith({ value: '' })
         await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull())
       })
    • apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx
      @@ -1,16 +1,8 @@
       import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
      -import { afterEach, expect, it, vi } from 'vitest'
      +import { afterEach, beforeEach, expect, it, vi } from 'vitest'
       
       import { stubResizeObserver } from '@/test/jsdom'
       
      -const gatewayMocks = vi.hoisted(() => ({
      -  requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' }))
      -}))
      -
      -vi.mock('@/store/gateway', async importActual => ({
      -  ...(await importActual<Record<string, unknown>>()),
      -  requestGatewayForAgent: gatewayMocks.requestGatewayForAgent
      -}))
       vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
       vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
       
      @@ -18,13 +10,19 @@ import { PromptOverlays } from '@/components/prompt-overlays'
       import { $gateway } from '@/store/gateway'
       import { $profiles } from '@/store/profile'
       import { clearAllPrompts, sessionVaultUnlockRequest, setVaultUnlockRequest } from '@/store/prompts'
      +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
       import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'
       
       stubResizeObserver()
       
      +beforeEach(() => {
      +  resetServerRequestsForTests()
      +})
      +
       afterEach(() => {
         cleanup()
         clearAllPrompts()
      +  resetServerRequestsForTests()
         _resetSessionOwnerHintsForTests()
         $gateway.set(null)
         vi.clearAllMocks()
      @@ -33,12 +31,16 @@ afterEach(() => {
       // A master password typed into a background profile's unlock card must reach the
       // backend that raised the prompt. The window's ambient `$gateway` may be another
       // profile's socket entirely; sending the password there is a cross-backend leak.
      -it('routes the master password to the owning profile socket, never the ambient gateway', async () => {
      +// The answer is the `vault.unlock` server request's own response frame — it rides
      +// the socket the request arrived on, so the ambient gateway is never dialled.
      +it('answers the vault.unlock server request with the master password, never the ambient gateway', async () => {
         $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never)
         setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })
         const ambient = vi.fn().mockResolvedValue({ status: 'ok' })
      +  const respond = vi.fn()
         $activeSessionId.set('session-b')
         $gateway.set({ request: ambient } as never)
      +  rememberServerRequest({ fail: vi.fn(), id: 'req-a', method: 'vault.unlock', params: {}, respond })
         setVaultUnlockRequest({ backend: 'bitwarden', displayName: 'Bitwarden', requestId: 'req-a', sessionId: 'session-a' })
       
         render(<PromptOverlays sessionId="session-a" />)
      @@ -46,12 +48,9 @@ it('routes the master password to the owning profile socket, never the ambient g
         fireEvent.change(input, { target: { value: 'fixture-master' } })
         fireEvent.submit(input.closest('form')!)
       
      -  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))
      -  expect(gatewayMocks.requestGatewayForAgent.mock.calls[0].slice(0, 3)).toEqual([
      -    'conn-1',
      -    'owner',
      -    'vault.unlock.respond'
      -  ])
      +  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))
      +  expect(respond).toHaveBeenCalledWith({ value: 'fixture-master' })
      +  expect(hasOpenServerRequest('req-a')).toBe(false)
         expect(ambient).not.toHaveBeenCalled()
         await waitFor(() => expect(sessionVaultUnlockRequest('session-a').get()).toBeNull())
       })
    • apps/desktop/src/lib/gateway-events.test.ts
      @@ -34,7 +34,9 @@ describe('gateway event routing', () => {
           expect(gatewayEventRequiresSessionId('message.interim')).toBe(false)
           expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
           expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
      -    expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
      +    // Prompts are server→client requests now; the one prompt-related EVENT
      +    // left (`request.cancel`) is likewise the focused turn's own when unscoped.
      +    expect(gatewayEventRequiresSessionId('request.cancel')).toBe(false)
         })
       
         it('allows global events to remain unscoped', () => {
    • apps/desktop/src/plugins/hermes-bots/group-test-utils.ts
      @@ -85,6 +85,7 @@ export interface GatewayOptions {
         /** Per profile: carry `pending_approval` on its first `until` resumes. */
         approvalUntil?: Record<string, { payload: Record<string, unknown>; until: number }>
         /** Per profile: carry `pending_clarify` on its first `until` resumes. */
      +  /** `payload` is an open-request frame `{ id, method: 'clarify', params }`. */
         clarifyUntil?: Record<string, { payload: Record<string, unknown>; until: number }>
         /** Land a competing writer's `ui_meta` under `key` during the FIRST
          *  `profiles.configure`, then reject it as a CAS conflict — the race the
      @@ -275,7 +276,7 @@ export function createGroupGateway(options: GatewayOptions = {}): ScriptedGatewa
               running: false,
               session_id: session.runtime,
               session_key: session.stored,
      -        ...(clarify && seen <= clarify.until ? { pending_clarify: clarify.payload } : {}),
      +        ...(clarify && seen <= clarify.until ? { open_requests: [clarify.payload] } : {}),
               ...(approval && seen <= approval.until ? { pending_approval: approval.payload } : {})
             }
           }
    • apps/desktop/src/plugins/hermes-bots/group-turns.test.ts
      @@ -344,11 +344,12 @@ describe('reply selection (#94376)', () => {
       // command approval. Those live in a hidden session the room can't see, so the
       // poll mirrors them into the room and holds the turn open until they resolve.
       describe('clarify and approvals (#90694)', () => {
      +  /** The member's blocking clarify as `session.resume` now reports it: an
      +   *  open server→client request frame (`open_requests`). */
         const CLARIFY = {
      -    choices: ['staging', 'prod'],
      -    multi_select: false,
      -    question: 'Which env should I target?',
      -    request_id: 'req-clarify-1'
      +    id: 'req-clarify-1',
      +    method: 'clarify',
      +    params: { choices: ['staging', 'prod'], multi_select: false, question: 'Which env should I target?' }
         }
       
         const APPROVAL = {
      @@ -365,7 +366,7 @@ describe('clarify and approvals (#90694)', () => {
           const room = await loadRoom({
             clarifyUntil: { research: { payload: CLARIFY, until: 3 } },
             // The mirror pass runs while the question is still blocking — this is
      -      // the observable proof the gate inspected pending_clarify. Asserting
      +      // the observable proof the gate inspected open_requests. Asserting
             // on $groupNeedsYou/$groupClarify AFTER the turn lands proves nothing:
             // the clarify has already resolved and its mirror is gone by then.
             onResumePoll: () => {
      @@ -401,7 +402,7 @@ describe('clarify and approvals (#90694)', () => {
           const { chat, turns } = await loadRoom()
           const member: GroupMember = { name: 'research', title: '' }
       
      -    expect(turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })).toBe(true)
      +    expect(turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })).toBe(true)
       
           const mirrored = Object.values(chat.$groupClarify.get())
       
      @@ -414,7 +415,7 @@ describe('clarify and approvals (#90694)', () => {
           expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true)
       
           // Same request again: no new entry, identity preserved.
      -    turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
      +    turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })
       
           expect(Object.values(chat.$groupClarify.get())[0]).toBe(mirrored[0])
       
      @@ -425,45 +426,50 @@ describe('clarify and approvals (#90694)', () => {
           expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false)
         })
       
      -  it('never mirrors a question for older backends without pending_clarify', async () => {
      +  it('never mirrors a question for older backends without open_requests', async () => {
           const { chat, turns } = await loadRoom()
       
           expect(turns.syncGroupClarify('Core', { name: 'research' }, { messages: [] })).toBe(false)
           expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0)
         })
       
      -  it('routes an answer through clarify.respond and clears the mirror', async () => {
      +  it('answers the open request by id through request.answer and clears the mirror', async () => {
           const room = await loadRoom()
           const member: GroupMember = { name: 'research', title: '' }
       
      -    room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
      +    room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })
           await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, 'staging')
       
      -    expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([
      -      { answer: 'staging', request_id: 'req-clarify-1' }
      +    expect(room.gateway.rpcFor('request.answer').map(call => call.params)).toEqual([
      +      { id: 'req-clarify-1', result: { answer: 'staging' } }
           ])
           expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
         })
       
      -  it('sends one respond per batch question, in order', async () => {
      +  it('locks one batch question per clarify.lock call, in order', async () => {
           const room = await loadRoom()
           const member: GroupMember = { name: 'research', title: '' }
       
           room.turns.syncGroupClarify('Core', member, {
      -      pending_clarify: {
      -        questions: [
      -          { choices: ['staging', 'prod'], qid: 'q0', question: 'Env?' },
      -          { choices: [], qid: 'q1', question: 'Region?' }
      -        ],
      -        request_id: 'req-batch-1'
      -      }
      +      open_requests: [
      +        {
      +          id: 'req-batch-1',
      +          method: 'clarify',
      +          params: {
      +            questions: [
      +              { choices: ['staging', 'prod'], qid: 'q0', question: 'Env?' },
      +              { choices: [], qid: 'q1', question: 'Region?' }
      +            ]
      +          }
      +        }
      +      ]
           })
           await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, {
             q0: 'staging',
             q1: 'eu-west'
           })
       
      -    expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([
      +    expect(room.gateway.rpcFor('clarify.lock').map(call => call.params)).toEqual([
             { answer: 'staging', question_id: 'q0', request_id: 'req-batch-1' },
             { answer: 'eu-west', question_id: 'q1', request_id: 'req-batch-1' }
           ])
      @@ -473,8 +479,8 @@ describe('clarify and approvals (#90694)', () => {
         it('clears only the disbanded room’s mirrored questions', async () => {
           const room = await loadRoom()
       
      -    room.turns.syncGroupClarify('Core', { name: 'research' }, { pending_clarify: CLARIFY })
      -    room.turns.syncGroupClarify('Other', { name: 'ops' }, { pending_clarify: { ...CLARIFY, request_id: 'req-2' } })
      +    room.turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY] })
      +    room.turns.syncGroupClarify('Other', { name: 'ops' }, { open_requests: [{ ...CLARIFY, id: 'req-2' }] })
           room.turns.clearGroupClarify('Core')
       
           const remaining = Object.values(room.chat.$groupClarify.get())
      @@ -489,7 +495,7 @@ describe('clarify and approvals (#90694)', () => {
         it('keeps pending prompts independent from mention attention through their lifecycle', async () => {
           const { chat, turns } = await loadRoom()
           const member = { name: 'research', title: '' }
      -    turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
      +    turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })
           expect(chat.$groupNeedsYou.get().Core).toBeFalsy()
           turns.syncGroupClarify('Core', { name: 'ops' }, { pending_approval: APPROVAL })
           expect(Object.values(chat.$groupClarify.get())).toHaveLength(2)
      @@ -540,7 +546,7 @@ describe('clarify and approvals (#90694)', () => {
                   submitted = true
                 }
       
      -          if (method === 'clarify.respond') {
      +          if (method === 'request.answer') {
                   answered = true
                 }
       
      @@ -550,7 +556,7 @@ describe('clarify and approvals (#90694)', () => {
                     await held
                   }
       
      -            return { ...result, pending_clarify: CLARIFY }
      +            return { ...result, open_requests: [CLARIFY] }
                 }
       
                 return result
      @@ -725,7 +731,7 @@ describe('clarify and approvals (#90694)', () => {
       
             if (recreate) {
               room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-room' }))
      -        room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })
      +        room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })
             }
       
             const roomsBefore = structuredClone(room.chat.$groupChats.get())
      @@ -789,7 +795,7 @@ describe('clarify and approvals (#90694)', () => {
               }
             }
       
      -      return { running: true, pending_clarify: CLARIFY }
      +      return { running: true, open_requests: [CLARIFY] }
           }
       
           await room.rounds.runGroupChatRounds('Core', members, 'thread')
      @@ -889,14 +895,14 @@ describe('clarify and approvals (#90694)', () => {
           expect(room.gateway.rpcFor('approval.respond').map(call => call.params)).toEqual([
             { choice: 'once', request_id: 'req-approval-1', session_id: 'rt-research-1' }
           ])
      -    expect(room.gateway.rpcFor('clarify.respond')).toHaveLength(0)
      +    expect(room.gateway.rpcFor('request.answer')).toHaveLength(0)
           expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)
         })
       
         it('lets clarify outrank approval when a snapshot carries both', async () => {
           const { chat, turns } = await loadRoom()
       
      -    turns.syncGroupClarify('Core', { name: 'research' }, { pending_approval: APPROVAL, pending_clarify: CLARIFY })
      +    turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY], pending_approval: APPROVAL })
       
           const entry = Object.values(chat.$groupClarify.get())[0]
       
    • apps/desktop/src/plugins/hermes-bots/group-turns.ts
      @@ -101,8 +101,10 @@ interface GroupSessionSnapshot {
         inflight?: boolean
         message_count?: number
         messages?: GroupTurnTranscriptMessage[]
      +  /** Still-open server→client requests (`server_requests.open_requests`); the
      +   *  member's blocking clarify question lives here as `{ id, method: 'clarify', params }`. */
      +  open_requests?: { id: string; method: string; params: Record<string, unknown> }[]
         pending_approval?: GroupPendingApproval
      -  pending_clarify?: GroupPendingClarify
         running?: boolean
         session_id?: string
         session_key?: string
      @@ -437,13 +439,18 @@ const GROUP_TURN_HARD_CAP_MS = 20 * 60000
        *  `${group}::${memberKey}` (#90694). Returns true while a prompt is
        *  blocking, so the turn poll can extend its deadline — a waiting prompt
        *  must not be eaten by the group-turn timeout. Feature-detected: older
      - *  backends without `pending_clarify`/`pending_approval` in the resume
      + *  backends without `open_requests`/`pending_approval` in the resume
        *  payload always sync to "no prompt". Clarify wins when both are somehow
        *  present (approvals resolve inside tool batches; clarify is the outer
        *  blocker). */
       export function syncGroupClarify(group: string, member: GroupMember, state: GroupSessionSnapshot | null): boolean {
         const key = `${group}::${groupMemberKey(member)}`
      -  const clarify = state && typeof state.pending_clarify === 'object' ? state.pending_clarify : null
      +  const openClarify = Array.isArray(state?.open_requests)
      +    ? state.open_requests.find(entry => entry?.method === 'clarify' && typeof entry.id === 'string' && entry.id)
      +    : null
      +  const clarify: GroupPendingClarify | null = openClarify
      +    ? { ...(openClarify.params as GroupPendingClarify), request_id: openClarify.id }
      +    : null
       
         // The `!requestId` bail below is what makes the approval branch reachable,
         // so an approval read there is never the null arm of this ternary — a fact
      @@ -586,15 +593,16 @@ export function renameGroupClarify(oldName: string, newName: string) {
         }
       }
       
      -/** Answer a member's pending prompt from the room. Routes to the member's
      - *  OWN source (requestForBot), so cross-connection members work.
      - *  - clarify: `clarify.respond`; batch questions send one respond per
      - *    question, sequentially — the LAST lock resolves the blocked tool
      - *    server-side (same contract as the 1:1 batch card). allow_expired
      - *    server-side makes racing the timeout harmless.
      +/** Answer a member's pending prompt from the room. The prompt was mirrored
      + *  from the member's resume snapshot (`open_requests` / `pending_approval`), so
      + *  this window never held the live server request: answer through RPCs routed
      + *  to the member's OWN source (requestForBot), so cross-connection members work.
      + *  - clarify: `clarify.lock` per question, sequentially — the LAST lock
      + *    resolves the blocked server request (same contract as the 1:1 batch
      + *    card). A single question answers the open request by id through
      + *    `request.answer` (the cross-socket proxy for a response frame).
        *  - approval: `approval.respond` with the choice (once/session/always/deny),
      - *    keyed by session + request_id — the same wire the 1:1 approval card
      - *    and native notifications use. */
      + *    keyed by session + request_id — the queue-level wire every surface shares. */
       export async function answerGroupClarify(
         entry: GroupPrompt,
         member: GroupMember,
      @@ -618,16 +626,16 @@ export async function answerGroupClarify(
               // Question ids are opaque on the wire (`GroupPrompt.questions` types
               // them `unknown`); the batch card keys its answer bag by exactly them.
               const qid = (question?.qid ?? question?.id) as string
      -        await requestForBot(member, 'clarify.respond', {
      +        await requestForBot(member, 'clarify.lock', {
                 request_id: entry.requestId,
                 question_id: qid,
                 answer: (answers as Record<string, string>)?.[qid] ?? ''
               })
             }
           } else {
      -      await requestForBot(member, 'clarify.respond', {
      -        request_id: entry.requestId,
      -        answer: typeof answers === 'string' ? answers : ''
      +      await requestForBot(member, 'request.answer', {
      +        id: entry.requestId,
      +        result: { answer: typeof answers === 'string' ? answers : '' }
             })
           }
       
    • apps/desktop/src/store/clarify.test.ts
      @@ -12,6 +12,7 @@ import {
         skipClarifyRequest
       } from './clarify'
       import { $gateway } from './gateway'
      +import { rememberServerRequest, resetServerRequestsForTests } from './server-requests'
       import { $activeSessionId } from './session'
       
       function clarify(sessionId: string | null, requestId: string): ClarifyRequest {
      @@ -91,6 +92,7 @@ describe('skipClarifyRequest', () => {
       
         beforeEach(() => {
           $clarifyRequests.set({})
      +    resetServerRequestsForTests()
           request.mockClear()
           $gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
         })
      @@ -101,12 +103,15 @@ describe('skipClarifyRequest', () => {
         })
       
         it('answers the session\u2019s clarify with an empty answer and drops it', async () => {
      +    const respond = vi.fn()
      +
      +    rememberServerRequest({ fail: vi.fn(), id: 'req-a', method: 'clarify', params: {}, respond })
           setClarifyRequest(clarify('session-a', 'req-a'))
           setClarifyRequest(clarify('session-b', 'req-b'))
       
           await expect(skipClarifyRequest('session-a')).resolves.toBe(true)
       
      -    expect(request).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-a', answer: '' })
      +    expect(respond).toHaveBeenCalledWith({ answer: '' })
           expect(hasClarifyRequest('session-a')).toBe(false)
           // A background session's question is untouched — only the one being typed
           // over is skipped.
      @@ -118,9 +123,8 @@ describe('skipClarifyRequest', () => {
           expect(request).not.toHaveBeenCalled()
         })
       
      -  it('still reports the skip when the respond RPC fails', async () => {
      +  it('still reports the skip when the server request is already gone (expired / other window answered)', async () => {
           setClarifyRequest(clarify('session-a', 'req-a'))
      -    request.mockRejectedValueOnce(new Error('socket closed'))
       
           await expect(skipClarifyRequest('session-a')).resolves.toBe(true)
           expect(hasClarifyRequest('session-a')).toBe(false)
    • apps/desktop/src/store/clarify.ts
      @@ -1,6 +1,6 @@
       import { atom, computed } from 'nanostores'
       
      -import { $gateway } from './gateway'
      +import { respondToServerRequest } from './server-requests'
       import { $activeSessionId } from './session'
       
       export interface ClarifyQuestion {
      @@ -187,8 +187,8 @@ export const hasClarifyRequest = (sessionId: string | null | undefined): boolean
        * (default 5 min) — the message looks sent and nothing happens. Skipping lets
        * the tool return and the turn carry on with the user's actual words.
        *
      - * An empty answer is the same thing the card's own Skip button sends, and
      - * `clarify.respond` is `allow_expired`, so racing the timeout is harmless.
      + * An empty answer is the same thing the card's own Skip button sends; answering
      + * a request that already expired is a no-op, so racing the timeout is harmless.
        */
       export async function skipClarifyRequest(sessionId: string | null | undefined): Promise<boolean> {
         const request = $clarifyRequests.get()[keyFor(sessionId)]
      @@ -201,12 +201,7 @@ export async function skipClarifyRequest(sessionId: string | null | undefined):
         // leave a live card the user can answer a second time.
         clearClarifyRequest(request.requestId, request.sessionId)
       
      -  try {
      -    await $gateway.get()?.request('clarify.respond', { request_id: request.requestId, answer: '' })
      -  } catch {
      -    // The tool times out on its own; a failed skip must never swallow the
      -    // message the user is actually sending.
      -  }
      +  respondToServerRequest(request.requestId, { answer: '' })
       
         return true
       }
    • apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts
      @@ -129,7 +129,7 @@ afterEach(() => {
       })
       
       describe('profile-only secondary approval ownership', () => {
      -  it('keeps a profile-only secondary event owner across a focus switch and dispatches approval on that secondary', async () => {
      +  it('keeps a profile-only secondary event owner across a focus switch and dispatches the approval.respond fallback on that secondary', async () => {
           await expect(ensureGatewayForProfile('research')).resolves.toBeUndefined()
           const secondary = gatewayMocks.instances[0]
       
      @@ -138,7 +138,10 @@ describe('profile-only secondary approval ownership', () => {
           expect($sessionTiles.get()).toEqual([])
           expect($sessionStates.get()).toEqual({})
       
      -    secondary.emit({ session_id: 'rt-secondary', type: 'approval.request' })
      +    // Prompts now arrive as server→client request frames, not events; owner
      +    // scope is still learned from any session-stamped event the secondary
      +    // socket emits (here the turn's status update).
      +    secondary.emit({ session_id: 'rt-secondary', type: 'status.update' })
           await expect(ensureGatewayForProfile('default')).resolves.toBeUndefined()
       
           expect(knownOwnerForSession('rt-secondary')).toBe('research')
      @@ -170,8 +173,8 @@ describe('profile-only secondary approval ownership', () => {
       
           await ensureGatewayForProfile('research')
           const secondary = gatewayMocks.instances[0]
      -    secondary.emit({ session_id: 'rt-retired', type: 'approval.request' })
      -    secondary.emit({ session_id: 'rt-durable', type: 'approval.request' })
      +    secondary.emit({ session_id: 'rt-retired', type: 'status.update' })
      +    secondary.emit({ session_id: 'rt-durable', type: 'status.update' })
           expect(knownOwnerForSession('rt-retired')).toBe('research')
           const exact = { connectionId: 'remote-same-name', profile: 'research' }
           setSessionOwnerHint('rt-durable', exact)
    • apps/desktop/src/store/gateway.ts
      @@ -3,7 +3,8 @@ import {
         type GatewayEvent,
         reconnectBackoffDelayMs,
         registryBackendScopeKey,
      -  resolveGatewayWsUrl
      +  resolveGatewayWsUrl,
      +  type ServerRequest
       } from '@hermes/shared'
       import { atom } from 'nanostores'
       
      @@ -56,6 +57,10 @@ interface RegistryConfig {
          * the connection store. */
         activeConnectionId?: () => null | string
         onEvent: (event: GatewayEvent) => void
      +  /** Server→client request (clarify, approval, …) from ANY socket the registry owns; the
      +   *  request's `respond` already routes to the socket it came from. `profile` /
      +   *  `connectionId` tag the source the same way events are tagged. */
      +  onServerRequest?: (request: ScopedServerRequest) => void
         onActiveConnectionInvalidated?: (fallbackProfile: string, activationEpoch: number) => void
         onActiveConnectionChanged?: (connection: HermesConnection) => void
         /**
      @@ -100,6 +105,7 @@ interface Secondary {
         activeRequests: number
         connectPromise: Promise<void> | null
         offEvent: () => void
      +  offRequest: () => void
         offState: () => void
         reconnectTimer: ReturnType<typeof setTimeout> | null
         reconnectAttempt: number
      @@ -269,6 +275,19 @@ export function emitLocalGatewayEvent(event: GatewayEvent): void {
         g.config?.onEvent(event)
       }
       
      +/** A server→client request tagged with the registry source it arrived from (like `GatewayEvent.profile`). */
      +export interface ScopedServerRequest extends ServerRequest {
      +  connectionId?: string
      +  profile: string
      +}
      +
      +/** Fan a primary-socket server request into the registry handler with the active source tags. */
      +export function dispatchPrimaryServerRequest(request: ServerRequest, profile: string): void {
      +  const connectionId = g.config?.activeConnectionId?.() ?? null
      +
      +  g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile })
      +}
      +
       export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
         const next = normKey(profile)
       
      @@ -800,6 +819,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
           activeRequests: 0,
           connectPromise: null,
           offEvent: () => {},
      +    offRequest: () => {},
           offState: () => {},
           reconnectTimer: null,
           reconnectAttempt: 0,
      @@ -822,6 +842,10 @@ function createSecondary(profile: string, connectionId: null | string = null): S
           g.config?.onEvent(scopedEvent)
           releaseTerminalTurnLease(entry.scope, event)
         })
      +  entry.offRequest =
      +    gateway.onRequest?.(request => {
      +      g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile })
      +    }) ?? (() => {})
         entry.offState = gateway.onState(state => {
           reportGatewayState(scope, state)
       
      @@ -1741,6 +1765,7 @@ function disposeSecondary(entry: Secondary): void {
         entry.wantOpen = false
         clearTimer(entry)
         entry.offEvent()
      +  entry.offRequest()
         entry.offState()
         entry.gateway.close()
       }
    • apps/desktop/src/store/mcp-setup.ts
      @@ -1,6 +1,6 @@
       import { atom, computed } from 'nanostores'
       
      -import { $gateway } from './gateway'
      +import { respondToServerRequest } from './server-requests'
       
       /**
        * Pending `mcp.setup.request`s — the desktop half of the `setup_mcp` tool's
      @@ -19,7 +19,7 @@ export interface McpSetupRequest {
         sessionId: string | null
       }
       
      -/** The card's answer, serialized back through `mcp.setup.respond`. */
      +/** The card's answer, serialized back as the `mcp.setup` request's `{value}`. */
       export interface McpSetupOutcome {
         status: 'authorized' | 'declined' | 'enabled' | 'error' | 'installed'
         server: string
      @@ -104,15 +104,9 @@ export async function skipMcpSetupRequest(sessionId: string | null | undefined):
         // leave a live card the user can answer a second time.
         clearMcpSetupRequest(request.requestId, request.sessionId)
       
      -  try {
      -    await $gateway.get()?.request('mcp.setup.respond', {
      -      request_id: request.requestId,
      -      result: JSON.stringify({ server: request.server, status: 'declined' })
      -    })
      -  } catch {
      -    // The tool times out on its own; a failed skip must never swallow the
      -    // message the user is actually sending.
      -  }
      +  respondToServerRequest(request.requestId, {
      +    value: JSON.stringify({ server: request.server, status: 'declined' })
      +  })
       
         return true
       }
    • apps/desktop/src/store/native-notifications.ts
      @@ -5,10 +5,11 @@ import { persistString, storedString } from '@/lib/storage'
       
       import { $gateway } from './gateway'
       import { withinNativeNotifyBaseline } from './notify-baseline'
      +import { $approvalRequests, answerApproval } from './prompts'
       import { clearApprovalRequest } from './prompts'
       import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'
       import { $activeSessionId } from './session'
      -import { requestForOwnedSession, storedSessionIdForRuntimeId } from './session-states'
      +import { storedSessionIdForRuntimeId } from './session-states'
       
       export type { HermesOpenTarget }
       
      @@ -366,18 +367,11 @@ export async function respondToApprovalAction(sessionId: null | string, actionId
         }
       
         try {
      -    // Route through the session's OWNER (tile route → known profile); the
      -    // ambient socket follows foreground focus and, for a background approval
      -    // raised by a cross-profile session, points at a backend that never held
      -    // the approval (#91684 client half). Ambient only when no owner is known.
      -    await requestForOwnedSession(
      -      sessionId,
      -      // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg
      -      // call shape gateway.request callers assert on.
      -      gateway.request.bind(gateway) as typeof gateway.request,
      -      'approval.respond',
      -      { choice, session_id: sessionId ?? undefined }
      -    )
      +    // The parked prompt knows how to answer itself: the live server request when
      +    // still open, else the owner-routed queue-level RPC (#91684 client half).
      +    const parked = $approvalRequests.get()[sessionId ?? '']
      +
      +    await answerApproval(gateway, parked ?? { sessionId: sessionId ?? null }, choice)
           clearApprovalRequest(sessionId)
         } catch (error) {
           if (sessionId && isSessionGoneForBackgroundPolling(error)) {
    • apps/desktop/src/store/prompts.ts
      @@ -2,14 +2,16 @@ import { atom, computed, type ReadableAtom } from 'nanostores'
       
       import { $clarifyRequest, $clarifyRequests } from './clarify'
       import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'
      +import { respondToServerRequest } from './server-requests'
       import { $activeSessionId } from './session'
       import { ambientRequestFor } from './session-gone-latch'
       import { requestForOwnedSession } from './session-states'
       
      -// 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.
      +// Blocking interactive prompts the gateway raises mid-turn. Each is a
      +// server→client JSON-RPC request (`tui_gateway/server_requests.py`) the Python
      +// side blocks on; `requestId` is that request's id and the card answers through
      +// `store/server-requests.ts::respondToServerRequest`. Without a renderer for
      +// these the channel answers -32601 and the tool fails fast.
       //
       // 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
      @@ -70,9 +72,12 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
         }
       }
       
      -// Approval is session-keyed on the backend and correlated by `request_id` when
      -// available (legacy ID-free responses remain FIFO-compatible). Resolved via
      -// approval.respond {choice, request_id, session_id}.
      +// Approval is queue-backed on the backend (`tools/approval.py`): `requestId` is
      +// the QUEUE entry's id (what `approval.pending` / `approval.received` /
      +// `approval.respond` key on), stable across delivery paths. The live prompt
      +// arrives as an `approval` server request whose id is `serverRequestId`; the
      +// card answers that request when it is still open and falls back to the
      +// `approval.respond` RPC when the prompt was restored from `approval.pending`.
       export interface ApprovalRequest extends KeyedPrompt {
         // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
         allowPermanent?: boolean
      @@ -80,6 +85,7 @@ export interface ApprovalRequest extends KeyedPrompt {
         command: string
         description: string
         requestId?: string
      +  serverRequestId?: string
         smartDenied?: boolean
       }
       
      @@ -106,8 +112,8 @@ export interface SecretRequest extends KeyedPrompt {
         requestId: string
       }
       
      -// External password-manager unlock (agent/vault_backends). Resolved via
      -// vault.unlock.respond {request_id, password}; "" keeps the manager locked.
      +// External password-manager unlock (agent/vault_backends): `vault.unlock_prompt`
      +// server request, answered `{value: password}`; "" keeps the manager locked.
       export interface VaultUnlockRequest extends KeyedPrompt {
         backend: string
         displayName: string
      @@ -119,8 +125,8 @@ const sudo = keyedPromptStore<SudoRequest>()
       const secret = [redacted:secret-assignment]
       const vaultUnlock = keyedPromptStore<VaultUnlockRequest>()
       
      -// "Save this login" for the page the agent is on (tools/browser_vault_tool). Resolved via
      -// vault.save_login.respond {request_id, login: JSON {identifier, password}}; "" declines.
      +// "Save this login" for the page the agent is on (tools/browser_vault_tool): `vault.save_login`
      +// server request, answered `{value: JSON {identifier, password}}`; "" declines.
       export interface VaultSaveLoginRequest extends KeyedPrompt {
         origin: string
         site: string
      @@ -129,8 +135,8 @@ export interface VaultSaveLoginRequest extends KeyedPrompt {
       
       const vaultSave = keyedPromptStore<VaultSaveLoginRequest>()
       
      -// Second-factor code for the page the agent is on. Resolved via vault.code.respond
      -// {request_id, code}; "" skips.
      +// Second-factor code for the page the agent is on: `vault.code` server request,
      +// answered `{value: code}`; "" skips.
       export interface VaultCodeRequest extends KeyedPrompt {
         site: string
         hint: string
      @@ -144,10 +150,19 @@ const vaultCode = keyedPromptStore<VaultCodeRequest>()
       const $approvalInlineAnchors = atom<Record<string, number>>({})
       
       export const $approvalRequest = approval.$active
      +export const $approvalRequests = approval.$all
       export const setApprovalRequest = approval.set
       export const clearApprovalRequest = approval.clear
       
       export async function receiveApprovalRequest(gateway: ApprovalGateway | null, request: ApprovalRequest): Promise<void> {
      +  // A prompt restored from `approval.pending` must not clobber the live server
      +  // request that already carries the same queue entry (it knows how to answer).
      +  const current = approval.$all.get()[keyFor(request.sessionId)]
      +
      +  if (current?.requestId && current.requestId === request.requestId && current.serverRequestId && !request.serverRequestId) {
      +    return
      +  }
      +
         setApprovalRequest(request)
       
         if (gateway && request.requestId && request.sessionId) {
      @@ -210,6 +225,10 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses
           return
         }
       
      +  if (previous?.requestId === pending.request_id) {
      +    return
      +  }
      +
         await receiveApprovalRequest(gateway, {
           allowPermanent: pending.allow_permanent !== false,
           choices: Array.isArray(pending.choices) ? pending.choices.filter(choice => typeof choice === 'string') : undefined,
      @@ -221,6 +240,35 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses
         })
       }
       
      +/**
      + * Resolve an approval: answer the live server request when it is still open
      + * (the response frame goes back over the socket it arrived on — the owner by
      + * construction), else the queue-level `approval.respond` RPC for a prompt that
      + * was restored from `approval.pending` or is being answered from another
      + * surface. Returns after the backend has the decision.
      + */
      +export async function answerApproval(
      +  gateway: ApprovalGateway | null,
      +  request: Pick<ApprovalRequest, 'requestId' | 'serverRequestId' | 'sessionId'>,
      +  choice: string,
      +  all = false
      +): Promise<void> {
      +  if (respondToServerRequest(request.serverRequestId, { choice, ...(all ? { all: true } : {}) })) {
      +    return
      +  }
      +
      +  if (!gateway) {
      +    throw new Error('Hermes gateway is not connected')
      +  }
      +
      +  await requestForOwnedSession(request.sessionId, ambientRequestFor(gateway), 'approval.respond', {
      +    ...(all ? { all: true } : {}),
      +    choice,
      +    ...(request.requestId ? { request_id: request.requestId } : {}),
      +    session_id: request.sessionId ?? undefined
      +  })
      +}
      +
       /** The prompt request for one specific session — the tile counterpart of the
        *  active-session `$*Request` views (same map, fixed key). */
       export const sessionApprovalRequest = (sessionId: string | null) =>
      @@ -250,10 +298,12 @@ export const sessionApprovalInlineVisible = (sessionId: string | null) =>
         computed($approvalInlineAnchors, anchors => (anchors[keyFor(sessionId)] ?? 0) > 0)
       
       export const $sudoRequest = sudo.$active
      +export const $sudoRequests = sudo.$all
       export const setSudoRequest = sudo.set
       export const clearSudoRequest = sudo.clear
       
       export const $secretRequest = [redacted:secret-assignment]
      +export const $secretRequests = [redacted:secret-assignment]
       export const setSecretRequest = [redacted:secret-assignment]
       export const clearSecretRequest = [redacted:secret-assignment]
       
    • apps/desktop/src/store/server-requests.ts
      @@ -0,0 +1,46 @@
      +import type { ServerRequest } from '@hermes/shared'
      +
      +/**
      + * Live server→client requests (`tui_gateway/server_requests.py`) keyed by
      + * request id: clarify / approval / sudo / secret / vault / MCP-setup cards.
      + *
      + * The per-session prompt stores keep only the id; a card answers through
      + * `respondToServerRequest`, which routes the response frame back over the
      + * socket the request arrived on — the owner backend by construction, so no
      + * owner-route lookup is needed (#91684's whole class disappears: the answer
      + * cannot land on the wrong backend because it is a JSON-RPC response, not a
      + * new call). A request re-delivered after a reconnect (`open_requests`)
      + * carries the same id and replaces the entry, so the still-visible card
      + * answers the new generation.
      + */
      +const open = new Map<string, ServerRequest>()
      +
      +export function rememberServerRequest(request: ServerRequest): void {
      +  open.set(request.id, request)
      +}
      +
      +export function forgetServerRequest(id: string): void {
      +  open.delete(id)
      +}
      +
      +/** Answer request `id`. False when nothing is open under that id (expired / already answered). */
      +export function respondToServerRequest(id: string | undefined, result: Record<string, unknown>): boolean {
      +  const request = id ? open.get(id) : undefined
      +
      +  if (!request) {
      +    return false
      +  }
      +
      +  open.delete(id!)
      +  request.respond(result)
      +
      +  return true
      +}
      +
      +export function hasOpenServerRequest(id: string): boolean {
      +  return open.has(id)
      +}
      +
      +export function resetServerRequestsForTests(): void {
      +  open.clear()
      +}
    • apps/desktop/src/store/session-states-runtime-map.test.ts
      @@ -64,7 +64,7 @@ describe('storedSessionIdForRuntimeId', () => {
         })
       
         it('maps a MAIN-PANE runtime id through the per-runtime state mirror (no tile involved)', () => {
      -    // approval.respond from a native notification, a queued send: the caller
      +    // The approval.respond RPC fallback from a native notification, a queued send: the caller
           // holds the runtime id of the primary thread, which no tile knows. The
           // state mirror carries the stored id the wiring cache bound.
           publishSessionState('rt-main', createClientSessionState('stored-main'))
    • apps/desktop/src/types/hermes.ts
      @@ -673,17 +673,11 @@ export interface SessionResumeResponse {
           request_id?: string
           smart_denied?: boolean
         }
      -  // The clarify question still blocking this session, if any. Same replay
      -  // class as pending_approval: emitted-while-detached prompts are restored
      -  // from the resume snapshot instead of being lost until server-side timeout.
      -  pending_clarify?: {
      -    answers?: Record<string, unknown>
      -    choices?: null | string[]
      -    multi_select?: boolean
      -    question?: string
      -    questions?: unknown
      -    request_id?: string
      -  }
      +  // Server→client requests still unanswered for this session (clarify, sudo,
      +  // vault prompts, …). The shared channel re-delivers them to the request
      +  // handlers before this response resolves; listed here so resume can tell an
      +  // authoritative "nothing pending" from a request the handler declined.
      +  open_requests?: Array<{ id: string; method: string; params: Record<string, unknown> & { session_id?: string } }>
         info?: SessionRuntimeInfo
         message_count: number
         messages: SessionMessage[]
    • apps/shared/src/gateway-events.json
      @@ -1,93 +1,86 @@
      -[
      -  "agent.terminal.output",
      -  "approval.request",
      -  "background.complete",
      -  "billing.step_up.verification",
      -  "bot_relay.outbox.pending",
      -  "browser.controller.cancel",
      -  "browser.controller.command",
      -  "browser.progress",
      -  "btw.complete",
      -  "clarify.expire",
      -  "clarify.request",
      -  "cron.changed",
      -  "error",
      -  "gateway.ready",
      -  "layout.apply",
      -  "mcp.setup.expire",
      -  "mcp.setup.request",
      -  "message.complete",
      -  "message.delta",
      -  "message.interim",
      -  "message.reaction",
      -  "message.start",
      -  "moa.aggregating",
      -  "moa.phase",
      -  "moa.progress",
      -  "moa.reference",
      -  "notice",
      -  "notification.clear",
      -  "notification.show",
      -  "pairing.changed",
      -  "pane.reveal",
      -  "pet.changed",
      -  "pet.generate.progress",
      -  "pet.hatch.progress",
      -  "platforms.changed",
      -  "preview.act.expire",
      -  "preview.act.request",
      -  "preview.close",
      -  "preview.open",
      -  "preview.read.expire",
      -  "preview.read.request",
      -  "preview.restart.complete",
      -  "preview.restart.progress",
      -  "reaction",
      -  "reasoning.available",
      -  "reasoning.delta",
      -  "review.summary",
      -  "secret.expire",
      -  "secret.request",
      -  "session.control.update",
      -  "session.info",
      -  "session.reclaimed",
      -  "session.resume_progress",
      -  "session.title",
      -  "session.usage",
      -  "sessions.changed",
      -  "setup.ready",
      -  "skin.changed",
      -  "status.update",
      -  "subagent.complete",
      -  "subagent.progress",
      -  "subagent.spawn_requested",
      -  "subagent.start",
      -  "subagent.thinking",
      -  "subagent.tool",
      -  "sudo.expire",
      -  "sudo.request",
      -  "terminal.close",
      -  "terminal.read.expire",
      -  "terminal.read.request",
      -  "thinking.delta",
      -  "tip.show",
      -  "todo.updated",
      -  "tool.complete",
      -  "tool.generating",
      -  "tool.output_risk",
      -  "tool.start",
      -  "tour.expire",
      -  "tour.request",
      -  "vault.code.expire",
      -  "vault.code.request",
      -  "vault.save_login.expire",
      -  "vault.save_login.request",
      -  "vault.unlock.expire",
      -  "vault.unlock.request",
      -  "voice.interrupted",
      -  "voice.status",
      -  "voice.transcript",
      -  "wake.detected",
      -  "window.read.expire",
      -  "window.read.request"
      -]
      +{
      +  "events": [
      +    "agent.terminal.output",
      +    "background.complete",
      +    "billing.step_up.verification",
      +    "bot_relay.outbox.pending",
      +    "browser.controller.cancel",
      +    "browser.controller.command",
      +    "browser.progress",
      +    "btw.complete",
      +    "cron.changed",
      +    "error",
      +    "gateway.ready",
      +    "layout.apply",
      +    "message.complete",
      +    "message.delta",
      +    "message.interim",
      +    "message.reaction",
      +    "message.start",
      +    "moa.aggregating",
      +    "moa.phase",
      +    "moa.progress",
      +    "moa.reference",
      +    "notice",
      +    "notification.clear",
      +    "notification.show",
      +    "pairing.changed",
      +    "pane.reveal",
      +    "pet.changed",
      +    "pet.generate.progress",
      +    "pet.hatch.progress",
      +    "platforms.changed",
      +    "preview.close",
      +    "preview.open",
      +    "preview.restart.complete",
      +    "preview.restart.progress",
      +    "reaction",
      +    "reasoning.available",
      +    "reasoning.delta",
      +    "request.cancel",
      +    "review.summary",
      +    "session.control.update",
      +    "session.info",
      +    "session.reclaimed",
      +    "session.resume_progress",
      +    "session.title",
      +    "session.usage",
      +    "sessions.changed",
      +    "setup.ready",
      +    "skin.changed",
      +    "status.update",
      +    "subagent.complete",
      +    "subagent.progress",
      +    "subagent.spawn_requested",
      +    "subagent.start",
      +    "subagent.thinking",
      +    "subagent.tool",
      +    "terminal.close",
      +    "thinking.delta",
      +    "tip.show",
      +    "todo.updated",
      +    "tool.complete",
      +    "tool.generating",
      +    "tool.output_risk",
      +    "tool.start",
      +    "voice.interrupted",
      +    "voice.status",
      +    "voice.transcript",
      +    "wake.detected"
      +  ],
      +  "server_requests": [
      +    "approval",
      +    "clarify",
      +    "mcp.setup",
      +    "preview.act",
      +    "preview.read",
      +    "secret",
      +    "sudo",
      +    "terminal.read",
      +    "tour",
      +    "vault.code",
      +    "vault.save_login",
      +    "vault.unlock_prompt",
      +    "window.read"
      +  ]
      +}
    • apps/shared/src/gateway-events.test.ts
      @@ -1,22 +1,29 @@
       import { describe, expect, it } from 'vitest'
       
      -import { BACKEND_EVENT_NAMES } from './gateway-events'
      +import { BACKEND_EVENT_NAMES, SERVER_REQUEST_METHODS } from './gateway-events'
       import contract from './gateway-events.json'
       
       /**
        * Two-sided contract with `tests/tui_gateway/test_gateway_event_contract.py`: the
      - * Python side pins the emitter call sites to `gateway-events.json`; this side pins
      - * `BACKEND_EVENT_NAMES` (which `BackendGatewayEventMap` is checked against via
      - * `satisfies`) to the same JSON. A name added on either side alone goes red here.
      + * Python side pins the emitter / server-request call sites to `gateway-events.json`;
      + * this side pins `BACKEND_EVENT_NAMES` (checked against `BackendGatewayEventMap` via
      + * `satisfies`) and `SERVER_REQUEST_METHODS` (against `ServerRequestMap`) to the same
      + * JSON. A name added on either side alone goes red here.
        */
      -describe('gateway-events.json ⇄ BackendGatewayEventMap', () => {
      +describe('gateway-events.json ⇄ BackendGatewayEventMap / ServerRequestMap', () => {
         it('lists exactly the backend-emitted notification names', () => {
      -    expect([...BACKEND_EVENT_NAMES]).toEqual(contract)
      +    expect([...BACKEND_EVENT_NAMES]).toEqual(contract.events)
         })
       
      -  it('keeps both lists sorted and duplicate-free (stable diffs)', () => {
      -    const sorted = [...contract].sort()
      -    expect(contract).toEqual(sorted)
      -    expect(new Set(contract).size).toBe(contract.length)
      +  it('lists exactly the server→client request methods', () => {
      +    expect([...SERVER_REQUEST_METHODS]).toEqual(contract.server_requests)
      +  })
      +
      +  it.each([
      +    ['events', contract.events],
      +    ['server_requests', contract.server_requests]
      +  ])('keeps %s sorted and duplicate-free (stable diffs)', (_label, list) => {
      +    expect(list).toEqual([...list].sort())
      +    expect(new Set(list).size).toBe(list.length)
         })
       })
    • apps/shared/src/gateway-events.ts
      @@ -3,7 +3,8 @@
        * TypeScript surfaces (Ink TUI, Desktop, web dashboard) share.
        *
        * Every notification arrives as `{jsonrpc: '2.0', method: 'event', params: GatewayEvent}`
      - * (`tui_gateway/server.py::_event_frame`). `GatewayEventMap` is the single map from
      + * (`tui_gateway/server.py::_event_frame`); server→client REQUESTS (`{id, method, params}`,
      + * `tui_gateway/server_requests.py`) are typed by `ServerRequestMap` below. `GatewayEventMap` is the single map from
        * event `type` to payload shape; `BACKEND_EVENT_NAMES` mirrors the emitter side and is
        * pinned to `gateway-events.json` by `gateway-events.test.ts` (vitest) and
        * `tests/tui_gateway/test_gateway_event_contract.py` (Python), so a name added on one
      @@ -304,11 +305,20 @@ export interface MoaPhasePayload {
         refs_total?: number
       }
       
      -// Blocking bridges (`tui_gateway/server.py::_block`): every `*.request` carries a
      -// `request_id`; the matching `*.expire` names the same id when the wait timed out.
      +// ── Server→client requests (`tui_gateway/server_requests.py`) ───────────
      +//
      +// The backend asks the renderer a question with a real JSON-RPC request
      +// (`{id: 'srq-…', method, params}`) and blocks on the response frame. Every
      +// entry below is one method: its `params` shape and the `result` the client
      +// answers with. `request.cancel` (an event) withdraws an open request on
      +// timeout / interrupt / session close; `open_requests` on `session.resume` /
      +// `session.events.since` re-delivers unanswered ones after a reconnect.
       
      -export interface RequestExpirePayload {
      -  request_id: string
      +/** `request.cancel` payload — the backend withdrew an open server request. */
      +export interface RequestCancelPayload {
      +  id: string
      +  method: string
      +  reason: string
       }
       
       export interface ClarifyQuestion {
      @@ -318,54 +328,113 @@ export interface ClarifyQuestion {
         question: string
       }
       
      -export interface ClarifyRequestPayload {
      +/** `clarify` params. Single question: `question`/`choices`(/`multi_select`); batch: `questions`.
      + *  `answers` rides along only on a reconnect replay (locks the server already accepted). */
      +export interface ClarifyRequestParams {
         answers?: Record<string, string>
         choices?: null | string[]
         multi_select?: boolean
         question?: string
         questions?: ClarifyQuestion[]
      -  request_id: string
       }
       
      -/** `tui_gateway/server.py::_approval_request_payload` (command redacted server-side). */
      -export interface ApprovalRequestPayload {
      +/** `clarify` result. Single: `{answer}` ('' = skip). Batch: the request resolves through
      + *  `clarify.lock` RPCs (the last lock completes it); a response with no `answers` is cancel-all. */
      +export interface ClarifyResult {
      +  answer?: string
      +  answers?: Record<string, string>
      +}
      +
      +/** `approval` params (`tui_gateway/server.py::_approval_request_payload`, command redacted server-side). */
      +export interface ApprovalRequestParams {
         allow_permanent?: boolean
         choices?: string[]
         command: string
         description: string
      -  request_id?: string
      +  request_id: string
         smart_denied?: boolean
       }
       
      -export interface SudoRequestPayload {
      -  request_id: string
      +export interface ApprovalResult {
      +  all?: boolean
      +  choice: 'always' | 'deny' | 'once' | 'session'
       }
       
      -export interface SecretRequestPayload {
      +/** Every prompt whose answer is one string: `sudo`, `secret`, the vault prompts, the desktop GUI
      + *  bridges (`terminal.read`, `preview.read`, `preview.act`, `window.read`, `tour`) and `mcp.setup`.
      + *  '' means skipped / declined. */
      +export interface ValueResult {
      +  value: string
      +}
      +
      +export interface SecretRequestParams {
         env_var: string
      +  metadata?: Record<string, unknown>
         prompt: string
      -  request_id: string
       }
       
      -export interface VaultUnlockRequestPayload {
      +export interface VaultUnlockRequestParams {
         backend: string
         display_name: string
      -  request_id: string
       }
       
      -export interface VaultCodeRequestPayload {
      +export interface VaultSaveLoginRequestParams {
      +  origin: string
      +  site: string
      +}
      +
      +export interface VaultCodeRequestParams {
         hint?: string
      -  request_id: string
         site?: string
       }
       
      -export interface McpSetupRequestPayload {
      +export interface McpSetupRequestParams {
         action?: string
         reason?: string
      -  request_id: string
         server?: string
       }
       
      +export interface ReadRangeRequestParams {
      +  count?: number
      +  start?: number
      +}
      +
      +/** Server→client request method → `{params, result}`. Every method the backend can ask. */
      +export interface ServerRequestMap {
      +  approval: { params: ApprovalRequestParams; result: ApprovalResult }
      +  clarify: { params: ClarifyRequestParams; result: ClarifyResult }
      +  'mcp.setup': { params: McpSetupRequestParams; result: ValueResult }
      +  'preview.act': { params: Record<string, unknown>; result: ValueResult }
      +  'preview.read': { params: ReadRangeRequestParams; result: ValueResult }
      +  secret: { params: SecretRequestParams; result: ValueResult }
      +  sudo: { params: Record<string, never>; result: ValueResult }
      +  'terminal.read': { params: ReadRangeRequestParams; result: ValueResult }
      +  tour: { params: Record<string, unknown>; result: ValueResult }
      +  'vault.code': { params: VaultCodeRequestParams; result: ValueResult }
      +  'vault.save_login': { params: VaultSaveLoginRequestParams; result: ValueResult }
      +  'vault.unlock_prompt': { params: VaultUnlockRequestParams; result: ValueResult }
      +  'window.read': { params: Record<string, never>; result: ValueResult }
      +}
      +
      +export type ServerRequestMethod = keyof ServerRequestMap
      +
      +/** Pinned to `gateway-events.json`'s `server_requests` list by the two contract tests. Keep sorted. */
      +export const SERVER_REQUEST_METHODS = [
      +  'approval',
      +  'clarify',
      +  'mcp.setup',
      +  'preview.act',
      +  'preview.read',
      +  'secret',
      +  'sudo',
      +  'terminal.read',
      +  'tour',
      +  'vault.code',
      +  'vault.save_login',
      +  'vault.unlock_prompt',
      +  'window.read'
      +] as const satisfies readonly ServerRequestMethod[]
      +
       /** Side agents (`tui_gateway/methods_prompt.py::_spawn_side_agent`). */
       export interface SideAgentCompletePayload {
         question?: string
      @@ -397,7 +466,6 @@ export interface TerminalClosePayload {
        */
       export const BACKEND_EVENT_NAMES = [
         'agent.terminal.output',
      -  'approval.request',
         'background.complete',
         'billing.step_up.verification',
         'bot_relay.outbox.pending',
      @@ -405,14 +473,10 @@ export const BACKEND_EVENT_NAMES = [
         'browser.controller.command',
         'browser.progress',
         'btw.complete',
      -  'clarify.expire',
      -  'clarify.request',
         'cron.changed',
         'error',
         'gateway.ready',
         'layout.apply',
      -  'mcp.setup.expire',
      -  'mcp.setup.request',
         'message.complete',
         'message.delta',
         'message.interim',
      @@ -431,20 +495,15 @@ export const BACKEND_EVENT_NAMES = [
         'pet.generate.progress',
         'pet.hatch.progress',
         'platforms.changed',
      -  'preview.act.expire',
      -  'preview.act.request',
         'preview.close',
         'preview.open',
      -  'preview.read.expire',
      -  'preview.read.request',
         'preview.restart.complete',
         'preview.restart.progress',
         'reaction',
         'reasoning.available',
         'reasoning.delta',
      +  'request.cancel',
         'review.summary',
      -  'secret.expire',
      -  'secret.request',
         'session.control.update',
         'session.info',
         'session.reclaimed',
      @@ -461,11 +520,7 @@ export const BACKEND_EVENT_NAMES = [
         'subagent.start',
         'subagent.thinking',
         'subagent.tool',
      -  'sudo.expire',
      -  'sudo.request',
         'terminal.close',
      -  'terminal.read.expire',
      -  'terminal.read.request',
         'thinking.delta',
         'tip.show',
         'todo.updated',
      @@ -473,20 +528,10 @@ export const BACKEND_EVENT_NAMES = [
         'tool.generating',
         'tool.output_risk',
         'tool.start',
      -  'tour.expire',
      -  'tour.request',
      -  'vault.code.expire',
      -  'vault.code.request',
      -  'vault.save_login.expire',
      -  'vault.save_login.request',
      -  'vault.unlock.expire',
      -  'vault.unlock.request',
         'voice.interrupted',
         'voice.status',
         'voice.transcript',
      -  'wake.detected',
      -  'window.read.expire',
      -  'window.read.request'
      +  'wake.detected'
       ] as const satisfies readonly (keyof BackendGatewayEventMap)[]
       
       export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number]
      @@ -494,7 +539,6 @@ export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number]
       /** Payload per backend-emitted notification `type`. Keys are exactly `BACKEND_EVENT_NAMES`. */
       export interface BackendGatewayEventMap {
         'agent.terminal.output': TerminalOutputPayload
      -  'approval.request': ApprovalRequestPayload
         'background.complete': SideAgentCompletePayload
         'billing.step_up.verification': BillingStepUpVerificationPayload
         'bot_relay.outbox.pending': Record<string, unknown>
      @@ -502,14 +546,10 @@ export interface BackendGatewayEventMap {
         'browser.controller.command': Record<string, unknown>
         'browser.progress': BrowserProgressPayload
         'btw.complete': SideAgentCompletePayload
      -  'clarify.expire': RequestExpirePayload
      -  'clarify.request': ClarifyRequestPayload
         'cron.changed': Record<string, unknown>
         error: ErrorPayload
         'gateway.ready': GatewayReadyPayload
         'layout.apply': Record<string, unknown>
      -  'mcp.setup.expire': RequestExpirePayload
      -  'mcp.setup.request': McpSetupRequestPayload
         'message.complete': MessageCompletePayload
         'message.delta': StreamDeltaPayload
         'message.interim': MessageInterimPayload
      @@ -528,20 +568,15 @@ export interface BackendGatewayEventMap {
         'pet.generate.progress': Record<string, unknown>
         'pet.hatch.progress': Record<string, unknown>
         'platforms.changed': Record<string, unknown>
      -  'preview.act.expire': RequestExpirePayload
      -  'preview.act.request': Record<string, unknown>
         'preview.close': Record<string, unknown>
         'preview.open': Record<string, unknown>
      -  'preview.read.expire': RequestExpirePayload
      -  'preview.read.request': Record<string, unknown>
         'preview.restart.complete': SideAgentCompletePayload
         'preview.restart.progress': PreviewRestartProgressPayload
         reaction: ReactionPayload
         'reasoning.available': StreamDeltaPayload
         'reasoning.delta': StreamDeltaPayload
      +  'request.cancel': RequestCancelPayload
         'review.summary': TextPayload
      -  'secret.expire': RequestExpirePayload
      -  'secret.request': SecretRequestPayload
         'session.control.update': SessionControlUpdatePayload
         /** Surface-specific shape (`tui_gateway/server.py::_session_info`); each client narrows. */
         'session.info': Record<string, unknown>
      @@ -559,11 +594,7 @@ export interface BackendGatewayEventMap {
         'subagent.start': SubagentEventPayload
         'subagent.thinking': SubagentEventPayload
         'subagent.tool': SubagentEventPayload
      -  'sudo.expire': RequestExpirePayload
      -  'sudo.request': SudoRequestPayload
         'terminal.close': TerminalClosePayload
      -  'terminal.read.expire': RequestExpirePayload
      -  'terminal.read.request': Record<string, unknown>
         'thinking.delta': StreamDeltaPayload
         'tip.show': Record<string, unknown>
         'todo.updated': TodoStatePayload
      @@ -571,20 +602,10 @@ export interface BackendGatewayEventMap {
         'tool.generating': ToolGeneratingPayload
         'tool.output_risk': ToolOutputRiskPayload
         'tool.start': ToolStartPayload
      -  'tour.expire': RequestExpirePayload
      -  'tour.request': Record<string, unknown>
      -  'vault.code.expire': RequestExpirePayload
      -  'vault.code.request': VaultCodeRequestPayload
      -  'vault.save_login.expire': RequestExpirePayload
      -  'vault.save_login.request': Record<string, unknown>
      -  'vault.unlock.expire': RequestExpirePayload
      -  'vault.unlock.request': VaultUnlockRequestPayload
         'voice.interrupted': Record<string, unknown>
         'voice.status': VoiceStatusPayload
         'voice.transcript': VoiceTranscriptPayload
         'wake.detected': WakeDetectedPayload
      -  'window.read.expire': RequestExpirePayload
      -  'window.read.request': Record<string, unknown>
       }
       
       /**
      @@ -757,6 +778,11 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message =
         messages: Message[]
         /** `omit_messages` resume: the client still learns the stored size. */
         messages_omitted?: boolean
      +  /** Server→client requests still unanswered for this session (a clarify, sudo prompt, …
      +   *  raised while the client was detached); the client re-delivers them to its request
      +   *  handlers. `pending_approval` (the approval queue's oldest entry) is the approval twin. */
      +  open_requests?: OpenServerRequest[]
      +  pending_approval?: ApprovalRequestParams
         resumed?: string
         running?: boolean
         session_id: string
      @@ -765,3 +791,10 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message =
         status?: string
         todo_state?: TodoStatePayload
       }
      +
      +/** One unanswered server→client request as returned by `open_requests`. */
      +export interface OpenServerRequest {
      +  id: string
      +  method: string
      +  params: Record<string, unknown> & { session_id?: string }
      +}
    • apps/shared/src/index.ts
      @@ -62,12 +62,14 @@ export {
       export { compactNumber } from './format'
       export { type FuzzyMatch, fuzzyRank, fuzzyScore, fuzzyScoreMulti, type RankedItem } from './fuzzy'
       export {
      -  type ApprovalRequestPayload,
      +  type ApprovalRequestParams,
      +  type ApprovalResult,
         BACKEND_EVENT_NAMES,
         type BackendGatewayEventMap,
         type BackendGatewayEventName,
         type ClarifyQuestion,
      -  type ClarifyRequestPayload,
      +  type ClarifyRequestParams,
      +  type ClarifyResult,
         type ClientLocalGatewayEventMap,
         type ErrorPayload,
         type ErrorSurface,
      @@ -76,7 +78,7 @@ export {
         type GatewayEventName,
         type GatewayReadyPayload,
         type GatewayTranscriptMessage,
      -  type McpSetupRequestPayload,
      +  type McpSetupRequestParams,
         type MessageCompletePayload,
         type MessageInterimPayload,
         type ModelCapabilities,
      @@ -84,8 +86,13 @@ export {
         type ModelOptionsResponse,
         type ModelPricing,
         type NotificationShowPayload,
      -  type RequestExpirePayload,
      -  type SecretRequestPayload,
      +  type OpenServerRequest,
      +  type ReadRangeRequestParams,
      +  type RequestCancelPayload,
      +  type SecretRequestParams,
      +  SERVER_REQUEST_METHODS,
      +  type ServerRequestMap,
      +  type ServerRequestMethod,
         type SessionInflightTurn,
         type SessionListItem,
         type SessionListResponse,
      @@ -100,7 +107,10 @@ export {
         type ToolCompletePayload,
         type ToolStartPayload,
         type Usage,
      -  type VaultUnlockRequestPayload,
      +  type ValueResult,
      +  type VaultCodeRequestParams,
      +  type VaultSaveLoginRequestParams,
      +  type VaultUnlockRequestParams,
         type WakeDetectedPayload
       } from './gateway-events'
       export {
      @@ -124,6 +134,9 @@ export {
         JsonRpcRequestChannel,
         type JsonRpcRequestChannelOptions,
         type JsonRpcTransport,
      +  type ServerRequest,
      +  type ServerRequestHandler,
      +  type ServerRequestParams,
         wireFrameText
       } from './json-rpc-channel'
       export {
    • apps/shared/src/json-rpc-channel.test.ts
      @@ -170,4 +170,50 @@ describe('JsonRpcRequestChannel', () => {
             vi.useRealTimers()
           }
         })
      +
      +  // Server→client requests (tui_gateway/server_requests.py): the backend asks,
      +  // the client answers with a RESPONSE frame carrying the same id.
      +  it('routes a server request to the first accepting handler and answers -32601 when nobody accepts', () => {
      +    const unhandled: string[] = []
      +    const channel = new JsonRpcRequestChannel({ onUnhandledRequest: req => void unhandled.push(req.method) })
      +    const { sent, transport } = spyTransport()
      +
      +    channel.attach(transport)
      +    channel.onRequest(req => (req.method === 'clarify' ? void req.respond({ answer: 'yes' }) : false))
      +
      +    channel.handleFrame(JSON.stringify({ id: 'srq-1', jsonrpc: '2.0', method: 'clarify', params: { session_id: 's1' } }))
      +    channel.handleFrame(JSON.stringify({ id: 'srq-2', jsonrpc: '2.0', method: 'tour', params: { session_id: 's1' } }))
      +
      +    const frames = sent.map(f => JSON.parse(f) as { id: string; result?: unknown; error?: { code: number } })
      +
      +    expect(frames[0]).toEqual({ id: 'srq-1', jsonrpc: '2.0', result: { answer: 'yes' } })
      +    expect(frames[1].id).toBe('srq-2')
      +    expect(frames[1].error?.code).toBe(-32601)
      +    expect(unhandled).toEqual(['tour'])
      +  })
      +
      +  it('re-delivers open_requests from a response before the caller sees the result, tagged replayed', async () => {
      +    const delivered: Array<{ id: string; replayed?: boolean }> = []
      +    const channel = new JsonRpcRequestChannel()
      +    const { sent, transport } = spyTransport()
      +
      +    channel.attach(transport)
      +    channel.onRequest(req => void delivered.push({ id: req.id, replayed: req.replayed }))
      +
      +    const resume = channel.request<{ session_id: string }>('session.resume', { session_id: 's1' })
      +    const rid = (JSON.parse(sent.at(-1)!) as { id: string }).id
      +
      +    channel.handleFrame(
      +      JSON.stringify({
      +        id: rid,
      +        jsonrpc: '2.0',
      +        result: {
      +          open_requests: [{ id: 'srq-9', method: 'sudo', params: { session_id: 's1' } }],
      +          session_id: 's1'
      +        }
      +      })
      +    )
      +    await expect(resume).resolves.toMatchObject({ session_id: 's1' })
      +    expect(delivered).toEqual([{ id: 'srq-9', replayed: true }])
      +  })
       })
    • apps/shared/src/json-rpc-channel.ts
      @@ -12,10 +12,47 @@ export interface JsonRpcFrame {
         error?: JsonRpcErrorPayload
         id?: GatewayRequestId | null
         method?: string
      -  params?: GatewayEvent
      +  params?: GatewayEvent | ServerRequestParams
         result?: unknown
       }
       
      +/**
      + * Params of a server→client request (`tui_gateway/server_requests.py`): the
      + * backend asking the renderer a question. `session_id` names the session
      + * blocked on the answer; the rest is method-specific.
      + */
      +export interface ServerRequestParams extends Record<string, unknown> {
      +  session_id?: string
      +}
      +
      +/** One inbound server→client request, as handed to a `ServerRequestHandler`. */
      +export interface ServerRequest<M extends string = string, P extends ServerRequestParams = ServerRequestParams> {
      +  id: string
      +  method: M
      +  params: P
      +  /**
      +   * Route the answer back to the backend that asked. Idempotent: the first
      +   * `respond` (or `fail`) wins; a request re-delivered after a reconnect
      +   * (`open_requests`) reuses the id, so a stale card answering twice is a
      +   * no-op on the wire.
      +   */
      +  respond: (result: Record<string, unknown>) => void
      +  /** Answer with a JSON-RPC error (the backend treats it as unanswered). */
      +  fail: (code: number, message: string) => void
      +  /**
      +   * Renderer-side tag set by the owner when a request arrives through a
      +   * replay (`open_requests`) rather than live; handlers that already show the
      +   * card can skip re-notifying.
      +   */
      +  replayed?: boolean
      +}
      +
      +/** Handles one inbound server→client request; return `false` to decline (next handler tries). */
      +export type ServerRequestHandler = (request: ServerRequest) => boolean | void
      +
      +const isServerRequestFrame = (frame: JsonRpcFrame): frame is JsonRpcFrame & { id: string; method: string } =>
      +  typeof frame.id === 'string' && typeof frame.method === 'string' && frame.method !== 'event'
      +
       /** JSON-RPC error with optional structured `data` from the gateway. */
       export class JsonRpcGatewayError extends Error {
         readonly code?: number
      @@ -59,6 +96,12 @@ export interface JsonRpcRequestChannelOptions {
         onHeartbeatFailure?: (error: Error) => void
         /** Decoded `event` notification. */
         onEvent?: (event: GatewayEvent) => void
      +  /**
      +   * Inbound server→client request nobody handled: the owner logs it. The
      +   * channel has already answered `-32601` so the backend does not wait out
      +   * its deadline against a client with no handler.
      +   */
      +  onUnhandledRequest?: (request: { id: string; method: string; params: ServerRequestParams }) => void
         requestIdPrefix?: string
         requestTimeoutMs?: number
         /**
      @@ -130,8 +173,11 @@ export class JsonRpcRequestChannel {
         private heartbeatSequence = 0
         private readonly outstandingPings = new Set<string>()
         private lastLivenessAt = 0
      -  private readonly options: Required<Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'>> &
      -    Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'>
      +  private readonly requestHandlers: ServerRequestHandler[] = []
      +  private readonly options: Required<
      +    Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'>
      +  > &
      +    Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'>
       
         constructor(options: JsonRpcRequestChannelOptions = {}) {
           this.options = {
      @@ -141,6 +187,7 @@ export class JsonRpcRequestChannel {
             heartbeatLiveness: options.heartbeatLiveness ?? 'response',
             onEvent: options.onEvent,
             onHeartbeatFailure: options.onHeartbeatFailure,
      +      onUnhandledRequest: options.onUnhandledRequest,
             requestIdPrefix: options.requestIdPrefix ?? 'r',
             requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
             unrefTimers: options.unrefTimers ?? false
      @@ -254,11 +301,89 @@ export class JsonRpcRequestChannel {
           })
         }
       
      +  /**
      +   * Register a handler for server→client requests (clarify, approval, sudo,
      +   * …). Handlers are tried in registration order until one accepts (returns
      +   * anything but `false`); an unhandled request is answered `-32601` so the
      +   * backend never waits out its deadline against a client that cannot answer.
      +   */
      +  onRequest(handler: ServerRequestHandler): () => void {
      +    this.requestHandlers.push(handler)
      +
      +    return () => {
      +      const index = this.requestHandlers.indexOf(handler)
      +
      +      if (index >= 0) {
      +        this.requestHandlers.splice(index, 1)
      +      }
      +    }
      +  }
      +
      +  /**
      +   * Deliver a server request to the handlers. Live frames arrive through
      +   * `handleFrame`; owners call this directly for `open_requests` returned by a
      +   * reconnect replay (`replayed: true`) so an unanswered question survives a
      +   * dropped socket.
      +   */
      +  deliverRequest(id: string, method: string, params: ServerRequestParams, replayed = false): boolean {
      +    let settled = false
      +
      +    const send = (frame: Record<string, unknown>) => {
      +      if (settled) {
      +        return
      +      }
      +
      +      settled = true
      +
      +      try {
      +        this.transport?.send(JSON.stringify({ jsonrpc: '2.0', id, ...frame }))
      +      } catch {
      +        // The generation is gone; the backend withdraws the request itself (timeout / reconnect replay).
      +      }
      +    }
      +
      +    const request: ServerRequest = {
      +      id,
      +      method,
      +      params,
      +      replayed,
      +      respond: result => send({ result }),
      +      fail: (code, message) => send({ error: { code, message } })
      +    }
      +
      +    for (const handler of this.requestHandlers) {
      +      if (handler(request) !== false) {
      +        return true
      +      }
      +    }
      +
      +    request.fail(JSON_RPC_METHOD_NOT_FOUND, `no handler for server request: ${method}`)
      +    this.options.onUnhandledRequest?.({ id, method, params })
      +
      +    return false
      +  }
      +
      +  private deliverOpenRequests(result: unknown): void {
      +    const open = (result as { open_requests?: unknown } | null)?.open_requests
      +
      +    if (!Array.isArray(open)) {
      +      return
      +    }
      +
      +    for (const entry of open as Array<{ id?: unknown; method?: unknown; params?: unknown }>) {
      +      if (typeof entry?.id === 'string' && typeof entry.method === 'string') {
      +        const params = entry.params && typeof entry.params === 'object' ? (entry.params as ServerRequestParams) : {}
      +        this.deliverRequest(entry.id, entry.method, params, true)
      +      }
      +    }
      +  }
      +
         /**
          * Route one inbound frame: a response settles its pending call, an
      -   * `event` notification reaches `onEvent`. Returns the decoded frame so the
      -   * owner can act on it too (mirror it, record seq, …) or `null` when the
      -   * text was not JSON or not a JSON object (`null`, a scalar).
      +   * `event` notification reaches `onEvent`, a server→client request reaches
      +   * the `onRequest` handlers. Returns the decoded frame so the owner can act
      +   * on it too (mirror it, record seq, …) or `null` when the text was not
      +   * JSON or not a JSON object (`null`, a scalar).
          */
         handleFrame(text: string): JsonRpcFrame | null {
           let frame: JsonRpcFrame
      @@ -277,6 +402,13 @@ export class JsonRpcRequestChannel {
             this.lastLivenessAt = Date.now()
           }
       
      +    if (isServerRequestFrame(frame)) {
      +      const params = frame.params && typeof frame.params === 'object' ? (frame.params as ServerRequestParams) : {}
      +      this.deliverRequest(frame.id, frame.method, params)
      +
      +      return frame
      +    }
      +
           if (frame.id !== undefined && frame.id !== null) {
             if (typeof frame.id === 'string' && this.outstandingPings.delete(frame.id)) {
               this.lastLivenessAt = Date.now()
      @@ -293,6 +425,13 @@ export class JsonRpcRequestChannel {
               if (frame.error) {
                 call.reject(jsonRpcErrorFromFrame(frame.error))
               } else {
      +          // Reconnect contract: `session.resume` / `session.activate` /
      +          // `session.events.since` answer with `open_requests` — the server→
      +          // client requests still waiting on this session. They cannot ride
      +          // the event replay ring (they are not events), so they are re-
      +          // delivered here, before the caller sees the result, over the very
      +          // socket that owns them.
      +          this.deliverOpenRequests(frame.result)
                 call.resolve(frame.result)
               }
             }
      @@ -300,8 +439,8 @@ export class JsonRpcRequestChannel {
             return frame
           }
       
      -    if (frame.method === 'event' && frame.params && typeof frame.params.type === 'string') {
      -      this.options.onEvent?.(frame.params)
      +    if (frame.method === 'event' && frame.params && typeof (frame.params as GatewayEvent).type === 'string') {
      +      this.options.onEvent?.(frame.params as GatewayEvent)
           }
       
           return frame
    • apps/shared/src/json-rpc-gateway.ts
      @@ -5,6 +5,7 @@ import {
         type GatewayRequestId,
         JsonRpcRequestChannel,
         type JsonRpcTransport,
      +  type ServerRequestHandler,
         wireFrameText
       } from './json-rpc-channel.js'
       
      @@ -343,6 +344,15 @@ export class JsonRpcGatewayClient {
           return this.onAny(handler)
         }
       
      +  /**
      +   * Server→client requests (clarify, approval, sudo, …). Live frames and
      +   * `open_requests` re-delivered after a reconnect both arrive here; the
      +   * latter carry `replayed: true`.
      +   */
      +  onRequest(handler: ServerRequestHandler): () => void {
      +    return this.channel.onRequest(handler)
      +  }
      +
         onState(handler: (state: ConnectionState) => void): () => void {
           this.stateHandlers.add(handler)
           handler(this.state)
      @@ -445,6 +455,7 @@ export class JsonRpcGatewayClient {
             // One RPC per known session keeps params flat; sessions are few (<20).
             const results = await Promise.allSettled(
               entries.map(([sid, lastSeen]) =>
      +          // `open_requests` on the answer are re-delivered by the channel itself.
                 this.request<{ events?: Array<{ type: string; session_id?: string; seq?: number; payload?: unknown }> }>(
                   'session.events.since',
                   { session_id: sid, last_seen: lastSeen },
    • tests/gateway/test_tui_approval_redaction.py
      @@ -15,24 +15,28 @@
       
       
       class TestTuiApprovalEmitRedaction:
      -    def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch):
      -        from tui_gateway import server as tui_server
      +    @staticmethod
      +    def _sent(monkeypatch):
      +        """Capture the ``approval`` server request frame ``_emit_approval_request`` sends."""
      +        from tui_gateway import server as tui_server, server_requests
      +
      +        sent = {}
      +        monkeypatch.setattr(server_requests, "send_async",
      +                            lambda method, sid, params, on_result: (sent.update(method=method, sid=sid, params=params),
      +                                                                    lambda reason: None)[1])
      +        monkeypatch.setattr(tui_server, "_sessions", {"sess-1": {"session_key": "key-1"}})
      +        return tui_server, sent
       
      -        emitted = {}
      -        monkeypatch.setattr(
      -            tui_server, "_emit",
      -            lambda event, sid, payload=None: emitted.update(
      -                {"event": event, "sid": sid, "payload": payload}
      -            ),
      -        )
      +    def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch):
      +        tui_server, sent = self._sent(monkeypatch)
               raw = "curl -H 'Authorization: token ghp_01...6789' https://api.github.com"
               tui_server._emit_approval_request("sess-1", {"command": raw, "description": "x"})
       
      -        assert emitted["event"] == "approval.request"
      +        assert sent["method"] == "approval" and sent["sid"] == "sess-1"
               # credential removed, non-command field + command structure preserved
      -        assert "ghp_01...6789" not in emitted["payload"]["command"]
      -        assert emitted["payload"]["description"] == "x"
      -        assert "github.com" in emitted["payload"]["command"]
      +        assert "ghp_01...6789" not in sent["params"]["command"]
      +        assert sent["params"]["description"] == "x"
      +        assert "github.com" in sent["params"]["command"]
       
           @pytest.mark.parametrize(
               ("allow_session", "allow_permanent", "expected"),
      @@ -45,23 +49,10 @@ def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch):
           def test_emit_approval_request_honors_allowed_scopes(
               self, monkeypatch, allow_session, allow_permanent, expected
           ):
      -        from tui_gateway import server as tui_server
      -
      -        emitted = {}
      -        monkeypatch.setattr(
      -            tui_server,
      -            "_emit",
      -            lambda event, sid, payload=None: emitted.update({"payload": payload}),
      -        )
      -
      +        tui_server, sent = self._sent(monkeypatch)
               tui_server._emit_approval_request(
                   "sess-1",
      -            {
      -                "allow_permanent": allow_permanent,
      -                "allow_session": allow_session,
      -                "command": "<write to AGENTS.md>",
      -            },
      +            {"allow_permanent": allow_permanent, "allow_session": allow_session, "command": "<write to AGENTS.md>"},
               )
       
      -        assert emitted["payload"]["choices"] == expected
      -
      +        assert sent["params"]["choices"] == expected
    • tests/tui_gateway/test_compute_host_phase1.py
      @@ -48,40 +48,38 @@ def test_compute_host_workers_inherit_tui_pool_env_or_8(monkeypatch):
           assert _default_workers() == 8
       
       
      -def test_compute_host_routes_clarify_response_to_child_pending_registry(monkeypatch):
      -    """Interactive answers are handled in the process that owns `_pending`."""
      +def test_compute_host_routes_relayed_response_and_lock_to_its_open_request(monkeypatch):
      +    """The child owns the server request's wait: a relayed client response frame resolves it in-process,
      +    and a relayed ``clarify.lock`` is answered with that method's result for the parent to ack."""
      +    from tui_gateway import server_requests
           out = io.StringIO()
           host = ComputeHost(stdout=out, heartbeat_secs=0)
           sid = "host-clarify"
           server._sessions[sid] = {"history_lock": threading.Lock()}
      -    calls = []
      -    monkeypatch.setitem(
      -        server._methods,
      -        "clarify.respond",
      -        lambda rid, params: calls.append((rid, dict(params))) or {"result": {"status": "ok"}},
      -    )
      +    req = server_requests.ServerRequest(sid, "clarify", {"question": "?"})
      +    with server_requests._lock:
      +        server_requests._open[req.id] = req
      +    locks = []
      +    monkeypatch.setitem(server._methods, "clarify.lock",
      +                        lambda rid, params: locks.append((rid, dict(params))) or {"result": {"status": "ok", "remaining": []}})
       
           try:
      -        host._handle_respond(
      -            {
      -                "sid": sid,
      -                "request_id": "relay-response",
      -                "params": {"request_id": "clarify-request", "answer": "yes"},
      -            }
      -        )
      -        assert calls == [("relay-response", {"request_id": "clarify-request", "answer": "yes"})]
      +        host._handle_respond({"sid": sid, "request_id": "relay-lock",
      +                              "params": {"lock": {"request_id": req.id, "question_id": "q0", "answer": "a"}}})
      +        assert locks == [("relay-lock", {"request_id": req.id, "question_id": "q0", "answer": "a"})]
      +        assert _json_lines(out)[-1]["response"] == {"result": {"status": "ok", "remaining": []}}
      +
      +        host._handle_respond({"sid": sid, "request_id": "relay-response",
      +                              "params": {"frame": {"jsonrpc": "2.0", "id": req.id, "result": {"answer": "yes"}}}})
      +        assert req.answered and req.result == {"answer": "yes"} and req.event.is_set()
               frame = _json_lines(out)[-1]
      -        assert frame == {
      -            "type": "respond.ack",
      -            "sid": sid,
      -            "request_id": "relay-response",
      -            "response": {"result": {"status": "ok"}},
      -            "host_ns": frame["host_ns"],
      -        }
      +        assert frame["type"] == "respond.ack" and frame["response"]["result"] == {"status": "ok"}
           finally:
               server._sessions.pop(sid, None)
      +        server_requests.reset_for_tests()
               host.close()
       
      +
       def test_mutator_route_table_matches_prd_inventory():
           assert MUTATOR_ROUTE_TABLE == {
               "prompt.submit": "turn-path",
    • tests/tui_gateway/test_gateway_event_contract.py
      @@ -1,16 +1,17 @@
      -"""Two-sided contract: every notification name ``tui_gateway`` emits is listed in
      -``apps/shared/src/gateway-events.json`` and nothing in the JSON is orphaned.
      +"""Two-sided contract: every notification name ``tui_gateway`` emits, and every
      +server→client request method it sends, is listed in ``apps/shared/src/gateway-events.json``
      +(``events`` / ``server_requests``) and nothing in the JSON is orphaned.
       
       The TypeScript half (``apps/shared/src/gateway-events.test.ts``) pins the typed
      -``GatewayEventMap`` to the same JSON, so a name added on either side alone goes red
      -somewhere. This file reads only Python sources and the JSON (never ``.ts`` text —
      -see ``tui_gateway/AGENTS.md``).
      -
      -Names are collected from the emitter side: literal first arguments to the emit
      -helpers, plus the tables that derive names at runtime (``_EXPIRING_REQUESTS`` →
      -``*.expire``, the change-watcher table, child delta mirroring, the subagent relay
      -events from ``tools/delegate_tool*.py``, the ``desktop_ui`` tool emitters, and the
      -literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames).
      +``GatewayEventMap`` and ``ServerRequestMap`` to the same JSON, so a name added on either
      +side alone goes red somewhere. This file reads only Python sources and the JSON (never
      +``.ts`` text — see ``tui_gateway/AGENTS.md``).
      +
      +Names are collected from the emitter side: literal first arguments to the emit helpers
      +and the server-request helpers (``server_requests.send`` / ``send_async`` / ``_ask``),
      +plus the tables that derive names at runtime (the change-watcher table, child delta
      +mirroring, the subagent relay events from ``tools/delegate_tool*.py``, the ``desktop_ui``
      +tool emitters, and the literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames).
       """
       
       from __future__ import annotations
      @@ -26,9 +27,12 @@
       GATEWAY_DIR = REPO / "tui_gateway"
       
       # Every helper whose first positional argument is the wire ``type``.
      -_EMIT_HELPERS = (
      -    "_emit", "_block", "_read_block", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle")
      +_EMIT_HELPERS = ("_emit", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle")
       _LITERAL_EMIT = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_EMIT_HELPERS))
      +# Server→client requests: ``server_requests.send("x", …)`` / ``send_async`` / the string-answer ``_ask`` and
      +# ``_read_block`` bridges.
      +_REQUEST_HELPERS = ("server_requests\\.send", "server_requests\\.send_async", "_ask", "_read_block")
      +_LITERAL_REQUEST = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_REQUEST_HELPERS))
       # ``{"type": "gateway.ready", ...}`` literal frames (entry.py / ws.py) and other
       # ``"type": "<name>"`` params written straight into an ``event`` frame.
       _LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S)
      @@ -50,10 +54,6 @@ def emitted_event_names() -> set[str]:
               names.update(_LITERAL_EMIT.findall(text))
               names.update(_LITERAL_FRAME.findall(text))
               names.update(_SIDE_AGENT.findall(text))
      -    # ``.request`` bridges that time out fire ``f"{event.removesuffix('.request')}.expire"``.
      -    from tui_gateway.server import _EXPIRING_REQUESTS
      -
      -    names.update(f"{event.removesuffix('.request')}.expire" for event in _EXPIRING_REQUESTS)
           from tui_gateway.change_watcher import _CHANGE_WATCHES
       
           names.update(_CHANGE_WATCHES)
      @@ -74,13 +74,33 @@ def emitted_event_names() -> set[str]:
           return names
       
       
      +def server_request_methods() -> set[str]:
      +    names: set[str] = set()
      +    for src in GATEWAY_DIR.glob("*.py"):
      +        names.update(_LITERAL_REQUEST.findall(_read(src)))
      +    return names
      +
      +
       @pytest.fixture(scope="module")
       def contract() -> list[str]:
      -    return json.loads(_read(CONTRACT))
      +    return json.loads(_read(CONTRACT))["events"]
      +
      +
      [redacted:email](scope="module")
      +def request_contract() -> list[str]:
      +    return json.loads(_read(CONTRACT))["server_requests"]
      +
      +
      +def test_contract_is_sorted_and_unique(contract, request_contract):
      +    assert contract == sorted(set(contract)), "gateway-events.json events must be a sorted, duplicate-free list"
      +    assert request_contract == sorted(set(request_contract)), "gateway-events.json server_requests must be sorted"
       
       
      -def test_contract_is_sorted_and_unique(contract):
      -    assert contract == sorted(set(contract)), "gateway-events.json must be a sorted, duplicate-free list"
      +def test_server_request_methods_match_the_contract(request_contract):
      +    sent = server_request_methods()
      +    assert sent == set(request_contract), (
      +        f"server requests sent {sorted(sent)} vs gateway-events.json server_requests {request_contract}; "
      +        "fix the JSON AND SERVER_REQUEST_METHODS / ServerRequestMap in apps/shared/src/gateway-events.ts")
       
       
       def test_every_emitted_event_is_in_the_contract(contract):
    • tests/tui_gateway/test_goal_command.py
      @@ -70,8 +70,7 @@ def server(hermes_home, monkeypatch):
           # _enter_buffered_busy. Clearing the per-session dicts gives the
           # next test a clean slate.
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
       
       
       @pytest.fixture()
    • tests/tui_gateway/test_hosted_room_member_activity_hook.py
      @@ -57,7 +57,10 @@ def test_room_member_session_events_reach_plugins_with_room_coordinates(observer
           _session("room-sid", hosted=True)
           try:
               server._emit("tool.start", "room-sid", {"tool_id": "call-1", "name": "terminal", "args": {"command": "ls"}})
      -        server._emit("approval.request", "room-sid", {"request_id": "req-1", "command": "rm -rf build"})
      +        # An approval is a server→client REQUEST frame, not an event; it is member activity all the same.
      +        from tui_gateway import server_requests
      +        server_requests.send_async("approval", "room-sid", {"request_id": "req-1", "command": "rm -rf build"},
      +                                   lambda result: None)("test_done")
               server._emit("session.info", "room-sid", {"title": "chrome, not member activity"})
               server._emit("tool.complete", "room-sid", {"tool_id": "call-1", "name": "terminal", "result": "ok"})
           finally:
      @@ -71,8 +74,9 @@ def test_room_member_session_events_reach_plugins_with_room_coordinates(observer
           assert {k: started[k] for k in HOSTED_TASK} == HOSTED_TASK
           assert started["payload"]["tool_id"] == "call-1" and started["payload"]["args"] == {"command": "ls"}
           assert observer[1]["payload"]["request_id"] == "req-1"
      -    # Per-session replay seq travels with the event so consumers can order/dedupe.
      -    assert [event["seq"] for event in observer] == sorted(event["seq"] for event in observer)
      +    # Per-session replay seq travels with the events so consumers can order/dedupe (a request frame carries none).
      +    seqs = [event["seq"] for event in observer if event["seq"] is not None]
      +    assert seqs == sorted(seqs) and len(seqs) == 2
       
       
       def test_ordinary_session_events_never_fire_the_room_hook(observer):
    • tests/tui_gateway/test_inline_rpc_gil_starvation.py
      @@ -56,8 +56,7 @@ def server():
           mod._methods.update(methods)
           mod._real_stdout = real_stdout
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
       
       
       @pytest.fixture()
    • tests/tui_gateway/test_loop_command.py
      @@ -44,8 +44,7 @@ def server(hermes_home):
               mod = importlib.import_module("tui_gateway.server")
               yield mod
               mod._sessions.clear()
      -        mod._pending.clear()
      -        mod._answers.clear()
      +        __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
       
       
       @pytest.fixture()
    • tests/tui_gateway/test_protocol.py
      @@ -56,8 +56,8 @@ def server():
           mod._real_stdout = real_stdout
           for sid in list(mod._sessions):
               mod._close_session_by_id(sid, end_reason="test_cleanup")
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    from tui_gateway import server_requests
      +    server_requests.reset_for_tests()
           mod._live_transports.clear()
       
       
      @@ -219,42 +219,26 @@ def test_live_session_payload_replays_pending_approval(server, monkeypatch):
           assert replayed == first
       
       
      -def test_live_session_payload_replays_pending_clarify(server):
      -    """A reattached client also receives a clarify question emitted while detached."""
      +def test_live_session_payload_replays_open_requests(server):
      +    """A reattached client receives the server→client request still blocking the session (a clarify sent
      +    while detached), scoped to the owning runtime session only, as a snapshot not a live reference."""
      +    from tui_gateway import server_requests
           session = {
      -        "agent": types.SimpleNamespace(),
      -        "cols": 80,
      -        "created_at": 1.0,
      -        "history": [],
      -        "history_lock": threading.Lock(),
      -        "running": True,
      -        "session_key": "stored-session",
      +        "agent": types.SimpleNamespace(), "cols": 80, "created_at": 1.0, "history": [],
      +        "history_lock": threading.Lock(), "running": True, "session_key": "stored-session",
           }
      -    clarify_payload = {
      -        "choices": ["staging", "production"],
      -        "question": "Which deployment target?",
      -        "request_id": "rid-clarify",
      -    }
      -    with server._prompt_lock:
      -        server._pending["rid-clarify"] = ("runtime-session", threading.Event())
      -        server._pending_prompt_payloads["rid-clarify"] = (
      -            "clarify.request",
      -            dict(clarify_payload),
      -        )
      -
      +    req = server_requests.ServerRequest("runtime-session", "clarify", {"question": "Which?", "choices": ["a", "b"]})
      +    server_requests._open[req.id] = req
           try:
               payload = server._live_session_payload("runtime-session", session)
               other = server._live_session_payload("other-session", session)
           finally:
      -        with server._prompt_lock:
      -            server._pending.pop("rid-clarify", None)
      -            server._pending_prompt_payloads.pop("rid-clarify", None)
      +        server_requests._open.pop(req.id, None)
       
      -    assert payload["pending_clarify"] == clarify_payload
      -    # Snapshot, not a live reference into the registry.
      -    assert payload["pending_clarify"] is not clarify_payload
      -    # Scoped to the owning runtime session only.
      -    assert "pending_clarify" not in other
      +    assert payload["open_requests"] == [{"id": req.id, "method": "clarify",
      +                                         "params": {"session_id": "runtime-session", "question": "Which?", "choices": ["a", "b"]}}]
      +    assert payload["open_requests"][0]["params"] is not req.params
      +    assert "open_requests" not in other
       
       
       def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
      @@ -290,275 +274,145 @@ def test_emit_with_payload(capture):
           assert msg["params"]["payload"]["key"] == "val"
       
       
      -# ── Blocking prompt round-trip ───────────────────────────────────────
      +# ── Server→client requests (tui_gateway/server_requests.py) ─────────
       
       
      -def test_block_and_respond(capture):
      -    server, _ = capture
      -    result = [None]
      +def _frames(buf):
      +    return [json.loads(line) for line in buf.getvalue().splitlines()]
       
      -    threading.Thread(
      -        target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
      -    ).start()
       
      -    for _ in range(100):
      -        if server._pending:
      -            break
      -        threading.Event().wait(0.01)
      -
      -    rid = next(iter(server._pending))
      -    server._answers[rid] = "my_answer"
      -    # _pending values are (sid, Event) tuples — unpack to set the Event
      -    _, ev = server._pending[rid]
      -    ev.set()
      -
      -    threading.Event().wait(0.1)
      -    assert result[0] == "my_answer"
      +def _wait_open(server_requests, buf=None, timeout=2.0):
      +    """The open request once its frame has been written (registration precedes the write)."""
      +    deadline = time.monotonic() + timeout
      +    while time.monotonic() < deadline:
      +        with server_requests._lock:
      +            req = next(iter(server_requests._open.values()), None)
      +        if req is not None and (buf is None or req.id in buf.getvalue()):
      +            return req
      +        time.sleep(0.01)
      +    raise AssertionError("server request never registered")
       
       
      [redacted:email](
      -    "event",
      -    ["secret.request", "sudo.request", "clarify.request", "terminal.read.request"],
      -)
      -def test_sensitive_prompt_timeout_emits_expiry(capture, event):
      +def test_server_request_round_trip_uses_response_frame(capture):
      +    """The backend sends a real JSON-RPC request (string id, method, params.session_id) and the client's
      +    response frame — not a method call — resolves it; the response frame itself gets no reply."""
      +    from tui_gateway import server_requests
           server, buf = capture
      +    box = {}
      +    thread = threading.Thread(target=lambda: box.__setitem__("r", server._ask("sudo", "s1", {}, timeout=5)), daemon=True)
      +    thread.start()
      +    req = _wait_open(server_requests, buf)
      +    frame = _frames(buf)[-1]
      +    assert frame == {"jsonrpc": "2.0", "id": req.id, "method": "sudo", "params": {"session_id": "s1"}}
      +    assert req.id.startswith("srq-")
       
      -    assert server._block(event, "s1", {}, timeout=0) == ""
      -
      -    messages = [json.loads(line) for line in buf.getvalue().splitlines()]
      -    request, expiry = [message["params"] for message in messages]
      -    assert request["type"] == event
      -    assert expiry["type"] == event.removesuffix(".request") + ".expire"
      -    assert expiry["session_id"] == "s1"
      -    assert expiry["payload"]["request_id"] == request["payload"]["request_id"]
      +    assert server.dispatch({"jsonrpc": "2.0", "id": req.id, "result": {"value": "hunter2"}}) is None
      +    thread.join(timeout=5)
      +    assert box["r"] == "hunter2"
      +    with server_requests._lock:
      +        assert not server_requests._open
       
       
      [redacted:email](
      -    ("method", "value_key"),
      -    [
      -        ("secret.respond", "value"),
      -        ("sudo.respond", "password"),
      -        ("clarify.respond", "answer"),
      -        ("terminal.read.respond", "text"),
      -    ],
      -)
      -def test_late_prompt_response_is_idempotent(server, method, value_key):
      -    """All four blocking bridges tolerate a late reply after their request has
      -    expired — the `*.respond` returns a graceful `{"status": "expired"}` instead
      -    of the raw 4009 protocol error a client would otherwise surface verbatim."""
      -    response = server.handle_request(
      -        {
      -            "id": "late-response",
      -            "method": method,
      -            "params": {"request_id": "expired-request", value_key: ""},
      -        }
      -    )
      -
      [redacted:email]("method", ["secret", "sudo", "terminal.read", "tour"])
      +def test_server_request_timeout_emits_one_request_cancel(capture, method):
      +    from tui_gateway import server_requests
      +    server, buf = capture
      +    assert server_requests.send(method, "s1", {}, timeout=0) is None
      +    request, cancel = _frames(buf)
      +    assert request["method"] == method
      +    assert cancel["params"]["type"] == "request.cancel"
      +    assert cancel["params"]["session_id"] == "s1"
      +    assert cancel["params"]["payload"] == {"id": request["id"], "method": method, "reason": "timeout"}
      +
      +
      +def test_late_response_and_lock_are_dropped_quietly(server):
      +    """A response for an id that already ended is ignored (no reply, no error); a late clarify.lock
      +    answers `expired` instead of a protocol error a card would surface verbatim."""
      +    assert server.dispatch({"jsonrpc": "2.0", "id": "srq-gone", "result": {"value": ""}}) is None
      +    response = server.handle_request({"id": "late", "method": "clarify.lock",
      +                                      "params": {"request_id": "srq-gone", "question_id": "q0", "answer": ""}})
           assert response["result"] == {"status": "expired"}
       
       
      -# ── clarify batch (multi-question) bridge ────────────────────────────
      -
      -
      -def _drain_batch_block(server, qids, timeout=5, payload=None):
      -    """Run a batch _block on a worker thread and return (thread, result box,
      -    emitted request payload). The caller resolves questions via
      -    handle_request and then joins."""
      +def _start_batch_clarify(server, buf, qids, timeout=None):
      +    from tui_gateway import server_requests
           box = {}
      -
      -    def run():
      -        box["answer"] = server._block(
      -            "clarify.request",
      -            "s1",
      -            dict(payload or {"questions": [{"qid": q, "question": q} for q in qids]}),
      -            timeout=timeout,
      -            batch_qids=list(qids),
      -        )
      -
      -    thread = threading.Thread(target=run, daemon=True)
      +    normalized = [{"qid": q, "id": "", "question": q, "choices": None, "choices_offered": [], "multi_select": False}
      +                  for q in qids]
      +    if timeout is not None:
      +        server._clarify_timeout_seconds = lambda: timeout
      +    thread = threading.Thread(
      +        target=lambda: box.__setitem__("answer", server._clarify_block("s1", "", None, questions=normalized)), daemon=True)
           thread.start()
      -    # Wait for the request to be registered so respond calls can find it.
      -    deadline = time.monotonic() + 2
      -    while time.monotonic() < deadline:
      -        with server._prompt_lock:
      -            if server._batch_clarify:
      -                rid = next(iter(server._batch_clarify))
      -                return thread, box, rid
      -        time.sleep(0.01)
      -    raise AssertionError("batch clarify request never registered")
      +    return thread, box, _wait_open(server_requests, buf)
       
       
      -def test_clarify_batch_resolves_when_all_questions_locked(capture):
      +def test_clarify_batch_locks_resolve_in_order_and_keep_partial_on_timeout(capture):
      +    """Per-question locks (clarify.lock) are editable until every qid is locked; the last lock resolves the
      +    request with the full answer set. Only wire fields reach the renderer."""
           server, buf = capture
      -    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
      -
      -    first = server.handle_request({
      -        "id": "a1", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q1", "answer": "beta"},
      -    })
      -    assert first["result"]["status"] == "ok"
      -    assert first["result"]["remaining"] == ["q0"]
      -    assert thread.is_alive()  # one question left — still blocking
      -
      -    second = server.handle_request({
      -        "id": "a2", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": "alpha"},
      -    })
      -    assert second["result"]["status"] == "ok"
      -    assert second["result"]["remaining"] == []
      -
      -    thread.join(timeout=5)
      -    assert not thread.is_alive()
      -    assert json.loads(box["answer"]) == {"answers": {"q0": "alpha", "q1": "beta"}}
      -
      -
      -def test_clarify_batch_answer_update_overwrites_before_completion(server):
      -    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
      -
      -    server.handle_request({
      -        "id": "a1", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": "first"},
      -    })
      -    server.handle_request({
      -        "id": "a2", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": "changed"},
      -    })
      -    server.handle_request({
      -        "id": "a3", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q1", "answer": "done"},
      -    })
      -
      -    thread.join(timeout=5)
      -    assert json.loads(box["answer"])["answers"]["q0"] == "changed"
      -
      -
      -def test_clarify_batch_empty_answer_is_a_locked_skip(server):
      -    """Skipping one question locks an empty answer — it counts toward
      -    completion instead of leaving the batch waiting."""
      -    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
      -
      -    server.handle_request({
      -        "id": "a1", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": ""},
      -    })
      -    server.handle_request({
      -        "id": "a2", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q1", "answer": "kept"},
      -    })
      +    thread, box, req = _start_batch_clarify(server, buf, ["q0", "q1"])
      +    sent = _frames(buf)[-1]["params"]["questions"][0]
      +    assert set(sent) == {"qid", "question", "choices", "multi_select"}
       
      +    first = server.handle_request({"id": "a1", "method": "clarify.lock",
      +                                   "params": {"request_id": req.id, "question_id": "q0", "answer": "x"}})
      +    assert first["result"] == {"status": "ok", "remaining": ["q1"]}
      +    redo = server.handle_request({"id": "a1b", "method": "clarify.lock",
      +                                  "params": {"request_id": req.id, "question_id": "q0", "answer": "y"}})
      +    assert redo["result"] == {"status": "ok", "remaining": ["q1"]}
      +    bad = server.handle_request({"id": "bad", "method": "clarify.lock",
      +                                 "params": {"request_id": req.id, "question_id": "nope", "answer": ""}})
      +    assert bad["error"]["code"] == 4002
      +    assert server._open_requests("s1")[0]["params"]["answers"] == {"q0": "y"}
      +    last = server.handle_request({"id": "a2", "method": "clarify.lock",
      +                                  "params": {"request_id": req.id, "question_id": "q1", "answer": ""}})
      +    assert last["result"] == {"status": "ok", "remaining": []}
           thread.join(timeout=5)
      -    assert json.loads(box["answer"]) == {"answers": {"q0": "", "q1": "kept"}}
      -
      -
      -def test_clarify_batch_unknown_question_id_rejected(server):
      -    thread, box, rid = _drain_batch_block(server, ["q0"])
      +    assert json.loads(box["answer"]) == {"answers": {"q0": "y", "q1": ""}}
       
      -    response = server.handle_request({
      -        "id": "bad", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q9", "answer": "x"},
      -    })
      -    assert response["error"]["code"] == 4002
      -
      -    server.handle_request({
      -        "id": "ok", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": "fine"},
      -    })
      -    thread.join(timeout=5)
      +    # Deadline: locked answers survive, timed_out flagged, one request.cancel.
      +    original_timeout = server._clarify_timeout_seconds
      +    try:
      +        thread, box, req = _start_batch_clarify(server, buf, ["q0", "q1"], timeout=1.5)
      +        locked = server.handle_request({"id": "b1", "method": "clarify.lock",
      +                                        "params": {"request_id": req.id, "question_id": "q0", "answer": "kept"}})
      +        assert locked["result"]["status"] == "ok"
      +        thread.join(timeout=5)
      +    finally:
      +        server._clarify_timeout_seconds = original_timeout
      +    assert json.loads(box["answer"]) == {"answers": {"q0": "kept"}, "timed_out": True}
      +    cancels = [f for f in _frames(buf) if f.get("method") == "event" and f["params"]["type"] == "request.cancel"]
      +    assert [c["params"]["payload"]["id"] for c in cancels] == [req.id]
       
       
      -def test_clarify_batch_timeout_keeps_locked_answers(capture):
      -    """Locked answers survive the deadline: the tool sees the partials plus
      -    timed_out instead of an empty string."""
      +def test_clarify_batch_cancel_all_is_a_response_without_answers(capture):
           server, buf = capture
      -    thread, box, rid = _drain_batch_block(server, ["q0", "q1"], timeout=1)
      -
      -    server.handle_request({
      -        "id": "a1", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": "kept"},
      -    })
      -
      -    thread.join(timeout=10)
      -    assert not thread.is_alive()
      -    result = json.loads(box["answer"])
      -    assert result == {"answers": {"q0": "kept"}, "timed_out": True}
      -    # The expire notification still fires for the un-finished batch.
      -    messages = [json.loads(line) for line in buf.getvalue().splitlines()]
      -    assert any(m["params"]["type"] == "clarify.expire" for m in messages)
      -
      -
      -def test_clarify_batch_cancel_all_returns_empty(server):
      -    """A respond without question_id cancels the whole batch (Esc path)."""
      -    thread, box, rid = _drain_batch_block(server, ["q0", "q1"])
      -
      -    server.handle_request({
      -        "id": "cancel", "method": "clarify.respond",
      -        "params": {"request_id": rid, "answer": ""},
      -    })
      -
      +    thread, box, req = _start_batch_clarify(server, buf, ["q0", "q1"])
      +    server.dispatch({"jsonrpc": "2.0", "id": req.id, "result": {}})
           thread.join(timeout=5)
           assert box["answer"] == ""
       
       
      -def test_clarify_batch_late_question_respond_is_idempotent(server):
      -    response = server.handle_request({
      -        "id": "late", "method": "clarify.respond",
      -        "params": {"request_id": "gone", "question_id": "q0", "answer": "x"},
      -    })
      -    assert response["result"] == {"status": "expired"}
      -
      -
      -def test_clarify_batch_state_cleared_after_resolution(server):
      -    thread, box, rid = _drain_batch_block(server, ["q0"])
      -    server.handle_request({
      -        "id": "a", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": "x"},
      -    })
      -    thread.join(timeout=5)
      -    with server._prompt_lock:
      -        assert rid not in server._batch_clarify
      -        assert rid not in server._pending
      -
      -
      -def test_clarify_block_helper_builds_batch_payload(capture):
      -    """_clarify_block forwards only wire fields (qid/question/choices/
      -    multi_select) — the tool-side normalized entries carry extra keys the
      -    renderer must not see."""
      +def test_clear_pending_cancels_only_that_session(capture):
      +    from tui_gateway import server_requests
           server, buf = capture
      -    normalized = [
      -        {
      -            "qid": "q0", "id": "approach", "question": "Which?",
      -            "choices": ["a (Recommended)", "b"], "choices_offered": ["a", "b"],
      -            "multi_select": False,
      -        },
      -    ]
      -
      -    box = {}
      -
      -    def run():
      -        box["answer"] = server._clarify_block("s1", "", None, questions=normalized)
      -
      -    thread = threading.Thread(target=run, daemon=True)
      -    thread.start()
      +    a = threading.Thread(target=lambda: server_requests.send("sudo", "sid-a", {}, timeout=None), daemon=True)
      +    b = threading.Thread(target=lambda: server_requests.send("sudo", "sid-b", {}, timeout=None), daemon=True)
      +    a.start(); b.start()
           deadline = time.monotonic() + 2
      -    rid = None
      -    while time.monotonic() < deadline and rid is None:
      -        with server._prompt_lock:
      -            rid = next(iter(server._batch_clarify), None)
      +    while time.monotonic() < deadline and len(server_requests._open) < 2:
               time.sleep(0.01)
      -    assert rid
      -
      -    server.handle_request({
      -        "id": "a", "method": "clarify.respond",
      -        "params": {"request_id": rid, "question_id": "q0", "answer": "a"},
      -    })
      -    thread.join(timeout=5)
      -
      -    messages = [json.loads(line) for line in buf.getvalue().splitlines()]
      -    request = messages[0]["params"]
      -    assert request["type"] == "clarify.request"
      -    sent = request["payload"]["questions"][0]
      -    assert set(sent) == {"qid", "question", "choices", "multi_select"}
      -    assert "id" not in sent and "choices_offered" not in sent
      +    assert server._session_pending_kind("sid-a") == "sudo"
      +    server._clear_pending("sid-a")
      +    a.join(timeout=2)
      +    assert not a.is_alive() and b.is_alive()
      +    assert server._session_pending_kind("sid-a") == "" and server._session_pending_kind("sid-b") == "sudo"
      +    server._clear_pending()
      +    b.join(timeout=2)
      +    assert not b.is_alive()
      +    reasons = [f["params"]["payload"]["reason"] for f in _frames(buf) if f.get("method") == "event"]
      +    assert reasons == ["interrupted", "shutdown"]
       
       
       def test_approval_pending_replays_unresolved_requests(server, monkeypatch):
      @@ -701,16 +555,6 @@ def test_approval_respond_4001_when_nothing_resolves(server, monkeypatch):
           assert response["error"]["code"] == 4001
       
       
      -def test_clear_pending(server):
      -    ev = threading.Event()
      -    # _pending values are (sid, Event) tuples
      -    server._pending["r1"] = ("sid-x", ev)
      -    server._clear_pending()
      -
      -    assert ev.is_set()
      -    assert server._answers["r1"] == ""
      -
      -
       # ── Session lookup ───────────────────────────────────────────────────
       
       
    • tests/tui_gateway/test_review_summary_callback.py
      @@ -43,8 +43,7 @@ def server():
           # _enter_buffered_busy. Clearing the per-session dicts gives the
           # next test a clean slate.
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
       
       
       def test_init_session_attaches_background_review_callback(server, monkeypatch):
    • tests/tui_gateway/test_session_control.py
      @@ -43,8 +43,7 @@ def server(hermes_home, monkeypatch):
           monkeypatch.setattr(mod, "_cfg_path", None)
           yield mod
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
       
       
       @pytest.fixture()
    • tests/tui_gateway/test_session_profile_db.py
      @@ -64,8 +64,7 @@ def server(hermes_home):
           mod._methods.clear()
           mod._methods.update(methods)
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
           mod._db = None
       
       
    • tests/tui_gateway/test_stranded_session_adoption.py
      @@ -183,8 +183,7 @@ def gateway(tmp_path, monkeypatch):
           mod._methods.clear()
           mod._methods.update(methods)
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
           mod._db = None
           default_db.close()
       
    • tests/tui_gateway/test_subagent_child_mirror.py
      @@ -36,8 +36,7 @@ def server():
       
           yield mod
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
           mod._child_mirrors.clear()
           mod._active_child_runs.clear()
       
    • tests/tui_gateway/test_tour_bridge_fail_fast.py
      @@ -1,9 +1,9 @@
      -"""A desktop client that cannot answer ``tour.request`` must not cost a full
      +"""A desktop client that cannot answer the ``tour`` server request must not cost a full
       bridge timeout per call.
       
       The renderer's handler ships in the desktop bundle; the tool is offered by the
       backend. An app build older than the tour tool has no branch for the event, so
      -nothing ever calls ``tour.respond`` and the agent blocks for the whole deadline
      +nothing ever answers the request and the agent blocks for the whole deadline
       — once per action the model tries. See tui_gateway.server._tour_request.
       """
       
      @@ -23,7 +23,7 @@ def session(monkeypatch):
       
       @pytest.fixture
       def bridge(monkeypatch):
      -    """Record every _block call and serve canned answers."""
      +    """Record every ``_ask`` (server request) call and serve canned answers."""
           calls = []
       
           def fake_block(event, sid, payload, timeout=None, **_kw):
      @@ -33,7 +33,7 @@ def fake_block(event, sid, payload, timeout=None, **_kw):
       
           fake_block.answers = []
           fake_block.calls = calls
      -    monkeypatch.setattr(server, "_block", fake_block)
      +    monkeypatch.setattr(server, "_ask", fake_block)
           return fake_block
       
       
      @@ -41,7 +41,7 @@ def test_first_action_is_probed_on_a_short_deadline(session, bridge):
           bridge.answers = [json.dumps({"success": True})]
           server._tour_request("s1", {"action": "targets"})
       
      -    assert bridge.calls[0]["event"] == "tour.request"
      +    assert bridge.calls[0]["event"] == "tour"
           assert bridge.calls[0]["timeout"] == server._TOUR_PROBE_TIMEOUT_S
           assert server._TOUR_PROBE_TIMEOUT_S < server._TOUR_TIMEOUT_S
       
    • tests/tui_gateway/test_tui_gateway_server.py
      @@ -496,15 +496,17 @@ def test_compute_host_turn_end_updates_metadata_mirror(monkeypatch):
               server._sessions.pop("iso-sid", None)
       
       
      -def test_compute_host_clarify_snapshot_replays_and_proxies_batch_answers(monkeypatch):
      -    """A host-owned clarify survives activation and receives its UI answers."""
      +def test_compute_host_open_request_survives_activation_and_proxies_locks_and_responses(monkeypatch):
      +    """A host-owned server request (batch clarify) is mirrored by the parent so `open_requests` replays it;
      +    `clarify.lock` and the client's response frame are relayed to the child that owns the wait."""
           class _Supervisor:
               def __init__(self):
                   self.responses = []
       
               def respond(self, sid, params, *, timeout=15.0):
                   self.responses.append((sid, dict(params), timeout))
      -            remaining = ["q1"] if params.get("question_id") == "q0" else []
      +            lock = params.get("lock") or {}
      +            remaining = ["q1"] if lock.get("question_id") == "q0" else []
                   return {"type": "respond.ack", "response": {"result": {"status": "ok", "remaining": remaining}}}
       
           sid = "host-clarify"
      @@ -515,53 +517,25 @@ def respond(self, sid, params, *, timeout=15.0):
           monkeypatch.setattr(server, "_get_compute_host_supervisor", lambda _cfg=None: supervisor)
           monkeypatch.setattr(server, "write_json", lambda _message: True)
       
      +    questions = [{"qid": "q0", "question": "First?", "choices": ["a"]}, {"qid": "q1", "question": "Second?", "choices": ["b"]}]
           try:
      -        server._relay_compute_host_rpc(
      -            {
      -                "jsonrpc": "2.0",
      -                "method": "event",
      -                "params": {
      -                    "type": "clarify.request",
      -                    "session_id": sid,
      -                    "payload": {
      -                        "request_id": "host-request",
      -                        "questions": [
      -                            {"qid": "q0", "question": "First?", "choices": ["a"]},
      -                            {"qid": "q1", "question": "Second?", "choices": ["b"]},
      -                        ],
      -                    },
      -                },
      -            }
      -        )
      +        server._relay_compute_host_rpc({"jsonrpc": "2.0", "id": "srq-host", "method": "clarify",
      +                                        "params": {"session_id": sid, "questions": questions}})
       
               activated = server._live_session_payload(sid, session)
      -        assert activated["pending_clarify"]["request_id"] == "host-request"
      -
      -        response = server.handle_request(
      -            {
      -                "id": "clarify-q0",
      -                "method": "clarify.respond",
      -                "params": {"request_id": "host-request", "question_id": "q0", "answer": "a"},
      -            }
      -        )
      +        assert activated["open_requests"] == [{"id": "srq-host", "method": "clarify",
      +                                               "params": {"session_id": sid, "questions": questions}}]
       
      +        response = server.handle_request({"id": "lock-q0", "method": "clarify.lock",
      +                                          "params": {"request_id": "srq-host", "question_id": "q0", "answer": "a"}})
               assert response["result"] == {"status": "ok", "remaining": ["q1"]}
      -        assert supervisor.responses == [
      -            (sid, {"request_id": "host-request", "question_id": "q0", "answer": "a"}, 15.0)
      -        ]
      -        replayed = server._live_session_payload(sid, session)["pending_clarify"]
      -        assert replayed["answers"] == {"q0": "a"}
      +        assert supervisor.responses == [(sid, {"lock": {"request_id": "srq-host", "question_id": "q0", "answer": "a"}}, 15.0)]
      +        assert server._live_session_payload(sid, session)["open_requests"][0]["params"]["answers"] == {"q0": "a"}
       
      -        final_response = server.handle_request(
      -            {
      -                "id": "clarify-q1",
      -                "method": "clarify.respond",
      -                "params": {"request_id": "host-request", "question_id": "q1", "answer": "b"},
      -            }
      -        )
      -
      -        assert final_response["result"] == {"status": "ok", "remaining": []}
      -        assert "pending_clarify" not in server._live_session_payload(sid, session)
      +        # The client's response frame (cancel-all) is relayed to the child and clears the mirror.
      +        assert server.dispatch({"jsonrpc": "2.0", "id": "srq-host", "result": {}}) is None
      +        assert supervisor.responses[-1] == (sid, {"frame": {"jsonrpc": "2.0", "id": "srq-host", "result": {}}}, 15.0)
      +        assert "open_requests" not in server._live_session_payload(sid, session)
           finally:
               server._sessions.pop(sid, None)
       
      @@ -13585,10 +13559,20 @@ def replace_messages(
       # ---------------------------------------------------------------------------
       
       
      +def _open_request(sid, method="clarify"):
      +    from tui_gateway import server_requests
      +    req = server_requests.ServerRequest(sid, method, {})
      +    with server_requests._lock:
      +        server_requests._open[req.id] = req
      +    return req
      +
      +
       def test_interrupt_only_clears_own_session_pending():
      -    """session.interrupt on session A must NOT release pending prompts
      -    that belong to session B."""
      +    """session.interrupt on session A withdraws A's open server→client requests (a request.cancel each)
      +    and must NOT touch session B's — otherwise B's clarify/sudo/secret prompt silently resolves as if
      +    the user cancelled it."""
           import types
      +    from tui_gateway import server_requests
       
           session_a = _session()
           session_a["agent"] = types.SimpleNamespace(interrupt=lambda: None)
      @@ -13596,71 +13580,21 @@ def test_interrupt_only_clears_own_session_pending():
           session_b["agent"] = types.SimpleNamespace(interrupt=lambda: None)
           server._sessions["sid_a"] = session_a
           server._sessions["sid_b"] = session_b
      +    req_a1, req_a2, req_b = _open_request("sid_a"), _open_request("sid_a", "sudo"), _open_request("sid_b")
       
           try:
      -        # Simulate pending prompts on both sessions (what _block creates
      -        # while a clarify/sudo/secret request is outstanding).
      -        ev_a = threading.Event()
      -        ev_b = threading.Event()
      -        server._pending["rid-a"] = ("sid_a", ev_a)
      -        server._pending["rid-b"] = ("sid_b", ev_b)
      -        server._answers.clear()
      -
      -        # Interrupt session A.
      -        resp = server.handle_request(
      -            {
      -                "id": "1",
      -                "method": "session.interrupt",
      -                "params": {"session_id": "sid_a"},
      -            }
      -        )
      +        resp = server.handle_request({"id": "1", "method": "session.interrupt", "params": {"session_id": "sid_a"}})
               assert resp.get("result"), f"got error: {resp.get('error')}"
       
      -        # Session A's pending must be released to empty.
      -        assert ev_a.is_set(), "sid_a pending Event should be set after interrupt"
      -        assert server._answers.get("rid-a") == ""
      -
      -        # Session B's pending MUST remain untouched — no cross-session blast.
      -        assert not ev_b.is_set(), (
      -            "CRITICAL: session.interrupt on sid_a released a pending prompt "
      -            "belonging to sid_b — other sessions' clarify/sudo/secret "
      -            "prompts are being silently cancelled"
      -        )
      -        assert "rid-b" not in server._answers
      +        assert req_a1.event.is_set() and req_a2.event.is_set() and not req_a1.answered
      +        assert not req_b.event.is_set(), (
      +            "CRITICAL: session.interrupt on sid_a released a prompt belonging to sid_b")
      +        assert server_requests.open_requests("sid_a") == []
      +        assert [r["id"] for r in server_requests.open_requests("sid_b")] == [req_b.id]
           finally:
               server._sessions.pop("sid_a", None)
               server._sessions.pop("sid_b", None)
      -        server._pending.pop("rid-a", None)
      -        server._pending.pop("rid-b", None)
      -        server._answers.pop("rid-a", None)
      -        server._answers.pop("rid-b", None)
      -
      -
      -def test_interrupt_clears_multiple_own_pending():
      -    """When a single session has multiple pending prompts (uncommon but
      -    possible via nested tool calls), interrupt must release all of them."""
      -    import types
      -
      -    sess = _session()
      -    sess["agent"] = types.SimpleNamespace(interrupt=lambda: None)
      -    server._sessions["sid"] = sess
      -
      -    try:
      -        ev1, ev2 = threading.Event(), threading.Event()
      -        server._pending["r1"] = ("sid", ev1)
      -        server._pending["r2"] = ("sid", ev2)
      -
      -        resp = server.handle_request(
      -            {"id": "1", "method": "session.interrupt", "params": {"session_id": "sid"}}
      -        )
      -        assert resp.get("result")
      -        assert ev1.is_set() and ev2.is_set()
      -        assert server._answers.get("r1") == "" and server._answers.get("r2") == ""
      -    finally:
      -        server._sessions.pop("sid", None)
      -        for key in ("r1", "r2"):
      -            server._pending.pop(key, None)
      -            server._answers.pop(key, None)
      +        server_requests.reset_for_tests()
       
       
       def test_run_prompt_submit_registers_turn_thread_for_interrupt(monkeypatch):
      @@ -14295,39 +14229,16 @@ def is_alive(self):
       
       
       def test_clear_pending_without_sid_clears_all():
      -    """_clear_pending(None) is the shutdown path — must still release
      -    every pending prompt regardless of owning session."""
      -    ev1, ev2, ev3 = threading.Event(), threading.Event(), threading.Event()
      -    server._pending["a"] = ("sid_x", ev1)
      -    server._pending["b"] = ("sid_y", ev2)
      -    server._pending["c"] = ("sid_z", ev3)
      +    """_clear_pending(None) is the process-exit path — every open request is withdrawn, and a response for
      +    a withdrawn id is dropped quietly (no error frame back to the client)."""
      +    from tui_gateway import server_requests
      +    reqs = [_open_request("sid-x"), _open_request("sid-y", "sudo")]
           try:
               server._clear_pending(None)
      -        assert ev1.is_set() and ev2.is_set() and ev3.is_set()
      -    finally:
      -        for key in ("a", "b", "c"):
      -            server._pending.pop(key, None)
      -            server._answers.pop(key, None)
      -
      -
      -def test_respond_unpacks_sid_tuple_correctly():
      -    """After the (sid, Event) tuple change, _respond must still work."""
      -    ev = threading.Event()
      -    server._pending["rid-x"] = ("sid_x", ev)
      -    try:
      -        resp = server.handle_request(
      -            {
      -                "id": "1",
      -                "method": "clarify.respond",
      -                "params": {"request_id": "rid-x", "answer": "the answer"},
      -            }
      -        )
      -        assert resp.get("result")
      -        assert ev.is_set()
      -        assert server._answers.get("rid-x") == "the answer"
      +        assert all(r.event.is_set() and not r.answered for r in reqs)
      +        assert server.dispatch({"jsonrpc": "2.0", "id": reqs[0].id, "result": {"answer": "late"}}) is None
           finally:
      -        server._pending.pop("rid-x", None)
      -        server._answers.pop("rid-x", None)
      +        server_requests.reset_for_tests()
       
       
       # ---------------------------------------------------------------------------
      @@ -20697,49 +20608,44 @@ def fake_listen(should_stop, capture=False, on_trigger=None, **_kw):
           assert not listened.is_set()
       
       
      -def test_clarify_callback_uses_configured_timeout(monkeypatch):
      -    """The TUI/desktop clarify bridge honors the canonical clarify timeout
      -    (via _clarify_timeout_seconds) instead of the hardcoded _block default."""
      +def _capture_server_request(monkeypatch, result):
      +    """Stub the server-request send and capture (method, sid, params, timeout)."""
      +    from tui_gateway import server_requests
           captured = {}
       
      -    monkeypatch.setattr(server, "_clarify_timeout_seconds", lambda: 42)
      +    def fake_send(method, sid, params, *, timeout, qids=None):
      +        captured.update(method=method, sid=sid, params=params, timeout=timeout, qids=qids)
      +        return result
       
      -    def fake_block(event, sid, payload, timeout=300):
      -        captured.update(event=event, sid=sid, payload=payload, timeout=timeout)
      -        return "answer"
      +    monkeypatch.setattr(server_requests, "send", fake_send)
      +    return captured
       
      -    monkeypatch.setattr(server, "_block", fake_block)
      +
      +def test_clarify_callback_uses_configured_timeout(monkeypatch):
      +    """The TUI/desktop clarify bridge sends a ``clarify`` server request with the canonical clarify timeout
      +    (via _clarify_timeout_seconds), and returns the response's ``answer``."""
      +    monkeypatch.setattr(server, "_clarify_timeout_seconds", lambda: 42)
      +    captured = _capture_server_request(monkeypatch, {"answer": "answer"})
       
           result = server._agent_cbs("sid-1")["clarify_callback"]("Pick one", ["a", "b"])
       
           assert result == "answer"
      -    assert captured["event"] == "clarify.request"
      +    assert captured["method"] == "clarify" and captured["sid"] == "sid-1"
           assert captured["timeout"] == 42
      -    assert captured["payload"] == {"question": "Pick one", "choices": ["a", "b"]}
      +    assert captured["params"] == {"question": "Pick one", "choices": ["a", "b"]}
       
       
       def test_clarify_callback_multi_select_hint(monkeypatch):
      -    """multi_select=True adds the hint to the payload; the single-select
      -    payload shape stays byte-identical to the pre-multi-select protocol
      -    (older renderers must never see the extra field)."""
      -    captured = {}
      -
      -    def fake_block(event, sid, payload, timeout=300):
      -        captured.update(payload=payload)
      -        return "answer"
      -
      -    monkeypatch.setattr(server, "_block", fake_block)
      +    """multi_select=True adds the hint to the params; the single-select shape stays byte-identical to the
      +    pre-multi-select protocol (older renderers must never see the extra field)."""
      +    captured = _capture_server_request(monkeypatch, {"answer": "answer"})
           cb = server._agent_cbs("sid-1")["clarify_callback"]
       
           cb("Pick many", ["a", "b"], multi_select=True)
      -    assert captured["payload"] == {
      -        "question": "Pick many",
      -        "choices": ["a", "b"],
      -        "multi_select": True,
      -    }
      +    assert captured["params"] == {"question": "Pick many", "choices": ["a", "b"], "multi_select": True}
       
           cb("Pick one", ["a", "b"], multi_select=False)
      -    assert captured["payload"] == {"question": "Pick one", "choices": ["a", "b"]}
      +    assert captured["params"] == {"question": "Pick one", "choices": ["a", "b"]}
       
       
       @pytest.mark.parametrize(
      @@ -20747,8 +20653,8 @@ def fake_block(event, sid, payload, timeout=300):
           [(0, None), (-1, None), (42, 42)],
       )
       def test_clarify_timeout_seconds_maps_non_positive_to_unlimited(monkeypatch, configured, expected):
      -    """A ``<= 0`` clarify timeout means unlimited and reaches _block as None
      -    (ev.wait(None) waits forever) rather than an immediate ev.wait(0) skip."""
      +    """A ``<= 0`` clarify timeout means unlimited and reaches the server request as None
      +    (wait(None) waits forever) rather than an immediate wait(0) skip."""
           monkeypatch.setattr("tools.clarify_gateway.get_clarify_timeout", lambda: configured)
       
           assert server._clarify_timeout_seconds() == expected
    • tests/tui_gateway/test_undo_command.py
      @@ -55,8 +55,7 @@ def server(hermes_home):
           mod._methods.clear()
           mod._methods.update(methods)
           mod._sessions.clear()
      -    mod._pending.clear()
      -    mod._answers.clear()
      +    __import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
           mod._db = None
       
       
    • tools/approval.py
      @@ -177,6 +177,17 @@ def list_gateway_approvals(session_key: str) -> list[dict]:
               return [dict(entry.data) for entry in _gateway_queues.get(session_key, [])]
       
       
      +def register_gateway_settle(session_key: str, request_id: str, settle) -> bool:
      +    """Attach ``settle(reason)`` to one pending approval; it runs once when that wait ends by any path.
      +    False when the request is no longer pending (the surface should withdraw its prompt itself)."""
      +    with _lock:
      +        for entry in _gateway_queues.get(session_key, []):
      +            if entry.data.get("request_id") == request_id:
      +                entry.settle = settle
      +                return True
      +    return False
      +
      +
       def ack_gateway_approval(session_key: str, request_id: str) -> bool:
           """Record that a client received a particular pending approval request."""
           with _lock:
    • tools/approval_gateway_wait.py
      @@ -24,13 +24,16 @@
       
       class _ApprovalEntry:
           """One pending dangerous-command approval inside a gateway session."""
      -    __slots__ = ("event", "data", "result", "reason", "acknowledged")
      +    __slots__ = ("event", "data", "result", "reason", "acknowledged", "settle")
       
           def __init__(self, data: dict):
               self.event = threading.Event()
               self.data = dict(data)
               self.data.setdefault("request_id", uuid.uuid4().hex)
               self.acknowledged = False
      +        # Surface hook run once when the wait ends by ANY path (answer, timeout, interrupt, /approve from
      +        # another client): the tui_gateway withdraws its open server→client request through it.
      +        self.settle = None
               self.result: str | None = None  # "once"|"session"|"always"|"deny"
               # Free-text reason from ``/deny <reason>`` so the agent can adapt, not just hear "denied".
               self.reason: str | None = None
      @@ -139,13 +142,19 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
           with _approval._lock:
               _approval._gateway_queues.setdefault(session_key, []).append(entry)
       
      -    def _drop_entry() -> None:
      +    def _drop_entry(reason: str) -> None:
               with _approval._lock:
                   queue = _approval._gateway_queues.get(session_key, [])
                   if entry in queue:
                       queue.remove(entry)
                   if not queue:
                       _approval._gateway_queues.pop(session_key, None)
      +            settle, entry.settle = entry.settle, None
      +        if settle is not None:
      +            try:
      +                settle(reason)
      +            except Exception:
      +                logger.debug("approval settle hook failed", exc_info=True)
       
           # Plugins hear about the request before the gateway does (real-time observers).
           _ctx._fire_approval_hook("pre_approval_request", **payload)
      @@ -154,7 +163,7 @@ def _drop_entry() -> None:
               notify_cb(dict(entry.data))
           except Exception as exc:
               logger.warning("Gateway approval notify failed: %s", exc)
      -        _drop_entry()
      +        _drop_entry("notify_failed")
               _ctx._fire_approval_hook("post_approval_response", **payload, choice="notify_failed")
               return {"resolved": False, "choice": None, "notify_failed": True}
       
      @@ -163,5 +172,5 @@ def _drop_entry() -> None:
           if state == "interrupted":
               entry.result = "deny"
               entry.event.set()
      -    _drop_entry()
      +    _drop_entry("answered" if state == "set" else state)
           return _finish(payload, state != "timeout", entry.result, entry.reason)
    • tui_gateway/AGENTS.md
      @@ -18,12 +18,20 @@ Never move agent behaviour into the renderer.
       
       ## Transport
       
      -Newline-delimited JSON-RPC over stdio: requests from Ink, events from Python. `tui_gateway/server.py`
      +Newline-delimited JSON-RPC over stdio, peer-to-peer: client→server method calls, server→client
      +**requests** (the agent asking the user something: `approval`, `clarify`, `sudo`, `secret`, `vault.*`,
      +`mcp.setup`, the desktop read/act bridges) and server→client `event` notifications. `tui_gateway/server.py`
       is the facade with the method/event catalog; methods live in `methods_*.py` siblings (`methods_config`,
       `methods_complete`, `methods_browser`, `methods_bot_relay`, ...), event publishing in
      -`event_publisher.py` / `event_replay.py`. Desktop reaches the same server over WebSocket via
      -`apps/shared` (`JsonRpcGatewayClient`). New RPC = a new `methods_<topic>.py` or an entry in an
      -existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules).
      +`event_publisher.py` / `event_replay.py`, server→client requests in `server_requests.py` (`send()` blocks
      +the agent thread until the response frame with the same `srq-<n>` id arrives; `cancel*` withdraws with a
      +`request.cancel` event; `open_requests(sid)` is what `session.resume` / `session.events.since` replay so a
      +reconnecting client re-renders the still-open questions). Desktop reaches the same server over WebSocket
      +via `apps/shared` (`JsonRpcGatewayClient`, `onRequest`). New RPC = a new `methods_<topic>.py` or an entry
      +in an existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules).
      +New question for the user = `_ask("<method>", sid, params, timeout)` in the emitter, a handler in
      +`apps/desktop/.../gateway-event/server-requests.ts` and `ui-tui/src/app/createServerRequestHandler.ts`,
      +and the method in `ServerRequestMap` + `apps/shared/src/gateway-events.json`.
       New event = a new key in `apps/shared/src/gateway-events.ts::GatewayEventMap` + `BACKEND_EVENT_NAMES`
       AND `apps/shared/src/gateway-events.json`; `tests/tui_gateway/test_gateway_event_contract.py` (emitter
       side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when either drifts.
      @@ -34,8 +42,8 @@ side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when ei
       |---|---|---|
       | Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta` / `message.complete` |
       | Tool activity | `thinking.tsx` | `tool.start` / `tool.generating` / `tool.complete` |
      -| Approvals | `prompts.tsx` | `approval.request` → `approval.respond` |
      -| Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify.respond`, `sudo.respond`, `secret.respond` |
      +| Approvals | `prompts.tsx` | server→client request `approval` → response `{choice}` |
      +| Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | server→client requests `clarify` / `sudo` / `secret` (`server_requests.py`) |
       | Session picker | `sessionPicker.tsx` | `session.list` / `session.resume` |
       | Slash commands | local handler + fallthrough | `slash.exec` → `_SlashWorker`; `command.dispatch` |
       | Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
    • tui_gateway/agent_callbacks.py
      @@ -79,11 +79,11 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:
       
       
       def _agent_cbs(sid: str) -> dict:
      -    def _read_block(event: str, timeout: int):
      -        # read_terminal / read_preview (desktop GUI): blocking bridge like clarify; the preview
      +    def _read_block(method: str, timeout: int):
      +        # read_terminal / read_preview (desktop GUI): server request like clarify; the preview
               # read gets longer since a URL tab extracts text from a live page.
      -        return lambda start=None, count=None: _block(
      -            event, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None},
      +        return lambda start=None, count=None: _ask(
      +            method, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None},
                   timeout=timeout)
       
           callbacks = {
      @@ -105,17 +105,17 @@ def _read_block(event: str, timeout: int):
               "notice_clear_callback": lambda key: _emit("notification.clear", sid, {"key": key}),
               "clarify_callback": lambda q, c, multi_select=False, questions=None: (
                   _clarify_block(sid, q, c, multi_select=multi_select, questions=questions)),
      -        "read_terminal_callback": _read_block("terminal.read.request", 30),
      -        "read_preview_callback": _read_block("preview.read.request", 45),
      +        "read_terminal_callback": _read_block("terminal.read", 30),
      +        "read_preview_callback": _read_block("preview.read", 45),
               # drive_preview / annotate_preview (desktop GUI): same budget as the preview read it ends with.
      -        "drive_preview_callback": lambda payload: _block("preview.act.request", sid, dict(payload), timeout=45),
      +        "drive_preview_callback": lambda payload: _ask("preview.act", sid, dict(payload), timeout=45),
               # read_window_below (desktop GUI): main process enumerates native windows.
      -        "read_window_below_callback": lambda: _block("window.read.request", sid, {}, timeout=30),
      +        "read_window_below_callback": lambda: _ask("window.read", sid, {}, timeout=30),
               # setup_mcp (desktop GUI): consent card + install/enable/OAuth; long timeout on purpose
      -        # (typing an API key, browser OAuth) and, like clarify, a late answer is tolerated.
      -        "setup_mcp_callback": lambda server, action, reason: _block(
      -            "mcp.setup.request", sid, {"server": server, "action": action, "reason": reason}, timeout=600),
      -        # tour (desktop GUI): renderer drives driver.js and answers tour.respond.
      +        # (typing an API key, browser OAuth).
      +        "setup_mcp_callback": lambda server, action, reason: _ask(
      +            "mcp.setup", sid, {"server": server, "action": action, "reason": reason}, timeout=600),
      +        # tour (desktop GUI): renderer drives driver.js and answers the ``tour`` request.
               "tour_callback": lambda payload: _tour_request(sid, payload)}
       
           # Interim assistant commentary (text alongside tool calls), gated on display.interim_assistant_
      @@ -162,13 +162,13 @@ def _wire_callbacks(sid: str):
       
           def secret_cb(env_var, prompt, metadata=None):
               pl = {"prompt": prompt, "env_var": env_var, **({"metadata": metadata} if metadata else {})}
      -        val = _block("secret.request", sid, pl)
      +        val = _ask("secret", sid, pl)
               if not val:
                   return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"}
               from hermes_cli.config import save_env_value_secure
               return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"}
       
      -    set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120))
      +    set_sudo_password_callback(lambda: _ask("sudo", sid, {}, timeout=120))
           set_project_workspace_callback(_apply_project_workspace)
           set_secret_capture_callback(secret_cb)
           # External password-manager unlock: the renderer shows a masked master-password card; the
      @@ -176,12 +176,12 @@ def secret_cb(env_var, prompt, metadata=None):
           from agent.vault_backends.unlock import (set_code_prompt_callback, set_current_session_id,
                                                    set_save_login_prompt_callback, set_unlock_prompt_callback)
           set_current_session_id(sid)  # an unlock made on this turn belongs to this session (released with it)
      -    set_unlock_prompt_callback(lambda backend, display_name: _block(
      -        "vault.unlock.request", sid, {"backend": backend, "display_name": display_name}, timeout=120))
      +    set_unlock_prompt_callback(lambda backend, display_name: _ask(
      +        "vault.unlock_prompt", sid, {"backend": backend, "display_name": display_name}, timeout=120))
       
           def save_login_cb(origin, site):
               # The renderer shows identifier + masked password; the JSON answer goes straight to the vault store.
      -        raw = _block("vault.save_login.request", sid, {"origin": origin, "site": site}, timeout=180)
      +        raw = _ask("vault.save_login", sid, {"origin": origin, "site": site}, timeout=180)
               try:
                   data = json.loads(raw) if raw else None
               except ValueError:
      @@ -189,8 +189,8 @@ def save_login_cb(origin, site):
               return data if isinstance(data, dict) and data.get("password") else None
       
           set_save_login_prompt_callback(save_login_cb)
      -    set_code_prompt_callback(lambda site, hint: _block(
      -        "vault.code.request", sid, {"site": site, "hint": hint}, timeout=180))
      +    set_code_prompt_callback(lambda site, hint: _ask(
      +        "vault.code", sid, {"site": site, "hint": hint}, timeout=180))
       
       
       def _available_personalities(cfg: dict | None = None) -> dict:
    • tui_gateway/compute_host.py
      @@ -190,7 +190,9 @@ def body(server: Any, sid: str, request_id: Any) -> None:
               self._guarded(frame, "interrupt.ack", body, applied=False)
       
           def _handle_respond(self, frame: dict[str, Any]) -> None:
      -        """Resolve an interactive request in the host-owned pending registry."""
      +        """Resolve a server→client request this host owns: ``params.frame`` is the client's JSON-RPC response
      +        frame relayed by the parent; ``params.lock`` is one batch-clarify lock (answered with ``clarify.lock``'s
      +        result so the parent can ack the client)."""
               def body(server: Any, sid: str, request_id: Any) -> None:
                   params = frame.get("params")
                   error = ("session not found" if sid not in server._sessions
      @@ -198,7 +200,13 @@ def body(server: Any, sid: str, request_id: Any) -> None:
                   if error:
                       self._reply("respond.error", sid, request_id, message=error)
                       return
      -            response = server._methods["clarify.respond"](request_id, params)
      +            from tui_gateway import server_requests
      +            if isinstance(params.get("lock"), dict):
      +                response = server._methods["clarify.lock"](request_id, params["lock"])
      +            else:
      +                response_frame = params.get("frame") if isinstance(params.get("frame"), dict) else params
      +                resolved = server_requests.resolve_response(response_frame)
      +                response = {"jsonrpc": "2.0", "id": request_id, "result": {"status": "ok" if resolved else "expired"}}
                   self._reply("respond.ack", sid, request_id, response=response)
               self._guarded(frame, "respond.error", body)
       
    • tui_gateway/compute_host_bridge.py
      @@ -90,7 +90,8 @@ def _compute_host_adopt_frame_meta(session: dict, frame: dict) -> None:
       
       
       def _relay_compute_host_rpc(message: dict) -> bool:
      -    """Relay host events while retaining the clarify snapshot needed on resume."""
      +    """Relay host frames to the client while mirroring the server→client request the host has open, so a
      +    reconnecting client gets it back through ``open_requests``."""
           params = message.get("params") if isinstance(message, dict) else None
           if isinstance(message, dict) and message.get("method") == "compute_host.activity":
               if isinstance(params, dict):
      @@ -101,17 +102,20 @@ def _relay_compute_host_rpc(message: dict) -> bool:
                                   and session.get("_compute_host_turn_id") == params["turn_id"]):
                               session["_compute_host_activity_ns"] = params.get("activity_ns")
               return True  # Internal observation, not a client event or replay entry.
      -    kind = params.get("type") if isinstance(params, dict) else None
      -    if kind in {"clarify.request", "clarify.expire"}:
      +    if isinstance(message, dict) and isinstance(message.get("id"), str) and message.get("method") not in (None, "event"):
      +        # A server request minted by the child: remember it against its session until it is answered/withdrawn.
      +        session = _sessions.get(str((params or {}).get("session_id") or "")) if isinstance(params, dict) else None
      +        if session is not None:
      +            with _history_lock(session):
      +                session["_compute_host_open_request"] = {
      +                    "id": message["id"], "method": message["method"], "params": dict(params)}
      +    elif isinstance(params, dict) and params.get("type") == "request.cancel":
               session = _sessions.get(str(params.get("session_id") or ""))
               payload = params.get("payload")
      -        request_id = payload.get("request_id") if isinstance(payload, dict) else None
      -        if session is not None and request_id:
      +        if session is not None and isinstance(payload, dict):
                   with _history_lock(session):
      -                if kind == "clarify.request":
      -                    session["_compute_host_pending_clarify"] = dict(payload)
      -                elif _pending_clarify_matches(session, request_id):
      -                    session.pop("_compute_host_pending_clarify", None)
      +                if _open_request_matches(session, payload.get("id")):
      +                    session.pop("_compute_host_open_request", None)
           return write_json(message)
       
       
      @@ -119,62 +123,65 @@ def _history_lock(session: dict):
           return session.get("history_lock", threading.Lock())
       
       
      -def _pending_clarify_matches(session: dict, request_id) -> bool:
      -    """Whether ``session``'s mirrored pending clarify is ``request_id``. Caller holds
      -    history_lock."""
      -    pending = session.get("_compute_host_pending_clarify")
      -    return isinstance(pending, dict) and pending.get("request_id") == request_id
      +def _open_request_matches(session: dict, request_id) -> bool:
      +    """Whether ``session``'s mirrored open request is ``request_id``. Caller holds history_lock."""
      +    mirrored = session.get("_compute_host_open_request")
      +    return isinstance(mirrored, dict) and mirrored.get("id") == request_id
       
       
      -def _compute_host_clarify_session(request_id: str) -> tuple[str, dict] | None:
      -    """Find the parent mirror for one host-owned clarify request."""
      +def _compute_host_request_session(request_id: str) -> tuple[str, dict] | None:
      +    """Find the parent mirror for one host-owned server request."""
           for sid, session in list(_sessions.items()) if request_id else ():
               with _history_lock(session):
      -            if _pending_clarify_matches(session, request_id):
      +            if _open_request_matches(session, request_id):
                       return sid, session
           return None
       
       
      -def _update_compute_host_clarify_snapshot(sid: str, session: dict, params: dict, result: dict) -> None:
      -    """Keep reconnect snapshots accurate while a batch clarify is answered."""
      -    request_id = str(params.get("request_id") or "")
      -    question_id = str(params.get("question_id") or "")
      +def _relay_compute_host_response(frame: dict) -> bool:
      +    """Forward a client's response frame to the compute-host child that owns the request. False when no
      +    child owns that id."""
      +    located = _compute_host_request_session(str(frame.get("id") or ""))
      +    if located is None or not _session_uses_compute_host(located[1]):
      +        return False
      +    sid, session = located
           with _history_lock(session):
      -        if not _pending_clarify_matches(session, request_id):
      -            return
      -        pending = session["_compute_host_pending_clarify"]
      -        if result.get("status") == "expired" or not result.get("remaining") and not question_id:
      -            session.pop("_compute_host_pending_clarify", None)
      -        elif question_id and isinstance(result.get("remaining"), list):
      -            pending["answers"] = {**(pending.get("answers") or {}),
      -                                  question_id: str(params.get("answer") or "")}
      -            if not result["remaining"]:
      -                session.pop("_compute_host_pending_clarify", None)
      -
      -
      -def _respond_compute_host_clarify(rid: str, params: dict) -> dict | None:
      -    """Proxy a clarify answer into the process that owns its pending Event."""
      -    located = _compute_host_clarify_session(str(params.get("request_id") or ""))
      +        session.pop("_compute_host_open_request", None)
      +    try:
      +        _get_compute_host_supervisor().respond(sid, {"frame": dict(frame)})
      +    except Exception:
      +        logger.debug("compute-host response relay failed sid=%s", sid, exc_info=True)
      +    return True
      +
      +
      +def _lock_compute_host_clarify(rid: str, request_id: str, question_id: str, answer: str) -> dict | None:
      +    """Proxy a batch-clarify lock into the child that owns the request; keeps the parent mirror's locked
      +    answers current for reconnect snapshots. None when the request is not host-owned."""
      +    located = _compute_host_request_session(request_id)
           if located is None or not _session_uses_compute_host(located[1]):
               return None
           sid, session = located
           try:
      -        ack = _get_compute_host_supervisor().respond(sid, params)
      +        ack = _get_compute_host_supervisor().respond(
      +            sid, {"lock": {"request_id": request_id, "question_id": question_id, "answer": answer}})
           except Exception as exc:
      -        return _err(rid, 5019, f"compute-host clarify response failed: {exc}")
      +        return _err(rid, 5019, f"compute-host clarify lock failed: {exc}")
           if ack.get("type") == "respond.error":
      -        return _err(rid, 5019, str(ack.get("message") or "compute-host clarify response failed"))
      +        return _err(rid, 5019, str(ack.get("message") or "compute-host clarify lock failed"))
           response = ack.get("response")
           if not isinstance(response, dict):
      -        return _err(rid, 5019, "compute-host clarify response returned an invalid response")
      +        return _err(rid, 5019, "compute-host clarify lock returned an invalid response")
           if "error" in response:
               error = response["error"] if isinstance(response["error"], dict) else {}
      -        return _err(rid, int(error.get("code") or 5000),
      -                    str(error.get("message") or "clarify response failed"))
      -    result = response.get("result")
      -    if not isinstance(result, dict):
      -        return _err(rid, 5019, "compute-host clarify response returned an invalid result")
      -    _update_compute_host_clarify_snapshot(sid, session, params, result)
      +        return _err(rid, int(error.get("code") or 5000), str(error.get("message") or "clarify lock failed"))
      +    result = response.get("result") if isinstance(response.get("result"), dict) else {}
      +    with _history_lock(session):
      +        if _open_request_matches(session, request_id):
      +            mirrored = session["_compute_host_open_request"]
      +            if result.get("status") == "expired" or result.get("remaining") == []:
      +                session.pop("_compute_host_open_request", None)
      +            else:
      +                mirrored["params"]["answers"] = {**(mirrored["params"].get("answers") or {}), question_id: answer}
           return _ok(rid, result)
       
       
      @@ -200,7 +207,7 @@ def _on_compute_host_turn_done(rid: str, sid: str, session: dict, frame: dict) -
               session["running"] = False
               session["last_active"] = time.time()
               _clear_inflight_turn(session)
      -        session.pop("_compute_host_pending_clarify", None)
      +        session.pop("_compute_host_open_request", None)
           if frame.get("type") == "turn.error":
               message = str(frame.get("message") or "compute host turn failed")
               _emit("message.complete", sid, {"text": f"Error: {message}", "status": "error"})
    • tui_gateway/hosted_room_member_activity.py
      @@ -23,7 +23,6 @@
           "tool.start": "tool.started",
           "tool.complete": "tool.completed",
           "tool.output_risk": "tool.output_risk",
      -    "approval.request": "request.opened",
           "message.delta": "message.delta",
           "message.interim": "message.interim",
           "reasoning.delta": "reasoning.delta",
      @@ -42,20 +41,29 @@ def emit_room_member_activity(hosted_task: Mapping[str, Any], *, kind: str, payl
           return enqueue_plugin_stream_hook(HOOK_NAME, **coordinates, kind=kind, seq=seq, payload=dict(payload or {}))
       
       
      +# Server→client request frames (``{"id": "srq-…", "method": …}``) that are member activity.
      +KIND_BY_REQUEST_METHOD: Mapping[str, str] = {"approval": "request.opened"}
      +
      +
       def project_room_member_activity(frame: Mapping[str, Any], sessions: Mapping[str, Mapping[str, Any]]) -> bool:
      -    """Fire the hook for an outgoing session event frame when its session is running a room turn."""
      -    if frame.get("method") != "event":
      -        return False
      +    """Fire the hook for an outgoing session frame (event notification or server→client request) when its
      +    session is running a room turn."""
           params = frame.get("params")
           if not isinstance(params, Mapping):
               return False
      -    kind = KIND_BY_FRAME_TYPE.get(str(params.get("type") or ""))
      +    if frame.get("method") == "event":
      +        kind = KIND_BY_FRAME_TYPE.get(str(params.get("type") or ""))
      +        payload = params.get("payload")
      +    elif "id" in frame:
      +        kind = KIND_BY_REQUEST_METHOD.get(str(frame.get("method") or ""))
      +        payload = {k: v for k, v in params.items() if k != "session_id"}
      +    else:
      +        return False
           if kind is None:
               return False
           session = sessions.get(str(params.get("session_id") or ""))
           hosted_task = session.get("_hosted_room_task") if isinstance(session, Mapping) else None
           if not isinstance(hosted_task, Mapping):
               return False
      -    payload = params.get("payload")
           return emit_room_member_activity(
               hosted_task, kind=kind, payload=payload if isinstance(payload, Mapping) else None, seq=params.get("seq"))
    • tui_gateway/methods_prompt.py
      @@ -1093,26 +1093,47 @@ def cleanup():
               cwd=preview_cwd, cleanup=cleanup)
       
       
      -# ── late-answer RPCs for tool-driven UI cards ───────────────────────────────
      -# allow_expired=True everywhere: a tool's bounded wait can expire (its _pending entry
      -# popped) while the card is still visible; a late answer must not surface the raw 4009.
      +# ── batch clarify locks ─────────────────────────────────────────────────────
      +# A batch ``clarify`` server request is answered one question at a time: each lock is a normal RPC
      +# (update-in-place, editable until every qid is locked); the LAST lock resolves the request itself.
      +# A cancel-all is the plain response frame with no ``answers``.
       
       
      -@method("clarify.respond")
      +@method("clarify.lock")
       def _(rid, params: dict) -> dict:
      -    if proxied := _respond_compute_host_clarify(rid, params):
      +    request_id = str(params.get("request_id") or "")
      +    question_id = str(params.get("question_id") or "")
      +    if not request_id or not question_id:
      +        return _err(rid, 4002, "request_id and question_id required")
      +    answer = params.get("answer", "")
      +    answer = answer if isinstance(answer, str) else json.dumps(answer, ensure_ascii=False)
      +    if (proxied := _lock_compute_host_clarify(rid, request_id, question_id, answer)) is not None:
               return proxied
      -    return _respond(rid, params, "answer", allow_expired=True)
      +    from tui_gateway import server_requests
      +    try:
      +        remaining = server_requests.lock_answer(request_id, question_id, answer)
      +    except ValueError as e:
      +        return _err(rid, 4002, str(e))
      +    if remaining is None:
      +        # The wait already ended (timeout / cancel) while the card was still visible: not an error.
      +        return _ok(rid, {"status": "expired"})
      +    return _ok(rid, {"status": "ok", "remaining": remaining})
       
       
      -_LATE_RESPOND_KEYS = {
      -    "terminal.read.respond": "text", "preview.read.respond": "text", "preview.act.respond": "text",
      -    "window.read.respond": "text", "tour.respond": "text", "mcp.setup.respond": "result",
      -    "sudo.respond": "password", "secret.respond": "value", "vault.unlock.respond": "password",
      -    "vault.save_login.respond": "login", "vault.code.respond": "code"}
      -for _name, _key in _LATE_RESPOND_KEYS.items():
      -    method(_name)(lambda rid, params, _k=_key: _respond(rid, params, _k, allow_expired=True))
      -del _name, _key
      +@method("request.answer")
      +def _(rid, params: dict) -> dict:
      +    """Answer an open server→client request from a client that did not receive it (a Bot Mode room
      +    window answering a member's prompt mirrored from its resume snapshot). The response-frame path is
      +    the norm; this is the proxy for it. ``expired`` when the request already ended."""
      +    request_id = str(params.get("id") or "")
      +    result = params.get("result")
      +    if not request_id or not isinstance(result, dict):
      +        return _err(rid, 4002, "id and an object result required")
      +    from tui_gateway import server_requests
      +    frame = {"jsonrpc": "2.0", "id": request_id, "result": result}
      +    if server_requests.resolve_response(frame) or _relay_compute_host_response(frame):
      +        return _ok(rid, {"status": "ok"})
      +    return _ok(rid, {"status": "expired"})
       
       
       # ── approvals ───────────────────────────────────────────────────────────────
    • tui_gateway/methods_session.py
      @@ -2177,8 +2177,10 @@ def _(rid, params: dict) -> dict:
           from tui_gateway import event_replay as er
           frames = er.events_since(sid, last_seen)
           # ``epoch``: in-process seq — clients reset watermarks when this differs from gateway.ready's.
      +    # ``open_requests``: server→client requests still unanswered — the ring cannot carry "a question still
      +    # waiting", so the reconnecting client re-delivers these to its request handlers.
           return _ok(rid, {"events": frames, "latest_seq": er.latest_seq(sid), "truncated": er.is_truncated(sid, last_seen),
      -                     "count": len(frames), "epoch": er.replay_epoch()})
      +                     "count": len(frames), "epoch": er.replay_epoch(), "open_requests": _open_requests(sid)})
       
       
       @method("session.events.stats")
    • tui_gateway/server.py
      @@ -83,13 +83,6 @@ def _panic_hook(exc_type, exc_value, exc_tb):
       
       _sessions: dict[str, dict] = {}
       _methods: dict[str, callable] = {}
      -_pending: dict[str, tuple[str, threading.Event]] = {}
      -_pending_prompt_payloads: dict[str, tuple[str, dict]] = {}
      -_answers: dict[str, str] = {}
      -# Batch clarify accumulators: rid → {"qids": [...], "answers": {qid: answer}}. Written by
      -# clarify.respond (per-question lock, update-in-place), read out by _block on resolution/timeout
      -# so locked answers survive the deadline.
      -_batch_clarify: dict[str, dict] = {}
       _db = None
       _db_error: str | None = None
       _stdout_lock = threading.Lock()
      @@ -98,7 +91,6 @@ def _panic_hook(exc_type, exc_value, exc_tb):
       # compare/check/write transaction needs its own lock, not the unrelated config cache lock.
       _profile_ui_meta_lock = threading.Lock()
       _sessions_lock = threading.RLock()  # reentrant: _close_session_by_id may run under callers that already hold it
      -_prompt_lock = threading.Lock()
       _cfg_cache: dict | None = None
       _cfg_mtime: float | None = None
       _cfg_path = None
      @@ -600,10 +592,12 @@ def write_json(obj: dict) -> bool:
           from tui_gateway.event_replay import _stamp_event
           from tui_gateway.hosted_room_member_activity import project_room_member_activity
           _stamp_event(obj)
      -    if obj.get("method") == "event":
      -        # A room member's hidden session has no transport: its frames would die at stdio below.
      +    params = obj.get("params")
      +    if obj.get("method") == "event" or (isinstance(obj.get("id"), str) and "method" in obj):
      +        # Event notifications AND server→client requests carry ``params.session_id``; both route to the
      +        # owning session's transport. A room member's hidden session has no transport: its frames would
      +        # die at stdio below.
               project_room_member_activity(obj, _sessions)
      -        params = obj.get("params")
               sid = ((params or {}).get("session_id")) if isinstance(params, dict) else ""
               if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None:
                   return t.write(obj)
      @@ -619,6 +613,11 @@ def _emit(event: str, sid: str, payload: dict | None = None) -> bool:
           return write_json(_event_frame(event, sid, payload))
       
       
      +from tui_gateway import server_requests as _server_requests  # noqa: E402
      +
      +_server_requests.bind_sinks(lambda frame: write_json(frame), lambda event, sid, payload: _emit(event, sid, payload))
      +
      +
       # Live WS peer transports (maintained by tui_gateway.ws): the only route for session-less background
       # events, which write_json would otherwise drop on stdio (see _broadcast_global_event).
       _live_transports: set[Transport] = set()
      @@ -669,25 +668,20 @@ def _approval_request_payload(data: dict | None) -> dict:
           return payload
       
       
      -def _pending_clarify_request_payload(sid: str) -> dict | None:
      -    """Read-only snapshot of the clarify prompt still blocking a session: a client detached when
      -    `clarify.request` was emitted would otherwise never see it (agent parked until timeout). Same replay
      -    contract as `pending_approval`: the registry stays authoritative; `clarify.respond` resolves by request_id."""
      -    with _prompt_lock:
      -        for rid, (owner_sid, _ev) in _pending.items():
      -            event, prompt_payload = _pending_prompt_payloads.get(rid, ("", {}))
      -            if owner_sid != sid or event != "clarify.request":
      -                continue
      -            snapshot = dict(prompt_payload)
      -            # Batch clarify: replay the answers locked so far so a reconnecting client restores its ✓ state.
      -            if (batch := _batch_clarify.get(rid)) is not None and batch["answers"]:
      -                snapshot["answers"] = dict(batch["answers"])
      -            return snapshot
      +def _open_requests(sid: str) -> list[dict]:
      +    """Server→client requests still waiting on *sid*'s renderer, for reconnect snapshots (``session.resume`` /
      +    ``session.activate`` / ``session.events.since``). A client detached when the request frame was written would
      +    otherwise never see it (agent parked until timeout). Under turn isolation the compute-host child owns the
      +    request; the parent mirrors it from the relayed frame (compute_host_bridge)."""
      +    from tui_gateway import server_requests
      +    reqs = server_requests.open_requests(sid)
      +    if reqs:
      +        return reqs
           if (session := _sessions.get(sid)) is not None:
               with session.get("history_lock", threading.Lock()):
      -            pending = session.get("_compute_host_pending_clarify")
      -            return dict(pending) if isinstance(pending, dict) else None
      -    return None
      +            mirrored = session.get("_compute_host_open_request")
      +            return [dict(mirrored)] if isinstance(mirrored, dict) else []
      +    return []
       
       
       def _pending_approval_request_payload(session_key: str) -> dict | None:
      @@ -702,12 +696,29 @@ def _pending_approval_request_payload(session_key: str) -> dict | None:
       
       
       def _emit_approval_request(sid: str, data: dict | None) -> None:
      -    """Emit ``approval.request`` with the command redacted: a credential-shaped value Tirith flagged would
      -    otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream).
      +    """Send an ``approval`` server request with the command redacted: a credential-shaped value Tirith flagged
      +    would otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream).
      +    See #48456, #50767.
      +
      +    The wait is owned by ``tools.approval``'s queue (its own timeout, ``/approve all``, coalescing), so the request
      +    is queue-backed: the response resolves the queue entry, and the entry's own resolution (any surface, timeout,
      +    interrupt) withdraws the request with ``request.cancel``."""
      +    from tui_gateway import server_requests
      +    from tools import approval as _approval
      +    payload = _approval_request_payload(data)
      +    request_id = str(payload.get("request_id") or "")
      +    session_key = str((_sessions.get(sid) or {}).get("session_key") or "")
      +
      +    def on_result(result: dict | None) -> None:
      +        if result is None:  # withdrawn: the queue entry resolves on its own path
      +            return
      +        choice = str(result.get("choice") or "deny")
      +        _approval.resolve_gateway_approval(session_key, choice, resolve_all=bool(result.get("all")),
      +                                           request_id=request_id or None)
       
      -    Reuse the shared gateway See #48456, #50767.
      -    """
      -    _emit("approval.request", sid, _approval_request_payload(data))
      +    settle = server_requests.send_async("approval", sid, payload, on_result)
      +    if request_id:
      +        _approval.register_gateway_settle(session_key, request_id, settle)
       
       
       def _status_update(sid: str, kind: str, text: str | None = None):
      @@ -809,6 +820,12 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:
           t = transport or _stdio_transport
           token = [redacted:secret-assignment]
           try:
      +        from tui_gateway import server_requests
      +        if server_requests.is_response_frame(req):
      +            # The renderer answering one of OUR requests (clarify, approval, …): no response frame goes back.
      +            if not server_requests.resolve_response(req) and not _relay_compute_host_response(req):
      +                logger.debug("dropping response for unknown server request id=%r", req.get("id"))
      +            return None
               normalized = _normalize_request(req)
               if isinstance(normalized, dict):
                   return normalized
      @@ -1257,55 +1274,13 @@ def _enable_gateway_prompts() -> None:
       # ── Blocking prompt factory ──────────────────────────────────────────
       
       
      -# Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the tool
      -# returns empty, but a slow renderer could still answer and hit a raw 4009 — `.expire` tears the card down.
      -_EXPIRING_REQUESTS = frozenset({
      -    "secret.request", "sudo.request", "vault.unlock.request", "vault.save_login.request", "vault.code.request", "clarify.request",
      -    "terminal.read.request",
      -    "preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request",
      -    "tour.request",
      -})
      -
      -
      -def _block(event: str, sid: str, payload: dict, timeout: float | None = 300, batch_qids: list[str] | None = None) -> str:
      -    rid = uuid.uuid4().hex[:8]
      -    ev = threading.Event()
      -    with _prompt_lock:
      -        _pending[rid] = (sid, ev)
      -        payload["request_id"] = rid
      -        _pending_prompt_payloads[rid] = (event, dict(payload))
      -        if batch_qids:
      -            # Multi-question clarify: per-question answers accumulate here (update-in-place until every
      -            # qid is locked); locked answers survive a timeout — see the batch read-out below.
      -            _batch_clarify[rid] = {"qids": list(batch_qids), "answers": {}}
      -    answered, batch_answers = False, None
      -    try:
      -        _emit(event, sid, payload)
      -        # Event semantics: None → wait forever (clarify_timeout <= 0; released only by a real answer or
      -        # session.interrupt), 0 → return immediately, > 0 → bounded wait.
      -        answered = ev.wait(timeout)
      -    finally:
      -        with _prompt_lock:
      -            _pending.pop(rid, None)
      -            _pending_prompt_payloads.pop(rid, None)
      -            answer_present = rid in _answers
      -            answer = _answers.pop(rid, "")
      -            if (batch_state := _batch_clarify.pop(rid, None)) is not None:
      -                batch_answers = dict(batch_state["answers"])
      -    expire = lambda: _emit(f"{event.removesuffix('.request')}.expire", sid, {"request_id": rid})
      -    if batch_qids is not None:
      -        # Cancel-all (respond with no question_id) resolves via _answers with "" — a plain cancel, not a partial result.
      -        if answer_present:
      -            return answer
      -        result: dict[str, object] = {"answers": batch_answers or {}}
      -        if not answered:
      -            # Deadline hit: keep what was locked, report the rest as absences (not skips), still expire live cards.
      -            result["timed_out"] = True
      -            expire()
      -        return json.dumps(result, ensure_ascii=False)
      -    if not answered and not answer_present and event in _EXPIRING_REQUESTS:
      -        expire()
      -    return answer
      +def _ask(method: str, sid: str, params: dict, timeout: float | None = 300) -> str:
      +    """Server→client request whose answer is one string under ``value`` (sudo, secret, vault prompts, GUI reads,
      +    MCP setup). Empty string when the renderer skipped, timed out, or was cancelled."""
      +    from tui_gateway import server_requests
      +    result = server_requests.send(method, sid, params, timeout=timeout)
      +    value = (result or {}).get("value", "")
      +    return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
       
       
       def _clarify_timeout_seconds() -> float | None:
      @@ -1319,16 +1294,23 @@ def _clarify_timeout_seconds() -> float | None:
       
       
       def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str:
      -    """Bridge the clarify tool callback onto _block. Single-question payloads keep their historical shape
      -    (``multi_select`` only when True — older renderers never see a new field); batch calls emit one
      -    clarify.request with only the wire fields (the tool-side entries carry result-assembly keys too)."""
      +    """Bridge the clarify tool callback onto a ``clarify`` server request. Single question: the response is
      +    ``{"answer"}`` ("" = skip). Batch: one request with only the wire fields (tool-side entries carry
      +    result-assembly keys too); answers lock one at a time through ``clarify.lock`` and the tool gets
      +    ``{"answers", "timed_out"?}`` as JSON — a response with no ``answers`` is a cancel-all."""
      +    from tui_gateway import server_requests
           if questions:
               wire = [{"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])}
                       for e in questions]
      -        return _block("clarify.request", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(),
      -                      batch_qids=[e["qid"] for e in questions])
      -    payload = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c}
      -    return _block("clarify.request", sid, payload, timeout=_clarify_timeout_seconds())
      +        result = server_requests.send("clarify", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(),
      +                                      qids=[e["qid"] for e in questions])
      +        if not result or "answers" not in result:
      +            return ""
      +        return json.dumps(result, ensure_ascii=False)
      +    params = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c}
      +    result = server_requests.send("clarify", sid, params, timeout=_clarify_timeout_seconds())
      +    answer = (result or {}).get("answer", "")
      +    return answer if isinstance(answer, str) else ""
       
       
       # A tour action is a DOM op the renderer answers in ms; the generous deadline exists only because a
      @@ -1346,12 +1328,12 @@ def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str:
       
       
       def _tour_request(sid: str, payload: dict) -> str:
      -    """Bridge the tour tool callback onto _block without paying for a client that cannot answer: against
      -    an older app nobody calls ``tour.respond`` and each action would block the full deadline, stacking per
      +    """Bridge the tour tool callback onto a ``tour`` server request without paying for a client that cannot answer: against
      +    an older app nobody answers ``tour`` and each action would block the full deadline, stacking per
           turn. First action per session gets the short probe deadline; unanswered → bridge marked unavailable
           for that session; once answered, the full deadline. Verdict lives on the record, so a new session re-probes.
       
      -    The renderer's ``tour.request`` handler ships in the desktop bundle, but the tool is offered by this
      +    The renderer's ``tour`` handler ships in the desktop bundle, but the tool is offered by this
           backend — and the two update on different clocks. The model then does what the schema tells it to and
           tries the next action, so a single "give me a tour" turn stacks those waits (the timeouts reported
           against #89620).
      @@ -1362,8 +1344,8 @@ def _tour_request(sid: str, payload: dict) -> str:
           state = session.get("tour_bridge")
           if state == "unanswered":
               return _TOUR_BRIDGE_UNAVAILABLE
      -    answer = _block("tour.request", sid, dict(payload),
      -                    timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S)
      +    answer = _ask("tour", sid, dict(payload),
      +                  timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S)
           if answer:
               session["tour_bridge"] = "answered"
           elif state != "answered":
      @@ -1372,13 +1354,10 @@ def _tour_request(sid: str, payload: dict) -> str:
       
       
       def _clear_pending(sid: str | None = None) -> None:
      -    """Release pending prompts with an empty answer: only *sid*'s (session.interrupt must not cancel other
      -    sessions' prompts), or every one when *sid* is None (shutdown)."""
      -    with _prompt_lock:
      -        for rid, (owner_sid, ev) in list(_pending.items()):
      -            if sid is None or owner_sid == sid:
      -                _answers[rid] = ""
      -                ev.set()
      +    """Withdraw open server→client requests: only *sid*'s (session.interrupt must not cancel other sessions'
      +    prompts), or every one when *sid* is None (process exit). Each one gets a ``request.cancel``."""
      +    from tui_gateway import server_requests
      +    server_requests.cancel(sid, reason="interrupted" if sid else "shutdown")
       
       
       # ── Agent factory ────────────────────────────────────────────────────
      @@ -2624,8 +2603,9 @@ def _run() -> None:
       
       
       def _session_pending_kind(sid: str) -> str:
      -    return next((str(_pending_prompt_payloads.get(rid, ("input.request", {}))[0]).removesuffix(".request")
      -                 for rid, (owner_sid, _ev) in list(_pending.items()) if owner_sid == sid), "")
      +    """Method of the server→client request *sid* is blocked on ("" when none)."""
      +    from tui_gateway import server_requests
      +    return server_requests.pending_kind(sid)
       
       
       def _session_live_status(sid: str, session: dict) -> str:
      @@ -2775,7 +2755,7 @@ def _live_session_payload(
           }
           for key, value in (("inflight", inflight), ("queued", queued),
                              ("pending_approval", _pending_approval_request_payload(str(session.get("session_key") or ""))),
      -                       ("pending_clarify", _pending_clarify_request_payload(sid))):
      +                       ("open_requests", _open_requests(sid))):
               if value:
                   payload[key] = value
           return _attach_todo_state(payload, session)
      @@ -3077,31 +3057,6 @@ def _loop() -> None:
           return stop, thread
       
       
      -# ── Methods: respond ─────────────────────────────────────────────────
      -
      -
      -def _respond(rid, params, key, *, allow_expired=False):
      -    r = params.get("request_id", "")
      -    question_id = str(params.get("question_id") or "")
      -    with _prompt_lock:
      -        entry = _pending.get(r)
      -        if not entry:
      -            return _ok(rid, {"status": "expired"}) if allow_expired and r else _err(rid, 4009, f"no pending {key} request")
      -        _, ev = entry
      -        batch = _batch_clarify.get(r)
      -        if batch is not None and question_id:
      -            # Per-question lock; update-in-place so an answer stays editable until every qid is locked (Confirm).
      -            if question_id not in batch["qids"]:
      -                return _err(rid, 4002, f"unknown question_id {question_id!r}")
      -            batch["answers"][question_id] = params.get(key, "")
      -            if not (remaining := [qid for qid in batch["qids"] if qid not in batch["answers"]]):
      -                ev.set()
      -            return _ok(rid, {"status": "ok", "remaining": remaining})
      -        _answers[r] = params.get(key, "")
      -        ev.set()
      -    return _ok(rid, {"status": "ok"})
      -
      -
       # ── Methods: tools & system ──────────────────────────────────────────
       
       
    • tui_gateway/server_requests.py
      @@ -0,0 +1,224 @@
      +"""Server→client JSON-RPC requests: the backend asks the renderer a question and waits for the
      +response frame carrying the same ``id``.
      +
      +JSON-RPC is peer-to-peer; this is the backend's half. Every "ask the renderer" bridge (clarify,
      +approval, sudo, secret, vault prompts, desktop GUI reads, MCP setup consent, the tour) is one
      +:func:`send` (blocking) or :func:`send_async` (queue-backed approvals) and one response frame from
      +the client — no paired ``*.request`` notification / ``*.respond`` method, no per-kind ``*.expire``.
      +
      +Ids are ``srq-<12 hex>``: strings never collide with client-minted integer ids, and the random
      +part keeps a compute-host child's requests distinct from the parent's when both reach one socket.
      +A request that times out or is cancelled (interrupt, session close, shutdown) emits ONE
      +``request.cancel {id, method, reason}`` notification so every renderer tears the card down the
      +same way. A response for an id that is no longer open is dropped — the wait already returned.
      +
      +Reconnect: unanswered requests are returned as ``open_requests`` by ``session.resume`` /
      +``session.activate`` / ``session.events.since`` (:func:`open_requests`); the shared TypeScript
      +channel re-delivers them as if they had just arrived, so the notification replay ring never
      +has to carry "a question still waiting for an answer".
      +
      +Batch clarify keeps per-question locks (``clarify.lock`` → :func:`lock_answer`): answers stay
      +editable until every question is locked, locked answers survive a timeout, and the last lock
      +resolves the request with the full answer set.
      +"""
      +
      +from __future__ import annotations
      +
      +import logging
      +import threading
      +import time
      +import uuid
      +from typing import Any, Callable
      +
      +logger = logging.getLogger(__name__)
      +
      +
      +class ServerRequest:
      +    __slots__ = ("id", "sid", "method", "params", "event", "result", "answered", "created_at",
      +                 "qids", "locked", "on_result")
      +
      +    def __init__(self, sid: str, method: str, params: dict, *, qids: list[str] | None = None,
      +                 on_result: Callable[[dict | None], None] | None = None) -> None:
      +        self.id = f"srq-{uuid.uuid4().hex[:12]}"
      +        self.sid = sid
      +        self.method = method
      +        self.params = dict(params)
      +        self.event = threading.Event()
      +        self.result: dict | None = None
      +        self.answered = False
      +        self.created_at = time.time()
      +        # Batch clarify: question ids still to lock, and the answers locked so far.
      +        self.qids = list(qids) if qids else None
      +        self.locked: dict[str, str] = {}
      +        self.on_result = on_result
      +
      +    def frame(self) -> dict:
      +        return {"jsonrpc": "2.0", "id": self.id, "method": self.method,
      +                "params": {"session_id": self.sid, **self.params}}
      +
      +    def snapshot(self) -> dict:
      +        """``open_requests`` entry: the request as sent, plus the batch answers locked so far so a
      +        reconnecting client restores its ✓ state."""
      +        params = {"session_id": self.sid, **self.params}
      +        if self.locked:
      +            params["answers"] = dict(self.locked)
      +        return {"id": self.id, "method": self.method, "params": params}
      +
      +
      +_lock = threading.Lock()
      +_open: dict[str, ServerRequest] = {}
      +
      +# Frame sinks, bound by ``bind_sinks`` from server.py at import time (like the method_ctx split
      +# modules): importing server back from here would pick a different module object under the test
      +# fixtures that patch ``sys.modules`` around the server import.
      +_write: Callable[[dict], Any] = lambda frame: None  # noqa: E731
      +_emit: Callable[[str, str, dict], Any] = lambda event, sid, payload: None  # noqa: E731
      +
      +
      +def bind_sinks(write_json: Callable[[dict], Any], emit: Callable[[str, str, dict], Any]) -> None:
      +    global _write, _emit
      +    _write, _emit = write_json, emit
      +
      +
      +def _emit_cancel(req: ServerRequest, reason: str) -> None:
      +    _emit("request.cancel", req.sid, {"id": req.id, "method": req.method, "reason": reason})
      +
      +
      +def _register(req: ServerRequest) -> None:
      +    with _lock:
      +        _open[req.id] = req
      +    _write(req.frame())
      +
      +
      +def send(method: str, sid: str, params: dict, *, timeout: float | None,
      +         qids: list[str] | None = None) -> dict | None:
      +    """Send one request and block for the response ``result`` (a dict).
      +
      +    Returns ``None`` when the renderer never answered (timeout, cancel, or an error response — e.g.
      +    a client without a handler for ``method``). ``timeout`` semantics: None → wait until answered or
      +    cancelled, 0 → return immediately, > 0 → bounded wait. A batch (``qids``) that times out
      +    returns ``{"answers": <locked so far>, "timed_out": True}`` instead of None.
      +    """
      +    req = ServerRequest(sid, method, params, qids=qids)
      +    _register(req)
      +    timed_out = False
      +    try:
      +        timed_out = not req.event.wait(timeout)
      +    finally:
      +        with _lock:
      +            _open.pop(req.id, None)
      +    if timed_out:
      +        _emit_cancel(req, "timeout")
      +        if req.qids is not None:
      +            return {"answers": dict(req.locked), "timed_out": True}
      +        return None
      +    return req.result if req.answered else None
      +
      +
      +def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict | None], None]) -> Callable[[str], None]:
      +    """Send one request whose wait is owned elsewhere (the approval queue's own timeout). ``on_result``
      +    runs on the dispatching thread when the response lands. Returns ``settle(reason)``: call it when
      +    the underlying wait ends; if the request is still open it is withdrawn with ``request.cancel``."""
      +    req = ServerRequest(sid, method, params, on_result=on_result)
      +    _register(req)
      +
      +    def settle(reason: str) -> None:
      +        with _lock:
      +            still_open = _open.pop(req.id, None) is not None
      +        if still_open:
      +            _emit_cancel(req, reason)
      +
      +    return settle
      +
      +
      +def resolve_response(frame: dict) -> bool:
      +    """Route one client response frame to its open request. False when nothing is waiting for that id
      +    (already timed out / cancelled, or owned by another process — see the compute-host bridge)."""
      +    rid = frame.get("id")
      +    if not isinstance(rid, str):
      +        return False
      +    with _lock:
      +        req = _open.get(rid)
      +        if req is None:
      +            return False
      +        if req.on_result is not None:
      +            _open.pop(rid, None)
      +    if "error" in frame:
      +        logger.debug("server request %s (%s) answered with error: %s", rid, req.method, frame.get("error"))
      +        req.result, req.answered = None, False
      +    else:
      +        result = frame.get("result")
      +        req.result = result if isinstance(result, dict) else {}
      +        if req.qids and "answers" in req.result:
      +            # Batch clarify: answers locked early via clarify.lock belong to the final set even when
      +            # the closing response only carries the tail the user answered last.
      +            answers = req.result.get("answers")
      +            merged = dict(req.locked)
      +            if isinstance(answers, dict):
      +                merged.update(answers)
      +            req.result = {**req.result, "answers": merged}
      +        req.answered = True
      +    if req.on_result is not None:
      +        req.on_result(req.result)
      +    req.event.set()
      +    return True
      +
      +
      +def lock_answer(request_id: str, question_id: str, answer: str) -> list[str] | None:
      +    """Lock one batch-clarify answer (update-in-place). Returns the question ids still unanswered;
      +    the last lock resolves the request with the full ``{"answers"}`` set. ``None`` when no open
      +    batch has that id (expired or foreign); ``ValueError`` for an unknown question id."""
      +    with _lock:
      +        req = _open.get(request_id)
      +        if req is None or req.qids is None:
      +            return None
      +        if question_id not in req.qids:
      +            raise ValueError(f"unknown question_id {question_id!r}")
      +        req.locked[question_id] = answer
      +        remaining = [qid for qid in req.qids if qid not in req.locked]
      +        if not remaining:
      +            req.result, req.answered = {"answers": dict(req.locked)}, True
      +    if not remaining:
      +        req.event.set()
      +    return remaining
      +
      +
      +def cancel(sid: str | None = None, reason: str = "interrupted") -> int:
      +    """Withdraw open requests — only *sid*'s (session.interrupt must not touch other sessions'), or
      +    every one when *sid* is None (shutdown). Blocked waits return None; queue-backed requests run
      +    ``on_result(None)`` so their owner can settle. Returns the number withdrawn."""
      +    with _lock:
      +        targets = [req for req in _open.values() if sid is None or req.sid == sid]
      +        for req in targets:
      +            _open.pop(req.id, None)
      +    for req in targets:
      +        req.result, req.answered = None, False
      +        if req.on_result is not None:
      +            req.on_result(None)
      +        req.event.set()
      +        _emit_cancel(req, reason)
      +    return len(targets)
      +
      +
      +def open_requests(sid: str) -> list[dict]:
      +    """Unanswered requests for *sid*, oldest first."""
      +    with _lock:
      +        reqs = sorted((req for req in _open.values() if req.sid == sid), key=lambda r: r.created_at)
      +    return [req.snapshot() for req in reqs]
      +
      +
      +def pending_kind(sid: str) -> str:
      +    """Method of the oldest open request for *sid* ("" when none) — the session is waiting on a human."""
      +    with _lock:
      +        reqs = [req for req in _open.values() if req.sid == sid]
      +    return min(reqs, key=lambda r: r.created_at).method if reqs else ""
      +
      +
      +def is_response_frame(obj: Any) -> bool:
      +    """A client response: has an ``id`` and a ``result``/``error`` member but no ``method``."""
      +    return isinstance(obj, dict) and "method" not in obj and "id" in obj and ("result" in obj or "error" in obj)
      +
      +
      +def reset_for_tests() -> None:
      +    with _lock:
      +        _open.clear()
    • ui-tui/README.md
      @@ -229,14 +229,18 @@ Tool/status activity is shown in a live activity lane. Transcript rows stay focu
       
       ## Prompt flows
       
      -The Python gateway can pause the main loop and request structured input:
      -
      -- `approval.request`: allow once, allow for session, allow always, or deny
      -- `clarify.request`: pick from choices or type a custom answer
      -- `sudo.request`: masked password entry
      -- `secret.request`: masked value entry for a named env var
      +The Python gateway can pause the main loop and ask the client a question. These are JSON-RPC
      +**requests from the server** (string id, answered with a response frame of the same id — see
      +`createServerRequestHandler.ts`), not events:
      +
      +- `approval`: allow once, allow for session, allow always, or deny → `{ choice }`
      +- `clarify`: pick from choices or type a custom answer → `{ answer }` (batch: `{ answers }`)
      +- `sudo`: masked password entry → `{ value }`
      +- `secret`: masked value entry for a named env var → `{ value }`
       - `session.list`: used by `SessionPicker` for `/resume`
       
      +A withdrawn question (timeout, interrupt) arrives as a `request.cancel` event carrying its id.
      +
       These are stateful UI branches in `app.tsx`, not separate screens.
       
       ## Commands
      @@ -304,12 +308,7 @@ Primary event types the client handles today:
       | `tool.generating`          | `{ name }`                                                                  |
       | `tool.progress`            | `{ name, preview }`                                                         |
       | `tool.complete`            | `{ tool_id, name, error?, summary?, duration_s?, inline_diff?, todos? }`    |
      -| `clarify.request`          | `{ question, choices?, request_id }`                                        |
      -| `approval.request`         | `{ command, description, allow_permanent? }`                                |
      -| `sudo.request`             | `{ request_id }`                                                            |
      -| `sudo.expire`              | `{ request_id }` clears a timed-out sudo prompt                             |
      -| `secret.request`           | `{ prompt, env_var, request_id }`                                           |
      -| `secret.expire`            | `{ request_id }` clears a timed-out secret prompt                           |
      +| `request.cancel`           | `{ id, method, reason }` clears the withdrawn server→client request         |
       | `background.complete`      | `{ task_id, text }`                                                         |
       | `billing.step_up.verification` | `{ verification_url, user_code }`                                       |
       | `review.summary`           | `{ text }`                                                                  |
    • ui-tui/src/__tests__/createGatewayEventHandler.test.ts
      @@ -1,7 +1,9 @@
       import { beforeEach, describe, expect, it, vi } from 'vitest'
       
       import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
      +import { createServerRequestHandler } from '../app/createServerRequestHandler.js'
       import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
      +import { resetServerRequestsForTests } from '../app/serverRequestStore.js'
       import { turnController } from '../app/turnController.js'
       import { getTurnState, resetTurnState } from '../app/turnStore.js'
       import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
      @@ -58,11 +60,27 @@ const buildCtx = (appended: Msg[]) =>
           }
         }) as any
       
      +/** Deliver one server→client request (`tui_gateway/server_requests.py`) to the TUI's request handler. */
      +const serverRequest = (method: string, params: Record<string, unknown>, id = `srq-${method}`) => {
      +  const respond = vi.fn()
      +
      +  const handled = createServerRequestHandler({ ringPromptBell: vi.fn(), setStatus: status => patchUiState({ status }) })({
      +    fail: vi.fn(),
      +    id,
      +    method,
      +    params,
      +    respond
      +  })
      +
      +  return { handled, respond }
      +}
      +
       describe('createGatewayEventHandler', () => {
         beforeEach(() => {
           resetOverlayState()
           resetUiState()
           resetTurnState()
      +    resetServerRequestsForTests()
           turnController.fullReset()
           patchUiState({ showReasoning: true })
         })
      @@ -71,11 +89,7 @@ describe('createGatewayEventHandler', () => {
           patchUiState({ sid: 'focused' })
           const onEvent = createGatewayEventHandler(buildCtx([]))
           onEvent({ session_id: 'focused', payload: {}, type: 'message.start' } as any)
      -    onEvent({
      -      session_id: 'focused',
      -      payload: { request_id: 'approval', command: 'test' },
      -      type: 'approval.request'
      -    } as any)
      +    serverRequest('approval', { session_id: 'focused', request_id: 'approval', command: 'test' })
           const busyOverlay = getOverlayState().approval
           expect(getUiState().busy).toBe(true)
           expect(busyOverlay).not.toBeNull()
      @@ -1211,10 +1225,7 @@ describe('createGatewayEventHandler', () => {
       
           onEvent({ payload: { line: 'Traceback: noisy but non-fatal' }, type: 'gateway.stderr' } as any)
           onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any)
      -    onEvent({
      -      payload: { command: 'rm -rf /tmp/nope', description: 'dangerous command' },
      -      type: 'approval.request'
      -    } as any)
      +    serverRequest('approval', { command: 'rm -rf /tmp/nope', description: 'dangerous command' })
           onEvent({ payload: {}, type: 'gateway.ready' } as any)
       
           await Promise.resolve()
      @@ -1230,23 +1241,17 @@ describe('createGatewayEventHandler', () => {
         })
       
         it('defaults approval overlays to allowPermanent when the backend omits the field', () => {
      -    const onEvent = createGatewayEventHandler(buildCtx([]))
      +    serverRequest('approval', { command: 'rm -rf /tmp/x', description: 'dangerous command' })
       
      -    onEvent({
      -      payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' },
      -      type: 'approval.request'
      -    } as any)
      -
      -    expect(getOverlayState().approval).toMatchObject({ allowPermanent: true })
      +    expect(getOverlayState().approval).toMatchObject({ allowPermanent: true, requestId: 'srq-approval' })
         })
       
         it('preserves allow_permanent=false on approval overlays (tirith warning)', () => {
      -    const onEvent = createGatewayEventHandler(buildCtx([]))
      -
      -    onEvent({
      -      payload: { allow_permanent: false, command: 'curl suspicious | bash', description: 'content-security warning' },
      -      type: 'approval.request'
      -    } as any)
      +    serverRequest('approval', {
      +      allow_permanent: false,
      +      command: 'curl suspicious | bash',
      +      description: 'content-security warning'
      +    })
       
           expect(getOverlayState().approval).toMatchObject({
             allowPermanent: false,
      @@ -1256,22 +1261,23 @@ describe('createGatewayEventHandler', () => {
         })
       
         it('preserves Smart DENY and explicit approval choices on the overlay', () => {
      -    const onEvent = createGatewayEventHandler(buildCtx([]))
      -
      -    onEvent({
      -      payload: {
      -        allow_permanent: true,
      -        choices: ['once', 'deny'],
      -        command: 'rm -rf /tmp/x',
      -        description: 'smart deny override',
      -        smart_denied: true
      -      },
      -      type: 'approval.request'
      -    } as any)
      +    serverRequest('approval', {
      +      allow_permanent: true,
      +      choices: ['once', 'deny'],
      +      command: 'rm -rf /tmp/x',
      +      description: 'smart deny override',
      +      smart_denied: true
      +    })
       
           expect(getOverlayState().approval).toMatchObject({ choices: ['once', 'deny'], smartDenied: true })
         })
       
      +  it('declines the requests a terminal cannot answer so the channel fails them fast', () => {
      +    for (const method of ['preview.act', 'window.read', 'tour', 'mcp.setup', 'vault.code']) {
      +      expect(serverRequest(method, {}).handled).toBe(false)
      +    }
      +  })
      +
         it('still surfaces terminal turn failures as errors', () => {
           const appended: Msg[] = []
           const onEvent = createGatewayEventHandler(buildCtx(appended))
      @@ -1629,39 +1635,36 @@ describe('createGatewayEventHandler', () => {
           expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)
         })
       
      -  it('clears only the matching sensitive prompt when the gateway expires it', () => {
      +  it('clears only the card whose request the gateway withdrew (request.cancel by id)', () => {
           const onEvent = createGatewayEventHandler(buildCtx([]))
       
      -    patchOverlayState({
      -      secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' },
      -      sudo: { requestId: 'sudo-1' }
      -    })
      +    serverRequest('secret', { env_var: 'NEW_KEY', prompt: 'Enter new key' }, 'secret-new')
      +    serverRequest('sudo', {}, 'sudo-1')
       
      -    onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any)
      +    onEvent({ payload: { id: 'secret-old', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any)
           expect(getOverlayState().secret?.requestId).toBe('secret-new')
       
      -    onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any)
      +    onEvent({ payload: { id: 'secret-new', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any)
           expect(getOverlayState().secret).toBeNull()
      +    expect(getOverlayState().sudo?.requestId).toBe('sudo-1')
       
      -    onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any)
      +    onEvent({ payload: { id: 'sudo-1', method: 'sudo', reason: 'interrupted' }, type: 'request.cancel' } as any)
           expect(getOverlayState().sudo).toBeNull()
         })
       
         // ── Batch (multi-question) clarify ─────────────────────────────────
       
      -  it('parses a batch clarify.request into a questions overlay', () => {
      -    const onEvent = createGatewayEventHandler(buildCtx([]))
      -
      -    onEvent({
      -      payload: {
      +  it('parses a batch clarify request into a questions overlay', () => {
      +    serverRequest(
      +      'clarify',
      +      {
               questions: [
                 { choices: ['a', 'b'], qid: 'q0', question: 'One?' },
                 { choices: null, qid: 'q1', question: 'Two?' }
      -        ],
      -        request_id: 'req-batch'
      +        ]
             },
      -      type: 'clarify.request'
      -    } as any)
      +      'req-batch'
      +    )
       
           const clarify = getOverlayState().clarify
           expect(clarify?.requestId).toBe('req-batch')
      @@ -1671,39 +1674,35 @@ describe('createGatewayEventHandler', () => {
           expect(clarify?.answers).toEqual({})
         })
       
      -  it('seeds locked answers from a reconnect-replay batch clarify.request', () => {
      -    const onEvent = createGatewayEventHandler(buildCtx([]))
      -
      -    onEvent({
      -      payload: {
      +  it('seeds locked answers from a reconnect-replayed batch clarify request', () => {
      +    serverRequest(
      +      'clarify',
      +      {
               answers: { q0: 'a' },
               questions: [
                 { choices: ['a', 'b'], qid: 'q0', question: 'One?' },
                 { choices: null, qid: 'q1', question: 'Two?' }
      -        ],
      -        request_id: 'req-replay'
      +        ]
             },
      -      type: 'clarify.request'
      -    } as any)
      +      'req-replay'
      +    )
       
           expect(getOverlayState().clarify?.answers).toEqual({ q0: 'a' })
         })
       
         it('drops malformed batch entries and falls back to single-question shape when none survive', () => {
      -    const onEvent = createGatewayEventHandler(buildCtx([]))
      -
      -    onEvent({
      -      payload: {
      +    serverRequest(
      +      'clarify',
      +      {
               choices: ['x', 'y'],
               question: 'Fallback?',
               questions: [
                 { qid: '', question: 'no qid' },
                 { qid: 'q1', question: '   ' }
      -        ],
      -        request_id: 'req-bad'
      +        ]
             },
      -      type: 'clarify.request'
      -    } as any)
      +      'req-bad'
      +    )
       
           const clarify = getOverlayState().clarify
           expect(clarify?.questions).toBeUndefined()
    • ui-tui/src/__tests__/useInputHandlers.test.ts
      @@ -1,6 +1,7 @@
       import { describe, expect, it, vi } from 'vitest'
       
       import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
      +import { rememberServerRequest, resetServerRequestsForTests } from '../app/serverRequestStore.js'
       import {
         applyVoiceRecordResponse,
         dismissSensitivePrompt,
      @@ -156,31 +157,37 @@ describe('applyVoiceRecordResponse', () => {
       })
       
       describe('dismissSensitivePrompt', () => {
      -  it('clears a sudo overlay before a stale cancel RPC resolves', async () => {
      +  const openRequest = (id: string, method: string) => {
      +    const respond = vi.fn()
      +
      +    rememberServerRequest({ fail: vi.fn(), id, method, params: {}, respond })
      +
      +    return respond
      +  }
      +
      +  it('clears a sudo overlay and answers the server request with an empty value', () => {
           resetOverlayState()
      -    patchOverlayState({ sudo: { requestId: 'sudo-1' } })
      -    const rpc = vi.fn().mockResolvedValue(null)
      +    resetServerRequestsForTests()
      +    patchOverlayState({ sudo: { requestId: 'srq-sudo' } })
      +    const respond = openRequest('srq-sudo', 'sudo')
           const sys = vi.fn()
       
      -    const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
      +    dismissSensitivePrompt(getOverlayState(), vi.fn(), sys)
       
           expect(getOverlayState().sudo).toBeNull()
           expect(sys).toHaveBeenCalledWith('sudo cancelled')
      -    expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
      -    await pending
      +    expect(respond).toHaveBeenCalledWith({ value: '' })
         })
       
      -  it('clears a secret overlay before a stale cancel RPC resolves', async () => {
      +  it('clears a secret overlay even when its request already expired (nothing left to answer)', () => {
           resetOverlayState()
      -    patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } })
      -    const rpc = vi.fn().mockResolvedValue(null)
      +    resetServerRequestsForTests()
      +    patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'srq-gone' } })
           const sys = vi.fn()
       
      -    const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
      +    dismissSensitivePrompt(getOverlayState(), vi.fn(), sys)
       
           expect(getOverlayState().secret).toBeNull()
           expect(sys).toHaveBeenCalledWith('secret entry cancelled')
      -    expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
      -    await pending
         })
       })
    • ui-tui/src/app/createGatewayEventHandler.ts
      @@ -31,6 +31,7 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
       import type { GatewayEventHandlerContext, NoticeLevel } from './interfaces.js'
       import { getOverlayState, patchOverlayState } from './overlayStore.js'
       import { flashGoodVibes, flashPet } from './petFlashStore.js'
      +import { forgetServerRequest } from './serverRequestStore.js'
       import { turnController } from './turnController.js'
       import { getTurnState } from './turnStore.js'
       import { getUiState, patchUiState } from './uiStore.js'
      @@ -449,8 +450,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
         // paths can't both persist the same prompt twice.
         const persistedAbandonedClarify = new Set<string>()
       
      -  // When a clarify prompt is dismissed without an answer (the backend _block
      -  // timed out and returned an empty string), the live ClarifyPrompt overlay is
      +  // When a clarify prompt is dismissed without an answer (the backend request
      +  // timed out and returned no answer), the live ClarifyPrompt overlay is
         // left set until the next turn's idle() silently nulls it — so the question
         // and options vanish from the screen while the agent's follow-up still refers
         // to them.  The reliable signal is the clarify tool's own tool.complete (and,
      @@ -1257,124 +1258,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
               return
             }
       
      -      case 'clarify.request': {
      -        if (!ev.payload) {
      -          return
      -        }
      -
      -        const batch = (ev.payload.questions ?? [])
      -          .filter(q => typeof q?.qid === 'string' && q.qid && typeof q?.question === 'string' && q.question.trim())
      -          .map(q => ({
      -            choices: q.choices && q.choices.length > 0 ? q.choices : null,
      -            multiSelect: q.multi_select === true,
      -            qid: q.qid,
      -            question: q.question.trim()
      -          }))
      -
      -        patchOverlayState({
      -          clarify: batch.length
      -            ? {
      -                answers: ev.payload.answers ?? {},
      -                choices: null,
      -                question: '',
      -                questions: batch,
      -                requestId: ev.payload.request_id
      -              }
      -            : {
      -                choices: ev.payload.choices ?? null,
      -                question: ev.payload.question ?? '',
      -                requestId: ev.payload.request_id
      -              }
      -        })
      -        setStatus('waiting for input…')
      -        ringPromptBell()
      -
      -        return
      -      }
      -
      -      case 'approval.request': {
      -        if (!ev.payload) {
      -          return
      -        }
      +      case 'request.cancel': {
      +        // The backend withdrew a server→client request (timeout / interrupt /
      +        // session close): tear down whichever card carries that id. A clarify
      +        // that timed out is persisted as an abandoned prompt by tool.complete.
      +        const id = ev.payload?.id
       
      -        const description = String(ev.payload.description ?? 'dangerous command')
      -        // Only an explicit false (tirith warning) drops the permanent-allow option.
      -        const allowPermanent = ev.payload.allow_permanent !== false
      -
      -        patchOverlayState({
      -          approval: {
      -            allowPermanent,
      -            choices: ev.payload.choices,
      -            command: String(ev.payload.command ?? ''),
      -            description,
      -            smartDenied: ev.payload.smart_denied === true
      -          }
      -        })
      -        setStatus('approval needed')
      -        ringPromptBell()
      -
      -        return
      -      }
      -
      -      case 'sudo.request':
      -        if (!ev.payload) {
      +        if (!id) {
                 return
               }
       
      -        patchOverlayState({ sudo: { requestId: ev.payload.request_id } })
      -        setStatus('sudo password needed')
      -        ringPromptBell()
      -
      -        return
      +        forgetServerRequest(id)
      +        patchOverlayState(prev => {
      +          const next = { ...prev }
      +          let changed = false
       
      -      case 'secret.request':
      -        if (!ev.payload) {
      -          return
      -        }
      -
      -        patchOverlayState({
      -          secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id }
      -        })
      -        setStatus('secret input needed')
      -        ringPromptBell()
      -
      -        return
      -      case 'sudo.expire': {
      -        const expired = ev.payload?.request_id
      -
      -        patchOverlayState(prev => (prev.sudo?.requestId === expired ? { ...prev, sudo: null } : prev))
      -
      -        return
      -      }
      -
      -      case 'secret.expire': {
      -        const expired = ev.payload?.request_id
      -
      -        patchOverlayState(prev => (prev.secret?.requestId === expired ? { ...prev, secret: null } : prev))
      -
      -        return
      -      }
      -
      -      case 'vault.unlock.request':
      -        if (!ev.payload) {
      -          return
      -        }
      -
      -        patchOverlayState({
      -          vaultUnlock: {
      -            backend: ev.payload.backend,
      -            displayName: ev.payload.display_name,
      -            requestId: ev.payload.request_id
      +          for (const key of ['approval', 'clarify', 'secret', 'sudo', 'vaultUnlock'] as const) {
      +            if (prev[key]?.requestId === id) {
      +              next[key] = null
      +              changed = true
      +            }
                 }
      -        })
      -        setStatus(`unlock ${ev.payload.display_name}`)
      -        ringPromptBell()
       
      -        return
      -      case 'vault.unlock.expire': {
      -        const expired = ev.payload?.request_id
      -
      -        patchOverlayState(prev => (prev.vaultUnlock?.requestId === expired ? { ...prev, vaultUnlock: null } : prev))
      +          return changed ? next : prev
      +        })
       
               return
             }
    • ui-tui/src/app/createServerRequestHandler.ts
      @@ -0,0 +1,114 @@
      +import type { ServerRequest } from '@hermes/shared/json-rpc-channel'
      +
      +import type { ClarifyBatchQuestion } from '../types.js'
      +
      +import { patchOverlayState } from './overlayStore.js'
      +import { rememberServerRequest } from './serverRequestStore.js'
      +
      +export interface ServerRequestHandlerContext {
      +  ringPromptBell: () => void
      +  setStatus: (status: string) => void
      +}
      +
      +const str = (v: unknown): string => (typeof v === 'string' ? v : '')
      +
      +const strList = (v: unknown): null | string[] =>
      +  Array.isArray(v) && v.length > 0 ? v.filter((c): c is string => typeof c === 'string') : null
      +
      +/**
      + * The Ink TUI's answer to the backend's server→client requests
      + * (`tui_gateway/server_requests.py`). Each method opens its overlay card;
      + * the card's answer path resolves the request through `serverRequestStore`.
      + * Methods the terminal cannot answer (desktop GUI bridges: `preview.*`,
      + * `window.read`, `tour`, `mcp.setup`, `terminal.read`, the vault card
      + * prompts) return `false` so the channel answers `-32601` and the tool
      + * fails fast instead of waiting out its deadline.
      + */
      +export function createServerRequestHandler(ctx: ServerRequestHandlerContext): (request: ServerRequest) => boolean {
      +  const { ringPromptBell, setStatus } = ctx
      +
      +  const open = (request: ServerRequest, status: string) => {
      +    rememberServerRequest(request)
      +    setStatus(status)
      +
      +    if (!request.replayed) {
      +      ringPromptBell()
      +    }
      +  }
      +
      +  return request => {
      +    const p = request.params
      +
      +    switch (request.method) {
      +      case 'clarify': {
      +        const batch: ClarifyBatchQuestion[] = (Array.isArray(p.questions) ? (p.questions as unknown[]) : [])
      +          .map(raw => (raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}))
      +          .filter(q => str(q.qid) && str(q.question).trim())
      +          .map(q => ({
      +            choices: strList(q.choices),
      +            multiSelect: q.multi_select === true,
      +            qid: str(q.qid),
      +            question: str(q.question).trim()
      +          }))
      +
      +        const answers =
      +          p.answers && typeof p.answers === 'object'
      +            ? Object.fromEntries(
      +                Object.entries(p.answers as Record<string, unknown>).filter(
      +                  (entry): entry is [string, string] => typeof entry[1] === 'string'
      +                )
      +              )
      +            : {}
      +
      +        patchOverlayState({
      +          clarify: batch.length
      +            ? { answers, choices: null, question: '', questions: batch, requestId: request.id }
      +            : { choices: strList(p.choices), question: str(p.question), requestId: request.id }
      +        })
      +        open(request, 'waiting for input…')
      +
      +        return true
      +      }
      +
      +      case 'approval': {
      +        patchOverlayState({
      +          approval: {
      +            // Only an explicit false (tirith warning) drops the permanent-allow option.
      +            allowPermanent: p.allow_permanent !== false,
      +            choices: strList(p.choices) ?? undefined,
      +            command: str(p.command),
      +            description: str(p.description) || 'dangerous command',
      +            requestId: request.id,
      +            smartDenied: p.smart_denied === true
      +          }
      +        })
      +        open(request, 'approval needed')
      +
      +        return true
      +      }
      +
      +      case 'sudo':
      +        patchOverlayState({ sudo: { requestId: request.id } })
      +        open(request, 'sudo password needed')
      +
      +        return true
      +
      +      case 'secret':
      +        patchOverlayState({ secret: { envVar: str(p.env_var), prompt: str(p.prompt), requestId: request.id } })
      +        open(request, 'secret input needed')
      +
      +        return true
      +
      +      case 'vault.unlock_prompt':
      +        patchOverlayState({
      +          vaultUnlock: { backend: str(p.backend), displayName: str(p.display_name), requestId: request.id }
      +        })
      +        open(request, `unlock ${str(p.display_name)}`)
      +
      +        return true
      +
      +      default:
      +        return false
      +    }
      +  }
      +}
    • ui-tui/src/app/serverRequestStore.ts
      @@ -0,0 +1,38 @@
      +import type { ServerRequest } from '@hermes/shared/json-rpc-channel'
      +
      +// Live server→client requests (clarify, approval, sudo, …) keyed by request
      +// id. Overlay state keeps only the id; answering resolves the stored request
      +// so a re-delivered (`open_requests`) request with the same id reuses the
      +// same card. Module-level, like the overlay store: the gateway client and
      +// the Ink handlers share one instance per process.
      +const open = new Map<string, ServerRequest>()
      +
      +export function rememberServerRequest(request: ServerRequest): void {
      +  open.set(request.id, request)
      +}
      +
      +export function forgetServerRequest(id: string): void {
      +  open.delete(id)
      +}
      +
      +/** Answer request `id` and forget it. False when nothing is open under that id (expired / already answered). */
      +export function respondToServerRequest(id: string, result: Record<string, unknown>): boolean {
      +  const request = open.get(id)
      +
      +  if (!request) {
      +    return false
      +  }
      +
      +  open.delete(id)
      +  request.respond(result)
      +
      +  return true
      +}
      +
      +export function hasOpenServerRequest(id: string): boolean {
      +  return open.has(id)
      +}
      +
      +export function resetServerRequestsForTests(): void {
      +  open.clear()
      +}
    • ui-tui/src/app/useInputHandlers.ts
      @@ -5,13 +5,7 @@ import { useEffect, useRef } from 'react'
       import { DASHBOARD_TUI_MODE } from '../config/env.js'
       import { DOUBLE_ESC_MS, TYPING_IDLE_MS } from '../config/timing.js'
       import { applyCompletion } from '../domain/slash.js'
      -import type {
      -  ApprovalRespondResponse,
      -  ConfigSetResponse,
      -  SecretRespondResponse,
      -  SudoRespondResponse,
      -  VoiceRecordResponse
      -} from '../gatewayTypes.js'
      +import type { ConfigSetResponse, VoiceRecordResponse } from '../gatewayTypes.js'
       import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
       import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
       import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
      @@ -27,6 +21,7 @@ import {
         type OverlayState
       } from './interfaces.js'
       import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
      +import { respondToServerRequest } from './serverRequestStore.js'
       import { turnController } from './turnController.js'
       import { patchTurnState } from './turnStore.js'
       import { getUiState } from './uiStore.js'
      @@ -153,7 +148,9 @@ export function dismissSensitivePrompt(
           patchOverlayState({ sudo: null })
           sys('sudo cancelled')
       
      -    return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId })
      +    respondToServerRequest(requestId, { value: '' })
      +
      +    return
         }
       
         if (overlay.secret) {
      @@ -162,7 +159,9 @@ export function dismissSensitivePrompt(
           patchOverlayState({ secret: null })
           sys('secret entry cancelled')
       
      -    return rpc<SecretRespondResponse>('secret.respond', { request_id: requestId, value: '' })
      +    respondToServerRequest(requestId, { value: '' })
      +
      +    return
         }
       
         if (overlay.vaultUnlock) {
      @@ -171,7 +170,7 @@ export function dismissSensitivePrompt(
           patchOverlayState({ vaultUnlock: null })
           sys(`${overlay.vaultUnlock.displayName} stays locked`)
       
      -    return rpc<SecretRespondResponse>('vault.unlock.respond', { password: '', request_id: requestId })
      +    respondToServerRequest(requestId, { value: '' })
         }
       }
       
      @@ -228,9 +227,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
           }
       
           if (overlay.approval) {
      -      return gateway
      -        .rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
      -        .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
      +      respondToServerRequest(overlay.approval.requestId, { choice: 'deny' })
      +      patchOverlayState({ approval: null })
      +      patchTurnState({ outcome: 'denied' })
      +
      +      return
           }
       
           if (overlay.sudo || overlay.secret || overlay.vaultUnlock) {
    • ui-tui/src/app/useMainApp.ts
      @@ -8,6 +8,7 @@ import {
         useStdout,
         useTerminalTitle
       } from '@hermes/ink'
      +import { JSON_RPC_METHOD_NOT_FOUND, type ServerRequest } from '@hermes/shared/json-rpc-channel'
       import { useStore } from '@nanostores/react'
       import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
       
      @@ -22,7 +23,7 @@ import { type GatewayClient } from '../gatewayClient.js'
       import type { SubagentListResponse } from '../gatewayTypes.js'
       import type {
         AnyGatewayEvent,
      -  ClarifyRespondResponse,
      +  ClarifyLockResponse,
         ConfigSetResponse,
         SessionActiveListResponse,
         SessionCloseResponse,
      @@ -49,6 +50,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js'
       
       import { applyAgentSnapshot } from './agentRoster.js'
       import { createGatewayEventHandler } from './createGatewayEventHandler.js'
      +import { createServerRequestHandler } from './createServerRequestHandler.js'
       import { createSlashHandler } from './createSlashHandler.js'
       import { planGatewayRecovery } from './gatewayRecovery.js'
       import { getInputSelection } from './inputSelectionStore.js'
      @@ -56,6 +58,7 @@ import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfa
       import { $overlayState, patchOverlayState } from './overlayStore.js'
       import { $goodVibesTick } from './petFlashStore.js'
       import { scrollWithSelectionBy } from './scroll.js'
      +import { respondToServerRequest } from './serverRequestStore.js'
       import { turnController } from './turnController.js'
       import { patchTurnState, useTurnSelector } from './turnStore.js'
       import { $uiState, getUiState, patchUiState } from './uiStore.js'
      @@ -232,6 +235,7 @@ export function useMainApp(gw: GatewayClient) {
         const colsRef = useRef(cols)
         const scrollRef = useRef<null | ScrollBoxHandle>(null)
         const onEventRef = useRef<(ev: AnyGatewayEvent) => void>(() => {})
      +  const onServerRequestRef = useRef<(request: ServerRequest) => boolean>(() => false)
         const sysRef = useRef<(text: string) => void>(() => {})
         const submitRef = useRef<(value: string) => void>(() => {})
         const submitLiteralRef = useRef<(value: string) => void>(() => {})
      @@ -710,11 +714,14 @@ export function useMainApp(gw: GatewayClient) {
             turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line))
             patchTurnState({ turnTrail: turnController.turnTools })
       
      -      rpc<ClarifyRespondResponse>('clarify.respond', { answer, request_id: clarify.requestId }).then(r => {
      -        if (!r) {
      -          return
      -        }
      +      if (!respondToServerRequest(clarify.requestId, { answer })) {
      +        // The request already expired (request.cancel raced the keystroke): nothing to answer.
      +        patchOverlayState({ clarify: null })
       
      +        return
      +      }
      +
      +      {
               if (answer) {
                 turnController.persistedToolLabels.add(label)
                 appendMessage({
      @@ -738,14 +745,14 @@ export function useMainApp(gw: GatewayClient) {
               }
       
               patchOverlayState({ clarify: null })
      -      })
      +      }
           },
      -    [appendMessage, overlay.clarify, rpc]
      +    [appendMessage, overlay.clarify]
         )
       
      -  // Lock one answer of a batch clarify (clarify.respond + question_id). The
      -  // overlay stays up until the server reports no remaining questions — the
      -  // final lock resolves the tool and the turn continues.
      +  // Lock one answer of a batch clarify (`clarify.lock` RPC). The overlay stays
      +  // up until the server reports no remaining questions — the final lock
      +  // resolves the server request and the turn continues.
         const answerClarifyQuestion = useCallback(
           (qid: string, answer: string) => {
             const clarify = overlay.clarify
      @@ -754,7 +761,7 @@ export function useMainApp(gw: GatewayClient) {
               return
             }
       
      -      rpc<ClarifyRespondResponse & { remaining?: string[] }>('clarify.respond', {
      +      rpc<ClarifyLockResponse>('clarify.lock', {
               answer,
               question_id: qid,
               request_id: clarify.requestId
      @@ -765,6 +772,12 @@ export function useMainApp(gw: GatewayClient) {
       
               const answers = { ...(clarify.answers ?? {}), [qid]: answer }
       
      +        if (r.status === 'expired') {
      +          patchOverlayState({ clarify: null })
      +
      +          return
      +        }
      +
               if ((r.remaining ?? []).length > 0) {
                 patchOverlayState({ clarify: { ...clarify, answers } })
       
      @@ -908,9 +921,30 @@ export function useMainApp(gw: GatewayClient) {
       
         onEventRef.current = onEvent
       
      +  const onServerRequest = useMemo(
      +    () =>
      +      createServerRequestHandler({
      +        ringPromptBell: () => {
      +          if (bellOnPrompt && stdout?.isTTY) {
      +            stdout.write('\x07')
      +          }
      +        },
      +        setStatus: status => patchUiState({ status })
      +      }),
      +    [bellOnPrompt, stdout]
      +  )
      +
      +  onServerRequestRef.current = onServerRequest
      +
         useEffect(() => {
           const handler = (ev: AnyGatewayEvent) => onEventRef.current(ev)
       
      +    const requestHandler = (request: ServerRequest) => {
      +      if (!onServerRequestRef.current(request)) {
      +        request.fail(JSON_RPC_METHOD_NOT_FOUND, `the terminal UI cannot answer ${request.method}`)
      +      }
      +    }
      +
           const exitHandler = () => {
             turnController.reset()
       
      @@ -946,12 +980,14 @@ export function useMainApp(gw: GatewayClient) {
           }
       
           gw.on('event', handler)
      +    gw.on('request', requestHandler)
           gw.on('exit', exitHandler)
           gw.drain()
       
           // entry.tsx's setupGracefulExit handles process cleanup on real exit.
           return () => {
             gw.off('event', handler)
      +      gw.off('request', requestHandler)
             gw.off('exit', exitHandler)
           }
         }, [gw, sys])
      @@ -1015,19 +1051,26 @@ export function useMainApp(gw: GatewayClient) {
       
         slashRef.current = slash
       
      -  const respondWith = useCallback(
      -    (method: string, params: Record<string, unknown>, done: () => void) => rpc(method, params).then(r => r && done()),
      -    [rpc]
      -  )
      +  // Answer a server→client request by id; the card closes either way (an
      +  // expired request has nothing left to answer).
      +  const respondWith = useCallback((requestId: string, result: Record<string, unknown>, done: () => void) => {
      +    respondToServerRequest(requestId, result)
      +    done()
      +  }, [])
       
         const answerApproval = useCallback(
      -    (choice: string) =>
      -      respondWith('approval.respond', { choice, session_id: ui.sid }, () => {
      +    (choice: string) => {
      +      if (!overlay.approval) {
      +        return
      +      }
      +
      +      respondWith(overlay.approval.requestId, { choice }, () => {
               patchOverlayState({ approval: null })
               patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` })
               patchUiState({ status: 'running…' })
      -      }),
      -    [respondWith, ui.sid]
      +      })
      +    },
      +    [overlay.approval, respondWith]
         )
       
         const answerSudo = useCallback(
      @@ -1042,7 +1085,7 @@ export function useMainApp(gw: GatewayClient) {
               patchOverlayState({ sudo: null })
             }
       
      -      return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => {
      +      respondWith(requestId, { value: pw }, () => {
               patchOverlayState({ sudo: null })
               patchUiState({ status: 'running…' })
             })
      @@ -1062,7 +1105,7 @@ export function useMainApp(gw: GatewayClient) {
               patchOverlayState({ secret: null })
             }
       
      -      return respondWith('secret.respond', { request_id: requestId, value }, () => {
      +      respondWith(requestId, { value }, () => {
               patchOverlayState({ secret: null })
               patchUiState({ status: 'running…' })
             })
      @@ -1082,7 +1125,7 @@ export function useMainApp(gw: GatewayClient) {
               patchOverlayState({ vaultUnlock: null })
             }
       
      -      return respondWith('vault.unlock.respond', { password, request_id: requestId }, () => {
      +      respondWith(requestId, { value: password }, () => {
               patchOverlayState({ vaultUnlock: null })
               patchUiState({ status: 'running…' })
             })
    • ui-tui/src/gatewayClient.ts
      @@ -9,6 +9,7 @@ import {
         DEFAULT_HEARTBEAT_DEADLINE_MS,
         DEFAULT_HEARTBEAT_INTERVAL_MS,
         JsonRpcRequestChannel,
      +  type ServerRequest,
         wireFrameText
       } from '@hermes/shared/json-rpc-channel'
       import { reconnectBackoffDelayMs } from '@hermes/shared/reconnect-backoff'
      @@ -134,10 +135,15 @@ export class GatewayClient extends EventEmitter {
         private readonly channel = new JsonRpcRequestChannel({
           onEvent: ev => this.publish(ev as AnyGatewayEvent),
           onHeartbeatFailure: () => this.onHeartbeatFailure(),
      +    onUnhandledRequest: req => this.pushLog(`[protocol] unhandled server request: ${req.method}`),
           requestTimeoutMs: REQUEST_TIMEOUT_MS,
           unrefTimers: true
         })
         private bufferedEvents = new CircularBuffer<AnyGatewayEvent>(MAX_BUFFERED_EVENTS)
      +  // Server→client requests (clarify, approval, sudo, …) follow the same
      +  // mount-order contract as events: an attached session mid-turn can send one
      +  // the instant the socket opens, before the Ink handler is registered.
      +  private bufferedRequests: ServerRequest[] = []
         private pendingExit: number | null | undefined
         private ready = false
         private readyTimer: ReturnType<typeof setTimeout> | null = null
      @@ -155,6 +161,13 @@ export class GatewayClient extends EventEmitter {
           // useInput / createGatewayEventHandler can legitimately attach many
           // listeners. Default 10-cap triggers spurious warnings.
           this.setMaxListeners(0)
      +    this.channel.onRequest(request => {
      +      if (this.subscribed) {
      +        this.emit('request', request)
      +      } else {
      +        this.bufferedRequests.push(request)
      +      }
      +    })
         }
       
         private publish(ev: AnyGatewayEvent) {
      @@ -280,6 +293,7 @@ export class GatewayClient extends EventEmitter {
           // its queued microtask becomes a no-op (it captured the old generation).
           this.drainGeneration += 1
           this.bufferedEvents.clear()
      +    this.bufferedRequests = []
           this.pendingExit = undefined
           this.stdoutRl?.close()
           this.stderrRl?.close()
      @@ -673,6 +687,10 @@ export class GatewayClient extends EventEmitter {
               this.emit('event', ev)
             }
       
      +      for (const request of this.bufferedRequests.splice(0)) {
      +        this.emit('request', request)
      +      }
      +
             if (this.pendingExit !== undefined) {
               const code = this.pendingExit
       
    • ui-tui/src/gatewayTypes.ts
      @@ -316,20 +316,10 @@ export interface BackgroundStartResponse {
         task_id?: string
       }
       
      -export interface ClarifyRespondResponse {
      -  ok?: boolean
      -}
      -
      -export interface ApprovalRespondResponse {
      -  ok?: boolean
      -}
      -
      -export interface SudoRespondResponse {
      -  ok?: boolean
      -}
      -
      -export interface SecretRespondResponse {
      -  ok?: boolean
      +/** `clarify.lock` — one batch-clarify answer locked; `expired` when the request already ended. */
      +export interface ClarifyLockResponse {
      +  remaining?: string[]
      +  status: 'expired' | 'ok'
       }
       
       // ── Shell / clipboard / input ────────────────────────────────────────
    • ui-tui/src/types.ts
      @@ -100,6 +100,8 @@ export interface ApprovalReq {
         choices?: string[]
         command: string
         description: string
      +  /** Server→client request id; the answer is the response frame for it. */
      +  requestId: string
         smartDenied?: boolean
       }
       
    • website/docs/developer-guide/programmatic-integration.md
      @@ -46,8 +46,7 @@ session.create          session.list            session.active_list
       session.activate        session.close           session.interrupt
       session.history         session.compress        session.branch
       session.title           session.usage           session.status
      -clarify.respond         sudo.respond            secret.respond
      -approval.respond        config.set / config.get commands.catalog
      +clarify.lock            config.set / config.get commands.catalog
       command.resolve         command.dispatch        cli.exec
       reload.mcp              reload.env              process.stop
       delegation.status       subagent.interrupt      subagent.steer
      @@ -76,7 +75,20 @@ On a successful truncating submit against a durable session, the `prompt.submit`
       
       ### Events streamed back
       
      -`message.delta`, `message.complete`, `tool.start`, `tool.generating`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt.
      +`message.delta`, `message.complete`, `tool.start`, `tool.generating`, `tool.complete`, `gateway.ready`, `request.cancel`, plus session lifecycle and error events.
      +
      +### Server→client requests (questions the agent asks you)
      +
      +Approvals, clarify questions, sudo/secret prompts, vault unlock, MCP setup and the desktop read/act bridges are **JSON-RPC requests from the gateway to the client**, not events. The frame carries a string id, and the client answers with a normal JSON-RPC response bearing the same id:
      +
      +```
      +← {"jsonrpc":"2.0","id":"srq-7","method":"approval","params":{"session_id":"…","request_id":"…","command":"rm -rf build","description":"…"}}
      +→ {"jsonrpc":"2.0","id":"srq-7","result":{"choice":"once"}}
      +```
      +
      +Methods: `approval` → `{choice}`; `clarify` → `{answer}` (single) or `{answers}` / `{}` cancel (batch, with `clarify.lock` to lock one answer early); `sudo`, `secret`, `vault.code`, `vault.unlock` → `{value}`; `mcp.setup` → `{result}`; `terminal.read`, `window.read`, `preview.act`, `tour` → `{value}` (JSON text). Respond with a JSON-RPC error (`-32601`) for a method your host does not implement so the agent fails fast instead of waiting out the timeout.
      +
      +When the gateway withdraws a question (timeout, interrupt, answered from another surface) it emits `request.cancel` `{ id, method, reason }`; clear only the matching prompt. `session.resume` / `session.activate` results and `session.events.since` carry `open_requests` — the still-open frames — so a reconnecting client re-renders (and can still answer) them.
       
       ### Pi-style RPC mapping
       
      @@ -94,7 +106,7 @@ Every command in the Pi-mono RPC spec ([issue #360](https://github.com/NousResea
       | `get_messages` | `session.history` |
       | `switch_session` | `session.resume` |
       | `fork` | `session.branch` |
      -| `ui_request` / `ui_response` | `clarify.respond` / `sudo.respond` / `secret.respond` / `approval.respond` |
      +| `ui_request` / `ui_response` | server→client requests `clarify` / `sudo` / `secret` / `approval` answered by JSON-RPC response frames |
       
       ---
       
  • #114972 fix(desktop): name the session in approval and input-request OS toasts (#114337, salvage #114383)

    merged to main

    files: apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts, apps/desktop/src/i18n/ar.ts, apps/desktop/src/i18n/en.ts, apps/desktop/src/i18n/ja.ts, apps/desktop/src/i18n/ru.ts, apps/desktop/src/i18n/types.ts, apps/desktop/src/i18n/zh-hant.ts, apps/desktop/src/i18n/zh.ts, apps/desktop/src/store/native-notifications.test.ts, apps/desktop/src/store/native-notifications.ts

3 PRs closed without merging

From the close comments: superseded by another PR 2 · done elsewhere 1

  • #86621 fix(desktop): report waiting status when a session is blocked on approval · done elsewhere → #86565, #110677, #114972
  • #88140 fix(gateway): report approval-blocked sessions as waiting in live status · superseded by another PR → #86772, #86565
  • #104501 feat(desktop): say which session needs you — sidebar chip, amber badge, named notification (+ fix approval-blocked status) · superseded by another PR → 1879088, #114972, #114337, #114383
Closed issues · 1
  • Closed#86565[Bug]: Desktop — session dot stays blue (Session running) while blocked on approval; turns amber (Needs your input) only after opening the session
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#86772, #110677, #114972
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
18 Sep 2026

For your agent

The same facts, typed.