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