mod event;
mod highlight;
mod notify;
mod pty;
mod pump;
mod state;
mod term;
mod ui;
use std::collections::HashMap;
use std::io::{self, Stdout};
use std::sync::Arc;
use anyhow::{Context, Result};
use bitrouter_substrate::engine::Session;
use bitrouter_substrate::translate::PermissionOutcome;
use crossterm::event::{
Event as CtEvent, EventStream, KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use crate::tui::event::{AppEvent, DiffData, Effect, Incoming, PermOption};
use crate::tui::state::pane::PaneState;
use crate::tui::state::{AppState, reduce};
pub async fn run(agent_id: &str, worktree: Option<&str>, model: Option<&str>) -> Result<()> {
let source = crate::paths::resolve_config(None)?;
let cfg = crate::paths::load_config(&source).await?;
let catalog = bitrouter_sdk::acp::ConfigAcpRoutingTable::from_configs(
cfg.agents.iter().map(|(k, v)| (k.clone(), v.clone())),
)
.context("building acp catalog from config.agents")?;
let agent_ids: Vec<String> = cfg.agents.keys().cloned().collect();
let base_repo = std::env::current_dir().context("resolving current directory")?;
let ports = (cfg.worktrees.ports.from, cfg.worktrees.ports.to);
let stderr_logged =
bitrouter_substrate::dotdir::redirect_stderr_to(&base_repo.join(".bitrouter/tui.log"));
let (tx, rx) = unbounded_channel::<Incoming>();
let mut sessions: HashMap<String, Arc<Session>> = HashMap::new();
let mut port_leases: crate::fleet::PortLeases = HashMap::new();
let mut ptys: HashMap<String, pty::PtyPane> = HashMap::new();
let _ = bitrouter_substrate::dotdir::ensure_self_ignored(&base_repo.join(".bitrouter"));
let fleet_store = bitrouter_substrate::fleet::FleetStore::new(&base_repo);
let previous_fleet = fleet_store.load().await;
#[cfg(unix)]
let fleet_sock = start_fleet_listener(&base_repo, tx.clone());
#[cfg(unix)]
let fleet_sock_path = fleet_sock.as_ref().map(|s| s.0.clone());
#[cfg(not(unix))]
let fleet_sock_path: Option<std::path::PathBuf> = None;
let models = fetch_models(&cfg.server.listen).await;
let orchestrator = crate::harness::by_interactive_binary(agent_id).or_else(|| {
if cfg.agents.contains_key(agent_id) {
None
} else {
crate::harness::by_id(agent_id).filter(|h| h.interactive_binary.is_some())
}
});
let base_url = crate::spawn::derive_base_url(&cfg.server.listen);
let mcp_route = cfg
.mcp
.aggregate
.enabled
.then(|| cfg.mcp.aggregate.route.clone());
let initial_pane = if let Some(h) = orchestrator {
if worktree.is_some() {
anyhow::bail!(
"--worktree applies to ACP agents; the orchestrator runs in the base repo"
);
}
let (record_id, pane, pty_pane) = launch_orchestrator(
h,
&OrchestratorCtx {
base_url: &base_url,
base_repo: &base_repo,
model,
models: &models,
fleet_sock: fleet_sock_path.as_deref(),
mcp_route: mcp_route.as_deref(),
},
tx.clone(),
"orchestrator",
)?;
ptys.insert(record_id, pty_pane);
pane
} else if cfg.agents.contains_key(agent_id) {
let initial_lease = crate::fleet::reserve_port(&base_repo, ports);
let initial_port = initial_lease.as_ref().map(|l| l.port());
let options = bitrouter_substrate::engine::LaunchOptions {
worktree: worktree.map(|name| bitrouter_substrate::worktree::WorktreeSpec {
name: name.to_string(),
branch: None,
remove_on_shutdown: false,
}),
env: crate::fleet::port_env(initial_port),
mcp_servers: gateway_acp_descriptors(&base_url, mcp_route.as_deref()),
..Default::default()
};
let session = Session::launch(&catalog, agent_id, base_repo.clone(), options)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))?;
let record_id = session.state().record_id.clone();
let session = Arc::new(session);
pump::spawn(Arc::clone(&session), record_id.clone(), tx.clone());
sessions.insert(record_id.clone(), session);
if let Some(lease) = initial_lease {
port_leases.insert(record_id.clone(), lease);
}
let mut pane = PaneState::new(record_id, agent_id.to_string());
pane.port = initial_port;
pane
} else {
let mut available: Vec<String> = crate::harness::CATALOG
.iter()
.filter_map(|h| h.interactive_binary.map(str::to_string))
.collect();
available.extend(agent_ids.iter().cloned());
available.sort();
available.dedup();
anyhow::bail!(
"'{agent_id}' is neither a native harness nor an `agents:` entry — available: {}",
if available.is_empty() {
"none configured (add one with `bitrouter agents install <id>`)".to_string()
} else {
available.join(", ")
}
);
};
let mut state = AppState::new(initial_pane);
state.bootstrap_cmd = cfg.worktrees.bootstrap.clone();
if let Some(spec) = &cfg.tui.leader {
match crate::tui::state::overlay::parse_leader(spec) {
Some(leader) => state.leader = leader,
None => tracing::warn!(spec, "unparseable tui.leader — using ctrl-space"),
}
}
state.set_available_agents(agent_ids);
state.available_sessions = crate::harness::CATALOG
.iter()
.filter_map(|h| h.interactive_binary.map(str::to_string))
.collect();
state.set_harness_map(
cfg.agents
.iter()
.map(|(k, v)| (k.clone(), harness_tag(&v.transport)))
.collect(),
);
match probe_serve(&cfg.server.listen).await {
Some(warning) => {
state.serve_ok = Some(false);
state.notice = Some(warning);
}
None => state.serve_ok = Some(true),
}
state.no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
if state.notice.is_none()
&& let Some(notice) = previous_fleet.as_ref().and_then(previous_fleet_notice)
{
state.notice = Some(notice);
}
let (integrate_tx, integrate_task) = start_integrator(base_repo.clone(), tx.clone());
install_panic_restore(restore_terminal, stderr_logged);
let mut terminal = setup_terminal().context("entering raw mode")?;
let rt = Runtime {
sessions,
pending: HashMap::new(),
prompt_tasks: HashMap::new(),
spawner: Spawner {
catalog: &catalog,
base_repo,
gateway_base: base_url,
mcp_route,
models,
model: model.map(str::to_string),
fleet_sock: fleet_sock_path,
tx,
},
fleet: Fleet {
ports,
leases: port_leases,
meta: HashMap::new(),
checks: cfg.worktrees.checks.clone(),
},
#[cfg(unix)]
bridges: HashMap::new(),
#[cfg(unix)]
bridge_agents: HashMap::new(),
#[cfg(unix)]
bridge_perms: HashMap::new(),
#[cfg(unix)]
_fleet_sock: fleet_sock,
ptys,
notify: notify::NotifyPath::detect(),
fleet_store,
session_seq: 0,
listen: cfg.server.listen.clone(),
last_fleet: None,
integrate_tx: Some(integrate_tx),
integrate_task: Some(integrate_task),
};
let result = event_loop(&mut terminal, state, rx, rt).await;
restore_terminal();
result
}
struct Fleet {
ports: (u16, u16),
leases: crate::fleet::PortLeases,
meta: HashMap<String, WorkMeta>,
checks: Vec<String>,
}
#[derive(Clone)]
struct WorkMeta {
worktree: std::path::PathBuf,
branch: String,
base_ref: String,
}
enum IntKind {
Merge,
Apply,
}
struct IntegrationJob {
record_id: String,
meta: WorkMeta,
kind: IntKind,
}
fn start_integrator(
base_repo: std::path::PathBuf,
tx: UnboundedSender<Incoming>,
) -> (UnboundedSender<IntegrationJob>, tokio::task::JoinHandle<()>) {
let (jtx, mut jrx) = unbounded_channel::<IntegrationJob>();
let handle = tokio::spawn(async move {
while let Some(job) = jrx.recv().await {
let (message, ok) = match job.kind {
IntKind::Merge => match crate::fleet::merge_branch(
&base_repo,
&job.meta.worktree,
&job.meta.branch,
"ask the agent to commit, or use p (apply)",
)
.await
{
Ok(()) => (format!("merged {}", job.meta.branch), true),
Err(e) => (format!("merge failed: {e:#}"), false),
},
IntKind::Apply => match crate::fleet::apply_diff(
&base_repo,
&job.meta.worktree,
&job.meta.base_ref,
)
.await
{
Ok(()) => (
"applied to the base working tree (uncommitted — you write the commit)"
.to_string(),
true,
),
Err(e) => (format!("apply failed: {e:#}"), false),
},
};
let _ = tx.send(Incoming::OpDone {
record_id: job.record_id,
message,
ok,
});
}
});
(jtx, handle)
}
fn harness_tag(transport: &bitrouter_sdk::acp::AcpTransport) -> String {
match transport {
bitrouter_sdk::acp::AcpTransport::Stdio { command, args, .. } => {
if let Some(h) = crate::harness::match_invocation(command, args) {
return h.interactive_binary.unwrap_or(h.id).to_string();
}
std::path::Path::new(command)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| command.clone())
}
}
}
fn attach_interactive(record_id: &str, state: &mut AppState, rt: &mut Runtime<'_>) {
let attach_id = format!("attach:{record_id}");
if rt.ptys.contains_key(&attach_id) {
state.notice = Some(format!(
"already attached — {} c on the attach pane detaches",
state.leader_label()
));
return;
}
let Some(sess) = rt.sessions.get(record_id) else {
state.notice = Some("nothing to attach to".into());
return;
};
let agent_id = sess.state().agent_id.clone();
let Some(bitrouter_sdk::acp::AcpTransport::Stdio { command, args, .. }) =
rt.spawner.catalog.lookup(&agent_id)
else {
state.notice = Some(format!("no transport for '{agent_id}'"));
return;
};
let Some(h) = crate::harness::match_invocation(command, args) else {
state.notice = Some(format!(
"'{agent_id}' matches no catalog harness — cannot attach"
));
return;
};
let Some(binary) = h.interactive_binary else {
state.notice = Some(format!("'{}' has no interactive binary to attach", h.id));
return;
};
let cwd = rt
.fleet
.meta
.get(record_id)
.map(|m| m.worktree.clone())
.or_else(|| sess.worktree_path().map(|p| p.to_path_buf()))
.unwrap_or_else(|| rt.spawner.base_repo.clone());
let mut extra: Vec<String> = Vec::new();
if let Some(sid) = &sess.state().agent_session_id {
match binary {
"claude" => extra.extend(["--resume".to_string(), sid.clone()]),
"codex" => extra.extend(["resume".to_string(), sid.clone()]),
_ => {}
}
}
let auth = crate::harness::resolve_gateway_auth(
std::env::var(crate::harness::BITROUTER_API_KEY_ENV).ok(),
false,
)
.unwrap_or_else(|| crate::harness::PLACEHOLDER_API_KEY.to_string());
let state_dir = rt
.spawner
.base_repo
.join(".bitrouter")
.join(format!("attach-{record_id}"));
let gateways = crate::gateways::gateway_servers(
&rt.spawner.gateway_base,
&auth,
rt.spawner.mcp_route.as_deref(),
);
let overlay = match h.orchestrator_overlay(
&rt.spawner.gateway_base,
&auth,
None,
&rt.spawner.models,
&gateways,
&state_dir,
) {
Ok(o) => o,
Err(e) => {
state.notice = Some(format!("attach overlay failed: {e:#}"));
return;
}
};
let mut all_args = overlay.args.clone();
all_args.extend(extra);
match pty::PtyPane::spawn(
&attach_id,
&pty::PtyLaunch {
command: binary,
args: &all_args,
env: &overlay.env,
cwd: &cwd,
},
80,
24,
rt.spawner.tx.clone(),
) {
Ok(pane) => {
rt.ptys.insert(attach_id.clone(), pane);
let _ = reduce(
state,
&AppEvent::PtyAttached {
record_id: attach_id,
agent_id: format!("{binary}⤴{}", agent_id),
},
);
}
Err(e) => state.notice = Some(format!("attach failed: {e:#}")),
}
}
struct OrchestratorCtx<'a> {
base_url: &'a str,
base_repo: &'a std::path::Path,
model: Option<&'a str>,
models: &'a [String],
fleet_sock: Option<&'a std::path::Path>,
mcp_route: Option<&'a str>,
}
fn launch_orchestrator(
h: &'static crate::harness::Harness,
ctx: &OrchestratorCtx<'_>,
tx: UnboundedSender<Incoming>,
record_id: &str,
) -> Result<(String, PaneState, pty::PtyPane)> {
let OrchestratorCtx {
base_url,
base_repo,
model,
models,
fleet_sock,
mcp_route,
} = *ctx;
let Some(binary) = h.interactive_binary else {
anyhow::bail!("harness '{}' has no interactive binary", h.id);
};
let auth = crate::harness::resolve_gateway_auth(
std::env::var(crate::harness::BITROUTER_API_KEY_ENV).ok(),
false,
)
.unwrap_or_else(|| crate::harness::PLACEHOLDER_API_KEY.to_string());
let state_dir = if record_id == "orchestrator" {
base_repo.join(".bitrouter")
} else {
base_repo.join(".bitrouter").join(record_id)
};
let mut mcp_servers = vec![fleet_mcp_server()];
mcp_servers.extend(crate::gateways::gateway_servers(base_url, &auth, mcp_route));
let overlay = h
.orchestrator_overlay(base_url, &auth, model, models, &mcp_servers, &state_dir)
.with_context(|| format!("assembling the '{}' orchestrator overlay", h.id))?;
let args = overlay.args.clone();
let mut env = overlay.env.clone();
if let Some(sock) = fleet_sock {
env.push((
crate::fleet::TUI_SOCK_ENV.to_string(),
sock.display().to_string(),
));
}
let pane = pty::PtyPane::spawn(
record_id,
&pty::PtyLaunch {
command: binary,
args: &args,
env: &env,
cwd: base_repo,
},
80,
24,
tx,
)
.with_context(|| format!("launching orchestrator '{binary}' on a pty"))?;
let mut pane_state = PaneState::new(record_id.to_string(), binary.to_string());
pane_state.kind = crate::tui::state::pane::PaneKind::Pty;
pane_state.harness = "pty".to_string();
pane_state.model = model.map(str::to_string);
Ok((record_id.to_string(), pane_state, pane))
}
#[cfg(unix)]
struct SockCleanup(std::path::PathBuf);
#[cfg(unix)]
impl Drop for SockCleanup {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[cfg(unix)]
fn start_fleet_listener(
base_repo: &std::path::Path,
tx: UnboundedSender<Incoming>,
) -> Option<SockCleanup> {
let path = crate::fleet::tui_sock_path(base_repo);
let _ = std::fs::remove_file(&path);
let listener = match tokio::net::UnixListener::bind(&path) {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "fleet socket bind failed");
return None;
}
};
tokio::spawn(async move {
let mut seq = 0u64;
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
seq += 1;
let conn = seq;
let (read, write) = stream.into_split();
if tx
.send(Incoming::BridgeConnected {
conn,
writer: write,
})
.is_err()
{
break; }
let tx = tx.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut lines = tokio::io::BufReader::new(read).lines();
while let Ok(Some(line)) = lines.next_line().await {
match serde_json::from_str::<crate::fleet::BridgeMsg>(&line) {
Ok(msg) => {
if tx.send(Incoming::Bridge { conn, msg }).is_err() {
return;
}
}
Err(e) => {
tracing::warn!(error = %e, "unparseable fleet bridge message")
}
}
}
let _ = tx.send(Incoming::BridgeGone { conn });
});
}
});
Some(SockCleanup(path))
}
#[cfg(unix)]
async fn bridge_write(writer: &mut tokio::net::unix::OwnedWriteHalf, msg: &crate::fleet::TuiMsg) {
use tokio::io::AsyncWriteExt;
if let Ok(mut line) = serde_json::to_string(msg) {
line.push('\n');
let _ = writer.write_all(line.as_bytes()).await;
}
}
fn fleet_mcp_server() -> crate::harness::McpServer {
crate::harness::McpServer {
name: "bitrouter_fleet".to_string(),
transport: crate::harness::McpTransport::Stdio {
command: std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "bitrouter".to_string()),
args: ["mcp", "serve", "--backend", "fleet"]
.map(str::to_string)
.to_vec(),
},
}
}
fn gateway_acp_descriptors(
base_url: &str,
mcp_route: Option<&str>,
) -> Vec<agent_client_protocol::schema::v1::McpServer> {
let auth = crate::harness::resolve_gateway_auth(
std::env::var(crate::harness::BITROUTER_API_KEY_ENV).ok(),
false,
)
.unwrap_or_else(|| crate::harness::PLACEHOLDER_API_KEY.to_string());
crate::gateways::gateway_servers(base_url, &auth, mcp_route)
.iter()
.map(crate::gateways::to_acp)
.collect()
}
async fn fetch_models(listen: &str) -> Vec<String> {
let base = crate::spawn::derive_base_url(listen);
let request = reqwest::Client::new()
.get(format!("{base}/v1/models"))
.timeout(std::time::Duration::from_secs(2))
.send();
let Ok(Ok(resp)) = tokio::time::timeout(std::time::Duration::from_secs(3), request).await
else {
return Vec::new();
};
let Ok(body) = resp.json::<serde_json::Value>().await else {
return Vec::new();
};
body["data"]
.as_array()
.map(|models| {
models
.iter()
.filter_map(|m| m["id"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
async fn probe_serve(listen: &str) -> Option<String> {
let addr = listen.replace("0.0.0.0", "127.0.0.1");
let connect = tokio::net::TcpStream::connect(&addr);
match tokio::time::timeout(std::time::Duration::from_millis(500), connect).await {
Ok(Ok(_)) => None,
_ => Some(format!(
"bitrouter serve unreachable at {addr} — agents' LLM calls will fail; start it with `bitrouter serve`"
)),
}
}
static KITTY_PUSHED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
match setup_after_raw() {
Ok(terminal) => Ok(terminal),
Err(e) => {
let _ = disable_raw_mode();
Err(e)
}
}
}
fn setup_after_raw() -> Result<Terminal<CrosstermBackend<Stdout>>> {
let mut out = io::stdout();
execute!(
out,
EnterAlternateScreen,
crossterm::event::EnableMouseCapture,
crossterm::event::EnableFocusChange
)?;
let _ = execute!(out, crossterm::event::EnableBracketedPaste);
write_out(¬ify::NotifyPath::detect().title_push());
if matches!(
crossterm::terminal::supports_keyboard_enhancement(),
Ok(true)
) && execute!(
out,
crossterm::event::PushKeyboardEnhancementFlags(
crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
)
)
.is_ok()
{
KITTY_PUSHED.store(true, std::sync::atomic::Ordering::SeqCst);
}
Ok(Terminal::new(CrosstermBackend::new(out))?)
}
fn restore_terminal() {
let _ = disable_raw_mode();
let mut out = io::stdout();
if KITTY_PUSHED.swap(false, std::sync::atomic::Ordering::SeqCst) {
let _ = execute!(out, crossterm::event::PopKeyboardEnhancementFlags);
}
write_out(¬ify::NotifyPath::detect().title_pop());
let _ = execute!(out, crossterm::event::DisableBracketedPaste);
let _ = execute!(
out,
crossterm::event::DisableFocusChange,
crossterm::event::DisableMouseCapture,
LeaveAlternateScreen,
crossterm::cursor::Show
);
}
fn write_out(bytes: &[u8]) {
use std::io::Write;
let mut out = io::stdout();
let _ = out.write_all(bytes);
let _ = out.flush();
}
fn install_panic_restore(restore: fn(), stderr_logged: bool) {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore();
if stderr_logged {
use std::io::Write;
let mut out = io::stdout();
let _ = writeln!(
out,
"bitrouter tui panicked: {info}\n(full log: .bitrouter/tui.log)"
);
let _ = out.flush();
}
prev(info);
}));
}
struct Spawner<'a> {
catalog: &'a bitrouter_sdk::acp::ConfigAcpRoutingTable,
base_repo: std::path::PathBuf,
gateway_base: String,
mcp_route: Option<String>,
models: Vec<String>,
model: Option<String>,
fleet_sock: Option<std::path::PathBuf>,
tx: UnboundedSender<Incoming>,
}
type PromptTasks = HashMap<String, Vec<tokio::task::JoinHandle<()>>>;
struct Runtime<'a> {
sessions: HashMap<String, Arc<Session>>,
pending:
HashMap<String, std::collections::VecDeque<bitrouter_substrate::up::PendingPermission>>,
prompt_tasks: PromptTasks,
spawner: Spawner<'a>,
fleet: Fleet,
#[cfg(unix)]
bridges: HashMap<u64, tokio::net::unix::OwnedWriteHalf>,
#[cfg(unix)]
bridge_agents: HashMap<String, u64>,
#[cfg(unix)]
bridge_perms: HashMap<String, (u64, u64)>,
#[cfg(unix)]
_fleet_sock: Option<SockCleanup>,
ptys: HashMap<String, pty::PtyPane>,
notify: notify::NotifyPath,
fleet_store: bitrouter_substrate::fleet::FleetStore,
session_seq: u64,
listen: String,
last_fleet: Option<bitrouter_substrate::fleet::FleetState>,
integrate_tx: Option<UnboundedSender<IntegrationJob>>,
integrate_task: Option<tokio::task::JoinHandle<()>>,
}
fn fleet_state(state: &AppState) -> bitrouter_substrate::fleet::FleetState {
bitrouter_substrate::fleet::FleetState {
version: bitrouter_substrate::fleet::FLEET_STATE_VERSION,
saved_at: 0, clean_shutdown: false,
writer_pid: std::process::id(),
sessions: state.fleet_sessions(),
agents: state.fleet_agents(),
}
}
async fn flush_fleet_state(state: &AppState, rt: &mut Runtime<'_>) {
let snapshot = fleet_state(state);
if rt.last_fleet.as_ref() == Some(&snapshot) {
return;
}
if let Err(e) = rt.fleet_store.save(&snapshot).await {
tracing::warn!(error = %e, "failed to write fleet state");
}
rt.last_fleet = Some(snapshot);
}
async fn flush_fleet_state_clean(state: &AppState, rt: &mut Runtime<'_>) {
let mut snapshot = fleet_state(state);
snapshot.clean_shutdown = true;
if let Err(e) = rt.fleet_store.save(&snapshot).await {
tracing::warn!(error = %e, "failed to write final fleet state");
}
}
fn previous_fleet_notice(prev: &bitrouter_substrate::fleet::FleetState) -> Option<String> {
if prev.agents.is_empty() {
return None;
}
let reviews = prev.agents.iter().filter(|a| a.review.is_some()).count();
let mut notice = format!("previous fleet remembered: {} agent(s)", prev.agents.len());
if prev.sessions.len() > 1 {
notice.push_str(&format!(", {} sessions", prev.sessions.len()));
}
if reviews > 0 {
notice.push_str(&format!(", {reviews} mid-review"));
}
if !prev.clean_shutdown {
notice.push_str(" · unclean shutdown");
}
notice.push_str(" — .bitrouter/fleet-state.json");
Some(notice)
}
async fn event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
mut state: AppState,
mut rx: UnboundedReceiver<Incoming>,
mut rt: Runtime<'_>,
) -> Result<()> {
let mut keys = EventStream::new();
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_title = String::new();
loop {
let badge = state.title_badge();
if badge != last_title {
write_out(&rt.notify.title(&badge));
last_title = badge;
}
let pty_views: Vec<ui::PtyView> = rt
.ptys
.iter()
.map(|(rid, pane)| ui::PtyView {
record_id: rid.clone(),
lines: pane.backend.lines(state.no_color),
scrolled: pane.backend.is_scrolled(),
})
.collect();
if let Err(e) = terminal.draw(|f| ui::render(&mut state, &pty_views, f)) {
flush_fleet_state_clean(&state, &mut rt).await;
cleanup(&mut rt).await;
return Err(e).context("draw");
}
for area in &state.pty_areas {
if let Some(pane) = rt.ptys.get_mut(&area.record_id) {
pane.resize(area.cols, area.rows);
}
}
if state.should_quit {
flush_fleet_state_clean(&state, &mut rt).await;
cleanup(&mut rt).await;
return Ok(());
}
let app_event: Option<AppEvent> = tokio::select! {
maybe_key = keys.next() => match maybe_key {
Some(Ok(CtEvent::Key(k)))
if k.kind == crossterm::event::KeyEventKind::Release => None,
Some(Ok(CtEvent::Key(k))) => Some(AppEvent::Key(k)),
Some(Ok(CtEvent::Paste(text))) => Some(AppEvent::Paste(text)),
Some(Ok(CtEvent::Mouse(m))) => forward_or_map_mouse(&m, &state, &mut rt),
Some(Ok(CtEvent::FocusGained)) => Some(AppEvent::Focus(true)),
Some(Ok(CtEvent::FocusLost)) => Some(AppEvent::Focus(false)),
Some(Ok(_)) => None,
Some(Err(_)) | None => Some(AppEvent::ForceQuit),
},
_ = ticker.tick() => Some(AppEvent::Tick),
maybe_in = rx.recv() => match maybe_in {
Some(incoming) => convert_incoming(incoming, &mut rt),
None => Some(AppEvent::ForceQuit),
},
};
if let Some(app_event) = app_event {
let is_tick = matches!(app_event, AppEvent::Tick);
let effects = reduce(&mut state, &app_event);
for effect in effects {
apply_effect(effect, &mut state, &mut rt).await;
}
if is_tick && state.tick.is_multiple_of(5) {
flush_fleet_state(&state, &mut rt).await;
}
if is_tick && state.tick.is_multiple_of(25) {
let listen = rt.listen.clone();
let tx = rt.spawner.tx.clone();
tokio::spawn(async move {
let ok = probe_serve(&listen).await.is_none();
let _ = tx.send(Incoming::ServeStatus { ok });
});
}
}
let mut drained = 0;
while drained < 256 {
let incoming = match rx.try_recv() {
Ok(i) => i,
Err(_) => break,
};
drained += 1;
if let Some(ev) = convert_incoming(incoming, &mut rt) {
let effects = reduce(&mut state, &ev);
for effect in effects {
apply_effect(effect, &mut state, &mut rt).await;
}
}
}
rt.prompt_tasks.retain(|_, handles| {
handles.retain(|h| !h.is_finished());
!handles.is_empty()
});
}
}
fn forward_or_map_mouse(
m: &MouseEvent,
state: &AppState,
rt: &mut Runtime<'_>,
) -> Option<AppEvent> {
if let Some(area) = state.pty_areas.iter().find(|a| a.contains(m.column, m.row))
&& let Some(pane) = rt.ptys.get_mut(&area.record_id)
&& pane.backend.mouse_enabled()
{
let col = m.column - area.x + 1;
let row = m.row - area.y + 1;
if let Some(bytes) = pane.backend.encode_mouse(m.kind, col, row, m.modifiers) {
pane.write_input(&bytes);
}
return None; }
match m.kind {
MouseEventKind::ScrollUp => Some(AppEvent::Scroll { up: true }),
MouseEventKind::ScrollDown => Some(AppEvent::Scroll { up: false }),
MouseEventKind::Down(MouseButton::Left) => Some(AppEvent::Click {
col: m.column,
row: m.row,
}),
_ => None,
}
}
fn convert_incoming(incoming: Incoming, rt: &mut Runtime<'_>) -> Option<AppEvent> {
match incoming {
Incoming::Update { record_id, update } => Some(AppEvent::Update { record_id, update }),
Incoming::Permission {
record_id,
pending: p,
} => {
if rt.sessions.contains_key(&record_id) {
let queue = rt.pending.entry(record_id.clone()).or_default();
queue.push_back(*p);
if queue.len() > 1 {
return None;
}
let front = queue.front()?;
Some(perm_event(&record_id, front, &rt.spawner.base_repo))
} else {
None
}
}
Incoming::PermissionNext { record_id } => {
let front = rt.pending.get(&record_id)?.front()?;
Some(perm_event(&record_id, front, &rt.spawner.base_repo))
}
Incoming::TurnEnded {
record_id,
stop_reason,
} => Some(AppEvent::TurnEnded {
record_id,
stop_reason,
}),
Incoming::Exited { record_id } => Some(AppEvent::Exited { record_id }),
Incoming::PromptFailed { record_id, error } => {
Some(AppEvent::PromptFailed { record_id, error })
}
Incoming::ReviewReady {
record_id,
files,
adds,
dels,
} => Some(AppEvent::ReviewReady {
record_id,
files,
adds,
dels,
}),
Incoming::ChecksFailed { record_id, output } => {
Some(AppEvent::ChecksFailed { record_id, output })
}
Incoming::DiffLoaded { record_id, text } => Some(AppEvent::DiffLoaded { record_id, text }),
Incoming::PtyOutput { record_id, bytes } => {
if let Some(pane) = rt.ptys.get_mut(&record_id) {
for seq in pane.feed(&bytes) {
use std::io::Write;
let mut out = std::io::stdout();
let _ = out.write_all(&seq);
let _ = out.flush();
}
}
None }
Incoming::PtyExited { record_id } => Some(AppEvent::Exited { record_id }),
Incoming::ServeStatus { ok } => Some(AppEvent::ServeStatus { ok }),
Incoming::Spawned {
agent_id,
session,
lease,
tag,
base_ref,
} => {
let sess = *session;
let rid = sess.state().record_id.clone();
if let Some(wt) = sess.worktree_path() {
let handle = crate::fleet::record16(&rid);
rt.fleet.meta.insert(
rid.clone(),
WorkMeta {
worktree: wt.to_path_buf(),
branch: format!("bitrouter/{tag}-{handle}"),
base_ref,
},
);
}
let port = lease.as_ref().map(|l| l.port());
let sess = Arc::new(sess);
rt.sessions.insert(rid.clone(), Arc::clone(&sess));
if let Some(lease) = lease {
rt.fleet.leases.insert(rid.clone(), lease);
}
pump::spawn(sess, rid.clone(), rt.spawner.tx.clone());
Some(AppEvent::AgentSpawned {
record_id: rid,
agent_id,
port,
})
}
Incoming::SpawnFailed { agent_id, error } => {
Some(AppEvent::AgentSpawnFailed { agent_id, error })
}
Incoming::OpDone {
record_id,
message,
ok,
} => Some(AppEvent::OpDone {
record_id,
message,
ok,
}),
#[cfg(unix)]
Incoming::BridgeConnected { conn, writer } => {
rt.bridges.insert(conn, writer);
Some(AppEvent::BridgeConnected { conn })
}
#[cfg(unix)]
Incoming::Bridge { conn, msg } => match msg {
crate::fleet::BridgeMsg::Spawned {
handle,
agent,
port,
} => {
let record_id = format!("mcp:{handle}");
rt.bridge_agents.insert(record_id.clone(), conn);
Some(AppEvent::BridgeSpawned {
record_id,
agent_id: agent,
port,
})
}
crate::fleet::BridgeMsg::State { handle, state } => Some(AppEvent::BridgeState {
record_id: format!("mcp:{handle}"),
state,
}),
crate::fleet::BridgeMsg::Closed { handle } => {
let record_id = format!("mcp:{handle}");
rt.bridge_agents.remove(&record_id);
rt.bridge_perms.remove(&record_id);
Some(AppEvent::Exited { record_id })
}
crate::fleet::BridgeMsg::Permission {
id,
handle,
title,
diff,
options,
} => {
let record_id = format!("mcp:{handle}");
rt.bridge_perms.insert(record_id.clone(), (conn, id));
Some(AppEvent::Permission {
record_id,
title,
diff: diff.map(|d| DiffData {
path: d.path,
old: d.old,
new: d.new,
}),
options: options
.into_iter()
.map(|o| PermOption {
outcome: crate::fleet::outcome_from_str(&o.outcome),
label: o.label,
})
.collect(),
risk: crate::risk::Risk::High,
})
}
crate::fleet::BridgeMsg::Notify { message } => Some(AppEvent::BridgeNotify { message }),
crate::fleet::BridgeMsg::RequestAttach { handle } => {
Some(AppEvent::BridgeRequestAttach {
record_id: format!("mcp:{handle}"),
})
}
crate::fleet::BridgeMsg::RequestReview { handle } => {
Some(AppEvent::BridgeRequestReview {
record_id: format!("mcp:{handle}"),
})
}
},
#[cfg(unix)]
Incoming::BridgeGone { conn } => {
rt.bridges.remove(&conn);
let record_ids: Vec<String> = rt
.bridge_agents
.iter()
.filter(|(_, c)| **c == conn)
.map(|(id, _)| id.clone())
.collect();
for id in &record_ids {
rt.bridge_agents.remove(id);
rt.bridge_perms.remove(id);
}
Some(AppEvent::BridgeGone { record_ids })
}
}
}
async fn apply_effect(effect: Effect, state: &mut AppState, rt: &mut Runtime<'_>) {
match effect {
Effect::Quit => {}
Effect::Bell => {
write_out(b"\x07");
}
Effect::Notify { title, body } => {
if let Some(seq) = rt.notify.notification(&title, &body) {
write_out(&seq);
}
}
Effect::Prompt { record_id, text } => {
if let Some(sess) = rt.sessions.get(&record_id) {
let sess = Arc::clone(sess);
let tx = rt.spawner.tx.clone();
let rid = record_id.clone();
let handle = tokio::spawn(async move {
match sess.prompt(&text).await {
Ok(resp) => {
let _ = tx.send(Incoming::TurnEnded {
record_id: rid,
stop_reason: resp.stop_reason,
});
}
Err(e) => {
let _ = tx.send(Incoming::PromptFailed {
record_id: rid,
error: e.to_string(),
});
}
}
});
rt.prompt_tasks.entry(record_id).or_default().push(handle);
}
}
Effect::ResolvePermission { record_id, outcome } => {
let mut emptied = false;
if let Some(queue) = rt.pending.get_mut(&record_id) {
if let Some(p) = queue.pop_front() {
let selected =
bitrouter_substrate::translate::select_option(outcome, &p.options);
p.resolve(selected);
}
emptied = queue.is_empty();
if !emptied {
let _ = rt.spawner.tx.send(Incoming::PermissionNext {
record_id: record_id.clone(),
});
}
}
if emptied {
rt.pending.remove(&record_id);
}
#[cfg(unix)]
if let Some((conn, id)) = rt.bridge_perms.remove(&record_id)
&& let Some(writer) = rt.bridges.get_mut(&conn)
{
bridge_write(
writer,
&crate::fleet::TuiMsg::Resolve {
id,
outcome: crate::fleet::outcome_str(outcome).to_string(),
},
)
.await;
}
}
Effect::ReviewVerdict { record_id, note } => {
#[cfg(unix)]
if let Some(&conn) = rt.bridge_agents.get(&record_id)
&& let Some(writer) = rt.bridges.get_mut(&conn)
{
let handle = record_id
.strip_prefix("mcp:")
.unwrap_or(&record_id)
.to_string();
bridge_write(
writer,
&crate::fleet::TuiMsg::ReviewVerdict {
handle,
verdict: "changes_requested".to_string(),
note,
},
)
.await;
} else {
state.notice = Some("orchestrator disconnected — verdict not delivered".into());
let _ = note;
}
#[cfg(not(unix))]
{
let _ = (record_id, note);
}
}
Effect::SpawnAgent { agent_id } => {
let tag = crate::fleet::branch_tag(&agent_id);
let lease = crate::fleet::reserve_port(&rt.spawner.base_repo, rt.fleet.ports);
let port = lease.as_ref().map(|l| l.port());
let options = bitrouter_substrate::engine::LaunchOptions {
worktree: Some(crate::fleet::worktree_spec(&tag)),
worktree_bootstrap: match state.bootstrap_decision {
Some(true) => state.bootstrap_cmd.clone(),
_ => None,
},
env: crate::fleet::port_env(port),
mcp_servers: gateway_acp_descriptors(
&rt.spawner.gateway_base,
rt.spawner.mcp_route.as_deref(),
),
..Default::default()
};
let catalog = (*rt.spawner.catalog).clone();
let base_repo = rt.spawner.base_repo.clone();
let tx = rt.spawner.tx.clone();
tokio::spawn(async move {
match Session::launch(&catalog, &agent_id, base_repo.clone(), options).await {
Ok(sess) => {
let base_ref = crate::fleet::base_head(&base_repo).await;
if let Err(unsent) = tx.send(Incoming::Spawned {
agent_id,
session: Box::new(sess),
lease,
tag,
base_ref,
}) {
if let Incoming::Spawned { session, .. } = unsent.0 {
let _ = session.shutdown().await;
}
}
}
Err(e) => {
tracing::warn!(agent = %agent_id, error = %format!("{e:#}"), "subagent spawn failed");
let _ = tx.send(Incoming::SpawnFailed {
agent_id,
error: format!("{e:#}"),
});
}
}
});
}
Effect::SpawnSession { binary } => {
let Some(h) = crate::harness::by_interactive_binary(&binary) else {
state.notice = Some(format!("'{binary}' is not a session harness"));
return;
};
rt.session_seq += 1;
let record_id = format!("session-{}", rt.session_seq);
let model = rt.spawner.model.clone();
match launch_orchestrator(
h,
&OrchestratorCtx {
base_url: &rt.spawner.gateway_base,
base_repo: &rt.spawner.base_repo,
model: model.as_deref(),
models: &rt.spawner.models,
fleet_sock: rt.spawner.fleet_sock.as_deref(),
mcp_route: rt.spawner.mcp_route.as_deref(),
},
rt.spawner.tx.clone(),
&record_id,
) {
Ok((rid, _, pty_pane)) => {
rt.ptys.insert(rid.clone(), pty_pane);
let _ = reduce(
state,
&AppEvent::SessionSpawned {
record_id: rid,
binary,
model,
},
);
}
Err(e) => {
tracing::warn!(session = %binary, error = %format!("{e:#}"), "session launch failed");
let _ = reduce(
state,
&AppEvent::AgentSpawnFailed {
agent_id: binary,
error: format!("{e:#}"),
},
);
}
}
}
Effect::CloseAgent { record_id } => {
if let Some(mut pane) = rt.ptys.remove(&record_id) {
pane.kill();
return;
}
rt.pending.remove(&record_id);
rt.fleet.leases.remove(&record_id); rt.fleet.meta.remove(&record_id);
if let Some(handles) = rt.prompt_tasks.remove(&record_id) {
for handle in handles {
handle.abort();
let _ = handle.await;
}
}
if let Some(sess) = rt.sessions.remove(&record_id) {
shutdown_session(sess).await;
}
}
Effect::PtyKey { record_id, key } => {
if let Some(pane) = rt.ptys.get_mut(&record_id) {
pane.backend.scroll_to_bottom();
if let Some(bytes) = pane.backend.encode_key(&key) {
pane.write_input(&bytes);
}
}
}
Effect::PtyScroll {
record_id,
up,
page,
} => {
if let Some(pane) = rt.ptys.get_mut(&record_id) {
if pane.backend.alt_screen() {
let code = match (page, up) {
(true, true) => KeyCode::PageUp,
(true, false) => KeyCode::PageDown,
(false, true) => KeyCode::Up,
(false, false) => KeyCode::Down,
};
let key = KeyEvent::from(code);
let repeat = if page { 1 } else { 3 };
for _ in 0..repeat {
if let Some(bytes) = pane.backend.encode_key(&key) {
pane.write_input(&bytes);
}
}
} else {
pane.backend.scroll(up, page);
}
}
}
Effect::PtyPaste { record_id, text } => {
if let Some(pane) = rt.ptys.get_mut(&record_id) {
let bytes = pane.backend.encode_paste(&text);
pane.write_input(&bytes);
}
}
Effect::Attach { record_id } => {
attach_interactive(&record_id, state, rt);
}
Effect::CancelTurn { record_id } => {
if let Some(sess) = rt.sessions.get(&record_id) {
let sess = Arc::clone(sess);
tokio::spawn(async move {
if let Err(e) = sess.cancel().await {
tracing::warn!(error = %e, "turn cancel failed");
}
});
}
}
Effect::CheckReview { record_id } => {
let Some(meta) = rt.fleet.meta.get(&record_id).cloned() else {
return; };
let checks = rt.fleet.checks.clone();
let tx = rt.spawner.tx.clone();
tokio::spawn(async move {
let incoming = inspect_worktree(&record_id, &meta, &checks).await;
if let Some(incoming) = incoming {
let _ = tx.send(incoming);
}
});
}
Effect::LoadDiff { record_id } => {
let Some(meta) = rt.fleet.meta.get(&record_id).cloned() else {
state.notice = Some("no worktree to diff (agent runs in the base repo)".into());
return;
};
let tx = rt.spawner.tx.clone();
tokio::spawn(async move {
let text = match crate::fleet::git_stdout(&meta.worktree, &["diff", &meta.base_ref])
.await
{
Ok(t) if t.trim().is_empty() => "(no changes vs the spawn base)".to_string(),
Ok(t) => t,
Err(e) => format!("diff failed: {e:#}"),
};
let _ = tx.send(Incoming::DiffLoaded { record_id, text });
});
}
Effect::Merge { record_id } => {
let Some(meta) = rt.fleet.meta.get(&record_id).cloned() else {
state.notice = Some("no worktree to merge".into());
return;
};
if let Some(tx) = &rt.integrate_tx {
let _ = tx.send(IntegrationJob {
record_id,
meta,
kind: IntKind::Merge,
});
}
}
Effect::Apply { record_id } => {
let Some(meta) = rt.fleet.meta.get(&record_id).cloned() else {
state.notice = Some("no worktree to apply from".into());
return;
};
if let Some(tx) = &rt.integrate_tx {
let _ = tx.send(IntegrationJob {
record_id,
meta,
kind: IntKind::Apply,
});
}
}
#[cfg(unix)]
Effect::BridgeHello {
conn,
bootstrap_approved,
} => {
if let Some(writer) = rt.bridges.get_mut(&conn) {
bridge_write(writer, &crate::fleet::TuiMsg::Hello { bootstrap_approved }).await;
}
}
#[cfg(unix)]
Effect::BridgeBootstrapApproved => {
for writer in rt.bridges.values_mut() {
bridge_write(writer, &crate::fleet::TuiMsg::BootstrapApproved).await;
}
}
}
}
async fn inspect_worktree(record_id: &str, meta: &WorkMeta, checks: &[String]) -> Option<Incoming> {
let stat = crate::fleet::diff_stat(&meta.worktree, &meta.base_ref).await?;
let files = stat["files"].as_u64().unwrap_or(0);
if files == 0 {
return None; }
for check in checks {
let out = tokio::process::Command::new("sh")
.arg("-c")
.arg(check)
.current_dir(&meta.worktree)
.output()
.await;
match out {
Ok(out) if out.status.success() => {}
Ok(out) => {
let mut text = format!(
"$ {check}
"
);
text.push_str(&String::from_utf8_lossy(&out.stdout));
text.push_str(&String::from_utf8_lossy(&out.stderr));
if text.len() > 4000 {
let mut start = text.len() - 4000;
while !text.is_char_boundary(start) {
start += 1;
}
text = format!("…{}", &text[start..]);
}
return Some(Incoming::ChecksFailed {
record_id: record_id.to_string(),
output: text,
});
}
Err(e) => {
return Some(Incoming::ChecksFailed {
record_id: record_id.to_string(),
output: format!(
"$ {check}
failed to run: {e}"
),
});
}
}
}
Some(Incoming::ReviewReady {
record_id: record_id.to_string(),
files,
adds: stat["adds"].as_u64().unwrap_or(0),
dels: stat["dels"].as_u64().unwrap_or(0),
})
}
async fn abort_prompt_tasks(tasks: &mut PromptTasks) {
for (_, handles) in tasks.drain() {
for handle in handles {
handle.abort();
let _ = handle.await;
}
}
}
async fn cleanup(rt: &mut Runtime<'_>) {
drop(rt.integrate_tx.take());
if let Some(worker) = rt.integrate_task.take() {
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), worker).await;
}
for pane in rt.ptys.values_mut() {
pane.kill();
}
abort_prompt_tasks(&mut rt.prompt_tasks).await;
rt.pending.clear();
rt.fleet.leases.clear();
for (_, sess) in rt.sessions.drain() {
shutdown_session(sess).await;
}
}
async fn shutdown_session(sess: Arc<Session>) {
match Arc::try_unwrap(sess) {
Ok(only) => {
if let Err(e) = only.shutdown().await {
tracing::warn!(error = %e, "session shutdown failed");
}
}
Err(_) => {
tracing::warn!("session still referenced at teardown; worktree may leak");
}
}
}
fn perm_event(
record_id: &str,
p: &bitrouter_substrate::up::PendingPermission,
base_repo: &std::path::Path,
) -> AppEvent {
AppEvent::Permission {
record_id: record_id.to_string(),
title: p.tool_call.fields.title.clone().unwrap_or_default(),
diff: perm_diff(p),
options: perm_options(p),
risk: crate::risk::classify(&p.tool_call.fields, base_repo),
}
}
fn perm_diff(p: &bitrouter_substrate::up::PendingPermission) -> Option<DiffData> {
use agent_client_protocol::schema::v1::ToolCallContent;
p.tool_call
.fields
.content
.as_deref()
.unwrap_or(&[])
.iter()
.find_map(|c| match c {
ToolCallContent::Diff(d) => Some(DiffData {
path: d.path.display().to_string(),
old: d.old_text.clone().unwrap_or_default(),
new: d.new_text.clone(),
}),
_ => None,
})
}
fn perm_options(p: &bitrouter_substrate::up::PendingPermission) -> Vec<PermOption> {
use agent_client_protocol::schema::v1::PermissionOptionKind;
p.options
.iter()
.map(|o| {
let (outcome, label) = match o.kind {
PermissionOptionKind::AllowOnce => (PermissionOutcome::AllowOnce, "allow"),
PermissionOptionKind::AllowAlways => {
(PermissionOutcome::AllowAlways, "allow always")
}
_ => (PermissionOutcome::Deny, "deny"),
};
PermOption {
outcome,
label: label.to_string(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn previous_fleet_notice_summarizes_and_flags_unclean_stops() {
use bitrouter_substrate::fleet::{FLEET_STATE_VERSION, FleetAgent, FleetState};
let agent = |review: Option<(u64, u64, u64)>| FleetAgent {
record_id: "r".into(),
autonomy: "manual".into(),
review,
port: None,
pending: None,
draft: None,
turn_active: false,
exited: false,
};
let mut prev = FleetState {
version: FLEET_STATE_VERSION,
saved_at: 1,
clean_shutdown: false,
writer_pid: 1,
sessions: Vec::new(),
agents: vec![agent(Some((1, 2, 3))), agent(None)],
};
let notice = super::previous_fleet_notice(&prev).expect("notice");
assert!(notice.contains("2 agent(s)"), "{notice}");
assert!(notice.contains("1 mid-review"), "{notice}");
assert!(notice.contains("unclean shutdown"), "{notice}");
assert!(notice.contains("fleet-state.json"), "{notice}");
prev.clean_shutdown = true;
let clean = super::previous_fleet_notice(&prev).expect("notice");
assert!(!clean.contains("unclean"), "{clean}");
prev.agents.clear();
assert!(
super::previous_fleet_notice(&prev).is_none(),
"an empty fleet is not worth a notice"
);
}
#[test]
fn harness_tag_names_the_catalog_harness_through_a_runner() {
let stdio = |command: &str, args: &[&str]| bitrouter_sdk::acp::AcpTransport::Stdio {
command: command.to_string(),
args: args.iter().map(|a| a.to_string()).collect(),
env: std::collections::HashMap::new(),
};
assert_eq!(
super::harness_tag(&stdio(
"npx",
&["-y", "@zed-industries/claude-code-acp@latest"]
)),
"claude"
);
assert_eq!(super::harness_tag(&stdio("codex-acp", &[])), "codex");
assert_eq!(
super::harness_tag(&stdio("/usr/local/bin/my-agent", &[])),
"my-agent"
);
}
#[test]
fn fleet_mcp_server_spec_names_the_fleet_backend() {
let mcp = super::fleet_mcp_server();
assert_eq!(mcp.name, "bitrouter_fleet");
match mcp.transport {
crate::harness::McpTransport::Stdio { command, args } => {
assert_eq!(args, vec!["mcp", "serve", "--backend", "fleet"]);
assert!(!command.is_empty());
}
other => panic!("fleet bridge must be stdio, got {other:?}"),
}
}
#[tokio::test]
async fn probe_serve_ok_when_listening() -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?.to_string();
assert!(super::probe_serve(&addr).await.is_none());
Ok(())
}
#[tokio::test]
async fn probe_serve_warns_when_port_closed() -> anyhow::Result<()> {
let addr = {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
listener.local_addr()?.to_string()
};
let warning = super::probe_serve(&addr).await;
assert!(
warning.is_some_and(|w| w.contains("bitrouter serve")),
"closed port must produce an actionable warning"
);
Ok(())
}
static RESTORED: AtomicBool = AtomicBool::new(false);
fn mark_restored() {
RESTORED.store(true, Ordering::SeqCst);
}
#[test]
fn panic_hook_restores_terminal_before_reporting() {
super::install_panic_restore(mark_restored, false);
let joined = std::thread::spawn(|| {
std::panic::panic_any("injected tui fault");
})
.join();
assert!(joined.is_err(), "injected fault should panic the thread");
assert!(
RESTORED.load(Ordering::SeqCst),
"panic hook must run the terminal restore"
);
}
}