Skip to main content

mail4agent_messenger_shell/provider/
chain.rs

1//! Ordered wake chains and host/session detection.
2//!
3//! A [`WakeChain`] tries mechanisms in order until one delivers or queues:
4//! in-session trigger first (a turn in the client that already has the
5//! session open), then hooks inside that session, then a durable inbox
6//! queue, and a new-process resume only for a headless session.
7//! [`plan_chain`] builds the chain from where this client runs
8//! ([`HostEnv`]) and what the session is ([`ProviderSession`]).
9
10use std::fmt;
11use std::path::{Path, PathBuf};
12
13use super::claude_channel::ClaudeChannelAdapter;
14use super::codex::CodexAppServerAdapter;
15use super::hook::{HookFlavor, InboxHookAdapter, InboxQueueAdapter};
16use super::kimi::KimiServerAdapter;
17use super::spawn::ResumeSpawnAdapter;
18use super::{
19    AdapterConfig, ClaudeRoutineFireAdapter, GrokLeaderAdapter, NoInboundAdapter, ProviderKind,
20    ProviderSession, RoutineWebhookAdapter, SessionKind, Surface, WakeAdapter, WakeError,
21    WakeLetter, WakeOutcome,
22};
23
24/// Explicit surface override: `web` or `local`.
25pub const SURFACE_ENV: &str = "M4A_SURFACE";
26/// Explicit web vendor override: `grok-bot`, `claude-web`, `codex-cloud`, `cursor-cloud`.
27pub const WEB_VENDOR_ENV: &str = "M4A_WEB_VENDOR";
28/// Marks the session as headless (no client holds it open): `1`.
29pub const HEADLESS_ENV: &str = "M4A_HEADLESS";
30
31/// Where a mechanism sits in the order.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Tier {
34    /// Starts a turn in the client that has the session open, now.
35    InSession,
36    /// A hook inside the open session picks the letter up.
37    Hook,
38    /// Durable inbox; delivered by the next hook run / channel start.
39    Queue,
40    /// New process resuming the session. Headless sessions only.
41    Spawn,
42}
43
44/// One row of the per-kind table.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Mechanism {
47    /// Stable id (also the chain link id).
48    pub id: &'static str,
49    /// Tier.
50    pub tier: Tier,
51    /// Verified live on a real CLI on 2026-10-06.
52    pub verified: bool,
53}
54
55const fn m(id: &'static str, tier: Tier, verified: bool) -> Mechanism {
56    Mechanism { id, tier, verified }
57}
58
59/// Vendor box this client runs on, when it is not the user's machine.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum WebVendor {
62    /// Grok Bot / Cursor-style agent box with a webhook routine.
63    GrokBot,
64    /// Claude Code on the web (cloud container).
65    ClaudeWeb,
66    /// Codex cloud task container.
67    CodexCloud,
68    /// Cursor cloud agent.
69    CursorCloud,
70}
71
72impl WebVendor {
73    /// Parses [`WEB_VENDOR_ENV`].
74    pub fn parse(value: &str) -> Option<Self> {
75        match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
76            "grok-bot" | "grokbot" => Some(Self::GrokBot),
77            "claude-web" | "claude" => Some(Self::ClaudeWeb),
78            "codex-cloud" | "codex" => Some(Self::CodexCloud),
79            "cursor-cloud" | "cursor" => Some(Self::CursorCloud),
80            _ => None,
81        }
82    }
83}
84
85/// Where this client runs.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct HostEnv {
88    /// Web vendor host or the user's machine.
89    pub surface: Surface,
90    /// Which vendor box, when web.
91    pub vendor: Option<WebVendor>,
92    /// `std::env::consts::OS` (`linux`, `macos`, `windows`).
93    pub os: &'static str,
94}
95
96impl HostEnv {
97    /// Detects from the process environment and the filesystem.
98    pub fn detect() -> Self {
99        Self::detect_from(|key| std::env::var(key).ok(), |path| path.exists())
100    }
101
102    /// Detection with injected lookups (tests).
103    ///
104    /// Order: [`SURFACE_ENV`] / [`WEB_VENDOR_ENV`] overrides, then vendor
105    /// markers: `CLAUDE_CODE_REMOTE=true` (Claude web container),
106    /// `<home>/agent-data` (Grok Bot box). Anything else is local.
107    pub fn detect_from(
108        get: impl Fn(&str) -> Option<String>,
109        exists: impl Fn(&Path) -> bool,
110    ) -> Self {
111        let os = std::env::consts::OS;
112        let set = |key: &str| get(key).filter(|value| !value.trim().is_empty());
113        let vendor = set(WEB_VENDOR_ENV)
114            .and_then(|value| WebVendor::parse(&value))
115            .or_else(|| {
116                set("CLAUDE_CODE_REMOTE")
117                    .filter(|value| value.eq_ignore_ascii_case("true") || value == "1")
118                    .map(|_| WebVendor::ClaudeWeb)
119            })
120            .or_else(|| {
121                let home = get("HOME").filter(|h| !h.is_empty());
122                let marker = home.map(|h| Path::new(&h).join("agent-data")).unwrap_or_else(|| Path::new("agent-data").to_path_buf());
123                exists(&marker).then_some(WebVendor::GrokBot)
124            });
125        let surface = match set(SURFACE_ENV)
126            .as_deref()
127            .map(str::to_ascii_lowercase)
128            .as_deref()
129        {
130            Some("local") => {
131                return Self {
132                    surface: Surface::Local,
133                    vendor: None,
134                    os,
135                }
136            }
137            Some("web") => Surface::Web,
138            _ if vendor.is_some() => Surface::Web,
139            _ => Surface::Local,
140        };
141        Self {
142            surface,
143            vendor,
144            os,
145        }
146    }
147}
148
149/// Provider of the session this process runs inside, from env markers
150/// the CLIs export to their child processes: `M4A_PROVIDER` first, then
151/// `CLAUDECODE` (Claude Code), `CODEX_THREAD_ID` (Codex), `CURSOR_AGENT`
152/// (Cursor CLI). Kimi and Grok export no session marker: set
153/// `M4A_PROVIDER`.
154pub fn detect_provider(get: impl Fn(&str) -> Option<String>) -> Option<ProviderKind> {
155    let set = |key: &str| get(key).filter(|value| !value.trim().is_empty());
156    if let Some(value) = set(super::PROVIDER_ENV) {
157        return ProviderKind::parse(&value);
158    }
159    if set("CLAUDECODE").is_some() || set("CLAUDE_CODE_REMOTE").is_some() {
160        return Some(ProviderKind::ClaudeCode);
161    }
162    if set("CODEX_THREAD_ID").is_some() {
163        return Some(ProviderKind::Codex);
164    }
165    if set("CURSOR_AGENT").is_some() {
166        return Some(ProviderKind::Cursor);
167    }
168    None
169}
170
171/// Vendor session id from env, where the CLI exports one.
172pub fn detect_session_id(
173    provider: ProviderKind,
174    get: impl Fn(&str) -> Option<String>,
175) -> Option<String> {
176    let keys: &[&str] = match provider {
177        ProviderKind::ClaudeCode => &[
178            "CLAUDE_CODE_SESSION_ID",
179            "CLAUDE_SESSION_ID",
180            "CLAUDE_CODE_REMOTE_SESSION_ID",
181        ],
182        ProviderKind::Codex => &["CODEX_THREAD_ID", "CODEX_SESSION_ID"],
183        _ => &[],
184    };
185    keys.iter()
186        .find_map(|key| get(key).filter(|value| !value.trim().is_empty()))
187}
188
189/// The ordered mechanism table for `kind` on `vendor` (web) hosts.
190/// [`plan_chain`] follows exactly this order.
191pub fn mechanisms(kind: SessionKind, vendor: Option<WebVendor>) -> Vec<Mechanism> {
192    use Tier::*;
193    match (kind.surface, kind.provider) {
194        (Surface::Local, ProviderKind::Grok) => vec![
195            m("grok-leader-acp", InSession, true),
196            m("grok-stop-hook", Hook, false),
197            m("inbox-queue", Queue, true),
198            m("grok-resume-spawn", Spawn, false),
199        ],
200        (Surface::Local, ProviderKind::Codex) => vec![
201            m("codex-app-server-turn", InSession, true),
202            m("codex-stop-hook", Hook, false),
203            m("inbox-queue", Queue, true),
204            m("codex-exec-resume-spawn", Spawn, false),
205        ],
206        (Surface::Local, ProviderKind::KimiCode) => vec![
207            m("kimi-server-prompt", InSession, true),
208            m("kimi-stop-hook", Hook, false),
209            m("inbox-queue", Queue, true),
210            m("kimi-resume-spawn", Spawn, false),
211        ],
212        (Surface::Local, ProviderKind::ClaudeCode) => vec![
213            m("claude-channel", InSession, false),
214            m("claude-async-rewake", InSession, false),
215            m("claude-stop-hook", Hook, false),
216            m("inbox-queue", Queue, true),
217            m("claude-resume-spawn", Spawn, false),
218        ],
219        (Surface::Local, ProviderKind::Cursor) => vec![
220            m("cursor-stop-followup", Hook, false),
221            m("inbox-queue", Queue, true),
222            m("cursor-resume-spawn", Spawn, false),
223        ],
224        (Surface::Web, provider) => {
225            let mut rows = Vec::new();
226            if matches!(vendor, Some(WebVendor::GrokBot | WebVendor::CursorCloud))
227                || provider == ProviderKind::Cursor
228            {
229                rows.push(m(
230                    "routine-webhook",
231                    InSession,
232                    vendor == Some(WebVendor::GrokBot),
233                ));
234            }
235            match provider {
236                ProviderKind::ClaudeCode => {
237                    rows.push(m("claude-async-rewake", InSession, false));
238                    rows.push(m("claude-stop-hook", Hook, false));
239                    rows.push(m("inbox-queue", Queue, true));
240                    rows.push(m("claude-routine-fire", Spawn, false));
241                }
242                ProviderKind::Codex => {
243                    rows.push(m("codex-stop-hook", Hook, false));
244                    rows.push(m("inbox-queue", Queue, true));
245                    rows.push(m("codex-cloud-exec", Spawn, false));
246                }
247                ProviderKind::Cursor => {
248                    rows.push(m("cursor-stop-followup", Hook, false));
249                    rows.push(m("inbox-queue", Queue, true));
250                }
251                ProviderKind::KimiCode | ProviderKind::Grok => {
252                    if rows.is_empty() {
253                        rows.push(m("no-inbound", Spawn, true));
254                    }
255                }
256            }
257            rows
258        }
259    }
260}
261
262/// Ordered adapters with ids.
263#[derive(Default)]
264pub struct WakeChain {
265    links: Vec<(&'static str, Box<dyn WakeAdapter>)>,
266}
267
268/// Why every link refused. Ids and errors only (no body, no secret).
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct ChainError(pub Vec<(&'static str, WakeError)>);
271
272impl fmt::Display for ChainError {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        if self.0.is_empty() {
275            return write!(f, "wake chain is empty");
276        }
277        let parts: Vec<String> = self
278            .0
279            .iter()
280            .map(|(id, err)| format!("{id}: {err}"))
281            .collect();
282        write!(f, "{}", parts.join("; "))
283    }
284}
285
286impl std::error::Error for ChainError {}
287
288impl fmt::Debug for WakeChain {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        f.debug_list().entries(self.ids()).finish()
291    }
292}
293
294impl WakeChain {
295    /// Empty chain.
296    pub fn new() -> Self {
297        Self::default()
298    }
299
300    /// Appends a link.
301    pub fn push(&mut self, id: &'static str, adapter: Box<dyn WakeAdapter>) {
302        self.links.push((id, adapter));
303    }
304
305    /// Link ids in order.
306    pub fn ids(&self) -> Vec<&'static str> {
307        self.links.iter().map(|(id, _)| *id).collect()
308    }
309
310    /// Whether the chain has no link.
311    pub fn is_empty(&self) -> bool {
312        self.links.is_empty()
313    }
314
315    /// First link whose probe passes, without waking.
316    pub fn best(&self, session: &ProviderSession) -> Option<&'static str> {
317        self.links
318            .iter()
319            .find(|(_, adapter)| adapter.probe(session).is_ok())
320            .map(|(id, _)| *id)
321    }
322
323    /// Tries each link in order. The first `Delivered` or `Queued` wins.
324    pub fn wake(
325        &mut self,
326        session: &ProviderSession,
327        letter: &WakeLetter<'_>,
328    ) -> Result<(&'static str, WakeOutcome), ChainError> {
329        let mut errors = Vec::new();
330        for (id, adapter) in &mut self.links {
331            if let Err(err) = adapter.probe(session) {
332                errors.push((*id, err));
333                continue;
334            }
335            match adapter.wake(session, letter) {
336                Ok(outcome) => return Ok((*id, outcome)),
337                Err(err) => errors.push((*id, err)),
338            }
339        }
340        Err(ChainError(errors))
341    }
342}
343
344/// Builds the ordered chain for `session` on `host` ([`mechanisms`] order).
345pub fn plan_chain(session: &ProviderSession, host: &HostEnv, config: &AdapterConfig) -> WakeChain {
346    let kind = session.kind;
347    let inbox = config.inbox_dir.clone();
348    let mut chain = WakeChain::new();
349    for row in mechanisms(kind, host.vendor) {
350        let adapter: Box<dyn WakeAdapter> = match row.id {
351            "grok-leader-acp" => Box::new(GrokLeaderAdapter {
352                leader_sock: config.leader_sock.clone(),
353            }),
354            "codex-app-server-turn" => Box::new(CodexAppServerAdapter::new(config.codex.clone())),
355            "kimi-server-prompt" => Box::new(KimiServerAdapter::new(
356                config.kimi_url.clone(),
357                config.kimi_bearer.clone(),
358            )),
359            "claude-channel" => Box::new(ClaudeChannelAdapter::new(inbox.clone())),
360            "routine-webhook" => Box::new(RoutineWebhookAdapter {
361                provider: kind.provider,
362                url: config.routine_url.clone(),
363                bearer: config.routine_bearer.clone(),
364            }),
365            "claude-async-rewake" => hook(kind, HookFlavor::ClaudeRewake, &inbox),
366            "claude-stop-hook" => hook(kind, HookFlavor::ClaudeStop, &inbox),
367            "codex-stop-hook" => hook(kind, HookFlavor::CodexStop, &inbox),
368            "kimi-stop-hook" => hook(kind, HookFlavor::KimiStop, &inbox),
369            "cursor-stop-followup" => hook(kind, HookFlavor::CursorStop, &inbox),
370            "grok-stop-hook" => hook(kind, HookFlavor::GrokStop, &inbox),
371            "inbox-queue" => Box::new(InboxQueueAdapter::new(kind, inbox.clone())),
372            "claude-routine-fire" => Box::new(ClaudeRoutineFireAdapter {
373                url: config.claude_fire_url.clone(),
374                bearer: config.claude_fire_bearer.clone(),
375            }),
376            "no-inbound" => Box::new(NoInboundAdapter {
377                provider: kind.provider,
378            }),
379            id if id.ends_with("-spawn") || id == "codex-cloud-exec" => {
380                let program = config.spawn_program.clone().or_else(|| {
381                    std::env::var_os(super::spawn::bin_env(kind.provider))
382                        .filter(|value| !value.is_empty())
383                        .map(PathBuf::from)
384                });
385                let cloud_env = config.codex_cloud_env.clone().or_else(|| {
386                    std::env::var(super::spawn::CODEX_CLOUD_ENV_ENV)
387                        .ok()
388                        .filter(|value| !value.is_empty())
389                });
390                Box::new(ResumeSpawnAdapter::new(kind, program, cloud_env))
391            }
392            _ => continue,
393        };
394        chain.push(row.id, adapter);
395    }
396    chain
397}
398
399/// Chain for a session the WEB machine client holds, when the session has
400/// no webhook routine: only on a detected web vendor host other than the
401/// Grok Bot box (which wakes through its routine), with the provider read
402/// from the CLI's env markers. `None` keeps today's behaviour.
403pub fn detected_web_chain(
404    session_id: &str,
405    nick: &str,
406    store_root: &Path,
407) -> Option<(ProviderSession, WakeChain)> {
408    let host = HostEnv::detect();
409    if host.surface != Surface::Web || host.vendor == Some(WebVendor::GrokBot) {
410        return None;
411    }
412    let provider = detect_provider(|key| std::env::var(key).ok())?;
413    let session = ProviderSession {
414        kind: SessionKind::web(provider),
415        session_id: session_id.to_string(),
416        nick: nick.to_string(),
417        cwd: std::env::current_dir().ok(),
418        headless: std::env::var(HEADLESS_ENV).is_ok_and(|v| v == "1"),
419    };
420    let config = AdapterConfig::from_env(Some(super::registry::inbox_dir(store_root, session_id)));
421    let chain = plan_chain(&session, &host, &config);
422    Some((session, chain))
423}
424
425fn hook(kind: SessionKind, flavor: HookFlavor, inbox: &Option<PathBuf>) -> Box<dyn WakeAdapter> {
426    Box::new(InboxHookAdapter::new(kind, flavor, inbox.clone()))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    #[cfg(feature = "wake-claude")]
433    use crate::provider::inbox;
434    use crate::provider::tests::{letter, session};
435
436    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
437        move |key| {
438            pairs
439                .iter()
440                .find(|(k, _)| *k == key)
441                .map(|(_, v)| v.to_string())
442        }
443    }
444
445    #[test]
446    fn host_detection_orders_overrides_then_markers() {
447        let none = |_: &Path| false;
448        assert_eq!(HostEnv::detect_from(env(&[]), none).surface, Surface::Local);
449        let claude = HostEnv::detect_from(env(&[("CLAUDE_CODE_REMOTE", "true")]), none);
450        assert_eq!(
451            (claude.surface, claude.vendor),
452            (Surface::Web, Some(WebVendor::ClaudeWeb))
453        );
454        let bot = HostEnv::detect_from(env(&[]), |p: &Path| p.ends_with("agent-data"));
455        assert_eq!(bot.vendor, Some(WebVendor::GrokBot));
456        let forced = HostEnv::detect_from(env(&[(SURFACE_ENV, "local")]), |_: &Path| true);
457        assert_eq!((forced.surface, forced.vendor), (Surface::Local, None));
458        let codex = HostEnv::detect_from(env(&[(WEB_VENDOR_ENV, "codex-cloud")]), none);
459        assert_eq!(codex.vendor, Some(WebVendor::CodexCloud));
460    }
461
462    #[test]
463    fn provider_detection_uses_cli_markers() {
464        assert_eq!(
465            detect_provider(env(&[("CLAUDECODE", "1")])),
466            Some(ProviderKind::ClaudeCode)
467        );
468        assert_eq!(
469            detect_provider(env(&[("CODEX_THREAD_ID", "t")])),
470            Some(ProviderKind::Codex)
471        );
472        assert_eq!(
473            detect_provider(env(&[("CURSOR_AGENT", "1")])),
474            Some(ProviderKind::Cursor)
475        );
476        assert_eq!(
477            detect_provider(env(&[("M4A_PROVIDER", "kimi"), ("CLAUDECODE", "1")])),
478            Some(ProviderKind::KimiCode)
479        );
480        assert_eq!(detect_provider(env(&[])), None);
481        assert_eq!(
482            detect_session_id(ProviderKind::Codex, env(&[("CODEX_THREAD_ID", "t9")])).as_deref(),
483            Some("t9")
484        );
485    }
486
487    #[test]
488    fn every_chain_puts_in_session_first_and_spawn_last() {
489        for provider in ProviderKind::ALL {
490            for (kind, vendor) in [
491                (SessionKind::local(provider), None),
492                (SessionKind::web(provider), Some(WebVendor::GrokBot)),
493                (SessionKind::web(provider), Some(WebVendor::ClaudeWeb)),
494            ] {
495                let rows = mechanisms(kind, vendor);
496                assert!(!rows.is_empty(), "{kind}");
497                let tiers: Vec<Tier> = rows.iter().map(|row| row.tier).collect();
498                let rank = |t: &Tier| match t {
499                    Tier::InSession => 0,
500                    Tier::Hook => 1,
501                    Tier::Queue => 2,
502                    Tier::Spawn => 3,
503                };
504                assert!(
505                    tiers.windows(2).all(|w| rank(&w[0]) <= rank(&w[1])),
506                    "{kind}: {tiers:?}"
507                );
508                let host = HostEnv {
509                    surface: kind.surface,
510                    vendor,
511                    os: "linux",
512                };
513                let chain = plan_chain(&session(kind), &host, &AdapterConfig::default());
514                let ids: Vec<&str> = rows.iter().map(|row| row.id).collect();
515                assert_eq!(chain.ids(), ids, "{kind}");
516            }
517        }
518    }
519
520    #[cfg(feature = "wake-claude")]
521    #[test]
522    fn chain_falls_through_to_durable_queue_without_spawning() {
523        let dir = std::env::temp_dir().join(format!("m4a-chain-{}", std::process::id()));
524        let _ = std::fs::remove_dir_all(&dir);
525        let kind = SessionKind::local(ProviderKind::ClaudeCode);
526        let config = AdapterConfig {
527            inbox_dir: Some(dir.clone()),
528            spawn_program: Some("/bin/false".into()),
529            ..AdapterConfig::default()
530        };
531        let host = HostEnv {
532            surface: Surface::Local,
533            vendor: None,
534            os: "linux",
535        };
536        let s = session(kind);
537        let mut chain = plan_chain(&s, &host, &config);
538        let (id, outcome) = chain.wake(&s, &letter("hi")).unwrap();
539        assert_eq!(id, "inbox-queue");
540        assert!(matches!(outcome, WakeOutcome::Queued(_)));
541        // With a live channel server the primary wins.
542        let _live = inbox::Presence::announce(&dir, inbox::consumer::CLAUDE_CHANNEL).unwrap();
543        assert_eq!(chain.best(&s), Some("claude-channel"));
544        let (id, _) = chain.wake(&s, &letter("hi")).unwrap();
545        assert_eq!(id, "claude-channel");
546        let _ = std::fs::remove_dir_all(&dir);
547    }
548
549    #[test]
550    fn chain_without_any_endpoint_reports_every_link() {
551        let kind = SessionKind::web(ProviderKind::KimiCode);
552        let host = HostEnv {
553            surface: Surface::Web,
554            vendor: None,
555            os: "linux",
556        };
557        let s = session(kind);
558        let mut chain = plan_chain(&s, &host, &AdapterConfig::default());
559        let err = chain.wake(&s, &letter("x")).unwrap_err();
560        assert_eq!(err.0.len(), 1);
561        assert!(err.to_string().contains("no inbound"));
562    }
563
564    /// Live: `M4A_LIVE_CODEX_APP_SERVER` (unix://PATH or ws://127.0.0.1:PORT)
565    /// and `M4A_LIVE_CODEX_THREAD` (a thread an attached client has open).
566    #[test]
567    #[ignore]
568    fn live_codex_chain_turns_in_the_open_thread() {
569        let endpoint = std::env::var("M4A_LIVE_CODEX_APP_SERVER").expect("endpoint");
570        let thread = std::env::var("M4A_LIVE_CODEX_THREAD").expect("thread");
571        let kind = SessionKind::local(ProviderKind::Codex);
572        let mut s = session(kind);
573        s.session_id = thread;
574        let config = AdapterConfig {
575            codex: super::super::CodexEndpoint::parse(&endpoint),
576            spawn_program: Some("/bin/false".into()),
577            ..AdapterConfig::default()
578        };
579        let host = HostEnv {
580            surface: Surface::Local,
581            vendor: None,
582            os: std::env::consts::OS,
583        };
584        let mut chain = plan_chain(&s, &host, &config);
585        let (id, outcome) = chain
586            .wake(&s, &letter("live chain ping, reply with one word"))
587            .unwrap();
588        assert_eq!(
589            (id, outcome),
590            ("codex-app-server-turn", WakeOutcome::Delivered)
591        );
592    }
593
594    /// Live: `M4A_LIVE_KIMI_URL`, `M4A_LIVE_KIMI_TOKEN`, `M4A_LIVE_KIMI_SESSION`
595    /// (a session open in `kimi web`), or discovery from `~/.kimi-code`.
596    #[test]
597    #[ignore]
598    fn live_kimi_chain_prompts_the_open_session() {
599        let kind = SessionKind::local(ProviderKind::KimiCode);
600        let mut s = session(kind);
601        s.session_id = std::env::var("M4A_LIVE_KIMI_SESSION").expect("session");
602        let mut config = AdapterConfig::from_env(None);
603        if let (Ok(url), Ok(token)) = (
604            std::env::var("M4A_LIVE_KIMI_URL"),
605            std::env::var("M4A_LIVE_KIMI_TOKEN"),
606        ) {
607            config.kimi_url = Some(url);
608            config.kimi_bearer = Some(token);
609        }
610        config.spawn_program = Some("/bin/false".into());
611        let host = HostEnv {
612            surface: Surface::Local,
613            vendor: None,
614            os: std::env::consts::OS,
615        };
616        let mut chain = plan_chain(&s, &host, &config);
617        let (id, outcome) = chain.wake(&s, &letter("live chain ping")).unwrap();
618        assert_eq!(
619            (id, outcome),
620            ("kimi-server-prompt", WakeOutcome::Delivered)
621        );
622    }
623}