1use std::collections::HashMap;
8use std::sync::{Mutex, OnceLock};
9use std::time::{Duration, Instant};
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum Signal {
13 Limit { hint: String },
15 LoginRequired,
16 Ready,
17 LetterStuck { hint: String },
19 ProviderError { class: &'static str, code: Option<u16>, attempts: u32, hint: String },
21}
22
23impl Signal {
24 pub fn kind(&self) -> &'static str {
25 match self {
26 Signal::Limit { .. } => "limit",
27 Signal::LoginRequired => "login_required",
28 Signal::Ready => "ready",
29 Signal::ProviderError { .. } => "provider_error",
30 Signal::LetterStuck { .. } => "letter_stuck",
31 }
32 }
33}
34
35const LIMIT_PHRASES: &[&str] = &[
36 "usage limit",
37 "rate limit",
38 "rate-limit",
39 "limit reached",
40 "reached your limit",
41 "you've hit your",
42 "you have hit your",
43 "quota exceeded",
44 "exceeded your current quota",
45 "too many requests",
46 "out of credits",
47 "weekly limit",
48 "5-hour limit",
49];
50const LOGIN_PHRASES: &[&str] = &[
51 "select login method",
52 "sign in with chatgpt",
53 "please log in",
54 "please login",
55 "run /login",
56 "not logged in",
57 "approve in your browser",
58 "how would you like to authenticate",
59 "invalid api key",
60 "authentication required",
61];
62
63pub fn usage_screen_used(screen: &str) -> Option<f64> {
66 let mut best: Option<f64> = None;
67 for line in screen.lines() {
68 let lower = line.to_lowercase();
69 for (marker, left) in [("% used", false), ("% left", true)] {
70 let Some(at) = lower.find(marker) else { continue };
71 let head = &lower[..at];
72 let digits: String = head.chars().rev().take_while(|c| c.is_ascii_digit() || *c == '.').collect::<Vec<_>>().into_iter().rev().collect();
73 let Ok(n) = digits.parse::<f64>() else { continue };
74 if !(0.0..=100.0).contains(&n) {
75 continue;
76 }
77 let used = if left { 100.0 - n } else { n };
78 best = Some(best.map_or(used, |b: f64| b.max(used)));
79 }
80 }
81 best
82}
83
84pub fn classify(screen: &str) -> Option<Signal> {
88 let report_below_full = usage_screen_used(screen).is_some_and(|u| u < 100.0);
89 for line in screen.lines().rev().filter(|l| !l.trim().is_empty()).take(40) {
90 let lower = line.to_lowercase();
91 if !report_below_full && LIMIT_PHRASES.iter().any(|p| lower.contains(p)) {
92 let hint: String = line.trim().trim_matches(|c: char| !c.is_alphanumeric()).chars().take(120).collect();
93 return Some(Signal::Limit { hint });
94 }
95 if LOGIN_PHRASES.iter().any(|p| lower.contains(p)) {
96 return Some(Signal::LoginRequired);
97 }
98 }
99 None
100}
101
102#[derive(Default)]
103struct Track {
104 last: Option<&'static str>,
105 scanned: Option<Instant>,
106}
107
108pub struct Detector {
110 every: Duration,
111 sessions: HashMap<(String, u64), Track>,
112}
113
114impl Detector {
115 pub fn new(every: Duration) -> Self {
116 Self { every, sessions: HashMap::new() }
117 }
118
119 pub fn observe(&mut self, workspace: &str, instance: u64, screen: &str, now: Instant) -> Option<Signal> {
123 let t = self.sessions.entry((workspace.to_owned(), instance)).or_default();
124 if t.scanned.is_some_and(|at| now.duration_since(at) < self.every) {
125 return None;
126 }
127 t.scanned = Some(now);
128 let found = classify(screen);
129 let kind = found.as_ref().map(Signal::kind);
130 match (t.last, kind) {
131 (a, b) if a == b => None,
132 (Some(_), None) => {
133 t.last = None;
134 Some(Signal::Ready)
135 }
136 (_, Some(k)) => {
137 t.last = Some(k);
138 found
139 }
140 (None, None) => None,
141 }
142 }
143
144 pub fn forget(&mut self, workspace: &str, instance: u64) {
145 self.sessions.remove(&(workspace.to_owned(), instance));
146 }
147}
148
149#[derive(Clone, Debug)]
151pub struct Outgoing {
152 pub workspace: String,
153 pub instance: u64,
154 pub provider: String,
155 pub signal: Signal,
156}
157
158static SINK: OnceLock<tokio::sync::mpsc::UnboundedSender<Outgoing>> = OnceLock::new();
159static DETECTOR: OnceLock<Mutex<Detector>> = OnceLock::new();
160
161pub fn set_sink(tx: tokio::sync::mpsc::UnboundedSender<Outgoing>) {
163 let _ = SINK.set(tx);
164}
165
166pub fn emit(workspace: &str, instance: u64, provider: &str, signal: Signal) {
168 if let Some(tx) = SINK.get() {
169 let _ = tx.send(Outgoing { workspace: workspace.to_owned(), instance, provider: provider.to_owned(), signal });
170 }
171}
172
173pub fn feed(workspace: &str, instance: u64, provider: &str, screen: &str) {
175 let Some(tx) = SINK.get() else { return };
176 let det = DETECTOR.get_or_init(|| Mutex::new(Detector::new(Duration::from_secs(1))));
177 let signal = det.lock().unwrap_or_else(|p| p.into_inner()).observe(workspace, instance, screen, Instant::now());
178 if let Some(signal) = signal {
179 let _ = tx.send(Outgoing { workspace: workspace.to_owned(), instance, provider: provider.to_owned(), signal });
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 #[test]
186 fn usage_report_below_full_is_not_a_limit() {
187 let claude = "Settings: Status Config Usage\n Current session\n ████████ 82% used\n Resets 3:40pm (Asia/Yekaterinburg)\n Current week (all models)\n 16% used\n Weekly limit resets Oct 24";
188 assert_eq!(usage_screen_used(claude), Some(82.0));
189 assert_eq!(classify(claude), None);
190 let codex = " 5h limit: [████░░] 18% left (resets 12:11)\n Weekly limit: [█░░░░░] 84% left (resets 09:00 on 24 Oct)";
191 assert_eq!(usage_screen_used(codex), Some(82.0));
192 assert_eq!(classify(codex), None);
193 let full = " 5h limit: [██████] 0% left (resets 12:11)\n Weekly limit: [█░░░░░] 84% left";
194 assert!(matches!(classify(full), Some(Signal::Limit { .. })));
195 assert!(matches!(classify("You've hit your session limit · resets 3:40pm"), Some(Signal::Limit { .. })));
196 }
197
198 use super::*;
199
200 #[test]
201 fn classifies_limits_and_logins() {
202 assert_eq!(
203 classify("● done\n\nClaude usage limit reached. Your limit will reset at 3pm.\n> "),
204 Some(Signal::Limit { hint: "Claude usage limit reached. Your limit will reset at 3pm".into() })
205 );
206 assert_eq!(classify("■ You've hit your usage limit. Try again in 2 hours."), Some(Signal::Limit { hint: "You've hit your usage limit. Try again in 2 hours".into() }));
207 assert_eq!(classify("Select login method:\n 1. Claude account"), Some(Signal::LoginRequired));
208 assert_eq!(classify("Welcome back!\n> fix the tests"), None);
209 }
210
211 #[test]
212 fn newest_line_wins() {
213 assert_eq!(classify("rate limit hit earlier\n...\nPlease log in again"), Some(Signal::LoginRequired));
215 }
216
217 #[test]
218 fn detector_reports_changes_only() {
219 let mut d = Detector::new(Duration::from_millis(0));
220 let t0 = Instant::now();
221 assert_eq!(d.observe("w", 1, "hello", t0), None);
222 assert!(matches!(d.observe("w", 1, "usage limit reached", t0), Some(Signal::Limit { .. })));
223 assert_eq!(d.observe("w", 1, "usage limit reached", t0), None);
224 assert_eq!(d.observe("w", 1, "Please log in", t0), Some(Signal::LoginRequired));
225 assert_eq!(d.observe("w", 1, "> ready", t0), Some(Signal::Ready));
226 assert_eq!(d.observe("w", 1, "> ready", t0), None);
227 assert!(d.observe("w", 2, "rate limit", t0).is_some());
229 }
230
231 #[test]
232 fn scan_budget_skips_frequent_frames() {
233 let mut d = Detector::new(Duration::from_secs(1));
234 let t0 = Instant::now();
235 assert_eq!(d.observe("w", 1, "x", t0), None);
236 assert_eq!(d.observe("w", 1, "usage limit", t0 + Duration::from_millis(10)), None);
237 assert!(d.observe("w", 1, "usage limit", t0 + Duration::from_millis(1100)).is_some());
238 }
239}