rho-coding-agent 0.21.1

A lightweight agent harness inspired by Pi
Documentation
use serde_json::json;
use std::{
    env,
    path::PathBuf,
    time::{Duration, SystemTime, UNIX_EPOCH},
};

const REQUEST_TIMEOUT: Duration = Duration::from_millis(500);
const SOURCE: &str = "herdr:rho";
const AGENT: &str = "rho";

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HerdrReporter {
    config: Option<HerdrConfig>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct HerdrConfig {
    socket_path: PathBuf,
    pane_id: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HerdrState {
    Idle,
    Working,
    Blocked,
}

impl HerdrState {
    fn as_str(self) -> &'static str {
        match self {
            Self::Idle => "idle",
            Self::Working => "working",
            Self::Blocked => "blocked",
        }
    }
}

impl HerdrReporter {
    pub fn from_env() -> Self {
        Self::from_env_vars(|key| env::var(key).ok())
    }

    fn from_env_vars(mut get_var: impl FnMut(&str) -> Option<String>) -> Self {
        let enabled = platform_supported() && get_var("HERDR_ENV").as_deref() == Some("1");
        let socket_path = get_var("HERDR_SOCKET_PATH").filter(|value| !value.is_empty());
        let pane_id = get_var("HERDR_PANE_ID").filter(|value| !value.is_empty());
        let config = enabled
            .then_some((socket_path, pane_id))
            .and_then(|(socket_path, pane_id)| Some((socket_path?, pane_id?)))
            .map(|(socket_path, pane_id)| HerdrConfig {
                socket_path: PathBuf::from(socket_path),
                pane_id,
            });
        Self { config }
    }

    #[cfg(test)]
    pub fn is_enabled(&self) -> bool {
        self.config.is_some()
    }

    pub async fn report_state(
        &self,
        state: HerdrState,
        message: Option<&str>,
        session_id: Option<&str>,
    ) {
        let Some(config) = &self.config else {
            return;
        };

        let mut params = json!({
            "pane_id": config.pane_id,
            "source": SOURCE,
            "agent": AGENT,
            "state": state.as_str(),
        });
        if let Some(message) = message {
            params["message"] = json!(message);
        }
        if let Some(session_id) = session_id {
            params["agent_session_id"] = json!(session_id);
        }

        self.send(json_rpc_request("pane.report_agent", params))
            .await;
    }

    pub async fn report_session(&self, session_id: Option<&str>) {
        let (Some(config), Some(session_id)) = (&self.config, session_id) else {
            return;
        };

        self.send(json_rpc_request(
            "pane.report_agent_session",
            json!({
                "pane_id": config.pane_id,
                "source": SOURCE,
                "agent": AGENT,
                "agent_session_id": session_id,
            }),
        ))
        .await;
    }

    pub async fn release(&self) {
        let Some(config) = &self.config else {
            return;
        };

        self.send(json_rpc_request(
            "pane.release_agent",
            json!({
                "pane_id": config.pane_id,
                "source": SOURCE,
                "agent": AGENT,
            }),
        ))
        .await;
    }

    async fn send(&self, request: serde_json::Value) {
        let Some(config) = &self.config else {
            return;
        };
        let socket_path = config.socket_path.clone();
        let payload = match serde_json::to_vec(&request) {
            Ok(mut payload) => {
                payload.push(b'\n');
                payload
            }
            Err(_) => return,
        };

        let _ = tokio::time::timeout(REQUEST_TIMEOUT, send_payload(socket_path, payload)).await;
    }
}

fn json_rpc_request(method: &str, params: serde_json::Value) -> serde_json::Value {
    json!({
        "id": format!("{SOURCE}:{}", request_id_suffix()),
        "method": method,
        "params": params,
    })
}

fn request_id_suffix() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_micros())
        .unwrap_or_default()
}

fn platform_supported() -> bool {
    cfg!(unix)
}

#[cfg(unix)]
async fn send_payload(socket_path: PathBuf, payload: Vec<u8>) -> std::io::Result<()> {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::UnixStream;

    let mut stream = UnixStream::connect(socket_path).await?;
    stream.write_all(&payload).await?;
    let _ = stream.shutdown().await;
    let mut buffer = [0_u8; 256];
    let _ = stream.read(&mut buffer).await;
    Ok(())
}

#[cfg(not(unix))]
async fn send_payload(_socket_path: PathBuf, _payload: Vec<u8>) -> std::io::Result<()> {
    Ok(())
}

#[cfg(test)]
#[path = "herdr_tests.rs"]
mod tests;