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_NETSKY_PROMPT_FILE, MCP_CHANNEL_DIR_PREFIX, MCP_CONFIG_FILENAME,
31    MCP_SERVER_AGENT, MCP_SERVER_IMESSAGE, NETSKY_BIN, RESTART_TOS_PROBE, TMUX_BIN,
32};
33use crate::error::{Error, Result};
34use crate::paths::{home, prompt_file_for, prompts_dir};
35use crate::prompt::{PromptContext, render_prompt};
36use crate::runtime::Runtime;
37
38const STARTUP_DEFAULT: &str = include_str!("../prompts/startup.md");
39const STARTUP_AGENTINFINITY: &str = include_str!("../prompts/startup-agentinfinity.md");
40
41/// Per-spawn options. Runtime-agnostic: the `runtime` field owns
42/// flavor-specific config (claude model + effort, codex knobs, ...).
43#[derive(Debug, Clone)]
44pub struct SpawnOptions {
45    pub runtime: Runtime,
46    pub cwd: PathBuf,
47}
48
49impl SpawnOptions {
50    /// Defaults for `agent`: selects the default runtime flavor
51    /// ([`Runtime::defaults_for`]) and sets the working directory.
52    pub fn defaults_for(agent: AgentId, cwd: PathBuf) -> Self {
53        Self {
54            runtime: Runtime::defaults_for(agent),
55            cwd,
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 prompt_ctx = PromptContext::new(agent, opts.cwd.display().to_string());
103    let prompt = render_prompt(prompt_ctx, &opts.cwd)?;
104    let prompt_file = write_prompt_file(&session, &prompt)?;
105    let startup = startup_prompt_for(agent);
106
107    let cmd = opts.runtime.build_command(agent, &mcp_config_path, startup);
108
109    let agent_n = agent.env_n();
110    let prompt_file_str = prompt_file.display().to_string();
111    let env: Vec<(&str, &str)> = vec![
112        (ENV_NETSKY_PROMPT_FILE, &prompt_file_str),
113        (ENV_AGENT_N, &agent_n),
114    ];
115
116    tmux::new_session_detached(&session, &cmd, Some(&opts.cwd), &env)?;
117
118    // Runtime post-spawn hook. Claude is a no-op (startup already went
119    // in as a CLI positional). Codex pastes startup into the pane as a
120    // follow-on user turn — without this, resident codex never runs
121    // /up, dropping identity + skills orientation (see the B1 finding
122    // in briefs/codex-integration-review-findings.md).
123    opts.runtime.post_spawn(&session, startup)?;
124
125    Ok(SpawnOutcome::Spawned)
126}
127
128/// Write the rendered system prompt to a per-agent file under
129/// `~/.netsky/state/prompts/<session>.md`. Atomic rename to avoid a
130/// half-written file being read by a racing spawn. Returns the target
131/// path. Single file per agent (fixed name, overwrite-on-spawn) — no
132/// cleanup needed; disk cost is bounded by agent count.
133fn write_prompt_file(session: &str, prompt: &str) -> Result<PathBuf> {
134    let dir = prompts_dir();
135    fs::create_dir_all(&dir)?;
136    let path = prompt_file_for(session);
137    atomic_write(&path, prompt)?;
138    Ok(path)
139}
140
141/// Tear down `agent`'s tmux session if present. Idempotent.
142pub fn kill(agent: AgentId) -> Result<()> {
143    tmux::kill_session(&agent.name()).map_err(Into::into)
144}
145
146/// Dismiss the Claude dev-channels TOS dialog on `session` by sending
147/// Enter once the prompt is visible. Polls for up to `timeout` at 1Hz.
148/// Returns true iff the prompt was seen and Enter was delivered.
149///
150/// Used by `netsky restart` (before waiting for /up) and by
151/// `netsky agent <N> --fresh` (after spawn) so every freshly-spawned
152/// claude session clears the one-shot consent dialog without manual
153/// intervention.
154///
155/// The sibling approval surface — the project-scope `.mcp.json`
156/// server-enablement dialog ("which servers would you like to enable?")
157/// — is NOT dismissed here. It is suppressed at config time via the
158/// `enabledMcpjsonServers` allowlist in `.agents/settings.json`, which
159/// is committed so every fresh workspace spawn (post-/restart, first
160/// spawn on a newly-cloned machine, first spawn after a `.mcp.json`
161/// change) skips the dialog outright. Without that allowlist, spawned
162/// agents hang on the multi-select dialog indefinitely because no human
163/// is watching their pane. The explicit list (over `enableAllProjectMcpServers:
164/// true`) keeps future `.mcp.json` additions gated — adding a server
165/// requires a deliberate settings.json update, not an automatic grant.
166pub fn dismiss_tos(session: &str, timeout: Duration) -> bool {
167    let deadline = std::time::Instant::now() + timeout;
168    while std::time::Instant::now() < deadline {
169        if let Ok(pane) = tmux::capture_pane(session, None)
170            && pane.contains(RESTART_TOS_PROBE)
171        {
172            let _ = std::process::Command::new(TMUX_BIN)
173                .args(["send-keys", "-t", session, "Enter"])
174                .status();
175            return true;
176        }
177        std::thread::sleep(Duration::from_secs(1));
178    }
179    false
180}
181
182fn startup_prompt_for(agent: AgentId) -> &'static str {
183    if agent.is_agentinfinity() {
184        STARTUP_AGENTINFINITY
185    } else {
186        STARTUP_DEFAULT
187    }
188}
189
190fn mcp_config_dir(agent: AgentId) -> PathBuf {
191    home().join(MCP_CHANNEL_DIR_PREFIX).join(agent.name())
192}
193
194fn mcp_config_path(agent: AgentId) -> PathBuf {
195    mcp_config_dir(agent).join(MCP_CONFIG_FILENAME)
196}
197
198fn write_mcp_config(agent: AgentId) -> Result<PathBuf> {
199    let dir = mcp_config_dir(agent);
200    fs::create_dir_all(&dir)?;
201    let path = mcp_config_path(agent);
202    atomic_write(&path, &render_mcp_config(agent))?;
203    Ok(path)
204}
205
206/// Atomic write: tmp file in the same dir + rename. Collision-resistant
207/// via PID + nanosecond suffix, so two concurrent spawns of different
208/// agents never trample each other's tmp. Partial writes never appear at
209/// the final path — a mid-write crash leaves only the tmp file.
210fn atomic_write(target: &Path, content: &str) -> Result<()> {
211    use std::time::{SystemTime, UNIX_EPOCH};
212    let nanos = SystemTime::now()
213        .duration_since(UNIX_EPOCH)
214        .map(|d| d.as_nanos())
215        .unwrap_or(0);
216    let tmp_name = format!(
217        "{}.tmp.{}.{}",
218        target
219            .file_name()
220            .and_then(|n| n.to_str())
221            .unwrap_or("mcp-config.json"),
222        std::process::id(),
223        nanos
224    );
225    let tmp = target
226        .parent()
227        .map(|p| p.join(&tmp_name))
228        .unwrap_or_else(|| PathBuf::from(tmp_name));
229    fs::write(&tmp, content)?;
230    fs::rename(&tmp, target)?;
231    Ok(())
232}
233
234/// Per-agent MCP config. All agents get the `agent` channel (the bus).
235/// agent0 + agentinfinity additionally get iMessage. Clones do not — agent0
236/// is the sole owner→system interface.
237fn render_mcp_config(agent: AgentId) -> String {
238    let include_imessage = !matches!(agent, AgentId::Clone(_));
239    let n = agent.env_n();
240    let mut servers = format!(
241        "    \"{MCP_SERVER_AGENT}\":    {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_AGENT}\"], \"env\": {{ \"{ENV_AGENT_N}\": \"{n}\" }} }}"
242    );
243    if include_imessage {
244        servers.push_str(",\n");
245        servers.push_str(&format!(
246            "    \"{MCP_SERVER_IMESSAGE}\": {{ \"command\": \"{NETSKY_BIN}\", \"args\": [\"io\", \"serve\", \"-s\", \"{MCP_SERVER_IMESSAGE}\"] }}"
247        ));
248    }
249    format!("{{\n  \"mcpServers\": {{\n{servers}\n  }}\n}}\n")
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn mcp_config_clone_has_only_agent_server() {
258        let cfg = render_mcp_config(AgentId::Clone(3));
259        assert!(cfg.contains("\"agent\""));
260        assert!(!cfg.contains("\"imessage\""));
261        assert!(cfg.contains("\"AGENT_N\": \"3\""));
262    }
263
264    #[test]
265    fn mcp_config_agent0_includes_imessage() {
266        let cfg = render_mcp_config(AgentId::Agent0);
267        assert!(cfg.contains("\"agent\""));
268        assert!(cfg.contains("\"imessage\""));
269        assert!(cfg.contains("\"AGENT_N\": \"0\""));
270    }
271
272    #[test]
273    fn mcp_config_agentinfinity_includes_imessage() {
274        let cfg = render_mcp_config(AgentId::Agentinfinity);
275        assert!(cfg.contains("\"agent\""));
276        assert!(cfg.contains("\"imessage\""));
277        assert!(cfg.contains("\"AGENT_N\": \"infinity\""));
278    }
279}