use std::sync::Arc;
use anyhow::{Context, Result};
use bitrouter_sdk::acp::ConfigAcpRoutingTable;
use bitrouter_sdk::config::Config;
use futures::StreamExt;
use serde::Serialize;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use bitrouter_substrate::engine::LaunchOptions;
use bitrouter_substrate::telemetry::RequestCompleted;
use bitrouter_substrate::translate::SessionUpdateKind;
use bitrouter_substrate::worktree::WorktreeSpec;
#[derive(Serialize)]
struct ResultLine<S: Serialize> {
#[serde(rename = "type")]
kind: &'static str,
stop_reason: S,
}
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")
}
#[derive(Debug, Clone)]
pub struct WarmOptions {
pub idle_timeout: std::time::Duration,
}
pub async fn serve(
config: Config,
agent_id: &str,
options: LaunchOptions,
warm: Option<WarmOptions>,
) -> Result<()> {
#[cfg(not(unix))]
if warm.is_some() {
anyhow::bail!("--warm requires unix domain sockets (unix-only in v1)");
}
let catalog = catalog_from_config(&config)?;
let base_repo = std::env::current_dir().context("resolving current directory")?;
let session = bitrouter_substrate::engine::Session::launch_deferred(
&catalog,
agent_id,
base_repo.clone(),
options,
)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))?;
let exporter = attach_observability(&config, agent_id, &session).await;
let session = Arc::new(session);
#[cfg(unix)]
let reattach = match &warm {
Some(_) => {
let dir = socket_dir();
tokio::fs::create_dir_all(&dir)
.await
.with_context(|| format!("creating {}", dir.display()))?;
let record_id = &session.state().record_id;
let short: String = record_id.chars().take(16).collect();
let path = dir.join(format!("{short}.sock"));
let _ = tokio::fs::remove_file(&path).await;
let listener = tokio::net::UnixListener::bind(&path)
.with_context(|| format!("binding reattach socket {}", path.display()))?;
session.advertise_socket(path.clone()).await;
Some((listener, path))
}
None => None,
};
let served = bitrouter_substrate::down::serve(Arc::clone(&session)).await;
#[cfg(unix)]
let served = match (&warm, &reattach) {
(Some(warm), Some((listener, socket_path))) => {
use agent_client_protocol::ByteStreams;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
let mut served = served;
loop {
match tokio::time::timeout(warm.idle_timeout, listener.accept()).await {
Err(_) => {
tracing::info!(
idle = ?warm.idle_timeout,
"no manager reattached within the idle timeout; shutting down"
);
break;
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "reattach accept failed; shutting down");
break;
}
Ok(Ok((stream, _addr))) => {
tracing::info!("manager reattached over {}", socket_path.display());
let (read_half, write_half) = stream.into_split();
let transport =
ByteStreams::new(write_half.compat_write(), read_half.compat());
served =
bitrouter_substrate::down::serve_on(Arc::clone(&session), transport)
.await;
}
}
}
let _ = tokio::fs::remove_file(socket_path).await;
served
}
_ => served,
};
match Arc::try_unwrap(session) {
Ok(session) => session
.shutdown()
.await
.context("shutting down acp session")?,
Err(_) => tracing::warn!("session still referenced after serve; skipping shutdown"),
}
if let Some(exporter) = exporter {
exporter.shutdown();
}
served.map_err(|e| anyhow::anyhow!("acp serve: {e}"))
}
#[cfg(unix)]
pub async fn attach(record_prefix: &str) -> Result<()> {
use bitrouter_substrate::record::RecordStore;
let base = std::env::current_dir().context("resolving current directory")?;
let records = RecordStore::new(&base).list().await?;
let matches: Vec<_> = records
.iter()
.filter(|r| r.record_id.starts_with(record_prefix))
.collect();
let record = match matches.as_slice() {
[] => anyhow::bail!(
"no session record matches '{record_prefix}' (see `bitrouter acp sessions`)"
),
[record] => *record,
_ => anyhow::bail!(
"'{record_prefix}' matches {} sessions; use more of the record id",
matches.len()
),
};
let Some(socket) = &record.socket else {
anyhow::bail!(
"session {} has no reattach socket — it is not running `acp serve --warm`",
&record.record_id[..8.min(record.record_id.len())]
);
};
let stream = tokio::net::UnixStream::connect(socket)
.await
.with_context(|| {
format!(
"connecting to {} (is the session still alive?)",
socket.display()
)
})?;
let (mut sock_read, mut sock_write) = stream.into_split();
let mut stdin = tokio::io::stdin();
let mut stdout = tokio::io::stdout();
tokio::select! {
r = tokio::io::copy(&mut sock_read, &mut stdout) => { r.context("socket → stdout")?; }
r = tokio::io::copy(&mut stdin, &mut sock_write) => { r.context("stdin → socket")?; }
}
Ok(())
}
#[cfg(not(unix))]
pub async fn attach(_record_prefix: &str) -> Result<()> {
anyhow::bail!("`bitrouter acp attach` requires unix domain sockets (unix-only in v1)")
}
pub async fn prompt<W>(
config: Config,
agent_id: &str,
options: LaunchOptions,
text: &str,
no_wait: bool,
out: &mut W,
) -> Result<()>
where
W: AsyncWrite + Unpin + Send,
{
let catalog = catalog_from_config(&config)?;
let base_repo = std::env::current_dir().context("resolving current directory")?;
let session =
bitrouter_substrate::engine::Session::launch(&catalog, agent_id, base_repo, options)
.await
.with_context(|| format!("launching acp session for agent '{agent_id}'"))?;
let exporter = attach_observability(&config, agent_id, &session).await;
let mut permissions = session.permissions();
tokio::spawn(async move {
while let Some(pending) = permissions.next().await {
tracing::warn!(
tool = pending
.tool_call
.fields
.title
.as_deref()
.unwrap_or("(unnamed)"),
"headless prompt: denying permission request (no manager attached)"
);
drop(pending);
}
});
if no_wait {
write_ndjson_line(out, &serde_json::json!({ "type": "submitted" })).await?;
session
.shutdown()
.await
.context("shutting down acp session")?;
if let Some(exporter) = exporter {
exporter.shutdown();
}
return Ok(());
}
let outcome = prompt_wait(session, text, out).await;
if let Some(exporter) = exporter {
exporter.shutdown();
}
outcome
}
async fn prompt_wait<W>(
session: bitrouter_substrate::engine::Session,
text: &str,
out: &mut W,
) -> Result<()>
where
W: AsyncWrite + Unpin,
{
let mut updates = session.updates();
let response = {
let prompt_future = session.prompt(text);
tokio::pin!(prompt_future);
loop {
tokio::select! {
biased;
result = &mut prompt_future => {
let response = result.context("acp prompt failed")?;
drain_pending_updates(&mut updates, out).await?;
break response;
}
maybe_update = updates.next() => {
if let Some(update) = maybe_update {
write_ndjson_line(out, &update).await?;
}
}
}
}
};
write_ndjson_line(
out,
&ResultLine {
kind: "result",
stop_reason: response.stop_reason,
},
)
.await?;
session
.shutdown()
.await
.context("shutting down acp session")?;
Ok(())
}
pub async fn sessions<W>(out: &mut W) -> Result<()>
where
W: AsyncWrite + Unpin,
{
use bitrouter_substrate::record::{RecordStatus, RecordStore, now_unix};
let base = std::env::current_dir().context("resolving current directory")?;
let store = RecordStore::new(&base);
let mut records = store.list().await?;
if records.is_empty() {
out.write_all(b"no sessions recorded under .bitrouter/sessions\n")
.await
.context("writing output")?;
return Ok(());
}
records.sort_by_key(|r| std::cmp::Reverse(r.started_at));
let now = now_unix();
let mut buf = String::from("RECORD AGENT STATUS AGE WORKTREE\n");
for r in records {
let status = match r.status {
RecordStatus::Exited => "exited",
RecordStatus::Running if pid_alive(r.pid) => "running",
RecordStatus::Running => "dead",
};
let short_id: String = r.record_id.chars().take(8).collect();
let worktree = r
.worktree
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "-".to_string());
buf.push_str(&format!(
"{short_id:<9} {agent:<17} {status:<8} {age:<8} {worktree}\n",
agent = r.agent_id,
age = format_age(now.saturating_sub(r.started_at)),
));
}
out.write_all(buf.as_bytes())
.await
.context("writing output")
}
fn pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
{
let _ = pid;
true
}
}
fn format_age(secs: u64) -> String {
match secs {
0..=59 => format!("{secs}s"),
60..=3599 => format!("{}m", secs / 60),
3600..=86_399 => format!("{}h", secs / 3600),
_ => format!("{}d", secs / 86_400),
}
}
async fn attach_observability(
config: &Config,
agent_id: &str,
session: &bitrouter_substrate::engine::Session,
) -> Option<Arc<bitrouter_observe::otel::OtelExporter>> {
let exporter = crate::assemble::build_otel_exporter_standalone(config).await;
let recorder = exporter.as_ref().map(|exporter| {
Arc::new(bitrouter_observe::acp::AcpSpanRecorder::new(
exporter,
agent_id,
session.state().record_id.clone(),
))
});
if let Some(mut rx) = session.telemetry() {
let recorder = recorder.clone();
tokio::spawn(async move {
while let Some(record) = rx.recv().await {
if let Some(recorder) = &recorder {
recorder.turn_completed(&bitrouter_observe::acp::TurnRecord {
stop_reason: record.stop_reason.clone(),
latency: std::time::Duration::from_millis(record.latency_ms),
context_used: record.context.map(|c| c.used),
context_size: record.context.map(|c| c.size),
});
}
drain_telemetry_record(record);
}
});
}
if let Some(recorder) = recorder {
let mut updates = session.updates();
tokio::spawn(async move {
use bitrouter_substrate::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());
}
_ => {}
},
_ => {}
}
}
});
}
exporter
}
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"
);
}
pub fn launch_options(
worktree: Option<&str>,
rm_worktree: bool,
no_transcript: bool,
turn_timeout_secs: Option<u64>,
) -> LaunchOptions {
LaunchOptions {
worktree: worktree.map(|name| WorktreeSpec {
name: name.to_string(),
remove_on_shutdown: rm_worktree,
}),
transcript: !no_transcript,
turn_timeout: turn_timeout_secs.map(std::time::Duration::from_secs),
}
}
#[cfg(unix)]
fn socket_dir() -> std::path::PathBuf {
use std::path::PathBuf;
std::env::var_os("BITROUTER_HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(|home| PathBuf::from(home).join(".bitrouter"))
})
.unwrap_or_else(std::env::temp_dir)
.join("sock")
}
fn catalog_from_config(config: &Config) -> Result<ConfigAcpRoutingTable> {
ConfigAcpRoutingTable::from_configs(config.agents.iter().map(|(k, v)| (k.clone(), v.clone())))
.context("building acp routing table from config")
}
async fn drain_pending_updates<W>(
updates: &mut (impl futures::Stream<Item = SessionUpdateKind> + Unpin),
out: &mut W,
) -> Result<()>
where
W: AsyncWrite + Unpin,
{
loop {
let maybe = tokio::select! {
biased;
v = updates.next() => v,
_ = std::future::ready(()) => None,
};
match maybe {
Some(update) => write_ndjson_line(out, &update).await?,
None => break,
}
}
Ok(())
}