//! 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::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>,
}
/// 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)?,
};
// 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]);
}
command.args(split_args(&config.instance.args));
Ok((binary, command))
}
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 var in &instance.env {
if !var.key.trim().is_empty() {
command.env(var.key.trim(), &var.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::split_args;
#[::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", ""]);
}
}