1use serde::Serialize;
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Mutex, OnceLock};
16
17#[derive(Clone, Debug, Serialize, PartialEq)]
18pub struct Window {
19 pub window: String,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub used_percent: Option<f64>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub resets_at_ms: Option<u64>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub resets_text: Option<String>,
28}
29
30#[derive(Clone, Debug, Serialize, PartialEq)]
31pub struct ProviderLimits {
32 pub provider: String,
33 pub source: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub tier: Option<String>,
37 pub windows: Vec<Window>,
38 pub observed_at_ms: u64,
39}
40
41pub trait LimitSource: Send + Sync {
43 fn provider(&self) -> &'static str;
44 fn read(&self, now_ms: u64) -> Option<ProviderLimits>;
45}
46
47fn window_name(minutes: u64) -> String {
48 match minutes {
49 300 => "5h".into(),
50 10080 => "7d".into(),
51 m => format!("{m}m"),
52 }
53}
54
55fn pct(line: &str) -> Option<f64> {
56 let i = line.find('%')?;
57 let head = &line[..i];
58 let start = head.rfind(|c: char| !(c.is_ascii_digit() || c == '.')).map_or(0, |p| p + 1);
59 head[start..].parse().ok().filter(|v: &f64| (0.0..=100.0).contains(v))
60}
61
62pub fn parse_claude_screen(screen: &str, now_ms: u64) -> Option<ProviderLimits> {
64 let lines: Vec<&str> = screen.lines().map(str::trim).collect();
65 let mut windows = Vec::new();
66 let mut tier = None;
67 for (i, l) in lines.iter().enumerate() {
68 let low = l.to_lowercase();
69 for t in ["max", "pro", "team", "enterprise"] {
70 if tier.is_none() && (low.contains(&format!("claude {t}")) || low.contains(&format!("{t} plan"))) {
71 tier = Some(t.to_owned());
72 }
73 }
74 let name = if low.starts_with("current session") {
75 "5h"
76 } else if low.starts_with("current week") && (low.contains("all models") || !low.contains('(')) {
77 "7d"
78 } else {
79 continue;
80 };
81 if windows.iter().any(|w: &Window| w.window == name) {
82 continue;
83 }
84 let mut w = Window { window: name.into(), used_percent: None, resets_at_ms: None, resets_text: None };
85 for next in lines.iter().skip(i + 1).take(3) {
86 let nl = next.to_lowercase();
87 if nl.starts_with("current ") {
88 break;
89 }
90 if w.used_percent.is_none() && nl.contains("used") {
91 w.used_percent = pct(next);
92 }
93 if let Some(p) = nl.find("resets") {
94 w.resets_text = Some(next[p..].chars().take(80).collect());
95 }
96 }
97 if w.used_percent.is_some() {
98 windows.push(w);
99 }
100 }
101 (!windows.is_empty() || tier.is_some()).then(|| ProviderLimits { provider: "claude".into(), source: "screen".into(), tier, windows, observed_at_ms: now_ms })
102}
103
104pub fn parse_codex_line(line: &str, now_ms: u64) -> Option<ProviderLimits> {
106 let v: serde_json::Value = serde_json::from_str(line).ok()?;
107 let rl = v.pointer("/payload/rate_limits").or_else(|| v.pointer("/payload/info/rate_limits")).or_else(|| v.get("rate_limits"))?;
108 let mut windows = Vec::new();
109 for key in ["primary", "secondary"] {
110 let Some(w) = rl.get(key).filter(|w| w.is_object()) else { continue };
111 let minutes = w.get("window_minutes").and_then(|x| x.as_u64()).unwrap_or(0);
112 let resets_at_ms = w
113 .get("resets_at")
114 .and_then(|x| x.as_u64())
115 .map(|s| s * 1000)
116 .or_else(|| w.get("resets_in_seconds").and_then(|x| x.as_u64()).map(|s| now_ms + s * 1000));
117 windows.push(Window { window: window_name(minutes), used_percent: w.get("used_percent").and_then(|x| x.as_f64()), resets_at_ms, resets_text: None });
118 }
119 let tier = rl.get("plan_type").and_then(|x| x.as_str()).map(str::to_owned);
120 (!windows.is_empty()).then(|| ProviderLimits { provider: "codex".into(), source: "rollout".into(), tier, windows, observed_at_ms: now_ms })
121}
122
123pub struct CodexRollouts {
125 pub root: PathBuf,
126}
127
128impl CodexRollouts {
129 pub fn from_home() -> Option<Self> {
130 let home = std::env::var_os("CODEX_HOME").map(PathBuf::from).or_else(|| std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(|h| PathBuf::from(h).join(".codex")))?;
131 Some(Self { root: home.join("sessions") })
132 }
133}
134
135fn newest_rollout(dir: &Path, depth: u8) -> Option<PathBuf> {
136 let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
137 for e in std::fs::read_dir(dir).ok()?.flatten() {
138 let p = e.path();
139 let cand = if p.is_dir() && depth > 0 {
140 newest_rollout(&p, depth - 1)
141 } else if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("rollout-") && n.ends_with(".jsonl")) {
142 Some(p)
143 } else {
144 None
145 };
146 if let Some(c) = cand {
147 let t = std::fs::metadata(&c).and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH);
148 if best.as_ref().map_or(true, |(bt, _)| t > *bt) {
149 best = Some((t, c));
150 }
151 }
152 }
153 best.map(|b| b.1)
154}
155
156impl LimitSource for CodexRollouts {
157 fn provider(&self) -> &'static str {
158 "codex"
159 }
160 fn read(&self, now_ms: u64) -> Option<ProviderLimits> {
161 let file = newest_rollout(&self.root, 4)?;
162 let text = std::fs::read_to_string(file).ok()?;
163 text.lines().rev().filter(|l| l.contains("rate_limits")).find_map(|l| parse_codex_line(l, now_ms))
164 }
165}
166
167static SCREEN: OnceLock<Mutex<BTreeMap<String, ProviderLimits>>> = OnceLock::new();
169
170pub fn now_ms() -> u64 {
171 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
172}
173
174pub fn observe_screen(provider: &str, screen: &str) {
176 if provider != "claude" || !(screen.contains("Current session") || screen.contains("Current week") || screen.contains("Claude ")) {
177 return;
178 }
179 let Some(new) = parse_claude_screen(screen, now_ms()) else { return };
180 let mut map = SCREEN.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
181 let merged = match map.remove(provider) {
182 Some(mut old) => {
183 if new.tier.is_some() {
184 old.tier = new.tier.clone();
185 }
186 for w in new.windows {
187 old.windows.retain(|o| o.window != w.window);
188 old.windows.push(w);
189 }
190 old.observed_at_ms = new.observed_at_ms;
191 old
192 }
193 None => new,
194 };
195 map.insert(provider.to_owned(), merged);
196}
197
198pub fn fresh_5h_used(provider: &str, max_age_ms: u64) -> Option<f64> {
200 let map = SCREEN.get()?.lock().unwrap_or_else(|p| p.into_inner());
201 let l = map.get(provider)?;
202 if now_ms().saturating_sub(l.observed_at_ms) > max_age_ms {
203 return None;
204 }
205 l.windows.iter().find(|w| w.window == "5h").and_then(|w| w.used_percent)
206}
207
208pub fn collect(sources: &[Box<dyn LimitSource>]) -> Vec<ProviderLimits> {
210 let now = now_ms();
211 let mut out: Vec<ProviderLimits> = SCREEN.get().map(|m| m.lock().unwrap_or_else(|p| p.into_inner()).values().cloned().collect()).unwrap_or_default();
212 out.extend(sources.iter().filter_map(|s| s.read(now)));
213 out
214}
215
216pub fn default_sources() -> Vec<Box<dyn LimitSource>> {
217 let mut v: Vec<Box<dyn LimitSource>> = Vec::new();
218 if let Some(c) = CodexRollouts::from_home() {
219 v.push(Box::new(c));
220 }
221 v
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 const USAGE: &str = " Settings: Status Config Usage\n\n Current session\n █████▌ 11% used\n Resets 3pm (Asia/Yekaterinburg)\n\n Current week (all models)\n ███████████ 22% used\n Resets Oct 23, 9am (Asia/Yekaterinburg)\n\n Current week (Opus)\n █ 2% used\n";
229
230 #[test]
231 fn claude_usage_screen() {
232 let l = parse_claude_screen(USAGE, 7).unwrap();
233 assert_eq!(l.windows.len(), 2);
234 assert_eq!(l.windows[0].window, "5h");
235 assert_eq!(l.windows[0].used_percent, Some(11.0));
236 assert_eq!(l.windows[0].resets_text.as_deref(), Some("Resets 3pm (Asia/Yekaterinburg)"));
237 assert_eq!(l.windows[1].window, "7d");
238 assert_eq!(l.windows[1].used_percent, Some(22.0));
239 }
240
241 #[test]
243 fn claude_usage_screen_live_capture() {
244 let screen = " Settings Status Config Usage Stats\n\n Session\n\n Total cost: $0.0000\n Usage: 0 input, 0 output, 0 cache read, 0 cache write\n\n Current session\n ▌ 1% used\n Resets 10:39am (Asia/Yekaterinburg)\n\n Current week (all models)\n ▌ 1% used\n Resets Oct 21, 11:59pm (Asia/Yekaterinburg)\n\n What's contributing to your limits usage?\n 31% of your usage came from the MCP server \"m4a\"\n";
245 let l = parse_claude_screen(screen, 0).unwrap();
246 assert_eq!(l.windows.iter().map(|w| (w.window.as_str(), w.used_percent)).collect::<Vec<_>>(), vec![("5h", Some(1.0)), ("7d", Some(1.0))]);
247 assert_eq!(l.windows[1].resets_text.as_deref(), Some("Resets Oct 21, 11:59pm (Asia/Yekaterinburg)"));
248 }
249
250 #[test]
251 fn claude_status_tier_and_noise() {
252 assert_eq!(parse_claude_screen("Login method: Claude Max Account", 1).unwrap().tier.as_deref(), Some("max"));
253 assert!(parse_claude_screen("> fix the tests\n● done", 1).is_none());
254 }
255
256 #[test]
257 fn codex_rollout_line() {
258 let line = r#"{"type":"event_msg","payload":{"type":"token_count","rate_limits":{"primary":{"used_percent":40.5,"window_minutes":300,"resets_in_seconds":60},"secondary":{"used_percent":9.0,"window_minutes":10080,"resets_at":1800000000},"plan_type":"plus"}}}"#;
259 let l = parse_codex_line(line, 1000).unwrap();
260 assert_eq!(l.tier.as_deref(), Some("plus"));
261 assert_eq!(l.windows[0].window, "5h");
262 assert_eq!(l.windows[0].resets_at_ms, Some(61_000));
263 assert_eq!(l.windows[1].window, "7d");
264 assert_eq!(l.windows[1].resets_at_ms, Some(1_800_000_000_000));
265 }
266
267 #[test]
268 fn codex_source_reads_newest_file() {
269 let d = std::env::temp_dir().join(format!("pl-{}", std::process::id()));
270 let day = d.join("2026/10/19");
271 std::fs::create_dir_all(&day).unwrap();
272 std::fs::write(day.join("rollout-a.jsonl"), "{}\n{\"payload\":{\"rate_limits\":{\"primary\":{\"used_percent\":5,\"window_minutes\":300}}}}\n").unwrap();
273 let l = CodexRollouts { root: d.clone() }.read(0).unwrap();
274 assert_eq!(l.windows[0].used_percent, Some(5.0));
275 let _ = std::fs::remove_dir_all(d);
276 }
277}