From ebe8cda8eac5860cc01d19d5b109da73a0df46a0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:23:36 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui=5Fgateway):=20real=20JSON-RPC=20server?= =?UTF-8?q?=E2=86=92client=20requests=20replace=20the=20*.request=20/=20*.?= =?UTF-8?q?respond=20notification=20pair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend never sent a JSON-RPC request; when it needed an answer from the renderer it hand-correlated a `*.request` notification with a later `*.respond` method through four module-level dicts, a timeout thread and 13 derived `*.expire` names, plus a separate reconnect snapshot per prompt kind. That is a second request/response layer built on a protocol that already has one. `tui_gateway/server_requests.py` sends `{id: "srq-…", method, params}` and blocks on the response frame with that id (string ids never collide with the clients' integer ids). One `request.cancel {id, method, reason}` notification withdraws a request on timeout / interrupt / session close. `open_requests` on `session.resume` / `session.activate` / `session.events.since` re-delivers unanswered requests after a reconnect; the shared TypeScript channel does that itself before the caller sees the result. Batch clarify keeps its per-question locks as a normal `clarify.lock` RPC (the last lock resolves the request). Approvals stay queue-backed (`tools.approval` owns the timeout, `/approve all`, coalescing): the request resolves the queue entry and the entry's own resolution withdraws the request through `register_gateway_settle`. Deleted: `_block`, `_respond`, `_pending`, `_answers`, `_pending_prompt_payloads`, `_batch_clarify`, `_EXPIRING_REQUESTS`, the `*.respond` methods, every `*.request` / `*.expire` event, `pending_clarify`. Compute-host (turn isolation) mirrors the child's open request and relays the response frame / lock to it. Desktop, TUI and shared clients register `onRequest` handlers where they used to switch on `*.request` events; answers are response frames over the socket the request arrived on, so #91684's owner-routing class cannot recur for prompts. --- apps/desktop/src/app/contrib/wiring.tsx | 3 +- .../src/app/gateway/hooks/use-gateway-boot.ts | 16 + .../gateway-event/desktop-bridge.ts | 214 +------- .../gateway-event/input-requests.ts | 469 ++---------------- .../gateway-event/server-requests.ts | 434 ++++++++++++++++ .../session/hooks/use-message-stream/index.ts | 16 + .../hooks/use-session-actions/index.ts | 7 +- .../restore-pending-clarify.ts | 69 +-- .../components/assistant-ui/clarify-tool.tsx | 55 +- .../assistant-ui/mcp-setup-tool.tsx | 12 +- .../components/assistant-ui/tool/approval.tsx | 23 +- .../src/components/prompt-overlays.tsx | 36 +- .../src/plugins/hermes-bots/group-turns.ts | 25 +- apps/desktop/src/store/clarify.ts | 12 +- apps/desktop/src/store/gateway.ts | 26 +- apps/desktop/src/store/mcp-setup.ts | 15 +- .../desktop/src/store/native-notifications.ts | 18 +- apps/desktop/src/store/prompts.ts | 76 ++- apps/desktop/src/store/server-requests.ts | 46 ++ apps/desktop/src/types/hermes.ts | 16 +- apps/shared/src/gateway-events.json | 179 ++++--- apps/shared/src/gateway-events.test.ts | 27 +- apps/shared/src/gateway-events.ts | 175 ++++--- apps/shared/src/index.ts | 25 +- apps/shared/src/json-rpc-channel.ts | 155 +++++- apps/shared/src/json-rpc-gateway.ts | 11 + .../test_gateway_event_contract.py | 58 ++- tools/approval.py | 11 + tools/approval_gateway_wait.py | 17 +- tui_gateway/agent_callbacks.py | 38 +- tui_gateway/compute_host.py | 12 +- tui_gateway/compute_host_bridge.py | 97 ++-- tui_gateway/hosted_room_member_activity.py | 20 +- tui_gateway/methods_prompt.py | 49 +- tui_gateway/methods_session.py | 4 +- tui_gateway/server.py | 208 +++----- tui_gateway/server_requests.py | 211 ++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 135 +---- ui-tui/src/app/createServerRequestHandler.ts | 114 +++++ ui-tui/src/app/serverRequestStore.ts | 38 ++ ui-tui/src/app/useInputHandlers.ts | 27 +- ui-tui/src/app/useMainApp.ts | 86 +++- ui-tui/src/gatewayClient.ts | 18 + ui-tui/src/gatewayTypes.ts | 18 +- ui-tui/src/types.ts | 2 + 45 files changed, 1894 insertions(+), 1429 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts create mode 100644 apps/desktop/src/store/server-requests.ts create mode 100644 tui_gateway/server_requests.py create mode 100644 ui-tui/src/app/createServerRequestHandler.ts create mode 100644 ui-tui/src/app/serverRequestStore.ts diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 6e56761e25..73f11ea394 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -497,7 +497,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState] ) - const { handleGatewayEvent } = useMessageStream({ + const { handleGatewayEvent, handleServerRequest } = useMessageStream({ activeGatewayProfile, activeSessionIdRef, hydrateFromStoredSession, @@ -898,6 +898,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { closeAllTerminals() }, handleGatewayEvent: handleGatewayEventWithPlugins, + handleServerRequest, onConnectionReady: c => { connectionRef.current = c }, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 24b8783d12..b635cc0a79 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -2,6 +2,7 @@ import { type GatewayEvent, isGatewayReauthRequired, isGatewayWebSocketUrl, + JSON_RPC_METHOD_NOT_FOUND, JsonRpcGatewayError, reconnectBackoffDelayMs, resolveGatewayWsUrl @@ -31,6 +32,7 @@ import { closeLegacySecondaryGateways, closeSecondaryGateways, configureGatewayRegistry, + dispatchPrimaryServerRequest, disposeSecondariesForConnection, ensureActiveGatewayOpen, ensureGatewayForProfile, @@ -40,6 +42,7 @@ import { pruneSecondaryGateways, reconnectSecondaryGateways, reportPrimaryGatewayState, + type ScopedServerRequest, setPrimaryGateway, setPrimaryGatewayConnection, touchSecondaryGateways @@ -148,6 +151,8 @@ export function primaryRuntimeConnectionId(connection: Pick void handleGatewayEvent: (event: GatewayEvent) => void + /** Server→client request from any registry socket; false = no handler (the channel answers -32601). */ + handleServerRequest: (request: ScopedServerRequest) => boolean onConnectionReady: ( connection: Awaited['getConnection']>> | null ) => void @@ -159,6 +164,7 @@ interface GatewayBootOptions { export function useGatewayBoot({ beforeConnectionSwitch, handleGatewayEvent, + handleServerRequest, onConnectionReady, onGatewayReady, refreshHermesConfig, @@ -167,6 +173,7 @@ export function useGatewayBoot({ const callbacksRef = useRef({ beforeConnectionSwitch, handleGatewayEvent, + handleServerRequest, onConnectionReady, onGatewayReady, refreshHermesConfig, @@ -176,6 +183,7 @@ export function useGatewayBoot({ callbacksRef.current = { beforeConnectionSwitch, handleGatewayEvent, + handleServerRequest, onConnectionReady, onGatewayReady, refreshHermesConfig, @@ -805,6 +813,11 @@ export function useGatewayBoot({ // (connectionId, profile) keep-set so two sources exposing the same // profile name (every source has a 'default') can't collide. configureGatewayRegistry({ + onServerRequest: request => { + if (!callbacksRef.current.handleServerRequest(request)) { + request.fail(JSON_RPC_METHOD_NOT_FOUND, `Hermes Desktop cannot answer ${request.method}`) + } + }, // The primary socket has no secondary entry to carry registry identity. // Electron's published active descriptor is authoritative after boot; // a true legacy primary has no connectionId and remains unqualified. @@ -909,6 +922,8 @@ export function useGatewayBoot({ recordSessionEventScope(scopedEvent) callbacksRef.current.handleGatewayEvent(scopedEvent) }) + // Secondary sockets reach the same handler through the registry's onServerRequest. + const offRequest = gateway.onRequest(request => dispatchPrimaryServerRequest(request, sourceProfile)) // Wake signals: power resume (macOS/Windows), network coming back, and the // window regaining focus/visibility. Each nudges an immediate reconnect. @@ -1274,6 +1289,7 @@ export function useGatewayBoot({ offActiveStateReauth() offState() offEvent() + offRequest() offExit() offWindowState?.() offBootProgress() diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts index be9edae842..b0a5d2c622 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts @@ -1,165 +1,17 @@ -import { readActivePreview } from '@/app/chat/right-rail/preview-reader' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' -import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' -import type { PreviewActAction } from '@/lib/preview-act/act-in-page' -import type { TourAction, TourStep } from '@/lib/tour' -import { $gateway } from '@/store/gateway' import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus' import { recordAgentReaction } from '@/store/reactions-local' import { setMessages } from '@/store/session' import { $tipsEnabled, type ActiveTip, showTip } from '@/store/tips' -import { $toursEnabled } from '@/store/tours' import type { GatewayEventContext } from './types' -/** The preview engine, loaded on demand so ~25KB of page-injectable source stays - * off the boot path. - * - * In dev that lazy chunk is also a trap. The browser caches a dynamic import by - * URL for the life of the page, so this bridge would hand every action to - * whichever build of the engine loaded first, and no edit to it — or to the - * overlay whose source it stringifies into the page — would reach the guest - * until the whole window reloaded. Asking for a fresh copy is more reliable - * than trusting hot-update propagation to reach a module nothing statically - * imports; the dev server stamps the dependency URLs it has invalidated, so a - * fresh engine pulls a fresh overlay down with it. - * - * The literal path is what a bare specifier can't be here, and it has to track - * this module's real location — hence the fall back to the static import, which - * is also the only branch production keeps, `import.meta.hot` being stripped - * there along with everything it guards. */ -const loadPreviewEngine = () => { - const stable = () => import('@/app/chat/right-rail/preview-act') - - if (!import.meta.hot) { - return stable().then(mod => mod.actOnActivePreview) - } - - return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now()) - .catch(stable) - .then(mod => mod.actOnActivePreview as Awaited>['actOnActivePreview']) -} - -/** Desktop-surface bridge events: read-back requests the agent blocks on - * (terminal/preview/window), agent terminal streaming, pane reveal, and - * message reactions. */ +/** Desktop-surface bridge events: agent terminal streaming, tips, pane + * reveal, layouts and message reactions. The read-back REQUESTS the agent + * blocks on (terminal/preview/window/tour) live in `server-requests.ts`. */ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { - const { event, payload, explicitSid, isActiveEvent } = ctx - - if (event.type === 'terminal.read.request') { - // read_terminal tool: serialize the renderer's xterm buffer and answer - // immediately (Python blocks on the respond). Empty text = no live pane. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const start = typeof payload?.start === 'number' ? payload.start : undefined - const count = typeof payload?.count === 'number' ? payload.count : undefined - const result = readActiveTerminal({ start, count }) - - void $gateway.get()?.request('terminal.read.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - } - - return true - } - - if (event.type === 'preview.read.request') { - // read_preview tool: serialize the active preview tab (a Browser - // webview's page text is async) and answer. Empty text = nothing open. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const start = typeof payload?.start === 'number' ? payload.start : undefined - const count = typeof payload?.count === 'number' ? payload.count : undefined - - void readActivePreview({ count, start }).then(result => { - void $gateway.get()?.request('preview.read.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - }) - } - - return true - } - - if (event.type === 'preview.act.request') { - // drive_preview tool: click/type/scroll/press inside the guest page, or - // drive the pane's history. Dynamic import keeps the injected engine off - // the boot path. Active session only: a background turn must never reach - // into the page the user is working in (desktop AGENTS.md: offer, don't - // hijack). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - // Every mounted desktop window can observe the same gateway event. A - // scoped mismatch belongs to another window, so answering here would race - // the owning window and could make this refusal win before its real result. - if (explicitSid && !isActiveEvent) { - return true - } - - const answer = (result: unknown) => - $gateway.get()?.request('preview.act.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - - if (isActiveEvent) { - void loadPreviewEngine() - .then(run => - run({ - amount: payload?.amount, - key: payload?.key, - kind: payload?.action ?? '', - max: payload?.max, - ref: payload?.ref, - selector: payload?.selector, - submit: payload?.submit, - text: payload?.text, - to: payload?.to as PreviewActAction['to'] - }) - ) - .then(answer, error => - answer({ error: error instanceof Error ? error.message : String(error), success: false }) - ) - } else { - void answer({ - error: 'The in-app browser only takes actions in the session the user is looking at.', - success: false - }) - } - } - - return true - } - - if (event.type === 'window.read.request') { - // read_window_below tool: main owns native window enumeration, so ask - // it over IPC and answer. Empty text = unavailable (no bridge, or - // enumeration unsupported on this system e.g. Wayland). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const read = window.hermesDesktop?.readWindowBelow - - const answer = (result: unknown) => - $gateway.get()?.request('window.read.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - - // .catch: ipcRenderer.invoke rejects on an older shell without the - // handler or a main-side throw — without an empty answer the tool - // would stall its full 30s timeout. - void Promise.resolve(read ? read() : null).then(answer, () => answer(null)) - } - - return true - } + const { event, payload, isActiveEvent } = ctx if (event.type === 'agent.terminal.output') { // Live chunk from a background process → its read-only agent terminal tab. @@ -176,64 +28,6 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { return true } - if (event.type === 'tour.request') { - // tour tool: run one guided-tour action (highlight/step/discover) via - // driver.js — on the app's own DOM or inside the preview pane's guest - // page — and answer with the outcome. Dynamic import keeps driver.js - // and the preview injection payload off the boot path. Active session - // only: a background turn must never paint overlays on the user's - // screen (desktop AGENTS.md: offer, don't hijack). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - // As with preview actions, only the renderer that owns an explicitly - // scoped request may answer. Inactive windows must stay silent even when - // tours are disabled locally, or their refusal can beat the owner. - if (explicitSid && !isActiveEvent) { - return true - } - - const answer = (result: unknown) => - $gateway.get()?.request('tour.respond', { - request_id: requestId, - text: result ? JSON.stringify(result) : '' - }) - - if (!$toursEnabled.get()) { - // Refused in words, not silently dropped: the agent asked for a - // walkthrough it isn't getting, and a no-op would leave it narrating - // a spotlight the user can't see. - void answer({ error: 'The user has turned guided tours off.', success: false }) - } else if (isActiveEvent) { - void import('@/lib/tour') - .then(({ runTour }) => - runTour( - { - kind: (payload?.action ?? 'stop') as TourAction['kind'], - selector: payload?.selector, - side: payload?.side as TourStep['side'], - startAt: payload?.step_index, - steps: payload?.steps as TourStep[] | undefined, - text: payload?.text, - title: payload?.title - }, - payload?.surface === 'preview' ? 'preview' : 'app' - ) - ) - .then(answer, error => - answer({ error: error instanceof Error ? error.message : String(error), success: false }) - ) - } else { - void answer({ - error: 'Tours only run in the session the user is looking at.', - success: false - }) - } - } - - return true - } - if (event.type === 'tip.show') { // tip tool: point the accent bubble at something and say one line about // it. Fire-and-forget — a tip is not a question, and blocking the turn on diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts index 9319993797..fd4e6ea6cc 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts @@ -1,448 +1,89 @@ import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify' -import { translateNow } from '@/i18n' -import { restorePendingClarifyToolCall, settlePendingClarifyToolCall } from '@/lib/chat-messages' -import { - $clarifyRequests, - clearClarifyRequest, - normalizeChoices, - normalizeQuestions, - setClarifyRequest, - warnDroppedChoices -} from '@/store/clarify' -import { $gateway } from '@/store/gateway' -import { setMcpSetupRequest } from '@/store/mcp-setup' -import { dispatchNativeNotification } from '@/store/native-notifications' +import { settlePendingClarifyToolCall } from '@/lib/chat-messages' +import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify' +import { $mcpSetupRequests, clearMcpSetupRequest } from '@/store/mcp-setup' import { + $approvalRequests, + $secretRequests, + $sudoRequests, $vaultCodeRequests, $vaultSaveLoginRequests, $vaultUnlockRequests, + clearApprovalRequest, + clearSecretRequest, + clearSudoRequest, clearVaultCodeRequest, clearVaultSaveLoginRequest, - clearVaultUnlockRequest, - receiveApprovalRequest, - setSecretRequest, - setSudoRequest, - setVaultCodeRequest, - setVaultSaveLoginRequest, - setVaultUnlockRequest + clearVaultUnlockRequest } from '@/store/prompts' -import { requestScrollToBottom } from '@/store/thread-scroll' +import { forgetServerRequest } from '@/store/server-requests' import type { GatewayEventContext } from './types' -/** The blocking-input family: clarify / MCP setup consent / approval / sudo / - * secret requests. The Python side is blocked on the matching *.respond, so - * each of these must be parked per-session and surfaced. */ +/** The blocking-input family arrives as server→client REQUESTS (see + * `server-requests.ts`); the one EVENT in the family is `request.cancel`, the + * backend withdrawing an open request (timeout / interrupt / session close): + * tear down whichever parked card carries that id. Cancel is request-correlated: + * a delayed cancel for an older prompt must not erase a newer one the same + * session raised. */ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { const { deps, event, payload, sessionId, occurredAt } = ctx - const { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall } = deps - if (event.type === 'clarify.request') { - // Surface the clarify tool's overlay. The Python side is blocked on - // `clarify.respond`, so without this handler the agent would hang - // forever (see tools/clarify_tool.py + tui_gateway/server.py:_block). - // - // Store the request for whichever session raised it — even a background - // one. clarify.request is a one-shot event; if we dropped it for an - // unfocused session, that session would block on `clarify.respond` - // indefinitely and re-focusing it could never recover (the event is - // gone). Parking it per-session lets the user answer once they switch - // over; the inline ClarifyTool reads the active session's entry. - if (sessionId && sessionInterrupted(sessionId)) { - return true - } + if (event.type !== 'request.cancel') { + return false + } - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const question = typeof payload?.question === 'string' ? payload.question : '' - const rawChoices = payload?.choices - const choices = normalizeChoices(rawChoices) - const multiSelect = payload?.multi_select === true - // Batch (multi-question) clarify: `questions` replaces question/choices - // on the wire. `answers` rides along only on reconnect replay, carrying - // the per-question locks the server already accepted. - const questions = normalizeQuestions(payload?.questions) - - const lockedAnswers = - typeof payload?.answers === 'object' && payload?.answers !== null - ? Object.fromEntries( - Object.entries(payload.answers as Record).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - ) - : undefined - - if (requestId && questions.length > 0) { - const request = { - choices: null, - lockedAnswers, - multiSelect: false, - question: '', - questions, - receivedAt: Date.now() / 1000, - requestId, - sessionId: sessionId ?? null - } - - setClarifyRequest(request) - - if (sessionId) { - // A resumed/hydrated transcript may already contain this provider's - // clarify call while carrying no live streamId. Re-arm that exact row - // instead of letting the generic stream mutator append a second card. - updateSessionState(sessionId, state => { - const projection = restorePendingClarifyToolCall( - state.messages, - pendingClarifyToolPayload(request), - occurredAt - ) - - return { - ...state, - messages: projection.messages, - streamId: projection.streamId, - sawAssistantPayload: true, - awaitingResponse: false, - needsInput: true - } - }) - - if (sessionId === activeSessionIdRef.current) { - requestScrollToBottom(sessionId) - } - } - - dispatchNativeNotification({ - body: questions.map(q => q.question).join(' · '), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } else if (requestId && question) { - if (rawChoices != null && choices.length === 0) { - warnDroppedChoices('gateway', question, rawChoices) - } - - const request = { - requestId, - question, - choices: choices.length > 0 ? choices : null, - multiSelect, - receivedAt: Date.now() / 1000, - sessionId: sessionId ?? null - } - - setClarifyRequest(request) - - if (sessionId) { - // Same provider-shape-aware repair as the batch path above. This keeps - // the original hydrated row and provider tool id, while still seeding - // a row when tool.start was genuinely missed. - updateSessionState(sessionId, state => { - const projection = restorePendingClarifyToolCall( - state.messages, - pendingClarifyToolPayload(request), - occurredAt - ) - - return { - ...state, - messages: projection.messages, - streamId: projection.streamId, - sawAssistantPayload: true, - awaitingResponse: false, - needsInput: true - } - }) - - if (sessionId === activeSessionIdRef.current) { - requestScrollToBottom(sessionId) - } - } - - dispatchNativeNotification({ - body: question, - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } + const id = typeof payload?.id === 'string' ? payload.id : '' + if (!id) { return true } - if (event.type === 'vault.code.expire') { - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = sessionId ? $vaultCodeRequests.get()[sessionId] : undefined + forgetServerRequest(id) - if (requestId && request && request.requestId === requestId) { - clearVaultCodeRequest(sessionId, requestId) - } + const key = sessionId ?? '' - return true - } + if ($clarifyRequests.get()[key]?.requestId === id) { + const request = $clarifyRequests.get()[key] - if (event.type === 'vault.save_login.expire') { - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = sessionId ? $vaultSaveLoginRequests.get()[sessionId] : undefined + clearClarifyRequest(id, sessionId) - if (requestId && request && request.requestId === requestId) { - clearVaultSaveLoginRequest(sessionId, requestId) - } - - return true - } - - if (event.type === 'vault.unlock.expire') { - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = sessionId ? $vaultUnlockRequests.get()[sessionId] : undefined - - if (requestId && request && request.requestId === requestId) { - clearVaultUnlockRequest(sessionId, requestId) - } - - return true - } - - if (event.type === 'clarify.expire') { - if (!sessionId) { - return true - } - - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const request = $clarifyRequests.get()[sessionId] - - // Expiry is request-correlated: a delayed event from an older prompt must - // not erase a newer clarify raised by the same session. - if (!requestId || !request || request.requestId !== requestId) { - return true - } - - clearClarifyRequest(requestId, sessionId) - updateSessionState(sessionId, state => { - const projection = settlePendingClarifyToolCall( - state.messages, - pendingClarifyToolPayload(request), - state.busy, - occurredAt - ) - - return { - ...state, - messages: projection.messages, - needsInput: false, - streamId: state.busy ? (projection.streamId ?? state.streamId) : null - } - }) - - return true - } - - if (event.type === 'mcp.setup.request') { - // setup_mcp tool (desktop GUI): the agent proposed an MCP server and - // the Python side is blocked on mcp.setup.respond. Park the request - // per-session (like clarify) and upsert a stable pending tool row so - // the inline consent card has somewhere to render even when the - // tool.start event was missed (stream reconnect / hydration race). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - const server = typeof payload?.server === 'string' ? payload.server : '' - const rawAction = typeof payload?.action === 'string' ? payload.action : 'install' - const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install' - const reason = typeof payload?.reason === 'string' ? payload.reason : '' - - if (requestId && server) { - setMcpSetupRequest({ action, reason, requestId, server, sessionId: sessionId ?? null }) - - if (sessionId) { - upsertToolCall( - sessionId, - { args: { action, reason, server }, name: 'setup_mcp', tool_id: requestId }, - 'running' + if (sessionId && request) { + deps.updateSessionState(sessionId, state => { + const projection = settlePendingClarifyToolCall( + state.messages, + pendingClarifyToolPayload(request), + state.busy, + occurredAt ) - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - dispatchNativeNotification({ - body: reason || server, - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') + return { + ...state, + messages: projection.messages, + needsInput: false, + streamId: state.busy ? (projection.streamId ?? state.streamId) : null + } }) } return true } - if (event.type === 'approval.request') { - // Dangerous-command / execute_code approval. The Python side is blocked - // in _await_gateway_decision() until approval.respond lands; without - // this the agent stalls until its 5-min timeout and the tool is BLOCKED. - // Park it per-session (like clarify) so a *background* profile's turn can - // raise it and wait — the sidebar flags "needs input" and the inline bar - // surfaces once the user focuses that chat. - const command = typeof payload?.command === 'string' ? payload.command : '' - const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command' - - void receiveApprovalRequest($gateway.get(), { - // false only when a tirith warning forbids it; backend omits the field otherwise. - allowPermanent: payload?.allow_permanent !== false, - choices: Array.isArray(payload?.choices) - ? payload.choices.filter(choice => typeof choice === 'string') - : undefined, - command, - description, - requestId: typeof payload?.request_id === 'string' ? payload.request_id : undefined, - sessionId: sessionId ?? null, - smartDenied: payload?.smart_denied === true - }).catch(() => undefined) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - actions: [ - { id: 'approve', text: translateNow('notifications.native.approveAction') }, - { id: 'reject', text: translateNow('notifications.native.rejectAction') } - ], - body: command || description, - kind: 'approval', - sessionId, - title: translateNow('notifications.native.approvalTitle') - }) - - return true + if ($approvalRequests.get()[key]?.serverRequestId === id) { + clearApprovalRequest(sessionId, $approvalRequests.get()[key]?.requestId) + } else if ($sudoRequests.get()[key]?.requestId === id) { + clearSudoRequest(sessionId, id) + } else if ($secretRequests.get()[key]?.requestId === id) { + clearSecretRequest(sessionId, id) + } else if ($vaultCodeRequests.get()[key]?.requestId === id) { + clearVaultCodeRequest(sessionId, id) + } else if ($vaultSaveLoginRequests.get()[key]?.requestId === id) { + clearVaultSaveLoginRequest(sessionId, id) + } else if ($vaultUnlockRequests.get()[key]?.requestId === id) { + clearVaultUnlockRequest(sessionId, id) + } else if ($mcpSetupRequests.get()[key]?.requestId === id) { + clearMcpSetupRequest(id, sessionId) } - if (event.type === 'sudo.request') { - // Sudo password capture (tools/terminal_tool.py). Blocked on - // sudo.respond {request_id, password}. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - setSudoRequest({ requestId, sessionId: sessionId ?? null }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('notifications.native.inputBody'), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - if (event.type === 'secret.request') { - // Skill credential capture (tools/skills_tool.py). Blocked on - // secret.respond {request_id, value}. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const envVar = typeof payload?.env_var === 'string' ? payload.env_var : '' - const promptText = typeof payload?.prompt === 'string' ? payload.prompt : '' - - setSecretRequest({ - requestId, - envVar, - prompt: promptText, - sessionId: sessionId ?? null - }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: promptText || envVar || translateNow('notifications.native.inputBody'), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - if (event.type === 'vault.code.request') { - // Second factor: the site asked for a one-time code and no authenticator key is saved for the login. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const site = typeof payload?.site === 'string' ? payload.site : '' - const hint = typeof payload?.hint === 'string' ? payload.hint : '' - - setVaultCodeRequest({ hint, requestId, sessionId: sessionId ?? null, site }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('prompts.vaultCodeTitle', site), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - if (event.type === 'vault.save_login.request') { - // The agent is on a sign-in page with no saved login: identifier + masked password card; the - // answer is stored in the encrypted vault by the backend and filled at once (never shown to the model). - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const origin = typeof payload?.origin === 'string' ? payload.origin : '' - const site = typeof payload?.site === 'string' ? payload.site : origin - - setVaultSaveLoginRequest({ origin, requestId, sessionId: sessionId ?? null, site }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('prompts.vaultSaveTitle', site), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - if (event.type === 'vault.unlock.request') { - // External password-manager unlock (agent/vault_backends). Blocked on - // vault.unlock.respond {request_id, password}; "" keeps it locked. - const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' - - if (requestId) { - const backend = typeof payload?.backend === 'string' ? payload.backend : '' - const displayName = typeof payload?.display_name === 'string' ? payload.display_name : backend - - setVaultUnlockRequest({ backend, displayName, requestId, sessionId: sessionId ?? null }) - - if (sessionId) { - updateSessionState(sessionId, state => ({ ...state, needsInput: true })) - } - - dispatchNativeNotification({ - body: translateNow('prompts.vaultUnlockTitle', displayName), - kind: 'input', - sessionId, - title: translateNow('notifications.native.inputTitle') - }) - } - - return true - } - - return false + return true } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts new file mode 100644 index 0000000000..cbbef3b538 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/server-requests.ts @@ -0,0 +1,434 @@ +import { readActivePreview } from '@/app/chat/right-rail/preview-reader' +import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' +import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify' +import { translateNow } from '@/i18n' +import { restorePendingClarifyToolCall } from '@/lib/chat-messages' +import type { PreviewActAction } from '@/lib/preview-act/act-in-page' +import type { TourAction, TourStep } from '@/lib/tour' +import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' +import type { ScopedServerRequest } from '@/store/gateway' +import { setMcpSetupRequest } from '@/store/mcp-setup' +import { dispatchNativeNotification } from '@/store/native-notifications' +import { + receiveApprovalRequest, + setSecretRequest, + setSudoRequest, + setVaultCodeRequest, + setVaultSaveLoginRequest, + setVaultUnlockRequest +} from '@/store/prompts' +import { rememberServerRequest } from '@/store/server-requests' +import { requestScrollToBottom } from '@/store/thread-scroll' +import { $toursEnabled } from '@/store/tours' + +import type { GatewayEventDeps } from './types' + +/** The preview engine, loaded on demand so ~25KB of page-injectable source stays + * off the boot path (dev: a fresh copy per action so edits reach the guest — see + * the previous home of this loader in desktop-bridge.ts for the full story). */ +const loadPreviewEngine = () => { + const stable = () => import('@/app/chat/right-rail/preview-act') + + if (!import.meta.hot) { + return stable().then(mod => mod.actOnActivePreview) + } + + return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now()) + .catch(stable) + .then(mod => mod.actOnActivePreview as Awaited>['actOnActivePreview']) +} + +const str = (v: unknown): string => (typeof v === 'string' ? v : '') +const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) + +/** Answer a string-valued request with a JSON-encoded result ('' = nothing / unavailable). */ +const answerValue = (request: ScopedServerRequest, result: unknown) => + request.respond({ value: result ? JSON.stringify(result) : '' }) + +export interface ServerRequestContext { + deps: Pick + request: ScopedServerRequest + /** The session the request names ('' when unscoped). */ + sessionId: string + /** The named session is the one on screen. */ + isActiveSession: boolean +} + +type Handler = (ctx: ServerRequestContext) => void + +const markNeedsInput = (ctx: ServerRequestContext) => { + if (ctx.sessionId) { + ctx.deps.updateSessionState(ctx.sessionId, state => ({ ...state, needsInput: true })) + } +} + +const notifyInput = (ctx: ServerRequestContext, body: string) => { + if (!ctx.request.replayed) { + dispatchNativeNotification({ + body, + kind: 'input', + sessionId: ctx.sessionId || null, + title: translateNow('notifications.native.inputTitle') + }) + } +} + +// ── Blocking-input family (clarify / approval / sudo / secret / vault / MCP setup) ── +// Every one is parked per-session (like clarify) so a BACKGROUND session's turn can +// raise it and wait — the sidebar flags "needs input" and the card surfaces once the +// user focuses that chat. The Python side blocks on the response frame; without a +// handler the channel answers -32601 and the tool fails fast instead of stalling. + +const clarify: Handler = ctx => { + const { deps, request, sessionId } = ctx + const p = request.params + + if (sessionId && deps.sessionInterrupted(sessionId)) { + request.respond({ answer: '' }) + + return + } + + const question = str(p.question) + const rawChoices = p.choices + const choices = normalizeChoices(rawChoices) + const multiSelect = p.multi_select === true + // Batch (multi-question) clarify: `questions` replaces question/choices on the + // wire. `answers` rides along only on a reconnect replay (locks the server + // already accepted). + const questions = normalizeQuestions(p.questions) + + const lockedAnswers = + typeof p.answers === 'object' && p.answers !== null + ? Object.fromEntries( + Object.entries(p.answers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + : undefined + + if (questions.length === 0 && !question) { + request.respond({ answer: '' }) + + return + } + + if (questions.length === 0 && rawChoices != null && choices.length === 0) { + warnDroppedChoices('gateway', question, rawChoices) + } + + const clarifyRequest = + questions.length > 0 + ? { + choices: null, + lockedAnswers, + multiSelect: false, + question: '', + questions, + receivedAt: Date.now() / 1000, + requestId: request.id, + sessionId: sessionId || null + } + : { + choices: choices.length > 0 ? choices : null, + multiSelect, + question, + receivedAt: Date.now() / 1000, + requestId: request.id, + sessionId: sessionId || null + } + + rememberServerRequest(request) + setClarifyRequest(clarifyRequest) + + if (sessionId) { + // A resumed/hydrated transcript may already contain this provider's clarify + // call while carrying no live streamId. Re-arm that exact row instead of + // letting the generic stream mutator append a second card. + const occurredAt = Date.now() / 1000 + + deps.updateSessionState(sessionId, state => { + const projection = restorePendingClarifyToolCall( + state.messages, + pendingClarifyToolPayload(clarifyRequest), + occurredAt + ) + + return { + ...state, + messages: projection.messages, + streamId: projection.streamId, + sawAssistantPayload: true, + awaitingResponse: false, + needsInput: true + } + }) + + if (sessionId === deps.activeSessionIdRef.current) { + requestScrollToBottom(sessionId) + } + } + + notifyInput(ctx, questions.length > 0 ? questions.map(q => q.question).join(' · ') : question) +} + +const approval: Handler = ctx => { + const { request, sessionId } = ctx + const p = request.params + const command = str(p.command) + const description = str(p.description) || 'dangerous command' + + rememberServerRequest(request) + void receiveApprovalRequest(null, { + // false only when a tirith warning forbids it; backend omits the field otherwise. + allowPermanent: p.allow_permanent !== false, + choices: Array.isArray(p.choices) ? p.choices.filter((choice): choice is string => typeof choice === 'string') : undefined, + command, + description, + // The approval queue's own id — `approval.pending` / `approval.received` / `approval.respond` key on it. + requestId: str(p.request_id) || undefined, + serverRequestId: request.id, + sessionId: sessionId || null, + smartDenied: p.smart_denied === true + }).catch(() => undefined) + markNeedsInput(ctx) + + if (!request.replayed) { + dispatchNativeNotification({ + actions: [ + { id: 'approve', text: translateNow('notifications.native.approveAction') }, + { id: 'reject', text: translateNow('notifications.native.rejectAction') } + ], + body: command || description, + kind: 'approval', + sessionId: sessionId || null, + title: translateNow('notifications.native.approvalTitle') + }) + } +} + +const sudo: Handler = ctx => { + rememberServerRequest(ctx.request) + setSudoRequest({ requestId: ctx.request.id, sessionId: ctx.sessionId || null }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('notifications.native.inputBody')) +} + +const secret: Handler = ctx => { + const p = ctx.request.params + const envVar = str(p.env_var) + const promptText = str(p.prompt) + + rememberServerRequest(ctx.request) + setSecretRequest({ envVar, prompt: promptText, requestId: ctx.request.id, sessionId: ctx.sessionId || null }) + markNeedsInput(ctx) + notifyInput(ctx, promptText || envVar || translateNow('notifications.native.inputBody')) +} + +const vaultCode: Handler = ctx => { + const p = ctx.request.params + const site = str(p.site) + + rememberServerRequest(ctx.request) + setVaultCodeRequest({ hint: str(p.hint), requestId: ctx.request.id, sessionId: ctx.sessionId || null, site }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('prompts.vaultCodeTitle', site)) +} + +const vaultSaveLogin: Handler = ctx => { + const p = ctx.request.params + const origin = str(p.origin) + const site = str(p.site) || origin + + rememberServerRequest(ctx.request) + setVaultSaveLoginRequest({ origin, requestId: ctx.request.id, sessionId: ctx.sessionId || null, site }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('prompts.vaultSaveTitle', site)) +} + +const vaultUnlockPrompt: Handler = ctx => { + const p = ctx.request.params + const backend = str(p.backend) + const displayName = str(p.display_name) || backend + + rememberServerRequest(ctx.request) + setVaultUnlockRequest({ backend, displayName, requestId: ctx.request.id, sessionId: ctx.sessionId || null }) + markNeedsInput(ctx) + notifyInput(ctx, translateNow('prompts.vaultUnlockTitle', displayName)) +} + +const mcpSetup: Handler = ctx => { + // setup_mcp tool (desktop GUI): the agent proposed an MCP server. Park the + // request per-session (like clarify) and upsert a stable pending tool row so + // the inline consent card has somewhere to render even when the tool.start + // event was missed (stream reconnect / hydration race). + const { deps, request, sessionId } = ctx + const p = request.params + const server = str(p.server) + const rawAction = str(p.action) || 'install' + const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install' + const reason = str(p.reason) + + if (!server) { + request.respond({ value: '' }) + + return + } + + rememberServerRequest(request) + setMcpSetupRequest({ action, reason, requestId: request.id, server, sessionId: sessionId || null }) + + if (sessionId) { + deps.upsertToolCall(sessionId, { args: { action, reason, server }, name: 'setup_mcp', tool_id: request.id }, 'running') + } + + markNeedsInput(ctx) + notifyInput(ctx, reason || server) +} + +// ── Desktop-surface bridges (answered immediately, no card) ───────────────── + +const terminalRead: Handler = ({ request }) => { + // read_terminal tool: serialize the renderer's xterm buffer. Empty = no live pane. + answerValue(request, readActiveTerminal({ count: num(request.params.count), start: num(request.params.start) })) +} + +const previewRead: Handler = ({ request }) => { + // read_preview tool: the active preview tab's page text is async. Empty = nothing open. + void readActivePreview({ count: num(request.params.count), start: num(request.params.start) }).then(result => + answerValue(request, result) + ) +} + +const previewAct: Handler = ({ isActiveSession, request, sessionId }) => { + // drive_preview tool: click/type/scroll/press inside the guest page. Active + // session only: a background turn must never reach into the page the user is + // working in (desktop AGENTS.md: offer, don't hijack). Every mounted window can + // observe the same request; a scoped mismatch belongs to another window, so + // answering here would race the owner — stay silent. + if (sessionId && !isActiveSession) { + return + } + + const p = request.params + + if (!isActiveSession) { + answerValue(request, { + error: 'The in-app browser only takes actions in the session the user is looking at.', + success: false + }) + + return + } + + void loadPreviewEngine() + .then(run => + run({ + amount: p.amount as never, + key: p.key as never, + kind: (str(p.action) || '') as never, + max: p.max as never, + ref: p.ref as never, + selector: p.selector as never, + submit: p.submit as never, + text: p.text as never, + to: p.to as PreviewActAction['to'] + }) + ) + .then( + result => answerValue(request, result), + error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false }) + ) +} + +const windowRead: Handler = ({ request }) => { + // read_window_below tool: main owns native window enumeration. Empty = + // unavailable (older shell without the handler, Wayland, …) — without an + // answer the tool would stall its full 30s deadline. + const read = window.hermesDesktop?.readWindowBelow + + void Promise.resolve(read ? read() : null).then( + result => answerValue(request, result), + () => answerValue(request, null) + ) +} + +const tour: Handler = ({ isActiveSession, request, sessionId }) => { + // tour tool: one guided-tour action via driver.js, app DOM or preview guest + // page. Active session only, same window-ownership rule as preview.act. + if (sessionId && !isActiveSession) { + return + } + + const p = request.params + + if (!$toursEnabled.get()) { + // Refused in words, not silently dropped: a no-op would leave the agent + // narrating a spotlight the user can't see. + answerValue(request, { error: 'The user has turned guided tours off.', success: false }) + + return + } + + if (!isActiveSession) { + answerValue(request, { error: 'Tours only run in the session the user is looking at.', success: false }) + + return + } + + void import('@/lib/tour') + .then(({ runTour }) => + runTour( + { + kind: (str(p.action) || 'stop') as TourAction['kind'], + selector: p.selector as never, + side: p.side as TourStep['side'], + startAt: p.step_index as never, + steps: p.steps as TourStep[] | undefined, + text: p.text as never, + title: p.title as never + }, + p.surface === 'preview' ? 'preview' : 'app' + ) + ) + .then( + result => answerValue(request, result), + error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false }) + ) +} + +/** Method → handler. Every `ServerRequestMap` key the desktop answers. */ +export const SERVER_REQUEST_HANDLERS: Record = { + approval, + clarify, + 'mcp.setup': mcpSetup, + 'preview.act': previewAct, + 'preview.read': previewRead, + secret, + sudo, + 'terminal.read': terminalRead, + tour, + 'vault.code': vaultCode, + 'vault.save_login': vaultSaveLogin, + 'vault.unlock_prompt': vaultUnlockPrompt, + 'window.read': windowRead +} + +/** Dispatch one server→client request; false when the desktop has no handler for its method. */ +export function handleServerRequest( + request: ScopedServerRequest, + deps: ServerRequestContext['deps'], + activeSessionId: null | string +): boolean { + const handler = SERVER_REQUEST_HANDLERS[request.method] + + if (!handler) { + return false + } + + const sessionId = str(request.params.session_id) + + handler({ deps, request, sessionId, isActiveSession: Boolean(sessionId) && sessionId === activeSessionId }) + + return true +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index dda959f508..86c23aa74d 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -24,6 +24,7 @@ import { stripGeneratedImageEchoes } from '@/lib/generated-images' import { isTodoToolName, nextTodosFromToolEvent, parseTodoRevision } from '@/lib/todos' +import type { ScopedServerRequest } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { isDiskFullErrorMessage, notifyError } from '@/store/notifications' import { broadcastSessionsChanged } from '@/store/session-sync' @@ -33,6 +34,7 @@ import { $todosBySession, setSessionTodos } from '@/store/todos' import type { ClientSessionState } from '../../../types' import { useGatewayEventHandler } from './gateway-event' +import { handleServerRequest as dispatchServerRequest } from './gateway-event/server-requests' import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils' interface MessageStreamOptions { @@ -880,11 +882,25 @@ export function useMessageStream({ upsertToolCall }) + // Server→client requests (clarify, approval, sudo, …) from every socket the + // registry owns. The request answers itself over the socket it arrived on, + // so no owner routing is involved here — only which card to show. + const handleServerRequest = useCallback( + (request: ScopedServerRequest): boolean => + dispatchServerRequest( + request, + { activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall }, + activeSessionIdRef.current + ), + [activeSessionIdRef, sessionInterrupted, updateSessionState, upsertToolCall] + ) + return { appendAssistantDelta, appendReasoningDelta, completeAssistantMessage, handleGatewayEvent, + handleServerRequest, finalizeInterimAssistantMessage, upsertToolCall } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 904407ef14..9a7cd3218e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -50,7 +50,7 @@ import { resolveNewChatOwnerRoute } from '@/store/profile' import { $projectScope, resolveNewSessionCwd } from '@/store/projects' -import { setApprovalRequest } from '@/store/prompts' +import { receiveApprovalRequest } from '@/store/prompts' import { clearStoredTranscriptReadOnly, markStoredTranscriptReadOnly } from '@/store/read-only-transcript' import { $activeSessionStoredIdRotation, @@ -345,7 +345,10 @@ function restorePendingApproval(response: SessionResumeResponse, sessionId: stri return false } - setApprovalRequest({ + // The live `approval` server request (re-delivered from `open_requests` + // before this ran) already parked itself with the same queue id; don't + // clobber it with a copy that can only answer through the RPC fallback. + void receiveApprovalRequest(null, { allowPermanent: pending.allow_permanent !== false, choices: pending.choices, command: pending.command ?? '', diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts b/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts index ce5570d263..0f1144a510 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts @@ -1,12 +1,5 @@ import type { GatewayEventPayload } from '@/lib/chat-messages' -import { - $clarifyRequests, - type ClarifyRequest, - clearClarifyRequest, - normalizeChoices, - normalizeQuestions, - setClarifyRequest -} from '@/store/clarify' +import { $clarifyRequests, type ClarifyRequest, clearClarifyRequest } from '@/store/clarify' import type { SessionResumeResponse } from '@/types/hermes' export interface PendingClarifyResumeState { @@ -16,28 +9,26 @@ export interface PendingClarifyResumeState { } /** - * Restore a pending clarify from a resume/activate snapshot onto `sessionId`. + * Reconcile the parked clarify for `sessionId` against a resume/activate + * snapshot. * - * The snapshot mirrors the live clarify.request wire shape: single-question - * payloads carry `question`/`choices`/`multi_select`; batch (multi-question) - * ones carry `questions` (+ any answers already locked server-side) and no - * top-level `question`. Multi-select locks arrive as JSON-encoded arrays - * inside a string — never a bare array — so `lockedAnswers` keeps string - * values only. - * - * A missing snapshot is authoritative only for requests that already existed - * when the RPC began. A newer clarify.request that arrives while the response - * is in flight is left alone. + * The snapshot's `open_requests` names every server→client request still + * blocking the session. The shared channel has ALREADY re-delivered those to + * the request handlers (which parked the clarify card) before the caller sees + * the response, so this only has to (a) report the parked request when the + * snapshot confirms it and (b) treat a snapshot WITHOUT a clarify as + * authoritative for requests that already existed when the RPC began — a + * newer request that arrived while the response was in flight is left alone. */ export function restorePendingClarifyFromSnapshot( - response: Pick, + response: Pick, sessionId: string, resumeStartedAt: number, requestIdAtStart?: string ): PendingClarifyResumeState { - const pending = response.pending_clarify + const pending = (response.open_requests ?? []).find(entry => entry.method === 'clarify') - if (!pending || typeof pending.request_id !== 'string') { + if (!pending) { const current = $clarifyRequests.get()[sessionId] const existedAtStart = Boolean(current && requestIdAtStart && current.requestId === requestIdAtStart) @@ -53,36 +44,12 @@ export function restorePendingClarifyFromSnapshot( return { authoritativeAbsent: true, cleared: null, request: null } } - const questions = normalizeQuestions(pending.questions) - const question = typeof pending.question === 'string' ? pending.question : '' + // The request handler parked it under this session when the channel + // re-delivered `open_requests`; a card the handler declined (empty + // question) is simply not there. + const parked = $clarifyRequests.get()[sessionId] - if (!question && questions.length === 0) { - return { authoritativeAbsent: false, cleared: null, request: null } - } - - const choices = normalizeChoices(pending.choices) - - const lockedAnswers = - typeof pending.answers === 'object' && pending.answers !== null - ? Object.fromEntries( - Object.entries(pending.answers).filter((entry): entry is [string, string] => typeof entry[1] === 'string') - ) - : undefined - - const request: ClarifyRequest = { - choices: choices.length > 0 ? choices : null, - lockedAnswers, - multiSelect: pending.multi_select === true, - question, - receivedAt: Date.now() / 1000, - requestId: pending.request_id, - sessionId, - ...(questions.length > 0 ? { questions } : {}) - } - - setClarifyRequest(request) - - return { authoritativeAbsent: false, cleared: null, request } + return { authoritativeAbsent: false, cleared: null, request: parked?.requestId === pending.id ? parked : null } } export function pendingClarifyToolPayload(request: ClarifyRequest): GatewayEventPayload { diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 0dd7053ed9..f087de4481 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -40,6 +40,7 @@ import { import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { requestForOwnedSession } from '@/store/session-states' +import { forgetServerRequest, respondToServerRequest } from '@/store/server-requests' import { handleClarifySubmitShortcut } from './clarify-submit-shortcut' import { selectMessageRunning } from './tool/fallback-model' @@ -476,22 +477,9 @@ function ClarifyToolSinglePending({ setSubmitting(true) try { - // Route through the session's OWNER (tile route → hint → tagged row); - // legacy ambient is allowed only when it is provably the sole backend. - // The ambient socket follows foreground focus, so after a profile / Bot - // Chat switch it can point at a backend that never held this clarify — - // and the owner stays blocked (#91684 client half, like approval.respond). - await requestForOwnedSession<{ ok?: boolean }>( - matchingRequest.sessionId, - // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg - // call shape gateway.request callers assert on. - gateway.request.bind(gateway) as typeof gateway.request, - 'clarify.respond', - { - request_id: matchingRequest.requestId, - answer - } - ) + // The response frame goes back over the socket the request arrived on — + // the owner backend by construction (#91684's class cannot recur). + respondToServerRequest(matchingRequest.requestId, { answer }) triggerHaptic('submit') onAnswered() clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId) @@ -1036,21 +1024,18 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo setSubmitting(true) try { - // Sequential, not Promise.all: the LAST lock resolves the blocked tool - // server-side, so every earlier lock must already be accepted when it - // lands — a reordered burst could complete the batch with a missing - // answer. - // - // Each lock rides the session's OWNER socket, not the ambient one: a - // profile / Bot Chat switch re-points ambient at a backend that never - // held this batch, which would leave the owner blocked. + // Sequential, not Promise.all: the LAST lock resolves the blocked + // server request, so every earlier lock must already be accepted when + // it lands — a reordered burst could complete the batch with a missing + // answer. `clarify.lock` is a normal RPC; it rides the session's OWNER + // socket (a profile / Bot Chat switch re-points ambient elsewhere). for (const question of questions) { const answer = stagedAnswer(question) - await requestForOwnedSession<{ ok?: boolean }>( + await requestForOwnedSession<{ remaining?: string[]; status?: string }>( request.sessionId, gateway.request.bind(gateway) as typeof gateway.request, - 'clarify.respond', + 'clarify.lock', { answer: answer ?? '', question_id: question.qid, @@ -1059,6 +1044,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo ) } + forgetServerRequest(request.requestId) + triggerHaptic('submit') onAnswered() // tool.complete lands next → ClarifyToolBatchSettled. @@ -1095,20 +1082,8 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo onAnswered() clearClarifyRequest(request.requestId, request.sessionId) - try { - if (gateway) { - // Owner-routed like the locks above — a skip sent to the wrong backend - // is a silent no-op that leaves the agent waiting out its timeout. - await requestForOwnedSession( - request.sessionId, - gateway.request.bind(gateway) as typeof gateway.request, - 'clarify.respond', - { answer: '', request_id: request.requestId } - ) - } - } catch { - // The tool times out on its own; a failed skip must never block the UI. - } + // A response with no `answers` is the cancel-all (the plain Esc path). + respondToServerRequest(request.requestId, {}) }, [gateway, onAnswered, request]) const handleSubmit = useCallback( diff --git a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx index 2ab75ed8b8..d2715901d5 100644 --- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx @@ -28,6 +28,7 @@ import { prettyName } from '@/lib/text' import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' import { clearMcpSetupRequest, type McpSetupOutcome, sessionMcpSetupRequest } from '@/store/mcp-setup' +import { respondToServerRequest } from '@/store/server-requests' import { notifyError } from '@/store/notifications' import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp' @@ -229,15 +230,8 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) { invalidateMcpSuggestionIndex() } - try { - await gateway.request<{ status?: string }>('mcp.setup.respond', { - request_id: request.requestId, - result: JSON.stringify(outcome) - }) - // tool.complete lands next → McpSetupSettled. - } catch (error) { - notifyError(error, copy.sendFailed) - } + respondToServerRequest(request.requestId, { value: JSON.stringify(outcome) }) + // tool.complete lands next → McpSetupSettled. }, [copy.gatewayDisconnected, copy.reloadFailed, copy.sendFailed, gateway, request] ) diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx index df75fcf456..99e794d151 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx @@ -20,6 +20,7 @@ import { AlertCircle, ChevronDown } from '@/lib/icons' import { isSubmitEnter } from '@/lib/ime' import { cn } from '@/lib/utils' import { $gateway } from '@/store/gateway' +import { answerApproval } from '@/store/prompts' import { notifyError } from '@/store/notifications' import { type ApprovalRequest, @@ -145,22 +146,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' setSubmitting(choice) try { - // Route through the session's OWNER (tile route → known profile); - // ambient only when no owner is known. The ambient socket follows - // foreground focus, and for a cross-profile session it points at a - // backend that never held this approval (#91684 client half). - await requestForOwnedSession<{ resolved?: boolean }>( - request.sessionId, - // Bound (not wrapped) so the ambient fallback keeps the exact - // 2-arg call shape gateway.request callers assert on. - gateway.request.bind(gateway) as typeof gateway.request, - 'approval.respond', - { - choice, - request_id: request.requestId, - session_id: request.sessionId ?? undefined - } - ) + // Live prompt: the response frame rides the socket the request came on + // (the owner backend by construction). Restored prompt: queue-level + // `approval.respond`, owner-routed (#91684 client half). + await answerApproval(gateway, request, choice) triggerHaptic(choice === 'deny' ? 'cancel' : 'submit') clearApprovalRequest(request.sessionId, request.requestId) void replayPendingApproval(gateway, request.sessionId).catch(() => undefined) @@ -169,7 +158,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' setSubmitting(null) } }, - [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request.requestId, request.sessionId] + [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request] ) // ⌘/Ctrl+Enter → Run, Esc → Reject. diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index 091e306ad5..a08f8493fb 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -34,6 +34,7 @@ import { sessionVaultUnlockRequest } from '@/store/prompts' import { ambientRequestFor } from '@/store/session-gone-latch' +import { respondToServerRequest } from '@/store/server-requests' import { requestForOwnedSession } from '@/store/session-states' // Renders the modal mid-turn prompts the gateway raises and waits on: sudo @@ -78,10 +79,7 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await gateway.request<{ status?: string }>('sudo.respond', { - password: value, - request_id: request.requestId - }) + respondToServerRequest(request.requestId, { value }) triggerHaptic('submit') clearSudoRequest(request.sessionId, request.requestId) } catch (error) { @@ -181,10 +179,7 @@ function SecretDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await gateway.request<{ status?: string }>('secret.respond', { - request_id: request.requestId, - value: secret - }) + respondToServerRequest(request.requestId, { value: secret }) triggerHaptic('submit') clearSecretRequest(request.sessionId, request.requestId) } catch (error) { @@ -285,14 +280,9 @@ function VaultUnlockDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - // A master password must reach the backend that raised the prompt, not whatever - // gateway is foreground right now (background profile tiles have their own socket). - await requestForOwnedSession<{ status?: string }>( - request.sessionId, - ambientRequestFor(gateway), - 'vault.unlock.respond', - { request_id: request.requestId, password } - ) + // The response frame goes back over the socket the request arrived on — the + // backend that raised the prompt, never whatever gateway is foreground. + respondToServerRequest(request.requestId, { value: password }) triggerHaptic('submit') clearVaultUnlockRequest(request.sessionId, request.requestId) } catch (error) { @@ -387,12 +377,7 @@ function VaultSaveLoginDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await requestForOwnedSession<{ status?: string }>( - request.sessionId, - ambientRequestFor(gateway), - 'vault.save_login.respond', - { login, request_id: request.requestId } - ) + respondToServerRequest(request.requestId, { value: login }) triggerHaptic('submit') clearVaultSaveLoginRequest(request.sessionId, request.requestId) } catch (error) { @@ -504,12 +489,7 @@ function VaultCodeDialog({ sessionId }: { sessionId: string | null }) { setSubmitting(true) try { - await requestForOwnedSession<{ status?: string }>( - request.sessionId, - ambientRequestFor(gateway), - 'vault.code.respond', - { code: value, request_id: request.requestId } - ) + respondToServerRequest(request.requestId, { value }) triggerHaptic('submit') clearVaultCodeRequest(request.sessionId, request.requestId) } catch (error) { diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index 6f6225ad53..a0517f9c2f 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -586,15 +586,16 @@ export function renameGroupClarify(oldName: string, newName: string) { } } -/** Answer a member's pending prompt from the room. Routes to the member's - * OWN source (requestForBot), so cross-connection members work. - * - clarify: `clarify.respond`; batch questions send one respond per - * question, sequentially — the LAST lock resolves the blocked tool - * server-side (same contract as the 1:1 batch card). allow_expired - * server-side makes racing the timeout harmless. +/** Answer a member's pending prompt from the room. The prompt was mirrored + * from the member's resume snapshot (`open_requests` / `pending_approval`), so + * this window never held the live server request: answer through RPCs routed + * to the member's OWN source (requestForBot), so cross-connection members work. + * - clarify: `clarify.lock` per question, sequentially — the LAST lock + * resolves the blocked server request (same contract as the 1:1 batch + * card). A single question answers the open request by id through + * `request.answer` (the cross-socket proxy for a response frame). * - approval: `approval.respond` with the choice (once/session/always/deny), - * keyed by session + request_id — the same wire the 1:1 approval card - * and native notifications use. */ + * keyed by session + request_id — the queue-level wire every surface shares. */ export async function answerGroupClarify( entry: GroupPrompt, member: GroupMember, @@ -618,16 +619,16 @@ export async function answerGroupClarify( // Question ids are opaque on the wire (`GroupPrompt.questions` types // them `unknown`); the batch card keys its answer bag by exactly them. const qid = (question?.qid ?? question?.id) as string - await requestForBot(member, 'clarify.respond', { + await requestForBot(member, 'clarify.lock', { request_id: entry.requestId, question_id: qid, answer: (answers as Record)?.[qid] ?? '' }) } } else { - await requestForBot(member, 'clarify.respond', { - request_id: entry.requestId, - answer: typeof answers === 'string' ? answers : '' + await requestForBot(member, 'request.answer', { + id: entry.requestId, + result: { answer: typeof answers === 'string' ? answers : '' } }) } diff --git a/apps/desktop/src/store/clarify.ts b/apps/desktop/src/store/clarify.ts index 485691eb89..7ce328c3dc 100644 --- a/apps/desktop/src/store/clarify.ts +++ b/apps/desktop/src/store/clarify.ts @@ -1,6 +1,7 @@ import { atom, computed } from 'nanostores' import { $gateway } from './gateway' +import { respondToServerRequest } from './server-requests' import { $activeSessionId } from './session' export interface ClarifyQuestion { @@ -187,8 +188,8 @@ export const hasClarifyRequest = (sessionId: string | null | undefined): boolean * (default 5 min) — the message looks sent and nothing happens. Skipping lets * the tool return and the turn carry on with the user's actual words. * - * An empty answer is the same thing the card's own Skip button sends, and - * `clarify.respond` is `allow_expired`, so racing the timeout is harmless. + * An empty answer is the same thing the card's own Skip button sends; answering + * a request that already expired is a no-op, so racing the timeout is harmless. */ export async function skipClarifyRequest(sessionId: string | null | undefined): Promise { const request = $clarifyRequests.get()[keyFor(sessionId)] @@ -201,12 +202,7 @@ export async function skipClarifyRequest(sessionId: string | null | undefined): // leave a live card the user can answer a second time. clearClarifyRequest(request.requestId, request.sessionId) - try { - await $gateway.get()?.request('clarify.respond', { request_id: request.requestId, answer: '' }) - } catch { - // The tool times out on its own; a failed skip must never swallow the - // message the user is actually sending. - } + respondToServerRequest(request.requestId, { answer: '' }) return true } diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 63b5031f3f..c6888544ed 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -3,7 +3,8 @@ import { type GatewayEvent, reconnectBackoffDelayMs, registryBackendScopeKey, - resolveGatewayWsUrl + resolveGatewayWsUrl, + type ServerRequest } from '@hermes/shared' import { atom } from 'nanostores' @@ -56,6 +57,10 @@ interface RegistryConfig { * the connection store. */ activeConnectionId?: () => null | string onEvent: (event: GatewayEvent) => void + /** Server→client request (clarify, approval, …) from ANY socket the registry owns; the + * request's `respond` already routes to the socket it came from. `profile` / + * `connectionId` tag the source the same way events are tagged. */ + onServerRequest?: (request: ScopedServerRequest) => void onActiveConnectionInvalidated?: (fallbackProfile: string, activationEpoch: number) => void onActiveConnectionChanged?: (connection: HermesConnection) => void /** @@ -100,6 +105,7 @@ interface Secondary { activeRequests: number connectPromise: Promise | null offEvent: () => void + offRequest: () => void offState: () => void reconnectTimer: ReturnType | null reconnectAttempt: number @@ -269,6 +275,19 @@ export function emitLocalGatewayEvent(event: GatewayEvent): void { g.config?.onEvent(event) } +/** A server→client request tagged with the registry source it arrived from (like `GatewayEvent.profile`). */ +export interface ScopedServerRequest extends ServerRequest { + connectionId?: string + profile: string +} + +/** Fan a primary-socket server request into the registry handler with the active source tags. */ +export function dispatchPrimaryServerRequest(request: ServerRequest, profile: string): void { + const connectionId = g.config?.activeConnectionId?.() ?? null + + g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile }) +} + export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void { const next = normKey(profile) @@ -800,6 +819,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S activeRequests: 0, connectPromise: null, offEvent: () => {}, + offRequest: () => {}, offState: () => {}, reconnectTimer: null, reconnectAttempt: 0, @@ -822,6 +842,9 @@ function createSecondary(profile: string, connectionId: null | string = null): S g.config?.onEvent(scopedEvent) releaseTerminalTurnLease(entry.scope, event) }) + entry.offRequest = gateway.onRequest(request => { + g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile }) + }) entry.offState = gateway.onState(state => { reportGatewayState(scope, state) @@ -1741,6 +1764,7 @@ function disposeSecondary(entry: Secondary): void { entry.wantOpen = false clearTimer(entry) entry.offEvent() + entry.offRequest() entry.offState() entry.gateway.close() } diff --git a/apps/desktop/src/store/mcp-setup.ts b/apps/desktop/src/store/mcp-setup.ts index 09a78b4277..bcccba05e0 100644 --- a/apps/desktop/src/store/mcp-setup.ts +++ b/apps/desktop/src/store/mcp-setup.ts @@ -1,6 +1,7 @@ import { atom, computed } from 'nanostores' import { $gateway } from './gateway' +import { respondToServerRequest } from './server-requests' /** * Pending `mcp.setup.request`s — the desktop half of the `setup_mcp` tool's @@ -19,7 +20,7 @@ export interface McpSetupRequest { sessionId: string | null } -/** The card's answer, serialized back through `mcp.setup.respond`. */ +/** The card's answer, serialized back as the `mcp.setup` request's `{value}`. */ export interface McpSetupOutcome { status: 'authorized' | 'declined' | 'enabled' | 'error' | 'installed' server: string @@ -104,15 +105,9 @@ export async function skipMcpSetupRequest(sessionId: string | null | undefined): // leave a live card the user can answer a second time. clearMcpSetupRequest(request.requestId, request.sessionId) - try { - await $gateway.get()?.request('mcp.setup.respond', { - request_id: request.requestId, - result: JSON.stringify({ server: request.server, status: 'declined' }) - }) - } catch { - // The tool times out on its own; a failed skip must never swallow the - // message the user is actually sending. - } + respondToServerRequest(request.requestId, { + value: JSON.stringify({ server: request.server, status: 'declined' }) + }) return true } diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts index b622ec258b..026566219a 100644 --- a/apps/desktop/src/store/native-notifications.ts +++ b/apps/desktop/src/store/native-notifications.ts @@ -4,6 +4,7 @@ import { type HermesOpenTarget, resolveHermesOpenPath } from '@/lib/hermes-open- import { persistString, storedString } from '@/lib/storage' import { $gateway } from './gateway' +import { $approvalRequests, answerApproval } from './prompts' import { withinNativeNotifyBaseline } from './notify-baseline' import { clearApprovalRequest } from './prompts' import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone' @@ -366,18 +367,11 @@ export async function respondToApprovalAction(sessionId: null | string, actionId } try { - // Route through the session's OWNER (tile route → known profile); the - // ambient socket follows foreground focus and, for a background approval - // raised by a cross-profile session, points at a backend that never held - // the approval (#91684 client half). Ambient only when no owner is known. - await requestForOwnedSession( - sessionId, - // Bound (not wrapped) so the ambient fallback keeps the exact 2-arg - // call shape gateway.request callers assert on. - gateway.request.bind(gateway) as typeof gateway.request, - 'approval.respond', - { choice, session_id: sessionId ?? undefined } - ) + // The parked prompt knows how to answer itself: the live server request when + // still open, else the owner-routed queue-level RPC (#91684 client half). + const parked = $approvalRequests.get()[sessionId ?? ''] + + await answerApproval(gateway, parked ?? { sessionId: sessionId ?? null }, choice) clearApprovalRequest(sessionId) } catch (error) { if (sessionId && isSessionGoneForBackgroundPolling(error)) { diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index 7325c4f54e..2ef2e70e3d 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -4,12 +4,14 @@ import { $clarifyRequest, $clarifyRequests } from './clarify' import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone' import { $activeSessionId } from './session' import { ambientRequestFor } from './session-gone-latch' +import { respondToServerRequest } from './server-requests' import { requestForOwnedSession } from './session-states' -// Blocking interactive prompts the gateway raises mid-turn. Each maps to a -// `*.request` event the Python side emits while it blocks the agent thread -// waiting for a `*.respond` RPC. Without a renderer for these, the agent -// silently stalls until its timeout (default 5 min) and the tool is BLOCKED. +// Blocking interactive prompts the gateway raises mid-turn. Each is a +// server→client JSON-RPC request (`tui_gateway/server_requests.py`) the Python +// side blocks on; `requestId` is that request's id and the card answers through +// `store/server-requests.ts::respondToServerRequest`. Without a renderer for +// these the channel answers -32601 and the tool fails fast. // // Like clarify, every prompt is parked under the runtime session id that raised // it (not one shared slot), so a *background* session running concurrently can @@ -70,9 +72,12 @@ function keyedPromptStore(): PromptStore { } } -// Approval is session-keyed on the backend and correlated by `request_id` when -// available (legacy ID-free responses remain FIFO-compatible). Resolved via -// approval.respond {choice, request_id, session_id}. +// Approval is queue-backed on the backend (`tools/approval.py`): `requestId` is +// the QUEUE entry's id (what `approval.pending` / `approval.received` / +// `approval.respond` key on), stable across delivery paths. The live prompt +// arrives as an `approval` server request whose id is `serverRequestId`; the +// card answers that request when it is still open and falls back to the +// `approval.respond` RPC when the prompt was restored from `approval.pending`. export interface ApprovalRequest extends KeyedPrompt { // false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow". allowPermanent?: boolean @@ -80,6 +85,7 @@ export interface ApprovalRequest extends KeyedPrompt { command: string description: string requestId?: string + serverRequestId?: string smartDenied?: boolean } @@ -106,8 +112,8 @@ export interface SecretRequest extends KeyedPrompt { requestId: string } -// External password-manager unlock (agent/vault_backends). Resolved via -// vault.unlock.respond {request_id, password}; "" keeps the manager locked. +// External password-manager unlock (agent/vault_backends): `vault.unlock_prompt` +// server request, answered `{value: password}`; "" keeps the manager locked. export interface VaultUnlockRequest extends KeyedPrompt { backend: string displayName: string @@ -119,8 +125,8 @@ const sudo = keyedPromptStore() const secret = keyedPromptStore() const vaultUnlock = keyedPromptStore() -// "Save this login" for the page the agent is on (tools/browser_vault_tool). Resolved via -// vault.save_login.respond {request_id, login: JSON {identifier, password}}; "" declines. +// "Save this login" for the page the agent is on (tools/browser_vault_tool): `vault.save_login` +// server request, answered `{value: JSON {identifier, password}}`; "" declines. export interface VaultSaveLoginRequest extends KeyedPrompt { origin: string site: string @@ -129,8 +135,8 @@ export interface VaultSaveLoginRequest extends KeyedPrompt { const vaultSave = keyedPromptStore() -// Second-factor code for the page the agent is on. Resolved via vault.code.respond -// {request_id, code}; "" skips. +// Second-factor code for the page the agent is on: `vault.code` server request, +// answered `{value: code}`; "" skips. export interface VaultCodeRequest extends KeyedPrompt { site: string hint: string @@ -144,10 +150,19 @@ const vaultCode = keyedPromptStore() const $approvalInlineAnchors = atom>({}) export const $approvalRequest = approval.$active +export const $approvalRequests = approval.$all export const setApprovalRequest = approval.set export const clearApprovalRequest = approval.clear export async function receiveApprovalRequest(gateway: ApprovalGateway | null, request: ApprovalRequest): Promise { + // A prompt restored from `approval.pending` must not clobber the live server + // request that already carries the same queue entry (it knows how to answer). + const current = approval.$all.get()[keyFor(request.sessionId)] + + if (current?.requestId && current.requestId === request.requestId && current.serverRequestId && !request.serverRequestId) { + return + } + setApprovalRequest(request) if (gateway && request.requestId && request.sessionId) { @@ -210,6 +225,10 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses return } + if (previous?.requestId === pending.request_id) { + return + } + await receiveApprovalRequest(gateway, { allowPermanent: pending.allow_permanent !== false, choices: Array.isArray(pending.choices) ? pending.choices.filter(choice => typeof choice === 'string') : undefined, @@ -221,6 +240,35 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses }) } +/** + * Resolve an approval: answer the live server request when it is still open + * (the response frame goes back over the socket it arrived on — the owner by + * construction), else the queue-level `approval.respond` RPC for a prompt that + * was restored from `approval.pending` or is being answered from another + * surface. Returns after the backend has the decision. + */ +export async function answerApproval( + gateway: ApprovalGateway | null, + request: Pick, + choice: string, + all = false +): Promise { + if (respondToServerRequest(request.serverRequestId, { choice, ...(all ? { all: true } : {}) })) { + return + } + + if (!gateway) { + throw new Error('Hermes gateway is not connected') + } + + await requestForOwnedSession(request.sessionId, ambientRequestFor(gateway), 'approval.respond', { + all, + choice, + request_id: request.requestId, + session_id: request.sessionId ?? undefined + }) +} + /** The prompt request for one specific session — the tile counterpart of the * active-session `$*Request` views (same map, fixed key). */ export const sessionApprovalRequest = (sessionId: string | null) => @@ -250,10 +298,12 @@ export const sessionApprovalInlineVisible = (sessionId: string | null) => computed($approvalInlineAnchors, anchors => (anchors[keyFor(sessionId)] ?? 0) > 0) export const $sudoRequest = sudo.$active +export const $sudoRequests = sudo.$all export const setSudoRequest = sudo.set export const clearSudoRequest = sudo.clear export const $secretRequest = secret.$active +export const $secretRequests = secret.$all export const setSecretRequest = secret.set export const clearSecretRequest = secret.clear diff --git a/apps/desktop/src/store/server-requests.ts b/apps/desktop/src/store/server-requests.ts new file mode 100644 index 0000000000..5b234a9200 --- /dev/null +++ b/apps/desktop/src/store/server-requests.ts @@ -0,0 +1,46 @@ +import type { ServerRequest } from '@hermes/shared' + +/** + * Live server→client requests (`tui_gateway/server_requests.py`) keyed by + * request id: clarify / approval / sudo / secret / vault / MCP-setup cards. + * + * The per-session prompt stores keep only the id; a card answers through + * `respondToServerRequest`, which routes the response frame back over the + * socket the request arrived on — the owner backend by construction, so no + * owner-route lookup is needed (#91684's whole class disappears: the answer + * cannot land on the wrong backend because it is a JSON-RPC response, not a + * new call). A request re-delivered after a reconnect (`open_requests`) + * carries the same id and replaces the entry, so the still-visible card + * answers the new generation. + */ +const open = new Map() + +export function rememberServerRequest(request: ServerRequest): void { + open.set(request.id, request) +} + +export function forgetServerRequest(id: string): void { + open.delete(id) +} + +/** Answer request `id`. False when nothing is open under that id (expired / already answered). */ +export function respondToServerRequest(id: string | undefined, result: Record): boolean { + const request = id ? open.get(id) : undefined + + if (!request) { + return false + } + + open.delete(id!) + request.respond(result) + + return true +} + +export function hasOpenServerRequest(id: string): boolean { + return open.has(id) +} + +export function resetServerRequestsForTests(): void { + open.clear() +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index d17cd4174e..4516c9a43a 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -673,17 +673,11 @@ export interface SessionResumeResponse { request_id?: string smart_denied?: boolean } - // The clarify question still blocking this session, if any. Same replay - // class as pending_approval: emitted-while-detached prompts are restored - // from the resume snapshot instead of being lost until server-side timeout. - pending_clarify?: { - answers?: Record - choices?: null | string[] - multi_select?: boolean - question?: string - questions?: unknown - request_id?: string - } + // Server→client requests still unanswered for this session (clarify, sudo, + // vault prompts, …). The shared channel re-delivers them to the request + // handlers before this response resolves; listed here so resume can tell an + // authoritative "nothing pending" from a request the handler declined. + open_requests?: Array<{ id: string; method: string; params: Record & { session_id?: string } }> info?: SessionRuntimeInfo message_count: number messages: SessionMessage[] diff --git a/apps/shared/src/gateway-events.json b/apps/shared/src/gateway-events.json index fe5088fe87..2337641428 100644 --- a/apps/shared/src/gateway-events.json +++ b/apps/shared/src/gateway-events.json @@ -1,93 +1,86 @@ -[ - "agent.terminal.output", - "approval.request", - "background.complete", - "billing.step_up.verification", - "bot_relay.outbox.pending", - "browser.controller.cancel", - "browser.controller.command", - "browser.progress", - "btw.complete", - "clarify.expire", - "clarify.request", - "cron.changed", - "error", - "gateway.ready", - "layout.apply", - "mcp.setup.expire", - "mcp.setup.request", - "message.complete", - "message.delta", - "message.interim", - "message.reaction", - "message.start", - "moa.aggregating", - "moa.phase", - "moa.progress", - "moa.reference", - "notice", - "notification.clear", - "notification.show", - "pairing.changed", - "pane.reveal", - "pet.changed", - "pet.generate.progress", - "pet.hatch.progress", - "platforms.changed", - "preview.act.expire", - "preview.act.request", - "preview.close", - "preview.open", - "preview.read.expire", - "preview.read.request", - "preview.restart.complete", - "preview.restart.progress", - "reaction", - "reasoning.available", - "reasoning.delta", - "review.summary", - "secret.expire", - "secret.request", - "session.control.update", - "session.info", - "session.reclaimed", - "session.resume_progress", - "session.title", - "session.usage", - "sessions.changed", - "setup.ready", - "skin.changed", - "status.update", - "subagent.complete", - "subagent.progress", - "subagent.spawn_requested", - "subagent.start", - "subagent.thinking", - "subagent.tool", - "sudo.expire", - "sudo.request", - "terminal.close", - "terminal.read.expire", - "terminal.read.request", - "thinking.delta", - "tip.show", - "todo.updated", - "tool.complete", - "tool.generating", - "tool.output_risk", - "tool.start", - "tour.expire", - "tour.request", - "vault.code.expire", - "vault.code.request", - "vault.save_login.expire", - "vault.save_login.request", - "vault.unlock.expire", - "vault.unlock.request", - "voice.interrupted", - "voice.status", - "voice.transcript", - "wake.detected", - "window.read.expire", - "window.read.request" -] +{ + "events": [ + "agent.terminal.output", + "background.complete", + "billing.step_up.verification", + "bot_relay.outbox.pending", + "browser.controller.cancel", + "browser.controller.command", + "browser.progress", + "btw.complete", + "cron.changed", + "error", + "gateway.ready", + "layout.apply", + "message.complete", + "message.delta", + "message.interim", + "message.reaction", + "message.start", + "moa.aggregating", + "moa.phase", + "moa.progress", + "moa.reference", + "notice", + "notification.clear", + "notification.show", + "pairing.changed", + "pane.reveal", + "pet.changed", + "pet.generate.progress", + "pet.hatch.progress", + "platforms.changed", + "preview.close", + "preview.open", + "preview.restart.complete", + "preview.restart.progress", + "reaction", + "reasoning.available", + "reasoning.delta", + "request.cancel", + "review.summary", + "session.control.update", + "session.info", + "session.reclaimed", + "session.resume_progress", + "session.title", + "session.usage", + "sessions.changed", + "setup.ready", + "skin.changed", + "status.update", + "subagent.complete", + "subagent.progress", + "subagent.spawn_requested", + "subagent.start", + "subagent.thinking", + "subagent.tool", + "terminal.close", + "thinking.delta", + "tip.show", + "todo.updated", + "tool.complete", + "tool.generating", + "tool.output_risk", + "tool.start", + "voice.interrupted", + "voice.status", + "voice.transcript", + "wake.detected" + ], + "server_requests": [ + "approval", + "clarify", + "mcp.setup", + "preview.act", + "preview.read", + "secret", + "sudo", + "terminal.read", + "tour", + "vault.code", + "vault.save_login", + "vault.unlock_prompt", + "window.read" + ] +} diff --git a/apps/shared/src/gateway-events.test.ts b/apps/shared/src/gateway-events.test.ts index 3232e3098e..708f4f5405 100644 --- a/apps/shared/src/gateway-events.test.ts +++ b/apps/shared/src/gateway-events.test.ts @@ -1,22 +1,29 @@ import { describe, expect, it } from 'vitest' -import { BACKEND_EVENT_NAMES } from './gateway-events' +import { BACKEND_EVENT_NAMES, SERVER_REQUEST_METHODS } from './gateway-events' import contract from './gateway-events.json' /** * Two-sided contract with `tests/tui_gateway/test_gateway_event_contract.py`: the - * Python side pins the emitter call sites to `gateway-events.json`; this side pins - * `BACKEND_EVENT_NAMES` (which `BackendGatewayEventMap` is checked against via - * `satisfies`) to the same JSON. A name added on either side alone goes red here. + * Python side pins the emitter / server-request call sites to `gateway-events.json`; + * this side pins `BACKEND_EVENT_NAMES` (checked against `BackendGatewayEventMap` via + * `satisfies`) and `SERVER_REQUEST_METHODS` (against `ServerRequestMap`) to the same + * JSON. A name added on either side alone goes red here. */ -describe('gateway-events.json ⇄ BackendGatewayEventMap', () => { +describe('gateway-events.json ⇄ BackendGatewayEventMap / ServerRequestMap', () => { it('lists exactly the backend-emitted notification names', () => { - expect([...BACKEND_EVENT_NAMES]).toEqual(contract) + expect([...BACKEND_EVENT_NAMES]).toEqual(contract.events) }) - it('keeps both lists sorted and duplicate-free (stable diffs)', () => { - const sorted = [...contract].sort() - expect(contract).toEqual(sorted) - expect(new Set(contract).size).toBe(contract.length) + it('lists exactly the server→client request methods', () => { + expect([...SERVER_REQUEST_METHODS]).toEqual(contract.server_requests) + }) + + it.each([ + ['events', contract.events], + ['server_requests', contract.server_requests] + ])('keeps %s sorted and duplicate-free (stable diffs)', (_label, list) => { + expect(list).toEqual([...list].sort()) + expect(new Set(list).size).toBe(list.length) }) }) diff --git a/apps/shared/src/gateway-events.ts b/apps/shared/src/gateway-events.ts index e6d26aed34..0ced15188d 100644 --- a/apps/shared/src/gateway-events.ts +++ b/apps/shared/src/gateway-events.ts @@ -3,7 +3,8 @@ * TypeScript surfaces (Ink TUI, Desktop, web dashboard) share. * * Every notification arrives as `{jsonrpc: '2.0', method: 'event', params: GatewayEvent}` - * (`tui_gateway/server.py::_event_frame`). `GatewayEventMap` is the single map from + * (`tui_gateway/server.py::_event_frame`); server→client REQUESTS (`{id, method, params}`, + * `tui_gateway/server_requests.py`) are typed by `ServerRequestMap` below. `GatewayEventMap` is the single map from * event `type` to payload shape; `BACKEND_EVENT_NAMES` mirrors the emitter side and is * pinned to `gateway-events.json` by `gateway-events.test.ts` (vitest) and * `tests/tui_gateway/test_gateway_event_contract.py` (Python), so a name added on one @@ -304,11 +305,20 @@ export interface MoaPhasePayload { refs_total?: number } -// Blocking bridges (`tui_gateway/server.py::_block`): every `*.request` carries a -// `request_id`; the matching `*.expire` names the same id when the wait timed out. +// ── Server→client requests (`tui_gateway/server_requests.py`) ─────────── +// +// The backend asks the renderer a question with a real JSON-RPC request +// (`{id: 'srq-…', method, params}`) and blocks on the response frame. Every +// entry below is one method: its `params` shape and the `result` the client +// answers with. `request.cancel` (an event) withdraws an open request on +// timeout / interrupt / session close; `open_requests` on `session.resume` / +// `session.events.since` re-delivers unanswered ones after a reconnect. -export interface RequestExpirePayload { - request_id: string +/** `request.cancel` payload — the backend withdrew an open server request. */ +export interface RequestCancelPayload { + id: string + method: string + reason: string } export interface ClarifyQuestion { @@ -318,54 +328,113 @@ export interface ClarifyQuestion { question: string } -export interface ClarifyRequestPayload { +/** `clarify` params. Single question: `question`/`choices`(/`multi_select`); batch: `questions`. + * `answers` rides along only on a reconnect replay (locks the server already accepted). */ +export interface ClarifyRequestParams { answers?: Record choices?: null | string[] multi_select?: boolean question?: string questions?: ClarifyQuestion[] - request_id: string } -/** `tui_gateway/server.py::_approval_request_payload` (command redacted server-side). */ -export interface ApprovalRequestPayload { +/** `clarify` result. Single: `{answer}` ('' = skip). Batch: the request resolves through + * `clarify.lock` RPCs (the last lock completes it); a response with no `answers` is cancel-all. */ +export interface ClarifyResult { + answer?: string + answers?: Record +} + +/** `approval` params (`tui_gateway/server.py::_approval_request_payload`, command redacted server-side). */ +export interface ApprovalRequestParams { allow_permanent?: boolean choices?: string[] command: string description: string - request_id?: string + request_id: string smart_denied?: boolean } -export interface SudoRequestPayload { - request_id: string +export interface ApprovalResult { + all?: boolean + choice: 'always' | 'deny' | 'once' | 'session' } -export interface SecretRequestPayload { +/** Every prompt whose answer is one string: `sudo`, `secret`, the vault prompts, the desktop GUI + * bridges (`terminal.read`, `preview.read`, `preview.act`, `window.read`, `tour`) and `mcp.setup`. + * '' means skipped / declined. */ +export interface ValueResult { + value: string +} + +export interface SecretRequestParams { env_var: string + metadata?: Record prompt: string - request_id: string } -export interface VaultUnlockRequestPayload { +export interface VaultUnlockRequestParams { backend: string display_name: string - request_id: string } -export interface VaultCodeRequestPayload { +export interface VaultSaveLoginRequestParams { + origin: string + site: string +} + +export interface VaultCodeRequestParams { hint?: string - request_id: string site?: string } -export interface McpSetupRequestPayload { +export interface McpSetupRequestParams { action?: string reason?: string - request_id: string server?: string } +export interface ReadRangeRequestParams { + count?: number + start?: number +} + +/** Server→client request method → `{params, result}`. Every method the backend can ask. */ +export interface ServerRequestMap { + approval: { params: ApprovalRequestParams; result: ApprovalResult } + clarify: { params: ClarifyRequestParams; result: ClarifyResult } + 'mcp.setup': { params: McpSetupRequestParams; result: ValueResult } + 'preview.act': { params: Record; result: ValueResult } + 'preview.read': { params: ReadRangeRequestParams; result: ValueResult } + secret: { params: SecretRequestParams; result: ValueResult } + sudo: { params: Record; result: ValueResult } + 'terminal.read': { params: ReadRangeRequestParams; result: ValueResult } + tour: { params: Record; result: ValueResult } + 'vault.code': { params: VaultCodeRequestParams; result: ValueResult } + 'vault.save_login': { params: VaultSaveLoginRequestParams; result: ValueResult } + 'vault.unlock_prompt': { params: VaultUnlockRequestParams; result: ValueResult } + 'window.read': { params: Record; result: ValueResult } +} + +export type ServerRequestMethod = keyof ServerRequestMap + +/** Pinned to `gateway-events.json`'s `server_requests` list by the two contract tests. Keep sorted. */ +export const SERVER_REQUEST_METHODS = [ + 'approval', + 'clarify', + 'mcp.setup', + 'preview.act', + 'preview.read', + 'secret', + 'sudo', + 'terminal.read', + 'tour', + 'vault.code', + 'vault.save_login', + 'vault.unlock_prompt', + 'window.read' +] as const satisfies readonly ServerRequestMethod[] + /** Side agents (`tui_gateway/methods_prompt.py::_spawn_side_agent`). */ export interface SideAgentCompletePayload { question?: string @@ -397,7 +466,6 @@ export interface TerminalClosePayload { */ export const BACKEND_EVENT_NAMES = [ 'agent.terminal.output', - 'approval.request', 'background.complete', 'billing.step_up.verification', 'bot_relay.outbox.pending', @@ -405,14 +473,10 @@ export const BACKEND_EVENT_NAMES = [ 'browser.controller.command', 'browser.progress', 'btw.complete', - 'clarify.expire', - 'clarify.request', 'cron.changed', 'error', 'gateway.ready', 'layout.apply', - 'mcp.setup.expire', - 'mcp.setup.request', 'message.complete', 'message.delta', 'message.interim', @@ -431,20 +495,15 @@ export const BACKEND_EVENT_NAMES = [ 'pet.generate.progress', 'pet.hatch.progress', 'platforms.changed', - 'preview.act.expire', - 'preview.act.request', 'preview.close', 'preview.open', - 'preview.read.expire', - 'preview.read.request', 'preview.restart.complete', 'preview.restart.progress', 'reaction', 'reasoning.available', 'reasoning.delta', + 'request.cancel', 'review.summary', - 'secret.expire', - 'secret.request', 'session.control.update', 'session.info', 'session.reclaimed', @@ -461,11 +520,7 @@ export const BACKEND_EVENT_NAMES = [ 'subagent.start', 'subagent.thinking', 'subagent.tool', - 'sudo.expire', - 'sudo.request', 'terminal.close', - 'terminal.read.expire', - 'terminal.read.request', 'thinking.delta', 'tip.show', 'todo.updated', @@ -473,20 +528,10 @@ export const BACKEND_EVENT_NAMES = [ 'tool.generating', 'tool.output_risk', 'tool.start', - 'tour.expire', - 'tour.request', - 'vault.code.expire', - 'vault.code.request', - 'vault.save_login.expire', - 'vault.save_login.request', - 'vault.unlock.expire', - 'vault.unlock.request', 'voice.interrupted', 'voice.status', 'voice.transcript', - 'wake.detected', - 'window.read.expire', - 'window.read.request' + 'wake.detected' ] as const satisfies readonly (keyof BackendGatewayEventMap)[] export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number] @@ -494,7 +539,6 @@ export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number] /** Payload per backend-emitted notification `type`. Keys are exactly `BACKEND_EVENT_NAMES`. */ export interface BackendGatewayEventMap { 'agent.terminal.output': TerminalOutputPayload - 'approval.request': ApprovalRequestPayload 'background.complete': SideAgentCompletePayload 'billing.step_up.verification': BillingStepUpVerificationPayload 'bot_relay.outbox.pending': Record @@ -502,14 +546,10 @@ export interface BackendGatewayEventMap { 'browser.controller.command': Record 'browser.progress': BrowserProgressPayload 'btw.complete': SideAgentCompletePayload - 'clarify.expire': RequestExpirePayload - 'clarify.request': ClarifyRequestPayload 'cron.changed': Record error: ErrorPayload 'gateway.ready': GatewayReadyPayload 'layout.apply': Record - 'mcp.setup.expire': RequestExpirePayload - 'mcp.setup.request': McpSetupRequestPayload 'message.complete': MessageCompletePayload 'message.delta': StreamDeltaPayload 'message.interim': MessageInterimPayload @@ -528,20 +568,15 @@ export interface BackendGatewayEventMap { 'pet.generate.progress': Record 'pet.hatch.progress': Record 'platforms.changed': Record - 'preview.act.expire': RequestExpirePayload - 'preview.act.request': Record 'preview.close': Record 'preview.open': Record - 'preview.read.expire': RequestExpirePayload - 'preview.read.request': Record 'preview.restart.complete': SideAgentCompletePayload 'preview.restart.progress': PreviewRestartProgressPayload reaction: ReactionPayload 'reasoning.available': StreamDeltaPayload 'reasoning.delta': StreamDeltaPayload + 'request.cancel': RequestCancelPayload 'review.summary': TextPayload - 'secret.expire': RequestExpirePayload - 'secret.request': SecretRequestPayload 'session.control.update': SessionControlUpdatePayload /** Surface-specific shape (`tui_gateway/server.py::_session_info`); each client narrows. */ 'session.info': Record @@ -559,11 +594,7 @@ export interface BackendGatewayEventMap { 'subagent.start': SubagentEventPayload 'subagent.thinking': SubagentEventPayload 'subagent.tool': SubagentEventPayload - 'sudo.expire': RequestExpirePayload - 'sudo.request': SudoRequestPayload 'terminal.close': TerminalClosePayload - 'terminal.read.expire': RequestExpirePayload - 'terminal.read.request': Record 'thinking.delta': StreamDeltaPayload 'tip.show': Record 'todo.updated': TodoStatePayload @@ -571,20 +602,10 @@ export interface BackendGatewayEventMap { 'tool.generating': ToolGeneratingPayload 'tool.output_risk': ToolOutputRiskPayload 'tool.start': ToolStartPayload - 'tour.expire': RequestExpirePayload - 'tour.request': Record - 'vault.code.expire': RequestExpirePayload - 'vault.code.request': VaultCodeRequestPayload - 'vault.save_login.expire': RequestExpirePayload - 'vault.save_login.request': Record - 'vault.unlock.expire': RequestExpirePayload - 'vault.unlock.request': VaultUnlockRequestPayload 'voice.interrupted': Record 'voice.status': VoiceStatusPayload 'voice.transcript': VoiceTranscriptPayload 'wake.detected': WakeDetectedPayload - 'window.read.expire': RequestExpirePayload - 'window.read.request': Record } /** @@ -757,6 +778,11 @@ export interface SessionResumeResponse, Message = messages: Message[] /** `omit_messages` resume: the client still learns the stored size. */ messages_omitted?: boolean + /** Server→client requests still unanswered for this session (a clarify, sudo prompt, … + * raised while the client was detached); the client re-delivers them to its request + * handlers. `pending_approval` (the approval queue's oldest entry) is the approval twin. */ + open_requests?: OpenServerRequest[] + pending_approval?: ApprovalRequestParams resumed?: string running?: boolean session_id: string @@ -765,3 +791,10 @@ export interface SessionResumeResponse, Message = status?: string todo_state?: TodoStatePayload } + +/** One unanswered server→client request as returned by `open_requests`. */ +export interface OpenServerRequest { + id: string + method: string + params: Record & { session_id?: string } +} diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index ef1f4f77fe..cdc86a5ccf 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -62,12 +62,14 @@ export { export { compactNumber } from './format' export { type FuzzyMatch, fuzzyRank, fuzzyScore, fuzzyScoreMulti, type RankedItem } from './fuzzy' export { - type ApprovalRequestPayload, + type ApprovalRequestParams, + type ApprovalResult, BACKEND_EVENT_NAMES, type BackendGatewayEventMap, type BackendGatewayEventName, type ClarifyQuestion, - type ClarifyRequestPayload, + type ClarifyRequestParams, + type ClarifyResult, type ClientLocalGatewayEventMap, type ErrorPayload, type ErrorSurface, @@ -76,7 +78,7 @@ export { type GatewayEventName, type GatewayReadyPayload, type GatewayTranscriptMessage, - type McpSetupRequestPayload, + type McpSetupRequestParams, type MessageCompletePayload, type MessageInterimPayload, type ModelCapabilities, @@ -84,8 +86,13 @@ export { type ModelOptionsResponse, type ModelPricing, type NotificationShowPayload, - type RequestExpirePayload, - type SecretRequestPayload, + type OpenServerRequest, + type ReadRangeRequestParams, + type RequestCancelPayload, + type SecretRequestParams, + SERVER_REQUEST_METHODS, + type ServerRequestMap, + type ServerRequestMethod, type SessionInflightTurn, type SessionListItem, type SessionListResponse, @@ -100,7 +107,10 @@ export { type ToolCompletePayload, type ToolStartPayload, type Usage, - type VaultUnlockRequestPayload, + type ValueResult, + type VaultCodeRequestParams, + type VaultSaveLoginRequestParams, + type VaultUnlockRequestParams, type WakeDetectedPayload } from './gateway-events' export { @@ -124,6 +134,9 @@ export { JsonRpcRequestChannel, type JsonRpcRequestChannelOptions, type JsonRpcTransport, + type ServerRequest, + type ServerRequestHandler, + type ServerRequestParams, wireFrameText } from './json-rpc-channel' export { diff --git a/apps/shared/src/json-rpc-channel.ts b/apps/shared/src/json-rpc-channel.ts index d261f0fff3..46b1f1b0be 100644 --- a/apps/shared/src/json-rpc-channel.ts +++ b/apps/shared/src/json-rpc-channel.ts @@ -12,10 +12,47 @@ export interface JsonRpcFrame { error?: JsonRpcErrorPayload id?: GatewayRequestId | null method?: string - params?: GatewayEvent + params?: GatewayEvent | ServerRequestParams result?: unknown } +/** + * Params of a server→client request (`tui_gateway/server_requests.py`): the + * backend asking the renderer a question. `session_id` names the session + * blocked on the answer; the rest is method-specific. + */ +export interface ServerRequestParams extends Record { + session_id?: string +} + +/** One inbound server→client request, as handed to a `ServerRequestHandler`. */ +export interface ServerRequest { + id: string + method: M + params: P + /** + * Route the answer back to the backend that asked. Idempotent: the first + * `respond` (or `fail`) wins; a request re-delivered after a reconnect + * (`open_requests`) reuses the id, so a stale card answering twice is a + * no-op on the wire. + */ + respond: (result: Record) => void + /** Answer with a JSON-RPC error (the backend treats it as unanswered). */ + fail: (code: number, message: string) => void + /** + * Renderer-side tag set by the owner when a request arrives through a + * replay (`open_requests`) rather than live; handlers that already show the + * card can skip re-notifying. + */ + replayed?: boolean +} + +/** Handles one inbound server→client request; return `false` to decline (next handler tries). */ +export type ServerRequestHandler = (request: ServerRequest) => boolean | void + +const isServerRequestFrame = (frame: JsonRpcFrame): frame is JsonRpcFrame & { id: string; method: string } => + typeof frame.id === 'string' && typeof frame.method === 'string' && frame.method !== 'event' + /** JSON-RPC error with optional structured `data` from the gateway. */ export class JsonRpcGatewayError extends Error { readonly code?: number @@ -59,6 +96,12 @@ export interface JsonRpcRequestChannelOptions { onHeartbeatFailure?: (error: Error) => void /** Decoded `event` notification. */ onEvent?: (event: GatewayEvent) => void + /** + * Inbound server→client request nobody handled: the owner logs it. The + * channel has already answered `-32601` so the backend does not wait out + * its deadline against a client with no handler. + */ + onUnhandledRequest?: (request: { id: string; method: string; params: ServerRequestParams }) => void requestIdPrefix?: string requestTimeoutMs?: number /** @@ -130,8 +173,11 @@ export class JsonRpcRequestChannel { private heartbeatSequence = 0 private readonly outstandingPings = new Set() private lastLivenessAt = 0 - private readonly options: Required> & - Pick + private readonly requestHandlers: ServerRequestHandler[] = [] + private readonly options: Required< + Omit + > & + Pick constructor(options: JsonRpcRequestChannelOptions = {}) { this.options = { @@ -141,6 +187,7 @@ export class JsonRpcRequestChannel { heartbeatLiveness: options.heartbeatLiveness ?? 'response', onEvent: options.onEvent, onHeartbeatFailure: options.onHeartbeatFailure, + onUnhandledRequest: options.onUnhandledRequest, requestIdPrefix: options.requestIdPrefix ?? 'r', requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, unrefTimers: options.unrefTimers ?? false @@ -254,11 +301,89 @@ export class JsonRpcRequestChannel { }) } + /** + * Register a handler for server→client requests (clarify, approval, sudo, + * …). Handlers are tried in registration order until one accepts (returns + * anything but `false`); an unhandled request is answered `-32601` so the + * backend never waits out its deadline against a client that cannot answer. + */ + onRequest(handler: ServerRequestHandler): () => void { + this.requestHandlers.push(handler) + + return () => { + const index = this.requestHandlers.indexOf(handler) + + if (index >= 0) { + this.requestHandlers.splice(index, 1) + } + } + } + + /** + * Deliver a server request to the handlers. Live frames arrive through + * `handleFrame`; owners call this directly for `open_requests` returned by a + * reconnect replay (`replayed: true`) so an unanswered question survives a + * dropped socket. + */ + deliverRequest(id: string, method: string, params: ServerRequestParams, replayed = false): boolean { + let settled = false + + const send = (frame: Record) => { + if (settled) { + return + } + + settled = true + + try { + this.transport?.send(JSON.stringify({ jsonrpc: '2.0', id, ...frame })) + } catch { + // The generation is gone; the backend withdraws the request itself (timeout / reconnect replay). + } + } + + const request: ServerRequest = { + id, + method, + params, + replayed, + respond: result => send({ result }), + fail: (code, message) => send({ error: { code, message } }) + } + + for (const handler of this.requestHandlers) { + if (handler(request) !== false) { + return true + } + } + + request.fail(JSON_RPC_METHOD_NOT_FOUND, `no handler for server request: ${method}`) + this.options.onUnhandledRequest?.({ id, method, params }) + + return false + } + + private deliverOpenRequests(result: unknown): void { + const open = (result as { open_requests?: unknown } | null)?.open_requests + + if (!Array.isArray(open)) { + return + } + + for (const entry of open as Array<{ id?: unknown; method?: unknown; params?: unknown }>) { + if (typeof entry?.id === 'string' && typeof entry.method === 'string') { + const params = entry.params && typeof entry.params === 'object' ? (entry.params as ServerRequestParams) : {} + this.deliverRequest(entry.id, entry.method, params, true) + } + } + } + /** * Route one inbound frame: a response settles its pending call, an - * `event` notification reaches `onEvent`. Returns the decoded frame so the - * owner can act on it too (mirror it, record seq, …) or `null` when the - * text was not JSON or not a JSON object (`null`, a scalar). + * `event` notification reaches `onEvent`, a server→client request reaches + * the `onRequest` handlers. Returns the decoded frame so the owner can act + * on it too (mirror it, record seq, …) or `null` when the text was not + * JSON or not a JSON object (`null`, a scalar). */ handleFrame(text: string): JsonRpcFrame | null { let frame: JsonRpcFrame @@ -277,6 +402,13 @@ export class JsonRpcRequestChannel { this.lastLivenessAt = Date.now() } + if (isServerRequestFrame(frame)) { + const params = frame.params && typeof frame.params === 'object' ? (frame.params as ServerRequestParams) : {} + this.deliverRequest(frame.id, frame.method, params) + + return frame + } + if (frame.id !== undefined && frame.id !== null) { if (typeof frame.id === 'string' && this.outstandingPings.delete(frame.id)) { this.lastLivenessAt = Date.now() @@ -293,6 +425,13 @@ export class JsonRpcRequestChannel { if (frame.error) { call.reject(jsonRpcErrorFromFrame(frame.error)) } else { + // Reconnect contract: `session.resume` / `session.activate` / + // `session.events.since` answer with `open_requests` — the server→ + // client requests still waiting on this session. They cannot ride + // the event replay ring (they are not events), so they are re- + // delivered here, before the caller sees the result, over the very + // socket that owns them. + this.deliverOpenRequests(frame.result) call.resolve(frame.result) } } @@ -300,8 +439,8 @@ export class JsonRpcRequestChannel { return frame } - if (frame.method === 'event' && frame.params && typeof frame.params.type === 'string') { - this.options.onEvent?.(frame.params) + if (frame.method === 'event' && frame.params && typeof (frame.params as GatewayEvent).type === 'string') { + this.options.onEvent?.(frame.params as GatewayEvent) } return frame diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index 48bb2acf81..338272b762 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -5,6 +5,7 @@ import { type GatewayRequestId, JsonRpcRequestChannel, type JsonRpcTransport, + type ServerRequestHandler, wireFrameText } from './json-rpc-channel.js' @@ -343,6 +344,15 @@ export class JsonRpcGatewayClient { return this.onAny(handler) } + /** + * Server→client requests (clarify, approval, sudo, …). Live frames and + * `open_requests` re-delivered after a reconnect both arrive here; the + * latter carry `replayed: true`. + */ + onRequest(handler: ServerRequestHandler): () => void { + return this.channel.onRequest(handler) + } + onState(handler: (state: ConnectionState) => void): () => void { this.stateHandlers.add(handler) handler(this.state) @@ -445,6 +455,7 @@ export class JsonRpcGatewayClient { // One RPC per known session keeps params flat; sessions are few (<20). const results = await Promise.allSettled( entries.map(([sid, lastSeen]) => + // `open_requests` on the answer are re-delivered by the channel itself. this.request<{ events?: Array<{ type: string; session_id?: string; seq?: number; payload?: unknown }> }>( 'session.events.since', { session_id: sid, last_seen: lastSeen }, diff --git a/tests/tui_gateway/test_gateway_event_contract.py b/tests/tui_gateway/test_gateway_event_contract.py index ccbddfc2c4..ba6ec037be 100644 --- a/tests/tui_gateway/test_gateway_event_contract.py +++ b/tests/tui_gateway/test_gateway_event_contract.py @@ -1,16 +1,17 @@ -"""Two-sided contract: every notification name ``tui_gateway`` emits is listed in -``apps/shared/src/gateway-events.json`` and nothing in the JSON is orphaned. +"""Two-sided contract: every notification name ``tui_gateway`` emits, and every +server→client request method it sends, is listed in ``apps/shared/src/gateway-events.json`` +(``events`` / ``server_requests``) and nothing in the JSON is orphaned. The TypeScript half (``apps/shared/src/gateway-events.test.ts``) pins the typed -``GatewayEventMap`` to the same JSON, so a name added on either side alone goes red -somewhere. This file reads only Python sources and the JSON (never ``.ts`` text — -see ``tui_gateway/AGENTS.md``). +``GatewayEventMap`` and ``ServerRequestMap`` to the same JSON, so a name added on either +side alone goes red somewhere. This file reads only Python sources and the JSON (never +``.ts`` text — see ``tui_gateway/AGENTS.md``). -Names are collected from the emitter side: literal first arguments to the emit -helpers, plus the tables that derive names at runtime (``_EXPIRING_REQUESTS`` → -``*.expire``, the change-watcher table, child delta mirroring, the subagent relay -events from ``tools/delegate_tool*.py``, the ``desktop_ui`` tool emitters, and the -literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames). +Names are collected from the emitter side: literal first arguments to the emit helpers +and the server-request helpers (``server_requests.send`` / ``send_async`` / ``_ask``), +plus the tables that derive names at runtime (the change-watcher table, child delta +mirroring, the subagent relay events from ``tools/delegate_tool*.py``, the ``desktop_ui`` +tool emitters, and the literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames). """ from __future__ import annotations @@ -26,9 +27,12 @@ CONTRACT = REPO / "apps" / "shared" / "src" / "gateway-events.json" GATEWAY_DIR = REPO / "tui_gateway" # Every helper whose first positional argument is the wire ``type``. -_EMIT_HELPERS = ( - "_emit", "_block", "_read_block", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle") +_EMIT_HELPERS = ("_emit", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle") _LITERAL_EMIT = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_EMIT_HELPERS)) +# Server→client requests: ``server_requests.send("x", …)`` / ``send_async`` / the string-answer ``_ask`` and +# ``_read_block`` bridges. +_REQUEST_HELPERS = ("server_requests\\.send", "server_requests\\.send_async", "_ask", "_read_block") +_LITERAL_REQUEST = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_REQUEST_HELPERS)) # ``{"type": "gateway.ready", ...}`` literal frames (entry.py / ws.py) and other # ``"type": ""`` params written straight into an ``event`` frame. _LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S) @@ -50,10 +54,6 @@ def emitted_event_names() -> set[str]: names.update(_LITERAL_EMIT.findall(text)) names.update(_LITERAL_FRAME.findall(text)) names.update(_SIDE_AGENT.findall(text)) - # ``.request`` bridges that time out fire ``f"{event.removesuffix('.request')}.expire"``. - from tui_gateway.server import _EXPIRING_REQUESTS - - names.update(f"{event.removesuffix('.request')}.expire" for event in _EXPIRING_REQUESTS) from tui_gateway.change_watcher import _CHANGE_WATCHES names.update(_CHANGE_WATCHES) @@ -74,13 +74,33 @@ def emitted_event_names() -> set[str]: return names +def server_request_methods() -> set[str]: + names: set[str] = set() + for src in GATEWAY_DIR.glob("*.py"): + names.update(_LITERAL_REQUEST.findall(_read(src))) + return names + + @pytest.fixture(scope="module") def contract() -> list[str]: - return json.loads(_read(CONTRACT)) + return json.loads(_read(CONTRACT))["events"] -def test_contract_is_sorted_and_unique(contract): - assert contract == sorted(set(contract)), "gateway-events.json must be a sorted, duplicate-free list" +@pytest.fixture(scope="module") +def request_contract() -> list[str]: + return json.loads(_read(CONTRACT))["server_requests"] + + +def test_contract_is_sorted_and_unique(contract, request_contract): + assert contract == sorted(set(contract)), "gateway-events.json events must be a sorted, duplicate-free list" + assert request_contract == sorted(set(request_contract)), "gateway-events.json server_requests must be sorted" + + +def test_server_request_methods_match_the_contract(request_contract): + sent = server_request_methods() + assert sent == set(request_contract), ( + f"server requests sent {sorted(sent)} vs gateway-events.json server_requests {request_contract}; " + "fix the JSON AND SERVER_REQUEST_METHODS / ServerRequestMap in apps/shared/src/gateway-events.ts") def test_every_emitted_event_is_in_the_contract(contract): diff --git a/tools/approval.py b/tools/approval.py index c0ca4e4670..7b66a45884 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -177,6 +177,17 @@ def list_gateway_approvals(session_key: str) -> list[dict]: return [dict(entry.data) for entry in _gateway_queues.get(session_key, [])] +def register_gateway_settle(session_key: str, request_id: str, settle) -> bool: + """Attach ``settle(reason)`` to one pending approval; it runs once when that wait ends by any path. + False when the request is no longer pending (the surface should withdraw its prompt itself).""" + with _lock: + for entry in _gateway_queues.get(session_key, []): + if entry.data.get("request_id") == request_id: + entry.settle = settle + return True + return False + + def ack_gateway_approval(session_key: str, request_id: str) -> bool: """Record that a client received a particular pending approval request.""" with _lock: diff --git a/tools/approval_gateway_wait.py b/tools/approval_gateway_wait.py index 1a127cb1bf..63edb73816 100644 --- a/tools/approval_gateway_wait.py +++ b/tools/approval_gateway_wait.py @@ -24,13 +24,16 @@ logger = logging.getLogger("tools.approval") class _ApprovalEntry: """One pending dangerous-command approval inside a gateway session.""" - __slots__ = ("event", "data", "result", "reason", "acknowledged") + __slots__ = ("event", "data", "result", "reason", "acknowledged", "settle") def __init__(self, data: dict): self.event = threading.Event() self.data = dict(data) self.data.setdefault("request_id", uuid.uuid4().hex) self.acknowledged = False + # Surface hook run once when the wait ends by ANY path (answer, timeout, interrupt, /approve from + # another client): the tui_gateway withdraws its open server→client request through it. + self.settle = None self.result: str | None = None # "once"|"session"|"always"|"deny" # Free-text reason from ``/deny `` so the agent can adapt, not just hear "denied". self.reason: str | None = None @@ -139,13 +142,19 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *, with _approval._lock: _approval._gateway_queues.setdefault(session_key, []).append(entry) - def _drop_entry() -> None: + def _drop_entry(reason: str) -> None: with _approval._lock: queue = _approval._gateway_queues.get(session_key, []) if entry in queue: queue.remove(entry) if not queue: _approval._gateway_queues.pop(session_key, None) + settle, entry.settle = entry.settle, None + if settle is not None: + try: + settle(reason) + except Exception: + logger.debug("approval settle hook failed", exc_info=True) # Plugins hear about the request before the gateway does (real-time observers). _ctx._fire_approval_hook("pre_approval_request", **payload) @@ -154,7 +163,7 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *, notify_cb(dict(entry.data)) except Exception as exc: logger.warning("Gateway approval notify failed: %s", exc) - _drop_entry() + _drop_entry("notify_failed") _ctx._fire_approval_hook("post_approval_response", **payload, choice="notify_failed") return {"resolved": False, "choice": None, "notify_failed": True} @@ -163,5 +172,5 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *, if state == "interrupted": entry.result = "deny" entry.event.set() - _drop_entry() + _drop_entry("answered" if state == "set" else state) return _finish(payload, state != "timeout", entry.result, entry.reason) diff --git a/tui_gateway/agent_callbacks.py b/tui_gateway/agent_callbacks.py index aca9757bf6..54b1944efb 100644 --- a/tui_gateway/agent_callbacks.py +++ b/tui_gateway/agent_callbacks.py @@ -79,11 +79,11 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None: def _agent_cbs(sid: str) -> dict: - def _read_block(event: str, timeout: int): - # read_terminal / read_preview (desktop GUI): blocking bridge like clarify; the preview + def _read_block(method: str, timeout: int): + # read_terminal / read_preview (desktop GUI): server request like clarify; the preview # read gets longer since a URL tab extracts text from a live page. - return lambda start=None, count=None: _block( - event, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None}, + return lambda start=None, count=None: _ask( + method, sid, {k: v for k, v in (("start", start), ("count", count)) if v is not None}, timeout=timeout) callbacks = { @@ -105,17 +105,17 @@ def _agent_cbs(sid: str) -> dict: "notice_clear_callback": lambda key: _emit("notification.clear", sid, {"key": key}), "clarify_callback": lambda q, c, multi_select=False, questions=None: ( _clarify_block(sid, q, c, multi_select=multi_select, questions=questions)), - "read_terminal_callback": _read_block("terminal.read.request", 30), - "read_preview_callback": _read_block("preview.read.request", 45), + "read_terminal_callback": _read_block("terminal.read", 30), + "read_preview_callback": _read_block("preview.read", 45), # drive_preview / annotate_preview (desktop GUI): same budget as the preview read it ends with. - "drive_preview_callback": lambda payload: _block("preview.act.request", sid, dict(payload), timeout=45), + "drive_preview_callback": lambda payload: _ask("preview.act", sid, dict(payload), timeout=45), # read_window_below (desktop GUI): main process enumerates native windows. - "read_window_below_callback": lambda: _block("window.read.request", sid, {}, timeout=30), + "read_window_below_callback": lambda: _ask("window.read", sid, {}, timeout=30), # setup_mcp (desktop GUI): consent card + install/enable/OAuth; long timeout on purpose - # (typing an API key, browser OAuth) and, like clarify, a late answer is tolerated. - "setup_mcp_callback": lambda server, action, reason: _block( - "mcp.setup.request", sid, {"server": server, "action": action, "reason": reason}, timeout=600), - # tour (desktop GUI): renderer drives driver.js and answers tour.respond. + # (typing an API key, browser OAuth). + "setup_mcp_callback": lambda server, action, reason: _ask( + "mcp.setup", sid, {"server": server, "action": action, "reason": reason}, timeout=600), + # tour (desktop GUI): renderer drives driver.js and answers the ``tour`` request. "tour_callback": lambda payload: _tour_request(sid, payload)} # Interim assistant commentary (text alongside tool calls), gated on display.interim_assistant_ @@ -162,13 +162,13 @@ def _wire_callbacks(sid: str): def secret_cb(env_var, prompt, metadata=None): pl = {"prompt": prompt, "env_var": env_var, **({"metadata": metadata} if metadata else {})} - val = _block("secret.request", sid, pl) + val = _ask("secret", sid, pl) if not val: return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"} from hermes_cli.config import save_env_value_secure return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"} - set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120)) + set_sudo_password_callback(lambda: _ask("sudo", sid, {}, timeout=120)) set_project_workspace_callback(_apply_project_workspace) set_secret_capture_callback(secret_cb) # External password-manager unlock: the renderer shows a masked master-password card; the @@ -176,12 +176,12 @@ def _wire_callbacks(sid: str): from agent.vault_backends.unlock import (set_code_prompt_callback, set_current_session_id, set_save_login_prompt_callback, set_unlock_prompt_callback) set_current_session_id(sid) # an unlock made on this turn belongs to this session (released with it) - set_unlock_prompt_callback(lambda backend, display_name: _block( - "vault.unlock.request", sid, {"backend": backend, "display_name": display_name}, timeout=120)) + set_unlock_prompt_callback(lambda backend, display_name: _ask( + "vault.unlock_prompt", sid, {"backend": backend, "display_name": display_name}, timeout=120)) def save_login_cb(origin, site): # The renderer shows identifier + masked password; the JSON answer goes straight to the vault store. - raw = _block("vault.save_login.request", sid, {"origin": origin, "site": site}, timeout=180) + raw = _ask("vault.save_login", sid, {"origin": origin, "site": site}, timeout=180) try: data = json.loads(raw) if raw else None except ValueError: @@ -189,8 +189,8 @@ def _wire_callbacks(sid: str): return data if isinstance(data, dict) and data.get("password") else None set_save_login_prompt_callback(save_login_cb) - set_code_prompt_callback(lambda site, hint: _block( - "vault.code.request", sid, {"site": site, "hint": hint}, timeout=180)) + set_code_prompt_callback(lambda site, hint: _ask( + "vault.code", sid, {"site": site, "hint": hint}, timeout=180)) def _available_personalities(cfg: dict | None = None) -> dict: diff --git a/tui_gateway/compute_host.py b/tui_gateway/compute_host.py index 6750241518..ee404e98f5 100644 --- a/tui_gateway/compute_host.py +++ b/tui_gateway/compute_host.py @@ -190,7 +190,9 @@ class ComputeHost: self._guarded(frame, "interrupt.ack", body, applied=False) def _handle_respond(self, frame: dict[str, Any]) -> None: - """Resolve an interactive request in the host-owned pending registry.""" + """Resolve a server→client request this host owns: ``params.frame`` is the client's JSON-RPC response + frame relayed by the parent; ``params.lock`` is one batch-clarify lock (answered with ``clarify.lock``'s + result so the parent can ack the client).""" def body(server: Any, sid: str, request_id: Any) -> None: params = frame.get("params") error = ("session not found" if sid not in server._sessions @@ -198,7 +200,13 @@ class ComputeHost: if error: self._reply("respond.error", sid, request_id, message=error) return - response = server._methods["clarify.respond"](request_id, params) + from tui_gateway import server_requests + if isinstance(params.get("lock"), dict): + response = server._methods["clarify.lock"](request_id, params["lock"]) + else: + response_frame = params.get("frame") if isinstance(params.get("frame"), dict) else params + resolved = server_requests.resolve_response(response_frame) + response = {"jsonrpc": "2.0", "id": request_id, "result": {"status": "ok" if resolved else "expired"}} self._reply("respond.ack", sid, request_id, response=response) self._guarded(frame, "respond.error", body) diff --git a/tui_gateway/compute_host_bridge.py b/tui_gateway/compute_host_bridge.py index 15fa092c60..74b968706f 100644 --- a/tui_gateway/compute_host_bridge.py +++ b/tui_gateway/compute_host_bridge.py @@ -90,7 +90,8 @@ def _compute_host_adopt_frame_meta(session: dict, frame: dict) -> None: def _relay_compute_host_rpc(message: dict) -> bool: - """Relay host events while retaining the clarify snapshot needed on resume.""" + """Relay host frames to the client while mirroring the server→client request the host has open, so a + reconnecting client gets it back through ``open_requests``.""" params = message.get("params") if isinstance(message, dict) else None if isinstance(message, dict) and message.get("method") == "compute_host.activity": if isinstance(params, dict): @@ -101,17 +102,20 @@ def _relay_compute_host_rpc(message: dict) -> bool: and session.get("_compute_host_turn_id") == params["turn_id"]): session["_compute_host_activity_ns"] = params.get("activity_ns") return True # Internal observation, not a client event or replay entry. - kind = params.get("type") if isinstance(params, dict) else None - if kind in {"clarify.request", "clarify.expire"}: + if isinstance(message, dict) and isinstance(message.get("id"), str) and message.get("method") not in (None, "event"): + # A server request minted by the child: remember it against its session until it is answered/withdrawn. + session = _sessions.get(str((params or {}).get("session_id") or "")) if isinstance(params, dict) else None + if session is not None: + with _history_lock(session): + session["_compute_host_open_request"] = { + "id": message["id"], "method": message["method"], "params": dict(params)} + elif isinstance(params, dict) and params.get("type") == "request.cancel": session = _sessions.get(str(params.get("session_id") or "")) payload = params.get("payload") - request_id = payload.get("request_id") if isinstance(payload, dict) else None - if session is not None and request_id: + if session is not None and isinstance(payload, dict): with _history_lock(session): - if kind == "clarify.request": - session["_compute_host_pending_clarify"] = dict(payload) - elif _pending_clarify_matches(session, request_id): - session.pop("_compute_host_pending_clarify", None) + if _open_request_matches(session, payload.get("id")): + session.pop("_compute_host_open_request", None) return write_json(message) @@ -119,62 +123,65 @@ def _history_lock(session: dict): return session.get("history_lock", threading.Lock()) -def _pending_clarify_matches(session: dict, request_id) -> bool: - """Whether ``session``'s mirrored pending clarify is ``request_id``. Caller holds - history_lock.""" - pending = session.get("_compute_host_pending_clarify") - return isinstance(pending, dict) and pending.get("request_id") == request_id +def _open_request_matches(session: dict, request_id) -> bool: + """Whether ``session``'s mirrored open request is ``request_id``. Caller holds history_lock.""" + mirrored = session.get("_compute_host_open_request") + return isinstance(mirrored, dict) and mirrored.get("id") == request_id -def _compute_host_clarify_session(request_id: str) -> tuple[str, dict] | None: - """Find the parent mirror for one host-owned clarify request.""" +def _compute_host_request_session(request_id: str) -> tuple[str, dict] | None: + """Find the parent mirror for one host-owned server request.""" for sid, session in list(_sessions.items()) if request_id else (): with _history_lock(session): - if _pending_clarify_matches(session, request_id): + if _open_request_matches(session, request_id): return sid, session return None -def _update_compute_host_clarify_snapshot(sid: str, session: dict, params: dict, result: dict) -> None: - """Keep reconnect snapshots accurate while a batch clarify is answered.""" - request_id = str(params.get("request_id") or "") - question_id = str(params.get("question_id") or "") +def _relay_compute_host_response(frame: dict) -> bool: + """Forward a client's response frame to the compute-host child that owns the request. False when no + child owns that id.""" + located = _compute_host_request_session(str(frame.get("id") or "")) + if located is None or not _session_uses_compute_host(located[1]): + return False + sid, session = located with _history_lock(session): - if not _pending_clarify_matches(session, request_id): - return - pending = session["_compute_host_pending_clarify"] - if result.get("status") == "expired" or not result.get("remaining") and not question_id: - session.pop("_compute_host_pending_clarify", None) - elif question_id and isinstance(result.get("remaining"), list): - pending["answers"] = {**(pending.get("answers") or {}), - question_id: str(params.get("answer") or "")} - if not result["remaining"]: - session.pop("_compute_host_pending_clarify", None) + session.pop("_compute_host_open_request", None) + try: + _get_compute_host_supervisor().respond(sid, {"frame": dict(frame)}) + except Exception: + logger.debug("compute-host response relay failed sid=%s", sid, exc_info=True) + return True -def _respond_compute_host_clarify(rid: str, params: dict) -> dict | None: - """Proxy a clarify answer into the process that owns its pending Event.""" - located = _compute_host_clarify_session(str(params.get("request_id") or "")) +def _lock_compute_host_clarify(rid: str, request_id: str, question_id: str, answer: str) -> dict | None: + """Proxy a batch-clarify lock into the child that owns the request; keeps the parent mirror's locked + answers current for reconnect snapshots. None when the request is not host-owned.""" + located = _compute_host_request_session(request_id) if located is None or not _session_uses_compute_host(located[1]): return None sid, session = located try: - ack = _get_compute_host_supervisor().respond(sid, params) + ack = _get_compute_host_supervisor().respond( + sid, {"lock": {"request_id": request_id, "question_id": question_id, "answer": answer}}) except Exception as exc: - return _err(rid, 5019, f"compute-host clarify response failed: {exc}") + return _err(rid, 5019, f"compute-host clarify lock failed: {exc}") if ack.get("type") == "respond.error": - return _err(rid, 5019, str(ack.get("message") or "compute-host clarify response failed")) + return _err(rid, 5019, str(ack.get("message") or "compute-host clarify lock failed")) response = ack.get("response") if not isinstance(response, dict): - return _err(rid, 5019, "compute-host clarify response returned an invalid response") + return _err(rid, 5019, "compute-host clarify lock returned an invalid response") if "error" in response: error = response["error"] if isinstance(response["error"], dict) else {} - return _err(rid, int(error.get("code") or 5000), - str(error.get("message") or "clarify response failed")) - result = response.get("result") - if not isinstance(result, dict): - return _err(rid, 5019, "compute-host clarify response returned an invalid result") - _update_compute_host_clarify_snapshot(sid, session, params, result) + return _err(rid, int(error.get("code") or 5000), str(error.get("message") or "clarify lock failed")) + result = response.get("result") if isinstance(response.get("result"), dict) else {} + with _history_lock(session): + if _open_request_matches(session, request_id): + mirrored = session["_compute_host_open_request"] + if result.get("status") == "expired" or result.get("remaining") == []: + session.pop("_compute_host_open_request", None) + else: + mirrored["params"]["answers"] = {**(mirrored["params"].get("answers") or {}), question_id: answer} return _ok(rid, result) @@ -200,7 +207,7 @@ def _on_compute_host_turn_done(rid: str, sid: str, session: dict, frame: dict) - session["running"] = False session["last_active"] = time.time() _clear_inflight_turn(session) - session.pop("_compute_host_pending_clarify", None) + session.pop("_compute_host_open_request", None) if frame.get("type") == "turn.error": message = str(frame.get("message") or "compute host turn failed") _emit("message.complete", sid, {"text": f"Error: {message}", "status": "error"}) diff --git a/tui_gateway/hosted_room_member_activity.py b/tui_gateway/hosted_room_member_activity.py index 51fe2e0a64..2de8f64af0 100644 --- a/tui_gateway/hosted_room_member_activity.py +++ b/tui_gateway/hosted_room_member_activity.py @@ -23,7 +23,6 @@ KIND_BY_FRAME_TYPE: Mapping[str, str] = { "tool.start": "tool.started", "tool.complete": "tool.completed", "tool.output_risk": "tool.output_risk", - "approval.request": "request.opened", "message.delta": "message.delta", "message.interim": "message.interim", "reasoning.delta": "reasoning.delta", @@ -42,20 +41,29 @@ def emit_room_member_activity(hosted_task: Mapping[str, Any], *, kind: str, payl return enqueue_plugin_stream_hook(HOOK_NAME, **coordinates, kind=kind, seq=seq, payload=dict(payload or {})) +# Server→client request frames (``{"id": "srq-…", "method": …}``) that are member activity. +KIND_BY_REQUEST_METHOD: Mapping[str, str] = {"approval": "request.opened"} + + def project_room_member_activity(frame: Mapping[str, Any], sessions: Mapping[str, Mapping[str, Any]]) -> bool: - """Fire the hook for an outgoing session event frame when its session is running a room turn.""" - if frame.get("method") != "event": - return False + """Fire the hook for an outgoing session frame (event notification or server→client request) when its + session is running a room turn.""" params = frame.get("params") if not isinstance(params, Mapping): return False - kind = KIND_BY_FRAME_TYPE.get(str(params.get("type") or "")) + if frame.get("method") == "event": + kind = KIND_BY_FRAME_TYPE.get(str(params.get("type") or "")) + payload = params.get("payload") + elif "id" in frame: + kind = KIND_BY_REQUEST_METHOD.get(str(frame.get("method") or "")) + payload = {k: v for k, v in params.items() if k != "session_id"} + else: + return False if kind is None: return False session = sessions.get(str(params.get("session_id") or "")) hosted_task = session.get("_hosted_room_task") if isinstance(session, Mapping) else None if not isinstance(hosted_task, Mapping): return False - payload = params.get("payload") return emit_room_member_activity( hosted_task, kind=kind, payload=payload if isinstance(payload, Mapping) else None, seq=params.get("seq")) diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index e02a8064f3..4503c7b71c 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -1093,26 +1093,47 @@ def _(rid, params: dict) -> dict: cwd=preview_cwd, cleanup=cleanup) -# ── late-answer RPCs for tool-driven UI cards ─────────────────────────────── -# allow_expired=True everywhere: a tool's bounded wait can expire (its _pending entry -# popped) while the card is still visible; a late answer must not surface the raw 4009. +# ── batch clarify locks ───────────────────────────────────────────────────── +# A batch ``clarify`` server request is answered one question at a time: each lock is a normal RPC +# (update-in-place, editable until every qid is locked); the LAST lock resolves the request itself. +# A cancel-all is the plain response frame with no ``answers``. -@method("clarify.respond") +@method("clarify.lock") def _(rid, params: dict) -> dict: - if proxied := _respond_compute_host_clarify(rid, params): + request_id = str(params.get("request_id") or "") + question_id = str(params.get("question_id") or "") + if not request_id or not question_id: + return _err(rid, 4002, "request_id and question_id required") + answer = params.get("answer", "") + answer = answer if isinstance(answer, str) else json.dumps(answer, ensure_ascii=False) + if (proxied := _lock_compute_host_clarify(rid, request_id, question_id, answer)) is not None: return proxied - return _respond(rid, params, "answer", allow_expired=True) + from tui_gateway import server_requests + try: + remaining = server_requests.lock_answer(request_id, question_id, answer) + except ValueError as e: + return _err(rid, 4002, str(e)) + if remaining is None: + # The wait already ended (timeout / cancel) while the card was still visible: not an error. + return _ok(rid, {"status": "expired"}) + return _ok(rid, {"status": "ok", "remaining": remaining}) -_LATE_RESPOND_KEYS = { - "terminal.read.respond": "text", "preview.read.respond": "text", "preview.act.respond": "text", - "window.read.respond": "text", "tour.respond": "text", "mcp.setup.respond": "result", - "sudo.respond": "password", "secret.respond": "value", "vault.unlock.respond": "password", - "vault.save_login.respond": "login", "vault.code.respond": "code"} -for _name, _key in _LATE_RESPOND_KEYS.items(): - method(_name)(lambda rid, params, _k=_key: _respond(rid, params, _k, allow_expired=True)) -del _name, _key +@method("request.answer") +def _(rid, params: dict) -> dict: + """Answer an open server→client request from a client that did not receive it (a Bot Mode room + window answering a member's prompt mirrored from its resume snapshot). The response-frame path is + the norm; this is the proxy for it. ``expired`` when the request already ended.""" + request_id = str(params.get("id") or "") + result = params.get("result") + if not request_id or not isinstance(result, dict): + return _err(rid, 4002, "id and an object result required") + from tui_gateway import server_requests + frame = {"jsonrpc": "2.0", "id": request_id, "result": result} + if server_requests.resolve_response(frame) or _relay_compute_host_response(frame): + return _ok(rid, {"status": "ok"}) + return _ok(rid, {"status": "expired"}) # ── approvals ─────────────────────────────────────────────────────────────── diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index b307f0e814..d111623a89 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -2195,8 +2195,10 @@ def _(rid, params: dict) -> dict: from tui_gateway import event_replay as er frames = er.events_since(sid, last_seen) # ``epoch``: in-process seq — clients reset watermarks when this differs from gateway.ready's. + # ``open_requests``: server→client requests still unanswered — the ring cannot carry "a question still + # waiting", so the reconnecting client re-delivers these to its request handlers. return _ok(rid, {"events": frames, "latest_seq": er.latest_seq(sid), "truncated": er.is_truncated(sid, last_seen), - "count": len(frames), "epoch": er.replay_epoch()}) + "count": len(frames), "epoch": er.replay_epoch(), "open_requests": _open_requests(sid)}) @method("session.events.stats") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b4cf0bc4a8..25e1e8e239 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -83,13 +83,6 @@ from tui_gateway.render import make_stream_renderer, render_diff, render_message _sessions: dict[str, dict] = {} _methods: dict[str, callable] = {} -_pending: dict[str, tuple[str, threading.Event]] = {} -_pending_prompt_payloads: dict[str, tuple[str, dict]] = {} -_answers: dict[str, str] = {} -# Batch clarify accumulators: rid → {"qids": [...], "answers": {qid: answer}}. Written by -# clarify.respond (per-question lock, update-in-place), read out by _block on resolution/timeout -# so locked answers survive the deadline. -_batch_clarify: dict[str, dict] = {} _db = None _db_error: str | None = None _stdout_lock = threading.Lock() @@ -98,7 +91,6 @@ _cfg_lock = threading.Lock() # compare/check/write transaction needs its own lock, not the unrelated config cache lock. _profile_ui_meta_lock = threading.Lock() _sessions_lock = threading.RLock() # reentrant: _close_session_by_id may run under callers that already hold it -_prompt_lock = threading.Lock() _cfg_cache: dict | None = None _cfg_mtime: float | None = None _cfg_path = None @@ -609,10 +601,12 @@ def write_json(obj: dict) -> bool: from tui_gateway.event_replay import _stamp_event from tui_gateway.hosted_room_member_activity import project_room_member_activity _stamp_event(obj) - if obj.get("method") == "event": - # A room member's hidden session has no transport: its frames would die at stdio below. + params = obj.get("params") + if obj.get("method") == "event" or (isinstance(obj.get("id"), str) and "method" in obj): + # Event notifications AND server→client requests carry ``params.session_id``; both route to the + # owning session's transport. A room member's hidden session has no transport: its frames would + # die at stdio below. project_room_member_activity(obj, _sessions) - params = obj.get("params") sid = ((params or {}).get("session_id")) if isinstance(params, dict) else "" if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None: return t.write(obj) @@ -678,25 +672,20 @@ def _approval_request_payload(data: dict | None) -> dict: return payload -def _pending_clarify_request_payload(sid: str) -> dict | None: - """Read-only snapshot of the clarify prompt still blocking a session: a client detached when - `clarify.request` was emitted would otherwise never see it (agent parked until timeout). Same replay - contract as `pending_approval`: the registry stays authoritative; `clarify.respond` resolves by request_id.""" - with _prompt_lock: - for rid, (owner_sid, _ev) in _pending.items(): - event, prompt_payload = _pending_prompt_payloads.get(rid, ("", {})) - if owner_sid != sid or event != "clarify.request": - continue - snapshot = dict(prompt_payload) - # Batch clarify: replay the answers locked so far so a reconnecting client restores its ✓ state. - if (batch := _batch_clarify.get(rid)) is not None and batch["answers"]: - snapshot["answers"] = dict(batch["answers"]) - return snapshot +def _open_requests(sid: str) -> list[dict]: + """Server→client requests still waiting on *sid*'s renderer, for reconnect snapshots (``session.resume`` / + ``session.activate`` / ``session.events.since``). A client detached when the request frame was written would + otherwise never see it (agent parked until timeout). Under turn isolation the compute-host child owns the + request; the parent mirrors it from the relayed frame (compute_host_bridge).""" + from tui_gateway import server_requests + reqs = server_requests.open_requests(sid) + if reqs: + return reqs if (session := _sessions.get(sid)) is not None: with session.get("history_lock", threading.Lock()): - pending = session.get("_compute_host_pending_clarify") - return dict(pending) if isinstance(pending, dict) else None - return None + mirrored = session.get("_compute_host_open_request") + return [dict(mirrored)] if isinstance(mirrored, dict) else [] + return [] def _pending_approval_request_payload(session_key: str) -> dict | None: @@ -711,12 +700,29 @@ def _pending_approval_request_payload(session_key: str) -> dict | None: def _emit_approval_request(sid: str, data: dict | None) -> None: - """Emit ``approval.request`` with the command redacted: a credential-shaped value Tirith flagged would - otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream). + """Send an ``approval`` server request with the command redacted: a credential-shaped value Tirith flagged + would otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream). + See #48456, #50767. - Reuse the shared gateway See #48456, #50767. - """ - _emit("approval.request", sid, _approval_request_payload(data)) + The wait is owned by ``tools.approval``'s queue (its own timeout, ``/approve all``, coalescing), so the request + is queue-backed: the response resolves the queue entry, and the entry's own resolution (any surface, timeout, + interrupt) withdraws the request with ``request.cancel``.""" + from tui_gateway import server_requests + from tools import approval as _approval + payload = _approval_request_payload(data) + request_id = str(payload.get("request_id") or "") + session_key = str((_sessions.get(sid) or {}).get("session_key") or "") + + def on_result(result: dict | None) -> None: + if result is None: # withdrawn: the queue entry resolves on its own path + return + choice = str(result.get("choice") or "deny") + _approval.resolve_gateway_approval(session_key, choice, resolve_all=bool(result.get("all")), + request_id=request_id or None) + + settle = server_requests.send_async("approval", sid, payload, on_result) + if request_id: + _approval.register_gateway_settle(session_key, request_id, settle) def _status_update(sid: str, kind: str, text: str | None = None): @@ -818,6 +824,12 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None: t = transport or _stdio_transport token = bind_transport(t) try: + from tui_gateway import server_requests + if server_requests.is_response_frame(req): + # The renderer answering one of OUR requests (clarify, approval, …): no response frame goes back. + if not server_requests.resolve_response(req) and not _relay_compute_host_response(req): + logger.debug("dropping response for unknown server request id=%r", req.get("id")) + return None normalized = _normalize_request(req) if isinstance(normalized, dict): return normalized @@ -1266,55 +1278,13 @@ def _enable_gateway_prompts() -> None: # ── Blocking prompt factory ────────────────────────────────────────── -# Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the tool -# returns empty, but a slow renderer could still answer and hit a raw 4009 — `.expire` tears the card down. -_EXPIRING_REQUESTS = frozenset({ - "secret.request", "sudo.request", "vault.unlock.request", "vault.save_login.request", "vault.code.request", "clarify.request", - "terminal.read.request", - "preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request", - "tour.request", -}) - - -def _block(event: str, sid: str, payload: dict, timeout: float | None = 300, batch_qids: list[str] | None = None) -> str: - rid = uuid.uuid4().hex[:8] - ev = threading.Event() - with _prompt_lock: - _pending[rid] = (sid, ev) - payload["request_id"] = rid - _pending_prompt_payloads[rid] = (event, dict(payload)) - if batch_qids: - # Multi-question clarify: per-question answers accumulate here (update-in-place until every - # qid is locked); locked answers survive a timeout — see the batch read-out below. - _batch_clarify[rid] = {"qids": list(batch_qids), "answers": {}} - answered, batch_answers = False, None - try: - _emit(event, sid, payload) - # Event semantics: None → wait forever (clarify_timeout <= 0; released only by a real answer or - # session.interrupt), 0 → return immediately, > 0 → bounded wait. - answered = ev.wait(timeout) - finally: - with _prompt_lock: - _pending.pop(rid, None) - _pending_prompt_payloads.pop(rid, None) - answer_present = rid in _answers - answer = _answers.pop(rid, "") - if (batch_state := _batch_clarify.pop(rid, None)) is not None: - batch_answers = dict(batch_state["answers"]) - expire = lambda: _emit(f"{event.removesuffix('.request')}.expire", sid, {"request_id": rid}) - if batch_qids is not None: - # Cancel-all (respond with no question_id) resolves via _answers with "" — a plain cancel, not a partial result. - if answer_present: - return answer - result: dict[str, object] = {"answers": batch_answers or {}} - if not answered: - # Deadline hit: keep what was locked, report the rest as absences (not skips), still expire live cards. - result["timed_out"] = True - expire() - return json.dumps(result, ensure_ascii=False) - if not answered and not answer_present and event in _EXPIRING_REQUESTS: - expire() - return answer +def _ask(method: str, sid: str, params: dict, timeout: float | None = 300) -> str: + """Server→client request whose answer is one string under ``value`` (sudo, secret, vault prompts, GUI reads, + MCP setup). Empty string when the renderer skipped, timed out, or was cancelled.""" + from tui_gateway import server_requests + result = server_requests.send(method, sid, params, timeout=timeout) + value = (result or {}).get("value", "") + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) def _clarify_timeout_seconds() -> float | None: @@ -1328,16 +1298,23 @@ def _clarify_timeout_seconds() -> float | None: def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str: - """Bridge the clarify tool callback onto _block. Single-question payloads keep their historical shape - (``multi_select`` only when True — older renderers never see a new field); batch calls emit one - clarify.request with only the wire fields (the tool-side entries carry result-assembly keys too).""" + """Bridge the clarify tool callback onto a ``clarify`` server request. Single question: the response is + ``{"answer"}`` ("" = skip). Batch: one request with only the wire fields (tool-side entries carry + result-assembly keys too); answers lock one at a time through ``clarify.lock`` and the tool gets + ``{"answers", "timed_out"?}`` as JSON — a response with no ``answers`` is a cancel-all.""" + from tui_gateway import server_requests if questions: wire = [{"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])} for e in questions] - return _block("clarify.request", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(), - batch_qids=[e["qid"] for e in questions]) - payload = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c} - return _block("clarify.request", sid, payload, timeout=_clarify_timeout_seconds()) + result = server_requests.send("clarify", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(), + qids=[e["qid"] for e in questions]) + if not result or "answers" not in result: + return "" + return json.dumps(result, ensure_ascii=False) + params = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c} + result = server_requests.send("clarify", sid, params, timeout=_clarify_timeout_seconds()) + answer = (result or {}).get("answer", "") + return answer if isinstance(answer, str) else "" # A tour action is a DOM op the renderer answers in ms; the generous deadline exists only because a @@ -1355,12 +1332,12 @@ _TOUR_BRIDGE_UNAVAILABLE = json.dumps({ def _tour_request(sid: str, payload: dict) -> str: - """Bridge the tour tool callback onto _block without paying for a client that cannot answer: against - an older app nobody calls ``tour.respond`` and each action would block the full deadline, stacking per + """Bridge the tour tool callback onto a ``tour`` server request without paying for a client that cannot answer: against + an older app nobody answers ``tour`` and each action would block the full deadline, stacking per turn. First action per session gets the short probe deadline; unanswered → bridge marked unavailable for that session; once answered, the full deadline. Verdict lives on the record, so a new session re-probes. - The renderer's ``tour.request`` handler ships in the desktop bundle, but the tool is offered by this + The renderer's ``tour`` handler ships in the desktop bundle, but the tool is offered by this backend — and the two update on different clocks. The model then does what the schema tells it to and tries the next action, so a single "give me a tour" turn stacks those waits (the timeouts reported against #89620). @@ -1371,8 +1348,8 @@ def _tour_request(sid: str, payload: dict) -> str: state = session.get("tour_bridge") if state == "unanswered": return _TOUR_BRIDGE_UNAVAILABLE - answer = _block("tour.request", sid, dict(payload), - timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S) + answer = _ask("tour", sid, dict(payload), + timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S) if answer: session["tour_bridge"] = "answered" elif state != "answered": @@ -1381,13 +1358,10 @@ def _tour_request(sid: str, payload: dict) -> str: def _clear_pending(sid: str | None = None) -> None: - """Release pending prompts with an empty answer: only *sid*'s (session.interrupt must not cancel other - sessions' prompts), or every one when *sid* is None (shutdown).""" - with _prompt_lock: - for rid, (owner_sid, ev) in list(_pending.items()): - if sid is None or owner_sid == sid: - _answers[rid] = "" - ev.set() + """Withdraw open server→client requests: only *sid*'s (session.interrupt must not cancel other sessions' + prompts), or every one when *sid* is None (process exit). Each one gets a ``request.cancel``.""" + from tui_gateway import server_requests + server_requests.cancel(sid, reason="interrupted" if sid else "shutdown") # ── Agent factory ──────────────────────────────────────────────────── @@ -2633,8 +2607,9 @@ def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = def _session_pending_kind(sid: str) -> str: - return next((str(_pending_prompt_payloads.get(rid, ("input.request", {}))[0]).removesuffix(".request") - for rid, (owner_sid, _ev) in list(_pending.items()) if owner_sid == sid), "") + """Method of the server→client request *sid* is blocked on ("" when none).""" + from tui_gateway import server_requests + return server_requests.pending_kind(sid) def _session_live_status(sid: str, session: dict) -> str: @@ -2784,7 +2759,7 @@ def _live_session_payload( } for key, value in (("inflight", inflight), ("queued", queued), ("pending_approval", _pending_approval_request_payload(str(session.get("session_key") or ""))), - ("pending_clarify", _pending_clarify_request_payload(sid))): + ("open_requests", _open_requests(sid))): if value: payload[key] = value return _attach_todo_state(payload, session) @@ -3086,31 +3061,6 @@ def _start_usage_ticker(sid: str, agent, interval: float = 1.0) -> tuple[threadi return stop, thread -# ── Methods: respond ───────────────────────────────────────────────── - - -def _respond(rid, params, key, *, allow_expired=False): - r = params.get("request_id", "") - question_id = str(params.get("question_id") or "") - with _prompt_lock: - entry = _pending.get(r) - if not entry: - return _ok(rid, {"status": "expired"}) if allow_expired and r else _err(rid, 4009, f"no pending {key} request") - _, ev = entry - batch = _batch_clarify.get(r) - if batch is not None and question_id: - # Per-question lock; update-in-place so an answer stays editable until every qid is locked (Confirm). - if question_id not in batch["qids"]: - return _err(rid, 4002, f"unknown question_id {question_id!r}") - batch["answers"][question_id] = params.get(key, "") - if not (remaining := [qid for qid in batch["qids"] if qid not in batch["answers"]]): - ev.set() - return _ok(rid, {"status": "ok", "remaining": remaining}) - _answers[r] = params.get(key, "") - ev.set() - return _ok(rid, {"status": "ok"}) - - # ── Methods: tools & system ────────────────────────────────────────── diff --git a/tui_gateway/server_requests.py b/tui_gateway/server_requests.py new file mode 100644 index 0000000000..b98848ed99 --- /dev/null +++ b/tui_gateway/server_requests.py @@ -0,0 +1,211 @@ +"""Server→client JSON-RPC requests: the backend asks the renderer a question and waits for the +response frame carrying the same ``id``. + +JSON-RPC is peer-to-peer; this is the backend's half. Every "ask the renderer" bridge (clarify, +approval, sudo, secret, vault prompts, desktop GUI reads, MCP setup consent, the tour) is one +:func:`send` (blocking) or :func:`send_async` (queue-backed approvals) and one response frame from +the client — no paired ``*.request`` notification / ``*.respond`` method, no per-kind ``*.expire``. + +Ids are ``srq-<12 hex>``: strings never collide with client-minted integer ids, and the random +part keeps a compute-host child's requests distinct from the parent's when both reach one socket. +A request that times out or is cancelled (interrupt, session close, shutdown) emits ONE +``request.cancel {id, method, reason}`` notification so every renderer tears the card down the +same way. A response for an id that is no longer open is dropped — the wait already returned. + +Reconnect: unanswered requests are returned as ``open_requests`` by ``session.resume`` / +``session.activate`` / ``session.events.since`` (:func:`open_requests`); the shared TypeScript +channel re-delivers them as if they had just arrived, so the notification replay ring never +has to carry "a question still waiting for an answer". + +Batch clarify keeps per-question locks (``clarify.lock`` → :func:`lock_answer`): answers stay +editable until every question is locked, locked answers survive a timeout, and the last lock +resolves the request with the full answer set. +""" + +from __future__ import annotations + +import logging +import threading +import time +import uuid +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +class ServerRequest: + __slots__ = ("id", "sid", "method", "params", "event", "result", "answered", "created_at", + "qids", "locked", "on_result") + + def __init__(self, sid: str, method: str, params: dict, *, qids: list[str] | None = None, + on_result: Callable[[dict | None], None] | None = None) -> None: + self.id = f"srq-{uuid.uuid4().hex[:12]}" + self.sid = sid + self.method = method + self.params = dict(params) + self.event = threading.Event() + self.result: dict | None = None + self.answered = False + self.created_at = time.time() + # Batch clarify: question ids still to lock, and the answers locked so far. + self.qids = list(qids) if qids else None + self.locked: dict[str, str] = {} + self.on_result = on_result + + def frame(self) -> dict: + return {"jsonrpc": "2.0", "id": self.id, "method": self.method, + "params": {"session_id": self.sid, **self.params}} + + def snapshot(self) -> dict: + """``open_requests`` entry: the request as sent, plus the batch answers locked so far so a + reconnecting client restores its ✓ state.""" + params = {"session_id": self.sid, **self.params} + if self.locked: + params["answers"] = dict(self.locked) + return {"id": self.id, "method": self.method, "params": params} + + +_lock = threading.Lock() +_open: dict[str, ServerRequest] = {} + + +def _write(frame: dict) -> None: + from tui_gateway.server import write_json + write_json(frame) + + +def _emit_cancel(req: ServerRequest, reason: str) -> None: + from tui_gateway.server import _emit + _emit("request.cancel", req.sid, {"id": req.id, "method": req.method, "reason": reason}) + + +def _register(req: ServerRequest) -> None: + with _lock: + _open[req.id] = req + _write(req.frame()) + + +def send(method: str, sid: str, params: dict, *, timeout: float | None, + qids: list[str] | None = None) -> dict | None: + """Send one request and block for the response ``result`` (a dict). + + Returns ``None`` when the renderer never answered (timeout, cancel, or an error response — e.g. + a client without a handler for ``method``). ``timeout`` semantics: None → wait until answered or + cancelled, 0 → return immediately, > 0 → bounded wait. A batch (``qids``) that times out + returns ``{"answers": , "timed_out": True}`` instead of None. + """ + req = ServerRequest(sid, method, params, qids=qids) + _register(req) + timed_out = False + try: + timed_out = not req.event.wait(timeout) + finally: + with _lock: + _open.pop(req.id, None) + if timed_out: + _emit_cancel(req, "timeout") + if req.qids is not None: + return {"answers": dict(req.locked), "timed_out": True} + return None + return req.result if req.answered else None + + +def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict | None], None]) -> Callable[[str], None]: + """Send one request whose wait is owned elsewhere (the approval queue's own timeout). ``on_result`` + runs on the dispatching thread when the response lands. Returns ``settle(reason)``: call it when + the underlying wait ends; if the request is still open it is withdrawn with ``request.cancel``.""" + req = ServerRequest(sid, method, params, on_result=on_result) + _register(req) + + def settle(reason: str) -> None: + with _lock: + still_open = _open.pop(req.id, None) is not None + if still_open: + _emit_cancel(req, reason) + + return settle + + +def resolve_response(frame: dict) -> bool: + """Route one client response frame to its open request. False when nothing is waiting for that id + (already timed out / cancelled, or owned by another process — see the compute-host bridge).""" + rid = frame.get("id") + if not isinstance(rid, str): + return False + with _lock: + req = _open.get(rid) + if req is None: + return False + if req.on_result is not None: + _open.pop(rid, None) + if "error" in frame: + logger.debug("server request %s (%s) answered with error: %s", rid, req.method, frame.get("error")) + req.result, req.answered = None, False + else: + result = frame.get("result") + req.result = result if isinstance(result, dict) else {} + req.answered = True + if req.on_result is not None: + req.on_result(req.result) + req.event.set() + return True + + +def lock_answer(request_id: str, question_id: str, answer: str) -> list[str] | None: + """Lock one batch-clarify answer (update-in-place). Returns the question ids still unanswered; + the last lock resolves the request with the full ``{"answers"}`` set. ``None`` when no open + batch has that id (expired or foreign); ``ValueError`` for an unknown question id.""" + with _lock: + req = _open.get(request_id) + if req is None or req.qids is None: + return None + if question_id not in req.qids: + raise ValueError(f"unknown question_id {question_id!r}") + req.locked[question_id] = answer + remaining = [qid for qid in req.qids if qid not in req.locked] + if not remaining: + req.result, req.answered = {"answers": dict(req.locked)}, True + if not remaining: + req.event.set() + return remaining + + +def cancel(sid: str | None = None, reason: str = "interrupted") -> int: + """Withdraw open requests — only *sid*'s (session.interrupt must not touch other sessions'), or + every one when *sid* is None (shutdown). Blocked waits return None; queue-backed requests run + ``on_result(None)`` so their owner can settle. Returns the number withdrawn.""" + with _lock: + targets = [req for req in _open.values() if sid is None or req.sid == sid] + for req in targets: + _open.pop(req.id, None) + for req in targets: + req.result, req.answered = None, False + if req.on_result is not None: + req.on_result(None) + req.event.set() + _emit_cancel(req, reason) + return len(targets) + + +def open_requests(sid: str) -> list[dict]: + """Unanswered requests for *sid*, oldest first.""" + with _lock: + reqs = sorted((req for req in _open.values() if req.sid == sid), key=lambda r: r.created_at) + return [req.snapshot() for req in reqs] + + +def pending_kind(sid: str) -> str: + """Method of the oldest open request for *sid* ("" when none) — the session is waiting on a human.""" + with _lock: + reqs = [req for req in _open.values() if req.sid == sid] + return min(reqs, key=lambda r: r.created_at).method if reqs else "" + + +def is_response_frame(obj: Any) -> bool: + """A client response: has an ``id`` and a ``result``/``error`` member but no ``method``.""" + return isinstance(obj, dict) and "method" not in obj and "id" in obj and ("result" in obj or "error" in obj) + + +def reset_for_tests() -> None: + with _lock: + _open.clear() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 4f5a821202..5bae7abbb7 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -30,6 +30,7 @@ import type { Msg, SessionInfo, SubagentProgress } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext, NoticeLevel } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' +import { forgetServerRequest } from './serverRequestStore.js' import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' @@ -449,8 +450,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // paths can't both persist the same prompt twice. const persistedAbandonedClarify = new Set() - // When a clarify prompt is dismissed without an answer (the backend _block - // timed out and returned an empty string), the live ClarifyPrompt overlay is + // When a clarify prompt is dismissed without an answer (the backend request + // timed out and returned no answer), the live ClarifyPrompt overlay is // left set until the next turn's idle() silently nulls it — so the question // and options vanish from the screen while the agent's follow-up still refers // to them. The reliable signal is the clarify tool's own tool.complete (and, @@ -1257,124 +1258,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - case 'clarify.request': { - if (!ev.payload) { + case 'request.cancel': { + // The backend withdrew a server→client request (timeout / interrupt / + // session close): tear down whichever card carries that id. A clarify + // that timed out is persisted as an abandoned prompt by tool.complete. + const id = ev.payload?.id + + if (!id) { return } - const batch = (ev.payload.questions ?? []) - .filter(q => typeof q?.qid === 'string' && q.qid && typeof q?.question === 'string' && q.question.trim()) - .map(q => ({ - choices: q.choices && q.choices.length > 0 ? q.choices : null, - multiSelect: q.multi_select === true, - qid: q.qid, - question: q.question.trim() - })) + forgetServerRequest(id) + patchOverlayState(prev => { + const next = { ...prev } + let changed = false - patchOverlayState({ - clarify: batch.length - ? { - answers: ev.payload.answers ?? {}, - choices: null, - question: '', - questions: batch, - requestId: ev.payload.request_id - } - : { - choices: ev.payload.choices ?? null, - question: ev.payload.question ?? '', - requestId: ev.payload.request_id - } - }) - setStatus('waiting for input…') - ringPromptBell() - - return - } - - case 'approval.request': { - if (!ev.payload) { - return - } - - const description = String(ev.payload.description ?? 'dangerous command') - // Only an explicit false (tirith warning) drops the permanent-allow option. - const allowPermanent = ev.payload.allow_permanent !== false - - patchOverlayState({ - approval: { - allowPermanent, - choices: ev.payload.choices, - command: String(ev.payload.command ?? ''), - description, - smartDenied: ev.payload.smart_denied === true + for (const key of ['approval', 'clarify', 'secret', 'sudo', 'vaultUnlock'] as const) { + if (prev[key]?.requestId === id) { + next[key] = null + changed = true + } } + + return changed ? next : prev }) - setStatus('approval needed') - ringPromptBell() - - return - } - - case 'sudo.request': - if (!ev.payload) { - return - } - - patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) - setStatus('sudo password needed') - ringPromptBell() - - return - - case 'secret.request': - if (!ev.payload) { - return - } - - patchOverlayState({ - secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id } - }) - setStatus('secret input needed') - ringPromptBell() - - return - case 'sudo.expire': { - const expired = ev.payload?.request_id - - patchOverlayState(prev => (prev.sudo?.requestId === expired ? { ...prev, sudo: null } : prev)) - - return - } - - case 'secret.expire': { - const expired = ev.payload?.request_id - - patchOverlayState(prev => (prev.secret?.requestId === expired ? { ...prev, secret: null } : prev)) - - return - } - - case 'vault.unlock.request': - if (!ev.payload) { - return - } - - patchOverlayState({ - vaultUnlock: { - backend: ev.payload.backend, - displayName: ev.payload.display_name, - requestId: ev.payload.request_id - } - }) - setStatus(`unlock ${ev.payload.display_name}`) - ringPromptBell() - - return - case 'vault.unlock.expire': { - const expired = ev.payload?.request_id - - patchOverlayState(prev => (prev.vaultUnlock?.requestId === expired ? { ...prev, vaultUnlock: null } : prev)) return } diff --git a/ui-tui/src/app/createServerRequestHandler.ts b/ui-tui/src/app/createServerRequestHandler.ts new file mode 100644 index 0000000000..fbed805d31 --- /dev/null +++ b/ui-tui/src/app/createServerRequestHandler.ts @@ -0,0 +1,114 @@ +import type { ServerRequest } from '@hermes/shared/json-rpc-channel' + +import type { ClarifyBatchQuestion } from '../types.js' + +import { patchOverlayState } from './overlayStore.js' +import { rememberServerRequest } from './serverRequestStore.js' + +export interface ServerRequestHandlerContext { + ringPromptBell: () => void + setStatus: (status: string) => void +} + +const str = (v: unknown): string => (typeof v === 'string' ? v : '') + +const strList = (v: unknown): null | string[] => + Array.isArray(v) && v.length > 0 ? v.filter((c): c is string => typeof c === 'string') : null + +/** + * The Ink TUI's answer to the backend's server→client requests + * (`tui_gateway/server_requests.py`). Each method opens its overlay card; + * the card's answer path resolves the request through `serverRequestStore`. + * Methods the terminal cannot answer (desktop GUI bridges: `preview.*`, + * `window.read`, `tour`, `mcp.setup`, `terminal.read`, the vault card + * prompts) return `false` so the channel answers `-32601` and the tool + * fails fast instead of waiting out its deadline. + */ +export function createServerRequestHandler(ctx: ServerRequestHandlerContext): (request: ServerRequest) => boolean { + const { ringPromptBell, setStatus } = ctx + + const open = (request: ServerRequest, status: string) => { + rememberServerRequest(request) + setStatus(status) + + if (!request.replayed) { + ringPromptBell() + } + } + + return request => { + const p = request.params + + switch (request.method) { + case 'clarify': { + const batch: ClarifyBatchQuestion[] = (Array.isArray(p.questions) ? (p.questions as unknown[]) : []) + .map(raw => (raw && typeof raw === 'object' ? (raw as Record) : {})) + .filter(q => str(q.qid) && str(q.question).trim()) + .map(q => ({ + choices: strList(q.choices), + multiSelect: q.multi_select === true, + qid: str(q.qid), + question: str(q.question).trim() + })) + + const answers = + p.answers && typeof p.answers === 'object' + ? Object.fromEntries( + Object.entries(p.answers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + : {} + + patchOverlayState({ + clarify: batch.length + ? { answers, choices: null, question: '', questions: batch, requestId: request.id } + : { choices: strList(p.choices), question: str(p.question), requestId: request.id } + }) + open(request, 'waiting for input…') + + return true + } + + case 'approval': { + patchOverlayState({ + approval: { + // Only an explicit false (tirith warning) drops the permanent-allow option. + allowPermanent: p.allow_permanent !== false, + choices: strList(p.choices) ?? undefined, + command: str(p.command), + description: str(p.description) || 'dangerous command', + requestId: request.id, + smartDenied: p.smart_denied === true + } + }) + open(request, 'approval needed') + + return true + } + + case 'sudo': + patchOverlayState({ sudo: { requestId: request.id } }) + open(request, 'sudo password needed') + + return true + + case 'secret': + patchOverlayState({ secret: { envVar: str(p.env_var), prompt: str(p.prompt), requestId: request.id } }) + open(request, 'secret input needed') + + return true + + case 'vault.unlock_prompt': + patchOverlayState({ + vaultUnlock: { backend: str(p.backend), displayName: str(p.display_name), requestId: request.id } + }) + open(request, `unlock ${str(p.display_name)}`) + + return true + + default: + return false + } + } +} diff --git a/ui-tui/src/app/serverRequestStore.ts b/ui-tui/src/app/serverRequestStore.ts new file mode 100644 index 0000000000..be7c71b1dd --- /dev/null +++ b/ui-tui/src/app/serverRequestStore.ts @@ -0,0 +1,38 @@ +import type { ServerRequest } from '@hermes/shared/json-rpc-channel' + +// Live server→client requests (clarify, approval, sudo, …) keyed by request +// id. Overlay state keeps only the id; answering resolves the stored request +// so a re-delivered (`open_requests`) request with the same id reuses the +// same card. Module-level, like the overlay store: the gateway client and +// the Ink handlers share one instance per process. +const open = new Map() + +export function rememberServerRequest(request: ServerRequest): void { + open.set(request.id, request) +} + +export function forgetServerRequest(id: string): void { + open.delete(id) +} + +/** Answer request `id` and forget it. False when nothing is open under that id (expired / already answered). */ +export function respondToServerRequest(id: string, result: Record): boolean { + const request = open.get(id) + + if (!request) { + return false + } + + open.delete(id) + request.respond(result) + + return true +} + +export function hasOpenServerRequest(id: string): boolean { + return open.has(id) +} + +export function resetServerRequestsForTests(): void { + open.clear() +} diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 27c8694b9d..9b7abb257d 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -5,13 +5,7 @@ import { useEffect, useRef } from 'react' import { DASHBOARD_TUI_MODE } from '../config/env.js' import { DOUBLE_ESC_MS, TYPING_IDLE_MS } from '../config/timing.js' import { applyCompletion } from '../domain/slash.js' -import type { - ApprovalRespondResponse, - ConfigSetResponse, - SecretRespondResponse, - SudoRespondResponse, - VoiceRecordResponse -} from '../gatewayTypes.js' +import type { ConfigSetResponse, VoiceRecordResponse } from '../gatewayTypes.js' import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' @@ -27,6 +21,7 @@ import { type OverlayState } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { respondToServerRequest } from './serverRequestStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' import { getUiState } from './uiStore.js' @@ -153,7 +148,9 @@ export function dismissSensitivePrompt( patchOverlayState({ sudo: null }) sys('sudo cancelled') - return rpc('sudo.respond', { password: '', request_id: requestId }) + respondToServerRequest(requestId, { value: '' }) + + return } if (overlay.secret) { @@ -162,7 +159,9 @@ export function dismissSensitivePrompt( patchOverlayState({ secret: null }) sys('secret entry cancelled') - return rpc('secret.respond', { request_id: requestId, value: '' }) + respondToServerRequest(requestId, { value: '' }) + + return } if (overlay.vaultUnlock) { @@ -171,7 +170,7 @@ export function dismissSensitivePrompt( patchOverlayState({ vaultUnlock: null }) sys(`${overlay.vaultUnlock.displayName} stays locked`) - return rpc('vault.unlock.respond', { password: '', request_id: requestId }) + respondToServerRequest(requestId, { value: '' }) } } @@ -228,9 +227,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (overlay.approval) { - return gateway - .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid }) - .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) + respondToServerRequest(overlay.approval.requestId, { choice: 'deny' }) + patchOverlayState({ approval: null }) + patchTurnState({ outcome: 'denied' }) + + return } if (overlay.sudo || overlay.secret || overlay.vaultUnlock) { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index bb5d22beaa..7b8279844c 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -8,6 +8,7 @@ import { useStdout, useTerminalTitle } from '@hermes/ink' +import { JSON_RPC_METHOD_NOT_FOUND, type ServerRequest } from '@hermes/shared/json-rpc-channel' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -22,7 +23,7 @@ import { type GatewayClient } from '../gatewayClient.js' import type { SubagentListResponse } from '../gatewayTypes.js' import type { AnyGatewayEvent, - ClarifyRespondResponse, + ClarifyLockResponse, ConfigSetResponse, SessionActiveListResponse, SessionCloseResponse, @@ -49,6 +50,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { applyAgentSnapshot } from './agentRoster.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' +import { createServerRequestHandler } from './createServerRequestHandler.js' import { createSlashHandler } from './createSlashHandler.js' import { planGatewayRecovery } from './gatewayRecovery.js' import { getInputSelection } from './inputSelectionStore.js' @@ -56,6 +58,7 @@ import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfa import { $overlayState, patchOverlayState } from './overlayStore.js' import { $goodVibesTick } from './petFlashStore.js' import { scrollWithSelectionBy } from './scroll.js' +import { respondToServerRequest } from './serverRequestStore.js' import { turnController } from './turnController.js' import { patchTurnState, useTurnSelector } from './turnStore.js' import { $uiState, getUiState, patchUiState } from './uiStore.js' @@ -232,6 +235,7 @@ export function useMainApp(gw: GatewayClient) { const colsRef = useRef(cols) const scrollRef = useRef(null) const onEventRef = useRef<(ev: AnyGatewayEvent) => void>(() => {}) + const onServerRequestRef = useRef<(request: ServerRequest) => boolean>(() => false) const sysRef = useRef<(text: string) => void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) const submitLiteralRef = useRef<(value: string) => void>(() => {}) @@ -710,11 +714,14 @@ export function useMainApp(gw: GatewayClient) { turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line)) patchTurnState({ turnTrail: turnController.turnTools }) - rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { - if (!r) { - return - } + if (!respondToServerRequest(clarify.requestId, { answer })) { + // The request already expired (request.cancel raced the keystroke): nothing to answer. + patchOverlayState({ clarify: null }) + return + } + + { if (answer) { turnController.persistedToolLabels.add(label) appendMessage({ @@ -738,14 +745,14 @@ export function useMainApp(gw: GatewayClient) { } patchOverlayState({ clarify: null }) - }) + } }, - [appendMessage, overlay.clarify, rpc] + [appendMessage, overlay.clarify] ) - // Lock one answer of a batch clarify (clarify.respond + question_id). The - // overlay stays up until the server reports no remaining questions — the - // final lock resolves the tool and the turn continues. + // Lock one answer of a batch clarify (`clarify.lock` RPC). The overlay stays + // up until the server reports no remaining questions — the final lock + // resolves the server request and the turn continues. const answerClarifyQuestion = useCallback( (qid: string, answer: string) => { const clarify = overlay.clarify @@ -754,7 +761,7 @@ export function useMainApp(gw: GatewayClient) { return } - rpc('clarify.respond', { + rpc('clarify.lock', { answer, question_id: qid, request_id: clarify.requestId @@ -765,6 +772,12 @@ export function useMainApp(gw: GatewayClient) { const answers = { ...(clarify.answers ?? {}), [qid]: answer } + if (r.status === 'expired') { + patchOverlayState({ clarify: null }) + + return + } + if ((r.remaining ?? []).length > 0) { patchOverlayState({ clarify: { ...clarify, answers } }) @@ -908,8 +921,28 @@ export function useMainApp(gw: GatewayClient) { onEventRef.current = onEvent + const onServerRequest = useMemo( + () => + createServerRequestHandler({ + ringPromptBell: () => { + if (bellOnPrompt && stdout?.isTTY) { + stdout.write('\x07') + } + }, + setStatus: status => patchUiState({ status }) + }), + [bellOnPrompt, stdout] + ) + + onServerRequestRef.current = onServerRequest + useEffect(() => { const handler = (ev: AnyGatewayEvent) => onEventRef.current(ev) + const requestHandler = (request: ServerRequest) => { + if (!onServerRequestRef.current(request)) { + request.fail(JSON_RPC_METHOD_NOT_FOUND, `the terminal UI cannot answer ${request.method}`) + } + } const exitHandler = () => { turnController.reset() @@ -946,12 +979,14 @@ export function useMainApp(gw: GatewayClient) { } gw.on('event', handler) + gw.on('request', requestHandler) gw.on('exit', exitHandler) gw.drain() // entry.tsx's setupGracefulExit handles process cleanup on real exit. return () => { gw.off('event', handler) + gw.off('request', requestHandler) gw.off('exit', exitHandler) } }, [gw, sys]) @@ -1015,19 +1050,26 @@ export function useMainApp(gw: GatewayClient) { slashRef.current = slash - const respondWith = useCallback( - (method: string, params: Record, done: () => void) => rpc(method, params).then(r => r && done()), - [rpc] - ) + // Answer a server→client request by id; the card closes either way (an + // expired request has nothing left to answer). + const respondWith = useCallback((requestId: string, result: Record, done: () => void) => { + respondToServerRequest(requestId, result) + done() + }, []) const answerApproval = useCallback( - (choice: string) => - respondWith('approval.respond', { choice, session_id: ui.sid }, () => { + (choice: string) => { + if (!overlay.approval) { + return + } + + respondWith(overlay.approval.requestId, { choice }, () => { patchOverlayState({ approval: null }) patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` }) patchUiState({ status: 'running…' }) - }), - [respondWith, ui.sid] + }) + }, + [overlay.approval, respondWith] ) const answerSudo = useCallback( @@ -1042,7 +1084,7 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ sudo: null }) } - return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => { + respondWith(requestId, { value: pw }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) @@ -1062,7 +1104,7 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ secret: null }) } - return respondWith('secret.respond', { request_id: requestId, value }, () => { + respondWith(requestId, { value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) @@ -1082,7 +1124,7 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ vaultUnlock: null }) } - return respondWith('vault.unlock.respond', { password, request_id: requestId }, () => { + respondWith(requestId, { value: password }, () => { patchOverlayState({ vaultUnlock: null }) patchUiState({ status: 'running…' }) }) diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 14d9350df8..29f709453b 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -9,6 +9,7 @@ import { DEFAULT_HEARTBEAT_DEADLINE_MS, DEFAULT_HEARTBEAT_INTERVAL_MS, JsonRpcRequestChannel, + type ServerRequest, wireFrameText } from '@hermes/shared/json-rpc-channel' import { reconnectBackoffDelayMs } from '@hermes/shared/reconnect-backoff' @@ -134,10 +135,15 @@ export class GatewayClient extends EventEmitter { private readonly channel = new JsonRpcRequestChannel({ onEvent: ev => this.publish(ev as AnyGatewayEvent), onHeartbeatFailure: () => this.onHeartbeatFailure(), + onUnhandledRequest: req => this.pushLog(`[protocol] unhandled server request: ${req.method}`), requestTimeoutMs: REQUEST_TIMEOUT_MS, unrefTimers: true }) private bufferedEvents = new CircularBuffer(MAX_BUFFERED_EVENTS) + // Server→client requests (clarify, approval, sudo, …) follow the same + // mount-order contract as events: an attached session mid-turn can send one + // the instant the socket opens, before the Ink handler is registered. + private bufferedRequests: ServerRequest[] = [] private pendingExit: number | null | undefined private ready = false private readyTimer: ReturnType | null = null @@ -155,6 +161,13 @@ export class GatewayClient extends EventEmitter { // useInput / createGatewayEventHandler can legitimately attach many // listeners. Default 10-cap triggers spurious warnings. this.setMaxListeners(0) + this.channel.onRequest(request => { + if (this.subscribed) { + this.emit('request', request) + } else { + this.bufferedRequests.push(request) + } + }) } private publish(ev: AnyGatewayEvent) { @@ -280,6 +293,7 @@ export class GatewayClient extends EventEmitter { // its queued microtask becomes a no-op (it captured the old generation). this.drainGeneration += 1 this.bufferedEvents.clear() + this.bufferedRequests = [] this.pendingExit = undefined this.stdoutRl?.close() this.stderrRl?.close() @@ -673,6 +687,10 @@ export class GatewayClient extends EventEmitter { this.emit('event', ev) } + for (const request of this.bufferedRequests.splice(0)) { + this.emit('request', request) + } + if (this.pendingExit !== undefined) { const code = this.pendingExit diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index a353a6e4f1..7d6efbf7ac 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -316,20 +316,10 @@ export interface BackgroundStartResponse { task_id?: string } -export interface ClarifyRespondResponse { - ok?: boolean -} - -export interface ApprovalRespondResponse { - ok?: boolean -} - -export interface SudoRespondResponse { - ok?: boolean -} - -export interface SecretRespondResponse { - ok?: boolean +/** `clarify.lock` — one batch-clarify answer locked; `expired` when the request already ended. */ +export interface ClarifyLockResponse { + remaining?: string[] + status: 'expired' | 'ok' } // ── Shell / clipboard / input ──────────────────────────────────────── diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4e2dfeb286..545f15849c 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -100,6 +100,8 @@ export interface ApprovalReq { choices?: string[] command: string description: string + /** Server→client request id; the answer is the response frame for it. */ + requestId: string smartDenied?: boolean }