Skip to main content

agent_relay/
daemon.rs

1//! Persistent daemon that polls for messages and spawns AI agents.
2//!
3//! - macOS: LaunchAgent plist (survives reboot)
4//! - Linux: systemd user service (survives reboot)
5//! - Fallback: background process with nohup
6
7use std::path::PathBuf;
8
9const LABEL: &str = "com.naridon.agent-relay-daemon";
10#[cfg(target_os = "linux")]
11const SERVICE_NAME: &str = "agent-relay-daemon";
12
13#[derive(Clone)]
14pub struct DaemonConfig {
15    pub server: String,
16    pub session: String,
17    pub agent: String,
18    pub interval: u64,
19    pub exec_cmd: String,
20    pub daily_cap: u32,
21    pub cooldown: u64,
22}
23
24impl DaemonConfig {
25    pub fn default_exec(server: &str, session: &str) -> String {
26        // Find claude binary — LaunchAgent/systemd PATH may not include ~/.local/bin
27        let claude_bin = find_claude_binary();
28        let relay_bin = std::env::current_exe()
29            .map(|p| p.display().to_string())
30            .unwrap_or_else(|_| "agent-relay".to_string());
31
32        format!(
33            "{claude} --dangerously-skip-permissions -p \"\
34             You have new messages on the agent-relay. \
35             Read them with: {relay} -S {server} inbox --session {session} \
36             Then respond with: {relay} -S {server} send -f {session} -a claude \\\"your reply\\\" \
37             Be concise. Read all messages, respond to each, then exit.\"",
38            claude = claude_bin,
39            relay = relay_bin,
40            server = server,
41            session = session,
42        )
43    }
44}
45
46/// Search common locations for the `claude` binary.
47fn find_claude_binary() -> String {
48    let candidates = [
49        // Check PATH first
50        which_claude(),
51        // Common install locations
52        Some(format!(
53            "{}/.local/bin/claude",
54            std::env::var("HOME").unwrap_or_default()
55        )),
56        Some(format!(
57            "{}/.cargo/bin/claude",
58            std::env::var("HOME").unwrap_or_default()
59        )),
60        Some("/usr/local/bin/claude".to_string()),
61    ];
62
63    for candidate in candidates.into_iter().flatten() {
64        if std::path::Path::new(&candidate).exists() {
65            return candidate;
66        }
67    }
68
69    // Fallback — hope it's in PATH at runtime
70    "claude".to_string()
71}
72
73fn which_claude() -> Option<String> {
74    std::process::Command::new("which")
75        .arg("claude")
76        .output()
77        .ok()
78        .filter(|o| o.status.success())
79        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
80}
81
82/// Install the daemon as a persistent background service.
83pub fn install(config: &DaemonConfig) -> Result<String, String> {
84    let binary = std::env::current_exe().map_err(|e| format!("Cannot find binary path: {}", e))?;
85
86    #[cfg(target_os = "macos")]
87    {
88        install_launchagent(&binary, config)
89    }
90
91    #[cfg(target_os = "linux")]
92    {
93        install_systemd(&binary, config)
94    }
95
96    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
97    {
98        Err(
99            "Daemon install not supported on this platform. Use 'agent-relay watch' manually."
100                .to_string(),
101        )
102    }
103}
104
105/// Uninstall the daemon.
106pub fn uninstall() -> Result<String, String> {
107    #[cfg(target_os = "macos")]
108    {
109        uninstall_launchagent()
110    }
111
112    #[cfg(target_os = "linux")]
113    {
114        uninstall_systemd()
115    }
116
117    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
118    {
119        Err("Daemon uninstall not supported on this platform.".to_string())
120    }
121}
122
123/// Check daemon status.
124pub fn status() -> Result<String, String> {
125    #[cfg(target_os = "macos")]
126    {
127        status_launchagent()
128    }
129
130    #[cfg(target_os = "linux")]
131    {
132        status_systemd()
133    }
134
135    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
136    {
137        Err("Daemon status not supported on this platform.".to_string())
138    }
139}
140
141// ── macOS LaunchAgent ──
142
143#[cfg(target_os = "macos")]
144fn plist_path() -> PathBuf {
145    let home = std::env::var("HOME").unwrap_or_default();
146    PathBuf::from(home)
147        .join("Library/LaunchAgents")
148        .join(format!("{}.plist", LABEL))
149}
150
151#[cfg(target_os = "macos")]
152fn install_launchagent(binary: &std::path::Path, config: &DaemonConfig) -> Result<String, String> {
153    let plist_dir = plist_path().parent().unwrap().to_path_buf();
154    let _ = std::fs::create_dir_all(&plist_dir);
155
156    let log_dir = PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".agent-relay");
157    let _ = std::fs::create_dir_all(&log_dir);
158
159    let plist = format!(
160        r#"<?xml version="1.0" encoding="UTF-8"?>
161<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
162<plist version="1.0">
163<dict>
164    <key>Label</key>
165    <string>{label}</string>
166    <key>ProgramArguments</key>
167    <array>
168        <string>{binary}</string>
169        <string>--server</string>
170        <string>{server}</string>
171        <string>watch</string>
172        <string>--session</string>
173        <string>{session}</string>
174        <string>--interval</string>
175        <string>{interval}</string>
176        <string>--exec</string>
177        <string>{exec_cmd}</string>
178    </array>
179    <key>RunAtLoad</key>
180    <true/>
181    <key>KeepAlive</key>
182    <true/>
183    <key>StandardOutPath</key>
184    <string>{log_dir}/daemon.log</string>
185    <key>StandardErrorPath</key>
186    <string>{log_dir}/daemon.err</string>
187    <key>ThrottleInterval</key>
188    <integer>{cooldown}</integer>
189</dict>
190</plist>"#,
191        label = LABEL,
192        binary = binary.display(),
193        server = config.server,
194        session = config.session,
195        interval = config.interval,
196        exec_cmd = config.exec_cmd.replace('"', "&quot;"),
197        log_dir = log_dir.display(),
198        cooldown = config.cooldown,
199    );
200
201    let path = plist_path();
202    std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {}", e))?;
203
204    // Unload if already loaded, then load
205    let _ = std::process::Command::new("launchctl")
206        .args(["unload", &path.to_string_lossy()])
207        .output();
208
209    let output = std::process::Command::new("launchctl")
210        .args(["load", &path.to_string_lossy()])
211        .output()
212        .map_err(|e| format!("launchctl load failed: {}", e))?;
213
214    if !output.status.success() {
215        return Err(format!(
216            "launchctl load failed: {}",
217            String::from_utf8_lossy(&output.stderr)
218        ));
219    }
220
221    Ok(format!(
222        "Daemon installed at {}\nLogs: {}/daemon.log\nPolling {} every {}s",
223        path.display(),
224        log_dir.display(),
225        config.server,
226        config.interval
227    ))
228}
229
230#[cfg(target_os = "macos")]
231fn uninstall_launchagent() -> Result<String, String> {
232    let path = plist_path();
233    if path.exists() {
234        let _ = std::process::Command::new("launchctl")
235            .args(["unload", &path.to_string_lossy()])
236            .output();
237        let _ = std::fs::remove_file(&path);
238        Ok(format!("Daemon uninstalled ({})", path.display()))
239    } else {
240        Ok("No daemon installed.".to_string())
241    }
242}
243
244#[cfg(target_os = "macos")]
245fn status_launchagent() -> Result<String, String> {
246    let output = std::process::Command::new("launchctl")
247        .args(["list", LABEL])
248        .output()
249        .map_err(|e| format!("launchctl list failed: {}", e))?;
250
251    if output.status.success() {
252        let stdout = String::from_utf8_lossy(&output.stdout);
253        Ok(format!("Daemon is running:\n{}", stdout))
254    } else {
255        Ok("Daemon is not running.".to_string())
256    }
257}
258
259// ── Linux systemd ──
260
261#[cfg(target_os = "linux")]
262fn service_path() -> PathBuf {
263    let home = std::env::var("HOME").unwrap_or_default();
264    PathBuf::from(home)
265        .join(".config/systemd/user")
266        .join(format!("{}.service", SERVICE_NAME))
267}
268
269#[cfg(target_os = "linux")]
270fn install_systemd(binary: &PathBuf, config: &DaemonConfig) -> Result<String, String> {
271    let svc_dir = service_path().parent().unwrap().to_path_buf();
272    let _ = std::fs::create_dir_all(&svc_dir);
273
274    let unit = format!(
275        r#"[Unit]
276Description=agent-relay daemon — auto-respond to AI agent messages
277After=network.target
278
279[Service]
280Type=simple
281ExecStart={binary} --server {server} watch --session {session} --interval {interval} --exec "{exec_cmd}"
282Restart=always
283RestartSec={cooldown}
284
285[Install]
286WantedBy=default.target
287"#,
288        binary = binary.display(),
289        server = config.server,
290        session = config.session,
291        interval = config.interval,
292        exec_cmd = config.exec_cmd.replace('"', "\\\""),
293        cooldown = config.cooldown,
294    );
295
296    let path = service_path();
297    std::fs::write(&path, unit).map_err(|e| format!("Failed to write service: {}", e))?;
298
299    let _ = std::process::Command::new("systemctl")
300        .args(["--user", "daemon-reload"])
301        .output();
302
303    let output = std::process::Command::new("systemctl")
304        .args(["--user", "enable", "--now", SERVICE_NAME])
305        .output()
306        .map_err(|e| format!("systemctl enable failed: {}", e))?;
307
308    if !output.status.success() {
309        return Err(format!(
310            "systemctl enable failed: {}",
311            String::from_utf8_lossy(&output.stderr)
312        ));
313    }
314
315    Ok(format!(
316        "Daemon installed at {}\nPolling {} every {}s",
317        path.display(),
318        config.server,
319        config.interval
320    ))
321}
322
323#[cfg(target_os = "linux")]
324fn uninstall_systemd() -> Result<String, String> {
325    let _ = std::process::Command::new("systemctl")
326        .args(["--user", "stop", SERVICE_NAME])
327        .output();
328    let _ = std::process::Command::new("systemctl")
329        .args(["--user", "disable", SERVICE_NAME])
330        .output();
331
332    let path = service_path();
333    if path.exists() {
334        let _ = std::fs::remove_file(&path);
335        let _ = std::process::Command::new("systemctl")
336            .args(["--user", "daemon-reload"])
337            .output();
338        Ok(format!("Daemon uninstalled ({})", path.display()))
339    } else {
340        Ok("No daemon installed.".to_string())
341    }
342}
343
344#[cfg(target_os = "linux")]
345fn status_systemd() -> Result<String, String> {
346    let output = std::process::Command::new("systemctl")
347        .args(["--user", "status", SERVICE_NAME])
348        .output()
349        .map_err(|e| format!("systemctl status failed: {}", e))?;
350
351    Ok(String::from_utf8_lossy(&output.stdout).to_string())
352}