clauth 0.7.4

Manage multiple Claude Code accounts, monitor 5h/7d usage with configurable auto-switch and delegation with an MCP plugin. CLI + TUI.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
use anyhow::Result;

use crate::actions::{switch_off, switch_profile};
use crate::lock::with_state_lock;
use crate::profile::{AppConfig, Profile};
use crate::usage::{UsageStore, UsageWindow, five_hour_live, iso_to_epoch_secs, now_epoch_secs};

/// What the auto-switch evaluator decided when the active profile crossed its
/// threshold.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SwitchAction {
    /// Switch the active profile to this chain member.
    To(String),
    /// Turn off all accounts: clear the live credentials and unset the active
    /// profile. Emitted only in wrap-off mode when the whole chain is exhausted
    /// and no member is marked `last_resort`.
    Off,
}

/// Default 5-hour utilization threshold (percent) applied when a chain member
/// has no per-profile override. Stays below 100 as poll-lag margin: at the
/// fixed refresh cadence a window can blow past a 100% trigger between polls,
/// so the default leaves headroom to switch before the account is already
/// rate-limited.
pub(crate) const DEFAULT_THRESHOLD: f64 = 95.0;

pub(crate) fn threshold_for(profile: &Profile) -> f64 {
    profile.fallback_threshold.unwrap_or(DEFAULT_THRESHOLD)
}

/// Live 5h window for `profile`, or `None` when there's no snapshot yet or its
/// window isn't currently live (`five_hour_live`) — a lapsed or windowless
/// snapshot means the account has headroom again whatever its last-known
/// utilization says. Shared by [`is_exhausted`] and the burn-aware active
/// check ([`is_exhausted_active`]): both refuse to act on a stale/rolled-over
/// window the same way; they differ only in how they judge a live one.
fn live_five_hour(profile: &Profile) -> Option<&UsageWindow> {
    let usage = profile.usage.as_ref()?;
    if !five_hour_live(usage, now_epoch_secs()) {
        return None;
    }
    usage.five_hour.as_ref()
}

/// True when the profile's 5h utilization has crossed its own threshold. Also
/// drives the TUI's all-spent banner wording. Static regardless of burn-aware
/// mode (issue #8 follow-up b) — the target walk's candidates and
/// `soonest_resume` keep this exact behavior by design; only the ACTIVE
/// profile's own decision becomes projection-aware (see
/// [`is_exhausted_active`]).
pub(crate) fn is_exhausted(profile: &Profile) -> bool {
    live_five_hour(profile).is_some_and(|w| w.utilization >= threshold_for(profile))
}

/// Recent burn rate (%/h) for `name`'s 5h window: durable per-profile history
/// (`usage_history.jsonl`, a plain disk read — no shared lock, so this is safe
/// to call without touching the order in `lockorder.rs`, but never while
/// holding the `AppConfig` guard) plus the current live sample, run through
/// the same recency-weighted computation and windowing `App::active_burn_rate`
/// uses for the Overview ETA line. `None` until enough distinct samples exist.
/// Sole caller is the scheduler-side [`is_exhausted_active_from_store`] — the
/// UI-thread [`is_exhausted_active`] takes its rate as a parameter instead so
/// the render pass never triggers this disk read under the config guard.
fn burn_rate_for_profile(name: &str, window: &UsageWindow) -> Option<f64> {
    let history = crate::profile::load_usage_history(name);
    let pair = ("5h", window);
    crate::usage::compute_burn_rates_from_history(
        &history,
        std::slice::from_ref(&pair),
        crate::usage::BURN_LOOKBACK_MS,
        crate::usage::BURN_MIN_SAMPLES,
        crate::usage::BURN_GAP_CUT_MS,
    )
    .remove("5h")
    .flatten()
}

/// Burn-aware exhaustion test shared by [`is_exhausted_active`] and its
/// scheduler-side store variant (issue #8 follow-up b, opt-in — off by
/// default). `None` burn (no history yet, a fresh profile, or a provider with
/// none) falls back to the plain `util_pct >= threshold` check — never leaves
/// an account uncovered for lack of data. With a rate, "exhausted" means the
/// *projected* utilization at the next poll has crossed the 100% cap, not the
/// per-profile threshold: a heavy burn switches ahead of the static
/// threshold, a light one may run past it since it won't blow the cap before
/// the next poll.
fn is_exhausted_projected(
    util_pct: f64,
    threshold: f64,
    burn_pct_per_hour: Option<f64>,
    interval_ms: u64,
) -> bool {
    match burn_pct_per_hour {
        Some(rate) => crate::usage::project_utilization(util_pct, rate, interval_ms) >= 100.0,
        None => util_pct >= threshold,
    }
}

/// ACTIVE-only exhaustion check (issue #8 follow-up b). `burn_aware` off
/// reproduces [`is_exhausted`] bit for bit — mode off must never diverge from
/// today's static behavior. On, `active_burn_pct_per_hour` — the caller's
/// in-memory rate; this function never reads disk itself, see
/// [`is_exhausted_active_from_store`] for the disk-reading scheduler twin —
/// feeds [`is_exhausted_projected`]. Deliberately never applied to the target
/// walk's candidates or `soonest_resume` — see docs/internals.md's auto-switch
/// asymmetry; this only ever changes whether the ACTIVE profile itself is
/// judged exhausted.
pub(crate) fn is_exhausted_active(
    profile: &Profile,
    burn_aware: bool,
    interval_ms: u64,
    active_burn_pct_per_hour: Option<f64>,
) -> bool {
    let Some(window) = live_five_hour(profile) else {
        return false;
    };
    let threshold = threshold_for(profile);
    if !burn_aware {
        return window.utilization >= threshold;
    }
    is_exhausted_projected(
        window.utilization,
        threshold,
        active_burn_pct_per_hour,
        interval_ms,
    )
}

/// Name + seconds-until-reset of the chain member that resumes soonest — the
/// all-exhausted caption's data source (issue #10: the implicit
/// resume-at-soonest-reset behavior, made explicit). Valid only when the
/// WHOLE chain is currently exhausted, covering both wrap-off's
/// switch-off-all (active cleared) and wrap mode's stalled-active equivalent
/// (`next_target` returns `None` with every member maxed). Reuses
/// [`is_exhausted`]'s `five_hour_live` gate: a single member with no live
/// window or a past reset already has headroom — `find_recovered_member` /
/// `scan_recovery` would relink it on the very next tick — so that member's
/// presence bails the WHOLE result to `None` rather than being skipped around;
/// the caption's premise is that NOTHING in the chain is currently usable.
/// Ties on `resets_at` keep the earlier chain-order member.
pub(crate) fn soonest_resume(config: &AppConfig) -> Option<(String, i64)> {
    let chain = &config.state.fallback_chain;
    if chain.is_empty() {
        return None;
    }
    let now = now_epoch_secs();
    let mut best: Option<(&str, i64)> = None;
    for name in chain {
        let profile = config.find(name)?;
        if !is_exhausted(profile) {
            return None;
        }
        let resets_at = profile
            .usage
            .as_ref()?
            .five_hour
            .as_ref()?
            .resets_at
            .as_deref()
            .and_then(iso_to_epoch_secs)?;
        if best.is_none_or(|(_, cur)| resets_at < cur) {
            best = Some((name.as_str(), resets_at));
        }
    }
    let (name, resets_at) = best?;
    Some((name.to_string(), (resets_at - now).max(0)))
}

/// One chain member as observed when a `ChainSnapshot` was built. Holds enough
/// to evaluate the fallback decision without re-locking `AppConfig` — caller
/// snapshots once under the config mutex then reads `UsageStore` separately,
/// avoiding the `config ↔ store` lock inversion against `App::apply_usage`
/// (which holds `usage_store` then takes `config`).
#[derive(Debug, Clone)]
pub(crate) struct ChainMember {
    pub(crate) name: String,
    pub(crate) threshold: f64,
    /// Mirrors `Profile::last_resort` — a terminal stop for the chain walk,
    /// decoupled from `threshold` (issue #8 follow-up: a threshold no longer
    /// doubles as a sink marker).
    pub(crate) last_resort: bool,
}

/// In-memory snapshot of the fields `next_auto_switch_target` needs: active
/// profile name + ordered chain with each member's resolved threshold. Built
/// under the `AppConfig` mutex by [`snapshot_chain`], then evaluated lock-free.
#[derive(Debug, Clone)]
pub(crate) struct ChainSnapshot {
    pub(crate) active: String,
    pub(crate) chain: Vec<ChainMember>,
    /// Snapshot of `AppState::wrap_off` — drives the switch-off-all decision.
    pub(crate) wrap_off: bool,
    /// Snapshot of `AppState::burn_aware_switching` (issue #8 follow-up b) —
    /// gates whether the ACTIVE-side check in `next_auto_switch_target`
    /// projects ahead of the next poll instead of using the static threshold.
    pub(crate) burn_aware: bool,
    /// Snapshot of `AppState::refresh_interval_ms` — the projection's poll
    /// interval. Read through `config.state` here (this snapshot is already
    /// built once under the config lock per tick) rather than the scheduler's
    /// hot-path `Arc<AtomicU64>`, mirroring exactly how `wrap_off` reaches
    /// this struct.
    pub(crate) interval_ms: u64,
}

/// Snapshot active profile + chain + per-member thresholds out of `AppConfig`.
/// Returns `None` when there's no active profile, the active isn't a chain
/// member, or the chain is empty — every case where `next_auto_switch_target`
/// short-circuits anyway, so callers can skip evaluation on `None`.
pub(crate) fn snapshot_chain(config: &AppConfig) -> Option<ChainSnapshot> {
    let active = config.state.active_profile.as_deref()?.to_string();
    let chain = &config.state.fallback_chain;
    if !chain.iter().any(|n| n == &active) {
        return None;
    }
    let chain = chain
        .iter()
        .map(|name| {
            let profile = config.find(name);
            ChainMember {
                name: name.to_string(),
                threshold: profile.map(threshold_for).unwrap_or(DEFAULT_THRESHOLD),
                last_resort: profile.is_some_and(|p| p.last_resort),
            }
        })
        .collect();
    Some(ChainSnapshot {
        active,
        chain,
        wrap_off: config.state.wrap_off,
        burn_aware: config.state.burn_aware_switching,
        interval_ms: config.state.refresh_interval_ms,
    })
}

/// Scheduler-side [`is_exhausted`]: reads 5h utilization from the shared
/// `UsageStore` rather than `Profile.usage` (which only the UI thread writes via
/// `apply_usage`). A poisoned store lock fails safe to "not exhausted" so a
/// momentarily wedged mutex can't trigger a switch.
fn is_exhausted_from_store(name: &str, threshold: f64, store: &UsageStore) -> bool {
    let now = now_epoch_secs();
    match store.lock() {
        Ok(s) => s.get(name).is_some_and(|info| {
            five_hour_live(info, now)
                && info
                    .five_hour
                    .as_ref()
                    .is_some_and(|w| w.utilization >= threshold)
        }),
        Err(_) => false,
    }
}

/// Scheduler-side [`is_exhausted_active`]: reads the 5h window from
/// `UsageStore` instead of `Profile.usage`, so the scheduler's periodic scan
/// agrees with the UI-thread one-shot (`auto_switch_if_needed`) on the ACTIVE
/// decision. The store lock is dropped before the (disk-only, unlocked) burn
/// rate lookup — never held across that I/O. A poisoned store lock fails safe
/// to "not exhausted".
fn is_exhausted_active_from_store(
    name: &str,
    threshold: f64,
    burn_aware: bool,
    interval_ms: u64,
    store: &UsageStore,
) -> bool {
    let now = now_epoch_secs();
    let window: Option<UsageWindow> = match store.lock() {
        Ok(s) => s.get(name).and_then(|info| {
            if five_hour_live(info, now) {
                info.five_hour.clone()
            } else {
                None
            }
        }),
        Err(_) => None,
    };
    let Some(window) = window else {
        return false;
    };
    if !burn_aware {
        return window.utilization >= threshold;
    }
    let rate = burn_rate_for_profile(name, &window);
    is_exhausted_projected(window.utilization, threshold, rate, interval_ms)
}

/// Chain walk shared by [`next_target`] and [`next_auto_switch_target`]. Scans
/// every other slot starting one after `idx` and wrapping. `skip_pred(i)` skips
/// slots (active profile, or a member with no resolvable profile);
/// `accept_pred(i)` selects the first matching slot, whose index is returned.
fn walk_chain(
    idx: usize,
    len: usize,
    skip_pred: &dyn Fn(usize) -> bool,
    accept_pred: &dyn Fn(usize) -> bool,
) -> Option<usize> {
    for offset in 1..=len {
        let i = (idx + offset) % len;
        if skip_pred(i) {
            continue;
        }
        if accept_pred(i) {
            return Some(i);
        }
    }
    None
}

/// Picks the next chain member to switch to, starting one slot after the active
/// profile and wrapping. Returns None when nothing is viable.
///
///   1. Any member with real headroom (5h utilization below threshold, or no
///      usage data fetched yet).
///   2. Last resort: a member marked `last_resort`, accepted even while
///      exhausted. Claude Code shows its own "out of 5h limit" message on
///      arrival. `last_resort` is independent of `threshold` — a member can
///      still switch away at, say, 80% utilization and remain the chain's
///      last resort once nothing else has headroom.
///   3. Wrap-off only: no headroom, no `last_resort` member anywhere, and the
///      active profile itself exhausted → [`SwitchAction::Off`] to halt usage.
///
/// `active_burn_pct_per_hour` is the caller's in-memory 5h burn rate for the
/// active profile, forwarded to [`is_exhausted_active`] for step 3's
/// burn-aware projection; ignored unless `burn_aware_switching` is on. This
/// function never reads disk — callers must supply the rate themselves.
pub(crate) fn next_target(
    config: &AppConfig,
    active_burn_pct_per_hour: Option<f64>,
) -> Option<SwitchAction> {
    let active = config.state.active_profile.as_deref()?;
    let chain = &config.state.fallback_chain;
    let active_idx = chain.iter().position(|n| n == active)?;
    let len = chain.len();

    let skip = |i: usize| chain[i] == active || config.find(&chain[i]).is_none();
    let walk = |accept: &dyn Fn(&Profile) -> bool| -> Option<String> {
        let pick = walk_chain(active_idx, len, &skip, &|i| {
            config.find(&chain[i]).is_some_and(&accept)
        });
        pick.map(|i| chain[i].to_string())
    };

    if let Some(name) = walk(&|p| !is_exhausted(p)) {
        return Some(SwitchAction::To(name));
    }

    // Only fall back to a `last_resort` member when the active profile is NOT
    // itself marked `last_resort`. Two last-resort members switching to each
    // other indefinitely gains nothing — one migration is fine, but the next
    // tick must stay put.
    let active_is_last_resort = config.find(active).is_some_and(|p| p.last_resort);
    if active_is_last_resort {
        return None;
    }
    if let Some(name) = walk(&|p| p.last_resort) {
        return Some(SwitchAction::To(name));
    }

    // No headroom, no `last_resort` member anywhere. In wrap-off mode, turn off
    // all accounts — but only when the active profile is itself exhausted,
    // since this picker is also exercised on a healthy active. The ACTIVE
    // check is burn-aware (issue #8 follow-up b) so this agrees with
    // `next_auto_switch_target`'s scheduler-side gate; the candidate walk
    // above stays on the static `is_exhausted`.
    if config.state.wrap_off
        && config.find(active).is_some_and(|p| {
            is_exhausted_active(
                p,
                config.state.burn_aware_switching,
                config.state.refresh_interval_ms,
                active_burn_pct_per_hour,
            )
        })
    {
        return Some(SwitchAction::Off);
    }
    None
}

/// Scheduler-side [`auto_switch_if_needed`]: same logic over an in-memory
/// [`ChainSnapshot`] taken under the config mutex, reading utilization from the
/// shared `UsageStore`. Returns the member to switch to, or `None`.
///
/// The store/config lock split is load-bearing: `App::apply_usage` locks
/// `usage_store` then `config`, so the scheduler must never hold `config` while
/// taking `usage_store`. Caller builds the snapshot under `config.lock()`, drops
/// the guard, then calls this.
pub(crate) fn next_auto_switch_target(
    snapshot: &ChainSnapshot,
    store: &UsageStore,
) -> Option<SwitchAction> {
    let active_idx = snapshot
        .chain
        .iter()
        .position(|m| m.name == snapshot.active)?;
    let len = snapshot.chain.len();

    let active = &snapshot.chain[active_idx];
    if !is_exhausted_active_from_store(
        &active.name,
        active.threshold,
        snapshot.burn_aware,
        snapshot.interval_ms,
        store,
    ) {
        return None;
    }

    let skip = |i: usize| snapshot.chain[i].name == active.name;
    let walk = |accept: &dyn Fn(&ChainMember) -> bool| -> Option<String> {
        let pick = walk_chain(active_idx, len, &skip, &|i| accept(&snapshot.chain[i]));
        pick.map(|i| snapshot.chain[i].name.clone())
    };

    if let Some(name) = walk(&|m| !is_exhausted_from_store(&m.name, m.threshold, store)) {
        return Some(SwitchAction::To(name));
    }

    let active_is_last_resort = active.last_resort;
    if active_is_last_resort {
        return None;
    }
    if let Some(name) = walk(&|m| m.last_resort) {
        return Some(SwitchAction::To(name));
    }

    // No headroom, no `last_resort` member anywhere, and the active profile is
    // already exhausted (gated above). In wrap-off mode, halt all usage
    // instead of staying on the spent profile.
    if snapshot.wrap_off {
        return Some(SwitchAction::Off);
    }
    None
}

/// Find the first chain member whose utilization is below its threshold
/// (has recovered headroom after switch-off-all). Returns the member name.
/// Safe to call without holding the config lock — reads from [`UsageStore`].
pub(crate) fn find_recovered_member(chain: &[ChainMember], store: &UsageStore) -> Option<String> {
    let now = now_epoch_secs();
    for member in chain {
        // A fetched entry whose 5h window is absent or past its reset is idle
        // headroom; a live window recovers only below the member's threshold.
        // An absent entry (never fetched) stays undecidable.
        let recovered = match store.lock() {
            Ok(s) => s.get(&member.name).map(|info| {
                !five_hour_live(info, now)
                    || info
                        .five_hour
                        .as_ref()
                        .is_none_or(|w| w.utilization < member.threshold)
            }),
            Err(_) => None,
        };
        if recovered == Some(true) {
            return Some(member.name.clone());
        }
    }
    None
}

/// If the active profile is a chain member past its threshold, switch to the
/// next viable member — or, in wrap-off mode when the whole chain is spent and
/// no sink exists, turn off all accounts. Returns the action taken, or None.
///
/// `active_burn_pct_per_hour` is the caller's in-memory burn rate for the
/// active profile (ignored unless burn-aware mode is on) — same contract as
/// [`next_target`], which this forwards it to.
pub(crate) fn auto_switch_if_needed(
    config: &mut AppConfig,
    active_burn_pct_per_hour: Option<f64>,
) -> Result<Option<SwitchAction>> {
    with_state_lock(|| {
        let Some(active_name) = config.state.active_profile.as_deref() else {
            return Ok(None);
        };
        if !config.state.fallback_chain.iter().any(|n| n == active_name) {
            return Ok(None);
        }
        let Some(active) = config.find(active_name) else {
            return Ok(None);
        };
        if !is_exhausted_active(
            active,
            config.state.burn_aware_switching,
            config.state.refresh_interval_ms,
            active_burn_pct_per_hour,
        ) {
            return Ok(None);
        }

        let Some(action) = next_target(config, active_burn_pct_per_hour) else {
            return Ok(None);
        };

        match &action {
            SwitchAction::To(target) => switch_profile(config, target)?,
            SwitchAction::Off => switch_off(config)?,
        }
        Ok(Some(action))
    })
}

#[cfg(test)]
#[path = "../tests/inline/fallback.rs"]
mod tests;