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