use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use super::{
wake_prompt, ProviderKind, ProviderSession, SessionKind, Surface, WakeAdapter, WakeError,
WakeLetter, WakeOutcome,
};
pub const CODEX_CLOUD_ENV_ENV: &str = "M4A_CODEX_CLOUD_ENV";
pub fn bin_env(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Grok => "M4A_GROK_BIN",
ProviderKind::KimiCode => "M4A_KIMI_BIN",
ProviderKind::ClaudeCode => "M4A_CLAUDE_BIN",
ProviderKind::Codex => "M4A_CODEX_BIN",
ProviderKind::Cursor => "M4A_CURSOR_AGENT_BIN",
}
}
pub fn default_program(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Grok => "grok",
ProviderKind::KimiCode => "kimi",
ProviderKind::ClaudeCode => "claude",
ProviderKind::Codex => "codex",
ProviderKind::Cursor => "agent",
}
}
pub fn resume_command(
kind: SessionKind,
session_id: &str,
prompt: &str,
cloud_env: Option<&str>,
) -> Result<(Vec<String>, Option<String>), WakeError> {
let id = session_id.to_string();
let p = prompt.to_string();
Ok(match (kind.surface, kind.provider) {
(Surface::Local, ProviderKind::Grok) => (vec!["--resume".into(), id, "-p".into(), p], None),
(Surface::Local, ProviderKind::Codex) => (
vec!["exec".into(), "resume".into(), id, "-".into()],
Some(p),
),
(Surface::Local, ProviderKind::KimiCode) => (vec!["-S".into(), id, "-p".into(), p], None),
(Surface::Local, ProviderKind::ClaudeCode) => {
(vec!["--resume".into(), id, "-p".into()], Some(p))
}
(Surface::Local, ProviderKind::Cursor) => {
(vec!["--resume".into(), id, "-p".into(), p], None)
}
(Surface::Web, ProviderKind::Codex) => {
let Some(env) = cloud_env.filter(|env| !env.is_empty()) else {
return Err(WakeError::Unavailable(format!(
"{CODEX_CLOUD_ENV_ENV} unset"
)));
};
(
vec![
"cloud".into(),
"exec".into(),
"--env".into(),
env.to_string(),
p,
],
None,
)
}
_ => return Err(WakeError::NoInboundTrigger(kind)),
})
}
pub struct ResumeSpawnAdapter {
kind: SessionKind,
program: Option<PathBuf>,
cloud_env: Option<String>,
}
impl ResumeSpawnAdapter {
pub fn new(kind: SessionKind, program: Option<PathBuf>, cloud_env: Option<String>) -> Self {
Self {
kind,
program,
cloud_env,
}
}
pub fn from_env(kind: SessionKind) -> Self {
let program = std::env::var_os(bin_env(kind.provider))
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let cloud_env = std::env::var(CODEX_CLOUD_ENV_ENV)
.ok()
.filter(|v| !v.is_empty());
Self::new(kind, program, cloud_env)
}
fn program(&self) -> PathBuf {
self.program
.clone()
.unwrap_or_else(|| PathBuf::from(default_program(self.kind.provider)))
}
}
impl WakeAdapter for ResumeSpawnAdapter {
fn kind(&self) -> SessionKind {
self.kind
}
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
if !session.headless {
return Err(WakeError::Unavailable(
"session has a live client; resume spawn is headless-only".into(),
));
}
resume_command(
self.kind,
&session.session_id,
"",
self.cloud_env.as_deref(),
)
.map(|_| ())
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
self.probe(session)?;
let prompt = wake_prompt(session, letter);
let (args, stdin) = resume_command(
self.kind,
&session.session_id,
&prompt,
self.cloud_env.as_deref(),
)?;
let mut command = Command::new(self.program());
command
.args(&args)
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Some(cwd) = session.cwd.as_deref().filter(|cwd| cwd.is_dir()) {
command.current_dir(cwd);
}
let mut child = command
.spawn()
.map_err(|err| WakeError::Unavailable(format!("spawn: {}", err.kind())))?;
if let (Some(text), Some(mut pipe)) = (stdin, child.stdin.take()) {
pipe.write_all(text.as_bytes())
.map_err(|err| WakeError::Transport(format!("spawn stdin: {}", err.kind())))?;
}
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(WakeOutcome::Delivered)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::tests::{letter, session};
#[test]
fn commands_keep_prompt_off_argv_where_the_cli_reads_stdin() {
let (args, stdin) =
resume_command(SessionKind::local(ProviderKind::Codex), "t1", "hi", None).unwrap();
assert_eq!(args, ["exec", "resume", "t1", "-"]);
assert_eq!(stdin.as_deref(), Some("hi"));
let (args, stdin) = resume_command(
SessionKind::local(ProviderKind::ClaudeCode),
"c1",
"hi",
None,
)
.unwrap();
assert!(!args.iter().any(|a| a == "hi") && stdin.is_some());
assert!(resume_command(SessionKind::web(ProviderKind::Codex), "x", "hi", None).is_err());
assert!(resume_command(SessionKind::web(ProviderKind::KimiCode), "x", "hi", None).is_err());
}
#[test]
fn never_spawns_into_a_session_with_a_live_client() {
let kind = SessionKind::local(ProviderKind::KimiCode);
let mut adapter = ResumeSpawnAdapter::new(kind, Some("/bin/false".into()), None);
let s = session(kind);
assert!(!s.headless);
let err = adapter.wake(&s, &letter("x")).unwrap_err().to_string();
assert!(err.contains("headless-only"), "{err}");
}
#[cfg(unix)]
#[test]
fn headless_session_gets_one_resume_process() {
let dir = std::env::temp_dir().join(format!("m4a-spawn-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let out = dir.join("argv");
let script = dir.join("fake-codex");
std::fs::write(
&script,
format!(
"#!/bin/sh\necho \"$@\" > {0}.tmp\ncat >> {0}.tmp\nmv {0}.tmp {0}\n",
out.display()
),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let kind = SessionKind::local(ProviderKind::Codex);
let mut adapter = ResumeSpawnAdapter::new(kind, Some(script), None);
let mut s = session(kind);
s.headless = true;
assert_eq!(
adapter.wake(&s, &letter("ping")).unwrap(),
WakeOutcome::Delivered
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !out.exists() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
}
let text = std::fs::read_to_string(&out).unwrap();
assert!(text.starts_with("exec resume s-1 -"));
assert!(text.trim_end().ends_with("ping"));
let _ = std::fs::remove_dir_all(&dir);
}
}