pub mod chain;
#[cfg(feature = "wake-claude")]
pub mod claude_channel;
#[cfg(not(feature = "wake-claude"))]
pub use off::claude_channel;
#[cfg(feature = "wake-codex")]
pub mod codex;
#[cfg(not(feature = "wake-codex"))]
pub use off::codex;
pub mod hook;
pub mod inbox;
#[cfg(feature = "wake-kimi")]
pub mod kimi;
#[cfg(not(feature = "wake-kimi"))]
pub use off::kimi;
pub mod registry;
#[cfg(not(all(feature = "wake-claude", feature = "wake-codex", feature = "wake-kimi", feature = "wake-spawn")))]
mod off;
#[cfg(feature = "wake-spawn")]
pub mod spawn;
#[cfg(not(feature = "wake-spawn"))]
pub use off::spawn;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::{post_decrypted_with_bearer, reply_hint, DecryptedWake, SEND_COMMAND};
pub use chain::{plan_chain, ChainError, HostEnv, Mechanism, Tier, WakeChain, WebVendor};
pub use claude_channel::ClaudeChannelAdapter;
pub use codex::{CodexAppServerAdapter, CodexEndpoint};
pub use hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
pub use inbox::{InboxEntry, InboxLetter};
pub use kimi::KimiServerAdapter;
pub use registry::SessionRecord;
pub use spawn::ResumeSpawnAdapter;
pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProviderKind {
Grok,
KimiCode,
ClaudeCode,
Codex,
Cursor,
}
impl ProviderKind {
pub const CORE: [ProviderKind; 4] = [
ProviderKind::Grok,
ProviderKind::KimiCode,
ProviderKind::ClaudeCode,
ProviderKind::Codex,
];
pub const ALL: [ProviderKind; 5] = [
ProviderKind::Grok,
ProviderKind::KimiCode,
ProviderKind::ClaudeCode,
ProviderKind::Codex,
ProviderKind::Cursor,
];
pub fn id(self) -> &'static str {
match self {
ProviderKind::Grok => "grok",
ProviderKind::KimiCode => "kimi",
ProviderKind::ClaudeCode => "claude",
ProviderKind::Codex => "codex",
ProviderKind::Cursor => "cursor",
}
}
pub fn is_optional(self) -> bool {
matches!(self, ProviderKind::Cursor)
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"grok" => Some(ProviderKind::Grok),
"kimi" | "kimi-code" | "kimicode" => Some(ProviderKind::KimiCode),
"claude" | "claude-code" | "claudecode" => Some(ProviderKind::ClaudeCode),
"codex" => Some(ProviderKind::Codex),
"cursor" | "cursor-agent" => Some(ProviderKind::Cursor),
_ => None,
}
}
pub fn from_env() -> Result<Self, WakeError> {
match std::env::var(PROVIDER_ENV) {
Ok(value) if !value.trim().is_empty() => {
Self::parse(&value).ok_or(WakeError::UnknownProvider)
}
_ => Ok(ProviderKind::Grok),
}
}
}
impl fmt::Display for ProviderKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.id())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Surface {
Web,
Local,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionKind {
pub provider: ProviderKind,
pub surface: Surface,
}
impl SessionKind {
pub fn local(provider: ProviderKind) -> Self {
Self {
provider,
surface: Surface::Local,
}
}
pub fn web(provider: ProviderKind) -> Self {
Self {
provider,
surface: Surface::Web,
}
}
}
impl fmt::Display for SessionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let surface = match self.surface {
Surface::Web => "web",
Surface::Local => "local",
};
write!(f, "{}/{}", self.provider, surface)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSession {
pub kind: SessionKind,
pub session_id: String,
pub nick: String,
pub cwd: Option<PathBuf>,
pub headless: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct WakeLetter<'a> {
pub body: &'a str,
pub from_nick: &'a str,
pub event_id: &'a str,
pub room: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeOutcome {
Delivered,
Queued(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeError {
UnknownProvider,
NotImplemented(SessionKind),
NoInboundTrigger(SessionKind),
Unavailable(String),
Transport(String),
}
impl fmt::Display for WakeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
WakeError::NotImplemented(kind) => {
write!(f, "wake adapter for {kind} is not implemented")
}
WakeError::NoInboundTrigger(kind) => {
write!(
f,
"{kind} exposes no inbound trigger into a running session"
)
}
WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
WakeError::Transport(why) => write!(f, "wake failed: {why}"),
}
}
}
impl std::error::Error for WakeError {}
pub trait WakeAdapter: Send {
fn kind(&self) -> SessionKind;
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError>;
}
pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
let reply = reply_hint(&session.nick, letter.from_nick);
format!(
"[mail4agent] Letter from {from} to {to} (event {event}).\n\
Answer every direct letter with {cmd}; at minimum \"принято\" plus what you will do and when.\n\
Reply: {reply}\n\
---\n\
{body}",
from = letter.from_nick,
to = session.nick,
event = letter.event_id,
cmd = SEND_COMMAND,
reply = reply,
body = letter.body,
)
}
#[derive(Default, Clone)]
pub struct AdapterConfig {
pub leader_sock: Option<PathBuf>,
pub inbox_dir: Option<PathBuf>,
pub codex: Option<CodexEndpoint>,
pub kimi_url: Option<String>,
pub kimi_bearer: Option<String>,
pub routine_url: Option<String>,
pub routine_bearer: Option<String>,
pub claude_fire_url: Option<String>,
pub claude_fire_bearer: Option<String>,
pub codex_cloud_env: Option<String>,
pub spawn_program: Option<PathBuf>,
}
pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
impl AdapterConfig {
pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
let mut kimi = KimiServerAdapter::from_env();
if !kimi.is_configured() {
if let Some(found) = kimi::discover_local_server(None) {
kimi = found;
}
}
let (kimi_url, kimi_bearer) = kimi.into_parts();
Self {
leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
codex: CodexEndpoint::from_env(),
kimi_url,
kimi_bearer,
routine_url: None,
routine_bearer: None,
claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
spawn_program: None,
}
}
}
impl fmt::Debug for AdapterConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AdapterConfig")
.field("leader_sock", &self.leader_sock.is_some())
.field("inbox_dir", &self.inbox_dir.is_some())
.field("codex", &self.codex.is_some())
.field("kimi_url", &self.kimi_url.is_some())
.field("routine_url", &self.routine_url.is_some())
.field("claude_fire_url", &self.claude_fire_url.is_some())
.field("codex_cloud_env", &self.codex_cloud_env.is_some())
.finish()
}
}
pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
match (kind.surface, kind.provider) {
(Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
leader_sock: config.leader_sock.clone(),
}),
(Surface::Local, ProviderKind::Codex) => {
Box::new(CodexAppServerAdapter::new(config.codex.clone()))
}
(Surface::Local, ProviderKind::KimiCode) => Box::new(KimiServerAdapter::new(
config.kimi_url.clone(),
config.kimi_bearer.clone(),
)),
(Surface::Local, ProviderKind::ClaudeCode) => {
Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
}
(Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
inbox_dir: config.inbox_dir.clone(),
}),
(Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
provider: ProviderKind::Cursor,
url: config.routine_url.clone(),
bearer: config.routine_bearer.clone(),
}),
(Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
url: config.claude_fire_url.clone(),
bearer: config.claude_fire_bearer.clone(),
}),
(Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
(Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
Box::new(NoInboundAdapter { provider })
}
}
}
pub struct GrokLeaderAdapter {
pub leader_sock: Option<PathBuf>,
}
impl WakeAdapter for GrokLeaderAdapter {
fn kind(&self) -> SessionKind {
SessionKind::local(ProviderKind::Grok)
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
match self.leader_sock.as_deref() {
Some(path) if path.exists() => Ok(()),
Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
None => Err(WakeError::Unavailable("leader socket unset".into())),
}
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
let _ = (&session, &letter);
#[cfg(not(feature = "wake-grok"))]
return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
#[cfg(feature = "wake-grok")]
self.probe(session)?;
#[cfg(feature = "wake-grok")]
let Some(sock) = self.leader_sock.as_deref() else {
return Err(WakeError::Unavailable("leader socket unset".into()));
};
#[cfg(feature = "wake-grok")]
let cwd = session_cwd(session)?;
#[cfg(feature = "wake-grok")]
{
mail4agent_grok::wake_decrypted_room_blocking(
sock,
&session.session_id,
&cwd,
&wake_prompt(session, letter),
)
.map(|()| WakeOutcome::Delivered)
.map_err(|err| WakeError::Transport(err.to_string()))
}
}
}
pub struct CursorAgentAdapter {
pub inbox_dir: Option<PathBuf>,
}
impl WakeAdapter for CursorAgentAdapter {
fn kind(&self) -> SessionKind {
SessionKind::local(ProviderKind::Cursor)
}
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
.probe(session)
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
if !cfg!(feature = "wake-cursor") {
return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
}
InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
.wake(session, letter)
}
}
pub struct RoutineWebhookAdapter {
pub provider: ProviderKind,
pub url: Option<String>,
pub bearer: Option<String>,
}
impl WakeAdapter for RoutineWebhookAdapter {
fn kind(&self) -> SessionKind {
SessionKind::web(self.provider)
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
match self.url.as_deref() {
Some(url) if !url.is_empty() => Ok(()),
_ => Err(WakeError::Unavailable("routine url unset".into())),
}
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
if !cfg!(feature = "wake-routine") {
return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
}
self.probe(session)?;
let url = self.url.as_deref().unwrap_or_default();
let reply = reply_hint(&session.nick, letter.from_nick);
let wake = DecryptedWake {
body: letter.body,
from: letter.from_nick,
nick: None,
event_id: letter.event_id,
room: letter.room,
from_nick: Some(letter.from_nick),
to: Some(&session.nick),
reply: Some(&reply),
};
post_decrypted_with_bearer(url, &wake, self.bearer.as_deref())
.map(|()| WakeOutcome::Delivered)
.map_err(|err| WakeError::Transport(err.to_string()))
}
}
pub struct ClaudeRoutineFireAdapter {
pub url: Option<String>,
pub bearer: Option<String>,
}
pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
impl WakeAdapter for ClaudeRoutineFireAdapter {
fn kind(&self) -> SessionKind {
SessionKind::web(ProviderKind::ClaudeCode)
}
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
if self.url.as_deref().is_none_or(str::is_empty) {
return Err(WakeError::Unavailable(
"claude routine fire url unset".into(),
));
}
if self.bearer.as_deref().is_none_or(str::is_empty) {
return Err(WakeError::Unavailable("claude routine token unset".into()));
}
if !session.headless {
return Err(WakeError::Unavailable(
"session is open; routine fire would start a new session".into(),
));
}
Ok(())
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
if !cfg!(feature = "wake-claude") {
return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
}
self.probe(session)?;
let url = self.url.as_deref().unwrap_or_default();
let token = self.bearer.as_deref().unwrap_or_default();
let parsed = reqwest::Url::parse(url)
.ok()
.filter(|u| u.scheme() == "https" || is_loopback_http(u))
.ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
let response = client
.post(parsed)
.bearer_auth(token)
.header("anthropic-version", "2023-06-01")
.header("anthropic-beta", beta)
.json(&serde_json::json!({"text": wake_prompt(session, letter)}))
.send()
.map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
if response.status().is_success() {
Ok(WakeOutcome::Delivered)
} else {
Err(WakeError::Transport(format!(
"routine fire status {}",
response.status().as_u16()
)))
}
}
}
fn is_loopback_http(url: &reqwest::Url) -> bool {
url.scheme() == "http"
&& matches!(
url.host_str(),
Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
)
}
pub struct CodexCloudAdapter;
pub struct NoInboundAdapter {
pub provider: ProviderKind,
}
macro_rules! refuse_adapter {
($ty:ty, $kind:expr, $err:ident) => {
impl WakeAdapter for $ty {
fn kind(&self) -> SessionKind {
$kind(self)
}
fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
Err(WakeError::$err(self.kind()))
}
fn wake(
&mut self,
_session: &ProviderSession,
_letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
Err(WakeError::$err(self.kind()))
}
}
};
}
refuse_adapter!(
CodexCloudAdapter,
|_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
NotImplemented
);
refuse_adapter!(
NoInboundAdapter,
|adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
NoInboundTrigger
);
#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
session
.cwd
.as_deref()
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.map(|path| path.display().to_string())
.filter(|cwd| !cwd.is_empty())
.ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn session(kind: SessionKind) -> ProviderSession {
ProviderSession {
kind,
session_id: "s-1".into(),
nick: "alice".into(),
cwd: Some(PathBuf::from("/tmp")),
headless: false,
}
}
pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
WakeLetter {
body,
from_nick: "carol",
event_id: "$ev1",
room: None,
}
}
#[test]
fn ids_round_trip() {
for kind in ProviderKind::ALL {
assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
}
assert_eq!(
ProviderKind::parse("Claude-Code"),
Some(ProviderKind::ClaudeCode)
);
assert_eq!(ProviderKind::parse("gemini"), None);
assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
assert!(ProviderKind::Cursor.is_optional());
assert_eq!(
SessionKind::web(ProviderKind::Codex).to_string(),
"codex/web"
);
}
#[test]
fn every_kind_gets_an_adapter_of_that_kind() {
let config = AdapterConfig::default();
for provider in ProviderKind::ALL {
for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
assert_eq!(adapter_for(kind, &config).kind(), kind);
}
}
}
#[test]
fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
let config = AdapterConfig::default();
let cases = [
(SessionKind::local(ProviderKind::Cursor), "unavailable"),
(SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
(SessionKind::web(ProviderKind::Codex), "not implemented"),
(SessionKind::web(ProviderKind::KimiCode), "no inbound"),
(SessionKind::web(ProviderKind::Grok), "no inbound"),
(SessionKind::web(ProviderKind::Cursor), "unavailable"),
(SessionKind::local(ProviderKind::Grok), "unavailable"),
(SessionKind::local(ProviderKind::Codex), "unavailable"),
(SessionKind::local(ProviderKind::KimiCode), "unavailable"),
(SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
];
for (kind, expect) in cases {
let mut adapter = adapter_for(kind, &config);
let s = session(kind);
let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
assert!(err.contains(expect), "{kind}: {err}");
}
}
#[test]
fn prompt_carries_reply_command_and_body() {
let text = wake_prompt(
&session(SessionKind::local(ProviderKind::Codex)),
&letter("ping"),
);
assert!(text.contains("m4a-send --as alice --to carol"));
assert!(text.ends_with("ping"));
}
#[test]
fn config_debug_hides_values() {
let config = AdapterConfig {
kimi_bearer: Some("k-secret".into()),
routine_url: Some("https://example.invalid/hook".into()),
..AdapterConfig::default()
};
let shown = format!("{config:?}");
assert!(!shown.contains("secret") && !shown.contains("example"));
}
}