Skip to main content

mail4agent_messenger_shell/
provider.rs

1//! Provider wake adapters for the one-per-machine mail4agent clients.
2//!
3//! The clients (push link, decrypt, sealed store, `m4a-send` socket) are
4//! provider-agnostic. The only provider-specific step is *wake*: putting one
5//! decrypted room text in front of an agent session so it takes a turn.
6//! That step is the [`WakeAdapter`] trait. A session is addressed by a
7//! [`SessionKind`]: the vendor ([`ProviderKind`]) and the [`Surface`] it runs
8//! on (vendor-hosted web/cloud, or a CLI on the user's machine). Replies
9//! never go through the adapter: every agent answers with the shell command
10//! `m4a-send --as <own-nick> --to <peer-nick> '<text>'`.
11//!
12//! Each session gets an ordered [`WakeChain`] ([`chain::plan_chain`],
13//! order in [`chain::mechanisms`]): a turn in the client that already has
14//! the session open first, then hooks inside that session, then the
15//! durable inbox, and a new-process resume only for a headless session.
16//!
17//! | Kind | Primary (in-session) | Fallbacks | Last resort (headless only) |
18//! | --- | --- | --- | --- |
19//! | Grok CLI | [`GrokLeaderAdapter`] ACP on `leader.sock` | Stop hook, inbox | `grok --resume -p` |
20//! | Codex CLI | [`codex::CodexAppServerAdapter`] `turn/start` | Stop hook, inbox | `codex exec resume` |
21//! | Kimi Code CLI | [`kimi::KimiServerAdapter`] `kimi web` prompts | Stop hook, inbox | `kimi -S -p` |
22//! | Claude Code CLI | [`ClaudeChannelAdapter`] channel MCP; `asyncRewake` waiter | Stop hook, inbox | `claude --resume -p` |
23//! | Cursor CLI | none for an idle chat | `stop` hook `followup_message`, inbox | `agent --resume -p` |
24//! | Grok Bot / Cursor web | [`RoutineWebhookAdapter`] | - | - |
25//! | Claude Code web | `asyncRewake` waiter in the cloud session | Stop hook, inbox | [`ClaudeRoutineFireAdapter`] (new session) |
26//! | Codex cloud | - | Stop hook, inbox | `codex cloud exec` (new task) |
27//! | Kimi web, Grok web | none documented ([`NoInboundAdapter`]) | - | - |
28//!
29//! Core four: Grok, Kimi Code, Claude Code, Codex. Cursor CLI is optional.
30//! Design: the project documentation.
31//! Only [`spawn::ResumeSpawnAdapter`] starts a provider process, and only
32//! for a headless session. No adapter answers a permission modal or logs
33//! the plaintext or a credential.
34
35pub 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
72/// Environment variable naming the provider of a local session.
73pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
74
75/// Environment variable for a per-session inbox directory used by
76/// queue-style adapters (Claude channel server, hook doorbells).
77pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
78
79/// Agent vendors the stack drives. Ids match the gate4agent catalog /
80/// adapter ids (`grok`, `kimi`, `claude`, `codex`, `cursor`).
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum ProviderKind {
83    /// xAI Grok.
84    Grok,
85    /// Kimi Code.
86    KimiCode,
87    /// Claude Code.
88    ClaudeCode,
89    /// OpenAI Codex.
90    Codex,
91    /// Cursor: the vendor-hosted web agents (Grok Bot boxes) and the
92    /// optional Linux-only `cursor-agent` CLI.
93    Cursor,
94}
95
96impl ProviderKind {
97    /// The core four, Grok first.
98    pub const CORE: [ProviderKind; 4] = [
99        ProviderKind::Grok,
100        ProviderKind::KimiCode,
101        ProviderKind::ClaudeCode,
102        ProviderKind::Codex,
103    ];
104
105    /// Every provider, including the optional Cursor CLI.
106    pub const ALL: [ProviderKind; 5] = [
107        ProviderKind::Grok,
108        ProviderKind::KimiCode,
109        ProviderKind::ClaudeCode,
110        ProviderKind::Codex,
111        ProviderKind::Cursor,
112    ];
113
114    /// Stable id (gate4agent catalog spelling).
115    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    /// `true` for the optional fifth CLI provider.
126    pub fn is_optional(self) -> bool {
127        matches!(self, ProviderKind::Cursor)
128    }
129
130    /// Parses an id or a common alias. Case-insensitive.
131    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    /// Reads [`PROVIDER_ENV`]. Unset means Grok.
143    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/// Where a session runs.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
161pub enum Surface {
162    /// Vendor-hosted web / cloud session (`m4a-web-client`).
163    Web,
164    /// CLI session on the user's machine (local client).
165    Local,
166}
167
168/// Vendor + surface: what picks the adapter.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub struct SessionKind {
171    /// Vendor.
172    pub provider: ProviderKind,
173    /// Web or local.
174    pub surface: Surface,
175}
176
177impl SessionKind {
178    /// Local CLI session of `provider`.
179    pub fn local(provider: ProviderKind) -> Self {
180        Self {
181            provider,
182            surface: Surface::Local,
183        }
184    }
185
186    /// Vendor-hosted web session of `provider`.
187    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/// One session a client wakes.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct ProviderSession {
208    /// Vendor + surface.
209    pub kind: SessionKind,
210    /// Vendor-native session / thread / chat id. Never logged or committed.
211    pub session_id: String,
212    /// mail4agent nick of this session (slug of the display name).
213    pub nick: String,
214    /// Working directory the session was started in (local only).
215    pub cwd: Option<PathBuf>,
216    /// No client holds the session open. Only then may a chain fall back
217    /// to spawning a new process that resumes it.
218    pub headless: bool,
219}
220
221/// One decrypted room text to put in front of the session.
222#[derive(Debug, Clone, Copy)]
223pub struct WakeLetter<'a> {
224    /// Plaintext body.
225    pub body: &'a str,
226    /// Sender nick.
227    pub from_nick: &'a str,
228    /// Matrix event id (dedupe key).
229    pub event_id: &'a str,
230    /// Room id, if known.
231    pub room: Option<&'a str>,
232}
233
234/// What a wake did.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum WakeOutcome {
237    /// The session accepted the prompt now (a turn started or was queued
238    /// by the vendor runtime).
239    Delivered,
240    /// Stored for the session to pick up (channel server / hook). The path
241    /// is the inbox entry written.
242    Queued(PathBuf),
243}
244
245/// Why a wake did not happen. Never carries the plaintext or a secret.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum WakeError {
248    /// [`PROVIDER_ENV`] named something unknown.
249    UnknownProvider,
250    /// The adapter is a skeleton on this branch.
251    NotImplemented(SessionKind),
252    /// The vendor exposes no inbound trigger into an existing session.
253    NoInboundTrigger(SessionKind),
254    /// The endpoint (socket, server, inbox, routine) is absent or unset.
255    Unavailable(String),
256    /// The endpoint answered but the wake failed.
257    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
281/// Vendor/surface-specific wake. One instance per session.
282///
283/// The caller keeps the per-`event_id` sent set. Implementations must not
284/// spawn a provider, must not answer permission prompts, and must not log
285/// `letter.body` or a credential.
286pub trait WakeAdapter: Send {
287    /// Session kind this adapter wakes.
288    fn kind(&self) -> SessionKind;
289
290    /// Cheap readiness check (socket exists, server configured, routine
291    /// key present). Called before register and on every full drive.
292    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
293
294    /// Puts one letter in front of the session.
295    fn wake(
296        &mut self,
297        session: &ProviderSession,
298        letter: &WakeLetter<'_>,
299    ) -> Result<WakeOutcome, WakeError>;
300}
301
302/// The prompt text every local adapter injects. Same shape for all
303/// providers so agents learn one protocol
304/// (`docs/mail4agent/agent-mail-protocol.md`). Web routines get the JSON
305/// object instead, whose `reply` field carries the same command.
306pub 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/// Endpoints the host hands to [`adapter_for`]. Every field is optional;
324/// an adapter whose endpoint is missing reports [`WakeError::Unavailable`].
325/// Credentials come from the host keychain / environment, never a literal.
326#[derive(Default, Clone)]
327pub struct AdapterConfig {
328    /// Grok `leader.sock` (`M4A_LEADER_SOCK`).
329    pub leader_sock: Option<PathBuf>,
330    /// Inbox directory for queue-style adapters (`M4A_INBOX_DIR`).
331    pub inbox_dir: Option<PathBuf>,
332    /// Codex app-server endpoint (`M4A_CODEX_APP_SERVER`).
333    pub codex: Option<CodexEndpoint>,
334    /// Kimi local server origin (`M4A_KIMI_SERVER_URL`).
335    pub kimi_url: Option<String>,
336    /// Kimi local server bearer (`M4A_KIMI_SERVER_TOKEN`), in memory only.
337    pub kimi_bearer: Option<String>,
338    /// Web routine URL of the bot (`M4A_ROUTINE_URL`), in memory only.
339    pub routine_url: Option<String>,
340    /// Web routine key (`M4A_ROUTINE_BEARER`), in memory only.
341    pub routine_bearer: Option<String>,
342    /// Claude routine `/fire` URL (`M4A_CLAUDE_ROUTINE_FIRE_URL`), in memory only.
343    pub claude_fire_url: Option<String>,
344    /// Claude routine token (`M4A_CLAUDE_ROUTINE_TOKEN`), in memory only.
345    pub claude_fire_bearer: Option<String>,
346    /// Codex cloud environment id for `codex cloud exec --env`.
347    pub codex_cloud_env: Option<String>,
348    /// Override of the provider binary for resume spawns (tests).
349    pub spawn_program: Option<PathBuf>,
350}
351
352/// Claude routine fire URL env.
353pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
354/// Claude routine token env.
355pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
356
357impl AdapterConfig {
358    /// Endpoints from the environment for one session. `inbox_dir` is the
359    /// session's inbox (see [`registry::inbox_dir`]) unless
360    /// [`INBOX_DIR_ENV`] overrides it. A Kimi server is discovered from
361    /// `~/.kimi-code` when its env is unset. Routine URL/bearer are not
362    /// read here: the web client passes its own.
363    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        // Never print endpoints that may embed keys, or bearers.
391        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
403/// Builds the adapter for `kind` from `config`.
404pub 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
438// ---------------------------------------------------------------- local --
439
440/// Grok CLI: ACP `session/prompt` on an already-running `leader.sock`.
441pub struct GrokLeaderAdapter {
442    /// `M4A_LEADER_SOCK`.
443    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
489/// Cursor CLI: the session's own `stop` hook (`m4a-inbox drain --format
490/// cursor-stop`) returns `followup_message`, which Cursor auto-submits as
491/// the next user turn. No documented way into an idle interactive chat
492/// another process owns (`agent acp` / `persist` start their own agent),
493/// so this is a hook doorbell; see [`chain::mechanisms`] for the order.
494pub struct CursorAgentAdapter {
495    /// `M4A_INBOX_DIR`.
496    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
522// ------------------------------------------------------------------ web --
523
524/// Vendor-hosted web session woken by its own webhook routine (Cursor /
525/// Grok Bot boxes). Same JSON object `m4a-web-client` posts today
526/// (`body`, `from`, `from_nick`, `to`, `event_id`, `room`, `reply`).
527pub struct RoutineWebhookAdapter {
528    /// Vendor of the web session.
529    pub provider: ProviderKind,
530    /// Routine URL. In memory only.
531    pub url: Option<String>,
532    /// Routine key. In memory only.
533    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
575/// Claude Code on the web, LAST resort: routine `/fire`
576/// (`POST https://api.anthropic.com/v1/claude_code/routines/{id}/fire`,
577/// per-routine bearer, `anthropic-beta: experimental-cc-routine-2026-04-01`,
578/// body `{"text": ...}`). Every fire starts a NEW cloud session (rate
579/// limited), so it is used only for a session marked headless. An open
580/// cloud session is reached by its own hooks (`claude-async-rewake`,
581/// `claude-stop-hook`) first.
582pub struct ClaudeRoutineFireAdapter {
583    /// Fire URL. In memory only.
584    pub url: Option<String>,
585    /// Routine token. In memory only.
586    pub bearer: Option<String>,
587}
588
589/// `anthropic-beta` value for routine fire (override `M4A_CLAUDE_ROUTINE_BETA`).
590pub 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
664/// Codex cloud (stub, vendor-side fix).
665///
666/// TODO(codex-cloud): `codex cloud exec --env <ENV_ID> <prompt>` creates a
667/// NEW task; no documented follow-up into an existing cloud task.
668pub struct CodexCloudAdapter;
669
670/// Vendor web surface with no documented inbound trigger (Kimi web, Grok web).
671pub struct NoInboundAdapter {
672    /// Vendor.
673    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}