//! 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)
}
}
/// What T3 Code's model manifest says about a Claude model the CLI doesn't
/// report: the effort it defaults to, whether it takes Ultracode, and
/// whether it's new.
pub struct ClaudeProfile {
pub default_effort: Option<Effort>,
pub ultracode: bool,
pub new: bool,
}
impl ModelEntry {
pub fn claude_profile(&self) -> ClaudeProfile {
let model = if self.resolved.is_empty() { self.id.as_str() } else { self.resolved.as_str() };
let model = model.trim_end_matches("[1m]");
let is = |prefix: &str| model.starts_with(prefix);
let default_effort = if self.provider != Provider::Claude {
None
} else if is("claude-fable") || is("claude-opus-5-5") || is("claude-haiku-5") || matches!(model, "opus" | "haiku" | "fable") {
Some(Effort::Medium)
} else if is("claude-opus-4-7") {
Some(Effort::XHigh)
} else if is("claude-haiku-4") {
None
} else {
Some(Effort::High)
};
let ultracode = self.provider == Provider::Claude
&& (is("claude-fable") || is("claude-opus-5") || is("claude-opus-4-8") || matches!(model, "opus" | "fable"));
let new = self.provider == Provider::Claude
&& (is("claude-opus-5-5") || is("claude-sonnet-5-5") || is("claude-haiku-5-5"));
ClaudeProfile { default_effort, ultracode, new }
}
}
/// 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())
}
/// The favorite models' keys, including ones no instance offers right now.
pub fn favorite_keys(&self) -> &[String] {
&self.favorites
}
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());
}
}