1use std::fs;
22use std::path::{Path, PathBuf};
23
24use std::time::Duration;
25
26use netsky_sh::{require, tmux};
27
28use crate::agent::AgentId;
29use crate::consts::{
30 ENV_AGENT_N, ENV_NETSKY_PROMPT_FILE, MCP_CHANNEL_DIR_PREFIX, MCP_CONFIG_FILENAME,
31 MCP_SERVER_AGENT, MCP_SERVER_IMESSAGE, NETSKY_BIN, RESTART_TOS_PROBE, TMUX_BIN,
32};
33use crate::error::{Error, Result};
34use crate::paths::{home, prompt_file_for, prompts_dir};
35use crate::prompt::{PromptContext, render_prompt};
36use crate::runtime::Runtime;
37
38const STARTUP_DEFAULT: &str = include_str!("../prompts/startup.md");
39const STARTUP_AGENTINFINITY: &str = include_str!("../prompts/startup-agentinfinity.md");
40
41#[derive(Debug, Clone)]
44pub struct SpawnOptions {
45 pub runtime: Runtime,
46 pub cwd: PathBuf,
47}
48
49impl SpawnOptions {
50 pub fn defaults_for(agent: AgentId, cwd: PathBuf) -> Self {
53 Self {
54 runtime: Runtime::defaults_for(agent),
55 cwd,
56 }
57 }
58}
59
60#[derive(Debug, PartialEq, Eq)]
62pub enum SpawnOutcome {
63 Spawned,
64 AlreadyUp,
65}
66
67pub fn is_up(agent: AgentId) -> bool {
69 tmux::session_is_alive(&agent.name())
70}
71
72pub fn require_deps_for(runtime: &Runtime) -> Result<()> {
74 for dep in runtime.required_deps() {
75 require(dep).map_err(|_| Error::MissingDep(dep))?;
76 }
77 Ok(())
78}
79
80pub fn require_deps() -> Result<()> {
84 for dep in crate::runtime::claude::required_deps() {
85 require(dep).map_err(|_| Error::MissingDep(dep))?;
86 }
87 Ok(())
88}
89
90pub fn spawn(agent: AgentId, opts: &SpawnOptions) -> Result<SpawnOutcome> {
92 let session = agent.name();
93 if tmux::session_is_alive(&session) {
94 return Ok(SpawnOutcome::AlreadyUp);
95 }
96 if tmux::has_session(&session) {
97 tmux::kill_session(&session)?;
98 }
99 require_deps_for(&opts.runtime)?;
100
101 let mcp_config_path = write_mcp_config(agent)?;
102 let prompt_ctx = PromptContext::new(agent, opts.cwd.display().to_string());
103 let prompt = render_prompt(prompt_ctx, &opts.cwd)?;
104 let prompt_file = write_prompt_file(&session, &prompt)?;
105 let startup = startup_prompt_for(agent);
106
107 let cmd = opts.runtime.build_command(agent, &mcp_config_path, startup);
108
109 let agent_n = agent.env_n();
110 let prompt_file_str = prompt_file.display().to_string();
111 let env: Vec<(&str, &str)> = vec![
112 (ENV_NETSKY_PROMPT_FILE, &prompt_file_str),
113 (ENV_AGENT_N, &agent_n),
114 ];
115
116 tmux::new_session_detached(&session, &cmd, Some(&opts.cwd), &env)?;
117
118 opts.runtime.post_spawn(&session, startup)?;
124
125 Ok(SpawnOutcome::Spawned)
126}
127
128fn write_prompt_file(session: &str, prompt: &str) -> Result<PathBuf> {
134 let dir = prompts_dir();
135 fs::create_dir_all(&dir)?;
136 let path = prompt_file_for(session);
137 atomic_write(&path, prompt)?;
138 Ok(path)
139}
140
141pub fn kill(agent: AgentId) -> Result<()> {
143 tmux::kill_session(&agent.name()).map_err(Into::into)
144}
145
146pub fn dismiss_tos(session: &str, timeout: Duration) -> bool {
167 let deadline = std::time::Instant::now() + timeout;
168 while std::time::Instant::now() < deadline {
169 if let Ok(pane) = tmux::capture_pane(session, None)
170 && pane.contains(RESTART_TOS_PROBE)
171 {
172 let _ = std::process::Command::new(TMUX_BIN)
173 .args(["send-keys", "-t", session, "Enter"])
174 .status();
175 return true;
176 }
177 std::thread::sleep(Duration::from_secs(1));
178 }
179 false
180}
181
182fn startup_prompt_for(agent: AgentId) -> &'static str {
183 if agent.is_agentinfinity() {
184 STARTUP_AGENTINFINITY
185 } else {
186 STARTUP_DEFAULT
187 }
188}
189
190fn mcp_config_dir(agent: AgentId) -> PathBuf {
191 home().join(MCP_CHANNEL_DIR_PREFIX).join(agent.name())
192}
193
194fn mcp_config_path(agent: AgentId) -> PathBuf {
195 mcp_config_dir(agent).join(MCP_CONFIG_FILENAME)
196}
197
198fn write_mcp_config(agent: AgentId) -> Result<PathBuf> {
199 let dir = mcp_config_dir(agent);
200 fs::create_dir_all(&dir)?;
201 let path = mcp_config_path(agent);
202 atomic_write(&path, &render_mcp_config(agent))?;
203 Ok(path)
204}
205
206fn atomic_write(target: &Path, content: &str) -> Result<()> {
211 use std::time::{SystemTime, UNIX_EPOCH};
212 let nanos = SystemTime::now()
213 .duration_since(UNIX_EPOCH)
214 .map(|d| d.as_nanos())
215 .unwrap_or(0);
216 let tmp_name = format!(
217 "{}.tmp.{}.{}",
218 target
219 .file_name()
220 .and_then(|n| n.to_str())
221 .unwrap_or("mcp-config.json"),
222 std::process::id(),
223 nanos
224 );
225 let tmp = target
226 .parent()
227 .map(|p| p.join(&tmp_name))
228 .unwrap_or_else(|| PathBuf::from(tmp_name));
229 fs::write(&tmp, content)?;
230 fs::rename(&tmp, target)?;
231 Ok(())
232}
233
234fn render_mcp_config(agent: AgentId) -> String {
238 let include_imessage = !matches!(agent, AgentId::Clone(_));
239 let n = agent.env_n();
240 let mut servers = format!(
241 " \"{MCP_SERVER_AGENT}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_AGENT}\"], \"env\": {{ \"{ENV_AGENT_N}\": \"{n}\" }} }}"
242 );
243 if include_imessage {
244 servers.push_str(",\n");
245 servers.push_str(&format!(
246 " \"{MCP_SERVER_IMESSAGE}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_IMESSAGE}\"] }}"
247 ));
248 }
249 format!("{{\n \"mcpServers\": {{\n{servers}\n }}\n}}\n")
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn mcp_config_clone_has_only_agent_server() {
258 let cfg = render_mcp_config(AgentId::Clone(3));
259 assert!(cfg.contains("\"agent\""));
260 assert!(!cfg.contains("\"imessage\""));
261 assert!(cfg.contains("\"AGENT_N\": \"3\""));
262 }
263
264 #[test]
265 fn mcp_config_agent0_includes_imessage() {
266 let cfg = render_mcp_config(AgentId::Agent0);
267 assert!(cfg.contains("\"agent\""));
268 assert!(cfg.contains("\"imessage\""));
269 assert!(cfg.contains("\"AGENT_N\": \"0\""));
270 }
271
272 #[test]
273 fn mcp_config_agentinfinity_includes_imessage() {
274 let cfg = render_mcp_config(AgentId::Agentinfinity);
275 assert!(cfg.contains("\"agent\""));
276 assert!(cfg.contains("\"imessage\""));
277 assert!(cfg.contains("\"AGENT_N\": \"infinity\""));
278 }
279}