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        format!(
27            "claude -p \"You have new messages on the agent-relay. \
28             Read them with: agent-relay -S {} inbox --session {} \
29             Then respond with: agent-relay -S {} send -f {} -a claude \\\"your reply\\\" \
30             Be concise. Read all messages, respond to each, then exit.\"",
31            server, session, server, session
32        )
33    }
34}
35
36/// Install the daemon as a persistent background service.
37pub fn install(config: &DaemonConfig) -> Result<String, String> {
38    let binary = std::env::current_exe().map_err(|e| format!("Cannot find binary path: {}", e))?;
39
40    #[cfg(target_os = "macos")]
41    {
42        install_launchagent(&binary, config)
43    }
44
45    #[cfg(target_os = "linux")]
46    {
47        install_systemd(&binary, config)
48    }
49
50    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
51    {
52        Err(
53            "Daemon install not supported on this platform. Use 'agent-relay watch' manually."
54                .to_string(),
55        )
56    }
57}
58
59/// Uninstall the daemon.
60pub fn uninstall() -> Result<String, String> {
61    #[cfg(target_os = "macos")]
62    {
63        uninstall_launchagent()
64    }
65
66    #[cfg(target_os = "linux")]
67    {
68        uninstall_systemd()
69    }
70
71    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
72    {
73        Err("Daemon uninstall not supported on this platform.".to_string())
74    }
75}
76
77/// Check daemon status.
78pub fn status() -> Result<String, String> {
79    #[cfg(target_os = "macos")]
80    {
81        status_launchagent()
82    }
83
84    #[cfg(target_os = "linux")]
85    {
86        status_systemd()
87    }
88
89    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
90    {
91        Err("Daemon status not supported on this platform.".to_string())
92    }
93}
94
95// ── macOS LaunchAgent ──
96
97#[cfg(target_os = "macos")]
98fn plist_path() -> PathBuf {
99    let home = std::env::var("HOME").unwrap_or_default();
100    PathBuf::from(home)
101        .join("Library/LaunchAgents")
102        .join(format!("{}.plist", LABEL))
103}
104
105#[cfg(target_os = "macos")]
106fn install_launchagent(binary: &std::path::Path, config: &DaemonConfig) -> Result<String, String> {
107    let plist_dir = plist_path().parent().unwrap().to_path_buf();
108    let _ = std::fs::create_dir_all(&plist_dir);
109
110    let log_dir = PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".agent-relay");
111    let _ = std::fs::create_dir_all(&log_dir);
112
113    let plist = format!(
114        r#"<?xml version="1.0" encoding="UTF-8"?>
115<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
116<plist version="1.0">
117<dict>
118    <key>Label</key>
119    <string>{label}</string>
120    <key>ProgramArguments</key>
121    <array>
122        <string>{binary}</string>
123        <string>--server</string>
124        <string>{server}</string>
125        <string>watch</string>
126        <string>--session</string>
127        <string>{session}</string>
128        <string>--interval</string>
129        <string>{interval}</string>
130        <string>--exec</string>
131        <string>{exec_cmd}</string>
132    </array>
133    <key>RunAtLoad</key>
134    <true/>
135    <key>KeepAlive</key>
136    <true/>
137    <key>StandardOutPath</key>
138    <string>{log_dir}/daemon.log</string>
139    <key>StandardErrorPath</key>
140    <string>{log_dir}/daemon.err</string>
141    <key>ThrottleInterval</key>
142    <integer>{cooldown}</integer>
143</dict>
144</plist>"#,
145        label = LABEL,
146        binary = binary.display(),
147        server = config.server,
148        session = config.session,
149        interval = config.interval,
150        exec_cmd = config.exec_cmd.replace('"', "&quot;"),
151        log_dir = log_dir.display(),
152        cooldown = config.cooldown,
153    );
154
155    let path = plist_path();
156    std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {}", e))?;
157
158    // Unload if already loaded, then load
159    let _ = std::process::Command::new("launchctl")
160        .args(["unload", &path.to_string_lossy()])
161        .output();
162
163    let output = std::process::Command::new("launchctl")
164        .args(["load", &path.to_string_lossy()])
165        .output()
166        .map_err(|e| format!("launchctl load failed: {}", e))?;
167
168    if !output.status.success() {
169        return Err(format!(
170            "launchctl load failed: {}",
171            String::from_utf8_lossy(&output.stderr)
172        ));
173    }
174
175    Ok(format!(
176        "Daemon installed at {}\nLogs: {}/daemon.log\nPolling {} every {}s",
177        path.display(),
178        log_dir.display(),
179        config.server,
180        config.interval
181    ))
182}
183
184#[cfg(target_os = "macos")]
185fn uninstall_launchagent() -> Result<String, String> {
186    let path = plist_path();
187    if path.exists() {
188        let _ = std::process::Command::new("launchctl")
189            .args(["unload", &path.to_string_lossy()])
190            .output();
191        let _ = std::fs::remove_file(&path);
192        Ok(format!("Daemon uninstalled ({})", path.display()))
193    } else {
194        Ok("No daemon installed.".to_string())
195    }
196}
197
198#[cfg(target_os = "macos")]
199fn status_launchagent() -> Result<String, String> {
200    let output = std::process::Command::new("launchctl")
201        .args(["list", LABEL])
202        .output()
203        .map_err(|e| format!("launchctl list failed: {}", e))?;
204
205    if output.status.success() {
206        let stdout = String::from_utf8_lossy(&output.stdout);
207        Ok(format!("Daemon is running:\n{}", stdout))
208    } else {
209        Ok("Daemon is not running.".to_string())
210    }
211}
212
213// ── Linux systemd ──
214
215#[cfg(target_os = "linux")]
216fn service_path() -> PathBuf {
217    let home = std::env::var("HOME").unwrap_or_default();
218    PathBuf::from(home)
219        .join(".config/systemd/user")
220        .join(format!("{}.service", SERVICE_NAME))
221}
222
223#[cfg(target_os = "linux")]
224fn install_systemd(binary: &PathBuf, config: &DaemonConfig) -> Result<String, String> {
225    let svc_dir = service_path().parent().unwrap().to_path_buf();
226    let _ = std::fs::create_dir_all(&svc_dir);
227
228    let unit = format!(
229        r#"[Unit]
230Description=agent-relay daemon — auto-respond to AI agent messages
231After=network.target
232
233[Service]
234Type=simple
235ExecStart={binary} --server {server} watch --session {session} --interval {interval} --exec "{exec_cmd}"
236Restart=always
237RestartSec={cooldown}
238
239[Install]
240WantedBy=default.target
241"#,
242        binary = binary.display(),
243        server = config.server,
244        session = config.session,
245        interval = config.interval,
246        exec_cmd = config.exec_cmd.replace('"', "\\\""),
247        cooldown = config.cooldown,
248    );
249
250    let path = service_path();
251    std::fs::write(&path, unit).map_err(|e| format!("Failed to write service: {}", e))?;
252
253    let _ = std::process::Command::new("systemctl")
254        .args(["--user", "daemon-reload"])
255        .output();
256
257    let output = std::process::Command::new("systemctl")
258        .args(["--user", "enable", "--now", SERVICE_NAME])
259        .output()
260        .map_err(|e| format!("systemctl enable failed: {}", e))?;
261
262    if !output.status.success() {
263        return Err(format!(
264            "systemctl enable failed: {}",
265            String::from_utf8_lossy(&output.stderr)
266        ));
267    }
268
269    Ok(format!(
270        "Daemon installed at {}\nPolling {} every {}s",
271        path.display(),
272        config.server,
273        config.interval
274    ))
275}
276
277#[cfg(target_os = "linux")]
278fn uninstall_systemd() -> Result<String, String> {
279    let _ = std::process::Command::new("systemctl")
280        .args(["--user", "stop", SERVICE_NAME])
281        .output();
282    let _ = std::process::Command::new("systemctl")
283        .args(["--user", "disable", SERVICE_NAME])
284        .output();
285
286    let path = service_path();
287    if path.exists() {
288        let _ = std::fs::remove_file(&path);
289        let _ = std::process::Command::new("systemctl")
290            .args(["--user", "daemon-reload"])
291            .output();
292        Ok(format!("Daemon uninstalled ({})", path.display()))
293    } else {
294        Ok("No daemon installed.".to_string())
295    }
296}
297
298#[cfg(target_os = "linux")]
299fn status_systemd() -> Result<String, String> {
300    let output = std::process::Command::new("systemctl")
301        .args(["--user", "status", SERVICE_NAME])
302        .output()
303        .map_err(|e| format!("systemctl status failed: {}", e))?;
304
305    Ok(String::from_utf8_lossy(&output.stdout).to_string())
306}