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_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/// Per-spawn options. Runtime-agnostic: the `runtime` field owns
43/// flavor-specific config (claude model + effort, codex knobs, ...).
44#[derive(Debug, Clone)]
45pub struct SpawnOptions {
46    pub runtime: Runtime,
47    pub cwd: PathBuf,
48}
49
50impl SpawnOptions {
51    /// Defaults for `agent`: selects the default runtime flavor
52    /// ([`Runtime::defaults_for`]) and sets the working directory.
53    pub fn defaults_for(agent: AgentId, cwd: PathBuf) -> Self {
54        Self {
55            runtime: Runtime::defaults_for(agent),
56            cwd,
57        }
58    }
59}
60
61/// Spawn outcome for idempotent callers.
62#[derive(Debug, PartialEq, Eq)]
63pub enum SpawnOutcome {
64    Spawned,
65    AlreadyUp,
66}
67
68/// True if the tmux session for `agent` already exists.
69pub fn is_up(agent: AgentId) -> bool {
70    tmux::session_is_alive(&agent.name())
71}
72
73/// Verify the runtime deps the configured flavor requires.
74pub 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
81/// Verify the runtime deps the default claude runtime requires.
82/// Kept for call sites (e.g. the restart path) that don't yet thread
83/// a SpawnOptions through.
84pub 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
91/// Idempotently spawn `agent` in a detached tmux session.
92pub 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    // Runtime post-spawn hook. Claude is a no-op (startup already went
130    // in as a CLI positional). Codex pastes startup into the pane as a
131    // follow-on user turn — without this, resident codex never runs
132    // /up, dropping identity + skills orientation (see the B1 finding
133    // in briefs/codex-integration-review-findings.md).
134    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
151/// Write the rendered system prompt to a per-agent file under
152/// `~/.netsky/state/prompts/<session>.md`. Atomic rename to avoid a
153/// half-written file being read by a racing spawn. Returns the target
154/// path. Single file per agent (fixed name, overwrite-on-spawn) — no
155/// cleanup needed; disk cost is bounded by agent count.
156fn 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
164/// Tear down `agent`'s tmux session if present. Idempotent.
165pub fn kill(agent: AgentId) -> Result<()> {
166    tmux::kill_session(&agent.name()).map_err(Into::into)
167}
168
169/// Dismiss the Claude dev-channels TOS dialog on `session` by sending
170/// Enter once the prompt is visible. Polls for up to `timeout` at 1Hz.
171/// Returns true iff the prompt was seen and Enter was delivered.
172///
173/// Used by `netsky restart` (before waiting for /up) and by
174/// `netsky agent <N> --fresh` (after spawn) so every freshly-spawned
175/// claude session clears the one-shot consent dialog without manual
176/// intervention.
177///
178/// The sibling approval surface — the project-scope `.mcp.json`
179/// server-enablement dialog ("which servers would you like to enable?")
180/// — is NOT dismissed here. It is suppressed at config time via the
181/// `enabledMcpjsonServers` allowlist in `.agents/settings.json`, which
182/// is committed so every fresh workspace spawn (post-/restart, first
183/// spawn on a newly-cloned machine, first spawn after a `.mcp.json`
184/// change) skips the dialog outright. Without that allowlist, spawned
185/// agents hang on the multi-select dialog indefinitely because no human
186/// is watching their pane. The explicit list (over `enableAllProjectMcpServers:
187/// true`) keeps future `.mcp.json` additions gated — adding a server
188/// requires a deliberate settings.json update, not an automatic grant.
189pub 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
229/// Atomic write: tmp file in the same dir + rename. Collision-resistant
230/// via PID + nanosecond suffix, so two concurrent spawns of different
231/// agents never trample each other's tmp. Partial writes never appear at
232/// the final path — a mid-write crash leaves only the tmp file.
233fn 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
257/// Per-agent MCP config. All agents get the `agent` channel (the bus).
258/// agent0 + agentinfinity additionally get iMessage. Clones do not — agent0
259/// is the sole owner→system interface.
260fn 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}