rho-coding-agent 0.29.1

A lightweight agent harness inspired by Pi
Documentation
use std::{
    path::{Path, PathBuf},
    sync::LazyLock,
    time::Duration,
};

use crate::tool::ToolResult;
use serde_json::json;
use tokio::{io::AsyncWriteExt, process::Command};

const REWRITE_TIMEOUT: Duration = Duration::from_secs(2);
static DISCOVER_LOG_NAME: LazyLock<String> =
    LazyLock::new(|| format!("rho-{}-{}.jsonl", std::process::id(), uuid::Uuid::new_v4()));

pub fn is_available() -> bool {
    let Ok(output) = std::process::Command::new("rtk").arg("--version").output() else {
        return false;
    };
    output.status.success() && supports_rewrite(&String::from_utf8_lossy(&output.stdout))
}

fn supports_rewrite(version: &str) -> bool {
    let Some((major, minor, _patch)) = parse_version(version) else {
        return true;
    };
    major > 0 || minor >= 23
}

fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
    let version = version
        .trim()
        .strip_prefix("rtk ")
        .unwrap_or(version.trim());
    let mut parts = version.split('.');
    Some((
        parts.next()?.parse().ok()?,
        parts.next()?.parse().ok()?,
        parts.next()?.split_whitespace().next()?.parse().ok()?,
    ))
}

pub async fn rewrite(command: &str) -> Option<String> {
    let command = command.trim();
    if command.is_empty()
        || command.starts_with("rtk ")
        || std::env::var("RTK_DISABLED").ok().as_deref() == Some("1")
    {
        return None;
    }

    let output = tokio::time::timeout(
        REWRITE_TIMEOUT,
        Command::new("rtk").arg("rewrite").arg(command).output(),
    )
    .await
    .ok()?
    .ok()?;

    let code = output.status.code()?;
    if code != 0 && code != 3 {
        return None;
    }

    let rewritten = String::from_utf8(output.stdout).ok()?;
    let rewritten = rewritten.trim();
    (!rewritten.is_empty() && rewritten != command).then(|| rewritten.to_string())
}

pub(super) async fn log_execution(cwd: &Path, command: &str, result: &ToolResult) {
    let Some(projects_dir) = discover_projects_dir() else {
        return;
    };
    let _ = log_execution_in_projects_dir(&projects_dir, cwd, command, result).await;
}

fn discover_projects_dir() -> Option<PathBuf> {
    let claude_dir = std::env::var_os("CLAUDE_CONFIG_DIR")
        .filter(|path| !path.is_empty())
        .map(PathBuf::from)
        .or_else(|| crate::paths::home_dir().map(|home| home.join(".claude")))?;
    Some(claude_dir.join("projects"))
}

async fn log_execution_in_projects_dir(
    projects_dir: &Path,
    cwd: &Path,
    command: &str,
    result: &ToolResult,
) -> std::io::Result<()> {
    let project_dir = projects_dir
        .join(encode_project_path(cwd))
        .join("rho-sessions");
    tokio::fs::create_dir_all(&project_dir).await?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        tokio::fs::set_permissions(&project_dir, std::fs::Permissions::from_mode(0o700)).await?;
    }

    let path = project_dir.join(DISCOVER_LOG_NAME.as_str());
    let tool_use_id = format!("rho-{}", uuid::Uuid::new_v4());
    let assistant = json!({
        "type": "assistant",
        "message": {
            "role": "assistant",
            "content": [{
                "type": "tool_use",
                "id": tool_use_id,
                "name": "Bash",
                "input": { "command": command }
            }]
        }
    });
    let output_size = " ".repeat(result.content.len());
    let result_entry = json!({
        "type": "user",
        "message": {
            "role": "user",
            "content": [{
                "type": "tool_result",
                "tool_use_id": tool_use_id,
                "content": output_size,
                "is_error": !result.ok
            }]
        }
    });
    let mut entry = serde_json::to_vec(&assistant).map_err(std::io::Error::other)?;
    entry.push(b'\n');
    entry.extend(serde_json::to_vec(&result_entry).map_err(std::io::Error::other)?);
    entry.push(b'\n');

    let mut options = tokio::fs::OpenOptions::new();
    options.create(true).append(true);
    #[cfg(unix)]
    options.mode(0o600);
    let mut file = options.open(path).await?;
    file.write_all(&entry).await?;
    file.flush().await
}

fn encode_project_path(path: &Path) -> String {
    const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\', ':', ' ', '[', ']'];

    path.to_string_lossy()
        .chars()
        .map(|ch| {
            if !ch.is_ascii() || SANITIZED_CHARS.contains(&ch) {
                '-'
            } else {
                ch
            }
        })
        .collect()
}

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