netsky-core 0.1.7

netsky core: agent model, prompt loader, spawner, config
Documentation
//! Agent spawn orchestration.
//!
//! Renders the full system prompt, writes it to a per-agent file under
//! `~/.netsky/state/prompts/<session>.md`, writes a per-agent MCP config,
//! asks the configured runtime to assemble its `claude`/`codex`/...
//! command line, then delegates to `netsky_sh::tmux` to create the
//! detached tmux session with `AGENT_N` + `NETSKY_PROMPT_FILE` (the
//! path, not the content) propagated via `-e`.
//!
//! The shell that tmux launches reads the prompt content at exec time
//! via `$(cat "$NETSKY_PROMPT_FILE")`. This indirection exists because
//! tmux's command parser rejects oversized argv elements with "command
//! too long" — passing the 20KB+ rendered prompt directly through
//! `-e NETSKY_PROMPT=<content>` took down every agent0 restart in
//! session-11 (see `INFINITY_EMERGENCY_FIX.md`).
//!
//! Single code path for agent0, clones, and agentinfinity. Per-agent
//! differences (MCP servers, channel flags) live here; runtime-flavor
//! differences (CLI syntax) live in [`crate::runtime`].

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_CODEX_CHANNEL_DIR, 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");

/// Per-spawn options. Runtime-agnostic: the `runtime` field owns
/// flavor-specific config (claude model + effort, codex knobs, ...).
#[derive(Debug, Clone)]
pub struct SpawnOptions {
    pub runtime: Runtime,
    pub cwd: PathBuf,
}

impl SpawnOptions {
    /// Defaults for `agent`: selects the default runtime flavor
    /// ([`Runtime::defaults_for`]) and sets the working directory.
    pub fn defaults_for(agent: AgentId, cwd: PathBuf) -> Self {
        Self {
            runtime: Runtime::defaults_for(agent),
            cwd,
        }
    }
}

/// Spawn outcome for idempotent callers.
#[derive(Debug, PartialEq, Eq)]
pub enum SpawnOutcome {
    Spawned,
    AlreadyUp,
}

/// True if the tmux session for `agent` already exists.
pub fn is_up(agent: AgentId) -> bool {
    tmux::session_is_alive(&agent.name())
}

/// Verify the runtime deps the configured flavor requires.
pub fn require_deps_for(runtime: &Runtime) -> Result<()> {
    for dep in runtime.required_deps() {
        require(dep).map_err(|_| Error::MissingDep(dep))?;
    }
    Ok(())
}

/// Verify the runtime deps the default claude runtime requires.
/// Kept for call sites (e.g. the restart path) that don't yet thread
/// a SpawnOptions through.
pub fn require_deps() -> Result<()> {
    for dep in crate::runtime::claude::required_deps() {
        require(dep).map_err(|_| Error::MissingDep(dep))?;
    }
    Ok(())
}

/// Idempotently spawn `agent` in a detached tmux session.
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 codex_channel_dir = if opts.runtime.name() == "codex" {
        Some(ensure_codex_channel_dir(agent)?)
    } else {
        None
    };
    let agent_n = agent.env_n();
    let prompt_file_str = prompt_file.display().to_string();
    let mut env: Vec<(&str, &str)> = vec![
        (ENV_NETSKY_PROMPT_FILE, &prompt_file_str),
        (ENV_AGENT_N, &agent_n),
    ];
    let codex_channel_dir_str;
    if let Some(dir) = codex_channel_dir {
        codex_channel_dir_str = dir.display().to_string();
        env.push((ENV_CODEX_CHANNEL_DIR, &codex_channel_dir_str));
    }

    tmux::new_session_detached(&session, &cmd, Some(&opts.cwd), &env)?;

    // Runtime post-spawn hook. Claude is a no-op (startup already went
    // in as a CLI positional). Codex pastes startup into the pane as a
    // follow-on user turn — without this, resident codex never runs
    // /up, dropping identity + skills orientation (see the B1 finding
    // in briefs/codex-integration-review-findings.md).
    opts.runtime.post_spawn(&session, startup)?;

    Ok(SpawnOutcome::Spawned)
}

fn ensure_codex_channel_dir(agent: AgentId) -> Result<PathBuf> {
    let root = home().join(MCP_CHANNEL_DIR_PREFIX);
    let dir = root.join(agent.name());
    crate::paths::assert_no_symlink_under(&root, &dir)?;
    for child in ["inbox", "outbox", "processed"] {
        let path = dir.join(child);
        crate::paths::assert_no_symlink_under(&root, &path)?;
        fs::create_dir_all(path)?;
    }
    Ok(dir)
}

/// Write the rendered system prompt to a per-agent file under
/// `~/.netsky/state/prompts/<session>.md`. Atomic rename to avoid a
/// half-written file being read by a racing spawn. Returns the target
/// path. Single file per agent (fixed name, overwrite-on-spawn) — no
/// cleanup needed; disk cost is bounded by agent count.
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)
}

/// Tear down `agent`'s tmux session if present. Idempotent.
pub fn kill(agent: AgentId) -> Result<()> {
    tmux::kill_session(&agent.name()).map_err(Into::into)
}

/// Dismiss the Claude dev-channels TOS dialog on `session` by sending
/// Enter once the prompt is visible. Polls for up to `timeout` at 1Hz.
/// Returns true iff the prompt was seen and Enter was delivered.
///
/// Used by `netsky restart` (before waiting for /up) and by
/// `netsky agent <N> --fresh` (after spawn) so every freshly-spawned
/// claude session clears the one-shot consent dialog without manual
/// intervention.
///
/// The sibling approval surface — the project-scope `.mcp.json`
/// server-enablement dialog ("which servers would you like to enable?")
/// — is NOT dismissed here. It is suppressed at config time via the
/// `enabledMcpjsonServers` allowlist in `.agents/settings.json`, which
/// is committed so every fresh workspace spawn (post-/restart, first
/// spawn on a newly-cloned machine, first spawn after a `.mcp.json`
/// change) skips the dialog outright. Without that allowlist, spawned
/// agents hang on the multi-select dialog indefinitely because no human
/// is watching their pane. The explicit list (over `enableAllProjectMcpServers:
/// true`) keeps future `.mcp.json` additions gated — adding a server
/// requires a deliberate settings.json update, not an automatic grant.
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)
}

/// Atomic write: tmp file in the same dir + rename. Collision-resistant
/// via PID + nanosecond suffix, so two concurrent spawns of different
/// agents never trample each other's tmp. Partial writes never appear at
/// the final path — a mid-write crash leaves only the tmp file.
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(())
}

/// Per-agent MCP config. All agents get the `agent` channel (the bus).
/// agent0 + agentinfinity additionally get iMessage. Clones do not — agent0
/// is the sole owner→system interface.
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\""));
    }
}