Skip to main content

gate4agent_wake/
lib.rs

1//! gate4agent-wake: the deliver-turn API -- put one letter into an agent session as a turn,
2//! over structured channels only. No PTY, no raw keystrokes, no screen read: this crate must
3//! never depend on the terminal crates (gate4agent-pty, -shell-*, -runtime-native, -node-core).
4//! It has no permission model of its own; the host (hatchery) decides who may deliver.
5//! Adapters never answer a permission request; the modal stays with the session's client.
6//!
7//! (Adapters moved here from mail4agent, 2026-10-13; the grok leader ACP client and the routine
8//! POST followed on `feat/deliver-adapters`, so the messenger links no session channel.)
9//!
10//! Layer rule: mail4agent (m4a) is a pure messenger -- it hands a letter over (`M4A_LETTER_HOOK`)
11//! and keeps it in its durable inbox. The host (the hatchery node) decides with [`hosted`] and
12//! delivers through the adapters here.
13//!
14//! Original description (mail4agent):
15//!
16//! The clients (push link, decrypt, sealed store, `m4a-send` socket) are
17//! provider-agnostic. The only provider-specific step is *wake*: putting one
18//! decrypted room text in front of an agent session so it takes a turn.
19//! That step is the [`WakeAdapter`] trait. A session is addressed by a
20//! [`SessionKind`]: the vendor ([`ProviderKind`]) and the [`Surface`] it runs
21//! on (vendor-hosted web/cloud, or a CLI on the user's machine). Replies
22//! never go through the adapter: every agent answers with the shell command
23//! `m4a-send --as <own-nick> --to <peer-nick> '<text>'`.
24//!
25//! Each session gets an ordered [`WakeChain`] ([`chain::plan_chain`],
26//! order in [`chain::mechanisms`]): a turn in the client that already has
27//! the session open first, then hooks inside that session, then the
28//! durable inbox, and a new-process resume only for a headless session.
29//!
30//! | Kind | Primary (in-session) | Fallbacks | Last resort (headless only) |
31//! | --- | --- | --- | --- |
32//! | Grok CLI | [`GrokLeaderAdapter`] ACP on `leader.sock` | Stop hook, inbox | `grok --resume -p` |
33//! | Codex CLI | [`codex::CodexAppServerAdapter`] `turn/start` | Stop hook, inbox | `codex exec resume` |
34//! | Kimi Code CLI | [`kimi::KimiServerAdapter`] `kimi web` prompts | Stop hook, inbox | `kimi -S -p` |
35//! | Claude Code CLI | [`ClaudeChannelAdapter`] channel MCP; `asyncRewake` waiter | Stop hook, inbox | `claude --resume -p` |
36//! | Cursor CLI | none for an idle chat | `stop` hook `followup_message`, inbox | `agent --resume -p` |
37//! | Grok Bot / Cursor web | [`RoutineWebhookAdapter`] | - | - |
38//! | Claude Code web | `asyncRewake` waiter in the cloud session | Stop hook, inbox | [`ClaudeRoutineFireAdapter`] (new session) |
39//! | Codex cloud | - | Stop hook, inbox | `codex cloud exec` (new task) |
40//! | Kimi web, Grok web | none documented ([`NoInboundAdapter`]) | - | - |
41//!
42//! Core four: Grok, Kimi Code, Claude Code, Codex. Cursor CLI is optional.
43//! Design: the project documentation.
44//! Only [`spawn::ResumeSpawnAdapter`] starts a provider process, and only
45//! for a headless session. No adapter answers a permission modal or logs
46//! the plaintext or a credential.
47
48pub 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
80/// `M4A_LEADER_SOCK`: the grok leader socket.
81pub const LEADER_SOCK_ENV: &str = "M4A_LEADER_SOCK";
82
83/// Command a woken bot runs to answer, as shown in the wake's `reply`.
84pub const SEND_COMMAND: &str = "m4a-send";
85
86/// The `reply` hint for a wake from `from_nick` to `to`.
87pub fn reply_hint(to: &str, from_nick: &str) -> String {
88    format!("{SEND_COMMAND} --as {to} --to {from_nick} '<your reply>'")
89}
90
91
92pub use chain::{plan_chain, ChainError, HostEnv, Mechanism, Tier, WakeChain, WebVendor};
93pub use claude_channel::ClaudeChannelAdapter;
94pub use codex::{CodexAppServerAdapter, CodexEndpoint};
95pub use hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
96pub use inbox::{InboxEntry, InboxLetter};
97pub use kimi::KimiServerAdapter;
98pub use registry::SessionRecord;
99pub use spawn::ResumeSpawnAdapter;
100
101/// Environment variable naming the provider of a local session.
102pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
103
104/// Environment variable for a per-session inbox directory used by
105/// queue-style adapters (Claude channel server, hook doorbells).
106pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
107
108/// Agent vendors the stack drives. Ids match the gate4agent catalog /
109/// adapter ids (`grok`, `kimi`, `claude`, `codex`, `cursor`).
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub enum ProviderKind {
112    /// xAI Grok.
113    Grok,
114    /// Kimi Code.
115    KimiCode,
116    /// Claude Code.
117    ClaudeCode,
118    /// OpenAI Codex.
119    Codex,
120    /// Cursor: the vendor-hosted web agents (Grok Bot boxes) and the
121    /// optional Linux-only `cursor-agent` CLI.
122    Cursor,
123}
124
125impl ProviderKind {
126    /// The core four, Grok first.
127    pub const CORE: [ProviderKind; 4] = [
128        ProviderKind::Grok,
129        ProviderKind::KimiCode,
130        ProviderKind::ClaudeCode,
131        ProviderKind::Codex,
132    ];
133
134    /// Every provider, including the optional Cursor CLI.
135    pub const ALL: [ProviderKind; 5] = [
136        ProviderKind::Grok,
137        ProviderKind::KimiCode,
138        ProviderKind::ClaudeCode,
139        ProviderKind::Codex,
140        ProviderKind::Cursor,
141    ];
142
143    /// Stable id (gate4agent catalog spelling).
144    pub fn id(self) -> &'static str {
145        match self {
146            ProviderKind::Grok => "grok",
147            ProviderKind::KimiCode => "kimi",
148            ProviderKind::ClaudeCode => "claude",
149            ProviderKind::Codex => "codex",
150            ProviderKind::Cursor => "cursor",
151        }
152    }
153
154    /// `true` for the optional fifth CLI provider.
155    pub fn is_optional(self) -> bool {
156        matches!(self, ProviderKind::Cursor)
157    }
158
159    /// Parses an id or a common alias. Case-insensitive.
160    pub fn parse(value: &str) -> Option<Self> {
161        match value.trim().to_ascii_lowercase().as_str() {
162            "grok" => Some(ProviderKind::Grok),
163            "kimi" | "kimi-code" | "kimicode" => Some(ProviderKind::KimiCode),
164            "claude" | "claude-code" | "claudecode" => Some(ProviderKind::ClaudeCode),
165            "codex" => Some(ProviderKind::Codex),
166            "cursor" | "cursor-agent" => Some(ProviderKind::Cursor),
167            _ => None,
168        }
169    }
170
171    /// Reads [`PROVIDER_ENV`]. Unset means Grok.
172    pub fn from_env() -> Result<Self, WakeError> {
173        match std::env::var(PROVIDER_ENV) {
174            Ok(value) if !value.trim().is_empty() => {
175                Self::parse(&value).ok_or(WakeError::UnknownProvider)
176            }
177            _ => Ok(ProviderKind::Grok),
178        }
179    }
180}
181
182impl fmt::Display for ProviderKind {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        f.write_str(self.id())
185    }
186}
187
188/// Where a session runs.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum Surface {
191    /// Vendor-hosted web / cloud session (`m4a-web-client`).
192    Web,
193    /// CLI session on the user's machine (local client).
194    Local,
195}
196
197/// Vendor + surface: what picks the adapter.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
199pub struct SessionKind {
200    /// Vendor.
201    pub provider: ProviderKind,
202    /// Web or local.
203    pub surface: Surface,
204}
205
206impl SessionKind {
207    /// Local CLI session of `provider`.
208    pub fn local(provider: ProviderKind) -> Self {
209        Self {
210            provider,
211            surface: Surface::Local,
212        }
213    }
214
215    /// Vendor-hosted web session of `provider`.
216    pub fn web(provider: ProviderKind) -> Self {
217        Self {
218            provider,
219            surface: Surface::Web,
220        }
221    }
222}
223
224impl fmt::Display for SessionKind {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        let surface = match self.surface {
227            Surface::Web => "web",
228            Surface::Local => "local",
229        };
230        write!(f, "{}/{}", self.provider, surface)
231    }
232}
233
234/// One session a client wakes.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct ProviderSession {
237    /// Vendor + surface.
238    pub kind: SessionKind,
239    /// Vendor-native session / thread / chat id. Never logged or committed.
240    pub session_id: String,
241    /// mail4agent nick of this session (slug of the display name).
242    pub nick: String,
243    /// Working directory the session was started in (local only).
244    pub cwd: Option<PathBuf>,
245    /// No client holds the session open. Only then may a chain fall back
246    /// to spawning a new process that resumes it.
247    pub headless: bool,
248}
249
250/// One decrypted room text to put in front of the session.
251#[derive(Debug, Clone, Copy)]
252pub struct WakeLetter<'a> {
253    /// Plaintext body.
254    pub body: &'a str,
255    /// Sender nick.
256    pub from_nick: &'a str,
257    /// Matrix event id (dedupe key).
258    pub event_id: &'a str,
259    /// Room id, if known.
260    pub room: Option<&'a str>,
261}
262
263/// What a wake did.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum WakeOutcome {
266    /// The session accepted the prompt now (a turn started or was queued
267    /// by the vendor runtime).
268    Delivered,
269    /// Stored for the session to pick up (channel server / hook). The path
270    /// is the inbox entry written.
271    Queued(PathBuf),
272}
273
274/// Why a wake did not happen. Never carries the plaintext or a secret.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum WakeError {
277    /// [`PROVIDER_ENV`] named something unknown.
278    UnknownProvider,
279    /// The adapter is a skeleton on this branch.
280    NotImplemented(SessionKind),
281    /// The vendor exposes no inbound trigger into an existing session.
282    NoInboundTrigger(SessionKind),
283    /// The endpoint (socket, server, inbox, routine) is absent or unset.
284    Unavailable(String),
285    /// The endpoint answered but the wake failed.
286    Transport(String),
287}
288
289impl fmt::Display for WakeError {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        match self {
292            WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
293            WakeError::NotImplemented(kind) => {
294                write!(f, "wake adapter for {kind} is not implemented")
295            }
296            WakeError::NoInboundTrigger(kind) => {
297                write!(
298                    f,
299                    "{kind} exposes no inbound trigger into a running session"
300                )
301            }
302            WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
303            WakeError::Transport(why) => write!(f, "wake failed: {why}"),
304        }
305    }
306}
307
308impl std::error::Error for WakeError {}
309
310/// Vendor/surface-specific wake. One instance per session.
311///
312/// The caller keeps the per-`event_id` sent set. Implementations must not
313/// spawn a provider, must not answer permission prompts, and must not log
314/// `letter.body` or a credential.
315pub trait WakeAdapter: Send {
316    /// Session kind this adapter wakes.
317    fn kind(&self) -> SessionKind;
318
319    /// Cheap readiness check (socket exists, server configured, routine
320    /// key present). Called before register and on every full drive.
321    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
322
323    /// Puts one letter in front of the session.
324    fn wake(
325        &mut self,
326        session: &ProviderSession,
327        letter: &WakeLetter<'_>,
328    ) -> Result<WakeOutcome, WakeError>;
329}
330
331/// The prompt text every local adapter injects. Same shape for all
332/// providers so agents learn one protocol
333/// (`docs/mail4agent/agent-mail-protocol.md`). Web routines get the JSON
334/// object instead, whose `reply` field carries the same command.
335pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
336    // A structured event: headers, then the body verbatim. No instruction text: whether and how to
337    // answer is up to the agent and the letter itself (a body may say "no reply needed").
338    let reply = reply_hint(&session.nick, letter.from_nick);
339    let mut head = format!(
340        "[mail4agent letter]\nfrom: {from}\nto: {to}\n",
341        from = letter.from_nick,
342        to = session.nick,
343    );
344    if let Some(room) = letter.room.map(str::trim).filter(|r| !r.is_empty()) {
345        head.push_str(&format!("room: {room}\n"));
346    }
347    format!("{head}event: {event}\nreply-command: {reply}\n---\n{body}", event = letter.event_id, body = letter.body)
348}
349
350/// Endpoints the host hands to [`adapter_for`]. Every field is optional;
351/// an adapter whose endpoint is missing reports [`WakeError::Unavailable`].
352/// Credentials come from the host keychain / environment, never a literal.
353#[derive(Default, Clone)]
354pub struct AdapterConfig {
355    /// Grok `leader.sock` (`M4A_LEADER_SOCK`).
356    pub leader_sock: Option<PathBuf>,
357    /// Inbox directory for queue-style adapters (`M4A_INBOX_DIR`).
358    pub inbox_dir: Option<PathBuf>,
359    /// Codex app-server endpoint (`M4A_CODEX_APP_SERVER`).
360    pub codex: Option<CodexEndpoint>,
361    /// Kimi local server origin (`M4A_KIMI_SERVER_URL`).
362    pub kimi_url: Option<String>,
363    /// Kimi local server bearer (`M4A_KIMI_SERVER_TOKEN`), in memory only.
364    pub kimi_bearer: Option<String>,
365    /// When set, local Kimi uses the ACP host (`M4A_KIMI_ACP` gate); value is the binary (`M4A_KIMI_BIN` or `"kimi"`).
366    pub kimi_acp_bin: Option<String>,
367    /// Web routine URL of the bot (`M4A_ROUTINE_URL`), in memory only.
368    pub routine_url: Option<String>,
369    /// Web routine key (`M4A_ROUTINE_BEARER`), in memory only.
370    pub routine_bearer: Option<String>,
371    /// Claude routine `/fire` URL (`M4A_CLAUDE_ROUTINE_FIRE_URL`), in memory only.
372    pub claude_fire_url: Option<String>,
373    /// Claude routine token (`M4A_CLAUDE_ROUTINE_TOKEN`), in memory only.
374    pub claude_fire_bearer: Option<String>,
375    /// Codex cloud environment id for `codex cloud exec --env`.
376    pub codex_cloud_env: Option<String>,
377    /// Override of the provider binary for resume spawns (tests).
378    pub spawn_program: Option<PathBuf>,
379}
380
381/// Claude routine fire URL env.
382pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
383/// Claude routine token env.
384pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
385
386impl AdapterConfig {
387    /// Endpoints from the environment for one session. `inbox_dir` is the
388    /// session's inbox (see [`registry::inbox_dir`]) unless
389    /// [`INBOX_DIR_ENV`] overrides it. A Kimi server is discovered from
390    /// `~/.kimi-code` when its env is unset. Routine URL/bearer are not
391    /// read here: the web client passes its own.
392    pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
393        let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
394        let mut kimi = KimiServerAdapter::from_env();
395        if !kimi.is_configured() {
396            if let Some(found) = kimi::discover_local_server(None) {
397                kimi = found;
398            }
399        }
400        let (kimi_url, kimi_bearer) = kimi.into_parts();
401        Self {
402            leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
403            inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
404            codex: CodexEndpoint::from_env(),
405            kimi_url,
406            kimi_bearer,
407            kimi_acp_bin: kimi_acp::KimiAcpConfig::from_env().map(|c| c.bin),
408            routine_url: None,
409            routine_bearer: None,
410            claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
411            claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
412            codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
413            spawn_program: None,
414        }
415    }
416}
417
418impl fmt::Debug for AdapterConfig {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        // Never print endpoints that may embed keys, or bearers.
421        f.debug_struct("AdapterConfig")
422            .field("leader_sock", &self.leader_sock.is_some())
423            .field("inbox_dir", &self.inbox_dir.is_some())
424            .field("codex", &self.codex.is_some())
425            .field("kimi_url", &self.kimi_url.is_some())
426            .field("routine_url", &self.routine_url.is_some())
427            .field("claude_fire_url", &self.claude_fire_url.is_some())
428            .field("codex_cloud_env", &self.codex_cloud_env.is_some())
429            .finish()
430    }
431}
432
433/// Builds the adapter for `kind` from `config`.
434pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
435    match (kind.surface, kind.provider) {
436        (Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
437            leader_sock: config.leader_sock.clone(),
438        }),
439        (Surface::Local, ProviderKind::Codex) => {
440            Box::new(CodexAppServerAdapter::new(config.codex.clone()))
441        }
442        (Surface::Local, ProviderKind::KimiCode) => {
443            // ACP host when the host gated it; else the local kimi server. One wake owner.
444            if let Some(bin) = config.kimi_acp_bin.clone() {
445                Box::new(kimi_acp::KimiAcpAdapter::new(Some(kimi_acp::KimiAcpConfig { bin })))
446            } else {
447                Box::new(KimiServerAdapter::new(
448                    config.kimi_url.clone(),
449                    config.kimi_bearer.clone(),
450                ))
451            }
452        },
453        (Surface::Local, ProviderKind::ClaudeCode) => {
454            Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
455        }
456        (Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
457            inbox_dir: config.inbox_dir.clone(),
458        }),
459        (Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
460            provider: ProviderKind::Cursor,
461            url: config.routine_url.clone(),
462            bearer: config.routine_bearer.clone(),
463        }),
464        (Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
465            url: config.claude_fire_url.clone(),
466            bearer: config.claude_fire_bearer.clone(),
467        }),
468        (Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
469        // A web session whose host handed a routine URL is woken through it; without one
470        // there is no inbound path.
471        (Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
472            if config.routine_url.as_deref().is_some_and(|u| !u.is_empty()) {
473                Box::new(RoutineWebhookAdapter {
474                    provider,
475                    url: config.routine_url.clone(),
476                    bearer: config.routine_bearer.clone(),
477                })
478            } else {
479                Box::new(NoInboundAdapter { provider })
480            }
481        }
482    }
483}
484
485// ---------------------------------------------------------------- local --
486
487/// Grok CLI: ACP `session/prompt` on an already-running `leader.sock`.
488
489pub struct GrokLeaderAdapter {
490    /// `M4A_LEADER_SOCK`.
491    pub leader_sock: Option<PathBuf>,
492}
493
494impl WakeAdapter for GrokLeaderAdapter {
495    fn kind(&self) -> SessionKind {
496        SessionKind::local(ProviderKind::Grok)
497    }
498
499    fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
500        match self.leader_sock.as_deref() {
501            Some(path) if path.exists() => Ok(()),
502            Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
503            None => Err(WakeError::Unavailable("leader socket unset".into())),
504        }
505    }
506
507    fn wake(
508        &mut self,
509        session: &ProviderSession,
510        letter: &WakeLetter<'_>,
511    ) -> Result<WakeOutcome, WakeError> {
512        let _ = (&session, &letter);
513        #[cfg(not(feature = "wake-grok"))]
514        return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
515        #[cfg(feature = "wake-grok")]
516        self.probe(session)?;
517        #[cfg(feature = "wake-grok")]
518        let Some(sock) = self.leader_sock.as_deref() else {
519            return Err(WakeError::Unavailable("leader socket unset".into()));
520        };
521        #[cfg(feature = "wake-grok")]
522        let cwd = session_cwd(session)?;
523        #[cfg(feature = "wake-grok")]
524        {
525            grok_leader::wake_decrypted_room_blocking(sock, &session.session_id, &cwd, &wake_prompt(session, letter))
526                .map(|()| WakeOutcome::Delivered)
527                .map_err(|e| WakeError::Transport(e.to_string()))
528        }
529    }
530}
531
532/// Cursor CLI: the session's own `stop` hook (`m4a-inbox drain --format
533/// cursor-stop`) returns `followup_message`, which Cursor auto-submits as
534/// the next user turn. No documented way into an idle interactive chat
535/// another process owns (`agent acp` / `persist` start their own agent),
536/// so this is a hook doorbell; see [`chain::mechanisms`] for the order.
537pub struct CursorAgentAdapter {
538    /// `M4A_INBOX_DIR`.
539    pub inbox_dir: Option<PathBuf>,
540}
541
542impl WakeAdapter for CursorAgentAdapter {
543    fn kind(&self) -> SessionKind {
544        SessionKind::local(ProviderKind::Cursor)
545    }
546
547    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
548        InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
549            .probe(session)
550    }
551
552    fn wake(
553        &mut self,
554        session: &ProviderSession,
555        letter: &WakeLetter<'_>,
556    ) -> Result<WakeOutcome, WakeError> {
557        if !cfg!(feature = "wake-cursor") {
558            return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
559        }
560        InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
561            .wake(session, letter)
562    }
563}
564
565// ------------------------------------------------------------------ web --
566
567/// Vendor-hosted web session woken by its own webhook routine (Cursor /
568/// Grok Bot boxes). Same JSON object `m4a-web-client` posts today
569/// (`body`, `from`, `from_nick`, `to`, `event_id`, `room`, `reply`).
570pub struct RoutineWebhookAdapter {
571    /// Vendor of the web session.
572    pub provider: ProviderKind,
573    /// Routine URL. In memory only.
574    pub url: Option<String>,
575    /// Routine key. In memory only.
576    pub bearer: Option<String>,
577}
578
579impl WakeAdapter for RoutineWebhookAdapter {
580    fn kind(&self) -> SessionKind {
581        SessionKind::web(self.provider)
582    }
583
584    fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
585        match self.url.as_deref() {
586            Some(url) if !url.is_empty() => Ok(()),
587            _ => Err(WakeError::Unavailable("routine url unset".into())),
588        }
589    }
590
591    fn wake(
592        &mut self,
593        session: &ProviderSession,
594        letter: &WakeLetter<'_>,
595    ) -> Result<WakeOutcome, WakeError> {
596        if !cfg!(feature = "wake-routine") {
597            return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
598        }
599        self.probe(session)?;
600        let url = self.url.as_deref().unwrap_or_default();
601        let reply = reply_hint(&session.nick, letter.from_nick);
602        let mut body = serde_json::Map::new();
603        for (k, v) in [
604            ("body", Some(letter.body)),
605            ("from", Some(letter.from_nick)),
606            ("event_id", Some(letter.event_id)),
607            ("room", letter.room),
608            ("from_nick", Some(letter.from_nick)),
609            ("to", Some(session.nick.as_str())),
610            ("reply", Some(reply.as_str())),
611        ] {
612            if let Some(v) = v.map(str::trim).filter(|v| !v.is_empty() || k == "body") {
613                body.insert(k.into(), serde_json::Value::String(v.into()));
614            }
615        }
616        #[cfg(not(feature = "wake-routine"))]
617        {
618            let _ = (url, body);
619            return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
620        }
621        #[cfg(feature = "wake-routine")]
622        routine::post_routine_json(url, &serde_json::Value::Object(body), self.bearer.as_deref())
623            .map(|()| WakeOutcome::Delivered)
624            .map_err(|e| WakeError::Transport(e.to_string()))
625    }
626}
627
628/// Claude Code on the web, LAST resort: routine `/fire`
629/// (`POST https://api.anthropic.com/v1/claude_code/routines/{id}/fire`,
630/// per-routine bearer, `anthropic-beta: experimental-cc-routine-2026-04-01`,
631/// body `{"text": ...}`). Every fire starts a NEW cloud session (rate
632/// limited), so it is used only for a session marked headless. An open
633/// cloud session is reached by its own hooks (`claude-async-rewake`,
634/// `claude-stop-hook`) first.
635pub struct ClaudeRoutineFireAdapter {
636    /// Fire URL. In memory only.
637    pub url: Option<String>,
638    /// Routine token. In memory only.
639    pub bearer: Option<String>,
640}
641
642/// `anthropic-beta` value for routine fire (override `M4A_CLAUDE_ROUTINE_BETA`).
643pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
644
645impl WakeAdapter for ClaudeRoutineFireAdapter {
646    fn kind(&self) -> SessionKind {
647        SessionKind::web(ProviderKind::ClaudeCode)
648    }
649
650    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
651        if self.url.as_deref().is_none_or(str::is_empty) {
652            return Err(WakeError::Unavailable(
653                "claude routine fire url unset".into(),
654            ));
655        }
656        if self.bearer.as_deref().is_none_or(str::is_empty) {
657            return Err(WakeError::Unavailable("claude routine token unset".into()));
658        }
659        if !session.headless {
660            return Err(WakeError::Unavailable(
661                "session is open; routine fire would start a new session".into(),
662            ));
663        }
664        Ok(())
665    }
666
667    fn wake(
668        &mut self,
669        session: &ProviderSession,
670        letter: &WakeLetter<'_>,
671    ) -> Result<WakeOutcome, WakeError> {
672        if !cfg!(feature = "wake-claude") {
673            return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
674        }
675        self.probe(session)?;
676        let url = self.url.as_deref().unwrap_or_default();
677        let token = self.bearer.as_deref().unwrap_or_default();
678        let parsed = reqwest::Url::parse(url)
679            .ok()
680            .filter(|u| u.scheme() == "https" || is_loopback_http(u))
681            .ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
682        let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
683            .ok()
684            .filter(|v| !v.is_empty())
685            .unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
686        let client = reqwest::blocking::Client::builder()
687            .timeout(std::time::Duration::from_secs(15))
688            .build()
689            .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
690        let response = client
691            .post(parsed)
692            .bearer_auth(token)
693            .header("anthropic-version", "2023-06-01")
694            .header("anthropic-beta", beta)
695            .json(&serde_json::json!({"text": wake_prompt(session, letter)}))
696            .send()
697            .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
698        if response.status().is_success() {
699            Ok(WakeOutcome::Delivered)
700        } else {
701            Err(WakeError::Transport(format!(
702                "routine fire status {}",
703                response.status().as_u16()
704            )))
705        }
706    }
707}
708
709fn is_loopback_http(url: &reqwest::Url) -> bool {
710    url.scheme() == "http"
711        && matches!(
712            url.host_str(),
713            Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
714        )
715}
716
717/// Codex cloud (stub, vendor-side fix).
718///
719/// TODO(codex-cloud): `codex cloud exec --env <ENV_ID> <prompt>` creates a
720/// NEW task; no documented follow-up into an existing cloud task.
721pub struct CodexCloudAdapter;
722
723/// Vendor web surface with no documented inbound trigger (Kimi web, Grok web).
724pub struct NoInboundAdapter {
725    /// Vendor.
726    pub provider: ProviderKind,
727}
728
729macro_rules! refuse_adapter {
730    ($ty:ty, $kind:expr, $err:ident) => {
731        impl WakeAdapter for $ty {
732            fn kind(&self) -> SessionKind {
733                $kind(self)
734            }
735
736            fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
737                Err(WakeError::$err(self.kind()))
738            }
739
740            fn wake(
741                &mut self,
742                _session: &ProviderSession,
743                _letter: &WakeLetter<'_>,
744            ) -> Result<WakeOutcome, WakeError> {
745                Err(WakeError::$err(self.kind()))
746            }
747        }
748    };
749}
750
751refuse_adapter!(
752    CodexCloudAdapter,
753    |_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
754    NotImplemented
755);
756refuse_adapter!(
757    NoInboundAdapter,
758    |adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
759    NoInboundTrigger
760);
761
762#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
763pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
764    session
765        .cwd
766        .as_deref()
767        .map(Path::to_path_buf)
768        .or_else(|| std::env::current_dir().ok())
769        .map(|path| path.display().to_string())
770        .filter(|cwd| !cwd.is_empty())
771        .ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
772}
773
774#[cfg(test)]
775pub(crate) mod tests {
776    use super::*;
777
778    pub(crate) fn session(kind: SessionKind) -> ProviderSession {
779        ProviderSession {
780            kind,
781            session_id: "s-1".into(),
782            nick: "alice".into(),
783            cwd: Some(PathBuf::from("/tmp")),
784            headless: false,
785        }
786    }
787
788    pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
789        WakeLetter {
790            body,
791            from_nick: "carol",
792            event_id: "$ev1",
793            room: None,
794        }
795    }
796
797    #[test]
798    fn web_grok_uses_the_routine_only_when_one_is_configured() {
799        let kind = SessionKind::web(ProviderKind::Grok);
800        let none = adapter_for(kind, &AdapterConfig::default());
801        assert!(none.probe(&session(kind)).is_err());
802        let cfg = AdapterConfig { routine_url: Some("http://127.0.0.1:9/hook".into()), ..Default::default() };
803        let routine = adapter_for(kind, &cfg);
804        assert!(routine.probe(&session(kind)).is_ok());
805    }
806
807    #[test]
808    fn ids_round_trip() {
809        for kind in ProviderKind::ALL {
810            assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
811        }
812        assert_eq!(
813            ProviderKind::parse("Claude-Code"),
814            Some(ProviderKind::ClaudeCode)
815        );
816        assert_eq!(ProviderKind::parse("gemini"), None);
817        assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
818        assert!(ProviderKind::Cursor.is_optional());
819        assert_eq!(
820            SessionKind::web(ProviderKind::Codex).to_string(),
821            "codex/web"
822        );
823    }
824
825    #[test]
826    fn every_kind_gets_an_adapter_of_that_kind() {
827        let config = AdapterConfig::default();
828        for provider in ProviderKind::ALL {
829            for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
830                assert_eq!(adapter_for(kind, &config).kind(), kind);
831            }
832        }
833    }
834
835    #[test]
836    fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
837        let config = AdapterConfig::default();
838        let cases = [
839            (SessionKind::local(ProviderKind::Cursor), "unavailable"),
840            (SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
841            (SessionKind::web(ProviderKind::Codex), "not implemented"),
842            (SessionKind::web(ProviderKind::KimiCode), "no inbound"),
843            (SessionKind::web(ProviderKind::Grok), "no inbound"),
844            (SessionKind::web(ProviderKind::Cursor), "unavailable"),
845            (SessionKind::local(ProviderKind::Grok), "unavailable"),
846            (SessionKind::local(ProviderKind::Codex), "unavailable"),
847            (SessionKind::local(ProviderKind::KimiCode), "unavailable"),
848            (SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
849        ];
850        for (kind, expect) in cases {
851            let mut adapter = adapter_for(kind, &config);
852            let s = session(kind);
853            let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
854            assert!(err.contains(expect), "{kind}: {err}");
855        }
856    }
857
858    #[test]
859    fn prompt_carries_reply_command_and_body() {
860        let text = wake_prompt(
861            &session(SessionKind::local(ProviderKind::Codex)),
862            &letter("ping"),
863        );
864        assert!(text.contains("reply-command: m4a-send --as alice --to carol"));
865        assert!(text.ends_with("---\nping"));
866        assert!(!text.contains("Answer every") && !text.contains("принято"), "no forced instruction");
867    }
868
869    #[test]
870    fn config_debug_hides_values() {
871        let config = AdapterConfig {
872            kimi_bearer: Some("k-secret".into()),
873            routine_url: Some("https://example.invalid/hook".into()),
874            ..AdapterConfig::default()
875        };
876        let shown = format!("{config:?}");
877        assert!(!shown.contains("secret") && !shown.contains("example"));
878    }
879}
880
881#[cfg(test)]
882mod g4a_alignment_tests {
883    use super::ProviderKind;
884
885    /// hatchery-node maps g4a ids (claude, codex, grok, kimi) to these names before
886    /// `m4a-agent account provision --provider ...`; every one must parse, and g4a's own ids too.
887    #[test]
888    fn names_hatchery_node_sends_all_parse() {
889        for (sent, kind) in [
890            ("claude-code", ProviderKind::ClaudeCode),
891            ("codex", ProviderKind::Codex),
892            ("grok", ProviderKind::Grok),
893            ("kimi-code", ProviderKind::KimiCode),
894            ("claude", ProviderKind::ClaudeCode),
895            ("kimi", ProviderKind::KimiCode),
896        ] {
897            assert_eq!(ProviderKind::parse(sent), Some(kind), "{sent}");
898        }
899        assert_eq!(ProviderKind::parse("custom"), None);
900    }
901}