use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use netsky_sh::{require, tmux};
use crate::agent::AgentId;
use crate::consts::{
ENV_AGENT_N, ENV_NETSKY_PROMPT_FILE, MCP_CHANNEL_DIR_PREFIX, MCP_CONFIG_FILENAME,
MCP_SERVER_AGENT, MCP_SERVER_IMESSAGE, NETSKY_BIN, RESTART_TOS_PROBE, TMUX_BIN,
};
use crate::error::{Error, Result};
use crate::paths::{home, prompt_file_for, prompts_dir};
use crate::prompt::{PromptContext, render_prompt};
use crate::runtime::Runtime;
const STARTUP_DEFAULT: &str = include_str!("../prompts/startup.md");
const STARTUP_AGENTINFINITY: &str = include_str!("../prompts/startup-agentinfinity.md");
#[derive(Debug, Clone)]
pub struct SpawnOptions {
pub runtime: Runtime,
pub cwd: PathBuf,
}
impl SpawnOptions {
pub fn defaults_for(agent: AgentId, cwd: PathBuf) -> Self {
Self {
runtime: Runtime::defaults_for(agent),
cwd,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum SpawnOutcome {
Spawned,
AlreadyUp,
}
pub fn is_up(agent: AgentId) -> bool {
tmux::session_is_alive(&agent.name())
}
pub fn require_deps_for(runtime: &Runtime) -> Result<()> {
for dep in runtime.required_deps() {
require(dep).map_err(|_| Error::MissingDep(dep))?;
}
Ok(())
}
pub fn require_deps() -> Result<()> {
for dep in crate::runtime::claude::required_deps() {
require(dep).map_err(|_| Error::MissingDep(dep))?;
}
Ok(())
}
pub fn spawn(agent: AgentId, opts: &SpawnOptions) -> Result<SpawnOutcome> {
let session = agent.name();
if tmux::session_is_alive(&session) {
return Ok(SpawnOutcome::AlreadyUp);
}
if tmux::has_session(&session) {
tmux::kill_session(&session)?;
}
require_deps_for(&opts.runtime)?;
let mcp_config_path = write_mcp_config(agent)?;
let prompt_ctx = PromptContext::new(agent, opts.cwd.display().to_string());
let prompt = render_prompt(prompt_ctx, &opts.cwd)?;
let prompt_file = write_prompt_file(&session, &prompt)?;
let startup = startup_prompt_for(agent);
let cmd = opts.runtime.build_command(agent, &mcp_config_path, startup);
let agent_n = agent.env_n();
let prompt_file_str = prompt_file.display().to_string();
let env: Vec<(&str, &str)> = vec![
(ENV_NETSKY_PROMPT_FILE, &prompt_file_str),
(ENV_AGENT_N, &agent_n),
];
tmux::new_session_detached(&session, &cmd, Some(&opts.cwd), &env)?;
opts.runtime.post_spawn(&session, startup)?;
Ok(SpawnOutcome::Spawned)
}
fn write_prompt_file(session: &str, prompt: &str) -> Result<PathBuf> {
let dir = prompts_dir();
fs::create_dir_all(&dir)?;
let path = prompt_file_for(session);
atomic_write(&path, prompt)?;
Ok(path)
}
pub fn kill(agent: AgentId) -> Result<()> {
tmux::kill_session(&agent.name()).map_err(Into::into)
}
pub fn dismiss_tos(session: &str, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if let Ok(pane) = tmux::capture_pane(session, None)
&& pane.contains(RESTART_TOS_PROBE)
{
let _ = std::process::Command::new(TMUX_BIN)
.args(["send-keys", "-t", session, "Enter"])
.status();
return true;
}
std::thread::sleep(Duration::from_secs(1));
}
false
}
fn startup_prompt_for(agent: AgentId) -> &'static str {
if agent.is_agentinfinity() {
STARTUP_AGENTINFINITY
} else {
STARTUP_DEFAULT
}
}
fn mcp_config_dir(agent: AgentId) -> PathBuf {
home().join(MCP_CHANNEL_DIR_PREFIX).join(agent.name())
}
fn mcp_config_path(agent: AgentId) -> PathBuf {
mcp_config_dir(agent).join(MCP_CONFIG_FILENAME)
}
fn write_mcp_config(agent: AgentId) -> Result<PathBuf> {
let dir = mcp_config_dir(agent);
fs::create_dir_all(&dir)?;
let path = mcp_config_path(agent);
atomic_write(&path, &render_mcp_config(agent))?;
Ok(path)
}
fn atomic_write(target: &Path, content: &str) -> Result<()> {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_name = format!(
"{}.tmp.{}.{}",
target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("mcp-config.json"),
std::process::id(),
nanos
);
let tmp = target
.parent()
.map(|p| p.join(&tmp_name))
.unwrap_or_else(|| PathBuf::from(tmp_name));
fs::write(&tmp, content)?;
fs::rename(&tmp, target)?;
Ok(())
}
fn render_mcp_config(agent: AgentId) -> String {
let include_imessage = !matches!(agent, AgentId::Clone(_));
let n = agent.env_n();
let mut servers = format!(
" \"{MCP_SERVER_AGENT}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_AGENT}\"], \"env\": {{ \"{ENV_AGENT_N}\": \"{n}\" }} }}"
);
if include_imessage {
servers.push_str(",\n");
servers.push_str(&format!(
" \"{MCP_SERVER_IMESSAGE}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_IMESSAGE}\"] }}"
));
}
format!("{{\n \"mcpServers\": {{\n{servers}\n }}\n}}\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_config_clone_has_only_agent_server() {
let cfg = render_mcp_config(AgentId::Clone(3));
assert!(cfg.contains("\"agent\""));
assert!(!cfg.contains("\"imessage\""));
assert!(cfg.contains("\"AGENT_N\": \"3\""));
}
#[test]
fn mcp_config_agent0_includes_imessage() {
let cfg = render_mcp_config(AgentId::Agent0);
assert!(cfg.contains("\"agent\""));
assert!(cfg.contains("\"imessage\""));
assert!(cfg.contains("\"AGENT_N\": \"0\""));
}
#[test]
fn mcp_config_agentinfinity_includes_imessage() {
let cfg = render_mcp_config(AgentId::Agentinfinity);
assert!(cfg.contains("\"agent\""));
assert!(cfg.contains("\"imessage\""));
assert!(cfg.contains("\"AGENT_N\": \"infinity\""));
}
}