//! 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. 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, } impl Provider { pub const ALL: [Provider; 2] = [Provider::Claude, Provider::Pi]; pub fn label(self) -> &'static str { match self { Provider::Claude => "Claude", Provider::Pi => "Pi", } } pub fn key(self) -> &'static str { match self { Provider::Claude => "claude", Provider::Pi => "pi", } } pub fn from_key(key: &str) -> Option { 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, or Pi's /// `provider/model`. 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, pub fast_mode: bool, pub auto_mode: bool, pub context_window: Option, } 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, pub models: Vec, /// Claude's `default` entry, which the picker shows as the model it resolves to. pub default: Option, /// The signed-in account (Claude's email). pub account: Option, /// The subscription or login (`Claude Team`). pub plan: Option, pub version: Option, /// The newest published version, once an update check ran. pub latest: Option, /// Claude's `get_usage` reply: plan limits and how much of them is used. pub usage: Option, pub commands: Vec, pub checked: Option, } impl InstanceState { fn new(config: ProviderInstance) -> Self { Self { status: if config.enabled { ProbeStatus::Checking } else { ProbeStatus::Disabled }, config, checking: false, models: 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, } } } pub struct Catalog { pub instances: Vec, favorites: Vec, } impl Global for Catalog {} /// What one health check of an instance found. #[derive(Default)] struct Probe { models: Vec, default: Option, account: Option, plan: Option, version: Option, usage: Option, commands: Vec, } 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::() } /// 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::(); 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; 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 = 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::().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), }; 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::().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::Pi { state.models = 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::Pi { state.models.clear(); } } } 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 { 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::().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.models != probe.models { state.models = 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 { 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() } 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::(); 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 { 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::(&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 { 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::(&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 { 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), }) } /// The newest published version of an agent, from the npm registry. fn latest_version(kind: Provider) -> Option { let package = match kind { Provider::Claude => "@anthropic-ai/claude-code", Provider::Pi => "@earendil-works/pi-coding-agent", }; let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?; serde_json::from_str::(&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")) } } } /// The first `1.2.3`-looking word in `text`. fn first_version(text: &str) -> Option { text.split_whitespace() .map(|word| word.trim_start_matches('v')) .find(|word| word.split('.').count() >= 2 && word.split('.').all(|part| part.parse::().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 { 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::>() .join(" ") } #[cfg(test)] mod tests { use super::{claude_probe_from, first_version, 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")); } }