use std::{
fs::{File, OpenOptions},
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
};
use serde::{Deserialize, Serialize};
pub const RESULT_FILE_NAME: &str = "result.json";
pub const LOG_FILE_NAME: &str = "log.txt";
pub const ATTACHMENT_FILE_NAME: &str = "events.jsonl";
fn status_write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunState {
#[default]
Starting,
Running,
Ok,
Error,
Stopped,
}
impl RunState {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Ok | Self::Error | Self::Stopped)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Starting => "starting",
Self::Running => "running",
Self::Ok => "ok",
Self::Error => "error",
Self::Stopped => "stopped",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct RunStatus {
pub state: RunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub turns: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost_usd: Option<f64>,
}
pub fn usd_to_micros(usd: f64) -> u64 {
if !usd.is_finite() || usd <= 0.0 {
return 0;
}
let micros = (usd * 1_000_000.0).round();
if micros >= u64::MAX as f64 {
u64::MAX
} else {
micros as u64
}
}
pub fn write_status(path: &Path, status: &RunStatus) -> std::io::Result<()> {
write_status_inner(path, status, false)
}
pub fn initialize_status(path: &Path, status: &RunStatus) -> std::io::Result<()> {
write_status_inner(path, status, true)
}
fn write_status_inner(path: &Path, status: &RunStatus, force: bool) -> std::io::Result<()> {
let _guard = status_write_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !force && !status.state.is_terminal() {
if let Some(existing) = read_status(path) {
if existing.state.is_terminal() {
return Ok(());
}
}
}
#[cfg(test)]
status_write_hooks::run_after_read(path, status);
let contents = serde_json::to_vec_pretty(status)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
crate::config_writer::write_bytes_atomically(path, &contents)
}
pub fn read_status(path: &Path) -> Option<RunStatus> {
let contents = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
pub fn directory(id: &str) -> anyhow::Result<PathBuf> {
let id = normalize_id(id)?;
Ok(crate::paths::rho_dir()?.join("subagents").join(id))
}
pub fn normalize_id(id: &str) -> anyhow::Result<String> {
if id.len() != 6 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
anyhow::bail!("invalid subagent id '{id}': expected 6 hexadecimal characters");
}
Ok(id.to_ascii_lowercase())
}
pub(crate) fn create_private_file(path: &Path) -> std::io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)
}
pub(crate) fn secure_directory(path: &Path) -> std::io::Result<()> {
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{} is not a trusted directory", path.display()),
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
pub(crate) fn create_private_directory(path: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(path)
}
#[cfg(test)]
pub(crate) mod status_write_hooks {
use super::*;
use std::sync::Mutex;
type AfterReadHook = Box<dyn Fn(&Path, &RunStatus) + Send>;
static AFTER_READ: Mutex<Option<AfterReadHook>> = Mutex::new(None);
pub(crate) fn set_after_read(hook: impl Fn(&Path, &RunStatus) + Send + 'static) {
*AFTER_READ
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Box::new(hook));
}
pub(crate) fn clear() {
*AFTER_READ
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
pub(crate) fn run_after_read(path: &Path, status: &RunStatus) {
let hook = AFTER_READ
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(hook) = hook.as_ref() {
hook(path, status);
}
}
}
#[cfg(test)]
#[path = "subagent_tests.rs"]
mod tests;