use super::*;
const KEYCHAIN_ACTIVE_REFRESH_SECS: u64 = 5 * 60;
fn apply_fetch_error(
usage: &mut crate::ai_usage::ClaudeUsage,
e: crate::ai_usage::FetchErr,
now: u64,
) {
usage.consecutive_failures = usage.consecutive_failures.saturating_add(1);
let backoff = match e.retry_after_secs {
Some(secs) if secs > 0 => secs,
_ => {
const BASE: u64 = 10 * 60;
const CAP: u64 = 60 * 60;
let shift = usage.consecutive_failures.saturating_sub(1).min(3);
(BASE << shift).min(CAP)
}
};
usage.retry_after_at = now.saturating_add(backoff);
usage.last_error = Some(e.message);
usage.needs_reauth = e.needs_reauth;
}
impl App {
fn warn_on_duplicate_credentials(&mut self) {
if self.dup_credentials_warned {
return;
}
let accounts = self.config.claude_accounts();
if accounts.len() < 2 {
return;
}
let dupes = crate::ai_usage::duplicate_credential_accounts(&accounts);
if dupes.is_empty() {
return;
}
self.dup_credentials_warned = true;
let pairs = dupes
.iter()
.map(|(a, b)| format!("{a} = {b}"))
.collect::<Vec<_>>()
.join(", ");
self.toast_leveled(
format!(
"Claude accounts share one login ({pairs}) — their usage numbers are the same account. Re-auth in the Claude usage pane."
),
crate::app::ToastLevel::Warn,
);
}
pub fn maybe_refresh_ai_usage(&mut self) {
self.warn_on_duplicate_credentials();
const REFRESH_INTERVAL_SECS: u64 = 5 * 60;
const IDLE_REFRESH_INTERVAL_SECS: u64 = 20 * 60;
const HOT_REFRESH_INTERVAL_SECS: u64 = 60;
const HOT_RANGE: std::ops::Range<u16> = 90..100;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let claude_enabled = self
.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == "claude_code" && ic.enabled);
let codex_enabled = self
.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == "codex" && ic.enabled);
if !claude_enabled && !codex_enabled {
return;
}
if codex_enabled
&& self.ai_usage_pending_codex.is_none()
&& now.saturating_sub(self.ai_usage_last_refresh_at) >= REFRESH_INTERVAL_SECS
{
self.ai_usage_last_refresh_at = now;
self.ai_usage_pending_codex = Some(crate::ai_usage::spawn_codex_fetch());
}
if !claude_enabled {
return;
}
self.kick_keychain_active_refresh();
let configured = self.config.claude_accounts();
let configured_names: std::collections::HashSet<String> =
configured.iter().map(|a| a.name.clone()).collect();
self.ai_usage_claude_accounts
.retain(|a| configured_names.contains(&a.name));
self.ai_usage_claude_last_refresh_at
.retain(|k, _| configured_names.contains(k));
const SPAWN_GAP_SECS: u64 = 20;
if now.saturating_sub(self.ai_usage_last_claude_spawn_at) < SPAWN_GAP_SECS {
return;
}
for account in &configured {
if self
.ai_usage_pending_claude_accounts
.iter()
.any(|(n, _)| n == &account.name)
{
continue;
}
if let Some(existing) = self
.ai_usage_claude_accounts
.iter()
.find(|a| a.name == account.name)
&& existing.usage.retry_after_at > now
{
continue;
}
let last = self
.ai_usage_claude_last_refresh_at
.get(&account.name)
.copied()
.unwrap_or(0);
let is_active = self
.ai_usage_claude_accounts
.iter()
.find(|a| a.name == account.name)
.map(|a| a.is_active)
.unwrap_or(false);
let percent = self
.ai_usage_claude_accounts
.iter()
.find(|a| a.name == account.name)
.map(|a| a.usage.percent)
.unwrap_or(0);
let interval = match (is_active, HOT_RANGE.contains(&percent)) {
(true, true) => HOT_REFRESH_INTERVAL_SECS,
(true, false) => REFRESH_INTERVAL_SECS,
(false, _) => IDLE_REFRESH_INTERVAL_SECS,
};
let interval = if last == 0 {
REFRESH_INTERVAL_SECS
} else {
interval
};
if now.saturating_sub(last) < interval {
continue;
}
self.ai_usage_claude_last_refresh_at
.insert(account.name.clone(), now);
self.ai_usage_last_claude_spawn_at = now;
let rx = crate::ai_usage::spawn_claude_fetch_account_of(
account.name.clone(),
account.resolved_token_path(),
configured.len(),
);
self.ai_usage_pending_claude_accounts
.push((account.name.clone(), rx));
break;
}
}
pub fn active_claude_account(&self) -> Option<&crate::ai_usage::ClaudeAccountUsage> {
let active_name: Option<String> =
self.autodetected_active_claude_account_name().or_else(|| {
self.config
.claude_accounts()
.into_iter()
.find(|a| a.active)
.map(|a| a.name)
});
if let Some(name) = active_name.as_ref()
&& let Some(hit) = self
.ai_usage_claude_accounts
.iter()
.find(|a| &a.name == name)
{
return Some(hit);
}
self.ai_usage_claude_accounts.first()
}
pub fn drain_ai_usage(&mut self) {
let autodetected: Option<String> = self.autodetected_active_claude_account_name();
let active_names: std::collections::HashSet<String> = if let Some(name) = autodetected {
std::iter::once(name).collect()
} else {
self.config
.claude_accounts()
.into_iter()
.filter(|a| a.active)
.map(|a| a.name)
.collect()
};
let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut drained: Vec<(
String,
Result<crate::ai_usage::ClaudeAccountUsage, crate::ai_usage::FetchErr>,
)> = Vec::new();
self.ai_usage_pending_claude_accounts
.retain(|(name, rx)| match rx.try_recv() {
Ok(payload) => {
drained.push((name.clone(), payload));
false
}
Err(std::sync::mpsc::TryRecvError::Empty) => true,
Err(std::sync::mpsc::TryRecvError::Disconnected) => false,
});
for (name, result) in drained {
let is_active = active_names.contains(&name);
match result {
Ok(mut acc) => {
acc.is_active = is_active;
if let Some(w) = acc.warning.take() {
self.toast(w);
}
upsert_claude_account(&mut self.ai_usage_claude_accounts, acc);
}
Err(e) => {
let mut existing = self
.ai_usage_claude_accounts
.iter()
.find(|a| a.name == name)
.cloned()
.unwrap_or_else(|| crate::ai_usage::ClaudeAccountUsage {
name: name.clone(),
usage: crate::ai_usage::ClaudeUsage::default(),
is_active,
email: None,
org_name: None,
warning: None,
});
existing.is_active = is_active;
apply_fetch_error(&mut existing.usage, e, now_ts);
upsert_claude_account(&mut self.ai_usage_claude_accounts, existing);
}
}
}
if let Some(rx) = &self.ai_usage_pending_codex {
match rx.try_recv() {
Ok(Ok(u)) => {
self.ai_usage_codex = Some(u);
self.ai_usage_pending_codex = None;
}
Ok(Err(e)) => {
let mut u = self.ai_usage_codex.clone().unwrap_or_default();
u.last_error = Some(e);
self.ai_usage_codex = Some(u);
self.ai_usage_pending_codex = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.ai_usage_pending_codex = None;
}
}
}
}
pub fn drain_pending_keychain(&mut self) {
let Some(rx) = &self.pending_keychain_fetch else {
return;
};
match rx.try_recv() {
Ok(Ok(raw)) => {
let is_link_prompt = matches!(
self.prompt.as_ref().map(|p| &p.kind),
Some(crate::prompt::PromptKind::LinkClaudeToken)
);
if is_link_prompt && let Some(prompt) = self.prompt.as_mut() {
prompt.cursor = raw.chars().count();
prompt.input = raw;
self.toast("fetched from Keychain — press Enter to link".to_string());
} else {
}
self.pending_keychain_fetch = None;
}
Ok(Err(e)) => {
self.toast(e);
self.pending_keychain_fetch = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.pending_keychain_fetch = None;
}
}
}
pub fn drain_keychain_active_watch(&mut self) {
let Some(rx) = &self.keychain_active_watch else {
return;
};
match rx.try_recv() {
Ok(Ok(Some(rt))) => {
self.keychain_claude_refresh_token = Some(rt);
self.keychain_active_watch = None;
self.restamp_claude_active_flags();
}
Ok(Ok(None)) => {
self.keychain_active_watch = None;
}
Ok(Err(_)) => {
self.keychain_active_watch = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.keychain_active_watch = None;
}
}
}
pub fn kick_keychain_active_refresh(&mut self) {
if self.keychain_active_watch.is_some() {
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now.saturating_sub(self.keychain_active_last_kick_at) < KEYCHAIN_ACTIVE_REFRESH_SECS {
return;
}
self.keychain_active_last_kick_at = now;
self.keychain_active_watch = Some(crate::ai_usage::spawn_keychain_active_refresh_token());
}
pub fn autodetected_active_claude_account_name(&self) -> Option<String> {
self.cached_autodetected_claude_account.clone()
}
fn recompute_autodetected_claude_account(&self) -> Option<String> {
let keychain_rt = self.keychain_claude_refresh_token.as_deref()?;
for account in self.config.claude_accounts() {
let token_path = account.resolved_token_path();
if let Some(disk_rt) = crate::ai_usage::read_refresh_token_from_path(&token_path)
&& disk_rt == keychain_rt
{
return Some(account.name);
}
}
None
}
pub fn restamp_claude_active_flags(&mut self) {
let autodetected = self.recompute_autodetected_claude_account();
self.cached_autodetected_claude_account = autodetected.clone();
let active_names: std::collections::HashSet<String> = if let Some(name) = autodetected {
std::iter::once(name).collect()
} else {
self.config
.claude_accounts()
.into_iter()
.filter(|a| a.active)
.map(|a| a.name)
.collect()
};
for acc in self.ai_usage_claude_accounts.iter_mut() {
acc.is_active = active_names.contains(&acc.name);
}
}
pub fn open_claude_login_pane(&mut self) {
self.toast("opening `claude login` — press R here after it finishes".to_string());
self.open_pty(crate::pty_pane::BinaryProfile::task(
"claude login",
"claude login",
self.workspace.clone(),
));
}
pub fn recapture_claude_token_from_keychain(&mut self) {
if self.pending_keychain_recapture.is_some() {
self.toast("already capturing…".to_string());
return;
}
let targets: Vec<crate::ai_usage::RecaptureTarget> = self
.config
.claude_accounts()
.iter()
.map(|a| crate::ai_usage::RecaptureTarget {
name: a.name.clone(),
token_path: a.resolved_token_path(),
pinned_email: crate::ai_usage::pinned_email_for(&a.name),
})
.collect();
if targets.is_empty() {
self.toast("no Claude accounts configured".to_string());
return;
}
self.toast("reading keychain + verifying identity…".to_string());
self.pending_keychain_recapture = Some(crate::ai_usage::spawn_keychain_recapture(targets));
}
pub fn drain_keychain_recapture(&mut self) {
let Some(rx) = &self.pending_keychain_recapture else {
return;
};
match rx.try_recv() {
Ok(Ok(msg)) => {
self.toast(msg);
self.pending_keychain_recapture = None;
self.ai_usage_claude_last_refresh_at.clear();
self.ai_usage_pending_claude_accounts.clear();
self.maybe_refresh_ai_usage();
}
Ok(Err(e)) => {
self.toast(e);
self.pending_keychain_recapture = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.pending_keychain_recapture = None;
}
}
}
pub fn open_link_claude_token_prompt(&mut self) {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::LinkClaudeToken,
"Paste access token OR the whole claudeAiOauth JSON (keeps refresh token → no daily re-paste)",
));
}
pub fn accept_link_claude_token(&mut self, token: String) {
match crate::ai_usage::write_claude_token(&token) {
Ok(path) => {
self.toast(format!("linked → {}", path.display()));
self.ai_usage_last_refresh_at = 0;
self.ai_usage_claude_last_refresh_at.clear();
self.ai_usage_pending_claude_accounts.clear();
self.maybe_refresh_ai_usage();
}
Err(e) => self.toast(format!("link failed: {e}")),
}
}
pub fn audit_shadowed_binaries(&mut self) {
let hits = crate::integration_detect::find_shadowed_binaries();
if hits.is_empty() {
self.toast("no shadowed integration binaries detected");
return;
}
let dest_root = crate::data_root::data_root()
.join("quarantine")
.join("shadowed-bins");
if let Err(e) = std::fs::create_dir_all(&dest_root) {
self.toast(format!("shadow audit: couldn't mkdir quarantine ({e})"));
return;
}
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut moved = 0usize;
let mut errors = Vec::new();
for hit in &hits {
let dest = dest_root.join(format!("{}.{stamp}", hit.name));
match std::fs::rename(&hit.active, &dest) {
Ok(()) => moved += 1,
Err(e) => errors.push(format!("{}: {e}", hit.name)),
}
}
crate::integration_detect::clear_cache();
if errors.is_empty() {
self.toast(format!(
"moved {moved} shadowed integration binaries → {}",
dest_root.display()
));
} else {
self.toast(format!(
"moved {moved}/{}; {} failed — see findings",
hits.len(),
errors.len()
));
}
}
}
#[cfg(test)]
mod fetch_error_tests {
use super::apply_fetch_error;
use crate::ai_usage::{ClaudeUsage, FetchErr};
fn err(message: &str, needs_reauth: bool) -> FetchErr {
FetchErr {
message: message.to_string(),
retry_after_secs: None,
needs_reauth,
}
}
#[test]
fn a_zero_retry_after_falls_back_to_backoff() {
let mut usage = ClaudeUsage::default();
let now = 1_000_000u64;
let mut e = err("HTTP 429 rate_limit_error", false);
e.retry_after_secs = Some(0);
apply_fetch_error(&mut usage, e, now);
assert!(
usage.retry_after_at > now,
"a Retry-After of 0 produced no cooldown at all — the account \
retries on the next tick and the limit never clears"
);
assert!(
usage.retry_after_at >= now + 10 * 60,
"expected at least the 10-minute base, got {}s",
usage.retry_after_at - now
);
}
#[test]
fn a_positive_retry_after_is_still_honoured() {
let mut usage = ClaudeUsage::default();
let now = 1_000_000u64;
let mut e = err("HTTP 429", false);
e.retry_after_secs = Some(45);
apply_fetch_error(&mut usage, e, now);
assert_eq!(
usage.retry_after_at,
now + 45,
"Anthropic's own number was discarded"
);
}
#[test]
fn repeated_zero_hints_escalate() {
let mut usage = ClaudeUsage::default();
let now = 1_000_000u64;
let mut prev = 0u64;
for i in 0..4 {
let mut e = err("HTTP 429", false);
e.retry_after_secs = Some(0);
apply_fetch_error(&mut usage, e, now);
let wait = usage.retry_after_at - now;
assert!(
wait >= prev,
"attempt {i}: backoff shrank ({prev}s -> {wait}s)"
);
prev = wait;
}
assert!(prev > 10 * 60, "backoff never escalated past the base");
}
#[test]
fn a_later_non_reauth_error_clears_the_reauth_flag() {
let mut usage = ClaudeUsage::default();
apply_fetch_error(&mut usage, err("that credential is other@x", true), 1_000);
assert!(usage.needs_reauth, "re-auth failure should raise the flag");
apply_fetch_error(&mut usage, err("http 429", false), 2_000);
assert!(
!usage.needs_reauth,
"an unrelated later error must clear the flag, not OR into it"
);
assert_eq!(usage.last_error.as_deref(), Some("http 429"));
}
#[test]
fn an_error_preserves_the_previous_readings() {
let mut usage = ClaudeUsage {
percent: 57,
weekly_percent: 86,
fetched_at: 500,
..Default::default()
};
apply_fetch_error(&mut usage, err("http 429", false), 2_000);
assert_eq!(usage.percent, 57);
assert_eq!(usage.weekly_percent, 86);
assert_eq!(usage.fetched_at, 500, "age must stay honest");
}
#[test]
fn a_failure_always_backs_off_even_without_a_retry_after_header() {
let mut usage = ClaudeUsage::default();
apply_fetch_error(&mut usage, err("boom", false), 2_000);
assert!(
usage.retry_after_at > 2_000,
"a failure with no Retry-After got no cooldown at all"
);
assert_eq!(usage.consecutive_failures, 1);
let mut usage = ClaudeUsage::default();
let mut throttled = err("http 429", false);
throttled.retry_after_secs = Some(300);
apply_fetch_error(&mut usage, throttled, 2_000);
assert_eq!(
usage.retry_after_at, 2_300,
"the server's Retry-After must win over our own backoff"
);
}
#[test]
fn consecutive_failures_back_off_further_each_time() {
let mut usage = ClaudeUsage::default();
let mut waits = Vec::new();
for _ in 0..5 {
apply_fetch_error(&mut usage, err("429", false), 1_000);
waits.push(usage.retry_after_at - 1_000);
}
assert!(
waits.windows(2).all(|w| w[1] >= w[0]),
"backoff did not grow: {waits:?}"
);
assert!(
waits[1] > waits[0],
"second failure waited no longer than the first: {waits:?}"
);
assert!(
*waits.last().unwrap() <= 60 * 60,
"backoff exceeded the 1h cap: {waits:?}"
);
}
#[test]
fn a_successful_parse_resets_the_failure_count() {
let mut usage = ClaudeUsage {
consecutive_failures: 4,
..Default::default()
};
apply_fetch_error(&mut usage, err("x", false), 10);
assert_eq!(usage.consecutive_failures, 5, "counter should climb");
}
}