1use std::fs;
22use std::path::{Path, PathBuf};
23
24use std::time::Duration;
25
26use netsky_prompts::prompt::{PromptContext, render_prompt, startup_prompt_for};
27use netsky_sh::{require, tmux};
28
29use crate::agent::AgentId;
30use crate::consts::{
31 ENV_AGENT_N, ENV_CODEX_CHANNEL_DIR, ENV_NETSKY_PROMPT_FILE, MCP_CHANNEL_DIR_PREFIX,
32 MCP_CONFIG_FILENAME, MCP_SERVER_AGENT, MCP_SERVER_IMESSAGE, NETSKY_BIN, RESTART_TOS_PROBE,
33 TMUX_BIN,
34};
35use crate::error::{Error, Result};
36use crate::paths::{home, prompt_file_for, prompts_dir};
37use crate::runtime::Runtime;
38
39#[derive(Debug, Clone)]
42pub struct SpawnOptions {
43 pub runtime: Runtime,
44 pub cwd: PathBuf,
45 pub claude_settings_template: Option<String>,
46}
47
48impl SpawnOptions {
49 pub fn defaults_for(agent: AgentId, cwd: PathBuf) -> Self {
52 Self {
53 runtime: Runtime::defaults_for(agent),
54 cwd,
55 claude_settings_template: None,
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 claude_settings_path = write_claude_settings(agent, opts)?;
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(
109 agent,
110 &mcp_config_path,
111 claude_settings_path.as_deref(),
112 startup,
113 );
114
115 let codex_channel_dir = if opts.runtime.name() == "codex" {
116 Some(ensure_codex_channel_dir(agent)?)
117 } else {
118 None
119 };
120 let agent_n = agent.env_n();
121 let prompt_file_str = prompt_file.display().to_string();
122 let mut env: Vec<(&str, &str)> = vec![
123 (ENV_NETSKY_PROMPT_FILE, &prompt_file_str),
124 (ENV_AGENT_N, &agent_n),
125 ];
126 let codex_channel_dir_str;
127 if let Some(dir) = codex_channel_dir {
128 codex_channel_dir_str = dir.display().to_string();
129 env.push((ENV_CODEX_CHANNEL_DIR, &codex_channel_dir_str));
130 }
131
132 tmux::new_session_detached(&session, &cmd, Some(&opts.cwd), &env)?;
133
134 opts.runtime.post_spawn(&session, startup)?;
140
141 Ok(SpawnOutcome::Spawned)
142}
143
144fn ensure_codex_channel_dir(agent: AgentId) -> Result<PathBuf> {
145 let root = home().join(MCP_CHANNEL_DIR_PREFIX);
146 let dir = root.join(agent.name());
147 crate::paths::assert_no_symlink_under(&root, &dir)?;
148 for child in ["inbox", "outbox", "processed"] {
149 let path = dir.join(child);
150 crate::paths::assert_no_symlink_under(&root, &path)?;
151 fs::create_dir_all(path)?;
152 }
153 Ok(dir)
154}
155
156fn write_prompt_file(session: &str, prompt: &str) -> Result<PathBuf> {
162 let dir = prompts_dir();
163 fs::create_dir_all(&dir)?;
164 let path = prompt_file_for(session);
165 atomic_write(&path, prompt)?;
166 Ok(path)
167}
168
169pub fn kill(agent: AgentId) -> Result<()> {
171 tmux::kill_session(&agent.name()).map_err(Into::into)
172}
173
174pub fn dismiss_tos(session: &str, timeout: Duration) -> bool {
195 let deadline = std::time::Instant::now() + timeout;
196 while std::time::Instant::now() < deadline {
197 if let Ok(pane) = tmux::capture_pane(session, None)
198 && pane.contains(RESTART_TOS_PROBE)
199 {
200 let _ = std::process::Command::new(TMUX_BIN)
201 .args(["send-keys", "-t", session, "Enter"])
202 .status();
203 return true;
204 }
205 std::thread::sleep(Duration::from_secs(1));
206 }
207 false
208}
209
210fn mcp_config_dir(agent: AgentId) -> PathBuf {
211 home().join(MCP_CHANNEL_DIR_PREFIX).join(agent.name())
212}
213
214fn mcp_config_path(agent: AgentId) -> PathBuf {
215 mcp_config_dir(agent).join(MCP_CONFIG_FILENAME)
216}
217
218fn claude_settings_path(agent: AgentId) -> PathBuf {
219 mcp_config_dir(agent).join("settings.json")
220}
221
222fn write_mcp_config(agent: AgentId) -> Result<PathBuf> {
223 let dir = mcp_config_dir(agent);
224 fs::create_dir_all(&dir)?;
225 let path = mcp_config_path(agent);
226 atomic_write(&path, &render_mcp_config(agent))?;
227 Ok(path)
228}
229
230fn write_claude_settings(agent: AgentId, opts: &SpawnOptions) -> Result<Option<PathBuf>> {
231 if opts.runtime.name() != "claude" || !matches!(agent, AgentId::Clone(_)) {
232 return Ok(None);
233 }
234 let Some(template) = opts.claude_settings_template.as_deref() else {
235 return Ok(None);
236 };
237 let dir = mcp_config_dir(agent);
238 fs::create_dir_all(&dir)?;
239 let path = claude_settings_path(agent);
240 atomic_write(&path, template)?;
241 Ok(Some(path))
242}
243
244fn atomic_write(target: &Path, content: &str) -> Result<()> {
249 use std::time::{SystemTime, UNIX_EPOCH};
250 let nanos = SystemTime::now()
251 .duration_since(UNIX_EPOCH)
252 .map(|d| d.as_nanos())
253 .unwrap_or(0);
254 let tmp_name = format!(
255 "{}.tmp.{}.{}",
256 target
257 .file_name()
258 .and_then(|n| n.to_str())
259 .unwrap_or("mcp-config.json"),
260 std::process::id(),
261 nanos
262 );
263 let tmp = target
264 .parent()
265 .map(|p| p.join(&tmp_name))
266 .unwrap_or_else(|| PathBuf::from(tmp_name));
267 fs::write(&tmp, content)?;
268 fs::rename(&tmp, target)?;
269 Ok(())
270}
271
272fn render_mcp_config(agent: AgentId) -> String {
276 let include_imessage = !matches!(agent, AgentId::Clone(_));
277 let n = agent.env_n();
278 let mut servers = format!(
279 " \"{MCP_SERVER_AGENT}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_AGENT}\"], \"env\": {{ \"{ENV_AGENT_N}\": \"{n}\" }} }}"
280 );
281 if include_imessage {
282 servers.push_str(",\n");
283 servers.push_str(&format!(
284 " \"{MCP_SERVER_IMESSAGE}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_IMESSAGE}\"] }}"
285 ));
286 }
287 format!("{{\n \"mcpServers\": {{\n{servers}\n }}\n}}\n")
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn mcp_config_clone_has_only_agent_server() {
296 let cfg = render_mcp_config(AgentId::Clone(3));
297 assert!(cfg.contains("\"agent\""));
298 assert!(!cfg.contains("\"imessage\""));
299 assert!(cfg.contains("\"AGENT_N\": \"3\""));
300 }
301
302 #[test]
303 fn mcp_config_agent0_includes_imessage() {
304 let cfg = render_mcp_config(AgentId::Agent0);
305 assert!(cfg.contains("\"agent\""));
306 assert!(cfg.contains("\"imessage\""));
307 assert!(cfg.contains("\"AGENT_N\": \"0\""));
308 }
309
310 #[test]
311 fn mcp_config_agentinfinity_includes_imessage() {
312 let cfg = render_mcp_config(AgentId::Agentinfinity);
313 assert!(cfg.contains("\"agent\""));
314 assert!(cfg.contains("\"imessage\""));
315 assert!(cfg.contains("\"AGENT_N\": \"infinity\""));
316 }
317}