feat(tui_gateway): real JSON-RPC server→client requests replace the *.request / *.respond notification pair

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.
This commit is contained in:
teknium1
2026-09-13 23:23:36 -07:00
committed by Teknium
parent 500e133bee
commit ebe8cda8ea
45 changed files with 1894 additions and 1429 deletions
+2 -1
View File
@@ -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
},
@@ -2,6 +2,7 @@ import {
type GatewayEvent,
isGatewayReauthRequired,
isGatewayWebSocketUrl,
JSON_RPC_METHOD_NOT_FOUND,
JsonRpcGatewayError,
reconnectBackoffDelayMs,
resolveGatewayWsUrl
@@ -31,6 +32,7 @@ import {
closeLegacySecondaryGateways,
closeSecondaryGateways,
configureGatewayRegistry,
dispatchPrimaryServerRequest,
disposeSecondariesForConnection,
ensureActiveGatewayOpen,
ensureGatewayForProfile,
@@ -40,6 +42,7 @@ import {
pruneSecondaryGateways,
reconnectSecondaryGateways,
reportPrimaryGatewayState,
type ScopedServerRequest,
setPrimaryGateway,
setPrimaryGatewayConnection,
touchSecondaryGateways
@@ -148,6 +151,8 @@ export function primaryRuntimeConnectionId(connection: Pick<HermesConnection, 'c
interface GatewayBootOptions {
beforeConnectionSwitch: () => void
handleGatewayEvent: (event: GatewayEvent) => void
/** Server→client request from any registry socket; false = no handler (the channel answers -32601). */
handleServerRequest: (request: ScopedServerRequest) => boolean
onConnectionReady: (
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
) => void
@@ -159,6 +164,7 @@ interface GatewayBootOptions {
export function useGatewayBoot({
beforeConnectionSwitch,
handleGatewayEvent,
handleServerRequest,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
@@ -167,6 +173,7 @@ export function useGatewayBoot({
const callbacksRef = useRef({
beforeConnectionSwitch,
handleGatewayEvent,
handleServerRequest,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
@@ -176,6 +183,7 @@ export function useGatewayBoot({
callbacksRef.current = {
beforeConnectionSwitch,
handleGatewayEvent,
handleServerRequest,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
@@ -805,6 +813,11 @@ export function useGatewayBoot({
// (connectionId, profile) keep-set so two sources exposing the same
// profile name (every source has a 'default') can't collide.
configureGatewayRegistry({
onServerRequest: request => {
if (!callbacksRef.current.handleServerRequest(request)) {
request.fail(JSON_RPC_METHOD_NOT_FOUND, `Hermes Desktop cannot answer ${request.method}`)
}
},
// The primary socket has no secondary entry to carry registry identity.
// Electron's published active descriptor is authoritative after boot;
// a true legacy primary has no connectionId and remains unqualified.
@@ -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()
@@ -1,165 +1,17 @@
import { readActivePreview } from '@/app/chat/right-rail/preview-reader'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
import type { PreviewActAction } from '@/lib/preview-act/act-in-page'
import type { TourAction, TourStep } from '@/lib/tour'
import { $gateway } from '@/store/gateway'
import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus'
import { recordAgentReaction } from '@/store/reactions-local'
import { setMessages } from '@/store/session'
import { $tipsEnabled, type ActiveTip, showTip } from '@/store/tips'
import { $toursEnabled } from '@/store/tours'
import type { GatewayEventContext } from './types'
/** The preview engine, loaded on demand so ~25KB of page-injectable source stays
* off the boot path.
*
* In dev that lazy chunk is also a trap. The browser caches a dynamic import by
* URL for the life of the page, so this bridge would hand every action to
* whichever build of the engine loaded first, and no edit to it — or to the
* overlay whose source it stringifies into the page — would reach the guest
* until the whole window reloaded. Asking for a fresh copy is more reliable
* than trusting hot-update propagation to reach a module nothing statically
* imports; the dev server stamps the dependency URLs it has invalidated, so a
* fresh engine pulls a fresh overlay down with it.
*
* The literal path is what a bare specifier can't be here, and it has to track
* this module's real location — hence the fall back to the static import, which
* is also the only branch production keeps, `import.meta.hot` being stripped
* there along with everything it guards. */
const loadPreviewEngine = () => {
const stable = () => import('@/app/chat/right-rail/preview-act')
if (!import.meta.hot) {
return stable().then(mod => mod.actOnActivePreview)
}
return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now())
.catch(stable)
.then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview'])
}
/** Desktop-surface bridge events: read-back requests the agent blocks on
* (terminal/preview/window), agent terminal streaming, pane reveal, and
* message reactions. */
/** Desktop-surface bridge events: agent terminal streaming, tips, pane
* reveal, layouts and message reactions. The read-back REQUESTS the agent
* blocks on (terminal/preview/window/tour) live in `server-requests.ts`. */
export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {
const { event, payload, explicitSid, isActiveEvent } = ctx
if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
// immediately (Python blocks on the respond). Empty text = no live pane.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const start = typeof payload?.start === 'number' ? payload.start : undefined
const count = typeof payload?.count === 'number' ? payload.count : undefined
const result = readActiveTerminal({ start, count })
void $gateway.get()?.request('terminal.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
}
return true
}
if (event.type === 'preview.read.request') {
// read_preview tool: serialize the active preview tab (a Browser
// webview's page text is async) and answer. Empty text = nothing open.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const start = typeof payload?.start === 'number' ? payload.start : undefined
const count = typeof payload?.count === 'number' ? payload.count : undefined
void readActivePreview({ count, start }).then(result => {
void $gateway.get()?.request('preview.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
})
}
return true
}
if (event.type === 'preview.act.request') {
// drive_preview tool: click/type/scroll/press inside the guest page, or
// drive the pane's history. Dynamic import keeps the injected engine off
// the boot path. Active session only: a background turn must never reach
// into the page the user is working in (desktop AGENTS.md: offer, don't
// hijack).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
// Every mounted desktop window can observe the same gateway event. A
// scoped mismatch belongs to another window, so answering here would race
// the owning window and could make this refusal win before its real result.
if (explicitSid && !isActiveEvent) {
return true
}
const answer = (result: unknown) =>
$gateway.get()?.request('preview.act.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
if (isActiveEvent) {
void loadPreviewEngine()
.then(run =>
run({
amount: payload?.amount,
key: payload?.key,
kind: payload?.action ?? '',
max: payload?.max,
ref: payload?.ref,
selector: payload?.selector,
submit: payload?.submit,
text: payload?.text,
to: payload?.to as PreviewActAction['to']
})
)
.then(answer, error =>
answer({ error: error instanceof Error ? error.message : String(error), success: false })
)
} else {
void answer({
error: 'The in-app browser only takes actions in the session the user is looking at.',
success: false
})
}
}
return true
}
if (event.type === 'window.read.request') {
// read_window_below tool: main owns native window enumeration, so ask
// it over IPC and answer. Empty text = unavailable (no bridge, or
// enumeration unsupported on this system e.g. Wayland).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const read = window.hermesDesktop?.readWindowBelow
const answer = (result: unknown) =>
$gateway.get()?.request('window.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
// .catch: ipcRenderer.invoke rejects on an older shell without the
// handler or a main-side throw — without an empty answer the tool
// would stall its full 30s timeout.
void Promise.resolve(read ? read() : null).then(answer, () => answer(null))
}
return true
}
const { event, payload, isActiveEvent } = ctx
if (event.type === 'agent.terminal.output') {
// Live chunk from a background process → its read-only agent terminal tab.
@@ -176,64 +28,6 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {
return true
}
if (event.type === 'tour.request') {
// tour tool: run one guided-tour action (highlight/step/discover) via
// driver.js — on the app's own DOM or inside the preview pane's guest
// page — and answer with the outcome. Dynamic import keeps driver.js
// and the preview injection payload off the boot path. Active session
// only: a background turn must never paint overlays on the user's
// screen (desktop AGENTS.md: offer, don't hijack).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
// As with preview actions, only the renderer that owns an explicitly
// scoped request may answer. Inactive windows must stay silent even when
// tours are disabled locally, or their refusal can beat the owner.
if (explicitSid && !isActiveEvent) {
return true
}
const answer = (result: unknown) =>
$gateway.get()?.request('tour.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
if (!$toursEnabled.get()) {
// Refused in words, not silently dropped: the agent asked for a
// walkthrough it isn't getting, and a no-op would leave it narrating
// a spotlight the user can't see.
void answer({ error: 'The user has turned guided tours off.', success: false })
} else if (isActiveEvent) {
void import('@/lib/tour')
.then(({ runTour }) =>
runTour(
{
kind: (payload?.action ?? 'stop') as TourAction['kind'],
selector: payload?.selector,
side: payload?.side as TourStep['side'],
startAt: payload?.step_index,
steps: payload?.steps as TourStep[] | undefined,
text: payload?.text,
title: payload?.title
},
payload?.surface === 'preview' ? 'preview' : 'app'
)
)
.then(answer, error =>
answer({ error: error instanceof Error ? error.message : String(error), success: false })
)
} else {
void answer({
error: 'Tours only run in the session the user is looking at.',
success: false
})
}
}
return true
}
if (event.type === 'tip.show') {
// tip tool: point the accent bubble at something and say one line about
// it. Fire-and-forget — a tip is not a question, and blocking the turn on
@@ -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<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
: undefined
if (requestId && questions.length > 0) {
const request = {
choices: null,
lockedAnswers,
multiSelect: false,
question: '',
questions,
receivedAt: Date.now() / 1000,
requestId,
sessionId: sessionId ?? null
}
setClarifyRequest(request)
if (sessionId) {
// A resumed/hydrated transcript may already contain this provider's
// clarify call while carrying no live streamId. Re-arm that exact row
// instead of letting the generic stream mutator append a second card.
updateSessionState(sessionId, state => {
const projection = restorePendingClarifyToolCall(
state.messages,
pendingClarifyToolPayload(request),
occurredAt
)
return {
...state,
messages: projection.messages,
streamId: projection.streamId,
sawAssistantPayload: true,
awaitingResponse: false,
needsInput: true
}
})
if (sessionId === activeSessionIdRef.current) {
requestScrollToBottom(sessionId)
}
}
dispatchNativeNotification({
body: questions.map(q => q.question).join(' · '),
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
} else if (requestId && question) {
if (rawChoices != null && choices.length === 0) {
warnDroppedChoices('gateway', question, rawChoices)
}
const request = {
requestId,
question,
choices: choices.length > 0 ? choices : null,
multiSelect,
receivedAt: Date.now() / 1000,
sessionId: sessionId ?? null
}
setClarifyRequest(request)
if (sessionId) {
// Same provider-shape-aware repair as the batch path above. This keeps
// the original hydrated row and provider tool id, while still seeding
// a row when tool.start was genuinely missed.
updateSessionState(sessionId, state => {
const projection = restorePendingClarifyToolCall(
state.messages,
pendingClarifyToolPayload(request),
occurredAt
)
return {
...state,
messages: projection.messages,
streamId: projection.streamId,
sawAssistantPayload: true,
awaitingResponse: false,
needsInput: true
}
})
if (sessionId === activeSessionIdRef.current) {
requestScrollToBottom(sessionId)
}
}
dispatchNativeNotification({
body: question,
kind: 'input',
sessionId,
title: translateNow('notifications.native.inputTitle')
})
}
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
}
@@ -0,0 +1,434 @@
import { readActivePreview } from '@/app/chat/right-rail/preview-reader'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'
import { translateNow } from '@/i18n'
import { restorePendingClarifyToolCall } from '@/lib/chat-messages'
import type { PreviewActAction } from '@/lib/preview-act/act-in-page'
import type { TourAction, TourStep } from '@/lib/tour'
import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
import type { ScopedServerRequest } from '@/store/gateway'
import { setMcpSetupRequest } from '@/store/mcp-setup'
import { dispatchNativeNotification } from '@/store/native-notifications'
import {
receiveApprovalRequest,
setSecretRequest,
setSudoRequest,
setVaultCodeRequest,
setVaultSaveLoginRequest,
setVaultUnlockRequest
} from '@/store/prompts'
import { rememberServerRequest } from '@/store/server-requests'
import { requestScrollToBottom } from '@/store/thread-scroll'
import { $toursEnabled } from '@/store/tours'
import type { GatewayEventDeps } from './types'
/** The preview engine, loaded on demand so ~25KB of page-injectable source stays
* off the boot path (dev: a fresh copy per action so edits reach the guest — see
* the previous home of this loader in desktop-bridge.ts for the full story). */
const loadPreviewEngine = () => {
const stable = () => import('@/app/chat/right-rail/preview-act')
if (!import.meta.hot) {
return stable().then(mod => mod.actOnActivePreview)
}
return import(/* @vite-ignore */ '/src/app/chat/right-rail/preview-act.ts?hot=' + Date.now())
.catch(stable)
.then(mod => mod.actOnActivePreview as Awaited<ReturnType<typeof stable>>['actOnActivePreview'])
}
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
/** Answer a string-valued request with a JSON-encoded result ('' = nothing / unavailable). */
const answerValue = (request: ScopedServerRequest, result: unknown) =>
request.respond({ value: result ? JSON.stringify(result) : '' })
export interface ServerRequestContext {
deps: Pick<GatewayEventDeps, 'activeSessionIdRef' | 'sessionInterrupted' | 'updateSessionState' | 'upsertToolCall'>
request: ScopedServerRequest
/** The session the request names ('' when unscoped). */
sessionId: string
/** The named session is the one on screen. */
isActiveSession: boolean
}
type Handler = (ctx: ServerRequestContext) => void
const markNeedsInput = (ctx: ServerRequestContext) => {
if (ctx.sessionId) {
ctx.deps.updateSessionState(ctx.sessionId, state => ({ ...state, needsInput: true }))
}
}
const notifyInput = (ctx: ServerRequestContext, body: string) => {
if (!ctx.request.replayed) {
dispatchNativeNotification({
body,
kind: 'input',
sessionId: ctx.sessionId || null,
title: translateNow('notifications.native.inputTitle')
})
}
}
// ── Blocking-input family (clarify / approval / sudo / secret / vault / MCP setup) ──
// Every one is parked per-session (like clarify) so a BACKGROUND session's turn can
// raise it and wait — the sidebar flags "needs input" and the card surfaces once the
// user focuses that chat. The Python side blocks on the response frame; without a
// handler the channel answers -32601 and the tool fails fast instead of stalling.
const clarify: Handler = ctx => {
const { deps, request, sessionId } = ctx
const p = request.params
if (sessionId && deps.sessionInterrupted(sessionId)) {
request.respond({ answer: '' })
return
}
const question = str(p.question)
const rawChoices = p.choices
const choices = normalizeChoices(rawChoices)
const multiSelect = p.multi_select === true
// Batch (multi-question) clarify: `questions` replaces question/choices on the
// wire. `answers` rides along only on a reconnect replay (locks the server
// already accepted).
const questions = normalizeQuestions(p.questions)
const lockedAnswers =
typeof p.answers === 'object' && p.answers !== null
? Object.fromEntries(
Object.entries(p.answers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
: undefined
if (questions.length === 0 && !question) {
request.respond({ answer: '' })
return
}
if (questions.length === 0 && rawChoices != null && choices.length === 0) {
warnDroppedChoices('gateway', question, rawChoices)
}
const clarifyRequest =
questions.length > 0
? {
choices: null,
lockedAnswers,
multiSelect: false,
question: '',
questions,
receivedAt: Date.now() / 1000,
requestId: request.id,
sessionId: sessionId || null
}
: {
choices: choices.length > 0 ? choices : null,
multiSelect,
question,
receivedAt: Date.now() / 1000,
requestId: request.id,
sessionId: sessionId || null
}
rememberServerRequest(request)
setClarifyRequest(clarifyRequest)
if (sessionId) {
// A resumed/hydrated transcript may already contain this provider's clarify
// call while carrying no live streamId. Re-arm that exact row instead of
// letting the generic stream mutator append a second card.
const occurredAt = Date.now() / 1000
deps.updateSessionState(sessionId, state => {
const projection = restorePendingClarifyToolCall(
state.messages,
pendingClarifyToolPayload(clarifyRequest),
occurredAt
)
return {
...state,
messages: projection.messages,
streamId: projection.streamId,
sawAssistantPayload: true,
awaitingResponse: false,
needsInput: true
}
})
if (sessionId === deps.activeSessionIdRef.current) {
requestScrollToBottom(sessionId)
}
}
notifyInput(ctx, questions.length > 0 ? questions.map(q => q.question).join(' · ') : question)
}
const approval: Handler = ctx => {
const { request, sessionId } = ctx
const p = request.params
const command = str(p.command)
const description = str(p.description) || 'dangerous command'
rememberServerRequest(request)
void receiveApprovalRequest(null, {
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: p.allow_permanent !== false,
choices: Array.isArray(p.choices) ? p.choices.filter((choice): choice is string => typeof choice === 'string') : undefined,
command,
description,
// The approval queue's own id — `approval.pending` / `approval.received` / `approval.respond` key on it.
requestId: str(p.request_id) || undefined,
serverRequestId: request.id,
sessionId: sessionId || null,
smartDenied: p.smart_denied === true
}).catch(() => undefined)
markNeedsInput(ctx)
if (!request.replayed) {
dispatchNativeNotification({
actions: [
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
],
body: command || description,
kind: 'approval',
sessionId: sessionId || null,
title: translateNow('notifications.native.approvalTitle')
})
}
}
const sudo: Handler = ctx => {
rememberServerRequest(ctx.request)
setSudoRequest({ requestId: ctx.request.id, sessionId: ctx.sessionId || null })
markNeedsInput(ctx)
notifyInput(ctx, translateNow('notifications.native.inputBody'))
}
const secret: Handler = ctx => {
const p = ctx.request.params
const envVar = str(p.env_var)
const promptText = str(p.prompt)
rememberServerRequest(ctx.request)
setSecretRequest({ envVar, prompt: promptText, requestId: ctx.request.id, sessionId: ctx.sessionId || null })
markNeedsInput(ctx)
notifyInput(ctx, promptText || envVar || translateNow('notifications.native.inputBody'))
}
const vaultCode: Handler = ctx => {
const p = ctx.request.params
const site = str(p.site)
rememberServerRequest(ctx.request)
setVaultCodeRequest({ hint: str(p.hint), requestId: ctx.request.id, sessionId: ctx.sessionId || null, site })
markNeedsInput(ctx)
notifyInput(ctx, translateNow('prompts.vaultCodeTitle', site))
}
const vaultSaveLogin: Handler = ctx => {
const p = ctx.request.params
const origin = str(p.origin)
const site = str(p.site) || origin
rememberServerRequest(ctx.request)
setVaultSaveLoginRequest({ origin, requestId: ctx.request.id, sessionId: ctx.sessionId || null, site })
markNeedsInput(ctx)
notifyInput(ctx, translateNow('prompts.vaultSaveTitle', site))
}
const vaultUnlockPrompt: Handler = ctx => {
const p = ctx.request.params
const backend = str(p.backend)
const displayName = str(p.display_name) || backend
rememberServerRequest(ctx.request)
setVaultUnlockRequest({ backend, displayName, requestId: ctx.request.id, sessionId: ctx.sessionId || null })
markNeedsInput(ctx)
notifyInput(ctx, translateNow('prompts.vaultUnlockTitle', displayName))
}
const mcpSetup: Handler = ctx => {
// setup_mcp tool (desktop GUI): the agent proposed an MCP server. Park the
// request per-session (like clarify) and upsert a stable pending tool row so
// the inline consent card has somewhere to render even when the tool.start
// event was missed (stream reconnect / hydration race).
const { deps, request, sessionId } = ctx
const p = request.params
const server = str(p.server)
const rawAction = str(p.action) || 'install'
const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install'
const reason = str(p.reason)
if (!server) {
request.respond({ value: '' })
return
}
rememberServerRequest(request)
setMcpSetupRequest({ action, reason, requestId: request.id, server, sessionId: sessionId || null })
if (sessionId) {
deps.upsertToolCall(sessionId, { args: { action, reason, server }, name: 'setup_mcp', tool_id: request.id }, 'running')
}
markNeedsInput(ctx)
notifyInput(ctx, reason || server)
}
// ── Desktop-surface bridges (answered immediately, no card) ─────────────────
const terminalRead: Handler = ({ request }) => {
// read_terminal tool: serialize the renderer's xterm buffer. Empty = no live pane.
answerValue(request, readActiveTerminal({ count: num(request.params.count), start: num(request.params.start) }))
}
const previewRead: Handler = ({ request }) => {
// read_preview tool: the active preview tab's page text is async. Empty = nothing open.
void readActivePreview({ count: num(request.params.count), start: num(request.params.start) }).then(result =>
answerValue(request, result)
)
}
const previewAct: Handler = ({ isActiveSession, request, sessionId }) => {
// drive_preview tool: click/type/scroll/press inside the guest page. Active
// session only: a background turn must never reach into the page the user is
// working in (desktop AGENTS.md: offer, don't hijack). Every mounted window can
// observe the same request; a scoped mismatch belongs to another window, so
// answering here would race the owner — stay silent.
if (sessionId && !isActiveSession) {
return
}
const p = request.params
if (!isActiveSession) {
answerValue(request, {
error: 'The in-app browser only takes actions in the session the user is looking at.',
success: false
})
return
}
void loadPreviewEngine()
.then(run =>
run({
amount: p.amount as never,
key: p.key as never,
kind: (str(p.action) || '') as never,
max: p.max as never,
ref: p.ref as never,
selector: p.selector as never,
submit: p.submit as never,
text: p.text as never,
to: p.to as PreviewActAction['to']
})
)
.then(
result => answerValue(request, result),
error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false })
)
}
const windowRead: Handler = ({ request }) => {
// read_window_below tool: main owns native window enumeration. Empty =
// unavailable (older shell without the handler, Wayland, …) — without an
// answer the tool would stall its full 30s deadline.
const read = window.hermesDesktop?.readWindowBelow
void Promise.resolve(read ? read() : null).then(
result => answerValue(request, result),
() => answerValue(request, null)
)
}
const tour: Handler = ({ isActiveSession, request, sessionId }) => {
// tour tool: one guided-tour action via driver.js, app DOM or preview guest
// page. Active session only, same window-ownership rule as preview.act.
if (sessionId && !isActiveSession) {
return
}
const p = request.params
if (!$toursEnabled.get()) {
// Refused in words, not silently dropped: a no-op would leave the agent
// narrating a spotlight the user can't see.
answerValue(request, { error: 'The user has turned guided tours off.', success: false })
return
}
if (!isActiveSession) {
answerValue(request, { error: 'Tours only run in the session the user is looking at.', success: false })
return
}
void import('@/lib/tour')
.then(({ runTour }) =>
runTour(
{
kind: (str(p.action) || 'stop') as TourAction['kind'],
selector: p.selector as never,
side: p.side as TourStep['side'],
startAt: p.step_index as never,
steps: p.steps as TourStep[] | undefined,
text: p.text as never,
title: p.title as never
},
p.surface === 'preview' ? 'preview' : 'app'
)
)
.then(
result => answerValue(request, result),
error => answerValue(request, { error: error instanceof Error ? error.message : String(error), success: false })
)
}
/** Method → handler. Every `ServerRequestMap` key the desktop answers. */
export const SERVER_REQUEST_HANDLERS: Record<string, Handler> = {
approval,
clarify,
'mcp.setup': mcpSetup,
'preview.act': previewAct,
'preview.read': previewRead,
secret,
sudo,
'terminal.read': terminalRead,
tour,
'vault.code': vaultCode,
'vault.save_login': vaultSaveLogin,
'vault.unlock_prompt': vaultUnlockPrompt,
'window.read': windowRead
}
/** Dispatch one server→client request; false when the desktop has no handler for its method. */
export function handleServerRequest(
request: ScopedServerRequest,
deps: ServerRequestContext['deps'],
activeSessionId: null | string
): boolean {
const handler = SERVER_REQUEST_HANDLERS[request.method]
if (!handler) {
return false
}
const sessionId = str(request.params.session_id)
handler({ deps, request, sessionId, isActiveSession: Boolean(sessionId) && sessionId === activeSessionId })
return true
}
@@ -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
}
@@ -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 ?? '',
@@ -1,12 +1,5 @@
import type { GatewayEventPayload } from '@/lib/chat-messages'
import {
$clarifyRequests,
type ClarifyRequest,
clearClarifyRequest,
normalizeChoices,
normalizeQuestions,
setClarifyRequest
} from '@/store/clarify'
import { $clarifyRequests, type ClarifyRequest, clearClarifyRequest } from '@/store/clarify'
import type { SessionResumeResponse } from '@/types/hermes'
export interface PendingClarifyResumeState {
@@ -16,28 +9,26 @@ export interface PendingClarifyResumeState {
}
/**
* Restore a pending clarify from a resume/activate snapshot onto `sessionId`.
* Reconcile the parked clarify for `sessionId` against a resume/activate
* snapshot.
*
* The snapshot mirrors the live clarify.request wire shape: single-question
* payloads carry `question`/`choices`/`multi_select`; batch (multi-question)
* ones carry `questions` (+ any answers already locked server-side) and no
* top-level `question`. Multi-select locks arrive as JSON-encoded arrays
* inside a string — never a bare array — so `lockedAnswers` keeps string
* values only.
*
* A missing snapshot is authoritative only for requests that already existed
* when the RPC began. A newer clarify.request that arrives while the response
* is in flight is left alone.
* The snapshot's `open_requests` names every server→client request still
* blocking the session. The shared channel has ALREADY re-delivered those to
* the request handlers (which parked the clarify card) before the caller sees
* the response, so this only has to (a) report the parked request when the
* snapshot confirms it and (b) treat a snapshot WITHOUT a clarify as
* authoritative for requests that already existed when the RPC began — a
* newer request that arrived while the response was in flight is left alone.
*/
export function restorePendingClarifyFromSnapshot(
response: Pick<SessionResumeResponse, 'pending_clarify'>,
response: Pick<SessionResumeResponse, 'open_requests'>,
sessionId: string,
resumeStartedAt: number,
requestIdAtStart?: string
): PendingClarifyResumeState {
const pending = response.pending_clarify
const pending = (response.open_requests ?? []).find(entry => entry.method === 'clarify')
if (!pending || typeof pending.request_id !== 'string') {
if (!pending) {
const current = $clarifyRequests.get()[sessionId]
const existedAtStart = Boolean(current && requestIdAtStart && current.requestId === requestIdAtStart)
@@ -53,36 +44,12 @@ export function restorePendingClarifyFromSnapshot(
return { authoritativeAbsent: true, cleared: null, request: null }
}
const questions = normalizeQuestions(pending.questions)
const question = typeof pending.question === 'string' ? pending.question : ''
// 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 {
@@ -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(
@@ -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]
)
@@ -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.
@@ -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) {
@@ -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<string, string>)?.[qid] ?? ''
})
}
} else {
await requestForBot(member, 'clarify.respond', {
request_id: entry.requestId,
answer: typeof answers === 'string' ? answers : ''
await requestForBot(member, 'request.answer', {
id: entry.requestId,
result: { answer: typeof answers === 'string' ? answers : '' }
})
}
+4 -8
View File
@@ -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<boolean> {
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
}
+25 -1
View File
@@ -3,7 +3,8 @@ import {
type GatewayEvent,
reconnectBackoffDelayMs,
registryBackendScopeKey,
resolveGatewayWsUrl
resolveGatewayWsUrl,
type ServerRequest
} from '@hermes/shared'
import { atom } from 'nanostores'
@@ -56,6 +57,10 @@ interface RegistryConfig {
* the connection store. */
activeConnectionId?: () => null | string
onEvent: (event: GatewayEvent) => void
/** Server→client request (clarify, approval, …) from ANY socket the registry owns; the
* request's `respond` already routes to the socket it came from. `profile` /
* `connectionId` tag the source the same way events are tagged. */
onServerRequest?: (request: ScopedServerRequest) => void
onActiveConnectionInvalidated?: (fallbackProfile: string, activationEpoch: number) => void
onActiveConnectionChanged?: (connection: HermesConnection) => void
/**
@@ -100,6 +105,7 @@ interface Secondary {
activeRequests: number
connectPromise: Promise<void> | null
offEvent: () => void
offRequest: () => void
offState: () => void
reconnectTimer: ReturnType<typeof setTimeout> | null
reconnectAttempt: number
@@ -269,6 +275,19 @@ export function emitLocalGatewayEvent(event: GatewayEvent): void {
g.config?.onEvent(event)
}
/** A server→client request tagged with the registry source it arrived from (like `GatewayEvent.profile`). */
export interface ScopedServerRequest extends ServerRequest {
connectionId?: string
profile: string
}
/** Fan a primary-socket server request into the registry handler with the active source tags. */
export function dispatchPrimaryServerRequest(request: ServerRequest, profile: string): void {
const connectionId = g.config?.activeConnectionId?.() ?? null
g.config?.onServerRequest?.({ ...request, ...(connectionId ? { connectionId } : {}), profile })
}
export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
const next = normKey(profile)
@@ -800,6 +819,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
activeRequests: 0,
connectPromise: null,
offEvent: () => {},
offRequest: () => {},
offState: () => {},
reconnectTimer: null,
reconnectAttempt: 0,
@@ -822,6 +842,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()
}
+5 -10
View File
@@ -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
}
+6 -12
View File
@@ -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)) {
+63 -13
View File
@@ -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<T extends KeyedPrompt>(): PromptStore<T> {
}
}
// Approval is session-keyed on the backend and correlated by `request_id` when
// available (legacy ID-free responses remain FIFO-compatible). Resolved via
// approval.respond {choice, request_id, session_id}.
// Approval is queue-backed on the backend (`tools/approval.py`): `requestId` is
// the QUEUE entry's id (what `approval.pending` / `approval.received` /
// `approval.respond` key on), stable across delivery paths. The live prompt
// arrives as an `approval` server request whose id is `serverRequestId`; the
// card answers that request when it is still open and falls back to the
// `approval.respond` RPC when the prompt was restored from `approval.pending`.
export interface ApprovalRequest extends KeyedPrompt {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
@@ -80,6 +85,7 @@ export interface ApprovalRequest extends KeyedPrompt {
command: string
description: string
requestId?: string
serverRequestId?: string
smartDenied?: boolean
}
@@ -106,8 +112,8 @@ export interface SecretRequest extends KeyedPrompt {
requestId: string
}
// External password-manager unlock (agent/vault_backends). Resolved via
// vault.unlock.respond {request_id, password}; "" keeps the manager locked.
// External password-manager unlock (agent/vault_backends): `vault.unlock_prompt`
// server request, answered `{value: password}`; "" keeps the manager locked.
export interface VaultUnlockRequest extends KeyedPrompt {
backend: string
displayName: string
@@ -119,8 +125,8 @@ const sudo = keyedPromptStore<SudoRequest>()
const secret = keyedPromptStore<SecretRequest>()
const vaultUnlock = keyedPromptStore<VaultUnlockRequest>()
// "Save this login" for the page the agent is on (tools/browser_vault_tool). Resolved via
// vault.save_login.respond {request_id, login: JSON {identifier, password}}; "" declines.
// "Save this login" for the page the agent is on (tools/browser_vault_tool): `vault.save_login`
// server request, answered `{value: JSON {identifier, password}}`; "" declines.
export interface VaultSaveLoginRequest extends KeyedPrompt {
origin: string
site: string
@@ -129,8 +135,8 @@ export interface VaultSaveLoginRequest extends KeyedPrompt {
const vaultSave = keyedPromptStore<VaultSaveLoginRequest>()
// Second-factor code for the page the agent is on. Resolved via vault.code.respond
// {request_id, code}; "" skips.
// Second-factor code for the page the agent is on: `vault.code` server request,
// answered `{value: code}`; "" skips.
export interface VaultCodeRequest extends KeyedPrompt {
site: string
hint: string
@@ -144,10 +150,19 @@ const vaultCode = keyedPromptStore<VaultCodeRequest>()
const $approvalInlineAnchors = atom<Record<string, number>>({})
export const $approvalRequest = approval.$active
export const $approvalRequests = approval.$all
export const setApprovalRequest = approval.set
export const clearApprovalRequest = approval.clear
export async function receiveApprovalRequest(gateway: ApprovalGateway | null, request: ApprovalRequest): Promise<void> {
// A prompt restored from `approval.pending` must not clobber the live server
// request that already carries the same queue entry (it knows how to answer).
const current = approval.$all.get()[keyFor(request.sessionId)]
if (current?.requestId && current.requestId === request.requestId && current.serverRequestId && !request.serverRequestId) {
return
}
setApprovalRequest(request)
if (gateway && request.requestId && request.sessionId) {
@@ -210,6 +225,10 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses
return
}
if (previous?.requestId === pending.request_id) {
return
}
await receiveApprovalRequest(gateway, {
allowPermanent: pending.allow_permanent !== false,
choices: Array.isArray(pending.choices) ? pending.choices.filter(choice => typeof choice === 'string') : undefined,
@@ -221,6 +240,35 @@ export async function replayPendingApproval(gateway: ApprovalGateway | null, ses
})
}
/**
* Resolve an approval: answer the live server request when it is still open
* (the response frame goes back over the socket it arrived on — the owner by
* construction), else the queue-level `approval.respond` RPC for a prompt that
* was restored from `approval.pending` or is being answered from another
* surface. Returns after the backend has the decision.
*/
export async function answerApproval(
gateway: ApprovalGateway | null,
request: Pick<ApprovalRequest, 'requestId' | 'serverRequestId' | 'sessionId'>,
choice: string,
all = false
): Promise<void> {
if (respondToServerRequest(request.serverRequestId, { choice, ...(all ? { all: true } : {}) })) {
return
}
if (!gateway) {
throw new Error('Hermes gateway is not connected')
}
await requestForOwnedSession(request.sessionId, ambientRequestFor(gateway), 'approval.respond', {
all,
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
+46
View File
@@ -0,0 +1,46 @@
import type { ServerRequest } from '@hermes/shared'
/**
* Live server→client requests (`tui_gateway/server_requests.py`) keyed by
* request id: clarify / approval / sudo / secret / vault / MCP-setup cards.
*
* The per-session prompt stores keep only the id; a card answers through
* `respondToServerRequest`, which routes the response frame back over the
* socket the request arrived on — the owner backend by construction, so no
* owner-route lookup is needed (#91684's whole class disappears: the answer
* cannot land on the wrong backend because it is a JSON-RPC response, not a
* new call). A request re-delivered after a reconnect (`open_requests`)
* carries the same id and replaces the entry, so the still-visible card
* answers the new generation.
*/
const open = new Map<string, ServerRequest>()
export function rememberServerRequest(request: ServerRequest): void {
open.set(request.id, request)
}
export function forgetServerRequest(id: string): void {
open.delete(id)
}
/** Answer request `id`. False when nothing is open under that id (expired / already answered). */
export function respondToServerRequest(id: string | undefined, result: Record<string, unknown>): boolean {
const request = id ? open.get(id) : undefined
if (!request) {
return false
}
open.delete(id!)
request.respond(result)
return true
}
export function hasOpenServerRequest(id: string): boolean {
return open.has(id)
}
export function resetServerRequestsForTests(): void {
open.clear()
}
+5 -11
View File
@@ -673,17 +673,11 @@ export interface SessionResumeResponse {
request_id?: string
smart_denied?: boolean
}
// The clarify question still blocking this session, if any. Same replay
// class as pending_approval: emitted-while-detached prompts are restored
// from the resume snapshot instead of being lost until server-side timeout.
pending_clarify?: {
answers?: Record<string, unknown>
choices?: null | string[]
multi_select?: boolean
question?: string
questions?: unknown
request_id?: string
}
// Server→client requests still unanswered for this session (clarify, sudo,
// vault prompts, …). The shared channel re-delivers them to the request
// handlers before this response resolves; listed here so resume can tell an
// authoritative "nothing pending" from a request the handler declined.
open_requests?: Array<{ id: string; method: string; params: Record<string, unknown> & { session_id?: string } }>
info?: SessionRuntimeInfo
message_count: number
messages: SessionMessage[]
+86 -93
View File
@@ -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"
]
}
+17 -10
View File
@@ -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)
})
})
+104 -71
View File
@@ -3,7 +3,8 @@
* TypeScript surfaces (Ink TUI, Desktop, web dashboard) share.
*
* Every notification arrives as `{jsonrpc: '2.0', method: 'event', params: GatewayEvent}`
* (`tui_gateway/server.py::_event_frame`). `GatewayEventMap` is the single map from
* (`tui_gateway/server.py::_event_frame`); server→client REQUESTS (`{id, method, params}`,
* `tui_gateway/server_requests.py`) are typed by `ServerRequestMap` below. `GatewayEventMap` is the single map from
* event `type` to payload shape; `BACKEND_EVENT_NAMES` mirrors the emitter side and is
* pinned to `gateway-events.json` by `gateway-events.test.ts` (vitest) and
* `tests/tui_gateway/test_gateway_event_contract.py` (Python), so a name added on one
@@ -304,11 +305,20 @@ export interface MoaPhasePayload {
refs_total?: number
}
// Blocking bridges (`tui_gateway/server.py::_block`): every `*.request` carries a
// `request_id`; the matching `*.expire` names the same id when the wait timed out.
// ── Server→client requests (`tui_gateway/server_requests.py`) ───────────
//
// The backend asks the renderer a question with a real JSON-RPC request
// (`{id: 'srq-…', method, params}`) and blocks on the response frame. Every
// entry below is one method: its `params` shape and the `result` the client
// answers with. `request.cancel` (an event) withdraws an open request on
// timeout / interrupt / session close; `open_requests` on `session.resume` /
// `session.events.since` re-delivers unanswered ones after a reconnect.
export interface RequestExpirePayload {
request_id: string
/** `request.cancel` payload — the backend withdrew an open server request. */
export interface RequestCancelPayload {
id: string
method: string
reason: string
}
export interface ClarifyQuestion {
@@ -318,54 +328,113 @@ export interface ClarifyQuestion {
question: string
}
export interface ClarifyRequestPayload {
/** `clarify` params. Single question: `question`/`choices`(/`multi_select`); batch: `questions`.
* `answers` rides along only on a reconnect replay (locks the server already accepted). */
export interface ClarifyRequestParams {
answers?: Record<string, string>
choices?: null | string[]
multi_select?: boolean
question?: string
questions?: ClarifyQuestion[]
request_id: string
}
/** `tui_gateway/server.py::_approval_request_payload` (command redacted server-side). */
export interface ApprovalRequestPayload {
/** `clarify` result. Single: `{answer}` ('' = skip). Batch: the request resolves through
* `clarify.lock` RPCs (the last lock completes it); a response with no `answers` is cancel-all. */
export interface ClarifyResult {
answer?: string
answers?: Record<string, string>
}
/** `approval` params (`tui_gateway/server.py::_approval_request_payload`, command redacted server-side). */
export interface ApprovalRequestParams {
allow_permanent?: boolean
choices?: string[]
command: string
description: string
request_id?: string
request_id: string
smart_denied?: boolean
}
export interface SudoRequestPayload {
request_id: string
export interface ApprovalResult {
all?: boolean
choice: 'always' | 'deny' | 'once' | 'session'
}
export interface SecretRequestPayload {
/** Every prompt whose answer is one string: `sudo`, `secret`, the vault prompts, the desktop GUI
* bridges (`terminal.read`, `preview.read`, `preview.act`, `window.read`, `tour`) and `mcp.setup`.
* '' means skipped / declined. */
export interface ValueResult {
value: string
}
export interface SecretRequestParams {
env_var: string
metadata?: Record<string, unknown>
prompt: string
request_id: string
}
export interface VaultUnlockRequestPayload {
export interface VaultUnlockRequestParams {
backend: string
display_name: string
request_id: string
}
export interface VaultCodeRequestPayload {
export interface VaultSaveLoginRequestParams {
origin: string
site: string
}
export interface VaultCodeRequestParams {
hint?: string
request_id: string
site?: string
}
export interface McpSetupRequestPayload {
export interface McpSetupRequestParams {
action?: string
reason?: string
request_id: string
server?: string
}
export interface ReadRangeRequestParams {
count?: number
start?: number
}
/** Server→client request method → `{params, result}`. Every method the backend can ask. */
export interface ServerRequestMap {
approval: { params: ApprovalRequestParams; result: ApprovalResult }
clarify: { params: ClarifyRequestParams; result: ClarifyResult }
'mcp.setup': { params: McpSetupRequestParams; result: ValueResult }
'preview.act': { params: Record<string, unknown>; result: ValueResult }
'preview.read': { params: ReadRangeRequestParams; result: ValueResult }
secret: { params: SecretRequestParams; result: ValueResult }
sudo: { params: Record<string, never>; result: ValueResult }
'terminal.read': { params: ReadRangeRequestParams; result: ValueResult }
tour: { params: Record<string, unknown>; result: ValueResult }
'vault.code': { params: VaultCodeRequestParams; result: ValueResult }
'vault.save_login': { params: VaultSaveLoginRequestParams; result: ValueResult }
'vault.unlock_prompt': { params: VaultUnlockRequestParams; result: ValueResult }
'window.read': { params: Record<string, never>; result: ValueResult }
}
export type ServerRequestMethod = keyof ServerRequestMap
/** Pinned to `gateway-events.json`'s `server_requests` list by the two contract tests. Keep sorted. */
export const SERVER_REQUEST_METHODS = [
'approval',
'clarify',
'mcp.setup',
'preview.act',
'preview.read',
'secret',
'sudo',
'terminal.read',
'tour',
'vault.code',
'vault.save_login',
'vault.unlock_prompt',
'window.read'
] as const satisfies readonly ServerRequestMethod[]
/** Side agents (`tui_gateway/methods_prompt.py::_spawn_side_agent`). */
export interface SideAgentCompletePayload {
question?: string
@@ -397,7 +466,6 @@ export interface TerminalClosePayload {
*/
export const BACKEND_EVENT_NAMES = [
'agent.terminal.output',
'approval.request',
'background.complete',
'billing.step_up.verification',
'bot_relay.outbox.pending',
@@ -405,14 +473,10 @@ export const BACKEND_EVENT_NAMES = [
'browser.controller.command',
'browser.progress',
'btw.complete',
'clarify.expire',
'clarify.request',
'cron.changed',
'error',
'gateway.ready',
'layout.apply',
'mcp.setup.expire',
'mcp.setup.request',
'message.complete',
'message.delta',
'message.interim',
@@ -431,20 +495,15 @@ export const BACKEND_EVENT_NAMES = [
'pet.generate.progress',
'pet.hatch.progress',
'platforms.changed',
'preview.act.expire',
'preview.act.request',
'preview.close',
'preview.open',
'preview.read.expire',
'preview.read.request',
'preview.restart.complete',
'preview.restart.progress',
'reaction',
'reasoning.available',
'reasoning.delta',
'request.cancel',
'review.summary',
'secret.expire',
'secret.request',
'session.control.update',
'session.info',
'session.reclaimed',
@@ -461,11 +520,7 @@ export const BACKEND_EVENT_NAMES = [
'subagent.start',
'subagent.thinking',
'subagent.tool',
'sudo.expire',
'sudo.request',
'terminal.close',
'terminal.read.expire',
'terminal.read.request',
'thinking.delta',
'tip.show',
'todo.updated',
@@ -473,20 +528,10 @@ export const BACKEND_EVENT_NAMES = [
'tool.generating',
'tool.output_risk',
'tool.start',
'tour.expire',
'tour.request',
'vault.code.expire',
'vault.code.request',
'vault.save_login.expire',
'vault.save_login.request',
'vault.unlock.expire',
'vault.unlock.request',
'voice.interrupted',
'voice.status',
'voice.transcript',
'wake.detected',
'window.read.expire',
'window.read.request'
'wake.detected'
] as const satisfies readonly (keyof BackendGatewayEventMap)[]
export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number]
@@ -494,7 +539,6 @@ export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number]
/** Payload per backend-emitted notification `type`. Keys are exactly `BACKEND_EVENT_NAMES`. */
export interface BackendGatewayEventMap {
'agent.terminal.output': TerminalOutputPayload
'approval.request': ApprovalRequestPayload
'background.complete': SideAgentCompletePayload
'billing.step_up.verification': BillingStepUpVerificationPayload
'bot_relay.outbox.pending': Record<string, unknown>
@@ -502,14 +546,10 @@ export interface BackendGatewayEventMap {
'browser.controller.command': Record<string, unknown>
'browser.progress': BrowserProgressPayload
'btw.complete': SideAgentCompletePayload
'clarify.expire': RequestExpirePayload
'clarify.request': ClarifyRequestPayload
'cron.changed': Record<string, unknown>
error: ErrorPayload
'gateway.ready': GatewayReadyPayload
'layout.apply': Record<string, unknown>
'mcp.setup.expire': RequestExpirePayload
'mcp.setup.request': McpSetupRequestPayload
'message.complete': MessageCompletePayload
'message.delta': StreamDeltaPayload
'message.interim': MessageInterimPayload
@@ -528,20 +568,15 @@ export interface BackendGatewayEventMap {
'pet.generate.progress': Record<string, unknown>
'pet.hatch.progress': Record<string, unknown>
'platforms.changed': Record<string, unknown>
'preview.act.expire': RequestExpirePayload
'preview.act.request': Record<string, unknown>
'preview.close': Record<string, unknown>
'preview.open': Record<string, unknown>
'preview.read.expire': RequestExpirePayload
'preview.read.request': Record<string, unknown>
'preview.restart.complete': SideAgentCompletePayload
'preview.restart.progress': PreviewRestartProgressPayload
reaction: ReactionPayload
'reasoning.available': StreamDeltaPayload
'reasoning.delta': StreamDeltaPayload
'request.cancel': RequestCancelPayload
'review.summary': TextPayload
'secret.expire': RequestExpirePayload
'secret.request': SecretRequestPayload
'session.control.update': SessionControlUpdatePayload
/** Surface-specific shape (`tui_gateway/server.py::_session_info`); each client narrows. */
'session.info': Record<string, unknown>
@@ -559,11 +594,7 @@ export interface BackendGatewayEventMap {
'subagent.start': SubagentEventPayload
'subagent.thinking': SubagentEventPayload
'subagent.tool': SubagentEventPayload
'sudo.expire': RequestExpirePayload
'sudo.request': SudoRequestPayload
'terminal.close': TerminalClosePayload
'terminal.read.expire': RequestExpirePayload
'terminal.read.request': Record<string, unknown>
'thinking.delta': StreamDeltaPayload
'tip.show': Record<string, unknown>
'todo.updated': TodoStatePayload
@@ -571,20 +602,10 @@ export interface BackendGatewayEventMap {
'tool.generating': ToolGeneratingPayload
'tool.output_risk': ToolOutputRiskPayload
'tool.start': ToolStartPayload
'tour.expire': RequestExpirePayload
'tour.request': Record<string, unknown>
'vault.code.expire': RequestExpirePayload
'vault.code.request': VaultCodeRequestPayload
'vault.save_login.expire': RequestExpirePayload
'vault.save_login.request': Record<string, unknown>
'vault.unlock.expire': RequestExpirePayload
'vault.unlock.request': VaultUnlockRequestPayload
'voice.interrupted': Record<string, unknown>
'voice.status': VoiceStatusPayload
'voice.transcript': VoiceTranscriptPayload
'wake.detected': WakeDetectedPayload
'window.read.expire': RequestExpirePayload
'window.read.request': Record<string, unknown>
}
/**
@@ -757,6 +778,11 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message =
messages: Message[]
/** `omit_messages` resume: the client still learns the stored size. */
messages_omitted?: boolean
/** Server→client requests still unanswered for this session (a clarify, sudo prompt, …
* raised while the client was detached); the client re-delivers them to its request
* handlers. `pending_approval` (the approval queue's oldest entry) is the approval twin. */
open_requests?: OpenServerRequest[]
pending_approval?: ApprovalRequestParams
resumed?: string
running?: boolean
session_id: string
@@ -765,3 +791,10 @@ export interface SessionResumeResponse<Info = Record<string, unknown>, Message =
status?: string
todo_state?: TodoStatePayload
}
/** One unanswered server→client request as returned by `open_requests`. */
export interface OpenServerRequest {
id: string
method: string
params: Record<string, unknown> & { session_id?: string }
}
+19 -6
View File
@@ -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 {
+147 -8
View File
@@ -12,10 +12,47 @@ export interface JsonRpcFrame {
error?: JsonRpcErrorPayload
id?: GatewayRequestId | null
method?: string
params?: GatewayEvent
params?: GatewayEvent | ServerRequestParams
result?: unknown
}
/**
* Params of a server→client request (`tui_gateway/server_requests.py`): the
* backend asking the renderer a question. `session_id` names the session
* blocked on the answer; the rest is method-specific.
*/
export interface ServerRequestParams extends Record<string, unknown> {
session_id?: string
}
/** One inbound server→client request, as handed to a `ServerRequestHandler`. */
export interface ServerRequest<M extends string = string, P extends ServerRequestParams = ServerRequestParams> {
id: string
method: M
params: P
/**
* Route the answer back to the backend that asked. Idempotent: the first
* `respond` (or `fail`) wins; a request re-delivered after a reconnect
* (`open_requests`) reuses the id, so a stale card answering twice is a
* no-op on the wire.
*/
respond: (result: Record<string, unknown>) => void
/** Answer with a JSON-RPC error (the backend treats it as unanswered). */
fail: (code: number, message: string) => void
/**
* Renderer-side tag set by the owner when a request arrives through a
* replay (`open_requests`) rather than live; handlers that already show the
* card can skip re-notifying.
*/
replayed?: boolean
}
/** Handles one inbound server→client request; return `false` to decline (next handler tries). */
export type ServerRequestHandler = (request: ServerRequest) => boolean | void
const isServerRequestFrame = (frame: JsonRpcFrame): frame is JsonRpcFrame & { id: string; method: string } =>
typeof frame.id === 'string' && typeof frame.method === 'string' && frame.method !== 'event'
/** JSON-RPC error with optional structured `data` from the gateway. */
export class JsonRpcGatewayError extends Error {
readonly code?: number
@@ -59,6 +96,12 @@ export interface JsonRpcRequestChannelOptions {
onHeartbeatFailure?: (error: Error) => void
/** Decoded `event` notification. */
onEvent?: (event: GatewayEvent) => void
/**
* Inbound server→client request nobody handled: the owner logs it. The
* channel has already answered `-32601` so the backend does not wait out
* its deadline against a client with no handler.
*/
onUnhandledRequest?: (request: { id: string; method: string; params: ServerRequestParams }) => void
requestIdPrefix?: string
requestTimeoutMs?: number
/**
@@ -130,8 +173,11 @@ export class JsonRpcRequestChannel {
private heartbeatSequence = 0
private readonly outstandingPings = new Set<string>()
private lastLivenessAt = 0
private readonly options: Required<Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'>> &
Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure'>
private readonly requestHandlers: ServerRequestHandler[] = []
private readonly options: Required<
Omit<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'>
> &
Pick<JsonRpcRequestChannelOptions, 'onEvent' | 'onHeartbeatFailure' | 'onUnhandledRequest'>
constructor(options: JsonRpcRequestChannelOptions = {}) {
this.options = {
@@ -141,6 +187,7 @@ export class JsonRpcRequestChannel {
heartbeatLiveness: options.heartbeatLiveness ?? 'response',
onEvent: options.onEvent,
onHeartbeatFailure: options.onHeartbeatFailure,
onUnhandledRequest: options.onUnhandledRequest,
requestIdPrefix: options.requestIdPrefix ?? 'r',
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
unrefTimers: options.unrefTimers ?? false
@@ -254,11 +301,89 @@ export class JsonRpcRequestChannel {
})
}
/**
* Register a handler for server→client requests (clarify, approval, sudo,
* …). Handlers are tried in registration order until one accepts (returns
* anything but `false`); an unhandled request is answered `-32601` so the
* backend never waits out its deadline against a client that cannot answer.
*/
onRequest(handler: ServerRequestHandler): () => void {
this.requestHandlers.push(handler)
return () => {
const index = this.requestHandlers.indexOf(handler)
if (index >= 0) {
this.requestHandlers.splice(index, 1)
}
}
}
/**
* Deliver a server request to the handlers. Live frames arrive through
* `handleFrame`; owners call this directly for `open_requests` returned by a
* reconnect replay (`replayed: true`) so an unanswered question survives a
* dropped socket.
*/
deliverRequest(id: string, method: string, params: ServerRequestParams, replayed = false): boolean {
let settled = false
const send = (frame: Record<string, unknown>) => {
if (settled) {
return
}
settled = true
try {
this.transport?.send(JSON.stringify({ jsonrpc: '2.0', id, ...frame }))
} catch {
// The generation is gone; the backend withdraws the request itself (timeout / reconnect replay).
}
}
const request: ServerRequest = {
id,
method,
params,
replayed,
respond: result => send({ result }),
fail: (code, message) => send({ error: { code, message } })
}
for (const handler of this.requestHandlers) {
if (handler(request) !== false) {
return true
}
}
request.fail(JSON_RPC_METHOD_NOT_FOUND, `no handler for server request: ${method}`)
this.options.onUnhandledRequest?.({ id, method, params })
return false
}
private deliverOpenRequests(result: unknown): void {
const open = (result as { open_requests?: unknown } | null)?.open_requests
if (!Array.isArray(open)) {
return
}
for (const entry of open as Array<{ id?: unknown; method?: unknown; params?: unknown }>) {
if (typeof entry?.id === 'string' && typeof entry.method === 'string') {
const params = entry.params && typeof entry.params === 'object' ? (entry.params as ServerRequestParams) : {}
this.deliverRequest(entry.id, entry.method, params, true)
}
}
}
/**
* Route one inbound frame: a response settles its pending call, an
* `event` notification reaches `onEvent`. Returns the decoded frame so the
* owner can act on it too (mirror it, record seq, …) or `null` when the
* text was not JSON or not a JSON object (`null`, a scalar).
* `event` notification reaches `onEvent`, a server→client request reaches
* the `onRequest` handlers. Returns the decoded frame so the owner can act
* on it too (mirror it, record seq, …) or `null` when the text was not
* JSON or not a JSON object (`null`, a scalar).
*/
handleFrame(text: string): JsonRpcFrame | null {
let frame: JsonRpcFrame
@@ -277,6 +402,13 @@ export class JsonRpcRequestChannel {
this.lastLivenessAt = Date.now()
}
if (isServerRequestFrame(frame)) {
const params = frame.params && typeof frame.params === 'object' ? (frame.params as ServerRequestParams) : {}
this.deliverRequest(frame.id, frame.method, params)
return frame
}
if (frame.id !== undefined && frame.id !== null) {
if (typeof frame.id === 'string' && this.outstandingPings.delete(frame.id)) {
this.lastLivenessAt = Date.now()
@@ -293,6 +425,13 @@ export class JsonRpcRequestChannel {
if (frame.error) {
call.reject(jsonRpcErrorFromFrame(frame.error))
} else {
// Reconnect contract: `session.resume` / `session.activate` /
// `session.events.since` answer with `open_requests` — the server→
// client requests still waiting on this session. They cannot ride
// the event replay ring (they are not events), so they are re-
// delivered here, before the caller sees the result, over the very
// socket that owns them.
this.deliverOpenRequests(frame.result)
call.resolve(frame.result)
}
}
@@ -300,8 +439,8 @@ export class JsonRpcRequestChannel {
return frame
}
if (frame.method === 'event' && frame.params && typeof frame.params.type === 'string') {
this.options.onEvent?.(frame.params)
if (frame.method === 'event' && frame.params && typeof (frame.params as GatewayEvent).type === 'string') {
this.options.onEvent?.(frame.params as GatewayEvent)
}
return frame
+11
View File
@@ -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 },
@@ -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": "<name>"`` params written straight into an ``event`` frame.
_LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S)
@@ -50,10 +54,6 @@ def emitted_event_names() -> set[str]:
names.update(_LITERAL_EMIT.findall(text))
names.update(_LITERAL_FRAME.findall(text))
names.update(_SIDE_AGENT.findall(text))
# ``.request`` bridges that time out fire ``f"{event.removesuffix('.request')}.expire"``.
from tui_gateway.server import _EXPIRING_REQUESTS
names.update(f"{event.removesuffix('.request')}.expire" for event in _EXPIRING_REQUESTS)
from tui_gateway.change_watcher import _CHANGE_WATCHES
names.update(_CHANGE_WATCHES)
@@ -74,13 +74,33 @@ def emitted_event_names() -> set[str]:
return names
def server_request_methods() -> set[str]:
names: set[str] = set()
for src in GATEWAY_DIR.glob("*.py"):
names.update(_LITERAL_REQUEST.findall(_read(src)))
return names
@pytest.fixture(scope="module")
def contract() -> list[str]:
return json.loads(_read(CONTRACT))
return json.loads(_read(CONTRACT))["events"]
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):
+11
View File
@@ -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:
+13 -4
View File
@@ -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 <reason>`` so the agent can adapt, not just hear "denied".
self.reason: str | None = None
@@ -139,13 +142,19 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, *,
with _approval._lock:
_approval._gateway_queues.setdefault(session_key, []).append(entry)
def _drop_entry() -> None:
def _drop_entry(reason: str) -> None:
with _approval._lock:
queue = _approval._gateway_queues.get(session_key, [])
if entry in queue:
queue.remove(entry)
if not queue:
_approval._gateway_queues.pop(session_key, None)
settle, entry.settle = entry.settle, None
if settle is not None:
try:
settle(reason)
except Exception:
logger.debug("approval settle hook failed", exc_info=True)
# Plugins hear about the request before the gateway does (real-time observers).
_ctx._fire_approval_hook("pre_approval_request", **payload)
@@ -154,7 +163,7 @@ def _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)
+19 -19
View File
@@ -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:
+10 -2
View File
@@ -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)
+52 -45
View File
@@ -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"})
+14 -6
View File
@@ -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"))
+35 -14
View File
@@ -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 ───────────────────────────────────────────────────────────────
+3 -1
View File
@@ -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")
+79 -129
View File
@@ -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 ──────────────────────────────────────────
+211
View File
@@ -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": <locked so far>, "timed_out": True}`` instead of None.
"""
req = ServerRequest(sid, method, params, qids=qids)
_register(req)
timed_out = False
try:
timed_out = not req.event.wait(timeout)
finally:
with _lock:
_open.pop(req.id, None)
if timed_out:
_emit_cancel(req, "timeout")
if req.qids is not None:
return {"answers": dict(req.locked), "timed_out": True}
return None
return req.result if req.answered else None
def send_async(method: str, sid: str, params: dict, on_result: Callable[[dict | None], None]) -> Callable[[str], None]:
"""Send one request whose wait is owned elsewhere (the approval queue's own timeout). ``on_result``
runs on the dispatching thread when the response lands. Returns ``settle(reason)``: call it when
the underlying wait ends; if the request is still open it is withdrawn with ``request.cancel``."""
req = ServerRequest(sid, method, params, on_result=on_result)
_register(req)
def settle(reason: str) -> None:
with _lock:
still_open = _open.pop(req.id, None) is not None
if still_open:
_emit_cancel(req, reason)
return settle
def resolve_response(frame: dict) -> bool:
"""Route one client response frame to its open request. False when nothing is waiting for that id
(already timed out / cancelled, or owned by another process — see the compute-host bridge)."""
rid = frame.get("id")
if not isinstance(rid, str):
return False
with _lock:
req = _open.get(rid)
if req is None:
return False
if req.on_result is not None:
_open.pop(rid, None)
if "error" in frame:
logger.debug("server request %s (%s) answered with error: %s", rid, req.method, frame.get("error"))
req.result, req.answered = None, False
else:
result = frame.get("result")
req.result = result if isinstance(result, dict) else {}
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()
+21 -114
View File
@@ -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<string>()
// When a clarify prompt is dismissed without an answer (the backend _block
// timed out and returned an empty string), the live ClarifyPrompt overlay is
// When a clarify prompt is dismissed without an answer (the backend request
// timed out and returned no answer), the live ClarifyPrompt overlay is
// left set until the next turn's idle() silently nulls it — so the question
// and options vanish from the screen while the agent's follow-up still refers
// to them. The reliable signal is the clarify tool's own tool.complete (and,
@@ -1257,124 +1258,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
case 'clarify.request': {
if (!ev.payload) {
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
}
@@ -0,0 +1,114 @@
import type { ServerRequest } from '@hermes/shared/json-rpc-channel'
import type { ClarifyBatchQuestion } from '../types.js'
import { patchOverlayState } from './overlayStore.js'
import { rememberServerRequest } from './serverRequestStore.js'
export interface ServerRequestHandlerContext {
ringPromptBell: () => void
setStatus: (status: string) => void
}
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const strList = (v: unknown): null | string[] =>
Array.isArray(v) && v.length > 0 ? v.filter((c): c is string => typeof c === 'string') : null
/**
* The Ink TUI's answer to the backend's server→client requests
* (`tui_gateway/server_requests.py`). Each method opens its overlay card;
* the card's answer path resolves the request through `serverRequestStore`.
* Methods the terminal cannot answer (desktop GUI bridges: `preview.*`,
* `window.read`, `tour`, `mcp.setup`, `terminal.read`, the vault card
* prompts) return `false` so the channel answers `-32601` and the tool
* fails fast instead of waiting out its deadline.
*/
export function createServerRequestHandler(ctx: ServerRequestHandlerContext): (request: ServerRequest) => boolean {
const { ringPromptBell, setStatus } = ctx
const open = (request: ServerRequest, status: string) => {
rememberServerRequest(request)
setStatus(status)
if (!request.replayed) {
ringPromptBell()
}
}
return request => {
const p = request.params
switch (request.method) {
case 'clarify': {
const batch: ClarifyBatchQuestion[] = (Array.isArray(p.questions) ? (p.questions as unknown[]) : [])
.map(raw => (raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}))
.filter(q => str(q.qid) && str(q.question).trim())
.map(q => ({
choices: strList(q.choices),
multiSelect: q.multi_select === true,
qid: str(q.qid),
question: str(q.question).trim()
}))
const answers =
p.answers && typeof p.answers === 'object'
? Object.fromEntries(
Object.entries(p.answers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
: {}
patchOverlayState({
clarify: batch.length
? { answers, choices: null, question: '', questions: batch, requestId: request.id }
: { choices: strList(p.choices), question: str(p.question), requestId: request.id }
})
open(request, 'waiting for input…')
return true
}
case 'approval': {
patchOverlayState({
approval: {
// Only an explicit false (tirith warning) drops the permanent-allow option.
allowPermanent: p.allow_permanent !== false,
choices: strList(p.choices) ?? undefined,
command: str(p.command),
description: str(p.description) || 'dangerous command',
requestId: request.id,
smartDenied: p.smart_denied === true
}
})
open(request, 'approval needed')
return true
}
case 'sudo':
patchOverlayState({ sudo: { requestId: request.id } })
open(request, 'sudo password needed')
return true
case 'secret':
patchOverlayState({ secret: { envVar: str(p.env_var), prompt: str(p.prompt), requestId: request.id } })
open(request, 'secret input needed')
return true
case 'vault.unlock_prompt':
patchOverlayState({
vaultUnlock: { backend: str(p.backend), displayName: str(p.display_name), requestId: request.id }
})
open(request, `unlock ${str(p.display_name)}`)
return true
default:
return false
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { ServerRequest } from '@hermes/shared/json-rpc-channel'
// Live server→client requests (clarify, approval, sudo, …) keyed by request
// id. Overlay state keeps only the id; answering resolves the stored request
// so a re-delivered (`open_requests`) request with the same id reuses the
// same card. Module-level, like the overlay store: the gateway client and
// the Ink handlers share one instance per process.
const open = new Map<string, ServerRequest>()
export function rememberServerRequest(request: ServerRequest): void {
open.set(request.id, request)
}
export function forgetServerRequest(id: string): void {
open.delete(id)
}
/** Answer request `id` and forget it. False when nothing is open under that id (expired / already answered). */
export function respondToServerRequest(id: string, result: Record<string, unknown>): boolean {
const request = open.get(id)
if (!request) {
return false
}
open.delete(id)
request.respond(result)
return true
}
export function hasOpenServerRequest(id: string): boolean {
return open.has(id)
}
export function resetServerRequestsForTests(): void {
open.clear()
}
+14 -13
View File
@@ -5,13 +5,7 @@ import { useEffect, useRef } from 'react'
import { DASHBOARD_TUI_MODE } from '../config/env.js'
import { DOUBLE_ESC_MS, TYPING_IDLE_MS } from '../config/timing.js'
import { applyCompletion } from '../domain/slash.js'
import type {
ApprovalRespondResponse,
ConfigSetResponse,
SecretRespondResponse,
SudoRespondResponse,
VoiceRecordResponse
} from '../gatewayTypes.js'
import type { ConfigSetResponse, VoiceRecordResponse } from '../gatewayTypes.js'
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
@@ -27,6 +21,7 @@ import {
type OverlayState
} from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { respondToServerRequest } from './serverRequestStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js'
@@ -153,7 +148,9 @@ export function dismissSensitivePrompt(
patchOverlayState({ sudo: null })
sys('sudo cancelled')
return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId })
respondToServerRequest(requestId, { value: '' })
return
}
if (overlay.secret) {
@@ -162,7 +159,9 @@ export function dismissSensitivePrompt(
patchOverlayState({ secret: null })
sys('secret entry cancelled')
return rpc<SecretRespondResponse>('secret.respond', { request_id: requestId, value: '' })
respondToServerRequest(requestId, { value: '' })
return
}
if (overlay.vaultUnlock) {
@@ -171,7 +170,7 @@ export function dismissSensitivePrompt(
patchOverlayState({ vaultUnlock: null })
sys(`${overlay.vaultUnlock.displayName} stays locked`)
return rpc<SecretRespondResponse>('vault.unlock.respond', { password: '', request_id: requestId })
respondToServerRequest(requestId, { value: '' })
}
}
@@ -228,9 +227,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
}
if (overlay.approval) {
return gateway
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
respondToServerRequest(overlay.approval.requestId, { choice: 'deny' })
patchOverlayState({ approval: null })
patchTurnState({ outcome: 'denied' })
return
}
if (overlay.sudo || overlay.secret || overlay.vaultUnlock) {
+64 -22
View File
@@ -8,6 +8,7 @@ import {
useStdout,
useTerminalTitle
} from '@hermes/ink'
import { JSON_RPC_METHOD_NOT_FOUND, type ServerRequest } from '@hermes/shared/json-rpc-channel'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -22,7 +23,7 @@ import { type GatewayClient } from '../gatewayClient.js'
import type { SubagentListResponse } from '../gatewayTypes.js'
import type {
AnyGatewayEvent,
ClarifyRespondResponse,
ClarifyLockResponse,
ConfigSetResponse,
SessionActiveListResponse,
SessionCloseResponse,
@@ -49,6 +50,7 @@ import type { Msg, PanelSection, SlashCatalog } from '../types.js'
import { applyAgentSnapshot } from './agentRoster.js'
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
import { createServerRequestHandler } from './createServerRequestHandler.js'
import { createSlashHandler } from './createSlashHandler.js'
import { planGatewayRecovery } from './gatewayRecovery.js'
import { getInputSelection } from './inputSelectionStore.js'
@@ -56,6 +58,7 @@ import { type GatewayRpc, type StateSetter, type TranscriptRow } from './interfa
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { $goodVibesTick } from './petFlashStore.js'
import { scrollWithSelectionBy } from './scroll.js'
import { respondToServerRequest } from './serverRequestStore.js'
import { turnController } from './turnController.js'
import { patchTurnState, useTurnSelector } from './turnStore.js'
import { $uiState, getUiState, patchUiState } from './uiStore.js'
@@ -232,6 +235,7 @@ export function useMainApp(gw: GatewayClient) {
const colsRef = useRef(cols)
const scrollRef = useRef<null | ScrollBoxHandle>(null)
const onEventRef = useRef<(ev: AnyGatewayEvent) => void>(() => {})
const onServerRequestRef = useRef<(request: ServerRequest) => boolean>(() => false)
const sysRef = useRef<(text: string) => void>(() => {})
const submitRef = useRef<(value: string) => void>(() => {})
const submitLiteralRef = useRef<(value: string) => void>(() => {})
@@ -710,11 +714,14 @@ export function useMainApp(gw: GatewayClient) {
turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line))
patchTurnState({ turnTrail: turnController.turnTools })
rpc<ClarifyRespondResponse>('clarify.respond', { answer, request_id: clarify.requestId }).then(r => {
if (!r) {
return
}
if (!respondToServerRequest(clarify.requestId, { answer })) {
// The request already expired (request.cancel raced the keystroke): nothing to answer.
patchOverlayState({ clarify: null })
return
}
{
if (answer) {
turnController.persistedToolLabels.add(label)
appendMessage({
@@ -738,14 +745,14 @@ export function useMainApp(gw: GatewayClient) {
}
patchOverlayState({ clarify: null })
})
}
},
[appendMessage, overlay.clarify, rpc]
[appendMessage, overlay.clarify]
)
// Lock one answer of a batch clarify (clarify.respond + question_id). The
// overlay stays up until the server reports no remaining questions — the
// final lock resolves the tool and the turn continues.
// Lock one answer of a batch clarify (`clarify.lock` RPC). The overlay stays
// up until the server reports no remaining questions — the final lock
// resolves the server request and the turn continues.
const answerClarifyQuestion = useCallback(
(qid: string, answer: string) => {
const clarify = overlay.clarify
@@ -754,7 +761,7 @@ export function useMainApp(gw: GatewayClient) {
return
}
rpc<ClarifyRespondResponse & { remaining?: string[] }>('clarify.respond', {
rpc<ClarifyLockResponse>('clarify.lock', {
answer,
question_id: qid,
request_id: clarify.requestId
@@ -765,6 +772,12 @@ export function useMainApp(gw: GatewayClient) {
const answers = { ...(clarify.answers ?? {}), [qid]: answer }
if (r.status === 'expired') {
patchOverlayState({ clarify: null })
return
}
if ((r.remaining ?? []).length > 0) {
patchOverlayState({ clarify: { ...clarify, answers } })
@@ -908,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<string, unknown>, done: () => void) => rpc(method, params).then(r => r && done()),
[rpc]
)
// Answer a server→client request by id; the card closes either way (an
// expired request has nothing left to answer).
const respondWith = useCallback((requestId: string, result: Record<string, unknown>, done: () => void) => {
respondToServerRequest(requestId, result)
done()
}, [])
const answerApproval = useCallback(
(choice: string) =>
respondWith('approval.respond', { choice, session_id: ui.sid }, () => {
(choice: string) => {
if (!overlay.approval) {
return
}
respondWith(overlay.approval.requestId, { choice }, () => {
patchOverlayState({ approval: null })
patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` })
patchUiState({ status: 'running…' })
}),
[respondWith, ui.sid]
})
},
[overlay.approval, respondWith]
)
const answerSudo = useCallback(
@@ -1042,7 +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…' })
})
+18
View File
@@ -9,6 +9,7 @@ import {
DEFAULT_HEARTBEAT_DEADLINE_MS,
DEFAULT_HEARTBEAT_INTERVAL_MS,
JsonRpcRequestChannel,
type ServerRequest,
wireFrameText
} from '@hermes/shared/json-rpc-channel'
import { reconnectBackoffDelayMs } from '@hermes/shared/reconnect-backoff'
@@ -134,10 +135,15 @@ export class GatewayClient extends EventEmitter {
private readonly channel = new JsonRpcRequestChannel({
onEvent: ev => this.publish(ev as AnyGatewayEvent),
onHeartbeatFailure: () => this.onHeartbeatFailure(),
onUnhandledRequest: req => this.pushLog(`[protocol] unhandled server request: ${req.method}`),
requestTimeoutMs: REQUEST_TIMEOUT_MS,
unrefTimers: true
})
private bufferedEvents = new CircularBuffer<AnyGatewayEvent>(MAX_BUFFERED_EVENTS)
// Server→client requests (clarify, approval, sudo, …) follow the same
// mount-order contract as events: an attached session mid-turn can send one
// the instant the socket opens, before the Ink handler is registered.
private bufferedRequests: ServerRequest[] = []
private pendingExit: number | null | undefined
private ready = false
private readyTimer: ReturnType<typeof setTimeout> | null = null
@@ -155,6 +161,13 @@ export class GatewayClient extends EventEmitter {
// useInput / createGatewayEventHandler can legitimately attach many
// listeners. Default 10-cap triggers spurious warnings.
this.setMaxListeners(0)
this.channel.onRequest(request => {
if (this.subscribed) {
this.emit('request', request)
} else {
this.bufferedRequests.push(request)
}
})
}
private publish(ev: AnyGatewayEvent) {
@@ -280,6 +293,7 @@ export class GatewayClient extends EventEmitter {
// its queued microtask becomes a no-op (it captured the old generation).
this.drainGeneration += 1
this.bufferedEvents.clear()
this.bufferedRequests = []
this.pendingExit = undefined
this.stdoutRl?.close()
this.stderrRl?.close()
@@ -673,6 +687,10 @@ export class GatewayClient extends EventEmitter {
this.emit('event', ev)
}
for (const request of this.bufferedRequests.splice(0)) {
this.emit('request', request)
}
if (this.pendingExit !== undefined) {
const code = this.pendingExit
+4 -14
View File
@@ -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 ────────────────────────────────────────
+2
View File
@@ -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
}