1use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use anyhow::{Context, Result};
11
12use crate::compose::AgentHandle;
13
14#[derive(Debug, Clone)]
15pub struct AgentSpec {
16 pub project: String,
17 pub agent: String,
18 pub tmux_session: String,
19 pub wrapper: PathBuf,
20 pub cwd: PathBuf,
21 pub env_file: PathBuf,
22}
23
24impl AgentSpec {
25 pub fn from_handle(h: AgentHandle<'_>, root: &Path, tmux_prefix: &str) -> Self {
26 Self {
27 project: h.project.into(),
28 agent: h.agent.into(),
29 tmux_session: format!("{tmux_prefix}{}-{}", h.project, h.agent),
30 wrapper: root.join("bin/agent-wrapper.sh"),
31 cwd: root.to_path_buf(),
32 env_file: crate::render::env_path(root, h.project, h.agent),
33 }
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum AgentState {
40 Running,
41 Stopped,
42 Unknown,
43}
44
45pub trait Supervisor {
46 fn up(&self, spec: &AgentSpec) -> Result<()>;
47 fn down(&self, spec: &AgentSpec) -> Result<()>;
48 fn state(&self, spec: &AgentSpec) -> Result<AgentState>;
49}
50
51pub struct TmuxSupervisor;
53
54impl Supervisor for TmuxSupervisor {
55 fn up(&self, spec: &AgentSpec) -> Result<()> {
56 if matches!(self.state(spec)?, AgentState::Running) {
57 return Ok(());
58 }
59 let cmd = format!(
60 "env $(cat {env}) {wrapper} {project}:{agent}",
61 env = shlex::try_quote(&spec.env_file.display().to_string())?,
62 wrapper = shlex::try_quote(&spec.wrapper.display().to_string())?,
63 project = spec.project,
64 agent = spec.agent,
65 );
66 let status = Command::new("tmux")
67 .args([
68 "new-session",
69 "-d",
70 "-s",
71 &spec.tmux_session,
72 "-c",
73 &spec.cwd.display().to_string(),
74 "sh",
75 "-c",
76 &cmd,
77 ])
78 .status()
79 .context("spawn tmux new-session")?;
80 anyhow::ensure!(status.success(), "tmux new-session exited {status}");
81 Ok(())
82 }
83
84 fn down(&self, spec: &AgentSpec) -> Result<()> {
85 let _ = Command::new("tmux")
86 .args(["kill-session", "-t", &spec.tmux_session])
87 .status();
88 Ok(())
89 }
90
91 fn state(&self, spec: &AgentSpec) -> Result<AgentState> {
92 let out = Command::new("tmux")
93 .args(["has-session", "-t", &spec.tmux_session])
94 .output();
95 Ok(match out {
96 Ok(o) if o.status.success() => AgentState::Running,
97 Ok(_) => AgentState::Stopped,
98 Err(_) => AgentState::Unknown,
99 })
100 }
101}
102
103mod shlex {
104 pub fn try_quote(s: &str) -> anyhow::Result<String> {
106 anyhow::ensure!(!s.contains('\0'), "null byte in shell arg");
107 let escaped = s.replace('\'', r"'\''");
108 Ok(format!("'{escaped}'"))
109 }
110
111 #[cfg(test)]
112 mod tests {
113 use super::*;
114
115 #[test]
116 fn quotes_plain_path() {
117 assert_eq!(try_quote("/a/b.sh").unwrap(), "'/a/b.sh'");
118 }
119
120 #[test]
121 fn escapes_embedded_single_quote() {
122 assert_eq!(try_quote("x'y").unwrap(), r"'x'\''y'");
123 }
124 }
125}