Skip to main content

hatchery_node/
limit_heartbeat.rs

1//! Limit heartbeat: the only way the node reads provider limits on purpose.
2//!
3//! One light service PTY per signed-in provider account (claude, codex, grok, kimi), outside the node's
4//! managed sessions: never shown in HQ, no record, no m4a account, no workspace. It runs the
5//! provider CLI in its own scratch folder and, every interval (env
6//! `HATCHERY_LIMIT_HEARTBEAT_SECS`, default 45, 15..=3600; `0` turns it off), types the
7//! provider's own local limit screen (`/usage` for claude, grok and kimi, `/status` for codex), reads it off
8//! the screen into [`crate::provider_limits`] (source `heartbeat`) and closes it with Escape.
9//! Only slash commands are typed: no prompt, no model turn. A CLI that is not signed in
10//! (`none`, or a screen that asks to log in) gets no heartbeat until a later check. grok and
11//! kimi have no `auth status`: their limit screen is the sign-in test. Tokens, OAuth and
12//! credential files are never touched.
13//!
14//! A first start in the scratch folder may ask whether to trust it; the heartbeat answers the
15//! default ("yes") for its own folder only.
16
17use 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;
26/// How long a typed limit screen may take to show.
27const SCREEN_WAIT: Duration = Duration::from_secs(10);
28/// Wait after a start before the first command (the CLI draws its prompt).
29const BOOT_WAIT: Duration = Duration::from_secs(8);
30
31/// A CLI start may itself cost the account (start-up requests), so the PTY is kept for the
32/// life of the node and a new one is started at most this often; the readings in between are
33/// slash commands into the same CLI.
34pub const RELAUNCH_MIN: Duration = Duration::from_secs(600);
35
36/// Wait before the next start after a session that lived `lived`.
37pub 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
41/// The heartbeat interval: `None` when off.
42pub 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
53/// The slash command that shows `provider`'s limits.
54pub 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
62/// Keys that answer a start-up question about trusting the heartbeat's own scratch folder
63/// with "yes": Enter when the selected option already says yes, else Down then Enter (claude
64/// selects "No, exit" first). `None` when the screen asks nothing of the kind.
65pub fn trust_keys(screen: &str) -> Option<&'static [u8]> {
66    let low = screen.to_lowercase();
67    // grok: "Grok Build may run or modify contents in this directory" with `y` / `n` keys.
68    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
79/// How a provider's sign-in is judged before its heartbeat starts: claude and codex by their
80/// `auth`/`login status`; grok and kimi have none, so only an installed CLI is needed and
81/// the limit screen itself tells.
82fn sign_in_known(provider: &str) -> bool {
83    matches!(provider, "claude" | "codex")
84}
85
86/// Run the heartbeat for every signed-in provider (one thread each). Returns at once.
87/// Base of the scratch folders: `HATCHERY_LIMIT_HEARTBEAT_DIR`, else `limit-heartbeat` next to
88/// the node state file (set once by [`start`]), else the temp folder.
89static 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
95/// `state_dir`: the folder of the node state file.
96pub 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
119/// One provider's heartbeat, for the life of the node: (re)start its PTY when it is gone.
120fn 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    // A plain terminal, and never a colour-less one (NO_COLOR changes nothing we read, but
165    // keep the CLI's normal screen).
166    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        // A CLI that opens on a sign-in screen (grok starts a device login by itself): leave
189        // at once, type nothing.
190        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            // Type the command, then Enter on its own (a TUI may read a pasted line with Enter
204            // as text).
205            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            // Close the panel (and any half-typed command).
224            send(b"\x1b")?;
225            std::thread::sleep(Duration::from_millis(300));
226            send(b"\x15")?;
227            std::thread::sleep(every);
228        }
229    })();
230    // Never leave the CLI behind.
231    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/// Live check on a machine with a signed-in claude: `cargo test -p hatchery-node --lib
284/// limit_heartbeat::live -- --ignored --nocapture`. Types only `/usage`.
285#[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}