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 ProviderError { class: &'static str, code: Option<u16>, attempts: u32, hint: String },
19}
20
21impl Signal {
22 pub fn kind(&self) -> &'static str {
23 match self {
24 Signal::Limit { .. } => "limit",
25 Signal::LoginRequired => "login_required",
26 Signal::Ready => "ready",
27 Signal::ProviderError { .. } => "provider_error",
28 }
29 }
30}
31
32const LIMIT_PHRASES: &[&str] = &[
33 "usage limit",
34 "rate limit",
35 "rate-limit",
36 "limit reached",
37 "reached your limit",
38 "you've hit your",
39 "you have hit your",
40 "quota exceeded",
41 "exceeded your current quota",
42 "too many requests",
43 "out of credits",
44 "weekly limit",
45 "5-hour limit",
46];
47const LOGIN_PHRASES: &[&str] = &[
48 "select login method",
49 "sign in with chatgpt",
50 "please log in",
51 "please login",
52 "run /login",
53 "not logged in",
54 "approve in your browser",
55 "how would you like to authenticate",
56 "invalid api key",
57 "authentication required",
58];
59
60pub fn classify(screen: &str) -> Option<Signal> {
62 for line in screen.lines().rev().filter(|l| !l.trim().is_empty()).take(40) {
63 let lower = line.to_lowercase();
64 if LIMIT_PHRASES.iter().any(|p| lower.contains(p)) {
65 let hint: String = line.trim().trim_matches(|c: char| !c.is_alphanumeric()).chars().take(120).collect();
66 return Some(Signal::Limit { hint });
67 }
68 if LOGIN_PHRASES.iter().any(|p| lower.contains(p)) {
69 return Some(Signal::LoginRequired);
70 }
71 }
72 None
73}
74
75#[derive(Default)]
76struct Track {
77 last: Option<&'static str>,
78 scanned: Option<Instant>,
79}
80
81pub struct Detector {
83 every: Duration,
84 sessions: HashMap<(String, u64), Track>,
85}
86
87impl Detector {
88 pub fn new(every: Duration) -> Self {
89 Self { every, sessions: HashMap::new() }
90 }
91
92 pub fn observe(&mut self, workspace: &str, instance: u64, screen: &str, now: Instant) -> Option<Signal> {
96 let t = self.sessions.entry((workspace.to_owned(), instance)).or_default();
97 if t.scanned.is_some_and(|at| now.duration_since(at) < self.every) {
98 return None;
99 }
100 t.scanned = Some(now);
101 let found = classify(screen);
102 let kind = found.as_ref().map(Signal::kind);
103 match (t.last, kind) {
104 (a, b) if a == b => None,
105 (Some(_), None) => {
106 t.last = None;
107 Some(Signal::Ready)
108 }
109 (_, Some(k)) => {
110 t.last = Some(k);
111 found
112 }
113 (None, None) => None,
114 }
115 }
116
117 pub fn forget(&mut self, workspace: &str, instance: u64) {
118 self.sessions.remove(&(workspace.to_owned(), instance));
119 }
120}
121
122#[derive(Clone, Debug)]
124pub struct Outgoing {
125 pub workspace: String,
126 pub instance: u64,
127 pub provider: String,
128 pub signal: Signal,
129}
130
131static SINK: OnceLock<tokio::sync::mpsc::UnboundedSender<Outgoing>> = OnceLock::new();
132static DETECTOR: OnceLock<Mutex<Detector>> = OnceLock::new();
133
134pub fn set_sink(tx: tokio::sync::mpsc::UnboundedSender<Outgoing>) {
136 let _ = SINK.set(tx);
137}
138
139pub fn emit(workspace: &str, instance: u64, provider: &str, signal: Signal) {
141 if let Some(tx) = SINK.get() {
142 let _ = tx.send(Outgoing { workspace: workspace.to_owned(), instance, provider: provider.to_owned(), signal });
143 }
144}
145
146pub fn feed(workspace: &str, instance: u64, provider: &str, screen: &str) {
148 let Some(tx) = SINK.get() else { return };
149 let det = DETECTOR.get_or_init(|| Mutex::new(Detector::new(Duration::from_secs(1))));
150 let signal = det.lock().unwrap_or_else(|p| p.into_inner()).observe(workspace, instance, screen, Instant::now());
151 if let Some(signal) = signal {
152 let _ = tx.send(Outgoing { workspace: workspace.to_owned(), instance, provider: provider.to_owned(), signal });
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn classifies_limits_and_logins() {
162 assert_eq!(
163 classify("● done\n\nClaude usage limit reached. Your limit will reset at 3pm.\n> "),
164 Some(Signal::Limit { hint: "Claude usage limit reached. Your limit will reset at 3pm".into() })
165 );
166 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() }));
167 assert_eq!(classify("Select login method:\n 1. Claude account"), Some(Signal::LoginRequired));
168 assert_eq!(classify("Welcome back!\n> fix the tests"), None);
169 }
170
171 #[test]
172 fn newest_line_wins() {
173 assert_eq!(classify("rate limit hit earlier\n...\nPlease log in again"), Some(Signal::LoginRequired));
175 }
176
177 #[test]
178 fn detector_reports_changes_only() {
179 let mut d = Detector::new(Duration::from_millis(0));
180 let t0 = Instant::now();
181 assert_eq!(d.observe("w", 1, "hello", t0), None);
182 assert!(matches!(d.observe("w", 1, "usage limit reached", t0), Some(Signal::Limit { .. })));
183 assert_eq!(d.observe("w", 1, "usage limit reached", t0), None);
184 assert_eq!(d.observe("w", 1, "Please log in", t0), Some(Signal::LoginRequired));
185 assert_eq!(d.observe("w", 1, "> ready", t0), Some(Signal::Ready));
186 assert_eq!(d.observe("w", 1, "> ready", t0), None);
187 assert!(d.observe("w", 2, "rate limit", t0).is_some());
189 }
190
191 #[test]
192 fn scan_budget_skips_frequent_frames() {
193 let mut d = Detector::new(Duration::from_secs(1));
194 let t0 = Instant::now();
195 assert_eq!(d.observe("w", 1, "x", t0), None);
196 assert_eq!(d.observe("w", 1, "usage limit", t0 + Duration::from_millis(10)), None);
197 assert!(d.observe("w", 1, "usage limit", t0 + Duration::from_millis(1100)).is_some());
198 }
199}