agent-relay 0.3.1

Agent-to-agent messaging for AI coding tools. Local or networked — run a relay server and let Claude talk to Gemini across the internet.
Documentation
//! Persistent daemon that polls for messages and spawns AI agents.
//!
//! - macOS: LaunchAgent plist (survives reboot)
//! - Linux: systemd user service (survives reboot)
//! - Fallback: background process with nohup

use std::path::PathBuf;

const LABEL: &str = "com.naridon.agent-relay-daemon";
#[cfg(target_os = "linux")]
const SERVICE_NAME: &str = "agent-relay-daemon";

#[derive(Clone)]
pub struct DaemonConfig {
    pub server: String,
    pub session: String,
    pub agent: String,
    pub interval: u64,
    pub exec_cmd: String,
    pub daily_cap: u32,
    pub cooldown: u64,
}

impl DaemonConfig {
    pub fn default_exec(server: &str, session: &str) -> String {
        format!(
            "claude -p \"You have new messages on the agent-relay. \
             Read them with: agent-relay -S {} inbox --session {} \
             Then respond with: agent-relay -S {} send -f {} -a claude \\\"your reply\\\" \
             Be concise. Read all messages, respond to each, then exit.\"",
            server, session, server, session
        )
    }
}

/// Install the daemon as a persistent background service.
pub fn install(config: &DaemonConfig) -> Result<String, String> {
    let binary = std::env::current_exe().map_err(|e| format!("Cannot find binary path: {}", e))?;

    #[cfg(target_os = "macos")]
    {
        install_launchagent(&binary, config)
    }

    #[cfg(target_os = "linux")]
    {
        install_systemd(&binary, config)
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        Err(
            "Daemon install not supported on this platform. Use 'agent-relay watch' manually."
                .to_string(),
        )
    }
}

/// Uninstall the daemon.
pub fn uninstall() -> Result<String, String> {
    #[cfg(target_os = "macos")]
    {
        uninstall_launchagent()
    }

    #[cfg(target_os = "linux")]
    {
        uninstall_systemd()
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        Err("Daemon uninstall not supported on this platform.".to_string())
    }
}

/// Check daemon status.
pub fn status() -> Result<String, String> {
    #[cfg(target_os = "macos")]
    {
        status_launchagent()
    }

    #[cfg(target_os = "linux")]
    {
        status_systemd()
    }

    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        Err("Daemon status not supported on this platform.".to_string())
    }
}

// ── macOS LaunchAgent ──

#[cfg(target_os = "macos")]
fn plist_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_default();
    PathBuf::from(home)
        .join("Library/LaunchAgents")
        .join(format!("{}.plist", LABEL))
}

#[cfg(target_os = "macos")]
fn install_launchagent(binary: &std::path::Path, config: &DaemonConfig) -> Result<String, String> {
    let plist_dir = plist_path().parent().unwrap().to_path_buf();
    let _ = std::fs::create_dir_all(&plist_dir);

    let log_dir = PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".agent-relay");
    let _ = std::fs::create_dir_all(&log_dir);

    let plist = format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>{label}</string>
    <key>ProgramArguments</key>
    <array>
        <string>{binary}</string>
        <string>--server</string>
        <string>{server}</string>
        <string>watch</string>
        <string>--session</string>
        <string>{session}</string>
        <string>--interval</string>
        <string>{interval}</string>
        <string>--exec</string>
        <string>{exec_cmd}</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>{log_dir}/daemon.log</string>
    <key>StandardErrorPath</key>
    <string>{log_dir}/daemon.err</string>
    <key>ThrottleInterval</key>
    <integer>{cooldown}</integer>
</dict>
</plist>"#,
        label = LABEL,
        binary = binary.display(),
        server = config.server,
        session = config.session,
        interval = config.interval,
        exec_cmd = config.exec_cmd.replace('"', "&quot;"),
        log_dir = log_dir.display(),
        cooldown = config.cooldown,
    );

    let path = plist_path();
    std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {}", e))?;

    // Unload if already loaded, then load
    let _ = std::process::Command::new("launchctl")
        .args(["unload", &path.to_string_lossy()])
        .output();

    let output = std::process::Command::new("launchctl")
        .args(["load", &path.to_string_lossy()])
        .output()
        .map_err(|e| format!("launchctl load failed: {}", e))?;

    if !output.status.success() {
        return Err(format!(
            "launchctl load failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    Ok(format!(
        "Daemon installed at {}\nLogs: {}/daemon.log\nPolling {} every {}s",
        path.display(),
        log_dir.display(),
        config.server,
        config.interval
    ))
}

#[cfg(target_os = "macos")]
fn uninstall_launchagent() -> Result<String, String> {
    let path = plist_path();
    if path.exists() {
        let _ = std::process::Command::new("launchctl")
            .args(["unload", &path.to_string_lossy()])
            .output();
        let _ = std::fs::remove_file(&path);
        Ok(format!("Daemon uninstalled ({})", path.display()))
    } else {
        Ok("No daemon installed.".to_string())
    }
}

#[cfg(target_os = "macos")]
fn status_launchagent() -> Result<String, String> {
    let output = std::process::Command::new("launchctl")
        .args(["list", LABEL])
        .output()
        .map_err(|e| format!("launchctl list failed: {}", e))?;

    if output.status.success() {
        let stdout = String::from_utf8_lossy(&output.stdout);
        Ok(format!("Daemon is running:\n{}", stdout))
    } else {
        Ok("Daemon is not running.".to_string())
    }
}

// ── Linux systemd ──

#[cfg(target_os = "linux")]
fn service_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_default();
    PathBuf::from(home)
        .join(".config/systemd/user")
        .join(format!("{}.service", SERVICE_NAME))
}

#[cfg(target_os = "linux")]
fn install_systemd(binary: &PathBuf, config: &DaemonConfig) -> Result<String, String> {
    let svc_dir = service_path().parent().unwrap().to_path_buf();
    let _ = std::fs::create_dir_all(&svc_dir);

    let unit = format!(
        r#"[Unit]
Description=agent-relay daemon — auto-respond to AI agent messages
After=network.target

[Service]
Type=simple
ExecStart={binary} --server {server} watch --session {session} --interval {interval} --exec "{exec_cmd}"
Restart=always
RestartSec={cooldown}

[Install]
WantedBy=default.target
"#,
        binary = binary.display(),
        server = config.server,
        session = config.session,
        interval = config.interval,
        exec_cmd = config.exec_cmd.replace('"', "\\\""),
        cooldown = config.cooldown,
    );

    let path = service_path();
    std::fs::write(&path, unit).map_err(|e| format!("Failed to write service: {}", e))?;

    let _ = std::process::Command::new("systemctl")
        .args(["--user", "daemon-reload"])
        .output();

    let output = std::process::Command::new("systemctl")
        .args(["--user", "enable", "--now", SERVICE_NAME])
        .output()
        .map_err(|e| format!("systemctl enable failed: {}", e))?;

    if !output.status.success() {
        return Err(format!(
            "systemctl enable failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    Ok(format!(
        "Daemon installed at {}\nPolling {} every {}s",
        path.display(),
        config.server,
        config.interval
    ))
}

#[cfg(target_os = "linux")]
fn uninstall_systemd() -> Result<String, String> {
    let _ = std::process::Command::new("systemctl")
        .args(["--user", "stop", SERVICE_NAME])
        .output();
    let _ = std::process::Command::new("systemctl")
        .args(["--user", "disable", SERVICE_NAME])
        .output();

    let path = service_path();
    if path.exists() {
        let _ = std::fs::remove_file(&path);
        let _ = std::process::Command::new("systemctl")
            .args(["--user", "daemon-reload"])
            .output();
        Ok(format!("Daemon uninstalled ({})", path.display()))
    } else {
        Ok("No daemon installed.".to_string())
    }
}

#[cfg(target_os = "linux")]
fn status_systemd() -> Result<String, String> {
    let output = std::process::Command::new("systemctl")
        .args(["--user", "status", SERVICE_NAME])
        .output()
        .map_err(|e| format!("systemctl status failed: {}", e))?;

    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}