feat(core/bridge): surface code-bridge events in sessions

This commit is contained in:
James Peter
2025-11-28 08:05:56 +10:00
parent d4da182a99
commit 727a95edd5
5 changed files with 230 additions and 2 deletions
+32 -2
View File
@@ -1181,6 +1181,7 @@ dependencies = [
"filetime",
"fs2",
"futures",
"futures-util",
"htmd",
"httpdate",
"img_hash",
@@ -1213,7 +1214,9 @@ dependencies = [
"thiserror 2.0.17",
"time",
"tokio",
"tokio-stream",
"tokio-test",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.8",
"toml_edit 0.23.7",
@@ -2948,7 +2951,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.3",
]
[[package]]
@@ -5143,7 +5146,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.3",
]
[[package]]
@@ -6580,6 +6583,22 @@ dependencies = [
"tokio-stream",
]
[[package]]
name = "tokio-tungstenite"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -6957,6 +6976,8 @@ dependencies = [
"httparse",
"log",
"rand 0.8.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
"utf-8",
@@ -7330,6 +7351,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.3",
]
[[package]]
name = "webpki-roots"
version = "1.0.3"
+3
View File
@@ -36,6 +36,7 @@ env-flags = { workspace = true }
encoding_rs = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
futures-util = "0.3"
indexmap = { workspace = true }
lazy_static = { workspace = true }
libc = { workspace = true }
@@ -75,7 +76,9 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] }
tokio-util = { workspace = true }
tokio-stream = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true, features = ["log"] }
+183
View File
@@ -0,0 +1,183 @@
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::Value;
use tokio::time::sleep;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use tracing::{info, warn};
use once_cell::sync::Lazy;
use std::sync::Mutex;
use crate::codex::Session;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct BridgeMeta {
url: String,
secret: String,
#[allow(dead_code)]
port: Option<u16>,
#[allow(dead_code)]
workspace_path: Option<String>,
#[allow(dead_code)]
started_at: Option<String>,
}
static DESIRED_LEVELS: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(vec!["errors".to_string()]));
static DESIRED_CAPABILITIES: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
static CONTROL_SENDER: Lazy<Mutex<Option<tokio::sync::mpsc::UnboundedSender<String>>>> =
Lazy::new(|| Mutex::new(None));
static DESIRED_FILTER: Lazy<Mutex<String>> = Lazy::new(|| Mutex::new("off".to_string()));
#[allow(dead_code)]
pub(crate) fn set_bridge_levels(levels: Vec<String>) {
let mut guard = DESIRED_LEVELS.lock().unwrap();
*guard = if levels.is_empty() { vec!["errors".to_string()] } else { levels };
}
#[allow(dead_code)]
pub(crate) fn set_bridge_subscription(levels: Vec<String>, capabilities: Vec<String>) {
let mut lvl = DESIRED_LEVELS.lock().unwrap();
*lvl = if levels.is_empty() { vec!["errors".to_string()] } else { levels };
let mut caps = DESIRED_CAPABILITIES.lock().unwrap();
*caps = capabilities;
}
#[allow(dead_code)]
pub(crate) fn set_bridge_filter(filter: &str) {
let mut f = DESIRED_FILTER.lock().unwrap();
*f = filter.to_string();
}
#[allow(dead_code)]
pub(crate) fn send_bridge_control(action: &str, args: serde_json::Value) {
let msg = serde_json::json!({
"type": "control",
"action": action,
"args": args,
})
.to_string();
if let Some(sender) = CONTROL_SENDER.lock().unwrap().as_ref() {
let _ = sender.send(msg);
}
}
/// Spawn a background task that watches `.code/code-bridge.json` and
/// connects as a consumer to the external bridge host when available.
pub(crate) fn spawn_bridge_listener(session: std::sync::Arc<Session>) {
let meta_path = session.get_cwd().join(".code/code-bridge.json");
tokio::spawn(async move {
loop {
if let Ok(meta) = read_meta(&meta_path) {
info!("[bridge] host metadata found, connecting");
if let Err(err) = connect_and_listen(meta, &session).await {
warn!("[bridge] connect failed: {err:?}");
}
}
sleep(Duration::from_secs(5)).await;
}
});
}
fn read_meta(path: &PathBuf) -> Result<BridgeMeta> {
let data = std::fs::read_to_string(path)?;
let meta: BridgeMeta = serde_json::from_str(&data)?;
Ok(meta)
}
async fn connect_and_listen(meta: BridgeMeta, session: &Session) -> Result<()> {
let (ws, _) = connect_async(&meta.url).await?;
let (mut tx, mut rx) = ws.split();
// auth frame
let auth = serde_json::json!({
"type": "auth",
"role": "consumer",
"secret": meta.secret,
"clientId": format!("code-consumer-{}", session.session_uuid()),
})
.to_string();
tx.send(Message::Text(auth)).await?;
// default subscription: errors only, no extra capabilities
let desired_levels = DESIRED_LEVELS.lock().unwrap().clone();
let desired_caps = DESIRED_CAPABILITIES.lock().unwrap().clone();
let desired_filter = DESIRED_FILTER.lock().unwrap().clone();
let subscribe = serde_json::json!({
"type": "subscribe",
"levels": desired_levels,
"capabilities": desired_caps,
"llm_filter": desired_filter,
})
.to_string();
tx.send(Message::Text(subscribe)).await?;
// set up control sender channel and forwarder (moves tx)
let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
{
let mut guard = CONTROL_SENDER.lock().unwrap();
*guard = Some(ctrl_tx);
}
tokio::spawn(async move {
while let Some(msg) = ctrl_rx.recv().await {
if let Err(err) = tx.send(Message::Text(msg)).await {
warn!("[bridge] control send error: {err:?}");
break;
}
}
});
// announce developer message
let announce = format!(
"Code Bridge host available.\n- url: {url}\n- secret: {secret}\n",
url = meta.url,
secret = meta.secret
);
session.record_bridge_event(announce).await;
while let Some(msg) = rx.next().await {
match msg {
Ok(Message::Text(text)) => {
let summary = summarize(&text);
session.record_bridge_event(summary).await;
}
Ok(Message::Binary(_)) => {}
Ok(Message::Close(_)) => break,
Ok(Message::Ping(_)) => {}
Ok(Message::Pong(_)) => {}
Ok(Message::Frame(_)) => {}
Err(err) => {
warn!("[bridge] websocket error: {err:?}");
break;
}
}
}
// clear sender on exit
{
let mut guard = CONTROL_SENDER.lock().unwrap();
*guard = None;
}
Ok(())
}
fn summarize(raw: &str) -> String {
if let Ok(val) = serde_json::from_str::<Value>(raw) {
let mut parts = Vec::new();
if let Some(t) = val.get("type").and_then(|v| v.as_str()) {
parts.push(format!("type: {t}"));
}
if let Some(level) = val.get("level").and_then(|v| v.as_str()) {
parts.push(format!("level: {level}"));
}
if let Some(msg) = val.get("message").and_then(|v| v.as_str()) {
parts.push(format!("message: {msg}"));
}
return format!("<code_bridge_event>\n{}\n</code_bridge_event>", parts.join("\n"));
}
raw.to_string()
}
+11
View File
@@ -22,6 +22,7 @@ use async_channel::Sender;
use base64::Engine;
use code_apply_patch::ApplyPatchAction;
use code_apply_patch::MaybeApplyPatchVerified;
use crate::bridge_client::spawn_bridge_listener;
use code_browser::BrowserConfig as CodexBrowserConfig;
use code_browser::BrowserManager;
use code_otel::otel_event_manager::{
@@ -1496,6 +1497,15 @@ impl Session {
&self.cwd
}
pub(crate) async fn record_bridge_event(&self, text: String) {
let message = ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText { text }],
};
self.record_conversation_items(&[message]).await;
}
pub(crate) fn get_sandbox_policy(&self) -> &SandboxPolicy {
&self.sandbox_policy
}
@@ -4655,6 +4665,7 @@ async fn submission_loop(
}
if let Some(sess_arc) = &sess {
spawn_bridge_listener(sess_arc.clone());
sess_arc.run_session_hooks(ProjectHookEvent::SessionStart).await;
}
+1
View File
@@ -15,6 +15,7 @@ mod client;
mod client_common;
pub mod codex;
mod code_conversation;
mod bridge_client;
pub mod token_data;
pub use code_conversation::CodexConversation;
mod command_safety;