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
+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: