Vectrum
Sign In
558 lines · 20.1 KB · Rust
//! Spawns the long-running agent process behind a thread: the `claude` CLI
//! speaking stream-json (the transport the Claude Agent SDKs use), Pi in its
//! JSONL RPC mode, or jcode's harness API bridge, which connects to jcode's
//! shared daemon. All read commands on stdin and stream events on stdout.

use std::{
    io::{BufRead, BufReader, Write},
    os::unix::process::CommandExt,
    path::{Path, PathBuf},
    process::{Child, Command, Stdio},
    sync::{Arc, Mutex, mpsc},
    thread,
};

use anyhow::{Context as _, Result, anyhow};
use serde_json::{Value, json};

use crate::browser;
use crate::orchestrator;
use crate::models::Provider;
use crate::settings::{ProviderInstance, expand_home};

/// Environment variables that mark a process as running *inside* another
/// Claude Code session. They leak in when this app is launched from a Claude
/// Code terminal and make the child CLI behave like a nested session.
const NESTED_SESSION_ENV: &[&str] = &[
    "CLAUDECODE",
    "CLAUDE_CODE_ENTRYPOINT",
    "CLAUDE_CODE_SESSION_ID",
    "CLAUDE_CODE_CHILD_SESSION",
    "CLAUDE_CODE_SESSION_ATTENDED",
    "CLAUDE_CODE_EXECPATH",
    "CLAUDE_CODE_MESSAGING_SOCKET",
    "CLAUDE_CODE_MESSAGING_TOKEN",
    "CLAUDE_AGENT_SDK_VERSION",
    "CLAUDE_PID",
];

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PermissionMode {
    #[default]
    Default,
    AcceptEdits,
    /// Claude's classifier approves routine actions and asks about the rest.
    Auto,
    Plan,
    BypassPermissions,
}

impl PermissionMode {
    pub const ALL: [PermissionMode; 5] = [
        PermissionMode::Default,
        PermissionMode::AcceptEdits,
        PermissionMode::Auto,
        PermissionMode::Plan,
        PermissionMode::BypassPermissions,
    ];

    pub fn as_cli(self) -> &'static str {
        match self {
            PermissionMode::Default => "default",
            PermissionMode::AcceptEdits => "acceptEdits",
            PermissionMode::Auto => "auto",
            PermissionMode::Plan => "plan",
            PermissionMode::BypassPermissions => "bypassPermissions",
        }
    }

    pub fn from_cli(value: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|mode| mode.as_cli() == value)
    }

    pub fn label(self) -> &'static str {
        match self {
            PermissionMode::Default => "Supervised",
            PermissionMode::AcceptEdits => "Auto-accept edits",
            PermissionMode::Auto => "Auto",
            PermissionMode::Plan => "Plan",
            PermissionMode::BypassPermissions => "Full access",
        }
    }

    pub fn description(self) -> &'static str {
        match self {
            PermissionMode::Default => "Ask before commands and file changes.",
            PermissionMode::AcceptEdits => "Auto-approve edits, ask before other actions.",
            PermissionMode::Auto => "Supported providers approve routine actions; others still ask.",
            PermissionMode::Plan => "Read-only: Claude plans before it changes anything.",
            PermissionMode::BypassPermissions => "Allow commands and edits without prompts.",
        }
    }
}

/// How hard the model thinks: Claude's `--effort` levels and Pi's thinking
/// levels, which add `off` and `minimal`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Effort {
    Off,
    Minimal,
    Low,
    Medium,
    High,
    XHigh,
    Max,
}

impl Effort {
    pub const ALL: [Effort; 7] =
        [Effort::Off, Effort::Minimal, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];

    pub fn as_cli(self) -> &'static str {
        match self {
            Effort::Off => "off",
            Effort::Minimal => "minimal",
            Effort::Low => "low",
            Effort::Medium => "medium",
            Effort::High => "high",
            Effort::XHigh => "xhigh",
            Effort::Max => "max",
        }
    }

    pub fn from_cli(value: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|effort| effort.as_cli() == value)
    }

    pub fn label(self) -> &'static str {
        match self {
            Effort::Off => "Off",
            Effort::Minimal => "Minimal",
            Effort::Low => "Low",
            Effort::Medium => "Medium",
            Effort::High => "High",
            Effort::XHigh => "Extra High",
            Effort::Max => "Max",
        }
    }
}

#[derive(Clone, Debug)]
pub struct LaunchConfig {
    /// Which agent, with its binary, home folder, arguments and environment.
    pub instance: ProviderInstance,
    pub cwd: PathBuf,
    /// A Claude model alias, `provider/model` for Pi, or a jcode model spec.
    /// `None` is the default.
    pub model: Option<String>,
    pub permission_mode: PermissionMode,
    /// `None` leaves effort at the model's default.
    pub effort: Option<Effort>,
    /// Session to resume (a Claude or jcode session id, or a Pi session
    /// file), used to restart a thread whose process stopped.
    pub resume: Option<String>,
    /// The thread's agent-browser session, shared with the browser panel.
    pub browser_session: Option<String>,
    /// The thread the agent runs for.
    pub thread: String,
    /// Which of the app's tools it gets: subagents (not in subagents' own
    /// threads) and devices (when agent device access is on).
    pub tools: orchestrator::Caps,
}

/// Everything the background reader threads report back to the UI.
#[derive(Debug)]
pub enum ProcessEvent {
    Message(Value),
    Stderr(String),
    Exited(Option<i32>),
}

pub struct AgentProcess {
    child: Arc<Mutex<Child>>,
    stdin_tx: mpsc::Sender<String>,
}

impl AgentProcess {
    pub fn spawn(config: &LaunchConfig, events: smol::channel::Sender<ProcessEvent>) -> Result<Self> {
        let (binary, mut command) = match config.instance.kind {
            Provider::Claude => claude_command(config)?,
            Provider::Pi => pi_command(config)?,
            // jcode's tools run in its daemon, out of reach of this process's
            // environment; the thread tells it about the browser in a message.
            Provider::Jcode => jcode_command(config)?,
        };
        // The app's tools; jcode's are registered over its API.
        orchestrator::set_caps(&config.thread, config.tools);
        match config.instance.kind {
            Provider::Claude => {
                if let Some(mcp) = config.tools.any().then(|| orchestrator::claude_config(&config.thread)).flatten() {
                    let allowed = format!("mcp__{}", orchestrator::SERVER);
                    command.args(["--mcp-config", &mcp, "--allowedTools", &allowed]);
                }
            }
            Provider::Pi => orchestrator::prepare_pi(&mut command, &config.thread, config.tools),
            Provider::Jcode => {}
        }
        // The agent-device launcher, on PATH for agents that may drive devices.
        if config.tools.devices {
            crate::device::agent::prepare_command(&mut command);
        }
        // Claude and Pi take extra system prompt with this flag.
        if config.instance.kind != Provider::Jcode
            && let Some(session) = &config.browser_session
            && let Some(prompt) = browser::prepare_agent(&mut command, session)
        {
            command.args(["--append-system-prompt", &prompt]);
        }
        Self::spawn_command(&binary, command, events)
    }

    fn spawn_command(
        binary: &Path,
        mut command: Command,
        events: smol::channel::Sender<ProcessEvent>,
    ) -> Result<Self> {
        command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
        // Take the agent down with us if the app dies without cleaning up.
        unsafe {
            command.pre_exec(|| {
                libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
                Ok(())
            });
        }

        let mut child = command
            .spawn()
            .with_context(|| format!("failed to start {}", binary.display()))?;
        let stdout = child.stdout.take().context("missing stdout pipe")?;
        let stderr = child.stderr.take().context("missing stderr pipe")?;
        let mut stdin = child.stdin.take().context("missing stdin pipe")?;
        let child = Arc::new(Mutex::new(child));

        // Writes happen on their own thread so the UI never blocks on a full pipe.
        let (stdin_tx, stdin_rx) = mpsc::channel::<String>();
        thread::Builder::new()
            .name("agent-stdin".into())
            .spawn(move || {
                for line in stdin_rx {
                    if stdin.write_all(line.as_bytes()).is_err()
                        || stdin.write_all(b"\n").is_err()
                        || stdin.flush().is_err()
                    {
                        break;
                    }
                }
            })?;

        let stderr_events = events.clone();
        thread::Builder::new()
            .name("agent-stderr".into())
            .spawn(move || {
                for line in BufReader::new(stderr).lines().map_while(Result::ok) {
                    if !line.trim().is_empty() {
                        let _ = stderr_events.send_blocking(ProcessEvent::Stderr(line));
                    }
                }
            })?;

        let reader_child = child.clone();
        thread::Builder::new()
            .name("agent-stdout".into())
            .spawn(move || {
                // `lines()` splits on LF only, as Pi's JSONL framing requires.
                for line in BufReader::new(stdout).lines().map_while(Result::ok) {
                    let line = line.trim();
                    if line.is_empty() {
                        continue;
                    }
                    let event = match serde_json::from_str::<Value>(line) {
                        Ok(value) => ProcessEvent::Message(value),
                        Err(_) => ProcessEvent::Stderr(line.to_string()),
                    };
                    if events.send_blocking(event).is_err() {
                        return;
                    }
                }
                let code = reader_child
                    .lock()
                    .ok()
                    .and_then(|mut child| child.wait().ok())
                    .and_then(|status| status.code());
                let _ = events.send_blocking(ProcessEvent::Exited(code));
            })?;

        Ok(Self { child, stdin_tx })
    }

    pub fn send(&self, message: Value) {
        let _ = self.stdin_tx.send(message.to_string());
    }

    /// Sends a Claude user turn made of API content blocks (text, images).
    pub fn send_user_content(&self, content: Vec<Value>) {
        self.send(json!({
            "type": "user",
            "message": {"role": "user", "content": content},
            "parent_tool_use_id": null,
        }));
    }

    pub fn send_control(&self, request_id: &str, request: Value) {
        self.send(json!({"type": "control_request", "request_id": request_id, "request": request}));
    }

    pub fn respond_control(&self, request_id: &str, response: Value) {
        self.send(json!({
            "type": "control_response",
            "response": {"subtype": "success", "request_id": request_id, "response": response},
        }));
    }

    pub fn kill(&self) {
        if let Ok(mut child) = self.child.lock() {
            let _ = child.kill();
        }
    }
}

impl Drop for AgentProcess {
    fn drop(&mut self) {
        self.kill();
    }
}

fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
    let (binary, mut command) = base_command(&config.instance)?;
    command
        .args([
            "-p",
            "--input-format",
            "stream-json",
            "--output-format",
            "stream-json",
            "--verbose",
            "--include-partial-messages",
            "--permission-prompt-tool",
            "stdio",
            // Lets the user switch to "Full access" at runtime.
            "--allow-dangerously-skip-permissions",
            "--permission-mode",
            config.permission_mode.as_cli(),
        ])
        .current_dir(&config.cwd);
    if let Some(model) = &config.model {
        command.args(["--model", model]);
    }
    if let Some(effort) = config.effort {
        command.args(["--effort", effort.as_cli()]);
    }
    if let Some(session_id) = &config.resume {
        command.args(["--resume", session_id]);
    }
    if let Some(window) = auto_compact_window(&config.instance.auto_compact) {
        command.args(["--settings", &format!(r#"{{"autoCompactWindow":{window}}}"#)]);
    }
    command.args(split_args(&config.instance.args));
    Ok((binary, command))
}

/// The "Auto-compact after" field as a token count, if it's one Claude
/// takes: 100,000 to 1,000,000.
pub fn auto_compact_window(text: &str) -> Option<u64> {
    let digits: String = text.chars().filter(|c| !matches!(c, ',' | '_' | ' ')).collect();
    digits.parse::<u64>().ok().filter(|tokens| (100_000..=1_000_000).contains(tokens))
}

fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
    let (binary, mut command) = base_command(&config.instance)?;
    command.args(["--mode", "rpc"]).current_dir(&config.cwd);
    if let Some((provider, model)) = config.model.as_deref().and_then(|model| model.split_once('/')) {
        command.args(["--provider", provider, "--model", model]);
    }
    if let Some(effort) = config.effort {
        command.args(["--thinking", effort.as_cli()]);
    }
    if let Some(session) = &config.resume {
        command.args(["--session", session]);
    }
    command.args(split_args(&config.instance.args));
    Ok((binary, command))
}

/// The bridge to jcode's harness API on stdin and stdout. It starts jcode's
/// daemon if none is running; the thread then creates or attaches its
/// session over the API.
fn jcode_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
    let (binary, mut command) = base_command(&config.instance)?;
    command.args(split_args(&config.instance.args));
    command.args(["--no-update", "api", "--stdio"]).current_dir(&config.cwd);
    Ok((binary, command))
}

/// The environment variable that moves an agent's home folder.
pub fn home_var(kind: Provider) -> &'static str {
    match kind {
        Provider::Claude => "CLAUDE_CONFIG_DIR",
        Provider::Pi => "PI_CODING_AGENT_DIR",
        Provider::Jcode => "JCODE_HOME",
    }
}

/// The variable that overrides the binary of an agent's first instance.
pub fn binary_var(kind: Provider) -> &'static str {
    match kind {
        Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN",
        Provider::Pi => "VECTRUM_CODE_PI_BIN",
        Provider::Jcode => "VECTRUM_CODE_JCODE_BIN",
    }
}

/// A command for an agent's binary with the instance's home folder and
/// environment, and without the variables of any Claude session around us.
pub fn base_command(instance: &ProviderInstance) -> Result<(PathBuf, Command)> {
    let binary = find_binary(instance).ok_or_else(|| {
        anyhow!(
            "could not find the `{}` CLI on PATH (set its path in Settings → Providers)",
            instance.kind.key()
        )
    })?;
    let mut command = Command::new(&binary);
    for key in NESTED_SESSION_ENV {
        command.env_remove(key);
    }
    if !instance.home.trim().is_empty() {
        let home = expand_home(instance.home.trim());
        // jcode's daemon is found by its socket, which lives in the runtime
        // folder rather than the home; give each home its own daemon.
        if instance.kind == Provider::Jcode {
            let runtime = home.join("runtime");
            if std::fs::create_dir_all(&runtime).is_ok() {
                use std::os::unix::fs::PermissionsExt;
                let _ = std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700));
                command.env("JCODE_RUNTIME_DIR", runtime);
            }
        }
        command.env(home_var(instance.kind), home);
    }
    for (key, value) in instance.env_vars() {
        command.env(key, value);
    }
    // Pi is a Node script; a desktop launcher's PATH may not reach the Node it
    // was installed with, which usually sits next to it.
    if instance.kind == Provider::Pi
        && let Some(dir) = binary.parent()
    {
        let path = std::env::var_os("PATH").unwrap_or_default();
        let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path));
        if let Ok(joined) = std::env::join_paths(paths) {
            command.env("PATH", joined);
        }
    }
    Ok((binary, command))
}

/// Where an agent's binary is: the environment override (first instance
/// only), the instance's own path, then PATH and the usual install folders.
pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> {
    if instance.id == instance.kind.key()
        && let Some(path) = std::env::var_os(binary_var(instance.kind))
    {
        return Some(PathBuf::from(path));
    }
    if !instance.binary.trim().is_empty() {
        return Some(expand_home(instance.binary.trim()));
    }
    let name = instance.kind.key();
    let on_path = std::env::var_os("PATH")
        .into_iter()
        .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
        .map(|dir| dir.join(name));
    // Desktop launchers often start apps without the user's shell PATH.
    let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
    let mut fallbacks = match instance.kind {
        Provider::Claude => vec![home.join(".local/bin/claude"), home.join(".claude/local/claude")],
        Provider::Pi => vec![
            home.join(".local/bin/pi"),
            home.join(".npm-global/bin/pi"),
            home.join(".bun/bin/pi"),
            home.join(".volta/bin/pi"),
        ],
        Provider::Jcode => vec![home.join(".local/bin/jcode"), home.join(".cargo/bin/jcode")],
    };
    fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]);
    // nvm keeps one bin directory per Node version.
    if instance.kind == Provider::Pi
        && let Ok(versions) = std::fs::read_dir(home.join(".nvm/versions/node"))
    {
        fallbacks.extend(versions.flatten().map(|version| version.path().join("bin/pi")));
    }
    on_path.chain(fallbacks).find(|path| is_executable(path))
}

/// Splits launch arguments like a shell: on whitespace, keeping quoted runs
/// together.
pub fn split_args(text: &str) -> Vec<String> {
    let mut args = Vec::new();
    let mut current = String::new();
    let mut quote: Option<char> = None;
    let mut started = false;
    let mut chars = text.chars();
    while let Some(c) = chars.next() {
        match (quote, c) {
            (Some(q), c) if c == q => quote = None,
            (Some('"'), '\\') => current.extend(chars.next()),
            (Some(_), c) => current.push(c),
            (None, '"' | '\'') => {
                quote = Some(c);
                started = true;
            }
            (None, '\\') => {
                current.extend(chars.next());
                started = true;
            }
            (None, c) if c.is_whitespace() => {
                if started || !current.is_empty() {
                    args.push(std::mem::take(&mut current));
                }
                started = false;
            }
            (None, c) => {
                current.push(c);
                started = true;
            }
        }
    }
    if started || !current.is_empty() {
        args.push(current);
    }
    args
}

pub fn is_executable(path: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    path.metadata()
        .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::{auto_compact_window, split_args};

    #[::core::prelude::v1::test]
    fn auto_compact_takes_claudes_range() {
        assert_eq!(auto_compact_window("300000"), Some(300_000));
        assert_eq!(auto_compact_window("1,000,000"), Some(1_000_000));
        assert_eq!(auto_compact_window("99999"), None);
        assert_eq!(auto_compact_window("2000000"), None);
        assert_eq!(auto_compact_window(""), None);
    }

    #[::core::prelude::v1::test]
    fn splits_like_a_shell() {
        assert_eq!(split_args(r#"--a 1 --b "two words" 'x y' z\ w"#), ["--a", "1", "--b", "two words", "x y", "z w"]);
        assert_eq!(split_args("  "), Vec::<String>::new());
        assert_eq!(split_args(r#"--empty """#), ["--empty", ""]);
    }
}