//! 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::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::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, pub permission_mode: PermissionMode, /// `None` leaves effort at the model's default. pub effort: Option, /// 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, /// The thread's agent-browser session, shared with the browser panel. pub browser_session: Option, /// 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), } pub struct AgentProcess { child: Arc>, stdin_tx: mpsc::Sender, } impl AgentProcess { pub fn spawn(config: &LaunchConfig, events: smol::channel::Sender) -> Result { 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, ) -> Result { 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::(); 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::(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) { 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 { let digits: String = text.chars().filter(|c| !matches!(c, ',' | '_' | ' ')).collect(); digits.parse::().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 { 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::>()) .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 { let mut args = Vec::new(); let mut current = String::new(); let mut quote: Option = 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::::new()); assert_eq!(split_args(r#"--empty """#), ["--empty", ""]); } }