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}
275
276/// What a wake did.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum WakeOutcome {
279    /// The session accepted the prompt now (a turn started or was queued
280    /// by the vendor runtime).
281    Delivered,
282    /// Stored for the session to pick up (channel server / hook). The path
283    /// is the inbox entry written.
284    Queued(PathBuf),
285}
286
287/// Why a wake did not happen. Never carries the plaintext or a secret.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub enum WakeError {
290    /// [`PROVIDER_ENV`] named something unknown.
291    UnknownProvider,
292    /// The adapter is a skeleton on this branch.
293    NotImplemented(SessionKind),
294    /// The vendor exposes no inbound trigger into an existing session.
295    NoInboundTrigger(SessionKind),
296    /// The endpoint (socket, server, inbox, routine) is absent or unset.
297    Unavailable(String),
298    /// The endpoint answered but the wake failed.
299    Transport(String),
300}
301
302impl fmt::Display for WakeError {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        match self {
305            WakeError::UnknownProvider => write!(f, "unknown provider in {PROVIDER_ENV}"),
306            WakeError::NotImplemented(kind) => {
307                write!(f, "wake adapter for {kind} is not implemented")
308            }
309            WakeError::NoInboundTrigger(kind) => {
310                write!(
311                    f,
312                    "{kind} exposes no inbound trigger into a running session"
313                )
314            }
315            WakeError::Unavailable(why) => write!(f, "wake endpoint unavailable: {why}"),
316            WakeError::Transport(why) => write!(f, "wake failed: {why}"),
317        }
318    }
319}
320
321impl std::error::Error for WakeError {}
322
323/// Vendor/surface-specific wake. One instance per session.
324///
325/// The caller keeps the per-`event_id` sent set. Implementations must not
326/// spawn a provider, must not answer permission prompts, and must not log
327/// `letter.body` or a credential.
328pub trait WakeAdapter: Send {
329    /// Session kind this adapter wakes.
330    fn kind(&self) -> SessionKind;
331
332    /// Cheap readiness check (socket exists, server configured, routine
333    /// key present). Called before register and on every full drive.
334    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError>;
335
336    /// Puts one letter in front of the session.
337    fn wake(
338        &mut self,
339        session: &ProviderSession,
340        letter: &WakeLetter<'_>,
341    ) -> Result<WakeOutcome, WakeError>;
342}
343
344/// The prompt text every local adapter injects. Same shape for all
345/// providers so agents learn one protocol
346/// (`docs/mail4agent/agent-mail-protocol.md`). Web routines get the JSON
347/// object instead, whose `reply` field carries the same command.
348pub fn wake_prompt(session: &ProviderSession, letter: &WakeLetter<'_>) -> String {
349    // A structured event: headers, then the body verbatim. No instruction text: whether and how to
350    // answer is up to the agent and the letter itself (a body may say "no reply needed").
351    let reply = reply_hint_for(&session.nick, letter.from_nick, letter.room);
352    let mut head = format!(
353        "[mail4agent letter]\nfrom: {from}\nto: {to}\n",
354        from = letter.from_nick,
355        to = session.nick,
356    );
357    if let Some(room) = letter.room.map(str::trim).filter(|r| !r.is_empty()) {
358        head.push_str(&format!("room: {room}\n"));
359    }
360    format!("{head}event: {event}\nreply-command: {reply}\n---\n{body}", event = letter.event_id, body = letter.body)
361}
362
363/// Endpoints the host hands to [`adapter_for`]. Every field is optional;
364/// an adapter whose endpoint is missing reports [`WakeError::Unavailable`].
365/// Credentials come from the host keychain / environment, never a literal.
366#[derive(Default, Clone)]
367pub struct AdapterConfig {
368    /// Grok `leader.sock` (`M4A_LEADER_SOCK`).
369    pub leader_sock: Option<PathBuf>,
370    /// Inbox directory for queue-style adapters (`M4A_INBOX_DIR`).
371    pub inbox_dir: Option<PathBuf>,
372    /// Codex app-server endpoint (`M4A_CODEX_APP_SERVER`).
373    pub codex: Option<CodexEndpoint>,
374    /// Kimi local server origin (`M4A_KIMI_SERVER_URL`).
375    pub kimi_url: Option<String>,
376    /// Kimi local server bearer (`M4A_KIMI_SERVER_TOKEN`), in memory only.
377    pub kimi_bearer: Option<String>,
378    /// When set, local Kimi uses the ACP host (`M4A_KIMI_ACP` gate); value is the binary (`M4A_KIMI_BIN` or `"kimi"`).
379    pub kimi_acp_bin: Option<String>,
380    /// Web routine URL of the bot (`M4A_ROUTINE_URL`), in memory only.
381    pub routine_url: Option<String>,
382    /// Web routine key (`M4A_ROUTINE_BEARER`), in memory only.
383    pub routine_bearer: Option<String>,
384    /// Claude routine `/fire` URL (`M4A_CLAUDE_ROUTINE_FIRE_URL`), in memory only.
385    pub claude_fire_url: Option<String>,
386    /// Claude routine token (`M4A_CLAUDE_ROUTINE_TOKEN`), in memory only.
387    pub claude_fire_bearer: Option<String>,
388    /// Codex cloud environment id for `codex cloud exec --env`.
389    pub codex_cloud_env: Option<String>,
390    /// Override of the provider binary for resume spawns (tests).
391    pub spawn_program: Option<PathBuf>,
392}
393
394/// Claude routine fire URL env.
395pub const CLAUDE_FIRE_URL_ENV: &str = "M4A_CLAUDE_ROUTINE_FIRE_URL";
396/// Claude routine token env.
397pub const CLAUDE_FIRE_TOKEN_ENV: &str = "M4A_CLAUDE_ROUTINE_TOKEN";
398
399impl AdapterConfig {
400    /// Endpoints from the environment for one session. `inbox_dir` is the
401    /// session's inbox (see [`registry::inbox_dir`]) unless
402    /// [`INBOX_DIR_ENV`] overrides it. A Kimi server is discovered from
403    /// `~/.kimi-code` when its env is unset. Routine URL/bearer are not
404    /// read here: the web client passes its own.
405    pub fn from_env(inbox_dir: Option<PathBuf>) -> Self {
406        let get = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty());
407        let mut kimi = KimiServerAdapter::from_env();
408        if !kimi.is_configured() {
409            if let Some(found) = kimi::discover_local_server(None) {
410                kimi = found;
411            }
412        }
413        let (kimi_url, kimi_bearer) = kimi.into_parts();
414        Self {
415            leader_sock: get(crate::LEADER_SOCK_ENV).map(PathBuf::from),
416            inbox_dir: get(INBOX_DIR_ENV).map(PathBuf::from).or(inbox_dir),
417            codex: CodexEndpoint::from_env(),
418            kimi_url,
419            kimi_bearer,
420            kimi_acp_bin: kimi_acp::KimiAcpConfig::from_env().map(|c| c.bin),
421            routine_url: None,
422            routine_bearer: None,
423            claude_fire_url: get(CLAUDE_FIRE_URL_ENV),
424            claude_fire_bearer: get(CLAUDE_FIRE_TOKEN_ENV),
425            codex_cloud_env: get(spawn::CODEX_CLOUD_ENV_ENV),
426            spawn_program: None,
427        }
428    }
429}
430
431impl fmt::Debug for AdapterConfig {
432    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433        // Never print endpoints that may embed keys, or bearers.
434        f.debug_struct("AdapterConfig")
435            .field("leader_sock", &self.leader_sock.is_some())
436            .field("inbox_dir", &self.inbox_dir.is_some())
437            .field("codex", &self.codex.is_some())
438            .field("kimi_url", &self.kimi_url.is_some())
439            .field("routine_url", &self.routine_url.is_some())
440            .field("claude_fire_url", &self.claude_fire_url.is_some())
441            .field("codex_cloud_env", &self.codex_cloud_env.is_some())
442            .finish()
443    }
444}
445
446/// Builds the adapter for `kind` from `config`.
447pub fn adapter_for(kind: SessionKind, config: &AdapterConfig) -> Box<dyn WakeAdapter> {
448    match (kind.surface, kind.provider) {
449        (Surface::Local, ProviderKind::Grok) => Box::new(GrokLeaderAdapter {
450            leader_sock: config.leader_sock.clone(),
451        }),
452        (Surface::Local, ProviderKind::Codex) => {
453            Box::new(CodexAppServerAdapter::new(config.codex.clone()))
454        }
455        (Surface::Local, ProviderKind::KimiCode) => {
456            // ACP host when the host gated it; else the local kimi server. One wake owner.
457            if let Some(bin) = config.kimi_acp_bin.clone() {
458                Box::new(kimi_acp::KimiAcpAdapter::new(Some(kimi_acp::KimiAcpConfig { bin })))
459            } else {
460                Box::new(KimiServerAdapter::new(
461                    config.kimi_url.clone(),
462                    config.kimi_bearer.clone(),
463                ))
464            }
465        },
466        (Surface::Local, ProviderKind::ClaudeCode) => {
467            Box::new(ClaudeChannelAdapter::new(config.inbox_dir.clone()))
468        }
469        (Surface::Local, ProviderKind::Cursor) => Box::new(CursorAgentAdapter {
470            inbox_dir: config.inbox_dir.clone(),
471        }),
472        (Surface::Web, ProviderKind::Cursor) => Box::new(RoutineWebhookAdapter {
473            provider: ProviderKind::Cursor,
474            url: config.routine_url.clone(),
475            bearer: config.routine_bearer.clone(),
476        }),
477        (Surface::Web, ProviderKind::ClaudeCode) => Box::new(ClaudeRoutineFireAdapter {
478            url: config.claude_fire_url.clone(),
479            bearer: config.claude_fire_bearer.clone(),
480        }),
481        (Surface::Web, ProviderKind::Codex) => Box::new(CodexCloudAdapter),
482        // A web session whose host handed a routine URL is woken through it; without one
483        // there is no inbound path.
484        (Surface::Web, provider @ (ProviderKind::KimiCode | ProviderKind::Grok)) => {
485            if config.routine_url.as_deref().is_some_and(|u| !u.is_empty()) {
486                Box::new(RoutineWebhookAdapter {
487                    provider,
488                    url: config.routine_url.clone(),
489                    bearer: config.routine_bearer.clone(),
490                })
491            } else {
492                Box::new(NoInboundAdapter { provider })
493            }
494        }
495    }
496}
497
498// ---------------------------------------------------------------- local --
499
500/// Grok CLI: ACP `session/prompt` on an already-running `leader.sock`.
501
502pub struct GrokLeaderAdapter {
503    /// `M4A_LEADER_SOCK`.
504    pub leader_sock: Option<PathBuf>,
505}
506
507impl WakeAdapter for GrokLeaderAdapter {
508    fn kind(&self) -> SessionKind {
509        SessionKind::local(ProviderKind::Grok)
510    }
511
512    fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
513        match self.leader_sock.as_deref() {
514            Some(path) if path.exists() => Ok(()),
515            Some(_) => Err(WakeError::Unavailable("leader socket missing".into())),
516            None => Err(WakeError::Unavailable("leader socket unset".into())),
517        }
518    }
519
520    fn wake(
521        &mut self,
522        session: &ProviderSession,
523        letter: &WakeLetter<'_>,
524    ) -> Result<WakeOutcome, WakeError> {
525        let _ = (&session, &letter);
526        #[cfg(not(feature = "wake-grok"))]
527        return Err(WakeError::Unavailable("this build was made without feature wake-grok".into()));
528        #[cfg(feature = "wake-grok")]
529        self.probe(session)?;
530        #[cfg(feature = "wake-grok")]
531        let Some(sock) = self.leader_sock.as_deref() else {
532            return Err(WakeError::Unavailable("leader socket unset".into()));
533        };
534        #[cfg(feature = "wake-grok")]
535        let cwd = session_cwd(session)?;
536        #[cfg(feature = "wake-grok")]
537        {
538            grok_leader::wake_decrypted_room_blocking(sock, &session.session_id, &cwd, &wake_prompt(session, letter))
539                .map(|()| WakeOutcome::Delivered)
540                .map_err(|e| WakeError::Transport(e.to_string()))
541        }
542    }
543}
544
545/// Cursor CLI: the session's own `stop` hook (`m4a-inbox drain --format
546/// cursor-stop`) returns `followup_message`, which Cursor auto-submits as
547/// the next user turn. No documented way into an idle interactive chat
548/// another process owns (`agent acp` / `persist` start their own agent),
549/// so this is a hook doorbell; see [`chain::mechanisms`] for the order.
550pub struct CursorAgentAdapter {
551    /// `M4A_INBOX_DIR`.
552    pub inbox_dir: Option<PathBuf>,
553}
554
555impl WakeAdapter for CursorAgentAdapter {
556    fn kind(&self) -> SessionKind {
557        SessionKind::local(ProviderKind::Cursor)
558    }
559
560    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
561        InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
562            .probe(session)
563    }
564
565    fn wake(
566        &mut self,
567        session: &ProviderSession,
568        letter: &WakeLetter<'_>,
569    ) -> Result<WakeOutcome, WakeError> {
570        if !cfg!(feature = "wake-cursor") {
571            return Err(WakeError::Unavailable("this build was made without feature wake-cursor".into()));
572        }
573        InboxHookAdapter::new(self.kind(), HookFlavor::CursorStop, self.inbox_dir.clone())
574            .wake(session, letter)
575    }
576}
577
578// ------------------------------------------------------------------ web --
579
580/// Vendor-hosted web session woken by its own webhook routine (Cursor /
581/// Grok Bot boxes). Same JSON object `m4a-web-client` posts today
582/// (`body`, `from`, `from_nick`, `to`, `event_id`, `room`, `reply`).
583pub struct RoutineWebhookAdapter {
584    /// Vendor of the web session.
585    pub provider: ProviderKind,
586    /// Routine URL. In memory only.
587    pub url: Option<String>,
588    /// Routine key. In memory only.
589    pub bearer: Option<String>,
590}
591
592impl WakeAdapter for RoutineWebhookAdapter {
593    fn kind(&self) -> SessionKind {
594        SessionKind::web(self.provider)
595    }
596
597    fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
598        match self.url.as_deref() {
599            Some(url) if !url.is_empty() => Ok(()),
600            _ => Err(WakeError::Unavailable("routine url unset".into())),
601        }
602    }
603
604    fn wake(
605        &mut self,
606        session: &ProviderSession,
607        letter: &WakeLetter<'_>,
608    ) -> Result<WakeOutcome, WakeError> {
609        if !cfg!(feature = "wake-routine") {
610            return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
611        }
612        self.probe(session)?;
613        let url = self.url.as_deref().unwrap_or_default();
614        let reply = reply_hint_for(&session.nick, letter.from_nick, letter.room);
615        let mut body = serde_json::Map::new();
616        for (k, v) in [
617            ("body", Some(letter.body)),
618            ("from", Some(letter.from_nick)),
619            ("event_id", Some(letter.event_id)),
620            ("room", letter.room),
621            ("from_nick", Some(letter.from_nick)),
622            ("to", Some(session.nick.as_str())),
623            ("reply", Some(reply.as_str())),
624        ] {
625            if let Some(v) = v.map(str::trim).filter(|v| !v.is_empty() || k == "body") {
626                body.insert(k.into(), serde_json::Value::String(v.into()));
627            }
628        }
629        #[cfg(not(feature = "wake-routine"))]
630        {
631            let _ = (url, body);
632            return Err(WakeError::Unavailable("this build was made without feature wake-routine".into()));
633        }
634        #[cfg(feature = "wake-routine")]
635        routine::post_routine_json(url, &serde_json::Value::Object(body), self.bearer.as_deref())
636            .map(|()| WakeOutcome::Delivered)
637            .map_err(|e| WakeError::Transport(e.to_string()))
638    }
639}
640
641/// Claude Code on the web, LAST resort: routine `/fire`
642/// (`POST https://api.anthropic.com/v1/claude_code/routines/{id}/fire`,
643/// per-routine bearer, `anthropic-beta: experimental-cc-routine-2026-04-01`,
644/// body `{"text": ...}`). Every fire starts a NEW cloud session (rate
645/// limited), so it is used only for a session marked headless. An open
646/// cloud session is reached by its own hooks (`claude-async-rewake`,
647/// `claude-stop-hook`) first.
648pub struct ClaudeRoutineFireAdapter {
649    /// Fire URL. In memory only.
650    pub url: Option<String>,
651    /// Routine token. In memory only.
652    pub bearer: Option<String>,
653}
654
655/// `anthropic-beta` value for routine fire (override `M4A_CLAUDE_ROUTINE_BETA`).
656pub const CLAUDE_ROUTINE_BETA: &str = "experimental-cc-routine-2026-04-01";
657
658impl WakeAdapter for ClaudeRoutineFireAdapter {
659    fn kind(&self) -> SessionKind {
660        SessionKind::web(ProviderKind::ClaudeCode)
661    }
662
663    fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
664        if self.url.as_deref().is_none_or(str::is_empty) {
665            return Err(WakeError::Unavailable(
666                "claude routine fire url unset".into(),
667            ));
668        }
669        if self.bearer.as_deref().is_none_or(str::is_empty) {
670            return Err(WakeError::Unavailable("claude routine token unset".into()));
671        }
672        if !session.headless {
673            return Err(WakeError::Unavailable(
674                "session is open; routine fire would start a new session".into(),
675            ));
676        }
677        Ok(())
678    }
679
680    fn wake(
681        &mut self,
682        session: &ProviderSession,
683        letter: &WakeLetter<'_>,
684    ) -> Result<WakeOutcome, WakeError> {
685        if !cfg!(feature = "wake-claude") {
686            return Err(WakeError::Unavailable("this build was made without feature wake-claude".into()));
687        }
688        self.probe(session)?;
689        let url = self.url.as_deref().unwrap_or_default();
690        let token = self.bearer.as_deref().unwrap_or_default();
691        let parsed = reqwest::Url::parse(url)
692            .ok()
693            .filter(|u| u.scheme() == "https" || is_loopback_http(u))
694            .ok_or_else(|| WakeError::Unavailable("claude routine fire url invalid".into()))?;
695        let beta = std::env::var("M4A_CLAUDE_ROUTINE_BETA")
696            .ok()
697            .filter(|v| !v.is_empty())
698            .unwrap_or_else(|| CLAUDE_ROUTINE_BETA.to_string());
699        let client = reqwest::blocking::Client::builder()
700            .timeout(std::time::Duration::from_secs(15))
701            .build()
702            .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
703        let response = client
704            .post(parsed)
705            .bearer_auth(token)
706            .header("anthropic-version", "2023-06-01")
707            .header("anthropic-beta", beta)
708            .json(&serde_json::json!({"text": wake_prompt(session, letter)}))
709            .send()
710            .map_err(|err| WakeError::Transport(err.without_url().to_string()))?;
711        if response.status().is_success() {
712            Ok(WakeOutcome::Delivered)
713        } else {
714            Err(WakeError::Transport(format!(
715                "routine fire status {}",
716                response.status().as_u16()
717            )))
718        }
719    }
720}
721
722fn is_loopback_http(url: &reqwest::Url) -> bool {
723    url.scheme() == "http"
724        && matches!(
725            url.host_str(),
726            Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
727        )
728}
729
730/// Codex cloud (stub, vendor-side fix).
731///
732/// TODO(codex-cloud): `codex cloud exec --env <ENV_ID> <prompt>` creates a
733/// NEW task; no documented follow-up into an existing cloud task.
734pub struct CodexCloudAdapter;
735
736/// Vendor web surface with no documented inbound trigger (Kimi web, Grok web).
737pub struct NoInboundAdapter {
738    /// Vendor.
739    pub provider: ProviderKind,
740}
741
742macro_rules! refuse_adapter {
743    ($ty:ty, $kind:expr, $err:ident) => {
744        impl WakeAdapter for $ty {
745            fn kind(&self) -> SessionKind {
746                $kind(self)
747            }
748
749            fn probe(&self, _session: &ProviderSession) -> Result<(), WakeError> {
750                Err(WakeError::$err(self.kind()))
751            }
752
753            fn wake(
754                &mut self,
755                _session: &ProviderSession,
756                _letter: &WakeLetter<'_>,
757            ) -> Result<WakeOutcome, WakeError> {
758                Err(WakeError::$err(self.kind()))
759            }
760        }
761    };
762}
763
764refuse_adapter!(
765    CodexCloudAdapter,
766    |_: &CodexCloudAdapter| SessionKind::web(ProviderKind::Codex),
767    NotImplemented
768);
769refuse_adapter!(
770    NoInboundAdapter,
771    |adapter: &NoInboundAdapter| SessionKind::web(adapter.provider),
772    NoInboundTrigger
773);
774
775#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
776pub(crate) fn session_cwd(session: &ProviderSession) -> Result<String, WakeError> {
777    session
778        .cwd
779        .as_deref()
780        .map(Path::to_path_buf)
781        .or_else(|| std::env::current_dir().ok())
782        .map(|path| path.display().to_string())
783        .filter(|cwd| !cwd.is_empty())
784        .ok_or_else(|| WakeError::Unavailable("session cwd is empty".into()))
785}
786
787#[cfg(test)]
788pub(crate) mod tests {
789    use super::*;
790
791    pub(crate) fn session(kind: SessionKind) -> ProviderSession {
792        ProviderSession {
793            kind,
794            session_id: "s-1".into(),
795            nick: "alice".into(),
796            cwd: Some(PathBuf::from("/tmp")),
797            headless: false,
798        }
799    }
800
801    pub(crate) fn letter<'a>(body: &'a str) -> WakeLetter<'a> {
802        WakeLetter {
803            body,
804            from_nick: "carol",
805            event_id: "$ev1",
806            room: None,
807        }
808    }
809
810    #[test]
811    fn web_grok_uses_the_routine_only_when_one_is_configured() {
812        let kind = SessionKind::web(ProviderKind::Grok);
813        let none = adapter_for(kind, &AdapterConfig::default());
814        assert!(none.probe(&session(kind)).is_err());
815        let cfg = AdapterConfig { routine_url: Some("http://127.0.0.1:9/hook".into()), ..Default::default() };
816        let routine = adapter_for(kind, &cfg);
817        assert!(routine.probe(&session(kind)).is_ok());
818    }
819
820    #[test]
821    fn ids_round_trip() {
822        for kind in ProviderKind::ALL {
823            assert_eq!(ProviderKind::parse(kind.id()), Some(kind));
824        }
825        assert_eq!(
826            ProviderKind::parse("Claude-Code"),
827            Some(ProviderKind::ClaudeCode)
828        );
829        assert_eq!(ProviderKind::parse("gemini"), None);
830        assert!(ProviderKind::CORE.iter().all(|kind| !kind.is_optional()));
831        assert!(ProviderKind::Cursor.is_optional());
832        assert_eq!(
833            SessionKind::web(ProviderKind::Codex).to_string(),
834            "codex/web"
835        );
836    }
837
838    #[test]
839    fn every_kind_gets_an_adapter_of_that_kind() {
840        let config = AdapterConfig::default();
841        for provider in ProviderKind::ALL {
842            for kind in [SessionKind::local(provider), SessionKind::web(provider)] {
843                assert_eq!(adapter_for(kind, &config).kind(), kind);
844            }
845        }
846    }
847
848    #[test]
849    fn stubs_and_unconfigured_adapters_refuse_without_side_effects() {
850        let config = AdapterConfig::default();
851        let cases = [
852            (SessionKind::local(ProviderKind::Cursor), "unavailable"),
853            (SessionKind::web(ProviderKind::ClaudeCode), "unavailable"),
854            (SessionKind::web(ProviderKind::Codex), "not implemented"),
855            (SessionKind::web(ProviderKind::KimiCode), "no inbound"),
856            (SessionKind::web(ProviderKind::Grok), "no inbound"),
857            (SessionKind::web(ProviderKind::Cursor), "unavailable"),
858            (SessionKind::local(ProviderKind::Grok), "unavailable"),
859            (SessionKind::local(ProviderKind::Codex), "unavailable"),
860            (SessionKind::local(ProviderKind::KimiCode), "unavailable"),
861            (SessionKind::local(ProviderKind::ClaudeCode), "unavailable"),
862        ];
863        for (kind, expect) in cases {
864            let mut adapter = adapter_for(kind, &config);
865            let s = session(kind);
866            let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
867            assert!(err.contains(expect), "{kind}: {err}");
868        }
869    }
870
871    #[test]
872    fn room_letters_are_answered_in_the_room() {
873        assert_eq!(reply_hint_for("alice", "carol", Some("!r1:example.org")), "m4a --as alice send '!r1:example.org' '<your reply>'");
874        assert_eq!(reply_hint_for("alice", "carol", None), reply_hint("alice", "carol"));
875        assert_eq!(reply_hint_for("alice", "carol", Some("bad room")), reply_hint("alice", "carol"));
876    }
877
878    #[test]
879    fn prompt_carries_reply_command_and_body() {
880        let text = wake_prompt(
881            &session(SessionKind::local(ProviderKind::Codex)),
882            &letter("ping"),
883        );
884        assert!(text.contains("reply-command: m4a-send --as alice --to carol"));
885        assert!(text.ends_with("---\nping"));
886        assert!(!text.contains("Answer every") && !text.contains("принято"), "no forced instruction");
887    }
888
889    #[test]
890    fn config_debug_hides_values() {
891        let config = AdapterConfig {
892            kimi_bearer: Some("k-secret".into()),
893            routine_url: Some("https://example.invalid/hook".into()),
894            ..AdapterConfig::default()
895        };
896        let shown = format!("{config:?}");
897        assert!(!shown.contains("secret") && !shown.contains("example"));
898    }
899}
900
901#[cfg(test)]
902mod g4a_alignment_tests {
903    use super::ProviderKind;
904
905    /// hatchery-node maps g4a ids (claude, codex, grok, kimi) to these names before
906    /// `m4a-agent account provision --provider ...`; every one must parse, and g4a's own ids too.
907    #[test]
908    fn names_hatchery_node_sends_all_parse() {
909        for (sent, kind) in [
910            ("claude-code", ProviderKind::ClaudeCode),
911            ("codex", ProviderKind::Codex),
912            ("grok", ProviderKind::Grok),
913            ("kimi-code", ProviderKind::KimiCode),
914            ("claude", ProviderKind::ClaudeCode),
915            ("kimi", ProviderKind::KimiCode),
916        ] {
917            assert_eq!(ProviderKind::parse(sent), Some(kind), "{sent}");
918        }
919        assert_eq!(ProviderKind::parse("custom"), None);
920    }
921}