# NousResearch/hermes-agent · Desktop session approval status indicators and notifications

## Open now

- 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.

## TL;DR

- 7 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 3 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #86772. Verify it addresses your case.
- **Recommended:** read PR #86772 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.29`: the share of member 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.

## Closed / duplicate issues

- #86565: \[Bug\]: Desktop — session dot stays blue (Session running) while blocked on approval; turns amber (Needs your input) only after opening the session

## Prior & competing PRs

- **#86772: fix(desktop): unread-dot read-state overhaul — correct clears, restart persistence, manual mark-unread (salvages #74162 #74293 #76504 #82824 #84822)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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)
  - fix hunks:
  - `apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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()
       })
     
    ```

    ```diff
    @@ -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 () => {
    ```

    ```diff
    @@ -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)', () => {
    ```

    ```diff
    @@ -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)
       })
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -497,7 +497,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
         [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]
       )
     
    -  const { handleGatewayEvent } = useMessageStream({
    +  const { handleGatewayEvent, handleServerRequest } = useMessageStream({
         activeGatewayProfile,
         activeSessionIdRef,
         hydrateFromStoredSession,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -2,6 +2,7 @@ import {
       type GatewayEvent,
       isGatewayReauthRequired,
       isGatewayWebSocketUrl,
    +  JSON_RPC_METHOD_NOT_FOUND,
       JsonRpcGatewayError,
       reconnectBackoffDelayMs,
       resolveGatewayWsUrl
    ```

    ```diff
    @@ -31,6 +32,7 @@ import {
       closeLegacySecondaryGateways,
       closeSecondaryGateways,
       configureGatewayRegistry,
    +  dispatchPrimaryServerRequest,
       disposeSecondariesForConnection,
       ensureActiveGatewayOpen,
       ensureGatewayForProfile,
    ```

    ```diff
    @@ -40,6 +42,7 @@ import {
       pruneSecondaryGateways,
       reconnectSecondaryGateways,
       reportPrimaryGatewayState,
    +  type ScopedServerRequest,
       setPrimaryGateway,
       setPrimaryGatewayConnection,
       touchSecondaryGateways
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -159,6 +164,7 @@ interface GatewayBootOptions {
     export function useGatewayBoot({
       beforeConnectionSwitch,
       handleGatewayEvent,
    +  handleServerRequest,
       onConnectionReady,
       onGatewayReady,
       refreshHermesConfig,
    ```

    ```diff
    @@ -167,6 +173,7 @@ export function useGatewayBoot({
       const callbacksRef = useRef({
         beforeConnectionSwitch,
         handleGatewayEvent,
    +    handleServerRequest,
         onConnectionReady,
         onGatewayReady,
         refreshHermesConfig,
    ```

    ```diff
    @@ -176,6 +183,7 @@ export function useGatewayBoot({
       callbacksRef.current = {
         beforeConnectionSwitch,
         handleGatewayEvent,
    +    handleServerRequest,
         onConnectionReady,
         onGatewayReady,
         refreshHermesConfig,
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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 })
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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' }))
    ```

    ```diff
    @@ -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 ?? []
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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
       }
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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', () => {
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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/ })
     
    ```

    ```diff
    @@ -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/ })
     
    ```

    ```diff
    @@ -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)
       })
     })
     
    ```

    ```diff
    @@ -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']) })
         })
       })
     
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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)' })
         })
       })
     })
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -581,7 +584,7 @@ function renderLiveBatch(lockedAnswers?: Record<string, string>, multiSelect = f
       })
       renderClarify(<ClarifyTool {...liveBatchProps()} />)
     
    -  return request
    +  return { request, respond }
     }
     
     describe('readClarifyBatchResult', () => {
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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' } })
    ```

    ```diff
    @@ -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/ }))
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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', () => {
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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
       )
     }
    ```

    ```diff
    @@ -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'],
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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()
       })
     })
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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. */
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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(
           <>
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -1059,6 +1044,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo
             )
           }
     
    +      forgetServerRequest(request.requestId)
    +
           triggerHaptic('submit')
           onAnswered()
           // tool.complete lands next → ClarifyToolBatchSettled.
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -46,9 +55,14 @@ function mockGateway() {
       return request
     }
     
    +beforeEach(() => {
    +  resetServerRequestsForTests()
    +})
    +
     afterEach(() => {
       cleanup()
       clearAllPrompts()
    +  resetServerRequestsForTests()
       $activeSessionId.set(null)
       $gateway.set(null)
     })
    ```

    ```diff
    @@ -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()
       })
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -29,7 +30,6 @@ import {
       sessionApprovalInlineVisible,
       sessionApprovalRequest
     } from '@/store/prompts'
    -import { requestForOwnedSession } from '@/store/session-states'
     
     import type { ToolPart } from './fallback-model'
     
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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() }))
     
    ```

    ```diff
    @@ -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" />)
    ```

    ```diff
    @@ -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())
     })
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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() }))
     
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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())
     })
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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() }))
     
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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" />)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 = {
    ```

    ```diff
    @@ -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: () => {
    ```

    ```diff
    @@ -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())
     
    ```

    ```diff
    @@ -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])
     
    ```

    ```diff
    @@ -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' }
         ])
    ```

    ```diff
    @@ -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())
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -540,7 +546,7 @@ describe('clarify and approvals (#90694)', () => {
                 submitted = true
               }
     
    -          if (method === 'clarify.respond') {
    +          if (method === 'request.answer') {
                 answered = true
               }
     
    ```

    ```diff
    @@ -550,7 +556,7 @@ describe('clarify and approvals (#90694)', () => {
                   await held
                 }
     
    -            return { ...result, pending_clarify: CLARIFY }
    +            return { ...result, open_requests: [CLARIFY] }
               }
     
               return result
    ```

    ```diff
    @@ -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())
    ```

    ```diff
    @@ -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')
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -91,6 +92,7 @@ describe('skipClarifyRequest', () => {
     
       beforeEach(() => {
         $clarifyRequests.set({})
    +    resetServerRequestsForTests()
         request.mockClear()
         $gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
       })
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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)]
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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]
     
    ```

    ```diff
    @@ -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')
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -3,7 +3,8 @@ import {
       type GatewayEvent,
       reconnectBackoffDelayMs,
       registryBackendScopeKey,
    -  resolveGatewayWsUrl
    +  resolveGatewayWsUrl,
    +  type ServerRequest
     } from '@hermes/shared'
     import { atom } from 'nanostores'
     
    ```

    ```diff
    @@ -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
       /**
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -800,6 +819,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
         activeRequests: 0,
         connectPromise: null,
         offEvent: () => {},
    +    offRequest: () => {},
         offState: () => {},
         reconnectTimer: null,
         reconnectAttempt: 0,
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 }
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -80,6 +85,7 @@ export interface ApprovalRequest extends KeyedPrompt {
       command: string
       description: string
       requestId?: string
    +  serverRequestId?: string
       smartDenied?: boolean
     }
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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) =>
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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]
    ```

    ```diff
    @@ -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>
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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>
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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>
     }
     
     /**
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -76,7 +78,7 @@ export {
       type GatewayEventName,
       type GatewayReadyPayload,
       type GatewayTranscriptMessage,
    -  type McpSetupRequestPayload,
    +  type McpSetupRequestParams,
       type MessageCompletePayload,
       type MessageInterimPayload,
       type ModelCapabilities,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
       /**
    ```

    ```diff
    @@ -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 = {
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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)
             }
           }
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -5,6 +5,7 @@ import {
       type GatewayRequestId,
       JsonRpcRequestChannel,
       type JsonRpcTransport,
    +  type ServerRequestHandler,
       wireFrameText
     } from './json-rpc-channel.js'
     
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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"),
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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()
     
     
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
     """
     
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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
     
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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)
     
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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()
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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}
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 = {
    ```

    ```diff
    @@ -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_
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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)
     
     
    ```

    ```diff
    @@ -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)
     
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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).
    ```

    ```diff
    @@ -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":
    ```

    ```diff
    @@ -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 ────────────────────────────────────────────────────
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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 })
       })
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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))
    ```

    ```diff
    @@ -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')
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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: '' })
       }
     }
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -22,7 +23,7 @@ import { type GatewayClient } from '../gatewayClient.js'
     import type { SubagentListResponse } from '../gatewayTypes.js'
     import type {
       AnyGatewayEvent,
    -  ClarifyRespondResponse,
    +  ClarifyLockResponse,
       ConfigSetResponse,
       SessionActiveListResponse,
       SessionCloseResponse,
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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>(() => {})
    ```

    ```diff
    @@ -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({
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 } })
     
    ```

    ```diff
    @@ -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()
     
    ```

    ```diff
    @@ -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])
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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…' })
           })
    ```

    ```diff
    @@ -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…' })
           })
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
- **#86621: fix(desktop): report waiting status when a session is blocked on approval**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#86565`, `#110677`, `#114972`
- **#88140: fix(gateway): report approval-blocked sessions as waiting in live status**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#86772`, `#86565`
- **#104501: feat(desktop): say which session needs you — sidebar chip, amber badge, named notification (+ fix approval-blocked status)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `1879088d3fad`, `#114972`, `#114972`, `#114337`, `#114383`, `#114337`

## Fix status

- Merged to main in PR #86772 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-18`.
- Subsystem: `apps/desktop`
