Vectrum
Sign In
993 lines · 39.3 KB · Rust
//! Every agent the app can run and what it offers, in one catalog the
//! pickers and settings read. Each configured provider instance gets a
//! background health check: Claude's models, account, slash commands and
//! usage limits come from a short-lived `claude` asked over its control
//! channel, Pi's models from asking `pi` for what its providers offer,
//! jcode's from the model routes its daemon reports. Also the user's
//! favorite models.

use std::{
    io::{BufRead, BufReader, Write},
    path::Path,
    process::Stdio,
    sync::mpsc,
    time::{Duration, Instant, SystemTime},
};

use gpui_kit::*;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use crate::claude::{Effort, base_command};
use crate::settings::{self, ProviderInstance};

/// The kind of agent behind a thread.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
    #[default]
    Claude,
    Pi,
    Jcode,
}

impl Provider {
    pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Pi, Provider::Jcode];

    pub fn label(self) -> &'static str {
        match self {
            Provider::Claude => "Claude",
            Provider::Pi => "Pi",
            Provider::Jcode => "jcode",
        }
    }

    /// The agent's full name, for menus and messages.
    pub fn agent_name(self) -> &'static str {
        match self {
            Provider::Claude => "Claude Code",
            Provider::Pi => "Pi",
            Provider::Jcode => "jcode",
        }
    }

    pub fn key(self) -> &'static str {
        match self {
            Provider::Claude => "claude",
            Provider::Pi => "pi",
            Provider::Jcode => "jcode",
        }
    }

    /// Whether the agent can answer a one-off prompt, for text generation,
    /// without keeping a session around. Every jcode run is saved.
    pub fn answers_one_off(self) -> bool {
        self != Provider::Jcode
    }

    /// Whether the agent asks before running tools. Pi and jcode just run them.
    pub fn asks_permission(self) -> bool {
        self == Provider::Claude
    }

    pub fn from_key(key: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|provider| provider.key() == key)
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct ModelEntry {
    pub provider: Provider,
    /// The provider instance offering it (`claude`, `claude-2`, `pi`).
    pub instance: String,
    /// What the agent is launched with: a Claude alias or model id, Pi's
    /// `provider/model`, or a jcode model spec like `claude-oauth:claude-opus-5`.
    pub id: String,
    /// The concrete model, e.g. `claude-opus-5-5` behind the `opus` alias.
    pub resolved: String,
    pub name: String,
    pub detail: String,
    /// Older Claude models, listed under "Legacy models".
    pub legacy: bool,
    /// Reasoning levels the model accepts.
    pub efforts: Vec<Effort>,
    pub fast_mode: bool,
    pub auto_mode: bool,
    pub context_window: Option<u64>,
}

impl ModelEntry {
    /// The key favorites and defaults are stored under.
    pub fn key(&self) -> String {
        format!("{}:{}", self.instance, self.id)
    }
}

/// A `/` command or skill an agent offers.
#[derive(Clone, Debug, PartialEq)]
pub struct SlashCommand {
    pub name: String,
    pub description: String,
    pub argument_hint: String,
    /// Installed skills, as opposed to the agent's own commands.
    pub skill: bool,
}

#[derive(Clone, Debug, PartialEq)]
pub enum ProbeStatus {
    /// Turned off in the settings.
    Disabled,
    Checking,
    Ready,
    /// The binary isn't installed (or isn't on a path we look at).
    Missing,
    Failed(String),
}

/// What the last health check found out about one provider instance.
#[derive(Clone, Debug)]
pub struct InstanceState {
    pub config: ProviderInstance,
    pub status: ProbeStatus,
    /// A check is running; the fields keep the previous one's findings.
    pub checking: bool,
    /// The models the agent offers plus the custom ones, in the order the
    /// settings put them.
    pub models: Vec<ModelEntry>,
    /// The models as the agent reported them.
    reported: Vec<ModelEntry>,
    /// Claude's `default` entry, which the picker shows as the model it resolves to.
    pub default: Option<ModelEntry>,
    /// The signed-in account (Claude's email).
    pub account: Option<String>,
    /// The subscription or login (`Claude Team`).
    pub plan: Option<String>,
    pub version: Option<String>,
    /// The newest published version, once an update check ran.
    pub latest: Option<String>,
    /// Claude's `get_usage` reply: plan limits and how much of them is used.
    pub usage: Option<Value>,
    pub commands: Vec<SlashCommand>,
    pub checked: Option<SystemTime>,
}

impl InstanceState {
    fn new(config: ProviderInstance) -> Self {
        Self {
            status: if config.enabled { ProbeStatus::Checking } else { ProbeStatus::Disabled },
            config,
            checking: false,
            models: Vec::new(),
            reported: Vec::new(),
            default: None,
            account: None,
            plan: None,
            version: None,
            latest: None,
            usage: None,
            commands: Vec::new(),
            checked: None,
        }
    }

    pub fn id(&self) -> &str {
        &self.config.id
    }

    pub fn label(&self) -> String {
        self.config.label()
    }

    pub fn kind(&self) -> Provider {
        self.config.kind
    }

    /// Whether a newer version than the installed one is out.
    pub fn update_available(&self) -> bool {
        match (&self.version, &self.latest) {
            (Some(version), Some(latest)) => version_newer(latest, version),
            _ => false,
        }
    }

    /// Whether a model was added by hand rather than reported by the agent.
    pub fn is_custom(&self, model: &ModelEntry) -> bool {
        self.config.custom_models.iter().any(|custom| custom.slug == model.id)
            && !self.reported.iter().any(|reported| reported.id == model.id)
    }

    /// Takes the agent's model list, adding the custom models and putting
    /// them in the settings' order.
    fn set_reported(&mut self, models: Vec<ModelEntry>) {
        self.reported = models;
        self.arrange();
    }

    fn arrange(&mut self) {
        let mut models = self.reported.clone();
        for custom in &self.config.custom_models {
            if custom.slug.trim().is_empty() || models.iter().any(|model| model.id == custom.slug) {
                continue;
            }
            models.push(ModelEntry {
                provider: self.config.kind,
                instance: self.config.id.clone(),
                id: custom.slug.clone(),
                resolved: custom.slug.clone(),
                name: if custom.name.trim().is_empty() { custom.slug.clone() } else { custom.name.trim().to_string() },
                detail: "Custom".into(),
                legacy: false,
                efforts: Vec::new(),
                fast_mode: false,
                auto_mode: false,
                context_window: None,
            });
        }
        let order = settings::get(|settings| settings.model_order.clone());
        models.sort_by_key(|model| order.iter().position(|key| *key == model.key()).unwrap_or(usize::MAX));
        self.models = models;
    }
}

pub struct Catalog {
    pub instances: Vec<InstanceState>,
    favorites: Vec<String>,
}

impl Global for Catalog {}

/// What one health check of an instance found.
#[derive(Default)]
struct Probe {
    models: Vec<ModelEntry>,
    default: Option<ModelEntry>,
    account: Option<String>,
    plan: Option<String>,
    version: Option<String>,
    usage: Option<Value>,
    commands: Vec<SlashCommand>,
}

impl Catalog {
    pub fn init(cx: &mut App) {
        let saved = settings::load();
        cx.set_global(Catalog {
            instances: saved.providers.into_iter().map(InstanceState::new).collect(),
            favorites: saved.favorites,
        });
        Self::refresh_all(cx);
    }

    pub fn global(cx: &App) -> &Catalog {
        cx.global::<Catalog>()
    }

    /// Picks up added, removed and edited instances from the settings, and
    /// checks the ones that changed.
    pub fn sync_instances(cx: &mut App) {
        let configs = settings::instances();
        let catalog = cx.global_mut::<Catalog>();
        let mut changed = Vec::new();
        let mut instances = Vec::new();
        for config in configs {
            match catalog.instances.iter().position(|state| state.config.id == config.id) {
                Some(ix) => {
                    let mut state = catalog.instances[ix].clone();
                    if state.config != config {
                        let enabled = config.enabled;
                        state.config = config;
                        state.arrange();
                        if !enabled {
                            state.status = ProbeStatus::Disabled;
                        }
                        changed.push(state.config.id.clone());
                    }
                    instances.push(state);
                }
                None => {
                    changed.push(config.id.clone());
                    instances.push(InstanceState::new(config));
                }
            }
        }
        catalog.instances = instances;
        for id in changed {
            Self::refresh(&id, cx);
        }
        cx.refresh_windows();
    }

    /// Checks every enabled instance again.
    pub fn refresh_all(cx: &mut App) {
        let ids: Vec<String> = Self::global(cx).instances.iter().map(|state| state.config.id.clone()).collect();
        for id in ids {
            Self::refresh(&id, cx);
        }
    }

    /// Checks one instance in the background: its version, account, models,
    /// commands and limits.
    pub fn refresh(id: &str, cx: &mut App) {
        let update_checks = settings::get(|settings| settings.update_checks);
        let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id) else {
            return;
        };
        if !state.config.enabled {
            state.status = ProbeStatus::Disabled;
            cx.refresh_windows();
            return;
        }
        if state.checking {
            return;
        }
        state.checking = true;
        if state.checked.is_none() {
            state.status = ProbeStatus::Checking;
        }
        let config = state.config.clone();
        cx.refresh_windows();
        let id = id.to_string();
        let probe = cx.background_executor().spawn(async move {
            let probe = match config.kind {
                Provider::Claude => probe_claude(&config),
                Provider::Pi => probe_pi(&config),
                Provider::Jcode => probe_jcode(&config),
            };
            let latest = if update_checks { latest_version(config.kind) } else { None };
            (probe, latest)
        });
        cx.spawn(async move |cx| {
            let (result, latest) = probe.await;
            cx.update(|cx| {
                let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id)
                else {
                    return;
                };
                state.checking = false;
                state.checked = Some(SystemTime::now());
                if latest.is_some() {
                    state.latest = latest;
                }
                match result {
                    Ok(probe) => {
                        state.status = ProbeStatus::Ready;
                        if !probe.models.is_empty() || state.config.kind != Provider::Claude {
                            state.set_reported(probe.models);
                            state.default = probe.default;
                        }
                        state.account = probe.account.or(state.account.take());
                        state.plan = probe.plan.or(state.plan.take());
                        state.version = probe.version.or(state.version.take());
                        if probe.usage.is_some() {
                            state.usage = probe.usage;
                        }
                        if !probe.commands.is_empty() {
                            state.commands = probe.commands;
                        }
                    }
                    Err(status) => {
                        if state.config.enabled {
                            state.status = status;
                        }
                        if state.config.kind != Provider::Claude {
                            state.set_reported(Vec::new());
                        }
                    }
                }
                cx.refresh_windows();
            })
        })
        .detach();
    }

    pub fn instance(&self, id: &str) -> Option<&InstanceState> {
        self.instances.iter().find(|state| state.config.id == id)
    }

    /// The enabled instances, in the order the settings list them.
    pub fn enabled(&self) -> impl Iterator<Item = &InstanceState> {
        self.instances.iter().filter(|state| state.config.enabled)
    }

    pub fn label(&self, id: &str) -> String {
        self.instance(id).map(InstanceState::label).unwrap_or_else(|| id.to_string())
    }

    /// The first enabled instance of a kind.
    pub fn first_of(&self, kind: Provider) -> Option<&InstanceState> {
        self.enabled().find(|state| state.config.kind == kind)
    }

    /// Takes Claude's model list, account and commands from a session's
    /// `initialize` reply.
    pub fn set_claude_info(instance: &str, info: &Value, cx: &mut App) {
        let probe = claude_probe_from(instance, info);
        let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == instance)
        else {
            return;
        };
        let mut changed = false;
        if probe.account.is_some() && state.account != probe.account {
            state.account = probe.account;
            state.plan = probe.plan;
            changed = true;
        }
        if !probe.models.is_empty() && state.reported != probe.models {
            state.set_reported(probe.models);
            state.default = probe.default;
            changed = true;
        }
        if !probe.commands.is_empty() && state.commands != probe.commands {
            state.commands = probe.commands;
            changed = true;
        }
        if state.status != ProbeStatus::Ready {
            state.status = ProbeStatus::Ready;
            changed = true;
        }
        if changed {
            cx.refresh_windows();
        }
    }

    pub fn models(&self, instance: &str) -> &[ModelEntry] {
        self.instance(instance).map(|state| state.models.as_slice()).unwrap_or_default()
    }

    /// Every enabled instance's models.
    pub fn all_models(&self) -> impl Iterator<Item = &ModelEntry> {
        self.enabled().flat_map(|state| &state.models)
    }

    /// The entry for a thread's model; `None` means the provider's default.
    pub fn find(&self, instance: &str, id: Option<&str>) -> Option<&ModelEntry> {
        let state = self.instance(instance)?;
        match id {
            None => state.default.as_ref(),
            Some(id) => state.models.iter().find(|model| model.id == id),
        }
    }

    /// The entry stored under `key` (see [`ModelEntry::key`]).
    pub fn find_key(&self, key: &str) -> Option<&ModelEntry> {
        self.instances.iter().flat_map(|state| &state.models).find(|model| model.key() == key)
    }

    pub fn commands(&self, instance: &str) -> &[SlashCommand] {
        self.instance(instance).map(|state| state.commands.as_slice()).unwrap_or_default()
    }

    /// Puts every instance's models in the order the settings give them,
    /// after that order changed.
    pub fn rearrange(cx: &mut App) {
        for state in &mut cx.global_mut::<Catalog>().instances {
            state.arrange();
        }
        cx.refresh_windows();
    }

    /// Whether the settings hide a model from the model picker.
    pub fn is_hidden(&self, model: &ModelEntry) -> bool {
        let key = model.key();
        settings::get(|settings| settings.hidden_models.contains(&key))
    }

    pub fn is_favorite(&self, model: &ModelEntry) -> bool {
        self.favorites.contains(&model.key())
    }

    pub fn favorites(&self) -> Vec<&ModelEntry> {
        self.favorites.iter().filter_map(|key| self.all_models().find(|model| &model.key() == key)).collect()
    }

    pub fn toggle_favorite(model: &ModelEntry, cx: &mut App) {
        let key = model.key();
        let catalog = cx.global_mut::<Catalog>();
        match catalog.favorites.iter().position(|favorite| *favorite == key) {
            Some(ix) => {
                catalog.favorites.remove(ix);
            }
            None => catalog.favorites.push(key),
        }
        let favorites = catalog.favorites.clone();
        settings::update(|settings| settings.favorites = favorites);
        cx.refresh_windows();
    }
}

/// Splits a model key into its instance and model id. Keys from before
/// instances existed name the kind, which is also its first instance's id.
pub fn split_key(key: &str) -> Option<(&str, &str)> {
    key.split_once(':')
}

/// Parses Claude's `initialize` reply: the models (the default entry apart),
/// the account and the commands.
fn claude_probe_from(instance: &str, info: &Value) -> Probe {
    let mut default = None;
    let mut models = Vec::new();
    for model in info.get("models").and_then(Value::as_array).into_iter().flatten() {
        let Some(id) = model.get("value").and_then(Value::as_str) else { continue };
        let text = |key: &str| model.get(key).and_then(Value::as_str).unwrap_or_default().to_string();
        let flag = |key: &str| model.get(key).and_then(Value::as_bool).unwrap_or(false);
        let display = text("displayName");
        let entry = ModelEntry {
            provider: Provider::Claude,
            instance: instance.to_string(),
            id: id.to_string(),
            resolved: Some(text("resolvedModel")).filter(|model| !model.is_empty()).unwrap_or_else(|| id.into()),
            name: if display.starts_with("Claude") { display.clone() } else { format!("Claude {display}") },
            detail: text("description"),
            // Aliases (`opus`, `sonnet`) track the current models; full ids are older ones.
            legacy: id.starts_with("claude-"),
            efforts: model
                .get("supportedEffortLevels")
                .and_then(Value::as_array)
                .map(|levels| levels.iter().filter_map(Value::as_str).filter_map(Effort::from_cli).collect())
                .unwrap_or_default(),
            fast_mode: flag("supportsFastMode"),
            auto_mode: flag("supportsAutoMode"),
            context_window: None,
        };
        if id == "default" {
            default = Some(entry);
        } else {
            models.push(entry);
        }
    }
    // Present the default as the model it resolves to.
    if let Some(default) = default.as_mut()
        && let Some(current) = models.iter().find(|model| !model.legacy && model.resolved == default.resolved)
    {
        default.name = current.name.clone();
    }
    let commands = info
        .get("commands")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|command| {
            let name = command.get("name")?.as_str()?;
            // Internal commands start with underscores.
            (!name.starts_with('_')).then(|| SlashCommand {
                name: name.to_string(),
                description: command.get("description").and_then(Value::as_str).unwrap_or_default().to_string(),
                argument_hint: command.get("argumentHint").and_then(Value::as_str).unwrap_or_default().to_string(),
                // Claude's own commands are marked builtin; skills from
                // plugins and skill folders aren't.
                skill: command.get("builtin").and_then(Value::as_bool) != Some(true),
            })
        })
        .collect();
    Probe {
        models,
        default,
        account: info.pointer("/account/email").and_then(Value::as_str).map(str::to_string),
        plan: info
            .pointer("/account/subscriptionType")
            .and_then(Value::as_str)
            .map(|plan| if plan.to_lowercase().contains("subscription") { plan.to_string() } else { format!("{plan} Subscription") }),
        version: info.get("claude_code_version").and_then(Value::as_str).map(str::to_string),
        usage: None,
        commands,
    }
}

/// Starts `claude` just long enough to ask what it offers and how much of
/// the plan's limits is used. Nothing is sent to a model.
fn probe_claude(config: &ProviderInstance) -> Result<Probe, ProbeStatus> {
    let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string());
    let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?;
    command
        .args(["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"])
        .arg("--no-session-persistence")
        .current_dir(std::env::temp_dir())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = command.spawn().map_err(|err| failed(&err))?;
    let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?;
    let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
    let stderr = child.stderr.take().ok_or_else(|| failed(&"no stderr"))?;
    let request = |id: &str, subtype: &str| {
        json!({"type": "control_request", "request_id": id, "request": {"subtype": subtype}}).to_string()
    };
    writeln!(stdin, "{}", request("probe-init", "initialize")).map_err(|err| failed(&err))?;
    writeln!(stdin, "{}", request("probe-usage", "get_usage")).map_err(|err| failed(&err))?;

    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if let Ok(value) = serde_json::from_str::<Value>(&line)
                && value.get("type").and_then(Value::as_str) == Some("control_response")
                && tx.send(value["response"].clone()).is_err()
            {
                return;
            }
        }
    });
    let stderr_tail = std::thread::spawn(move || {
        BufReader::new(stderr).lines().map_while(Result::ok).filter(|line| !line.trim().is_empty()).last()
    });
    let deadline = Instant::now() + Duration::from_secs(25);
    let (mut init, mut usage) = (None, None);
    while init.is_none() || usage.is_none() {
        let Some(left) = deadline.checked_duration_since(Instant::now()) else { break };
        let Ok(response) = rx.recv_timeout(left) else { break };
        let ok = response.get("subtype").and_then(Value::as_str) == Some("success");
        match response.get("request_id").and_then(Value::as_str) {
            Some("probe-init") => init = Some(ok.then(|| response["response"].clone())),
            Some("probe-usage") => usage = Some(ok.then(|| response["response"].clone())),
            _ => {}
        }
    }
    drop(stdin);
    let _ = child.kill();
    let _ = child.wait();
    let Some(Some(info)) = init else {
        let detail = stderr_tail.join().ok().flatten().unwrap_or_else(|| "Claude Code didn't answer".into());
        return Err(failed(&detail));
    };
    let mut probe = claude_probe_from(&config.id, &info);
    probe.usage = usage.flatten().filter(|usage| usage.get("rate_limits_available").and_then(Value::as_bool) != Some(false));
    Ok(probe)
}

/// Starts `pi` just long enough to list the models its providers offer, and
/// asks its version.
fn probe_pi(config: &ProviderInstance) -> Result<Probe, ProbeStatus> {
    let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string());
    let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?;
    command
        .args(["--mode", "rpc", "--no-session"])
        .current_dir(std::env::temp_dir())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null());
    let mut child = command.spawn().map_err(|err| failed(&err))?;
    let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?;
    let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
    let request = json!({"id": "models", "type": "get_available_models"});
    writeln!(stdin, "{request}").map_err(|err| failed(&err))?;

    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if let Ok(value) = serde_json::from_str::<Value>(&line)
                && value.get("id").and_then(Value::as_str) == Some("models")
            {
                let _ = tx.send(value);
                return;
            }
        }
    });
    let reply = rx.recv_timeout(Duration::from_secs(30));
    drop(stdin);
    let _ = child.kill();
    let _ = child.wait();
    let reply = reply.map_err(|_| failed(&"pi didn't list its models"))?;
    if reply.get("success").and_then(Value::as_bool) != Some(true) {
        let error = reply.get("error").and_then(Value::as_str).unwrap_or("unknown error");
        return Err(failed(&error));
    }
    let models = reply
        .pointer("/data/models")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|model| pi_model_from(&config.id, model))
        .collect();
    let version = base_command(config).ok().and_then(|(_, mut command)| {
        let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?;
        first_version(&String::from_utf8_lossy(&output.stdout))
    });
    Ok(Probe { models, account: None, plan: None, version, ..Probe::default() })
}

pub fn pi_model_from(instance: &str, model: &Value) -> Option<ModelEntry> {
    let id = model.get("id")?.as_str()?;
    let provider = model.get("provider")?.as_str()?;
    let reasoning = model.get("reasoning").and_then(Value::as_bool).unwrap_or(false);
    Some(ModelEntry {
        provider: Provider::Pi,
        instance: instance.to_string(),
        id: format!("{provider}/{id}"),
        resolved: id.to_string(),
        name: model.get("name").and_then(Value::as_str).unwrap_or(id).to_string(),
        detail: provider_label(provider),
        legacy: false,
        efforts: if reasoning {
            vec![Effort::Off, Effort::Minimal, Effort::Low, Effort::Medium, Effort::High]
        } else {
            vec![Effort::Off]
        },
        fast_mode: false,
        auto_mode: false,
        context_window: model.get("contextWindow").and_then(Value::as_u64),
    })
}

/// Starts jcode's API bridge just long enough to open a throwaway session
/// and read the model routes its daemon offers. jcode only keeps sessions
/// that got a message, so the check leaves nothing behind.
fn probe_jcode(config: &ProviderInstance) -> Result<Probe, ProbeStatus> {
    let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string());
    let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?;
    command
        .args(["--no-update", "api", "--stdio"])
        .current_dir(std::env::temp_dir())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null());
    let mut child = command.spawn().map_err(|err| failed(&err))?;
    let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?;
    let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if let Ok(value) = serde_json::from_str::<Value>(&line)
                && tx.send(value).is_err()
            {
                return;
            }
        }
    });
    let mut send = |id: u64, request: Value| {
        let mut request = request;
        request["v"] = 1.into();
        request["id"] = id.into();
        writeln!(stdin, "{request}")
    };
    let hello = json!({"req": "hello", "min_version": 1, "max_version": 1, "client": crate::session::JCODE_CLIENT});
    let working_dir = std::env::temp_dir().display().to_string();
    send(1, hello)
        .and_then(|_| send(2, json!({"req": "create_session", "working_dir": working_dir})))
        .map_err(|err| failed(&err))?;

    // The daemon may need a while to start the first time.
    let deadline = Instant::now() + Duration::from_secs(45);
    let mut info = None;
    while info.is_none() {
        let Some(left) = deadline.checked_duration_since(Instant::now()) else { break };
        let Ok(event) = rx.recv_timeout(left) else { break };
        match event.get("ev").and_then(Value::as_str) {
            Some("attached") => {
                let session = event.pointer("/session/session_id").cloned().unwrap_or_default();
                send(3, json!({"req": "get_runtime_info", "session_id": session})).map_err(|err| failed(&err))?;
            }
            Some("runtime_info") if event.get("reply_to").and_then(Value::as_u64) == Some(3) => info = Some(event),
            Some("error") => {
                let message = event.get("message").and_then(Value::as_str).unwrap_or("unknown error").to_string();
                let _ = child.kill();
                let _ = child.wait();
                return Err(failed(&message));
            }
            _ => {}
        }
    }
    let _ = child.kill();
    let _ = child.wait();
    let info = info.ok_or_else(|| failed(&"jcode didn't list its models"))?;
    let mut models: Vec<ModelEntry> = Vec::new();
    for route in info.get("routes").and_then(Value::as_array).into_iter().flatten() {
        if let Some(model) = jcode_model_from(&config.id, route)
            && !models.iter().any(|known| known.id == model.id)
        {
            models.push(model);
        }
    }
    let version = base_command(config).ok().and_then(|(_, mut command)| {
        let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?;
        first_version(&String::from_utf8_lossy(&output.stdout))
    });
    // What new sessions start on, e.g. `Default claude-opus-5 via Anthropic`.
    let plan = match (info.get("provider").and_then(Value::as_str), info.get("model").and_then(Value::as_str)) {
        (Some(provider), Some(model)) => Some(format!("Default {model} via {provider}")),
        _ => None,
    };
    Ok(Probe { models, plan, version, ..Probe::default() })
}

/// One of jcode's model routes as a picker entry, if it's usable.
pub fn jcode_model_from(instance: &str, route: &Value) -> Option<ModelEntry> {
    if route.get("available").and_then(Value::as_bool) != Some(true) {
        return None;
    }
    let model = route.get("model")?.as_str()?;
    let provider = route.get("provider").and_then(Value::as_str).unwrap_or_default();
    let method = route.get("api_method").and_then(Value::as_str).unwrap_or_default();
    // Anthropic and OpenAI can be reached with a login or an API key.
    let detail = match method {
        "claude-oauth" | "openai-oauth" => format!("{provider} · Subscription"),
        "claude-api" | "openai-api-key" => format!("{provider} · API key"),
        _ => provider.to_string(),
    };
    let efforts = match method {
        "claude-oauth" | "claude-api" => vec![Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max],
        "openai-oauth" | "openai-api-key" => vec![Effort::Low, Effort::Medium, Effort::High, Effort::XHigh],
        "openrouter" => vec![Effort::Low, Effort::Medium, Effort::High],
        _ => Vec::new(),
    };
    Some(ModelEntry {
        provider: Provider::Jcode,
        instance: instance.to_string(),
        id: jcode_model_spec(model, provider, method),
        resolved: model.to_string(),
        name: model.to_string(),
        detail,
        legacy: false,
        efforts,
        fast_mode: false,
        auto_mode: false,
        context_window: None,
    })
}

/// What jcode's `set_model` takes to pick a model on a particular route, as
/// its own model picker builds it. A bare name stays on the current provider.
fn jcode_model_spec(model: &str, provider: &str, method: &str) -> String {
    let prefix = match method {
        "claude-oauth" => Some("claude-oauth"),
        "claude-api" => Some("claude-api"),
        "openai-oauth" => Some("openai-oauth"),
        "openai-api-key" => Some("openai-api"),
        "copilot" => Some("copilot"),
        "cursor" => Some("cursor"),
        "bedrock" => Some("bedrock"),
        _ if provider == "Antigravity" => Some("antigravity"),
        _ => method.strip_prefix("openai-compatible:"),
    };
    match prefix {
        Some(prefix) => format!("{prefix}:{model}"),
        None if method == "openrouter" && !matches!(provider, "auto" | "OpenRouter" | "") => {
            format!("{model}@{provider}")
        }
        None => model.to_string(),
    }
}

/// The newest published version of an agent, from the npm registry (or
/// GitHub, for jcode).
fn latest_version(kind: Provider) -> Option<String> {
    let package = match kind {
        Provider::Claude => "@anthropic-ai/claude-code",
        Provider::Pi => "@earendil-works/pi-coding-agent",
        Provider::Jcode => {
            let url = "https://api.github.com/repos/1jehuang/jcode/releases/latest";
            let body = crate::http::get(url, &[("Accept", "application/vnd.github+json")], Duration::from_secs(10)).ok()?;
            let tag = serde_json::from_str::<Value>(&body).ok()?.get("tag_name")?.as_str()?.to_string();
            return Some(tag.trim_start_matches('v').to_string());
        }
    };
    let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?;
    serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string)
}

/// The command that updates an agent in place, and how to describe it.
pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Command, &'static str)> {
    let (binary, mut command) = base_command(config).ok()?;
    match config.kind {
        Provider::Claude => {
            command.arg("update");
            Some((command, "claude update"))
        }
        Provider::Pi => {
            // Pi is an npm package; update it with the npm next to it.
            let npm = binary.parent().map(|dir| dir.join("npm")).filter(|npm| npm.exists());
            let mut command = std::process::Command::new(npm.as_deref().unwrap_or(Path::new("npm")));
            command.args(["install", "-g", "@earendil-works/pi-coding-agent@latest"]);
            Some((command, "npm install -g @earendil-works/pi-coding-agent"))
        }
        Provider::Jcode => {
            command.args(["update"]);
            Some((command, "jcode update"))
        }
    }
}

/// The first `1.2.3`-looking word in `text`.
fn first_version(text: &str) -> Option<String> {
    text.split_whitespace()
        .map(|word| word.trim_start_matches('v'))
        .find(|word| word.split('.').count() >= 2 && word.split('.').all(|part| part.parse::<u64>().is_ok()))
        .map(str::to_string)
}

/// Whether version `a` is newer than `b`, comparing numerically.
fn version_newer(a: &str, b: &str) -> bool {
    let parse = |version: &str| -> Vec<u64> {
        version.trim_start_matches('v').split(['.', '-', '+']).map_while(|part| part.parse().ok()).collect()
    };
    parse(a) > parse(b)
}

/// `openai-codex` → `OpenAI Codex`.
fn provider_label(provider: &str) -> String {
    provider
        .split(['-', '_'])
        .map(|word| match word {
            "openai" => "OpenAI".to_string(),
            "openrouter" => "OpenRouter".to_string(),
            "xai" => "xAI".to_string(),
            "ai" => "AI".to_string(),
            word => {
                let mut chars = word.chars();
                chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

#[cfg(test)]
mod tests {
    use super::{claude_probe_from, first_version, jcode_model_from, provider_label, version_newer};
    use serde_json::json;

    #[::core::prelude::v1::test]
    fn provider_labels() {
        assert_eq!(provider_label("openai-codex"), "OpenAI Codex");
        assert_eq!(provider_label("anthropic"), "Anthropic");
        assert_eq!(provider_label("openrouter"), "OpenRouter");
        assert_eq!(provider_label("google_vertex"), "Google Vertex");
    }

    #[::core::prelude::v1::test]
    fn claude_default_takes_the_current_name() {
        let info = json!({
            "models": [
                {"value": "default", "resolvedModel": "claude-opus-5-5", "displayName": "Default (recommended)"},
                {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus 5.5",
                 "supportedEffortLevels": ["low", "high"], "supportsFastMode": true},
                {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"},
            ],
            "account": {"email": "me@example.com", "subscriptionType": "Claude Team"},
            "commands": [
                {"name": "compact", "description": "Compact", "argumentHint": "", "builtin": true},
                {"name": "pdf", "description": "PDF skill", "argumentHint": ""},
                {"name": "__internal", "description": "", "argumentHint": "", "builtin": true},
            ],
            "claude_code_version": "2.1.296",
        });
        let probe = claude_probe_from("claude", &info);
        assert_eq!(probe.default.unwrap().name, "Claude Opus 5.5");
        assert_eq!(probe.models.len(), 2);
        assert!(probe.models[1].legacy);
        assert_eq!(probe.models[0].efforts.len(), 2);
        assert_eq!(probe.models[0].key(), "claude:opus");
        assert_eq!(probe.plan.as_deref(), Some("Claude Team Subscription"));
        assert_eq!(probe.commands.len(), 2);
        assert!(!probe.commands[0].skill && probe.commands[1].skill);
        assert_eq!(probe.version.as_deref(), Some("2.1.296"));
    }

    #[::core::prelude::v1::test]
    fn compares_versions() {
        assert!(version_newer("2.1.300", "2.1.296"));
        assert!(!version_newer("2.1.296", "2.1.296"));
        assert!(version_newer("1.10.0", "1.9.9"));
        assert_eq!(first_version("pi v1.1.0\n").as_deref(), Some("1.1.0"));
        assert_eq!(first_version("2.1.296 (Claude Code)").as_deref(), Some("2.1.296"));
    }

    #[::core::prelude::v1::test]
    fn jcode_routes() {
        let route = |model: &str, provider: &str, method: &str, available: bool| {
            json!({"model": model, "provider": provider, "api_method": method, "available": available, "detail": ""})
        };
        let model = jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-oauth", true)).unwrap();
        assert_eq!(model.id, "claude-oauth:claude-opus-5");
        assert_eq!(model.detail, "Anthropic · Subscription");
        assert_eq!(model.key(), "jcode:claude-oauth:claude-opus-5");
        assert!(model.efforts.contains(&crate::claude::Effort::Max));
        let model = jcode_model_from("jcode", &route("glm-5", "Z.AI", "openai-compatible:zai", true)).unwrap();
        assert_eq!(model.id, "zai:glm-5");
        assert!(model.efforts.is_empty());
        let model = jcode_model_from("jcode", &route("gpt-5.5", "OpenAI", "openai-api-key", true)).unwrap();
        assert_eq!(model.id, "openai-api:gpt-5.5");
        let model = jcode_model_from("jcode", &route("anthropic/claude-sonnet-5", "Anthropic", "openrouter", true));
        assert_eq!(model.unwrap().id, "anthropic/claude-sonnet-5@Anthropic");
        assert_eq!(jcode_model_from("jcode", &route("auto", "auto", "openrouter", true)).unwrap().id, "auto");
        assert!(jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-api", false)).is_none());
    }
}