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