1pub mod chain;
36#[cfg(feature = "wake-claude")]
37pub mod claude_channel;
38#[cfg(not(feature = "wake-claude"))]
39pub use off::claude_channel;
40#[cfg(feature = "wake-codex")]
41pub mod codex;
42#[cfg(not(feature = "wake-codex"))]
43pub use off::codex;
44pub mod hook;
45pub mod inbox;
46#[cfg(feature = "wake-kimi")]
47pub mod kimi;
48#[cfg(not(feature = "wake-kimi"))]
49pub use off::kimi;
50pub mod registry;
51#[cfg(not(all(feature = "wake-claude", feature = "wake-codex", feature = "wake-kimi", feature = "wake-spawn")))]
52mod off;
53#[cfg(feature = "wake-spawn")]
54pub mod spawn;
55#[cfg(not(feature = "wake-spawn"))]
56pub use off::spawn;
57
58use std::fmt;
59use std::path::{Path, PathBuf};
60
61use crate::{post_decrypted_with_bearer, reply_hint, DecryptedWake, SEND_COMMAND};
62
63pub use chain::{plan_chain, ChainError, HostEnv, Mechanism, Tier, WakeChain, WebVendor};
64pub use claude_channel::ClaudeChannelAdapter;
65pub use codex::{CodexAppServerAdapter, CodexEndpoint};
66pub use hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
67pub use inbox::{InboxEntry, InboxLetter};
68pub use kimi::KimiServerAdapter;
69pub use registry::SessionRecord;
70pub use spawn::ResumeSpawnAdapter;
71
72pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
74
75pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum ProviderKind {
83 Grok,
85 KimiCode,
87 ClaudeCode,
89 Codex,
91 Cursor,
94}
95
96impl ProviderKind {
97 pub const CORE: [ProviderKind; 4] = [
99 ProviderKind::Grok,
100 ProviderKind::KimiCode,
101 ProviderKind::ClaudeCode,
102 ProviderKind::Codex,
103 ];
104
105 pub const ALL: [ProviderKind; 5] = [
107 ProviderKind::Grok,
108 ProviderKind::KimiCode,
109 ProviderKind::ClaudeCode,
110 ProviderKind::Codex,
111 ProviderKind::Cursor,
112 ];
113
114 pub fn id(self) -> &'static str {
116 match self {
117 ProviderKind::Grok => "grok",
118 ProviderKind::KimiCode => "kimi",
119 ProviderKind::ClaudeCode => "claude",
120 ProviderKind::Codex => "codex",
121 ProviderKind::Cursor => "cursor",
122 }
123 }
124
125 pub fn is_optional(self) -> bool {
127 matches!(self, ProviderKind::Cursor)
128 }
129
130 pub fn parse(value: &str) -> Option<Self> {
132 match value.trim().to_ascii_lowercase().as_str() {
133 "grok" => Some(ProviderKind::Grok),
134 "kimi" | "kimi-code" | "kimicode" => Some(ProviderKind::KimiCode),
135 "claude" | "claude-code" | "claudecode" => Some(ProviderKind::ClaudeCode),
136 "codex" => Some(ProviderKind::Codex),
137 "cursor" | "cursor-agent" => Some(ProviderKind::Cursor),
138 _ => None,
139 }
140 }
141
142 pub fn from_env() -> Result<Self, WakeError> {
144 match std::env::var(PROVIDER_ENV) {
145 Ok(value) if !value.trim().is_empty() => {
146 Self::parse(&value).ok_or(WakeError::UnknownProvider)
147 }
148 _ => Ok(ProviderKind::Grok),
149 }
150 }
151}
152
153impl fmt::Display for ProviderKind {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 f.write_str(self.id())
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
161pub enum Surface {
162 Web,
164 Local,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub struct SessionKind {
171 pub provider: ProviderKind,
173 pub surface: Surface,
175}
176
177impl SessionKind {
178 pub fn local(provider: ProviderKind) -> Self {
180 Self {
181 provider,
182 surface: Surface::Local,
183 }
184 }
185
186 pub fn web(provider: ProviderKind) -> Self {
188 Self {
189 provider,
190 surface: Surface::Web,
191 }
192 }
193}
194
195impl fmt::Display for SessionKind {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 let surface = match self.surface {
198 Surface::Web => "web",
199 Surface::Local => "local",
200 };
201 write!(f, "{}/{}", self.provider, surface)
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct ProviderSession {
208 pub kind: SessionKind,
210 pub session_id: String,
212 pub nick: String,
214 pub cwd: Option<PathBuf>,
216 pub headless: bool,
219}
220
221#[derive(Debug, Clone, Copy)]
223pub struct WakeLetter<'a> {
224 pub body: &'a str,
226 pub from_nick: &'a str,
228 pub event_id: &'a str,
230 pub room: Option<&'a str>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum WakeOutcome {
237 Delivered,
240 Queued(PathBuf),
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum WakeError {
248 UnknownProvider,
250 NotImplemented(SessionKind),
252 NoInboundTrigger(SessionKind),
254 Unavailable(String),
256 Transport(String),
258}
259
260impl fmt::Display for WakeError {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 match self {
263 WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
264 WakeError::NotImplemented(kind) => {
265 write!(f, "wake adapter for {kind} is not implemented")
266 }
267 WakeError::NoInboundTrigger(kind) => {
268 write!(
269 f,
270 "{kind} exposes no inbound trigger into a running session"
271 )
272 }
273 WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
274 WakeError::Transport(why) => write!(f, "wake failed: {why}"),
275 }
276 }
277}
278
279impl std::error::Error for WakeError {}
280
281pub trait WakeAdapter: Send {
287 fn kind(&self) -> SessionKind;
289
290 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
293
294 fn wake(
296 &mut self,
297 session: &ProviderSession,
298 letter: &WakeLetter<'_>,
299 ) -> Result<WakeOutcome, WakeError>;
300}
301
302pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
307 let reply = reply_hint(&session.nick, letter.from_nick);
308 format!(
309 "[mail4agent] Letter from {from} to {to} (event {event}).\n\
310 Answer every direct letter with {cmd}; at minimum \"принято\" plus what you will do and when.\n\
311 Reply: {reply}\n\
312 ---\n\
313 {body}",
314 from = letter.from_nick,
315 to = session.nick,
316 event = letter.event_id,
317 cmd = SEND_COMMAND,
318 reply = reply,
319 body = letter.body,
320 )
321}
322
323#[derive(Default, Clone)]
327pub struct AdapterConfig {
328 pub leader_sock: Option<PathBuf>,
330 pub inbox_dir: Option<PathBuf>,
332 pub codex: Option<CodexEndpoint>,
334 pub kimi_url: Option<String>,
336 pub kimi_bearer: Option<String>,
338 pub routine_url: Option<String>,
340 pub routine_bearer: Option<String>,
342 pub claude_fire_url: Option<String>,
344 pub claude_fire_bearer: Option<String>,
346 pub codex_cloud_env: Option<String>,
348 pub spawn_program: Option<PathBuf>,
350}
351
352pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
354pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
356
357impl AdapterConfig {
358 pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
364 let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
365 let mut kimi = KimiServerAdapter::from_env();
366 if !kimi.is_configured() {
367 if let Some(found) = kimi::discover_local_server(None) {
368 kimi = found;
369 }
370 }
371 let (kimi_url, kimi_bearer) = kimi.into_parts();
372 Self {
373 leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
374 inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
375 codex: CodexEndpoint::from_env(),
376 kimi_url,
377 kimi_bearer,
378 routine_url: None,
379 routine_bearer: None,
380 claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
381 claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
382 codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
383 spawn_program: None,
384 }
385 }
386}
387
388impl fmt::Debug for AdapterConfig {
389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390 f.debug_struct("AdapterConfig")
392 .field("leader_sock", &self.leader_sock.is_some())
393 .field("inbox_dir", &self.inbox_dir.is_some())
394 .field("codex", &self.codex.is_some())
395 .field("kimi_url", &self.kimi_url.is_some())
396 .field("routine_url", &self.routine_url.is_some())
397 .field("claude_fire_url", &self.claude_fire_url.is_some())
398 .field("codex_cloud_env", &self.codex_cloud_env.is_some())
399 .finish()
400 }
401}
402
403pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
405 match (kind.surface, kind.provider) {
406 (Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
407 leader_sock: config.leader_sock.clone(),
408 }),
409 (Surface::Local, ProviderKind::Codex) => {
410 Box::new(CodexAppServerAdapter::new(config.codex.clone()))
411 }
412 (Surface::Local, ProviderKind::KimiCode) => Box::new(KimiServerAdapter::new(
413 config.kimi_url.clone(),
414 config.kimi_bearer.clone(),
415 )),
416 (Surface::Local, ProviderKind::ClaudeCode) => {
417 Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
418 }
419 (Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
420 inbox_dir: config.inbox_dir.clone(),
421 }),
422 (Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
423 provider: ProviderKind::Cursor,
424 url: config.routine_url.clone(),
425 bearer: config.routine_bearer.clone(),
426 }),
427 (Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
428 url: config.claude_fire_url.clone(),
429 bearer: config.claude_fire_bearer.clone(),
430 }),
431 (Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
432 (Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
433 Box::new(NoInboundAdapter { provider })
434 }
435 }
436}
437
438pub struct GrokLeaderAdapter {
442 pub leader_sock: Option<PathBuf>,
444}
445
446impl WakeAdapter for GrokLeaderAdapter {
447 fn kind(&self) -> SessionKind {
448 SessionKind::local(ProviderKind::Grok)
449 }
450
451 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
452 match self.leader_sock.as_deref() {
453 Some(path) if path.exists() => Ok(()),
454 Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
455 None => Err(WakeError::Unavailable("leader socket unset".into())),
456 }
457 }
458
459 fn wake(
460 &mut self,
461 session: &ProviderSession,
462 letter: &WakeLetter<'_>,
463 ) -> Result<WakeOutcome, WakeError> {
464 let _ = (&session, &letter);
465 #[cfg(not(feature = "wake-grok"))]
466 return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
467 #[cfg(feature = "wake-grok")]
468 self.probe(session)?;
469 #[cfg(feature = "wake-grok")]
470 let Some(sock) = self.leader_sock.as_deref() else {
471 return Err(WakeError::Unavailable("leader socket unset".into()));
472 };
473 #[cfg(feature = "wake-grok")]
474 let cwd = session_cwd(session)?;
475 #[cfg(feature = "wake-grok")]
476 {
477 mail4agent_grok::wake_decrypted_room_blocking(
478 sock,
479 &session.session_id,
480 &cwd,
481 &wake_prompt(session, letter),
482 )
483 .map(|()| WakeOutcome::Delivered)
484 .map_err(|err| WakeError::Transport(err.to_string()))
485 }
486 }
487}
488
489pub struct CursorAgentAdapter {
495 pub inbox_dir: Option<PathBuf>,
497}
498
499impl WakeAdapter for CursorAgentAdapter {
500 fn kind(&self) -> SessionKind {
501 SessionKind::local(ProviderKind::Cursor)
502 }
503
504 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
505 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
506 .probe(session)
507 }
508
509 fn wake(
510 &mut self,
511 session: &ProviderSession,
512 letter: &WakeLetter<'_>,
513 ) -> Result<WakeOutcome, WakeError> {
514 if !cfg!(feature = "wake-cursor") {
515 return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
516 }
517 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
518 .wake(session, letter)
519 }
520}
521
522pub struct RoutineWebhookAdapter {
528 pub provider: ProviderKind,
530 pub url: Option<String>,
532 pub bearer: Option<String>,
534}
535
536impl WakeAdapter for RoutineWebhookAdapter {
537 fn kind(&self) -> SessionKind {
538 SessionKind::web(self.provider)
539 }
540
541 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
542 match self.url.as_deref() {
543 Some(url) if !url.is_empty() => Ok(()),
544 _ => Err(WakeError::Unavailable("routine url unset".into())),
545 }
546 }
547
548 fn wake(
549 &mut self,
550 session: &ProviderSession,
551 letter: &WakeLetter<'_>,
552 ) -> Result<WakeOutcome, WakeError> {
553 if !cfg!(feature = "wake-routine") {
554 return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
555 }
556 self.probe(session)?;
557 let url = self.url.as_deref().unwrap_or_default();
558 let reply = reply_hint(&session.nick, letter.from_nick);
559 let wake = DecryptedWake {
560 body: letter.body,
561 from: letter.from_nick,
562 nick: None,
563 event_id: letter.event_id,
564 room: letter.room,
565 from_nick: Some(letter.from_nick),
566 to: Some(&session.nick),
567 reply: Some(&reply),
568 };
569 post_decrypted_with_bearer(url, &wake, self.bearer.as_deref())
570 .map(|()| WakeOutcome::Delivered)
571 .map_err(|err| WakeError::Transport(err.to_string()))
572 }
573}
574
575pub struct ClaudeRoutineFireAdapter {
583 pub url: Option<String>,
585 pub bearer: Option<String>,
587}
588
589pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
591
592impl WakeAdapter for ClaudeRoutineFireAdapter {
593 fn kind(&self) -> SessionKind {
594 SessionKind::web(ProviderKind::ClaudeCode)
595 }
596
597 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
598 if self.url.as_deref().is_none_or(str::is_empty) {
599 return Err(WakeError::Unavailable(
600 "claude routine fire url unset".into(),
601 ));
602 }
603 if self.bearer.as_deref().is_none_or(str::is_empty) {
604 return Err(WakeError::Unavailable("claude routine token unset".into()));
605 }
606 if !session.headless {
607 return Err(WakeError::Unavailable(
608 "session is open; routine fire would start a new session".into(),
609 ));
610 }
611 Ok(())
612 }
613
614 fn wake(
615 &mut self,
616 session: &ProviderSession,
617 letter: &WakeLetter<'_>,
618 ) -> Result<WakeOutcome, WakeError> {
619 if !cfg!(feature = "wake-claude") {
620 return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
621 }
622 self.probe(session)?;
623 let url = self.url.as_deref().unwrap_or_default();
624 let token = self.bearer.as_deref().unwrap_or_default();
625 let parsed = reqwest::Url::parse(url)
626 .ok()
627 .filter(|u| u.scheme() == "https" || is_loopback_http(u))
628 .ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
629 let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
630 .ok()
631 .filter(|v| !v.is_empty())
632 .unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
633 let client = reqwest::blocking::Client::builder()
634 .timeout(std::time::Duration::from_secs(15))
635 .build()
636 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
637 let response = client
638 .post(parsed)
639 .bearer_auth(token)
640 .header("anthropic-version", "2023-06-01")
641 .header("anthropic-beta", beta)
642 .json(&serde_json::json!({"text": wake_prompt(session, letter)}))
643 .send()
644 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
645 if response.status().is_success() {
646 Ok(WakeOutcome::Delivered)
647 } else {
648 Err(WakeError::Transport(format!(
649 "routine fire status {}",
650 response.status().as_u16()
651 )))
652 }
653 }
654}
655
656fn is_loopback_http(url: &reqwest::Url) -> bool {
657 url.scheme() == "http"
658 && matches!(
659 url.host_str(),
660 Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
661 )
662}
663
664pub struct CodexCloudAdapter;
669
670pub struct NoInboundAdapter {
672 pub provider: ProviderKind,
674}
675
676macro_rules! refuse_adapter {
677 ($ty:ty, $kind:expr, $err:ident) => {
678 impl WakeAdapter for $ty {
679 fn kind(&self) -> SessionKind {
680 $kind(self)
681 }
682
683 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
684 Err(WakeError::$err(self.kind()))
685 }
686
687 fn wake(
688 &mut self,
689 _session: &ProviderSession,
690 _letter: &WakeLetter<'_>,
691 ) -> Result<WakeOutcome, WakeError> {
692 Err(WakeError::$err(self.kind()))
693 }
694 }
695 };
696}
697
698refuse_adapter!(
699 CodexCloudAdapter,
700 |_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
701 NotImplemented
702);
703refuse_adapter!(
704 NoInboundAdapter,
705 |adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
706 NoInboundTrigger
707);
708
709#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
710pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
711 session
712 .cwd
713 .as_deref()
714 .map(Path::to_path_buf)
715 .or_else(|| std::env::current_dir().ok())
716 .map(|path| path.display().to_string())
717 .filter(|cwd| !cwd.is_empty())
718 .ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
719}
720
721#[cfg(test)]
722pub(crate) mod tests {
723 use super::*;
724
725 pub(crate) fn session(kind: SessionKind) -> ProviderSession {
726 ProviderSession {
727 kind,
728 session_id: "s-1".into(),
729 nick: "alice".into(),
730 cwd: Some(PathBuf::from("/tmp")),
731 headless: false,
732 }
733 }
734
735 pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
736 WakeLetter {
737 body,
738 from_nick: "carol",
739 event_id: "$ev1",
740 room: None,
741 }
742 }
743
744 #[test]
745 fn ids_round_trip() {
746 for kind in ProviderKind::ALL {
747 assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
748 }
749 assert_eq!(
750 ProviderKind::parse("Claude-Code"),
751 Some(ProviderKind::ClaudeCode)
752 );
753 assert_eq!(ProviderKind::parse("gemini"), None);
754 assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
755 assert!(ProviderKind::Cursor.is_optional());
756 assert_eq!(
757 SessionKind::web(ProviderKind::Codex).to_string(),
758 "codex/web"
759 );
760 }
761
762 #[test]
763 fn every_kind_gets_an_adapter_of_that_kind() {
764 let config = AdapterConfig::default();
765 for provider in ProviderKind::ALL {
766 for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
767 assert_eq!(adapter_for(kind, &config).kind(), kind);
768 }
769 }
770 }
771
772 #[test]
773 fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
774 let config = AdapterConfig::default();
775 let cases = [
776 (SessionKind::local(ProviderKind::Cursor), "unavailable"),
777 (SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
778 (SessionKind::web(ProviderKind::Codex), "not implemented"),
779 (SessionKind::web(ProviderKind::KimiCode), "no inbound"),
780 (SessionKind::web(ProviderKind::Grok), "no inbound"),
781 (SessionKind::web(ProviderKind::Cursor), "unavailable"),
782 (SessionKind::local(ProviderKind::Grok), "unavailable"),
783 (SessionKind::local(ProviderKind::Codex), "unavailable"),
784 (SessionKind::local(ProviderKind::KimiCode), "unavailable"),
785 (SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
786 ];
787 for (kind, expect) in cases {
788 let mut adapter = adapter_for(kind, &config);
789 let s = session(kind);
790 let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
791 assert!(err.contains(expect), "{kind}: {err}");
792 }
793 }
794
795 #[test]
796 fn prompt_carries_reply_command_and_body() {
797 let text = wake_prompt(
798 &session(SessionKind::local(ProviderKind::Codex)),
799 &letter("ping"),
800 );
801 assert!(text.contains("m4a-send --as alice --to carol"));
802 assert!(text.ends_with("ping"));
803 }
804
805 #[test]
806 fn config_debug_hides_values() {
807 let config = AdapterConfig {
808 kimi_bearer: Some("k-secret".into()),
809 routine_url: Some("https://example.invalid/hook".into()),
810 ..AdapterConfig::default()
811 };
812 let shown = format!("{config:?}");
813 assert!(!shown.contains("secret") && !shown.contains("example"));
814 }
815}