use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use bitrouter_sdk::acp::transport::{AcpAgentConfig, AcpTransport};
use bitrouter_sdk::config::Config;
use bitrouter_sdk::invocation;
use futures::{FutureExt, StreamExt};
use serde::Serialize;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio_util::sync::CancellationToken;
use agent_client_protocol::schema::v1::{Cost, LlmProtocol, SessionUpdate};
use bitrouter_sdk::acp::client::{AcpClient, ClientOptions, PendingPermission};
use bitrouter_sdk::acp::controller::{
RouteControl as AcpRouteControl, RouteControlError, RouteControlState,
SessionCost as AcpSessionCost,
};
use bitrouter_sdk::acp::translate::{SessionUpdateKind, translate};
use bitrouter_tui::permission::{Decision, Mode, Policy, Prompt as PermissionPrompt};
use crate::chat::effects::Wire;
use crate::paths::ConfigSource;
#[derive(clap::Args, Debug, Clone, Default, PartialEq, Eq)]
pub struct RoutingOptions {
#[arg(long)]
pub direct: bool,
#[arg(long)]
pub base_url: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub no_start: bool,
}
pub fn resolve_agent_id(config: &Config, requested: &str) -> Result<String> {
if config.agents.contains_key(requested) {
return Ok(requested.to_string());
}
let canonical = match requested {
"claude" | "claude-code" => "claude-acp",
"codex" => "codex-acp",
other => other,
};
if config.agents.contains_key(canonical)
|| crate::harness::by_id(canonical).is_some_and(|harness| harness.acp_command.is_some())
{
Ok(canonical.to_string())
} else {
Err(anyhow::anyhow!(
"'{requested}' is neither a configured ACP agent nor an unambiguous catalog alias"
))
}
}
#[cfg(test)]
mod agent_resolution_tests {
use bitrouter_sdk::acp::transport::{AcpAgentConfig, AcpTransport};
use bitrouter_sdk::config::Config;
use super::resolve_agent_id;
#[test]
fn configured_name_wins_over_builtin_alias() -> anyhow::Result<()> {
let mut config = Config::default();
config.agents.insert(
"codex".to_string(),
AcpAgentConfig {
name: "codex".to_string(),
transport: AcpTransport::Stdio {
command: "custom-codex".to_string(),
args: Vec::new(),
env: Default::default(),
},
},
);
assert_eq!(resolve_agent_id(&config, "codex")?, "codex");
Ok(())
}
#[test]
fn friendly_alias_resolves_to_acp_adapter() -> anyhow::Result<()> {
assert_eq!(
resolve_agent_id(&Config::default(), "claude")?,
"claude-acp"
);
assert_eq!(resolve_agent_id(&Config::default(), "codex")?, "codex-acp");
Ok(())
}
}
#[cfg(all(test, unix))]
mod session_host_tests {
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use futures::StreamExt;
use tokio_util::sync::CancellationToken;
use super::{
RoutingOptions, SessionHost, SessionSelection, SpawnContext, is_lifecycle_cancelled,
launch_options,
};
use bitrouter_sdk::acp::transport::{AcpAgentConfig, AcpTransport};
use bitrouter_sdk::config::Config;
const STUB: &str = r#"
while read line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*initialize*) printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{},"resume":{},"close":{}}}}}\n' "$id";;
*session/new*) printf '{"jsonrpc":"2.0","id":"%s","result":{"sessionId":"native-1"}}\n' "$id";;
*session/load*) printf '{"jsonrpc":"2.0","id":"%s","error":{"code":-32000,"message":"native load failed"}}\n' "$id";;
*session/prompt*) printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"native-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}\n';
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$id";;
esac
done
"#;
const HANG_INITIALIZE_STUB: &str = r#"
while read line; do
case "$line" in
*initialize*) printf 'initialize\n' > "$READY_PATH";;
esac
done
"#;
const HANG_NEW_STUB: &str = r#"
while read line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*initialize*) printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$id";;
*session/new*) printf 'session-new\n' > "$READY_PATH";;
esac
done
"#;
const HANG_LOAD_STUB: &str = r#"
while read line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*initialize*) printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id";;
*session/new*) printf '{"jsonrpc":"2.0","id":"%s","result":{"sessionId":"native-1"}}\n' "$id";;
*session/load*) printf 'session-load\n' > "$READY_PATH";;
*session/prompt*) printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"native-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}\n';
printf '{"jsonrpc":"2.0","id":"%s","result":{"stopReason":"end_turn"}}\n' "$id";;
esac
done
"#;
async fn wait_for_ready(path: &Path) -> anyhow::Result<()> {
tokio::time::timeout(Duration::from_secs(5), async {
while !path.exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.map_err(|_| anyhow::anyhow!("stub did not reach its expected lifecycle phase"))?;
Ok(())
}
async fn host_with_stub(
source: &crate::paths::ConfigSource,
script: &str,
ready_path: &Path,
) -> anyhow::Result<SessionHost> {
let mut config = Config::default();
let mut env = HashMap::new();
env.insert(
"READY_PATH".to_string(),
ready_path.to_string_lossy().into_owned(),
);
config.agents.insert(
"stub".to_string(),
AcpAgentConfig {
name: "stub".to_string(),
transport: AcpTransport::Stdio {
command: "bash".to_string(),
args: vec!["-c".to_string(), script.to_string()],
env,
},
},
);
SessionHost::prepare(
SpawnContext {
source,
config,
agent_id: "stub",
options: launch_options(None),
routing: RoutingOptions {
direct: true,
..RoutingOptions::default()
},
},
false,
)
.await
}
#[tokio::test]
async fn full_screen_host_returns_routing_notes_to_its_presenter() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let source = crate::paths::ConfigSource::Default {
home: directory.path().to_path_buf(),
};
let mut config = Config::default();
config.agents.insert(
"stub".to_string(),
AcpAgentConfig {
name: "stub".to_string(),
transport: AcpTransport::Stdio {
command: "bash".to_string(),
args: vec!["-c".to_string(), STUB.to_string()],
env: HashMap::new(),
},
},
);
let mut diagnostics = Vec::new();
let host = SessionHost::prepare_with_diagnostics(
SpawnContext {
source: &source,
config,
agent_id: "stub",
options: launch_options(None),
routing: RoutingOptions {
direct: true,
model: Some("ignored-model".to_string()),
..RoutingOptions::default()
},
},
false,
&mut |message| diagnostics.push(message),
)
.await?;
assert_eq!(
diagnostics,
["note: --model 'ignored-model' ignored — running --direct"]
);
assert_eq!(host.agent_id, "stub");
Ok(())
}
#[tokio::test]
async fn shared_host_negotiates_lifecycle_and_drives_one_turn() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let source = crate::paths::ConfigSource::Default {
home: directory.path().to_path_buf(),
};
let mut config = Config::default();
config.agents.insert(
"stub".to_string(),
AcpAgentConfig {
name: "stub".to_string(),
transport: AcpTransport::Stdio {
command: "bash".to_string(),
args: vec!["-c".to_string(), STUB.to_string()],
env: HashMap::new(),
},
},
);
let host = SessionHost::prepare(
SpawnContext {
source: &source,
config,
agent_id: "stub",
options: launch_options(None),
routing: RoutingOptions {
direct: true,
..RoutingOptions::default()
},
},
true,
)
.await?;
let mut handle = host
.open(&SessionSelection::New, std::env::current_dir()?)
.await?;
assert_eq!(handle.session_id, "native-1");
assert!(handle.capabilities.load);
assert!(handle.capabilities.list);
assert!(handle.capabilities.resume);
assert!(handle.capabilities.close);
assert!(!handle.capabilities.delete);
let mut updates = handle.take_updates();
let response = handle.client.prompt(&handle.session_id, "hi").await?;
assert_eq!(format!("{:?}", response.stop_reason), "EndTurn");
let update = tokio::time::timeout(std::time::Duration::from_secs(2), updates.next())
.await?
.ok_or_else(|| anyhow::anyhow!("stub emitted no update"))?;
assert!(matches!(
update,
agent_client_protocol::schema::v1::SessionUpdate::AgentMessageChunk(_)
));
assert!(handle.shutdown().await);
Ok(())
}
#[tokio::test]
async fn failed_load_retains_the_active_session_identity_and_stream() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let source = crate::paths::ConfigSource::Default {
home: directory.path().to_path_buf(),
};
let mut config = Config::default();
config.agents.insert(
"stub".to_string(),
AcpAgentConfig {
name: "stub".to_string(),
transport: AcpTransport::Stdio {
command: "bash".to_string(),
args: vec!["-c".to_string(), STUB.to_string()],
env: HashMap::new(),
},
},
);
let host = SessionHost::prepare(
SpawnContext {
source: &source,
config,
agent_id: "stub",
options: launch_options(None),
routing: RoutingOptions {
direct: true,
..RoutingOptions::default()
},
},
false,
)
.await?;
let mut handle = host
.open(&SessionSelection::New, std::env::current_dir()?)
.await?;
let active_id = handle.session_id.clone();
let active_agent_id = handle.agent_session_id.clone();
let mut updates = handle.take_updates();
let load = handle
.select_with_cancel(
&SessionSelection::Load("missing".to_string()),
&CancellationToken::new(),
)
.await;
assert!(load.is_err());
assert_eq!(handle.session_id, active_id);
assert_eq!(handle.agent_session_id, active_agent_id);
let response = handle.client.prompt(&handle.session_id, "hi").await?;
assert_eq!(format!("{:?}", response.stop_reason), "EndTurn");
let update = tokio::time::timeout(std::time::Duration::from_secs(2), updates.next())
.await?
.ok_or_else(|| anyhow::anyhow!("active session emitted no update after failed load"))?;
assert!(matches!(
update,
agent_client_protocol::schema::v1::SessionUpdate::AgentMessageChunk(_)
));
assert!(handle.shutdown().await);
Ok(())
}
#[tokio::test]
async fn cancelled_initialize_reaps_the_owned_controller() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let source = crate::paths::ConfigSource::Default {
home: directory.path().to_path_buf(),
};
let ready = directory.path().join("initialize-ready");
let host = host_with_stub(&source, HANG_INITIALIZE_STUB, &ready).await?;
let cancel = CancellationToken::new();
let opening =
host.open_with_cancel(&SessionSelection::New, std::env::current_dir()?, &cancel);
tokio::pin!(opening);
tokio::select! {
result = &mut opening => {
return if result.is_ok() {
Err(anyhow::anyhow!("unresponsive initialize unexpectedly opened a session"))
} else {
Err(anyhow::anyhow!("unresponsive initialize failed before cancellation"))
};
}
ready = wait_for_ready(&ready) => ready?,
}
cancel.cancel();
let result = tokio::time::timeout(Duration::from_secs(20), &mut opening)
.await
.map_err(|_| anyhow::anyhow!("cancelled initialize did not settle"))?;
let error = result.err().ok_or_else(|| {
anyhow::anyhow!("cancelled initialize unexpectedly returned a session")
})?;
assert!(is_lifecycle_cancelled(&error));
Ok(())
}
#[tokio::test]
async fn cancelled_new_session_reaps_the_owned_controller() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let source = crate::paths::ConfigSource::Default {
home: directory.path().to_path_buf(),
};
let ready = directory.path().join("new-ready");
let host = host_with_stub(&source, HANG_NEW_STUB, &ready).await?;
let cancel = CancellationToken::new();
let opening =
host.open_with_cancel(&SessionSelection::New, std::env::current_dir()?, &cancel);
tokio::pin!(opening);
tokio::select! {
result = &mut opening => {
return if result.is_ok() {
Err(anyhow::anyhow!("unresponsive session/new unexpectedly opened a session"))
} else {
Err(anyhow::anyhow!("unresponsive session/new failed before cancellation"))
};
}
ready = wait_for_ready(&ready) => ready?,
}
cancel.cancel();
let result = tokio::time::timeout(Duration::from_secs(20), &mut opening)
.await
.map_err(|_| anyhow::anyhow!("cancelled session/new did not settle"))?;
let error = result.err().ok_or_else(|| {
anyhow::anyhow!("cancelled session/new unexpectedly returned a session")
})?;
assert!(is_lifecycle_cancelled(&error));
Ok(())
}
#[tokio::test]
async fn cancelled_load_keeps_the_active_session_and_stream() -> anyhow::Result<()> {
let directory = tempfile::tempdir()?;
let source = crate::paths::ConfigSource::Default {
home: directory.path().to_path_buf(),
};
let ready = directory.path().join("load-ready");
let host = host_with_stub(&source, HANG_LOAD_STUB, &ready).await?;
let mut handle = host
.open(&SessionSelection::New, std::env::current_dir()?)
.await?;
let active_id = handle.session_id.clone();
let active_agent_id = handle.agent_session_id.clone();
let mut updates = handle.take_updates();
let cancel = CancellationToken::new();
{
let selection = SessionSelection::Load("native-other".to_string());
let loading = handle.select_with_cancel(&selection, &cancel);
tokio::pin!(loading);
tokio::select! {
result = &mut loading => {
return if result.is_ok() {
Err(anyhow::anyhow!("unresponsive session/load unexpectedly completed"))
} else {
Err(anyhow::anyhow!("unresponsive session/load failed before cancellation"))
};
}
ready = wait_for_ready(&ready) => ready?,
}
cancel.cancel();
let result = tokio::time::timeout(Duration::from_secs(5), &mut loading)
.await
.map_err(|_| anyhow::anyhow!("cancelled session/load did not settle"))?;
let error = result
.err()
.ok_or_else(|| anyhow::anyhow!("cancelled session/load unexpectedly completed"))?;
assert!(is_lifecycle_cancelled(&error));
}
assert_eq!(handle.session_id, active_id);
assert_eq!(handle.agent_session_id, active_agent_id);
let response = handle.client.prompt(&handle.session_id, "hi").await?;
assert_eq!(format!("{:?}", response.stop_reason), "EndTurn");
let update = tokio::time::timeout(Duration::from_secs(2), updates.next())
.await?
.ok_or_else(|| anyhow::anyhow!("active session stream ended after cancelled load"))?;
assert!(matches!(
update,
agent_client_protocol::schema::v1::SessionUpdate::AgentMessageChunk(_)
));
assert!(handle.shutdown().await);
Ok(())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum PromptFormat {
#[default]
#[value(alias = "json")]
Ndjson,
Text,
Quiet,
}
#[derive(clap::Args, Debug, Clone, Default, PartialEq, Eq)]
pub struct HeadlessOptions {
#[arg(long, conflicts_with_all = ["approve_reads", "deny_all"])]
pub approve_all: bool,
#[arg(long, conflicts_with_all = ["approve_all", "deny_all"])]
pub approve_reads: bool,
#[arg(long, conflicts_with_all = ["approve_all", "approve_reads"])]
pub deny_all: bool,
#[arg(long, value_name = "JSON|@PATH")]
pub permission_policy: Option<String>,
#[arg(long, value_enum, default_value_t)]
pub format: PromptFormat,
}
#[derive(serde::Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PolicyFile {
#[serde(default)]
auto_approve: Vec<String>,
#[serde(default)]
auto_deny: Vec<String>,
default_action: Option<PolicyAction>,
}
#[derive(serde::Deserialize, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum PolicyAction {
Approve,
Deny,
}
impl HeadlessOptions {
pub fn policy(&self) -> Result<Policy> {
let mode = if self.approve_all {
Mode::ApproveAll
} else if self.approve_reads {
Mode::ApproveReads
} else {
Mode::DenyAll
};
let file = match self.permission_policy.as_deref() {
None => PolicyFile::default(),
Some(flag) => {
let text = match flag.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("reading --permission-policy from {path}"))?,
None => flag.to_string(),
};
serde_json::from_str(&text).context("--permission-policy is not a valid policy")?
}
};
Ok(Policy {
mode,
auto_approve: file.auto_approve,
auto_deny: file.auto_deny,
default_action: file.default_action.map(|action| match action {
PolicyAction::Approve => Decision::Approve,
PolicyAction::Deny => Decision::Deny,
}),
})
}
}
#[derive(Default)]
pub struct PromptOptions {
pub no_wait: bool,
pub contract: Option<crate::result_contract::ResultContract>,
pub policy: Policy,
pub format: PromptFormat,
pub session: SessionSelection,
pub cwd: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SessionSelection {
#[default]
New,
Load(String),
Resume(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapabilitySnapshot {
pub load: bool,
pub resume: bool,
pub list: bool,
pub close: bool,
pub delete: bool,
pub fork: bool,
pub mcp_stdio: bool,
pub mcp_http: bool,
pub mcp_sse: bool,
pub authentication_methods: Vec<String>,
pub terminal_auth: bool,
pub route_list: bool,
pub route_set: bool,
pub route_reset: bool,
}
impl CapabilitySnapshot {
fn from_client(client: &AcpClient, terminal_auth: bool) -> Self {
let agent = client.agent_capabilities();
let sessions = &agent.session_capabilities;
let route = client.route_control();
Self {
load: agent.load_session,
resume: sessions.resume.is_some(),
list: sessions.list.is_some(),
close: sessions.close.is_some(),
delete: sessions.delete.is_some(),
fork: sessions.fork.is_some(),
mcp_stdio: true,
mcp_http: agent.mcp_capabilities.http,
mcp_sse: agent.mcp_capabilities.sse,
authentication_methods: client
.auth_methods()
.iter()
.map(|method| method.id().0.to_string())
.collect(),
terminal_auth,
route_list: route.allows(bitrouter_sdk::acp::client::RouteMethod::List),
route_set: route.allows(bitrouter_sdk::acp::client::RouteMethod::Set),
route_reset: route.allows(bitrouter_sdk::acp::client::RouteMethod::Reset),
}
}
pub fn lifecycle_summary(&self) -> String {
let mut supported = vec!["new"];
for (name, enabled) in [
("list", self.list),
("load", self.load),
("resume", self.resume),
("close", self.close),
("fork*", self.fork),
("delete*", self.delete),
] {
if enabled {
supported.push(name);
}
}
supported.join(", ")
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct PermissionTally {
approved: usize,
denied: usize,
}
impl PermissionTally {
fn record(&mut self, decision: Decision) {
match decision {
Decision::Approve => self.approved += 1,
Decision::Deny => self.denied += 1,
}
}
pub fn exit_code(&self) -> i32 {
if self.denied > 0 && self.approved == 0 {
5
} else {
0
}
}
}
pub struct SpawnContext<'a> {
pub source: &'a ConfigSource,
pub config: Config,
pub agent_id: &'a str,
pub options: LaunchOptions,
pub routing: RoutingOptions,
}
#[derive(Debug)]
pub enum RoutingError {
DaemonUnreachable {
via: String,
},
AuthRequired {
via: String,
},
EndpointConfiguration {
via: String,
message: String,
},
}
impl std::fmt::Display for RoutingError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}\n hint: {}", self.message(), self.hint())
}
}
impl std::error::Error for RoutingError {}
impl RoutingError {
fn code(&self) -> &'static str {
match self {
RoutingError::DaemonUnreachable { .. } => "daemon_unreachable",
RoutingError::AuthRequired { .. } => "auth_required",
RoutingError::EndpointConfiguration { .. } => "endpoint_configuration",
}
}
fn via(&self) -> &str {
match self {
RoutingError::DaemonUnreachable { via }
| RoutingError::AuthRequired { via }
| RoutingError::EndpointConfiguration { via, .. } => via,
}
}
fn hint(&self) -> String {
match self {
RoutingError::DaemonUnreachable { .. } => {
format!("run `{} start`, or pass --direct", invocation::name())
}
RoutingError::AuthRequired { .. } => {
"export BITROUTER_API_KEY (or create a key), or pass --direct".to_string()
}
RoutingError::EndpointConfiguration { .. } => {
"check the pinned ACP adapter configuration, or pass --direct".to_string()
}
}
}
fn message(&self) -> String {
match self {
RoutingError::DaemonUnreachable { via } => {
format!("BitRouter daemon unreachable at {via}")
}
RoutingError::AuthRequired { via } => {
format!("daemon at {via} requires auth but no BITROUTER_API_KEY is set")
}
RoutingError::EndpointConfiguration { message, .. } => message.clone(),
}
}
fn ndjson(&self) -> serde_json::Value {
serde_json::json!({
"type": "error",
"code": self.code(),
"via": self.via(),
"hint": self.hint(),
"message": self.message(),
})
}
}
#[derive(Debug, Clone, Default)]
pub struct Routed {
pub via: Option<String>,
pub launch_id: Option<String>,
pub controller_instance_id: Option<String>,
pub api_principal: Option<String>,
pub endpoint_plan: Option<crate::harness::HarnessEndpointPlan>,
}
pub async fn apply_routing(
source: &ConfigSource,
config: &mut Config,
agent_id: &str,
opts: &RoutingOptions,
) -> std::result::Result<Routed, RoutingError> {
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
apply_routing_with_cloud_credentials(source, config, agent_id, opts, &cloud_credentials).await
}
async fn apply_routing_with_cloud_credentials(
source: &ConfigSource,
config: &mut Config,
agent_id: &str,
opts: &RoutingOptions,
cloud_credentials: &crate::cloud::StandaloneCloudCredentials,
) -> std::result::Result<Routed, RoutingError> {
let mut diagnostics = |message| eprintln!("{message}");
apply_routing_with_diagnostics(
source,
config,
agent_id,
opts,
cloud_credentials,
&mut diagnostics,
)
.await
}
async fn apply_routing_with_diagnostics(
source: &ConfigSource,
config: &mut Config,
agent_id: &str,
opts: &RoutingOptions,
cloud_credentials: &crate::cloud::StandaloneCloudCredentials,
diagnostics: &mut dyn FnMut(String),
) -> std::result::Result<Routed, RoutingError> {
config.database.url = crate::db::anchor_url(&config.database.url, source.home());
if !config.agents.contains_key(agent_id)
&& let Some(h) = crate::harness::by_id(agent_id)
&& let Some(command) = h.acp_command
{
config.agents.insert(
agent_id.to_string(),
AcpAgentConfig {
name: agent_id.to_string(),
transport: AcpTransport::Stdio {
command: command.to_string(),
args: h.acp_args.iter().map(|s| s.to_string()).collect(),
env: Default::default(),
},
},
);
}
if let Some(agent) = config.agents.get_mut(agent_id) {
crate::local_cli::apply(&mut agent.transport).await;
}
if opts.direct {
warn_model_dropped(opts, "running --direct", diagnostics);
return Ok(Routed::default());
}
let harness = match config.agents.get(agent_id) {
Some(entry) => {
let AcpTransport::Stdio { command, args, .. } = &entry.transport;
crate::harness::match_invocation(command, args)
}
None => return Ok(Routed::default()),
};
let Some(harness) = harness else {
diagnostics(format!(
"note: routing unavailable for '{agent_id}' (not catalog-matched); \
launching direct — set its `env` to route manually"
));
warn_model_dropped(opts, "the agent is not catalog-matched", diagnostics);
return Ok(Routed::default());
};
if !harness.env_args_routable() {
diagnostics(format!(
"note: '{}' routes via synthesized config, which headless spawn doesn't do yet \
in this ACP adapter; launching direct",
harness.id
));
warn_model_dropped(
opts,
"the harness routes only in the interactive facet",
diagnostics,
);
return Ok(Routed::default());
}
let uses_maintained_adapter =
config
.agents
.get(agent_id)
.is_some_and(|entry| match &entry.transport {
AcpTransport::Stdio { command, args, .. } => {
harness.uses_maintained_adapter(command, args)
}
});
if harness.id == "codex-acp" && !uses_maintained_adapter {
diagnostics(format!(
"note: routing unavailable for '{agent_id}': Codex ACP endpoint configuration \
requires @agentclientprotocol/codex-acp@1.10.0; launching direct"
));
warn_model_dropped(
opts,
"the configured Codex ACP adapter is not the maintained pin",
diagnostics,
);
return Ok(Routed::default());
}
let base_url = opts
.base_url
.clone()
.unwrap_or_else(|| crate::spawn::derive_base_url(&config.server.listen));
let target_authority = opts
.base_url
.as_deref()
.and_then(crate::spawn::listen_from_base_url);
let target_is_local = match &target_authority {
Some(a) => crate::spawn::listen_is_local(a),
None => crate::spawn::listen_is_local(&config.server.listen),
};
let require_key = !target_is_local || !config.server.skip_auth;
if require_key && !harness.auth_is_bearer() {
diagnostics(format!(
"warning: '{}' sends its API key as a non-Bearer header the daemon rejects under \
auth mode (`skip_auth: false`) — this session will likely 401. Use `skip_auth: \
true`, a `--direct` session, or a different harness.",
harness.id
));
}
let explicit_key = crate::spawn::nonempty_env(crate::harness::BITROUTER_API_KEY_ENV);
let stored_cloud_key = if explicit_key.is_none() && require_key && !target_is_local {
cloud_credentials.routing_fallback(&base_url).await
} else {
None
};
let supplied = explicit_key.or(stored_cloud_key);
if supplied.is_none() && require_key {
return Err(RoutingError::AuthRequired { via: base_url });
}
let auth = crate::spawn::resolve_launch_token(supplied, None);
let launch_id = crate::spawn::is_launch_token(&auth).then(|| auth.clone());
let api_principal = if target_is_local && config.server.skip_auth {
"local".to_string()
} else {
crate::auth::keys::hash_key(&auth)
};
if opts.base_url.is_none() && target_is_local {
crate::spawn::ensure_local_daemon(source, config, opts.no_start).await;
}
if !crate::spawn::base_url_reachable(&base_url).await {
return Err(RoutingError::DaemonUnreachable { via: base_url });
}
let controller_instance_id = format!("brc_{}", uuid::Uuid::new_v4().simple());
let endpoint_plan = if uses_maintained_adapter {
harness.endpoint_plan(
&base_url,
&auth,
opts.model.as_deref(),
&controller_instance_id,
)
} else {
None
};
let overlay = match &endpoint_plan {
Some(plan) => {
plan.fallback_overlay()
.map_err(|error| RoutingError::EndpointConfiguration {
via: base_url.clone(),
message: format!("could not configure pinned '{}': {error}", harness.id),
})?
}
None => harness.routing_overlay(&base_url, &auth, opts.model.as_deref()),
};
if let Some(entry) = config.agents.get_mut(agent_id) {
let AcpTransport::Stdio { args, env, .. } = &mut entry.transport;
for (k, v) in overlay.env {
if let Some(existing) = env.get(&k)
&& existing != &v
{
diagnostics(format!(
"note: routing overrides your `env.{k}` for '{agent_id}' \
(pass --direct to keep your value)"
));
}
env.insert(k, v);
}
args.extend(overlay.args);
}
Ok(Routed {
via: Some(base_url),
launch_id,
controller_instance_id: endpoint_plan.as_ref().map(|_| controller_instance_id),
api_principal: endpoint_plan.as_ref().map(|_| api_principal),
endpoint_plan,
})
}
fn warn_model_dropped(opts: &RoutingOptions, why: &str, diagnostics: &mut dyn FnMut(String)) {
if let Some(model) = &opts.model {
diagnostics(format!("note: --model '{model}' ignored — {why}"));
}
}
#[derive(Serialize)]
struct ResultLine<S: Serialize> {
#[serde(rename = "type")]
kind: &'static str,
stop_reason: S,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
schema_ok: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
raw: Option<String>,
}
async fn write_ndjson_line<W, T>(out: &mut W, value: &T) -> Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize,
{
let mut line = serde_json::to_string(value).context("serialising NDJSON line")?;
line.push('\n');
out.write_all(line.as_bytes())
.await
.context("writing NDJSON line")
}
pub async fn spawn_check(
config: Config,
agent_id: &str,
routing: &RoutingOptions,
) -> Result<crate::spawn::SpawnCheckReport> {
use crate::spawn::{SpawnCheckReport, SpawnCheckRow, SpawnCheckStatus};
let row = |name: &str, status: SpawnCheckStatus, message: String| SpawnCheckRow {
name: name.to_string(),
status,
message,
};
let mut checks = Vec::new();
let configured = config.agents.get(agent_id);
let catalog = crate::harness::by_id(agent_id);
let (command, args) = match (configured, catalog) {
(Some(entry), _) => {
let AcpTransport::Stdio { command, args, .. } = &entry.transport;
checks.push(row(
"agent",
SpawnCheckStatus::Pass,
format!("configured in agents: ({command})"),
));
(command.clone(), args.clone())
}
(None, Some(h)) => match h.acp_command {
Some(command) => {
checks.push(row(
"agent",
SpawnCheckStatus::Pass,
format!("bundled catalog ({} {})", command, h.acp_args.join(" ")),
));
(
command.to_string(),
h.acp_args.iter().map(|s| s.to_string()).collect(),
)
}
None => {
checks.push(row(
"agent",
SpawnCheckStatus::Fail,
format!("'{agent_id}' has no ACP adapter and cannot be used as an agent"),
));
(String::new(), Vec::new())
}
},
(None, None) => {
checks.push(row(
"agent",
SpawnCheckStatus::Fail,
format!("'{agent_id}' is neither a configured agent nor a bundled-catalog id"),
));
(String::new(), Vec::new())
}
};
let base_url = routing
.base_url
.clone()
.unwrap_or_else(|| crate::spawn::derive_base_url(&config.server.listen));
let harness = crate::harness::match_invocation(&command, &args);
let mut routable_harness: Option<&'static crate::harness::Harness> = None;
if routing.direct {
checks.push(row(
"routing",
SpawnCheckStatus::Warn,
"--direct: the sub-agent uses its own provider auth".to_string(),
));
} else {
match harness {
Some(h) if h.env_args_routable() => {
routable_harness = Some(h);
checks.push(row(
"routing",
SpawnCheckStatus::Pass,
format!("via daemon {base_url} [{}]", h.id),
));
}
Some(h) => {
checks.push(row(
"routing",
SpawnCheckStatus::Warn,
format!(
"'{}' routes via synthesized config (interactive facet only); \
will run direct",
h.id
),
));
}
None => checks.push(row(
"routing",
SpawnCheckStatus::Warn,
"not catalog-matched; will run direct (set its `env` to route manually)"
.to_string(),
)),
}
}
if let Some(h) = routable_harness {
let target_is_local = routing
.base_url
.as_deref()
.and_then(crate::spawn::listen_from_base_url)
.map(|a| crate::spawn::listen_is_local(&a))
.unwrap_or_else(|| crate::spawn::listen_is_local(&config.server.listen));
let require_key = !target_is_local || !config.server.skip_auth;
let has_key = crate::spawn::nonempty_env(crate::harness::BITROUTER_API_KEY_ENV).is_some();
checks.push(if require_key && !has_key {
row(
"auth",
SpawnCheckStatus::Fail,
"daemon requires auth but BITROUTER_API_KEY is not set — export it or pass --direct"
.to_string(),
)
} else if require_key && !h.auth_is_bearer() {
row(
"auth",
SpawnCheckStatus::Warn,
format!(
"'{}' sends a non-Bearer header the daemon rejects under skip_auth:false — \
the session will likely 401",
h.id
),
)
} else if has_key {
row(
"auth",
SpawnCheckStatus::Pass,
"BITROUTER_API_KEY present".to_string(),
)
} else {
row(
"auth",
SpawnCheckStatus::Pass,
"skip_auth: credential-less requests admitted".to_string(),
)
});
}
if routable_harness.is_some() {
checks.push(if crate::spawn::base_url_reachable(&base_url).await {
row(
"daemon",
SpawnCheckStatus::Pass,
format!("{base_url} is reachable"),
)
} else {
row(
"daemon",
SpawnCheckStatus::Fail,
format!(
"{base_url} is unreachable — run `{} start` (or pass --direct)",
invocation::name()
),
)
});
}
Ok(SpawnCheckReport {
agent: agent_id.to_string(),
base_url,
model: routing.model.clone(),
checks,
})
}
fn controller_identity(
agent_id: &str,
command: &str,
args: &[String],
endpoint: Option<&crate::harness::HarnessEndpointPlan>,
) -> bitrouter_sdk::acp::controller::ControllerIdentity {
if let Some(endpoint) = endpoint {
return bitrouter_sdk::acp::controller::ControllerIdentity::new(
endpoint.harness_id,
endpoint.adapter_package,
endpoint.adapter_version,
);
}
if let Some(harness) = crate::harness::match_invocation(command, args)
&& let Some((package, version)) = harness.maintained_adapter_identity()
&& harness.uses_maintained_adapter(command, args)
{
return bitrouter_sdk::acp::controller::ControllerIdentity::new(
harness.id, package, version,
);
}
bitrouter_sdk::acp::controller::ControllerIdentity::new(
agent_id,
"configured-acp-adapter",
"configured",
)
}
fn controller_endpoint(
endpoint: &crate::harness::HarnessEndpointPlan,
) -> bitrouter_sdk::acp::controller::ProviderEndpointPlan {
let protocol = match endpoint.protocol {
crate::harness::HarnessProtocol::Anthropic => LlmProtocol::Anthropic,
crate::harness::HarnessProtocol::OpenAi => LlmProtocol::OpenAi,
};
bitrouter_sdk::acp::controller::ProviderEndpointPlan {
provider_id: endpoint.provider_id.to_string(),
protocol,
base_url: endpoint.base_url.clone(),
headers: endpoint.headers.clone(),
}
}
struct DaemonRouteControl {
socket_path: PathBuf,
api_principal: String,
controller_instance_id: String,
}
impl DaemonRouteControl {
fn new(
socket_path: PathBuf,
api_principal: impl Into<String>,
controller_instance_id: impl Into<String>,
) -> Self {
Self {
socket_path,
api_principal: api_principal.into(),
controller_instance_id: controller_instance_id.into(),
}
}
async fn route_state(
&self,
command: crate::daemon::DaemonCommand,
) -> std::result::Result<RouteControlState, RouteControlError> {
match crate::daemon::send_command(&self.socket_path, &command).await {
Ok(crate::daemon::DaemonResponse::AcpRouteState {
available, current, ..
}) => Ok(RouteControlState { available, current }),
Ok(crate::daemon::DaemonResponse::Error { message }) => {
if message.starts_with("route is not available") {
Err(RouteControlError::invalid_route(message))
} else {
Err(RouteControlError::unavailable(message))
}
}
Ok(other) => Err(RouteControlError::unavailable(format!(
"daemon returned an unexpected route-control response: {other:?}"
))),
Err(error) => Err(RouteControlError::unavailable(format!(
"daemon route control is unavailable: {error}"
))),
}
}
}
#[async_trait::async_trait]
impl AcpRouteControl for DaemonRouteControl {
async fn list(
&self,
session_id: &str,
) -> std::result::Result<RouteControlState, RouteControlError> {
self.route_state(crate::daemon::DaemonCommand::AcpRouteList {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
})
.await
}
async fn set(
&self,
session_id: &str,
route: &str,
) -> std::result::Result<RouteControlState, RouteControlError> {
self.route_state(crate::daemon::DaemonCommand::AcpRouteSet {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
route: route.to_string(),
})
.await
}
async fn reset(
&self,
session_id: &str,
) -> std::result::Result<RouteControlState, RouteControlError> {
self.route_state(crate::daemon::DaemonCommand::AcpRouteReset {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
})
.await
}
async fn session_closed(&self, session_id: &str) -> std::result::Result<(), RouteControlError> {
self.reset(session_id).await.map(|_| ())
}
async fn disconnected(&self) -> std::result::Result<(), RouteControlError> {
match crate::daemon::send_command(
&self.socket_path,
&crate::daemon::DaemonCommand::AcpControllerCleanup {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
},
)
.await
{
Ok(crate::daemon::DaemonResponse::Ok) => Ok(()),
Ok(crate::daemon::DaemonResponse::Error { message }) => {
Err(RouteControlError::unavailable(message))
}
Ok(other) => Err(RouteControlError::unavailable(format!(
"daemon returned an unexpected cleanup response: {other:?}"
))),
Err(error) => Err(RouteControlError::unavailable(format!(
"daemon controller cleanup is unavailable: {error}"
))),
}
}
}
struct DaemonSessionCost {
socket_path: PathBuf,
api_principal: String,
controller_instance_id: String,
}
const SESSION_COST_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
#[async_trait::async_trait]
impl AcpSessionCost for DaemonSessionCost {
async fn attributed_cost(&self, session_id: &str) -> Option<Cost> {
let command = crate::daemon::DaemonCommand::AcpSessionSpend {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
session_id: session_id.to_string(),
};
let response = tokio::time::timeout(
SESSION_COST_LOOKUP_TIMEOUT,
crate::daemon::send_command(&self.socket_path, &command),
)
.await;
match response {
Ok(Ok(crate::daemon::DaemonResponse::AcpSessionSpend {
spend_micro_usd,
requests,
unpriced,
})) => attributed_cost(spend_micro_usd, requests, unpriced),
Ok(Ok(crate::daemon::DaemonResponse::Error { message })) => {
tracing::debug!(session_id, %message, "session cost is unavailable");
None
}
Ok(Ok(other)) => {
tracing::debug!(
session_id,
?other,
"daemon returned an unexpected session-spend response"
);
None
}
Ok(Err(error)) => {
tracing::debug!(session_id, %error, "session cost lookup failed");
None
}
Err(_) => {
tracing::debug!(session_id, "session cost lookup timed out");
None
}
}
}
}
fn attributed_cost(spend_micro_usd: u64, requests: u64, unpriced: u64) -> Option<Cost> {
(requests > 0 && unpriced < requests)
.then(|| Cost::new(spend_micro_usd as f64 / 1_000_000.0, "USD"))
}
struct CachedSessionCost {
inner: Arc<dyn AcpSessionCost>,
cache: Arc<CostCache>,
}
#[derive(Default)]
struct CostCache {
figures: std::sync::Mutex<std::collections::HashMap<String, Cost>>,
refreshing: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl CostCache {
fn held(&self, session_id: &str) -> Option<Cost> {
match self.figures.lock() {
Ok(figures) => figures.get(session_id).cloned(),
Err(poisoned) => poisoned.into_inner().get(session_id).cloned(),
}
}
fn claim_refresh(&self, session_id: &str) -> bool {
match self.refreshing.lock() {
Ok(mut refreshing) => refreshing.insert(session_id.to_string()),
Err(poisoned) => poisoned.into_inner().insert(session_id.to_string()),
}
}
fn settle_refresh(&self, session_id: &str, figure: Option<Cost>) {
if let Some(figure) = figure {
match self.figures.lock() {
Ok(mut figures) => {
figures.insert(session_id.to_string(), figure);
}
Err(poisoned) => {
poisoned.into_inner().insert(session_id.to_string(), figure);
}
}
}
match self.refreshing.lock() {
Ok(mut refreshing) => {
refreshing.remove(session_id);
}
Err(poisoned) => {
poisoned.into_inner().remove(session_id);
}
}
}
}
impl CachedSessionCost {
fn new(inner: Arc<dyn AcpSessionCost>) -> Self {
Self {
inner,
cache: Arc::new(CostCache::default()),
}
}
}
#[async_trait::async_trait]
impl AcpSessionCost for CachedSessionCost {
async fn attributed_cost(&self, session_id: &str) -> Option<Cost> {
if self.cache.claim_refresh(session_id) {
let inner = Arc::clone(&self.inner);
let cache = Arc::clone(&self.cache);
let session_id = session_id.to_string();
tokio::spawn(async move {
let figure = inner.attributed_cost(&session_id).await;
cache.settle_refresh(&session_id, figure);
});
}
self.cache.held(session_id)
}
}
#[derive(Clone)]
pub(crate) struct LocalControllerBinding {
socket_path: PathBuf,
api_principal: String,
controller_instance_id: String,
}
pub(crate) struct SessionHost {
config: Config,
agent_id: String,
options: LaunchOptions,
routed: Routed,
binding: Option<LocalControllerBinding>,
}
pub(crate) struct SessionHandle {
pub(crate) client: AcpClient,
pub(crate) session_id: String,
pub(crate) agent_session_id: Option<String>,
pub(crate) agent_id: String,
pub(crate) via: Option<String>,
pub(crate) launch_id: Option<String>,
pub(crate) capabilities: CapabilitySnapshot,
pub(crate) initial_settings: bitrouter_sdk::acp::client::SessionInitialSettings,
cwd: PathBuf,
mcp_servers: Vec<agent_client_protocol::schema::v1::McpServer>,
pub(crate) updates: std::pin::Pin<Box<dyn futures::Stream<Item = SessionUpdate> + Send>>,
pub(crate) permissions:
std::pin::Pin<Box<dyn futures::Stream<Item = PendingPermission> + Send>>,
session: ControlledSession,
}
impl SessionHandle {
pub(crate) async fn select_with_cancel(
&mut self,
selection: &SessionSelection,
cancel: &CancellationToken,
) -> Result<()> {
if cancel.is_cancelled() {
return Err(lifecycle_cancelled());
}
let updates = self.client.subscribe_lifecycle_notifications();
let ids = tokio::select! {
biased;
_ = cancel.cancelled() => return Err(lifecycle_cancelled()),
result = open_session(
&self.client,
selection,
self.cwd.clone(),
self.mcp_servers.clone(),
) => result?,
};
let updates = updates.session_updates(&ids);
self.session_id = ids.acp_session_id;
self.agent_session_id = ids.agent_session_id;
self.initial_settings = ids.initial_settings;
self.updates = updates;
Ok(())
}
pub(crate) fn take_updates(
&mut self,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = SessionUpdate> + Send>> {
std::mem::replace(&mut self.updates, Box::pin(futures::stream::empty()))
}
pub(crate) fn take_permissions(
&mut self,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = PendingPermission> + Send>> {
std::mem::replace(&mut self.permissions, Box::pin(futures::stream::empty()))
}
pub(crate) async fn shutdown(&mut self) -> bool {
self.session.shutdown().await
}
pub(crate) fn closed(&self) -> futures::future::BoxFuture<'static, ()> {
self.session.closed()
}
}
impl SessionHost {
pub(crate) async fn prepare(ctx: SpawnContext<'_>, terminal_auth: bool) -> Result<Self> {
let mut diagnostics = |message| eprintln!("{message}");
Self::prepare_with_diagnostics(ctx, terminal_auth, &mut diagnostics).await
}
pub(crate) async fn prepare_with_diagnostics(
ctx: SpawnContext<'_>,
terminal_auth: bool,
diagnostics: &mut dyn FnMut(String),
) -> Result<Self> {
let SpawnContext {
source,
mut config,
agent_id,
mut options,
routing,
} = ctx;
let agent_id = resolve_agent_id(&config, agent_id)?;
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let routed = apply_routing_with_diagnostics(
source,
&mut config,
&agent_id,
&routing,
&cloud_credentials,
diagnostics,
)
.await
.map_err(anyhow::Error::new)?;
config
.agents
.get(&agent_id)
.with_context(|| format!("ACP agent '{agent_id}' is not configured"))?
.validate()
.with_context(|| format!("invalid ACP agent '{agent_id}'"))?;
let binding = LocalControllerBinding::open_with_diagnostics(
source,
&config,
&routed,
routing.base_url.is_some(),
diagnostics,
);
options.terminal_auth = terminal_auth;
Ok(Self {
config,
agent_id,
options,
routed,
binding,
})
}
pub(crate) async fn open(
self,
selection: &SessionSelection,
cwd: PathBuf,
) -> Result<SessionHandle> {
self.open_with_cancel(selection, cwd, &CancellationToken::new())
.await
}
pub(crate) async fn open_with_cancel(
self,
selection: &SessionSelection,
cwd: PathBuf,
cancel: &CancellationToken,
) -> Result<SessionHandle> {
if cancel.is_cancelled() {
return Err(lifecycle_cancelled());
}
let mcp_servers = self.options.mcp_servers.clone();
let terminal_auth = self.options.terminal_auth;
let mut session = launch_controlled_with_cancel(
&self.config,
&self.agent_id,
&self.routed,
self.options,
self.binding,
cancel,
)
.await
.with_context(|| format!("launching ACP session for agent '{}'", self.agent_id))?;
let updates = session.client.subscribe_lifecycle_notifications();
let permissions = session.client.subscribe_permissions();
let opened = tokio::select! {
biased;
_ = cancel.cancelled() => None,
result = open_session(&session.client, selection, cwd.clone(), mcp_servers.clone()) => Some(result),
};
let ids = match opened {
None => {
return if session.shutdown().await {
Err(lifecycle_cancelled())
} else {
Err(lifecycle_teardown_unconfirmed())
};
}
Some(Ok(ids)) => ids,
Some(Err(error)) => {
return if session.shutdown().await {
Err(error.context("opening the harness session"))
} else {
Err(error.context(
"opening the harness session; controller teardown did not confirm",
))
};
}
};
let client = session.client.clone();
let capabilities = CapabilitySnapshot::from_client(&client, terminal_auth);
let updates = updates.session_updates(&ids);
Ok(SessionHandle {
client,
session_id: ids.acp_session_id,
agent_session_id: ids.agent_session_id,
agent_id: self.agent_id,
via: self.routed.via,
launch_id: self.routed.launch_id,
capabilities,
initial_settings: ids.initial_settings,
cwd,
mcp_servers,
updates,
permissions,
session,
})
}
}
#[derive(Debug)]
struct LifecycleCancelled;
impl std::fmt::Display for LifecycleCancelled {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ACP session lifecycle cancelled")
}
}
impl std::error::Error for LifecycleCancelled {}
pub(crate) fn lifecycle_cancelled() -> anyhow::Error {
anyhow::Error::new(LifecycleCancelled)
}
pub(crate) fn is_lifecycle_cancelled(error: &anyhow::Error) -> bool {
error.chain().any(|cause| cause.is::<LifecycleCancelled>())
}
fn lifecycle_teardown_unconfirmed() -> anyhow::Error {
anyhow::anyhow!(
"ACP session lifecycle cancelled, but controller teardown did not confirm; see the session log"
)
}
impl LocalControllerBinding {
fn open(
source: &ConfigSource,
config: &Config,
routed: &Routed,
explicit_base_url: bool,
) -> Option<Self> {
let mut diagnostics = |message| eprintln!("{message}");
Self::open_with_diagnostics(source, config, routed, explicit_base_url, &mut diagnostics)
}
fn open_with_diagnostics(
source: &ConfigSource,
config: &Config,
routed: &Routed,
explicit_base_url: bool,
diagnostics: &mut dyn FnMut(String),
) -> Option<Self> {
let (Some(_endpoint), Some(controller_instance_id), Some(api_principal)) = (
routed.endpoint_plan.as_ref(),
routed.controller_instance_id.clone(),
routed.api_principal.clone(),
) else {
return None;
};
if explicit_base_url {
diagnostics(
"note: _bitrouter/route/* is unavailable with an explicit --base-url; \
the remote model endpoint remains usable without session route control"
.to_string(),
);
return None;
}
Some(Self {
socket_path: crate::daemon::socket_path_for(source, config),
api_principal,
controller_instance_id,
})
}
fn route_control(&self) -> Arc<dyn AcpRouteControl> {
Arc::new(DaemonRouteControl::new(
self.socket_path.clone(),
self.api_principal.clone(),
self.controller_instance_id.clone(),
))
}
fn session_cost(&self) -> Arc<dyn AcpSessionCost> {
Arc::new(CachedSessionCost::new(Arc::new(DaemonSessionCost {
socket_path: self.socket_path.clone(),
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
})))
}
async fn revoke(&self) {
let cleaned = crate::daemon::send_command(
&self.socket_path,
&crate::daemon::DaemonCommand::AcpControllerCleanup {
api_principal: self.api_principal.clone(),
controller_instance_id: self.controller_instance_id.clone(),
},
)
.await;
match cleaned {
Ok(crate::daemon::DaemonResponse::Ok) => {}
Ok(other) => tracing::debug!(?other, "controller lease cleanup was not acknowledged"),
Err(error) => {
tracing::debug!(%error, "controller lease cleanup did not reach the daemon")
}
}
}
}
pub async fn serve(ctx: SpawnContext<'_>) -> Result<()> {
for message in crate::assemble::ignored_config_warnings(&ctx.config) {
tracing::warn!("{message}");
}
let host = SessionHost::prepare(ctx, false).await?;
let agent_id = host.agent_id.as_str();
let agent = host
.config
.agents
.get(agent_id)
.with_context(|| format!("ACP agent '{agent_id}' is not configured"))?;
let AcpTransport::Stdio { command, args, env } = &agent.transport;
let identity = controller_identity(agent_id, command, args, host.routed.endpoint_plan.as_ref());
let mut controller_config = bitrouter_sdk::acp::controller::ControllerConfig::new(identity);
if let Some(endpoint) = host.routed.endpoint_plan.as_ref() {
controller_config = controller_config.endpoint(controller_endpoint(endpoint));
}
if host.options.turn_timeout.is_some() {
eprintln!(
"note: --turn-timeout is not enforced by transparent acp serve; \
the ACP client controls prompt deadlines"
);
}
let process =
bitrouter_sdk::acp::up::AgentProcess::new(command.clone(), args.clone(), env.clone())
.strip_inherited_env(host.options.strip_inherited_env);
let mut controller =
bitrouter_sdk::acp::controller::Controller::new(process, controller_config);
if let Some(binding) = &host.binding {
controller = controller
.route_control(binding.route_control())
.session_cost(binding.session_cost());
}
if let Some(capture) = open_capture(&host.config, agent_id, host.binding.as_ref()).await? {
controller = controller.capture(capture);
}
controller
.run(agent_client_protocol::Stdio::new())
.await
.map_err(|error| anyhow::anyhow!("acp serve: {error}"))
}
fn show_session_log(error: anyhow::Error) -> anyhow::Error {
crate::chat::session::report_failed_launch();
error
}
pub async fn chat(ctx: SpawnContext<'_>) -> Result<()> {
use std::io::IsTerminal as _;
if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
let (services, request) = crate::actions::code::CodeServices::from_spawn_context(ctx)?;
return crate::chat::code::run(services, Some(request)).await;
}
let SpawnContext {
source,
mut config,
agent_id,
options,
routing,
} = ctx;
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let routed = apply_routing_with_cloud_credentials(
source,
&mut config,
agent_id,
&routing,
&cloud_credentials,
)
.await
.map_err(anyhow::Error::new)?;
match &routed.via {
Some(via) => eprintln!("code: '{agent_id}' routed via bitrouter ({via})"),
None => eprintln!("code: '{agent_id}' running direct (not routed, not metered)"),
}
let binding =
LocalControllerBinding::open(source, &config, &routed, routing.base_url.is_some());
let ports = crate::actions::session::SessionPorts::open(
source.clone(),
crate::daemon::socket_path_for(source, &config),
);
let prompt_commands = crate::actions::session::prompt_commands(&config.chat)?;
chat_piped(
&config,
agent_id,
&routed,
options,
&cloud_credentials,
binding,
crate::actions::session::SessionSurface {
commands: Vec::new(),
prompt_commands,
ports: &ports,
},
)
.await
}
fn unauthenticated_message(
agent_id: &str,
methods: &[agent_client_protocol::schema::v1::AuthMethod],
) -> String {
use std::io::IsTerminal as _;
if methods.is_empty() {
return format!(
"'{agent_id}' is not authenticated, and advertises no authentication method. \
Sign in with the harness's own tooling, then run this again"
);
}
let offered = methods
.iter()
.map(|method| match method.description() {
Some(description) => format!("{} ({description})", method.name()),
None => method.name().to_string(),
})
.collect::<Vec<_>>()
.join("; ");
if std::io::stdin().is_terminal() {
format!(
"'{agent_id}' is not authenticated and the sign-in was cancelled. \
It offers: {offered}"
)
} else {
format!(
"'{agent_id}' is not authenticated. It offers: {offered}. \
Run `{cli} code {agent_id}` in a terminal to sign in, or use the \
harness's own tooling",
cli = invocation::name()
)
}
}
async fn chat_piped(
config: &Config,
agent_id: &str,
routed: &Routed,
options: LaunchOptions,
cloud_credentials: &crate::cloud::StandaloneCloudCredentials,
binding: Option<LocalControllerBinding>,
mut surface: crate::actions::session::SessionSurface<'_>,
) -> Result<()> {
let cwd = std::env::current_dir().context("resolving current directory")?;
let mcp_servers = options.mcp_servers.clone();
let mut session = launch_controlled(config, agent_id, routed, options, binding)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))
.map_err(show_session_log)?;
let ids = match session.client.new_session(cwd, mcp_servers).await {
Ok(ids) => ids,
Err(error) => {
let unauthenticated = bitrouter_sdk::acp::client::is_auth_required(&error);
let context = if unauthenticated {
unauthenticated_message(agent_id, session.client.auth_methods())
} else {
"opening the harness session".to_string()
};
session.shutdown().await;
let failure = error.context(context);
return Err(if unauthenticated {
failure
} else {
show_session_log(failure)
});
}
};
let observability =
build_observability(config, agent_id, &ids.acp_session_id, cloud_credentials).await;
if let Some(recorder) = observability.recorder.clone() {
spawn_tool_spans(recorder, session.client.subscribe_updates());
}
surface.commands = crate::actions::session::offered_commands(&session.client);
let ended = crate::chat::session::chat_plain(
&session.client,
&ids.acp_session_id,
agent_id,
observability.recorder,
surface,
)
.await;
session.shutdown().await;
if let Some(exporter) = observability.exporter {
exporter.shutdown();
}
ended
}
pub async fn prompt<W>(
ctx: SpawnContext<'_>,
text: &str,
options: PromptOptions,
out: &mut W,
) -> Result<PermissionTally>
where
W: AsyncWrite + Unpin + Send,
{
let PromptOptions {
no_wait,
contract,
policy,
format,
session: session_selection,
cwd,
} = options;
let mut presenter = Presenter::new(format, contract.is_some(), out);
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let host = match SessionHost::prepare(ctx, false).await {
Ok(host) => host,
Err(error) => {
if let Some(routing) = error.downcast_ref::<RoutingError>() {
presenter.routing_error(routing).await?;
} else {
presenter
.error("session_prepare_failed", &format!("{error:#}"))
.await?;
}
return Err(error);
}
};
let config = host.config.clone();
let agent_id = host.agent_id.clone();
let expanded = match bitrouter_tui::machine::resolve(
&[],
&crate::actions::session::prompt_commands(&config.chat)?,
text,
) {
bitrouter_tui::machine::Resolution::Expand(prompt) => prompt,
_ => text.to_string(),
};
let text = expanded.as_str();
let cwd = match cwd {
Some(cwd) => cwd
.canonicalize()
.with_context(|| format!("resolving working directory {}", cwd.display()))?,
None => std::env::current_dir().context("resolving current directory")?,
};
let mut session = match host.open(&session_selection, cwd).await {
Ok(session) => session,
Err(error) => {
presenter
.error("agent_not_ready", &format!("{error:#}"))
.await?;
return Err(error);
}
};
let mut updates = session.take_updates();
let permissions = session.take_permissions();
let observability =
build_observability(&config, &agent_id, &session.session_id, &cloud_credentials).await;
if let Some(recorder) = observability.recorder.clone() {
spawn_tool_spans(recorder, session.client.subscribe_updates());
}
presenter
.session(&serde_json::json!({
"type": "session",
"session_id": session.session_id,
"agent_session_id": session.agent_session_id,
"agent": agent_id,
"via": session.via,
"launch_id": session.launch_id,
}))
.await?;
if no_wait {
presenter.submitted().await?;
let clean = session.shutdown().await;
if let Some(exporter) = observability.exporter {
exporter.shutdown();
}
return teardown_result(clean).map(|()| PermissionTally::default());
}
let mut turn = Turn {
client: &session.client,
session_id: &session.session_id,
agent_id: &agent_id,
recorder: observability.recorder.clone(),
wire: Wire::new(&session.client, &session.session_id),
permissions,
policy,
tally: PermissionTally::default(),
};
let outcome = prompt_wait(&mut turn, &mut updates, text, contract, &mut presenter).await;
let tally = turn.tally;
let clean = session.shutdown().await;
if let Some(exporter) = observability.exporter {
exporter.shutdown();
}
if let Err(error) = outcome {
presenter
.error("turn_failed", &format!("{error:#}"))
.await?;
return Err(error);
}
teardown_result(clean).map(|()| tally)
}
async fn open_session(
client: &AcpClient,
selection: &SessionSelection,
cwd: PathBuf,
mcp_servers: Vec<agent_client_protocol::schema::v1::McpServer>,
) -> Result<bitrouter_sdk::acp::client::SessionIds> {
match selection {
SessionSelection::New => client.new_session(cwd, mcp_servers).await,
SessionSelection::Load(session_id) => {
client.load_session(session_id, cwd, mcp_servers).await
}
SessionSelection::Resume(session_id) => {
client.resume_session(session_id, cwd, mcp_servers).await
}
}
}
fn teardown_result(clean: bool) -> Result<()> {
if clean {
Ok(())
} else {
Err(anyhow::anyhow!(
"shutting down acp session: teardown did not confirm; see the session log"
))
}
}
pub(crate) struct ControlledSession {
pub(crate) client: AcpClient,
cleanup: ControlledCleanup,
}
struct ControlledCleanup {
binding: Option<LocalControllerBinding>,
reaped: futures::future::Shared<futures::channel::oneshot::Receiver<()>>,
controller: tokio::task::JoinHandle<std::result::Result<(), anyhow::Error>>,
}
const CONTROLLER_EXIT_TIMEOUT: Duration = Duration::from_secs(10);
impl ControlledSession {
pub(crate) async fn shutdown(&mut self) -> bool {
let mut clean = true;
if let Err(error) = self.client.shutdown().await {
tracing::warn!(%error, "acp teardown unconfirmed; the harness may not have terminated");
clean = false;
}
self.cleanup.finish(false).await && clean
}
fn closed(&self) -> futures::future::BoxFuture<'static, ()> {
let reaped = self.cleanup.reaped.clone();
Box::pin(async move {
let _ = reaped.await;
})
}
}
impl ControlledCleanup {
async fn abort(&mut self) -> bool {
self.controller.abort();
self.finish(true).await
}
async fn finish(&mut self, controller_aborted: bool) -> bool {
let mut clean = true;
if tokio::time::timeout(bitrouter_sdk::acp::up::REAP_CONFIRM, self.reaped.clone())
.await
.is_err()
{
tracing::warn!("harness child not confirmed reaped; a grandchild may have survived");
clean = false;
}
match tokio::time::timeout(CONTROLLER_EXIT_TIMEOUT, &mut self.controller).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => tracing::debug!(%error, "acp controller ended with an error"),
Ok(Err(error)) => {
if controller_aborted && error.is_cancelled() {
tracing::debug!("acp controller aborted during lifecycle cancellation");
} else {
tracing::warn!(%error, "acp controller task failed");
clean = false;
}
}
Err(_) => {
tracing::warn!("acp controller did not exit within {CONTROLLER_EXIT_TIMEOUT:?}");
self.controller.abort();
clean = false;
}
}
if let Some(binding) = &self.binding {
binding.revoke().await;
}
clean
}
}
async fn open_capture(
config: &Config,
agent_id: &str,
binding: Option<&LocalControllerBinding>,
) -> Result<Option<Arc<dyn bitrouter_sdk::acp::capture::CapturePort>>> {
if !config.acp_recording.enabled {
return Ok(None);
}
let db = crate::db::connect(&config.database.url)
.await
.context("opening the opt-in ACP content store")?;
crate::db::run_migrations(&db).await?;
let store = crate::acp_trajectory::CanonicalStore::new(db);
let recorder = store
.recorder(crate::acp_trajectory::RecordingScope {
owner: "local".into(),
source: agent_id.to_owned(),
controller_instance_id: binding.map(|value| value.controller_instance_id.clone()),
route_scope_id: binding.map(|value| value.api_principal.clone()),
})
.await?;
if let Some(binding) = binding {
let command = crate::daemon::DaemonCommand::AcpRecordingRegister {
connection_id: recorder.connection_id().to_owned(),
api_principal: binding.api_principal.clone(),
controller_instance_id: binding.controller_instance_id.clone(),
};
let acknowledged = tokio::time::timeout(
std::time::Duration::from_secs(5),
crate::daemon::send_command(&binding.socket_path, &command),
)
.await;
match acknowledged {
Ok(Ok(crate::daemon::DaemonResponse::AcpRecordingRegistered { registration }))
if registration.connection_id == recorder.connection_id()
&& registration.version == crate::evolution::inventory::INVENTORY_VERSION
&& registration.invalid_reason.is_none() => {}
response => tracing::warn!(
?response,
"ACP capture has no acknowledged gateway cost coverage"
),
}
}
Ok(Some(recorder))
}
async fn launch_controlled(
config: &Config,
agent_id: &str,
routed: &Routed,
options: LaunchOptions,
binding: Option<LocalControllerBinding>,
) -> Result<ControlledSession> {
launch_controlled_with_cancel(
config,
agent_id,
routed,
options,
binding,
&CancellationToken::new(),
)
.await
}
async fn launch_controlled_with_cancel(
config: &Config,
agent_id: &str,
routed: &Routed,
options: LaunchOptions,
binding: Option<LocalControllerBinding>,
cancel: &CancellationToken,
) -> Result<ControlledSession> {
if cancel.is_cancelled() {
return Err(lifecycle_cancelled());
}
let agent = config
.agents
.get(agent_id)
.with_context(|| format!("no acp agent configured for '{agent_id}'"))?;
agent
.validate()
.with_context(|| format!("invalid ACP agent '{agent_id}'"))?;
let AcpTransport::Stdio { command, args, env } = &agent.transport;
let identity = controller_identity(agent_id, command, args, routed.endpoint_plan.as_ref());
let mut controller_config = bitrouter_sdk::acp::controller::ControllerConfig::new(identity);
if let Some(endpoint) = routed.endpoint_plan.as_ref() {
controller_config = controller_config.endpoint(controller_endpoint(endpoint));
}
let mut process =
bitrouter_sdk::acp::up::AgentProcess::new(command.clone(), args.clone(), env.clone())
.strip_inherited_env(options.strip_inherited_env);
let reaped = process.reaped().shared();
let mut controller =
bitrouter_sdk::acp::controller::Controller::new(process, controller_config);
if let Some(binding) = &binding {
controller = controller
.route_control(binding.route_control())
.session_cost(binding.session_cost());
}
if let Some(capture) = open_capture(config, agent_id, binding.as_ref()).await? {
controller = controller.capture(capture);
}
let (manager_side, controller_side) = agent_client_protocol::Channel::duplex();
let controller = tokio::spawn(async move {
controller
.run(controller_side)
.await
.map_err(|error| anyhow::anyhow!("acp controller: {error}"))
});
let mut cleanup = ControlledCleanup {
binding,
reaped,
controller,
};
let connected = tokio::select! {
biased;
_ = cancel.cancelled() => None,
result = AcpClient::connect(
manager_side,
ClientOptions {
turn_timeout: options.turn_timeout,
terminal_auth: options.terminal_auth,
},
) => Some(result),
};
match connected {
Some(Ok(client)) => Ok(ControlledSession { client, cleanup }),
Some(Err(error)) => {
let clean = cleanup.abort().await;
if !clean {
tracing::warn!("acp lifecycle failure left teardown unconfirmed");
}
Err(error)
}
None => {
let clean = cleanup.abort().await;
if clean {
Err(lifecycle_cancelled())
} else {
tracing::warn!("acp lifecycle cancellation left teardown unconfirmed");
Err(lifecycle_teardown_unconfirmed())
}
}
}
}
struct Turn<'a> {
client: &'a AcpClient,
session_id: &'a str,
agent_id: &'a str,
recorder: Option<Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
wire: Wire<'a>,
permissions: std::pin::Pin<Box<dyn futures::Stream<Item = PendingPermission> + Send>>,
policy: Policy,
tally: PermissionTally,
}
async fn prompt_wait<W>(
turn: &mut Turn<'_>,
updates: &mut (impl futures::Stream<Item = SessionUpdate> + Unpin),
text: &str,
contract: Option<crate::result_contract::ResultContract>,
presenter: &mut Presenter<'_, W>,
) -> Result<()>
where
W: AsyncWrite + Unpin,
{
let task = match &contract {
Some(c) => format!("{text}{}", c.instruction()),
None => text.to_string(),
};
let (response, reply) = run_turn(turn, updates, &task, contract.is_some(), presenter).await?;
let (response, result, schema_ok, raw) = match &contract {
None => (response, None, None, None),
Some(c) => match c.check(&reply) {
Ok(value) => (response, Some(value), Some(true), None),
Err(problem) => {
let (response, reply) =
run_turn(turn, updates, &c.repair_prompt(&problem), true, presenter).await?;
match c.check(&reply) {
Ok(value) => (response, Some(value), Some(true), None),
Err(_) => (
response,
Some(serde_json::Value::Null),
Some(false),
Some(reply),
),
}
}
},
};
presenter
.result(&ResultLine {
kind: "result",
stop_reason: response.stop_reason,
result,
schema_ok,
raw,
})
.await
}
async fn run_turn<W>(
turn: &mut Turn<'_>,
updates: &mut (impl futures::Stream<Item = SessionUpdate> + Unpin),
text: &str,
capture: bool,
presenter: &mut Presenter<'_, W>,
) -> Result<(agent_client_protocol::schema::v1::PromptResponse, String)>
where
W: AsyncWrite + Unpin,
{
let mut reply = String::new();
let started = std::time::Instant::now();
let response = {
let prompt_future = turn.client.prompt(turn.session_id, text);
tokio::pin!(prompt_future);
loop {
tokio::select! {
biased;
result = &mut prompt_future => {
let response = result.context("acp prompt failed")?;
loop {
let maybe = tokio::select! {
biased;
v = updates.next() => v,
_ = std::future::ready(()) => None,
};
match maybe {
Some(update) => presenter.update(update, capture, &mut reply).await?,
None => break,
}
}
break response;
}
maybe_update = updates.next() => {
if let Some(update) = maybe_update {
presenter.update(update, capture, &mut reply).await?;
}
}
request = turn.permissions.next() => {
if let Some(request) = request {
let (decision, prompt) = turn.wire.answer(request, &turn.policy).await;
turn.tally.record(decision);
presenter.permission(&prompt, decision).await?;
}
}
}
}
};
presenter.turn_settled().await?;
report_turn(
turn.client,
turn.agent_id,
turn.recorder.as_ref(),
&response,
started.elapsed(),
);
Ok((response, reply))
}
pub(crate) fn report_turn(
client: &AcpClient,
agent_id: &str,
recorder: Option<&Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
response: &agent_client_protocol::schema::v1::PromptResponse,
latency: Duration,
) {
let record = RequestCompleted {
agent: agent_id.to_string(),
stop_reason: format!("{:?}", response.stop_reason),
latency_ms: u64::try_from(latency.as_millis()).unwrap_or(u64::MAX),
context: client.context_usage().lock().ok().and_then(|slot| *slot),
};
if let Some(recorder) = recorder {
recorder.turn_completed(&bitrouter_telemetry::otel::acp::TurnRecord {
stop_reason: record.stop_reason.clone(),
latency,
context_used: record.context.map(|c| c.used),
context_size: record.context.map(|c| c.size),
});
}
drain_telemetry_record(record);
}
enum Presenter<'a, W> {
Ndjson { out: &'a mut W, seq: u64 },
Quiet { out: &'a mut W, contracted: bool },
Text {
out: &'a mut W,
transcript: Box<bitrouter_tui::plain::Transcript>,
},
}
impl<'a, W> Presenter<'a, W>
where
W: AsyncWrite + Unpin,
{
fn new(format: PromptFormat, contracted: bool, out: &'a mut W) -> Self {
match format {
PromptFormat::Ndjson => Presenter::Ndjson { out, seq: 0 },
PromptFormat::Quiet => Presenter::Quiet { out, contracted },
PromptFormat::Text => Presenter::Text {
out,
transcript: Box::default(),
},
}
}
async fn routing_error(&mut self, error: &RoutingError) -> Result<()> {
if matches!(self, Presenter::Ndjson { .. }) {
self.ndjson(error.ndjson()).await?;
let Presenter::Ndjson { out, .. } = self else {
return Ok(());
};
out.flush().await.ok();
}
Ok(())
}
async fn error(&mut self, code: &str, message: &str) -> Result<()> {
if matches!(self, Presenter::Ndjson { .. }) {
self.ndjson(serde_json::json!({
"type": "error",
"code": code,
"message": message,
}))
.await?;
}
Ok(())
}
async fn ndjson(&mut self, value: serde_json::Value) -> Result<()> {
let Presenter::Ndjson { out, seq } = self else {
return Ok(());
};
let mut object = value
.as_object()
.cloned()
.ok_or_else(|| anyhow::anyhow!("NDJSON event must serialize as an object"))?;
object.insert("version".to_string(), serde_json::json!(1));
object.insert("seq".to_string(), serde_json::json!(*seq));
*seq = seq.saturating_add(1);
write_ndjson_line(out, &object).await
}
async fn session(&mut self, line: &serde_json::Value) -> Result<()> {
match self {
Presenter::Ndjson { .. } => self.ndjson(line.clone()).await,
Presenter::Quiet { .. } => Ok(()),
Presenter::Text { out, .. } => {
let field = |name: &str| {
line.get(name)
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string()
};
let via = line
.get("via")
.and_then(|v| v.as_str())
.map_or_else(|| "direct".to_string(), |via| format!("via {via}"));
write_line(
out,
&format!(
"session {} · agent {} · {via}",
field("session_id"),
field("agent")
),
)
.await
}
}
}
async fn update(
&mut self,
update: SessionUpdate,
capture: bool,
reply: &mut String,
) -> Result<()> {
match self {
Presenter::Text { transcript, .. } => {
if capture {
if let Some(SessionUpdateKind::MessageChunk { text, .. }) =
translate(update.clone())
{
reply.push_str(&text);
}
}
transcript.apply(update);
Ok(())
}
Presenter::Ndjson { .. } => {
let Some(kind) = translate(update) else {
return Ok(());
};
if capture && let SessionUpdateKind::MessageChunk { text, .. } = &kind {
reply.push_str(text);
}
self.ndjson(serde_json::to_value(kind).context("serialising NDJSON event")?)
.await
}
Presenter::Quiet { out, contracted } => {
let Some(SessionUpdateKind::MessageChunk { text, .. }) = translate(update) else {
return Ok(());
};
if capture {
reply.push_str(&text);
}
if *contracted {
return Ok(());
}
out.write_all(text.as_bytes())
.await
.context("writing assistant text")
}
}
}
async fn permission(&mut self, prompt: &PermissionPrompt, decision: Decision) -> Result<()> {
match self {
Presenter::Ndjson { .. } => {
self.ndjson(serde_json::json!({
"type": "permission",
"decision": decision.to_string(),
"title": prompt.title(),
"kind": prompt.kind(),
}))
.await
}
Presenter::Quiet { .. } => Ok(()),
Presenter::Text { out, .. } => {
write_line(out, &format!(" permission {decision}: {}", prompt.title())).await
}
}
}
async fn turn_settled(&mut self) -> Result<()> {
if let Presenter::Text { out, transcript } = self {
let mut rendered = Vec::new();
bitrouter_tui::plain::write(&mut rendered, &transcript.unwritten())
.context("rendering the transcript")?;
out.write_all(&rendered)
.await
.context("writing the transcript")?;
}
Ok(())
}
async fn result<S: Serialize>(&mut self, line: &ResultLine<S>) -> Result<()> {
match self {
Presenter::Ndjson { .. } => {
self.ndjson(serde_json::to_value(line).context("serialising NDJSON result")?)
.await
}
Presenter::Quiet { out, contracted } => {
let text = match (&line.result, &line.raw) {
(Some(result), None) => {
serde_json::to_string(result).context("serialising the result")?
}
(_, Some(raw)) => raw.clone(),
(None, None) => String::new(),
};
if *contracted || text.is_empty() {
write_line(out, &text).await
} else {
Ok(())
}
}
Presenter::Text { out, .. } => {
let stop = serde_json::to_value(&line.stop_reason)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
write_line(out, &format!("[{stop}]")).await?;
if let Some(result) = &line.result {
let pretty =
serde_json::to_string_pretty(result).context("serialising the result")?;
write_line(out, &format!("result: {pretty}")).await?;
}
if let Some(raw) = &line.raw {
write_line(out, &format!("raw: {raw}")).await?;
}
Ok(())
}
}
}
async fn submitted(&mut self) -> Result<()> {
match self {
Presenter::Ndjson { .. } => {
self.ndjson(serde_json::json!({ "type": "submitted" }))
.await
}
Presenter::Quiet { .. } => Ok(()),
Presenter::Text { out, .. } => write_line(out, "[submitted]").await,
}
}
}
#[cfg(test)]
mod ndjson_presenter_tests {
use super::{Presenter, PromptFormat};
#[tokio::test]
async fn every_event_is_versioned_and_sequenced() -> anyhow::Result<()> {
let mut output = Vec::new();
let mut presenter = Presenter::new(PromptFormat::Ndjson, false, &mut output);
presenter.submitted().await?;
presenter.error("failed", "broken").await?;
drop(presenter);
let lines: Vec<serde_json::Value> = String::from_utf8(output)?
.lines()
.map(serde_json::from_str)
.collect::<Result<_, _>>()?;
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["version"], 1);
assert_eq!(lines[0]["seq"], 0);
assert_eq!(lines[1]["version"], 1);
assert_eq!(lines[1]["seq"], 1);
assert_eq!(lines[1]["type"], "error");
Ok(())
}
}
async fn write_line<W>(out: &mut W, line: &str) -> Result<()>
where
W: AsyncWrite + Unpin,
{
out.write_all(line.as_bytes())
.await
.context("writing to stdout")?;
out.write_all(b"\n").await.context("writing to stdout")
}
pub async fn commands(
ctx: SpawnContext<'_>,
wait_ms: u64,
) -> Result<crate::actions::commands::CommandsReport> {
let SpawnContext {
source,
mut config,
agent_id,
options,
routing,
} = ctx;
let cloud_credentials = crate::cloud::StandaloneCloudCredentials::new();
let routed = apply_routing_with_cloud_credentials(
source,
&mut config,
agent_id,
&routing,
&cloud_credentials,
)
.await
.map_err(anyhow::Error::new)?;
let configured = crate::actions::session::prompt_commands(&config.chat)?;
let binding =
LocalControllerBinding::open(source, &config, &routed, routing.base_url.is_some());
let cwd = std::env::current_dir().context("resolving current directory")?;
let mcp_servers = options.mcp_servers.clone();
let mut session = launch_controlled(&config, agent_id, &routed, options, binding)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))?;
let mut updates = session.client.subscribe_raw_updates();
if let Err(error) = session.client.new_session(cwd, mcp_servers).await {
let context = if bitrouter_sdk::acp::client::is_auth_required(&error) {
unauthenticated_message(agent_id, session.client.auth_methods())
} else {
"opening the harness session".to_string()
};
session.shutdown().await;
return Err(error.context(context));
}
let mut advertised = Vec::new();
let mut received = false;
let deadline = tokio::time::sleep(std::time::Duration::from_millis(wait_ms));
tokio::pin!(deadline);
loop {
tokio::select! {
update = updates.next() => match update {
Some(agent_client_protocol::schema::v1::SessionUpdate::AvailableCommandsUpdate(
update,
)) => {
advertised = update.available_commands;
received = true;
}
Some(_) => continue,
None => break,
},
() = &mut deadline => break,
}
}
let report = crate::actions::commands::commands_report(
&crate::actions::session::offered_commands(&session.client),
&configured,
&advertised,
received,
);
session.shutdown().await;
Ok(report)
}
struct Observability {
exporter: Option<Arc<bitrouter_telemetry::otel::OtelExporter>>,
recorder: Option<Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>>,
}
async fn build_observability(
config: &Config,
agent_id: &str,
conversation_id: &str,
cloud_credentials: &crate::cloud::StandaloneCloudCredentials,
) -> Observability {
for message in crate::assemble::ignored_config_warnings(config) {
tracing::warn!("{message}");
}
let exporter =
crate::assemble::build_otel_exporter_standalone_with_credentials(config, cloud_credentials)
.await;
let recorder = exporter.as_ref().map(|exporter| {
Arc::new(bitrouter_telemetry::otel::acp::AcpSpanRecorder::new(
exporter,
agent_id,
conversation_id,
))
});
Observability { exporter, recorder }
}
fn spawn_tool_spans(
recorder: Arc<bitrouter_telemetry::otel::acp::AcpSpanRecorder>,
mut updates: std::pin::Pin<Box<dyn futures::Stream<Item = SessionUpdateKind> + Send>>,
) {
tokio::spawn(async move {
use bitrouter_sdk::acp::translate::ToolStatus;
while let Some(update) = updates.next().await {
match update {
SessionUpdateKind::ToolCall {
id, title, status, ..
} => match status {
ToolStatus::Pending | ToolStatus::Running => {
recorder.tool_started(id, title);
}
ToolStatus::Ok => recorder.tool_finished(&id, true, Some(&title)),
ToolStatus::Failed => recorder.tool_finished(&id, false, Some(&title)),
},
SessionUpdateKind::ToolCallUpdate {
id, status, title, ..
} => match status {
Some(ToolStatus::Ok) => {
recorder.tool_finished(&id, true, title.as_deref());
}
Some(ToolStatus::Failed) => {
recorder.tool_finished(&id, false, title.as_deref());
}
_ => {}
},
_ => {}
}
}
});
}
#[cfg(test)]
mod standalone_cloud_credentials_tests {
use std::sync::Arc;
use bitrouter_providers::hosted::account::credentials::{Credentials, StoredCredential};
use bitrouter_providers::hosted::account::manager::CredentialManager;
use chrono::{Duration, Utc};
use serde_json::json;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::cloud::StandaloneCloudCredentials;
#[tokio::test]
async fn standalone_wiring_single_flights_routing_and_account_telemetry() -> anyhow::Result<()>
{
let server = MockServer::start().await;
let origin = server.uri();
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"issuer": origin,
"device_authorization_endpoint": format!("{origin}/oauth/device_authorization"),
"token_endpoint": format!("{origin}/oauth/token"),
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("grant_type=refresh_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "rotated-access",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rotated-refresh",
"scope": "inference:invoke",
})))
.mount(&server)
.await;
let directory = tempfile::tempdir()?;
let manager = Arc::new(CredentialManager::with_client(
directory.path().join("account-credentials.json"),
reqwest::Client::new(),
));
manager
.save(StoredCredential::from(Credentials {
access_token: "stale-access".to_owned(),
refresh_token: Some("original-refresh".to_owned()),
expires_at: Utc::now() + Duration::seconds(10),
refresh_token_expires_at: None,
token_type: "Bearer".to_owned(),
scope: "inference:invoke".to_owned(),
client_id: "bitrouter-cli".to_owned(),
authorization_server: origin.clone(),
namespace_id: Some("ns-test".to_owned()),
subject: None,
}))
.await?;
let credentials = StandaloneCloudCredentials::with_manager(manager);
let telemetry = credentials
.telemetry_bearer(&origin)
.await
.ok_or_else(|| anyhow::anyhow!("account telemetry source was not built"))?;
let (routing, bearer) =
tokio::join!(credentials.routing_fallback(&origin), telemetry.bearer(),);
assert_eq!(routing.as_deref(), Some("rotated-access"));
assert_eq!(bearer.as_deref(), Some("rotated-access"));
let requests = server
.received_requests()
.await
.ok_or_else(|| anyhow::anyhow!("wiremock did not record requests"))?;
let metadata_requests = requests
.iter()
.filter(|request| request.url.path() == "/.well-known/oauth-authorization-server")
.count();
let refresh_requests = requests
.iter()
.filter(|request| request.url.path() == "/oauth/token")
.count();
assert_eq!(metadata_requests, 1);
assert_eq!(refresh_requests, 1);
Ok(())
}
}
fn drain_telemetry_record(r: RequestCompleted) {
tracing::info!(
agent = %r.agent,
stop_reason = %r.stop_reason,
latency_ms = r.latency_ms,
context_used = r.context.map(|c| c.used),
context_size = r.context.map(|c| c.size),
"acp turn completed"
);
}
#[derive(Clone, Debug, Default)]
pub struct LaunchOptions {
pub strip_inherited_env: Vec<String>,
pub turn_timeout: Option<Duration>,
pub mcp_servers: Vec<agent_client_protocol::schema::v1::McpServer>,
pub terminal_auth: bool,
}
#[derive(Debug, Clone)]
pub struct RequestCompleted {
pub agent: String,
pub stop_reason: String,
pub latency_ms: u64,
pub context: Option<bitrouter_sdk::acp::telemetry::ContextUsage>,
}
pub fn launch_options(turn_timeout_secs: Option<u64>) -> LaunchOptions {
LaunchOptions {
turn_timeout: turn_timeout_secs.map(std::time::Duration::from_secs),
..Default::default()
}
}
#[cfg(all(test, unix))]
mod evolution_acceptance;
#[cfg(test)]
mod auth_report_tests {
use agent_client_protocol::schema::v1::{AuthMethod, AuthMethodAgent};
use super::{LaunchOptions, unauthenticated_message};
#[test]
fn terminal_auth_is_off_for_standard_launches() {
assert!(
!LaunchOptions::default().terminal_auth,
"the default must not claim a terminal this caller may not have"
);
assert!(
!super::launch_options(None).terminal_auth,
"the shared serve/prompt constructor must not claim it either"
);
}
#[test]
fn the_message_names_the_harness_and_what_it_offers() {
let methods = vec![AuthMethod::Agent(
AuthMethodAgent::new("oauth", "Sign in with Anthropic")
.description("opens a browser".to_string()),
)];
let message = unauthenticated_message("claude-acp", &methods);
assert!(message.contains("'claude-acp'"), "{message}");
assert!(message.contains("Sign in with Anthropic"), "{message}");
assert!(message.contains("opens a browser"), "{message}");
}
#[test]
fn advertising_nothing_is_reported_as_nothing() {
let message = unauthenticated_message("pi-acp", &[]);
assert!(
message.contains("advertises no authentication method"),
"{message}"
);
}
#[test]
fn the_message_never_mentions_the_routing_axis() {
let methods = vec![AuthMethod::Agent(AuthMethodAgent::new("t", "Terminal"))];
for message in [
unauthenticated_message("claude-acp", &methods),
unauthenticated_message("claude-acp", &[]),
] {
let prose = message.replace("bitrouter", "");
for forbidden in ["providers login", "provider", "route", "--direct"] {
assert!(
!prose.contains(forbidden),
"an agent-auth message must not name the routing axis \
({forbidden:?}): {message}"
);
}
}
}
}
#[cfg(test)]
mod controller_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use agent_client_protocol::schema::v1::Cost;
use bitrouter_sdk::acp::controller::SessionCost;
use super::{CachedSessionCost, attributed_cost, controller_identity};
struct StallingCost {
release: tokio::sync::Notify,
asked: AtomicUsize,
figure: Option<Cost>,
}
#[async_trait::async_trait]
impl SessionCost for StallingCost {
async fn attributed_cost(&self, _session_id: &str) -> Option<Cost> {
self.asked.fetch_add(1, Ordering::SeqCst);
self.release.notified().await;
self.figure.clone()
}
}
async fn settled(cache: &CachedSessionCost, session_id: &str) -> Option<Cost> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let held = cache.cache.held(session_id);
if held.is_some() || std::time::Instant::now() >= deadline {
return held;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
#[tokio::test]
async fn the_cached_cost_answers_without_awaiting_the_daemon() {
let daemon = Arc::new(StallingCost {
release: tokio::sync::Notify::new(),
asked: AtomicUsize::new(0),
figure: Some(Cost::new(0.42, "USD")),
});
let cache = CachedSessionCost::new(daemon.clone());
let first = tokio::time::timeout(Duration::from_millis(200), cache.attributed_cost("s1"))
.await
.expect("the forward path must not block on the daemon");
assert_eq!(first, None, "nothing measured yet is nothing, not zero");
let second = tokio::time::timeout(Duration::from_millis(200), cache.attributed_cost("s1"))
.await
.expect("still not blocked while the refresh is in flight");
assert_eq!(second, None);
tokio::task::yield_now().await;
assert_eq!(
daemon.asked.load(Ordering::SeqCst),
1,
"one refresh in flight per session, however many updates arrive"
);
daemon.release.notify_one();
assert_eq!(settled(&cache, "s1").await, Some(Cost::new(0.42, "USD")));
assert_eq!(
cache.attributed_cost("s1").await,
Some(Cost::new(0.42, "USD")),
"the figure the daemon confirmed is what the next update carries"
);
}
#[tokio::test]
async fn an_unmeasured_session_stays_absent_and_a_held_figure_survives() {
let daemon = Arc::new(StallingCost {
release: tokio::sync::Notify::new(),
asked: AtomicUsize::new(0),
figure: None,
});
let cache = CachedSessionCost::new(daemon.clone());
assert_eq!(cache.attributed_cost("never").await, None);
daemon.release.notify_one();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(
cache.attributed_cost("never").await,
None,
"no priced evidence is no figure — not $0.00"
);
cache
.cache
.settle_refresh("held", Some(Cost::new(0.25, "USD")));
cache.cache.settle_refresh("held", None);
assert_eq!(
cache.cache.held("held"),
Some(Cost::new(0.25, "USD")),
"a stale measured figure understates; it is never dropped for nothing"
);
}
#[test]
fn the_cost_marker_is_spelled_the_same_on_both_sides() {
assert_eq!(
bitrouter_tui::cost::COST_PROVENANCE_META_KEY,
bitrouter_sdk::acp::controller::COST_PROVENANCE_META_KEY
);
assert_eq!(
bitrouter_tui::cost::COST_PROVENANCE_ROUTER,
bitrouter_sdk::acp::controller::COST_PROVENANCE_ROUTER
);
}
#[test]
fn unattributed_or_unpriced_sessions_carry_no_figure() {
assert_eq!(attributed_cost(0, 0, 0), None);
assert_eq!(attributed_cost(0, 3, 3), None);
}
#[test]
fn priced_evidence_becomes_a_usd_figure() {
assert_eq!(attributed_cost(420_000, 2, 0), Some(Cost::new(0.42, "USD")));
assert_eq!(attributed_cost(250_000, 3, 2), Some(Cost::new(0.25, "USD")));
}
#[test]
fn direct_maintained_adapter_keeps_exact_identity() {
let identity = controller_identity(
"codex-acp",
"npx",
&[
"-y".to_string(),
"@agentclientprotocol/codex-acp@1.10.0".to_string(),
],
None,
);
assert_eq!(identity.harness_id, "codex-acp");
assert_eq!(identity.adapter_package, "@agentclientprotocol/codex-acp");
assert_eq!(identity.adapter_version, "1.10.0");
}
#[test]
fn custom_adapter_identity_exposes_no_launch_command() {
let identity = controller_identity(
"private-agent",
"/private/bin/agent-with-token-in-path",
&["--secret-argument".to_string()],
None,
);
assert_eq!(identity.harness_id, "private-agent");
assert_eq!(identity.adapter_package, "configured-acp-adapter");
assert_eq!(identity.adapter_version, "configured");
let rendered = format!("{identity:?}");
assert!(!rendered.contains("token"));
assert!(!rendered.contains("secret"));
}
#[test]
fn unpinned_catalog_invocation_is_not_reported_as_the_pin() {
let identity = controller_identity(
"codex-acp",
"npx",
&["@agentclientprotocol/codex-acp@1.6.0".to_string()],
None,
);
assert_eq!(identity.harness_id, "codex-acp");
assert_eq!(identity.adapter_package, "configured-acp-adapter");
assert_eq!(identity.adapter_version, "configured");
}
}