netsky 0.2.0

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky agent <N> [--type claude|codex] [--fresh]` — spawn a single agent.
//!
//! N=0 spawns agent0, N>0 spawns a clone. Idempotent by default: a
//! live session is left alone. With `--fresh` the session is killed
//! first, forcing a clean context. Used by agent0 to reset a clone
//! whose context is polluted with unrelated prior work.
//!
//! Runtime selection:
//! - `--type claude` (default): Claude Code in a tmux-resident session
//!   (the original shape).
//! - `--type codex`: codex REPL in a tmux-resident session
//!   (resident-codex parity v0). Inbox delivery uses
//!   `netsky channel drain <agent>` from inside the REPL; outbound
//!   replies use `netsky channel send <target> <text>`.

use std::time::Duration;

use chrono::Utc;
use netsky_core::agent::AgentId;
use netsky_core::consts::RESTART_AGENT0_TOS_WAIT_S;
use netsky_core::runtime::CodexConfig;
use netsky_core::runtime::Runtime;
use netsky_core::spawn::{self, SpawnOptions, SpawnOutcome};
use netsky_db::CloneDispatchRecord;

use crate::observability;

/// Spawn a resident agent (claude default, codex on explicit opt-in).
pub fn run(n: u32, fresh: bool) -> netsky_core::Result<()> {
    spawn_resident(n, fresh, RuntimeKind::Claude, true).map(|_| ())
}

/// Spawn a tmux-resident codex agent. Called by cli::dispatch when
/// `--type codex` is passed without `--prompt` or `--drain`.
pub fn run_codex_resident(n: u32, fresh: bool) -> netsky_core::Result<()> {
    spawn_resident(n, fresh, RuntimeKind::Codex, true).map(|_| ())
}

#[derive(Clone, Copy)]
pub(crate) enum RuntimeKind {
    Claude,
    Codex,
}

pub(crate) fn spawn_resident(
    n: u32,
    fresh: bool,
    kind: RuntimeKind,
    announce: bool,
) -> netsky_core::Result<SpawnOutcome> {
    let agent = AgentId::from_number(n);
    // Pin spawn cwd to the resolved netsky root, NOT whatever shell
    // cwd agent0 happened to be in. Closes briefs/clone-cwd-pin.md
    // (agent5): a clone spawned from `workspaces/foo/repo` previously
    // inherited that cwd silently and broke /up.
    let cwd = netsky_core::paths::netsky_root_or_cwd()?;
    let runtime = match kind {
        RuntimeKind::Claude => Runtime::defaults_for(agent),
        RuntimeKind::Codex => Runtime::Codex(CodexConfig::defaults_for()),
    };
    let opts = SpawnOptions {
        runtime,
        cwd: cwd.clone(),
        claude_settings_template: clone_settings_template(agent, kind),
    };

    if fresh && spawn::is_up(agent) {
        spawn::kill(agent)?;
        if announce {
            println!("[agent] killed existing session '{agent}' for fresh spawn");
        }
    }

    match spawn::spawn(agent, &opts)? {
        SpawnOutcome::AlreadyUp => {
            if announce {
                println!("[agent] session '{agent}' already up; skipping spawn");
            }
            Ok(SpawnOutcome::AlreadyUp)
        }
        SpawnOutcome::Spawned => {
            let tag = if fresh { "fresh" } else { "spawned" };
            if announce {
                println!("[agent] {tag} '{agent}' ({})", opts.runtime.describe());
            }
            // Record clone-dispatch start. Lifetime end is populated
            // either by the clone's own /down skill or by a /gc sweep.
            // Clone lifetime analytics derive from clone_dispatches;
            // see the clone_lifetimes view in netsky-db.
            let runtime_label = match kind {
                RuntimeKind::Claude => "claude",
                RuntimeKind::Codex => "codex",
            };
            let agent_name = agent.name();
            observability::record_clone_dispatch(CloneDispatchRecord {
                ts_utc_start: Utc::now(),
                ts_utc_end: None,
                agent_id: &agent_name,
                runtime: Some(runtime_label),
                brief_path: None,
                brief: None,
                workspace: None,
                branch: None,
                status: Some("spawned"),
                exit_code: None,
                detail_json: None,
            });
            // Auto-dismiss the dev-channels TOS dialog for claude. Codex
            // has no equivalent one-shot consent dialog on spawn, so
            // skip the probe there (it would just burn up to the
            // RESTART_AGENT0_TOS_WAIT_S wait waiting for a string that
            // never appears).
            if matches!(kind, RuntimeKind::Claude)
                && spawn::dismiss_tos(
                    &agent.name(),
                    Duration::from_secs(RESTART_AGENT0_TOS_WAIT_S),
                )
                && announce
            {
                println!("[agent] {agent}: TOS dismissed");
            }
            Ok(SpawnOutcome::Spawned)
        }
    }
}

fn clone_settings_template(agent: AgentId, kind: RuntimeKind) -> Option<String> {
    if matches!(agent, AgentId::Clone(_)) && matches!(kind, RuntimeKind::Claude) {
        Some(crate::clone_tools::CLAUDE_CLONE_SETTINGS_TEMPLATE.to_string())
    } else {
        None
    }
}