{"repo":"NousResearch/hermes-agent","public_id":"86565","display_label":"Desktop session approval status indicators and notifications","summary":"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.","subsystem":"apps/desktop","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":3,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":86772},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-09-18T19:45:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":74142,"pr_number":86772,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":78159,"pr_number":86772,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":83056,"pr_number":86772,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":85305,"pr_number":86772,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":86565,"pr_number":86772,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":86565,"pr_number":110677,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":86565,"pr_number":114972,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":105469,"pr_number":110677,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":110521,"pr_number":110677,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":114337,"pr_number":114972,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":86565,"title":"[Bug]: Desktop — session dot stays blue (Session running) while blocked on approval; turns amber (Needs your input) only after opening the session","url":null,"state":"closed"},{"kind":"pr","number":86621,"title":"fix(desktop): report waiting status when a session is blocked on approval","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#86565","number":86565},{"kind":"pr","value":"#110677","number":110677},{"kind":"pr","value":"#114972","number":114972}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":86772,"title":"fix(desktop): unread-dot read-state overhaul — correct clears, restart persistence, manual mark-unread (salvages #74162 #74293 #76504 #82824 #84822)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","apps/desktop/src/i18n/zh.ts","apps/desktop/src/lib/persisted.test.ts","apps/desktop/src/lib/persisted.ts","apps/desktop/src/store/gateway-switch.ts","apps/desktop/src/store/session-dot-state.test.ts","apps/desktop/src/store/session-dot-state.ts","apps/desktop/src/store/session-states.ts","apps/desktop/src/store/session-unread-remote.test.ts","apps/desktop/src/store/session-unread-remote.ts","apps/desktop/src/store/session-unread-tile.test.ts","apps/desktop/src/store/session-unread.test.ts","apps/desktop/src/store/session-unread.ts","apps/desktop/src/store/session.test.ts","apps/desktop/src/store/session.ts","apps/desktop/src/types/hermes.ts","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","contributors/emails/[redacted:email]","gateway/platforms/api_server.py","hermes_cli/web_models.py","hermes_cli/web_routers/sessions.py","tests/hermes_cli/test_web_server.py"]},{"kind":"pr","number":88140,"title":"fix(gateway): report approval-blocked sessions as waiting in live status","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#86772","number":86772},{"kind":"pr","value":"#86565","number":86565}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":104501,"title":"feat(desktop): say which session needs you — sidebar chip, amber badge, named notification (+ fix approval-blocked status)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1879088d3fad","number":0},{"kind":"pr","value":"#114972","number":114972},{"kind":"pr","value":"#114972","number":114972},{"kind":"pr","value":"#114337","number":114337},{"kind":"pr","value":"#114383","number":114383},{"kind":"pr","value":"#114337","number":114337}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":110677,"title":"feat(gateway): server→client JSON-RPC requests replace the *.request/*.respond event pairs (#110521)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","apps/desktop/src/components/assistant-ui/tool/approval.tsx","apps/desktop/src/components/prompt-overlays.test.tsx","apps/desktop/src/components/prompt-overlays.tsx","apps/desktop/src/components/prompt-overlays.vault-code.test.tsx","apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx","apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx","apps/desktop/src/lib/gateway-events.test.ts","apps/desktop/src/plugins/hermes-bots/group-test-utils.ts","apps/desktop/src/plugins/hermes-bots/group-turns.test.ts","apps/desktop/src/plugins/hermes-bots/group-turns.ts","apps/desktop/src/store/clarify.test.ts","apps/desktop/src/store/clarify.ts","apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts","apps/desktop/src/store/gateway.ts","apps/desktop/src/store/mcp-setup.ts","apps/desktop/src/store/native-notifications.ts","apps/desktop/src/store/prompts.ts","apps/desktop/src/store/server-requests.ts","apps/desktop/src/store/session-states-runtime-map.test.ts","apps/desktop/src/types/hermes.ts","apps/shared/src/gateway-events.json","apps/shared/src/gateway-events.test.ts","apps/shared/src/gateway-events.ts","apps/shared/src/index.ts","apps/shared/src/json-rpc-channel.test.ts","apps/shared/src/json-rpc-channel.ts","apps/shared/src/json-rpc-gateway.ts","tests/gateway/test_tui_approval_redaction.py","tests/tui_gateway/test_compute_host_phase1.py","tests/tui_gateway/test_gateway_event_contract.py","tests/tui_gateway/test_goal_command.py","tests/tui_gateway/test_hosted_room_member_activity_hook.py","tests/tui_gateway/test_inline_rpc_gil_starvation.py","tests/tui_gateway/test_loop_command.py","tests/tui_gateway/test_protocol.py","tests/tui_gateway/test_review_summary_callback.py","tests/tui_gateway/test_session_control.py","tests/tui_gateway/test_session_profile_db.py","tests/tui_gateway/test_stranded_session_adoption.py","tests/tui_gateway/test_subagent_child_mirror.py","tests/tui_gateway/test_tour_bridge_fail_fast.py","tests/tui_gateway/test_tui_gateway_server.py","tests/tui_gateway/test_undo_command.py","tools/approval.py","tools/approval_gateway_wait.py","tui_gateway/AGENTS.md","tui_gateway/agent_callbacks.py","tui_gateway/compute_host.py","tui_gateway/compute_host_bridge.py","tui_gateway/hosted_room_member_activity.py","tui_gateway/methods_prompt.py","tui_gateway/methods_session.py","tui_gateway/server.py","tui_gateway/server_requests.py","ui-tui/README.md","ui-tui/src/__tests__/createGatewayEventHandler.test.ts","ui-tui/src/__tests__/useInputHandlers.test.ts","ui-tui/src/app/createGatewayEventHandler.ts","ui-tui/src/app/createServerRequestHandler.ts","ui-tui/src/app/serverRequestStore.ts","ui-tui/src/app/useInputHandlers.ts","ui-tui/src/app/useMainApp.ts","ui-tui/src/gatewayClient.ts","ui-tui/src/gatewayTypes.ts","ui-tui/src/types.ts","website/docs/developer-guide/programmatic-integration.md"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx","hunks":["@@ -6,7 +6,6 @@ import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'\n import { $clarifyRequests } from '@/store/clarify'\n import type { ComposerAttachment } from '@/store/composer'\n import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue'\n-import { $gateway } from '@/store/gateway'\n import {\n   clearAllPrompts,\n   hasBlockingPromptRequest,","@@ -14,6 +13,7 @@ import {\n   setSecretRequest,\n   setSudoRequest\n } from '@/store/prompts'\n+import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'\n \n import { type ComposerTarget, requestComposerSubmit } from '../focus'\n import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'","@@ -457,26 +457,30 @@ describe('useComposerSubmit busy-turn routing', () => {\n })\n \n describe('useComposerSubmit with a clarify parked on the session', () => {\n-  const gatewayRequest = vi.fn(async () => ({ ok: true }))\n+  // The clarify is a live server→client request: skipping it answers that\n+  // request frame (`{ answer: '' }`), not a `clarify.respond` RPC.\n+  const respond = vi.fn()\n \n   const parkClarify = (sessionId: string) => {\n+    const requestId = `req-${sessionId}`\n+\n+    rememberServerRequest({ fail: vi.fn(), id: requestId, method: 'clarify', params: {}, respond })\n     $clarifyRequests.set({\n       [sessionId]: {\n-        requestId: `req-${sessionId}`,\n+        requestId,\n         question: 'which one?',\n         choices: ['a', 'b'],\n         multiSelect: false,\n         sessionId\n       }\n     })\n-    $gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)\n   }\n \n   afterEach(() => {\n     cleanup()\n-    gatewayRequest.mockClear()\n+    respond.mockClear()\n+    resetServerRequestsForTests()\n     $clarifyRequests.set({})\n-    $gateway.set(null)\n     vi.restoreAllMocks()\n   })\n ","@@ -488,16 +492,12 @@ describe('useComposerSubmit with a clarify parked on the session', () => {\n       hook.result.current.submitDraft()\n     })\n \n-    await waitFor(() =>\n-      expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {\n-        request_id: 'req-runtime-session',\n-        answer: ''\n-      })\n-    )\n+    await waitFor(() => expect(respond).toHaveBeenCalledWith({ answer: '' }))\n     await waitFor(() =>\n       expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))\n     )\n     expect($clarifyRequests.get()['runtime-session']).toBeUndefined()\n+    expect(hasOpenServerRequest('req-runtime-session')).toBe(false)\n   })\n \n   it('skips the question before steering a busy turn', async () => {","@@ -509,7 +509,7 @@ describe('useComposerSubmit with a clarify parked on the session', () => {\n     })\n \n     await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))\n-    expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })\n+    expect(respond).toHaveBeenCalledWith({ answer: '' })\n   })\n \n   it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {","@@ -520,7 +520,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {\n       hook.result.current.submitDraft()\n     })\n \n-    expect(gatewayRequest).not.toHaveBeenCalled()\n+    expect(respond).not.toHaveBeenCalled()\n+    expect(hasOpenServerRequest('req-runtime-session')).toBe(true)\n     expect($clarifyRequests.get()['runtime-session']).toBeDefined()\n     expect(onCancel).toHaveBeenCalledTimes(1)\n   })","@@ -534,7 +535,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {\n     })\n \n     await waitFor(() => expect(onSubmit).toHaveBeenCalled())\n-    expect(gatewayRequest).not.toHaveBeenCalled()\n+    expect(respond).not.toHaveBeenCalled()\n+    expect(hasOpenServerRequest('req-other-session')).toBe(true)\n     expect($clarifyRequests.get()['other-session']).toBeDefined()\n   })\n })"]},{"file":"apps/desktop/src/app/contrib/wiring.tsx","hunks":["@@ -497,7 +497,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {\n     [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]\n   )\n \n-  const { handleGatewayEvent } = useMessageStream({\n+  const { handleGatewayEvent, handleServerRequest } = useMessageStream({\n     activeGatewayProfile,\n     activeSessionIdRef,\n     hydrateFromStoredSession,","@@ -898,6 +898,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {\n       closeAllTerminals()\n     },\n     handleGatewayEvent: handleGatewayEventWithPlugins,\n+    handleServerRequest,\n     onConnectionReady: c => {\n       connectionRef.current = c\n     },"]},{"file":"apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx","hunks":["@@ -258,6 +258,7 @@ function Harness({\n   useGatewayBoot({\n     beforeConnectionSwitch,\n     handleGatewayEvent: () => undefined,\n+    handleServerRequest: () => false,\n     onConnectionReady: () => undefined,\n     onGatewayReady: () => undefined,\n     refreshHermesConfig,"]},{"file":"apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts","hunks":["@@ -2,6 +2,7 @@ import {\n   type GatewayEvent,\n   isGatewayReauthRequired,\n   isGatewayWebSocketUrl,\n+  JSON_RPC_METHOD_NOT_FOUND,\n   JsonRpcGatewayError,\n   reconnectBackoffDelayMs,\n   resolveGatewayWsUrl","@@ -31,6 +32,7 @@ import {\n   closeLegacySecondaryGateways,\n   closeSecondaryGateways,\n   configureGatewayRegistry,\n+  dispatchPrimaryServerRequest,\n   disposeSecondariesForConnection,\n   ensureActiveGatewayOpen,\n   ensureGatewayForProfile,","@@ -40,6 +42,7 @@ import {\n   pruneSecondaryGateways,\n   reconnectSecondaryGateways,\n   reportPrimaryGatewayState,\n+  type ScopedServerRequest,\n   setPrimaryGateway,\n   setPrimaryGatewayConnection,\n   touchSecondaryGateways","@@ -148,6 +151,8 @@ export function primaryRuntimeConnectionId(connection: Pick<HermesConnection, 'c\n interface GatewayBootOptions {\n   beforeConnectionSwitch: () => void\n   handleGatewayEvent: (event: GatewayEvent) => void\n+  /** Server→client request from any registry socket; false = no handler (the channel answers -32601). */\n+  handleServerRequest: (request: ScopedServerRequest) => boolean\n   onConnectionReady: (\n     connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null\n   ) => void","@@ -159,6 +164,7 @@ interface GatewayBootOptions {\n export function useGatewayBoot({\n   beforeConnectionSwitch,\n   handleGatewayEvent,\n+  handleServerRequest,\n   onConnectionReady,\n   onGatewayReady,\n   refreshHermesConfig,","@@ -167,6 +173,7 @@ export function useGatewayBoot({\n   const callbacksRef = useRef({\n     beforeConnectionSwitch,\n     handleGatewayEvent,\n+    handleServerRequest,\n     onConnectionReady,\n     onGatewayReady,\n     refreshHermesConfig,","@@ -176,6 +183,7 @@ export function useGatewayBoot({\n   callbacksRef.current = {\n     beforeConnectionSwitch,\n     handleGatewayEvent,\n+    handleServerRequest,\n     onConnectionReady,\n     onGatewayReady,\n     refreshHermesConfig,","@@ -805,6 +813,11 @@ export function useGatewayBoot({\n     // (connectionId, profile) keep-set so two sources exposing the same\n     // profile name (every source has a 'default') can't collide.\n     configureGatewayRegistry({\n+      onServerRequest: request => {\n+        if (!callbacksRef.current.handleServerRequest(request)) {\n+          request.fail(JSON_RPC_METHOD_NOT_FOUND, `Hermes Desktop cannot answer ${request.method}`)\n+        }\n+      },\n       // The primary socket has no secondary entry to carry registry identity.\n       // Electron's published active descriptor is authoritative after boot;\n       // a true legacy primary has no connectionId and remains unqualified.","@@ -910,6 +923,9 @@ export function useGatewayBoot({\n       callbacksRef.current.handleGatewayEvent(scopedEvent)\n     })\n \n+    // Secondary sockets reach the same handler through the registry's onServerRequest.\n+    const offRequest = gateway.onRequest(request => dispatchPrimaryServerRequest(request, sourceProfile))\n+\n     // Wake signals: power resume (macOS/Windows), network coming back, and the\n     // window regaining focus/visibility. Each nudges an immediate reconnect.\n     const forceReconnectNow = () => reconnectNow({ forceOpenSocket: true })","@@ -1274,6 +1290,7 @@ export function useGatewayBoot({\n       offActiveStateReauth()\n       offState()\n       offEvent()\n+      offRequest()\n       offExit()\n       offWindowState?.()\n       offBootProgress()"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx","hunks":["@@ -8,7 +8,7 @@ import { onScrollToBottomRequest } from '@/store/thread-scroll'\n \n import { type MessageStreamHarness, renderMessageStream } from './test-harness'\n \n-// A `clarify.request` must leave an answerable inline row even when the\n+// A `clarify` server request must leave an answerable inline row even when the\n // `tool.start` that normally mounts it was missed (stream reconnect /\n // hydration race). Without it the sidebar says \"needs input\" but the\n // transcript has nowhere to render the choices, so the agent blocks forever.","@@ -24,8 +24,8 @@ function mountStream() {\n   stream = renderMessageStream(SID)\n }\n \n-const clarifyRequest = (payload: Record<string, unknown>) =>\n-  act(() => stream.handleEvent({ payload, session_id: SID, type: 'clarify.request' }))\n+const clarifyRequest = ({ request_id, ...params }: Record<string, unknown>) =>\n+  act(() => void stream.handleRequest('clarify', { ...params, session_id: SID }, request_id as string))\n \n const toolStart = (payload: Record<string, unknown>) =>\n   act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.start' }))","@@ -34,7 +34,13 @@ const toolComplete = (payload: Record<string, unknown>) =>\n   act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.complete' }))\n \n const clarifyExpire = (requestId: string) =>\n-  act(() => stream.handleEvent({ payload: { request_id: requestId }, session_id: SID, type: 'clarify.expire' }))\n+  act(() =>\n+    stream.handleEvent({\n+      payload: { id: requestId, method: 'clarify', reason: 'timeout' },\n+      session_id: SID,\n+      type: 'request.cancel'\n+    })\n+  )\n \n function clarifyParts() {\n   const messages = stream.state().messages ?? []","@@ -49,7 +55,7 @@ function seedHydratedMessages(messages: ChatMessage[]) {\n   stream.states.set(SID, state)\n }\n \n-describe('clarify.request stream hydration', () => {\n+describe('clarify request stream hydration', () => {\n   beforeEach(() => {\n     clearClarifyRequest()\n     scrollToBottom.mockClear()","@@ -89,12 +95,13 @@ describe('clarify.request stream hydration', () => {\n   it('does not move the active thread for a background session clarify', () => {\n     mountStream()\n \n-    act(() =>\n-      stream.handleEvent({\n-        payload: { choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-background' },\n-        session_id: 'session-background',\n-        type: 'clarify.request'\n-      })\n+    act(\n+      () =>\n+        void stream.handleRequest(\n+          'clarify',\n+          { choices: ['yes', 'no'], question: 'Ship it?', session_id: 'session-background' },\n+          'req-background'\n+        )\n     )\n \n     expect(scrollToBottom).not.toHaveBeenCalled()","@@ -129,7 +136,7 @@ describe('clarify.request stream hydration', () => {\n   it('merges with the real tool.start row even though its id differs from the request id', () => {\n     mountStream()\n \n-    // Reality: tool.start carries the model's tool_call_id, clarify.request a\n+    // Reality: tool.start carries the model's tool_call_id, the clarify request a\n     // separately-generated request_id. They must still collapse to ONE card\n     // (correlated by question), not two.\n     toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' })"]},{"file":"/dev/null","hunks":["@@ -1,95 +0,0 @@\n-import { afterEach, describe, expect, it, vi } from 'vitest'\n-\n-import { $gateway } from '@/store/gateway'\n-import { $toursEnabled } from '@/store/tours'\n-\n-import { handleDesktopBridgeEvent } from './desktop-bridge'\n-import type { GatewayEventContext } from './types'\n-\n-function previewActContext({\n-  explicitSid,\n-  isActiveEvent\n-}: {\n-  explicitSid: string\n-  isActiveEvent: boolean\n-}): GatewayEventContext {\n-  return {\n-    event: { session_id: explicitSid || undefined, type: 'preview.act.request' },\n-    explicitSid,\n-    isActiveEvent,\n-    payload: { action: 'elements', request_id: 'request-1' }\n-  } as GatewayEventContext\n-}\n-\n-describe('preview action bridge routing', () => {\n-  afterEach(() => {\n-    $gateway.set(null)\n-  })\n-\n-  it('leaves a scoped action request unanswered in a window showing another session', () => {\n-    const request = vi.fn()\n-    $gateway.set({ request } as never)\n-\n-    expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true)\n-    expect(request).not.toHaveBeenCalled()\n-  })\n-\n-  it('keeps the legacy fail-fast response for an unscoped inactive request', () => {\n-    const request = vi.fn()\n-    $gateway.set({ request } as never)\n-\n-    expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: '', isActiveEvent: false }))).toBe(true)\n-    expect(request).toHaveBeenCalledWith('preview.act.respond', {\n-      request_id: 'request-1',\n-      text: JSON.stringify({\n-        error: 'The in-app browser only takes actions in the session the user is looking at.',\n-        success: false\n-      })\n-    })\n-  })\n-})\n-\n-function tourContext({\n-  explicitSid,\n-  isActiveEvent\n-}: {\n-  explicitSid: string\n-  isActiveEvent: boolean\n-}): GatewayEventContext {\n-  return {\n-    event: { session_id: explicitSid || undefined, type: 'tour.request' },\n-    explicitSid,\n-    isActiveEvent,\n-    payload: { action: 'discover', request_id: 'tour-request-1' }\n-  } as GatewayEventContext\n-}\n-\n-describe('tour bridge routing', () => {\n-  afterEach(() => {\n-    $gateway.set(null)\n-    $toursEnabled.set(true)\n-  })\n-\n-  it('leaves a scoped request unanswered in another session even when tours are disabled', () => {\n-    const request = vi.fn()\n-    $gateway.set({ request } as never)\n-    $toursEnabled.set(false)\n-\n-    expect(handleDesktopBridgeEvent(tourContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true)\n-    expect(request).not.toHaveBeenCalled()\n-  })\n-\n-  it('keeps the legacy fail-fast response for an unscoped inactive request', () => {\n-    const request = vi.fn()\n-    $gateway.set({ request } as never)\n-\n-    expect(handleDesktopBridgeEvent(tourContext({ explicitSid: '', isActiveEvent: false }))).toBe(true)\n-    expect(request).toHaveBeenCalledWith('tour.respond', {\n-      request_id: 'tour-request-1',\n-      text: JSON.stringify({\n-        error: 'Tours only run in the session the user is looking at.',\n-        success: false\n-      })\n-    })\n-  })\n-})"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts","hunks":["@@ -1,165 +1,17 @@\n-import { readActivePreview } from '@/app/chat/right-rail/preview-reader'\n import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'\n-import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'\n import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'\n-import type { PreviewActAction } from '@/lib/preview-act/act-in-page'\n-import type { TourAction, TourStep } from '@/lib/tour'\n-import { $gateway } from '@/store/gateway'\n import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus'\n import { recordAgentReaction } from '@/store/reactions-local'\n import { setMessages } from '@/store/session'\n import { $tipsEnabled, type ActiveTip, showTip } from '@/store/tips'\n-import { $toursEnabled } from '@/store/tours'\n \n import type { GatewayEventContext } from './types'\n \n-/** The preview engine, loaded on demand so ~25KB of page-injectable source stays\n- *  off the boot path.\n- *\n- *  In dev that lazy chunk is also a trap. The browser caches a dynamic import by\n- *  URL for the life of the page, so this bridge would hand every action to\n- *  whichever build of the engine loaded first, and no edit to it — or to the\n- *  overlay whose source it stringifies into the page — would reach the guest\n- *  until the whole window reloaded. Asking for a fresh copy is more reliable\n- *  than trusting hot-update propagation to reach a module nothing statically\n- *  imports; the dev server stamps the dependency URLs it has invalidated, so a\n- *  fresh engine pulls a fresh overlay down with it.\n- *\n- *  The literal path is what a bare specifier can't be here, and it has to track\n- *  this module's real location — hence the fall back to the static import, which\n- *  is also the only branch production keeps, `import.meta.hot` being stripped\n- *  there along with everything it guards. */\n-const loadPreviewEngine = () => {\n-  const stable = () => import('@/app/chat/right-rail/preview-act')\n-\n-  if (!import.meta.hot) {\n-    return stable().then(mod => mod.actOnActivePreview)\n-  }\n-\n-  return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now())\n-    .catch(stable)\n-    .then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview'])\n-}\n-\n-/** Desktop-surface bridge events: read-back requests the agent blocks on\n- *  (terminal/preview/window), agent terminal streaming, pane reveal, and\n- *  message reactions. */\n+/** Desktop-surface bridge events: agent terminal streaming, tips, pane\n+ *  reveal, layouts and message reactions. The read-back REQUESTS the agent\n+ *  blocks on (terminal/preview/window/tour) live in `server-requests.ts`. */\n export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {\n-  const { event, payload, explicitSid, isActiveEvent } = ctx\n-\n-  if (event.type === 'terminal.read.request') {\n-    // read_terminal tool: serialize the renderer's xterm buffer and answer\n-    // immediately (Python blocks on the respond). Empty text = no live pane.\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      const start = typeof payload?.start === 'number' ? payload.start : undefined\n-      const count = typeof payload?.count === 'number' ? payload.count : undefined\n-      const result = readActiveTerminal({ start, count })\n-\n-      void $gateway.get()?.request('terminal.read.respond', {\n-        request_id: requestId,\n-        text: result ? JSON.stringify(result) : ''\n-      })\n-    }\n-\n-    return true\n-  }\n-\n-  if (event.type === 'preview.read.request') {\n-    // read_preview tool: serialize the active preview tab (a Browser\n-    // webview's page text is async) and answer. Empty text = nothing open.\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      const start = typeof payload?.start === 'number' ? payload.start : undefined\n-      const count = typeof payload?.count === 'number' ? payload.count : undefined\n-\n-      void readActivePreview({ count, start }).then(result => {\n-        void $gateway.get()?.request('preview.read.respond', {\n-          request_id: requestId,\n-          text: result ? JSON.stringify(result) : ''\n-        })\n-      })\n-    }\n-\n-    return true\n-  }\n-\n-  if (event.type === 'preview.act.request') {\n-    // drive_preview tool: click/type/scroll/press inside the guest page, or\n-    // drive the pane's history. Dynamic import keeps the injected engine off\n-    // the boot path. Active session only: a background turn must never reach\n-    // into the page the user is working in (desktop AGENTS.md: offer, don't\n-    // hijack).\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      // Every mounted desktop window can observe the same gateway event. A\n-      // scoped mismatch belongs to another window, so answering here would race\n-      // the owning window and could make this refusal win before its real result.\n-      if (explicitSid && !isActiveEvent) {\n-        return true\n-      }\n-\n-      const answer = (result: unknown) =>\n-        $gateway.get()?.request('preview.act.respond', {\n-          request_id: requestId,\n-          text: result ? JSON.stringify(result) : ''\n-        })\n-\n-      if (isActiveEvent) {\n-        void loadPreviewEngine()\n-          .then(run =>\n-            run({\n-              amount: payload?.amount,\n-              key: payload?.key,\n-              kind: payload?.action ?? '',\n-              max: payload?.max,\n-              ref: payload?.ref,\n-              selector: payload?.selector,\n-              submit: payload?.submit,\n-              text: payload?.text,\n-              to: payload?.to as PreviewActAction['to']\n-            })\n-          )\n-          .then(answer, error =>\n-            answer({ error: error instanceof Error ? error.message : String(error), success: false })\n-          )\n-      } else {\n-        void answer({\n-          error: 'The in-app browser only takes actions in the session the user is looking at.',\n-          success: false\n-        })\n-      }\n-    }\n-\n-    return true\n-  }\n-\n-  if (event.type === 'window.read.request') {\n-    // read_window_below tool: main owns native window enumeration, so ask\n-    // it over IPC and answer. Empty text = unavailable (no bridge, or\n-    // enumeration unsupported on this system e.g. Wayland).\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      const read = window.hermesDesktop?.readWindowBelow\n-\n-      const answer = (result: unknown) =>\n-        $gateway.get()?.request('window.read.respond', {\n-          request_id: requestId,\n-          text: result ? JSON.stringify(result) : ''\n-        })\n-\n-      // .catch: ipcRenderer.invoke rejects on an older shell without the\n-      // handler or a main-side throw — without an empty answer the tool\n-      // would stall its full 30s timeout.\n-      void Promise.resolve(read ? read() : null).then(answer, () => answer(null))\n-    }\n-\n-    return true\n-  }\n+  const { event, payload, isActiveEvent } = ctx\n \n   if (event.type === 'agent.terminal.output') {\n     // Live chunk from a background process → its read-only agent terminal tab.","@@ -176,64 +28,6 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {\n     return true\n   }\n \n-  if (event.type === 'tour.request') {\n-    // tour tool: run one guided-tour action (highlight/step/discover) via\n-    // driver.js — on the app's own DOM or inside the preview pane's guest\n-    // page — and answer with the outcome. Dynamic import keeps driver.js\n-    // and the preview injection payload off the boot path. Active session\n-    // only: a background turn must never paint overlays on the user's\n-    // screen (desktop AGENTS.md: offer, don't hijack).\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      // As with preview actions, only the renderer that owns an explicitly\n-      // scoped request may answer. Inactive windows must stay silent even when\n-      // tours are disabled locally, or their refusal can beat the owner.\n-      if (explicitSid && !isActiveEvent) {\n-        return true\n-      }\n-\n-      const answer = (result: unknown) =>\n-        $gateway.get()?.request('tour.respond', {\n-          request_id: requestId,\n-          text: result ? JSON.stringify(result) : ''\n-        })\n-\n-      if (!$toursEnabled.get()) {\n-        // Refused in words, not silently dropped: the agent asked for a\n-        // walkthrough it isn't getting, and a no-op would leave it narrating\n-        // a spotlight the user can't see.\n-        void answer({ error: 'The user has turned guided tours off.', success: false })\n-      } else if (isActiveEvent) {\n-        void import('@/lib/tour')\n-          .then(({ runTour }) =>\n-            runTour(\n-              {\n-                kind: (payload?.action ?? 'stop') as TourAction['kind'],\n-                selector: payload?.selector,\n-                side: payload?.side as TourStep['side'],\n-                startAt: payload?.step_index,\n-                steps: payload?.steps as TourStep[] | undefined,\n-                text: payload?.text,\n-                title: payload?.title\n-              },\n-              payload?.surface === 'preview' ? 'preview' : 'app'\n-            )\n-          )\n-          .then(answer, error =>\n-            answer({ error: error instanceof Error ? error.message : String(error), success: false })\n-          )\n-      } else {\n-        void answer({\n-          error: 'Tours only run in the session the user is looking at.',\n-          success: false\n-        })\n-      }\n-    }\n-\n-    return true\n-  }\n-\n   if (event.type === 'tip.show') {\n     // tip tool: point the accent bubble at something and say one line about\n     // it. Fire-and-forget — a tip is not a question, and blocking the turn on"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts","hunks":["@@ -1,448 +1,89 @@\n import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'\n-import { translateNow } from '@/i18n'\n-import { restorePendingClarifyToolCall, settlePendingClarifyToolCall } from '@/lib/chat-messages'\n-import {\n-  $clarifyRequests,\n-  clearClarifyRequest,\n-  normalizeChoices,\n-  normalizeQuestions,\n-  setClarifyRequest,\n-  warnDroppedChoices\n-} from '@/store/clarify'\n-import { $gateway } from '@/store/gateway'\n-import { setMcpSetupRequest } from '@/store/mcp-setup'\n-import { dispatchNativeNotification } from '@/store/native-notifications'\n+import { settlePendingClarifyToolCall } from '@/lib/chat-messages'\n+import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify'\n+import { $mcpSetupRequests, clearMcpSetupRequest } from '@/store/mcp-setup'\n import {\n+  $approvalRequests,\n+  $secretRequests,\n+  $sudoRequests,\n   $vaultCodeRequests,\n   $vaultSaveLoginRequests,\n   $vaultUnlockRequests,\n+  clearApprovalRequest,\n+  clearSecretRequest,\n+  clearSudoRequest,\n   clearVaultCodeRequest,\n   clearVaultSaveLoginRequest,\n-  clearVaultUnlockRequest,\n-  receiveApprovalRequest,\n-  setSecretRequest,\n-  setSudoRequest,\n-  setVaultCodeRequest,\n-  setVaultSaveLoginRequest,\n-  setVaultUnlockRequest\n+  clearVaultUnlockRequest\n } from '@/store/prompts'\n-import { requestScrollToBottom } from '@/store/thread-scroll'\n+import { forgetServerRequest } from '@/store/server-requests'\n \n import type { GatewayEventContext } from './types'\n \n-/** The blocking-input family: clarify / MCP setup consent / approval / sudo /\n- *  secret requests. The Python side is blocked on the matching *.respond, so\n- *  each of these must be parked per-session and surfaced. */\n+/** The blocking-input family arrives as server→client REQUESTS (see\n+ *  `server-requests.ts`); the one EVENT in the family is `request.cancel`, the\n+ *  backend withdrawing an open request (timeout / interrupt / session close):\n+ *  tear down whichever parked card carries that id. Cancel is request-correlated:\n+ *  a delayed cancel for an older prompt must not erase a newer one the same\n+ *  session raised. */\n export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {\n   const { deps, event, payload, sessionId, occurredAt } = ctx\n-  const { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall } = deps\n-\n-  if (event.type === 'clarify.request') {\n-    // Surface the clarify tool's overlay. The Python side is blocked on\n-    // `clarify.respond`, so without this handler the agent would hang\n-    // forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).\n-    //\n-    // Store the request for whichever session raised it — even a background\n-    // one. clarify.request is a one-shot event; if we dropped it for an\n-    // unfocused session, that session would block on `clarify.respond`\n-    // indefinitely and re-focusing it could never recover (the event is\n-    // gone). Parking it per-session lets the user answer once they switch\n-    // over; the inline ClarifyTool reads the active session's entry.\n-    if (sessionId && sessionInterrupted(sessionId)) {\n-      return true\n-    }\n-\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-    const question = typeof payload?.question === 'string' ? payload.question : ''\n-    const rawChoices = payload?.choices\n-    const choices = normalizeChoices(rawChoices)\n-    const multiSelect = payload?.multi_select === true\n-    // Batch (multi-question) clarify: `questions` replaces question/choices\n-    // on the wire. `answers` rides along only on reconnect replay, carrying\n-    // the per-question locks the server already accepted.\n-    const questions = normalizeQuestions(payload?.questions)\n-\n-    const lockedAnswers =\n-      typeof payload?.answers === 'object' && payload?.answers !== null\n-        ? Object.fromEntries(\n-            Object.entries(payload.answers as Record<string, unknown>).filter(\n-              (entry): entry is [string, string] => typeof entry[1] === 'string'\n-            )\n-          )\n-        : undefined\n-\n-    if (requestId && questions.length > 0) {\n-      const request = {\n-        choices: null,\n-        lockedAnswers,\n-        multiSelect: false,\n-        question: '',\n-        questions,\n-        receivedAt: Date.now() / 1000,\n-        requestId,\n-        sessionId: sessionId ?? null\n-      }\n-\n-      setClarifyRequest(request)\n-\n-      if (sessionId) {\n-        // A resumed/hydrated transcript may already contain this provider's\n-        // clarify call while carrying no live streamId. Re-arm that exact row\n-        // instead of letting the generic stream mutator append a second card.\n-        updateSessionState(sessionId, state => {\n-          const projection = restorePendingClarifyToolCall(\n-            state.messages,\n-            pendingClarifyToolPayload(request),\n-            occurredAt\n-          )\n-\n-          return {\n-            ...state,\n-            messages: projection.messages,\n-            streamId: projection.streamId,\n-            sawAssistantPayload: true,\n-            awaitingResponse: false,\n-            needsInput: true\n-          }\n-        })\n-\n-        if (sessionId === activeSessionIdRef.current) {\n-          requestScrollToBottom(sessionId)\n-        }\n-      }\n-\n-      dispatchNativeNotification({\n-        body: questions.map(q => q.question).join(' · '),\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n-      })\n-    } else if (requestId && question) {\n-      if (rawChoices != null && choices.length === 0) {\n-        warnDroppedChoices('gateway', question, rawChoices)\n-      }\n \n-      const request = {\n-        requestId,\n-        question,\n-        choices: choices.length > 0 ? choices : null,\n-        multiSelect,\n-        receivedAt: Date.now() / 1000,\n-        sessionId: sessionId ?? null\n-      }\n-\n-      setClarifyRequest(request)\n-\n-      if (sessionId) {\n-        // Same provider-shape-aware repair as the batch path above. This keeps\n-        // the original hydrated row and provider tool id, while still seeding\n-        // a row when tool.start was genuinely missed.\n-        updateSessionState(sessionId, state => {\n-          const projection = restorePendingClarifyToolCall(\n-            state.messages,\n-            pendingClarifyToolPayload(request),\n-            occurredAt\n-          )\n-\n-          return {\n-            ...state,\n-            messages: projection.messages,\n-            streamId: projection.streamId,\n-            sawAssistantPayload: true,\n-            awaitingResponse: false,\n-            needsInput: true\n-          }\n-        })\n-\n-        if (sessionId === activeSessionIdRef.current) {\n-          requestScrollToBottom(sessionId)\n-        }\n-      }\n-\n-      dispatchNativeNotification({\n-        body: question,\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n-      })\n-    }\n-\n-    return true\n+  if (event.type !== 'request.cancel') {\n+    return false\n   }\n \n-  if (event.type === 'vault.code.expire') {\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-    const request = sessionId ? $vaultCodeRequests.get()[sessionId] : undefined\n-\n-    if (requestId && request && request.requestId === requestId) {\n-      clearVaultCodeRequest(sessionId, requestId)\n-    }\n+  const id = typeof payload?.id === 'string' ? payload.id : ''\n \n+  if (!id) {\n     return true\n   }\n \n-  if (event.type === 'vault.save_login.expire') {\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-    const request = sessionId ? $vaultSaveLoginRequests.get()[sessionId] : undefined\n+  forgetServerRequest(id)\n \n-    if (requestId && request && request.requestId === requestId) {\n-      clearVaultSaveLoginRequest(sessionId, requestId)\n-    }\n+  const key = sessionId ?? ''\n \n-    return true\n-  }\n-\n-  if (event.type === 'vault.unlock.expire') {\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-    const request = sessionId ? $vaultUnlockRequests.get()[sessionId] : undefined\n-\n-    if (requestId && request && request.requestId === requestId) {\n-      clearVaultUnlockRequest(sessionId, requestId)\n-    }\n-\n-    return true\n-  }\n-\n-  if (event.type === 'clarify.expire') {\n-    if (!sessionId) {\n-      return true\n-    }\n-\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-    const request = $clarifyRequests.get()[sessionId]\n-\n-    // Expiry is request-correlated: a delayed event from an older prompt must\n-    // not erase a newer clarify raised by the same session.\n-    if (!requestId || !request || request.requestId !== requestId) {\n-      return true\n-    }\n+  if ($clarifyRequests.get()[key]?.requestId === id) {\n+    const request = $clarifyRequests.get()[key]\n \n-    clearClarifyRequest(requestId, sessionId)\n-    updateSessionState(sessionId, state => {\n-      const projection = settlePendingClarifyToolCall(\n-        state.messages,\n-        pendingClarifyToolPayload(request),\n-        state.busy,\n-        occurredAt\n-      )\n+    clearClarifyRequest(id, sessionId)\n \n-      return {\n-        ...state,\n-        messages: projection.messages,\n-        needsInput: false,\n-        streamId: state.busy ? (projection.streamId ?? state.streamId) : null\n-      }\n-    })\n-\n-    return true\n-  }\n-\n-  if (event.type === 'mcp.setup.request') {\n-    // setup_mcp tool (desktop GUI): the agent proposed an MCP server and\n-    // the Python side is blocked on mcp.setup.respond. Park the request\n-    // per-session (like clarify) and upsert a stable pending tool row so\n-    // the inline consent card has somewhere to render even when the\n-    // tool.start event was missed (stream reconnect / hydration race).\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-    const server = typeof payload?.server === 'string' ? payload.server : ''\n-    const rawAction = typeof payload?.action === 'string' ? payload.action : 'install'\n-    const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install'\n-    const reason = typeof payload?.reason === 'string' ? payload.reason : ''\n-\n-    if (requestId && server) {\n-      setMcpSetupRequest({ action, reason, requestId, server, sessionId: sessionId ?? null })\n-\n-      if (sessionId) {\n-        upsertToolCall(\n-          sessionId,\n-          { args: { action, reason, server }, name: 'setup_mcp', tool_id: requestId },\n-          'running'\n+    if (sessionId && request) {\n+      deps.updateSessionState(sessionId, state => {\n+        const projection = settlePendingClarifyToolCall(\n+          state.messages,\n+          pendingClarifyToolPayload(request),\n+          state.busy,\n+          occurredAt\n         )\n-        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))\n-      }\n-\n-      dispatchNativeNotification({\n-        body: reason || server,\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n-      })\n-    }\n-\n-    return true\n-  }\n \n-  if (event.type === 'approval.request') {\n-    // Dangerous-command / execute_code approval. The Python side is blocked\n-    // in _await_gateway_decision() until approval.respond lands; without\n-    // this the agent stalls until its 5-min timeout and the tool is BLOCKED.\n-    // Park it per-session (like clarify) so a *background* profile's turn can\n-    // raise it and wait — the sidebar flags \"needs input\" and the inline bar\n-    // surfaces once the user focuses that chat.\n-    const command = typeof payload?.command === 'string' ? payload.command : ''\n-    const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'\n-\n-    void receiveApprovalRequest($gateway.get(), {\n-      // false only when a tirith warning forbids it; backend omits the field otherwise.\n-      allowPermanent: payload?.allow_permanent !== false,\n-      choices: Array.isArray(payload?.choices)\n-        ? payload.choices.filter(choice => typeof choice === 'string')\n-        : undefined,\n-      command,\n-      description,\n-      requestId: typeof payload?.request_id === 'string' ? payload.request_id : undefined,\n-      sessionId: sessionId ?? null,\n-      smartDenied: payload?.smart_denied === true\n-    }).catch(() => undefined)\n-\n-    if (sessionId) {\n-      updateSessionState(sessionId, state => ({ ...state, needsInput: true }))\n-    }\n-\n-    dispatchNativeNotification({\n-      actions: [\n-        { id: 'approve', text: translateNow('notifications.native.approveAction') },\n-        { id: 'reject', text: translateNow('notifications.native.rejectAction') }\n-      ],\n-      body: command || description,\n-      kind: 'approval',\n-      sessionId,\n-      title: translateNow('notifications.native.approvalTitle')\n-    })\n-\n-    return true\n-  }\n-\n-  if (event.type === 'sudo.request') {\n-    // Sudo password capture (tools/terminal_tool.py). Blocked on\n-    // sudo.respond {request_id, password}.\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      setSudoRequest({ requestId, sessionId: sessionId ?? null })\n-\n-      if (sessionId) {\n-        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))\n-      }\n-\n-      dispatchNativeNotification({\n-        body: translateNow('notifications.native.inputBody'),\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n-      })\n-    }\n-\n-    return true\n-  }\n-\n-  if (event.type === 'secret.request') {\n-    // Skill credential capture (tools/skills_tool.py). Blocked on\n-    // secret.respond {request_id, value}.\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''\n-      const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''\n-\n-      setSecretRequest({\n-        requestId,\n-        envVar,\n-        prompt: promptText,\n-        sessionId: sessionId ?? null\n-      })\n-\n-      if (sessionId) {\n-        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))\n-      }\n-\n-      dispatchNativeNotification({\n-        body: promptText || envVar || translateNow('notifications.native.inputBody'),\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n-      })\n-    }\n-\n-    return true\n-  }\n-\n-  if (event.type === 'vault.code.request') {\n-    // Second factor: the site asked for a one-time code and no authenticator key is saved for the login.\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      const site = typeof payload?.site === 'string' ? payload.site : ''\n-      const hint = typeof payload?.hint === 'string' ? payload.hint : ''\n-\n-      setVaultCodeRequest({ hint, requestId, sessionId: sessionId ?? null, site })\n-\n-      if (sessionId) {\n-        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))\n-      }\n-\n-      dispatchNativeNotification({\n-        body: translateNow('prompts.vaultCodeTitle', site),\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n-      })\n-    }\n-\n-    return true\n-  }\n-\n-  if (event.type === 'vault.save_login.request') {\n-    // The agent is on a sign-in page with no saved login: identifier + masked password card; the\n-    // answer is stored in the encrypted vault by the backend and filled at once (never shown to the model).\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      const origin = typeof payload?.origin === 'string' ? payload.origin : ''\n-      const site = typeof payload?.site === 'string' ? payload.site : origin\n-\n-      setVaultSaveLoginRequest({ origin, requestId, sessionId: sessionId ?? null, site })\n-\n-      if (sessionId) {\n-        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))\n-      }\n-\n-      dispatchNativeNotification({\n-        body: translateNow('prompts.vaultSaveTitle', site),\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n+        return {\n+          ...state,\n+          messages: projection.messages,\n+          needsInput: false,\n+          streamId: state.busy ? (projection.streamId ?? state.streamId) : null\n+        }\n       })\n     }\n \n     return true\n   }\n \n-  if (event.type === 'vault.unlock.request') {\n-    // External password-manager unlock (agent/vault_backends). Blocked on\n-    // vault.unlock.respond {request_id, password}; \"\" keeps it locked.\n-    const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''\n-\n-    if (requestId) {\n-      const backend = typeof payload?.backend === 'string' ? payload.backend : ''\n-      const displayName = typeof payload?.display_name === 'string' ? payload.display_name : backend\n-\n-      setVaultUnlockRequest({ backend, displayName, requestId, sessionId: sessionId ?? null })\n-\n-      if (sessionId) {\n-        updateSessionState(sessionId, state => ({ ...state, needsInput: true }))\n-      }\n-\n-      dispatchNativeNotification({\n-        body: translateNow('prompts.vaultUnlockTitle', displayName),\n-        kind: 'input',\n-        sessionId,\n-        title: translateNow('notifications.native.inputTitle')\n-      })\n-    }\n-\n-    return true\n+  if ($approvalRequests.get()[key]?.serverRequestId === id) {\n+    clearApprovalRequest(sessionId, $approvalRequests.get()[key]?.requestId)\n+  } else if ($sudoRequests.get()[key]?.requestId === id) {\n+    clearSudoRequest(sessionId, id)\n+  } else if ($secretRequests.get()[key]?.requestId === id) {\n+    clearSecretRequest(sessionId, id)\n+  } else if ($vaultCodeRequests.get()[key]?.requestId === id) {\n+    clearVaultCodeRequest(sessionId, id)\n+  } else if ($vaultSaveLoginRequests.get()[key]?.requestId === id) {\n+    clearVaultSaveLoginRequest(sessionId, id)\n+  } else if ($vaultUnlockRequests.get()[key]?.requestId === id) {\n+    clearVaultUnlockRequest(sessionId, id)\n+  } else if ($mcpSetupRequests.get()[key]?.requestId === id) {\n+    clearMcpSetupRequest(id, sessionId)\n   }\n \n-  return false\n+  return true\n }"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.test.ts","hunks":["@@ -0,0 +1,59 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+import { $toursEnabled } from '@/store/tours'\n+\n+import { handleServerRequest } from './server-requests'\n+import type { ServerRequestContext } from './server-requests'\n+\n+const deps = {} as ServerRequestContext['deps']\n+\n+function deliver(method: string, params: Record<string, unknown>, activeSessionId: null | string) {\n+  const respond = vi.fn()\n+  const fail = vi.fn()\n+  const handled = handleServerRequest({ fail, id: 'srq-1', method, params, profile: 'default', respond }, deps, activeSessionId)\n+\n+  return { fail, handled, respond }\n+}\n+\n+describe('preview action request routing', () => {\n+  it('leaves a scoped action request unanswered in a window showing another session', () => {\n+    const { handled, respond, fail } = deliver('preview.act', { action: 'elements', session_id: 'session-a' }, 'session-b')\n+\n+    expect(handled).toBe(true)\n+    expect(respond).not.toHaveBeenCalled()\n+    expect(fail).not.toHaveBeenCalled()\n+  })\n+\n+  it('fails fast for an unscoped request with no session in view', () => {\n+    const { respond } = deliver('preview.act', { action: 'elements' }, null)\n+\n+    expect(respond).toHaveBeenCalledWith({\n+      value: JSON.stringify({\n+        error: 'The in-app browser only takes actions in the session the user is looking at.',\n+        success: false\n+      })\n+    })\n+  })\n+})\n+\n+describe('tour request routing', () => {\n+  afterEach(() => {\n+    $toursEnabled.set(true)\n+  })\n+\n+  it('leaves a scoped request unanswered in another session even when tours are disabled', () => {\n+    $toursEnabled.set(false)\n+    const { handled, respond } = deliver('tour', { action: 'discover', session_id: 'session-a' }, 'session-b')\n+\n+    expect(handled).toBe(true)\n+    expect(respond).not.toHaveBeenCalled()\n+  })\n+\n+  it('fails fast for an unscoped request with no session in view', () => {\n+    const { respond } = deliver('tour', { action: 'discover' }, null)\n+\n+    expect(respond).toHaveBeenCalledWith({\n+      value: JSON.stringify({ error: 'Tours only run in the session the user is looking at.', success: false })\n+    })\n+  })\n+})"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts","hunks":["@@ -0,0 +1,434 @@\n+import { readActivePreview } from '@/app/chat/right-rail/preview-reader'\n+import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'\n+import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'\n+import { translateNow } from '@/i18n'\n+import { restorePendingClarifyToolCall } from '@/lib/chat-messages'\n+import type { PreviewActAction } from '@/lib/preview-act/act-in-page'\n+import type { TourAction, TourStep } from '@/lib/tour'\n+import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'\n+import type { ScopedServerRequest } from '@/store/gateway'\n+import { setMcpSetupRequest } from '@/store/mcp-setup'\n+import { dispatchNativeNotification } from '@/store/native-notifications'\n+import {\n+  receiveApprovalRequest,\n+  setSecretRequest,\n+  setSudoRequest,\n+  setVaultCodeRequest,\n+  setVaultSaveLoginRequest,\n+  setVaultUnlockRequest\n+} from '@/store/prompts'\n+import { rememberServerRequest } from '@/store/server-requests'\n+import { requestScrollToBottom } from '@/store/thread-scroll'\n+import { $toursEnabled } from '@/store/tours'\n+\n+import type { GatewayEventDeps } from './types'\n+\n+/** The preview engine, loaded on demand so ~25KB of page-injectable source stays\n+ *  off the boot path (dev: a fresh copy per action so edits reach the guest — see\n+ *  the previous home of this loader in desktop-bridge.ts for the full story). */\n+const loadPreviewEngine = () => {\n+  const stable = () => import('@/app/chat/right-rail/preview-act')\n+\n+  if (!import.meta.hot) {\n+    return stable().then(mod => mod.actOnActivePreview)\n+  }\n+\n+  return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now())\n+    .catch(stable)\n+    .then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview'])\n+}\n+\n+const str = (v: unknown): string => (typeof v === 'string' ? v : '')\n+const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)\n+\n+/** Answer a string-valued request with a JSON-encoded result ('' = nothing / unavailable). */\n+const answerValue = (request: ScopedServerRequest, result: unknown) =>\n+  request.respond({ value: result ? JSON.stringify(result) : '' })\n+\n+export interface ServerRequestContext {\n+  deps: Pick<GatewayEventDeps, 'activeSessionIdRef' | 'sessionInterrupted' | 'updateSessionState' | 'upsertToolCall'>\n+  request: ScopedServerRequest\n+  /** The session the request names ('' when unscoped). */\n+  sessionId: string\n+  /** The named session is the one on screen. */\n+  isActiveSession: boolean\n+}\n+\n+type Handler = (ctx: ServerRequestContext) => void\n+\n+const markNeedsInput = (ctx: ServerRequestContext) => {\n+  if (ctx.sessionId) {\n+    ctx.deps.updateSessionState(ctx.sessionId, state => ({ ...state, needsInput: true }))\n+  }\n+}\n+\n+const notifyInput = (ctx: ServerRequestContext, body: string) => {\n+  if (!ctx.request.replayed) {\n+    dispatchNativeNotification({\n+      body,\n+      kind: 'input',\n+      sessionId: ctx.sessionId || null,\n+      title: translateNow('notifications.native.inputTitle')\n+    })\n+  }\n+}\n+\n+// ── Blocking-input family (clarify / approval / sudo / secret / vault / MCP setup) ──\n+// Every one is parked per-session (like clarify) so a BACKGROUND session's turn can\n+// raise it and wait — the sidebar flags \"needs input\" and the card surfaces once the\n+// user focuses that chat. The Python side blocks on the response frame; without a\n+// handler the channel answers -32601 and the tool fails fast instead of stalling.\n+\n+const clarify: Handler = ctx => {\n+  const { deps, request, sessionId } = ctx\n+  const p = request.params\n+\n+  if (sessionId && deps.sessionInterrupted(sessionId)) {\n+    request.respond({ answer: '' })\n+\n+    return\n+  }\n+\n+  const question = str(p.question)\n+  const rawChoices = p.choices\n+  const choices = normalizeChoices(rawChoices)\n+  const multiSelect = p.multi_select === true\n+  // Batch (multi-question) clarify: `questions` replaces question/choices on the\n+  // wire. `answers` rides along only on a reconnect replay (locks the server\n+  // already accepted).\n+  const questions = normalizeQuestions(p.questions)\n+\n+  const lockedAnswers =\n+    typeof p.answers === 'object' && p.answers !== null\n+      ? Object.fromEntries(\n+          Object.entries(p.answers as Record<string, unknown>).filter(\n+            (entry): entry is [string, string] => typeof entry[1] === 'string'\n+          )\n+        )\n+      : undefined\n+\n+  if (questions.length === 0 && !question) {\n+    request.respond({ answer: '' })\n+\n+    return\n+  }\n+\n+  if (questions.length === 0 && rawChoices != null && choices.length === 0) {\n+    warnDroppedChoices('gateway', question, rawChoices)\n+  }\n+\n+  const clarifyRequest =\n+    questions.length > 0\n+      ? {\n+          choices: null,\n+          lockedAnswers,\n+          multiSelect: false,\n+          question: '',\n+          questions,\n+          receivedAt: Date.now() / 1000,\n+          requestId: request.id,\n+          sessionId: sessionId || null\n+        }\n+      : {\n+          choices: choices.length > 0 ? choices : null,\n+          multiSelect,\n+          question,\n+          receivedAt: Date.now() / 1000,\n+          requestId: request.id,\n+          sessionId: sessionId || null\n+        }\n+\n+  rememberServerRequest(request)\n+  setClarifyRequest(clarifyRequest)\n+\n+  if (sessionId) {\n+    // A resumed/hydrated transcript may already contain this provider's clarify\n+    // call while carrying no live streamId. Re-arm that exact row instead of\n+    // letting the generic stream mutator append a second card.\n+    const occurredAt = Date.now() / 1000\n+\n+    deps.updateSessionState(sessionId, state => {\n+      const projection = restorePendingClarifyToolCall(\n+        state.messages,\n+        pendingClarifyToolPayload(clarifyRequest),\n+        occurredAt\n+      )\n+\n+      return {\n+        ...state,\n+        messages: projection.messages,\n+        streamId: projection.streamId,\n+        sawAssistantPayload: true,\n+        awaitingResponse: false,\n+        needsInput: true\n+      }\n+    })\n+\n+    if (sessionId === deps.activeSessionIdRef.current) {\n+      requestScrollToBottom(sessionId)\n+    }\n+  }\n+\n+  notifyInput(ctx, questions.length > 0 ? questions.map(q => q.question).join(' · ') : question)\n+}\n+\n+const approval: Handler = ctx => {\n+  const { request, sessionId } = ctx\n+  const p = request.params\n+  const command = str(p.command)\n+  const description = str(p.description) || 'dangerous command'\n+\n+  rememberServerRequest(request)\n+  void receiveApprovalRequest(null, {\n+    // false only when a tirith warning forbids it; backend omits the field otherwise.\n+    allowPermanent: p.allow_permanent !== false,\n+    choices: Array.isArray(p.choices) ? p.choices.filter((choice): choice is string => typeof choice === 'string') : undefined,\n+    command,\n+    description,\n+    // The approval queue's own id — `approval.pending` / `approval.received` / `approval.respond` key on it.\n+    requestId: str(p.request_id) || undefined,\n+    serverRequestId: request.id,\n+    sessionId: sessionId || null,\n+    smartDenied: p.smart_denied === true\n+  }).catch(() => undefined)\n+  markNeedsInput(ctx)\n+\n+  if (!request.replayed) {\n+    dispatchNativeNotification({\n+      actions: [\n+        { id: 'approve', text: translateNow('notifications.native.approveAction') },\n+        { id: 'reject', text: translateNow('notifications.native.rejectAction') }\n+      ],\n+      body: command || description,\n+      kind: 'approval',\n+      sessionId: sessionId || null,\n+      title: translateNow('notifications.native.approvalTitle')\n+    })\n+  }\n+}\n+\n+const sudo: Handler = ctx => {\n+  rememberServerRequest(ctx.request)\n+  setSudoRequest({ requestId: ctx.request.id, sessionId: ctx.sessionId || null })\n+  markNeedsInput(ctx)\n+  notifyInput(ctx, translateNow('notifications.native.inputBody'))\n+}\n+\n+const secret: Handler = ctx => {\n+  const p = ctx.request.params\n+  const envVar = str(p.env_var)\n+  const promptText = str(p.prompt)\n+\n+  rememberServerRequest(ctx.request)\n+  setSecretRequest({ envVar, prompt: promptText, requestId: ctx.request.id, sessionId: ctx.sessionId || null })\n+  markNeedsInput(ctx)\n+  notifyInput(ctx, promptText || envVar || translateNow('notifications.native.inputBody'))\n+}\n+\n+const vaultCode: Handler = ctx => {\n+  const p = ctx.request.params\n+  const site = str(p.site)\n+\n+  rememberServerRequest(ctx.request)\n+  setVaultCodeRequest({ hint: str(p.hint), requestId: ctx.request.id, sessionId: ctx.sessionId || null, site })\n+  markNeedsInput(ctx)\n+  notifyInput(ctx, translateNow('prompts.vaultCodeTitle', site))\n+}\n+\n+const vaultSaveLogin: Handler = ctx => {\n+  const p = ctx.request.params\n+  const origin = str(p.origin)\n+  const site = str(p.site) || origin\n+\n+  rememberServerRequest(ctx.request)\n+  setVaultSaveLoginRequest({ origin, requestId: ctx.request.id, sessionId: ctx.sessionId || null, site })\n+  markNeedsInput(ctx)\n+  notifyInput(ctx, translateNow('prompts.vaultSaveTitle', site))\n+}\n+\n+const vaultUnlockPrompt: Handler = ctx => {\n+  const p = ctx.request.params\n+  const backend = str(p.backend)\n+  const displayName = str(p.display_name) || backend\n+\n+  rememberServerRequest(ctx.request)\n+  setVaultUnlockRequest({ backend, displayName, requestId: ctx.request.id, sessionId: ctx.sessionId || null })\n+  markNeedsInput(ctx)\n+  notifyInput(ctx, translateNow('prompts.vaultUnlockTitle', displayName))\n+}\n+\n+const mcpSetup: Handler = ctx => {\n+  // setup_mcp tool (desktop GUI): the agent proposed an MCP server. Park the\n+  // request per-session (like clarify) and upsert a stable pending tool row so\n+  // the inline consent card has somewhere to render even when the tool.start\n+  // event was missed (stream reconnect / hydration race).\n+  const { deps, request, sessionId } = ctx\n+  const p = request.params\n+  const server = str(p.server)\n+  const rawAction = str(p.action) || 'install'\n+  const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install'\n+  const reason = str(p.reason)\n+\n+  if (!server) {\n+    request.respond({ value: '' })\n+\n+    return\n+  }\n+\n+  rememberServerRequest(request)\n+  setMcpSetupRequest({ action, reason, requestId: request.id, server, sessionId: sessionId || null })\n+\n+  if (sessionId) {\n+    deps.upsertToolCall(sessionId, { args: { action, reason, server }, name: 'setup_mcp', tool_id: request.id }, 'running')\n+  }\n+\n+  markNeedsInput(ctx)\n+  notifyInput(ctx, reason || server)\n+}\n+\n+// ── Desktop-surface bridges (answered immediately, no card) ─────────────────\n+\n+const terminalRead: Handler = ({ request }) => {\n+  // read_terminal tool: serialize the renderer's xterm buffer. Empty = no live pane.\n+  answerValue(request, readActiveTerminal({ count: num(request.params.count), start: num(request.params.start) }))\n+}\n+\n+const previewRead: Handler = ({ request }) => {\n+  // read_preview tool: the active preview tab's page text is async. Empty = nothing open.\n+  void readActivePreview({ count: num(request.params.count), start: num(request.params.start) }).then(result =>\n+    answerValue(request, result)\n+  )\n+}\n+\n+const previewAct: Handler = ({ isActiveSession, request, sessionId }) => {\n+  // drive_preview tool: click/type/scroll/press inside the guest page. Active\n+  // session only: a background turn must never reach into the page the user is\n+  // working in (desktop AGENTS.md: offer, don't hijack). Every mounted window can\n+  // observe the same request; a scoped mismatch belongs to another window, so\n+  // answering here would race the owner — stay silent.\n+  if (sessionId && !isActiveSession) {\n+    return\n+  }\n+\n+  const p = request.params\n+\n+  if (!isActiveSession) {\n+    answerValue(request, {\n+      error: 'The in-app browser only takes actions in the session the user is looking at.',\n+      success: false\n+    })\n+\n+    return\n+  }\n+\n+  void loadPreviewEngine()\n+    .then(run =>\n+      run({\n+        amount: p.amount as never,\n+        key: p.key as never,\n+        kind: (str(p.action) || '') as never,\n+        max: p.max as never,\n+        ref: p.ref as never,\n+        selector: p.selector as never,\n+        submit: p.submit as never,\n+        text: p.text as never,\n+        to: p.to as PreviewActAction['to']\n+      })\n+    )\n+    .then(\n+      result => answerValue(request, result),\n+      error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false })\n+    )\n+}\n+\n+const windowRead: Handler = ({ request }) => {\n+  // read_window_below tool: main owns native window enumeration. Empty =\n+  // unavailable (older shell without the handler, Wayland, …) — without an\n+  // answer the tool would stall its full 30s deadline.\n+  const read = window.hermesDesktop?.readWindowBelow\n+\n+  void Promise.resolve(read ? read() : null).then(\n+    result => answerValue(request, result),\n+    () => answerValue(request, null)\n+  )\n+}\n+\n+const tour: Handler = ({ isActiveSession, request, sessionId }) => {\n+  // tour tool: one guided-tour action via driver.js, app DOM or preview guest\n+  // page. Active session only, same window-ownership rule as preview.act.\n+  if (sessionId && !isActiveSession) {\n+    return\n+  }\n+\n+  const p = request.params\n+\n+  if (!$toursEnabled.get()) {\n+    // Refused in words, not silently dropped: a no-op would leave the agent\n+    // narrating a spotlight the user can't see.\n+    answerValue(request, { error: 'The user has turned guided tours off.', success: false })\n+\n+    return\n+  }\n+\n+  if (!isActiveSession) {\n+    answerValue(request, { error: 'Tours only run in the session the user is looking at.', success: false })\n+\n+    return\n+  }\n+\n+  void import('@/lib/tour')\n+    .then(({ runTour }) =>\n+      runTour(\n+        {\n+          kind: (str(p.action) || 'stop') as TourAction['kind'],\n+          selector: p.selector as never,\n+          side: p.side as TourStep['side'],\n+          startAt: p.step_index as never,\n+          steps: p.steps as TourStep[] | undefined,\n+          text: p.text as never,\n+          title: p.title as never\n+        },\n+        p.surface === 'preview' ? 'preview' : 'app'\n+      )\n+    )\n+    .then(\n+      result => answerValue(request, result),\n+      error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false })\n+    )\n+}\n+\n+/** Method → handler. Every `ServerRequestMap` key the desktop answers. */\n+export const SERVER_REQUEST_HANDLERS: Record<string, Handler> = {\n+  approval,\n+  clarify,\n+  'mcp.setup': mcpSetup,\n+  'preview.act': previewAct,\n+  'preview.read': previewRead,\n+  secret,\n+  sudo,\n+  'terminal.read': terminalRead,\n+  tour,\n+  'vault.code': vaultCode,\n+  'vault.save_login': vaultSaveLogin,\n+  'vault.unlock_prompt': vaultUnlockPrompt,\n+  'window.read': windowRead\n+}\n+\n+/** Dispatch one server→client request; false when the desktop has no handler for its method. */\n+export function handleServerRequest(\n+  request: ScopedServerRequest,\n+  deps: ServerRequestContext['deps'],\n+  activeSessionId: null | string\n+): boolean {\n+  const handler = SERVER_REQUEST_HANDLERS[request.method]\n+\n+  if (!handler) {\n+    return false\n+  }\n+\n+  const sessionId = str(request.params.session_id)\n+\n+  handler({ deps, request, sessionId, isActiveSession: Boolean(sessionId) && sessionId === activeSessionId })\n+\n+  return true\n+}"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/index.ts","hunks":["@@ -24,6 +24,7 @@ import {\n   stripGeneratedImageEchoes\n } from '@/lib/generated-images'\n import { isTodoToolName, nextTodosFromToolEvent, parseTodoRevision } from '@/lib/todos'\n+import type { ScopedServerRequest } from '@/store/gateway'\n import { dispatchNativeNotification } from '@/store/native-notifications'\n import { isDiskFullErrorMessage, notifyError } from '@/store/notifications'\n import { broadcastSessionsChanged } from '@/store/session-sync'","@@ -33,6 +34,7 @@ import { $todosBySession, setSessionTodos } from '@/store/todos'\n import type { ClientSessionState } from '../../../types'\n \n import { useGatewayEventHandler } from './gateway-event'\n+import { handleServerRequest as dispatchServerRequest } from './gateway-event/server-requests'\n import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils'\n \n interface MessageStreamOptions {","@@ -880,11 +882,25 @@ export function useMessageStream({\n     upsertToolCall\n   })\n \n+  // Server→client requests (clarify, approval, sudo, …) from every socket the\n+  // registry owns. The request answers itself over the socket it arrived on,\n+  // so no owner routing is involved here — only which card to show.\n+  const handleServerRequest = useCallback(\n+    (request: ScopedServerRequest): boolean =>\n+      dispatchServerRequest(\n+        request,\n+        { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall },\n+        activeSessionIdRef.current\n+      ),\n+    [activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall]\n+  )\n+\n   return {\n     appendAssistantDelta,\n     appendReasoningDelta,\n     completeAssistantMessage,\n     handleGatewayEvent,\n+    handleServerRequest,\n     finalizeInterimAssistantMessage,\n     upsertToolCall\n   }"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx","hunks":["@@ -6,6 +6,7 @@ import { vi } from 'vitest'\n \n import type { ClientSessionState } from '@/app/types'\n import { createClientSessionState } from '@/lib/chat-runtime'\n+import type { ScopedServerRequest } from '@/store/gateway'\n \n import { useMessageStream } from './index'\n ","@@ -17,6 +18,9 @@ export interface MessageStreamHarnessOptions extends Partial<Parameters<typeof u\n export interface MessageStreamHarness {\n   /** Feed a gateway event into the mounted hook. */\n   handleEvent: (event: GatewayEvent) => void\n+  /** Feed a server→client request (clarify, approval, …) into the mounted hook;\n+   *  returns the `respond` spy so a test can assert the answer frame. */\n+  handleRequest: (method: string, params: Record<string, unknown>, id?: string) => ReturnType<typeof vi.fn>\n   /** Push streaming assistant text, bypassing the event envelope. For the specs\n    *  about flush scheduling rather than about a particular event. */\n   appendDelta: (sessionId: string, delta: string) => void","@@ -48,6 +52,7 @@ export function renderMessageStream(\n   { states = new Map<string, ClientSessionState>(), ...overrides }: MessageStreamHarnessOptions = {}\n ): MessageStreamHarness {\n   let dispatch: ((event: GatewayEvent) => void) | null = null\n+  let dispatchRequest: ((request: ScopedServerRequest) => boolean) | null = null\n   let appendDelta: ((sessionId: string, delta: string) => void) | null = null\n   let latest: ClientSessionState | null = null\n ","@@ -75,8 +80,9 @@ export function renderMessageStream(\n \n     useEffect(() => {\n       dispatch = stream.handleGatewayEvent\n+      dispatchRequest = stream.handleServerRequest\n       appendDelta = stream.appendAssistantDelta\n-    }, [stream.appendAssistantDelta, stream.handleGatewayEvent])\n+    }, [stream.appendAssistantDelta, stream.handleGatewayEvent, stream.handleServerRequest])\n \n     return null\n   }","@@ -93,6 +99,17 @@ export function renderMessageStream(\n \n       dispatch(event)\n     },\n+    handleRequest: (method, params, id = `srq-${method}`) => {\n+      const respond = vi.fn()\n+\n+      if (!dispatchRequest) {\n+        throw new Error('renderMessageStream: the hook never mounted')\n+      }\n+\n+      dispatchRequest({ fail: vi.fn(), id, method, params, profile: 'default', respond })\n+\n+      return respond\n+    },\n     appendDelta: (id, delta) => {\n       if (!appendDelta) {\n         throw new Error('renderMessageStream: the hook never mounted')"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","hunks":["@@ -1136,16 +1136,26 @@ describe('resumeSession failure recovery', () => {\n \n     const requestGateway = vi.fn(async (method: string) => {\n       if (method === 'session.resume') {\n+        // The channel re-delivers `open_requests` to the request handler BEFORE\n+        // the caller sees the result; that handler parks the card. Mirror that\n+        // ordering here (no channel in this harness).\n+        setClarifyRequest({\n+          choices: ['safe', 'fast'],\n+          multiSelect: false,\n+          question: 'Which path?',\n+          receivedAt: Date.now(),\n+          requestId: 'req-resumed',\n+          sessionId: 'runtime-1'\n+        })\n+\n         return {\n           info: {},\n           message_count: 2,\n           messages: [],\n           messages_omitted: true,\n-          pending_clarify: {\n-            choices: ['safe', 'fast'],\n-            question: 'Which path?',\n-            request_id: 'req-resumed'\n-          },\n+          open_requests: [\n+            { id: 'req-resumed', method: 'clarify', params: { choices: ['safe', 'fast'], question: 'Which path?' } }\n+          ],\n           resumed: 'stored-1',\n           running: true,\n           session_id: 'runtime-1',","@@ -1196,21 +1206,34 @@ describe('resumeSession failure recovery', () => {\n       session_id: 'stored-1'\n     } as never)\n \n+    const questions = [\n+      { choices: ['Blue', 'Red'], qid: 'q0', question: 'Color?' },\n+      { choices: ['Small', 'Large'], qid: 'q1', question: 'Size?' }\n+    ]\n+\n     const requestGateway = vi.fn(async (method: string) => {\n       if (method === 'session.resume') {\n+        // Request handler parks the batch card (with the server-locked answer)\n+        // from the re-delivered open request before the result lands.\n+        setClarifyRequest({\n+          choices: null,\n+          lockedAnswers: { q0: 'Blue' },\n+          multiSelect: false,\n+          question: '',\n+          questions: questions.map(q => ({ ...q, multiSelect: false })),\n+          receivedAt: Date.now(),\n+          requestId: 'req-batch-resumed',\n+          sessionId: 'runtime-1'\n+        })\n+\n         return {\n           info: {},\n           message_count: 2,\n           messages: [],\n           messages_omitted: true,\n-          pending_clarify: {\n-            answers: { q0: 'Blue' },\n-            questions: [\n-              { choices: ['Blue', 'Red'], qid: 'q0', question: 'Color?' },\n-              { choices: ['Small', 'Large'], qid: 'q1', question: 'Size?' }\n-            ],\n-            request_id: 'req-batch-resumed'\n-          },\n+          open_requests: [\n+            { id: 'req-batch-resumed', method: 'clarify', params: { answers: { q0: 'Blue' }, questions } }\n+          ],\n           resumed: 'stored-1',\n           running: true,\n           session_id: 'runtime-1',","@@ -2900,16 +2923,23 @@ describe('resumeSession warm-cache mapping integrity', () => {\n \n     const requestGateway = vi.fn(async (method: string) => {\n       if (method === 'session.activate') {\n+        setClarifyRequest({\n+          choices: ['safe', 'fast'],\n+          multiSelect: false,\n+          question: 'Which path?',\n+          receivedAt: Date.now(),\n+          requestId: 'req-warm',\n+          sessionId: 'rt-A'\n+        })\n+\n         return {\n           info: {},\n           message_count: 2,\n           messages: [],\n           messages_omitted: true,\n-          pending_clarify: {\n-            choices: ['safe', 'fast'],\n-            question: 'Which path?',\n-            request_id: 'req-warm'\n-          },\n+          open_requests: [\n+            { id: 'req-warm', method: 'clarify', params: { choices: ['safe', 'fast'], question: 'Which path?' } }\n+          ],\n           resumed: 'stored-A',\n           running: true,\n           session_id: 'rt-A',"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions/index.ts","hunks":["@@ -50,7 +50,7 @@ import {\n   resolveNewChatOwnerRoute\n } from '@/store/profile'\n import { $projectScope, resolveNewSessionCwd } from '@/store/projects'\n-import { setApprovalRequest } from '@/store/prompts'\n+import { receiveApprovalRequest } from '@/store/prompts'\n import { clearStoredTranscriptReadOnly, markStoredTranscriptReadOnly } from '@/store/read-only-transcript'\n import {\n   $activeSessionStoredIdRotation,","@@ -345,7 +345,10 @@ function restorePendingApproval(response: SessionResumeResponse, sessionId: stri\n     return false\n   }\n \n-  setApprovalRequest({\n+  // The live `approval` server request (re-delivered from `open_requests`\n+  // before this ran) already parked itself with the same queue id; don't\n+  // clobber it with a copy that can only answer through the RPC fallback.\n+  void receiveApprovalRequest(null, {\n     allowPermanent: pending.allow_permanent !== false,\n     choices: pending.choices,\n     command: pending.command ?? '',"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.test.ts","hunks":["@@ -30,102 +30,41 @@ describe('restorePendingClarifyFromSnapshot', () => {\n     $clarifyRequests.set({})\n   })\n \n-  it('restores a batch clarify snapshot (questions, no top-level question)', () => {\n-    const state = restorePendingClarifyFromSnapshot(\n-      {\n-        pending_clarify: {\n-          request_id: 'rid1',\n-          questions: [\n-            { choices: ['Yes', 'No'], multi_select: false, qid: 'q0', question: 'Proceed?' },\n-            { qid: 'q1', question: 'Which region?' }\n-          ]\n-        }\n-      },\n-      'sess-1',\n-      resumeStartedAt\n-    )\n-\n-    expect(state.request).not.toBeNull()\n-    expect(setClarifyRequestMock).toHaveBeenCalledWith(\n-      expect.objectContaining({\n+  it('hands back the card the request handler already parked for a replayed open clarify request', () => {\n+    $clarifyRequests.set({\n+      'sess-1': {\n+        choices: null,\n         multiSelect: false,\n-        question: '',\n+        question: 'Proceed?',\n+        receivedAt: resumeStartedAt + 5,\n         requestId: 'rid1',\n-        sessionId: 'sess-1',\n-        questions: [\n-          { choices: ['Yes', 'No'], multiSelect: false, qid: 'q0', question: 'Proceed?' },\n-          { multiSelect: false, qid: 'q1', question: 'Which region?', choices: null }\n-        ]\n-      })\n-    )\n-  })\n-\n-  it('carries server-locked answers into the replayed batch card', () => {\n-    restorePendingClarifyFromSnapshot(\n-      {\n-        pending_clarify: {\n-          answers: { q0: 'Yes', junk: 42 },\n-          request_id: 'rid2',\n-          questions: [{ qid: 'q0', question: 'Proceed?' }]\n-        }\n-      },\n-      'sess-2',\n-      resumeStartedAt\n-    )\n-\n-    expect(setClarifyRequestMock).toHaveBeenCalledWith(\n-      expect.objectContaining({ lockedAnswers: { q0: 'Yes' }, requestId: 'rid2' })\n-    )\n-  })\n+        sessionId: 'sess-1'\n+      }\n+    })\n \n-  it('still restores the single-question form', () => {\n     const state = restorePendingClarifyFromSnapshot(\n-      {\n-        pending_clarify: {\n-          choices: ['A', 'B'],\n-          multi_select: true,\n-          question: 'Pick one',\n-          request_id: 'rid3'\n-        }\n-      },\n-      'sess-3',\n+      { open_requests: [{ id: 'rid1', method: 'clarify', params: { question: 'Proceed?' } }] },\n+      'sess-1',\n       resumeStartedAt\n     )\n \n-    expect(state.request).not.toBeNull()\n-    expect(setClarifyRequestMock).toHaveBeenCalledWith(\n-      expect.objectContaining({\n-        choices: ['A', 'B'],\n-        multiSelect: true,\n-        question: 'Pick one',\n-        requestId: 'rid3'\n-      })\n-    )\n+    expect(state.authoritativeAbsent).toBe(false)\n+    expect(state.request?.requestId).toBe('rid1')\n+    expect(clearClarifyRequestMock).not.toHaveBeenCalled()\n   })\n \n-  it('rejects a payload with neither form (no request restored)', () => {\n+  it('reports an open request the handler declined (no card parked) without inventing one', () => {\n     const state = restorePendingClarifyFromSnapshot(\n-      { pending_clarify: { request_id: 'rid4' } },\n+      { open_requests: [{ id: 'rid4', method: 'clarify', params: {} }] },\n       'sess-4',\n       resumeStartedAt\n     )\n \n+    expect(state.authoritativeAbsent).toBe(false)\n     expect(state.request).toBeNull()\n     expect(setClarifyRequestMock).not.toHaveBeenCalled()\n   })\n \n-  it('rejects a payload with no request id', () => {\n-    const state = restorePendingClarifyFromSnapshot(\n-      { pending_clarify: { question: 'Orphaned prompt' } },\n-      'sess-5',\n-      resumeStartedAt\n-    )\n-\n-    expect(state.request).toBeNull()\n-    expect(state.authoritativeAbsent).toBe(true)\n-    expect(setClarifyRequestMock).not.toHaveBeenCalled()\n-  })\n-\n   it('clears a stale local request when the snapshot has none, and leaves a newer in-flight one', () => {\n     $clarifyRequests.set({\n       'sess-6': {"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts","hunks":["@@ -1,12 +1,5 @@\n import type { GatewayEventPayload } from '@/lib/chat-messages'\n-import {\n-  $clarifyRequests,\n-  type ClarifyRequest,\n-  clearClarifyRequest,\n-  normalizeChoices,\n-  normalizeQuestions,\n-  setClarifyRequest\n-} from '@/store/clarify'\n+import { $clarifyRequests, type ClarifyRequest, clearClarifyRequest } from '@/store/clarify'\n import type { SessionResumeResponse } from '@/types/hermes'\n \n export interface PendingClarifyResumeState {","@@ -16,28 +9,26 @@ export interface PendingClarifyResumeState {\n }\n \n /**\n- * Restore a pending clarify from a resume/activate snapshot onto `sessionId`.\n+ * Reconcile the parked clarify for `sessionId` against a resume/activate\n+ * snapshot.\n  *\n- * The snapshot mirrors the live clarify.request wire shape: single-question\n- * payloads carry `question`/`choices`/`multi_select`; batch (multi-question)\n- * ones carry `questions` (+ any answers already locked server-side) and no\n- * top-level `question`. Multi-select locks arrive as JSON-encoded arrays\n- * inside a string — never a bare array — so `lockedAnswers` keeps string\n- * values only.\n- *\n- * A missing snapshot is authoritative only for requests that already existed\n- * when the RPC began. A newer clarify.request that arrives while the response\n- * is in flight is left alone.\n+ * The snapshot's `open_requests` names every server→client request still\n+ * blocking the session. The shared channel has ALREADY re-delivered those to\n+ * the request handlers (which parked the clarify card) before the caller sees\n+ * the response, so this only has to (a) report the parked request when the\n+ * snapshot confirms it and (b) treat a snapshot WITHOUT a clarify as\n+ * authoritative for requests that already existed when the RPC began — a\n+ * newer request that arrived while the response was in flight is left alone.\n  */\n export function restorePendingClarifyFromSnapshot(\n-  response: Pick<SessionResumeResponse, 'pending_clarify'>,\n+  response: Pick<SessionResumeResponse, 'open_requests'>,\n   sessionId: string,\n   resumeStartedAt: number,\n   requestIdAtStart?: string\n ): PendingClarifyResumeState {\n-  const pending = response.pending_clarify\n+  const pending = (response.open_requests ?? []).find(entry => entry.method === 'clarify')\n \n-  if (!pending || typeof pending.request_id !== 'string') {\n+  if (!pending) {\n     const current = $clarifyRequests.get()[sessionId]\n \n     const existedAtStart = Boolean(current && requestIdAtStart && current.requestId === requestIdAtStart)","@@ -53,36 +44,12 @@ export function restorePendingClarifyFromSnapshot(\n     return { authoritativeAbsent: true, cleared: null, request: null }\n   }\n \n-  const questions = normalizeQuestions(pending.questions)\n-  const question = typeof pending.question === 'string' ? pending.question : ''\n-\n-  if (!question && questions.length === 0) {\n-    return { authoritativeAbsent: false, cleared: null, request: null }\n-  }\n-\n-  const choices = normalizeChoices(pending.choices)\n-\n-  const lockedAnswers =\n-    typeof pending.answers === 'object' && pending.answers !== null\n-      ? Object.fromEntries(\n-          Object.entries(pending.answers).filter((entry): entry is [string, string] => typeof entry[1] === 'string')\n-        )\n-      : undefined\n-\n-  const request: ClarifyRequest = {\n-    choices: choices.length > 0 ? choices : null,\n-    lockedAnswers,\n-    multiSelect: pending.multi_select === true,\n-    question,\n-    receivedAt: Date.now() / 1000,\n-    requestId: pending.request_id,\n-    sessionId,\n-    ...(questions.length > 0 ? { questions } : {})\n-  }\n-\n-  setClarifyRequest(request)\n+  // The request handler parked it under this session when the channel\n+  // re-delivered `open_requests`; a card the handler declined (empty\n+  // question) is simply not there.\n+  const parked = $clarifyRequests.get()[sessionId]\n \n-  return { authoritativeAbsent: false, cleared: null, request }\n+  return { authoritativeAbsent: false, cleared: null, request: parked?.requestId === pending.id ? parked : null }\n }\n \n export function pendingClarifyToolPayload(request: ClarifyRequest): GatewayEventPayload {"]},{"file":"apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx","hunks":["@@ -2,7 +2,7 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react'\n import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'\n import { atom } from 'nanostores'\n import type { ReactNode } from 'react'\n-import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n import { onComposerInsertRequest } from '@/app/chat/composer/focus'\n import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'","@@ -12,6 +12,7 @@ import { I18nProvider } from '@/i18n'\n import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'\n import { $gateway } from '@/store/gateway'\n import { $profiles } from '@/store/profile'\n+import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'\n import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'\n \n import { ClarifyTool, readClarifyBatchResult, readClarifyResult } from './clarify-tool'","@@ -38,9 +39,23 @@ vi.mock('@assistant-ui/react', () => ({\n   useAuiState: () => messageRunning\n }))\n \n+/** Seed a live `clarify` server request; the card answers it synchronously with\n+ *  `respondToServerRequest`, so the returned spy sees the response frame. */\n+function liveServerRequest(id: string) {\n+  const respond = vi.fn()\n+  rememberServerRequest({ fail: vi.fn(), id, method: 'clarify', params: {}, respond })\n+\n+  return respond\n+}\n+\n+beforeEach(() => {\n+  resetServerRequestsForTests()\n+})\n+\n afterEach(() => {\n   cleanup()\n   clearClarifyRequest()\n+  resetServerRequestsForTests()\n   $activeSessionId.set(null)\n   $gateway.set(null)\n   messageRunning = true","@@ -99,6 +114,7 @@ function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessageP\n \n function renderLiveClarify({ multiSelect = false }: { multiSelect?: boolean } = {}) {\n   const request = vi.fn().mockResolvedValue({ ok: true })\n+  const respond = liveServerRequest('request-1')\n \n   $activeSessionId.set('session-1')\n   $gateway.set({ request } as never)","@@ -111,7 +127,7 @@ function renderLiveClarify({ multiSelect = false }: { multiSelect?: boolean } =\n   })\n   const { rerender } = renderClarify(<ClarifyTool {...liveClarifyProps()} />)\n \n-  return { request, rerender }\n+  return { request, rerender, respond }\n }\n \n describe('ClarifyTool live card stays mounted across settle', () => {","@@ -135,13 +151,13 @@ describe('ClarifyTool live card stays mounted across settle', () => {\n   })\n \n   it('holds the card through the gap between answering and the settled result', async () => {\n-    const { request, rerender } = renderLiveClarify()\n+    const { rerender, respond } = renderLiveClarify()\n \n     fireEvent.click(screen.getByRole('button', { name: /staging/ }))\n     fireEvent.click(screen.getByRole('button', { name: /Continue/ }))\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalled()\n+      expect(respond).toHaveBeenCalled()\n     })\n \n     // tool.complete is what swaps in the settled card; the turn can already","@@ -177,7 +193,7 @@ describe('ClarifyTool live card stays mounted across settle', () => {\n \n describe('ClarifyTool choice selection', () => {\n   it('selects independently, deselects and submits multi-select choices as a JSON array', async () => {\n-    const { request } = renderLiveClarify({ multiSelect: true })\n+    const { respond } = renderLiveClarify({ multiSelect: true })\n     const staging = screen.getByRole('button', { name: /staging/ })\n     const production = screen.getByRole('button', { name: /production/ })\n ","@@ -197,15 +213,12 @@ describe('ClarifyTool choice selection', () => {\n     fireEvent.click(screen.getByRole('button', { name: /Continue/ }))\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('clarify.respond', {\n-        answer: JSON.stringify(['production', 'staging']),\n-        request_id: 'request-1'\n-      })\n+      expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production', 'staging']) })\n     })\n   })\n \n   it('keeps single-select replacement and plain-string submission', async () => {\n-    const { request } = renderLiveClarify()\n+    const { respond } = renderLiveClarify()\n     const staging = screen.getByRole('button', { name: /staging/ })\n     const production = screen.getByRole('button', { name: /production/ })\n ","@@ -218,11 +231,9 @@ describe('ClarifyTool choice selection', () => {\n     fireEvent.click(screen.getByRole('button', { name: /Continue/ }))\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('clarify.respond', {\n-        answer: 'production',\n-        request_id: 'request-1'\n-      })\n+      expect(respond).toHaveBeenCalledWith({ answer: 'production' })\n     })\n+    expect(hasOpenServerRequest('request-1')).toBe(false)\n   })\n })\n ","@@ -397,36 +408,30 @@ describe('ClarifyTool keyboard navigation', () => {\n   })\n \n   it('selects by number and confirms the answer with Enter', async () => {\n-    const { request } = renderLiveClarify()\n+    const { respond } = renderLiveClarify()\n \n     fireEvent.keyDown(window, { key: '2' })\n     fireEvent.keyDown(window, { key: 'Enter' })\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('clarify.respond', {\n-        answer: 'production',\n-        request_id: 'request-1'\n-      })\n+      expect(respond).toHaveBeenCalledWith({ answer: 'production' })\n     })\n   })\n \n   it('stages a highlighted multi-select choice with Enter and submits it with Continue', async () => {\n-    const { request } = renderLiveClarify({ multiSelect: true })\n+    const { respond } = renderLiveClarify({ multiSelect: true })\n     const production = screen.getByRole('button', { name: /production/ })\n \n     fireEvent.keyDown(window, { key: 'ArrowDown' })\n     fireEvent.keyDown(window, { key: 'Enter' })\n \n     expect(production.getAttribute('aria-pressed')).toBe('true')\n-    expect(request).not.toHaveBeenCalled()\n+    expect(respond).not.toHaveBeenCalled()\n \n     fireEvent.click(screen.getByRole('button', { name: /Continue/ }))\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('clarify.respond', {\n-        answer: JSON.stringify(['production']),\n-        request_id: 'request-1'\n-      })\n+      expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production']) })\n     })\n   })\n ","@@ -445,23 +450,23 @@ describe('ClarifyTool keyboard navigation', () => {\n   })\n \n   it('does not intercept keyboard events while an action button has focus', () => {\n-    const { request } = renderLiveClarify()\n+    const { respond } = renderLiveClarify()\n     const skip = screen.getByRole('button', { name: 'Skip' })\n \n     skip.focus()\n \n     expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)\n     expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true)\n-    expect(request).not.toHaveBeenCalled()\n+    expect(respond).not.toHaveBeenCalled()\n   })\n })\n \n describe('ClarifyTool recommended option', () => {\n   it('dims the (Recommended) label and answers with the choice the backend sent', async () => {\n-    const request = vi.fn().mockResolvedValue({ ok: true })\n+    const respond = liveServerRequest('request-1')\n \n     $activeSessionId.set('session-1')\n-    $gateway.set({ request } as never)\n+    $gateway.set({ request: vi.fn() } as never)\n     setClarifyRequest({\n       choices: ['staging (Recommended)', 'production'],\n       multiSelect: false,","@@ -482,10 +487,7 @@ describe('ClarifyTool recommended option', () => {\n     // The decorated string goes back verbatim; the tool strips the label before\n     // the agent ever sees the answer.\n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('clarify.respond', {\n-        answer: 'staging (Recommended)',\n-        request_id: 'request-1'\n-      })\n+      expect(respond).toHaveBeenCalledWith({ answer: 'staging (Recommended)' })\n     })\n   })\n })","@@ -564,6 +566,7 @@ function liveBatchProps(): ToolCallMessagePartProps {\n \n function renderLiveBatch(lockedAnswers?: Record<string, string>, multiSelect = false) {\n   const request = vi.fn().mockResolvedValue({ ok: true, remaining: [] })\n+  const respond = liveServerRequest('request-batch')\n \n   $activeSessionId.set('session-1')\n   $gateway.set({ request } as never)","@@ -581,7 +584,7 @@ function renderLiveBatch(lockedAnswers?: Record<string, string>, multiSelect = f\n   })\n   renderClarify(<ClarifyTool {...liveBatchProps()} />)\n \n-  return request\n+  return { request, respond }\n }\n \n describe('readClarifyBatchResult', () => {","@@ -611,23 +614,20 @@ describe('readClarifyBatchResult', () => {\n describe('ClarifyTool submit shortcut', () => {\n   it('submits selected choices with Cmd/Ctrl+Enter without toggling the focused option', async () => {\n     for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) {\n-      const { request } = renderLiveClarify({ multiSelect: true })\n+      const { respond } = renderLiveClarify({ multiSelect: true })\n       const choice = screen.getByRole('button', { name: /staging/ })\n       fireEvent.click(choice)\n       choice.focus()\n       fireEvent.keyDown(choice, { key: 'Enter', ...modifier })\n-      await waitFor(() => expect(request).toHaveBeenCalledTimes(1))\n-      expect(request).toHaveBeenCalledWith('clarify.respond', {\n-        request_id: 'request-1',\n-        answer: '[\"staging\"]'\n-      })\n+      await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))\n+      expect(respond).toHaveBeenCalledWith({ answer: '[\"staging\"]' })\n       cleanup()\n     }\n   })\n \n   it('submits a complete batch from its text field while preserving incomplete and multiline input', async () => {\n     for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) {\n-      const request = renderLiveBatch()\n+      const { request } = renderLiveBatch()\n       const field = screen.getByPlaceholderText('Type your answer…')\n       fireEvent.change(field, { target: { value: 'packet' } })\n       field.focus()","@@ -639,7 +639,7 @@ describe('ClarifyTool submit shortcut', () => {\n       expect(request).not.toHaveBeenCalled()\n       fireEvent.keyDown(field, { key: 'Enter', ...modifier })\n       await waitFor(() => expect(request).toHaveBeenCalledTimes(2))\n-      expect(request).toHaveBeenNthCalledWith(2, 'clarify.respond', {\n+      expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', {\n         answer: 'packet',\n         question_id: 'q1',\n         request_id: 'request-batch'","@@ -659,7 +659,7 @@ describe('ClarifyTool batch card', () => {\n   })\n \n   it('stages locally and keeps the single confirm disabled until all answered', async () => {\n-    const request = renderLiveBatch()\n+    const { request } = renderLiveBatch()\n     const confirm = screen.getByRole('button', { name: /Confirm and continue/ })\n \n     expect((confirm as HTMLButtonElement).disabled).toBe(true)","@@ -676,8 +676,8 @@ describe('ClarifyTool batch card', () => {\n     expect((confirm as HTMLButtonElement).disabled).toBe(false)\n   })\n \n-  it('confirm sends every per-question lock in order and completes the batch', async () => {\n-    const request = renderLiveBatch()\n+  it('confirm sends every per-question clarify.lock in order and completes the batch', async () => {\n+    const { request } = renderLiveBatch()\n \n     fireEvent.click(screen.getByRole('button', { name: /red/ }))\n     fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } })","@@ -686,20 +686,22 @@ describe('ClarifyTool batch card', () => {\n     await waitFor(() => {\n       expect(request).toHaveBeenCalledTimes(2)\n     })\n-    expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', {\n+    expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', {\n       answer: 'red',\n       question_id: 'q0',\n       request_id: 'request-batch'\n     })\n-    expect(request).toHaveBeenNthCalledWith(2, 'clarify.respond', {\n+    expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', {\n       answer: 'packet',\n       question_id: 'q1',\n       request_id: 'request-batch'\n     })\n+    // The last lock resolves the server request; the card forgets it locally.\n+    await waitFor(() => expect(hasOpenServerRequest('request-batch')).toBe(false))\n   })\n \n   it('a staged answer stays editable before confirm', async () => {\n-    const request = renderLiveBatch()\n+    const { request } = renderLiveBatch()\n \n     fireEvent.click(screen.getByRole('button', { name: /red/ }))\n     fireEvent.click(screen.getByRole('button', { name: /blue/ }))","@@ -710,7 +712,7 @@ describe('ClarifyTool batch card', () => {\n       expect(request).toHaveBeenCalledTimes(2)\n     })\n     // The re-pick won: blue, not red.\n-    expect(request).toHaveBeenNthCalledWith(1, 'clarify.respond', {\n+    expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', {\n       answer: 'blue',\n       question_id: 'q0',\n       request_id: 'request-batch'","@@ -732,17 +734,15 @@ describe('ClarifyTool batch card', () => {\n     expect(screen.getByText('1 of 2 answered')).toBeTruthy()\n   })\n \n-  it('Skip cancels the whole batch without a question_id', async () => {\n-    const request = renderLiveBatch()\n+  it('Skip cancels the whole batch with an empty response (no answers)', async () => {\n+    const { request, respond } = renderLiveBatch()\n \n     fireEvent.click(screen.getByRole('button', { name: 'Skip' }))\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('clarify.respond', {\n-        answer: '',\n-        request_id: 'request-batch'\n-      })\n+      expect(respond).toHaveBeenCalledWith({})\n     })\n+    expect(request).not.toHaveBeenCalled()\n   })\n \n   it('renders the settled batch with all questions and answers', () => {","@@ -773,7 +773,10 @@ describe('ClarifyTool batch card', () => {\n // foreground focus, so after a profile / Bot Chat switch it can be profile B\n // while the blocking clarify belongs to profile A — the response lands on a\n // backend that never held the request and the owner stays blocked until the\n-// tool times out. Every live clarify.respond now routes by request.sessionId.\n+// tool times out. A clarify answer is now the server request's own response\n+// frame — it rides the socket the request arrived on, so no routing decision is\n+// left to make. Only `clarify.lock` (a real client→server RPC) still dials a\n+// socket, and it must route by request.sessionId.\n \n const OWNER_CONNECTION_ID = 'conn-profile-a'\n const OWNER_PROFILE = 'profile-a'","@@ -794,12 +797,12 @@ function armCrossProfileOwner() {\n   return ambient\n }\n \n-function expectOwnerCall(nth: number, params: Record<string, unknown>) {\n+function expectOwnerLock(nth: number, params: Record<string, unknown>) {\n   expect(gatewayMocks.requestGatewayForAgent).toHaveBeenNthCalledWith(\n     nth,\n     OWNER_CONNECTION_ID,\n     OWNER_PROFILE,\n-    'clarify.respond',\n+    'clarify.lock',\n     params\n   )\n }","@@ -811,8 +814,9 @@ describe('ClarifyTool owner routing', () => {\n     gatewayMocks.requestGatewayForAgent.mockClear()\n   })\n \n-  it('answers a single clarify on the owner socket, never profile B ambient', async () => {\n+  it('answers a single clarify through its server request, never profile B ambient', async () => {\n     const ambient = armCrossProfileOwner()\n+    const respond = liveServerRequest('request-1')\n \n     setClarifyRequest({\n       choices: ['staging', 'production'],","@@ -827,14 +831,15 @@ describe('ClarifyTool owner routing', () => {\n     fireEvent.click(screen.getByRole('button', { name: /Continue/ }))\n \n     await waitFor(() => {\n-      expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)\n+      expect(respond).toHaveBeenCalledWith({ answer: 'staging' })\n     })\n-    expectOwnerCall(1, { answer: 'staging', request_id: 'request-1' })\n+    expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled()\n     expect(ambient).not.toHaveBeenCalled()\n   })\n \n   it('sends both sequential batch locks on the owner socket, in order', async () => {\n     const ambient = armCrossProfileOwner()\n+    liveServerRequest('request-batch')\n \n     setClarifyRequest({\n       choices: null,","@@ -857,13 +862,14 @@ describe('ClarifyTool owner routing', () => {\n       expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(2)\n     })\n     // The LAST lock resolves the blocked tool, so order is load-bearing.\n-    expectOwnerCall(1, { answer: 'red', question_id: 'q0', request_id: 'request-batch' })\n-    expectOwnerCall(2, { answer: 'packet', question_id: 'q1', request_id: 'request-batch' })\n+    expectOwnerLock(1, { answer: 'red', question_id: 'q0', request_id: 'request-batch' })\n+    expectOwnerLock(2, { answer: 'packet', question_id: 'q1', request_id: 'request-batch' })\n     expect(ambient).not.toHaveBeenCalled()\n   })\n \n-  it('sends a batch skip/cancel on the owner socket', async () => {\n+  it('answers a batch skip/cancel through its server request, never profile B ambient', async () => {\n     const ambient = armCrossProfileOwner()\n+    const respond = liveServerRequest('request-batch')\n \n     setClarifyRequest({\n       choices: null,","@@ -881,9 +887,9 @@ describe('ClarifyTool owner routing', () => {\n     fireEvent.click(screen.getByRole('button', { name: 'Skip' }))\n \n     await waitFor(() => {\n-      expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)\n+      expect(respond).toHaveBeenCalledWith({})\n     })\n-    expectOwnerCall(1, { answer: '', request_id: 'request-batch' })\n+    expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled()\n     expect(ambient).not.toHaveBeenCalled()\n   })\n })","@@ -915,7 +921,11 @@ describe('ClarifyTool visible-card scoping', () => {\n     return { ...liveClarifyProps(), args, argsText: JSON.stringify(args), toolCallId }\n   }\n \n+  /** Park a clarify on `sessionId` with its own live server request; the\n+   *  returned spy sees that request's (and only that request's) answer. */\n   function parkClarify(requestId: string, sessionId: string) {\n+    const respond = liveServerRequest(requestId)\n+\n     setClarifyRequest({\n       choices: ['staging', 'production'],\n       multiSelect: false,","@@ -923,6 +933,8 @@ describe('ClarifyTool visible-card scoping', () => {\n       requestId,\n       sessionId\n     })\n+\n+    return respond\n   }\n \n   /** A card inside an inactive tab layer — mounted and live, just not on screen. */","@@ -937,11 +949,9 @@ describe('ClarifyTool visible-card scoping', () => {\n   }\n \n   it('answers the visible card, not a background one that mounted first', async () => {\n-    const request = vi.fn().mockResolvedValue({ ok: true })\n-\n-    $gateway.set({ request } as never)\n-    parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)\n-    parkClarify(FOREGROUND_REQUEST, FOREGROUND_SESSION)\n+    $gateway.set({ request: vi.fn() } as never)\n+    const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)\n+    const foreground = parkClarify(FOREGROUND_REQUEST, FOREGROUND_SESSION)\n \n     // The background card is rendered FIRST, so its window listener registers\n     // first. Registration order used to decide the winner, which meant the card","@@ -958,29 +968,25 @@ describe('ClarifyTool visible-card scoping', () => {\n     fireEvent.keyDown(window, { key: 'Enter' })\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledTimes(1)\n+      expect(foreground).toHaveBeenCalledTimes(1)\n     })\n \n-    // Exactly one answer, carrying the FOREGROUND request id — the background\n-    // session's turn must not be resumed by a keystroke aimed at this one.\n-    expect(request).toHaveBeenCalledWith('clarify.respond', {\n-      answer: 'staging',\n-      request_id: FOREGROUND_REQUEST\n-    })\n+    // Exactly one answer, on the FOREGROUND request — the background session's\n+    // turn must not be resumed by a keystroke aimed at this one.\n+    expect(foreground).toHaveBeenCalledWith({ answer: 'staging' })\n+    expect(background).not.toHaveBeenCalled()\n   })\n \n   it('leaves the key alone when the only pending card is hidden', () => {\n-    const request = vi.fn().mockResolvedValue({ ok: true })\n-\n-    $gateway.set({ request } as never)\n-    parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)\n+    $gateway.set({ request: vi.fn() } as never)\n+    const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION)\n \n     renderClarify(backgroundCard())\n \n     // Untouched (no preventDefault) ⇒ the keystroke stays available to the\n     // composer, matching what `clarifyCardOwnsKey` reports with no visible card.\n     expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true)\n-    expect(request).not.toHaveBeenCalled()\n+    expect(background).not.toHaveBeenCalled()\n   })\n \n   /** A card in its own split zone — unlike `backgroundCard` this one IS on","@@ -998,11 +1004,9 @@ describe('ClarifyTool visible-card scoping', () => {\n \n   /** Both zones visible, zone-a first in document order. */\n   function renderSplit() {\n-    const request = vi.fn().mockResolvedValue({ ok: true })\n-\n-    $gateway.set({ request } as never)\n-    parkClarify(ZONE_A_REQUEST, ZONE_A_SESSION)\n-    parkClarify(ZONE_B_REQUEST, ZONE_B_SESSION)\n+    $gateway.set({ request: vi.fn() } as never)\n+    const zoneA = parkClarify(ZONE_A_REQUEST, ZONE_A_SESSION)\n+    const zoneB = parkClarify(ZONE_B_REQUEST, ZONE_B_SESSION)\n \n     renderClarify(\n       <>","@@ -1011,42 +1015,38 @@ describe('ClarifyTool visible-card scoping', () => {\n       </>\n     )\n \n-    return request\n+    return { zoneA, zoneB }\n   }\n \n   it('answers the later-in-document card when its zone is the focused one', async () => {\n-    const request = renderSplit()\n+    const { zoneA, zoneB } = renderSplit()\n \n     $activeTreeGroup.set('zone-b')\n     fireEvent.keyDown(window, { key: 'Enter' })\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledTimes(1)\n+      expect(zoneB).toHaveBeenCalledTimes(1)\n     })\n \n     // Both cards are visible and both hold a live window listener, so this is\n     // the case document order gets wrong: it would answer zone-a's question.\n-    expect(request).toHaveBeenCalledWith('clarify.respond', {\n-      answer: 'staging',\n-      request_id: ZONE_B_REQUEST\n-    })\n+    expect(zoneB).toHaveBeenCalledWith({ answer: 'staging' })\n+    expect(zoneA).not.toHaveBeenCalled()\n   })\n \n   it('answers the other visible card once the focus moves to its zone', async () => {\n-    const request = renderSplit()\n+    const { zoneA, zoneB } = renderSplit()\n \n     $activeTreeGroup.set('zone-a')\n     fireEvent.keyDown(window, { key: 'Enter' })\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledTimes(1)\n+      expect(zoneA).toHaveBeenCalledTimes(1)\n     })\n \n     // The direct pin for \"the other visible card then cannot receive its\n     // shortcut\": neither zone may be permanently starved of its own keys.\n-    expect(request).toHaveBeenCalledWith('clarify.respond', {\n-      answer: 'staging',\n-      request_id: ZONE_A_REQUEST\n-    })\n+    expect(zoneA).toHaveBeenCalledWith({ answer: 'staging' })\n+    expect(zoneB).not.toHaveBeenCalled()\n   })\n })"]},{"file":"apps/desktop/src/components/assistant-ui/clarify-tool.tsx","hunks":["@@ -39,6 +39,7 @@ import {\n } from '@/store/clarify'\n import { $gateway } from '@/store/gateway'\n import { notifyError } from '@/store/notifications'\n+import { forgetServerRequest, respondToServerRequest } from '@/store/server-requests'\n import { requestForOwnedSession } from '@/store/session-states'\n \n import { handleClarifySubmitShortcut } from './clarify-submit-shortcut'","@@ -476,22 +477,9 @@ function ClarifyToolSinglePending({\n       setSubmitting(true)\n \n       try {\n-        // Route through the session's OWNER (tile route → hint → tagged row);\n-        // legacy ambient is allowed only when it is provably the sole backend.\n-        // The ambient socket follows foreground focus, so after a profile / Bot\n-        // Chat switch it can point at a backend that never held this clarify —\n-        // and the owner stays blocked (#91684 client half, like approval.respond).\n-        await requestForOwnedSession<{ ok?: boolean }>(\n-          matchingRequest.sessionId,\n-          // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg\n-          // call shape gateway.request callers assert on.\n-          gateway.request.bind(gateway) as typeof gateway.request,\n-          'clarify.respond',\n-          {\n-            request_id: matchingRequest.requestId,\n-            answer\n-          }\n-        )\n+        // The response frame goes back over the socket the request arrived on —\n+        // the owner backend by construction (#91684's class cannot recur).\n+        respondToServerRequest(matchingRequest.requestId, { answer })\n         triggerHaptic('submit')\n         onAnswered()\n         clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)","@@ -1036,21 +1024,18 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo\n     setSubmitting(true)\n \n     try {\n-      // Sequential, not Promise.all: the LAST lock resolves the blocked tool\n-      // server-side, so every earlier lock must already be accepted when it\n-      // lands — a reordered burst could complete the batch with a missing\n-      // answer.\n-      //\n-      // Each lock rides the session's OWNER socket, not the ambient one: a\n-      // profile / Bot Chat switch re-points ambient at a backend that never\n-      // held this batch, which would leave the owner blocked.\n+      // Sequential, not Promise.all: the LAST lock resolves the blocked\n+      // server request, so every earlier lock must already be accepted when\n+      // it lands — a reordered burst could complete the batch with a missing\n+      // answer. `clarify.lock` is a normal RPC; it rides the session's OWNER\n+      // socket (a profile / Bot Chat switch re-points ambient elsewhere).\n       for (const question of questions) {\n         const answer = stagedAnswer(question)\n \n-        await requestForOwnedSession<{ ok?: boolean }>(\n+        await requestForOwnedSession<{ remaining?: string[]; status?: string }>(\n           request.sessionId,\n           gateway.request.bind(gateway) as typeof gateway.request,\n-          'clarify.respond',\n+          'clarify.lock',\n           {\n             answer: answer ?? '',\n             question_id: question.qid,","@@ -1059,6 +1044,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo\n         )\n       }\n \n+      forgetServerRequest(request.requestId)\n+\n       triggerHaptic('submit')\n       onAnswered()\n       // tool.complete lands next → ClarifyToolBatchSettled.","@@ -1095,20 +1082,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo\n     onAnswered()\n     clearClarifyRequest(request.requestId, request.sessionId)\n \n-    try {\n-      if (gateway) {\n-        // Owner-routed like the locks above — a skip sent to the wrong backend\n-        // is a silent no-op that leaves the agent waiting out its timeout.\n-        await requestForOwnedSession(\n-          request.sessionId,\n-          gateway.request.bind(gateway) as typeof gateway.request,\n-          'clarify.respond',\n-          { answer: '', request_id: request.requestId }\n-        )\n-      }\n-    } catch {\n-      // The tool times out on its own; a failed skip must never block the UI.\n-    }\n+    // A response with no `answers` is the cancel-all (the plain Esc path).\n+    respondToServerRequest(request.requestId, {})\n   }, [gateway, onAnswered, request])\n \n   const handleSubmit = useCallback("]},{"file":"apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx","hunks":["@@ -29,6 +29,7 @@ import { cn } from '@/lib/utils'\n import { $gateway } from '@/store/gateway'\n import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup'\n import { notifyError } from '@/store/notifications'\n+import { respondToServerRequest } from '@/store/server-requests'\n import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp'\n \n import { selectMessageRunning } from './tool/fallback-model'","@@ -229,15 +230,8 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {\n         invalidateMcpSuggestionIndex()\n       }\n \n-      try {\n-        await gateway.request<{ status?: string }>('mcp.setup.respond', {\n-          request_id: request.requestId,\n-          result: JSON.stringify(outcome)\n-        })\n-        // tool.complete lands next → McpSetupSettled.\n-      } catch (error) {\n-        notifyError(error, copy.sendFailed)\n-      }\n+      respondToServerRequest(request.requestId, { value: JSON.stringify(outcome) })\n+      // tool.complete lands next → McpSetupSettled.\n     },\n     [copy.gatewayDisconnected, copy.reloadFailed, copy.sendFailed, gateway, request]\n   )"]},{"file":"apps/desktop/src/components/assistant-ui/tool/approval.test.tsx","hunks":["@@ -1,9 +1,10 @@\n import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'\n-import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'\n+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'\n \n import type { HermesGateway } from '@/hermes'\n import { $gateway } from '@/store/gateway'\n import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts'\n+import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'\n import { $activeSessionId } from '@/store/session'\n \n import { PendingApprovalFallback, PendingToolApproval } from './approval'","@@ -33,12 +34,20 @@ function part(toolName: string): ToolPart {\n function setRequest(\n   command = 'rm -rf /tmp/x',\n   allowPermanent?: boolean,\n-  extra: { choices?: string[]; smartDenied?: boolean } = {}\n+  extra: { choices?: string[]; requestId?: string; serverRequestId?: string; smartDenied?: boolean } = {}\n ) {\n   $activeSessionId.set('sess-1')\n   setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1', ...extra })\n }\n \n+/** A live `approval` server request the card answers synchronously. */\n+function liveApproval(id = 'srq-approval') {\n+  const respond = vi.fn()\n+  rememberServerRequest({ fail: vi.fn(), id, method: 'approval', params: {}, respond })\n+\n+  return respond\n+}\n+\n function mockGateway() {\n   const request = vi.fn().mockResolvedValue({ resolved: true })\n   $gateway.set({ request } as unknown as HermesGateway)","@@ -46,9 +55,14 @@ function mockGateway() {\n   return request\n }\n \n+beforeEach(() => {\n+  resetServerRequestsForTests()\n+})\n+\n afterEach(() => {\n   cleanup()\n   clearAllPrompts()\n+  resetServerRequestsForTests()\n   $activeSessionId.set(null)\n   $gateway.set(null)\n })","@@ -83,15 +97,37 @@ describe('PendingToolApproval', () => {\n     expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy()\n   })\n \n-  it('sends approval.respond {choice: \"once\"} and clears the request on Run', async () => {\n+  it('answers the live approval request with {choice: \"once\"} and clears the request on Run', async () => {\n     const request = mockGateway()\n-    setRequest()\n+    const respond = liveApproval()\n+    setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1', serverRequestId: 'srq-approval' })\n+    render(<PendingToolApproval part={part('terminal')} />)\n+\n+    fireEvent.click(screen.getByRole('button', { name: /Run/ }))\n+\n+    await waitFor(() => {\n+      expect(respond).toHaveBeenCalledWith({ choice: 'once' })\n+    })\n+    expect(hasOpenServerRequest('srq-approval')).toBe(false)\n+    expect(request).not.toHaveBeenCalledWith('approval.respond', expect.anything())\n+    expect($approvalRequest.get()).toBeNull()\n+  })\n+\n+  it('falls back to the approval.respond RPC when no live server request is registered', async () => {\n+    // A prompt restored from `approval.pending` (no socket carried the frame):\n+    // the queue-level RPC is the only way to answer it.\n+    const request = mockGateway()\n+    setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1' })\n     render(<PendingToolApproval part={part('terminal')} />)\n \n     fireEvent.click(screen.getByRole('button', { name: /Run/ }))\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' })\n+      expect(request).toHaveBeenCalledWith('approval.respond', {\n+        choice: 'once',\n+        request_id: 'apr-1',\n+        session_id: 'sess-1'\n+      })\n     })\n     expect($approvalRequest.get()).toBeNull()\n   })","@@ -109,15 +145,16 @@ describe('PendingToolApproval', () => {\n     expect(screen.getByText(longCommand)).toBeTruthy()\n   })\n \n-  it('sends choice \"deny\" on Reject', async () => {\n-    const request = mockGateway()\n-    setRequest()\n+  it('answers the live approval request with {choice: \"deny\"} on Reject', async () => {\n+    mockGateway()\n+    const respond = liveApproval()\n+    setRequest('rm -rf /tmp/x', undefined, { requestId: 'apr-1', serverRequestId: 'srq-approval' })\n     render(<PendingToolApproval part={part('terminal')} />)\n \n     fireEvent.click(screen.getByRole('button', { name: /Reject/ }))\n \n     await waitFor(() => {\n-      expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })\n+      expect(respond).toHaveBeenCalledWith({ choice: 'deny' })\n     })\n   })\n "]},{"file":"apps/desktop/src/components/assistant-ui/tool/approval.tsx","hunks":["@@ -21,6 +21,7 @@ import { isSubmitEnter } from '@/lib/ime'\n import { cn } from '@/lib/utils'\n import { $gateway } from '@/store/gateway'\n import { notifyError } from '@/store/notifications'\n+import { answerApproval } from '@/store/prompts'\n import {\n   type ApprovalRequest,\n   clearApprovalRequest,","@@ -29,7 +30,6 @@ import {\n   sessionApprovalInlineVisible,\n   sessionApprovalRequest\n } from '@/store/prompts'\n-import { requestForOwnedSession } from '@/store/session-states'\n \n import type { ToolPart } from './fallback-model'\n ","@@ -145,22 +145,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'\n       setSubmitting(choice)\n \n       try {\n-        // Route through the session's OWNER (tile route → known profile);\n-        // ambient only when no owner is known. The ambient socket follows\n-        // foreground focus, and for a cross-profile session it points at a\n-        // backend that never held this approval (#91684 client half).\n-        await requestForOwnedSession<{ resolved?: boolean }>(\n-          request.sessionId,\n-          // Bound (not wrapped) so the ambient fallback keeps the exact\n-          // 2-arg call shape gateway.request callers assert on.\n-          gateway.request.bind(gateway) as typeof gateway.request,\n-          'approval.respond',\n-          {\n-            choice,\n-            request_id: request.requestId,\n-            session_id: request.sessionId ?? undefined\n-          }\n-        )\n+        // Live prompt: the response frame rides the socket the request came on\n+        // (the owner backend by construction). Restored prompt: queue-level\n+        // `approval.respond`, owner-routed (#91684 client half).\n+        await answerApproval(gateway, request, choice)\n         triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')\n         clearApprovalRequest(request.sessionId, request.requestId)\n         void replayPendingApproval(gateway, request.sessionId).catch(() => undefined)","@@ -169,7 +157,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'\n         setSubmitting(null)\n       }\n     },\n-    [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request.requestId, request.sessionId]\n+    [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request]\n   )\n \n   // ⌘/Ctrl+Enter → Run, Esc → Reject."]},{"file":"apps/desktop/src/components/prompt-overlays.test.tsx","hunks":["@@ -1,10 +1,11 @@\n import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'\n-import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n import { I18nProvider } from '@/i18n'\n import { $gateway } from '@/store/gateway'\n import { notifyError } from '@/store/notifications'\n import { $secretRequest, $sudoRequest, clearAllPrompts, setSecretRequest, setSudoRequest } from '@/store/prompts'\n+import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'\n import { $activeSessionId } from '@/store/session'\n \n import { PromptOverlays } from './prompt-overlays'","@@ -20,20 +21,48 @@ function renderPrompts(sessionId: string | null = 's1') {\n   )\n }\n \n+beforeEach(() => {\n+  resetServerRequestsForTests()\n+})\n+\n afterEach(() => {\n   cleanup()\n   clearAllPrompts()\n+  resetServerRequestsForTests()\n   $activeSessionId.set(null)\n   $gateway.set(null)\n   vi.clearAllMocks()\n })\n \n describe('PromptOverlays', () => {\n-  it('dismisses a stale sudo dialog when the gateway no longer has the password request', async () => {\n-    const request = vi.fn().mockRejectedValue(new Error('no pending password request'))\n+  it('answers the live sudo request with an empty value on Cancel and clears the dialog', async () => {\n+    const respond = vi.fn()\n+    const request = vi.fn()\n+\n+    $activeSessionId.set('s1')\n+    $gateway.set({ request } as never)\n+    rememberServerRequest({ fail: vi.fn(), id: 'sudo-1', method: 'sudo', params: {}, respond })\n+    setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' })\n+\n+    renderPrompts()\n+\n+    expect(screen.getByText('Administrator password')).toBeTruthy()\n+\n+    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))\n+\n+    await waitFor(() => expect($sudoRequest.get()).toBeNull())\n+    expect(respond).toHaveBeenCalledWith({ value: '' })\n+    expect(hasOpenServerRequest('sudo-1')).toBe(false)\n+    expect(request).not.toHaveBeenCalled()\n+    expect(notifyError).not.toHaveBeenCalled()\n+  })\n+\n+  it('dismisses a stale sudo dialog when the gateway no longer has the request open', async () => {\n+    const request = vi.fn()\n \n     $activeSessionId.set('s1')\n     $gateway.set({ request } as never)\n+    // No server request registered under this id: it expired / was cancelled.\n     setSudoRequest({ requestId: 'sudo-1', sessionId: 's1' })\n \n     renderPrompts()","@@ -43,12 +72,34 @@ describe('PromptOverlays', () => {\n     fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))\n \n     await waitFor(() => expect($sudoRequest.get()).toBeNull())\n-    expect(request).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })\n+    expect(request).not.toHaveBeenCalled()\n+    expect(notifyError).not.toHaveBeenCalled()\n+  })\n+\n+  it('answers the live secret request with an empty value on Cancel and clears the dialog', async () => {\n+    const respond = vi.fn()\n+    const request = vi.fn()\n+\n+    $activeSessionId.set('s1')\n+    $gateway.set({ request } as never)\n+    rememberServerRequest({ fail: vi.fn(), id: 'secret-1', method: 'secret', params: {}, respond })\n+    setSecretRequest({ envVar: 'TEST_SECRET', prompt: 'Paste a secret', requestId: 'secret-1', sessionId: 's1' })\n+\n+    renderPrompts()\n+\n+    expect(screen.getByText('TEST_SECRET')).toBeTruthy()\n+\n+    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))\n+\n+    await waitFor(() => expect($secretRequest.get()).toBeNull())\n+    expect(respond).toHaveBeenCalledWith({ value: '' })\n+    expect(hasOpenServerRequest('secret-1')).toBe(false)\n+    expect(request).not.toHaveBeenCalled()\n     expect(notifyError).not.toHaveBeenCalled()\n   })\n \n-  it('dismisses a stale secret dialog when the gateway no longer has the value request', async () => {\n-    const request = vi.fn().mockRejectedValue(new Error('no pending value request'))\n+  it('dismisses a stale secret dialog when the gateway no longer has the request open', async () => {\n+    const request = vi.fn()\n \n     $activeSessionId.set('s1')\n     $gateway.set({ request } as never)","@@ -61,7 +112,7 @@ describe('PromptOverlays', () => {\n     fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))\n \n     await waitFor(() => expect($secretRequest.get()).toBeNull())\n-    expect(request).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })\n+    expect(request).not.toHaveBeenCalled()\n     expect(notifyError).not.toHaveBeenCalled()\n   })\n })"]},{"file":"apps/desktop/src/components/prompt-overlays.tsx","hunks":["@@ -33,8 +33,7 @@ import {\n   sessionVaultSaveLoginRequest,\n   sessionVaultUnlockRequest\n } from '@/store/prompts'\n-import { ambientRequestFor } from '@/store/session-gone-latch'\n-import { requestForOwnedSession } from '@/store/session-states'\n+import { respondToServerRequest } from '@/store/server-requests'\n \n // Renders the modal mid-turn prompts the gateway raises and waits on: sudo\n // password and skill secret capture. Dangerous-command / execute_code approval","@@ -78,10 +77,7 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) {\n       setSubmitting(true)\n \n       try {\n-        await gateway.request<{ status?: string }>('sudo.respond', {\n-          password: [redacted:secret-assignment]\n-          request_id: request.requestId\n-        })\n+        respondToServerRequest(request.requestId, { value })\n         triggerHaptic('submit')\n         clearSudoRequest(request.sessionId, request.requestId)\n       } catch (error) {","@@ -181,10 +177,7 @@ function SecretDialog({ sessionId }: { sessionId: string | null }) {\n       setSubmitting(true)\n \n       try {\n-        await gateway.request<{ status?: string }>('secret.respond', {\n-          request_id: request.requestId,\n-          value: secret\n-        })\n+        respondToServerRequest(request.requestId, { value: secret })\n         triggerHaptic('submit')\n         clearSecretRequest(request.sessionId, request.requestId)\n       } catch (error) {","@@ -285,14 +278,9 @@ function VaultUnlockDialog({ sessionId }: { sessionId: string | null }) {\n       setSubmitting(true)\n \n       try {\n-        // A master password must reach the backend that raised the prompt, not whatever\n-        // gateway is foreground right now (background profile tiles have their own socket).\n-        await requestForOwnedSession<{ status?: string }>(\n-          request.sessionId,\n-          ambientRequestFor(gateway),\n-          'vault.unlock.respond',\n-          { request_id: request.requestId, password }\n-        )\n+        // The response frame goes back over the socket the request arrived on — the\n+        // backend that raised the prompt, never whatever gateway is foreground.\n+        respondToServerRequest(request.requestId, { value: password })\n         triggerHaptic('submit')\n         clearVaultUnlockRequest(request.sessionId, request.requestId)\n       } catch (error) {","@@ -387,12 +375,7 @@ function VaultSaveLoginDialog({ sessionId }: { sessionId: string | null }) {\n       setSubmitting(true)\n \n       try {\n-        await requestForOwnedSession<{ status?: string }>(\n-          request.sessionId,\n-          ambientRequestFor(gateway),\n-          'vault.save_login.respond',\n-          { login, request_id: request.requestId }\n-        )\n+        respondToServerRequest(request.requestId, { value: login })\n         triggerHaptic('submit')\n         clearVaultSaveLoginRequest(request.sessionId, request.requestId)\n       } catch (error) {","@@ -504,12 +487,7 @@ function VaultCodeDialog({ sessionId }: { sessionId: string | null }) {\n       setSubmitting(true)\n \n       try {\n-        await requestForOwnedSession<{ status?: string }>(\n-          request.sessionId,\n-          ambientRequestFor(gateway),\n-          'vault.code.respond',\n-          { code: value, request_id: request.requestId }\n-        )\n+        respondToServerRequest(request.requestId, { value })\n         triggerHaptic('submit')\n         clearVaultCodeRequest(request.sessionId, request.requestId)\n       } catch (error) {"]},{"file":"apps/desktop/src/components/prompt-overlays.vault-code.test.tsx","hunks":["@@ -1,16 +1,8 @@\n import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'\n-import { afterEach, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, expect, it, vi } from 'vitest'\n \n import { stubResizeObserver } from '@/test/jsdom'\n \n-const gatewayMocks = vi.hoisted(() => ({\n-  requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' }))\n-}))\n-\n-vi.mock('@/store/gateway', async importActual => ({\n-  ...(await importActual<Record<string, unknown>>()),\n-  requestGatewayForAgent: gatewayMocks.requestGatewayForAgent\n-}))\n vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))\n vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))\n ","@@ -18,26 +10,35 @@ import { PromptOverlays } from '@/components/prompt-overlays'\n import { $gateway } from '@/store/gateway'\n import { $profiles } from '@/store/profile'\n import { clearAllPrompts, sessionVaultCodeRequest, setVaultCodeRequest } from '@/store/prompts'\n+import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'\n import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'\n \n stubResizeObserver()\n \n+beforeEach(() => {\n+  resetServerRequestsForTests()\n+})\n+\n afterEach(() => {\n   cleanup()\n   clearAllPrompts()\n+  resetServerRequestsForTests()\n   _resetSessionOwnerHintsForTests()\n   $gateway.set(null)\n   vi.clearAllMocks()\n })\n \n-// The 2FA code goes to the OWNING profile socket as vault.code.respond, whitespace/dashes stripped\n-// (users paste \"246 810\" from an SMS); Skip answers \"\".\n-it('sends the trimmed code to the owning profile socket', async () => {\n+// The 2FA code answers the `vault.code` server request (the frame rides the socket the\n+// request arrived on, never the ambient gateway), whitespace/dashes stripped (users paste\n+// \"246 810\" from an SMS); Skip answers \"\".\n+it('answers the vault.code server request with the trimmed code, never the ambient gateway', async () => {\n   $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never)\n   setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })\n   const ambient = vi.fn().mockResolvedValue({ status: 'ok' })\n+  const respond = vi.fn()\n   $activeSessionId.set('session-b')\n   $gateway.set({ request: ambient } as never)\n+  rememberServerRequest({ fail: vi.fn(), id: 'req-c', method: 'vault.code', params: {}, respond })\n   setVaultCodeRequest({ hint: '', requestId: 'req-c', sessionId: 'session-a', site: 'github.com' })\n \n   render(<PromptOverlays sessionId=\"session-a\" />)","@@ -49,13 +50,9 @@ it('sends the trimmed code to the owning profile socket', async () => {\n   expect(submit.disabled).toBe(false)\n   fireEvent.submit(input.closest('form')!)\n \n-  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))\n-  expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[]).slice(0, 4)).toEqual([\n-    'conn-1',\n-    'owner',\n-    'vault.code.respond',\n-    { code: '246810', request_id: 'req-c' }\n-  ])\n+  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))\n+  expect(respond).toHaveBeenCalledWith({ value: '246810' })\n+  expect(hasOpenServerRequest('req-c')).toBe(false)\n   expect(ambient).not.toHaveBeenCalled()\n   await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull())\n })","@@ -63,13 +60,15 @@ it('sends the trimmed code to the owning profile socket', async () => {\n it('Skip answers an empty code and clears the card', async () => {\n   $profiles.set([{ name: 'owner' }] as never)\n   setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })\n+  const respond = vi.fn()\n   $gateway.set({ request: vi.fn() } as never)\n+  rememberServerRequest({ fail: vi.fn(), id: 'req-d', method: 'vault.code', params: {}, respond })\n   setVaultCodeRequest({ hint: '', requestId: 'req-d', sessionId: 'session-a', site: 'github.com' })\n \n   render(<PromptOverlays sessionId=\"session-a\" />)\n   fireEvent.click(Array.from(document.querySelectorAll('button')).find(b => b.textContent === 'Skip')!)\n \n-  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))\n-  expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ code: '', request_id: 'req-d' })\n+  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))\n+  expect(respond).toHaveBeenCalledWith({ value: '' })\n   await waitFor(() => expect(sessionVaultCodeRequest('session-a').get()).toBeNull())\n })"]},{"file":"apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx","hunks":["@@ -1,16 +1,8 @@\n import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'\n-import { afterEach, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, expect, it, vi } from 'vitest'\n \n import { stubResizeObserver } from '@/test/jsdom'\n \n-const gatewayMocks = vi.hoisted(() => ({\n-  requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' }))\n-}))\n-\n-vi.mock('@/store/gateway', async importActual => ({\n-  ...(await importActual<Record<string, unknown>>()),\n-  requestGatewayForAgent: gatewayMocks.requestGatewayForAgent\n-}))\n vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))\n vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))\n ","@@ -18,26 +10,36 @@ import { PromptOverlays } from '@/components/prompt-overlays'\n import { $gateway } from '@/store/gateway'\n import { $profiles } from '@/store/profile'\n import { clearAllPrompts, sessionVaultSaveLoginRequest, setVaultSaveLoginRequest } from '@/store/prompts'\n+import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'\n import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'\n \n stubResizeObserver()\n \n+beforeEach(() => {\n+  resetServerRequestsForTests()\n+})\n+\n afterEach(() => {\n   cleanup()\n   clearAllPrompts()\n+  resetServerRequestsForTests()\n   _resetSessionOwnerHintsForTests()\n   $gateway.set(null)\n   vi.clearAllMocks()\n })\n \n-// The \"save this login\" card is the zero-setup path: the pair goes to the OWNING profile's socket as\n-// one JSON answer, the password field is masked, and Save is disabled until both fields are filled.\n-it('sends identifier + password as one vault.save_login.respond to the owning profile socket', async () => {\n+// The \"save this login\" card is the zero-setup path: the pair answers the `vault.save_login`\n+// server request as one JSON value (the frame rides the socket the request arrived on, never\n+// the ambient gateway), the password field is masked, and Save is disabled until both fields\n+// are filled.\n+it('answers the vault.save_login server request with identifier + password as one JSON value', async () => {\n   $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never)\n   setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })\n   const ambient = vi.fn().mockResolvedValue({ status: 'ok' })\n+  const respond = vi.fn()\n   $activeSessionId.set('session-b')\n   $gateway.set({ request: ambient } as never)\n+  rememberServerRequest({ fail: vi.fn(), id: 'req-s', method: 'vault.save_login', params: {}, respond })\n   setVaultSaveLoginRequest({\n     origin: 'https://github.com',\n     requestId: 'req-s',","@@ -57,13 +59,13 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr\n   expect(submit.disabled).toBe(false)\n   fireEvent.submit(password.closest('form')!)\n \n-  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))\n-  const [conn, profile, method, params] = gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[]\n-  expect([conn, profile, method]).toEqual(['conn-1', 'owner', 'vault.save_login.respond'])\n-  expect(JSON.parse((params as { login: string }).login)).toEqual({\n+  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))\n+  const [result] = respond.mock.calls[0] as [{ value: string }]\n+  expect(JSON.parse(result.value)).toEqual({\n     identifier: '[redacted:email]',\n     password: '[redacted:secret-assignment]'\n   })\n+  expect(hasOpenServerRequest('req-s')).toBe(false)\n   expect(ambient).not.toHaveBeenCalled()\n   await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull())\n })","@@ -71,7 +73,9 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr\n it(\"Don't save answers an empty login and clears the card\", async () => {\n   $profiles.set([{ name: 'owner' }] as never)\n   setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })\n+  const respond = vi.fn()\n   $gateway.set({ request: vi.fn() } as never)\n+  rememberServerRequest({ fail: vi.fn(), id: 'req-d', method: 'vault.save_login', params: {}, respond })\n   setVaultSaveLoginRequest({\n     origin: 'https://github.com',\n     requestId: 'req-d',","@@ -83,10 +87,7 @@ it(\"Don't save answers an empty login and clears the card\", async () => {\n   const decline = Array.from(document.querySelectorAll('button')).find(b => b.textContent === \"Don't save\")!\n   fireEvent.click(decline)\n \n-  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))\n-  expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({\n-    login: '',\n-    request_id: 'req-d'\n-  })\n+  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))\n+  expect(respond).toHaveBeenCalledWith({ value: '' })\n   await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull())\n })"]},{"file":"apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx","hunks":["@@ -1,16 +1,8 @@\n import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'\n-import { afterEach, expect, it, vi } from 'vitest'\n+import { afterEach, beforeEach, expect, it, vi } from 'vitest'\n \n import { stubResizeObserver } from '@/test/jsdom'\n \n-const gatewayMocks = vi.hoisted(() => ({\n-  requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' }))\n-}))\n-\n-vi.mock('@/store/gateway', async importActual => ({\n-  ...(await importActual<Record<string, unknown>>()),\n-  requestGatewayForAgent: gatewayMocks.requestGatewayForAgent\n-}))\n vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))\n vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))\n ","@@ -18,13 +10,19 @@ import { PromptOverlays } from '@/components/prompt-overlays'\n import { $gateway } from '@/store/gateway'\n import { $profiles } from '@/store/profile'\n import { clearAllPrompts, sessionVaultUnlockRequest, setVaultUnlockRequest } from '@/store/prompts'\n+import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'\n import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session'\n \n stubResizeObserver()\n \n+beforeEach(() => {\n+  resetServerRequestsForTests()\n+})\n+\n afterEach(() => {\n   cleanup()\n   clearAllPrompts()\n+  resetServerRequestsForTests()\n   _resetSessionOwnerHintsForTests()\n   $gateway.set(null)\n   vi.clearAllMocks()","@@ -33,12 +31,16 @@ afterEach(() => {\n // A master password typed into a background profile's unlock card must reach the\n // backend that raised the prompt. The window's ambient `$gateway` may be another\n // profile's socket entirely; sending the password there is a cross-backend leak.\n-it('routes the master password to the owning profile socket, never the ambient gateway', async () => {\n+// The answer is the `vault.unlock` server request's own response frame — it rides\n+// the socket the request arrived on, so the ambient gateway is never dialled.\n+it('answers the vault.unlock server request with the master password, never the ambient gateway', async () => {\n   $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never)\n   setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' })\n   const ambient = vi.fn().mockResolvedValue({ status: 'ok' })\n+  const respond = vi.fn()\n   $activeSessionId.set('session-b')\n   $gateway.set({ request: ambient } as never)\n+  rememberServerRequest({ fail: vi.fn(), id: 'req-a', method: 'vault.unlock', params: {}, respond })\n   setVaultUnlockRequest({ backend: 'bitwarden', displayName: 'Bitwarden', requestId: 'req-a', sessionId: 'session-a' })\n \n   render(<PromptOverlays sessionId=\"session-a\" />)","@@ -46,12 +48,9 @@ it('routes the master password to the owning profile socket, never the ambient g\n   fireEvent.change(input, { target: { value: 'fixture-master' } })\n   fireEvent.submit(input.closest('form')!)\n \n-  await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1))\n-  expect(gatewayMocks.requestGatewayForAgent.mock.calls[0].slice(0, 3)).toEqual([\n-    'conn-1',\n-    'owner',\n-    'vault.unlock.respond'\n-  ])\n+  await waitFor(() => expect(respond).toHaveBeenCalledTimes(1))\n+  expect(respond).toHaveBeenCalledWith({ value: 'fixture-master' })\n+  expect(hasOpenServerRequest('req-a')).toBe(false)\n   expect(ambient).not.toHaveBeenCalled()\n   await waitFor(() => expect(sessionVaultUnlockRequest('session-a').get()).toBeNull())\n })"]},{"file":"apps/desktop/src/lib/gateway-events.test.ts","hunks":["@@ -34,7 +34,9 @@ describe('gateway event routing', () => {\n     expect(gatewayEventRequiresSessionId('message.interim')).toBe(false)\n     expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)\n     expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)\n-    expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)\n+    // Prompts are server→client requests now; the one prompt-related EVENT\n+    // left (`request.cancel`) is likewise the focused turn's own when unscoped.\n+    expect(gatewayEventRequiresSessionId('request.cancel')).toBe(false)\n   })\n \n   it('allows global events to remain unscoped', () => {"]},{"file":"apps/desktop/src/plugins/hermes-bots/group-test-utils.ts","hunks":["@@ -85,6 +85,7 @@ export interface GatewayOptions {\n   /** Per profile: carry `pending_approval` on its first `until` resumes. */\n   approvalUntil?: Record<string, { payload: Record<string, unknown>; until: number }>\n   /** Per profile: carry `pending_clarify` on its first `until` resumes. */\n+  /** `payload` is an open-request frame `{ id, method: 'clarify', params }`. */\n   clarifyUntil?: Record<string, { payload: Record<string, unknown>; until: number }>\n   /** Land a competing writer's `ui_meta` under `key` during the FIRST\n    *  `profiles.configure`, then reject it as a CAS conflict — the race the","@@ -275,7 +276,7 @@ export function createGroupGateway(options: GatewayOptions = {}): ScriptedGatewa\n         running: false,\n         session_id: session.runtime,\n         session_key: session.stored,\n-        ...(clarify && seen <= clarify.until ? { pending_clarify: clarify.payload } : {}),\n+        ...(clarify && seen <= clarify.until ? { open_requests: [clarify.payload] } : {}),\n         ...(approval && seen <= approval.until ? { pending_approval: approval.payload } : {})\n       }\n     }"]},{"file":"apps/desktop/src/plugins/hermes-bots/group-turns.test.ts","hunks":["@@ -344,11 +344,12 @@ describe('reply selection (#94376)', () => {\n // command approval. Those live in a hidden session the room can't see, so the\n // poll mirrors them into the room and holds the turn open until they resolve.\n describe('clarify and approvals (#90694)', () => {\n+  /** The member's blocking clarify as `session.resume` now reports it: an\n+   *  open server→client request frame (`open_requests`). */\n   const CLARIFY = {\n-    choices: ['staging', 'prod'],\n-    multi_select: false,\n-    question: 'Which env should I target?',\n-    request_id: 'req-clarify-1'\n+    id: 'req-clarify-1',\n+    method: 'clarify',\n+    params: { choices: ['staging', 'prod'], multi_select: false, question: 'Which env should I target?' }\n   }\n \n   const APPROVAL = {","@@ -365,7 +366,7 @@ describe('clarify and approvals (#90694)', () => {\n     const room = await loadRoom({\n       clarifyUntil: { research: { payload: CLARIFY, until: 3 } },\n       // The mirror pass runs while the question is still blocking — this is\n-      // the observable proof the gate inspected pending_clarify. Asserting\n+      // the observable proof the gate inspected open_requests. Asserting\n       // on $groupNeedsYou/$groupClarify AFTER the turn lands proves nothing:\n       // the clarify has already resolved and its mirror is gone by then.\n       onResumePoll: () => {","@@ -401,7 +402,7 @@ describe('clarify and approvals (#90694)', () => {\n     const { chat, turns } = await loadRoom()\n     const member: GroupMember = { name: 'research', title: '' }\n \n-    expect(turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })).toBe(true)\n+    expect(turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })).toBe(true)\n \n     const mirrored = Object.values(chat.$groupClarify.get())\n ","@@ -414,7 +415,7 @@ describe('clarify and approvals (#90694)', () => {\n     expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(true)\n \n     // Same request again: no new entry, identity preserved.\n-    turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })\n+    turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })\n \n     expect(Object.values(chat.$groupClarify.get())[0]).toBe(mirrored[0])\n ","@@ -425,45 +426,50 @@ describe('clarify and approvals (#90694)', () => {\n     expect(turns.groupHasPendingClarify(chat.$groupClarify.get(), 'Core')).toBe(false)\n   })\n \n-  it('never mirrors a question for older backends without pending_clarify', async () => {\n+  it('never mirrors a question for older backends without open_requests', async () => {\n     const { chat, turns } = await loadRoom()\n \n     expect(turns.syncGroupClarify('Core', { name: 'research' }, { messages: [] })).toBe(false)\n     expect(Object.keys(chat.$groupClarify.get())).toHaveLength(0)\n   })\n \n-  it('routes an answer through clarify.respond and clears the mirror', async () => {\n+  it('answers the open request by id through request.answer and clears the mirror', async () => {\n     const room = await loadRoom()\n     const member: GroupMember = { name: 'research', title: '' }\n \n-    room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })\n+    room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })\n     await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, 'staging')\n \n-    expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([\n-      { answer: 'staging', request_id: 'req-clarify-1' }\n+    expect(room.gateway.rpcFor('request.answer').map(call => call.params)).toEqual([\n+      { id: 'req-clarify-1', result: { answer: 'staging' } }\n     ])\n     expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)\n   })\n \n-  it('sends one respond per batch question, in order', async () => {\n+  it('locks one batch question per clarify.lock call, in order', async () => {\n     const room = await loadRoom()\n     const member: GroupMember = { name: 'research', title: '' }\n \n     room.turns.syncGroupClarify('Core', member, {\n-      pending_clarify: {\n-        questions: [\n-          { choices: ['staging', 'prod'], qid: 'q0', question: 'Env?' },\n-          { choices: [], qid: 'q1', question: 'Region?' }\n-        ],\n-        request_id: 'req-batch-1'\n-      }\n+      open_requests: [\n+        {\n+          id: 'req-batch-1',\n+          method: 'clarify',\n+          params: {\n+            questions: [\n+              { choices: ['staging', 'prod'], qid: 'q0', question: 'Env?' },\n+              { choices: [], qid: 'q1', question: 'Region?' }\n+            ]\n+          }\n+        }\n+      ]\n     })\n     await room.turns.answerGroupClarify(Object.values(room.chat.$groupClarify.get())[0], member, {\n       q0: 'staging',\n       q1: 'eu-west'\n     })\n \n-    expect(room.gateway.rpcFor('clarify.respond').map(call => call.params)).toEqual([\n+    expect(room.gateway.rpcFor('clarify.lock').map(call => call.params)).toEqual([\n       { answer: 'staging', question_id: 'q0', request_id: 'req-batch-1' },\n       { answer: 'eu-west', question_id: 'q1', request_id: 'req-batch-1' }\n     ])","@@ -473,8 +479,8 @@ describe('clarify and approvals (#90694)', () => {\n   it('clears only the disbanded room’s mirrored questions', async () => {\n     const room = await loadRoom()\n \n-    room.turns.syncGroupClarify('Core', { name: 'research' }, { pending_clarify: CLARIFY })\n-    room.turns.syncGroupClarify('Other', { name: 'ops' }, { pending_clarify: { ...CLARIFY, request_id: 'req-2' } })\n+    room.turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY] })\n+    room.turns.syncGroupClarify('Other', { name: 'ops' }, { open_requests: [{ ...CLARIFY, id: 'req-2' }] })\n     room.turns.clearGroupClarify('Core')\n \n     const remaining = Object.values(room.chat.$groupClarify.get())","@@ -489,7 +495,7 @@ describe('clarify and approvals (#90694)', () => {\n   it('keeps pending prompts independent from mention attention through their lifecycle', async () => {\n     const { chat, turns } = await loadRoom()\n     const member = { name: 'research', title: '' }\n-    turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })\n+    turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })\n     expect(chat.$groupNeedsYou.get().Core).toBeFalsy()\n     turns.syncGroupClarify('Core', { name: 'ops' }, { pending_approval: APPROVAL })\n     expect(Object.values(chat.$groupClarify.get())).toHaveLength(2)","@@ -540,7 +546,7 @@ describe('clarify and approvals (#90694)', () => {\n             submitted = true\n           }\n \n-          if (method === 'clarify.respond') {\n+          if (method === 'request.answer') {\n             answered = true\n           }\n ","@@ -550,7 +556,7 @@ describe('clarify and approvals (#90694)', () => {\n               await held\n             }\n \n-            return { ...result, pending_clarify: CLARIFY }\n+            return { ...result, open_requests: [CLARIFY] }\n           }\n \n           return result","@@ -725,7 +731,7 @@ describe('clarify and approvals (#90694)', () => {\n \n       if (recreate) {\n         room.chat.updateGroupChat('Core', current => ({ ...current, roomId: 'replacement-room' }))\n-        room.turns.syncGroupClarify('Core', member, { pending_clarify: CLARIFY })\n+        room.turns.syncGroupClarify('Core', member, { open_requests: [CLARIFY] })\n       }\n \n       const roomsBefore = structuredClone(room.chat.$groupChats.get())","@@ -789,7 +795,7 @@ describe('clarify and approvals (#90694)', () => {\n         }\n       }\n \n-      return { running: true, pending_clarify: CLARIFY }\n+      return { running: true, open_requests: [CLARIFY] }\n     }\n \n     await room.rounds.runGroupChatRounds('Core', members, 'thread')","@@ -889,14 +895,14 @@ describe('clarify and approvals (#90694)', () => {\n     expect(room.gateway.rpcFor('approval.respond').map(call => call.params)).toEqual([\n       { choice: 'once', request_id: 'req-approval-1', session_id: 'rt-research-1' }\n     ])\n-    expect(room.gateway.rpcFor('clarify.respond')).toHaveLength(0)\n+    expect(room.gateway.rpcFor('request.answer')).toHaveLength(0)\n     expect(Object.keys(room.chat.$groupClarify.get())).toHaveLength(0)\n   })\n \n   it('lets clarify outrank approval when a snapshot carries both', async () => {\n     const { chat, turns } = await loadRoom()\n \n-    turns.syncGroupClarify('Core', { name: 'research' }, { pending_approval: APPROVAL, pending_clarify: CLARIFY })\n+    turns.syncGroupClarify('Core', { name: 'research' }, { open_requests: [CLARIFY], pending_approval: APPROVAL })\n \n     const entry = Object.values(chat.$groupClarify.get())[0]\n "]},{"file":"apps/desktop/src/plugins/hermes-bots/group-turns.ts","hunks":["@@ -101,8 +101,10 @@ interface GroupSessionSnapshot {\n   inflight?: boolean\n   message_count?: number\n   messages?: GroupTurnTranscriptMessage[]\n+  /** Still-open server→client requests (`server_requests.open_requests`); the\n+   *  member's blocking clarify question lives here as `{ id, method: 'clarify', params }`. */\n+  open_requests?: { id: string; method: string; params: Record<string, unknown> }[]\n   pending_approval?: GroupPendingApproval\n-  pending_clarify?: GroupPendingClarify\n   running?: boolean\n   session_id?: string\n   session_key?: string","@@ -437,13 +439,18 @@ const GROUP_TURN_HARD_CAP_MS = 20 * 60000\n  *  `${group}::${memberKey}` (#90694). Returns true while a prompt is\n  *  blocking, so the turn poll can extend its deadline — a waiting prompt\n  *  must not be eaten by the group-turn timeout. Feature-detected: older\n- *  backends without `pending_clarify`/`pending_approval` in the resume\n+ *  backends without `open_requests`/`pending_approval` in the resume\n  *  payload always sync to \"no prompt\". Clarify wins when both are somehow\n  *  present (approvals resolve inside tool batches; clarify is the outer\n  *  blocker). */\n export function syncGroupClarify(group: string, member: GroupMember, state: GroupSessionSnapshot | null): boolean {\n   const key = `${group}::${groupMemberKey(member)}`\n-  const clarify = state && typeof state.pending_clarify === 'object' ? state.pending_clarify : null\n+  const openClarify = Array.isArray(state?.open_requests)\n+    ? state.open_requests.find(entry => entry?.method === 'clarify' && typeof entry.id === 'string' && entry.id)\n+    : null\n+  const clarify: GroupPendingClarify | null = openClarify\n+    ? { ...(openClarify.params as GroupPendingClarify), request_id: openClarify.id }\n+    : null\n \n   // The `!requestId` bail below is what makes the approval branch reachable,\n   // so an approval read there is never the null arm of this ternary — a fact","@@ -586,15 +593,16 @@ export function renameGroupClarify(oldName: string, newName: string) {\n   }\n }\n \n-/** Answer a member's pending prompt from the room. Routes to the member's\n- *  OWN source (requestForBot), so cross-connection members work.\n- *  - clarify: `clarify.respond`; batch questions send one respond per\n- *    question, sequentially — the LAST lock resolves the blocked tool\n- *    server-side (same contract as the 1:1 batch card). allow_expired\n- *    server-side makes racing the timeout harmless.\n+/** Answer a member's pending prompt from the room. The prompt was mirrored\n+ *  from the member's resume snapshot (`open_requests` / `pending_approval`), so\n+ *  this window never held the live server request: answer through RPCs routed\n+ *  to the member's OWN source (requestForBot), so cross-connection members work.\n+ *  - clarify: `clarify.lock` per question, sequentially — the LAST lock\n+ *    resolves the blocked server request (same contract as the 1:1 batch\n+ *    card). A single question answers the open request by id through\n+ *    `request.answer` (the cross-socket proxy for a response frame).\n  *  - approval: `approval.respond` with the choice (once/session/always/deny),\n- *    keyed by session + request_id — the same wire the 1:1 approval card\n- *    and native notifications use. */\n+ *    keyed by session + request_id — the queue-level wire every surface shares. */\n export async function answerGroupClarify(\n   entry: GroupPrompt,\n   member: GroupMember,","@@ -618,16 +626,16 @@ export async function answerGroupClarify(\n         // Question ids are opaque on the wire (`GroupPrompt.questions` types\n         // them `unknown`); the batch card keys its answer bag by exactly them.\n         const qid = (question?.qid ?? question?.id) as string\n-        await requestForBot(member, 'clarify.respond', {\n+        await requestForBot(member, 'clarify.lock', {\n           request_id: entry.requestId,\n           question_id: qid,\n           answer: (answers as Record<string, string>)?.[qid] ?? ''\n         })\n       }\n     } else {\n-      await requestForBot(member, 'clarify.respond', {\n-        request_id: entry.requestId,\n-        answer: typeof answers === 'string' ? answers : ''\n+      await requestForBot(member, 'request.answer', {\n+        id: entry.requestId,\n+        result: { answer: typeof answers === 'string' ? answers : '' }\n       })\n     }\n "]},{"file":"apps/desktop/src/store/clarify.test.ts","hunks":["@@ -12,6 +12,7 @@ import {\n   skipClarifyRequest\n } from './clarify'\n import { $gateway } from './gateway'\n+import { rememberServerRequest, resetServerRequestsForTests } from './server-requests'\n import { $activeSessionId } from './session'\n \n function clarify(sessionId: string | null, requestId: string): ClarifyRequest {","@@ -91,6 +92,7 @@ describe('skipClarifyRequest', () => {\n \n   beforeEach(() => {\n     $clarifyRequests.set({})\n+    resetServerRequestsForTests()\n     request.mockClear()\n     $gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)\n   })","@@ -101,12 +103,15 @@ describe('skipClarifyRequest', () => {\n   })\n \n   it('answers the session\\u2019s clarify with an empty answer and drops it', async () => {\n+    const respond = vi.fn()\n+\n+    rememberServerRequest({ fail: vi.fn(), id: 'req-a', method: 'clarify', params: {}, respond })\n     setClarifyRequest(clarify('session-a', 'req-a'))\n     setClarifyRequest(clarify('session-b', 'req-b'))\n \n     await expect(skipClarifyRequest('session-a')).resolves.toBe(true)\n \n-    expect(request).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-a', answer: '' })\n+    expect(respond).toHaveBeenCalledWith({ answer: '' })\n     expect(hasClarifyRequest('session-a')).toBe(false)\n     // A background session's question is untouched — only the one being typed\n     // over is skipped.","@@ -118,9 +123,8 @@ describe('skipClarifyRequest', () => {\n     expect(request).not.toHaveBeenCalled()\n   })\n \n-  it('still reports the skip when the respond RPC fails', async () => {\n+  it('still reports the skip when the server request is already gone (expired / other window answered)', async () => {\n     setClarifyRequest(clarify('session-a', 'req-a'))\n-    request.mockRejectedValueOnce(new Error('socket closed'))\n \n     await expect(skipClarifyRequest('session-a')).resolves.toBe(true)\n     expect(hasClarifyRequest('session-a')).toBe(false)"]},{"file":"apps/desktop/src/store/clarify.ts","hunks":["@@ -1,6 +1,6 @@\n import { atom, computed } from 'nanostores'\n \n-import { $gateway } from './gateway'\n+import { respondToServerRequest } from './server-requests'\n import { $activeSessionId } from './session'\n \n export interface ClarifyQuestion {","@@ -187,8 +187,8 @@ export const hasClarifyRequest = (sessionId: string | null | undefined): boolean\n  * (default 5 min) — the message looks sent and nothing happens. Skipping lets\n  * the tool return and the turn carry on with the user's actual words.\n  *\n- * An empty answer is the same thing the card's own Skip button sends, and\n- * `clarify.respond` is `allow_expired`, so racing the timeout is harmless.\n+ * An empty answer is the same thing the card's own Skip button sends; answering\n+ * a request that already expired is a no-op, so racing the timeout is harmless.\n  */\n export async function skipClarifyRequest(sessionId: string | null | undefined): Promise<boolean> {\n   const request = $clarifyRequests.get()[keyFor(sessionId)]","@@ -201,12 +201,7 @@ export async function skipClarifyRequest(sessionId: string | null | undefined):\n   // leave a live card the user can answer a second time.\n   clearClarifyRequest(request.requestId, request.sessionId)\n \n-  try {\n-    await $gateway.get()?.request('clarify.respond', { request_id: request.requestId, answer: '' })\n-  } catch {\n-    // The tool times out on its own; a failed skip must never swallow the\n-    // message the user is actually sending.\n-  }\n+  respondToServerRequest(request.requestId, { answer: '' })\n \n   return true\n }"]},{"file":"apps/desktop/src/store/gateway-profile-only-owner.integration.test.ts","hunks":["@@ -129,7 +129,7 @@ afterEach(() => {\n })\n \n describe('profile-only secondary approval ownership', () => {\n-  it('keeps a profile-only secondary event owner across a focus switch and dispatches approval on that secondary', async () => {\n+  it('keeps a profile-only secondary event owner across a focus switch and dispatches the approval.respond fallback on that secondary', async () => {\n     await expect(ensureGatewayForProfile('research')).resolves.toBeUndefined()\n     const secondary = gatewayMocks.instances[0]\n ","@@ -138,7 +138,10 @@ describe('profile-only secondary approval ownership', () => {\n     expect($sessionTiles.get()).toEqual([])\n     expect($sessionStates.get()).toEqual({})\n \n-    secondary.emit({ session_id: 'rt-secondary', type: 'approval.request' })\n+    // Prompts now arrive as server→client request frames, not events; owner\n+    // scope is still learned from any session-stamped event the secondary\n+    // socket emits (here the turn's status update).\n+    secondary.emit({ session_id: 'rt-secondary', type: 'status.update' })\n     await expect(ensureGatewayForProfile('default')).resolves.toBeUndefined()\n \n     expect(knownOwnerForSession('rt-secondary')).toBe('research')","@@ -170,8 +173,8 @@ describe('profile-only secondary approval ownership', () => {\n \n     await ensureGatewayForProfile('research')\n     const secondary = gatewayMocks.instances[0]\n-    secondary.emit({ session_id: 'rt-retired', type: 'approval.request' })\n-    secondary.emit({ session_id: 'rt-durable', type: 'approval.request' })\n+    secondary.emit({ session_id: 'rt-retired', type: 'status.update' })\n+    secondary.emit({ session_id: 'rt-durable', type: 'status.update' })\n     expect(knownOwnerForSession('rt-retired')).toBe('research')\n     const exact = { connectionId: 'remote-same-name', profile: 'research' }\n     setSessionOwnerHint('rt-durable', exact)"]},{"file":"apps/desktop/src/store/gateway.ts","hunks":["@@ -3,7 +3,8 @@ import {\n   type GatewayEvent,\n   reconnectBackoffDelayMs,\n   registryBackendScopeKey,\n-  resolveGatewayWsUrl\n+  resolveGatewayWsUrl,\n+  type ServerRequest\n } from '@hermes/shared'\n import { atom } from 'nanostores'\n ","@@ -56,6 +57,10 @@ interface RegistryConfig {\n    * the connection store. */\n   activeConnectionId?: () => null | string\n   onEvent: (event: GatewayEvent) => void\n+  /** Server→client request (clarify, approval, …) from ANY socket the registry owns; the\n+   *  request's `respond` already routes to the socket it came from. `profile` /\n+   *  `connectionId` tag the source the same way events are tagged. */\n+  onServerRequest?: (request: ScopedServerRequest) => void\n   onActiveConnectionInvalidated?: (fallbackProfile: string, activationEpoch: number) => void\n   onActiveConnectionChanged?: (connection: HermesConnection) => void\n   /**","@@ -100,6 +105,7 @@ interface Secondary {\n   activeRequests: number\n   connectPromise: Promise<void> | null\n   offEvent: () => void\n+  offRequest: () => void\n   offState: () => void\n   reconnectTimer: ReturnType<typeof setTimeout> | null\n   reconnectAttempt: number","@@ -269,6 +275,19 @@ export function emitLocalGatewayEvent(event: GatewayEvent): void {\n   g.config?.onEvent(event)\n }\n \n+/** A server→client request tagged with the registry source it arrived from (like `GatewayEvent.profile`). */\n+export interface ScopedServerRequest extends ServerRequest {\n+  connectionId?: string\n+  profile: string\n+}\n+\n+/** Fan a primary-socket server request into the registry handler with the active source tags. */\n+export function dispatchPrimaryServerRequest(request: ServerRequest, profile: string): void {\n+  const connectionId = g.config?.activeConnectionId?.() ?? null\n+\n+  g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile })\n+}\n+\n export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {\n   const next = normKey(profile)\n ","@@ -800,6 +819,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S\n     activeRequests: 0,\n     connectPromise: null,\n     offEvent: () => {},\n+    offRequest: () => {},\n     offState: () => {},\n     reconnectTimer: null,\n     reconnectAttempt: 0,","@@ -822,6 +842,10 @@ function createSecondary(profile: string, connectionId: null | string = null): S\n     g.config?.onEvent(scopedEvent)\n     releaseTerminalTurnLease(entry.scope, event)\n   })\n+  entry.offRequest =\n+    gateway.onRequest?.(request => {\n+      g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile })\n+    }) ?? (() => {})\n   entry.offState = gateway.onState(state => {\n     reportGatewayState(scope, state)\n ","@@ -1741,6 +1765,7 @@ function disposeSecondary(entry: Secondary): void {\n   entry.wantOpen = false\n   clearTimer(entry)\n   entry.offEvent()\n+  entry.offRequest()\n   entry.offState()\n   entry.gateway.close()\n }"]},{"file":"apps/desktop/src/store/mcp-setup.ts","hunks":["@@ -1,6 +1,6 @@\n import { atom, computed } from 'nanostores'\n \n-import { $gateway } from './gateway'\n+import { respondToServerRequest } from './server-requests'\n \n /**\n  * Pending `mcp.setup.request`s — the desktop half of the `setup_mcp` tool's","@@ -19,7 +19,7 @@ export interface McpSetupRequest {\n   sessionId: string | null\n }\n \n-/** The card's answer, serialized back through `mcp.setup.respond`. */\n+/** The card's answer, serialized back as the `mcp.setup` request's `{value}`. */\n export interface McpSetupOutcome {\n   status: 'authorized' | 'declined' | 'enabled' | 'error' | 'installed'\n   server: string","@@ -104,15 +104,9 @@ export async function skipMcpSetupRequest(sessionId: string | null | undefined):\n   // leave a live card the user can answer a second time.\n   clearMcpSetupRequest(request.requestId, request.sessionId)\n \n-  try {\n-    await $gateway.get()?.request('mcp.setup.respond', {\n-      request_id: request.requestId,\n-      result: JSON.stringify({ server: request.server, status: 'declined' })\n-    })\n-  } catch {\n-    // The tool times out on its own; a failed skip must never swallow the\n-    // message the user is actually sending.\n-  }\n+  respondToServerRequest(request.requestId, {\n+    value: JSON.stringify({ server: request.server, status: 'declined' })\n+  })\n \n   return true\n }"]},{"file":"apps/desktop/src/store/native-notifications.ts","hunks":["@@ -5,10 +5,11 @@ import { persistString, storedString } from '@/lib/storage'\n \n import { $gateway } from './gateway'\n import { withinNativeNotifyBaseline } from './notify-baseline'\n+import { $approvalRequests, answerApproval } from './prompts'\n import { clearApprovalRequest } from './prompts'\n import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'\n import { $activeSessionId } from './session'\n-import { requestForOwnedSession, storedSessionIdForRuntimeId } from './session-states'\n+import { storedSessionIdForRuntimeId } from './session-states'\n \n export type { HermesOpenTarget }\n ","@@ -366,18 +367,11 @@ export async function respondToApprovalAction(sessionId: null | string, actionId\n   }\n \n   try {\n-    // Route through the session's OWNER (tile route → known profile); the\n-    // ambient socket follows foreground focus and, for a background approval\n-    // raised by a cross-profile session, points at a backend that never held\n-    // the approval (#91684 client half). Ambient only when no owner is known.\n-    await requestForOwnedSession(\n-      sessionId,\n-      // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg\n-      // call shape gateway.request callers assert on.\n-      gateway.request.bind(gateway) as typeof gateway.request,\n-      'approval.respond',\n-      { choice, session_id: sessionId ?? undefined }\n-    )\n+    // The parked prompt knows how to answer itself: the live server request when\n+    // still open, else the owner-routed queue-level RPC (#91684 client half).\n+    const parked = $approvalRequests.get()[sessionId ?? '']\n+\n+    await answerApproval(gateway, parked ?? { sessionId: sessionId ?? null }, choice)\n     clearApprovalRequest(sessionId)\n   } catch (error) {\n     if (sessionId && isSessionGoneForBackgroundPolling(error)) {"]},{"file":"apps/desktop/src/store/prompts.ts","hunks":["@@ -2,14 +2,16 @@ import { atom, computed, type ReadableAtom } from 'nanostores'\n \n import { $clarifyRequest, $clarifyRequests } from './clarify'\n import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'\n+import { respondToServerRequest } from './server-requests'\n import { $activeSessionId } from './session'\n import { ambientRequestFor } from './session-gone-latch'\n import { requestForOwnedSession } from './session-states'\n \n-// Blocking interactive prompts the gateway raises mid-turn. Each maps to a\n-// `*.request` event the Python side emits while it blocks the agent thread\n-// waiting for a `*.respond` RPC. Without a renderer for these, the agent\n-// silently stalls until its timeout (default 5 min) and the tool is BLOCKED.\n+// Blocking interactive prompts the gateway raises mid-turn. Each is a\n+// server→client JSON-RPC request (`tui_gateway/server_requests.py`) the Python\n+// side blocks on; `requestId` is that request's id and the card answers through\n+// `store/server-requests.ts::respondToServerRequest`. Without a renderer for\n+// these the channel answers -32601 and the tool fails fast.\n //\n // Like clarify, every prompt is parked under the runtime session id that raised\n // it (not one shared slot), so a *background* session running concurrently can","@@ -70,9 +72,12 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {\n   }\n }\n \n-// Approval is session-keyed on the backend and correlated by `request_id` when\n-// available (legacy ID-free responses remain FIFO-compatible). Resolved via\n-// approval.respond {choice, request_id, session_id}.\n+// Approval is queue-backed on the backend (`tools/approval.py`): `requestId` is\n+// the QUEUE entry's id (what `approval.pending` / `approval.received` /\n+// `approval.respond` key on), stable across delivery paths. The live prompt\n+// arrives as an `approval` server request whose id is `serverRequestId`; the\n+// card answers that request when it is still open and falls back to the\n+// `approval.respond` RPC when the prompt was restored from `approval.pending`.\n export interface ApprovalRequest extends KeyedPrompt {\n   // false when the backend won't honor a permanent allow (tirith warning) → hide \"Always allow\".\n   allowPermanent?: boolean","@@ -80,6 +85,7 @@ export interface ApprovalRequest extends KeyedPrompt {\n   command: string\n   description: string\n   requestId?: string\n+  serverRequestId?: string\n   smartDenied?: boolean\n }\n ","@@ -106,8 +112,8 @@ export interface SecretRequest extends KeyedPrompt {\n   requestId: string\n }\n \n-// External password-manager unlock (agent/vault_backends). Resolved via\n-// vault.unlock.respond {request_id, password}; \"\" keeps the manager locked.\n+// External password-manager unlock (agent/vault_backends): `vault.unlock_prompt`\n+// server request, answered `{value: password}`; \"\" keeps the manager locked.\n export interface VaultUnlockRequest extends KeyedPrompt {\n   backend: string\n   displayName: string","@@ -119,8 +125,8 @@ const sudo = keyedPromptStore<SudoRequest>()\n const secret = [redacted:secret-assignment]\n const vaultUnlock = keyedPromptStore<VaultUnlockRequest>()\n \n-// \"Save this login\" for the page the agent is on (tools/browser_vault_tool). Resolved via\n-// vault.save_login.respond {request_id, login: JSON {identifier, password}}; \"\" declines.\n+// \"Save this login\" for the page the agent is on (tools/browser_vault_tool): `vault.save_login`\n+// server request, answered `{value: JSON {identifier, password}}`; \"\" declines.\n export interface VaultSaveLoginRequest extends KeyedPrompt {\n   origin: string\n   site: string","@@ -129,8 +135,8 @@ export interface VaultSaveLoginRequest extends KeyedPrompt {\n \n const vaultSave = keyedPromptStore<VaultSaveLoginRequest>()\n \n-// Second-factor code for the page the agent is on. Resolved via vault.code.respond\n-// {request_id, code}; \"\" skips.\n+// Second-factor code for the page the agent is on: `vault.code` server request,\n+// answered `{value: code}`; \"\" skips.\n export interface VaultCodeRequest extends KeyedPrompt {\n   site: string\n   hint: string","@@ -144,10 +150,19 @@ const vaultCode = keyedPromptStore<VaultCodeRequest>()\n const $approvalInlineAnchors = atom<Record<string, number>>({})\n \n export const $approvalRequest = approval.$active\n+export const $approvalRequests = approval.$all\n export const setApprovalRequest = approval.set\n export const clearApprovalRequest = approval.clear\n \n export async function receiveApprovalRequest(gateway: ApprovalGateway | null, request: ApprovalRequest): Promise<void> {\n+  // A prompt restored from `approval.pending` must not clobber the live server\n+  // request that already carries the same queue entry (it knows how to answer).\n+  const current = approval.$all.get()[keyFor(request.sessionId)]\n+\n+  if (current?.requestId && current.requestId === request.requestId && current.serverRequestId && !request.serverRequestId) {\n+    return\n+  }\n+\n   setApprovalRequest(request)\n \n   if (gateway && request.requestId && request.sessionId) {","@@ -210,6 +225,10 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses\n     return\n   }\n \n+  if (previous?.requestId === pending.request_id) {\n+    return\n+  }\n+\n   await receiveApprovalRequest(gateway, {\n     allowPermanent: pending.allow_permanent !== false,\n     choices: Array.isArray(pending.choices) ? pending.choices.filter(choice => typeof choice === 'string') : undefined,","@@ -221,6 +240,35 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses\n   })\n }\n \n+/**\n+ * Resolve an approval: answer the live server request when it is still open\n+ * (the response frame goes back over the socket it arrived on — the owner by\n+ * construction), else the queue-level `approval.respond` RPC for a prompt that\n+ * was restored from `approval.pending` or is being answered from another\n+ * surface. Returns after the backend has the decision.\n+ */\n+export async function answerApproval(\n+  gateway: ApprovalGateway | null,\n+  request: Pick<ApprovalRequest, 'requestId' | 'serverRequestId' | 'sessionId'>,\n+  choice: string,\n+  all = false\n+): Promise<void> {\n+  if (respondToServerRequest(request.serverRequestId, { choice, ...(all ? { all: true } : {}) })) {\n+    return\n+  }\n+\n+  if (!gateway) {\n+    throw new Error('Hermes gateway is not connected')\n+  }\n+\n+  await requestForOwnedSession(request.sessionId, ambientRequestFor(gateway), 'approval.respond', {\n+    ...(all ? { all: true } : {}),\n+    choice,\n+    ...(request.requestId ? { request_id: request.requestId } : {}),\n+    session_id: request.sessionId ?? undefined\n+  })\n+}\n+\n /** The prompt request for one specific session — the tile counterpart of the\n  *  active-session `$*Request` views (same map, fixed key). */\n export const sessionApprovalRequest = (sessionId: string | null) =>","@@ -250,10 +298,12 @@ export const sessionApprovalInlineVisible = (sessionId: string | null) =>\n   computed($approvalInlineAnchors, anchors => (anchors[keyFor(sessionId)] ?? 0) > 0)\n \n export const $sudoRequest = sudo.$active\n+export const $sudoRequests = sudo.$all\n export const setSudoRequest = sudo.set\n export const clearSudoRequest = sudo.clear\n \n export const $secretRequest = [redacted:secret-assignment]\n+export const $secretRequests = [redacted:secret-assignment]\n export const setSecretRequest = [redacted:secret-assignment]\n export const clearSecretRequest = [redacted:secret-assignment]\n "]},{"file":"apps/desktop/src/store/server-requests.ts","hunks":["@@ -0,0 +1,46 @@\n+import type { ServerRequest } from '@hermes/shared'\n+\n+/**\n+ * Live server→client requests (`tui_gateway/server_requests.py`) keyed by\n+ * request id: clarify / approval / sudo / secret / vault / MCP-setup cards.\n+ *\n+ * The per-session prompt stores keep only the id; a card answers through\n+ * `respondToServerRequest`, which routes the response frame back over the\n+ * socket the request arrived on — the owner backend by construction, so no\n+ * owner-route lookup is needed (#91684's whole class disappears: the answer\n+ * cannot land on the wrong backend because it is a JSON-RPC response, not a\n+ * new call). A request re-delivered after a reconnect (`open_requests`)\n+ * carries the same id and replaces the entry, so the still-visible card\n+ * answers the new generation.\n+ */\n+const open = new Map<string, ServerRequest>()\n+\n+export function rememberServerRequest(request: ServerRequest): void {\n+  open.set(request.id, request)\n+}\n+\n+export function forgetServerRequest(id: string): void {\n+  open.delete(id)\n+}\n+\n+/** Answer request `id`. False when nothing is open under that id (expired / already answered). */\n+export function respondToServerRequest(id: string | undefined, result: Record<string, unknown>): boolean {\n+  const request = id ? open.get(id) : undefined\n+\n+  if (!request) {\n+    return false\n+  }\n+\n+  open.delete(id!)\n+  request.respond(result)\n+\n+  return true\n+}\n+\n+export function hasOpenServerRequest(id: string): boolean {\n+  return open.has(id)\n+}\n+\n+export function resetServerRequestsForTests(): void {\n+  open.clear()\n+}"]},{"file":"apps/desktop/src/store/session-states-runtime-map.test.ts","hunks":["@@ -64,7 +64,7 @@ describe('storedSessionIdForRuntimeId', () => {\n   })\n \n   it('maps a MAIN-PANE runtime id through the per-runtime state mirror (no tile involved)', () => {\n-    // approval.respond from a native notification, a queued send: the caller\n+    // The approval.respond RPC fallback from a native notification, a queued send: the caller\n     // holds the runtime id of the primary thread, which no tile knows. The\n     // state mirror carries the stored id the wiring cache bound.\n     publishSessionState('rt-main', createClientSessionState('stored-main'))"]},{"file":"apps/desktop/src/types/hermes.ts","hunks":["@@ -673,17 +673,11 @@ export interface SessionResumeResponse {\n     request_id?: string\n     smart_denied?: boolean\n   }\n-  // The clarify question still blocking this session, if any. Same replay\n-  // class as pending_approval: emitted-while-detached prompts are restored\n-  // from the resume snapshot instead of being lost until server-side timeout.\n-  pending_clarify?: {\n-    answers?: Record<string, unknown>\n-    choices?: null | string[]\n-    multi_select?: boolean\n-    question?: string\n-    questions?: unknown\n-    request_id?: string\n-  }\n+  // Server→client requests still unanswered for this session (clarify, sudo,\n+  // vault prompts, …). The shared channel re-delivers them to the request\n+  // handlers before this response resolves; listed here so resume can tell an\n+  // authoritative \"nothing pending\" from a request the handler declined.\n+  open_requests?: Array<{ id: string; method: string; params: Record<string, unknown> & { session_id?: string } }>\n   info?: SessionRuntimeInfo\n   message_count: number\n   messages: SessionMessage[]"]},{"file":"apps/shared/src/gateway-events.json","hunks":["@@ -1,93 +1,86 @@\n-[\n-  \"agent.terminal.output\",\n-  \"approval.request\",\n-  \"background.complete\",\n-  \"billing.step_up.verification\",\n-  \"bot_relay.outbox.pending\",\n-  \"browser.controller.cancel\",\n-  \"browser.controller.command\",\n-  \"browser.progress\",\n-  \"btw.complete\",\n-  \"clarify.expire\",\n-  \"clarify.request\",\n-  \"cron.changed\",\n-  \"error\",\n-  \"gateway.ready\",\n-  \"layout.apply\",\n-  \"mcp.setup.expire\",\n-  \"mcp.setup.request\",\n-  \"message.complete\",\n-  \"message.delta\",\n-  \"message.interim\",\n-  \"message.reaction\",\n-  \"message.start\",\n-  \"moa.aggregating\",\n-  \"moa.phase\",\n-  \"moa.progress\",\n-  \"moa.reference\",\n-  \"notice\",\n-  \"notification.clear\",\n-  \"notification.show\",\n-  \"pairing.changed\",\n-  \"pane.reveal\",\n-  \"pet.changed\",\n-  \"pet.generate.progress\",\n-  \"pet.hatch.progress\",\n-  \"platforms.changed\",\n-  \"preview.act.expire\",\n-  \"preview.act.request\",\n-  \"preview.close\",\n-  \"preview.open\",\n-  \"preview.read.expire\",\n-  \"preview.read.request\",\n-  \"preview.restart.complete\",\n-  \"preview.restart.progress\",\n-  \"reaction\",\n-  \"reasoning.available\",\n-  \"reasoning.delta\",\n-  \"review.summary\",\n-  \"secret.expire\",\n-  \"secret.request\",\n-  \"session.control.update\",\n-  \"session.info\",\n-  \"session.reclaimed\",\n-  \"session.resume_progress\",\n-  \"session.title\",\n-  \"session.usage\",\n-  \"sessions.changed\",\n-  \"setup.ready\",\n-  \"skin.changed\",\n-  \"status.update\",\n-  \"subagent.complete\",\n-  \"subagent.progress\",\n-  \"subagent.spawn_requested\",\n-  \"subagent.start\",\n-  \"subagent.thinking\",\n-  \"subagent.tool\",\n-  \"sudo.expire\",\n-  \"sudo.request\",\n-  \"terminal.close\",\n-  \"terminal.read.expire\",\n-  \"terminal.read.request\",\n-  \"thinking.delta\",\n-  \"tip.show\",\n-  \"todo.updated\",\n-  \"tool.complete\",\n-  \"tool.generating\",\n-  \"tool.output_risk\",\n-  \"tool.start\",\n-  \"tour.expire\",\n-  \"tour.request\",\n-  \"vault.code.expire\",\n-  \"vault.code.request\",\n-  \"vault.save_login.expire\",\n-  \"vault.save_login.request\",\n-  \"vault.unlock.expire\",\n-  \"vault.unlock.request\",\n-  \"voice.interrupted\",\n-  \"voice.status\",\n-  \"voice.transcript\",\n-  \"wake.detected\",\n-  \"window.read.expire\",\n-  \"window.read.request\"\n-]\n+{\n+  \"events\": [\n+    \"agent.terminal.output\",\n+    \"background.complete\",\n+    \"billing.step_up.verification\",\n+    \"bot_relay.outbox.pending\",\n+    \"browser.controller.cancel\",\n+    \"browser.controller.command\",\n+    \"browser.progress\",\n+    \"btw.complete\",\n+    \"cron.changed\",\n+    \"error\",\n+    \"gateway.ready\",\n+    \"layout.apply\",\n+    \"message.complete\",\n+    \"message.delta\",\n+    \"message.interim\",\n+    \"message.reaction\",\n+    \"message.start\",\n+    \"moa.aggregating\",\n+    \"moa.phase\",\n+    \"moa.progress\",\n+    \"moa.reference\",\n+    \"notice\",\n+    \"notification.clear\",\n+    \"notification.show\",\n+    \"pairing.changed\",\n+    \"pane.reveal\",\n+    \"pet.changed\",\n+    \"pet.generate.progress\",\n+    \"pet.hatch.progress\",\n+    \"platforms.changed\",\n+    \"preview.close\",\n+    \"preview.open\",\n+    \"preview.restart.complete\",\n+    \"preview.restart.progress\",\n+    \"reaction\",\n+    \"reasoning.available\",\n+    \"reasoning.delta\",\n+    \"request.cancel\",\n+    \"review.summary\",\n+    \"session.control.update\",\n+    \"session.info\",\n+    \"session.reclaimed\",\n+    \"session.resume_progress\",\n+    \"session.title\",\n+    \"session.usage\",\n+    \"sessions.changed\",\n+    \"setup.ready\",\n+    \"skin.changed\",\n+    \"status.update\",\n+    \"subagent.complete\",\n+    \"subagent.progress\",\n+    \"subagent.spawn_requested\",\n+    \"subagent.start\",\n+    \"subagent.thinking\",\n+    \"subagent.tool\",\n+    \"terminal.close\",\n+    \"thinking.delta\",\n+    \"tip.show\",\n+    \"todo.updated\",\n+    \"tool.complete\",\n+    \"tool.generating\",\n+    \"tool.output_risk\",\n+    \"tool.start\",\n+    \"voice.interrupted\",\n+    \"voice.status\",\n+    \"voice.transcript\",\n+    \"wake.detected\"\n+  ],\n+  \"server_requests\": [\n+    \"approval\",\n+    \"clarify\",\n+    \"mcp.setup\",\n+    \"preview.act\",\n+    \"preview.read\",\n+    \"secret\",\n+    \"sudo\",\n+    \"terminal.read\",\n+    \"tour\",\n+    \"vault.code\",\n+    \"vault.save_login\",\n+    \"vault.unlock_prompt\",\n+    \"window.read\"\n+  ]\n+}"]},{"file":"apps/shared/src/gateway-events.test.ts","hunks":["@@ -1,22 +1,29 @@\n import { describe, expect, it } from 'vitest'\n \n-import { BACKEND_EVENT_NAMES } from './gateway-events'\n+import { BACKEND_EVENT_NAMES, SERVER_REQUEST_METHODS } from './gateway-events'\n import contract from './gateway-events.json'\n \n /**\n  * Two-sided contract with `tests/tui_gateway/test_gateway_event_contract.py`: the\n- * Python side pins the emitter call sites to `gateway-events.json`; this side pins\n- * `BACKEND_EVENT_NAMES` (which `BackendGatewayEventMap` is checked against via\n- * `satisfies`) to the same JSON. A name added on either side alone goes red here.\n+ * Python side pins the emitter / server-request call sites to `gateway-events.json`;\n+ * this side pins `BACKEND_EVENT_NAMES` (checked against `BackendGatewayEventMap` via\n+ * `satisfies`) and `SERVER_REQUEST_METHODS` (against `ServerRequestMap`) to the same\n+ * JSON. A name added on either side alone goes red here.\n  */\n-describe('gateway-events.json ⇄ BackendGatewayEventMap', () => {\n+describe('gateway-events.json ⇄ BackendGatewayEventMap / ServerRequestMap', () => {\n   it('lists exactly the backend-emitted notification names', () => {\n-    expect([...BACKEND_EVENT_NAMES]).toEqual(contract)\n+    expect([...BACKEND_EVENT_NAMES]).toEqual(contract.events)\n   })\n \n-  it('keeps both lists sorted and duplicate-free (stable diffs)', () => {\n-    const sorted = [...contract].sort()\n-    expect(contract).toEqual(sorted)\n-    expect(new Set(contract).size).toBe(contract.length)\n+  it('lists exactly the server→client request methods', () => {\n+    expect([...SERVER_REQUEST_METHODS]).toEqual(contract.server_requests)\n+  })\n+\n+  it.each([\n+    ['events', contract.events],\n+    ['server_requests', contract.server_requests]\n+  ])('keeps %s sorted and duplicate-free (stable diffs)', (_label, list) => {\n+    expect(list).toEqual([...list].sort())\n+    expect(new Set(list).size).toBe(list.length)\n   })\n })"]},{"file":"apps/shared/src/gateway-events.ts","hunks":["@@ -3,7 +3,8 @@\n  * TypeScript surfaces (Ink TUI, Desktop, web dashboard) share.\n  *\n  * Every notification arrives as `{jsonrpc: '2.0', method: 'event', params: GatewayEvent}`\n- * (`tui_gateway/server.py::_event_frame`). `GatewayEventMap` is the single map from\n+ * (`tui_gateway/server.py::_event_frame`); server→client REQUESTS (`{id, method, params}`,\n+ * `tui_gateway/server_requests.py`) are typed by `ServerRequestMap` below. `GatewayEventMap` is the single map from\n  * event `type` to payload shape; `BACKEND_EVENT_NAMES` mirrors the emitter side and is\n  * pinned to `gateway-events.json` by `gateway-events.test.ts` (vitest) and\n  * `tests/tui_gateway/test_gateway_event_contract.py` (Python), so a name added on one","@@ -304,11 +305,20 @@ export interface MoaPhasePayload {\n   refs_total?: number\n }\n \n-// Blocking bridges (`tui_gateway/server.py::_block`): every `*.request` carries a\n-// `request_id`; the matching `*.expire` names the same id when the wait timed out.\n+// ── Server→client requests (`tui_gateway/server_requests.py`) ───────────\n+//\n+// The backend asks the renderer a question with a real JSON-RPC request\n+// (`{id: 'srq-…', method, params}`) and blocks on the response frame. Every\n+// entry below is one method: its `params` shape and the `result` the client\n+// answers with. `request.cancel` (an event) withdraws an open request on\n+// timeout / interrupt / session close; `open_requests` on `session.resume` /\n+// `session.events.since` re-delivers unanswered ones after a reconnect.\n \n-export interface RequestExpirePayload {\n-  request_id: string\n+/** `request.cancel` payload — the backend withdrew an open server request. */\n+export interface RequestCancelPayload {\n+  id: string\n+  method: string\n+  reason: string\n }\n \n export interface ClarifyQuestion {","@@ -318,54 +328,113 @@ export interface ClarifyQuestion {\n   question: string\n }\n \n-export interface ClarifyRequestPayload {\n+/** `clarify` params. Single question: `question`/`choices`(/`multi_select`); batch: `questions`.\n+ *  `answers` rides along only on a reconnect replay (locks the server already accepted). */\n+export interface ClarifyRequestParams {\n   answers?: Record<string, string>\n   choices?: null | string[]\n   multi_select?: boolean\n   question?: string\n   questions?: ClarifyQuestion[]\n-  request_id: string\n }\n \n-/** `tui_gateway/server.py::_approval_request_payload` (command redacted server-side). */\n-export interface ApprovalRequestPayload {\n+/** `clarify` result. Single: `{answer}` ('' = skip). Batch: the request resolves through\n+ *  `clarify.lock` RPCs (the last lock completes it); a response with no `answers` is cancel-all. */\n+export interface ClarifyResult {\n+  answer?: string\n+  answers?: Record<string, string>\n+}\n+\n+/** `approval` params (`tui_gateway/server.py::_approval_request_payload`, command redacted server-side). */\n+export interface ApprovalRequestParams {\n   allow_permanent?: boolean\n   choices?: string[]\n   command: string\n   description: string\n-  request_id?: string\n+  request_id: string\n   smart_denied?: boolean\n }\n \n-export interface SudoRequestPayload {\n-  request_id: string\n+export interface ApprovalResult {\n+  all?: boolean\n+  choice: 'always' | 'deny' | 'once' | 'session'\n }\n \n-export interface SecretRequestPayload {\n+/** Every prompt whose answer is one string: `sudo`, `secret`, the vault prompts, the desktop GUI\n+ *  bridges (`terminal.read`, `preview.read`, `preview.act`, `window.read`, `tour`) and `mcp.setup`.\n+ *  '' means skipped / declined. */\n+export interface ValueResult {\n+  value: string\n+}\n+\n+export interface SecretRequestParams {\n   env_var: string\n+  metadata?: Record<string, unknown>\n   prompt: string\n-  request_id: string\n }\n \n-export interface VaultUnlockRequestPayload {\n+export interface VaultUnlockRequestParams {\n   backend: string\n   display_name: string\n-  request_id: string\n }\n \n-export interface VaultCodeRequestPayload {\n+export interface VaultSaveLoginRequestParams {\n+  origin: string\n+  site: string\n+}\n+\n+export interface VaultCodeRequestParams {\n   hint?: string\n-  request_id: string\n   site?: string\n }\n \n-export interface McpSetupRequestPayload {\n+export interface McpSetupRequestParams {\n   action?: string\n   reason?: string\n-  request_id: string\n   server?: string\n }\n \n+export interface ReadRangeRequestParams {\n+  count?: number\n+  start?: number\n+}\n+\n+/** Server→client request method → `{params, result}`. Every method the backend can ask. */\n+export interface ServerRequestMap {\n+  approval: { params: ApprovalRequestParams; result: ApprovalResult }\n+  clarify: { params: ClarifyRequestParams; result: ClarifyResult }\n+  'mcp.setup': { params: McpSetupRequestParams; result: ValueResult }\n+  'preview.act': { params: Record<string, unknown>; result: ValueResult }\n+  'preview.read': { params: ReadRangeRequestParams; result: ValueResult }\n+  secret: { params: SecretRequestParams; result: ValueResult }\n+  sudo: { params: Record<string, never>; result: ValueResult }\n+  'terminal.read': { params: ReadRangeRequestParams; result: ValueResult }\n+  tour: { params: Record<string, unknown>; result: ValueResult }\n+  'vault.code': { params: VaultCodeRequestParams; result: ValueResult }\n+  'vault.save_login': { params: VaultSaveLoginRequestParams; result: ValueResult }\n+  'vault.unlock_prompt': { params: VaultUnlockRequestParams; result: ValueResult }\n+  'window.read': { params: Record<string, never>; result: ValueResult }\n+}\n+\n+export type ServerRequestMethod = keyof ServerRequestMap\n+\n+/** Pinned to `gateway-events.json`'s `server_requests` list by the two contract tests. Keep sorted. */\n+export const SERVER_REQUEST_METHODS = [\n+  'approval',\n+  'clarify',\n+  'mcp.setup',\n+  'preview.act',\n+  'preview.read',\n+  'secret',\n+  'sudo',\n+  'terminal.read',\n+  'tour',\n+  'vault.code',\n+  'vault.save_login',\n+  'vault.unlock_prompt',\n+  'window.read'\n+] as const satisfies readonly ServerRequestMethod[]\n+\n /** Side agents (`tui_gateway/methods_prompt.py::_spawn_side_agent`). */\n export interface SideAgentCompletePayload {\n   question?: string","@@ -397,7 +466,6 @@ export interface TerminalClosePayload {\n  */\n export const BACKEND_EVENT_NAMES = [\n   'agent.terminal.output',\n-  'approval.request',\n   'background.complete',\n   'billing.step_up.verification',\n   'bot_relay.outbox.pending',","@@ -405,14 +473,10 @@ export const BACKEND_EVENT_NAMES = [\n   'browser.controller.command',\n   'browser.progress',\n   'btw.complete',\n-  'clarify.expire',\n-  'clarify.request',\n   'cron.changed',\n   'error',\n   'gateway.ready',\n   'layout.apply',\n-  'mcp.setup.expire',\n-  'mcp.setup.request',\n   'message.complete',\n   'message.delta',\n   'message.interim',","@@ -431,20 +495,15 @@ export const BACKEND_EVENT_NAMES = [\n   'pet.generate.progress',\n   'pet.hatch.progress',\n   'platforms.changed',\n-  'preview.act.expire',\n-  'preview.act.request',\n   'preview.close',\n   'preview.open',\n-  'preview.read.expire',\n-  'preview.read.request',\n   'preview.restart.complete',\n   'preview.restart.progress',\n   'reaction',\n   'reasoning.available',\n   'reasoning.delta',\n+  'request.cancel',\n   'review.summary',\n-  'secret.expire',\n-  'secret.request',\n   'session.control.update',\n   'session.info',\n   'session.reclaimed',","@@ -461,11 +520,7 @@ export const BACKEND_EVENT_NAMES = [\n   'subagent.start',\n   'subagent.thinking',\n   'subagent.tool',\n-  'sudo.expire',\n-  'sudo.request',\n   'terminal.close',\n-  'terminal.read.expire',\n-  'terminal.read.request',\n   'thinking.delta',\n   'tip.show',\n   'todo.updated',","@@ -473,20 +528,10 @@ export const BACKEND_EVENT_NAMES = [\n   'tool.generating',\n   'tool.output_risk',\n   'tool.start',\n-  'tour.expire',\n-  'tour.request',\n-  'vault.code.expire',\n-  'vault.code.request',\n-  'vault.save_login.expire',\n-  'vault.save_login.request',\n-  'vault.unlock.expire',\n-  'vault.unlock.request',\n   'voice.interrupted',\n   'voice.status',\n   'voice.transcript',\n-  'wake.detected',\n-  'window.read.expire',\n-  'window.read.request'\n+  'wake.detected'\n ] as const satisfies readonly (keyof BackendGatewayEventMap)[]\n \n export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number]","@@ -494,7 +539,6 @@ export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number]\n /** Payload per backend-emitted notification `type`. Keys are exactly `BACKEND_EVENT_NAMES`. */\n export interface BackendGatewayEventMap {\n   'agent.terminal.output': TerminalOutputPayload\n-  'approval.request': ApprovalRequestPayload\n   'background.complete': SideAgentCompletePayload\n   'billing.step_up.verification': BillingStepUpVerificationPayload\n   'bot_relay.outbox.pending': Record<string, unknown>","@@ -502,14 +546,10 @@ export interface BackendGatewayEventMap {\n   'browser.controller.command': Record<string, unknown>\n   'browser.progress': BrowserProgressPayload\n   'btw.complete': SideAgentCompletePayload\n-  'clarify.expire': RequestExpirePayload\n-  'clarify.request': ClarifyRequestPayload\n   'cron.changed': Record<string, unknown>\n   error: ErrorPayload\n   'gateway.ready': GatewayReadyPayload\n   'layout.apply': Record<string, unknown>\n-  'mcp.setup.expire': RequestExpirePayload\n-  'mcp.setup.request': McpSetupRequestPayload\n   'message.complete': MessageCompletePayload\n   'message.delta': StreamDeltaPayload\n   'message.interim': MessageInterimPayload","@@ -528,20 +568,15 @@ export interface BackendGatewayEventMap {\n   'pet.generate.progress': Record<string, unknown>\n   'pet.hatch.progress': Record<string, unknown>\n   'platforms.changed': Record<string, unknown>\n-  'preview.act.expire': RequestExpirePayload\n-  'preview.act.request': Record<string, unknown>\n   'preview.close': Record<string, unknown>\n   'preview.open': Record<string, unknown>\n-  'preview.read.expire': RequestExpirePayload\n-  'preview.read.request': Record<string, unknown>\n   'preview.restart.complete': SideAgentCompletePayload\n   'preview.restart.progress': PreviewRestartProgressPayload\n   reaction: ReactionPayload\n   'reasoning.available': StreamDeltaPayload\n   'reasoning.delta': StreamDeltaPayload\n+  'request.cancel': RequestCancelPayload\n   'review.summary': TextPayload\n-  'secret.expire': RequestExpirePayload\n-  'secret.request': SecretRequestPayload\n   'session.control.update': SessionControlUpdatePayload\n   /** Surface-specific shape (`tui_gateway/server.py::_session_info`); each client narrows. */\n   'session.info': Record<string, unknown>","@@ -559,11 +594,7 @@ export interface BackendGatewayEventMap {\n   'subagent.start': SubagentEventPayload\n   'subagent.thinking': SubagentEventPayload\n   'subagent.tool': SubagentEventPayload\n-  'sudo.expire': RequestExpirePayload\n-  'sudo.request': SudoRequestPayload\n   'terminal.close': TerminalClosePayload\n-  'terminal.read.expire': RequestExpirePayload\n-  'terminal.read.request': Record<string, unknown>\n   'thinking.delta': StreamDeltaPayload\n   'tip.show': Record<string, unknown>\n   'todo.updated': TodoStatePayload","@@ -571,20 +602,10 @@ export interface BackendGatewayEventMap {\n   'tool.generating': ToolGeneratingPayload\n   'tool.output_risk': ToolOutputRiskPayload\n   'tool.start': ToolStartPayload\n-  'tour.expire': RequestExpirePayload\n-  'tour.request': Record<string, unknown>\n-  'vault.code.expire': RequestExpirePayload\n-  'vault.code.request': VaultCodeRequestPayload\n-  'vault.save_login.expire': RequestExpirePayload\n-  'vault.save_login.request': Record<string, unknown>\n-  'vault.unlock.expire': RequestExpirePayload\n-  'vault.unlock.request': VaultUnlockRequestPayload\n   'voice.interrupted': Record<string, unknown>\n   'voice.status': VoiceStatusPayload\n   'voice.transcript': VoiceTranscriptPayload\n   'wake.detected': WakeDetectedPayload\n-  'window.read.expire': RequestExpirePayload\n-  'window.read.request': Record<string, unknown>\n }\n \n /**","@@ -757,6 +778,11 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message =\n   messages: Message[]\n   /** `omit_messages` resume: the client still learns the stored size. */\n   messages_omitted?: boolean\n+  /** Server→client requests still unanswered for this session (a clarify, sudo prompt, …\n+   *  raised while the client was detached); the client re-delivers them to its request\n+   *  handlers. `pending_approval` (the approval queue's oldest entry) is the approval twin. */\n+  open_requests?: OpenServerRequest[]\n+  pending_approval?: ApprovalRequestParams\n   resumed?: string\n   running?: boolean\n   session_id: string","@@ -765,3 +791,10 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message =\n   status?: string\n   todo_state?: TodoStatePayload\n }\n+\n+/** One unanswered server→client request as returned by `open_requests`. */\n+export interface OpenServerRequest {\n+  id: string\n+  method: string\n+  params: Record<string, unknown> & { session_id?: string }\n+}"]},{"file":"apps/shared/src/index.ts","hunks":["@@ -62,12 +62,14 @@ export {\n export { compactNumber } from './format'\n export { type FuzzyMatch, fuzzyRank, fuzzyScore, fuzzyScoreMulti, type RankedItem } from './fuzzy'\n export {\n-  type ApprovalRequestPayload,\n+  type ApprovalRequestParams,\n+  type ApprovalResult,\n   BACKEND_EVENT_NAMES,\n   type BackendGatewayEventMap,\n   type BackendGatewayEventName,\n   type ClarifyQuestion,\n-  type ClarifyRequestPayload,\n+  type ClarifyRequestParams,\n+  type ClarifyResult,\n   type ClientLocalGatewayEventMap,\n   type ErrorPayload,\n   type ErrorSurface,","@@ -76,7 +78,7 @@ export {\n   type GatewayEventName,\n   type GatewayReadyPayload,\n   type GatewayTranscriptMessage,\n-  type McpSetupRequestPayload,\n+  type McpSetupRequestParams,\n   type MessageCompletePayload,\n   type MessageInterimPayload,\n   type ModelCapabilities,","@@ -84,8 +86,13 @@ export {\n   type ModelOptionsResponse,\n   type ModelPricing,\n   type NotificationShowPayload,\n-  type RequestExpirePayload,\n-  type SecretRequestPayload,\n+  type OpenServerRequest,\n+  type ReadRangeRequestParams,\n+  type RequestCancelPayload,\n+  type SecretRequestParams,\n+  SERVER_REQUEST_METHODS,\n+  type ServerRequestMap,\n+  type ServerRequestMethod,\n   type SessionInflightTurn,\n   type SessionListItem,\n   type SessionListResponse,","@@ -100,7 +107,10 @@ export {\n   type ToolCompletePayload,\n   type ToolStartPayload,\n   type Usage,\n-  type VaultUnlockRequestPayload,\n+  type ValueResult,\n+  type VaultCodeRequestParams,\n+  type VaultSaveLoginRequestParams,\n+  type VaultUnlockRequestParams,\n   type WakeDetectedPayload\n } from './gateway-events'\n export {","@@ -124,6 +134,9 @@ export {\n   JsonRpcRequestChannel,\n   type JsonRpcRequestChannelOptions,\n   type JsonRpcTransport,\n+  type ServerRequest,\n+  type ServerRequestHandler,\n+  type ServerRequestParams,\n   wireFrameText\n } from './json-rpc-channel'\n export {"]},{"file":"apps/shared/src/json-rpc-channel.test.ts","hunks":["@@ -170,4 +170,50 @@ describe('JsonRpcRequestChannel', () => {\n       vi.useRealTimers()\n     }\n   })\n+\n+  // Server→client requests (tui_gateway/server_requests.py): the backend asks,\n+  // the client answers with a RESPONSE frame carrying the same id.\n+  it('routes a server request to the first accepting handler and answers -32601 when nobody accepts', () => {\n+    const unhandled: string[] = []\n+    const channel = new JsonRpcRequestChannel({ onUnhandledRequest: req => void unhandled.push(req.method) })\n+    const { sent, transport } = spyTransport()\n+\n+    channel.attach(transport)\n+    channel.onRequest(req => (req.method === 'clarify' ? void req.respond({ answer: 'yes' }) : false))\n+\n+    channel.handleFrame(JSON.stringify({ id: 'srq-1', jsonrpc: '2.0', method: 'clarify', params: { session_id: 's1' } }))\n+    channel.handleFrame(JSON.stringify({ id: 'srq-2', jsonrpc: '2.0', method: 'tour', params: { session_id: 's1' } }))\n+\n+    const frames = sent.map(f => JSON.parse(f) as { id: string; result?: unknown; error?: { code: number } })\n+\n+    expect(frames[0]).toEqual({ id: 'srq-1', jsonrpc: '2.0', result: { answer: 'yes' } })\n+    expect(frames[1].id).toBe('srq-2')\n+    expect(frames[1].error?.code).toBe(-32601)\n+    expect(unhandled).toEqual(['tour'])\n+  })\n+\n+  it('re-delivers open_requests from a response before the caller sees the result, tagged replayed', async () => {\n+    const delivered: Array<{ id: string; replayed?: boolean }> = []\n+    const channel = new JsonRpcRequestChannel()\n+    const { sent, transport } = spyTransport()\n+\n+    channel.attach(transport)\n+    channel.onRequest(req => void delivered.push({ id: req.id, replayed: req.replayed }))\n+\n+    const resume = channel.request<{ session_id: string }>('session.resume', { session_id: 's1' })\n+    const rid = (JSON.parse(sent.at(-1)!) as { id: string }).id\n+\n+    channel.handleFrame(\n+      JSON.stringify({\n+        id: rid,\n+        jsonrpc: '2.0',\n+        result: {\n+          open_requests: [{ id: 'srq-9', method: 'sudo', params: { session_id: 's1' } }],\n+          session_id: 's1'\n+        }\n+      })\n+    )\n+    await expect(resume).resolves.toMatchObject({ session_id: 's1' })\n+    expect(delivered).toEqual([{ id: 'srq-9', replayed: true }])\n+  })\n })"]},{"file":"apps/shared/src/json-rpc-channel.ts","hunks":["@@ -12,10 +12,47 @@ export interface JsonRpcFrame {\n   error?: JsonRpcErrorPayload\n   id?: GatewayRequestId | null\n   method?: string\n-  params?: GatewayEvent\n+  params?: GatewayEvent | ServerRequestParams\n   result?: unknown\n }\n \n+/**\n+ * Params of a server→client request (`tui_gateway/server_requests.py`): the\n+ * backend asking the renderer a question. `session_id` names the session\n+ * blocked on the answer; the rest is method-specific.\n+ */\n+export interface ServerRequestParams extends Record<string, unknown> {\n+  session_id?: string\n+}\n+\n+/** One inbound server→client request, as handed to a `ServerRequestHandler`. */\n+export interface ServerRequest<M extends string = string, P extends ServerRequestParams = ServerRequestParams> {\n+  id: string\n+  method: M\n+  params: P\n+  /**\n+   * Route the answer back to the backend that asked. Idempotent: the first\n+   * `respond` (or `fail`) wins; a request re-delivered after a reconnect\n+   * (`open_requests`) reuses the id, so a stale card answering twice is a\n+   * no-op on the wire.\n+   */\n+  respond: (result: Record<string, unknown>) => void\n+  /** Answer with a JSON-RPC error (the backend treats it as unanswered). */\n+  fail: (code: number, message: string) => void\n+  /**\n+   * Renderer-side tag set by the owner when a request arrives through a\n+   * replay (`open_requests`) rather than live; handlers that already show the\n+   * card can skip re-notifying.\n+   */\n+  replayed?: boolean\n+}\n+\n+/** Handles one inbound server→client request; return `false` to decline (next handler tries). */\n+export type ServerRequestHandler = (request: ServerRequest) => boolean | void\n+\n+const isServerRequestFrame = (frame: JsonRpcFrame): frame is JsonRpcFrame & { id: string; method: string } =>\n+  typeof frame.id === 'string' && typeof frame.method === 'string' && frame.method !== 'event'\n+\n /** JSON-RPC error with optional structured `data` from the gateway. */\n export class JsonRpcGatewayError extends Error {\n   readonly code?: number","@@ -59,6 +96,12 @@ export interface JsonRpcRequestChannelOptions {\n   onHeartbeatFailure?: (error: Error) => void\n   /** Decoded `event` notification. */\n   onEvent?: (event: GatewayEvent) => void\n+  /**\n+   * Inbound server→client request nobody handled: the owner logs it. The\n+   * channel has already answered `-32601` so the backend does not wait out\n+   * its deadline against a client with no handler.\n+   */\n+  onUnhandledRequest?: (request: { id: string; method: string; params: ServerRequestParams }) => void\n   requestIdPrefix?: string\n   requestTimeoutMs?: number\n   /**","@@ -130,8 +173,11 @@ export class JsonRpcRequestChannel {\n   private heartbeatSequence = 0\n   private readonly outstandingPings = new Set<string>()\n   private lastLivenessAt = 0\n-  private readonly options: Required<Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'>> &\n-    Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'>\n+  private readonly requestHandlers: ServerRequestHandler[] = []\n+  private readonly options: Required<\n+    Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'>\n+  > &\n+    Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'>\n \n   constructor(options: JsonRpcRequestChannelOptions = {}) {\n     this.options = {","@@ -141,6 +187,7 @@ export class JsonRpcRequestChannel {\n       heartbeatLiveness: options.heartbeatLiveness ?? 'response',\n       onEvent: options.onEvent,\n       onHeartbeatFailure: options.onHeartbeatFailure,\n+      onUnhandledRequest: options.onUnhandledRequest,\n       requestIdPrefix: options.requestIdPrefix ?? 'r',\n       requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,\n       unrefTimers: options.unrefTimers ?? false","@@ -254,11 +301,89 @@ export class JsonRpcRequestChannel {\n     })\n   }\n \n+  /**\n+   * Register a handler for server→client requests (clarify, approval, sudo,\n+   * …). Handlers are tried in registration order until one accepts (returns\n+   * anything but `false`); an unhandled request is answered `-32601` so the\n+   * backend never waits out its deadline against a client that cannot answer.\n+   */\n+  onRequest(handler: ServerRequestHandler): () => void {\n+    this.requestHandlers.push(handler)\n+\n+    return () => {\n+      const index = this.requestHandlers.indexOf(handler)\n+\n+      if (index >= 0) {\n+        this.requestHandlers.splice(index, 1)\n+      }\n+    }\n+  }\n+\n+  /**\n+   * Deliver a server request to the handlers. Live frames arrive through\n+   * `handleFrame`; owners call this directly for `open_requests` returned by a\n+   * reconnect replay (`replayed: true`) so an unanswered question survives a\n+   * dropped socket.\n+   */\n+  deliverRequest(id: string, method: string, params: ServerRequestParams, replayed = false): boolean {\n+    let settled = false\n+\n+    const send = (frame: Record<string, unknown>) => {\n+      if (settled) {\n+        return\n+      }\n+\n+      settled = true\n+\n+      try {\n+        this.transport?.send(JSON.stringify({ jsonrpc: '2.0', id, ...frame }))\n+      } catch {\n+        // The generation is gone; the backend withdraws the request itself (timeout / reconnect replay).\n+      }\n+    }\n+\n+    const request: ServerRequest = {\n+      id,\n+      method,\n+      params,\n+      replayed,\n+      respond: result => send({ result }),\n+      fail: (code, message) => send({ error: { code, message } })\n+    }\n+\n+    for (const handler of this.requestHandlers) {\n+      if (handler(request) !== false) {\n+        return true\n+      }\n+    }\n+\n+    request.fail(JSON_RPC_METHOD_NOT_FOUND, `no handler for server request: ${method}`)\n+    this.options.onUnhandledRequest?.({ id, method, params })\n+\n+    return false\n+  }\n+\n+  private deliverOpenRequests(result: unknown): void {\n+    const open = (result as { open_requests?: unknown } | null)?.open_requests\n+\n+    if (!Array.isArray(open)) {\n+      return\n+    }\n+\n+    for (const entry of open as Array<{ id?: unknown; method?: unknown; params?: unknown }>) {\n+      if (typeof entry?.id === 'string' && typeof entry.method === 'string') {\n+        const params = entry.params && typeof entry.params === 'object' ? (entry.params as ServerRequestParams) : {}\n+        this.deliverRequest(entry.id, entry.method, params, true)\n+      }\n+    }\n+  }\n+\n   /**\n    * Route one inbound frame: a response settles its pending call, an\n-   * `event` notification reaches `onEvent`. Returns the decoded frame so the\n-   * owner can act on it too (mirror it, record seq, …) or `null` when the\n-   * text was not JSON or not a JSON object (`null`, a scalar).\n+   * `event` notification reaches `onEvent`, a server→client request reaches\n+   * the `onRequest` handlers. Returns the decoded frame so the owner can act\n+   * on it too (mirror it, record seq, …) or `null` when the text was not\n+   * JSON or not a JSON object (`null`, a scalar).\n    */\n   handleFrame(text: string): JsonRpcFrame | null {\n     let frame: JsonRpcFrame","@@ -277,6 +402,13 @@ export class JsonRpcRequestChannel {\n       this.lastLivenessAt = Date.now()\n     }\n \n+    if (isServerRequestFrame(frame)) {\n+      const params = frame.params && typeof frame.params === 'object' ? (frame.params as ServerRequestParams) : {}\n+      this.deliverRequest(frame.id, frame.method, params)\n+\n+      return frame\n+    }\n+\n     if (frame.id !== undefined && frame.id !== null) {\n       if (typeof frame.id === 'string' && this.outstandingPings.delete(frame.id)) {\n         this.lastLivenessAt = Date.now()","@@ -293,6 +425,13 @@ export class JsonRpcRequestChannel {\n         if (frame.error) {\n           call.reject(jsonRpcErrorFromFrame(frame.error))\n         } else {\n+          // Reconnect contract: `session.resume` / `session.activate` /\n+          // `session.events.since` answer with `open_requests` — the server→\n+          // client requests still waiting on this session. They cannot ride\n+          // the event replay ring (they are not events), so they are re-\n+          // delivered here, before the caller sees the result, over the very\n+          // socket that owns them.\n+          this.deliverOpenRequests(frame.result)\n           call.resolve(frame.result)\n         }\n       }","@@ -300,8 +439,8 @@ export class JsonRpcRequestChannel {\n       return frame\n     }\n \n-    if (frame.method === 'event' && frame.params && typeof frame.params.type === 'string') {\n-      this.options.onEvent?.(frame.params)\n+    if (frame.method === 'event' && frame.params && typeof (frame.params as GatewayEvent).type === 'string') {\n+      this.options.onEvent?.(frame.params as GatewayEvent)\n     }\n \n     return frame"]},{"file":"apps/shared/src/json-rpc-gateway.ts","hunks":["@@ -5,6 +5,7 @@ import {\n   type GatewayRequestId,\n   JsonRpcRequestChannel,\n   type JsonRpcTransport,\n+  type ServerRequestHandler,\n   wireFrameText\n } from './json-rpc-channel.js'\n ","@@ -343,6 +344,15 @@ export class JsonRpcGatewayClient {\n     return this.onAny(handler)\n   }\n \n+  /**\n+   * Server→client requests (clarify, approval, sudo, …). Live frames and\n+   * `open_requests` re-delivered after a reconnect both arrive here; the\n+   * latter carry `replayed: true`.\n+   */\n+  onRequest(handler: ServerRequestHandler): () => void {\n+    return this.channel.onRequest(handler)\n+  }\n+\n   onState(handler: (state: ConnectionState) => void): () => void {\n     this.stateHandlers.add(handler)\n     handler(this.state)","@@ -445,6 +455,7 @@ export class JsonRpcGatewayClient {\n       // One RPC per known session keeps params flat; sessions are few (<20).\n       const results = await Promise.allSettled(\n         entries.map(([sid, lastSeen]) =>\n+          // `open_requests` on the answer are re-delivered by the channel itself.\n           this.request<{ events?: Array<{ type: string; session_id?: string; seq?: number; payload?: unknown }> }>(\n             'session.events.since',\n             { session_id: sid, last_seen: lastSeen },"]},{"file":"tests/gateway/test_tui_approval_redaction.py","hunks":["@@ -15,24 +15,28 @@\n \n \n class TestTuiApprovalEmitRedaction:\n-    def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch):\n-        from tui_gateway import server as tui_server\n+    @staticmethod\n+    def _sent(monkeypatch):\n+        \"\"\"Capture the ``approval`` server request frame ``_emit_approval_request`` sends.\"\"\"\n+        from tui_gateway import server as tui_server, server_requests\n+\n+        sent = {}\n+        monkeypatch.setattr(server_requests, \"send_async\",\n+                            lambda method, sid, params, on_result: (sent.update(method=method, sid=sid, params=params),\n+                                                                    lambda reason: None)[1])\n+        monkeypatch.setattr(tui_server, \"_sessions\", {\"sess-1\": {\"session_key\": \"key-1\"}})\n+        return tui_server, sent\n \n-        emitted = {}\n-        monkeypatch.setattr(\n-            tui_server, \"_emit\",\n-            lambda event, sid, payload=None: emitted.update(\n-                {\"event\": event, \"sid\": sid, \"payload\": payload}\n-            ),\n-        )\n+    def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch):\n+        tui_server, sent = self._sent(monkeypatch)\n         raw = \"curl -H 'Authorization: token ghp_01...6789' https://api.github.com\"\n         tui_server._emit_approval_request(\"sess-1\", {\"command\": raw, \"description\": \"x\"})\n \n-        assert emitted[\"event\"] == \"approval.request\"\n+        assert sent[\"method\"] == \"approval\" and sent[\"sid\"] == \"sess-1\"\n         # credential removed, non-command field + command structure preserved\n-        assert \"ghp_01...6789\" not in emitted[\"payload\"][\"command\"]\n-        assert emitted[\"payload\"][\"description\"] == \"x\"\n-        assert \"github.com\" in emitted[\"payload\"][\"command\"]\n+        assert \"ghp_01...6789\" not in sent[\"params\"][\"command\"]\n+        assert sent[\"params\"][\"description\"] == \"x\"\n+        assert \"github.com\" in sent[\"params\"][\"command\"]\n \n     @pytest.mark.parametrize(\n         (\"allow_session\", \"allow_permanent\", \"expected\"),","@@ -45,23 +49,10 @@ def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch):\n     def test_emit_approval_request_honors_allowed_scopes(\n         self, monkeypatch, allow_session, allow_permanent, expected\n     ):\n-        from tui_gateway import server as tui_server\n-\n-        emitted = {}\n-        monkeypatch.setattr(\n-            tui_server,\n-            \"_emit\",\n-            lambda event, sid, payload=None: emitted.update({\"payload\": payload}),\n-        )\n-\n+        tui_server, sent = self._sent(monkeypatch)\n         tui_server._emit_approval_request(\n             \"sess-1\",\n-            {\n-                \"allow_permanent\": allow_permanent,\n-                \"allow_session\": allow_session,\n-                \"command\": \"<write to AGENTS.md>\",\n-            },\n+            {\"allow_permanent\": allow_permanent, \"allow_session\": allow_session, \"command\": \"<write to AGENTS.md>\"},\n         )\n \n-        assert emitted[\"payload\"][\"choices\"] == expected\n-\n+        assert sent[\"params\"][\"choices\"] == expected"]},{"file":"tests/tui_gateway/test_compute_host_phase1.py","hunks":["@@ -48,40 +48,38 @@ def test_compute_host_workers_inherit_tui_pool_env_or_8(monkeypatch):\n     assert _default_workers() == 8\n \n \n-def test_compute_host_routes_clarify_response_to_child_pending_registry(monkeypatch):\n-    \"\"\"Interactive answers are handled in the process that owns `_pending`.\"\"\"\n+def test_compute_host_routes_relayed_response_and_lock_to_its_open_request(monkeypatch):\n+    \"\"\"The child owns the server request's wait: a relayed client response frame resolves it in-process,\n+    and a relayed ``clarify.lock`` is answered with that method's result for the parent to ack.\"\"\"\n+    from tui_gateway import server_requests\n     out = io.StringIO()\n     host = ComputeHost(stdout=out, heartbeat_secs=0)\n     sid = \"host-clarify\"\n     server._sessions[sid] = {\"history_lock\": threading.Lock()}\n-    calls = []\n-    monkeypatch.setitem(\n-        server._methods,\n-        \"clarify.respond\",\n-        lambda rid, params: calls.append((rid, dict(params))) or {\"result\": {\"status\": \"ok\"}},\n-    )\n+    req = server_requests.ServerRequest(sid, \"clarify\", {\"question\": \"?\"})\n+    with server_requests._lock:\n+        server_requests._open[req.id] = req\n+    locks = []\n+    monkeypatch.setitem(server._methods, \"clarify.lock\",\n+                        lambda rid, params: locks.append((rid, dict(params))) or {\"result\": {\"status\": \"ok\", \"remaining\": []}})\n \n     try:\n-        host._handle_respond(\n-            {\n-                \"sid\": sid,\n-                \"request_id\": \"relay-response\",\n-                \"params\": {\"request_id\": \"clarify-request\", \"answer\": \"yes\"},\n-            }\n-        )\n-        assert calls == [(\"relay-response\", {\"request_id\": \"clarify-request\", \"answer\": \"yes\"})]\n+        host._handle_respond({\"sid\": sid, \"request_id\": \"relay-lock\",\n+                              \"params\": {\"lock\": {\"request_id\": req.id, \"question_id\": \"q0\", \"answer\": \"a\"}}})\n+        assert locks == [(\"relay-lock\", {\"request_id\": req.id, \"question_id\": \"q0\", \"answer\": \"a\"})]\n+        assert _json_lines(out)[-1][\"response\"] == {\"result\": {\"status\": \"ok\", \"remaining\": []}}\n+\n+        host._handle_respond({\"sid\": sid, \"request_id\": \"relay-response\",\n+                              \"params\": {\"frame\": {\"jsonrpc\": \"2.0\", \"id\": req.id, \"result\": {\"answer\": \"yes\"}}}})\n+        assert req.answered and req.result == {\"answer\": \"yes\"} and req.event.is_set()\n         frame = _json_lines(out)[-1]\n-        assert frame == {\n-            \"type\": \"respond.ack\",\n-            \"sid\": sid,\n-            \"request_id\": \"relay-response\",\n-            \"response\": {\"result\": {\"status\": \"ok\"}},\n-            \"host_ns\": frame[\"host_ns\"],\n-        }\n+        assert frame[\"type\"] == \"respond.ack\" and frame[\"response\"][\"result\"] == {\"status\": \"ok\"}\n     finally:\n         server._sessions.pop(sid, None)\n+        server_requests.reset_for_tests()\n         host.close()\n \n+\n def test_mutator_route_table_matches_prd_inventory():\n     assert MUTATOR_ROUTE_TABLE == {\n         \"prompt.submit\": \"turn-path\","]},{"file":"tests/tui_gateway/test_gateway_event_contract.py","hunks":["@@ -1,16 +1,17 @@\n-\"\"\"Two-sided contract: every notification name ``tui_gateway`` emits is listed in\n-``apps/shared/src/gateway-events.json`` and nothing in the JSON is orphaned.\n+\"\"\"Two-sided contract: every notification name ``tui_gateway`` emits, and every\n+server→client request method it sends, is listed in ``apps/shared/src/gateway-events.json``\n+(``events`` / ``server_requests``) and nothing in the JSON is orphaned.\n \n The TypeScript half (``apps/shared/src/gateway-events.test.ts``) pins the typed\n-``GatewayEventMap`` to the same JSON, so a name added on either side alone goes red\n-somewhere. This file reads only Python sources and the JSON (never ``.ts`` text —\n-see ``tui_gateway/AGENTS.md``).\n-\n-Names are collected from the emitter side: literal first arguments to the emit\n-helpers, plus the tables that derive names at runtime (``_EXPIRING_REQUESTS`` →\n-``*.expire``, the change-watcher table, child delta mirroring, the subagent relay\n-events from ``tools/delegate_tool*.py``, the ``desktop_ui`` tool emitters, and the\n-literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames).\n+``GatewayEventMap`` and ``ServerRequestMap`` to the same JSON, so a name added on either\n+side alone goes red somewhere. This file reads only Python sources and the JSON (never\n+``.ts`` text — see ``tui_gateway/AGENTS.md``).\n+\n+Names are collected from the emitter side: literal first arguments to the emit helpers\n+and the server-request helpers (``server_requests.send`` / ``send_async`` / ``_ask``),\n+plus the tables that derive names at runtime (the change-watcher table, child delta\n+mirroring, the subagent relay events from ``tools/delegate_tool*.py``, the ``desktop_ui``\n+tool emitters, and the literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames).\n \"\"\"\n \n from __future__ import annotations","@@ -26,9 +27,12 @@\n GATEWAY_DIR = REPO / \"tui_gateway\"\n \n # Every helper whose first positional argument is the wire ``type``.\n-_EMIT_HELPERS = (\n-    \"_emit\", \"_block\", \"_read_block\", \"_broadcast_global_event\", \"_voice_emit\", \"_pet_emit\", \"_emit_tool_lifecycle\")\n+_EMIT_HELPERS = (\"_emit\", \"_broadcast_global_event\", \"_voice_emit\", \"_pet_emit\", \"_emit_tool_lifecycle\")\n _LITERAL_EMIT = re.compile(r\"\\b(?:%s)\\(\\s*\\\"([a-z_][a-z0-9_.]*)\\\"\" % \"|\".join(_EMIT_HELPERS))\n+# Server→client requests: ``server_requests.send(\"x\", …)`` / ``send_async`` / the string-answer ``_ask`` and\n+# ``_read_block`` bridges.\n+_REQUEST_HELPERS = (\"server_requests\\\\.send\", \"server_requests\\\\.send_async\", \"_ask\", \"_read_block\")\n+_LITERAL_REQUEST = re.compile(r\"\\b(?:%s)\\(\\s*\\\"([a-z_][a-z0-9_.]*)\\\"\" % \"|\".join(_REQUEST_HELPERS))\n # ``{\"type\": \"gateway.ready\", ...}`` literal frames (entry.py / ws.py) and other\n # ``\"type\": \"<name>\"`` params written straight into an ``event`` frame.\n _LITERAL_FRAME = re.compile(r\"\\\"method\\\":\\s*\\\"event\\\".{0,120}?\\\"type\\\":\\s*\\\"([a-z_][a-z0-9_.]*)\\\"\", re.S)","@@ -50,10 +54,6 @@ def emitted_event_names() -> set[str]:\n         names.update(_LITERAL_EMIT.findall(text))\n         names.update(_LITERAL_FRAME.findall(text))\n         names.update(_SIDE_AGENT.findall(text))\n-    # ``.request`` bridges that time out fire ``f\"{event.removesuffix('.request')}.expire\"``.\n-    from tui_gateway.server import _EXPIRING_REQUESTS\n-\n-    names.update(f\"{event.removesuffix('.request')}.expire\" for event in _EXPIRING_REQUESTS)\n     from tui_gateway.change_watcher import _CHANGE_WATCHES\n \n     names.update(_CHANGE_WATCHES)","@@ -74,13 +74,33 @@ def emitted_event_names() -> set[str]:\n     return names\n \n \n+def server_request_methods() -> set[str]:\n+    names: set[str] = set()\n+    for src in GATEWAY_DIR.glob(\"*.py\"):\n+        names.update(_LITERAL_REQUEST.findall(_read(src)))\n+    return names\n+\n+\n @pytest.fixture(scope=\"module\")\n def contract() -> list[str]:\n-    return json.loads(_read(CONTRACT))\n+    return json.loads(_read(CONTRACT))[\"events\"]\n+\n+\n[redacted:email](scope=\"module\")\n+def request_contract() -> list[str]:\n+    return json.loads(_read(CONTRACT))[\"server_requests\"]\n+\n+\n+def test_contract_is_sorted_and_unique(contract, request_contract):\n+    assert contract == sorted(set(contract)), \"gateway-events.json events must be a sorted, duplicate-free list\"\n+    assert request_contract == sorted(set(request_contract)), \"gateway-events.json server_requests must be sorted\"\n \n \n-def test_contract_is_sorted_and_unique(contract):\n-    assert contract == sorted(set(contract)), \"gateway-events.json must be a sorted, duplicate-free list\"\n+def test_server_request_methods_match_the_contract(request_contract):\n+    sent = server_request_methods()\n+    assert sent == set(request_contract), (\n+        f\"server requests sent {sorted(sent)} vs gateway-events.json server_requests {request_contract}; \"\n+        \"fix the JSON AND SERVER_REQUEST_METHODS / ServerRequestMap in apps/shared/src/gateway-events.ts\")\n \n \n def test_every_emitted_event_is_in_the_contract(contract):"]},{"file":"tests/tui_gateway/test_goal_command.py","hunks":["@@ -70,8 +70,7 @@ def server(hermes_home, monkeypatch):\n     # _enter_buffered_busy. Clearing the per-session dicts gives the\n     # next test a clean slate.\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n \n \n @pytest.fixture()"]},{"file":"tests/tui_gateway/test_hosted_room_member_activity_hook.py","hunks":["@@ -57,7 +57,10 @@ def test_room_member_session_events_reach_plugins_with_room_coordinates(observer\n     _session(\"room-sid\", hosted=True)\n     try:\n         server._emit(\"tool.start\", \"room-sid\", {\"tool_id\": \"call-1\", \"name\": \"terminal\", \"args\": {\"command\": \"ls\"}})\n-        server._emit(\"approval.request\", \"room-sid\", {\"request_id\": \"req-1\", \"command\": \"rm -rf build\"})\n+        # An approval is a server→client REQUEST frame, not an event; it is member activity all the same.\n+        from tui_gateway import server_requests\n+        server_requests.send_async(\"approval\", \"room-sid\", {\"request_id\": \"req-1\", \"command\": \"rm -rf build\"},\n+                                   lambda result: None)(\"test_done\")\n         server._emit(\"session.info\", \"room-sid\", {\"title\": \"chrome, not member activity\"})\n         server._emit(\"tool.complete\", \"room-sid\", {\"tool_id\": \"call-1\", \"name\": \"terminal\", \"result\": \"ok\"})\n     finally:","@@ -71,8 +74,9 @@ def test_room_member_session_events_reach_plugins_with_room_coordinates(observer\n     assert {k: started[k] for k in HOSTED_TASK} == HOSTED_TASK\n     assert started[\"payload\"][\"tool_id\"] == \"call-1\" and started[\"payload\"][\"args\"] == {\"command\": \"ls\"}\n     assert observer[1][\"payload\"][\"request_id\"] == \"req-1\"\n-    # Per-session replay seq travels with the event so consumers can order/dedupe.\n-    assert [event[\"seq\"] for event in observer] == sorted(event[\"seq\"] for event in observer)\n+    # Per-session replay seq travels with the events so consumers can order/dedupe (a request frame carries none).\n+    seqs = [event[\"seq\"] for event in observer if event[\"seq\"] is not None]\n+    assert seqs == sorted(seqs) and len(seqs) == 2\n \n \n def test_ordinary_session_events_never_fire_the_room_hook(observer):"]},{"file":"tests/tui_gateway/test_inline_rpc_gil_starvation.py","hunks":["@@ -56,8 +56,7 @@ def server():\n     mod._methods.update(methods)\n     mod._real_stdout = real_stdout\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n \n \n @pytest.fixture()"]},{"file":"tests/tui_gateway/test_loop_command.py","hunks":["@@ -44,8 +44,7 @@ def server(hermes_home):\n         mod = importlib.import_module(\"tui_gateway.server\")\n         yield mod\n         mod._sessions.clear()\n-        mod._pending.clear()\n-        mod._answers.clear()\n+        __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n \n \n @pytest.fixture()"]},{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -56,8 +56,8 @@ def server():\n     mod._real_stdout = real_stdout\n     for sid in list(mod._sessions):\n         mod._close_session_by_id(sid, end_reason=\"test_cleanup\")\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    from tui_gateway import server_requests\n+    server_requests.reset_for_tests()\n     mod._live_transports.clear()\n \n ","@@ -219,42 +219,26 @@ def test_live_session_payload_replays_pending_approval(server, monkeypatch):\n     assert replayed == first\n \n \n-def test_live_session_payload_replays_pending_clarify(server):\n-    \"\"\"A reattached client also receives a clarify question emitted while detached.\"\"\"\n+def test_live_session_payload_replays_open_requests(server):\n+    \"\"\"A reattached client receives the server→client request still blocking the session (a clarify sent\n+    while detached), scoped to the owning runtime session only, as a snapshot not a live reference.\"\"\"\n+    from tui_gateway import server_requests\n     session = {\n-        \"agent\": types.SimpleNamespace(),\n-        \"cols\": 80,\n-        \"created_at\": 1.0,\n-        \"history\": [],\n-        \"history_lock\": threading.Lock(),\n-        \"running\": True,\n-        \"session_key\": \"stored-session\",\n+        \"agent\": types.SimpleNamespace(), \"cols\": 80, \"created_at\": 1.0, \"history\": [],\n+        \"history_lock\": threading.Lock(), \"running\": True, \"session_key\": \"stored-session\",\n     }\n-    clarify_payload = {\n-        \"choices\": [\"staging\", \"production\"],\n-        \"question\": \"Which deployment target?\",\n-        \"request_id\": \"rid-clarify\",\n-    }\n-    with server._prompt_lock:\n-        server._pending[\"rid-clarify\"] = (\"runtime-session\", threading.Event())\n-        server._pending_prompt_payloads[\"rid-clarify\"] = (\n-            \"clarify.request\",\n-            dict(clarify_payload),\n-        )\n-\n+    req = server_requests.ServerRequest(\"runtime-session\", \"clarify\", {\"question\": \"Which?\", \"choices\": [\"a\", \"b\"]})\n+    server_requests._open[req.id] = req\n     try:\n         payload = server._live_session_payload(\"runtime-session\", session)\n         other = server._live_session_payload(\"other-session\", session)\n     finally:\n-        with server._prompt_lock:\n-            server._pending.pop(\"rid-clarify\", None)\n-            server._pending_prompt_payloads.pop(\"rid-clarify\", None)\n+        server_requests._open.pop(req.id, None)\n \n-    assert payload[\"pending_clarify\"] == clarify_payload\n-    # Snapshot, not a live reference into the registry.\n-    assert payload[\"pending_clarify\"] is not clarify_payload\n-    # Scoped to the owning runtime session only.\n-    assert \"pending_clarify\" not in other\n+    assert payload[\"open_requests\"] == [{\"id\": req.id, \"method\": \"clarify\",\n+                                         \"params\": {\"session_id\": \"runtime-session\", \"question\": \"Which?\", \"choices\": [\"a\", \"b\"]}}]\n+    assert payload[\"open_requests\"][0][\"params\"] is not req.params\n+    assert \"open_requests\" not in other\n \n \n def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):","@@ -290,275 +274,145 @@ def test_emit_with_payload(capture):\n     assert msg[\"params\"][\"payload\"][\"key\"] == \"val\"\n \n \n-# ── Blocking prompt round-trip ───────────────────────────────────────\n+# ── Server→client requests (tui_gateway/server_requests.py) ─────────\n \n \n-def test_block_and_respond(capture):\n-    server, _ = capture\n-    result = [None]\n+def _frames(buf):\n+    return [json.loads(line) for line in buf.getvalue().splitlines()]\n \n-    threading.Thread(\n-        target=lambda: result.__setitem__(0, server._block(\"test.prompt\", \"s1\", {\"q\": \"?\"}, timeout=5)),\n-    ).start()\n \n-    for _ in range(100):\n-        if server._pending:\n-            break\n-        threading.Event().wait(0.01)\n-\n-    rid = next(iter(server._pending))\n-    server._answers[rid] = \"my_answer\"\n-    # _pending values are (sid, Event) tuples — unpack to set the Event\n-    _, ev = server._pending[rid]\n-    ev.set()\n-\n-    threading.Event().wait(0.1)\n-    assert result[0] == \"my_answer\"\n+def _wait_open(server_requests, buf=None, timeout=2.0):\n+    \"\"\"The open request once its frame has been written (registration precedes the write).\"\"\"\n+    deadline = time.monotonic() + timeout\n+    while time.monotonic() < deadline:\n+        with server_requests._lock:\n+            req = next(iter(server_requests._open.values()), None)\n+        if req is not None and (buf is None or req.id in buf.getvalue()):\n+            return req\n+        time.sleep(0.01)\n+    raise AssertionError(\"server request never registered\")\n \n \n[redacted:email](\n-    \"event\",\n-    [\"secret.request\", \"sudo.request\", \"clarify.request\", \"terminal.read.request\"],\n-)\n-def test_sensitive_prompt_timeout_emits_expiry(capture, event):\n+def test_server_request_round_trip_uses_response_frame(capture):\n+    \"\"\"The backend sends a real JSON-RPC request (string id, method, params.session_id) and the client's\n+    response frame — not a method call — resolves it; the response frame itself gets no reply.\"\"\"\n+    from tui_gateway import server_requests\n     server, buf = capture\n+    box = {}\n+    thread = threading.Thread(target=lambda: box.__setitem__(\"r\", server._ask(\"sudo\", \"s1\", {}, timeout=5)), daemon=True)\n+    thread.start()\n+    req = _wait_open(server_requests, buf)\n+    frame = _frames(buf)[-1]\n+    assert frame == {\"jsonrpc\": \"2.0\", \"id\": req.id, \"method\": \"sudo\", \"params\": {\"session_id\": \"s1\"}}\n+    assert req.id.startswith(\"srq-\")\n \n-    assert server._block(event, \"s1\", {}, timeout=0) == \"\"\n-\n-    messages = [json.loads(line) for line in buf.getvalue().splitlines()]\n-    request, expiry = [message[\"params\"] for message in messages]\n-    assert request[\"type\"] == event\n-    assert expiry[\"type\"] == event.removesuffix(\".request\") + \".expire\"\n-    assert expiry[\"session_id\"] == \"s1\"\n-    assert expiry[\"payload\"][\"request_id\"] == request[\"payload\"][\"request_id\"]\n+    assert server.dispatch({\"jsonrpc\": \"2.0\", \"id\": req.id, \"result\": {\"value\": \"hunter2\"}}) is None\n+    thread.join(timeout=5)\n+    assert box[\"r\"] == \"hunter2\"\n+    with server_requests._lock:\n+        assert not server_requests._open\n \n \n[redacted:email](\n-    (\"method\", \"value_key\"),\n-    [\n-        (\"secret.respond\", \"value\"),\n-        (\"sudo.respond\", \"password\"),\n-        (\"clarify.respond\", \"answer\"),\n-        (\"terminal.read.respond\", \"text\"),\n-    ],\n-)\n-def test_late_prompt_response_is_idempotent(server, method, value_key):\n-    \"\"\"All four blocking bridges tolerate a late reply after their request has\n-    expired — the `*.respond` returns a graceful `{\"status\": \"expired\"}` instead\n-    of the raw 4009 protocol error a client would otherwise surface verbatim.\"\"\"\n-    response = server.handle_request(\n-        {\n-            \"id\": \"late-response\",\n-            \"method\": method,\n-            \"params\": {\"request_id\": \"expired-request\", value_key: \"\"},\n-        }\n-    )\n-\n[redacted:email](\"method\", [\"secret\", \"sudo\", \"terminal.read\", \"tour\"])\n+def test_server_request_timeout_emits_one_request_cancel(capture, method):\n+    from tui_gateway import server_requests\n+    server, buf = capture\n+    assert server_requests.send(method, \"s1\", {}, timeout=0) is None\n+    request, cancel = _frames(buf)\n+    assert request[\"method\"] == method\n+    assert cancel[\"params\"][\"type\"] == \"request.cancel\"\n+    assert cancel[\"params\"][\"session_id\"] == \"s1\"\n+    assert cancel[\"params\"][\"payload\"] == {\"id\": request[\"id\"], \"method\": method, \"reason\": \"timeout\"}\n+\n+\n+def test_late_response_and_lock_are_dropped_quietly(server):\n+    \"\"\"A response for an id that already ended is ignored (no reply, no error); a late clarify.lock\n+    answers `expired` instead of a protocol error a card would surface verbatim.\"\"\"\n+    assert server.dispatch({\"jsonrpc\": \"2.0\", \"id\": \"srq-gone\", \"result\": {\"value\": \"\"}}) is None\n+    response = server.handle_request({\"id\": \"late\", \"method\": \"clarify.lock\",\n+                                      \"params\": {\"request_id\": \"srq-gone\", \"question_id\": \"q0\", \"answer\": \"\"}})\n     assert response[\"result\"] == {\"status\": \"expired\"}\n \n \n-# ── clarify batch (multi-question) bridge ────────────────────────────\n-\n-\n-def _drain_batch_block(server, qids, timeout=5, payload=None):\n-    \"\"\"Run a batch _block on a worker thread and return (thread, result box,\n-    emitted request payload). The caller resolves questions via\n-    handle_request and then joins.\"\"\"\n+def _start_batch_clarify(server, buf, qids, timeout=None):\n+    from tui_gateway import server_requests\n     box = {}\n-\n-    def run():\n-        box[\"answer\"] = server._block(\n-            \"clarify.request\",\n-            \"s1\",\n-            dict(payload or {\"questions\": [{\"qid\": q, \"question\": q} for q in qids]}),\n-            timeout=timeout,\n-            batch_qids=list(qids),\n-        )\n-\n-    thread = threading.Thread(target=run, daemon=True)\n+    normalized = [{\"qid\": q, \"id\": \"\", \"question\": q, \"choices\": None, \"choices_offered\": [], \"multi_select\": False}\n+                  for q in qids]\n+    if timeout is not None:\n+        server._clarify_timeout_seconds = lambda: timeout\n+    thread = threading.Thread(\n+        target=lambda: box.__setitem__(\"answer\", server._clarify_block(\"s1\", \"\", None, questions=normalized)), daemon=True)\n     thread.start()\n-    # Wait for the request to be registered so respond calls can find it.\n-    deadline = time.monotonic() + 2\n-    while time.monotonic() < deadline:\n-        with server._prompt_lock:\n-            if server._batch_clarify:\n-                rid = next(iter(server._batch_clarify))\n-                return thread, box, rid\n-        time.sleep(0.01)\n-    raise AssertionError(\"batch clarify request never registered\")\n+    return thread, box, _wait_open(server_requests, buf)\n \n \n-def test_clarify_batch_resolves_when_all_questions_locked(capture):\n+def test_clarify_batch_locks_resolve_in_order_and_keep_partial_on_timeout(capture):\n+    \"\"\"Per-question locks (clarify.lock) are editable until every qid is locked; the last lock resolves the\n+    request with the full answer set. Only wire fields reach the renderer.\"\"\"\n     server, buf = capture\n-    thread, box, rid = _drain_batch_block(server, [\"q0\", \"q1\"])\n-\n-    first = server.handle_request({\n-        \"id\": \"a1\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q1\", \"answer\": \"beta\"},\n-    })\n-    assert first[\"result\"][\"status\"] == \"ok\"\n-    assert first[\"result\"][\"remaining\"] == [\"q0\"]\n-    assert thread.is_alive()  # one question left — still blocking\n-\n-    second = server.handle_request({\n-        \"id\": \"a2\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"alpha\"},\n-    })\n-    assert second[\"result\"][\"status\"] == \"ok\"\n-    assert second[\"result\"][\"remaining\"] == []\n-\n-    thread.join(timeout=5)\n-    assert not thread.is_alive()\n-    assert json.loads(box[\"answer\"]) == {\"answers\": {\"q0\": \"alpha\", \"q1\": \"beta\"}}\n-\n-\n-def test_clarify_batch_answer_update_overwrites_before_completion(server):\n-    thread, box, rid = _drain_batch_block(server, [\"q0\", \"q1\"])\n-\n-    server.handle_request({\n-        \"id\": \"a1\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"first\"},\n-    })\n-    server.handle_request({\n-        \"id\": \"a2\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"changed\"},\n-    })\n-    server.handle_request({\n-        \"id\": \"a3\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q1\", \"answer\": \"done\"},\n-    })\n-\n-    thread.join(timeout=5)\n-    assert json.loads(box[\"answer\"])[\"answers\"][\"q0\"] == \"changed\"\n-\n-\n-def test_clarify_batch_empty_answer_is_a_locked_skip(server):\n-    \"\"\"Skipping one question locks an empty answer — it counts toward\n-    completion instead of leaving the batch waiting.\"\"\"\n-    thread, box, rid = _drain_batch_block(server, [\"q0\", \"q1\"])\n-\n-    server.handle_request({\n-        \"id\": \"a1\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"\"},\n-    })\n-    server.handle_request({\n-        \"id\": \"a2\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q1\", \"answer\": \"kept\"},\n-    })\n+    thread, box, req = _start_batch_clarify(server, buf, [\"q0\", \"q1\"])\n+    sent = _frames(buf)[-1][\"params\"][\"questions\"][0]\n+    assert set(sent) == {\"qid\", \"question\", \"choices\", \"multi_select\"}\n \n+    first = server.handle_request({\"id\": \"a1\", \"method\": \"clarify.lock\",\n+                                   \"params\": {\"request_id\": req.id, \"question_id\": \"q0\", \"answer\": \"x\"}})\n+    assert first[\"result\"] == {\"status\": \"ok\", \"remaining\": [\"q1\"]}\n+    redo = server.handle_request({\"id\": \"a1b\", \"method\": \"clarify.lock\",\n+                                  \"params\": {\"request_id\": req.id, \"question_id\": \"q0\", \"answer\": \"y\"}})\n+    assert redo[\"result\"] == {\"status\": \"ok\", \"remaining\": [\"q1\"]}\n+    bad = server.handle_request({\"id\": \"bad\", \"method\": \"clarify.lock\",\n+                                 \"params\": {\"request_id\": req.id, \"question_id\": \"nope\", \"answer\": \"\"}})\n+    assert bad[\"error\"][\"code\"] == 4002\n+    assert server._open_requests(\"s1\")[0][\"params\"][\"answers\"] == {\"q0\": \"y\"}\n+    last = server.handle_request({\"id\": \"a2\", \"method\": \"clarify.lock\",\n+                                  \"params\": {\"request_id\": req.id, \"question_id\": \"q1\", \"answer\": \"\"}})\n+    assert last[\"result\"] == {\"status\": \"ok\", \"remaining\": []}\n     thread.join(timeout=5)\n-    assert json.loads(box[\"answer\"]) == {\"answers\": {\"q0\": \"\", \"q1\": \"kept\"}}\n-\n-\n-def test_clarify_batch_unknown_question_id_rejected(server):\n-    thread, box, rid = _drain_batch_block(server, [\"q0\"])\n+    assert json.loads(box[\"answer\"]) == {\"answers\": {\"q0\": \"y\", \"q1\": \"\"}}\n \n-    response = server.handle_request({\n-        \"id\": \"bad\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q9\", \"answer\": \"x\"},\n-    })\n-    assert response[\"error\"][\"code\"] == 4002\n-\n-    server.handle_request({\n-        \"id\": \"ok\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"fine\"},\n-    })\n-    thread.join(timeout=5)\n+    # Deadline: locked answers survive, timed_out flagged, one request.cancel.\n+    original_timeout = server._clarify_timeout_seconds\n+    try:\n+        thread, box, req = _start_batch_clarify(server, buf, [\"q0\", \"q1\"], timeout=1.5)\n+        locked = server.handle_request({\"id\": \"b1\", \"method\": \"clarify.lock\",\n+                                        \"params\": {\"request_id\": req.id, \"question_id\": \"q0\", \"answer\": \"kept\"}})\n+        assert locked[\"result\"][\"status\"] == \"ok\"\n+        thread.join(timeout=5)\n+    finally:\n+        server._clarify_timeout_seconds = original_timeout\n+    assert json.loads(box[\"answer\"]) == {\"answers\": {\"q0\": \"kept\"}, \"timed_out\": True}\n+    cancels = [f for f in _frames(buf) if f.get(\"method\") == \"event\" and f[\"params\"][\"type\"] == \"request.cancel\"]\n+    assert [c[\"params\"][\"payload\"][\"id\"] for c in cancels] == [req.id]\n \n \n-def test_clarify_batch_timeout_keeps_locked_answers(capture):\n-    \"\"\"Locked answers survive the deadline: the tool sees the partials plus\n-    timed_out instead of an empty string.\"\"\"\n+def test_clarify_batch_cancel_all_is_a_response_without_answers(capture):\n     server, buf = capture\n-    thread, box, rid = _drain_batch_block(server, [\"q0\", \"q1\"], timeout=1)\n-\n-    server.handle_request({\n-        \"id\": \"a1\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"kept\"},\n-    })\n-\n-    thread.join(timeout=10)\n-    assert not thread.is_alive()\n-    result = json.loads(box[\"answer\"])\n-    assert result == {\"answers\": {\"q0\": \"kept\"}, \"timed_out\": True}\n-    # The expire notification still fires for the un-finished batch.\n-    messages = [json.loads(line) for line in buf.getvalue().splitlines()]\n-    assert any(m[\"params\"][\"type\"] == \"clarify.expire\" for m in messages)\n-\n-\n-def test_clarify_batch_cancel_all_returns_empty(server):\n-    \"\"\"A respond without question_id cancels the whole batch (Esc path).\"\"\"\n-    thread, box, rid = _drain_batch_block(server, [\"q0\", \"q1\"])\n-\n-    server.handle_request({\n-        \"id\": \"cancel\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"answer\": \"\"},\n-    })\n-\n+    thread, box, req = _start_batch_clarify(server, buf, [\"q0\", \"q1\"])\n+    server.dispatch({\"jsonrpc\": \"2.0\", \"id\": req.id, \"result\": {}})\n     thread.join(timeout=5)\n     assert box[\"answer\"] == \"\"\n \n \n-def test_clarify_batch_late_question_respond_is_idempotent(server):\n-    response = server.handle_request({\n-        \"id\": \"late\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": \"gone\", \"question_id\": \"q0\", \"answer\": \"x\"},\n-    })\n-    assert response[\"result\"] == {\"status\": \"expired\"}\n-\n-\n-def test_clarify_batch_state_cleared_after_resolution(server):\n-    thread, box, rid = _drain_batch_block(server, [\"q0\"])\n-    server.handle_request({\n-        \"id\": \"a\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"x\"},\n-    })\n-    thread.join(timeout=5)\n-    with server._prompt_lock:\n-        assert rid not in server._batch_clarify\n-        assert rid not in server._pending\n-\n-\n-def test_clarify_block_helper_builds_batch_payload(capture):\n-    \"\"\"_clarify_block forwards only wire fields (qid/question/choices/\n-    multi_select) — the tool-side normalized entries carry extra keys the\n-    renderer must not see.\"\"\"\n+def test_clear_pending_cancels_only_that_session(capture):\n+    from tui_gateway import server_requests\n     server, buf = capture\n-    normalized = [\n-        {\n-            \"qid\": \"q0\", \"id\": \"approach\", \"question\": \"Which?\",\n-            \"choices\": [\"a (Recommended)\", \"b\"], \"choices_offered\": [\"a\", \"b\"],\n-            \"multi_select\": False,\n-        },\n-    ]\n-\n-    box = {}\n-\n-    def run():\n-        box[\"answer\"] = server._clarify_block(\"s1\", \"\", None, questions=normalized)\n-\n-    thread = threading.Thread(target=run, daemon=True)\n-    thread.start()\n+    a = threading.Thread(target=lambda: server_requests.send(\"sudo\", \"sid-a\", {}, timeout=None), daemon=True)\n+    b = threading.Thread(target=lambda: server_requests.send(\"sudo\", \"sid-b\", {}, timeout=None), daemon=True)\n+    a.start(); b.start()\n     deadline = time.monotonic() + 2\n-    rid = None\n-    while time.monotonic() < deadline and rid is None:\n-        with server._prompt_lock:\n-            rid = next(iter(server._batch_clarify), None)\n+    while time.monotonic() < deadline and len(server_requests._open) < 2:\n         time.sleep(0.01)\n-    assert rid\n-\n-    server.handle_request({\n-        \"id\": \"a\", \"method\": \"clarify.respond\",\n-        \"params\": {\"request_id\": rid, \"question_id\": \"q0\", \"answer\": \"a\"},\n-    })\n-    thread.join(timeout=5)\n-\n-    messages = [json.loads(line) for line in buf.getvalue().splitlines()]\n-    request = messages[0][\"params\"]\n-    assert request[\"type\"] == \"clarify.request\"\n-    sent = request[\"payload\"][\"questions\"][0]\n-    assert set(sent) == {\"qid\", \"question\", \"choices\", \"multi_select\"}\n-    assert \"id\" not in sent and \"choices_offered\" not in sent\n+    assert server._session_pending_kind(\"sid-a\") == \"sudo\"\n+    server._clear_pending(\"sid-a\")\n+    a.join(timeout=2)\n+    assert not a.is_alive() and b.is_alive()\n+    assert server._session_pending_kind(\"sid-a\") == \"\" and server._session_pending_kind(\"sid-b\") == \"sudo\"\n+    server._clear_pending()\n+    b.join(timeout=2)\n+    assert not b.is_alive()\n+    reasons = [f[\"params\"][\"payload\"][\"reason\"] for f in _frames(buf) if f.get(\"method\") == \"event\"]\n+    assert reasons == [\"interrupted\", \"shutdown\"]\n \n \n def test_approval_pending_replays_unresolved_requests(server, monkeypatch):","@@ -701,16 +555,6 @@ def test_approval_respond_4001_when_nothing_resolves(server, monkeypatch):\n     assert response[\"error\"][\"code\"] == 4001\n \n \n-def test_clear_pending(server):\n-    ev = threading.Event()\n-    # _pending values are (sid, Event) tuples\n-    server._pending[\"r1\"] = (\"sid-x\", ev)\n-    server._clear_pending()\n-\n-    assert ev.is_set()\n-    assert server._answers[\"r1\"] == \"\"\n-\n-\n # ── Session lookup ───────────────────────────────────────────────────\n \n "]},{"file":"tests/tui_gateway/test_review_summary_callback.py","hunks":["@@ -43,8 +43,7 @@ def server():\n     # _enter_buffered_busy. Clearing the per-session dicts gives the\n     # next test a clean slate.\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n \n \n def test_init_session_attaches_background_review_callback(server, monkeypatch):"]},{"file":"tests/tui_gateway/test_session_control.py","hunks":["@@ -43,8 +43,7 @@ def server(hermes_home, monkeypatch):\n     monkeypatch.setattr(mod, \"_cfg_path\", None)\n     yield mod\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n \n \n @pytest.fixture()"]},{"file":"tests/tui_gateway/test_session_profile_db.py","hunks":["@@ -64,8 +64,7 @@ def server(hermes_home):\n     mod._methods.clear()\n     mod._methods.update(methods)\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n     mod._db = None\n \n "]},{"file":"tests/tui_gateway/test_stranded_session_adoption.py","hunks":["@@ -183,8 +183,7 @@ def gateway(tmp_path, monkeypatch):\n     mod._methods.clear()\n     mod._methods.update(methods)\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n     mod._db = None\n     default_db.close()\n "]},{"file":"tests/tui_gateway/test_subagent_child_mirror.py","hunks":["@@ -36,8 +36,7 @@ def server():\n \n     yield mod\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n     mod._child_mirrors.clear()\n     mod._active_child_runs.clear()\n "]},{"file":"tests/tui_gateway/test_tour_bridge_fail_fast.py","hunks":["@@ -1,9 +1,9 @@\n-\"\"\"A desktop client that cannot answer ``tour.request`` must not cost a full\n+\"\"\"A desktop client that cannot answer the ``tour`` server request must not cost a full\n bridge timeout per call.\n \n The renderer's handler ships in the desktop bundle; the tool is offered by the\n backend. An app build older than the tour tool has no branch for the event, so\n-nothing ever calls ``tour.respond`` and the agent blocks for the whole deadline\n+nothing ever answers the request and the agent blocks for the whole deadline\n — once per action the model tries. See tui_gateway.server._tour_request.\n \"\"\"\n ","@@ -23,7 +23,7 @@ def session(monkeypatch):\n \n @pytest.fixture\n def bridge(monkeypatch):\n-    \"\"\"Record every _block call and serve canned answers.\"\"\"\n+    \"\"\"Record every ``_ask`` (server request) call and serve canned answers.\"\"\"\n     calls = []\n \n     def fake_block(event, sid, payload, timeout=None, **_kw):","@@ -33,7 +33,7 @@ def fake_block(event, sid, payload, timeout=None, **_kw):\n \n     fake_block.answers = []\n     fake_block.calls = calls\n-    monkeypatch.setattr(server, \"_block\", fake_block)\n+    monkeypatch.setattr(server, \"_ask\", fake_block)\n     return fake_block\n \n ","@@ -41,7 +41,7 @@ def test_first_action_is_probed_on_a_short_deadline(session, bridge):\n     bridge.answers = [json.dumps({\"success\": True})]\n     server._tour_request(\"s1\", {\"action\": \"targets\"})\n \n-    assert bridge.calls[0][\"event\"] == \"tour.request\"\n+    assert bridge.calls[0][\"event\"] == \"tour\"\n     assert bridge.calls[0][\"timeout\"] == server._TOUR_PROBE_TIMEOUT_S\n     assert server._TOUR_PROBE_TIMEOUT_S < server._TOUR_TIMEOUT_S\n "]},{"file":"tests/tui_gateway/test_tui_gateway_server.py","hunks":["@@ -496,15 +496,17 @@ def test_compute_host_turn_end_updates_metadata_mirror(monkeypatch):\n         server._sessions.pop(\"iso-sid\", None)\n \n \n-def test_compute_host_clarify_snapshot_replays_and_proxies_batch_answers(monkeypatch):\n-    \"\"\"A host-owned clarify survives activation and receives its UI answers.\"\"\"\n+def test_compute_host_open_request_survives_activation_and_proxies_locks_and_responses(monkeypatch):\n+    \"\"\"A host-owned server request (batch clarify) is mirrored by the parent so `open_requests` replays it;\n+    `clarify.lock` and the client's response frame are relayed to the child that owns the wait.\"\"\"\n     class _Supervisor:\n         def __init__(self):\n             self.responses = []\n \n         def respond(self, sid, params, *, timeout=15.0):\n             self.responses.append((sid, dict(params), timeout))\n-            remaining = [\"q1\"] if params.get(\"question_id\") == \"q0\" else []\n+            lock = params.get(\"lock\") or {}\n+            remaining = [\"q1\"] if lock.get(\"question_id\") == \"q0\" else []\n             return {\"type\": \"respond.ack\", \"response\": {\"result\": {\"status\": \"ok\", \"remaining\": remaining}}}\n \n     sid = \"host-clarify\"","@@ -515,53 +517,25 @@ def respond(self, sid, params, *, timeout=15.0):\n     monkeypatch.setattr(server, \"_get_compute_host_supervisor\", lambda _cfg=None: supervisor)\n     monkeypatch.setattr(server, \"write_json\", lambda _message: True)\n \n+    questions = [{\"qid\": \"q0\", \"question\": \"First?\", \"choices\": [\"a\"]}, {\"qid\": \"q1\", \"question\": \"Second?\", \"choices\": [\"b\"]}]\n     try:\n-        server._relay_compute_host_rpc(\n-            {\n-                \"jsonrpc\": \"2.0\",\n-                \"method\": \"event\",\n-                \"params\": {\n-                    \"type\": \"clarify.request\",\n-                    \"session_id\": sid,\n-                    \"payload\": {\n-                        \"request_id\": \"host-request\",\n-                        \"questions\": [\n-                            {\"qid\": \"q0\", \"question\": \"First?\", \"choices\": [\"a\"]},\n-                            {\"qid\": \"q1\", \"question\": \"Second?\", \"choices\": [\"b\"]},\n-                        ],\n-                    },\n-                },\n-            }\n-        )\n+        server._relay_compute_host_rpc({\"jsonrpc\": \"2.0\", \"id\": \"srq-host\", \"method\": \"clarify\",\n+                                        \"params\": {\"session_id\": sid, \"questions\": questions}})\n \n         activated = server._live_session_payload(sid, session)\n-        assert activated[\"pending_clarify\"][\"request_id\"] == \"host-request\"\n-\n-        response = server.handle_request(\n-            {\n-                \"id\": \"clarify-q0\",\n-                \"method\": \"clarify.respond\",\n-                \"params\": {\"request_id\": \"host-request\", \"question_id\": \"q0\", \"answer\": \"a\"},\n-            }\n-        )\n+        assert activated[\"open_requests\"] == [{\"id\": \"srq-host\", \"method\": \"clarify\",\n+                                               \"params\": {\"session_id\": sid, \"questions\": questions}}]\n \n+        response = server.handle_request({\"id\": \"lock-q0\", \"method\": \"clarify.lock\",\n+                                          \"params\": {\"request_id\": \"srq-host\", \"question_id\": \"q0\", \"answer\": \"a\"}})\n         assert response[\"result\"] == {\"status\": \"ok\", \"remaining\": [\"q1\"]}\n-        assert supervisor.responses == [\n-            (sid, {\"request_id\": \"host-request\", \"question_id\": \"q0\", \"answer\": \"a\"}, 15.0)\n-        ]\n-        replayed = server._live_session_payload(sid, session)[\"pending_clarify\"]\n-        assert replayed[\"answers\"] == {\"q0\": \"a\"}\n+        assert supervisor.responses == [(sid, {\"lock\": {\"request_id\": \"srq-host\", \"question_id\": \"q0\", \"answer\": \"a\"}}, 15.0)]\n+        assert server._live_session_payload(sid, session)[\"open_requests\"][0][\"params\"][\"answers\"] == {\"q0\": \"a\"}\n \n-        final_response = server.handle_request(\n-            {\n-                \"id\": \"clarify-q1\",\n-                \"method\": \"clarify.respond\",\n-                \"params\": {\"request_id\": \"host-request\", \"question_id\": \"q1\", \"answer\": \"b\"},\n-            }\n-        )\n-\n-        assert final_response[\"result\"] == {\"status\": \"ok\", \"remaining\": []}\n-        assert \"pending_clarify\" not in server._live_session_payload(sid, session)\n+        # The client's response frame (cancel-all) is relayed to the child and clears the mirror.\n+        assert server.dispatch({\"jsonrpc\": \"2.0\", \"id\": \"srq-host\", \"result\": {}}) is None\n+        assert supervisor.responses[-1] == (sid, {\"frame\": {\"jsonrpc\": \"2.0\", \"id\": \"srq-host\", \"result\": {}}}, 15.0)\n+        assert \"open_requests\" not in server._live_session_payload(sid, session)\n     finally:\n         server._sessions.pop(sid, None)\n ","@@ -13585,10 +13559,20 @@ def replace_messages(\n # ---------------------------------------------------------------------------\n \n \n+def _open_request(sid, method=\"clarify\"):\n+    from tui_gateway import server_requests\n+    req = server_requests.ServerRequest(sid, method, {})\n+    with server_requests._lock:\n+        server_requests._open[req.id] = req\n+    return req\n+\n+\n def test_interrupt_only_clears_own_session_pending():\n-    \"\"\"session.interrupt on session A must NOT release pending prompts\n-    that belong to session B.\"\"\"\n+    \"\"\"session.interrupt on session A withdraws A's open server→client requests (a request.cancel each)\n+    and must NOT touch session B's — otherwise B's clarify/sudo/secret prompt silently resolves as if\n+    the user cancelled it.\"\"\"\n     import types\n+    from tui_gateway import server_requests\n \n     session_a = _session()\n     session_a[\"agent\"] = types.SimpleNamespace(interrupt=lambda: None)","@@ -13596,71 +13580,21 @@ def test_interrupt_only_clears_own_session_pending():\n     session_b[\"agent\"] = types.SimpleNamespace(interrupt=lambda: None)\n     server._sessions[\"sid_a\"] = session_a\n     server._sessions[\"sid_b\"] = session_b\n+    req_a1, req_a2, req_b = _open_request(\"sid_a\"), _open_request(\"sid_a\", \"sudo\"), _open_request(\"sid_b\")\n \n     try:\n-        # Simulate pending prompts on both sessions (what _block creates\n-        # while a clarify/sudo/secret request is outstanding).\n-        ev_a = threading.Event()\n-        ev_b = threading.Event()\n-        server._pending[\"rid-a\"] = (\"sid_a\", ev_a)\n-        server._pending[\"rid-b\"] = (\"sid_b\", ev_b)\n-        server._answers.clear()\n-\n-        # Interrupt session A.\n-        resp = server.handle_request(\n-            {\n-                \"id\": \"1\",\n-                \"method\": \"session.interrupt\",\n-                \"params\": {\"session_id\": \"sid_a\"},\n-            }\n-        )\n+        resp = server.handle_request({\"id\": \"1\", \"method\": \"session.interrupt\", \"params\": {\"session_id\": \"sid_a\"}})\n         assert resp.get(\"result\"), f\"got error: {resp.get('error')}\"\n \n-        # Session A's pending must be released to empty.\n-        assert ev_a.is_set(), \"sid_a pending Event should be set after interrupt\"\n-        assert server._answers.get(\"rid-a\") == \"\"\n-\n-        # Session B's pending MUST remain untouched — no cross-session blast.\n-        assert not ev_b.is_set(), (\n-            \"CRITICAL: session.interrupt on sid_a released a pending prompt \"\n-            \"belonging to sid_b — other sessions' clarify/sudo/secret \"\n-            \"prompts are being silently cancelled\"\n-        )\n-        assert \"rid-b\" not in server._answers\n+        assert req_a1.event.is_set() and req_a2.event.is_set() and not req_a1.answered\n+        assert not req_b.event.is_set(), (\n+            \"CRITICAL: session.interrupt on sid_a released a prompt belonging to sid_b\")\n+        assert server_requests.open_requests(\"sid_a\") == []\n+        assert [r[\"id\"] for r in server_requests.open_requests(\"sid_b\")] == [req_b.id]\n     finally:\n         server._sessions.pop(\"sid_a\", None)\n         server._sessions.pop(\"sid_b\", None)\n-        server._pending.pop(\"rid-a\", None)\n-        server._pending.pop(\"rid-b\", None)\n-        server._answers.pop(\"rid-a\", None)\n-        server._answers.pop(\"rid-b\", None)\n-\n-\n-def test_interrupt_clears_multiple_own_pending():\n-    \"\"\"When a single session has multiple pending prompts (uncommon but\n-    possible via nested tool calls), interrupt must release all of them.\"\"\"\n-    import types\n-\n-    sess = _session()\n-    sess[\"agent\"] = types.SimpleNamespace(interrupt=lambda: None)\n-    server._sessions[\"sid\"] = sess\n-\n-    try:\n-        ev1, ev2 = threading.Event(), threading.Event()\n-        server._pending[\"r1\"] = (\"sid\", ev1)\n-        server._pending[\"r2\"] = (\"sid\", ev2)\n-\n-        resp = server.handle_request(\n-            {\"id\": \"1\", \"method\": \"session.interrupt\", \"params\": {\"session_id\": \"sid\"}}\n-        )\n-        assert resp.get(\"result\")\n-        assert ev1.is_set() and ev2.is_set()\n-        assert server._answers.get(\"r1\") == \"\" and server._answers.get(\"r2\") == \"\"\n-    finally:\n-        server._sessions.pop(\"sid\", None)\n-        for key in (\"r1\", \"r2\"):\n-            server._pending.pop(key, None)\n-            server._answers.pop(key, None)\n+        server_requests.reset_for_tests()\n \n \n def test_run_prompt_submit_registers_turn_thread_for_interrupt(monkeypatch):","@@ -14295,39 +14229,16 @@ def is_alive(self):\n \n \n def test_clear_pending_without_sid_clears_all():\n-    \"\"\"_clear_pending(None) is the shutdown path — must still release\n-    every pending prompt regardless of owning session.\"\"\"\n-    ev1, ev2, ev3 = threading.Event(), threading.Event(), threading.Event()\n-    server._pending[\"a\"] = (\"sid_x\", ev1)\n-    server._pending[\"b\"] = (\"sid_y\", ev2)\n-    server._pending[\"c\"] = (\"sid_z\", ev3)\n+    \"\"\"_clear_pending(None) is the process-exit path — every open request is withdrawn, and a response for\n+    a withdrawn id is dropped quietly (no error frame back to the client).\"\"\"\n+    from tui_gateway import server_requests\n+    reqs = [_open_request(\"sid-x\"), _open_request(\"sid-y\", \"sudo\")]\n     try:\n         server._clear_pending(None)\n-        assert ev1.is_set() and ev2.is_set() and ev3.is_set()\n-    finally:\n-        for key in (\"a\", \"b\", \"c\"):\n-            server._pending.pop(key, None)\n-            server._answers.pop(key, None)\n-\n-\n-def test_respond_unpacks_sid_tuple_correctly():\n-    \"\"\"After the (sid, Event) tuple change, _respond must still work.\"\"\"\n-    ev = threading.Event()\n-    server._pending[\"rid-x\"] = (\"sid_x\", ev)\n-    try:\n-        resp = server.handle_request(\n-            {\n-                \"id\": \"1\",\n-                \"method\": \"clarify.respond\",\n-                \"params\": {\"request_id\": \"rid-x\", \"answer\": \"the answer\"},\n-            }\n-        )\n-        assert resp.get(\"result\")\n-        assert ev.is_set()\n-        assert server._answers.get(\"rid-x\") == \"the answer\"\n+        assert all(r.event.is_set() and not r.answered for r in reqs)\n+        assert server.dispatch({\"jsonrpc\": \"2.0\", \"id\": reqs[0].id, \"result\": {\"answer\": \"late\"}}) is None\n     finally:\n-        server._pending.pop(\"rid-x\", None)\n-        server._answers.pop(\"rid-x\", None)\n+        server_requests.reset_for_tests()\n \n \n # ---------------------------------------------------------------------------","@@ -20697,49 +20608,44 @@ def fake_listen(should_stop, capture=False, on_trigger=None, **_kw):\n     assert not listened.is_set()\n \n \n-def test_clarify_callback_uses_configured_timeout(monkeypatch):\n-    \"\"\"The TUI/desktop clarify bridge honors the canonical clarify timeout\n-    (via _clarify_timeout_seconds) instead of the hardcoded _block default.\"\"\"\n+def _capture_server_request(monkeypatch, result):\n+    \"\"\"Stub the server-request send and capture (method, sid, params, timeout).\"\"\"\n+    from tui_gateway import server_requests\n     captured = {}\n \n-    monkeypatch.setattr(server, \"_clarify_timeout_seconds\", lambda: 42)\n+    def fake_send(method, sid, params, *, timeout, qids=None):\n+        captured.update(method=method, sid=sid, params=params, timeout=timeout, qids=qids)\n+        return result\n \n-    def fake_block(event, sid, payload, timeout=300):\n-        captured.update(event=event, sid=sid, payload=payload, timeout=timeout)\n-        return \"answer\"\n+    monkeypatch.setattr(server_requests, \"send\", fake_send)\n+    return captured\n \n-    monkeypatch.setattr(server, \"_block\", fake_block)\n+\n+def test_clarify_callback_uses_configured_timeout(monkeypatch):\n+    \"\"\"The TUI/desktop clarify bridge sends a ``clarify`` server request with the canonical clarify timeout\n+    (via _clarify_timeout_seconds), and returns the response's ``answer``.\"\"\"\n+    monkeypatch.setattr(server, \"_clarify_timeout_seconds\", lambda: 42)\n+    captured = _capture_server_request(monkeypatch, {\"answer\": \"answer\"})\n \n     result = server._agent_cbs(\"sid-1\")[\"clarify_callback\"](\"Pick one\", [\"a\", \"b\"])\n \n     assert result == \"answer\"\n-    assert captured[\"event\"] == \"clarify.request\"\n+    assert captured[\"method\"] == \"clarify\" and captured[\"sid\"] == \"sid-1\"\n     assert captured[\"timeout\"] == 42\n-    assert captured[\"payload\"] == {\"question\": \"Pick one\", \"choices\": [\"a\", \"b\"]}\n+    assert captured[\"params\"] == {\"question\": \"Pick one\", \"choices\": [\"a\", \"b\"]}\n \n \n def test_clarify_callback_multi_select_hint(monkeypatch):\n-    \"\"\"multi_select=True adds the hint to the payload; the single-select\n-    payload shape stays byte-identical to the pre-multi-select protocol\n-    (older renderers must never see the extra field).\"\"\"\n-    captured = {}\n-\n-    def fake_block(event, sid, payload, timeout=300):\n-        captured.update(payload=payload)\n-        return \"answer\"\n-\n-    monkeypatch.setattr(server, \"_block\", fake_block)\n+    \"\"\"multi_select=True adds the hint to the params; the single-select shape stays byte-identical to the\n+    pre-multi-select protocol (older renderers must never see the extra field).\"\"\"\n+    captured = _capture_server_request(monkeypatch, {\"answer\": \"answer\"})\n     cb = server._agent_cbs(\"sid-1\")[\"clarify_callback\"]\n \n     cb(\"Pick many\", [\"a\", \"b\"], multi_select=True)\n-    assert captured[\"payload\"] == {\n-        \"question\": \"Pick many\",\n-        \"choices\": [\"a\", \"b\"],\n-        \"multi_select\": True,\n-    }\n+    assert captured[\"params\"] == {\"question\": \"Pick many\", \"choices\": [\"a\", \"b\"], \"multi_select\": True}\n \n     cb(\"Pick one\", [\"a\", \"b\"], multi_select=False)\n-    assert captured[\"payload\"] == {\"question\": \"Pick one\", \"choices\": [\"a\", \"b\"]}\n+    assert captured[\"params\"] == {\"question\": \"Pick one\", \"choices\": [\"a\", \"b\"]}\n \n \n @pytest.mark.parametrize(","@@ -20747,8 +20653,8 @@ def fake_block(event, sid, payload, timeout=300):\n     [(0, None), (-1, None), (42, 42)],\n )\n def test_clarify_timeout_seconds_maps_non_positive_to_unlimited(monkeypatch, configured, expected):\n-    \"\"\"A ``<= 0`` clarify timeout means unlimited and reaches _block as None\n-    (ev.wait(None) waits forever) rather than an immediate ev.wait(0) skip.\"\"\"\n+    \"\"\"A ``<= 0`` clarify timeout means unlimited and reaches the server request as None\n+    (wait(None) waits forever) rather than an immediate wait(0) skip.\"\"\"\n     monkeypatch.setattr(\"tools.clarify_gateway.get_clarify_timeout\", lambda: configured)\n \n     assert server._clarify_timeout_seconds() == expected"]},{"file":"tests/tui_gateway/test_undo_command.py","hunks":["@@ -55,8 +55,7 @@ def server(hermes_home):\n     mod._methods.clear()\n     mod._methods.update(methods)\n     mod._sessions.clear()\n-    mod._pending.clear()\n-    mod._answers.clear()\n+    __import__(\"tui_gateway.server_requests\", fromlist=[\"x\"]).reset_for_tests()\n     mod._db = None\n \n "]},{"file":"tools/approval.py","hunks":["@@ -177,6 +177,17 @@ def list_gateway_approvals(session_key: str) -> list[dict]:\n         return [dict(entry.data) for entry in _gateway_queues.get(session_key, [])]\n \n \n+def register_gateway_settle(session_key: str, request_id: str, settle) -> bool:\n+    \"\"\"Attach ``settle(reason)`` to one pending approval; it runs once when that wait ends by any path.\n+    False when the request is no longer pending (the surface should withdraw its prompt itself).\"\"\"\n+    with _lock:\n+        for entry in _gateway_queues.get(session_key, []):\n+            if entry.data.get(\"request_id\") == request_id:\n+                entry.settle = settle\n+                return True\n+    return False\n+\n+\n def ack_gateway_approval(session_key: str, request_id: str) -> bool:\n     \"\"\"Record that a client received a particular pending approval request.\"\"\"\n     with _lock:"]},{"file":"tools/approval_gateway_wait.py","hunks":["@@ -24,13 +24,16 @@\n \n class _ApprovalEntry:\n     \"\"\"One pending dangerous-command approval inside a gateway session.\"\"\"\n-    __slots__ = (\"event\", \"data\", \"result\", \"reason\", \"acknowledged\")\n+    __slots__ = (\"event\", \"data\", \"result\", \"reason\", \"acknowledged\", \"settle\")\n \n     def __init__(self, data: dict):\n         self.event = threading.Event()\n         self.data = dict(data)\n         self.data.setdefault(\"request_id\", uuid.uuid4().hex)\n         self.acknowledged = False\n+        # Surface hook run once when the wait ends by ANY path (answer, timeout, interrupt, /approve from\n+        # another client): the tui_gateway withdraws its open server→client request through it.\n+        self.settle = None\n         self.result: str | None = None  # \"once\"|\"session\"|\"always\"|\"deny\"\n         # Free-text reason from ``/deny <reason>`` so the agent can adapt, not just hear \"denied\".\n         self.reason: str | None = None","@@ -139,13 +142,19 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,\n     with _approval._lock:\n         _approval._gateway_queues.setdefault(session_key, []).append(entry)\n \n-    def _drop_entry() -> None:\n+    def _drop_entry(reason: str) -> None:\n         with _approval._lock:\n             queue = _approval._gateway_queues.get(session_key, [])\n             if entry in queue:\n                 queue.remove(entry)\n             if not queue:\n                 _approval._gateway_queues.pop(session_key, None)\n+            settle, entry.settle = entry.settle, None\n+        if settle is not None:\n+            try:\n+                settle(reason)\n+            except Exception:\n+                logger.debug(\"approval settle hook failed\", exc_info=True)\n \n     # Plugins hear about the request before the gateway does (real-time observers).\n     _ctx._fire_approval_hook(\"pre_approval_request\", **payload)","@@ -154,7 +163,7 @@ def _drop_entry() -> None:\n         notify_cb(dict(entry.data))\n     except Exception as exc:\n         logger.warning(\"Gateway approval notify failed: %s\", exc)\n-        _drop_entry()\n+        _drop_entry(\"notify_failed\")\n         _ctx._fire_approval_hook(\"post_approval_response\", **payload, choice=\"notify_failed\")\n         return {\"resolved\": False, \"choice\": None, \"notify_failed\": True}\n ","@@ -163,5 +172,5 @@ def _drop_entry() -> None:\n     if state == \"interrupted\":\n         entry.result = \"deny\"\n         entry.event.set()\n-    _drop_entry()\n+    _drop_entry(\"answered\" if state == \"set\" else state)\n     return _finish(payload, state != \"timeout\", entry.result, entry.reason)"]},{"file":"tui_gateway/AGENTS.md","hunks":["@@ -18,12 +18,20 @@ Never move agent behaviour into the renderer.\n \n ## Transport\n \n-Newline-delimited JSON-RPC over stdio: requests from Ink, events from Python. `tui_gateway/server.py`\n+Newline-delimited JSON-RPC over stdio, peer-to-peer: client→server method calls, server→client\n+**requests** (the agent asking the user something: `approval`, `clarify`, `sudo`, `secret`, `vault.*`,\n+`mcp.setup`, the desktop read/act bridges) and server→client `event` notifications. `tui_gateway/server.py`\n is the facade with the method/event catalog; methods live in `methods_*.py` siblings (`methods_config`,\n `methods_complete`, `methods_browser`, `methods_bot_relay`, ...), event publishing in\n-`event_publisher.py` / `event_replay.py`. Desktop reaches the same server over WebSocket via\n-`apps/shared` (`JsonRpcGatewayClient`). New RPC = a new `methods_<topic>.py` or an entry in an\n-existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules).\n+`event_publisher.py` / `event_replay.py`, server→client requests in `server_requests.py` (`send()` blocks\n+the agent thread until the response frame with the same `srq-<n>` id arrives; `cancel*` withdraws with a\n+`request.cancel` event; `open_requests(sid)` is what `session.resume` / `session.events.since` replay so a\n+reconnecting client re-renders the still-open questions). Desktop reaches the same server over WebSocket\n+via `apps/shared` (`JsonRpcGatewayClient`, `onRequest`). New RPC = a new `methods_<topic>.py` or an entry\n+in an existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules).\n+New question for the user = `_ask(\"<method>\", sid, params, timeout)` in the emitter, a handler in\n+`apps/desktop/.../gateway-event/server-requests.ts` and `ui-tui/src/app/createServerRequestHandler.ts`,\n+and the method in `ServerRequestMap` + `apps/shared/src/gateway-events.json`.\n New event = a new key in `apps/shared/src/gateway-events.ts::GatewayEventMap` + `BACKEND_EVENT_NAMES`\n AND `apps/shared/src/gateway-events.json`; `tests/tui_gateway/test_gateway_event_contract.py` (emitter\n side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when either drifts.","@@ -34,8 +42,8 @@ side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when ei\n |---|---|---|\n | Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta` / `message.complete` |\n | Tool activity | `thinking.tsx` | `tool.start` / `tool.generating` / `tool.complete` |\n-| Approvals | `prompts.tsx` | `approval.request` → `approval.respond` |\n-| Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify.respond`, `sudo.respond`, `secret.respond` |\n+| Approvals | `prompts.tsx` | server→client request `approval` → response `{choice}` |\n+| Clarify / sudo / secret | `prompts.tsx`, `maskedPrompt.tsx` | server→client requests `clarify` / `sudo` / `secret` (`server_requests.py`) |\n | Session picker | `sessionPicker.tsx` | `session.list` / `session.resume` |\n | Slash commands | local handler + fallthrough | `slash.exec` → `_SlashWorker`; `command.dispatch` |\n | Completions | `useCompletion` hook | `complete.slash`, `complete.path` |"]},{"file":"tui_gateway/agent_callbacks.py","hunks":["@@ -79,11 +79,11 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:\n \n \n def _agent_cbs(sid: str) -> dict:\n-    def _read_block(event: str, timeout: int):\n-        # read_terminal / read_preview (desktop GUI): blocking bridge like clarify; the preview\n+    def _read_block(method: str, timeout: int):\n+        # read_terminal / read_preview (desktop GUI): server request like clarify; the preview\n         # read gets longer since a URL tab extracts text from a live page.\n-        return lambda start=None, count=None: _block(\n-            event, sid, {k: v for k, v in ((\"start\", start), (\"count\", count)) if v is not None},\n+        return lambda start=None, count=None: _ask(\n+            method, sid, {k: v for k, v in ((\"start\", start), (\"count\", count)) if v is not None},\n             timeout=timeout)\n \n     callbacks = {","@@ -105,17 +105,17 @@ def _read_block(event: str, timeout: int):\n         \"notice_clear_callback\": lambda key: _emit(\"notification.clear\", sid, {\"key\": key}),\n         \"clarify_callback\": lambda q, c, multi_select=False, questions=None: (\n             _clarify_block(sid, q, c, multi_select=multi_select, questions=questions)),\n-        \"read_terminal_callback\": _read_block(\"terminal.read.request\", 30),\n-        \"read_preview_callback\": _read_block(\"preview.read.request\", 45),\n+        \"read_terminal_callback\": _read_block(\"terminal.read\", 30),\n+        \"read_preview_callback\": _read_block(\"preview.read\", 45),\n         # drive_preview / annotate_preview (desktop GUI): same budget as the preview read it ends with.\n-        \"drive_preview_callback\": lambda payload: _block(\"preview.act.request\", sid, dict(payload), timeout=45),\n+        \"drive_preview_callback\": lambda payload: _ask(\"preview.act\", sid, dict(payload), timeout=45),\n         # read_window_below (desktop GUI): main process enumerates native windows.\n-        \"read_window_below_callback\": lambda: _block(\"window.read.request\", sid, {}, timeout=30),\n+        \"read_window_below_callback\": lambda: _ask(\"window.read\", sid, {}, timeout=30),\n         # setup_mcp (desktop GUI): consent card + install/enable/OAuth; long timeout on purpose\n-        # (typing an API key, browser OAuth) and, like clarify, a late answer is tolerated.\n-        \"setup_mcp_callback\": lambda server, action, reason: _block(\n-            \"mcp.setup.request\", sid, {\"server\": server, \"action\": action, \"reason\": reason}, timeout=600),\n-        # tour (desktop GUI): renderer drives driver.js and answers tour.respond.\n+        # (typing an API key, browser OAuth).\n+        \"setup_mcp_callback\": lambda server, action, reason: _ask(\n+            \"mcp.setup\", sid, {\"server\": server, \"action\": action, \"reason\": reason}, timeout=600),\n+        # tour (desktop GUI): renderer drives driver.js and answers the ``tour`` request.\n         \"tour_callback\": lambda payload: _tour_request(sid, payload)}\n \n     # Interim assistant commentary (text alongside tool calls), gated on display.interim_assistant_","@@ -162,13 +162,13 @@ def _wire_callbacks(sid: str):\n \n     def secret_cb(env_var, prompt, metadata=None):\n         pl = {\"prompt\": prompt, \"env_var\": env_var, **({\"metadata\": metadata} if metadata else {})}\n-        val = _block(\"secret.request\", sid, pl)\n+        val = _ask(\"secret\", sid, pl)\n         if not val:\n             return {\"success\": True, \"stored_as\": env_var, \"validated\": False, \"skipped\": True, \"message\": \"skipped\"}\n         from hermes_cli.config import save_env_value_secure\n         return {**save_env_value_secure(env_var, val), \"skipped\": False, \"message\": \"ok\"}\n \n-    set_sudo_password_callback(lambda: _block(\"sudo.request\", sid, {}, timeout=120))\n+    set_sudo_password_callback(lambda: _ask(\"sudo\", sid, {}, timeout=120))\n     set_project_workspace_callback(_apply_project_workspace)\n     set_secret_capture_callback(secret_cb)\n     # External password-manager unlock: the renderer shows a masked master-password card; the","@@ -176,12 +176,12 @@ def secret_cb(env_var, prompt, metadata=None):\n     from agent.vault_backends.unlock import (set_code_prompt_callback, set_current_session_id,\n                                              set_save_login_prompt_callback, set_unlock_prompt_callback)\n     set_current_session_id(sid)  # an unlock made on this turn belongs to this session (released with it)\n-    set_unlock_prompt_callback(lambda backend, display_name: _block(\n-        \"vault.unlock.request\", sid, {\"backend\": backend, \"display_name\": display_name}, timeout=120))\n+    set_unlock_prompt_callback(lambda backend, display_name: _ask(\n+        \"vault.unlock_prompt\", sid, {\"backend\": backend, \"display_name\": display_name}, timeout=120))\n \n     def save_login_cb(origin, site):\n         # The renderer shows identifier + masked password; the JSON answer goes straight to the vault store.\n-        raw = _block(\"vault.save_login.request\", sid, {\"origin\": origin, \"site\": site}, timeout=180)\n+        raw = _ask(\"vault.save_login\", sid, {\"origin\": origin, \"site\": site}, timeout=180)\n         try:\n             data = json.loads(raw) if raw else None\n         except ValueError:","@@ -189,8 +189,8 @@ def save_login_cb(origin, site):\n         return data if isinstance(data, dict) and data.get(\"password\") else None\n \n     set_save_login_prompt_callback(save_login_cb)\n-    set_code_prompt_callback(lambda site, hint: _block(\n-        \"vault.code.request\", sid, {\"site\": site, \"hint\": hint}, timeout=180))\n+    set_code_prompt_callback(lambda site, hint: _ask(\n+        \"vault.code\", sid, {\"site\": site, \"hint\": hint}, timeout=180))\n \n \n def _available_personalities(cfg: dict | None = None) -> dict:"]},{"file":"tui_gateway/compute_host.py","hunks":["@@ -190,7 +190,9 @@ def body(server: Any, sid: str, request_id: Any) -> None:\n         self._guarded(frame, \"interrupt.ack\", body, applied=False)\n \n     def _handle_respond(self, frame: dict[str, Any]) -> None:\n-        \"\"\"Resolve an interactive request in the host-owned pending registry.\"\"\"\n+        \"\"\"Resolve a server→client request this host owns: ``params.frame`` is the client's JSON-RPC response\n+        frame relayed by the parent; ``params.lock`` is one batch-clarify lock (answered with ``clarify.lock``'s\n+        result so the parent can ack the client).\"\"\"\n         def body(server: Any, sid: str, request_id: Any) -> None:\n             params = frame.get(\"params\")\n             error = (\"session not found\" if sid not in server._sessions","@@ -198,7 +200,13 @@ def body(server: Any, sid: str, request_id: Any) -> None:\n             if error:\n                 self._reply(\"respond.error\", sid, request_id, message=error)\n                 return\n-            response = server._methods[\"clarify.respond\"](request_id, params)\n+            from tui_gateway import server_requests\n+            if isinstance(params.get(\"lock\"), dict):\n+                response = server._methods[\"clarify.lock\"](request_id, params[\"lock\"])\n+            else:\n+                response_frame = params.get(\"frame\") if isinstance(params.get(\"frame\"), dict) else params\n+                resolved = server_requests.resolve_response(response_frame)\n+                response = {\"jsonrpc\": \"2.0\", \"id\": request_id, \"result\": {\"status\": \"ok\" if resolved else \"expired\"}}\n             self._reply(\"respond.ack\", sid, request_id, response=response)\n         self._guarded(frame, \"respond.error\", body)\n "]},{"file":"tui_gateway/compute_host_bridge.py","hunks":["@@ -90,7 +90,8 @@ def _compute_host_adopt_frame_meta(session: dict, frame: dict) -> None:\n \n \n def _relay_compute_host_rpc(message: dict) -> bool:\n-    \"\"\"Relay host events while retaining the clarify snapshot needed on resume.\"\"\"\n+    \"\"\"Relay host frames to the client while mirroring the server→client request the host has open, so a\n+    reconnecting client gets it back through ``open_requests``.\"\"\"\n     params = message.get(\"params\") if isinstance(message, dict) else None\n     if isinstance(message, dict) and message.get(\"method\") == \"compute_host.activity\":\n         if isinstance(params, dict):","@@ -101,17 +102,20 @@ def _relay_compute_host_rpc(message: dict) -> bool:\n                             and session.get(\"_compute_host_turn_id\") == params[\"turn_id\"]):\n                         session[\"_compute_host_activity_ns\"] = params.get(\"activity_ns\")\n         return True  # Internal observation, not a client event or replay entry.\n-    kind = params.get(\"type\") if isinstance(params, dict) else None\n-    if kind in {\"clarify.request\", \"clarify.expire\"}:\n+    if isinstance(message, dict) and isinstance(message.get(\"id\"), str) and message.get(\"method\") not in (None, \"event\"):\n+        # A server request minted by the child: remember it against its session until it is answered/withdrawn.\n+        session = _sessions.get(str((params or {}).get(\"session_id\") or \"\")) if isinstance(params, dict) else None\n+        if session is not None:\n+            with _history_lock(session):\n+                session[\"_compute_host_open_request\"] = {\n+                    \"id\": message[\"id\"], \"method\": message[\"method\"], \"params\": dict(params)}\n+    elif isinstance(params, dict) and params.get(\"type\") == \"request.cancel\":\n         session = _sessions.get(str(params.get(\"session_id\") or \"\"))\n         payload = params.get(\"payload\")\n-        request_id = payload.get(\"request_id\") if isinstance(payload, dict) else None\n-        if session is not None and request_id:\n+        if session is not None and isinstance(payload, dict):\n             with _history_lock(session):\n-                if kind == \"clarify.request\":\n-                    session[\"_compute_host_pending_clarify\"] = dict(payload)\n-                elif _pending_clarify_matches(session, request_id):\n-                    session.pop(\"_compute_host_pending_clarify\", None)\n+                if _open_request_matches(session, payload.get(\"id\")):\n+                    session.pop(\"_compute_host_open_request\", None)\n     return write_json(message)\n \n ","@@ -119,62 +123,65 @@ def _history_lock(session: dict):\n     return session.get(\"history_lock\", threading.Lock())\n \n \n-def _pending_clarify_matches(session: dict, request_id) -> bool:\n-    \"\"\"Whether ``session``'s mirrored pending clarify is ``request_id``. Caller holds\n-    history_lock.\"\"\"\n-    pending = session.get(\"_compute_host_pending_clarify\")\n-    return isinstance(pending, dict) and pending.get(\"request_id\") == request_id\n+def _open_request_matches(session: dict, request_id) -> bool:\n+    \"\"\"Whether ``session``'s mirrored open request is ``request_id``. Caller holds history_lock.\"\"\"\n+    mirrored = session.get(\"_compute_host_open_request\")\n+    return isinstance(mirrored, dict) and mirrored.get(\"id\") == request_id\n \n \n-def _compute_host_clarify_session(request_id: str) -> tuple[str, dict] | None:\n-    \"\"\"Find the parent mirror for one host-owned clarify request.\"\"\"\n+def _compute_host_request_session(request_id: str) -> tuple[str, dict] | None:\n+    \"\"\"Find the parent mirror for one host-owned server request.\"\"\"\n     for sid, session in list(_sessions.items()) if request_id else ():\n         with _history_lock(session):\n-            if _pending_clarify_matches(session, request_id):\n+            if _open_request_matches(session, request_id):\n                 return sid, session\n     return None\n \n \n-def _update_compute_host_clarify_snapshot(sid: str, session: dict, params: dict, result: dict) -> None:\n-    \"\"\"Keep reconnect snapshots accurate while a batch clarify is answered.\"\"\"\n-    request_id = str(params.get(\"request_id\") or \"\")\n-    question_id = str(params.get(\"question_id\") or \"\")\n+def _relay_compute_host_response(frame: dict) -> bool:\n+    \"\"\"Forward a client's response frame to the compute-host child that owns the request. False when no\n+    child owns that id.\"\"\"\n+    located = _compute_host_request_session(str(frame.get(\"id\") or \"\"))\n+    if located is None or not _session_uses_compute_host(located[1]):\n+        return False\n+    sid, session = located\n     with _history_lock(session):\n-        if not _pending_clarify_matches(session, request_id):\n-            return\n-        pending = session[\"_compute_host_pending_clarify\"]\n-        if result.get(\"status\") == \"expired\" or not result.get(\"remaining\") and not question_id:\n-            session.pop(\"_compute_host_pending_clarify\", None)\n-        elif question_id and isinstance(result.get(\"remaining\"), list):\n-            pending[\"answers\"] = {**(pending.get(\"answers\") or {}),\n-                                  question_id: str(params.get(\"answer\") or \"\")}\n-            if not result[\"remaining\"]:\n-                session.pop(\"_compute_host_pending_clarify\", None)\n-\n-\n-def _respond_compute_host_clarify(rid: str, params: dict) -> dict | None:\n-    \"\"\"Proxy a clarify answer into the process that owns its pending Event.\"\"\"\n-    located = _compute_host_clarify_session(str(params.get(\"request_id\") or \"\"))\n+        session.pop(\"_compute_host_open_request\", None)\n+    try:\n+        _get_compute_host_supervisor().respond(sid, {\"frame\": dict(frame)})\n+    except Exception:\n+        logger.debug(\"compute-host response relay failed sid=%s\", sid, exc_info=True)\n+    return True\n+\n+\n+def _lock_compute_host_clarify(rid: str, request_id: str, question_id: str, answer: str) -> dict | None:\n+    \"\"\"Proxy a batch-clarify lock into the child that owns the request; keeps the parent mirror's locked\n+    answers current for reconnect snapshots. None when the request is not host-owned.\"\"\"\n+    located = _compute_host_request_session(request_id)\n     if located is None or not _session_uses_compute_host(located[1]):\n         return None\n     sid, session = located\n     try:\n-        ack = _get_compute_host_supervisor().respond(sid, params)\n+        ack = _get_compute_host_supervisor().respond(\n+            sid, {\"lock\": {\"request_id\": request_id, \"question_id\": question_id, \"answer\": answer}})\n     except Exception as exc:\n-        return _err(rid, 5019, f\"compute-host clarify response failed: {exc}\")\n+        return _err(rid, 5019, f\"compute-host clarify lock failed: {exc}\")\n     if ack.get(\"type\") == \"respond.error\":\n-        return _err(rid, 5019, str(ack.get(\"message\") or \"compute-host clarify response failed\"))\n+        return _err(rid, 5019, str(ack.get(\"message\") or \"compute-host clarify lock failed\"))\n     response = ack.get(\"response\")\n     if not isinstance(response, dict):\n-        return _err(rid, 5019, \"compute-host clarify response returned an invalid response\")\n+        return _err(rid, 5019, \"compute-host clarify lock returned an invalid response\")\n     if \"error\" in response:\n         error = response[\"error\"] if isinstance(response[\"error\"], dict) else {}\n-        return _err(rid, int(error.get(\"code\") or 5000),\n-                    str(error.get(\"message\") or \"clarify response failed\"))\n-    result = response.get(\"result\")\n-    if not isinstance(result, dict):\n-        return _err(rid, 5019, \"compute-host clarify response returned an invalid result\")\n-    _update_compute_host_clarify_snapshot(sid, session, params, result)\n+        return _err(rid, int(error.get(\"code\") or 5000), str(error.get(\"message\") or \"clarify lock failed\"))\n+    result = response.get(\"result\") if isinstance(response.get(\"result\"), dict) else {}\n+    with _history_lock(session):\n+        if _open_request_matches(session, request_id):\n+            mirrored = session[\"_compute_host_open_request\"]\n+            if result.get(\"status\") == \"expired\" or result.get(\"remaining\") == []:\n+                session.pop(\"_compute_host_open_request\", None)\n+            else:\n+                mirrored[\"params\"][\"answers\"] = {**(mirrored[\"params\"].get(\"answers\") or {}), question_id: answer}\n     return _ok(rid, result)\n \n ","@@ -200,7 +207,7 @@ def _on_compute_host_turn_done(rid: str, sid: str, session: dict, frame: dict) -\n         session[\"running\"] = False\n         session[\"last_active\"] = time.time()\n         _clear_inflight_turn(session)\n-        session.pop(\"_compute_host_pending_clarify\", None)\n+        session.pop(\"_compute_host_open_request\", None)\n     if frame.get(\"type\") == \"turn.error\":\n         message = str(frame.get(\"message\") or \"compute host turn failed\")\n         _emit(\"message.complete\", sid, {\"text\": f\"Error: {message}\", \"status\": \"error\"})"]},{"file":"tui_gateway/hosted_room_member_activity.py","hunks":["@@ -23,7 +23,6 @@\n     \"tool.start\": \"tool.started\",\n     \"tool.complete\": \"tool.completed\",\n     \"tool.output_risk\": \"tool.output_risk\",\n-    \"approval.request\": \"request.opened\",\n     \"message.delta\": \"message.delta\",\n     \"message.interim\": \"message.interim\",\n     \"reasoning.delta\": \"reasoning.delta\",","@@ -42,20 +41,29 @@ def emit_room_member_activity(hosted_task: Mapping[str, Any], *, kind: str, payl\n     return enqueue_plugin_stream_hook(HOOK_NAME, **coordinates, kind=kind, seq=seq, payload=dict(payload or {}))\n \n \n+# Server→client request frames (``{\"id\": \"srq-…\", \"method\": …}``) that are member activity.\n+KIND_BY_REQUEST_METHOD: Mapping[str, str] = {\"approval\": \"request.opened\"}\n+\n+\n def project_room_member_activity(frame: Mapping[str, Any], sessions: Mapping[str, Mapping[str, Any]]) -> bool:\n-    \"\"\"Fire the hook for an outgoing session event frame when its session is running a room turn.\"\"\"\n-    if frame.get(\"method\") != \"event\":\n-        return False\n+    \"\"\"Fire the hook for an outgoing session frame (event notification or server→client request) when its\n+    session is running a room turn.\"\"\"\n     params = frame.get(\"params\")\n     if not isinstance(params, Mapping):\n         return False\n-    kind = KIND_BY_FRAME_TYPE.get(str(params.get(\"type\") or \"\"))\n+    if frame.get(\"method\") == \"event\":\n+        kind = KIND_BY_FRAME_TYPE.get(str(params.get(\"type\") or \"\"))\n+        payload = params.get(\"payload\")\n+    elif \"id\" in frame:\n+        kind = KIND_BY_REQUEST_METHOD.get(str(frame.get(\"method\") or \"\"))\n+        payload = {k: v for k, v in params.items() if k != \"session_id\"}\n+    else:\n+        return False\n     if kind is None:\n         return False\n     session = sessions.get(str(params.get(\"session_id\") or \"\"))\n     hosted_task = session.get(\"_hosted_room_task\") if isinstance(session, Mapping) else None\n     if not isinstance(hosted_task, Mapping):\n         return False\n-    payload = params.get(\"payload\")\n     return emit_room_member_activity(\n         hosted_task, kind=kind, payload=payload if isinstance(payload, Mapping) else None, seq=params.get(\"seq\"))"]},{"file":"tui_gateway/methods_prompt.py","hunks":["@@ -1093,26 +1093,47 @@ def cleanup():\n         cwd=preview_cwd, cleanup=cleanup)\n \n \n-# ── late-answer RPCs for tool-driven UI cards ───────────────────────────────\n-# allow_expired=True everywhere: a tool's bounded wait can expire (its _pending entry\n-# popped) while the card is still visible; a late answer must not surface the raw 4009.\n+# ── batch clarify locks ─────────────────────────────────────────────────────\n+# A batch ``clarify`` server request is answered one question at a time: each lock is a normal RPC\n+# (update-in-place, editable until every qid is locked); the LAST lock resolves the request itself.\n+# A cancel-all is the plain response frame with no ``answers``.\n \n \n-@method(\"clarify.respond\")\n+@method(\"clarify.lock\")\n def _(rid, params: dict) -> dict:\n-    if proxied := _respond_compute_host_clarify(rid, params):\n+    request_id = str(params.get(\"request_id\") or \"\")\n+    question_id = str(params.get(\"question_id\") or \"\")\n+    if not request_id or not question_id:\n+        return _err(rid, 4002, \"request_id and question_id required\")\n+    answer = params.get(\"answer\", \"\")\n+    answer = answer if isinstance(answer, str) else json.dumps(answer, ensure_ascii=False)\n+    if (proxied := _lock_compute_host_clarify(rid, request_id, question_id, answer)) is not None:\n         return proxied\n-    return _respond(rid, params, \"answer\", allow_expired=True)\n+    from tui_gateway import server_requests\n+    try:\n+        remaining = server_requests.lock_answer(request_id, question_id, answer)\n+    except ValueError as e:\n+        return _err(rid, 4002, str(e))\n+    if remaining is None:\n+        # The wait already ended (timeout / cancel) while the card was still visible: not an error.\n+        return _ok(rid, {\"status\": \"expired\"})\n+    return _ok(rid, {\"status\": \"ok\", \"remaining\": remaining})\n \n \n-_LATE_RESPOND_KEYS = {\n-    \"terminal.read.respond\": \"text\", \"preview.read.respond\": \"text\", \"preview.act.respond\": \"text\",\n-    \"window.read.respond\": \"text\", \"tour.respond\": \"text\", \"mcp.setup.respond\": \"result\",\n-    \"sudo.respond\": \"password\", \"secret.respond\": \"value\", \"vault.unlock.respond\": \"password\",\n-    \"vault.save_login.respond\": \"login\", \"vault.code.respond\": \"code\"}\n-for _name, _key in _LATE_RESPOND_KEYS.items():\n-    method(_name)(lambda rid, params, _k=_key: _respond(rid, params, _k, allow_expired=True))\n-del _name, _key\n+@method(\"request.answer\")\n+def _(rid, params: dict) -> dict:\n+    \"\"\"Answer an open server→client request from a client that did not receive it (a Bot Mode room\n+    window answering a member's prompt mirrored from its resume snapshot). The response-frame path is\n+    the norm; this is the proxy for it. ``expired`` when the request already ended.\"\"\"\n+    request_id = str(params.get(\"id\") or \"\")\n+    result = params.get(\"result\")\n+    if not request_id or not isinstance(result, dict):\n+        return _err(rid, 4002, \"id and an object result required\")\n+    from tui_gateway import server_requests\n+    frame = {\"jsonrpc\": \"2.0\", \"id\": request_id, \"result\": result}\n+    if server_requests.resolve_response(frame) or _relay_compute_host_response(frame):\n+        return _ok(rid, {\"status\": \"ok\"})\n+    return _ok(rid, {\"status\": \"expired\"})\n \n \n # ── approvals ───────────────────────────────────────────────────────────────"]},{"file":"tui_gateway/methods_session.py","hunks":["@@ -2177,8 +2177,10 @@ def _(rid, params: dict) -> dict:\n     from tui_gateway import event_replay as er\n     frames = er.events_since(sid, last_seen)\n     # ``epoch``: in-process seq — clients reset watermarks when this differs from gateway.ready's.\n+    # ``open_requests``: server→client requests still unanswered — the ring cannot carry \"a question still\n+    # waiting\", so the reconnecting client re-delivers these to its request handlers.\n     return _ok(rid, {\"events\": frames, \"latest_seq\": er.latest_seq(sid), \"truncated\": er.is_truncated(sid, last_seen),\n-                     \"count\": len(frames), \"epoch\": er.replay_epoch()})\n+                     \"count\": len(frames), \"epoch\": er.replay_epoch(), \"open_requests\": _open_requests(sid)})\n \n \n @method(\"session.events.stats\")"]},{"file":"tui_gateway/server.py","hunks":["@@ -83,13 +83,6 @@ def _panic_hook(exc_type, exc_value, exc_tb):\n \n _sessions: dict[str, dict] = {}\n _methods: dict[str, callable] = {}\n-_pending: dict[str, tuple[str, threading.Event]] = {}\n-_pending_prompt_payloads: dict[str, tuple[str, dict]] = {}\n-_answers: dict[str, str] = {}\n-# Batch clarify accumulators: rid → {\"qids\": [...], \"answers\": {qid: answer}}. Written by\n-# clarify.respond (per-question lock, update-in-place), read out by _block on resolution/timeout\n-# so locked answers survive the deadline.\n-_batch_clarify: dict[str, dict] = {}\n _db = None\n _db_error: str | None = None\n _stdout_lock = threading.Lock()","@@ -98,7 +91,6 @@ def _panic_hook(exc_type, exc_value, exc_tb):\n # compare/check/write transaction needs its own lock, not the unrelated config cache lock.\n _profile_ui_meta_lock = threading.Lock()\n _sessions_lock = threading.RLock()  # reentrant: _close_session_by_id may run under callers that already hold it\n-_prompt_lock = threading.Lock()\n _cfg_cache: dict | None = None\n _cfg_mtime: float | None = None\n _cfg_path = None","@@ -600,10 +592,12 @@ def write_json(obj: dict) -> bool:\n     from tui_gateway.event_replay import _stamp_event\n     from tui_gateway.hosted_room_member_activity import project_room_member_activity\n     _stamp_event(obj)\n-    if obj.get(\"method\") == \"event\":\n-        # A room member's hidden session has no transport: its frames would die at stdio below.\n+    params = obj.get(\"params\")\n+    if obj.get(\"method\") == \"event\" or (isinstance(obj.get(\"id\"), str) and \"method\" in obj):\n+        # Event notifications AND server→client requests carry ``params.session_id``; both route to the\n+        # owning session's transport. A room member's hidden session has no transport: its frames would\n+        # die at stdio below.\n         project_room_member_activity(obj, _sessions)\n-        params = obj.get(\"params\")\n         sid = ((params or {}).get(\"session_id\")) if isinstance(params, dict) else \"\"\n         if sid and (t := (_sessions.get(sid) or {}).get(\"transport\")) is not None:\n             return t.write(obj)","@@ -619,6 +613,11 @@ def _emit(event: str, sid: str, payload: dict | None = None) -> bool:\n     return write_json(_event_frame(event, sid, payload))\n \n \n+from tui_gateway import server_requests as _server_requests  # noqa: E402\n+\n+_server_requests.bind_sinks(lambda frame: write_json(frame), lambda event, sid, payload: _emit(event, sid, payload))\n+\n+\n # Live WS peer transports (maintained by tui_gateway.ws): the only route for session-less background\n # events, which write_json would otherwise drop on stdio (see _broadcast_global_event).\n _live_transports: set[Transport] = set()","@@ -669,25 +668,20 @@ def _approval_request_payload(data: dict | None) -> dict:\n     return payload\n \n \n-def _pending_clarify_request_payload(sid: str) -> dict | None:\n-    \"\"\"Read-only snapshot of the clarify prompt still blocking a session: a client detached when\n-    `clarify.request` was emitted would otherwise never see it (agent parked until timeout). Same replay\n-    contract as `pending_approval`: the registry stays authoritative; `clarify.respond` resolves by request_id.\"\"\"\n-    with _prompt_lock:\n-        for rid, (owner_sid, _ev) in _pending.items():\n-            event, prompt_payload = _pending_prompt_payloads.get(rid, (\"\", {}))\n-            if owner_sid != sid or event != \"clarify.request\":\n-                continue\n-            snapshot = dict(prompt_payload)\n-            # Batch clarify: replay the answers locked so far so a reconnecting client restores its ✓ state.\n-            if (batch := _batch_clarify.get(rid)) is not None and batch[\"answers\"]:\n-                snapshot[\"answers\"] = dict(batch[\"answers\"])\n-            return snapshot\n+def _open_requests(sid: str) -> list[dict]:\n+    \"\"\"Server→client requests still waiting on *sid*'s renderer, for reconnect snapshots (``session.resume`` /\n+    ``session.activate`` / ``session.events.since``). A client detached when the request frame was written would\n+    otherwise never see it (agent parked until timeout). Under turn isolation the compute-host child owns the\n+    request; the parent mirrors it from the relayed frame (compute_host_bridge).\"\"\"\n+    from tui_gateway import server_requests\n+    reqs = server_requests.open_requests(sid)\n+    if reqs:\n+        return reqs\n     if (session := _sessions.get(sid)) is not None:\n         with session.get(\"history_lock\", threading.Lock()):\n-            pending = session.get(\"_compute_host_pending_clarify\")\n-            return dict(pending) if isinstance(pending, dict) else None\n-    return None\n+            mirrored = session.get(\"_compute_host_open_request\")\n+            return [dict(mirrored)] if isinstance(mirrored, dict) else []\n+    return []\n \n \n def _pending_approval_request_payload(session_key: str) -> dict | None:","@@ -702,12 +696,29 @@ def _pending_approval_request_payload(session_key: str) -> dict | None:\n \n \n def _emit_approval_request(sid: str, data: dict | None) -> None:\n-    \"\"\"Emit ``approval.request`` with the command redacted: a credential-shaped value Tirith flagged would\n-    otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream).\n+    \"\"\"Send an ``approval`` server request with the command redacted: a credential-shaped value Tirith flagged\n+    would otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream).\n+    See #48456, #50767.\n+\n+    The wait is owned by ``tools.approval``'s queue (its own timeout, ``/approve all``, coalescing), so the request\n+    is queue-backed: the response resolves the queue entry, and the entry's own resolution (any surface, timeout,\n+    interrupt) withdraws the request with ``request.cancel``.\"\"\"\n+    from tui_gateway import server_requests\n+    from tools import approval as _approval\n+    payload = _approval_request_payload(data)\n+    request_id = str(payload.get(\"request_id\") or \"\")\n+    session_key = str((_sessions.get(sid) or {}).get(\"session_key\") or \"\")\n+\n+    def on_result(result: dict | None) -> None:\n+        if result is None:  # withdrawn: the queue entry resolves on its own path\n+            return\n+        choice = str(result.get(\"choice\") or \"deny\")\n+        _approval.resolve_gateway_approval(session_key, choice, resolve_all=bool(result.get(\"all\")),\n+                                           request_id=request_id or None)\n \n-    Reuse the shared gateway See #48456, #50767.\n-    \"\"\"\n-    _emit(\"approval.request\", sid, _approval_request_payload(data))\n+    settle = server_requests.send_async(\"approval\", sid, payload, on_result)\n+    if request_id:\n+        _approval.register_gateway_settle(session_key, request_id, settle)\n \n \n def _status_update(sid: str, kind: str, text: str | None = None):","@@ -809,6 +820,12 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None:\n     t = transport or _stdio_transport\n     token = [redacted:secret-assignment]\n     try:\n+        from tui_gateway import server_requests\n+        if server_requests.is_response_frame(req):\n+            # The renderer answering one of OUR requests (clarify, approval, …): no response frame goes back.\n+            if not server_requests.resolve_response(req) and not _relay_compute_host_response(req):\n+                logger.debug(\"dropping response for unknown server request id=%r\", req.get(\"id\"))\n+            return None\n         normalized = _normalize_request(req)\n         if isinstance(normalized, dict):\n             return normalized","@@ -1257,55 +1274,13 @@ def _enable_gateway_prompts() -> None:\n # ── Blocking prompt factory ──────────────────────────────────────────\n \n \n-# Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the tool\n-# returns empty, but a slow renderer could still answer and hit a raw 4009 — `.expire` tears the card down.\n-_EXPIRING_REQUESTS = frozenset({\n-    \"secret.request\", \"sudo.request\", \"vault.unlock.request\", \"vault.save_login.request\", \"vault.code.request\", \"clarify.request\",\n-    \"terminal.read.request\",\n-    \"preview.read.request\", \"preview.act.request\", \"window.read.request\", \"mcp.setup.request\",\n-    \"tour.request\",\n-})\n-\n-\n-def _block(event: str, sid: str, payload: dict, timeout: float | None = 300, batch_qids: list[str] | None = None) -> str:\n-    rid = uuid.uuid4().hex[:8]\n-    ev = threading.Event()\n-    with _prompt_lock:\n-        _pending[rid] = (sid, ev)\n-        payload[\"request_id\"] = rid\n-        _pending_prompt_payloads[rid] = (event, dict(payload))\n-        if batch_qids:\n-            # Multi-question clarify: per-question answers accumulate here (update-in-place until every\n-            # qid is locked); locked answers survive a timeout — see the batch read-out below.\n-            _batch_clarify[rid] = {\"qids\": list(batch_qids), \"answers\": {}}\n-    answered, batch_answers = False, None\n-    try:\n-        _emit(event, sid, payload)\n-        # Event semantics: None → wait forever (clarify_timeout <= 0; released only by a real answer or\n-        # session.interrupt), 0 → return immediately, > 0 → bounded wait.\n-        answered = ev.wait(timeout)\n-    finally:\n-        with _prompt_lock:\n-            _pending.pop(rid, None)\n-            _pending_prompt_payloads.pop(rid, None)\n-            answer_present = rid in _answers\n-            answer = _answers.pop(rid, \"\")\n-            if (batch_state := _batch_clarify.pop(rid, None)) is not None:\n-                batch_answers = dict(batch_state[\"answers\"])\n-    expire = lambda: _emit(f\"{event.removesuffix('.request')}.expire\", sid, {\"request_id\": rid})\n-    if batch_qids is not None:\n-        # Cancel-all (respond with no question_id) resolves via _answers with \"\" — a plain cancel, not a partial result.\n-        if answer_present:\n-            return answer\n-        result: dict[str, object] = {\"answers\": batch_answers or {}}\n-        if not answered:\n-            # Deadline hit: keep what was locked, report the rest as absences (not skips), still expire live cards.\n-            result[\"timed_out\"] = True\n-            expire()\n-        return json.dumps(result, ensure_ascii=False)\n-    if not answered and not answer_present and event in _EXPIRING_REQUESTS:\n-        expire()\n-    return answer\n+def _ask(method: str, sid: str, params: dict, timeout: float | None = 300) -> str:\n+    \"\"\"Server→client request whose answer is one string under ``value`` (sudo, secret, vault prompts, GUI reads,\n+    MCP setup). Empty string when the renderer skipped, timed out, or was cancelled.\"\"\"\n+    from tui_gateway import server_requests\n+    result = server_requests.send(method, sid, params, timeout=timeout)\n+    value = (result or {}).get(\"value\", \"\")\n+    return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)\n \n \n def _clarify_timeout_seconds() -> float | None:","@@ -1319,16 +1294,23 @@ def _clarify_timeout_seconds() -> float | None:\n \n \n def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str:\n-    \"\"\"Bridge the clarify tool callback onto _block. Single-question payloads keep their historical shape\n-    (``multi_select`` only when True — older renderers never see a new field); batch calls emit one\n-    clarify.request with only the wire fields (the tool-side entries carry result-assembly keys too).\"\"\"\n+    \"\"\"Bridge the clarify tool callback onto a ``clarify`` server request. Single question: the response is\n+    ``{\"answer\"}`` (\"\" = skip). Batch: one request with only the wire fields (tool-side entries carry\n+    result-assembly keys too); answers lock one at a time through ``clarify.lock`` and the tool gets\n+    ``{\"answers\", \"timed_out\"?}`` as JSON — a response with no ``answers`` is a cancel-all.\"\"\"\n+    from tui_gateway import server_requests\n     if questions:\n         wire = [{\"qid\": e[\"qid\"], \"question\": e[\"question\"], \"choices\": e[\"choices\"], \"multi_select\": bool(e[\"multi_select\"])}\n                 for e in questions]\n-        return _block(\"clarify.request\", sid, {\"questions\": wire}, timeout=_clarify_timeout_seconds(),\n-                      batch_qids=[e[\"qid\"] for e in questions])\n-    payload = {\"question\": q, \"choices\": c, \"multi_select\": True} if multi_select else {\"question\": q, \"choices\": c}\n-    return _block(\"clarify.request\", sid, payload, timeout=_clarify_timeout_seconds())\n+        result = server_requests.send(\"clarify\", sid, {\"questions\": wire}, timeout=_clarify_timeout_seconds(),\n+                                      qids=[e[\"qid\"] for e in questions])\n+        if not result or \"answers\" not in result:\n+            return \"\"\n+        return json.dumps(result, ensure_ascii=False)\n+    params = {\"question\": q, \"choices\": c, \"multi_select\": True} if multi_select else {\"question\": q, \"choices\": c}\n+    result = server_requests.send(\"clarify\", sid, params, timeout=_clarify_timeout_seconds())\n+    answer = (result or {}).get(\"answer\", \"\")\n+    return answer if isinstance(answer, str) else \"\"\n \n \n # A tour action is a DOM op the renderer answers in ms; the generous deadline exists only because a","@@ -1346,12 +1328,12 @@ def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str:\n \n \n def _tour_request(sid: str, payload: dict) -> str:\n-    \"\"\"Bridge the tour tool callback onto _block without paying for a client that cannot answer: against\n-    an older app nobody calls ``tour.respond`` and each action would block the full deadline, stacking per\n+    \"\"\"Bridge the tour tool callback onto a ``tour`` server request without paying for a client that cannot answer: against\n+    an older app nobody answers ``tour`` and each action would block the full deadline, stacking per\n     turn. First action per session gets the short probe deadline; unanswered → bridge marked unavailable\n     for that session; once answered, the full deadline. Verdict lives on the record, so a new session re-probes.\n \n-    The renderer's ``tour.request`` handler ships in the desktop bundle, but the tool is offered by this\n+    The renderer's ``tour`` handler ships in the desktop bundle, but the tool is offered by this\n     backend — and the two update on different clocks. The model then does what the schema tells it to and\n     tries the next action, so a single \"give me a tour\" turn stacks those waits (the timeouts reported\n     against #89620).","@@ -1362,8 +1344,8 @@ def _tour_request(sid: str, payload: dict) -> str:\n     state = session.get(\"tour_bridge\")\n     if state == \"unanswered\":\n         return _TOUR_BRIDGE_UNAVAILABLE\n-    answer = _block(\"tour.request\", sid, dict(payload),\n-                    timeout=_TOUR_TIMEOUT_S if state == \"answered\" else _TOUR_PROBE_TIMEOUT_S)\n+    answer = _ask(\"tour\", sid, dict(payload),\n+                  timeout=_TOUR_TIMEOUT_S if state == \"answered\" else _TOUR_PROBE_TIMEOUT_S)\n     if answer:\n         session[\"tour_bridge\"] = \"answered\"\n     elif state != \"answered\":","@@ -1372,13 +1354,10 @@ def _tour_request(sid: str, payload: dict) -> str:\n \n \n def _clear_pending(sid: str | None = None) -> None:\n-    \"\"\"Release pending prompts with an empty answer: only *sid*'s (session.interrupt must not cancel other\n-    sessions' prompts), or every one when *sid* is None (shutdown).\"\"\"\n-    with _prompt_lock:\n-        for rid, (owner_sid, ev) in list(_pending.items()):\n-            if sid is None or owner_sid == sid:\n-                _answers[rid] = \"\"\n-                ev.set()\n+    \"\"\"Withdraw open server→client requests: only *sid*'s (session.interrupt must not cancel other sessions'\n+    prompts), or every one when *sid* is None (process exit). Each one gets a ``request.cancel``.\"\"\"\n+    from tui_gateway import server_requests\n+    server_requests.cancel(sid, reason=\"interrupted\" if sid else \"shutdown\")\n \n \n # ── Agent factory ────────────────────────────────────────────────────","@@ -2624,8 +2603,9 @@ def _run() -> None:\n \n \n def _session_pending_kind(sid: str) -> str:\n-    return next((str(_pending_prompt_payloads.get(rid, (\"input.request\", {}))[0]).removesuffix(\".request\")\n-                 for rid, (owner_sid, _ev) in list(_pending.items()) if owner_sid == sid), \"\")\n+    \"\"\"Method of the server→client request *sid* is blocked on (\"\" when none).\"\"\"\n+    from tui_gateway import server_requests\n+    return server_requests.pending_kind(sid)\n \n \n def _session_live_status(sid: str, session: dict) -> str:","@@ -2775,7 +2755,7 @@ def _live_session_payload(\n     }\n     for key, value in ((\"inflight\", inflight), (\"queued\", queued),\n                        (\"pending_approval\", _pending_approval_request_payload(str(session.get(\"session_key\") or \"\"))),\n-                       (\"pending_clarify\", _pending_clarify_request_payload(sid))):\n+                       (\"open_requests\", _open_requests(sid))):\n         if value:\n             payload[key] = value\n     return _attach_todo_state(payload, session)","@@ -3077,31 +3057,6 @@ def _loop() -> None:\n     return stop, thread\n \n \n-# ── Methods: respond ─────────────────────────────────────────────────\n-\n-\n-def _respond(rid, params, key, *, allow_expired=False):\n-    r = params.get(\"request_id\", \"\")\n-    question_id = str(params.get(\"question_id\") or \"\")\n-    with _prompt_lock:\n-        entry = _pending.get(r)\n-        if not entry:\n-            return _ok(rid, {\"status\": \"expired\"}) if allow_expired and r else _err(rid, 4009, f\"no pending {key} request\")\n-        _, ev = entry\n-        batch = _batch_clarify.get(r)\n-        if batch is not None and question_id:\n-            # Per-question lock; update-in-place so an answer stays editable until every qid is locked (Confirm).\n-            if question_id not in batch[\"qids\"]:\n-                return _err(rid, 4002, f\"unknown question_id {question_id!r}\")\n-            batch[\"answers\"][question_id] = params.get(key, \"\")\n-            if not (remaining := [qid for qid in batch[\"qids\"] if qid not in batch[\"answers\"]]):\n-                ev.set()\n-            return _ok(rid, {\"status\": \"ok\", \"remaining\": remaining})\n-        _answers[r] = params.get(key, \"\")\n-        ev.set()\n-    return _ok(rid, {\"status\": \"ok\"})\n-\n-\n # ── Methods: tools & system ──────────────────────────────────────────\n \n "]},{"file":"tui_gateway/server_requests.py","hunks":["@@ -0,0 +1,224 @@\n+\"\"\"Server→client JSON-RPC requests: the backend asks the renderer a question and waits for the\n+response frame carrying the same ``id``.\n+\n+JSON-RPC is peer-to-peer; this is the backend's half. Every \"ask the renderer\" bridge (clarify,\n+approval, sudo, secret, vault prompts, desktop GUI reads, MCP setup consent, the tour) is one\n+:func:`send` (blocking) or :func:`send_async` (queue-backed approvals) and one response frame from\n+the client — no paired ``*.request`` notification / ``*.respond`` method, no per-kind ``*.expire``.\n+\n+Ids are ``srq-<12 hex>``: strings never collide with client-minted integer ids, and the random\n+part keeps a compute-host child's requests distinct from the parent's when both reach one socket.\n+A request that times out or is cancelled (interrupt, session close, shutdown) emits ONE\n+``request.cancel {id, method, reason}`` notification so every renderer tears the card down the\n+same way. A response for an id that is no longer open is dropped — the wait already returned.\n+\n+Reconnect: unanswered requests are returned as ``open_requests`` by ``session.resume`` /\n+``session.activate`` / ``session.events.since`` (:func:`open_requests`); the shared TypeScript\n+channel re-delivers them as if they had just arrived, so the notification replay ring never\n+has to carry \"a question still waiting for an answer\".\n+\n+Batch clarify keeps per-question locks (``clarify.lock`` → :func:`lock_answer`): answers stay\n+editable until every question is locked, locked answers survive a timeout, and the last lock\n+resolves the request with the full answer set.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import logging\n+import threading\n+import time\n+import uuid\n+from typing import Any, Callable\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+class ServerRequest:\n+    __slots__ = (\"id\", \"sid\", \"method\", \"params\", \"event\", \"result\", \"answered\", \"created_at\",\n+                 \"qids\", \"locked\", \"on_result\")\n+\n+    def __init__(self, sid: str, method: str, params: dict, *, qids: list[str] | None = None,\n+                 on_result: Callable[[dict | None], None] | None = None) -> None:\n+        self.id = f\"srq-{uuid.uuid4().hex[:12]}\"\n+        self.sid = sid\n+        self.method = method\n+        self.params = dict(params)\n+        self.event = threading.Event()\n+        self.result: dict | None = None\n+        self.answered = False\n+        self.created_at = time.time()\n+        # Batch clarify: question ids still to lock, and the answers locked so far.\n+        self.qids = list(qids) if qids else None\n+        self.locked: dict[str, str] = {}\n+        self.on_result = on_result\n+\n+    def frame(self) -> dict:\n+        return {\"jsonrpc\": \"2.0\", \"id\": self.id, \"method\": self.method,\n+                \"params\": {\"session_id\": self.sid, **self.params}}\n+\n+    def snapshot(self) -> dict:\n+        \"\"\"``open_requests`` entry: the request as sent, plus the batch answers locked so far so a\n+        reconnecting client restores its ✓ state.\"\"\"\n+        params = {\"session_id\": self.sid, **self.params}\n+        if self.locked:\n+            params[\"answers\"] = dict(self.locked)\n+        return {\"id\": self.id, \"method\": self.method, \"params\": params}\n+\n+\n+_lock = threading.Lock()\n+_open: dict[str, ServerRequest] = {}\n+\n+# Frame sinks, bound by ``bind_sinks`` from server.py at import time (like the method_ctx split\n+# modules): importing server back from here would pick a different module object under the test\n+# fixtures that patch ``sys.modules`` around the server import.\n+_write: Callable[[dict], Any] = lambda frame: None  # noqa: E731\n+_emit: Callable[[str, str, dict], Any] = lambda event, sid, payload: None  # noqa: E731\n+\n+\n+def bind_sinks(write_json: Callable[[dict], Any], emit: Callable[[str, str, dict], Any]) -> None:\n+    global _write, _emit\n+    _write, _emit = write_json, emit\n+\n+\n+def _emit_cancel(req: ServerRequest, reason: str) -> None:\n+    _emit(\"request.cancel\", req.sid, {\"id\": req.id, \"method\": req.method, \"reason\": reason})\n+\n+\n+def _register(req: ServerRequest) -> None:\n+    with _lock:\n+        _open[req.id] = req\n+    _write(req.frame())\n+\n+\n+def send(method: str, sid: str, params: dict, *, timeout: float | None,\n+         qids: list[str] | None = None) -> dict | None:\n+    \"\"\"Send one request and block for the response ``result`` (a dict).\n+\n+    Returns ``None`` when the renderer never answered (timeout, cancel, or an error response — e.g.\n+    a client without a handler for ``method``). ``timeout`` semantics: None → wait until answered or\n+    cancelled, 0 → return immediately, > 0 → bounded wait. A batch (``qids``) that times out\n+    returns ``{\"answers\": <locked so far>, \"timed_out\": True}`` instead of None.\n+    \"\"\"\n+    req = ServerRequest(sid, method, params, qids=qids)\n+    _register(req)\n+    timed_out = False\n+    try:\n+        timed_out = not req.event.wait(timeout)\n+    finally:\n+        with _lock:\n+            _open.pop(req.id, None)\n+    if timed_out:\n+        _emit_cancel(req, \"timeout\")\n+        if req.qids is not None:\n+            return {\"answers\": dict(req.locked), \"timed_out\": True}\n+        return None\n+    return req.result if req.answered else None\n+\n+\n+def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict | None], None]) -> Callable[[str], None]:\n+    \"\"\"Send one request whose wait is owned elsewhere (the approval queue's own timeout). ``on_result``\n+    runs on the dispatching thread when the response lands. Returns ``settle(reason)``: call it when\n+    the underlying wait ends; if the request is still open it is withdrawn with ``request.cancel``.\"\"\"\n+    req = ServerRequest(sid, method, params, on_result=on_result)\n+    _register(req)\n+\n+    def settle(reason: str) -> None:\n+        with _lock:\n+            still_open = _open.pop(req.id, None) is not None\n+        if still_open:\n+            _emit_cancel(req, reason)\n+\n+    return settle\n+\n+\n+def resolve_response(frame: dict) -> bool:\n+    \"\"\"Route one client response frame to its open request. False when nothing is waiting for that id\n+    (already timed out / cancelled, or owned by another process — see the compute-host bridge).\"\"\"\n+    rid = frame.get(\"id\")\n+    if not isinstance(rid, str):\n+        return False\n+    with _lock:\n+        req = _open.get(rid)\n+        if req is None:\n+            return False\n+        if req.on_result is not None:\n+            _open.pop(rid, None)\n+    if \"error\" in frame:\n+        logger.debug(\"server request %s (%s) answered with error: %s\", rid, req.method, frame.get(\"error\"))\n+        req.result, req.answered = None, False\n+    else:\n+        result = frame.get(\"result\")\n+        req.result = result if isinstance(result, dict) else {}\n+        if req.qids and \"answers\" in req.result:\n+            # Batch clarify: answers locked early via clarify.lock belong to the final set even when\n+            # the closing response only carries the tail the user answered last.\n+            answers = req.result.get(\"answers\")\n+            merged = dict(req.locked)\n+            if isinstance(answers, dict):\n+                merged.update(answers)\n+            req.result = {**req.result, \"answers\": merged}\n+        req.answered = True\n+    if req.on_result is not None:\n+        req.on_result(req.result)\n+    req.event.set()\n+    return True\n+\n+\n+def lock_answer(request_id: str, question_id: str, answer: str) -> list[str] | None:\n+    \"\"\"Lock one batch-clarify answer (update-in-place). Returns the question ids still unanswered;\n+    the last lock resolves the request with the full ``{\"answers\"}`` set. ``None`` when no open\n+    batch has that id (expired or foreign); ``ValueError`` for an unknown question id.\"\"\"\n+    with _lock:\n+        req = _open.get(request_id)\n+        if req is None or req.qids is None:\n+            return None\n+        if question_id not in req.qids:\n+            raise ValueError(f\"unknown question_id {question_id!r}\")\n+        req.locked[question_id] = answer\n+        remaining = [qid for qid in req.qids if qid not in req.locked]\n+        if not remaining:\n+            req.result, req.answered = {\"answers\": dict(req.locked)}, True\n+    if not remaining:\n+        req.event.set()\n+    return remaining\n+\n+\n+def cancel(sid: str | None = None, reason: str = \"interrupted\") -> int:\n+    \"\"\"Withdraw open requests — only *sid*'s (session.interrupt must not touch other sessions'), or\n+    every one when *sid* is None (shutdown). Blocked waits return None; queue-backed requests run\n+    ``on_result(None)`` so their owner can settle. Returns the number withdrawn.\"\"\"\n+    with _lock:\n+        targets = [req for req in _open.values() if sid is None or req.sid == sid]\n+        for req in targets:\n+            _open.pop(req.id, None)\n+    for req in targets:\n+        req.result, req.answered = None, False\n+        if req.on_result is not None:\n+            req.on_result(None)\n+        req.event.set()\n+        _emit_cancel(req, reason)\n+    return len(targets)\n+\n+\n+def open_requests(sid: str) -> list[dict]:\n+    \"\"\"Unanswered requests for *sid*, oldest first.\"\"\"\n+    with _lock:\n+        reqs = sorted((req for req in _open.values() if req.sid == sid), key=lambda r: r.created_at)\n+    return [req.snapshot() for req in reqs]\n+\n+\n+def pending_kind(sid: str) -> str:\n+    \"\"\"Method of the oldest open request for *sid* (\"\" when none) — the session is waiting on a human.\"\"\"\n+    with _lock:\n+        reqs = [req for req in _open.values() if req.sid == sid]\n+    return min(reqs, key=lambda r: r.created_at).method if reqs else \"\"\n+\n+\n+def is_response_frame(obj: Any) -> bool:\n+    \"\"\"A client response: has an ``id`` and a ``result``/``error`` member but no ``method``.\"\"\"\n+    return isinstance(obj, dict) and \"method\" not in obj and \"id\" in obj and (\"result\" in obj or \"error\" in obj)\n+\n+\n+def reset_for_tests() -> None:\n+    with _lock:\n+        _open.clear()"]},{"file":"ui-tui/README.md","hunks":["@@ -229,14 +229,18 @@ Tool/status activity is shown in a live activity lane. Transcript rows stay focu\n \n ## Prompt flows\n \n-The Python gateway can pause the main loop and request structured input:\n-\n-- `approval.request`: allow once, allow for session, allow always, or deny\n-- `clarify.request`: pick from choices or type a custom answer\n-- `sudo.request`: masked password entry\n-- `secret.request`: masked value entry for a named env var\n+The Python gateway can pause the main loop and ask the client a question. These are JSON-RPC\n+**requests from the server** (string id, answered with a response frame of the same id — see\n+`createServerRequestHandler.ts`), not events:\n+\n+- `approval`: allow once, allow for session, allow always, or deny → `{ choice }`\n+- `clarify`: pick from choices or type a custom answer → `{ answer }` (batch: `{ answers }`)\n+- `sudo`: masked password entry → `{ value }`\n+- `secret`: masked value entry for a named env var → `{ value }`\n - `session.list`: used by `SessionPicker` for `/resume`\n \n+A withdrawn question (timeout, interrupt) arrives as a `request.cancel` event carrying its id.\n+\n These are stateful UI branches in `app.tsx`, not separate screens.\n \n ## Commands","@@ -304,12 +308,7 @@ Primary event types the client handles today:\n | `tool.generating`          | `{ name }`                                                                  |\n | `tool.progress`            | `{ name, preview }`                                                         |\n | `tool.complete`            | `{ tool_id, name, error?, summary?, duration_s?, inline_diff?, todos? }`    |\n-| `clarify.request`          | `{ question, choices?, request_id }`                                        |\n-| `approval.request`         | `{ command, description, allow_permanent? }`                                |\n-| `sudo.request`             | `{ request_id }`                                                            |\n-| `sudo.expire`              | `{ request_id }` clears a timed-out sudo prompt                             |\n-| `secret.request`           | `{ prompt, env_var, request_id }`                                           |\n-| `secret.expire`            | `{ request_id }` clears a timed-out secret prompt                           |\n+| `request.cancel`           | `{ id, method, reason }` clears the withdrawn server→client request         |\n | `background.complete`      | `{ task_id, text }`                                                         |\n | `billing.step_up.verification` | `{ verification_url, user_code }`                                       |\n | `review.summary`           | `{ text }`                                                                  |"]},{"file":"ui-tui/src/__tests__/createGatewayEventHandler.test.ts","hunks":["@@ -1,7 +1,9 @@\n import { beforeEach, describe, expect, it, vi } from 'vitest'\n \n import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'\n+import { createServerRequestHandler } from '../app/createServerRequestHandler.js'\n import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'\n+import { resetServerRequestsForTests } from '../app/serverRequestStore.js'\n import { turnController } from '../app/turnController.js'\n import { getTurnState, resetTurnState } from '../app/turnStore.js'\n import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'","@@ -58,11 +60,27 @@ const buildCtx = (appended: Msg[]) =>\n     }\n   }) as any\n \n+/** Deliver one server→client request (`tui_gateway/server_requests.py`) to the TUI's request handler. */\n+const serverRequest = (method: string, params: Record<string, unknown>, id = `srq-${method}`) => {\n+  const respond = vi.fn()\n+\n+  const handled = createServerRequestHandler({ ringPromptBell: vi.fn(), setStatus: status => patchUiState({ status }) })({\n+    fail: vi.fn(),\n+    id,\n+    method,\n+    params,\n+    respond\n+  })\n+\n+  return { handled, respond }\n+}\n+\n describe('createGatewayEventHandler', () => {\n   beforeEach(() => {\n     resetOverlayState()\n     resetUiState()\n     resetTurnState()\n+    resetServerRequestsForTests()\n     turnController.fullReset()\n     patchUiState({ showReasoning: true })\n   })","@@ -71,11 +89,7 @@ describe('createGatewayEventHandler', () => {\n     patchUiState({ sid: 'focused' })\n     const onEvent = createGatewayEventHandler(buildCtx([]))\n     onEvent({ session_id: 'focused', payload: {}, type: 'message.start' } as any)\n-    onEvent({\n-      session_id: 'focused',\n-      payload: { request_id: 'approval', command: 'test' },\n-      type: 'approval.request'\n-    } as any)\n+    serverRequest('approval', { session_id: 'focused', request_id: 'approval', command: 'test' })\n     const busyOverlay = getOverlayState().approval\n     expect(getUiState().busy).toBe(true)\n     expect(busyOverlay).not.toBeNull()","@@ -1211,10 +1225,7 @@ describe('createGatewayEventHandler', () => {\n \n     onEvent({ payload: { line: 'Traceback: noisy but non-fatal' }, type: 'gateway.stderr' } as any)\n     onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any)\n-    onEvent({\n-      payload: { command: 'rm -rf /tmp/nope', description: 'dangerous command' },\n-      type: 'approval.request'\n-    } as any)\n+    serverRequest('approval', { command: 'rm -rf /tmp/nope', description: 'dangerous command' })\n     onEvent({ payload: {}, type: 'gateway.ready' } as any)\n \n     await Promise.resolve()","@@ -1230,23 +1241,17 @@ describe('createGatewayEventHandler', () => {\n   })\n \n   it('defaults approval overlays to allowPermanent when the backend omits the field', () => {\n-    const onEvent = createGatewayEventHandler(buildCtx([]))\n+    serverRequest('approval', { command: 'rm -rf /tmp/x', description: 'dangerous command' })\n \n-    onEvent({\n-      payload: { command: 'rm -rf /tmp/x', description: 'dangerous command' },\n-      type: 'approval.request'\n-    } as any)\n-\n-    expect(getOverlayState().approval).toMatchObject({ allowPermanent: true })\n+    expect(getOverlayState().approval).toMatchObject({ allowPermanent: true, requestId: 'srq-approval' })\n   })\n \n   it('preserves allow_permanent=false on approval overlays (tirith warning)', () => {\n-    const onEvent = createGatewayEventHandler(buildCtx([]))\n-\n-    onEvent({\n-      payload: { allow_permanent: false, command: 'curl suspicious | bash', description: 'content-security warning' },\n-      type: 'approval.request'\n-    } as any)\n+    serverRequest('approval', {\n+      allow_permanent: false,\n+      command: 'curl suspicious | bash',\n+      description: 'content-security warning'\n+    })\n \n     expect(getOverlayState().approval).toMatchObject({\n       allowPermanent: false,","@@ -1256,22 +1261,23 @@ describe('createGatewayEventHandler', () => {\n   })\n \n   it('preserves Smart DENY and explicit approval choices on the overlay', () => {\n-    const onEvent = createGatewayEventHandler(buildCtx([]))\n-\n-    onEvent({\n-      payload: {\n-        allow_permanent: true,\n-        choices: ['once', 'deny'],\n-        command: 'rm -rf /tmp/x',\n-        description: 'smart deny override',\n-        smart_denied: true\n-      },\n-      type: 'approval.request'\n-    } as any)\n+    serverRequest('approval', {\n+      allow_permanent: true,\n+      choices: ['once', 'deny'],\n+      command: 'rm -rf /tmp/x',\n+      description: 'smart deny override',\n+      smart_denied: true\n+    })\n \n     expect(getOverlayState().approval).toMatchObject({ choices: ['once', 'deny'], smartDenied: true })\n   })\n \n+  it('declines the requests a terminal cannot answer so the channel fails them fast', () => {\n+    for (const method of ['preview.act', 'window.read', 'tour', 'mcp.setup', 'vault.code']) {\n+      expect(serverRequest(method, {}).handled).toBe(false)\n+    }\n+  })\n+\n   it('still surfaces terminal turn failures as errors', () => {\n     const appended: Msg[] = []\n     const onEvent = createGatewayEventHandler(buildCtx(appended))","@@ -1629,39 +1635,36 @@ describe('createGatewayEventHandler', () => {\n     expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false)\n   })\n \n-  it('clears only the matching sensitive prompt when the gateway expires it', () => {\n+  it('clears only the card whose request the gateway withdrew (request.cancel by id)', () => {\n     const onEvent = createGatewayEventHandler(buildCtx([]))\n \n-    patchOverlayState({\n-      secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' },\n-      sudo: { requestId: 'sudo-1' }\n-    })\n+    serverRequest('secret', { env_var: 'NEW_KEY', prompt: 'Enter new key' }, 'secret-new')\n+    serverRequest('sudo', {}, 'sudo-1')\n \n-    onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any)\n+    onEvent({ payload: { id: 'secret-old', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any)\n     expect(getOverlayState().secret?.requestId).toBe('secret-new')\n \n-    onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any)\n+    onEvent({ payload: { id: 'secret-new', method: 'secret', reason: 'timeout' }, type: 'request.cancel' } as any)\n     expect(getOverlayState().secret).toBeNull()\n+    expect(getOverlayState().sudo?.requestId).toBe('sudo-1')\n \n-    onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any)\n+    onEvent({ payload: { id: 'sudo-1', method: 'sudo', reason: 'interrupted' }, type: 'request.cancel' } as any)\n     expect(getOverlayState().sudo).toBeNull()\n   })\n \n   // ── Batch (multi-question) clarify ─────────────────────────────────\n \n-  it('parses a batch clarify.request into a questions overlay', () => {\n-    const onEvent = createGatewayEventHandler(buildCtx([]))\n-\n-    onEvent({\n-      payload: {\n+  it('parses a batch clarify request into a questions overlay', () => {\n+    serverRequest(\n+      'clarify',\n+      {\n         questions: [\n           { choices: ['a', 'b'], qid: 'q0', question: 'One?' },\n           { choices: null, qid: 'q1', question: 'Two?' }\n-        ],\n-        request_id: 'req-batch'\n+        ]\n       },\n-      type: 'clarify.request'\n-    } as any)\n+      'req-batch'\n+    )\n \n     const clarify = getOverlayState().clarify\n     expect(clarify?.requestId).toBe('req-batch')","@@ -1671,39 +1674,35 @@ describe('createGatewayEventHandler', () => {\n     expect(clarify?.answers).toEqual({})\n   })\n \n-  it('seeds locked answers from a reconnect-replay batch clarify.request', () => {\n-    const onEvent = createGatewayEventHandler(buildCtx([]))\n-\n-    onEvent({\n-      payload: {\n+  it('seeds locked answers from a reconnect-replayed batch clarify request', () => {\n+    serverRequest(\n+      'clarify',\n+      {\n         answers: { q0: 'a' },\n         questions: [\n           { choices: ['a', 'b'], qid: 'q0', question: 'One?' },\n           { choices: null, qid: 'q1', question: 'Two?' }\n-        ],\n-        request_id: 'req-replay'\n+        ]\n       },\n-      type: 'clarify.request'\n-    } as any)\n+      'req-replay'\n+    )\n \n     expect(getOverlayState().clarify?.answers).toEqual({ q0: 'a' })\n   })\n \n   it('drops malformed batch entries and falls back to single-question shape when none survive', () => {\n-    const onEvent = createGatewayEventHandler(buildCtx([]))\n-\n-    onEvent({\n-      payload: {\n+    serverRequest(\n+      'clarify',\n+      {\n         choices: ['x', 'y'],\n         question: 'Fallback?',\n         questions: [\n           { qid: '', question: 'no qid' },\n           { qid: 'q1', question: '   ' }\n-        ],\n-        request_id: 'req-bad'\n+        ]\n       },\n-      type: 'clarify.request'\n-    } as any)\n+      'req-bad'\n+    )\n \n     const clarify = getOverlayState().clarify\n     expect(clarify?.questions).toBeUndefined()"]},{"file":"ui-tui/src/__tests__/useInputHandlers.test.ts","hunks":["@@ -1,6 +1,7 @@\n import { describe, expect, it, vi } from 'vitest'\n \n import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'\n+import { rememberServerRequest, resetServerRequestsForTests } from '../app/serverRequestStore.js'\n import {\n   applyVoiceRecordResponse,\n   dismissSensitivePrompt,","@@ -156,31 +157,37 @@ describe('applyVoiceRecordResponse', () => {\n })\n \n describe('dismissSensitivePrompt', () => {\n-  it('clears a sudo overlay before a stale cancel RPC resolves', async () => {\n+  const openRequest = (id: string, method: string) => {\n+    const respond = vi.fn()\n+\n+    rememberServerRequest({ fail: vi.fn(), id, method, params: {}, respond })\n+\n+    return respond\n+  }\n+\n+  it('clears a sudo overlay and answers the server request with an empty value', () => {\n     resetOverlayState()\n-    patchOverlayState({ sudo: { requestId: 'sudo-1' } })\n-    const rpc = vi.fn().mockResolvedValue(null)\n+    resetServerRequestsForTests()\n+    patchOverlayState({ sudo: { requestId: 'srq-sudo' } })\n+    const respond = openRequest('srq-sudo', 'sudo')\n     const sys = vi.fn()\n \n-    const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)\n+    dismissSensitivePrompt(getOverlayState(), vi.fn(), sys)\n \n     expect(getOverlayState().sudo).toBeNull()\n     expect(sys).toHaveBeenCalledWith('sudo cancelled')\n-    expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })\n-    await pending\n+    expect(respond).toHaveBeenCalledWith({ value: '' })\n   })\n \n-  it('clears a secret overlay before a stale cancel RPC resolves', async () => {\n+  it('clears a secret overlay even when its request already expired (nothing left to answer)', () => {\n     resetOverlayState()\n-    patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } })\n-    const rpc = vi.fn().mockResolvedValue(null)\n+    resetServerRequestsForTests()\n+    patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'srq-gone' } })\n     const sys = vi.fn()\n \n-    const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)\n+    dismissSensitivePrompt(getOverlayState(), vi.fn(), sys)\n \n     expect(getOverlayState().secret).toBeNull()\n     expect(sys).toHaveBeenCalledWith('secret entry cancelled')\n-    expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })\n-    await pending\n   })\n })"]},{"file":"ui-tui/src/app/createGatewayEventHandler.ts","hunks":["@@ -31,6 +31,7 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js'\n import type { GatewayEventHandlerContext, NoticeLevel } from './interfaces.js'\n import { getOverlayState, patchOverlayState } from './overlayStore.js'\n import { flashGoodVibes, flashPet } from './petFlashStore.js'\n+import { forgetServerRequest } from './serverRequestStore.js'\n import { turnController } from './turnController.js'\n import { getTurnState } from './turnStore.js'\n import { getUiState, patchUiState } from './uiStore.js'","@@ -449,8 +450,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:\n   // paths can't both persist the same prompt twice.\n   const persistedAbandonedClarify = new Set<string>()\n \n-  // When a clarify prompt is dismissed without an answer (the backend _block\n-  // timed out and returned an empty string), the live ClarifyPrompt overlay is\n+  // When a clarify prompt is dismissed without an answer (the backend request\n+  // timed out and returned no answer), the live ClarifyPrompt overlay is\n   // left set until the next turn's idle() silently nulls it — so the question\n   // and options vanish from the screen while the agent's follow-up still refers\n   // to them.  The reliable signal is the clarify tool's own tool.complete (and,","@@ -1257,124 +1258,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:\n         return\n       }\n \n-      case 'clarify.request': {\n-        if (!ev.payload) {\n-          return\n-        }\n-\n-        const batch = (ev.payload.questions ?? [])\n-          .filter(q => typeof q?.qid === 'string' && q.qid && typeof q?.question === 'string' && q.question.trim())\n-          .map(q => ({\n-            choices: q.choices && q.choices.length > 0 ? q.choices : null,\n-            multiSelect: q.multi_select === true,\n-            qid: q.qid,\n-            question: q.question.trim()\n-          }))\n-\n-        patchOverlayState({\n-          clarify: batch.length\n-            ? {\n-                answers: ev.payload.answers ?? {},\n-                choices: null,\n-                question: '',\n-                questions: batch,\n-                requestId: ev.payload.request_id\n-              }\n-            : {\n-                choices: ev.payload.choices ?? null,\n-                question: ev.payload.question ?? '',\n-                requestId: ev.payload.request_id\n-              }\n-        })\n-        setStatus('waiting for input…')\n-        ringPromptBell()\n-\n-        return\n-      }\n-\n-      case 'approval.request': {\n-        if (!ev.payload) {\n-          return\n-        }\n+      case 'request.cancel': {\n+        // The backend withdrew a server→client request (timeout / interrupt /\n+        // session close): tear down whichever card carries that id. A clarify\n+        // that timed out is persisted as an abandoned prompt by tool.complete.\n+        const id = ev.payload?.id\n \n-        const description = String(ev.payload.description ?? 'dangerous command')\n-        // Only an explicit false (tirith warning) drops the permanent-allow option.\n-        const allowPermanent = ev.payload.allow_permanent !== false\n-\n-        patchOverlayState({\n-          approval: {\n-            allowPermanent,\n-            choices: ev.payload.choices,\n-            command: String(ev.payload.command ?? ''),\n-            description,\n-            smartDenied: ev.payload.smart_denied === true\n-          }\n-        })\n-        setStatus('approval needed')\n-        ringPromptBell()\n-\n-        return\n-      }\n-\n-      case 'sudo.request':\n-        if (!ev.payload) {\n+        if (!id) {\n           return\n         }\n \n-        patchOverlayState({ sudo: { requestId: ev.payload.request_id } })\n-        setStatus('sudo password needed')\n-        ringPromptBell()\n-\n-        return\n+        forgetServerRequest(id)\n+        patchOverlayState(prev => {\n+          const next = { ...prev }\n+          let changed = false\n \n-      case 'secret.request':\n-        if (!ev.payload) {\n-          return\n-        }\n-\n-        patchOverlayState({\n-          secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id }\n-        })\n-        setStatus('secret input needed')\n-        ringPromptBell()\n-\n-        return\n-      case 'sudo.expire': {\n-        const expired = ev.payload?.request_id\n-\n-        patchOverlayState(prev => (prev.sudo?.requestId === expired ? { ...prev, sudo: null } : prev))\n-\n-        return\n-      }\n-\n-      case 'secret.expire': {\n-        const expired = ev.payload?.request_id\n-\n-        patchOverlayState(prev => (prev.secret?.requestId === expired ? { ...prev, secret: null } : prev))\n-\n-        return\n-      }\n-\n-      case 'vault.unlock.request':\n-        if (!ev.payload) {\n-          return\n-        }\n-\n-        patchOverlayState({\n-          vaultUnlock: {\n-            backend: ev.payload.backend,\n-            displayName: ev.payload.display_name,\n-            requestId: ev.payload.request_id\n+          for (const key of ['approval', 'clarify', 'secret', 'sudo', 'vaultUnlock'] as const) {\n+            if (prev[key]?.requestId === id) {\n+              next[key] = null\n+              changed = true\n+            }\n           }\n-        })\n-        setStatus(`unlock ${ev.payload.display_name}`)\n-        ringPromptBell()\n \n-        return\n-      case 'vault.unlock.expire': {\n-        const expired = ev.payload?.request_id\n-\n-        patchOverlayState(prev => (prev.vaultUnlock?.requestId === expired ? { ...prev, vaultUnlock: null } : prev))\n+          return changed ? next : prev\n+        })\n \n         return\n       }"]},{"file":"ui-tui/src/app/createServerRequestHandler.ts","hunks":["@@ -0,0 +1,114 @@\n+import type { ServerRequest } from '@hermes/shared/json-rpc-channel'\n+\n+import type { ClarifyBatchQuestion } from '../types.js'\n+\n+import { patchOverlayState } from './overlayStore.js'\n+import { rememberServerRequest } from './serverRequestStore.js'\n+\n+export interface ServerRequestHandlerContext {\n+  ringPromptBell: () => void\n+  setStatus: (status: string) => void\n+}\n+\n+const str = (v: unknown): string => (typeof v === 'string' ? v : '')\n+\n+const strList = (v: unknown): null | string[] =>\n+  Array.isArray(v) && v.length > 0 ? v.filter((c): c is string => typeof c === 'string') : null\n+\n+/**\n+ * The Ink TUI's answer to the backend's server→client requests\n+ * (`tui_gateway/server_requests.py`). Each method opens its overlay card;\n+ * the card's answer path resolves the request through `serverRequestStore`.\n+ * Methods the terminal cannot answer (desktop GUI bridges: `preview.*`,\n+ * `window.read`, `tour`, `mcp.setup`, `terminal.read`, the vault card\n+ * prompts) return `false` so the channel answers `-32601` and the tool\n+ * fails fast instead of waiting out its deadline.\n+ */\n+export function createServerRequestHandler(ctx: ServerRequestHandlerContext): (request: ServerRequest) => boolean {\n+  const { ringPromptBell, setStatus } = ctx\n+\n+  const open = (request: ServerRequest, status: string) => {\n+    rememberServerRequest(request)\n+    setStatus(status)\n+\n+    if (!request.replayed) {\n+      ringPromptBell()\n+    }\n+  }\n+\n+  return request => {\n+    const p = request.params\n+\n+    switch (request.method) {\n+      case 'clarify': {\n+        const batch: ClarifyBatchQuestion[] = (Array.isArray(p.questions) ? (p.questions as unknown[]) : [])\n+          .map(raw => (raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}))\n+          .filter(q => str(q.qid) && str(q.question).trim())\n+          .map(q => ({\n+            choices: strList(q.choices),\n+            multiSelect: q.multi_select === true,\n+            qid: str(q.qid),\n+            question: str(q.question).trim()\n+          }))\n+\n+        const answers =\n+          p.answers && typeof p.answers === 'object'\n+            ? Object.fromEntries(\n+                Object.entries(p.answers as Record<string, unknown>).filter(\n+                  (entry): entry is [string, string] => typeof entry[1] === 'string'\n+                )\n+              )\n+            : {}\n+\n+        patchOverlayState({\n+          clarify: batch.length\n+            ? { answers, choices: null, question: '', questions: batch, requestId: request.id }\n+            : { choices: strList(p.choices), question: str(p.question), requestId: request.id }\n+        })\n+        open(request, 'waiting for input…')\n+\n+        return true\n+      }\n+\n+      case 'approval': {\n+        patchOverlayState({\n+          approval: {\n+            // Only an explicit false (tirith warning) drops the permanent-allow option.\n+            allowPermanent: p.allow_permanent !== false,\n+            choices: strList(p.choices) ?? undefined,\n+            command: str(p.command),\n+            description: str(p.description) || 'dangerous command',\n+            requestId: request.id,\n+            smartDenied: p.smart_denied === true\n+          }\n+        })\n+        open(request, 'approval needed')\n+\n+        return true\n+      }\n+\n+      case 'sudo':\n+        patchOverlayState({ sudo: { requestId: request.id } })\n+        open(request, 'sudo password needed')\n+\n+        return true\n+\n+      case 'secret':\n+        patchOverlayState({ secret: { envVar: str(p.env_var), prompt: str(p.prompt), requestId: request.id } })\n+        open(request, 'secret input needed')\n+\n+        return true\n+\n+      case 'vault.unlock_prompt':\n+        patchOverlayState({\n+          vaultUnlock: { backend: str(p.backend), displayName: str(p.display_name), requestId: request.id }\n+        })\n+        open(request, `unlock ${str(p.display_name)}`)\n+\n+        return true\n+\n+      default:\n+        return false\n+    }\n+  }\n+}"]},{"file":"ui-tui/src/app/serverRequestStore.ts","hunks":["@@ -0,0 +1,38 @@\n+import type { ServerRequest } from '@hermes/shared/json-rpc-channel'\n+\n+// Live server→client requests (clarify, approval, sudo, …) keyed by request\n+// id. Overlay state keeps only the id; answering resolves the stored request\n+// so a re-delivered (`open_requests`) request with the same id reuses the\n+// same card. Module-level, like the overlay store: the gateway client and\n+// the Ink handlers share one instance per process.\n+const open = new Map<string, ServerRequest>()\n+\n+export function rememberServerRequest(request: ServerRequest): void {\n+  open.set(request.id, request)\n+}\n+\n+export function forgetServerRequest(id: string): void {\n+  open.delete(id)\n+}\n+\n+/** Answer request `id` and forget it. False when nothing is open under that id (expired / already answered). */\n+export function respondToServerRequest(id: string, result: Record<string, unknown>): boolean {\n+  const request = open.get(id)\n+\n+  if (!request) {\n+    return false\n+  }\n+\n+  open.delete(id)\n+  request.respond(result)\n+\n+  return true\n+}\n+\n+export function hasOpenServerRequest(id: string): boolean {\n+  return open.has(id)\n+}\n+\n+export function resetServerRequestsForTests(): void {\n+  open.clear()\n+}"]},{"file":"ui-tui/src/app/useInputHandlers.ts","hunks":["@@ -5,13 +5,7 @@ import { useEffect, useRef } from 'react'\n import { DASHBOARD_TUI_MODE } from '../config/env.js'\n import { DOUBLE_ESC_MS, TYPING_IDLE_MS } from '../config/timing.js'\n import { applyCompletion } from '../domain/slash.js'\n-import type {\n-  ApprovalRespondResponse,\n-  ConfigSetResponse,\n-  SecretRespondResponse,\n-  SudoRespondResponse,\n-  VoiceRecordResponse\n-} from '../gatewayTypes.js'\n+import type { ConfigSetResponse, VoiceRecordResponse } from '../gatewayTypes.js'\n import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'\n import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'\n import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'","@@ -27,6 +21,7 @@ import {\n   type OverlayState\n } from './interfaces.js'\n import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'\n+import { respondToServerRequest } from './serverRequestStore.js'\n import { turnController } from './turnController.js'\n import { patchTurnState } from './turnStore.js'\n import { getUiState } from './uiStore.js'","@@ -153,7 +148,9 @@ export function dismissSensitivePrompt(\n     patchOverlayState({ sudo: null })\n     sys('sudo cancelled')\n \n-    return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId })\n+    respondToServerRequest(requestId, { value: '' })\n+\n+    return\n   }\n \n   if (overlay.secret) {","@@ -162,7 +159,9 @@ export function dismissSensitivePrompt(\n     patchOverlayState({ secret: null })\n     sys('secret entry cancelled')\n \n-    return rpc<SecretRespondResponse>('secret.respond', { request_id: requestId, value: '' })\n+    respondToServerRequest(requestId, { value: '' })\n+\n+    return\n   }\n \n   if (overlay.vaultUnlock) {","@@ -171,7 +170,7 @@ export function dismissSensitivePrompt(\n     patchOverlayState({ vaultUnlock: null })\n     sys(`${overlay.vaultUnlock.displayName} stays locked`)\n \n-    return rpc<SecretRespondResponse>('vault.unlock.respond', { password: '', request_id: requestId })\n+    respondToServerRequest(requestId, { value: '' })\n   }\n }\n ","@@ -228,9 +227,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {\n     }\n \n     if (overlay.approval) {\n-      return gateway\n-        .rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })\n-        .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))\n+      respondToServerRequest(overlay.approval.requestId, { choice: 'deny' })\n+      patchOverlayState({ approval: null })\n+      patchTurnState({ outcome: 'denied' })\n+\n+      return\n     }\n \n     if (overlay.sudo || overlay.secret || overlay.vaultUnlock) {"]},{"file":"ui-tui/src/app/useMainApp.ts","hunks":["@@ -8,6 +8,7 @@ import {\n   useStdout,\n   useTerminalTitle\n } from '@hermes/ink'\n+import { JSON_RPC_METHOD_NOT_FOUND, type ServerRequest } from '@hermes/shared/json-rpc-channel'\n import { useStore } from '@nanostores/react'\n import { useCallback, useEffect, useMemo, useRef, useState } from 'react'\n ","@@ -22,7 +23,7 @@ import { type GatewayClient } from '../gatewayClient.js'\n import type { SubagentListResponse } from '../gatewayTypes.js'\n import type {\n   AnyGatewayEvent,\n-  ClarifyRespondResponse,\n+  ClarifyLockResponse,\n   ConfigSetResponse,\n   SessionActiveListResponse,\n   SessionCloseResponse,","@@ -49,6 +50,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js'\n \n import { applyAgentSnapshot } from './agentRoster.js'\n import { createGatewayEventHandler } from './createGatewayEventHandler.js'\n+import { createServerRequestHandler } from './createServerRequestHandler.js'\n import { createSlashHandler } from './createSlashHandler.js'\n import { planGatewayRecovery } from './gatewayRecovery.js'\n import { getInputSelection } from './inputSelectionStore.js'","@@ -56,6 +58,7 @@ import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfa\n import { $overlayState, patchOverlayState } from './overlayStore.js'\n import { $goodVibesTick } from './petFlashStore.js'\n import { scrollWithSelectionBy } from './scroll.js'\n+import { respondToServerRequest } from './serverRequestStore.js'\n import { turnController } from './turnController.js'\n import { patchTurnState, useTurnSelector } from './turnStore.js'\n import { $uiState, getUiState, patchUiState } from './uiStore.js'","@@ -232,6 +235,7 @@ export function useMainApp(gw: GatewayClient) {\n   const colsRef = useRef(cols)\n   const scrollRef = useRef<null | ScrollBoxHandle>(null)\n   const onEventRef = useRef<(ev: AnyGatewayEvent) => void>(() => {})\n+  const onServerRequestRef = useRef<(request: ServerRequest) => boolean>(() => false)\n   const sysRef = useRef<(text: string) => void>(() => {})\n   const submitRef = useRef<(value: string) => void>(() => {})\n   const submitLiteralRef = useRef<(value: string) => void>(() => {})","@@ -710,11 +714,14 @@ export function useMainApp(gw: GatewayClient) {\n       turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line))\n       patchTurnState({ turnTrail: turnController.turnTools })\n \n-      rpc<ClarifyRespondResponse>('clarify.respond', { answer, request_id: clarify.requestId }).then(r => {\n-        if (!r) {\n-          return\n-        }\n+      if (!respondToServerRequest(clarify.requestId, { answer })) {\n+        // The request already expired (request.cancel raced the keystroke): nothing to answer.\n+        patchOverlayState({ clarify: null })\n \n+        return\n+      }\n+\n+      {\n         if (answer) {\n           turnController.persistedToolLabels.add(label)\n           appendMessage({","@@ -738,14 +745,14 @@ export function useMainApp(gw: GatewayClient) {\n         }\n \n         patchOverlayState({ clarify: null })\n-      })\n+      }\n     },\n-    [appendMessage, overlay.clarify, rpc]\n+    [appendMessage, overlay.clarify]\n   )\n \n-  // Lock one answer of a batch clarify (clarify.respond + question_id). The\n-  // overlay stays up until the server reports no remaining questions — the\n-  // final lock resolves the tool and the turn continues.\n+  // Lock one answer of a batch clarify (`clarify.lock` RPC). The overlay stays\n+  // up until the server reports no remaining questions — the final lock\n+  // resolves the server request and the turn continues.\n   const answerClarifyQuestion = useCallback(\n     (qid: string, answer: string) => {\n       const clarify = overlay.clarify","@@ -754,7 +761,7 @@ export function useMainApp(gw: GatewayClient) {\n         return\n       }\n \n-      rpc<ClarifyRespondResponse & { remaining?: string[] }>('clarify.respond', {\n+      rpc<ClarifyLockResponse>('clarify.lock', {\n         answer,\n         question_id: qid,\n         request_id: clarify.requestId","@@ -765,6 +772,12 @@ export function useMainApp(gw: GatewayClient) {\n \n         const answers = { ...(clarify.answers ?? {}), [qid]: answer }\n \n+        if (r.status === 'expired') {\n+          patchOverlayState({ clarify: null })\n+\n+          return\n+        }\n+\n         if ((r.remaining ?? []).length > 0) {\n           patchOverlayState({ clarify: { ...clarify, answers } })\n ","@@ -908,9 +921,30 @@ export function useMainApp(gw: GatewayClient) {\n \n   onEventRef.current = onEvent\n \n+  const onServerRequest = useMemo(\n+    () =>\n+      createServerRequestHandler({\n+        ringPromptBell: () => {\n+          if (bellOnPrompt && stdout?.isTTY) {\n+            stdout.write('\\x07')\n+          }\n+        },\n+        setStatus: status => patchUiState({ status })\n+      }),\n+    [bellOnPrompt, stdout]\n+  )\n+\n+  onServerRequestRef.current = onServerRequest\n+\n   useEffect(() => {\n     const handler = (ev: AnyGatewayEvent) => onEventRef.current(ev)\n \n+    const requestHandler = (request: ServerRequest) => {\n+      if (!onServerRequestRef.current(request)) {\n+        request.fail(JSON_RPC_METHOD_NOT_FOUND, `the terminal UI cannot answer ${request.method}`)\n+      }\n+    }\n+\n     const exitHandler = () => {\n       turnController.reset()\n ","@@ -946,12 +980,14 @@ export function useMainApp(gw: GatewayClient) {\n     }\n \n     gw.on('event', handler)\n+    gw.on('request', requestHandler)\n     gw.on('exit', exitHandler)\n     gw.drain()\n \n     // entry.tsx's setupGracefulExit handles process cleanup on real exit.\n     return () => {\n       gw.off('event', handler)\n+      gw.off('request', requestHandler)\n       gw.off('exit', exitHandler)\n     }\n   }, [gw, sys])","@@ -1015,19 +1051,26 @@ export function useMainApp(gw: GatewayClient) {\n \n   slashRef.current = slash\n \n-  const respondWith = useCallback(\n-    (method: string, params: Record<string, unknown>, done: () => void) => rpc(method, params).then(r => r && done()),\n-    [rpc]\n-  )\n+  // Answer a server→client request by id; the card closes either way (an\n+  // expired request has nothing left to answer).\n+  const respondWith = useCallback((requestId: string, result: Record<string, unknown>, done: () => void) => {\n+    respondToServerRequest(requestId, result)\n+    done()\n+  }, [])\n \n   const answerApproval = useCallback(\n-    (choice: string) =>\n-      respondWith('approval.respond', { choice, session_id: ui.sid }, () => {\n+    (choice: string) => {\n+      if (!overlay.approval) {\n+        return\n+      }\n+\n+      respondWith(overlay.approval.requestId, { choice }, () => {\n         patchOverlayState({ approval: null })\n         patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` })\n         patchUiState({ status: 'running…' })\n-      }),\n-    [respondWith, ui.sid]\n+      })\n+    },\n+    [overlay.approval, respondWith]\n   )\n \n   const answerSudo = useCallback(","@@ -1042,7 +1085,7 @@ export function useMainApp(gw: GatewayClient) {\n         patchOverlayState({ sudo: null })\n       }\n \n-      return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => {\n+      respondWith(requestId, { value: pw }, () => {\n         patchOverlayState({ sudo: null })\n         patchUiState({ status: 'running…' })\n       })","@@ -1062,7 +1105,7 @@ export function useMainApp(gw: GatewayClient) {\n         patchOverlayState({ secret: null })\n       }\n \n-      return respondWith('secret.respond', { request_id: requestId, value }, () => {\n+      respondWith(requestId, { value }, () => {\n         patchOverlayState({ secret: null })\n         patchUiState({ status: 'running…' })\n       })","@@ -1082,7 +1125,7 @@ export function useMainApp(gw: GatewayClient) {\n         patchOverlayState({ vaultUnlock: null })\n       }\n \n-      return respondWith('vault.unlock.respond', { password, request_id: requestId }, () => {\n+      respondWith(requestId, { value: password }, () => {\n         patchOverlayState({ vaultUnlock: null })\n         patchUiState({ status: 'running…' })\n       })"]},{"file":"ui-tui/src/gatewayClient.ts","hunks":["@@ -9,6 +9,7 @@ import {\n   DEFAULT_HEARTBEAT_DEADLINE_MS,\n   DEFAULT_HEARTBEAT_INTERVAL_MS,\n   JsonRpcRequestChannel,\n+  type ServerRequest,\n   wireFrameText\n } from '@hermes/shared/json-rpc-channel'\n import { reconnectBackoffDelayMs } from '@hermes/shared/reconnect-backoff'","@@ -134,10 +135,15 @@ export class GatewayClient extends EventEmitter {\n   private readonly channel = new JsonRpcRequestChannel({\n     onEvent: ev => this.publish(ev as AnyGatewayEvent),\n     onHeartbeatFailure: () => this.onHeartbeatFailure(),\n+    onUnhandledRequest: req => this.pushLog(`[protocol] unhandled server request: ${req.method}`),\n     requestTimeoutMs: REQUEST_TIMEOUT_MS,\n     unrefTimers: true\n   })\n   private bufferedEvents = new CircularBuffer<AnyGatewayEvent>(MAX_BUFFERED_EVENTS)\n+  // Server→client requests (clarify, approval, sudo, …) follow the same\n+  // mount-order contract as events: an attached session mid-turn can send one\n+  // the instant the socket opens, before the Ink handler is registered.\n+  private bufferedRequests: ServerRequest[] = []\n   private pendingExit: number | null | undefined\n   private ready = false\n   private readyTimer: ReturnType<typeof setTimeout> | null = null","@@ -155,6 +161,13 @@ export class GatewayClient extends EventEmitter {\n     // useInput / createGatewayEventHandler can legitimately attach many\n     // listeners. Default 10-cap triggers spurious warnings.\n     this.setMaxListeners(0)\n+    this.channel.onRequest(request => {\n+      if (this.subscribed) {\n+        this.emit('request', request)\n+      } else {\n+        this.bufferedRequests.push(request)\n+      }\n+    })\n   }\n \n   private publish(ev: AnyGatewayEvent) {","@@ -280,6 +293,7 @@ export class GatewayClient extends EventEmitter {\n     // its queued microtask becomes a no-op (it captured the old generation).\n     this.drainGeneration += 1\n     this.bufferedEvents.clear()\n+    this.bufferedRequests = []\n     this.pendingExit = undefined\n     this.stdoutRl?.close()\n     this.stderrRl?.close()","@@ -673,6 +687,10 @@ export class GatewayClient extends EventEmitter {\n         this.emit('event', ev)\n       }\n \n+      for (const request of this.bufferedRequests.splice(0)) {\n+        this.emit('request', request)\n+      }\n+\n       if (this.pendingExit !== undefined) {\n         const code = this.pendingExit\n "]},{"file":"ui-tui/src/gatewayTypes.ts","hunks":["@@ -316,20 +316,10 @@ export interface BackgroundStartResponse {\n   task_id?: string\n }\n \n-export interface ClarifyRespondResponse {\n-  ok?: boolean\n-}\n-\n-export interface ApprovalRespondResponse {\n-  ok?: boolean\n-}\n-\n-export interface SudoRespondResponse {\n-  ok?: boolean\n-}\n-\n-export interface SecretRespondResponse {\n-  ok?: boolean\n+/** `clarify.lock` — one batch-clarify answer locked; `expired` when the request already ended. */\n+export interface ClarifyLockResponse {\n+  remaining?: string[]\n+  status: 'expired' | 'ok'\n }\n \n // ── Shell / clipboard / input ────────────────────────────────────────"]},{"file":"ui-tui/src/types.ts","hunks":["@@ -100,6 +100,8 @@ export interface ApprovalReq {\n   choices?: string[]\n   command: string\n   description: string\n+  /** Server→client request id; the answer is the response frame for it. */\n+  requestId: string\n   smartDenied?: boolean\n }\n "]},{"file":"website/docs/developer-guide/programmatic-integration.md","hunks":["@@ -46,8 +46,7 @@ session.create          session.list            session.active_list\n session.activate        session.close           session.interrupt\n session.history         session.compress        session.branch\n session.title           session.usage           session.status\n-clarify.respond         sudo.respond            secret.respond\n-approval.respond        config.set / config.get commands.catalog\n+clarify.lock            config.set / config.get commands.catalog\n command.resolve         command.dispatch        cli.exec\n reload.mcp              reload.env              process.stop\n delegation.status       subagent.interrupt      subagent.steer","@@ -76,7 +75,20 @@ On a successful truncating submit against a durable session, the `prompt.submit`\n \n ### Events streamed back\n \n-`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.\n+`message.delta`, `message.complete`, `tool.start`, `tool.generating`, `tool.complete`, `gateway.ready`, `request.cancel`, plus session lifecycle and error events.\n+\n+### Server→client requests (questions the agent asks you)\n+\n+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:\n+\n+```\n+← {\"jsonrpc\":\"2.0\",\"id\":\"srq-7\",\"method\":\"approval\",\"params\":{\"session_id\":\"…\",\"request_id\":\"…\",\"command\":\"rm -rf build\",\"description\":\"…\"}}\n+→ {\"jsonrpc\":\"2.0\",\"id\":\"srq-7\",\"result\":{\"choice\":\"once\"}}\n+```\n+\n+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.\n+\n+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.\n \n ### Pi-style RPC mapping\n ","@@ -94,7 +106,7 @@ Every command in the Pi-mono RPC spec ([issue #360](https://github.com/NousResea\n | `get_messages` | `session.history` |\n | `switch_session` | `session.resume` |\n | `fork` | `session.branch` |\n-| `ui_request` / `ui_response` | `clarify.respond` / `sudo.respond` / `secret.respond` / `approval.respond` |\n+| `ui_request` / `ui_response` | server→client requests `clarify` / `sudo` / `secret` / `approval` answered by JSON-RPC response frames |\n \n ---\n \n"]}]},{"kind":"pr","number":114972,"title":"fix(desktop): name the session in approval and input-request OS toasts (#114337, salvage #114383)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}