import type { ServerRequest } from '@hermes/shared/json-rpc-channel' import type { ClarifyBatchQuestion } from '../types.js' import { patchOverlayState } from './overlayStore.js' import { rememberServerRequest } from './serverRequestStore.js' export interface ServerRequestHandlerContext { ringPromptBell: () => void setStatus: (status: string) => void } const str = (v: unknown): string => (typeof v === 'string' ? v : '') const strList = (v: unknown): null | string[] => Array.isArray(v) && v.length > 0 ? v.filter((c): c is string => typeof c === 'string') : null /** * The Ink TUI's answer to the backend's server→client requests * (`tui_gateway/server_requests.py`). Each method opens its overlay card; * the card's answer path resolves the request through `serverRequestStore`. * Methods the terminal cannot answer (desktop GUI bridges: `preview.*`, * `window.read`, `tour`, `mcp.setup`, `terminal.read`, the vault card * prompts) return `false` so the channel answers `-32601` and the tool * fails fast instead of waiting out its deadline. */ export function createServerRequestHandler(ctx: ServerRequestHandlerContext): (request: ServerRequest) => boolean { const { ringPromptBell, setStatus } = ctx const open = (request: ServerRequest, status: string) => { rememberServerRequest(request) setStatus(status) if (!request.replayed) { ringPromptBell() } } return request => { const p = request.params switch (request.method) { case 'clarify': { const batch: ClarifyBatchQuestion[] = (Array.isArray(p.questions) ? (p.questions as unknown[]) : []) .map(raw => (raw && typeof raw === 'object' ? (raw as Record) : {})) .filter(q => str(q.qid) && str(q.question).trim()) .map(q => ({ choices: strList(q.choices), multiSelect: q.multi_select === true, qid: str(q.qid), question: str(q.question).trim() })) const answers = p.answers && typeof p.answers === 'object' ? Object.fromEntries( Object.entries(p.answers as Record).filter( (entry): entry is [string, string] => typeof entry[1] === 'string' ) ) : {} patchOverlayState({ clarify: batch.length ? { answers, choices: null, question: '', questions: batch, requestId: request.id } : { choices: strList(p.choices), question: str(p.question), requestId: request.id } }) open(request, 'waiting for input…') return true } case 'approval': { patchOverlayState({ approval: { // Only an explicit false (tirith warning) drops the permanent-allow option. allowPermanent: p.allow_permanent !== false, choices: strList(p.choices) ?? undefined, command: str(p.command), description: str(p.description) || 'dangerous command', requestId: request.id, smartDenied: p.smart_denied === true } }) open(request, 'approval needed') return true } case 'sudo': patchOverlayState({ sudo: { requestId: request.id } }) open(request, 'sudo password needed') return true case 'secret': patchOverlayState({ secret: { envVar: str(p.env_var), prompt: str(p.prompt), requestId: request.id } }) open(request, 'secret input needed') return true case 'vault.unlock_prompt': patchOverlayState({ vaultUnlock: { backend: str(p.backend), displayName: str(p.display_name), requestId: request.id } }) open(request, `unlock ${str(p.display_name)}`) return true default: return false } } }