Skip to main content

supercode_harness/runtime/
adapters.rs

1//! Harness-specific live-runtime adapters built on the primitive contracts.
2
3use std::collections::{BTreeMap, VecDeque};
4use std::net::TcpListener;
5use std::path::Path;
6use std::process::Stdio;
7use std::sync::Arc;
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use async_trait::async_trait;
11use futures::StreamExt;
12use serde_json::{json, Value};
13use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
14use tokio::process::{Child, ChildStdin, Command};
15use tokio::sync::{mpsc, Mutex};
16
17use super::{
18    HarnessEvent, JsonLineClient, RuntimeAttachRequest, RuntimeBackend, RuntimeCapabilities,
19    RuntimeConnection, RuntimeEndpoint, RuntimeHandle, RuntimeInput, RuntimeLaunch,
20    RuntimeStartRequest,
21};
22use crate::{Error, HarnessId, Result};
23
24/// Pi live-runtime backend using `pi --mode rpc` JSONL.
25#[derive(Debug, Clone)]
26pub struct PiRuntimeBackend {
27    launch: RuntimeLaunch,
28}
29
30impl Default for PiRuntimeBackend {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl PiRuntimeBackend {
37    /// Use `pi --mode rpc` from `PATH`.
38    pub fn new() -> Self {
39        Self {
40            launch: RuntimeLaunch {
41                program: "pi".into(),
42                arguments: vec!["--mode".into(), "rpc".into()],
43                env: BTreeMap::new(),
44            },
45        }
46    }
47
48    /// Use an explicit Pi RPC command prefix.
49    pub fn with_launch(launch: RuntimeLaunch) -> Self {
50        Self { launch }
51    }
52
53    async fn open(
54        &self,
55        cwd: &Path,
56        runtime_id: String,
57        launch: Option<RuntimeLaunch>,
58        resume: bool,
59    ) -> Result<Box<dyn RuntimeConnection>> {
60        let mut launch = launch.unwrap_or_else(|| self.launch.clone());
61        if resume {
62            launch
63                .arguments
64                .extend(["--session".into(), runtime_id.clone()]);
65        } else {
66            launch
67                .arguments
68                .extend(["--session-id".into(), runtime_id.clone()]);
69        }
70        let transport = RawLineTransport::spawn(&launch, Some(cwd), "pi-rpc-jsonl").await?;
71        let handle = RuntimeHandle {
72            harness: HarnessId::from(HarnessId::PI),
73            runtime_id,
74            endpoint: transport.endpoint.clone(),
75        };
76        Ok(Box::new(PiRuntimeConnection {
77            handle,
78            transport,
79            next_request: 1,
80        }))
81    }
82}
83
84#[async_trait]
85impl RuntimeBackend for PiRuntimeBackend {
86    fn harness(&self) -> HarnessId {
87        HarnessId::from(HarnessId::PI)
88    }
89
90    fn capabilities(&self) -> RuntimeCapabilities {
91        RuntimeCapabilities {
92            start_session: true,
93            resume_session: true,
94            attach_existing_process: false,
95            send_input: true,
96            stream_events: true,
97            interrupt: true,
98            respond_to_requests: true,
99        }
100    }
101
102    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
103        self.open(&request.cwd, generated_session_id(), request.launch, false)
104            .await
105    }
106
107    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
108        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
109        self.open(&cwd, request.runtime_id, request.launch, true)
110            .await
111    }
112}
113
114struct PiRuntimeConnection {
115    handle: RuntimeHandle,
116    transport: RawLineTransport,
117    next_request: u64,
118}
119
120#[async_trait]
121impl RuntimeConnection for PiRuntimeConnection {
122    fn handle(&self) -> &RuntimeHandle {
123        &self.handle
124    }
125
126    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
127        if !input.image_urls.is_empty() {
128            return Err(Error::Other(
129                "Pi RPC image input is not verified by the installed protocol contract".into(),
130            ));
131        }
132        let id = format!("supercode-{}", self.next_request);
133        self.next_request += 1;
134        self.transport
135            .write(json!({"id": id, "type": "prompt", "message": input.text}))
136            .await?;
137        Ok(Some(id))
138    }
139
140    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
141        raw_next_event(&mut self.transport.receiver).await
142    }
143
144    async fn interrupt(&mut self) -> Result<()> {
145        self.transport.write(json!({"type": "abort"})).await
146    }
147
148    async fn respond(&mut self, request_id: Value, mut response: Value) -> Result<()> {
149        if let Value::Object(object) = &mut response {
150            object.entry("id").or_insert(request_id);
151            self.transport.write(response).await
152        } else {
153            self.transport
154                .write(json!({"id": request_id, "response": response}))
155                .await
156        }
157    }
158
159    async fn close(&mut self) -> Result<()> {
160        self.transport.close().await
161    }
162}
163
164/// Claude Code live-runtime backend using bidirectional stream-json print
165/// mode. It can create/resume sessions and cancel the running turn through the
166/// stream-json control channel; the print-mode protocol still exposes no
167/// permission-response primitive to this adapter.
168#[derive(Debug, Clone)]
169pub struct ClaudeCodeRuntimeBackend {
170    launch: RuntimeLaunch,
171}
172
173impl Default for ClaudeCodeRuntimeBackend {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179impl ClaudeCodeRuntimeBackend {
180    /// Use `claude` from `PATH` in bidirectional stream-json mode.
181    pub fn new() -> Self {
182        Self {
183            launch: RuntimeLaunch {
184                program: "claude".into(),
185                arguments: vec![
186                    "--print".into(),
187                    "--input-format".into(),
188                    "stream-json".into(),
189                    "--output-format".into(),
190                    "stream-json".into(),
191                    "--verbose".into(),
192                ],
193                env: BTreeMap::new(),
194            },
195        }
196    }
197
198    /// Use an explicit Claude Code stream-json command prefix.
199    pub fn with_launch(launch: RuntimeLaunch) -> Self {
200        Self { launch }
201    }
202
203    async fn open(
204        &self,
205        cwd: &Path,
206        runtime_id: String,
207        launch: Option<RuntimeLaunch>,
208        resume: bool,
209    ) -> Result<Box<dyn RuntimeConnection>> {
210        let mut launch = launch.unwrap_or_else(|| self.launch.clone());
211        launch.arguments.extend(if resume {
212            vec!["--resume".into(), runtime_id.clone()]
213        } else {
214            vec!["--session-id".into(), runtime_id.clone()]
215        });
216        let transport = RawLineTransport::spawn(&launch, Some(cwd), "claude-stream-json").await?;
217        Ok(Box::new(ClaudeRuntimeConnection {
218            handle: RuntimeHandle {
219                harness: HarnessId::from(HarnessId::CLAUDE_CODE),
220                runtime_id,
221                endpoint: transport.endpoint.clone(),
222            },
223            transport,
224            buffered_events: VecDeque::new(),
225            next_control_request: 1,
226            control_timeout: CLAUDE_CONTROL_RESPONSE_TIMEOUT,
227        }))
228    }
229}
230
231/// How long `interrupt` waits for the CLI's matching `control_response` before
232/// returning a structured error instead of hanging the caller.
233///
234/// Measured against claude 2.1.224: an interrupt issued while a turn is in
235/// flight is acknowledged in ~1 ms, but one issued during process startup —
236/// before the CLI has emitted `system/init` — is queued behind session-start
237/// hooks and took 1.15 s to acknowledge on a warm box. The bound is set well
238/// above the slow case so a legitimately busy startup is never reported as a
239/// protocol failure.
240const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
241
242#[async_trait]
243impl RuntimeBackend for ClaudeCodeRuntimeBackend {
244    fn harness(&self) -> HarnessId {
245        HarnessId::from(HarnessId::CLAUDE_CODE)
246    }
247
248    fn capabilities(&self) -> RuntimeCapabilities {
249        RuntimeCapabilities {
250            start_session: true,
251            resume_session: true,
252            attach_existing_process: false,
253            send_input: true,
254            stream_events: true,
255            interrupt: true,
256            respond_to_requests: false,
257        }
258    }
259
260    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
261        self.open(&request.cwd, generated_session_id(), request.launch, false)
262            .await
263    }
264
265    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
266        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
267        self.open(&cwd, request.runtime_id, request.launch, true)
268            .await
269    }
270}
271
272struct ClaudeRuntimeConnection {
273    handle: RuntimeHandle,
274    transport: RawLineTransport,
275    /// Native events read off the transport while `interrupt` was waiting for
276    /// its `control_response`. They are handed to `next_event` in arrival order
277    /// so cancelling a turn never costs the consumer an event.
278    buffered_events: VecDeque<Value>,
279    next_control_request: u64,
280    control_timeout: Duration,
281}
282
283impl ClaudeRuntimeConnection {
284    /// The control channel is adapter-private plumbing: a `control_response` is
285    /// the reply to a frame this adapter sent, never a harness event, so it is
286    /// dropped rather than forwarded to event consumers. A late reply that
287    /// arrives after `interrupt` gave up is dropped here too.
288    fn is_control_response(value: &Value) -> bool {
289        value.get("type").and_then(Value::as_str) == Some("control_response")
290    }
291
292    /// Match one `control_response` envelope against an outstanding request id.
293    ///
294    /// Ground truth (claude 2.1.224, verified live): the CLI answers
295    /// `{"type":"control_request","request_id":ID,"request":{"subtype":"interrupt"}}`
296    /// with
297    /// `{"type":"control_response","response":{"subtype":"success","request_id":ID,"response":{"still_queued":[]}}}`,
298    /// or with `{"subtype":"error","request_id":ID,"error":"…"}` on failure.
299    fn control_result(value: &Value, request_id: &str) -> Option<Result<()>> {
300        let response = value.get("response")?;
301        if response.get("request_id").and_then(Value::as_str) != Some(request_id) {
302            return None;
303        }
304        match response.get("subtype").and_then(Value::as_str) {
305            Some("success") => Some(Ok(())),
306            other => Some(Err(Error::Other(format!(
307                "Claude Code rejected the interrupt control request: {}",
308                response
309                    .get("error")
310                    .and_then(Value::as_str)
311                    .map(str::to_string)
312                    .unwrap_or_else(|| format!(
313                        "control_response subtype {}",
314                        other.unwrap_or("(missing)")
315                    ))
316            )))),
317        }
318    }
319}
320
321#[async_trait]
322impl RuntimeConnection for ClaudeRuntimeConnection {
323    fn handle(&self) -> &RuntimeHandle {
324        &self.handle
325    }
326
327    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
328        let content = if input.image_urls.is_empty() {
329            Value::String(input.text)
330        } else {
331            let mut parts = Vec::new();
332            if !input.text.is_empty() {
333                parts.push(json!({"type":"text", "text":input.text}));
334            }
335            for url in input.image_urls {
336                parts.push(claude_image_part(&url)?);
337            }
338            Value::Array(parts)
339        };
340        self.transport
341            .write(json!({
342                "type": "user",
343                "session_id": self.handle.runtime_id,
344                "message": {"role": "user", "content": content},
345            }))
346            .await?;
347        Ok(None)
348    }
349
350    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
351        if let Some(payload) = self.buffered_events.pop_front() {
352            return Ok(Some(harness_event(payload)));
353        }
354        loop {
355            let Some(payload) = self.transport.receiver.recv().await else {
356                return Ok(None);
357            };
358            if Self::is_control_response(&payload) {
359                continue;
360            }
361            return Ok(Some(harness_event(payload)));
362        }
363    }
364
365    /// Cancel the running turn through the stream-json control channel and wait
366    /// for the CLI's acknowledgement.
367    ///
368    /// Interrupting with no turn in flight is safe and succeeds: claude 2.1.224
369    /// acknowledges the request with `subtype: "success"` and an empty
370    /// `still_queued` list rather than erroring, and the session keeps
371    /// accepting input. The adapter reports what the harness reports instead of
372    /// inventing a turn-state gate of its own.
373    async fn interrupt(&mut self) -> Result<()> {
374        let request_id = format!(
375            "supercode-{}-interrupt-{}",
376            self.handle.runtime_id, self.next_control_request
377        );
378        self.next_control_request += 1;
379        self.transport
380            .write(json!({
381                "type": "control_request",
382                "request_id": request_id,
383                "request": {"subtype": "interrupt"},
384            }))
385            .await?;
386
387        let deadline = tokio::time::Instant::now() + self.control_timeout;
388        loop {
389            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
390            if remaining.is_zero() {
391                return Err(claude_interrupt_timeout(self.control_timeout));
392            }
393            match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
394                Err(_) => return Err(claude_interrupt_timeout(self.control_timeout)),
395                Ok(None) => return Err(Error::Other(
396                    "Claude Code stream-json transport closed before acknowledging the interrupt"
397                        .into(),
398                )),
399                Ok(Some(payload)) => {
400                    if Self::is_control_response(&payload) {
401                        if let Some(result) = Self::control_result(&payload, &request_id) {
402                            return result;
403                        }
404                        continue;
405                    }
406                    self.buffered_events.push_back(payload);
407                }
408            }
409        }
410    }
411
412    async fn respond(&mut self, _request_id: Value, _response: Value) -> Result<()> {
413        Err(unsupported(
414            "Claude Code stream-json",
415            "respond to protocol requests",
416        ))
417    }
418
419    async fn close(&mut self) -> Result<()> {
420        self.transport.close().await
421    }
422}
423
424/// Generic ACP v1 client backend for any ACP agent command.
425#[derive(Debug, Clone)]
426pub struct AcpRuntimeBackend {
427    harness: HarnessId,
428    launch: RuntimeLaunch,
429    resume_session: bool,
430}
431
432impl AcpRuntimeBackend {
433    /// Construct an ACP adapter for a named harness/agent command.
434    pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
435        Self {
436            harness,
437            launch,
438            resume_session: false,
439        }
440    }
441
442    /// Declare that this known ACP agent advertises `session/load` or
443    /// `session/resume`. Attach still validates the capability negotiated by
444    /// `initialize`, so a changed or incompatible agent fails honestly.
445    pub fn with_resume_support(mut self, supported: bool) -> Self {
446        self.resume_session = supported;
447        self
448    }
449
450    async fn connect(
451        &self,
452        cwd: &Path,
453        launch: Option<RuntimeLaunch>,
454    ) -> Result<(
455        Arc<JsonLineClient>,
456        mpsc::UnboundedReceiver<Value>,
457        RuntimeEndpoint,
458        Value,
459    )> {
460        let launch = launch.unwrap_or_else(|| self.launch.clone());
461        let (client, receiver, endpoint) =
462            JsonLineClient::spawn(&launch, Some(cwd), true, "acp-v1-jsonrpc").await?;
463        let initialized = client
464            .request(
465                "initialize",
466                json!({
467                    "protocolVersion": 1,
468                    "clientCapabilities": {},
469                    "clientInfo": {
470                        "name": "supercode",
471                        "title": "Supercode",
472                        "version": env!("CARGO_PKG_VERSION"),
473                    },
474                }),
475            )
476            .await?;
477        if initialized.get("protocolVersion").and_then(Value::as_u64) != Some(1) {
478            return Err(Error::Other(format!(
479                "ACP agent negotiated unsupported protocol version: {}",
480                initialized
481                    .get("protocolVersion")
482                    .cloned()
483                    .unwrap_or(Value::Null)
484            )));
485        }
486        Ok((client, receiver, endpoint, initialized))
487    }
488
489    async fn session_request(
490        &self,
491        client: &JsonLineClient,
492        initialized: &Value,
493        method: &str,
494        params: Value,
495    ) -> Result<Value> {
496        match client.request(method, params.clone()).await {
497            Ok(response) => Ok(response),
498            Err(error) if acp_auth_required(&error.to_string()) => {
499                let cached = initialized
500                    .get("authMethods")
501                    .and_then(Value::as_array)
502                    .and_then(|methods| {
503                        methods.iter().find_map(|candidate| {
504                            (candidate.get("id").and_then(Value::as_str) == Some("cached_token"))
505                                .then_some("cached_token")
506                        })
507                    });
508                let Some(method_id) = cached else {
509                    return Err(Error::Other(
510                        "ACP agent requires authentication but did not advertise the non-interactive `cached_token` method"
511                            .into(),
512                    ));
513                };
514                client
515                    .request(
516                        "authenticate",
517                        json!({"methodId": method_id, "_meta": {"headless": true}}),
518                    )
519                    .await?;
520                client.request(method, params).await
521            }
522            Err(error) => Err(error),
523        }
524    }
525
526    async fn connection(
527        &self,
528        cwd: &Path,
529        runtime_id: Option<String>,
530        launch: Option<RuntimeLaunch>,
531    ) -> Result<Box<dyn RuntimeConnection>> {
532        let (client, mut receiver, endpoint, initialized) = self.connect(cwd, launch).await?;
533        let session_id = if let Some(session_id) = runtime_id {
534            let resume = initialized
535                .pointer("/agentCapabilities/sessionCapabilities/resume")
536                .is_some();
537            let load = initialized
538                .pointer("/agentCapabilities/loadSession")
539                .and_then(Value::as_bool)
540                .unwrap_or(false);
541            let method = if resume {
542                "session/resume"
543            } else if load {
544                "session/load"
545            } else {
546                return Err(Error::Other(
547                    "ACP agent did not advertise session resume or load".into(),
548                ));
549            };
550            self.session_request(
551                client.as_ref(),
552                &initialized,
553                method,
554                json!({"sessionId": session_id, "cwd": cwd, "mcpServers": []}),
555            )
556            .await?;
557            session_id
558        } else {
559            self.session_request(
560                client.as_ref(),
561                &initialized,
562                "session/new",
563                json!({"cwd": cwd, "mcpServers": []}),
564            )
565            .await?
566            .get("sessionId")
567            .and_then(Value::as_str)
568            .ok_or_else(|| Error::Other("ACP session/new omitted sessionId".into()))?
569            .to_string()
570        };
571        // `session/load` is allowed to replay the persisted conversation as
572        // `session/update` notifications before returning its response. Those
573        // are bootstrap data, not output from a newly submitted prompt. If
574        // they escape through the live runtime stream, clients fabricate an
575        // assistant delta and a turn that can never complete because no
576        // `session/prompt` request exists. The persisted transcript already
577        // supplies this history, so discard every notification queued by the
578        // completed new/load handshake before exposing the connection.
579        while receiver.try_recv().is_ok() {}
580        Ok(Box::new(AcpRuntimeConnection {
581            handle: RuntimeHandle {
582                harness: self.harness.clone(),
583                runtime_id: session_id,
584                endpoint,
585            },
586            client,
587            receiver,
588            active_prompt: None,
589        }))
590    }
591}
592
593fn acp_auth_required(message: &str) -> bool {
594    let message = message.to_ascii_lowercase();
595    [
596        "auth",
597        "login",
598        "sign in",
599        "sign-in",
600        "unauthorized",
601        "forbidden",
602        "credential",
603    ]
604    .iter()
605    .any(|needle| message.contains(needle))
606}
607
608#[async_trait]
609impl RuntimeBackend for AcpRuntimeBackend {
610    fn harness(&self) -> HarnessId {
611        self.harness.clone()
612    }
613
614    fn capabilities(&self) -> RuntimeCapabilities {
615        RuntimeCapabilities {
616            start_session: true,
617            // Optional in ACP v1. Known agents may declare it here; attach
618            // still checks the actual initialize response before use.
619            resume_session: self.resume_session,
620            attach_existing_process: false,
621            send_input: true,
622            stream_events: true,
623            interrupt: true,
624            respond_to_requests: true,
625        }
626    }
627
628    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
629        self.connection(&request.cwd, None, request.launch).await
630    }
631
632    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
633        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
634        self.connection(&cwd, Some(request.runtime_id), request.launch)
635            .await
636    }
637}
638
639struct AcpRuntimeConnection {
640    handle: RuntimeHandle,
641    client: Arc<JsonLineClient>,
642    receiver: mpsc::UnboundedReceiver<Value>,
643    active_prompt: Option<u64>,
644}
645
646#[async_trait]
647impl RuntimeConnection for AcpRuntimeConnection {
648    fn handle(&self) -> &RuntimeHandle {
649        &self.handle
650    }
651
652    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
653        let mut prompt = Vec::new();
654        if !input.text.is_empty() {
655            prompt.push(json!({"type": "text", "text": input.text}));
656        }
657        for url in input.image_urls {
658            let (mime_type, data) = data_image_parts(&url).ok_or_else(|| {
659                Error::Other("ACP image prompts require base64 image data URLs".into())
660            })?;
661            prompt.push(json!({"type":"image", "mimeType":mime_type, "data":data}));
662        }
663        let (id, response) = self
664            .client
665            .begin_request(
666                "session/prompt",
667                json!({
668                    "sessionId": self.handle.runtime_id,
669                    "prompt": prompt,
670                }),
671            )
672            .await?;
673        self.active_prompt = Some(id);
674        let client = self.client.clone();
675        tokio::spawn(async move {
676            let result = match response.await {
677                Ok(Ok(result)) => json!({"id": id, "result": result}),
678                Ok(Err(error)) => json!({"id": id, "error": error}),
679                Err(_) => json!({"id": id, "error": "response channel closed"}),
680            };
681            client.emit(json!({
682                "jsonrpc": "2.0",
683                "method": "supercode/acp_request_completed",
684                "params": result,
685            }));
686        });
687        Ok(Some(id.to_string()))
688    }
689
690    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
691        let Some(payload) = self.receiver.recv().await else {
692            return Ok(None);
693        };
694        let kind = payload
695            .get("method")
696            .and_then(Value::as_str)
697            .or_else(|| payload.get("type").and_then(Value::as_str))
698            .unwrap_or("protocol")
699            .to_string();
700        if kind == "supercode/acp_request_completed" {
701            self.active_prompt = None;
702        }
703        Ok(Some(HarnessEvent {
704            sequence: None,
705            kind,
706            payload,
707        }))
708    }
709
710    async fn interrupt(&mut self) -> Result<()> {
711        self.client
712            .notify(
713                "session/cancel",
714                json!({"sessionId": self.handle.runtime_id}),
715            )
716            .await
717    }
718
719    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
720        self.client.respond(request_id, response).await
721    }
722
723    async fn close(&mut self) -> Result<()> {
724        self.client.close().await
725    }
726}
727
728/// OpenCode live-runtime backend using its official HTTP API and SSE event
729/// stream. [`OpenCodeRuntimeBackend::connect`] can join the server embedded in
730/// an already-running TUI when that TUI was launched with a known host/port.
731#[derive(Debug, Clone)]
732pub struct OpenCodeRuntimeBackend {
733    launch: RuntimeLaunch,
734    base_url: Option<String>,
735}
736
737impl Default for OpenCodeRuntimeBackend {
738    fn default() -> Self {
739        Self::new()
740    }
741}
742
743impl OpenCodeRuntimeBackend {
744    /// Launch a fresh `opencode serve` process for each connection.
745    pub fn new() -> Self {
746        Self {
747            launch: RuntimeLaunch {
748                program: "opencode".into(),
749                arguments: vec!["serve".into()],
750                env: BTreeMap::new(),
751            },
752            base_url: None,
753        }
754    }
755
756    /// Connect to an existing OpenCode server, including a TUI's server when
757    /// it was launched on a known address.
758    pub fn connect(base_url: impl Into<String>) -> Self {
759        Self {
760            base_url: Some(base_url.into().trim_end_matches('/').to_string()),
761            ..Self::new()
762        }
763    }
764
765    /// Override the command used when launching a new OpenCode server.
766    pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
767        self.launch = launch;
768        self
769    }
770
771    async fn service(&self, launch: Option<RuntimeLaunch>) -> Result<(String, Option<Child>)> {
772        if let Some(base_url) = &self.base_url {
773            wait_for_health(base_url).await?;
774            return Ok((base_url.clone(), None));
775        }
776        let port = TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port();
777        let mut launch = launch.unwrap_or_else(|| self.launch.clone());
778        launch.arguments.extend([
779            "--hostname".into(),
780            "127.0.0.1".into(),
781            "--port".into(),
782            port.to_string(),
783        ]);
784        let mut command = Command::new(&launch.program);
785        command
786            .args(&launch.arguments)
787            .envs(&launch.env)
788            .stdin(Stdio::null())
789            .stdout(Stdio::null())
790            .stderr(Stdio::inherit())
791            .kill_on_drop(true);
792        // OpenCode's launcher may replace itself with or spawn a native
793        // worker. Give the runtime its own group so close can reap the whole
794        // server tree instead of orphaning the worker and its inherited FDs.
795        #[cfg(unix)]
796        command.process_group(0);
797        let mut child = command.spawn().map_err(|error| {
798            Error::Other(format!("could not launch {}: {error}", launch.program))
799        })?;
800        let base_url = format!("http://127.0.0.1:{port}");
801        if let Err(error) = wait_for_health(&base_url).await {
802            // `kill_on_drop` only targets the launcher. Explicitly close its
803            // isolated group so a slow or failed startup cannot orphan the
804            // native OpenCode worker.
805            let _ = terminate_opencode_server(&mut child).await;
806            return Err(error);
807        }
808        Ok((base_url, Some(child)))
809    }
810
811    async fn open(
812        &self,
813        cwd: &Path,
814        runtime_id: Option<String>,
815        launch: Option<RuntimeLaunch>,
816    ) -> Result<Box<dyn RuntimeConnection>> {
817        let (base_url, child) = self.service(launch).await?;
818        let client = reqwest::Client::new();
819        let cwd_string = cwd.to_string_lossy().to_string();
820        let runtime_id = match runtime_id {
821            Some(id) => {
822                http_ok(
823                    client
824                        .get(format!("{base_url}/session/{id}"))
825                        .query(&[("directory", &cwd_string)])
826                        .send()
827                        .await,
828                )
829                .await?;
830                id
831            }
832            None => {
833                let response = http_ok(
834                    client
835                        .post(format!("{base_url}/session"))
836                        .query(&[("directory", &cwd_string)])
837                        .json(&json!({}))
838                        .send()
839                        .await,
840                )
841                .await?;
842                response
843                    .json::<Value>()
844                    .await
845                    .map_err(http_error)?
846                    .get("id")
847                    .and_then(Value::as_str)
848                    .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
849                    .to_string()
850            }
851        };
852        let receiver = spawn_sse(
853            client.clone(),
854            format!("{base_url}/event"),
855            cwd_string.clone(),
856        );
857        Ok(Box::new(OpenCodeRuntimeConnection {
858            handle: RuntimeHandle {
859                harness: HarnessId::from(HarnessId::OPENCODE),
860                runtime_id,
861                endpoint: RuntimeEndpoint::Http {
862                    base_url: base_url.clone(),
863                    protocol: "opencode-http-sse".into(),
864                },
865            },
866            base_url,
867            cwd: cwd_string,
868            client,
869            receiver,
870            child,
871        }))
872    }
873}
874
875#[async_trait]
876impl RuntimeBackend for OpenCodeRuntimeBackend {
877    fn harness(&self) -> HarnessId {
878        HarnessId::from(HarnessId::OPENCODE)
879    }
880
881    fn capabilities(&self) -> RuntimeCapabilities {
882        RuntimeCapabilities {
883            start_session: true,
884            resume_session: true,
885            attach_existing_process: self.base_url.is_some(),
886            send_input: true,
887            stream_events: true,
888            interrupt: true,
889            respond_to_requests: true,
890        }
891    }
892
893    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
894        self.open(&request.cwd, None, request.launch).await
895    }
896
897    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
898        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
899        self.open(&cwd, Some(request.runtime_id), request.launch)
900            .await
901    }
902
903    async fn attach_existing(
904        &self,
905        request: RuntimeAttachRequest,
906    ) -> Result<Box<dyn RuntimeConnection>> {
907        if self.base_url.is_none() {
908            return Err(Error::Other(
909                "OpenCode live attach requires the existing server's `base_url`".into(),
910            ));
911        }
912        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
913        self.open(&cwd, Some(request.runtime_id), request.launch)
914            .await
915    }
916}
917
918struct OpenCodeRuntimeConnection {
919    handle: RuntimeHandle,
920    base_url: String,
921    cwd: String,
922    client: reqwest::Client,
923    receiver: mpsc::UnboundedReceiver<Value>,
924    child: Option<Child>,
925}
926
927#[async_trait]
928impl RuntimeConnection for OpenCodeRuntimeConnection {
929    fn handle(&self) -> &RuntimeHandle {
930        &self.handle
931    }
932
933    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
934        let mut parts = Vec::new();
935        if !input.text.is_empty() {
936            parts.push(json!({"type": "text", "text": input.text}));
937        }
938        for url in input.image_urls {
939            let mime = image_mime_type(&url).ok_or_else(|| {
940                Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
941            })?;
942            parts.push(json!({"type":"file", "mime":mime, "url":url}));
943        }
944        http_ok(
945            self.client
946                .post(format!(
947                    "{}/session/{}/prompt_async",
948                    self.base_url, self.handle.runtime_id
949                ))
950                .query(&[("directory", &self.cwd)])
951                .json(&json!({"parts": parts}))
952                .send()
953                .await,
954        )
955        .await?;
956        Ok(None)
957    }
958
959    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
960        loop {
961            let Some(payload) = self.receiver.recv().await else {
962                return Ok(None);
963            };
964            if opencode_event_session_id(&payload)
965                .is_some_and(|session_id| session_id != self.handle.runtime_id)
966            {
967                continue;
968            }
969            let kind = payload
970                .get("type")
971                .and_then(Value::as_str)
972                .unwrap_or("event")
973                .to_string();
974            return Ok(Some(HarnessEvent {
975                sequence: None,
976                kind,
977                payload,
978            }));
979        }
980    }
981
982    async fn interrupt(&mut self) -> Result<()> {
983        http_ok(
984            self.client
985                .post(format!(
986                    "{}/session/{}/abort",
987                    self.base_url, self.handle.runtime_id
988                ))
989                .query(&[("directory", &self.cwd)])
990                .send()
991                .await,
992        )
993        .await?;
994        Ok(())
995    }
996
997    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
998        let permission = request_id.as_str().ok_or_else(|| {
999            Error::Other("OpenCode permission request id must be a string".into())
1000        })?;
1001        http_ok(
1002            self.client
1003                .post(format!(
1004                    "{}/session/{}/permissions/{permission}",
1005                    self.base_url, self.handle.runtime_id
1006                ))
1007                .query(&[("directory", &self.cwd)])
1008                .json(&response)
1009                .send()
1010                .await,
1011        )
1012        .await?;
1013        Ok(())
1014    }
1015
1016    async fn close(&mut self) -> Result<()> {
1017        if let Some(child) = &mut self.child {
1018            terminate_opencode_server(child).await?;
1019        }
1020        Ok(())
1021    }
1022}
1023
1024fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1025    let rest = url.strip_prefix("data:")?;
1026    let (mime_type, data) = rest.split_once(";base64,")?;
1027    mime_type.starts_with("image/").then_some((mime_type, data))
1028}
1029
1030fn image_mime_type(url: &str) -> Option<&str> {
1031    if let Some((mime_type, _)) = data_image_parts(url) {
1032        return Some(mime_type);
1033    }
1034    let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1035    if path.ends_with(".png") {
1036        Some("image/png")
1037    } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1038        Some("image/jpeg")
1039    } else if path.ends_with(".gif") {
1040        Some("image/gif")
1041    } else if path.ends_with(".webp") {
1042        Some("image/webp")
1043    } else {
1044        None
1045    }
1046}
1047
1048fn claude_image_part(url: &str) -> Result<Value> {
1049    if let Some((media_type, data)) = data_image_parts(url) {
1050        return Ok(json!({
1051            "type":"image",
1052            "source":{"type":"base64", "media_type":media_type, "data":data}
1053        }));
1054    }
1055    if url.starts_with("https://") || url.starts_with("http://") {
1056        return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1057    }
1058    Err(Error::Other(
1059        "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1060    ))
1061}
1062
1063fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1064    let properties = payload.get("properties").unwrap_or(payload);
1065    properties
1066        .get("sessionID")
1067        .and_then(Value::as_str)
1068        .or_else(|| {
1069            properties
1070                .get("part")
1071                .and_then(|part| part.get("sessionID"))
1072                .and_then(Value::as_str)
1073        })
1074        .or_else(|| {
1075            properties
1076                .get("info")
1077                .and_then(|info| info.get("sessionID"))
1078                .and_then(Value::as_str)
1079        })
1080}
1081
1082async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1083    #[cfg(unix)]
1084    let process_group = child.id();
1085    let leader_exited = child.try_wait()?.is_some();
1086    if leader_exited {
1087        #[cfg(unix)]
1088        if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1089            crate::lsp::kill_process_group(pid);
1090            wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1091        }
1092        return Ok(());
1093    }
1094    // `Child::kill().await` waits for process reaping and can block forever
1095    // when a launcher leaves its native worker and inherited handles alive.
1096    // Terminate the isolated group while its leader can still reap workers;
1097    // killing leader and workers simultaneously can leave transient orphan
1098    // zombies and made close observably race process cleanup on Linux.
1099    #[cfg(unix)]
1100    if let Some(pid) = process_group {
1101        unsafe {
1102            libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1103        }
1104        let mut leader_reaped = false;
1105        if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1106            status?;
1107            leader_reaped = true;
1108            if !process_group_exists(pid) {
1109                return Ok(());
1110            }
1111        }
1112        // The leader may exit while a detached worker ignores SIGTERM. Do
1113        // not mistake a reaped launcher for a stopped server tree.
1114        crate::lsp::kill_process_group(pid);
1115        if leader_reaped {
1116            return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1117        }
1118    }
1119    #[cfg(not(unix))]
1120    child.start_kill()?;
1121    tokio::time::timeout(Duration::from_secs(3), child.wait())
1122        .await
1123        .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1124    #[cfg(unix)]
1125    if let Some(pid) = process_group {
1126        wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1127    }
1128    Ok(())
1129}
1130
1131#[cfg(unix)]
1132fn process_group_exists(pid: u32) -> bool {
1133    let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1134    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1135}
1136
1137#[cfg(unix)]
1138async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1139    let deadline = tokio::time::Instant::now() + timeout;
1140    while process_group_exists(pid) {
1141        if tokio::time::Instant::now() >= deadline {
1142            return Err(Error::Other(format!(
1143                "timed out stopping OpenCode process group {pid}"
1144            )));
1145        }
1146        tokio::time::sleep(Duration::from_millis(10)).await;
1147    }
1148    Ok(())
1149}
1150
1151struct RawLineTransport {
1152    stdin: Mutex<ChildStdin>,
1153    child: Mutex<Child>,
1154    receiver: mpsc::UnboundedReceiver<Value>,
1155    endpoint: RuntimeEndpoint,
1156}
1157
1158impl RawLineTransport {
1159    async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1160        let mut command = Command::new(&launch.program);
1161        command
1162            .args(&launch.arguments)
1163            .envs(&launch.env)
1164            .stdin(Stdio::piped())
1165            .stdout(Stdio::piped())
1166            .stderr(Stdio::inherit())
1167            .kill_on_drop(true);
1168        if let Some(cwd) = cwd {
1169            command.current_dir(cwd);
1170        }
1171        let mut child = command.spawn().map_err(|error| {
1172            Error::Other(format!("could not launch {}: {error}", launch.program))
1173        })?;
1174        let pid = child.id();
1175        let stdin = child
1176            .stdin
1177            .take()
1178            .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1179        let stdout = child
1180            .stdout
1181            .take()
1182            .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1183        let (sender, receiver) = mpsc::unbounded_channel();
1184        tokio::spawn(async move {
1185            let mut lines = BufReader::new(stdout).lines();
1186            while let Ok(Some(line)) = lines.next_line().await {
1187                let value = serde_json::from_str(&line)
1188                    .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1189                let _ = sender.send(value);
1190            }
1191        });
1192        Ok(Self {
1193            stdin: Mutex::new(stdin),
1194            child: Mutex::new(child),
1195            receiver,
1196            endpoint: RuntimeEndpoint::LocalProcess {
1197                pid,
1198                command: std::iter::once(launch.program.clone())
1199                    .chain(launch.arguments.iter().cloned())
1200                    .collect(),
1201                protocol: protocol.into(),
1202            },
1203        })
1204    }
1205
1206    async fn write(&self, value: Value) -> Result<()> {
1207        let mut stdin = self.stdin.lock().await;
1208        stdin.write_all(value.to_string().as_bytes()).await?;
1209        stdin.write_all(b"\n").await?;
1210        stdin.flush().await?;
1211        Ok(())
1212    }
1213
1214    async fn close(&self) -> Result<()> {
1215        let mut child = self.child.lock().await;
1216        if child.try_wait()?.is_none() {
1217            child.kill().await?;
1218        }
1219        Ok(())
1220    }
1221}
1222
1223async fn raw_next_event(
1224    receiver: &mut mpsc::UnboundedReceiver<Value>,
1225) -> Result<Option<HarnessEvent>> {
1226    let Some(payload) = receiver.recv().await else {
1227        return Ok(None);
1228    };
1229    Ok(Some(harness_event(payload)))
1230}
1231
1232fn harness_event(payload: Value) -> HarnessEvent {
1233    let kind = payload
1234        .get("type")
1235        .and_then(Value::as_str)
1236        .unwrap_or("event")
1237        .to_string();
1238    HarnessEvent {
1239        sequence: None,
1240        kind,
1241        payload,
1242    }
1243}
1244
1245fn claude_interrupt_timeout(bound: Duration) -> Error {
1246    Error::Other(format!(
1247        "Claude Code did not acknowledge the interrupt control request within {}s",
1248        bound.as_secs_f32()
1249    ))
1250}
1251
1252pub(crate) fn generated_session_id() -> String {
1253    let mut bytes = [0_u8; 16];
1254    if getrandom::getrandom(&mut bytes).is_err() {
1255        let nanos = SystemTime::now()
1256            .duration_since(UNIX_EPOCH)
1257            .unwrap_or_default()
1258            .as_nanos()
1259            .to_le_bytes();
1260        bytes.copy_from_slice(&nanos);
1261    }
1262    bytes[6] = (bytes[6] & 0x0f) | 0x40;
1263    bytes[8] = (bytes[8] & 0x3f) | 0x80;
1264    format!(
1265        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1266        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1267        bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1268    )
1269}
1270
1271fn unsupported(protocol: &str, operation: &str) -> Error {
1272    Error::Other(format!("{protocol} does not support {operation}"))
1273}
1274
1275async fn wait_for_health(base_url: &str) -> Result<()> {
1276    wait_for_health_for(base_url, Duration::from_secs(10)).await
1277}
1278
1279async fn wait_for_health_for(base_url: &str, total_timeout: Duration) -> Result<()> {
1280    let client = reqwest::Client::new();
1281    let url = format!("{base_url}/global/health");
1282    let mut last = None;
1283    let deadline = tokio::time::Instant::now() + total_timeout;
1284    // Local package-manager shims can take longer than five seconds to start
1285    // under build or indexing load. Ten seconds avoids false unavailability
1286    // without permitting an unbounded launch; inventory handshakes retain
1287    // their separate 30-second bound around the complete startup.
1288    loop {
1289        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1290        if remaining.is_zero() {
1291            break;
1292        }
1293        let request_timeout = remaining.min(Duration::from_millis(500));
1294        match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1295            Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1296            Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1297            Ok(Err(error)) => last = Some(error.to_string()),
1298            Err(_) => last = Some("health request timed out".into()),
1299        }
1300        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1301        if !remaining.is_zero() {
1302            tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1303        }
1304    }
1305    Err(Error::Other(format!(
1306        "OpenCode server at {base_url} did not become healthy: {}",
1307        last.unwrap_or_else(|| "no response".into())
1308    )))
1309}
1310
1311async fn http_ok(
1312    response: std::result::Result<reqwest::Response, reqwest::Error>,
1313) -> Result<reqwest::Response> {
1314    response
1315        .map_err(http_error)?
1316        .error_for_status()
1317        .map_err(http_error)
1318}
1319
1320fn http_error(error: reqwest::Error) -> Error {
1321    Error::Other(format!("runtime HTTP request failed: {error}"))
1322}
1323
1324fn spawn_sse(
1325    client: reqwest::Client,
1326    url: String,
1327    directory: String,
1328) -> mpsc::UnboundedReceiver<Value> {
1329    let (sender, receiver) = mpsc::unbounded_channel();
1330    tokio::spawn(async move {
1331        let response = client
1332            .get(url)
1333            .query(&[("directory", directory)])
1334            .send()
1335            .await;
1336        let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1337            let _ = sender.send(
1338                json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1339            );
1340            return;
1341        };
1342        let mut stream = response.bytes_stream();
1343        let mut buffer = String::new();
1344        while let Some(chunk) = stream.next().await {
1345            let Ok(chunk) = chunk else {
1346                break;
1347            };
1348            buffer.push_str(&String::from_utf8_lossy(&chunk));
1349            while let Some(newline) = buffer.find('\n') {
1350                let line = buffer[..newline].trim_end_matches('\r').to_string();
1351                buffer.drain(..=newline);
1352                if let Some(data) = line.strip_prefix("data:") {
1353                    let data = data.trim();
1354                    if let Ok(value) = serde_json::from_str(data) {
1355                        let _ = sender.send(value);
1356                    }
1357                }
1358            }
1359        }
1360    });
1361    receiver
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    /// Fake `claude --print --input-format stream-json` child. It appends every
1369    /// stdin frame to `$1` so a test can assert the exact bytes this adapter
1370    /// wrote, and replies with the control envelope the real CLI replies with.
1371    #[cfg(unix)]
1372    const FAKE_CLAUDE_ACKS: &str = r#"
1373cap="$1"
1374while IFS= read -r line; do
1375  printf '%s\n' "$line" >> "$cap"
1376  case "$line" in
1377    *'"subtype":"interrupt"'*)
1378      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1379      printf '{"type":"system","subtype":"mid_flight"}\n'
1380      printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
1381      printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
1382      ;;
1383    *'"type":"user"'*)
1384      printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
1385      ;;
1386  esac
1387done
1388"#;
1389
1390    /// Same, but the control channel never answers — the hang this adapter must
1391    /// convert into a bounded, structured error.
1392    #[cfg(unix)]
1393    const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
1394cap="$1"
1395while IFS= read -r line; do
1396  printf '%s\n' "$line" >> "$cap"
1397done
1398"#;
1399
1400    /// Rejects the interrupt the way the CLI reports a control failure.
1401    #[cfg(unix)]
1402    const FAKE_CLAUDE_REJECTS: &str = r#"
1403cap="$1"
1404while IFS= read -r line; do
1405  printf '%s\n' "$line" >> "$cap"
1406  case "$line" in
1407    *'"subtype":"interrupt"'*)
1408      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1409      printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
1410      ;;
1411  esac
1412done
1413"#;
1414
1415    #[cfg(unix)]
1416    struct FakeClaude {
1417        connection: ClaudeRuntimeConnection,
1418        capture: std::path::PathBuf,
1419        _dir: std::path::PathBuf,
1420    }
1421
1422    #[cfg(unix)]
1423    impl FakeClaude {
1424        async fn spawn(script: &str, control_timeout: Duration) -> Self {
1425            let dir = std::env::temp_dir().join(format!(
1426                "supercode-fake-claude-{}-{}",
1427                std::process::id(),
1428                generated_session_id()
1429            ));
1430            std::fs::create_dir_all(&dir).unwrap();
1431            let capture = dir.join("stdin.jsonl");
1432            let launch = RuntimeLaunch {
1433                program: "/bin/sh".into(),
1434                arguments: vec![
1435                    "-c".into(),
1436                    script.into(),
1437                    "fake-claude".into(),
1438                    capture.display().to_string(),
1439                ],
1440                env: BTreeMap::new(),
1441            };
1442            let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
1443                .await
1444                .unwrap();
1445            let connection = ClaudeRuntimeConnection {
1446                handle: RuntimeHandle {
1447                    harness: HarnessId::from(HarnessId::CLAUDE_CODE),
1448                    runtime_id: "fake-session".into(),
1449                    endpoint: transport.endpoint.clone(),
1450                },
1451                transport,
1452                buffered_events: VecDeque::new(),
1453                next_control_request: 1,
1454                control_timeout,
1455            };
1456            Self {
1457                connection,
1458                capture,
1459                _dir: dir,
1460            }
1461        }
1462
1463        fn written_frames(&self) -> Vec<Value> {
1464            std::fs::read_to_string(&self.capture)
1465                .unwrap_or_default()
1466                .lines()
1467                .filter(|line| !line.trim().is_empty())
1468                .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
1469                .collect()
1470        }
1471    }
1472
1473    #[cfg(unix)]
1474    #[tokio::test]
1475    async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
1476        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1477
1478        fake.connection.interrupt().await.unwrap();
1479        fake.connection.interrupt().await.unwrap();
1480
1481        let frames = fake.written_frames();
1482        assert_eq!(
1483            frames.len(),
1484            2,
1485            "each interrupt must write exactly one control frame: {frames:?}"
1486        );
1487        let mut ids = Vec::new();
1488        for frame in &frames {
1489            assert_eq!(frame["type"], "control_request");
1490            assert_eq!(frame["request"]["subtype"], "interrupt");
1491            let id = frame["request_id"].as_str().expect("frame carries an id");
1492            assert!(!id.is_empty());
1493            ids.push(id.to_string());
1494        }
1495        assert_ne!(ids[0], ids[1], "request ids must be unique per call");
1496    }
1497
1498    /// The harness acknowledges an interrupt sent with no turn in flight —
1499    /// measured against claude 2.1.224, which replies `success` with an empty
1500    /// `still_queued` list and keeps taking input. The adapter reports that
1501    /// rather than inventing a turn-state gate, and the session stays usable.
1502    #[cfg(unix)]
1503    #[tokio::test]
1504    async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
1505        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1506
1507        fake.connection.interrupt().await.unwrap();
1508        fake.connection
1509            .send_input(RuntimeInput {
1510                text: String::new(),
1511                image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
1512            })
1513            .await
1514            .unwrap();
1515
1516        // Events observed while the interrupt was pending are replayed first,
1517        // and the control channel's own frames never reach the consumer. Read
1518        // through the assistant event before inspecting the fake child's
1519        // capture so the child has necessarily consumed the user frame.
1520        let mut kinds = Vec::new();
1521        while kinds.len() < 2 {
1522            let event = fake.connection.next_event().await.unwrap().unwrap();
1523            assert_ne!(event.kind, "control_response");
1524            kinds.push(event.kind);
1525        }
1526        assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
1527
1528        let frames = fake.written_frames();
1529        assert_eq!(frames[0]["type"], "control_request");
1530        assert_eq!(
1531            frames[1]["type"], "user",
1532            "a send issued after an interrupt must reach the harness, in order"
1533        );
1534        assert_eq!(
1535            frames[1]["message"]["content"][0]["source"],
1536            json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
1537            "an image-only turn must remain native without a synthetic text block"
1538        );
1539    }
1540
1541    #[cfg(unix)]
1542    #[tokio::test]
1543    async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
1544        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
1545
1546        let started = std::time::Instant::now();
1547        let error = fake.connection.interrupt().await.unwrap_err();
1548
1549        assert!(
1550            started.elapsed() < Duration::from_secs(5),
1551            "interrupt must return on its own bound, not hang"
1552        );
1553        assert!(
1554            error
1555                .to_string()
1556                .contains("did not acknowledge the interrupt"),
1557            "unexpected error: {error}"
1558        );
1559        assert_eq!(fake.written_frames().len(), 1);
1560    }
1561
1562    #[cfg(unix)]
1563    #[tokio::test]
1564    async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
1565        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
1566
1567        let error = fake.connection.interrupt().await.unwrap_err();
1568
1569        assert!(
1570            error.to_string().contains("no active worker"),
1571            "unexpected error: {error}"
1572        );
1573    }
1574
1575    #[test]
1576    fn claude_code_runtime_advertises_interrupt() {
1577        assert!(ClaudeCodeRuntimeBackend::new().capabilities().interrupt);
1578    }
1579
1580    #[test]
1581    fn capability_reports_distinguish_resume_from_process_attach() {
1582        assert!(
1583            !PiRuntimeBackend::new()
1584                .capabilities()
1585                .attach_existing_process
1586        );
1587        assert!(
1588            !ClaudeCodeRuntimeBackend::new()
1589                .capabilities()
1590                .attach_existing_process
1591        );
1592        assert!(
1593            !OpenCodeRuntimeBackend::new()
1594                .capabilities()
1595                .attach_existing_process
1596        );
1597        assert!(
1598            OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
1599                .capabilities()
1600                .attach_existing_process
1601        );
1602    }
1603
1604    #[test]
1605    fn generated_ids_are_uuid_shaped_and_unique() {
1606        let first = generated_session_id();
1607        let second = generated_session_id();
1608        assert_eq!(first.len(), 36);
1609        assert_ne!(first, second);
1610    }
1611
1612    #[test]
1613    fn opencode_event_session_id_covers_current_event_shapes() {
1614        assert_eq!(
1615            opencode_event_session_id(&json!({
1616                "type": "session.status",
1617                "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
1618            })),
1619            Some("session-direct")
1620        );
1621        assert_eq!(
1622            opencode_event_session_id(&json!({
1623                "type": "message.part.updated",
1624                "properties": {"part": {"sessionID": "session-part", "type": "text"}}
1625            })),
1626            Some("session-part")
1627        );
1628        assert_eq!(
1629            opencode_event_session_id(&json!({
1630                "type": "message.updated",
1631                "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
1632            })),
1633            Some("session-info")
1634        );
1635        assert_eq!(
1636            opencode_event_session_id(&json!({"type": "server.connected"})),
1637            None
1638        );
1639    }
1640
1641    #[tokio::test]
1642    async fn opencode_runtime_skips_events_for_other_sessions() {
1643        let (sender, receiver) = mpsc::unbounded_channel();
1644        sender
1645            .send(json!({
1646                "type": "session.idle",
1647                "properties": {"sessionID": "foreign-session"}
1648            }))
1649            .unwrap();
1650        sender
1651            .send(json!({
1652                "type": "message.part.delta",
1653                "properties": {"sessionID": "local-session", "delta": "hello"}
1654            }))
1655            .unwrap();
1656        let mut connection = OpenCodeRuntimeConnection {
1657            handle: RuntimeHandle {
1658                harness: HarnessId::from(HarnessId::OPENCODE),
1659                runtime_id: "local-session".into(),
1660                endpoint: RuntimeEndpoint::Http {
1661                    base_url: "http://127.0.0.1:1".into(),
1662                    protocol: "opencode-http".into(),
1663                },
1664            },
1665            base_url: "http://127.0.0.1:1".into(),
1666            cwd: "/tmp".into(),
1667            client: reqwest::Client::new(),
1668            receiver,
1669            child: None,
1670        };
1671
1672        let event = connection.next_event().await.unwrap().unwrap();
1673
1674        assert_eq!(event.kind, "message.part.delta");
1675        assert_eq!(event.payload["properties"]["sessionID"], "local-session");
1676    }
1677
1678    #[cfg(unix)]
1679    #[tokio::test]
1680    async fn opencode_shutdown_reaps_a_launcher_process_group() {
1681        let mut command = Command::new("/bin/sh");
1682        command
1683            .args(["-c", "sleep 30 & wait"])
1684            .stdin(Stdio::null())
1685            .stdout(Stdio::null())
1686            .stderr(Stdio::null())
1687            .kill_on_drop(true)
1688            .process_group(0);
1689        let mut child = command.spawn().unwrap();
1690        let pid = child.id().unwrap();
1691
1692        terminate_opencode_server(&mut child).await.unwrap();
1693
1694        assert!(child.try_wait().unwrap().is_some());
1695        let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
1696        assert!(
1697            !group_still_exists,
1698            "OpenCode worker process group survived close"
1699        );
1700    }
1701
1702    #[cfg(unix)]
1703    #[tokio::test]
1704    async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
1705        let mut command = Command::new("/bin/sh");
1706        command
1707            .args(["-c", "sleep 30 & exit 0"])
1708            .stdin(Stdio::null())
1709            .stdout(Stdio::null())
1710            .stderr(Stdio::null())
1711            .kill_on_drop(true)
1712            .process_group(0);
1713        let mut child = command.spawn().unwrap();
1714        let pid = child.id().unwrap();
1715        tokio::time::sleep(Duration::from_millis(200)).await;
1716
1717        terminate_opencode_server(&mut child).await.unwrap();
1718
1719        assert!(child.try_wait().unwrap().is_some());
1720        assert!(
1721            !process_group_exists(pid),
1722            "OpenCode worker process group survived its exited launcher"
1723        );
1724    }
1725
1726    #[tokio::test]
1727    async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
1728        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1729        let address = listener.local_addr().unwrap();
1730        let server = tokio::spawn(async move {
1731            let (_socket, _) = listener.accept().await.unwrap();
1732            tokio::time::sleep(Duration::from_secs(30)).await;
1733        });
1734        let started = tokio::time::Instant::now();
1735
1736        let error = wait_for_health_for(&format!("http://{address}"), Duration::from_millis(200))
1737            .await
1738            .unwrap_err();
1739
1740        assert!(error.to_string().contains("health request timed out"));
1741        assert!(started.elapsed() < Duration::from_secs(1));
1742        server.abort();
1743    }
1744
1745    #[cfg(unix)]
1746    #[tokio::test]
1747    async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
1748        let script = r#"
1749            i=0
1750            while IFS= read -r line; do
1751              i=$((i + 1))
1752              case "$i" in
1753                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1754                2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
1755                3)
1756                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
1757                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
1758                  ;;
1759              esac
1760            done
1761        "#;
1762        let backend = AcpRuntimeBackend::new(
1763            HarnessId::from("mock-acp"),
1764            RuntimeLaunch {
1765                program: "/bin/sh".into(),
1766                arguments: vec!["-c".into(), script.into()],
1767                env: BTreeMap::new(),
1768            },
1769        );
1770        let mut connection = backend
1771            .start(RuntimeStartRequest {
1772                cwd: std::env::current_dir().unwrap(),
1773                launch: None,
1774            })
1775            .await
1776            .unwrap();
1777        assert_eq!(connection.handle().runtime_id, "acp_mock");
1778        assert_eq!(
1779            connection
1780                .send_input(RuntimeInput {
1781                    text: "hi".into(),
1782                    image_urls: Vec::new(),
1783                })
1784                .await
1785                .unwrap()
1786                .as_deref(),
1787            Some("3")
1788        );
1789        assert_eq!(
1790            connection.next_event().await.unwrap().unwrap().kind,
1791            "session/update"
1792        );
1793        assert_eq!(
1794            connection.next_event().await.unwrap().unwrap().kind,
1795            "supercode/acp_request_completed"
1796        );
1797        connection.close().await.unwrap();
1798    }
1799
1800    #[cfg(unix)]
1801    #[tokio::test]
1802    async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
1803        let script = r#"
1804            i=0
1805            while IFS= read -r line; do
1806              i=$((i + 1))
1807              if [ "$i" -eq 1 ]; then
1808                printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
1809              elif printf '%s' "$line" | grep -q 'session/new'; then
1810                printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
1811              else
1812                exit 9
1813              fi
1814            done
1815        "#;
1816        let backend = AcpRuntimeBackend::new(
1817            HarnessId::from("mock-acp"),
1818            RuntimeLaunch {
1819                program: "/bin/sh".into(),
1820                arguments: vec!["-c".into(), script.into()],
1821                env: BTreeMap::new(),
1822            },
1823        );
1824        let mut connection = backend
1825            .start(RuntimeStartRequest {
1826                cwd: std::env::current_dir().unwrap(),
1827                launch: None,
1828            })
1829            .await
1830            .unwrap();
1831        assert_eq!(connection.handle().runtime_id, "existing_login");
1832        connection.close().await.unwrap();
1833    }
1834
1835    #[cfg(unix)]
1836    #[tokio::test]
1837    async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
1838        let script = r#"
1839            i=0
1840            while IFS= read -r line; do
1841              i=$((i + 1))
1842              case "$i" in
1843                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
1844                2)
1845                  case "$line" in
1846                    *'"method":"session/load"'*'"sessionId":"existing-session"'*)
1847                      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
1848                      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
1849                      ;;
1850                    *) exit 42 ;;
1851                  esac
1852                  ;;
1853                3)
1854                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
1855                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
1856                  ;;
1857              esac
1858            done
1859        "#;
1860        let backend = AcpRuntimeBackend::new(
1861            HarnessId::from("known-acp"),
1862            RuntimeLaunch {
1863                program: "/bin/sh".into(),
1864                arguments: vec!["-c".into(), script.into()],
1865                env: BTreeMap::new(),
1866            },
1867        )
1868        .with_resume_support(true);
1869        assert!(backend.capabilities().resume_session);
1870        let mut connection = backend
1871            .attach(RuntimeAttachRequest {
1872                runtime_id: "existing-session".into(),
1873                cwd: Some(std::env::current_dir().unwrap()),
1874                launch: None,
1875            })
1876            .await
1877            .unwrap();
1878        assert_eq!(connection.handle().runtime_id, "existing-session");
1879        assert_eq!(
1880            connection
1881                .send_input(RuntimeInput {
1882                    text: "continue".into(),
1883                    image_urls: Vec::new(),
1884                })
1885                .await
1886                .unwrap()
1887                .as_deref(),
1888            Some("3")
1889        );
1890        let event = connection.next_event().await.unwrap().unwrap();
1891        assert_eq!(event.kind, "session/update");
1892        assert_eq!(
1893            event
1894                .payload
1895                .pointer("/params/update/content/text")
1896                .and_then(Value::as_str),
1897            Some("fresh output")
1898        );
1899        assert_eq!(
1900            connection.next_event().await.unwrap().unwrap().kind,
1901            "supercode/acp_request_completed"
1902        );
1903        connection.close().await.unwrap();
1904    }
1905}