From 95ba91862977c1cd2523496a88992d1a2a1bb1fd Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Sat, 15 Aug 2026 09:10:07 +0000 Subject: [PATCH] Add /rc remote control bridge (patch 0004) Exports the work/ commit that adds the RC bridge: the ACP tee, the outbound glance transport with reconnect and replay, the first-answer-wins interaction race, and the /rc slash command. --- ...remote-control-bridge-to-grok-glance.patch | 2396 +++++++++++++++++ 1 file changed, 2396 insertions(+) create mode 100644 patches/0004-Add-rc-remote-control-bridge-to-grok-glance.patch diff --git a/patches/0004-Add-rc-remote-control-bridge-to-grok-glance.patch b/patches/0004-Add-rc-remote-control-bridge-to-grok-glance.patch new file mode 100644 index 0000000..b4a99f7 --- /dev/null +++ b/patches/0004-Add-rc-remote-control-bridge-to-grok-glance.patch @@ -0,0 +1,2396 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: iceBear67 +Date: Sat, 15 Aug 2026 09:09:50 +0000 +Subject: [PATCH] Add /rc remote control bridge to grok-glance + +Mirror the running session to a grok-glance server over ACP, without +disturbing the terminal: no restart, no headless switch, no degraded TUI. + +The TUI is already an ACP client -- it holds an AcpAgentTx to send to the +agent and an AcpClientRx to receive from it. That pair is the tee point, so +remote control needs no changes to the agent runtime, the session actor or +the shell. It is also transport-agnostic: connect() and connect_via_leader() +yield the same AcpConnection, so one implementation covers both modes. + +Toward glance the roles invert -- grok is the Agent, glance is the Client -- +which makes glance a stock ACP client rather than something bespoke. + +Both notification rails are mirrored: the stable acp::SessionUpdate and the +xAI ExtNotification rail, whose ~60 variants carry the streaming deltas and +turn boundaries that make a transcript readable. Forwarding only the former +would give glance a transcript with the middle missing. _meta is preserved +verbatim so eventId/promptId/chunkId/isReplay still order and dedup the +stream on the far side. + +The three interaction requests -- request_permission, ask_user_question and +exit_plan_mode -- are raced rather than forwarded. This is the same set +grok's own leader broadcasts for first-answer-wins arbitration; handling +only permissions would strand a remote user the moment the agent asked a +question. The race is a channel swap: lift the real response_tx out, hand +the TUI a substitute, and let whichever side answers first win. The loser's +dialog closes by itself. + +Failure is always local-only. A glance that is unreachable, slow or killed +mid-turn reconnects with backoff and replays from a bounded ring; it can +never take the local session down with it. + +Remote actions stay attributable: a remote cancel sets cancelTrigger to +Client("glance") -- an existing variant that maps to StopGesture, the same +class as Esc -- rather than faking a keypress and lying in the session log. +Remote prompts carry clientIdentifier for the same reason. + +diff --git a/Cargo.lock b/Cargo.lock +index 3f214ce0ec69942190a8d35ca99f0b7662bee8c7..65ffbd9a021c7e7de45248a3237f52ae02e763a4 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -13696,6 +13696,7 @@ dependencies = [ + "dunce", + "enum_delegate", + "flate2", ++ "futures-util", + "git2", + "image", + "indexmap", +@@ -13724,6 +13725,7 @@ dependencies = [ + "tempfile", + "textwrap", + "tokio", ++ "tokio-tungstenite 0.27.0", + "tokio-util", + "toml", + "toml_edit 0.22.27", +diff --git a/crates/codegen/xai-grok-pager/Cargo.toml b/crates/codegen/xai-grok-pager/Cargo.toml +index db32aa6610bb1276b53842117cf99d7602a5f736..c0a2d49158def2249255c25a3abef59b6e857cad 100644 +--- a/crates/codegen/xai-grok-pager/Cargo.toml ++++ b/crates/codegen/xai-grok-pager/Cargo.toml +@@ -92,6 +92,11 @@ xai-grok-update = { path = "../xai-grok-update" } + # ACP + agent-client-protocol = { workspace = true } + xai-acp-lib = { workspace = true } ++# `/rc` remote control: outbound WebSocket to a grok-glance server. Same TLS ++# feature as xai-grok-voice and xai-grok-workspace, which is also what this ++# crate already pulls in transitively via xai-grok-voice — no new rustls weight. ++tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } ++futures-util = { workspace = true } + xai-file-utils = { path = "../xai-file-utils" } + # Crash-recovery registry of open TUI sessions. + xai-grok-active-sessions = { workspace = true } +diff --git a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md +index 1a06af17055384f657adf066af8e8b30b79d809c..1ad8905a4495a8b37a5339ede68cba8304f418bf 100644 +--- a/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md ++++ b/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md +@@ -103,6 +103,34 @@ Rename the current session. Alias: `/title`. + + `--auto` unpins a manual title and lets auto-titling resume. It applies to Build sessions only — chat conversations have no local auto-titler. It must be the only argument (`/rename --auto Something` is an error). A session cannot be named `--auto` via this command; use the dashboard rename editor (`Ctrl+R`) for that pathological case. + ++### `/rc` ++ ++Mirror the current session to a [grok-glance](https://github.com/user/grok-glance) server so you can watch and drive it from a browser. ++ ++``` ++/rc toggle ++/rc on connect (aliases: start, connect) ++/rc off disconnect (aliases: stop, disconnect) ++/rc status report the link ++``` ++ ++The terminal is not affected. `/rc` does not restart the session, switch it to headless, or hand it to another process — the TUI stays exactly as interactive as it was, and glance becomes a second view of the same session. ++ ++From either side you can follow the turn stream, send a prompt, interrupt a running turn, and answer tool-permission requests, questions, and plan approvals. **Both sides can answer; whoever answers first wins**, and the other side's dialog closes by itself. ++ ++Configure the server in `[remote_control]`: ++ ++```toml ++[remote_control] ++url = "wss://glance.example.com/api/acp/agent" ++api_key = "glance_sk_..." ++auto_start = false # connect on startup without typing /rc ++``` ++ ++`GROK_RC_URL` and `GROK_RC_API_KEY` override the file, which is worth using — a bearer token in a plaintext config file is a poor default. Mint the key with `glance apikey add ` on the server. ++ ++The link is scoped to this session and lasts until you quit; nothing is persisted. If glance is unreachable the connection retries with backoff and `/rc status` says why — a failed or dropped link never affects the local session, and `/rc off` clears a failed one. ++ + --- + + ## Model and Mode +diff --git a/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs b/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs +index 80aa6e5544434d0403cc87a5bda8628ab4e0d5c6..358a7ea9c234613bfcaf701e60315ba27d2bc7b5 100644 +--- a/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs ++++ b/crates/codegen/xai-grok-pager/src/app/acp_handler/mod.rs +@@ -152,6 +152,16 @@ use workflow_ingest::*; + /// background agent must still land in its own scrollback so the user sees + /// the full turn after switching back. + pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool { ++ // `/rc` tee. Every inbound ACP message funnels through here -- the event ++ // loop calls this both on `recv` and inside its drain batch -- so this one ++ // line mirrors the whole stream to grok-glance. It returns the message ++ // unchanged except for interactive reverse-requests, whose reply channel is ++ // swapped for a race between the terminal and the browser. When remote ++ // control is off it is a plain move. See [`crate::rc`]. ++ let msg = match app.rc.as_ref() { ++ Some(rc) => rc.intercept(msg), ++ None => msg, ++ }; + match msg { + AcpClientMessage::SessionNotification(notif) => { + let mut meta = NotificationMeta::from_json(notif.request.meta.as_ref()); +diff --git a/crates/codegen/xai-grok-pager/src/app/actions.rs b/crates/codegen/xai-grok-pager/src/app/actions.rs +index 1d205af8f0ba4ed065087a84e5050d35b2edd804..1644f044962b9fb1d9c4573b2b02db4798ecafda 100644 +--- a/crates/codegen/xai-grok-pager/src/app/actions.rs ++++ b/crates/codegen/xai-grok-pager/src/app/actions.rs +@@ -30,6 +30,22 @@ pub enum SwitchModelError { + /// Any other failure (network, auth, server error, etc.). + Other(String), + } ++/// What `/rc` was asked to do. ++/// ++/// One `Action` with a sub-enum rather than four variants: turning remote ++/// control on, off, and explaining it share all their preconditions (a live ++/// session, a resolvable `[remote_control]` config) and belong in one handler. ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++pub enum RemoteControlAction { ++ /// Bare `/rc` — on if off, off if on. ++ Toggle, ++ /// `/rc on` — idempotent; reports the current state if already connected. ++ On, ++ /// `/rc off` — close the glance link. The session itself is untouched. ++ Off, ++ /// `/rc status` — where the bridge is, without changing it. ++ Status, ++} + /// Synchronous, side-effect-free user intent. + /// + /// Produced by [`super::input`] from key/mouse events. +@@ -714,6 +730,11 @@ pub enum Action { + /// tasks as a system block (`/tasks`). The surface minimal mode uses in + /// place of the `TasksPane`. + ShowTasks, ++ /// `/rc` — mirror this session to a grok-glance server so it can also be ++ /// watched and driven from a browser. Deliberately additive: the terminal ++ /// session keeps running exactly as it was, and nothing here can restart it, ++ /// switch it to headless, or hand it over. ++ RemoteControl(RemoteControlAction), + /// Show the current plan: preview popover if exists, toast if not. + ShowPlan, + /// Enter plan mode. If a description is provided, also start a turn +diff --git a/crates/codegen/xai-grok-pager/src/app/app_view.rs b/crates/codegen/xai-grok-pager/src/app/app_view.rs +index 5a960e588c81ecc9d642217455fa28a87fc84b28..c7a0e0e729560569dac2e603a6178cec81dfd121 100644 +--- a/crates/codegen/xai-grok-pager/src/app/app_view.rs ++++ b/crates/codegen/xai-grok-pager/src/app/app_view.rs +@@ -1217,6 +1217,18 @@ pub struct AppView { + /// combinations are unrepresentable; production mutates it only through the + /// `AppView::voice_*` transition methods. + pub voice_state: VoiceState, ++ /// Live `/rc` bridge to grok-glance, `None` while remote control is off. ++ /// Lives here rather than in the event loop because the tee runs on the ACP ++ /// receive path, which is reached with `&mut AppView` and nothing else. ++ pub(crate) rc: Option, ++ /// Last state the bridge reported. Separate from [`Self::rc`] so `/rc ++ /// status` can still explain a bridge that gave up (bad API key) after its ++ /// task exited and the handle went away. ++ pub(crate) rc_state: crate::rc::RcState, ++ /// Parked by `/rc`, consumed at the top of the event loop, which is the only ++ /// place that can own the bridge's event receiver. Boxed to keep this large ++ /// struct's size unchanged. See [`crate::rc::RcStart`]. ++ pub(crate) rc_pending_start: Option>, + } + /// Reshow window elapsed? None/0 = never. Unparseable ack fails open (show). + fn privacy_banner_reshow_elapsed(acked_at: &str, reshow_days: Option) -> bool { +@@ -1648,6 +1660,9 @@ impl AppView { + voice_auth: None, + voice_cmd_tx: None, + voice_state: VoiceState::Idle, ++ rc: None, ++ rc_state: crate::rc::RcState::Off, ++ rc_pending_start: None, + } + } + /// Seed `deferred_model_switch` from CLI `-m`. The CLI effort token is +diff --git a/crates/codegen/xai-grok-pager/src/app/app_view_tests.rs b/crates/codegen/xai-grok-pager/src/app/app_view_tests.rs +index 272f3b11dcf8b83935622dd01142bb4a15e2e7fc..2cd3d0eb554b1a4d9572efa3353c1db48aa178a0 100644 +--- a/crates/codegen/xai-grok-pager/src/app/app_view_tests.rs ++++ b/crates/codegen/xai-grok-pager/src/app/app_view_tests.rs +@@ -305,6 +305,9 @@ pub(crate) fn test_app() -> AppView { + voice_auth: None, + voice_cmd_tx: None, + voice_state: VoiceState::Idle, ++ rc: None, ++ rc_state: crate::rc::RcState::Off, ++ rc_pending_start: None, + } + } + pub(crate) fn test_app_with_agent() -> AppView { +diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs +index ac6d52ad658122ffefe10ec7391924c558f78860..d46d5c8b2ff31f94627843f029735e24c1aaa132 100644 +--- a/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs ++++ b/crates/codegen/xai-grok-pager/src/app/dispatch/mod.rs +@@ -27,6 +27,7 @@ mod notes; + mod permissions; + mod prompt; + mod queue; ++pub(crate) mod rc; + mod rewind; + mod router; + mod session; +diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/rc.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/rc.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..43a3fb0c4694c7da7741b683f5286c71da554d3c +--- /dev/null ++++ b/crates/codegen/xai-grok-pager/src/app/dispatch/rc.rs +@@ -0,0 +1,271 @@ ++//! `/rc` -- remote control: start, stop, and report the grok-glance bridge. ++//! ++//! This module is the whole TUI-facing surface of remote control. Everything ++//! below it ([`crate::rc`]) is protocol and transport and knows nothing about ++//! views; everything above it is one slash command. ++//! ++//! Starting is deliberately two-step -- the handler parks an [`RcStart`] and the ++//! event loop spawns from it -- because the bridge's event receiver has to live ++//! as a local of the event loop. See [`RcStart`] for why. ++ ++use crate::app::actions::{Effect, RemoteControlAction}; ++use crate::app::app_view::{ActiveView, AppView}; ++use crate::rc::{RcEvent, RcStart, RcState, SessionMeta}; ++use crate::scrollback::block::RenderBlock; ++ ++pub(super) fn dispatch_remote_control(app: &mut AppView, what: RemoteControlAction) -> Vec { ++ match what { ++ RemoteControlAction::Status => { ++ let text = status_text(app); ++ report(app, text) ++ } ++ RemoteControlAction::Off => stop(app), ++ RemoteControlAction::On => start(app), ++ RemoteControlAction::Toggle => { ++ if app.rc.is_some() || app.rc_pending_start.is_some() { ++ stop(app) ++ } else { ++ start(app) ++ } ++ } ++ } ++} ++ ++fn start(app: &mut AppView) -> Vec { ++ if app.rc.is_some() || app.rc_pending_start.is_some() { ++ let text = status_text(app); ++ return report(app, format!("Remote control is already on.\n{text}")); ++ } ++ ++ // The bridge mirrors one session; without one there is nothing to mirror and ++ // glance would show an empty shell. ++ if app.active_session_id().is_none() { ++ return report( ++ app, ++ "Remote control needs a live session. Start one, then run /rc.".to_owned(), ++ ); ++ } ++ ++ let settings = match crate::rc::load_settings() { ++ Ok(settings) => settings, ++ Err(why) => return report(app, why), ++ }; ++ ++ let url = settings.url.clone(); ++ let meta = session_meta(app); ++ app.rc_pending_start = Some(Box::new(RcStart { settings, meta })); ++ app.rc_state = RcState::Connecting; ++ report( ++ app, ++ format!( ++ "Remote control: connecting to {url}\n\ ++ This terminal stays fully interactive -- glance becomes a second view of the \ ++ same session, not a replacement for it. Prompts, interrupts and tool approvals \ ++ work from either side; whichever answers first wins.\n\ ++ /rc off closes the link." ++ ), ++ ) ++} ++ ++fn stop(app: &mut AppView) -> Vec { ++ let was_pending = app.rc_pending_start.take().is_some(); ++ let handle = app.rc.take(); ++ // A bridge that gave up has already dropped its handle, but `rc_state` still ++ // holds the reason. That is not "already off" -- `/rc off` is how the user ++ // clears it. ++ let was_failed = app.rc_state.is_active() && handle.is_none() && !was_pending; ++ app.rc_state = RcState::Off; ++ ++ if let Some(handle) = handle { ++ // Fire-and-forget: the bridge closes its socket and exits, and the event ++ // loop drops its receiver when the channel closes. ++ handle.shutdown(); ++ return report( ++ app, ++ "Remote control: off. The glance link is closed; this session is unaffected." ++ .to_owned(), ++ ); ++ } ++ if was_pending { ++ return report(app, "Remote control: cancelled before connecting.".to_owned()); ++ } ++ if was_failed { ++ return report( ++ app, ++ "Remote control: off. The failed link is cleared; /rc tries again.".to_owned(), ++ ); ++ } ++ report(app, "Remote control is already off.".to_owned()) ++} ++ ++fn status_text(app: &AppView) -> String { ++ match (&app.rc, &app.rc_state) { ++ (None, RcState::Off) => { ++ "Remote control: off. Run /rc to mirror this session to glance.".to_owned() ++ } ++ // The bridge gave up (rejected key, unusable URL) and its handle is gone, ++ // but the reason still matters -- reporting a bare "off" here would hide it. ++ (None, state) => format!("Remote control: {} (link closed)", state.summary()), ++ (Some(handle), state) => { ++ format!("Remote control: {}\nGlance: {}", state.summary(), handle.url()) ++ } ++ } ++} ++ ++/// Session identity for glance's session list, so a browser can label the ++/// session without asking the agent anything. ++fn session_meta(app: &AppView) -> SessionMeta { ++ let session_id = app.active_session_id().map(str::to_owned); ++ let (title, model) = match app.active_view { ++ ActiveView::Agent(id) => match app.agents.get(&id) { ++ Some(agent) => ( ++ agent.generated_session_title.clone(), ++ agent.session.models.current_model_name(), ++ ), ++ None => (None, None), ++ }, ++ _ => (None, None), ++ }; ++ SessionMeta { ++ session_id, ++ cwd: Some(app.cwd.display().to_string()), ++ title, ++ model, ++ hostname: hostname(), ++ version: Some(xai_grok_version::VERSION.to_owned()), ++ } ++} ++ ++/// Best-effort host label for the session list. Purely cosmetic -- a user with ++/// several machines connected needs to tell them apart. ++fn hostname() -> Option { ++ std::env::var("HOSTNAME") ++ .ok() ++ .or_else(|| std::env::var("COMPUTERNAME").ok()) ++ .map(|h| h.trim().to_owned()) ++ .filter(|h| !h.is_empty()) ++} ++ ++/// Bridge -> TUI. Returns whether the view needs a redraw. ++/// ++/// Called from the event loop's lowest-priority arm, mirroring ++/// [`crate::voice::handle_voice_event`]. ++pub(crate) fn handle_rc_event(app: &mut AppView, event: RcEvent) -> bool { ++ match event { ++ RcEvent::State(state) => { ++ let announce = announcement(&app.rc_state, &state); ++ app.rc_state = state; ++ match announce { ++ Some(text) => { ++ report(app, text); ++ true ++ } ++ // Reconnect churn is not worth a scrollback line each time; the ++ // status line already carries it. ++ None => true, ++ } ++ } ++ ++ // Glance answered a permission / question / plan approval first. Close ++ // the terminal's modal so the user is not staring at a dialog whose ++ // request has already been resolved. ++ RcEvent::InteractionResolvedRemotely { tool_call_id } => { ++ let mut dismissed = false; ++ for agent in app.agents.values_mut() { ++ if agent.dismiss_resolved_interaction(&tool_call_id) { ++ dismissed = true; ++ } ++ } ++ if dismissed { ++ tracing::info!(%tool_call_id, "rc: interaction answered from glance"); ++ } ++ dismissed ++ } ++ ++ RcEvent::Notice(text) => { ++ report(app, format!("Remote control: {text}")); ++ true ++ } ++ } ++} ++ ++/// Which state transitions are worth a scrollback line. ++/// ++/// Connecting -> Connected and anything -> Failed are events the user acts on. ++/// A reconnect loop is not: it can fire every few seconds during an outage, and ++/// filling the transcript with it would bury the session's actual output. ++fn announcement(previous: &RcState, next: &RcState) -> Option { ++ match (previous, next) { ++ (_, RcState::Failed { detail }) => Some(format!( ++ "Remote control failed: {detail}\nThe session is unaffected. Fix the setting and run /rc again." ++ )), ++ (RcState::Connected { .. }, RcState::Connected { .. }) => None, ++ (_, RcState::Connected { .. }) => { ++ Some("Remote control: connected to glance.".to_owned()) ++ } ++ (RcState::Reconnecting { .. }, RcState::Reconnecting { .. }) => None, ++ (_, RcState::Reconnecting { detail, .. }) => Some(format!( ++ "Remote control: lost the glance link ({detail}); reconnecting in the background." ++ )), ++ (_, RcState::Off) => Some("Remote control: off.".to_owned()), ++ (_, RcState::Connecting) => None, ++ } ++} ++ ++/// Push a system block onto the top-level agent, as `/tasks` and friends do. ++/// Falls back to a toast on session-less surfaces so a message is never lost. ++fn report(app: &mut AppView, text: String) -> Vec { ++ if let ActiveView::Agent(id) = app.active_view ++ && let Some(agent) = app.agents.get_mut(&id) ++ { ++ agent.scrollback.push_block(RenderBlock::system(text)); ++ } else { ++ app.show_toast(&text); ++ } ++ vec![] ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn reconnect_churn_is_not_announced() { ++ let first = RcState::Reconnecting { ++ attempt: 1, ++ detail: "closed".into(), ++ }; ++ let second = RcState::Reconnecting { ++ attempt: 2, ++ detail: "closed".into(), ++ }; ++ assert!(announcement(&RcState::Connected { viewers: None }, &first).is_some()); ++ assert!( ++ announcement(&first, &second).is_none(), ++ "an outage must not write one scrollback line per retry" ++ ); ++ } ++ ++ #[test] ++ fn viewer_count_changes_are_silent_but_failures_are_not() { ++ let one = RcState::Connected { viewers: Some(1) }; ++ let two = RcState::Connected { viewers: Some(2) }; ++ assert!(announcement(&one, &two).is_none()); ++ ++ let failed = RcState::Failed { ++ detail: "HTTP 401".into(), ++ }; ++ let text = announcement(&one, &failed).expect("a failure must be reported"); ++ assert!(text.contains("HTTP 401"), "the reason has to survive: {text}"); ++ assert!( ++ text.contains("session is unaffected"), ++ "the user must be told the local session is fine: {text}" ++ ); ++ } ++ ++ #[test] ++ fn connecting_is_silent_because_the_command_already_said_so() { ++ assert!(announcement(&RcState::Off, &RcState::Connecting).is_none()); ++ assert!(announcement(&RcState::Connecting, &RcState::Connected { viewers: None }).is_some()); ++ } ++} +diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs +index 61b37dcd39deb4d6dde7873f6ad29ef68c537b41..f5895ee6181bc0e50766c538f7110e246fbfba8b 100644 +--- a/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs ++++ b/crates/codegen/xai-grok-pager/src/app/dispatch/router.rs +@@ -48,6 +48,7 @@ use super::prompt::{ + }; + use super::queue; + use super::queue::dispatch_drain_queue; ++use super::rc::dispatch_remote_control; + use super::rewind::{ + dispatch_inline_edit_submit, dispatch_rewind, dispatch_rewind_cancel_offer, + dispatch_rewind_confirm, dispatch_rewind_confirm_never_ask, dispatch_rewind_dismiss, +@@ -152,6 +153,12 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { + if let Some(tx) = &app.voice_cmd_tx { + let _ = tx.try_send(xai_grok_voice::VoiceCommand::Shutdown); + } ++ // Close the glance socket on the way out, so a viewer sees the ++ // session end rather than a link that simply stops responding. ++ // Fire-and-forget, like voice: quitting must not wait on the network. ++ if let Some(rc) = &app.rc { ++ rc.shutdown(); ++ } + let mut effects = unregister_all_active_sessions(app); + effects.push(Effect::Quit); + effects +@@ -1022,6 +1029,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec { + Action::ManageBilling => dispatch_manage_billing(app), + Action::ShowQueue => dispatch_show_queue(app), + Action::ShowTasks => dispatch_show_tasks(app), ++ Action::RemoteControl(what) => dispatch_remote_control(app, what), + Action::ShowPlan => dispatch_show_plan(app), + Action::EnterPlanMode { description } => dispatch_enter_plan_mode(app, description), + Action::SetPlanMode(kind) => set_plan_mode(app, kind), +diff --git a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs +index 4aa72560f1ff7c9158e7905f53a83ec51cbea714..270ec9d3d2a19550cabd1884bf4eeb2ce77326e0 100644 +--- a/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs ++++ b/crates/codegen/xai-grok-pager/src/app/dispatch/tests/mod.rs +@@ -293,6 +293,9 @@ fn test_app() -> AppView { + voice_auth: None, + voice_cmd_tx: None, + voice_state: VoiceState::Idle, ++ rc: None, ++ rc_state: crate::rc::RcState::Off, ++ rc_pending_start: None, + } + } + /// Build a default `AgentSession` for +diff --git a/crates/codegen/xai-grok-pager/src/app/event_loop.rs b/crates/codegen/xai-grok-pager/src/app/event_loop.rs +index 134a05c92417bf9038e9c784c0ee34b9b9d5bec7..1d31be3fe37b6cbbdf62c498fc30cb973d2d6e0b 100644 +--- a/crates/codegen/xai-grok-pager/src/app/event_loop.rs ++++ b/crates/codegen/xai-grok-pager/src/app/event_loop.rs +@@ -1707,6 +1707,12 @@ pub(crate) async fn run( + let mut voice_rx = None::>; + let voice_auth_factory = connection.auth_manager.clone(); + ++ // `/rc` bridge events. Owned by this loop, for the same reason as ++ // `voice_rx`: an `AppView`-held receiver would need `&mut app` inside the ++ // `select!` below, where it would collide with every arm that mutates the ++ // app. `None` until `/rc` parks an `RcStart` (see `crate::rc::RcStart`). ++ let mut rc_rx = None::>; ++ + // Animation tick: only scheduled when there are running entries. + let mut tick_interval = tick_interval; + let mut animation_tick_at: Option = None; +@@ -2128,6 +2134,19 @@ pub(crate) async fn run( + presenter.request_presentation(&mut app, terminal, false); + } + ++ // Lazy `/rc` bridge, armed by the dispatcher one iteration ago. Spawned ++ // here rather than in the dispatcher because `rc_rx` must be a local of ++ // this loop. Dialing happens inside the task, so this never blocks: the ++ // socket comes up (or does not) as an `RcEvent` later, and the session ++ // carries on either way. ++ if let Some(start) = app.rc_pending_start.take() { ++ let (handle, event_rx) = ++ crate::rc::start(start.settings, start.meta, app.acp_tx.clone()); ++ tracing::info!(url = handle.url(), "rc: remote control bridge starting"); ++ app.rc = Some(handle); ++ rc_rx = Some(event_rx); ++ } ++ + // Stop voice if the user has left the recording session (see method). + app.enforce_voice_session_bound(); + +@@ -3023,6 +3042,37 @@ pub(crate) async fn run( + presenter.request(false); + } + ++ // `/rc` bridge events: connection state, notices, and interactions ++ // that glance answered before the terminal did. Low frequency by ++ // construction — a connect, a disconnect, the occasional approval — ++ // so it cannot starve the arms below it. Kept above voice so a modal ++ // closing because the browser answered is never queued behind a hot ++ // mic's interim transcripts. ++ rc_ev = async { ++ match rc_rx.as_mut() { ++ Some(rx) => rx.recv().await, ++ None => std::future::pending().await, ++ } ++ } => { ++ match rc_ev { ++ Some(rc_ev) => { ++ if dispatch::rc::handle_rc_event(&mut app, rc_ev) { ++ presenter.request(false); ++ } ++ } ++ // The bridge task exited: `/rc off`, quit, or it gave up on a ++ // rejected key. `rc_state` already holds the reason, so drop ++ // the handle and revert to pending() (no hot-loop on None). ++ None => { ++ rc_rx = None; ++ if app.rc.take().is_some() { ++ tracing::info!("rc: bridge task ended"); ++ presenter.request(false); ++ } ++ } ++ } ++ } ++ + // Voice STT — DELIBERATELY THE LAST (lowest-priority) arm. In a + // biased select, an arm that is ready on most iterations masks every + // arm below it. A hot mic (toggle capture stays open across pauses) +diff --git a/crates/codegen/xai-grok-pager/src/lib.rs b/crates/codegen/xai-grok-pager/src/lib.rs +index 2747f2a7a35f6c4fa444cc830cb06e5049e4e594..a8309e6b5ecfd7c992afaafbda0e10ad370486a4 100644 +--- a/crates/codegen/xai-grok-pager/src/lib.rs ++++ b/crates/codegen/xai-grok-pager/src/lib.rs +@@ -44,6 +44,8 @@ pub mod notifications; + pub mod obf; + pub mod plugin_cmd; + pub mod pty_wrap; ++/// `/rc` remote control: mirrors the live session to a grok-glance server. ++pub(crate) mod rc; + pub mod recent_dirs; + pub mod scrollback; + pub mod search; +diff --git a/crates/codegen/xai-grok-pager/src/rc/mod.rs b/crates/codegen/xai-grok-pager/src/rc/mod.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..402a021fea440914acba09b9c80e2401f79d8e40 +--- /dev/null ++++ b/crates/codegen/xai-grok-pager/src/rc/mod.rs +@@ -0,0 +1,338 @@ ++//! `/rc` -- remote control: mirror the live session to a grok-glance server. ++//! ++//! # Why this is a tee and not another entry point ++//! ++//! `grok` already speaks ACP four ways (`agent stdio`, `serve`, `leader`, ++//! `headless`), and every one of them *replaces* the TUI. Remote control must ++//! not: the terminal session stays exactly as interactive as it was, and the ++//! browser becomes a second peer on the same session rather than its successor. ++//! ++//! What makes that cheap is that the TUI is *itself* an ACP client. It holds an ++//! [`AcpAgentTx`](xai_acp_lib::AcpAgentTx) to send to the agent and an ++//! `AcpClientRx` to receive from it. Everything the terminal knows arrives on ++//! that receiver. So remote control needs no changes to the agent runtime, the ++//! session actor, or the shell -- only a tee on the receive path and a clone of ++//! the send half: ++//! ++//! ```text ++//! ┌─────────────┐ ++//! agent ── AcpClientMessage ─▶│ intercept │─▶ acp_handler ─▶ TUI ++//! └──────┬──────┘ ++//! │ copy ++//! ▼ ++//! bridge task ──WS──▶ glance ──▶ browser ++//! │ ++//! agent ◀── acp_send(prompt/cancel) ───────┘ ++//! ``` ++//! ++//! The tee is transport-agnostic: both `connect()` and `connect_via_leader()` ++//! yield the same `AcpConnection`, so `/rc` behaves identically whether or not ++//! the session is leader-hosted. ++//! ++//! # First responder wins ++//! ++//! Interactive reverse-requests (permission, `ask_user_question`, ++//! `exit_plan_mode`) carry their own reply channel. The tee lifts the real ++//! `response_tx` out, hands the TUI a substitute, forwards a copy to glance, and ++//! races the two answers -- whichever arrives first is the answer, and the other ++//! side is told to retract its dialog. Neither side is privileged. ++//! ++//! This mirrors what the leader already does for multi-pane sessions ++//! (`is_interaction_request` + `InteractionResolved` broadcast); the same three ++//! methods are covered, so a `/rc` user is not stranded the moment the agent ++//! asks a question instead of requesting a permission. ++//! ++//! # Failure is always local-only ++//! ++//! Every failure path here degrades to "the terminal keeps working". The bridge ++//! runs in its own task, reconnects with backoff, and the tee's fast path when ++//! remote control is off is a plain move. ++ ++mod protocol; ++mod ring; ++mod tee; ++mod transport; ++ ++use std::sync::atomic::{AtomicU64, Ordering}; ++ ++use tokio::sync::{mpsc, oneshot}; ++use xai_acp_lib::AcpAgentTx; ++ ++/// Client identifier stamped on prompts and cancels that originate in the ++/// browser, so the session log attributes them to glance rather than silently ++/// impersonating the terminal. ++pub(crate) const RC_CLIENT_ID: &str = "glance"; ++ ++/// What `/rc` needs to dial out, resolved from `[remote_control]` + env. ++#[derive(Clone)] ++pub(crate) struct RcSettings { ++ pub(crate) url: String, ++ pub(crate) api_key: String, ++ pub(crate) replay_buffer: usize, ++} ++ ++/// Hand-written so the bearer token cannot reach a log line, a panic message, ++/// or a crash report through a stray `{:?}`. ++impl std::fmt::Debug for RcSettings { ++ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { ++ f.debug_struct("RcSettings") ++ .field("url", &self.url) ++ .field("api_key", &"") ++ .field("replay_buffer", &self.replay_buffer) ++ .finish() ++ } ++} ++ ++/// Session identity, sent in the `initialize` reply so glance can label the ++/// session without asking the agent anything. ++#[derive(Clone, Debug, Default)] ++pub(crate) struct SessionMeta { ++ pub(crate) session_id: Option, ++ pub(crate) cwd: Option, ++ pub(crate) title: Option, ++ pub(crate) model: Option, ++ pub(crate) hostname: Option, ++ pub(crate) version: Option, ++} ++ ++impl SessionMeta { ++ fn to_json(&self) -> serde_json::Value { ++ serde_json::json!({ ++ "sessionId": self.session_id, ++ "cwd": self.cwd, ++ "title": self.title, ++ "model": self.model, ++ "hostname": self.hostname, ++ "version": self.version, ++ }) ++ } ++} ++ ++/// A start request parked on [`AppView`](crate::app::AppView) by `/rc` and ++/// consumed at the top of the event loop. ++/// ++/// The bridge cannot be spawned from the dispatch handler because its event ++/// receiver has to live as a local of the event loop -- holding it on `AppView` ++/// would put a `&mut app` borrow inside the main `select!`, where it would ++/// collide with every arm that mutates the app. Voice parks ++/// `VoiceState::ColdStart` for exactly the same reason. ++pub(crate) struct RcStart { ++ pub(crate) settings: RcSettings, ++ pub(crate) meta: SessionMeta, ++} ++ ++/// Resolve `[remote_control]` from the layered config plus env overrides. ++/// ++/// Returns a message fit to print in the scrollback rather than an error type: ++/// every failure here is "you have not configured this yet", and the useful ++/// response is a sentence naming the missing key. ++pub(crate) fn load_settings() -> Result { ++ use xai_grok_shell::agent::config::RemoteControlConfig; ++ ++ // A missing file or a missing section is not an error: `GROK_RC_URL` + ++ // `GROK_RC_API_KEY` alone are a supported way to configure this, and the ++ // defaults resolve those. ++ let config = xai_grok_shell::config::load_effective_config() ++ .ok() ++ .and_then(|root| root.get("remote_control").cloned()) ++ .and_then(|value| value.try_into::().ok()) ++ .unwrap_or_default(); ++ ++ match (config.resolved_url(), config.resolved_api_key()) { ++ (Some(url), Some(api_key)) => Ok(RcSettings { ++ url, ++ api_key, ++ replay_buffer: config.replay_buffer_len(), ++ }), ++ (None, Some(_)) => Err("remote control needs a URL: set `url` under \ ++ [remote_control] in ~/.grok/config.toml, or export GROK_RC_URL" ++ .into()), ++ (Some(_), None) => Err("remote control needs an API key: set `api_key` under \ ++ [remote_control] in ~/.grok/config.toml, or export GROK_RC_API_KEY" ++ .into()), ++ (None, None) => Err("remote control is not configured. Add [remote_control] with \ ++ `url` and `api_key` to ~/.grok/config.toml (or export GROK_RC_URL and \ ++ GROK_RC_API_KEY), then run /rc again." ++ .into()), ++ } ++} ++ ++/// Where the bridge is, for the status line and `/rc status`. ++#[derive(Clone, Debug, PartialEq, Eq, Default)] ++pub(crate) enum RcState { ++ #[default] ++ Off, ++ Connecting, ++ Connected { ++ /// Browsers currently attached, as reported by glance. `None` until it ++ /// says -- distinct from `Some(0)`, which means "connected, nobody ++ /// watching". ++ viewers: Option, ++ }, ++ /// Lost the socket and retrying. The session is unaffected. ++ Reconnecting { ++ attempt: u32, ++ detail: String, ++ }, ++ /// Gave up, or was rejected in a way retrying cannot fix (bad API key). ++ Failed { ++ detail: String, ++ }, ++} ++ ++impl RcState { ++ pub(crate) fn is_active(&self) -> bool { ++ !matches!(self, Self::Off) ++ } ++ ++ /// One-line summary for `/rc status` and the footer. ++ pub(crate) fn summary(&self) -> String { ++ match self { ++ Self::Off => "off".into(), ++ Self::Connecting => "connecting…".into(), ++ Self::Connected { viewers: None } => "connected".into(), ++ Self::Connected { viewers: Some(0) } => "connected (no viewers)".into(), ++ Self::Connected { viewers: Some(1) } => "connected (1 viewer)".into(), ++ Self::Connected { viewers: Some(n) } => format!("connected ({n} viewers)"), ++ Self::Reconnecting { attempt, detail } => { ++ format!("reconnecting (attempt {attempt}): {detail}") ++ } ++ Self::Failed { detail } => format!("failed: {detail}"), ++ } ++ } ++} ++ ++/// Bridge -> event loop. Delivered on the `/rc` arm of the main `select!`. ++#[derive(Debug)] ++pub(crate) enum RcEvent { ++ State(RcState), ++ /// Glance answered an interaction before the terminal did: retract the ++ /// local modal for this tool call. ++ InteractionResolvedRemotely { tool_call_id: String }, ++ /// Something worth a line in the scrollback (remote prompt accepted, remote ++ /// stop, a rejected request). Not an error path -- errors go through ++ /// [`RcState::Failed`]. ++ Notice(String), ++} ++ ++/// Event loop / tee -> bridge. One channel so ordering between a mirrored ++/// update and the interaction that follows it is preserved. ++pub(crate) enum ToBridge { ++ /// A mirrored notification, pre-framed as the exact text frame to send. ++ Frame(String), ++ /// An interactive reverse-request that needs an answer from either side. ++ Interaction(Interaction), ++ /// The terminal answered first -- retract glance's dialog and drop the ++ /// pending entry so a late remote answer is ignored. ++ InteractionSettled { id: u64 }, ++ Shutdown, ++} ++ ++/// One interaction in flight, as handed to the bridge. ++pub(crate) struct Interaction { ++ /// JSON-RPC id used toward glance; also the key of the pending table. ++ pub(crate) id: u64, ++ /// ACP method name, forwarded verbatim so glance sees real ACP. ++ pub(crate) method: &'static str, ++ pub(crate) params: serde_json::Value, ++ /// Identifies the modal on both sides; what a retraction is keyed by. ++ pub(crate) tool_call_id: Option, ++ /// Resolved with glance's raw `result` when it answers first. ++ pub(crate) remote_tx: oneshot::Sender, ++} ++ ++/// Handle held by `AppView` while remote control is on. ++pub(crate) struct RcHandle { ++ to_bridge: mpsc::UnboundedSender, ++ events: mpsc::UnboundedSender, ++ url: String, ++ next_id: AtomicU64, ++} ++ ++impl RcHandle { ++ pub(crate) fn url(&self) -> &str { ++ &self.url ++ } ++ ++ /// True once the bridge task has gone away. The tee checks this so a dead ++ /// bridge degrades to a plain passthrough instead of accumulating sends ++ /// into a closed channel. ++ pub(crate) fn is_closed(&self) -> bool { ++ self.to_bridge.is_closed() ++ } ++ ++ fn next_id(&self) -> u64 { ++ self.next_id.fetch_add(1, Ordering::Relaxed) ++ } ++ ++ /// Best-effort send. A closed channel means the bridge task exited; the ++ /// session is unaffected and the next `RcEvent` will report the state. ++ fn send(&self, msg: ToBridge) { ++ let _ = self.to_bridge.send(msg); ++ } ++ ++ /// Ask the bridge to close the socket and exit. Fire-and-forget, called ++ /// from `/rc off` and from `Action::Quit`. ++ pub(crate) fn shutdown(&self) { ++ self.send(ToBridge::Shutdown); ++ } ++} ++ ++/// Start the bridge task and return the handle plus the event stream the main ++/// loop selects on. ++/// ++/// Dialing happens inside the task, so this never blocks the TUI: `/rc` returns ++/// immediately in [`RcState::Connecting`] and the outcome arrives as an event. ++pub(crate) fn start( ++ settings: RcSettings, ++ meta: SessionMeta, ++ acp_tx: AcpAgentTx, ++) -> (RcHandle, mpsc::UnboundedReceiver) { ++ let (to_bridge_tx, to_bridge_rx) = mpsc::unbounded_channel(); ++ let (event_tx, event_rx) = mpsc::unbounded_channel(); ++ ++ let handle = RcHandle { ++ to_bridge: to_bridge_tx, ++ events: event_tx.clone(), ++ url: settings.url.clone(), ++ next_id: AtomicU64::new(1), ++ }; ++ ++ tokio::spawn(transport::run(settings, meta, acp_tx, to_bridge_rx, event_tx)); ++ ++ (handle, event_rx) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn state_summaries_distinguish_no_viewers_from_unknown() { ++ // `Some(0)` is "connected, nobody watching"; `None` is "glance has not ++ // told us yet". Collapsing them would make `/rc status` lie right after ++ // connecting. ++ assert_eq!(RcState::Connected { viewers: None }.summary(), "connected"); ++ assert_eq!( ++ RcState::Connected { viewers: Some(0) }.summary(), ++ "connected (no viewers)" ++ ); ++ assert_eq!( ++ RcState::Connected { viewers: Some(2) }.summary(), ++ "connected (2 viewers)" ++ ); ++ } ++ ++ #[test] ++ fn only_off_is_inactive() { ++ assert!(!RcState::Off.is_active()); ++ assert!(RcState::Connecting.is_active()); ++ assert!( ++ RcState::Failed { ++ detail: "bad key".into() ++ } ++ .is_active(), ++ "a failed bridge is still 'on' -- /rc must report it, not look off" ++ ); ++ } ++} +diff --git a/crates/codegen/xai-grok-pager/src/rc/protocol.rs b/crates/codegen/xai-grok-pager/src/rc/protocol.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..4bba548cc7789aee2c5af941ce68e2d86ef3a127 +--- /dev/null ++++ b/crates/codegen/xai-grok-pager/src/rc/protocol.rs +@@ -0,0 +1,165 @@ ++//! JSON-RPC 2.0 framing for the grok <-> glance link. ++//! ++//! The link is ACP with the roles inverted relative to the TUI: over this ++//! socket **grok is the Agent and glance is the Client**. That is deliberate -- ++//! it makes glance a stock ACP client that happens to talk to a session it did ++//! not create, instead of a bespoke protocol only grok can speak. ++//! ++//! Only the framing lives here. What each method *means* is in ++//! [`transport`](super::transport). ++ ++use serde::Deserialize; ++ ++pub(crate) const JSONRPC_VERSION: &str = "2.0"; ++ ++// --- methods glance may call on us ----------------------------------------- ++ ++/// Handshake. Answered with our capabilities plus session metadata, so glance ++/// can label the session without a second round-trip. ++pub(crate) const M_INITIALIZE: &str = "initialize"; ++/// The one session this bridge mirrors. `/rc` is session-scoped by design. ++pub(crate) const M_SESSION_LIST: &str = "session/list"; ++/// Send a prompt as if it had been typed in the terminal. ++pub(crate) const M_SESSION_PROMPT: &str = "session/prompt"; ++/// Interrupt the running turn. ++pub(crate) const M_SESSION_CANCEL: &str = "session/cancel"; ++/// Re-send the replay ring (used after a browser reattaches). ++pub(crate) const M_RC_REPLAY: &str = "x.ai/rc/replay"; ++ ++// --- methods we call on glance --------------------------------------------- ++ ++/// Mirrored standard session update. ++pub(crate) const M_SESSION_UPDATE: &str = "session/update"; ++/// Bridge status: connection state, replay bounds, session metadata. ++pub(crate) const M_RC_STATUS: &str = "x.ai/rc/status"; ++/// An interaction we forwarded was answered in the terminal first; glance must ++/// retract its dialog. The mirror image of grok's own `InteractionResolved`. ++pub(crate) const M_RC_INTERACTION_CANCELLED: &str = "x.ai/rc/interaction_cancelled"; ++ ++// --- error codes ------------------------------------------------------------ ++ ++pub(crate) const E_METHOD_NOT_FOUND: i32 = -32601; ++pub(crate) const E_INVALID_PARAMS: i32 = -32602; ++pub(crate) const E_INTERNAL: i32 = -32603; ++ ++/// A frame arriving from glance. ++/// ++/// One struct for all three JSON-RPC shapes, disambiguated by which fields are ++/// present: `method` + `id` is a request, `method` alone is a notification, ++/// `result`/`error` is a response to something we sent. Parsing permissively ++/// and classifying afterwards means a frame we do not understand produces a ++/// JSON-RPC error rather than killing the socket -- which would take the ++/// mirror down over a cosmetic disagreement. ++#[derive(Debug, Deserialize)] ++pub(crate) struct InboundFrame { ++ #[serde(default)] ++ pub(crate) id: Option, ++ #[serde(default)] ++ pub(crate) method: Option, ++ #[serde(default)] ++ pub(crate) params: Option, ++ #[serde(default)] ++ pub(crate) result: Option, ++ #[serde(default)] ++ pub(crate) error: Option, ++} ++ ++impl InboundFrame { ++ /// A response carries no `method` and has exactly one of `result`/`error`. ++ pub(crate) fn is_response(&self) -> bool { ++ self.method.is_none() && (self.result.is_some() || self.error.is_some()) ++ } ++} ++ ++/// Serialise a request we are sending to glance. ++pub(crate) fn request(id: u64, method: &str, params: serde_json::Value) -> String { ++ encode(serde_json::json!({ ++ "jsonrpc": JSONRPC_VERSION, ++ "id": id, ++ "method": method, ++ "params": params, ++ })) ++} ++ ++/// Serialise a notification we are sending to glance. ++pub(crate) fn notification(method: &str, params: serde_json::Value) -> String { ++ encode(serde_json::json!({ ++ "jsonrpc": JSONRPC_VERSION, ++ "method": method, ++ "params": params, ++ })) ++} ++ ++/// Serialise a successful reply to one of glance's requests. ++pub(crate) fn response(id: serde_json::Value, result: serde_json::Value) -> String { ++ encode(serde_json::json!({ ++ "jsonrpc": JSONRPC_VERSION, ++ "id": id, ++ "result": result, ++ })) ++} ++ ++/// Serialise a failed reply. Unsupported methods get a real `method not found` ++/// rather than a faked success -- glance must be able to tell what this bridge ++/// can actually do. ++pub(crate) fn error_response(id: serde_json::Value, code: i32, message: impl Into) -> String { ++ encode(serde_json::json!({ ++ "jsonrpc": JSONRPC_VERSION, ++ "id": id, ++ "error": { "code": code, "message": message.into() }, ++ })) ++} ++ ++/// `serde_json::to_string` on a `Value` built from owned data cannot fail ++/// (no borrowed keys, no non-string map keys, no custom `Serialize`), so the ++/// fallback here is unreachable in practice. It exists so a framing bug can ++/// never panic the bridge and take the mirror down mid-turn. ++fn encode(value: serde_json::Value) -> String { ++ serde_json::to_string(&value).unwrap_or_else(|e| { ++ tracing::error!(error = %e, "rc: failed to encode frame"); ++ format!( ++ r#"{{"jsonrpc":"{JSONRPC_VERSION}","method":"{M_RC_STATUS}","params":{{"error":"encode failed"}}}}"# ++ ) ++ }) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn classifies_the_three_frame_shapes() { ++ let req: InboundFrame = ++ serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"session/prompt"}"#).unwrap(); ++ assert!(!req.is_response()); ++ assert_eq!(req.method.as_deref(), Some("session/prompt")); ++ ++ let notif: InboundFrame = ++ serde_json::from_str(r#"{"jsonrpc":"2.0","method":"session/cancel"}"#).unwrap(); ++ assert!(!notif.is_response()); ++ assert!(notif.id.is_none()); ++ ++ let resp: InboundFrame = ++ serde_json::from_str(r#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#).unwrap(); ++ assert!(resp.is_response()); ++ } ++ ++ #[test] ++ fn an_error_reply_is_still_a_response() { ++ // Glance refusing a permission forward must resolve our pending entry, ++ // not be mistaken for a request and answered with `method not found`. ++ let resp: InboundFrame = ++ serde_json::from_str(r#"{"jsonrpc":"2.0","id":7,"error":{"code":-32603}}"#).unwrap(); ++ assert!(resp.is_response()); ++ } ++ ++ #[test] ++ fn request_frame_round_trips() { ++ let frame = request(3, M_SESSION_UPDATE, serde_json::json!({"a": 1})); ++ let parsed: serde_json::Value = serde_json::from_str(&frame).unwrap(); ++ assert_eq!(parsed["jsonrpc"], JSONRPC_VERSION); ++ assert_eq!(parsed["id"], 3); ++ assert_eq!(parsed["method"], M_SESSION_UPDATE); ++ assert_eq!(parsed["params"]["a"], 1); ++ } ++} +diff --git a/crates/codegen/xai-grok-pager/src/rc/ring.rs b/crates/codegen/xai-grok-pager/src/rc/ring.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..89060b8237b536046880dd6a53a864fb4387beb1 +--- /dev/null ++++ b/crates/codegen/xai-grok-pager/src/rc/ring.rs +@@ -0,0 +1,79 @@ ++//! Bounded replay buffer for the glance bridge. ++//! ++//! A browser that attaches mid-session, or reattaches after glance restarted, ++//! has missed everything the agent emitted so far. The ring holds the recent ++//! tail so it can be caught up without the agent replaying anything -- the ++//! local session is never asked to do work on the remote's behalf. ++//! ++//! Frames are stored **already serialised**, for two reasons: replay is then a ++//! straight write with no re-encoding, and a reattaching viewer receives frames ++//! byte-identical to the ones it missed, so its `_meta.eventId` dedup behaves ++//! exactly as it would have live. ++ ++use std::collections::VecDeque; ++ ++pub(crate) struct ReplayRing { ++ frames: VecDeque, ++ capacity: usize, ++ /// Frames evicted since start. A non-zero value tells glance its history is ++ /// truncated rather than complete, which is the difference between "the ++ /// session started here" and "you are missing the beginning". ++ dropped: u64, ++} ++ ++impl ReplayRing { ++ pub(crate) fn new(capacity: usize) -> Self { ++ // A zero capacity would make `push` an infinite pop loop. ++ let capacity = capacity.max(1); ++ Self { ++ frames: VecDeque::with_capacity(capacity.min(256)), ++ capacity, ++ dropped: 0, ++ } ++ } ++ ++ pub(crate) fn push(&mut self, frame: String) { ++ while self.frames.len() >= self.capacity { ++ self.frames.pop_front(); ++ self.dropped += 1; ++ } ++ self.frames.push_back(frame); ++ } ++ ++ pub(crate) fn iter(&self) -> impl Iterator { ++ self.frames.iter() ++ } ++ ++ pub(crate) fn len(&self) -> usize { ++ self.frames.len() ++ } ++ ++ pub(crate) fn dropped(&self) -> u64 { ++ self.dropped ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::ReplayRing; ++ ++ #[test] ++ fn evicts_oldest_and_counts_the_loss() { ++ let mut ring = ReplayRing::new(3); ++ for i in 0..5 { ++ ring.push(format!("f{i}")); ++ } ++ let kept: Vec<_> = ring.iter().cloned().collect(); ++ assert_eq!(kept, vec!["f2", "f3", "f4"]); ++ assert_eq!(ring.len(), 3); ++ assert_eq!(ring.dropped(), 2, "the two evicted frames must be reported"); ++ } ++ ++ #[test] ++ fn zero_capacity_is_clamped_not_fatal() { ++ // `replay_buffer = 0` in config must degrade to "keep one", not hang. ++ let mut ring = ReplayRing::new(0); ++ ring.push("only".into()); ++ assert_eq!(ring.len(), 1); ++ } ++} +diff --git a/crates/codegen/xai-grok-pager/src/rc/tee.rs b/crates/codegen/xai-grok-pager/src/rc/tee.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..6605a554530279cd1a34a1d8a7f76af83f740286 +--- /dev/null ++++ b/crates/codegen/xai-grok-pager/src/rc/tee.rs +@@ -0,0 +1,352 @@ ++//! The tee: copy what the agent says to glance, and race what it asks. ++//! ++//! [`RcHandle::intercept`] sits at the top of ++//! [`acp_handler::handle`](crate::app::acp_handler::handle), which is the single ++//! place every inbound `AcpClientMessage` passes through -- the event loop calls ++//! it both on `recv` and inside its drain batch, so one insertion covers both. ++//! ++//! Two rules govern everything here: ++//! ++//! 1. **The original message is passed through unchanged.** Mirroring copies; ++//! it never rewrites, reorders, or drops. `_meta` in particular is forwarded ++//! verbatim -- `eventId`, `promptId`, `chunkId` and `isReplay` are what let a ++//! viewer dedup and order the stream the same way the TUI does. ++//! 2. **Nothing here can block or fail the local session.** Sends are ++//! best-effort on unbounded channels, the race runs in a spawned task, and a ++//! dead bridge degrades to a plain move. ++ ++use agent_client_protocol as acp; ++use tokio::sync::oneshot; ++use xai_acp_lib::{AcpArgs, AcpClientMessage, AcpResult}; ++ ++use super::{Interaction, RcEvent, RcHandle, ToBridge, protocol}; ++ ++/// Ext methods that are interactive reverse-requests rather than notifications. ++/// ++/// Deliberately the same set the leader singles out in `is_interaction_request` ++/// (`leader/server.rs`): a `/rc` user who could approve tool calls but not ++/// answer a question or leave plan mode would be stranded the first time the ++/// agent did either. ++fn is_interaction_ext_method(method: &str) -> bool { ++ matches!(method, "x.ai/ask_user_question" | "x.ai/exit_plan_mode") ++} ++ ++/// Which side answered first. Computed inside `select!` so the handler arms ++/// touch neither receiver -- the loser is used *after* the statement, once its ++/// borrow has been released. ++enum Winner { ++ Local(AcpResult), ++ /// The terminal dropped the request without answering. ++ LocalGone, ++ Remote(serde_json::Value), ++ /// Glance went away mid-interaction. ++ RemoteGone, ++} ++ ++impl RcHandle { ++ /// Mirror `msg` to glance and return it for the TUI. ++ /// ++ /// The hot path (streaming deltas while remote control is off, or after the ++ /// bridge died) is a match and a move. ++ pub(crate) fn intercept(&self, msg: AcpClientMessage) -> AcpClientMessage { ++ if self.is_closed() { ++ return msg; ++ } ++ match msg { ++ AcpClientMessage::SessionNotification(args) => { ++ match serde_json::to_value(&args.request) { ++ Ok(params) => self.send(ToBridge::Frame(protocol::notification( ++ protocol::M_SESSION_UPDATE, ++ params, ++ ))), ++ Err(e) => tracing::warn!(error = %e, "rc: could not mirror session/update"), ++ } ++ AcpClientMessage::SessionNotification(args) ++ } ++ ++ // The xAI rail. grok emits ~60 grok-specific variants here (tool-call ++ // deltas, subagents, retries, turn boundaries); a mirror carrying only ++ // `session/update` would show glance a transcript with the streaming ++ // taken out. Same predicate the pager itself uses to recognise them. ++ AcpClientMessage::ExtNotification(args) ++ if crate::acp::is_session_update_ext_method(args.request.method.as_ref()) => ++ { ++ match serde_json::from_str::(args.request.params.get()) { ++ Ok(params) => { ++ let method = args.request.method.to_string(); ++ self.send(ToBridge::Frame(protocol::notification(&method, params))); ++ } ++ Err(e) => tracing::warn!(error = %e, "rc: could not mirror ext notification"), ++ } ++ AcpClientMessage::ExtNotification(args) ++ } ++ ++ AcpClientMessage::RequestPermission(args) => self.race_permission(args), ++ ++ AcpClientMessage::ExtMethod(args) ++ if is_interaction_ext_method(args.request.method.as_ref()) => ++ { ++ self.race_ext(args) ++ } ++ ++ // Everything else is local business. In the pager's default ++ // configuration `fs/*` and `terminal/*` are not even advertised, so ++ // this arm is mostly the non-interactive ext methods. ++ other => other, ++ } ++ } ++ ++ fn race_permission( ++ &self, ++ args: AcpArgs, ++ ) -> AcpClientMessage { ++ let AcpArgs { ++ request, ++ response_tx, ++ } = args; ++ ++ let params = match serde_json::to_value(&request) { ++ Ok(params) => params, ++ Err(e) => { ++ // Un-mirrorable: hand it to the terminal untouched rather than ++ // holding a request nobody can answer. ++ tracing::warn!(error = %e, "rc: could not forward permission request"); ++ return AcpClientMessage::RequestPermission(AcpArgs { ++ request, ++ response_tx, ++ }); ++ } ++ }; ++ let tool_call_id = request.tool_call.tool_call_id.0.to_string(); ++ ++ let (tui_tx, tui_rx) = oneshot::channel(); ++ let id = self.begin_interaction( ++ acp::CLIENT_METHOD_NAMES.session_request_permission, ++ params, ++ Some(tool_call_id.clone()), ++ response_tx, ++ tui_rx, ++ |value| { ++ serde_json::from_value::(value) ++ .map_err(|e| e.to_string()) ++ }, ++ ); ++ tracing::debug!(id, %tool_call_id, "rc: permission request mirrored"); ++ ++ AcpClientMessage::RequestPermission(AcpArgs { ++ request, ++ response_tx: tui_tx, ++ }) ++ } ++ ++ fn race_ext(&self, args: AcpArgs) -> AcpClientMessage { ++ let AcpArgs { ++ request, ++ response_tx, ++ } = args; ++ ++ let params = match serde_json::from_str::(request.params.get()) { ++ Ok(params) => params, ++ Err(e) => { ++ tracing::warn!(error = %e, method = %request.method, "rc: could not forward ext request"); ++ return AcpClientMessage::ExtMethod(AcpArgs { ++ request, ++ response_tx, ++ }); ++ } ++ }; ++ // Both interactive ext requests carry `toolCallId`; it is what a ++ // retraction is keyed by on either side. Absent, the race still works -- ++ // only the automatic dismissal of the loser's modal is lost. ++ let tool_call_id = params ++ .get("toolCallId") ++ .and_then(|v| v.as_str()) ++ .map(str::to_owned); ++ ++ // `method` is `Arc` on the request, but the pending table wants a ++ // `&'static str`. Both interaction methods are known constants, so map ++ // rather than leak. ++ let method: &'static str = match request.method.as_ref() { ++ "x.ai/ask_user_question" => "x.ai/ask_user_question", ++ other => { ++ debug_assert_eq!(other, "x.ai/exit_plan_mode"); ++ "x.ai/exit_plan_mode" ++ } ++ }; ++ ++ let (tui_tx, tui_rx) = oneshot::channel(); ++ let id = self.begin_interaction( ++ method, ++ params, ++ tool_call_id.clone(), ++ response_tx, ++ tui_rx, ++ |value| { ++ serde_json::value::to_raw_value(&value) ++ .map(|raw| acp::ExtResponse::new(raw.into())) ++ .map_err(|e| e.to_string()) ++ }, ++ ); ++ tracing::debug!(id, method, ?tool_call_id, "rc: ext interaction mirrored"); ++ ++ AcpClientMessage::ExtMethod(AcpArgs { ++ request, ++ response_tx: tui_tx, ++ }) ++ } ++ ++ /// Register an interaction with the bridge and spawn the task that races the ++ /// two answers. Returns the id used on the wire. ++ fn begin_interaction( ++ &self, ++ method: &'static str, ++ params: serde_json::Value, ++ tool_call_id: Option, ++ real_tx: oneshot::Sender>, ++ tui_rx: oneshot::Receiver>, ++ decode: F, ++ ) -> u64 ++ where ++ T: Send + 'static, ++ F: FnOnce(serde_json::Value) -> Result + Send + 'static, ++ { ++ let id = self.next_id(); ++ let (remote_tx, remote_rx) = oneshot::channel(); ++ self.send(ToBridge::Interaction(Interaction { ++ id, ++ method, ++ params, ++ tool_call_id: tool_call_id.clone(), ++ remote_tx, ++ })); ++ ++ let to_bridge = self.to_bridge.clone(); ++ let events = self.events.clone(); ++ tokio::spawn(async move { ++ let mut tui_rx = tui_rx; ++ let mut remote_rx = remote_rx; ++ ++ let winner = tokio::select! { ++ local = &mut tui_rx => match local { ++ Ok(answer) => Winner::Local(answer), ++ Err(_) => Winner::LocalGone, ++ }, ++ remote = &mut remote_rx => match remote { ++ Ok(value) => Winner::Remote(value), ++ Err(_) => Winner::RemoteGone, ++ }, ++ }; ++ ++ match winner { ++ // The terminal answered first. Forward it and retract the ++ // browser's dialog; a late remote answer finds no pending entry ++ // and is dropped. ++ Winner::Local(answer) => { ++ let _ = real_tx.send(answer); ++ let _ = to_bridge.send(ToBridge::InteractionSettled { id }); ++ } ++ ++ // The terminal dropped the request without answering (session ++ // teardown, or a question displaced by a newer one). Answering on ++ // its behalf would be wrong, so give glance the rest of the window. ++ Winner::LocalGone => match remote_rx.await { ++ Ok(value) => { ++ finish_remote(value, real_tx, decode, &events, tool_call_id); ++ } ++ Err(_) => drop(real_tx), ++ }, ++ ++ Winner::Remote(value) => { ++ if let Some(real_tx) = ++ finish_remote(value, real_tx, decode, &events, tool_call_id) ++ { ++ // The remote answer did not decode, so it cannot resolve ++ // the agent's request. The terminal is still showing the ++ // modal -- let it stay authoritative. ++ match tui_rx.await { ++ Ok(answer) => { ++ let _ = real_tx.send(answer); ++ let _ = to_bridge.send(ToBridge::InteractionSettled { id }); ++ } ++ Err(_) => drop(real_tx), ++ } ++ } ++ } ++ ++ // Glance disconnected mid-interaction. Nothing to retract; the ++ // terminal was never told anything was in flight. ++ Winner::RemoteGone => match tui_rx.await { ++ Ok(answer) => { ++ let _ = real_tx.send(answer); ++ } ++ Err(_) => drop(real_tx), ++ }, ++ } ++ }); ++ ++ id ++ } ++} ++ ++/// Decode and deliver a remote answer. ++/// ++/// Returns `Some(real_tx)` when the answer was unusable, handing the reply ++/// channel back so the caller can fall back to the terminal. A malformed remote ++/// answer must never resolve the agent's request -- `RequestPermissionOutcome` ++/// is `#[non_exhaustive]`, so an outcome this build does not know about is a ++/// real possibility and has to fail closed rather than panic or guess. ++fn finish_remote( ++ value: serde_json::Value, ++ real_tx: oneshot::Sender>, ++ decode: F, ++ events: &tokio::sync::mpsc::UnboundedSender, ++ tool_call_id: Option, ++) -> Option>> ++where ++ F: FnOnce(serde_json::Value) -> Result, ++{ ++ match decode(value) { ++ Ok(answer) => { ++ let _ = real_tx.send(Ok(answer)); ++ if let Some(tool_call_id) = tool_call_id { ++ let _ = events.send(RcEvent::InteractionResolvedRemotely { tool_call_id }); ++ } ++ None ++ } ++ Err(e) => { ++ tracing::warn!(error = %e, "rc: remote answer rejected"); ++ let _ = events.send(RcEvent::Notice(format!( ++ "glance sent an answer this build could not read ({e}); answer in the terminal" ++ ))); ++ Some(real_tx) ++ } ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn interaction_ext_methods_match_the_leaders_set() { ++ assert!(is_interaction_ext_method("x.ai/ask_user_question")); ++ assert!(is_interaction_ext_method("x.ai/exit_plan_mode")); ++ // Notifications must not be raced -- they have no reply to win. ++ assert!(!is_interaction_ext_method("x.ai/session_notification")); ++ assert!(!is_interaction_ext_method("x.ai/toggle_plan_mode")); ++ } ++ ++ /// The two rails the mirror must carry. If upstream renames either, this ++ /// fails here rather than silently producing a transcript with the ++ /// streaming removed. ++ #[test] ++ fn both_notification_rails_are_recognised() { ++ assert!(crate::acp::is_session_update_ext_method( ++ "x.ai/session_notification" ++ )); ++ assert!(crate::acp::is_session_update_ext_method("x.ai/session/update")); ++ assert!(!crate::acp::is_session_update_ext_method( ++ "x.ai/ask_user_question" ++ )); ++ } ++} +diff --git a/crates/codegen/xai-grok-pager/src/rc/transport.rs b/crates/codegen/xai-grok-pager/src/rc/transport.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..98e7e45a9428953c29b14c78b33566740219612a +--- /dev/null ++++ b/crates/codegen/xai-grok-pager/src/rc/transport.rs +@@ -0,0 +1,639 @@ ++//! The bridge task: one WebSocket to glance, for the life of `/rc`. ++//! ++//! Runs entirely off the event loop. Its only couplings to the TUI are the ++//! [`ToBridge`] channel it drains, the [`RcEvent`] channel it reports on, and a ++//! clone of [`AcpAgentTx`] -- which is how a remote prompt or stop reaches the ++//! agent by exactly the same path the terminal uses, rather than being injected ++//! into the TUI and replayed. ++//! ++//! Disconnection is routine, not exceptional: glance restarting must never be ++//! visible in the terminal beyond a status line. So the socket is dialed with ++//! backoff, traffic keeps flowing into the replay ring while it is down, and ++//! reconnecting replays the ring instead of asking the session to re-emit ++//! anything. ++ ++use std::collections::HashMap; ++use std::time::Duration; ++ ++use agent_client_protocol as acp; ++use futures_util::{SinkExt, StreamExt}; ++use tokio::sync::{mpsc, oneshot}; ++use tokio_tungstenite::tungstenite::Message; ++use tokio_tungstenite::tungstenite::client::IntoClientRequest; ++use xai_acp_lib::{AcpAgentTx, acp_send}; ++ ++use super::{ ++ Interaction, RC_CLIENT_ID, RcEvent, RcSettings, RcState, SessionMeta, ToBridge, protocol, ++ ring::ReplayRing, ++}; ++ ++/// Long enough that a glance restart or a laptop lid-close is ridden out, short ++/// enough that a reconnect is not perceived as a hang. ++const BACKOFF_MIN: Duration = Duration::from_secs(1); ++const BACKOFF_MAX: Duration = Duration::from_secs(30); ++const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); ++ ++/// An interaction forwarded to glance and awaiting its answer. ++struct Pending { ++ remote_tx: oneshot::Sender, ++ tool_call_id: Option, ++} ++ ++enum PumpOutcome { ++ /// `/rc off`, `Action::Quit`, or the handle was dropped. ++ Shutdown, ++ /// Socket died. Retry. ++ Disconnected(String), ++} ++ ++enum AbsorbOutcome { ++ Shutdown, ++ Elapsed, ++} ++ ++pub(super) async fn run( ++ settings: RcSettings, ++ meta: SessionMeta, ++ acp_tx: AcpAgentTx, ++ mut to_bridge: mpsc::UnboundedReceiver, ++ events: mpsc::UnboundedSender, ++) { ++ let mut ring = ReplayRing::new(settings.replay_buffer); ++ let mut attempt: u32 = 0; ++ ++ loop { ++ let _ = events.send(RcEvent::State(if attempt == 0 { ++ RcState::Connecting ++ } else { ++ RcState::Reconnecting { ++ attempt, ++ detail: "dialing".into(), ++ } ++ })); ++ ++ match dial(&settings).await { ++ Ok(ws) => { ++ let _ = events.send(RcEvent::State(RcState::Connected { viewers: None })); ++ tracing::info!(url = %settings.url, "rc: connected to glance"); ++ ++ let outcome = pump( ++ ws, ++ &mut to_bridge, ++ &mut ring, ++ &acp_tx, ++ &meta, ++ &events, ++ &settings, ++ ) ++ .await; ++ ++ match outcome { ++ PumpOutcome::Shutdown => break, ++ PumpOutcome::Disconnected(detail) => { ++ tracing::info!(%detail, "rc: glance link dropped"); ++ // Back to the floor of the backoff curve: a link that ++ // worked until a second ago has earned a fast retry, ++ // whatever the attempt count was before it came up. ++ attempt = 1; ++ let _ = events.send(RcEvent::State(RcState::Reconnecting { ++ attempt, ++ detail, ++ })); ++ } ++ } ++ } ++ Err(DialError::Fatal(detail)) => { ++ // A rejected key or an unusable URL will be rejected identically ++ // forever. Retrying would only produce a status line that ++ // flickers between "reconnecting" and "failed" until the user ++ // gives up reading it. ++ tracing::warn!(%detail, "rc: giving up on glance"); ++ let _ = events.send(RcEvent::State(RcState::Failed { detail })); ++ break; ++ } ++ Err(DialError::Retry(detail)) => { ++ attempt = attempt.saturating_add(1); ++ let _ = events.send(RcEvent::State(RcState::Reconnecting { ++ attempt, ++ detail, ++ })); ++ } ++ } ++ ++ // Keep draining while disconnected: the tee has an unbounded channel and ++ // a session streaming through a long outage would otherwise grow it ++ // without limit. Frames land in the ring so the reconnect replays them. ++ match absorb(&mut to_bridge, &mut ring, backoff(attempt)).await { ++ AbsorbOutcome::Shutdown => break, ++ AbsorbOutcome::Elapsed => {} ++ } ++ } ++ ++ let _ = events.send(RcEvent::State(RcState::Off)); ++ tracing::info!("rc: bridge stopped"); ++} ++ ++fn backoff(attempt: u32) -> Duration { ++ let shift = attempt.saturating_sub(1).min(5); ++ (BACKOFF_MIN * 2u32.saturating_pow(shift)).min(BACKOFF_MAX) ++} ++ ++enum DialError { ++ /// Worth retrying: server down, DNS blip, network change. ++ Retry(String), ++ /// Will fail the same way every time: bad URL, rejected credentials. ++ Fatal(String), ++} ++ ++type Ws = tokio_tungstenite::WebSocketStream< ++ tokio_tungstenite::MaybeTlsStream, ++>; ++ ++async fn dial(settings: &RcSettings) -> Result { ++ let mut request = settings ++ .url ++ .as_str() ++ .into_client_request() ++ .map_err(|e| DialError::Fatal(format!("bad remote_control.url: {e}")))?; ++ ++ // ACP has no transport auth of its own, so the API key rides the upgrade. ++ request.headers_mut().insert( ++ "Authorization", ++ format!("Bearer {}", settings.api_key) ++ .parse() ++ .map_err(|_| DialError::Fatal("remote_control.api_key is not a valid header".into()))?, ++ ); ++ ++ let connect = tokio::time::timeout(CONNECT_TIMEOUT, tokio_tungstenite::connect_async(request)); ++ match connect.await { ++ Err(_) => Err(DialError::Retry("connect timed out".into())), ++ Ok(Ok((ws, _response))) => Ok(ws), ++ Ok(Err(e)) => Err(classify_dial_error(e)), ++ } ++} ++ ++/// An HTTP rejection at upgrade time is the server's verdict on our credentials ++/// or URL; anything else is a transport problem that may clear on its own. ++fn classify_dial_error(e: tokio_tungstenite::tungstenite::Error) -> DialError { ++ use tokio_tungstenite::tungstenite::Error as WsError; ++ match &e { ++ WsError::Http(response) => { ++ let status = response.status(); ++ let detail = format!("glance rejected the connection: HTTP {status}"); ++ if status.is_client_error() { ++ DialError::Fatal(detail) ++ } else { ++ DialError::Retry(detail) ++ } ++ } ++ WsError::Url(_) => DialError::Fatal(format!("bad remote_control.url: {e}")), ++ _ => DialError::Retry(e.to_string()), ++ } ++} ++ ++/// Drain the tee into the ring for `budget`, so an outage costs history rather ++/// than memory. Returns early only for shutdown. ++async fn absorb( ++ to_bridge: &mut mpsc::UnboundedReceiver, ++ ring: &mut ReplayRing, ++ budget: Duration, ++) -> AbsorbOutcome { ++ let deadline = tokio::time::Instant::now() + budget; ++ loop { ++ tokio::select! { ++ _ = tokio::time::sleep_until(deadline) => return AbsorbOutcome::Elapsed, ++ msg = to_bridge.recv() => match msg { ++ // Handle dropped: nothing left to mirror for. ++ None | Some(ToBridge::Shutdown) => return AbsorbOutcome::Shutdown, ++ Some(ToBridge::Frame(frame)) => ring.push(frame), ++ // Dropping `remote_tx` is the signal the race task waits on: it ++ // stops waiting for a glance that is not there and lets the ++ // terminal answer normally. ++ Some(ToBridge::Interaction(_)) | Some(ToBridge::InteractionSettled { .. }) => {} ++ }, ++ } ++ } ++} ++ ++#[allow(clippy::too_many_arguments)] ++async fn pump( ++ ws: Ws, ++ to_bridge: &mut mpsc::UnboundedReceiver, ++ ring: &mut ReplayRing, ++ acp_tx: &AcpAgentTx, ++ meta: &SessionMeta, ++ events: &mpsc::UnboundedSender, ++ settings: &RcSettings, ++) -> PumpOutcome { ++ let (mut sink, mut stream) = ws.split(); ++ ++ // A writer task, rather than sending inline: `session/prompt` blocks for the ++ // whole turn, so the handlers that need to reply must be able to run ++ // concurrently with the mirror. They share this channel instead of the sink. ++ let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); ++ let mut writer = tokio::spawn(async move { ++ while let Some(message) = out_rx.recv().await { ++ if let Err(e) = sink.send(message).await { ++ return e.to_string(); ++ } ++ } ++ let _ = sink.close().await; ++ "closed".to_string() ++ }); ++ ++ // Catch a viewer up before anything new arrives: the ring is what a browser ++ // attaching mid-session (or after a glance restart) sees as history. ++ let _ = out_tx.send(status_frame(meta, ring)); ++ for frame in ring.iter() { ++ if out_tx.send(Message::Text(frame.clone().into())).is_err() { ++ return PumpOutcome::Disconnected("writer stopped during replay".into()); ++ } ++ } ++ ++ let mut pending: HashMap = HashMap::new(); ++ ++ let outcome = loop { ++ tokio::select! { ++ biased; ++ ++ // The socket died under the writer. Notice it here rather than ++ // waiting for the read half to time out. ++ writer_result = &mut writer => { ++ let detail = writer_result.unwrap_or_else(|e| format!("writer panicked: {e}")); ++ break PumpOutcome::Disconnected(detail); ++ } ++ ++ inbound = stream.next() => match inbound { ++ None => break PumpOutcome::Disconnected("glance closed the connection".into()), ++ Some(Err(e)) => break PumpOutcome::Disconnected(e.to_string()), ++ Some(Ok(Message::Close(_))) => { ++ break PumpOutcome::Disconnected("glance closed the connection".into()); ++ } ++ Some(Ok(Message::Text(text))) => { ++ handle_inbound( ++ &text, &mut pending, &out_tx, acp_tx, meta, events, settings, ring, ++ ); ++ } ++ // Ping/pong are answered by tungstenite; binary frames are not ++ // part of this protocol. ++ Some(Ok(_)) => {} ++ }, ++ ++ outgoing = to_bridge.recv() => match outgoing { ++ None | Some(ToBridge::Shutdown) => break PumpOutcome::Shutdown, ++ ++ Some(ToBridge::Frame(frame)) => { ++ ring.push(frame.clone()); ++ if out_tx.send(Message::Text(frame.into())).is_err() { ++ break PumpOutcome::Disconnected("writer stopped".into()); ++ } ++ } ++ ++ Some(ToBridge::Interaction(interaction)) => { ++ let Interaction { id, method, params, tool_call_id, remote_tx } = interaction; ++ let frame = protocol::request(id, method, params); ++ pending.insert(id, Pending { remote_tx, tool_call_id }); ++ if out_tx.send(Message::Text(frame.into())).is_err() { ++ break PumpOutcome::Disconnected("writer stopped".into()); ++ } ++ } ++ ++ // The terminal won the race. Removing the entry drops `remote_tx`, ++ // which is what makes a late answer from glance a silent no-op. ++ Some(ToBridge::InteractionSettled { id }) => { ++ if let Some(entry) = pending.remove(&id) { ++ let frame = protocol::notification( ++ protocol::M_RC_INTERACTION_CANCELLED, ++ serde_json::json!({ "id": id, "toolCallId": entry.tool_call_id }), ++ ); ++ if out_tx.send(Message::Text(frame.into())).is_err() { ++ break PumpOutcome::Disconnected("writer stopped".into()); ++ } ++ } ++ } ++ }, ++ } ++ }; ++ ++ // Every still-pending interaction loses its remote half here, so each race ++ // task falls back to the terminal instead of waiting on a socket that is gone. ++ drop(pending); ++ drop(out_tx); ++ writer.abort(); ++ ++ outcome ++} ++ ++fn status_frame(meta: &SessionMeta, ring: &ReplayRing) -> Message { ++ Message::Text( ++ protocol::notification( ++ protocol::M_RC_STATUS, ++ serde_json::json!({ ++ "session": meta.to_json(), ++ "replay": { "frames": ring.len(), "dropped": ring.dropped() }, ++ }), ++ ) ++ .into(), ++ ) ++} ++ ++#[allow(clippy::too_many_arguments)] ++fn handle_inbound( ++ text: &str, ++ pending: &mut HashMap, ++ out_tx: &mpsc::UnboundedSender, ++ acp_tx: &AcpAgentTx, ++ meta: &SessionMeta, ++ events: &mpsc::UnboundedSender, ++ settings: &RcSettings, ++ ring: &ReplayRing, ++) { ++ let frame: protocol::InboundFrame = match serde_json::from_str(text) { ++ Ok(frame) => frame, ++ Err(e) => { ++ tracing::warn!(error = %e, "rc: unparseable frame from glance"); ++ return; ++ } ++ }; ++ ++ if frame.is_response() { ++ resolve_pending(frame, pending); ++ return; ++ } ++ ++ let Some(method) = frame.method.clone() else { ++ tracing::warn!("rc: frame from glance has neither method nor result"); ++ return; ++ }; ++ let params = frame.params.clone().unwrap_or(serde_json::Value::Null); ++ ++ // `id` present => a request expecting a reply; absent => a notification. ++ let reply_id = frame.id.clone(); ++ let reply = |result: Result| { ++ let Some(id) = reply_id.clone() else { return }; ++ let frame = match result { ++ Ok(value) => protocol::response(id, value), ++ Err((code, message)) => protocol::error_response(id, code, message), ++ }; ++ let _ = out_tx.send(Message::Text(frame.into())); ++ }; ++ ++ match method.as_str() { ++ protocol::M_INITIALIZE => reply(Ok(serde_json::json!({ ++ "protocolVersion": 1, ++ "agentCapabilities": { ++ // This bridge mirrors one existing session. It cannot create or ++ // load others, and says so rather than failing the calls later. ++ "loadSession": false, ++ "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, ++ }, ++ "_meta": { ++ "session": meta.to_json(), ++ "remoteControl": { ++ "replayBuffer": settings.replay_buffer, ++ "frames": ring.len(), ++ "dropped": ring.dropped(), ++ }, ++ }, ++ }))), ++ ++ protocol::M_SESSION_LIST => reply(Ok(serde_json::json!({ ++ "sessions": meta.session_id.as_ref().map(|_| vec![meta.to_json()]).unwrap_or_default(), ++ }))), ++ ++ protocol::M_RC_REPLAY => { ++ for frame in ring.iter() { ++ if out_tx.send(Message::Text(frame.clone().into())).is_err() { ++ break; ++ } ++ } ++ reply(Ok(serde_json::json!({ ++ "frames": ring.len(), ++ "dropped": ring.dropped(), ++ }))); ++ } ++ ++ protocol::M_SESSION_PROMPT => { ++ let Some(session_id) = meta.session_id.clone() else { ++ reply(Err(( ++ protocol::E_INTERNAL, ++ "no session is attached to this bridge".into(), ++ ))); ++ return; ++ }; ++ let blocks = match prompt_blocks(¶ms) { ++ Ok(blocks) => blocks, ++ Err(e) => { ++ reply(Err((protocol::E_INVALID_PARAMS, e))); ++ return; ++ } ++ }; ++ spawn_prompt(session_id, blocks, acp_tx.clone(), out_tx.clone(), reply_id, events.clone()); ++ } ++ ++ protocol::M_SESSION_CANCEL => { ++ let Some(session_id) = meta.session_id.clone() else { ++ reply(Err(( ++ protocol::E_INTERNAL, ++ "no session is attached to this bridge".into(), ++ ))); ++ return; ++ }; ++ spawn_cancel(session_id, acp_tx.clone(), events.clone()); ++ reply(Ok(serde_json::json!({}))); ++ } ++ ++ // Glance telling us how many browsers are watching. Purely cosmetic -- ++ // it drives the `/rc status` line, nothing load-bearing. ++ "x.ai/rc/viewers" => { ++ let viewers = params.get("count").and_then(|v| v.as_u64()).map(|n| n as usize); ++ let _ = events.send(RcEvent::State(RcState::Connected { viewers })); ++ } ++ ++ unknown => { ++ // A real `method not found`, never a faked success: glance has to be ++ // able to tell what this bridge actually implements. ++ tracing::debug!(%unknown, "rc: unsupported method from glance"); ++ reply(Err(( ++ protocol::E_METHOD_NOT_FOUND, ++ format!("Method not found: {unknown}"), ++ ))); ++ } ++ } ++} ++ ++/// Route a JSON-RPC response back to the race task waiting on it. ++fn resolve_pending(frame: protocol::InboundFrame, pending: &mut HashMap) { ++ let Some(id) = frame.id.as_ref().and_then(serde_json::Value::as_u64) else { ++ tracing::warn!("rc: response from glance with no usable id"); ++ return; ++ }; ++ let Some(entry) = pending.remove(&id) else { ++ // Expected whenever the terminal won: we already retracted the dialog ++ // and dropped the entry, and glance's answer lost the race. ++ tracing::debug!(id, "rc: answer for an interaction already settled"); ++ return; ++ }; ++ match frame.result { ++ Some(result) => { ++ let _ = entry.remote_tx.send(result); ++ } ++ None => { ++ // Glance declined. Dropping `remote_tx` hands the interaction back ++ // to the terminal, which is still showing the modal. ++ tracing::debug!(id, error = ?frame.error, "rc: glance declined an interaction"); ++ } ++ } ++} ++ ++/// Accept either ACP-standard `prompt: ContentBlock[]` or a plain `text`. ++fn prompt_blocks(params: &serde_json::Value) -> Result, String> { ++ if let Some(prompt) = params.get("prompt") { ++ return serde_json::from_value(prompt.clone()) ++ .map_err(|e| format!("invalid prompt blocks: {e}")); ++ } ++ let text = params ++ .get("text") ++ .and_then(|v| v.as_str()) ++ .ok_or_else(|| "expected `prompt` (ContentBlock[]) or `text` (string)".to_string())?; ++ if text.trim().is_empty() { ++ return Err("prompt text is empty".into()); ++ } ++ serde_json::from_value(serde_json::json!([{ "type": "text", "text": text }])) ++ .map_err(|e| format!("could not build text block: {e}")) ++} ++ ++/// A prompt blocks for the whole turn, so it runs in its own task and replies ++/// when the turn ends. ++fn spawn_prompt( ++ session_id: String, ++ blocks: Vec, ++ acp_tx: AcpAgentTx, ++ out_tx: mpsc::UnboundedSender, ++ reply_id: Option, ++ events: mpsc::UnboundedSender, ++) { ++ tokio::spawn(async move { ++ // `clientIdentifier` is what makes the turn attributable to glance in ++ // the session log, and what tells the TUI this is another client's turn ++ // to view rather than one of its own to drive. ++ let prompt_id = format!("glance-{}", uuid::Uuid::new_v4()); ++ let meta = serde_json::json!({ ++ "clientIdentifier": RC_CLIENT_ID, ++ "promptId": prompt_id, ++ }); ++ let request = acp::PromptRequest::new(acp::SessionId::new(session_id), blocks) ++ .meta(meta.as_object().cloned()); ++ ++ let _ = events.send(RcEvent::Notice("prompt received from glance".into())); ++ let result = acp_send(request, &acp_tx).await; ++ ++ let Some(id) = reply_id else { return }; ++ let frame = match result { ++ Ok(response) => match serde_json::to_value(&response) { ++ Ok(value) => protocol::response(id, value), ++ Err(e) => protocol::error_response(id, protocol::E_INTERNAL, e.to_string()), ++ }, ++ Err(e) => protocol::error_response(id, protocol::E_INTERNAL, e.to_string()), ++ }; ++ let _ = out_tx.send(Message::Text(frame.into())); ++ }); ++} ++ ++fn spawn_cancel(session_id: String, acp_tx: AcpAgentTx, events: mpsc::UnboundedSender) { ++ tokio::spawn(async move { ++ // `CancelTrigger::from_client("glance")` classifies as `StopGesture` -- ++ // the same class as Esc, so the runtime treats it identically -- while ++ // telemetry still records who interrupted. Sending `esc` would work and ++ // would lie. ++ let meta = serde_json::json!({ ++ "cancelTrigger": RC_CLIENT_ID, ++ "cancelSubagents": true, ++ }); ++ let request = acp::CancelNotification::new(acp::SessionId::new(session_id)) ++ .meta(meta.as_object().cloned()); ++ ++ match acp_send(request, &acp_tx).await { ++ Ok(()) => { ++ let _ = events.send(RcEvent::Notice("turn interrupted from glance".into())); ++ } ++ Err(e) => { ++ tracing::warn!(error = %e, "rc: remote cancel failed"); ++ } ++ } ++ }); ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn backoff_grows_then_caps() { ++ assert_eq!(backoff(1), BACKOFF_MIN); ++ assert_eq!(backoff(2), Duration::from_secs(2)); ++ assert_eq!(backoff(4), Duration::from_secs(8)); ++ // Capped, so a long outage does not turn into a multi-minute blind spot. ++ assert_eq!(backoff(20), BACKOFF_MAX); ++ } ++ ++ #[test] ++ fn backoff_handles_attempt_zero() { ++ // `attempt` is 0 on the very first dial; the shift must not underflow. ++ assert_eq!(backoff(0), BACKOFF_MIN); ++ } ++ ++ #[test] ++ fn prompt_accepts_text_shorthand_and_acp_blocks() { ++ let from_text = prompt_blocks(&serde_json::json!({ "text": "hello" })).unwrap(); ++ assert_eq!(from_text.len(), 1); ++ ++ let from_blocks = prompt_blocks(&serde_json::json!({ ++ "prompt": [{ "type": "text", "text": "hi" }] ++ })) ++ .unwrap(); ++ assert_eq!(from_blocks.len(), 1); ++ } ++ ++ #[test] ++ fn prompt_rejects_empty_and_missing() { ++ assert!(prompt_blocks(&serde_json::json!({})).is_err()); ++ assert!(prompt_blocks(&serde_json::json!({ "text": " " })).is_err()); ++ } ++ ++ #[test] ++ fn a_settled_interaction_ignores_a_late_answer() { ++ let mut pending: HashMap = HashMap::new(); ++ let (tx, mut rx) = oneshot::channel(); ++ pending.insert(1, Pending { remote_tx: tx, tool_call_id: None }); ++ ++ // The terminal won: the entry is removed, dropping the sender. ++ pending.remove(&1); ++ ++ // Glance's late answer finds nothing to resolve, and the race task sees ++ // a closed channel rather than a second answer. ++ let frame: protocol::InboundFrame = ++ serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"result":{"outcome":"cancelled"}}"#) ++ .unwrap(); ++ resolve_pending(frame, &mut pending); ++ assert!(rx.try_recv().is_err()); ++ } ++ ++ #[test] ++ fn a_declined_interaction_falls_back_to_the_terminal() { ++ let mut pending: HashMap = HashMap::new(); ++ let (tx, mut rx) = oneshot::channel(); ++ pending.insert(2, Pending { remote_tx: tx, tool_call_id: None }); ++ ++ let frame: protocol::InboundFrame = serde_json::from_str( ++ r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"no viewer"}}"#, ++ ) ++ .unwrap(); ++ resolve_pending(frame, &mut pending); ++ ++ // No value delivered, and the channel is closed -- which is exactly what ++ // makes the race task wait on the terminal instead. ++ assert!(rx.try_recv().is_err()); ++ assert!(pending.is_empty()); ++ } ++} +diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs b/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs +index a838cc6d558a032897f0e112c2591dec56b18fec..8ca138f738c4b9a5bb03cff2ca5532b7e656283d 100644 +--- a/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs ++++ b/crates/codegen/xai-grok-pager/src/slash/commands/mod.rs +@@ -47,6 +47,7 @@ pub mod plan; + pub mod plugin; + pub mod privacy; + pub mod queue; ++pub mod rc; + pub mod recap; + pub mod release_notes; + pub mod remember; +@@ -142,6 +143,7 @@ pub fn builtin_commands() -> Vec> { + Arc::new(usage::UsageCommand), + Arc::new(queue::QueueCommand), + Arc::new(tasks::TasksCommand), ++ Arc::new(rc::RcCommand), + Arc::new(release_notes::ReleaseNotesCommand), + Arc::new(tutorial::TutorialCommand), + Arc::new(config_agents::ConfigAgentsCommand), +diff --git a/crates/codegen/xai-grok-pager/src/slash/commands/rc.rs b/crates/codegen/xai-grok-pager/src/slash/commands/rc.rs +new file mode 100644 +index 0000000000000000000000000000000000000000..41cb21d2d0cc3c3e56f7a7fc75488c83d60b0c2e +--- /dev/null ++++ b/crates/codegen/xai-grok-pager/src/slash/commands/rc.rs +@@ -0,0 +1,108 @@ ++//! `/rc` -- remote control: mirror this session to a grok-glance server. ++//! ++//! The point of `/rc` is what it does *not* do. It does not restart the session, ++//! switch it to headless, hand it to another process, or degrade the terminal in ++//! any way. The TUI stays exactly as interactive as it was; glance simply ++//! becomes a second view of the same session, reachable from a browser. ++//! ++//! From either side you can watch the turn stream, send a prompt, interrupt a ++//! running turn, and answer tool-permission requests, questions, and plan ++//! approvals. Both sides can answer -- whoever answers first wins, and the other ++//! side's dialog closes by itself. ++//! ++//! Configuration lives in `[remote_control]` (`url`, `api_key`), with ++//! `GROK_RC_URL` / `GROK_RC_API_KEY` overriding it -- a bearer token in a ++//! plaintext config file is a poor default. ++//! ++//! **Scope.** This session only, and only until you quit. Nothing is persisted. ++ ++use crate::app::actions::{Action, RemoteControlAction}; ++use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; ++ ++/// Remote control toggle via `/rc`. ++pub struct RcCommand; ++ ++impl SlashCommand for RcCommand { ++ fn name(&self) -> &str { ++ "rc" ++ } ++ ++ fn description(&self) -> &str { ++ "Remote control: mirror this session to grok-glance" ++ } ++ ++ fn usage(&self) -> &str { ++ "/rc [on|off|status]" ++ } ++ ++ fn takes_args(&self) -> bool { ++ true ++ } ++ ++ /// Bare `/rc` toggles, which is the common case. ++ fn args_required(&self) -> bool { ++ false ++ } ++ ++ fn arg_placeholder(&self) -> Option<&str> { ++ Some("on|off|status") ++ } ++ ++ /// There is nothing to mirror without a session. ++ fn session_scoped(&self) -> bool { ++ true ++ } ++ ++ fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult { ++ if ctx.session_id.is_none() { ++ return CommandResult::Error("No active session".to_string()); ++ } ++ match parse(args) { ++ Some(what) => CommandResult::Action(Action::RemoteControl(what)), ++ None => CommandResult::Error(format!( ++ "Unknown argument {:?}. Usage: /rc [on|off|status]", ++ args.trim() ++ )), ++ } ++ } ++} ++ ++/// `stop` and `start` are accepted alongside `off`/`on` because both are ++/// plausible guesses, and being told "unknown argument" for a synonym of the ++/// thing you meant is a poor trade for strictness. ++fn parse(args: &str) -> Option { ++ match args.trim().to_ascii_lowercase().as_str() { ++ "" => Some(RemoteControlAction::Toggle), ++ "on" | "start" | "connect" => Some(RemoteControlAction::On), ++ "off" | "stop" | "disconnect" => Some(RemoteControlAction::Off), ++ "status" | "info" => Some(RemoteControlAction::Status), ++ _ => None, ++ } ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn bare_rc_toggles() { ++ assert_eq!(parse(""), Some(RemoteControlAction::Toggle)); ++ assert_eq!(parse(" "), Some(RemoteControlAction::Toggle)); ++ } ++ ++ #[test] ++ fn subcommands_are_case_insensitive_and_accept_synonyms() { ++ assert_eq!(parse("OFF"), Some(RemoteControlAction::Off)); ++ assert_eq!(parse(" stop "), Some(RemoteControlAction::Off)); ++ assert_eq!(parse("On"), Some(RemoteControlAction::On)); ++ assert_eq!(parse("status"), Some(RemoteControlAction::Status)); ++ } ++ ++ /// A typo must not silently toggle: `/rc of` turning remote control *on* ++ /// would be the worst possible reading of the user's intent. ++ #[test] ++ fn an_unknown_argument_is_an_error_not_a_toggle() { ++ assert_eq!(parse("of"), None); ++ assert_eq!(parse("nonsense"), None); ++ } ++} +diff --git a/crates/codegen/xai-grok-shell/src/session/slash_commands.rs b/crates/codegen/xai-grok-shell/src/session/slash_commands.rs +index 3f017f2da6765fc680fbe431d0e776fdd5b2122f..f93c675530b0c72d0e611faeddf617decb39dc4d 100644 +--- a/crates/codegen/xai-grok-shell/src/session/slash_commands.rs ++++ b/crates/codegen/xai-grok-shell/src/session/slash_commands.rs +@@ -495,6 +495,7 @@ pub const PAGER_COMMAND_KEYS: &[&str] = &[ + "privacy", + "queue", + "quit", ++ "rc", + "recap", + "release-notes", + "reload-plugins",