Skip to main content

netsky_core/
spawn.rs

1//! Agent spawn orchestration.
2//!
3//! Renders the full system prompt, writes it to a per-agent file under
4//! `~/.netsky/state/prompts/<session>.md`, writes a per-agent MCP config,
5//! asks the configured runtime to assemble its `claude`/`codex`/...
6//! command line, then delegates to `netsky_sh::tmux` to create the
7//! detached tmux session with `AGENT_N` + `NETSKY_PROMPT_FILE` (the
8//! path, not the content) propagated via `-e`.
9//!
10//! The shell that tmux launches reads the prompt content at exec time
11//! via `$(cat "$NETSKY_PROMPT_FILE")`. This indirection exists because
12//! tmux's command parser rejects oversized argv elements with "command
13//! too long" — passing the 20KB+ rendered prompt directly through
14//! `-e NETSKY_PROMPT=<content>` took down every agent0 restart in
15//! session-11 (see `INFINITY_EMERGENCY_FIX.md`).
16//!
17//! Single code path for agent0, clones, and agentinfinity. Per-agent
18//! differences (MCP servers, channel flags) live here; runtime-flavor
19//! differences (CLI syntax) live in [`crate::runtime`].
20
21use 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/// Per-spawn options. Runtime-agnostic: the `runtime` field owns
40/// flavor-specific config (claude model + effort, codex knobs, ...).
41#[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    /// Defaults for `agent`: selects the default runtime flavor
50    /// ([`Runtime::defaults_for`]) and sets the working directory.
51    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/// Spawn outcome for idempotent callers.
61#[derive(Debug, PartialEq, Eq)]
62pub enum SpawnOutcome {
63    Spawned,
64    AlreadyUp,
65}
66
67/// True if the tmux session for `agent` already exists.
68pub fn is_up(agent: AgentId) -> bool {
69    tmux::session_is_alive(&agent.name())
70}
71
72/// Verify the runtime deps the configured flavor requires.
73pub 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
80/// Verify the runtime deps the default claude runtime requires.
81/// Kept for call sites (e.g. the restart path) that don't yet thread
82/// a SpawnOptions through.
83pub 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
90/// Idempotently spawn `agent` in a detached tmux session.
91pub 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    // Runtime post-spawn hook. Claude is a no-op (startup already went
135    // in as a CLI positional). Codex pastes startup into the pane as a
136    // follow-on user turn — without this, resident codex never runs
137    // /up, dropping identity + skills orientation (see the B1 finding
138    // in briefs/codex-integration-review-findings.md).
139    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
156/// Write the rendered system prompt to a per-agent file under
157/// `~/.netsky/state/prompts/<session>.md`. Atomic rename to avoid a
158/// half-written file being read by a racing spawn. Returns the target
159/// path. Single file per agent (fixed name, overwrite-on-spawn) — no
160/// cleanup needed; disk cost is bounded by agent count.
161fn 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
169/// Tear down `agent`'s tmux session if present. Idempotent.
170pub fn kill(agent: AgentId) -> Result<()> {
171    tmux::kill_session(&agent.name()).map_err(Into::into)
172}
173
174/// Dismiss the Claude dev-channels TOS dialog on `session` by sending
175/// Enter once the prompt is visible. Polls for up to `timeout` at 1Hz.
176/// Returns true iff the prompt was seen and Enter was delivered.
177///
178/// Used by `netsky restart` (before waiting for /up) and by
179/// `netsky agent <N> --fresh` (after spawn) so every freshly-spawned
180/// claude session clears the one-shot consent dialog without manual
181/// intervention.
182///
183/// The sibling approval surface — the project-scope `.mcp.json`
184/// server-enablement dialog ("which servers would you like to enable?")
185/// — is NOT dismissed here. It is suppressed at config time via the
186/// `enabledMcpjsonServers` allowlist in `.agents/settings.json`, which
187/// is committed so every fresh workspace spawn (post-/restart, first
188/// spawn on a newly-cloned machine, first spawn after a `.mcp.json`
189/// change) skips the dialog outright. Without that allowlist, spawned
190/// agents hang on the multi-select dialog indefinitely because no human
191/// is watching their pane. The explicit list (over `enableAllProjectMcpServers:
192/// true`) keeps future `.mcp.json` additions gated — adding a server
193/// requires a deliberate settings.json update, not an automatic grant.
194pub 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
244/// Atomic write: tmp file in the same dir + rename. Collision-resistant
245/// via PID + nanosecond suffix, so two concurrent spawns of different
246/// agents never trample each other's tmp. Partial writes never appear at
247/// the final path — a mid-write crash leaves only the tmp file.
248fn 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
272/// Per-agent MCP config. All agents get the `agent` channel (the bus).
273/// agent0 + agentinfinity additionally get iMessage. Clones do not — agent0
274/// is the sole owner→system interface.
275fn 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}