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/// Room-aware reply: a letter that came from a room (group or DM) is answered in that same room
92/// (`m4a --as <to> send <room> '<text>'`), so a group message is never answered by private DM. Falls
93/// back to [`reply_hint`] when the room is unknown.
94pub 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
101/// The room-aware messenger CLI (`m4a`, i.e. `m4a-agent mail`).
102pub 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
114/// Environment variable naming the provider of a local session.
115pub const PROVIDER_ENV: &str = "M4A_PROVIDER";
116
117/// Environment variable for a per-session inbox directory used by
118/// queue-style adapters (Claude channel server, hook doorbells).
119pub const INBOX_DIR_ENV: &str = "M4A_INBOX_DIR";
120
121/// Agent vendors the stack drives. Ids match the gate4agent catalog /
122/// adapter ids (`grok`, `kimi`, `claude`, `codex`, `cursor`).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum ProviderKind {
125    /// xAI Grok.
126    Grok,
127    /// Kimi Code.
128    KimiCode,
129    /// Claude Code.
130    ClaudeCode,
131    /// OpenAI Codex.
132    Codex,
133    /// Cursor: the vendor-hosted web agents (Grok Bot boxes) and the
134    /// optional Linux-only `cursor-agent` CLI.
135    Cursor,
136}
137
138impl ProviderKind {
139    /// The core four, Grok first.
140    pub const CORE: [ProviderKind; 4] = [
141        ProviderKind::Grok,
142        ProviderKind::KimiCode,
143        ProviderKind::ClaudeCode,
144        ProviderKind::Codex,
145    ];
146
147    /// Every provider, including the optional Cursor CLI.
148    pub const ALL: [ProviderKind; 5] = [
149        ProviderKind::Grok,
150        ProviderKind::KimiCode,
151        ProviderKind::ClaudeCode,
152        ProviderKind::Codex,
153        ProviderKind::Cursor,
154    ];
155
156    /// Stable id (gate4agent catalog spelling).
157    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    /// `true` for the optional fifth CLI provider.
168    pub fn is_optional(self) -> bool {
169        matches!(self, ProviderKind::Cursor)
170    }
171
172    /// Parses an id or a common alias. Case-insensitive.
173    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    /// Reads [`PROVIDER_ENV`]. Unset means Grok.
185    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/// Where a session runs.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum Surface {
204    /// Vendor-hosted web / cloud session (`m4a-web-client`).
205    Web,
206    /// CLI session on the user's machine (local client).
207    Local,
208}
209
210/// Vendor + surface: what picks the adapter.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct SessionKind {
213    /// Vendor.
214    pub provider: ProviderKind,
215    /// Web or local.
216    pub surface: Surface,
217}
218
219impl SessionKind {
220    /// Local CLI session of `provider`.
221    pub fn local(provider: ProviderKind) -> Self {
222        Self {
223            provider,
224            surface: Surface::Local,
225        }
226    }
227
228    /// Vendor-hosted web session of `provider`.
229    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/// One session a client wakes.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct ProviderSession {
250    /// Vendor + surface.
251    pub kind: SessionKind,
252    /// Vendor-native session / thread / chat id. Never logged or committed.
253    pub session_id: String,
254    /// mail4agent nick of this session (slug of the display name).
255    pub nick: String,
256    /// Working directory the session was started in (local only).
257    pub cwd: Option<PathBuf>,
258    /// No client holds the session open. Only then may a chain fall back
259    /// to spawning a new process that resumes it.
260    pub headless: bool,
261}
262
263/// One decrypted room text to put in front of the session.
264#[derive(Debug, Clone, Copy)]
265pub struct WakeLetter<'a> {
266    /// Plaintext body.
267    pub body: &'a str,
268    /// Sender nick.
269    pub from_nick: &'a str,
270    /// Matrix event id (dedupe key).
271    pub event_id: &'a str,
272    /// Room id, if known.
273    pub room: Option<&'a str>,
274    /// Set when this letter reports a reaction instead of carrying a message. A reaction letter is
275    /// low priority: it has no reply command and the routine JSON says `"reply_needed": false`.
276    pub reaction: Option<ReactionNote<'a>>,
277}
278
279/// What a reaction letter is about: `from_nick` reacted `key` to `target` (an event in `room`).
280/// The letter's own `event_id` is the reaction event; `body` is a short human line with a snippet.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub struct ReactionNote<'a> {
283    /// The emoji (annotation key).
284    pub key: &'a str,
285    /// The event that was reacted to.
286    pub target: &'a str,
287}
288
289/// The `react` hint: `m4a --as <to> react '<room>' '<event>' '<emoji>'`, or `None` without a room.
290pub 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/// What a wake did.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum WakeOutcome {
299    /// The session accepted the prompt now (a turn started or was queued
300    /// by the vendor runtime).
301    Delivered,
302    /// Stored for the session to pick up (channel server / hook). The path
303    /// is the inbox entry written.
304    Queued(PathBuf),
305}
306
307/// Why a wake did not happen. Never carries the plaintext or a secret.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum WakeError {
310    /// [`PROVIDER_ENV`] named something unknown.
311    UnknownProvider,
312    /// The adapter is a skeleton on this branch.
313    NotImplemented(SessionKind),
314    /// The vendor exposes no inbound trigger into an existing session.
315    NoInboundTrigger(SessionKind),
316    /// The endpoint (socket, server, inbox, routine) is absent or unset.
317    Unavailable(String),
318    /// The endpoint answered but the wake failed.
319    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
343/// Vendor/surface-specific wake. One instance per session.
344///
345/// The caller keeps the per-`event_id` sent set. Implementations must not
346/// spawn a provider, must not answer permission prompts, and must not log
347/// `letter.body` or a credential.
348pub trait WakeAdapter: Send {
349    /// Session kind this adapter wakes.
350    fn kind(&self) -> SessionKind;
351
352    /// Cheap readiness check (socket exists, server configured, routine
353    /// key present). Called before register and on every full drive.
354    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
355
356    /// Puts one letter in front of the session.
357    fn wake(
358        &mut self,
359        session: &ProviderSession,
360        letter: &WakeLetter<'_>,
361    ) -> Result<WakeOutcome, WakeError>;
362}
363
364/// The prompt text every local adapter injects. Same shape for all
365/// providers so agents learn one protocol
366/// (`docs/mail4agent/agent-mail-protocol.md`). Web routines get the JSON
367/// object instead, whose `reply` field carries the same command.
368pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
369    // A structured event: headers, then the body verbatim. No instruction text: whether and how to
370    // answer is up to the agent and the letter itself (a body may say "no reply needed").
371    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/// Endpoints the host hands to [`adapter_for`]. Every field is optional;
399/// an adapter whose endpoint is missing reports [`WakeError::Unavailable`].
400/// Credentials come from the host keychain / environment, never a literal.
401#[derive(Default, Clone)]
402pub struct AdapterConfig {
403    /// Grok `leader.sock` (`M4A_LEADER_SOCK`).
404    pub leader_sock: Option<PathBuf>,
405    /// Inbox directory for queue-style adapters (`M4A_INBOX_DIR`).
406    pub inbox_dir: Option<PathBuf>,
407    /// Codex app-server endpoint (`M4A_CODEX_APP_SERVER`).
408    pub codex: Option<CodexEndpoint>,
409    /// Kimi local server origin (`M4A_KIMI_SERVER_URL`).
410    pub kimi_url: Option<String>,
411    /// Kimi local server bearer (`M4A_KIMI_SERVER_TOKEN`), in memory only.
412    pub kimi_bearer: Option<String>,
413    /// When set, local Kimi uses the ACP host (`M4A_KIMI_ACP` gate); value is the binary (`M4A_KIMI_BIN` or `"kimi"`).
414    pub kimi_acp_bin: Option<String>,
415    /// Web routine URL of the bot (`M4A_ROUTINE_URL`), in memory only.
416    pub routine_url: Option<String>,
417    /// Web routine key (`M4A_ROUTINE_BEARER`), in memory only.
418    pub routine_bearer: Option<String>,
419    /// Claude routine `/fire` URL (`M4A_CLAUDE_ROUTINE_FIRE_URL`), in memory only.
420    pub claude_fire_url: Option<String>,
421    /// Claude routine token (`M4A_CLAUDE_ROUTINE_TOKEN`), in memory only.
422    pub claude_fire_bearer: Option<String>,
423    /// Codex cloud environment id for `codex cloud exec --env`.
424    pub codex_cloud_env: Option<String>,
425    /// Override of the provider binary for resume spawns (tests).
426    pub spawn_program: Option<PathBuf>,
427}
428
429/// Claude routine fire URL env.
430pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
431/// Claude routine token env.
432pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
433
434impl AdapterConfig {
435    /// Endpoints from the environment for one session. `inbox_dir` is the
436    /// session's inbox (see [`registry::inbox_dir`]) unless
437    /// [`INBOX_DIR_ENV`] overrides it. A Kimi server is discovered from
438    /// `~/.kimi-code` when its env is unset. Routine URL/bearer are not
439    /// read here: the web client passes its own.
440    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        // Never print endpoints that may embed keys, or bearers.
469        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
481/// Builds the adapter for `kind` from `config`.
482pub 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            // ACP host when the host gated it; else the local kimi server. One wake owner.
492            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        // A web session whose host handed a routine URL is woken through it; without one
518        // there is no inbound path.
519        (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
533// ---------------------------------------------------------------- local --
534
535/// Grok CLI: ACP `session/prompt` on an already-running `leader.sock`.
536
537pub struct GrokLeaderAdapter {
538    /// `M4A_LEADER_SOCK`.
539    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
580/// Cursor CLI: the session's own `stop` hook (`m4a-inbox drain --format
581/// cursor-stop`) returns `followup_message`, which Cursor auto-submits as
582/// the next user turn. No documented way into an idle interactive chat
583/// another process owns (`agent acp` / `persist` start their own agent),
584/// so this is a hook doorbell; see [`chain::mechanisms`] for the order.
585pub struct CursorAgentAdapter {
586    /// `M4A_INBOX_DIR`.
587    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
613// ------------------------------------------------------------------ web --
614
615/// Vendor-hosted web session woken by its own webhook routine (Cursor /
616/// Grok Bot boxes). Same JSON object `m4a-web-client` posts today
617/// (`body`, `from`, `from_nick`, `to`, `event_id`, `room`, `reply`).
618pub struct RoutineWebhookAdapter {
619    /// Vendor of the web session.
620    pub provider: ProviderKind,
621    /// Routine URL. In memory only.
622    pub url: Option<String>,
623    /// Routine key. In memory only.
624    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
686/// Claude Code on the web, LAST resort: routine `/fire`
687/// (`POST https://api.anthropic.com/v1/claude_code/routines/{id}/fire`,
688/// per-routine bearer, `anthropic-beta: experimental-cc-routine-2026-04-01`,
689/// body `{"text": ...}`). Every fire starts a NEW cloud session (rate
690/// limited), so it is used only for a session marked headless. An open
691/// cloud session is reached by its own hooks (`claude-async-rewake`,
692/// `claude-stop-hook`) first.
693pub struct ClaudeRoutineFireAdapter {
694    /// Fire URL. In memory only.
695    pub url: Option<String>,
696    /// Routine token. In memory only.
697    pub bearer: Option<String>,
698}
699
700/// `anthropic-beta` value for routine fire (override `M4A_CLAUDE_ROUTINE_BETA`).
701pub 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
775/// Codex cloud (stub, vendor-side fix).
776///
777/// TODO(codex-cloud): `codex cloud exec --env <ENV_ID> <prompt>` creates a
778/// NEW task; no documented follow-up into an existing cloud task.
779pub struct CodexCloudAdapter;
780
781/// Vendor web surface with no documented inbound trigger (Kimi web, Grok web).
782pub struct NoInboundAdapter {
783    /// Vendor.
784    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    /// hatchery-node maps g4a ids (claude, codex, grok, kimi) to these names before
966    /// `m4a-agent account provision --provider ...`; every one must parse, and g4a's own ids too.
967    #[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}