Skip to main content

supercode_harness/
runtime.rs

1//! Primitive live-runtime contracts and the Codex app-server reference adapter.
2//!
3//! These APIs control harness-native sessions; they do not emulate terminal
4//! keystrokes and do not claim to attach to an arbitrary already-running TUI.
5
6use std::collections::{BTreeMap, HashMap};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, Command};
17use tokio::sync::{mpsc, oneshot, Mutex};
18
19use crate::{Error, HarnessId, Result};
20
21mod adapters;
22mod hosted;
23#[cfg(feature = "adapter-api")]
24mod supercode_http;
25pub(crate) use adapters::generated_session_id;
26pub use adapters::{
27    AcpRuntimeBackend, ClaudeCodeRuntimeBackend, OpenCodeRuntimeBackend, PiRuntimeBackend,
28};
29pub use hosted::{HostedHarnessConnection, HostedHarnessRuntime};
30#[cfg(feature = "adapter-api")]
31pub use supercode_http::SupercodeHttpRuntimeBackend;
32
33/// Mechanical facts an adapter can guarantee.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RuntimeCapabilities {
36    /// Can create a fresh harness-native session.
37    pub start_session: bool,
38    /// Can resume a harness-native persisted session by id.
39    pub resume_session: bool,
40    /// Can join an arbitrary already-running harness process.
41    pub attach_existing_process: bool,
42    /// Can send user input through a structured protocol.
43    pub send_input: bool,
44    /// Can receive structured live events.
45    pub stream_events: bool,
46    /// Can interrupt an in-flight turn.
47    pub interrupt: bool,
48    /// Can answer protocol requests such as approvals or elicitation.
49    pub respond_to_requests: bool,
50}
51
52/// Executable configuration used to launch one adapter endpoint.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct RuntimeLaunch {
55    /// Executable name or path.
56    pub program: String,
57    /// Arguments passed before adapter-generated protocol arguments.
58    pub arguments: Vec<String>,
59    /// Extra environment variables.
60    pub env: BTreeMap<String, String>,
61}
62
63/// Request to create a fresh runtime session.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct RuntimeStartRequest {
66    /// Project working directory.
67    pub cwd: PathBuf,
68    /// Optional executable override, primarily for alternate installs/tests.
69    pub launch: Option<RuntimeLaunch>,
70}
71
72/// Request to resume or attach through a new adapter connection.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct RuntimeAttachRequest {
75    /// Harness-native session/thread id.
76    pub runtime_id: String,
77    /// Optional cwd override accepted by the harness protocol.
78    pub cwd: Option<PathBuf>,
79    /// Optional executable override.
80    pub launch: Option<RuntimeLaunch>,
81}
82
83/// Observable endpoint backing a runtime connection.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(tag = "kind", rename_all = "snake_case")]
86pub enum RuntimeEndpoint {
87    /// Child process owned by this connection.
88    LocalProcess {
89        /// Process id when available.
90        pid: Option<u32>,
91        /// Executable plus arguments.
92        command: Vec<String>,
93        /// Native protocol spoken over stdio.
94        protocol: String,
95    },
96    /// Existing HTTP service.
97    Http {
98        /// Service base URL.
99        base_url: String,
100        /// Native protocol name.
101        protocol: String,
102    },
103}
104
105/// Identity returned after a live session is started or resumed.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct RuntimeHandle {
108    /// Runtime adapter/harness.
109    pub harness: HarnessId,
110    /// Harness-native live session identity.
111    pub runtime_id: String,
112    /// Concrete endpoint used by this connection.
113    pub endpoint: RuntimeEndpoint,
114}
115
116/// User input accepted by a live runtime.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct RuntimeInput {
119    /// Plain text prompt or steering instruction.
120    pub text: String,
121}
122
123/// Protocol-neutral envelope around a native live event.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct HarnessEvent {
126    /// Canonical SDK sequence when the event originated from an SDK runtime.
127    /// Native harness adapters leave this absent and the service sequences
128    /// their transport stream locally.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub sequence: Option<u64>,
131    /// Native method/type name, or `request` for a server-initiated request.
132    pub kind: String,
133    /// Lossless native event/request value.
134    pub payload: Value,
135}
136
137/// One connected harness-native runtime session.
138#[async_trait]
139pub trait RuntimeConnection: Send {
140    /// Identity and endpoint of this connection.
141    fn handle(&self) -> &RuntimeHandle;
142    /// Submit structured user input and return the harness-native turn id when
143    /// one is allocated.
144    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>>;
145    /// Wait for the next native live event.
146    async fn next_event(&mut self) -> Result<Option<HarnessEvent>>;
147    /// Interrupt the current turn, when supported.
148    async fn interrupt(&mut self) -> Result<()>;
149    /// Answer a server-initiated protocol request by its native JSON id.
150    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()>;
151    /// Close the adapter-owned transport/process.
152    async fn close(&mut self) -> Result<()>;
153}
154
155/// Factory for starting, resuming, and (where the native protocol permits it)
156/// joining one harness's already-running runtime endpoint.
157#[async_trait]
158pub trait RuntimeBackend: Send + Sync {
159    /// Harness implemented by this backend.
160    fn harness(&self) -> HarnessId;
161    /// Honest mechanical capability report.
162    fn capabilities(&self) -> RuntimeCapabilities;
163    /// Create a fresh harness-native session.
164    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>>;
165    /// Resume a persisted harness-native session through a new protocol
166    /// connection. This does not imply joining the process that originally
167    /// wrote the session.
168    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>>;
169    /// Join an already-running harness process or server. Most stock harnesses
170    /// cannot do this; adapters must opt in rather than silently treating a
171    /// persisted resume as a live attach.
172    async fn attach_existing(
173        &self,
174        _request: RuntimeAttachRequest,
175    ) -> Result<Box<dyn RuntimeConnection>> {
176        Err(Error::Other(format!(
177            "{} cannot attach to an already-running process",
178            self.harness().as_str()
179        )))
180    }
181}
182
183/// Codex live-runtime backend using the official `codex app-server` JSONL
184/// protocol (`initialize`, `thread/start|resume`, `turn/start|interrupt`).
185#[derive(Debug, Clone)]
186pub struct CodexRuntimeBackend {
187    launch: RuntimeLaunch,
188}
189
190const CODEX_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
191
192/// A stock Codex app-server eagerly indexes everything below `CODEX_HOME`
193/// before answering `initialize`. That turns a runtime open into an unbounded
194/// corpus scan for long-time Codex users. Give each connection a private state
195/// database and project only the one native rollout it needs into that home.
196/// The rollout itself is hard-linked, so Codex continues the original inode
197/// rather than a copy that would need lossy reconciliation later.
198#[derive(Debug)]
199struct CodexRuntimeHome {
200    root: PathBuf,
201    native_home: PathBuf,
202}
203
204impl CodexRuntimeHome {
205    fn prepare(launch: &mut RuntimeLaunch, runtime_id: Option<&str>) -> Result<Self> {
206        let native_home = codex_native_home(launch)?;
207        let root = supercode_runtime_root()
208            .join("codex")
209            .join(generated_session_id());
210        std::fs::create_dir_all(&root).map_err(|error| {
211            Error::Other(format!(
212                "could not create isolated Codex runtime home {}: {error}",
213                root.display()
214            ))
215        })?;
216        set_private_directory(&root)?;
217        let root = std::fs::canonicalize(&root)?;
218
219        for entry in [
220            "auth.json",
221            "config.toml",
222            "hooks.json",
223            "models_cache.json",
224            "installation_id",
225            ".personality_migration",
226            ".sandbox_migration",
227            "cache",
228            "generated_images",
229            "mcp-oauth-locks",
230            "memories",
231            "plugins",
232            "rules",
233            "shell_snapshots",
234            "skills",
235            "thread-writer-locks",
236        ] {
237            link_runtime_resource(&native_home.join(entry), &root.join(entry))?;
238        }
239
240        if let Some(runtime_id) = runtime_id {
241            let source = find_codex_rollout(&native_home.join("sessions"), runtime_id)?
242                .ok_or_else(|| {
243                    Error::Other(format!(
244                        "could not find Codex rollout `{runtime_id}` below {}",
245                        native_home.join("sessions").display()
246                    ))
247                })?;
248            let relative = source.strip_prefix(&native_home).map_err(|_| {
249                Error::Other(format!(
250                    "Codex rollout {} is outside native home {}",
251                    source.display(),
252                    native_home.display()
253                ))
254            })?;
255            let projected = root.join(relative);
256            if let Some(parent) = projected.parent() {
257                std::fs::create_dir_all(parent)?;
258            }
259            std::fs::hard_link(&source, &projected).map_err(|error| {
260                Error::Other(format!(
261                    "could not project Codex rollout {} into isolated runtime home: {error}",
262                    source.display()
263                ))
264            })?;
265        }
266
267        launch
268            .env
269            .insert("CODEX_HOME".into(), root.to_string_lossy().into_owned());
270        Ok(Self { root, native_home })
271    }
272
273    fn started_rollout_path(&self, response: &Value) -> Result<PathBuf> {
274        let path = response
275            .pointer("/thread/path")
276            .and_then(Value::as_str)
277            .map(PathBuf::from)
278            .ok_or_else(|| {
279                Error::Other("Codex thread/start response omitted thread.path".into())
280            })?;
281        let relative = path.strip_prefix(&self.root).map_err(|_| {
282            Error::Other(format!(
283                "Codex created rollout {} outside isolated runtime home {}",
284                path.display(),
285                self.root.display()
286            ))
287        })?;
288        if !relative.starts_with("sessions") {
289            return Err(Error::Other(format!(
290                "Codex created non-session rollout {}",
291                path.display()
292            )));
293        }
294        Ok(path)
295    }
296
297    async fn publish_rollout(&self, path: &Path) -> Result<()> {
298        let relative = path.strip_prefix(&self.root).map_err(|_| {
299            Error::Other(format!(
300                "Codex created rollout {} outside isolated runtime home {}",
301                path.display(),
302                self.root.display()
303            ))
304        })?;
305        let publish_deadline = tokio::time::Instant::now() + Duration::from_secs(2);
306        while !path.is_file() {
307            if tokio::time::Instant::now() >= publish_deadline {
308                return Err(Error::Other(format!(
309                    "Codex did not create promised rollout {} within 2s",
310                    path.display()
311                )));
312            }
313            tokio::time::sleep(Duration::from_millis(10)).await;
314        }
315        let native = self.native_home.join(relative);
316        if let Some(parent) = native.parent() {
317            std::fs::create_dir_all(parent)?;
318        }
319        std::fs::hard_link(path, &native).map_err(|error| {
320            Error::Other(format!(
321                "could not publish Codex rollout {} to native home: {error}",
322                path.display()
323            ))
324        })
325    }
326
327    fn cleanup(&self) -> Result<()> {
328        match std::fs::remove_dir_all(&self.root) {
329            Ok(()) => Ok(()),
330            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
331            Err(error) => Err(Error::Other(format!(
332                "could not clean isolated Codex runtime home {}: {error}",
333                self.root.display()
334            ))),
335        }
336    }
337}
338
339impl Drop for CodexRuntimeHome {
340    fn drop(&mut self) {
341        let _ = self.cleanup();
342    }
343}
344
345fn is_stock_codex_launch(launch: &RuntimeLaunch) -> bool {
346    launch
347        .arguments
348        .iter()
349        .any(|argument| argument == "app-server")
350        && Path::new(&launch.program)
351            .file_name()
352            .and_then(|name| name.to_str())
353            .is_some_and(|name| name == "codex" || name == "codex.exe")
354}
355
356fn codex_native_home(launch: &RuntimeLaunch) -> Result<PathBuf> {
357    launch
358        .env
359        .get("CODEX_HOME")
360        .map(PathBuf::from)
361        .or_else(|| std::env::var_os("CODEX_HOME").map(PathBuf::from))
362        .or_else(|| {
363            std::env::var_os("HOME")
364                .map(PathBuf::from)
365                .map(|home| home.join(".codex"))
366        })
367        .ok_or_else(|| Error::Other("Codex runtime requires CODEX_HOME or HOME".into()))
368}
369
370fn supercode_runtime_root() -> PathBuf {
371    std::env::var_os("SUPERCODE_HOME")
372        .map(PathBuf::from)
373        .or_else(|| {
374            std::env::var_os("HOME")
375                .map(PathBuf::from)
376                .map(|home| home.join(".supercode"))
377        })
378        .unwrap_or_else(|| std::env::temp_dir().join("supercode"))
379        .join("runtime-homes")
380}
381
382fn find_codex_rollout(root: &Path, runtime_id: &str) -> Result<Option<PathBuf>> {
383    let entries = match std::fs::read_dir(root) {
384        Ok(entries) => entries,
385        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
386        Err(error) => return Err(error.into()),
387    };
388    let expected_suffix = format!("-{runtime_id}.jsonl");
389    for entry in entries {
390        let entry = entry?;
391        let kind = entry.file_type()?;
392        if kind.is_dir() {
393            if let Some(path) = find_codex_rollout(&entry.path(), runtime_id)? {
394                return Ok(Some(path));
395            }
396        } else if kind.is_file()
397            && entry
398                .file_name()
399                .to_str()
400                .is_some_and(|name| name.ends_with(&expected_suffix))
401        {
402            return Ok(Some(entry.path()));
403        }
404    }
405    Ok(None)
406}
407
408#[cfg(unix)]
409fn link_runtime_resource(source: &Path, target: &Path) -> Result<()> {
410    use std::os::unix::fs::symlink;
411
412    if source.exists() {
413        symlink(source, target)?;
414    }
415    Ok(())
416}
417
418#[cfg(not(unix))]
419fn link_runtime_resource(source: &Path, target: &Path) -> Result<()> {
420    if source.is_file() {
421        std::fs::copy(source, target)?;
422    }
423    Ok(())
424}
425
426#[cfg(unix)]
427fn set_private_directory(path: &Path) -> Result<()> {
428    use std::os::unix::fs::PermissionsExt;
429
430    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
431    Ok(())
432}
433
434#[cfg(not(unix))]
435fn set_private_directory(_path: &Path) -> Result<()> {
436    Ok(())
437}
438
439impl Default for CodexRuntimeBackend {
440    fn default() -> Self {
441        Self::new()
442    }
443}
444
445impl CodexRuntimeBackend {
446    /// Use `codex app-server` from `PATH`.
447    pub fn new() -> Self {
448        Self {
449            launch: RuntimeLaunch {
450                program: "codex".into(),
451                arguments: vec!["app-server".into()],
452                env: BTreeMap::new(),
453            },
454        }
455    }
456
457    /// Use an explicit command prefix.
458    pub fn with_launch(launch: RuntimeLaunch) -> Self {
459        Self { launch }
460    }
461
462    async fn connect(
463        &self,
464        launch: Option<RuntimeLaunch>,
465        runtime_id: Option<&str>,
466    ) -> Result<(
467        Arc<JsonLineClient>,
468        mpsc::UnboundedReceiver<Value>,
469        RuntimeEndpoint,
470        Option<CodexRuntimeHome>,
471    )> {
472        let mut launch = launch.unwrap_or_else(|| self.launch.clone());
473        let runtime_home = if is_stock_codex_launch(&launch) {
474            Some(CodexRuntimeHome::prepare(&mut launch, runtime_id)?)
475        } else {
476            None
477        };
478        let (client, receiver, endpoint) =
479            JsonLineClient::spawn(&launch, None, false, "codex-app-server-jsonl").await?;
480        tokio::time::timeout(
481            CODEX_STARTUP_TIMEOUT,
482            client.request(
483                "initialize",
484                json!({
485                    "clientInfo": {
486                        "name": "supercode",
487                        "title": "Supercode",
488                        "version": env!("CARGO_PKG_VERSION"),
489                    }
490                }),
491            ),
492        )
493        .await
494        .map_err(|_| Error::Other("Codex app-server initialize timed out after 10s".into()))??;
495        client.notify("initialized", json!({})).await?;
496        Ok((client, receiver, endpoint, runtime_home))
497    }
498
499    async fn open_thread(
500        &self,
501        method: &str,
502        params: Value,
503        launch: Option<RuntimeLaunch>,
504        runtime_id: Option<&str>,
505    ) -> Result<Box<dyn RuntimeConnection>> {
506        let (client, receiver, endpoint, runtime_home) = self.connect(launch, runtime_id).await?;
507        let response = tokio::time::timeout(CODEX_STARTUP_TIMEOUT, client.request(method, params))
508            .await
509            .map_err(|_| Error::Other(format!("Codex {method} timed out after 10s")))??;
510        let thread_id = response
511            .pointer("/thread/id")
512            .and_then(Value::as_str)
513            .ok_or_else(|| Error::Other(format!("Codex {method} response omitted thread.id")))?
514            .to_string();
515        let unpublished_rollout = if method == "thread/start" {
516            runtime_home
517                .as_ref()
518                .map(|home| home.started_rollout_path(&response))
519                .transpose()?
520        } else {
521            None
522        };
523        Ok(Box::new(CodexRuntimeConnection {
524            handle: RuntimeHandle {
525                harness: HarnessId::from(HarnessId::CODEX),
526                runtime_id: thread_id,
527                endpoint,
528            },
529            client,
530            receiver,
531            active_turn: None,
532            runtime_home,
533            unpublished_rollout,
534        }))
535    }
536}
537
538#[async_trait]
539impl RuntimeBackend for CodexRuntimeBackend {
540    fn harness(&self) -> HarnessId {
541        HarnessId::from(HarnessId::CODEX)
542    }
543
544    fn capabilities(&self) -> RuntimeCapabilities {
545        RuntimeCapabilities {
546            start_session: true,
547            resume_session: true,
548            // A new app-server can resume the same stored thread, but stock
549            // Codex does not let it join an arbitrary already-running TUI's
550            // transport/event fanout.
551            attach_existing_process: false,
552            send_input: true,
553            stream_events: true,
554            interrupt: true,
555            respond_to_requests: true,
556        }
557    }
558
559    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
560        self.open_thread(
561            "thread/start",
562            json!({"cwd": request.cwd}),
563            request.launch,
564            None,
565        )
566        .await
567    }
568
569    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
570        let mut params = json!({"threadId": request.runtime_id});
571        if let Some(cwd) = request.cwd {
572            params["cwd"] = json!(cwd);
573        }
574        let runtime_id = request.runtime_id.clone();
575        self.open_thread("thread/resume", params, request.launch, Some(&runtime_id))
576            .await
577    }
578}
579
580struct CodexRuntimeConnection {
581    handle: RuntimeHandle,
582    client: Arc<JsonLineClient>,
583    receiver: mpsc::UnboundedReceiver<Value>,
584    active_turn: Option<String>,
585    runtime_home: Option<CodexRuntimeHome>,
586    unpublished_rollout: Option<PathBuf>,
587}
588
589#[async_trait]
590impl RuntimeConnection for CodexRuntimeConnection {
591    fn handle(&self) -> &RuntimeHandle {
592        &self.handle
593    }
594
595    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
596        let response = self
597            .client
598            .request(
599                "turn/start",
600                json!({
601                    "threadId": self.handle.runtime_id,
602                    "input": [{"type": "text", "text": input.text}],
603                }),
604            )
605            .await?;
606        let turn_id = response
607            .pointer("/turn/id")
608            .and_then(Value::as_str)
609            .map(str::to_owned);
610        if let (Some(home), Some(path)) = (
611            self.runtime_home.as_ref(),
612            self.unpublished_rollout.as_ref(),
613        ) {
614            home.publish_rollout(path).await?;
615            self.unpublished_rollout = None;
616        }
617        self.active_turn = turn_id.clone();
618        Ok(turn_id)
619    }
620
621    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
622        let Some(payload) = self.receiver.recv().await else {
623            return Ok(None);
624        };
625        let kind = payload
626            .get("method")
627            .and_then(Value::as_str)
628            .map(str::to_owned)
629            .unwrap_or_else(|| "protocol".into());
630        if kind == "turn/completed" {
631            self.active_turn = None;
632        }
633        Ok(Some(HarnessEvent {
634            sequence: None,
635            kind,
636            payload,
637        }))
638    }
639
640    async fn interrupt(&mut self) -> Result<()> {
641        let Some(turn_id) = self.active_turn.as_ref() else {
642            return Err(Error::Other("Codex has no active turn to interrupt".into()));
643        };
644        self.client
645            .request(
646                "turn/interrupt",
647                json!({"threadId": self.handle.runtime_id, "turnId": turn_id}),
648            )
649            .await?;
650        Ok(())
651    }
652
653    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
654        self.client.respond(request_id, response).await
655    }
656
657    async fn close(&mut self) -> Result<()> {
658        self.client.close().await?;
659        if let Some(home) = self.runtime_home.take() {
660            home.cleanup()?;
661        }
662        Ok(())
663    }
664}
665
666type PendingResponse = oneshot::Sender<std::result::Result<Value, String>>;
667type PendingResponses = Arc<Mutex<HashMap<u64, PendingResponse>>>;
668
669pub(super) struct JsonLineClient {
670    stdin: Mutex<ChildStdin>,
671    child: Mutex<Child>,
672    pending: PendingResponses,
673    next_id: Mutex<u64>,
674    include_jsonrpc: bool,
675    events: mpsc::UnboundedSender<Value>,
676    process_group: Option<u32>,
677}
678
679impl JsonLineClient {
680    pub(super) async fn spawn(
681        launch: &RuntimeLaunch,
682        cwd: Option<&std::path::Path>,
683        include_jsonrpc: bool,
684        protocol: &str,
685    ) -> Result<(Arc<Self>, mpsc::UnboundedReceiver<Value>, RuntimeEndpoint)> {
686        let mut command = Command::new(&launch.program);
687        command
688            .args(&launch.arguments)
689            .envs(&launch.env)
690            .stdin(Stdio::piped())
691            .stdout(Stdio::piped())
692            .stderr(Stdio::piped())
693            .kill_on_drop(true);
694        // Package-manager shims commonly spawn a native worker. Isolate the
695        // complete adapter tree so close can reap it instead of orphaning the
696        // worker with inherited protocol handles.
697        #[cfg(unix)]
698        command.process_group(0);
699        if let Some(cwd) = cwd {
700            command.current_dir(cwd);
701        }
702        let mut child = command.spawn().map_err(|error| {
703            Error::Other(format!("could not launch {}: {error}", launch.program))
704        })?;
705        let pid = child.id();
706        let stdin = child
707            .stdin
708            .take()
709            .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
710        let stdout = child
711            .stdout
712            .take()
713            .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
714        let stderr = child
715            .stderr
716            .take()
717            .ok_or_else(|| Error::Other("runtime child has no stderr".into()))?;
718        let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
719        let (events_tx, events_rx) = mpsc::unbounded_channel();
720        let reader_events = events_tx.clone();
721        let reader_pending = pending.clone();
722        tokio::spawn(async move {
723            let mut stdout_lines = BufReader::new(stdout).lines();
724            let mut stderr_lines = BufReader::new(stderr).lines();
725            let mut stdout_open = true;
726            let mut stderr_open = true;
727            while stdout_open || stderr_open {
728                tokio::select! {
729                    line = stdout_lines.next_line(), if stdout_open => match line {
730                        Ok(Some(line)) => {
731                            let Ok(value) = serde_json::from_str::<Value>(&line) else {
732                                let _ = reader_events.send(json!({"type": "malformed_output", "line": line}));
733                                continue;
734                            };
735                            let response_id = value.get("id").and_then(Value::as_u64);
736                            let is_response = value.get("result").is_some() || value.get("error").is_some();
737                            if let Some(id) = response_id.filter(|_| is_response) {
738                                if let Some(sender) = reader_pending.lock().await.remove(&id) {
739                                    let result = if let Some(error) = value.get("error") {
740                                        Err(error.to_string())
741                                    } else {
742                                        Ok(value.get("result").cloned().unwrap_or(Value::Null))
743                                    };
744                                    let _ = sender.send(result);
745                                    continue;
746                                }
747                            }
748                            let _ = reader_events.send(value);
749                        }
750                        Ok(None) => stdout_open = false,
751                        Err(error) => {
752                            let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
753                            stdout_open = false;
754                        }
755                    },
756                    line = stderr_lines.next_line(), if stderr_open => match line {
757                        Ok(Some(line)) => {
758                            let _ = reader_events.send(json!({"type": "transport_stderr", "line": line}));
759                        }
760                        Ok(None) => stderr_open = false,
761                        Err(error) => {
762                            let _ = reader_events.send(json!({"type": "transport_error", "message": error.to_string()}));
763                            stderr_open = false;
764                        }
765                    }
766                }
767            }
768            let _ = reader_events.send(json!({"type": "transport_closed"}));
769            let mut pending = reader_pending.lock().await;
770            for (_, sender) in pending.drain() {
771                let _ = sender.send(Err("runtime protocol closed".into()));
772            }
773        });
774        let endpoint = RuntimeEndpoint::LocalProcess {
775            pid,
776            command: std::iter::once(launch.program.clone())
777                .chain(launch.arguments.iter().cloned())
778                .collect(),
779            protocol: protocol.into(),
780        };
781        Ok((
782            Arc::new(Self {
783                stdin: Mutex::new(stdin),
784                child: Mutex::new(child),
785                pending,
786                next_id: Mutex::new(1),
787                include_jsonrpc,
788                events: events_tx,
789                process_group: pid,
790            }),
791            events_rx,
792            endpoint,
793        ))
794    }
795
796    pub(super) async fn request(&self, method: &str, params: Value) -> Result<Value> {
797        let (_id, rx) = self.begin_request(method, params).await?;
798        rx.await
799            .map_err(|_| Error::Other("runtime response channel closed".into()))?
800            .map_err(|message| {
801                Error::Other(format!("runtime request `{method}` failed: {message}"))
802            })
803    }
804
805    pub(super) async fn begin_request(
806        &self,
807        method: &str,
808        params: Value,
809    ) -> Result<(u64, oneshot::Receiver<std::result::Result<Value, String>>)> {
810        let id = {
811            let mut next = self.next_id.lock().await;
812            let id = *next;
813            *next += 1;
814            id
815        };
816        let (tx, rx) = oneshot::channel();
817        self.pending.lock().await.insert(id, tx);
818        let mut request = json!({"id": id, "method": method, "params": params});
819        if self.include_jsonrpc {
820            request["jsonrpc"] = json!("2.0");
821        }
822        if let Err(error) = self.write(&request).await {
823            self.pending.lock().await.remove(&id);
824            return Err(error);
825        }
826        Ok((id, rx))
827    }
828
829    pub(super) async fn notify(&self, method: &str, params: Value) -> Result<()> {
830        let mut notification = json!({"method": method, "params": params});
831        if self.include_jsonrpc {
832            notification["jsonrpc"] = json!("2.0");
833        }
834        self.write(&notification).await
835    }
836
837    pub(super) async fn respond(&self, id: Value, result: Value) -> Result<()> {
838        let mut response = json!({"id": id, "result": result});
839        if self.include_jsonrpc {
840            response["jsonrpc"] = json!("2.0");
841        }
842        self.write(&response).await
843    }
844
845    async fn write(&self, value: &Value) -> Result<()> {
846        let mut stdin = self.stdin.lock().await;
847        stdin.write_all(value.to_string().as_bytes()).await?;
848        stdin.write_all(b"\n").await?;
849        stdin.flush().await?;
850        Ok(())
851    }
852
853    pub(super) fn emit(&self, value: Value) {
854        let _ = self.events.send(value);
855    }
856
857    pub(super) async fn close(&self) -> Result<()> {
858        let mut child = self.child.lock().await;
859        #[cfg(unix)]
860        if let Some(pid) = self.process_group {
861            crate::lsp::kill_process_group(pid);
862            tokio::time::timeout(Duration::from_secs(3), child.wait())
863                .await
864                .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
865            return Ok(());
866        }
867        #[cfg(not(unix))]
868        if child.try_wait()?.is_none() {
869            child.kill().await?;
870        }
871        Ok(())
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    #[test]
880    fn codex_capabilities_do_not_claim_arbitrary_process_attach() {
881        let capabilities = CodexRuntimeBackend::new().capabilities();
882        assert!(capabilities.start_session);
883        assert!(capabilities.resume_session);
884        assert!(!capabilities.attach_existing_process);
885        assert!(capabilities.send_input);
886        assert!(capabilities.stream_events);
887        assert!(capabilities.interrupt);
888    }
889
890    #[test]
891    fn runtime_handle_is_language_neutral_json() {
892        let handle = RuntimeHandle {
893            harness: HarnessId::from(HarnessId::CODEX),
894            runtime_id: "thread-1".into(),
895            endpoint: RuntimeEndpoint::LocalProcess {
896                pid: Some(42),
897                command: vec!["codex".into(), "app-server".into()],
898                protocol: "codex-app-server-jsonl".into(),
899            },
900        };
901        let encoded = serde_json::to_string(&handle).unwrap();
902        assert_eq!(
903            serde_json::from_str::<RuntimeHandle>(&encoded).unwrap(),
904            handle
905        );
906    }
907
908    #[cfg(unix)]
909    #[tokio::test]
910    async fn codex_adapter_performs_handshake_start_and_turn() {
911        let script = r#"
912            i=0
913            while IFS= read -r line; do
914              i=$((i + 1))
915              case "$i" in
916                1) printf '%s\n' '{"id":1,"result":{"userAgent":"mock"}}' ;;
917                2) ;;
918                3) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_mock"}}}' ;;
919                4)
920                  printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn_mock"}}}'
921                  printf '%s\n' '{"method":"turn/started","params":{"turn":{"id":"turn_mock"}}}'
922                  ;;
923              esac
924            done
925        "#;
926        let backend = CodexRuntimeBackend::with_launch(RuntimeLaunch {
927            program: "/bin/sh".into(),
928            arguments: vec!["-c".into(), script.into()],
929            env: BTreeMap::new(),
930        });
931        let mut connection = backend
932            .start(RuntimeStartRequest {
933                cwd: std::env::current_dir().unwrap(),
934                launch: None,
935            })
936            .await
937            .unwrap();
938        assert_eq!(connection.handle().runtime_id, "thr_mock");
939        assert_eq!(
940            connection
941                .send_input(RuntimeInput { text: "hi".into() })
942                .await
943                .unwrap()
944                .as_deref(),
945            Some("turn_mock")
946        );
947        assert_eq!(
948            connection.next_event().await.unwrap().unwrap().kind,
949            "turn/started"
950        );
951        connection.close().await.unwrap();
952    }
953}