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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user