File size: 3,868 Bytes
48ea638 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | 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
}
}
}
|