1use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::process::{Child, Command, Stdio};
24use std::sync::Mutex;
25use std::time::{Duration, Instant};
26
27use serde::{Deserialize, Serialize};
28
29pub const ADOPTED_FILE: &str = "adopted.json";
30const HOOK_ENV: &str = "HATCHERY_LETTER_HOOK";
31const PUSH_ENV: &str = "HATCHERY_LETTER_PUSH";
32const WAKE_PROVIDER_ENV: &str = "HATCHERY_WAKE_PROVIDER";
33const WAKE_SESSION_ENV: &str = "HATCHERY_WAKE_SESSION";
34const WAKE_CWD_ENV: &str = "HATCHERY_WAKE_CWD";
35const WAKE_LEADER_ENV: &str = "HATCHERY_WAKE_LEADER";
36const WAKE_NICK_ENV: &str = "HATCHERY_WAKE_NICK";
37const SEND_SOCK_NAME: &str = "web-client.sock";
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Adoption {
43 pub provider: String,
44 pub session: String,
45 #[serde(default)]
46 pub cwd: Option<String>,
47 pub nick: String,
48 pub account_ref: String,
49 #[serde(default)]
50 pub store_root: Option<String>,
51}
52
53pub fn load_adoptions(base: &Path) -> Vec<Adoption> {
54 std::fs::read(base.join(ADOPTED_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
55}
56
57pub fn save_adoption(base: &Path, a: &Adoption) -> Result<(), String> {
59 let mut all = load_adoptions(base);
60 all.retain(|x| !(x.provider == a.provider && x.session == a.session));
61 all.push(a.clone());
62 let tmp = base.join(format!("{ADOPTED_FILE}.tmp"));
63 std::fs::write(&tmp, serde_json::to_vec_pretty(&all).map_err(|e| e.to_string())?).map_err(|e| format!("adopted: {}", e.kind()))?;
64 std::fs::rename(&tmp, base.join(ADOPTED_FILE)).map_err(|e| format!("adopted: {}", e.kind()))
65}
66
67pub fn sole_adoption(base: &Path, provider: &str) -> Option<String> {
70 let mut it = load_adoptions(base).into_iter().filter(|a| a.provider == provider);
71 let first = it.next()?;
72 it.next().is_none().then_some(first.session)
73}
74
75pub fn grok_session_cwd(grok_home: &Path, session: &str) -> Option<String> {
78 let rows: serde_json::Value = std::fs::read(grok_home.join("active_sessions.json")).ok().and_then(|b| serde_json::from_slice(&b).ok())?;
79 let rows = rows.as_array().cloned().or_else(|| rows.get("sessions").and_then(|s| s.as_array().cloned()))?;
80 rows.iter()
81 .find(|r| r.get("session_id").and_then(|v| v.as_str()) == Some(session))
82 .and_then(|r| r.get("cwd").and_then(|v| v.as_str()))
83 .filter(|c| !c.is_empty())
84 .map(str::to_owned)
85}
86
87pub fn leader_for(provider: &str) -> Option<PathBuf> {
89 match provider {
90 "grok" => {
91 let home = gate4agent_wake::grok_leader::grok_home()?;
92 Some(gate4agent_wake::grok_leader::leader_socket(&home, std::env::var_os("GROK_LEADER_SOCKET").as_deref()))
93 }
94 _ => None,
95 }
96}
97
98pub fn listener_env(a: &Adoption, product_url: &str, leader: &Path, hook: &Path) -> Result<Vec<(String, String)>, String> {
100 let store_root = a.store_root.as_deref().ok_or("adoption has no store root")?;
101 let cwd = a.cwd.as_deref().ok_or("adoption has no working directory")?;
102 let send_sock = Path::new(store_root).join(SEND_SOCK_NAME);
103 let leader = leader.to_string_lossy().into_owned();
104 Ok(vec![
105 ("M4A_PRODUCT_URL".into(), product_url.to_owned()),
106 ("M4A_SESSION_ID".into(), a.account_ref.clone()),
107 ("M4A_STORE_ROOT".into(), store_root.to_owned()),
108 ("M4A_BOT_NAME".into(), a.nick.clone()),
109 ("M4A_NICK".into(), a.nick.clone()),
110 ("M4A_LEADER_SOCK".into(), leader.clone()),
111 ("M4A_LEADER_CWD".into(), cwd.to_owned()),
112 ("M4A_SEND_SOCK".into(), send_sock.to_string_lossy().into_owned()),
113 ("M4A_LETTER_HOOK".into(), hook.to_string_lossy().into_owned()),
114 (HOOK_ENV.into(), "1".into()),
115 (WAKE_PROVIDER_ENV.into(), a.provider.clone()),
116 (WAKE_SESSION_ENV.into(), a.session.clone()),
117 (WAKE_CWD_ENV.into(), cwd.to_owned()),
118 (WAKE_LEADER_ENV.into(), leader),
119 (WAKE_NICK_ENV.into(), a.nick.clone()),
120 ])
121}
122
123struct Slot {
124 adoption: Adoption,
125 child: Option<Child>,
126 next_start: Instant,
127 backoff: Duration,
128}
129
130static SLOTS: Mutex<BTreeMap<String, Slot>> = Mutex::new(BTreeMap::new());
131static STARTED: std::sync::Once = std::sync::Once::new();
132
133pub fn watch(accounts: &crate::m4a_accounts::M4aSessionAccounts, a: Adoption) {
135 if leader_for(&a.provider).is_none() {
136 tracing::info!(provider = %a.provider, nick = %a.nick, "letter wake for this provider is not built in yet; letters stay in the inbox");
137 return;
138 }
139 {
140 let mut slots = SLOTS.lock().unwrap_or_else(|p| p.into_inner());
141 if let Some(mut old) = slots.remove(&a.account_ref) {
142 stop(&mut old);
143 }
144 slots.insert(a.account_ref.clone(), Slot { adoption: a, child: None, next_start: Instant::now(), backoff: Duration::from_secs(5) });
145 }
146 let (bin, base) = (accounts.bin().to_path_buf(), accounts.base().to_path_buf());
147 STARTED.call_once(move || {
148 std::thread::Builder::new()
149 .name("m4a-wake".into())
150 .spawn(move || loop {
151 supervise(&bin, &base);
152 std::thread::sleep(Duration::from_secs(2));
153 })
154 .ok();
155 });
156}
157
158pub fn restore(accounts: &crate::m4a_accounts::M4aSessionAccounts) {
160 for a in load_adoptions(accounts.base()) {
161 watch(accounts, a);
162 }
163}
164
165fn stop(slot: &mut Slot) {
166 if let Some(mut c) = slot.child.take() {
167 let _ = c.kill();
168 let _ = c.wait();
169 }
170}
171
172fn supervise(bin: &Path, base: &Path) {
173 let product_url = std::env::var(crate::m4a_accounts::PRODUCT_URL_ENV).ok().filter(|u| !u.is_empty());
174 let hook = std::env::current_exe().ok();
175 let mut slots = SLOTS.lock().unwrap_or_else(|p| p.into_inner());
176 for slot in slots.values_mut() {
177 if let Some(c) = slot.child.as_mut() {
178 match c.try_wait() {
179 Ok(None) => continue,
180 Ok(Some(status)) => {
181 tracing::warn!(nick = %slot.adoption.nick, code = ?status.code(), "m4a letter listener exited; restarting");
182 slot.child = None;
183 slot.next_start = Instant::now() + slot.backoff;
184 slot.backoff = (slot.backoff * 2).min(Duration::from_secs(300));
185 continue;
186 }
187 Err(_) => continue,
188 }
189 }
190 if Instant::now() < slot.next_start {
191 continue;
192 }
193 let (Some(url), Some(hook), Some(leader)) = (product_url.as_deref(), hook.as_deref(), leader_for(&slot.adoption.provider)) else {
194 slot.next_start = Instant::now() + Duration::from_secs(300);
195 tracing::warn!(nick = %slot.adoption.nick, "m4a letter listener not started: messenger url, node binary or leader unknown");
196 continue;
197 };
198 let env = match listener_env(&slot.adoption, url, &leader, hook) {
199 Ok(env) => env,
200 Err(why) => {
201 tracing::warn!(nick = %slot.adoption.nick, %why, "m4a letter listener not started");
202 slot.next_start = Instant::now() + Duration::from_secs(300);
203 continue;
204 }
205 };
206 let logs = base.join("logs");
207 let _ = std::fs::create_dir_all(&logs);
208 let log = std::fs::OpenOptions::new().create(true).append(true).open(logs.join(format!("{}.listen.log", slot.adoption.nick)));
209 let mut cmd = Command::new(bin);
210 cmd.arg("node-client").stdin(Stdio::null());
211 for k in ["M4A_SESSION_CAP", "M4A_PRODUCT_INVITE", "M4A_ROUTINE_URL", "M4A_ROUTINE_BEARER", "M4A_HOMESERVER_URL"] {
212 cmd.env_remove(k);
213 }
214 cmd.envs(env);
215 match log.and_then(|f| Ok((f.try_clone()?, f))) {
216 Ok((out, err)) => {
217 cmd.stdout(out).stderr(err);
218 }
219 Err(_) => {
220 cmd.stdout(Stdio::null()).stderr(Stdio::null());
221 }
222 }
223 match cmd.spawn() {
224 Ok(child) => {
225 tracing::info!(nick = %slot.adoption.nick, provider = %slot.adoption.provider, pid = child.id(), "m4a letter listener started");
226 slot.child = Some(child);
227 slot.backoff = Duration::from_secs(5);
228 }
229 Err(e) => {
230 tracing::warn!(nick = %slot.adoption.nick, error = %e.kind(), "m4a letter listener spawn failed");
231 slot.next_start = Instant::now() + slot.backoff;
232 slot.backoff = (slot.backoff * 2).min(Duration::from_secs(300));
233 }
234 }
235 }
236}
237
238pub fn letter_prompt(nick: &str, letter: &serde_json::Value) -> String {
240 let s = |k: &str| letter.get(k).and_then(|v| v.as_str()).unwrap_or("").to_owned();
241 let mut head = format!("[m4a letter]\nkind: {}\nfrom: {}\nto: {nick}\n", if s("kind").is_empty() { "message".into() } else { s("kind") }, s("from"));
242 if !s("room").is_empty() {
243 head.push_str(&format!("room: {}\n", s("room")));
244 }
245 head.push_str(&format!("event: {}\n", s("event_id")));
246 if letter.get("reply_needed").and_then(|v| v.as_bool()).unwrap_or(true) {
247 head.push_str(&format!("reply: your m4a MCP tool `send` with to = \"{}\"\n", s("from")));
248 }
249 format!("{head}---\n{}", s("body"))
250}
251
252pub fn hook_main() -> Option<i32> {
254 if std::env::var(PUSH_ENV).is_ok_and(|v| v == "1") {
255 return Some(push_main());
256 }
257 if !std::env::var(HOOK_ENV).is_ok_and(|v| v == "1") {
258 return None;
259 }
260 use std::io::{Read, Write};
261 let mut letter = Vec::new();
262 if std::io::stdin().take(1 << 20).read_to_end(&mut letter).is_err() {
263 return Some(2);
264 }
265 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&letter) else { return Some(2) };
266 let nick = std::env::var(WAKE_NICK_ENV).ok().or_else(|| v.get("nick").and_then(|n| n.as_str()).map(str::to_owned)).unwrap_or_default();
267 let prompt = letter_prompt(&nick, &v);
268 let Ok(exe) = std::env::current_exe() else { return Some(2) };
269 let mut cmd = Command::new(exe);
271 cmd.env_remove(HOOK_ENV).env(PUSH_ENV, "1").stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null());
272 #[cfg(windows)]
273 {
274 use std::os::windows::process::CommandExt;
275 const DETACHED_PROCESS: u32 = 0x0000_0008;
276 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
277 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
278 }
279 let Ok(mut child) = cmd.spawn() else { return Some(3) };
280 if let Some(mut stdin) = child.stdin.take() {
281 if stdin.write_all(prompt.as_bytes()).is_err() {
282 return Some(3);
283 }
284 }
285 Some(0)
286}
287
288fn push_main() -> i32 {
289 use std::io::Read;
290 let mut prompt = String::new();
291 if std::io::stdin().take(1 << 20).read_to_string(&mut prompt).is_err() || prompt.trim().is_empty() {
292 return 2;
293 }
294 let get = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
295 let (Some(provider), Some(session), Some(cwd), Some(leader)) = (get(WAKE_PROVIDER_ENV), get(WAKE_SESSION_ENV), get(WAKE_CWD_ENV), get(WAKE_LEADER_ENV)) else {
296 return 2;
297 };
298 if provider != "grok" {
299 return 4;
300 }
301 let Ok(rt) = tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build() else { return 5 };
302 match rt.block_on(gate4agent_wake::grok_leader::push_into_session(Path::new(&leader), &session, &cwd, &prompt)) {
303 Ok(()) => 0,
304 Err(_) => 1,
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 fn adoption(session: &str) -> Adoption {
313 Adoption { provider: "grok".into(), session: session.into(), cwd: Some("/w".into()), nick: "grok-1".into(), account_ref: "grok-ab".into(), store_root: Some("/s/grok-ab".into()) }
314 }
315
316 #[test]
317 fn adoptions_persist_replace_and_resolve_a_sole_session() {
318 let dir = std::env::temp_dir().join(format!("m4a-wake-{}", std::process::id()));
319 let _ = std::fs::remove_dir_all(&dir);
320 std::fs::create_dir_all(&dir).unwrap();
321 assert_eq!(sole_adoption(&dir, "grok"), None);
322 save_adoption(&dir, &adoption("s-1")).unwrap();
323 save_adoption(&dir, &adoption("s-1")).unwrap();
324 assert_eq!(load_adoptions(&dir).len(), 1);
325 assert_eq!(sole_adoption(&dir, "grok").as_deref(), Some("s-1"));
326 assert_eq!(sole_adoption(&dir, "kimi"), None);
327 save_adoption(&dir, &adoption("s-2")).unwrap();
328 assert_eq!(sole_adoption(&dir, "grok"), None, "two adoptions are ambiguous");
329 std::fs::remove_dir_all(&dir).unwrap();
330 }
331
332 #[test]
333 fn listener_env_carries_no_secret_and_points_the_hook_at_the_session() {
334 let env = listener_env(&adoption("s-1"), "https://example.invalid", Path::new("/g/leader.sock"), Path::new("/bin/node")).unwrap();
335 let m: BTreeMap<_, _> = env.into_iter().collect();
336 assert!(!m.contains_key("M4A_SESSION_CAP"));
337 assert_eq!(m["M4A_SESSION_ID"], "grok-ab");
338 assert_eq!(m[WAKE_SESSION_ENV], "s-1");
339 assert_eq!(m[WAKE_CWD_ENV], "/w");
340 assert_eq!(m["M4A_LETTER_HOOK"], "/bin/node");
341 assert!(m["M4A_SEND_SOCK"].ends_with(SEND_SOCK_NAME));
342 let mut no_cwd = adoption("s-1");
343 no_cwd.cwd = None;
344 assert!(listener_env(&no_cwd, "u", Path::new("l"), Path::new("h")).is_err());
345 }
346
347 #[test]
348 fn prompt_names_sender_and_reply_tool() {
349 let p = letter_prompt("grok-1", &serde_json::json!({"from":"web","event_id":"$e","room":"!r","body":"hi"}));
350 assert!(p.contains("from: web") && p.contains("to: grok-1") && p.contains("to = \"web\"") && p.ends_with("---\nhi"));
351 }
352
353 #[test]
354 fn grok_cwd_comes_from_the_index() {
355 let dir = std::env::temp_dir().join(format!("m4a-wake-idx-{}", std::process::id()));
356 std::fs::create_dir_all(&dir).unwrap();
357 std::fs::write(dir.join("active_sessions.json"), r#"[{"session_id":"a","cwd":"C:\\w"},{"session_id":"b","cwd":""}]"#).unwrap();
358 assert_eq!(grok_session_cwd(&dir, "a").as_deref(), Some("C:\\w"));
359 assert_eq!(grok_session_cwd(&dir, "b"), None);
360 std::fs::remove_dir_all(&dir).unwrap();
361 }
362}