1pub mod chain;
49pub mod hosted;
50#[cfg(feature = "wake-claude")]
51pub mod claude_channel;
52#[cfg(not(feature = "wake-claude"))]
53pub use off::claude_channel;
54#[cfg(feature = "wake-codex")]
55pub mod codex;
56#[cfg(not(feature = "wake-codex"))]
57pub use off::codex;
58pub mod hook;
59pub mod inbox;
60#[cfg(feature = "wake-kimi")]
61pub mod kimi;
62pub mod kimi_acp;
63#[cfg(not(feature = "wake-kimi"))]
64pub use off::kimi;
65pub mod registry;
66#[cfg(feature = "wake-grok")]
67pub mod grok_leader;
68#[cfg(feature = "wake-routine")]
69pub mod routine;
70#[cfg(not(all(feature = "wake-claude", feature = "wake-codex", feature = "wake-kimi", feature = "wake-spawn")))]
71mod off;
72#[cfg(feature = "wake-spawn")]
73pub mod spawn;
74#[cfg(not(feature = "wake-spawn"))]
75pub use off::spawn;
76
77use std::fmt;
78use std::path::{Path, PathBuf};
79
80pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
82
83pub const SEND_COMMAND: &str = "m4a-send";
85
86pub fn reply_hint(to: &str, from_nick: &str) -> String {
88 format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
89}
90
91pub fn reply_hint_for(to: &str, from_nick: &str, room: Option<&str>) -> String {
95 match room.map(str::trim).filter(|r| r.starts_with('!') && !r.contains(char::is_whitespace) && !r.contains('\'')) {
96 Some(room) => format!("{ROOM_COMMAND} --as {to} send '{room}' '<your reply>'"),
97 None => reply_hint(to, from_nick),
98 }
99}
100
101pub const ROOM_COMMAND: &str = "m4a";
103
104
105pub use chain::{plan_chain, ChainError, HostEnv, Mechanism, Tier, WakeChain, WebVendor};
106pub use claude_channel::ClaudeChannelAdapter;
107pub use codex::{CodexAppServerAdapter, CodexEndpoint};
108pub use hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
109pub use inbox::{InboxEntry, InboxLetter};
110pub use kimi::KimiServerAdapter;
111pub use registry::SessionRecord;
112pub use spawn::ResumeSpawnAdapter;
113
114pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
116
117pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum ProviderKind {
125 Grok,
127 KimiCode,
129 ClaudeCode,
131 Codex,
133 Cursor,
136}
137
138impl ProviderKind {
139 pub const CORE: [ProviderKind; 4] = [
141 ProviderKind::Grok,
142 ProviderKind::KimiCode,
143 ProviderKind::ClaudeCode,
144 ProviderKind::Codex,
145 ];
146
147 pub const ALL: [ProviderKind; 5] = [
149 ProviderKind::Grok,
150 ProviderKind::KimiCode,
151 ProviderKind::ClaudeCode,
152 ProviderKind::Codex,
153 ProviderKind::Cursor,
154 ];
155
156 pub fn id(self) -> &'static str {
158 match self {
159 ProviderKind::Grok => "grok",
160 ProviderKind::KimiCode => "kimi",
161 ProviderKind::ClaudeCode => "claude",
162 ProviderKind::Codex => "codex",
163 ProviderKind::Cursor => "cursor",
164 }
165 }
166
167 pub fn is_optional(self) -> bool {
169 matches!(self, ProviderKind::Cursor)
170 }
171
172 pub fn parse(value: &str) -> Option<Self> {
174 match value.trim().to_ascii_lowercase().as_str() {
175 "grok" => Some(ProviderKind::Grok),
176 "kimi" | "kimi-code" | "kimicode" => Some(ProviderKind::KimiCode),
177 "claude" | "claude-code" | "claudecode" => Some(ProviderKind::ClaudeCode),
178 "codex" => Some(ProviderKind::Codex),
179 "cursor" | "cursor-agent" => Some(ProviderKind::Cursor),
180 _ => None,
181 }
182 }
183
184 pub fn from_env() -> Result<Self, WakeError> {
186 match std::env::var(PROVIDER_ENV) {
187 Ok(value) if !value.trim().is_empty() => {
188 Self::parse(&value).ok_or(WakeError::UnknownProvider)
189 }
190 _ => Ok(ProviderKind::Grok),
191 }
192 }
193}
194
195impl fmt::Display for ProviderKind {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 f.write_str(self.id())
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum Surface {
204 Web,
206 Local,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct SessionKind {
213 pub provider: ProviderKind,
215 pub surface: Surface,
217}
218
219impl SessionKind {
220 pub fn local(provider: ProviderKind) -> Self {
222 Self {
223 provider,
224 surface: Surface::Local,
225 }
226 }
227
228 pub fn web(provider: ProviderKind) -> Self {
230 Self {
231 provider,
232 surface: Surface::Web,
233 }
234 }
235}
236
237impl fmt::Display for SessionKind {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 let surface = match self.surface {
240 Surface::Web => "web",
241 Surface::Local => "local",
242 };
243 write!(f, "{}/{}", self.provider, surface)
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct ProviderSession {
250 pub kind: SessionKind,
252 pub session_id: String,
254 pub nick: String,
256 pub cwd: Option<PathBuf>,
258 pub headless: bool,
261}
262
263#[derive(Debug, Clone, Copy)]
265pub struct WakeLetter<'a> {
266 pub body: &'a str,
268 pub from_nick: &'a str,
270 pub event_id: &'a str,
272 pub room: Option<&'a str>,
274 pub reaction: Option<ReactionNote<'a>>,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct ReactionNote<'a> {
283 pub key: &'a str,
285 pub target: &'a str,
287}
288
289pub fn react_hint_for(to: &str, room: Option<&str>, event: &str) -> Option<String> {
291 let room = room.map(str::trim).filter(|r| r.starts_with('!') && !r.contains(char::is_whitespace) && !r.contains('\''))?;
292 let event = Some(event.trim()).filter(|e| e.starts_with('$') && !e.contains(char::is_whitespace) && !e.contains('\''))?;
293 Some(format!("{ROOM_COMMAND} --as {to} react '{room}' '{event}' '<emoji>'"))
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum WakeOutcome {
299 Delivered,
302 Queued(PathBuf),
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum WakeError {
310 UnknownProvider,
312 NotImplemented(SessionKind),
314 NoInboundTrigger(SessionKind),
316 Unavailable(String),
318 Transport(String),
320}
321
322impl fmt::Display for WakeError {
323 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324 match self {
325 WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
326 WakeError::NotImplemented(kind) => {
327 write!(f, "wake adapter for {kind} is not implemented")
328 }
329 WakeError::NoInboundTrigger(kind) => {
330 write!(
331 f,
332 "{kind} exposes no inbound trigger into a running session"
333 )
334 }
335 WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
336 WakeError::Transport(why) => write!(f, "wake failed: {why}"),
337 }
338 }
339}
340
341impl std::error::Error for WakeError {}
342
343pub trait WakeAdapter: Send {
349 fn kind(&self) -> SessionKind;
351
352 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
355
356 fn wake(
358 &mut self,
359 session: &ProviderSession,
360 letter: &WakeLetter<'_>,
361 ) -> Result<WakeOutcome, WakeError>;
362}
363
364pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
369 let mut head = format!("[mail4agent letter]\nkind: {}\nfrom: {from}\nto: {to}\n", if letter.reaction.is_some() { "reaction" } else { "message" }, from = letter.from_nick, to = session.nick);
372 if let Some(room) = letter.room.map(str::trim).filter(|r| !r.is_empty()) {
373 head.push_str(&format!("room: {room}\n"));
374 }
375 head.push_str(&format!("event: {}\n", letter.event_id));
376 match letter.reaction {
377 Some(r) => {
378 head.push_str(&format!("reaction: {}\ntarget-event: {}\npriority: low\nreply-needed: no\n", r.key, r.target));
379 if let Some(h) = react_hint_for(&session.nick, letter.room, r.target) {
380 head.push_str(&format!("react-command: {h}\n"));
381 }
382 }
383 None => {
384 head.push_str(&format!("reply-command: {}\n", reply_hint_for(&session.nick, letter.from_nick, letter.room)));
385 if let Some(room) = letter.room.map(str::trim).filter(|r| r.starts_with('!') && !r.contains('\'')) {
386 if letter.event_id.starts_with('$') && !letter.event_id.contains('\'') {
387 head.push_str(&format!("reply-to-command: {ROOM_COMMAND} --as {} send '{room}' --reply-to '{}' '<your reply>'\n", session.nick, letter.event_id));
388 }
389 }
390 if let Some(h) = react_hint_for(&session.nick, letter.room, letter.event_id) {
391 head.push_str(&format!("react-command: {h}\n"));
392 }
393 }
394 }
395 format!("{head}---\n{body}", body = letter.body)
396}
397
398#[derive(Default, Clone)]
402pub struct AdapterConfig {
403 pub leader_sock: Option<PathBuf>,
405 pub inbox_dir: Option<PathBuf>,
407 pub codex: Option<CodexEndpoint>,
409 pub kimi_url: Option<String>,
411 pub kimi_bearer: Option<String>,
413 pub kimi_acp_bin: Option<String>,
415 pub routine_url: Option<String>,
417 pub routine_bearer: Option<String>,
419 pub claude_fire_url: Option<String>,
421 pub claude_fire_bearer: Option<String>,
423 pub codex_cloud_env: Option<String>,
425 pub spawn_program: Option<PathBuf>,
427}
428
429pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
431pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
433
434impl AdapterConfig {
435 pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
441 let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
442 let mut kimi = KimiServerAdapter::from_env();
443 if !kimi.is_configured() {
444 if let Some(found) = kimi::discover_local_server(None) {
445 kimi = found;
446 }
447 }
448 let (kimi_url, kimi_bearer) = kimi.into_parts();
449 Self {
450 leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
451 inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
452 codex: CodexEndpoint::from_env(),
453 kimi_url,
454 kimi_bearer,
455 kimi_acp_bin: kimi_acp::KimiAcpConfig::from_env().map(|c| c.bin),
456 routine_url: None,
457 routine_bearer: None,
458 claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
459 claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
460 codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
461 spawn_program: None,
462 }
463 }
464}
465
466impl fmt::Debug for AdapterConfig {
467 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
468 f.debug_struct("AdapterConfig")
470 .field("leader_sock", &self.leader_sock.is_some())
471 .field("inbox_dir", &self.inbox_dir.is_some())
472 .field("codex", &self.codex.is_some())
473 .field("kimi_url", &self.kimi_url.is_some())
474 .field("routine_url", &self.routine_url.is_some())
475 .field("claude_fire_url", &self.claude_fire_url.is_some())
476 .field("codex_cloud_env", &self.codex_cloud_env.is_some())
477 .finish()
478 }
479}
480
481pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
483 match (kind.surface, kind.provider) {
484 (Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
485 leader_sock: config.leader_sock.clone(),
486 }),
487 (Surface::Local, ProviderKind::Codex) => {
488 Box::new(CodexAppServerAdapter::new(config.codex.clone()))
489 }
490 (Surface::Local, ProviderKind::KimiCode) => {
491 if let Some(bin) = config.kimi_acp_bin.clone() {
493 Box::new(kimi_acp::KimiAcpAdapter::new(Some(kimi_acp::KimiAcpConfig { bin })))
494 } else {
495 Box::new(KimiServerAdapter::new(
496 config.kimi_url.clone(),
497 config.kimi_bearer.clone(),
498 ))
499 }
500 },
501 (Surface::Local, ProviderKind::ClaudeCode) => {
502 Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
503 }
504 (Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
505 inbox_dir: config.inbox_dir.clone(),
506 }),
507 (Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
508 provider: ProviderKind::Cursor,
509 url: config.routine_url.clone(),
510 bearer: config.routine_bearer.clone(),
511 }),
512 (Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
513 url: config.claude_fire_url.clone(),
514 bearer: config.claude_fire_bearer.clone(),
515 }),
516 (Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
517 (Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
520 if config.routine_url.as_deref().is_some_and(|u| !u.is_empty()) {
521 Box::new(RoutineWebhookAdapter {
522 provider,
523 url: config.routine_url.clone(),
524 bearer: config.routine_bearer.clone(),
525 })
526 } else {
527 Box::new(NoInboundAdapter { provider })
528 }
529 }
530 }
531}
532
533pub struct GrokLeaderAdapter {
538 pub leader_sock: Option<PathBuf>,
540}
541
542impl WakeAdapter for GrokLeaderAdapter {
543 fn kind(&self) -> SessionKind {
544 SessionKind::local(ProviderKind::Grok)
545 }
546
547 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
548 match self.leader_sock.as_deref() {
549 Some(path) if path.exists() => Ok(()),
550 Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
551 None => Err(WakeError::Unavailable("leader socket unset".into())),
552 }
553 }
554
555 fn wake(
556 &mut self,
557 session: &ProviderSession,
558 letter: &WakeLetter<'_>,
559 ) -> Result<WakeOutcome, WakeError> {
560 let _ = (&session, &letter);
561 #[cfg(not(feature = "wake-grok"))]
562 return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
563 #[cfg(feature = "wake-grok")]
564 self.probe(session)?;
565 #[cfg(feature = "wake-grok")]
566 let Some(sock) = self.leader_sock.as_deref() else {
567 return Err(WakeError::Unavailable("leader socket unset".into()));
568 };
569 #[cfg(feature = "wake-grok")]
570 let cwd = session_cwd(session)?;
571 #[cfg(feature = "wake-grok")]
572 {
573 grok_leader::wake_decrypted_room_blocking(sock, &session.session_id, &cwd, &wake_prompt(session, letter))
574 .map(|()| WakeOutcome::Delivered)
575 .map_err(|e| WakeError::Transport(e.to_string()))
576 }
577 }
578}
579
580pub struct CursorAgentAdapter {
586 pub inbox_dir: Option<PathBuf>,
588}
589
590impl WakeAdapter for CursorAgentAdapter {
591 fn kind(&self) -> SessionKind {
592 SessionKind::local(ProviderKind::Cursor)
593 }
594
595 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
596 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
597 .probe(session)
598 }
599
600 fn wake(
601 &mut self,
602 session: &ProviderSession,
603 letter: &WakeLetter<'_>,
604 ) -> Result<WakeOutcome, WakeError> {
605 if !cfg!(feature = "wake-cursor") {
606 return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
607 }
608 InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
609 .wake(session, letter)
610 }
611}
612
613pub struct RoutineWebhookAdapter {
619 pub provider: ProviderKind,
621 pub url: Option<String>,
623 pub bearer: Option<String>,
625}
626
627impl WakeAdapter for RoutineWebhookAdapter {
628 fn kind(&self) -> SessionKind {
629 SessionKind::web(self.provider)
630 }
631
632 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
633 match self.url.as_deref() {
634 Some(url) if !url.is_empty() => Ok(()),
635 _ => Err(WakeError::Unavailable("routine url unset".into())),
636 }
637 }
638
639 fn wake(
640 &mut self,
641 session: &ProviderSession,
642 letter: &WakeLetter<'_>,
643 ) -> Result<WakeOutcome, WakeError> {
644 if !cfg!(feature = "wake-routine") {
645 return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
646 }
647 self.probe(session)?;
648 let url = self.url.as_deref().unwrap_or_default();
649 let reply = if letter.reaction.is_some() { String::new() } else { reply_hint_for(&session.nick, letter.from_nick, letter.room) };
650 let react_target = letter.reaction.map(|r| r.target).unwrap_or(letter.event_id);
651 let react = react_hint_for(&session.nick, letter.room, react_target).unwrap_or_default();
652 let mut body = serde_json::Map::new();
653 body.insert("kind".into(), serde_json::Value::String(if letter.reaction.is_some() { "reaction" } else { "message" }.into()));
654 body.insert("reply_needed".into(), serde_json::Value::Bool(letter.reaction.is_none()));
655 if let Some(r) = letter.reaction {
656 body.insert("priority".into(), serde_json::Value::String("low".into()));
657 body.insert("reaction".into(), serde_json::Value::String(r.key.into()));
658 body.insert("target_event_id".into(), serde_json::Value::String(r.target.into()));
659 }
660 for (k, v) in [
661 ("body", Some(letter.body)),
662 ("from", Some(letter.from_nick)),
663 ("event_id", Some(letter.event_id)),
664 ("room", letter.room),
665 ("from_nick", Some(letter.from_nick)),
666 ("to", Some(session.nick.as_str())),
667 ("reply", Some(reply.as_str())),
668 ("react", Some(react.as_str())),
669 ] {
670 if let Some(v) = v.map(str::trim).filter(|v| !v.is_empty() || k == "body") {
671 body.insert(k.into(), serde_json::Value::String(v.into()));
672 }
673 }
674 #[cfg(not(feature = "wake-routine"))]
675 {
676 let _ = (url, body);
677 return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
678 }
679 #[cfg(feature = "wake-routine")]
680 routine::post_routine_json(url, &serde_json::Value::Object(body), self.bearer.as_deref())
681 .map(|()| WakeOutcome::Delivered)
682 .map_err(|e| WakeError::Transport(e.to_string()))
683 }
684}
685
686pub struct ClaudeRoutineFireAdapter {
694 pub url: Option<String>,
696 pub bearer: Option<String>,
698}
699
700pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
702
703impl WakeAdapter for ClaudeRoutineFireAdapter {
704 fn kind(&self) -> SessionKind {
705 SessionKind::web(ProviderKind::ClaudeCode)
706 }
707
708 fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
709 if self.url.as_deref().is_none_or(str::is_empty) {
710 return Err(WakeError::Unavailable(
711 "claude routine fire url unset".into(),
712 ));
713 }
714 if self.bearer.as_deref().is_none_or(str::is_empty) {
715 return Err(WakeError::Unavailable("claude routine token unset".into()));
716 }
717 if !session.headless {
718 return Err(WakeError::Unavailable(
719 "session is open; routine fire would start a new session".into(),
720 ));
721 }
722 Ok(())
723 }
724
725 fn wake(
726 &mut self,
727 session: &ProviderSession,
728 letter: &WakeLetter<'_>,
729 ) -> Result<WakeOutcome, WakeError> {
730 if !cfg!(feature = "wake-claude") {
731 return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
732 }
733 self.probe(session)?;
734 let url = self.url.as_deref().unwrap_or_default();
735 let token = self.bearer.as_deref().unwrap_or_default();
736 let parsed = reqwest::Url::parse(url)
737 .ok()
738 .filter(|u| u.scheme() == "https" || is_loopback_http(u))
739 .ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
740 let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
741 .ok()
742 .filter(|v| !v.is_empty())
743 .unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
744 let client = reqwest::blocking::Client::builder()
745 .timeout(std::time::Duration::from_secs(15))
746 .build()
747 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
748 let response = client
749 .post(parsed)
750 .bearer_auth(token)
751 .header("anthropic-version", "2023-06-01")
752 .header("anthropic-beta", beta)
753 .json(&serde_json::json!({"text": wake_prompt(session, letter)}))
754 .send()
755 .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
756 if response.status().is_success() {
757 Ok(WakeOutcome::Delivered)
758 } else {
759 Err(WakeError::Transport(format!(
760 "routine fire status {}",
761 response.status().as_u16()
762 )))
763 }
764 }
765}
766
767fn is_loopback_http(url: &reqwest::Url) -> bool {
768 url.scheme() == "http"
769 && matches!(
770 url.host_str(),
771 Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
772 )
773}
774
775pub struct CodexCloudAdapter;
780
781pub struct NoInboundAdapter {
783 pub provider: ProviderKind,
785}
786
787macro_rules! refuse_adapter {
788 ($ty:ty, $kind:expr, $err:ident) => {
789 impl WakeAdapter for $ty {
790 fn kind(&self) -> SessionKind {
791 $kind(self)
792 }
793
794 fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
795 Err(WakeError::$err(self.kind()))
796 }
797
798 fn wake(
799 &mut self,
800 _session: &ProviderSession,
801 _letter: &WakeLetter<'_>,
802 ) -> Result<WakeOutcome, WakeError> {
803 Err(WakeError::$err(self.kind()))
804 }
805 }
806 };
807}
808
809refuse_adapter!(
810 CodexCloudAdapter,
811 |_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
812 NotImplemented
813);
814refuse_adapter!(
815 NoInboundAdapter,
816 |adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
817 NoInboundTrigger
818);
819
820#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
821pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
822 session
823 .cwd
824 .as_deref()
825 .map(Path::to_path_buf)
826 .or_else(|| std::env::current_dir().ok())
827 .map(|path| path.display().to_string())
828 .filter(|cwd| !cwd.is_empty())
829 .ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
830}
831
832#[cfg(test)]
833pub(crate) mod tests {
834 use super::*;
835
836 pub(crate) fn session(kind: SessionKind) -> ProviderSession {
837 ProviderSession {
838 kind,
839 session_id: "s-1".into(),
840 nick: "alice".into(),
841 cwd: Some(PathBuf::from("/tmp")),
842 headless: false,
843 }
844 }
845
846 pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
847 WakeLetter {
848 body,
849 from_nick: "carol",
850 event_id: "$ev1",
851 room: None,
852 reaction: None,
853 }
854 }
855
856 #[test]
857 fn reaction_letters_are_low_priority_with_no_reply_command() {
858 let session = ProviderSession { kind: SessionKind::web(ProviderKind::Grok), session_id: "s".into(), nick: "hatcheryweb".into(), cwd: None, headless: false };
859 let l = WakeLetter { body: "kimi reacted ๐ to your message: \"hi\"", from_nick: "kimi", event_id: "$r1", room: Some("!room:x"), reaction: Some(ReactionNote { key: "๐", target: "$m1" }) };
860 let p = wake_prompt(&session, &l);
861 assert!(p.contains("kind: reaction\n") && p.contains("reaction: ๐\n") && p.contains("target-event: $m1\n") && p.contains("priority: low\n") && p.contains("reply-needed: no\n"));
862 assert!(!p.contains("reply-command:"));
863 assert!(p.contains("react-command: m4a --as hatcheryweb react '!room:x' '$m1' '<emoji>'"));
864 let m = WakeLetter { reaction: None, event_id: "$m2", ..l };
865 let p = wake_prompt(&session, &m);
866 assert!(p.contains("kind: message\n") && p.contains("event: $m2\n") && p.contains("reply-command: m4a --as hatcheryweb send '!room:x'"));
867 assert!(p.contains("reply-to-command: m4a --as hatcheryweb send '!room:x' --reply-to '$m2' '<your reply>'"));
868 }
869
870 #[test]
871 fn web_grok_uses_the_routine_only_when_one_is_configured() {
872 let kind = SessionKind::web(ProviderKind::Grok);
873 let none = adapter_for(kind, &AdapterConfig::default());
874 assert!(none.probe(&session(kind)).is_err());
875 let cfg = AdapterConfig { routine_url: Some("http://127.0.0.1:9/hook".into()), ..Default::default() };
876 let routine = adapter_for(kind, &cfg);
877 assert!(routine.probe(&session(kind)).is_ok());
878 }
879
880 #[test]
881 fn ids_round_trip() {
882 for kind in ProviderKind::ALL {
883 assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
884 }
885 assert_eq!(
886 ProviderKind::parse("Claude-Code"),
887 Some(ProviderKind::ClaudeCode)
888 );
889 assert_eq!(ProviderKind::parse("gemini"), None);
890 assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
891 assert!(ProviderKind::Cursor.is_optional());
892 assert_eq!(
893 SessionKind::web(ProviderKind::Codex).to_string(),
894 "codex/web"
895 );
896 }
897
898 #[test]
899 fn every_kind_gets_an_adapter_of_that_kind() {
900 let config = AdapterConfig::default();
901 for provider in ProviderKind::ALL {
902 for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
903 assert_eq!(adapter_for(kind, &config).kind(), kind);
904 }
905 }
906 }
907
908 #[test]
909 fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
910 let config = AdapterConfig::default();
911 let cases = [
912 (SessionKind::local(ProviderKind::Cursor), "unavailable"),
913 (SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
914 (SessionKind::web(ProviderKind::Codex), "not implemented"),
915 (SessionKind::web(ProviderKind::KimiCode), "no inbound"),
916 (SessionKind::web(ProviderKind::Grok), "no inbound"),
917 (SessionKind::web(ProviderKind::Cursor), "unavailable"),
918 (SessionKind::local(ProviderKind::Grok), "unavailable"),
919 (SessionKind::local(ProviderKind::Codex), "unavailable"),
920 (SessionKind::local(ProviderKind::KimiCode), "unavailable"),
921 (SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
922 ];
923 for (kind, expect) in cases {
924 let mut adapter = adapter_for(kind, &config);
925 let s = session(kind);
926 let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
927 assert!(err.contains(expect), "{kind}: {err}");
928 }
929 }
930
931 #[test]
932 fn room_letters_are_answered_in_the_room() {
933 assert_eq!(reply_hint_for("alice", "carol", Some("!r1:example.org")), "m4a --as alice send '!r1:example.org' '<your reply>'");
934 assert_eq!(reply_hint_for("alice", "carol", None), reply_hint("alice", "carol"));
935 assert_eq!(reply_hint_for("alice", "carol", Some("bad room")), reply_hint("alice", "carol"));
936 }
937
938 #[test]
939 fn prompt_carries_reply_command_and_body() {
940 let text = wake_prompt(
941 &session(SessionKind::local(ProviderKind::Codex)),
942 &letter("ping"),
943 );
944 assert!(text.contains("reply-command: m4a-send --as alice --to carol"));
945 assert!(text.ends_with("---\nping"));
946 assert!(!text.contains("Answer every") && !text.contains("ะฟัะธะฝััะพ"), "no forced instruction");
947 }
948
949 #[test]
950 fn config_debug_hides_values() {
951 let config = AdapterConfig {
952 kimi_bearer: Some("k-secret".into()),
953 routine_url: Some("https://example.invalid/hook".into()),
954 ..AdapterConfig::default()
955 };
956 let shown = format!("{config:?}");
957 assert!(!shown.contains("secret") && !shown.contains("example"));
958 }
959}
960
961#[cfg(test)]
962mod g4a_alignment_tests {
963 use super::ProviderKind;
964
965 #[test]
968 fn names_hatchery_node_sends_all_parse() {
969 for (sent, kind) in [
970 ("claude-code", ProviderKind::ClaudeCode),
971 ("codex", ProviderKind::Codex),
972 ("grok", ProviderKind::Grok),
973 ("kimi-code", ProviderKind::KimiCode),
974 ("claude", ProviderKind::ClaudeCode),
975 ("kimi", ProviderKind::KimiCode),
976 ] {
977 assert_eq!(ProviderKind::parse(sent), Some(kind), "{sent}");
978 }
979 assert_eq!(ProviderKind::parse("custom"), None);
980 }
981}