1use gate4agent_pty::{native_pty_system, CommandBuilder, PtySize};
18use std::io::{Read, Write};
19use std::path::PathBuf;
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23pub const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi"];
24const ROWS: u16 = 50;
25const COLS: u16 = 140;
26const SCREEN_WAIT: Duration = Duration::from_secs(10);
28const BOOT_WAIT: Duration = Duration::from_secs(8);
30
31pub const RELAUNCH_MIN: Duration = Duration::from_secs(600);
35
36pub fn relaunch_after(lived: Duration, last: Duration) -> Duration {
38 if lived > Duration::from_secs(3600) { RELAUNCH_MIN } else { (last * 2).clamp(RELAUNCH_MIN, Duration::from_secs(3600)) }
39}
40
41pub fn interval_from(v: Option<&str>) -> Option<Duration> {
43 match v.map(str::trim).filter(|v| !v.is_empty()) {
44 None => Some(Duration::from_secs(45)),
45 Some(v) => match v.parse::<u64>() {
46 Ok(0) => None,
47 Ok(n) => Some(Duration::from_secs(n.clamp(15, 3600))),
48 Err(_) => Some(Duration::from_secs(45)),
49 },
50 }
51}
52
53pub fn command_for(provider: &str) -> Option<&'static str> {
55 match provider {
56 "claude" | "grok" | "kimi" => Some("/usage"),
57 "codex" => Some("/status"),
58 _ => None,
59 }
60}
61
62pub fn trust_keys(screen: &str) -> Option<&'static [u8]> {
66 let low = screen.to_lowercase();
67 if low.contains("grok build may run or modify") {
69 return Some(b"y\r");
70 }
71 let asks = ["i trust this folder", "one you trust", "trust the files in this folder", "allow codex to work in this folder", "trust this folder?"].iter().any(|p| low.contains(p));
72 if !asks {
73 return None;
74 }
75 let selected = low.lines().find(|l| l.trim_start().starts_with('❯') || l.trim_start().starts_with('›') || l.trim_start().starts_with("> "));
76 Some(if selected.is_some_and(|l| l.contains("yes") || l.contains("trust and continue")) { b"\r" } else { b"\x1b[B\r" })
77}
78
79fn sign_in_known(provider: &str) -> bool {
83 matches!(provider, "claude" | "codex")
84}
85
86static STATE_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
90
91pub fn scratch_base(env_dir: Option<PathBuf>, state_dir: Option<&std::path::Path>) -> PathBuf {
92 env_dir.or_else(|| state_dir.map(|d| d.join("limit-heartbeat"))).unwrap_or_else(|| std::env::temp_dir().join("hatchery-limit-heartbeat"))
93}
94
95pub fn start(state_dir: Option<PathBuf>) {
97 if let Some(d) = state_dir {
98 let _ = STATE_DIR.set(d);
99 }
100 let Some(every) = interval_from(std::env::var("HATCHERY_LIMIT_HEARTBEAT_SECS").ok().as_deref()) else {
101 tracing::info!("limit heartbeat off");
102 return;
103 };
104 for provider in PROVIDERS {
105 std::thread::Builder::new()
106 .name(format!("limit-heartbeat-{provider}"))
107 .spawn(move || run(provider, every))
108 .ok();
109 }
110}
111
112fn scratch_dir(provider: &str) -> Option<PathBuf> {
113 let base = scratch_base(std::env::var_os("HATCHERY_LIMIT_HEARTBEAT_DIR").map(PathBuf::from), STATE_DIR.get().map(PathBuf::as_path));
114 let dir = base.join(provider);
115 std::fs::create_dir_all(&dir).ok()?;
116 Some(dir)
117}
118
119fn run(provider: &'static str, every: Duration) {
121 let mut backoff = RELAUNCH_MIN;
122 loop {
123 let kind = if sign_in_known(provider) {
124 crate::provider_inventory::account_kind(provider)
125 } else {
126 crate::provider_inventory::resolve_binary(provider, &[]).map(|_| "screen".to_owned())
127 };
128 match kind.as_deref() {
129 None => {
130 tracing::info!(provider, "limit heartbeat: no CLI or account kind; not started");
131 return;
132 }
133 Some("none") => {
134 tracing::info!(provider, "limit heartbeat: not signed in; checking again later");
135 std::thread::sleep(Duration::from_secs(1800));
136 continue;
137 }
138 Some(_) => {}
139 }
140 let started = Instant::now();
141 match session(provider, every) {
142 Err(e) if e == NOT_SIGNED_IN => {
143 tracing::info!(provider, "limit heartbeat: the CLI asks to sign in; checking again in an hour");
144 std::thread::sleep(Duration::from_secs(3600));
145 continue;
146 }
147 Err(e) => tracing::warn!(provider, error = %e, "limit heartbeat session ended"),
148 Ok(()) => {}
149 }
150 backoff = relaunch_after(started.elapsed(), backoff);
151 std::thread::sleep(backoff);
152 }
153}
154
155const NOT_SIGNED_IN: &str = "not signed in";
156
157fn session(provider: &'static str, every: Duration) -> Result<(), String> {
158 let bin = crate::provider_inventory::resolve_binary(provider, &[]).ok_or("no binary")?;
159 let dir = scratch_dir(provider).ok_or("no scratch folder")?;
160 let command = command_for(provider).ok_or("no limit command")?;
161 let pair = native_pty_system().openpty(PtySize { rows: ROWS, cols: COLS, pixel_width: 0, pixel_height: 0 }).map_err(|e| e.to_string())?;
162 let mut cmd = CommandBuilder::new(&bin);
163 cmd.cwd(&dir);
164 cmd.env("TERM", "xterm-256color");
167 cmd.env_remove("NO_COLOR");
168 let mut child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
169 drop(pair.slave);
170 let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0)));
171 let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?;
172 let feed = Arc::clone(&parser);
173 std::thread::spawn(move || {
174 let mut buf = [0u8; 8192];
175 while let Ok(n) = reader.read(&mut buf) {
176 if n == 0 {
177 break;
178 }
179 feed.lock().unwrap_or_else(|p| p.into_inner()).process(&buf[..n]);
180 }
181 });
182 let mut writer = pair.master.take_writer().map_err(|e| e.to_string())?;
183 let screen = || parser.lock().unwrap_or_else(|p| p.into_inner()).screen().contents();
184 let mut send = |bytes: &[u8]| writer.write_all(bytes).and_then(|_| writer.flush()).map_err(|e| e.to_string());
185 tracing::info!(provider, "limit heartbeat started");
186 let result = (|| -> Result<(), String> {
187 std::thread::sleep(BOOT_WAIT);
188 if crate::provider_limits::says_not_signed_in(provider, &screen()) {
191 return Err(NOT_SIGNED_IN.into());
192 }
193 if let Some(keys) = trust_keys(&screen()) {
194 send(&keys[..keys.len() - 1])?;
195 std::thread::sleep(Duration::from_millis(400));
196 send(b"\r")?;
197 std::thread::sleep(BOOT_WAIT);
198 }
199 loop {
200 if child.try_wait().map_err(|e| e.to_string())?.is_some() {
201 return Err("CLI exited".into());
202 }
203 send(command.as_bytes())?;
206 std::thread::sleep(Duration::from_millis(400));
207 send(b"\r")?;
208 let deadline = Instant::now() + SCREEN_WAIT;
209 let mut read = false;
210 while Instant::now() < deadline {
211 std::thread::sleep(Duration::from_millis(500));
212 if crate::provider_limits::observe_screen_as(provider, &screen(), "heartbeat") {
213 read = true;
214 break;
215 }
216 }
217 if !read {
218 if crate::provider_limits::says_not_signed_in(provider, &screen()) {
219 return Err(NOT_SIGNED_IN.into());
220 }
221 tracing::debug!(provider, "limit heartbeat: no limit screen this time");
222 }
223 send(b"\x1b")?;
225 std::thread::sleep(Duration::from_millis(300));
226 send(b"\x15")?;
227 std::thread::sleep(every);
228 }
229 })();
230 let _ = child.kill();
232 let _ = child.wait();
233 result
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn scratch_lives_in_node_state_dir() {
242 let st = std::path::Path::new("/var/lib/node");
243 assert_eq!(scratch_base(None, Some(st)), st.join("limit-heartbeat"));
244 assert_eq!(scratch_base(Some("/x".into()), Some(st)), PathBuf::from("/x"));
245 assert_eq!(scratch_base(None, None), std::env::temp_dir().join("hatchery-limit-heartbeat"));
246 }
247
248 #[test]
249 fn relaunch_is_rare() {
250 let m = Duration::from_secs(60);
251 assert_eq!(relaunch_after(Duration::from_secs(5), RELAUNCH_MIN), RELAUNCH_MIN * 2);
252 assert_eq!(relaunch_after(Duration::from_secs(5), Duration::from_secs(1)), RELAUNCH_MIN);
253 assert_eq!(relaunch_after(Duration::from_secs(5), 50 * m), 60 * m);
254 assert_eq!(relaunch_after(2 * 60 * m, 60 * m), RELAUNCH_MIN);
255 }
256
257 #[test]
258 fn interval_is_configurable_and_bounded() {
259 assert_eq!(interval_from(None), Some(Duration::from_secs(45)));
260 assert_eq!(interval_from(Some("30")), Some(Duration::from_secs(30)));
261 assert_eq!(interval_from(Some("1")), Some(Duration::from_secs(15)));
262 assert_eq!(interval_from(Some("99999")), Some(Duration::from_secs(3600)));
263 assert_eq!(interval_from(Some("0")), None);
264 assert_eq!(interval_from(Some("x")), Some(Duration::from_secs(45)));
265 }
266
267 #[test]
268 fn commands_and_trust_prompt() {
269 assert_eq!((command_for("claude"), command_for("codex"), command_for("grok"), command_for("kimi"), command_for("bash")), (Some("/usage"), Some("/status"), Some("/usage"), Some("/usage"), None));
270 let grok = "Do you trust the contents of this directory?\n Grok Build may run or modify contents in this directory,\n posing security risks.\n Yes, proceed y\n No, quit n";
271 assert_eq!(trust_keys(grok), Some(&b"y\r"[..]));
272 let kimi = " Trust this folder?\n ↑↓ navigate · Enter select · Esc exit\n ❯ Trust and continue\n Exit";
273 assert_eq!(trust_keys(kimi), Some(&b"\r"[..]));
274 assert!(sign_in_known("claude") && sign_in_known("codex") && !sign_in_known("grok") && !sign_in_known("kimi"));
275 let claude = " Quick safety check: Is this a project you created or one you trust?\n ❯ No, exit\n Yes, I trust this folder\n Enter to confirm";
276 assert_eq!(trust_keys(claude), Some(&b"\x1b[B\r"[..]));
277 let codex = "Do you trust the contents of this directory?\n› 1. Yes, allow Codex to work in this folder without asking for approval\n 2. No, quit";
278 assert_eq!(trust_keys(codex), Some(&b"\r"[..]));
279 assert_eq!(trust_keys("> what is trust?"), None);
280 }
281}
282
283#[cfg(test)]
286mod live {
287 #[test]
288 #[ignore]
289 fn claude_heartbeat_reads_usage() {
290 std::thread::spawn(|| super::session("claude", std::time::Duration::from_secs(3600)));
291 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
292 while std::time::Instant::now() < deadline {
293 if let Some(l) = crate::provider_limits::collect().into_iter().find(|l| l.provider == "claude" && l.source == "heartbeat") {
294 println!("heartbeat: {:?}", l.windows.iter().map(|w| (&w.window, w.used_percent)).collect::<Vec<_>>());
295 return;
296 }
297 std::thread::sleep(std::time::Duration::from_secs(1));
298 }
299 panic!("no /usage reading within 60s");
300 }
301}