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_CODEX_CHANNEL_DIR, ENV_NETSKY_PROMPT_FILE, MCP_CHANNEL_DIR_PREFIX,
31 MCP_CONFIG_FILENAME, MCP_SERVER_AGENT, MCP_SERVER_IMESSAGE, NETSKY_BIN, RESTART_TOS_PROBE,
32 TMUX_BIN,
33};
34use crate::error::{Error, Result};
35use crate::paths::{home, prompt_file_for, prompts_dir};
36use crate::prompt::{PromptContext, render_prompt};
37use crate::runtime::Runtime;
38
39const STARTUP_DEFAULT: &str = include_str!("../prompts/startup.md");
40const STARTUP_AGENTINFINITY: &str = include_str!("../prompts/startup-agentinfinity.md");
41
42#[derive(Debug, Clone)]
45pub struct SpawnOptions {
46 pub runtime: Runtime,
47 pub cwd: PathBuf,
48}
49
50impl SpawnOptions {
51 pub fn defaults_for(agent: AgentId, cwd: PathBuf) -> Self {
54 Self {
55 runtime: Runtime::defaults_for(agent),
56 cwd,
57 }
58 }
59}
60
61#[derive(Debug, PartialEq, Eq)]
63pub enum SpawnOutcome {
64 Spawned,
65 AlreadyUp,
66}
67
68pub fn is_up(agent: AgentId) -> bool {
70 tmux::session_is_alive(&agent.name())
71}
72
73pub fn require_deps_for(runtime: &Runtime) -> Result<()> {
75 for dep in runtime.required_deps() {
76 require(dep).map_err(|_| Error::MissingDep(dep))?;
77 }
78 Ok(())
79}
80
81pub fn require_deps() -> Result<()> {
85 for dep in crate::runtime::claude::required_deps() {
86 require(dep).map_err(|_| Error::MissingDep(dep))?;
87 }
88 Ok(())
89}
90
91pub fn spawn(agent: AgentId, opts: &SpawnOptions) -> Result<SpawnOutcome> {
93 let session = agent.name();
94 if tmux::session_is_alive(&session) {
95 return Ok(SpawnOutcome::AlreadyUp);
96 }
97 if tmux::has_session(&session) {
98 tmux::kill_session(&session)?;
99 }
100 require_deps_for(&opts.runtime)?;
101
102 let mcp_config_path = write_mcp_config(agent)?;
103 let prompt_ctx = PromptContext::new(agent, opts.cwd.display().to_string());
104 let prompt = render_prompt(prompt_ctx, &opts.cwd)?;
105 let prompt_file = write_prompt_file(&session, &prompt)?;
106 let startup = startup_prompt_for(agent);
107
108 let cmd = opts.runtime.build_command(agent, &mcp_config_path, startup);
109
110 let codex_channel_dir = if opts.runtime.name() == "codex" {
111 Some(ensure_codex_channel_dir(agent)?)
112 } else {
113 None
114 };
115 let agent_n = agent.env_n();
116 let prompt_file_str = prompt_file.display().to_string();
117 let mut env: Vec<(&str, &str)> = vec![
118 (ENV_NETSKY_PROMPT_FILE, &prompt_file_str),
119 (ENV_AGENT_N, &agent_n),
120 ];
121 let codex_channel_dir_str;
122 if let Some(dir) = codex_channel_dir {
123 codex_channel_dir_str = dir.display().to_string();
124 env.push((ENV_CODEX_CHANNEL_DIR, &codex_channel_dir_str));
125 }
126
127 tmux::new_session_detached(&session, &cmd, Some(&opts.cwd), &env)?;
128
129 opts.runtime.post_spawn(&session, startup)?;
135
136 Ok(SpawnOutcome::Spawned)
137}
138
139fn ensure_codex_channel_dir(agent: AgentId) -> Result<PathBuf> {
140 let root = home().join(MCP_CHANNEL_DIR_PREFIX);
141 let dir = root.join(agent.name());
142 crate::paths::assert_no_symlink_under(&root, &dir)?;
143 for child in ["inbox", "outbox", "processed"] {
144 let path = dir.join(child);
145 crate::paths::assert_no_symlink_under(&root, &path)?;
146 fs::create_dir_all(path)?;
147 }
148 Ok(dir)
149}
150
151fn write_prompt_file(session: &str, prompt: &str) -> Result<PathBuf> {
157 let dir = prompts_dir();
158 fs::create_dir_all(&dir)?;
159 let path = prompt_file_for(session);
160 atomic_write(&path, prompt)?;
161 Ok(path)
162}
163
164pub fn kill(agent: AgentId) -> Result<()> {
166 tmux::kill_session(&agent.name()).map_err(Into::into)
167}
168
169pub fn dismiss_tos(session: &str, timeout: Duration) -> bool {
190 let deadline = std::time::Instant::now() + timeout;
191 while std::time::Instant::now() < deadline {
192 if let Ok(pane) = tmux::capture_pane(session, None)
193 && pane.contains(RESTART_TOS_PROBE)
194 {
195 let _ = std::process::Command::new(TMUX_BIN)
196 .args(["send-keys", "-t", session, "Enter"])
197 .status();
198 return true;
199 }
200 std::thread::sleep(Duration::from_secs(1));
201 }
202 false
203}
204
205fn startup_prompt_for(agent: AgentId) -> &'static str {
206 if agent.is_agentinfinity() {
207 STARTUP_AGENTINFINITY
208 } else {
209 STARTUP_DEFAULT
210 }
211}
212
213fn mcp_config_dir(agent: AgentId) -> PathBuf {
214 home().join(MCP_CHANNEL_DIR_PREFIX).join(agent.name())
215}
216
217fn mcp_config_path(agent: AgentId) -> PathBuf {
218 mcp_config_dir(agent).join(MCP_CONFIG_FILENAME)
219}
220
221fn write_mcp_config(agent: AgentId) -> Result<PathBuf> {
222 let dir = mcp_config_dir(agent);
223 fs::create_dir_all(&dir)?;
224 let path = mcp_config_path(agent);
225 atomic_write(&path, &render_mcp_config(agent))?;
226 Ok(path)
227}
228
229fn atomic_write(target: &Path, content: &str) -> Result<()> {
234 use std::time::{SystemTime, UNIX_EPOCH};
235 let nanos = SystemTime::now()
236 .duration_since(UNIX_EPOCH)
237 .map(|d| d.as_nanos())
238 .unwrap_or(0);
239 let tmp_name = format!(
240 "{}.tmp.{}.{}",
241 target
242 .file_name()
243 .and_then(|n| n.to_str())
244 .unwrap_or("mcp-config.json"),
245 std::process::id(),
246 nanos
247 );
248 let tmp = target
249 .parent()
250 .map(|p| p.join(&tmp_name))
251 .unwrap_or_else(|| PathBuf::from(tmp_name));
252 fs::write(&tmp, content)?;
253 fs::rename(&tmp, target)?;
254 Ok(())
255}
256
257fn render_mcp_config(agent: AgentId) -> String {
261 let include_imessage = !matches!(agent, AgentId::Clone(_));
262 let n = agent.env_n();
263 let mut servers = format!(
264 " \"{MCP_SERVER_AGENT}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_AGENT}\"], \"env\": {{ \"{ENV_AGENT_N}\": \"{n}\" }} }}"
265 );
266 if include_imessage {
267 servers.push_str(",\n");
268 servers.push_str(&format!(
269 " \"{MCP_SERVER_IMESSAGE}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_IMESSAGE}\"] }}"
270 ));
271 }
272 format!("{{\n \"mcpServers\": {{\n{servers}\n }}\n}}\n")
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn mcp_config_clone_has_only_agent_server() {
281 let cfg = render_mcp_config(AgentId::Clone(3));
282 assert!(cfg.contains("\"agent\""));
283 assert!(!cfg.contains("\"imessage\""));
284 assert!(cfg.contains("\"AGENT_N\": \"3\""));
285 }
286
287 #[test]
288 fn mcp_config_agent0_includes_imessage() {
289 let cfg = render_mcp_config(AgentId::Agent0);
290 assert!(cfg.contains("\"agent\""));
291 assert!(cfg.contains("\"imessage\""));
292 assert!(cfg.contains("\"AGENT_N\": \"0\""));
293 }
294
295 #[test]
296 fn mcp_config_agentinfinity_includes_imessage() {
297 let cfg = render_mcp_config(AgentId::Agentinfinity);
298 assert!(cfg.contains("\"agent\""));
299 assert!(cfg.contains("\"imessage\""));
300 assert!(cfg.contains("\"AGENT_N\": \"infinity\""));
301 }
302}