malvin 0.2.6

Non-interactive research and coding agent
use std::process::Stdio;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;

use tokio::io::BufReader;
use tokio::sync::Mutex as AsyncMutex;

use super::discover::resolve_npm_pi_entry;
use super::session::NpmPiSession;
use crate::acp::AgentError;
use crate::bridge_sdk::{BridgeSpawnArgs, StreamLog};

pub(super) type NpmPiProcess = (
    tokio::process::Child,
    tokio::process::ChildStdin,
    tokio::process::ChildStdout,
    Option<u32>,
    std::collections::HashSet<u32>,
);

pub(super) fn spawn_npm_pi_session(
    args: &BridgeSpawnArgs<'_>,
    ticket: crate::malvin_sandbox::SandboxSpawnTicket,
) -> Result<NpmPiSession, AgentError> {
    let process = spawn_npm_pi_process(args)?;
    build_npm_pi_session(args, process, ticket)
}

pub(super) fn spawn_npm_pi_process(args: &BridgeSpawnArgs<'_>) -> Result<NpmPiProcess, AgentError> {
    let entry = resolve_npm_pi_entry().map_err(AgentError)?;
    let node = crate::cursor_sdk::node_resolve::resolve_node_bin().map_err(AgentError)?;
    let mut cmd = configured_npm_pi_command(node, entry, args)?;
    let mut child = cmd
        .spawn()
        .map_err(|e| AgentError(format!("spawn npm pi rpc: {e}")))?;
    let stdin = child
        .stdin
        .take()
        .ok_or_else(|| AgentError("npm pi stdin missing".into()))?;
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| AgentError("npm pi stdout missing".into()))?;
    let pgid = child.id();
    Ok((child, stdin, stdout, pgid, std::collections::HashSet::new()))
}

fn configured_npm_pi_command(
    node: std::path::PathBuf,
    entry: std::path::PathBuf,
    args: &BridgeSpawnArgs<'_>,
) -> Result<tokio::process::Command, AgentError> {
    let (provider, model) = args.model.pi_provider_and_model().ok_or_else(|| {
        AgentError(format!(
            "pi model id must be `pi:<provider>/<model>` (got `{}`)",
            args.model.canonical()
        ))
    })?;
    let mut cmd = crate::malvin_sandbox::malvin_tokio_command(node);
    cmd.arg(&entry);
    if !entry_is_rpc_entry(&entry) {
        cmd.arg("--mode").arg("rpc");
    }
    append_provider_model(&mut cmd, provider, model, args);
    Ok(cmd)
}

fn append_provider_model(
    cmd: &mut tokio::process::Command,
    provider: &str,
    model: &str,
    args: &BridgeSpawnArgs<'_>,
) {
    cmd.arg("--provider")
        .arg(provider)
        .arg("--model")
        .arg(model)
        .arg("--no-session")
        .current_dir(args.cwd)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::inherit())
        .env("MALLOC_ARENA_MAX", "2");
    if let Some(thinking) = args.thinking {
        cmd.arg("--thinking").arg(thinking);
    }
}

fn entry_is_rpc_entry(entry: &std::path::Path) -> bool {
    entry
        .file_name()
        .and_then(|n| n.to_str())
        .is_some_and(|n| n.contains("rpc-entry"))
}

pub(super) fn build_npm_pi_session(
    args: &BridgeSpawnArgs<'_>,
    process: NpmPiProcess,
    ticket: crate::malvin_sandbox::SandboxSpawnTicket,
) -> Result<NpmPiSession, AgentError> {
    let (child, stdin, stdout, pgid, mut baseline) = process;
    baseline.extend(crate::malvin_sandbox::malvin_spawn_baseline());
    crate::malvin_sandbox::note_active_sandbox_session(
        ticket,
        pgid,
        baseline.clone(),
        args.cwd,
    )
    .map_err(AgentError)?;
    Ok(NpmPiSession {
        child: AsyncMutex::new(Some(child)),
        stdin: Arc::new(AsyncMutex::new(stdin)),
        stdout: Arc::new(AsyncMutex::new(BufReader::new(stdout))),
        process_group_id: pgid,
        spawn_pid_baseline: baseline,
        reader_dead: Arc::new(AtomicBool::new(false)),
        work_dir: args.cwd.to_path_buf(),
        log: StreamLog::from_spawn(args),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rpc_entry_detection() {
        assert!(entry_is_rpc_entry(std::path::Path::new(
            "/tmp/dist/bundle/rpc-entry.js"
        )));
        assert!(!entry_is_rpc_entry(std::path::Path::new(
            "/tmp/dist/bundle/cli.js"
        )));
    }

    #[test]
    fn kiss_cov_process_names() {
        let _ = spawn_npm_pi_session;
        let _ = spawn_npm_pi_process;
        let _ = configured_npm_pi_command;
        let _ = build_npm_pi_session;
        let _ = entry_is_rpc_entry;
    }
}