1use std::fmt;
11use std::path::{Path, PathBuf};
12
13use super::claude_channel::ClaudeChannelAdapter;
14use super::codex::CodexAppServerAdapter;
15use super::hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
16use super::kimi::KimiServerAdapter;
17use super::kimi_acp::{KimiAcpAdapter, KimiAcpConfig};
18use super::spawn::ResumeSpawnAdapter;
19use super::{
20 AdapterConfig, ClaudeRoutineFireAdapter, GrokLeaderAdapter, NoInboundAdapter, ProviderKind,
21 ProviderSession, RoutineWebhookAdapter, SessionKind, Surface, WakeAdapter, WakeError,
22 WakeLetter, WakeOutcome,
23};
24
25pub const SURFACE_ENV: &str = "M4A_SURFACE";
27pub const WEB_VENDOR_ENV: &str = "M4A_WEB_VENDOR";
29pub const HEADLESS_ENV: &str = "M4A_HEADLESS";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Tier {
35 InSession,
37 Hook,
39 Queue,
41 Spawn,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Mechanism {
48 pub id: &'static str,
50 pub tier: Tier,
52 pub verified: bool,
54}
55
56const fn m(id: &'static str, tier: Tier, verified: bool) -> Mechanism {
57 Mechanism { id, tier, verified }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum WebVendor {
63 GrokBot,
65 ClaudeWeb,
67 CodexCloud,
69 CursorCloud,
71}
72
73impl WebVendor {
74 pub fn parse(value: &str) -> Option<Self> {
76 match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
77 "grok-bot" | "grokbot" => Some(Self::GrokBot),
78 "claude-web" | "claude" => Some(Self::ClaudeWeb),
79 "codex-cloud" | "codex" => Some(Self::CodexCloud),
80 "cursor-cloud" | "cursor" => Some(Self::CursorCloud),
81 _ => None,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct HostEnv {
89 pub surface: Surface,
91 pub vendor: Option<WebVendor>,
93 pub os: &'static str,
95}
96
97impl HostEnv {
98 pub fn detect() -> Self {
100 Self::detect_from(|key| std::env::var(key).ok(), |path| path.exists())
101 }
102
103 pub fn detect_from(
109 get: impl Fn(&str) -> Option<String>,
110 exists: impl Fn(&Path) -> bool,
111 ) -> Self {
112 let os = std::env::consts::OS;
113 let set = |key: &str| get(key).filter(|value| !value.trim().is_empty());
114 let vendor = set(WEB_VENDOR_ENV)
115 .and_then(|value| WebVendor::parse(&value))
116 .or_else(|| {
117 set("CLAUDE_CODE_REMOTE")
118 .filter(|value| value.eq_ignore_ascii_case("true") || value == "1")
119 .map(|_| WebVendor::ClaudeWeb)
120 })
121 .or_else(|| {
122 let home = get("HOME").filter(|h| !h.is_empty());
123 let marker = home.map(|h| Path::new(&h).join("agent-data")).unwrap_or_else(|| Path::new("agent-data").to_path_buf());
124 exists(&marker).then_some(WebVendor::GrokBot)
125 });
126 let surface = match set(SURFACE_ENV)
127 .as_deref()
128 .map(str::to_ascii_lowercase)
129 .as_deref()
130 {
131 Some("local") => {
132 return Self {
133 surface: Surface::Local,
134 vendor: None,
135 os,
136 }
137 }
138 Some("web") => Surface::Web,
139 _ if vendor.is_some() => Surface::Web,
140 _ => Surface::Local,
141 };
142 Self {
143 surface,
144 vendor,
145 os,
146 }
147 }
148}
149
150pub fn detect_provider(get: impl Fn(&str) -> Option<String>) -> Option<ProviderKind> {
156 let set = |key: &str| get(key).filter(|value| !value.trim().is_empty());
157 if let Some(value) = set(super::PROVIDER_ENV) {
158 return ProviderKind::parse(&value);
159 }
160 if set("CLAUDECODE").is_some() || set("CLAUDE_CODE_REMOTE").is_some() {
161 return Some(ProviderKind::ClaudeCode);
162 }
163 if set("CODEX_THREAD_ID").is_some() {
164 return Some(ProviderKind::Codex);
165 }
166 if set("CURSOR_AGENT").is_some() {
167 return Some(ProviderKind::Cursor);
168 }
169 None
170}
171
172pub fn detect_session_id(
174 provider: ProviderKind,
175 get: impl Fn(&str) -> Option<String>,
176) -> Option<String> {
177 let keys: &[&str] = match provider {
178 ProviderKind::ClaudeCode => &[
179 "CLAUDE_CODE_SESSION_ID",
180 "CLAUDE_SESSION_ID",
181 "CLAUDE_CODE_REMOTE_SESSION_ID",
182 ],
183 ProviderKind::Codex => &["CODEX_THREAD_ID", "CODEX_SESSION_ID"],
184 _ => &[],
185 };
186 keys.iter()
187 .find_map(|key| get(key).filter(|value| !value.trim().is_empty()))
188}
189
190pub fn mechanisms(kind: SessionKind, vendor: Option<WebVendor>) -> Vec<Mechanism> {
193 use Tier::*;
194 match (kind.surface, kind.provider) {
195 (Surface::Local, ProviderKind::Grok) => vec![
196 m("grok-leader-acp", InSession, true),
197 m("grok-stop-hook", Hook, false),
198 m("inbox-queue", Queue, true),
199 m("grok-resume-spawn", Spawn, false),
200 ],
201 (Surface::Local, ProviderKind::Codex) => vec![
202 m("codex-app-server-turn", InSession, true),
203 m("codex-stop-hook", Hook, false),
204 m("inbox-queue", Queue, true),
205 m("codex-exec-resume-spawn", Spawn, false),
206 ],
207 (Surface::Local, ProviderKind::KimiCode) => vec![
208 m("kimi-acp-host", InSession, true),
209 m("kimi-server-prompt", InSession, true),
210 m("kimi-stop-hook", Hook, false),
211 m("inbox-queue", Queue, true),
212 m("kimi-resume-spawn", Spawn, false),
213 ],
214 (Surface::Local, ProviderKind::ClaudeCode) => vec![
215 m("claude-channel", InSession, false),
216 m("claude-async-rewake", InSession, false),
217 m("claude-stop-hook", Hook, false),
218 m("inbox-queue", Queue, true),
219 m("claude-resume-spawn", Spawn, false),
220 ],
221 (Surface::Local, ProviderKind::Cursor) => vec![
222 m("cursor-stop-followup", Hook, false),
223 m("inbox-queue", Queue, true),
224 m("cursor-resume-spawn", Spawn, false),
225 ],
226 (Surface::Web, provider) => {
227 let mut rows = Vec::new();
228 if matches!(vendor, Some(WebVendor::GrokBot | WebVendor::CursorCloud))
229 || provider == ProviderKind::Cursor
230 {
231 rows.push(m(
232 "routine-webhook",
233 InSession,
234 vendor == Some(WebVendor::GrokBot),
235 ));
236 }
237 match provider {
238 ProviderKind::ClaudeCode => {
239 rows.push(m("claude-async-rewake", InSession, false));
240 rows.push(m("claude-stop-hook", Hook, false));
241 rows.push(m("inbox-queue", Queue, true));
242 rows.push(m("claude-routine-fire", Spawn, false));
243 }
244 ProviderKind::Codex => {
245 rows.push(m("codex-stop-hook", Hook, false));
246 rows.push(m("inbox-queue", Queue, true));
247 rows.push(m("codex-cloud-exec", Spawn, false));
248 }
249 ProviderKind::Cursor => {
250 rows.push(m("cursor-stop-followup", Hook, false));
251 rows.push(m("inbox-queue", Queue, true));
252 }
253 ProviderKind::KimiCode | ProviderKind::Grok => {
254 if rows.is_empty() {
255 rows.push(m("no-inbound", Spawn, true));
256 }
257 }
258 }
259 rows
260 }
261 }
262}
263
264#[derive(Default)]
266pub struct WakeChain {
267 links: Vec<(&'static str, Box<dyn WakeAdapter>)>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct ChainError(pub Vec<(&'static str, WakeError)>);
273
274impl fmt::Display for ChainError {
275 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276 if self.0.is_empty() {
277 return write!(f, "wake chain is empty");
278 }
279 let parts: Vec<String> = self
280 .0
281 .iter()
282 .map(|(id, err)| format!("{id}: {err}"))
283 .collect();
284 write!(f, "{}", parts.join("; "))
285 }
286}
287
288impl std::error::Error for ChainError {}
289
290impl fmt::Debug for WakeChain {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 f.debug_list().entries(self.ids()).finish()
293 }
294}
295
296impl WakeChain {
297 pub fn new() -> Self {
299 Self::default()
300 }
301
302 pub fn push(&mut self, id: &'static str, adapter: Box<dyn WakeAdapter>) {
304 self.links.push((id, adapter));
305 }
306
307 pub fn prepend(&mut self, id: &'static str, adapter: Box<dyn WakeAdapter>) {
309 self.links.insert(0, (id, adapter));
310 }
311
312 pub fn ids(&self) -> Vec<&'static str> {
314 self.links.iter().map(|(id, _)| *id).collect()
315 }
316
317 pub fn is_empty(&self) -> bool {
319 self.links.is_empty()
320 }
321
322 pub fn best(&self, session: &ProviderSession) -> Option<&'static str> {
324 self.links
325 .iter()
326 .find(|(_, adapter)| adapter.probe(session).is_ok())
327 .map(|(id, _)| *id)
328 }
329
330 pub fn wake(
332 &mut self,
333 session: &ProviderSession,
334 letter: &WakeLetter<'_>,
335 ) -> Result<(&'static str, WakeOutcome), ChainError> {
336 let mut errors = Vec::new();
337 for (id, adapter) in &mut self.links {
338 if let Err(err) = adapter.probe(session) {
339 errors.push((*id, err));
340 continue;
341 }
342 match adapter.wake(session, letter) {
343 Ok(outcome) => return Ok((*id, outcome)),
344 Err(err) => errors.push((*id, err)),
345 }
346 }
347 Err(ChainError(errors))
348 }
349}
350
351pub fn plan_chain(session: &ProviderSession, host: &HostEnv, config: &AdapterConfig) -> WakeChain {
353 let kind = session.kind;
354 let inbox = config.inbox_dir.clone();
355 let mut chain = WakeChain::new();
356 for row in mechanisms(kind, host.vendor) {
357 let adapter: Box<dyn WakeAdapter> = match row.id {
358 "grok-leader-acp" => Box::new(GrokLeaderAdapter {
359 leader_sock: config.leader_sock.clone(),
360 }),
361 "codex-app-server-turn" => Box::new(CodexAppServerAdapter::new(config.codex.clone())),
362 "kimi-acp-host" => Box::new(KimiAcpAdapter::new(
363 config.kimi_acp_bin.clone().map(|bin| KimiAcpConfig { bin }),
364 )),
365 "kimi-server-prompt" => Box::new(KimiServerAdapter::new(
366 config.kimi_url.clone(),
367 config.kimi_bearer.clone(),
368 )),
369 "claude-channel" => Box::new(ClaudeChannelAdapter::new(inbox.clone())),
370 "routine-webhook" => Box::new(RoutineWebhookAdapter {
371 provider: kind.provider,
372 url: config.routine_url.clone(),
373 bearer: config.routine_bearer.clone(),
374 }),
375 "claude-async-rewake" => hook(kind, HookFlavor::ClaudeRewake, &inbox),
376 "claude-stop-hook" => hook(kind, HookFlavor::ClaudeStop, &inbox),
377 "codex-stop-hook" => hook(kind, HookFlavor::CodexStop, &inbox),
378 "kimi-stop-hook" => hook(kind, HookFlavor::KimiStop, &inbox),
379 "cursor-stop-followup" => hook(kind, HookFlavor::CursorStop, &inbox),
380 "grok-stop-hook" => hook(kind, HookFlavor::GrokStop, &inbox),
381 "inbox-queue" => Box::new(InboxQueueAdapter::new(kind, inbox.clone())),
382 "claude-routine-fire" => Box::new(ClaudeRoutineFireAdapter {
383 url: config.claude_fire_url.clone(),
384 bearer: config.claude_fire_bearer.clone(),
385 }),
386 "no-inbound" => Box::new(NoInboundAdapter {
387 provider: kind.provider,
388 }),
389 id if id.ends_with("-spawn") || id == "codex-cloud-exec" => {
390 let program = config.spawn_program.clone().or_else(|| {
391 std::env::var_os(super::spawn::bin_env(kind.provider))
392 .filter(|value| !value.is_empty())
393 .map(PathBuf::from)
394 });
395 let cloud_env = config.codex_cloud_env.clone().or_else(|| {
396 std::env::var(super::spawn::CODEX_CLOUD_ENV_ENV)
397 .ok()
398 .filter(|value| !value.is_empty())
399 });
400 Box::new(ResumeSpawnAdapter::new(kind, program, cloud_env))
401 }
402 _ => continue,
403 };
404 chain.push(row.id, adapter);
405 }
406 chain
407}
408
409pub fn detected_web_chain(
414 session_id: &str,
415 nick: &str,
416 store_root: &Path,
417) -> Option<(ProviderSession, WakeChain)> {
418 let host = HostEnv::detect();
419 if host.surface != Surface::Web || host.vendor == Some(WebVendor::GrokBot) {
420 return None;
421 }
422 let provider = detect_provider(|key| std::env::var(key).ok())?;
423 let session = ProviderSession {
424 kind: SessionKind::web(provider),
425 session_id: session_id.to_string(),
426 nick: nick.to_string(),
427 cwd: std::env::current_dir().ok(),
428 headless: std::env::var(HEADLESS_ENV).is_ok_and(|v| v == "1"),
429 };
430 let config = AdapterConfig::from_env(Some(super::registry::inbox_dir(store_root, session_id)));
431 let chain = plan_chain(&session, &host, &config);
432 Some((session, chain))
433}
434
435fn hook(kind: SessionKind, flavor: HookFlavor, inbox: &Option<PathBuf>) -> Box<dyn WakeAdapter> {
436 Box::new(InboxHookAdapter::new(kind, flavor, inbox.clone()))
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 #[cfg(feature = "wake-claude")]
443 use crate::inbox;
444 use crate::tests::{letter, session};
445
446 fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
447 move |key| {
448 pairs
449 .iter()
450 .find(|(k, _)| *k == key)
451 .map(|(_, v)| v.to_string())
452 }
453 }
454
455 #[test]
456 fn host_detection_orders_overrides_then_markers() {
457 let none = |_: &Path| false;
458 assert_eq!(HostEnv::detect_from(env(&[]), none).surface, Surface::Local);
459 let claude = HostEnv::detect_from(env(&[("CLAUDE_CODE_REMOTE", "true")]), none);
460 assert_eq!(
461 (claude.surface, claude.vendor),
462 (Surface::Web, Some(WebVendor::ClaudeWeb))
463 );
464 let bot = HostEnv::detect_from(env(&[]), |p: &Path| p.ends_with("agent-data"));
465 assert_eq!(bot.vendor, Some(WebVendor::GrokBot));
466 let forced = HostEnv::detect_from(env(&[(SURFACE_ENV, "local")]), |_: &Path| true);
467 assert_eq!((forced.surface, forced.vendor), (Surface::Local, None));
468 let codex = HostEnv::detect_from(env(&[(WEB_VENDOR_ENV, "codex-cloud")]), none);
469 assert_eq!(codex.vendor, Some(WebVendor::CodexCloud));
470 }
471
472 #[test]
473 fn provider_detection_uses_cli_markers() {
474 assert_eq!(
475 detect_provider(env(&[("CLAUDECODE", "1")])),
476 Some(ProviderKind::ClaudeCode)
477 );
478 assert_eq!(
479 detect_provider(env(&[("CODEX_THREAD_ID", "t")])),
480 Some(ProviderKind::Codex)
481 );
482 assert_eq!(
483 detect_provider(env(&[("CURSOR_AGENT", "1")])),
484 Some(ProviderKind::Cursor)
485 );
486 assert_eq!(
487 detect_provider(env(&[("M4A_PROVIDER", "kimi"), ("CLAUDECODE", "1")])),
488 Some(ProviderKind::KimiCode)
489 );
490 assert_eq!(detect_provider(env(&[])), None);
491 assert_eq!(
492 detect_session_id(ProviderKind::Codex, env(&[("CODEX_THREAD_ID", "t9")])).as_deref(),
493 Some("t9")
494 );
495 }
496
497 #[test]
498 fn every_chain_puts_in_session_first_and_spawn_last() {
499 for provider in ProviderKind::ALL {
500 for (kind, vendor) in [
501 (SessionKind::local(provider), None),
502 (SessionKind::web(provider), Some(WebVendor::GrokBot)),
503 (SessionKind::web(provider), Some(WebVendor::ClaudeWeb)),
504 ] {
505 let rows = mechanisms(kind, vendor);
506 assert!(!rows.is_empty(), "{kind}");
507 let tiers: Vec<Tier> = rows.iter().map(|row| row.tier).collect();
508 let rank = |t: &Tier| match t {
509 Tier::InSession => 0,
510 Tier::Hook => 1,
511 Tier::Queue => 2,
512 Tier::Spawn => 3,
513 };
514 assert!(
515 tiers.windows(2).all(|w| rank(&w[0]) <= rank(&w[1])),
516 "{kind}: {tiers:?}"
517 );
518 let host = HostEnv {
519 surface: kind.surface,
520 vendor,
521 os: "linux",
522 };
523 let chain = plan_chain(&session(kind), &host, &AdapterConfig::default());
524 let ids: Vec<&str> = rows.iter().map(|row| row.id).collect();
525 assert_eq!(chain.ids(), ids, "{kind}");
526 }
527 }
528 }
529
530 #[cfg(feature = "wake-claude")]
531 #[test]
532 fn chain_falls_through_to_durable_queue_without_spawning() {
533 let dir = std::env::temp_dir().join(format!("m4a-chain-{}", std::process::id()));
534 let _ = std::fs::remove_dir_all(&dir);
535 let kind = SessionKind::local(ProviderKind::ClaudeCode);
536 let config = AdapterConfig {
537 inbox_dir: Some(dir.clone()),
538 spawn_program: Some("/bin/false".into()),
539 ..AdapterConfig::default()
540 };
541 let host = HostEnv {
542 surface: Surface::Local,
543 vendor: None,
544 os: "linux",
545 };
546 let s = session(kind);
547 let mut chain = plan_chain(&s, &host, &config);
548 let (id, outcome) = chain.wake(&s, &letter("hi")).unwrap();
549 assert_eq!(id, "inbox-queue");
550 assert!(matches!(outcome, WakeOutcome::Queued(_)));
551 let _live = inbox::Presence::announce(&dir, inbox::consumer::CLAUDE_CHANNEL).unwrap();
553 assert_eq!(chain.best(&s), Some("claude-channel"));
554 let (id, _) = chain.wake(&s, &letter("hi")).unwrap();
555 assert_eq!(id, "claude-channel");
556 let _ = std::fs::remove_dir_all(&dir);
557 }
558
559 #[test]
560 fn chain_without_any_endpoint_reports_every_link() {
561 let kind = SessionKind::web(ProviderKind::KimiCode);
562 let host = HostEnv {
563 surface: Surface::Web,
564 vendor: None,
565 os: "linux",
566 };
567 let s = session(kind);
568 let mut chain = plan_chain(&s, &host, &AdapterConfig::default());
569 let err = chain.wake(&s, &letter("x")).unwrap_err();
570 assert_eq!(err.0.len(), 1);
571 assert!(err.to_string().contains("no inbound"));
572 }
573
574 #[test]
577 #[ignore]
578 fn live_codex_chain_turns_in_the_open_thread() {
579 let endpoint = std::env::var("M4A_LIVE_CODEX_APP_SERVER").expect("endpoint");
580 let thread = std::env::var("M4A_LIVE_CODEX_THREAD").expect("thread");
581 let kind = SessionKind::local(ProviderKind::Codex);
582 let mut s = session(kind);
583 s.session_id = thread;
584 let config = AdapterConfig {
585 codex: super::super::CodexEndpoint::parse(&endpoint),
586 spawn_program: Some("/bin/false".into()),
587 ..AdapterConfig::default()
588 };
589 let host = HostEnv {
590 surface: Surface::Local,
591 vendor: None,
592 os: std::env::consts::OS,
593 };
594 let mut chain = plan_chain(&s, &host, &config);
595 let (id, outcome) = chain
596 .wake(&s, &letter("live chain ping, reply with one word"))
597 .unwrap();
598 assert_eq!(
599 (id, outcome),
600 ("codex-app-server-turn", WakeOutcome::Delivered)
601 );
602 }
603
604 #[test]
607 #[ignore]
608 fn live_kimi_chain_prompts_the_open_session() {
609 let kind = SessionKind::local(ProviderKind::KimiCode);
610 let mut s = session(kind);
611 s.session_id = std::env::var("M4A_LIVE_KIMI_SESSION").expect("session");
612 let mut config = AdapterConfig::from_env(None);
613 if let (Ok(url), Ok(token)) = (
614 std::env::var("M4A_LIVE_KIMI_URL"),
615 std::env::var("M4A_LIVE_KIMI_TOKEN"),
616 ) {
617 config.kimi_url = Some(url);
618 config.kimi_bearer = Some(token);
619 }
620 config.spawn_program = Some("/bin/false".into());
621 let host = HostEnv {
622 surface: Surface::Local,
623 vendor: None,
624 os: std::env::consts::OS,
625 };
626 let mut chain = plan_chain(&s, &host, &config);
627 let (id, outcome) = chain.wake(&s, &letter("live chain ping")).unwrap();
628 assert_eq!(
629 (id, outcome),
630 ("kimi-server-prompt", WakeOutcome::Delivered)
631 );
632 }
633}