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    BearerToken, HarnessEvent, JsonLineClient, McpServerLaunch, RuntimeAttachRequest,
19    RuntimeBackend, RuntimeCapabilities, RuntimeConnection, RuntimeEndpoint, RuntimeHandle,
20    RuntimeInput, RuntimeLaunch, 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 with supercode registered as the CLI's permission handler.
167///
168/// It can create/resume sessions, cancel the running turn through the
169/// stream-json control channel, and answer the permission requests the CLI
170/// raises while a tool call is blocked.
171#[derive(Debug, Clone)]
172pub struct ClaudeCodeRuntimeBackend {
173    launch: RuntimeLaunch,
174    permission_timeout: Duration,
175}
176
177impl Default for ClaudeCodeRuntimeBackend {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183impl ClaudeCodeRuntimeBackend {
184    /// Use `claude` from `PATH` in bidirectional stream-json mode, with
185    /// supercode registered as the permission handler.
186    pub fn new() -> Self {
187        Self {
188            launch: RuntimeLaunch {
189                program: "claude".into(),
190                arguments: vec![
191                    "--print".into(),
192                    "--input-format".into(),
193                    "stream-json".into(),
194                    "--output-format".into(),
195                    "stream-json".into(),
196                    "--verbose".into(),
197                    // Register this adapter as the permission handler. Measured
198                    // against claude 2.1.258: without it a tool call that needs
199                    // an answer is refused outright with
200                    // `{"type":"system","subtype":"permission_denied"}`; with
201                    // it the CLI raises a `can_use_tool` control request on
202                    // stdout and blocks the turn until a `control_response`
203                    // arrives. See
204                    // `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`.
205                    "--permission-prompt-tool".into(),
206                    "stdio".into(),
207                ],
208                env: BTreeMap::new(),
209            },
210            permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
211        }
212    }
213
214    /// The command prefix this backend launches: what the registry publishes
215    /// as `default_launch`, so a caller that hands the published launch back
216    /// (the orchestrator does) gets stream-json mode and not the interactive
217    /// TUI blocked on a pipe.
218    pub fn launch(&self) -> &RuntimeLaunch {
219        &self.launch
220    }
221
222    /// Use an explicit Claude Code stream-json command prefix.
223    pub fn with_launch(launch: RuntimeLaunch) -> Self {
224        Self {
225            launch,
226            permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
227        }
228    }
229
230    /// Override how long an unanswered permission request is held before this
231    /// adapter denies it on the caller's behalf.
232    ///
233    /// See [`CLAUDE_PERMISSION_RESPONSE_TIMEOUT`] for the default and what it
234    /// was measured against.
235    pub fn with_permission_timeout(mut self, timeout: Duration) -> Self {
236        self.permission_timeout = timeout;
237        self
238    }
239
240    async fn open(
241        &self,
242        cwd: &Path,
243        runtime_id: String,
244        launch: Option<RuntimeLaunch>,
245        resume: bool,
246    ) -> Result<Box<dyn RuntimeConnection>> {
247        let mut launch = launch.unwrap_or_else(|| self.launch.clone());
248        launch.arguments.extend(if resume {
249            vec!["--resume".into(), runtime_id.clone()]
250        } else {
251            vec!["--session-id".into(), runtime_id.clone()]
252        });
253        let transport = RawLineTransport::spawn(&launch, Some(cwd), "claude-stream-json").await?;
254        Ok(Box::new(ClaudeRuntimeConnection {
255            handle: RuntimeHandle {
256                harness: HarnessId::from(HarnessId::CLAUDE_CODE),
257                runtime_id,
258                endpoint: transport.endpoint.clone(),
259            },
260            transport,
261            buffered_events: VecDeque::new(),
262            next_control_request: 1,
263            control_timeout: CLAUDE_CONTROL_RESPONSE_TIMEOUT,
264            pending_permissions: Vec::new(),
265            permission_timeout: self.permission_timeout,
266        }))
267    }
268}
269
270/// How long `interrupt` waits for the CLI's matching `control_response` before
271/// returning a structured error instead of hanging the caller.
272///
273/// Measured against claude 2.1.224: an interrupt issued while a turn is in
274/// flight is acknowledged in ~1 ms, but one issued during process startup —
275/// before the CLI has emitted `system/init` — is queued behind session-start
276/// hooks and took 1.15 s to acknowledge on a warm box. The bound is set well
277/// above the slow case so a legitimately busy startup is never reported as a
278/// protocol failure.
279const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
280
281/// How long an unanswered `can_use_tool` request is held before this adapter
282/// denies it on the caller's behalf.
283///
284/// Measured against claude 2.1.258: a `can_use_tool` control request the host
285/// never answers blocks the turn indefinitely — the probe watched one sit for
286/// 20 s with no further frame and no tool result, and the CLI has no bound of
287/// its own (receipt step `unanswered_request_blocks_the_turn` in
288/// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`). The
289/// bound therefore has to live here. Five minutes is long enough for an
290/// operator to read `supercode approvals list` and answer, and short enough
291/// that a forgotten prompt does not wedge a driven session forever. Override
292/// per backend with [`ClaudeCodeRuntimeBackend::with_permission_timeout`].
293pub const CLAUDE_PERMISSION_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
294
295/// The `behavior` values Claude Code's permission handler protocol accepts.
296///
297/// Measured against claude 2.1.258: anything else — including a `deny` with no
298/// `message` — is refused by the CLI with "The canUseTool callback returned an
299/// invalid permission result. Expected {behavior: 'allow', updatedInput?:
300/// object} or {behavior: 'deny', message: string}."
301const CLAUDE_PERMISSION_BEHAVIORS: [&str; 2] = ["allow", "deny"];
302
303/// What this adapter says when it denies a request nobody answered in time.
304const CLAUDE_PERMISSION_TIMEOUT_MESSAGE: &str =
305    "supercode denied this permission request: no answer arrived before the adapter's \
306     permission timeout elapsed";
307
308#[async_trait]
309impl RuntimeBackend for ClaudeCodeRuntimeBackend {
310    fn harness(&self) -> HarnessId {
311        HarnessId::from(HarnessId::CLAUDE_CODE)
312    }
313
314    fn capabilities(&self) -> RuntimeCapabilities {
315        RuntimeCapabilities {
316            start_session: true,
317            resume_session: true,
318            attach_existing_process: false,
319            send_input: true,
320            stream_events: true,
321            interrupt: true,
322            steer: true,
323            respond_to_requests: true,
324        }
325    }
326
327    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
328        self.open(&request.cwd, generated_session_id(), request.launch, false)
329            .await
330    }
331
332    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
333        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
334        self.open(&cwd, request.runtime_id, request.launch, true)
335            .await
336    }
337}
338
339struct ClaudeRuntimeConnection {
340    handle: RuntimeHandle,
341    transport: RawLineTransport,
342    /// Native events read off the transport while `interrupt` was waiting for
343    /// its `control_response`. They are handed to `next_event` in arrival order
344    /// so cancelling a turn never costs the consumer an event.
345    buffered_events: VecDeque<Value>,
346    next_control_request: u64,
347    control_timeout: Duration,
348    /// Permission requests the CLI raised and is still blocked on, oldest
349    /// first. A request is recorded when this adapter hands it to the event
350    /// consumer and dropped when it is answered or denied on timeout.
351    pending_permissions: Vec<PendingPermission>,
352    permission_timeout: Duration,
353}
354
355/// One `can_use_tool` control request Claude Code is blocked on.
356struct PendingPermission {
357    /// The CLI's own `request_id`, echoed verbatim in the answer.
358    request_id: String,
359    /// When this adapter denies it if nobody has answered.
360    deadline: tokio::time::Instant,
361}
362
363impl ClaudeRuntimeConnection {
364    /// The control channel is adapter-private plumbing: a `control_response` is
365    /// the reply to a frame this adapter sent, never a harness event, so it is
366    /// dropped rather than forwarded to event consumers. A late reply that
367    /// arrives after `interrupt` gave up is dropped here too.
368    fn is_control_response(value: &Value) -> bool {
369        value.get("type").and_then(Value::as_str) == Some("control_response")
370    }
371
372    /// Match one `control_response` envelope against an outstanding request id.
373    ///
374    /// Ground truth (claude 2.1.224, verified live): the CLI answers
375    /// `{"type":"control_request","request_id":ID,"request":{"subtype":"interrupt"}}`
376    /// with
377    /// `{"type":"control_response","response":{"subtype":"success","request_id":ID,"response":{"still_queued":[]}}}`,
378    /// or with `{"subtype":"error","request_id":ID,"error":"…"}` on failure.
379    fn control_result(value: &Value, request_id: &str) -> Option<Result<()>> {
380        let response = value.get("response")?;
381        if response.get("request_id").and_then(Value::as_str) != Some(request_id) {
382            return None;
383        }
384        match response.get("subtype").and_then(Value::as_str) {
385            Some("success") => Some(Ok(())),
386            other => Some(Err(Error::Other(format!(
387                "Claude Code rejected the interrupt control request: {}",
388                response
389                    .get("error")
390                    .and_then(Value::as_str)
391                    .map(str::to_string)
392                    .unwrap_or_else(|| format!(
393                        "control_response subtype {}",
394                        other.unwrap_or("(missing)")
395                    ))
396            )))),
397        }
398    }
399
400    /// The CLI's own `request_id` when this frame is a permission request.
401    ///
402    /// Ground truth (claude 2.1.258, recorded live): a tool call that needs an
403    /// answer from the registered permission handler arrives as
404    /// `{"type":"control_request","request_id":"<uuid>","request":{"subtype":"can_use_tool","tool_name":…,"input":{…},"permission_suggestions":[…],"tool_use_id":…}}`.
405    /// No other inbound `control_request` subtype is answered here.
406    fn permission_request_id(value: &Value) -> Option<&str> {
407        if value.get("type").and_then(Value::as_str)? != "control_request" {
408            return None;
409        }
410        let request = value.get("request")?;
411        if request.get("subtype").and_then(Value::as_str)? != "can_use_tool" {
412            return None;
413        }
414        value.get("request_id").and_then(Value::as_str)
415    }
416
417    /// Start the clock on a permission request about to reach the consumer.
418    fn note_permission_request(&mut self, payload: &Value) {
419        let Some(request_id) = Self::permission_request_id(payload) else {
420            return;
421        };
422        if self
423            .pending_permissions
424            .iter()
425            .any(|pending| pending.request_id == request_id)
426        {
427            return;
428        }
429        self.pending_permissions.push(PendingPermission {
430            request_id: request_id.to_string(),
431            deadline: tokio::time::Instant::now() + self.permission_timeout,
432        });
433    }
434
435    /// Write one `control_response` for a request the CLI is blocked on.
436    async fn write_permission_response(&mut self, request_id: &str, body: Value) -> Result<()> {
437        self.transport
438            .write(json!({
439                "type": "control_response",
440                "response": {
441                    "subtype": "success",
442                    "request_id": request_id,
443                    "response": body,
444                },
445            }))
446            .await
447    }
448
449    /// Deny every held request whose deadline has passed.
450    ///
451    /// Claude Code blocks the turn forever on an unanswered request, so an
452    /// unanswered request is denied rather than left to wedge the session.
453    async fn deny_expired_permissions(&mut self) -> Result<()> {
454        let now = tokio::time::Instant::now();
455        let expired = self
456            .pending_permissions
457            .iter()
458            .filter(|pending| pending.deadline <= now)
459            .map(|pending| pending.request_id.clone())
460            .collect::<Vec<_>>();
461        self.pending_permissions
462            .retain(|pending| pending.deadline > now);
463        for request_id in expired {
464            self.write_permission_response(
465                &request_id,
466                json!({"behavior": "deny", "message": CLAUDE_PERMISSION_TIMEOUT_MESSAGE}),
467            )
468            .await?;
469        }
470        Ok(())
471    }
472
473    /// How long until the oldest held request must be denied.
474    fn next_permission_deadline(&self) -> Option<Duration> {
475        let now = tokio::time::Instant::now();
476        self.pending_permissions
477            .iter()
478            .map(|pending| pending.deadline.saturating_duration_since(now))
479            .min()
480    }
481}
482
483/// Validate one caller-supplied answer against the permission-result shape
484/// Claude Code accepts, filling in the parts the CLI requires.
485///
486/// The uniform door (`harness.v1.approvals.resolve`) sends
487/// `{"behavior":"allow"}` or `{"behavior":"deny","message":…}`; a caller using
488/// `harness.v1.runtimes.respond` directly may add the CLI's optional fields
489/// (`updatedInput`, `updatedPermissions`) and they are passed through
490/// untouched. Anything without a recognized `behavior` is refused by name
491/// rather than guessed at, because the CLI itself refuses it.
492fn claude_permission_result(response: Value) -> Result<Value> {
493    let Value::Object(mut body) = response else {
494        return Err(claude_permission_shape_error(&response));
495    };
496    match body.get("behavior").and_then(Value::as_str) {
497        Some("allow") => {}
498        Some("deny") => {
499            // Measured: the CLI rejects a `deny` with no `message`.
500            let empty = body
501                .get("message")
502                .and_then(Value::as_str)
503                .is_none_or(str::is_empty);
504            if empty {
505                body.insert(
506                    "message".into(),
507                    Value::String("supercode denied this permission request".into()),
508                );
509            }
510        }
511        _ => return Err(claude_permission_shape_error(&Value::Object(body))),
512    }
513    Ok(Value::Object(body))
514}
515
516fn claude_permission_shape_error(response: &Value) -> Error {
517    Error::Other(format!(
518        "Claude Code permission answers must carry a `behavior` of {}; got {response}",
519        CLAUDE_PERMISSION_BEHAVIORS
520            .map(|behavior| format!("`{behavior}`"))
521            .join(" or "),
522    ))
523}
524
525#[async_trait]
526impl RuntimeConnection for ClaudeRuntimeConnection {
527    fn handle(&self) -> &RuntimeHandle {
528        &self.handle
529    }
530
531    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
532        let content = if input.image_urls.is_empty() {
533            Value::String(input.text)
534        } else {
535            let mut parts = Vec::new();
536            if !input.text.is_empty() {
537                parts.push(json!({"type":"text", "text":input.text}));
538            }
539            for url in input.image_urls {
540                parts.push(claude_image_part(&url)?);
541            }
542            Value::Array(parts)
543        };
544        self.transport
545            .write(json!({
546                "type": "user",
547                "session_id": self.handle.runtime_id,
548                "message": {"role": "user", "content": content},
549            }))
550            .await?;
551        Ok(None)
552    }
553
554    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
555        if let Some(payload) = self.buffered_events.pop_front() {
556            return Ok(Some(harness_event(payload)));
557        }
558        loop {
559            // A held permission request has a deadline, so the wait is bounded
560            // by the nearest one: an unanswered request is denied here rather
561            // than left blocking the CLI's turn forever.
562            self.deny_expired_permissions().await?;
563            let payload = match self.next_permission_deadline() {
564                Some(remaining) => {
565                    match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
566                        Err(_) => continue,
567                        Ok(None) => return Ok(None),
568                        Ok(Some(payload)) => payload,
569                    }
570                }
571                None => match self.transport.receiver.recv().await {
572                    None => return Ok(None),
573                    Some(payload) => payload,
574                },
575            };
576            if Self::is_control_response(&payload) {
577                continue;
578            }
579            self.note_permission_request(&payload);
580            return Ok(Some(harness_event(payload)));
581        }
582    }
583
584    /// Cancel the running turn through the stream-json control channel and wait
585    /// for the CLI's acknowledgement.
586    ///
587    /// Interrupting with no turn in flight is safe and succeeds: claude 2.1.224
588    /// acknowledges the request with `subtype: "success"` and an empty
589    /// `still_queued` list rather than erroring, and the session keeps
590    /// accepting input. The adapter reports what the harness reports instead of
591    /// inventing a turn-state gate of its own.
592    async fn interrupt(&mut self) -> Result<()> {
593        let request_id = format!(
594            "supercode-{}-interrupt-{}",
595            self.handle.runtime_id, self.next_control_request
596        );
597        self.next_control_request += 1;
598        self.transport
599            .write(json!({
600                "type": "control_request",
601                "request_id": request_id,
602                "request": {"subtype": "interrupt"},
603            }))
604            .await?;
605
606        let deadline = tokio::time::Instant::now() + self.control_timeout;
607        loop {
608            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
609            if remaining.is_zero() {
610                return Err(claude_interrupt_timeout(self.control_timeout));
611            }
612            match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
613                Err(_) => return Err(claude_interrupt_timeout(self.control_timeout)),
614                Ok(None) => return Err(Error::Other(
615                    "Claude Code stream-json transport closed before acknowledging the interrupt"
616                        .into(),
617                )),
618                Ok(Some(payload)) => {
619                    if Self::is_control_response(&payload) {
620                        if let Some(result) = Self::control_result(&payload, &request_id) {
621                            return result;
622                        }
623                        continue;
624                    }
625                    // A permission request raised while the interrupt was in
626                    // flight is still a request the CLI is blocked on: start
627                    // its clock now, not when the consumer drains the buffer.
628                    self.note_permission_request(&payload);
629                    self.buffered_events.push_back(payload);
630                }
631            }
632        }
633    }
634
635    async fn steer(&mut self, text: String) -> Result<()> {
636        self.send_input(RuntimeInput {
637            text,
638            image_urls: Vec::new(),
639        })
640        .await
641        .map(|_| ())
642    }
643
644    /// Answer one `can_use_tool` permission request through the stream-json
645    /// control channel.
646    ///
647    /// Only a request this adapter has surfaced to the event consumer is
648    /// answerable: the CLI's `request_id` is the identity, the answer is sent
649    /// once, and a second answer for the same id is refused rather than
650    /// silently ignored (claude 2.1.258 logs and drops a duplicate
651    /// `control_response`).
652    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
653        let Some(request_id) = request_id.as_str().map(str::to_string) else {
654            return Err(Error::Other(format!(
655                "Claude Code control requests are identified by a string `request_id`; got \
656                 {request_id}"
657            )));
658        };
659        let Some(index) = self
660            .pending_permissions
661            .iter()
662            .position(|pending| pending.request_id == request_id)
663        else {
664            return Err(Error::Other(format!(
665                "no Claude Code permission request `{request_id}` is waiting on this connection — \
666                 a `can_use_tool` request is answerable only while its turn is blocked on it, and \
667                 only until it is answered or denied on timeout"
668            )));
669        };
670        let body = claude_permission_result(response)?;
671        self.pending_permissions.remove(index);
672        self.write_permission_response(&request_id, body).await
673    }
674
675    async fn close(&mut self) -> Result<()> {
676        self.transport.close().await
677    }
678}
679
680/// Generic ACP v1 client backend for any ACP agent command.
681#[derive(Debug, Clone)]
682pub struct AcpRuntimeBackend {
683    harness: HarnessId,
684    launch: RuntimeLaunch,
685    resume_session: bool,
686}
687
688impl AcpRuntimeBackend {
689    /// Construct an ACP adapter for a named harness/agent command.
690    pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
691        Self {
692            harness,
693            launch,
694            resume_session: false,
695        }
696    }
697
698    /// Declare that this known ACP agent advertises `session/load` or
699    /// `session/resume`. Attach still validates the capability negotiated by
700    /// `initialize`, so a changed or incompatible agent fails honestly.
701    pub fn with_resume_support(mut self, supported: bool) -> Self {
702        self.resume_session = supported;
703        self
704    }
705
706    async fn connect(
707        &self,
708        cwd: &Path,
709        launch: Option<RuntimeLaunch>,
710    ) -> Result<(
711        Arc<JsonLineClient>,
712        mpsc::UnboundedReceiver<Value>,
713        RuntimeEndpoint,
714        Value,
715    )> {
716        let launch = launch.unwrap_or_else(|| self.launch.clone());
717        let (client, receiver, endpoint) =
718            JsonLineClient::spawn(&launch, Some(cwd), true, "acp-v1-jsonrpc").await?;
719        let initialized = client
720            .request(
721                "initialize",
722                json!({
723                    "protocolVersion": 1,
724                    "clientCapabilities": {},
725                    "clientInfo": {
726                        "name": "supercode",
727                        "title": "Supercode",
728                        "version": env!("CARGO_PKG_VERSION"),
729                    },
730                }),
731            )
732            .await?;
733        if initialized.get("protocolVersion").and_then(Value::as_u64) != Some(1) {
734            return Err(Error::Other(format!(
735                "ACP agent negotiated unsupported protocol version: {}",
736                initialized
737                    .get("protocolVersion")
738                    .cloned()
739                    .unwrap_or(Value::Null)
740            )));
741        }
742        Ok((client, receiver, endpoint, initialized))
743    }
744
745    async fn session_request(
746        &self,
747        client: &JsonLineClient,
748        initialized: &Value,
749        method: &str,
750        params: Value,
751    ) -> Result<Value> {
752        match client.request(method, params.clone()).await {
753            Ok(response) => Ok(response),
754            Err(error) if acp_auth_required(&error.to_string()) => {
755                let cached = initialized
756                    .get("authMethods")
757                    .and_then(Value::as_array)
758                    .and_then(|methods| {
759                        methods.iter().find_map(|candidate| {
760                            (candidate.get("id").and_then(Value::as_str) == Some("cached_token"))
761                                .then_some("cached_token")
762                        })
763                    });
764                let Some(method_id) = cached else {
765                    return Err(Error::Other(
766                        "ACP agent requires authentication but did not advertise the non-interactive `cached_token` method"
767                            .into(),
768                    ));
769                };
770                client
771                    .request(
772                        "authenticate",
773                        json!({"methodId": method_id, "_meta": {"headless": true}}),
774                    )
775                    .await?;
776                client.request(method, params).await
777            }
778            Err(error) => Err(error),
779        }
780    }
781
782    async fn connection(
783        &self,
784        cwd: &Path,
785        runtime_id: Option<String>,
786        launch: Option<RuntimeLaunch>,
787        mcp_servers: Vec<McpServerLaunch>,
788    ) -> Result<Box<dyn RuntimeConnection>> {
789        let mcp_servers = acp_mcp_servers(&mcp_servers);
790        let (client, mut receiver, endpoint, initialized) = self.connect(cwd, launch).await?;
791        let session_id = if let Some(session_id) = runtime_id {
792            let resume = initialized
793                .pointer("/agentCapabilities/sessionCapabilities/resume")
794                .is_some();
795            let load = initialized
796                .pointer("/agentCapabilities/loadSession")
797                .and_then(Value::as_bool)
798                .unwrap_or(false);
799            let method = if resume {
800                "session/resume"
801            } else if load {
802                "session/load"
803            } else {
804                return Err(Error::Other(
805                    "ACP agent did not advertise session resume or load".into(),
806                ));
807            };
808            self.session_request(
809                client.as_ref(),
810                &initialized,
811                method,
812                json!({"sessionId": session_id, "cwd": cwd, "mcpServers": mcp_servers}),
813            )
814            .await?;
815            session_id
816        } else {
817            self.session_request(
818                client.as_ref(),
819                &initialized,
820                "session/new",
821                json!({"cwd": cwd, "mcpServers": mcp_servers}),
822            )
823            .await?
824            .get("sessionId")
825            .and_then(Value::as_str)
826            .ok_or_else(|| Error::Other("ACP session/new omitted sessionId".into()))?
827            .to_string()
828        };
829        // `session/load` is allowed to replay the persisted conversation as
830        // `session/update` notifications before returning its response. Those
831        // are bootstrap data, not output from a newly submitted prompt. If
832        // they escape through the live runtime stream, clients fabricate an
833        // assistant delta and a turn that can never complete because no
834        // `session/prompt` request exists. The persisted transcript already
835        // supplies this history, so discard every notification queued by the
836        // completed new/load handshake before exposing the connection.
837        while receiver.try_recv().is_ok() {}
838        Ok(Box::new(AcpRuntimeConnection {
839            handle: RuntimeHandle {
840                harness: self.harness.clone(),
841                runtime_id: session_id,
842                endpoint,
843            },
844            client,
845            receiver,
846            active_prompt: None,
847        }))
848    }
849}
850
851/// The uniform [`McpServerLaunch`] list in ACP's own `session/new` shape:
852/// `{name, command, args, env: [{name, value}]}` (Agent Client Protocol v1
853/// `McpServer`, the stdio form). An empty list stays `[]`, which is what this
854/// backend has always sent.
855fn acp_mcp_servers(servers: &[McpServerLaunch]) -> Value {
856    Value::Array(
857        servers
858            .iter()
859            .map(|server| {
860                json!({
861                    "name": server.name,
862                    "command": server.command,
863                    "args": server.arguments,
864                    "env": server
865                        .env
866                        .iter()
867                        .map(|(name, value)| json!({"name": name, "value": value}))
868                        .collect::<Vec<_>>(),
869                })
870            })
871            .collect::<Vec<_>>(),
872    )
873}
874
875fn acp_auth_required(message: &str) -> bool {
876    let message = message.to_ascii_lowercase();
877    [
878        "auth",
879        "login",
880        "sign in",
881        "sign-in",
882        "unauthorized",
883        "forbidden",
884        "credential",
885    ]
886    .iter()
887    .any(|needle| message.contains(needle))
888}
889
890#[async_trait]
891impl RuntimeBackend for AcpRuntimeBackend {
892    fn harness(&self) -> HarnessId {
893        self.harness.clone()
894    }
895
896    fn capabilities(&self) -> RuntimeCapabilities {
897        RuntimeCapabilities {
898            start_session: true,
899            // Optional in ACP v1. Known agents may declare it here; attach
900            // still checks the actual initialize response before use.
901            resume_session: self.resume_session,
902            attach_existing_process: false,
903            send_input: true,
904            stream_events: true,
905            interrupt: true,
906            steer: false,
907            respond_to_requests: true,
908        }
909    }
910
911    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
912        self.connection(&request.cwd, None, request.launch, request.mcp_servers)
913            .await
914    }
915
916    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
917        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
918        self.connection(&cwd, Some(request.runtime_id), request.launch, Vec::new())
919            .await
920    }
921}
922
923struct AcpRuntimeConnection {
924    handle: RuntimeHandle,
925    client: Arc<JsonLineClient>,
926    receiver: mpsc::UnboundedReceiver<Value>,
927    active_prompt: Option<u64>,
928}
929
930#[async_trait]
931impl RuntimeConnection for AcpRuntimeConnection {
932    fn handle(&self) -> &RuntimeHandle {
933        &self.handle
934    }
935
936    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
937        let mut prompt = Vec::new();
938        if !input.text.is_empty() {
939            prompt.push(json!({"type": "text", "text": input.text}));
940        }
941        for url in input.image_urls {
942            let (mime_type, data) = data_image_parts(&url).ok_or_else(|| {
943                Error::Other("ACP image prompts require base64 image data URLs".into())
944            })?;
945            prompt.push(json!({"type":"image", "mimeType":mime_type, "data":data}));
946        }
947        let (id, response) = self
948            .client
949            .begin_request(
950                "session/prompt",
951                json!({
952                    "sessionId": self.handle.runtime_id,
953                    "prompt": prompt,
954                }),
955            )
956            .await?;
957        self.active_prompt = Some(id);
958        let client = self.client.clone();
959        tokio::spawn(async move {
960            let result = match response.await {
961                Ok(Ok(result)) => json!({"id": id, "result": result}),
962                Ok(Err(error)) => json!({"id": id, "error": error}),
963                Err(_) => json!({"id": id, "error": "response channel closed"}),
964            };
965            client.emit(json!({
966                "jsonrpc": "2.0",
967                "method": "supercode/acp_request_completed",
968                "params": result,
969            }));
970        });
971        Ok(Some(id.to_string()))
972    }
973
974    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
975        let Some(payload) = self.receiver.recv().await else {
976            return Ok(None);
977        };
978        let kind = payload
979            .get("method")
980            .and_then(Value::as_str)
981            .or_else(|| payload.get("type").and_then(Value::as_str))
982            .unwrap_or("protocol")
983            .to_string();
984        if kind == "supercode/acp_request_completed" {
985            self.active_prompt = None;
986        }
987        Ok(Some(HarnessEvent {
988            sequence: None,
989            kind,
990            payload,
991        }))
992    }
993
994    async fn interrupt(&mut self) -> Result<()> {
995        self.client
996            .notify(
997                "session/cancel",
998                json!({"sessionId": self.handle.runtime_id}),
999            )
1000            .await
1001    }
1002
1003    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1004        self.client.respond(request_id, response).await
1005    }
1006
1007    async fn close(&mut self) -> Result<()> {
1008        self.client.close().await
1009    }
1010}
1011
1012/// OpenCode live-runtime backend using its official HTTP API and SSE event
1013/// stream. [`OpenCodeRuntimeBackend::connect`] can join the server embedded in
1014/// an already-running TUI when that TUI was launched with a known host/port.
1015#[derive(Debug, Clone)]
1016pub struct OpenCodeRuntimeBackend {
1017    launch: RuntimeLaunch,
1018    base_url: Option<String>,
1019    bearer: Option<BearerToken>,
1020}
1021
1022impl Default for OpenCodeRuntimeBackend {
1023    fn default() -> Self {
1024        Self::new()
1025    }
1026}
1027
1028impl OpenCodeRuntimeBackend {
1029    /// Launch a fresh `opencode serve` process for each connection.
1030    pub fn new() -> Self {
1031        Self {
1032            launch: RuntimeLaunch {
1033                program: "opencode".into(),
1034                arguments: vec!["serve".into()],
1035                env: BTreeMap::new(),
1036            },
1037            base_url: None,
1038            bearer: None,
1039        }
1040    }
1041
1042    /// Connect to an existing OpenCode server, including a TUI's server when
1043    /// it was launched on a known address.
1044    pub fn connect(base_url: impl Into<String>) -> Self {
1045        Self {
1046            base_url: Some(base_url.into().trim_end_matches('/').to_string()),
1047            ..Self::new()
1048        }
1049    }
1050
1051    /// Override the command used when launching a new OpenCode server.
1052    pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
1053        self.launch = launch;
1054        self
1055    }
1056
1057    /// Send the resolved connect-mode credential as a bearer Authorization
1058    /// header on every request to the joined server.
1059    pub fn with_bearer(mut self, token: BearerToken) -> Self {
1060        self.bearer = Some(token);
1061        self
1062    }
1063
1064    fn http_client(&self) -> Result<reqwest::Client> {
1065        let Some(token) = &self.bearer else {
1066            return Ok(reqwest::Client::new());
1067        };
1068        let mut headers = reqwest::header::HeaderMap::new();
1069        let mut value =
1070            reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token.secret())).map_err(
1071                |_| Error::Other("connect-mode bearer token is not a valid header value".into()),
1072            )?;
1073        value.set_sensitive(true);
1074        headers.insert(reqwest::header::AUTHORIZATION, value);
1075        reqwest::Client::builder()
1076            .default_headers(headers)
1077            .build()
1078            .map_err(|error| Error::Other(format!("could not build HTTP client: {error}")))
1079    }
1080
1081    async fn service(
1082        &self,
1083        client: &reqwest::Client,
1084        launch: Option<RuntimeLaunch>,
1085    ) -> Result<(String, Option<Child>)> {
1086        if let Some(base_url) = &self.base_url {
1087            wait_for_health(client, base_url).await?;
1088            return Ok((base_url.clone(), None));
1089        }
1090        let port = TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port();
1091        let mut launch = launch.unwrap_or_else(|| self.launch.clone());
1092        launch.arguments.extend([
1093            "--hostname".into(),
1094            "127.0.0.1".into(),
1095            "--port".into(),
1096            port.to_string(),
1097        ]);
1098        let mut command = Command::new(&launch.program);
1099        command
1100            .args(&launch.arguments)
1101            .envs(&launch.env)
1102            .stdin(Stdio::null())
1103            .stdout(Stdio::null())
1104            .stderr(Stdio::inherit())
1105            .kill_on_drop(true);
1106        // OpenCode's launcher may replace itself with or spawn a native
1107        // worker. Give the runtime its own group so close can reap the whole
1108        // server tree instead of orphaning the worker and its inherited FDs.
1109        #[cfg(unix)]
1110        command.process_group(0);
1111        let mut child = command.spawn().map_err(|error| {
1112            Error::Other(format!("could not launch {}: {error}", launch.program))
1113        })?;
1114        let base_url = format!("http://127.0.0.1:{port}");
1115        if let Err(error) = wait_for_health(client, &base_url).await {
1116            // `kill_on_drop` only targets the launcher. Explicitly close its
1117            // isolated group so a slow or failed startup cannot orphan the
1118            // native OpenCode worker.
1119            let _ = terminate_opencode_server(&mut child).await;
1120            return Err(error);
1121        }
1122        Ok((base_url, Some(child)))
1123    }
1124
1125    async fn open(
1126        &self,
1127        cwd: &Path,
1128        runtime_id: Option<String>,
1129        launch: Option<RuntimeLaunch>,
1130    ) -> Result<Box<dyn RuntimeConnection>> {
1131        let client = self.http_client()?;
1132        let (base_url, child) = self.service(&client, launch).await?;
1133        let cwd_string = cwd.to_string_lossy().to_string();
1134        let runtime_id = match runtime_id {
1135            Some(id) => {
1136                http_ok(
1137                    client
1138                        .get(format!("{base_url}/session/{id}"))
1139                        .query(&[("directory", &cwd_string)])
1140                        .send()
1141                        .await,
1142                )
1143                .await?;
1144                id
1145            }
1146            None => {
1147                let response = http_ok(
1148                    client
1149                        .post(format!("{base_url}/session"))
1150                        .query(&[("directory", &cwd_string)])
1151                        .json(&json!({}))
1152                        .send()
1153                        .await,
1154                )
1155                .await?;
1156                response
1157                    .json::<Value>()
1158                    .await
1159                    .map_err(http_error)?
1160                    .get("id")
1161                    .and_then(Value::as_str)
1162                    .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
1163                    .to_string()
1164            }
1165        };
1166        let receiver = spawn_sse(
1167            client.clone(),
1168            format!("{base_url}/event"),
1169            cwd_string.clone(),
1170        );
1171        Ok(Box::new(OpenCodeRuntimeConnection {
1172            handle: RuntimeHandle {
1173                harness: HarnessId::from(HarnessId::OPENCODE),
1174                runtime_id,
1175                endpoint: RuntimeEndpoint::Http {
1176                    base_url: base_url.clone(),
1177                    protocol: "opencode-http-sse".into(),
1178                },
1179            },
1180            base_url,
1181            cwd: cwd_string,
1182            client,
1183            receiver,
1184            child,
1185        }))
1186    }
1187}
1188
1189#[async_trait]
1190impl RuntimeBackend for OpenCodeRuntimeBackend {
1191    fn harness(&self) -> HarnessId {
1192        HarnessId::from(HarnessId::OPENCODE)
1193    }
1194
1195    fn capabilities(&self) -> RuntimeCapabilities {
1196        RuntimeCapabilities {
1197            start_session: true,
1198            resume_session: true,
1199            attach_existing_process: self.base_url.is_some(),
1200            send_input: true,
1201            stream_events: true,
1202            interrupt: true,
1203            steer: false,
1204            respond_to_requests: true,
1205        }
1206    }
1207
1208    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1209        self.open(&request.cwd, None, request.launch).await
1210    }
1211
1212    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1213        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1214        self.open(&cwd, Some(request.runtime_id), request.launch)
1215            .await
1216    }
1217
1218    async fn attach_existing(
1219        &self,
1220        request: RuntimeAttachRequest,
1221    ) -> Result<Box<dyn RuntimeConnection>> {
1222        if self.base_url.is_none() {
1223            return Err(Error::Other(
1224                "OpenCode live attach requires the existing server's `base_url`".into(),
1225            ));
1226        }
1227        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1228        self.open(&cwd, Some(request.runtime_id), request.launch)
1229            .await
1230    }
1231}
1232
1233struct OpenCodeRuntimeConnection {
1234    handle: RuntimeHandle,
1235    base_url: String,
1236    cwd: String,
1237    client: reqwest::Client,
1238    receiver: mpsc::UnboundedReceiver<Value>,
1239    child: Option<Child>,
1240}
1241
1242#[async_trait]
1243impl RuntimeConnection for OpenCodeRuntimeConnection {
1244    fn handle(&self) -> &RuntimeHandle {
1245        &self.handle
1246    }
1247
1248    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1249        let mut parts = Vec::new();
1250        if !input.text.is_empty() {
1251            parts.push(json!({"type": "text", "text": input.text}));
1252        }
1253        for url in input.image_urls {
1254            let mime = image_mime_type(&url).ok_or_else(|| {
1255                Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
1256            })?;
1257            parts.push(json!({"type":"file", "mime":mime, "url":url}));
1258        }
1259        http_ok(
1260            self.client
1261                .post(format!(
1262                    "{}/session/{}/prompt_async",
1263                    self.base_url, self.handle.runtime_id
1264                ))
1265                .query(&[("directory", &self.cwd)])
1266                .json(&json!({"parts": parts}))
1267                .send()
1268                .await,
1269        )
1270        .await?;
1271        Ok(None)
1272    }
1273
1274    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1275        loop {
1276            let Some(payload) = self.receiver.recv().await else {
1277                return Ok(None);
1278            };
1279            if opencode_event_session_id(&payload)
1280                .is_some_and(|session_id| session_id != self.handle.runtime_id)
1281            {
1282                continue;
1283            }
1284            let kind = payload
1285                .get("type")
1286                .and_then(Value::as_str)
1287                .unwrap_or("event")
1288                .to_string();
1289            return Ok(Some(HarnessEvent {
1290                sequence: None,
1291                kind,
1292                payload,
1293            }));
1294        }
1295    }
1296
1297    async fn interrupt(&mut self) -> Result<()> {
1298        http_ok(
1299            self.client
1300                .post(format!(
1301                    "{}/session/{}/abort",
1302                    self.base_url, self.handle.runtime_id
1303                ))
1304                .query(&[("directory", &self.cwd)])
1305                .send()
1306                .await,
1307        )
1308        .await?;
1309        Ok(())
1310    }
1311
1312    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1313        let permission = request_id.as_str().ok_or_else(|| {
1314            Error::Other("OpenCode permission request id must be a string".into())
1315        })?;
1316        http_ok(
1317            self.client
1318                .post(format!(
1319                    "{}/session/{}/permissions/{permission}",
1320                    self.base_url, self.handle.runtime_id
1321                ))
1322                .query(&[("directory", &self.cwd)])
1323                .json(&response)
1324                .send()
1325                .await,
1326        )
1327        .await?;
1328        Ok(())
1329    }
1330
1331    async fn close(&mut self) -> Result<()> {
1332        if let Some(child) = &mut self.child {
1333            terminate_opencode_server(child).await?;
1334        }
1335        Ok(())
1336    }
1337}
1338
1339fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1340    let rest = url.strip_prefix("data:")?;
1341    let (mime_type, data) = rest.split_once(";base64,")?;
1342    mime_type.starts_with("image/").then_some((mime_type, data))
1343}
1344
1345fn image_mime_type(url: &str) -> Option<&str> {
1346    if let Some((mime_type, _)) = data_image_parts(url) {
1347        return Some(mime_type);
1348    }
1349    let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1350    if path.ends_with(".png") {
1351        Some("image/png")
1352    } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1353        Some("image/jpeg")
1354    } else if path.ends_with(".gif") {
1355        Some("image/gif")
1356    } else if path.ends_with(".webp") {
1357        Some("image/webp")
1358    } else {
1359        None
1360    }
1361}
1362
1363fn claude_image_part(url: &str) -> Result<Value> {
1364    if let Some((media_type, data)) = data_image_parts(url) {
1365        return Ok(json!({
1366            "type":"image",
1367            "source":{"type":"base64", "media_type":media_type, "data":data}
1368        }));
1369    }
1370    if url.starts_with("https://") || url.starts_with("http://") {
1371        return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1372    }
1373    Err(Error::Other(
1374        "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1375    ))
1376}
1377
1378fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1379    let properties = payload.get("properties").unwrap_or(payload);
1380    properties
1381        .get("sessionID")
1382        .and_then(Value::as_str)
1383        .or_else(|| {
1384            properties
1385                .get("part")
1386                .and_then(|part| part.get("sessionID"))
1387                .and_then(Value::as_str)
1388        })
1389        .or_else(|| {
1390            properties
1391                .get("info")
1392                .and_then(|info| info.get("sessionID"))
1393                .and_then(Value::as_str)
1394        })
1395}
1396
1397async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1398    #[cfg(unix)]
1399    let process_group = child.id();
1400    let leader_exited = child.try_wait()?.is_some();
1401    if leader_exited {
1402        #[cfg(unix)]
1403        if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1404            crate::lsp::kill_process_group(pid);
1405            wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1406        }
1407        return Ok(());
1408    }
1409    // `Child::kill().await` waits for process reaping and can block forever
1410    // when a launcher leaves its native worker and inherited handles alive.
1411    // Terminate the isolated group while its leader can still reap workers;
1412    // killing leader and workers simultaneously can leave transient orphan
1413    // zombies and made close observably race process cleanup on Linux.
1414    #[cfg(unix)]
1415    if let Some(pid) = process_group {
1416        unsafe {
1417            libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1418        }
1419        let mut leader_reaped = false;
1420        if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1421            status?;
1422            leader_reaped = true;
1423            if !process_group_exists(pid) {
1424                return Ok(());
1425            }
1426        }
1427        // The leader may exit while a detached worker ignores SIGTERM. Do
1428        // not mistake a reaped launcher for a stopped server tree.
1429        crate::lsp::kill_process_group(pid);
1430        if leader_reaped {
1431            return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1432        }
1433    }
1434    #[cfg(not(unix))]
1435    child.start_kill()?;
1436    tokio::time::timeout(Duration::from_secs(3), child.wait())
1437        .await
1438        .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1439    #[cfg(unix)]
1440    if let Some(pid) = process_group {
1441        wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1442    }
1443    Ok(())
1444}
1445
1446#[cfg(unix)]
1447fn process_group_exists(pid: u32) -> bool {
1448    let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1449    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1450}
1451
1452#[cfg(unix)]
1453async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1454    let deadline = tokio::time::Instant::now() + timeout;
1455    while process_group_exists(pid) {
1456        if tokio::time::Instant::now() >= deadline {
1457            return Err(Error::Other(format!(
1458                "timed out stopping OpenCode process group {pid}"
1459            )));
1460        }
1461        tokio::time::sleep(Duration::from_millis(10)).await;
1462    }
1463    Ok(())
1464}
1465
1466struct RawLineTransport {
1467    stdin: Mutex<ChildStdin>,
1468    child: Mutex<Child>,
1469    receiver: mpsc::UnboundedReceiver<Value>,
1470    endpoint: RuntimeEndpoint,
1471}
1472
1473impl RawLineTransport {
1474    async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1475        let mut command = Command::new(&launch.program);
1476        command
1477            .args(&launch.arguments)
1478            .envs(&launch.env)
1479            .stdin(Stdio::piped())
1480            .stdout(Stdio::piped())
1481            .stderr(Stdio::inherit())
1482            .kill_on_drop(true);
1483        if let Some(cwd) = cwd {
1484            command.current_dir(cwd);
1485        }
1486        let mut child = command.spawn().map_err(|error| {
1487            Error::Other(format!("could not launch {}: {error}", launch.program))
1488        })?;
1489        let pid = child.id();
1490        let stdin = child
1491            .stdin
1492            .take()
1493            .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1494        let stdout = child
1495            .stdout
1496            .take()
1497            .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1498        let (sender, receiver) = mpsc::unbounded_channel();
1499        tokio::spawn(async move {
1500            let mut lines = BufReader::new(stdout).lines();
1501            while let Ok(Some(line)) = lines.next_line().await {
1502                let value = serde_json::from_str(&line)
1503                    .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1504                let _ = sender.send(value);
1505            }
1506        });
1507        Ok(Self {
1508            stdin: Mutex::new(stdin),
1509            child: Mutex::new(child),
1510            receiver,
1511            endpoint: RuntimeEndpoint::LocalProcess {
1512                pid,
1513                command: std::iter::once(launch.program.clone())
1514                    .chain(launch.arguments.iter().cloned())
1515                    .collect(),
1516                protocol: protocol.into(),
1517            },
1518        })
1519    }
1520
1521    async fn write(&self, value: Value) -> Result<()> {
1522        let mut stdin = self.stdin.lock().await;
1523        stdin.write_all(value.to_string().as_bytes()).await?;
1524        stdin.write_all(b"\n").await?;
1525        stdin.flush().await?;
1526        Ok(())
1527    }
1528
1529    async fn close(&self) -> Result<()> {
1530        let mut child = self.child.lock().await;
1531        if child.try_wait()?.is_none() {
1532            child.kill().await?;
1533        }
1534        Ok(())
1535    }
1536}
1537
1538async fn raw_next_event(
1539    receiver: &mut mpsc::UnboundedReceiver<Value>,
1540) -> Result<Option<HarnessEvent>> {
1541    let Some(payload) = receiver.recv().await else {
1542        return Ok(None);
1543    };
1544    Ok(Some(harness_event(payload)))
1545}
1546
1547fn harness_event(payload: Value) -> HarnessEvent {
1548    let kind = payload
1549        .get("type")
1550        .and_then(Value::as_str)
1551        .unwrap_or("event")
1552        .to_string();
1553    HarnessEvent {
1554        sequence: None,
1555        kind,
1556        payload,
1557    }
1558}
1559
1560fn claude_interrupt_timeout(bound: Duration) -> Error {
1561    Error::Other(format!(
1562        "Claude Code did not acknowledge the interrupt control request within {}s",
1563        bound.as_secs_f32()
1564    ))
1565}
1566
1567pub(crate) fn generated_session_id() -> String {
1568    let mut bytes = [0_u8; 16];
1569    if getrandom::getrandom(&mut bytes).is_err() {
1570        let nanos = SystemTime::now()
1571            .duration_since(UNIX_EPOCH)
1572            .unwrap_or_default()
1573            .as_nanos()
1574            .to_le_bytes();
1575        bytes.copy_from_slice(&nanos);
1576    }
1577    bytes[6] = (bytes[6] & 0x0f) | 0x40;
1578    bytes[8] = (bytes[8] & 0x3f) | 0x80;
1579    format!(
1580        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1581        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1582        bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1583    )
1584}
1585
1586async fn wait_for_health(client: &reqwest::Client, base_url: &str) -> Result<()> {
1587    wait_for_health_for(client, base_url, Duration::from_secs(10)).await
1588}
1589
1590async fn wait_for_health_for(
1591    client: &reqwest::Client,
1592    base_url: &str,
1593    total_timeout: Duration,
1594) -> Result<()> {
1595    let url = format!("{base_url}/global/health");
1596    let mut last = None;
1597    let deadline = tokio::time::Instant::now() + total_timeout;
1598    // Local package-manager shims can take longer than five seconds to start
1599    // under build or indexing load. Ten seconds avoids false unavailability
1600    // without permitting an unbounded launch; inventory handshakes retain
1601    // their separate 30-second bound around the complete startup.
1602    loop {
1603        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1604        if remaining.is_zero() {
1605            break;
1606        }
1607        let request_timeout = remaining.min(Duration::from_millis(500));
1608        match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1609            Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1610            Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1611            Ok(Err(error)) => last = Some(error.to_string()),
1612            Err(_) => last = Some("health request timed out".into()),
1613        }
1614        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1615        if !remaining.is_zero() {
1616            tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1617        }
1618    }
1619    Err(Error::Other(format!(
1620        "OpenCode server at {base_url} did not become healthy: {}",
1621        last.unwrap_or_else(|| "no response".into())
1622    )))
1623}
1624
1625async fn http_ok(
1626    response: std::result::Result<reqwest::Response, reqwest::Error>,
1627) -> Result<reqwest::Response> {
1628    response
1629        .map_err(http_error)?
1630        .error_for_status()
1631        .map_err(http_error)
1632}
1633
1634fn http_error(error: reqwest::Error) -> Error {
1635    Error::Other(format!("runtime HTTP request failed: {error}"))
1636}
1637
1638fn spawn_sse(
1639    client: reqwest::Client,
1640    url: String,
1641    directory: String,
1642) -> mpsc::UnboundedReceiver<Value> {
1643    let (sender, receiver) = mpsc::unbounded_channel();
1644    tokio::spawn(async move {
1645        let response = client
1646            .get(url)
1647            .query(&[("directory", directory)])
1648            .send()
1649            .await;
1650        let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1651            let _ = sender.send(
1652                json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1653            );
1654            return;
1655        };
1656        let mut stream = response.bytes_stream();
1657        let mut buffer = String::new();
1658        while let Some(chunk) = stream.next().await {
1659            let Ok(chunk) = chunk else {
1660                break;
1661            };
1662            buffer.push_str(&String::from_utf8_lossy(&chunk));
1663            while let Some(newline) = buffer.find('\n') {
1664                let line = buffer[..newline].trim_end_matches('\r').to_string();
1665                buffer.drain(..=newline);
1666                if let Some(data) = line.strip_prefix("data:") {
1667                    let data = data.trim();
1668                    if let Ok(value) = serde_json::from_str(data) {
1669                        let _ = sender.send(value);
1670                    }
1671                }
1672            }
1673        }
1674    });
1675    receiver
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::*;
1681
1682    /// Fake `claude --print --input-format stream-json` child. It appends every
1683    /// stdin frame to `$1` so a test can assert the exact bytes this adapter
1684    /// wrote, and replies with the control envelope the real CLI replies with.
1685    #[cfg(unix)]
1686    const FAKE_CLAUDE_ACKS: &str = r#"
1687cap="$1"
1688while IFS= read -r line; do
1689  printf '%s\n' "$line" >> "$cap"
1690  case "$line" in
1691    *'"subtype":"interrupt"'*)
1692      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1693      printf '{"type":"system","subtype":"mid_flight"}\n'
1694      printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
1695      printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
1696      ;;
1697    *'"type":"user"'*)
1698      printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
1699      ;;
1700  esac
1701done
1702"#;
1703
1704    /// Same, but the control channel never answers — the hang this adapter must
1705    /// convert into a bounded, structured error.
1706    #[cfg(unix)]
1707    const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
1708cap="$1"
1709while IFS= read -r line; do
1710  printf '%s\n' "$line" >> "$cap"
1711done
1712"#;
1713
1714    /// Raises the permission request the real CLI raises, byte-for-byte from
1715    /// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`
1716    /// (claude 2.1.258, `--permission-prompt-tool stdio`), then blocks exactly
1717    /// as the CLI does: the tool result is emitted only once a
1718    /// `control_response` for that `request_id` arrives, and its content
1719    /// reports the `behavior` that was sent.
1720    #[cfg(unix)]
1721    const FAKE_CLAUDE_ASKS_PERMISSION: &str = r#"
1722cap="$1"
1723printf '{"type":"control_request","request_id":"053f8a2d-3445-4011-a259-4261b31c7326","request":{"subtype":"can_use_tool","tool_name":"Bash","display_name":"Bash","input":{"command":"touch probe-artifact.txt","description":"probe"},"description":"probe","permission_suggestions":[{"type":"addRules","rules":[{"toolName":"Bash","ruleContent":"touch probe-artifact.txt"}],"behavior":"allow","destination":"localSettings"}],"tool_use_id":"toolu_mock_1"}}\n'
1724while IFS= read -r line; do
1725  printf '%s\n' "$line" >> "$cap"
1726  case "$line" in
1727    *'"request_id":"053f8a2d-3445-4011-a259-4261b31c7326"'*)
1728      case "$line" in
1729        *'"behavior":"allow"'*) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"(Bash completed with no output)","is_error":false}]}}\n' ;;
1730        *) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"denied","is_error":true}]}}\n' ;;
1731      esac
1732      ;;
1733  esac
1734done
1735"#;
1736
1737    /// Rejects the interrupt the way the CLI reports a control failure.
1738    #[cfg(unix)]
1739    const FAKE_CLAUDE_REJECTS: &str = r#"
1740cap="$1"
1741while IFS= read -r line; do
1742  printf '%s\n' "$line" >> "$cap"
1743  case "$line" in
1744    *'"subtype":"interrupt"'*)
1745      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1746      printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
1747      ;;
1748  esac
1749done
1750"#;
1751
1752    #[cfg(unix)]
1753    struct FakeClaude {
1754        connection: ClaudeRuntimeConnection,
1755        capture: std::path::PathBuf,
1756        _dir: std::path::PathBuf,
1757    }
1758
1759    #[cfg(unix)]
1760    impl FakeClaude {
1761        async fn spawn(script: &str, control_timeout: Duration) -> Self {
1762            Self::spawn_with(script, control_timeout, CLAUDE_PERMISSION_RESPONSE_TIMEOUT).await
1763        }
1764
1765        async fn spawn_with(
1766            script: &str,
1767            control_timeout: Duration,
1768            permission_timeout: Duration,
1769        ) -> Self {
1770            let dir = std::env::temp_dir().join(format!(
1771                "supercode-fake-claude-{}-{}",
1772                std::process::id(),
1773                generated_session_id()
1774            ));
1775            std::fs::create_dir_all(&dir).unwrap();
1776            let capture = dir.join("stdin.jsonl");
1777            let launch = RuntimeLaunch {
1778                program: "/bin/sh".into(),
1779                arguments: vec![
1780                    "-c".into(),
1781                    script.into(),
1782                    "fake-claude".into(),
1783                    capture.display().to_string(),
1784                ],
1785                env: BTreeMap::new(),
1786            };
1787            let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
1788                .await
1789                .unwrap();
1790            let connection = ClaudeRuntimeConnection {
1791                handle: RuntimeHandle {
1792                    harness: HarnessId::from(HarnessId::CLAUDE_CODE),
1793                    runtime_id: "fake-session".into(),
1794                    endpoint: transport.endpoint.clone(),
1795                },
1796                transport,
1797                buffered_events: VecDeque::new(),
1798                next_control_request: 1,
1799                control_timeout,
1800                pending_permissions: Vec::new(),
1801                permission_timeout,
1802            };
1803            Self {
1804                connection,
1805                capture,
1806                _dir: dir,
1807            }
1808        }
1809
1810        fn written_frames(&self) -> Vec<Value> {
1811            std::fs::read_to_string(&self.capture)
1812                .unwrap_or_default()
1813                .lines()
1814                .filter(|line| !line.trim().is_empty())
1815                .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
1816                .collect()
1817        }
1818    }
1819
1820    #[cfg(unix)]
1821    #[tokio::test]
1822    async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
1823        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1824
1825        fake.connection.interrupt().await.unwrap();
1826        fake.connection.interrupt().await.unwrap();
1827
1828        let frames = fake.written_frames();
1829        assert_eq!(
1830            frames.len(),
1831            2,
1832            "each interrupt must write exactly one control frame: {frames:?}"
1833        );
1834        let mut ids = Vec::new();
1835        for frame in &frames {
1836            assert_eq!(frame["type"], "control_request");
1837            assert_eq!(frame["request"]["subtype"], "interrupt");
1838            let id = frame["request_id"].as_str().expect("frame carries an id");
1839            assert!(!id.is_empty());
1840            ids.push(id.to_string());
1841        }
1842        assert_ne!(ids[0], ids[1], "request ids must be unique per call");
1843    }
1844
1845    /// The harness acknowledges an interrupt sent with no turn in flight —
1846    /// measured against claude 2.1.224, which replies `success` with an empty
1847    /// `still_queued` list and keeps taking input. The adapter reports that
1848    /// rather than inventing a turn-state gate, and the session stays usable.
1849    #[cfg(unix)]
1850    #[tokio::test]
1851    async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
1852        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1853
1854        fake.connection.interrupt().await.unwrap();
1855        fake.connection
1856            .send_input(RuntimeInput {
1857                text: String::new(),
1858                image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
1859            })
1860            .await
1861            .unwrap();
1862
1863        // Events observed while the interrupt was pending are replayed first,
1864        // and the control channel's own frames never reach the consumer. Read
1865        // through the assistant event before inspecting the fake child's
1866        // capture so the child has necessarily consumed the user frame.
1867        let mut kinds = Vec::new();
1868        while kinds.len() < 2 {
1869            let event = fake.connection.next_event().await.unwrap().unwrap();
1870            assert_ne!(event.kind, "control_response");
1871            kinds.push(event.kind);
1872        }
1873        assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
1874
1875        let frames = fake.written_frames();
1876        assert_eq!(frames[0]["type"], "control_request");
1877        assert_eq!(
1878            frames[1]["type"], "user",
1879            "a send issued after an interrupt must reach the harness, in order"
1880        );
1881        assert_eq!(
1882            frames[1]["message"]["content"][0]["source"],
1883            json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
1884            "an image-only turn must remain native without a synthetic text block"
1885        );
1886    }
1887
1888    #[cfg(unix)]
1889    #[tokio::test]
1890    async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
1891        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
1892
1893        let started = std::time::Instant::now();
1894        let error = fake.connection.interrupt().await.unwrap_err();
1895
1896        assert!(
1897            started.elapsed() < Duration::from_secs(5),
1898            "interrupt must return on its own bound, not hang"
1899        );
1900        assert!(
1901            error
1902                .to_string()
1903                .contains("did not acknowledge the interrupt"),
1904            "unexpected error: {error}"
1905        );
1906        assert_eq!(fake.written_frames().len(), 1);
1907    }
1908
1909    #[cfg(unix)]
1910    #[tokio::test]
1911    async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
1912        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
1913
1914        let error = fake.connection.interrupt().await.unwrap_err();
1915
1916        assert!(
1917            error.to_string().contains("no active worker"),
1918            "unexpected error: {error}"
1919        );
1920    }
1921
1922    #[test]
1923    fn claude_code_runtime_advertises_mid_turn_controls() {
1924        let capabilities = ClaudeCodeRuntimeBackend::new().capabilities();
1925        assert!(capabilities.interrupt);
1926        assert!(capabilities.steer);
1927        assert!(capabilities.respond_to_requests);
1928    }
1929
1930    /// ORC-2: the flag that makes the CLI raise its permission prompt on
1931    /// stdout instead of refusing the tool call outright. Measured against
1932    /// claude 2.1.258 — without it the same tool call comes back as
1933    /// `{"type":"system","subtype":"permission_denied"}` and no control
1934    /// request is ever written (receipt step `baseline_without_the_flag`).
1935    #[test]
1936    fn claude_code_launches_as_the_cli_permission_handler() {
1937        let backend = ClaudeCodeRuntimeBackend::new();
1938        let arguments = backend.launch.arguments.join(" ");
1939        assert!(
1940            arguments.contains("--permission-prompt-tool stdio"),
1941            "the default launch must register supercode as the permission handler: {arguments}"
1942        );
1943        assert!(arguments.contains("--input-format stream-json"));
1944        assert!(arguments.contains("--output-format stream-json"));
1945    }
1946
1947    /// ORC-2 dev/01 + dev/03: the whole permission loop on the adapter. The
1948    /// CLI's own `can_use_tool` frame reaches the event consumer, `respond`
1949    /// writes exactly the `control_response` the CLI accepts, and the blocked
1950    /// tool proceeds. Reverting the handler (dropping the `respond`
1951    /// implementation or the pending-request bookkeeping) makes this red.
1952    #[cfg(unix)]
1953    #[tokio::test]
1954    async fn claude_permission_request_surfaces_and_respond_allows_the_blocked_tool() {
1955        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
1956
1957        let request = fake.connection.next_event().await.unwrap().unwrap();
1958        assert_eq!(request.kind, "control_request");
1959        assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
1960        let request_id = request.payload["request_id"].clone();
1961
1962        fake.connection
1963            .respond(request_id.clone(), json!({"behavior": "allow"}))
1964            .await
1965            .unwrap();
1966
1967        let result = fake.connection.next_event().await.unwrap().unwrap();
1968        assert_eq!(result.kind, "user");
1969        assert_eq!(
1970            result.payload["message"]["content"][0]["is_error"],
1971            json!(false),
1972            "the allowed tool must have run: {}",
1973            result.payload
1974        );
1975
1976        let frames = fake.written_frames();
1977        assert_eq!(frames.len(), 1, "one answer per request: {frames:?}");
1978        assert_eq!(
1979            frames[0],
1980            json!({
1981                "type": "control_response",
1982                "response": {
1983                    "subtype": "success",
1984                    "request_id": request_id,
1985                    "response": {"behavior": "allow"},
1986                },
1987            }),
1988            "the answer must be the envelope claude 2.1.258 accepts"
1989        );
1990    }
1991
1992    /// Deny blocks the tool, and the `message` the CLI's validator requires is
1993    /// filled in when the caller omits it — measured: claude 2.1.258 refuses a
1994    /// bare `{"behavior":"deny"}` as a schema-invalid permission result.
1995    #[cfg(unix)]
1996    #[tokio::test]
1997    async fn claude_permission_deny_blocks_the_tool_and_always_carries_a_message() {
1998        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
1999
2000        let request = fake.connection.next_event().await.unwrap().unwrap();
2001        fake.connection
2002            .respond(
2003                request.payload["request_id"].clone(),
2004                json!({"behavior": "deny"}),
2005            )
2006            .await
2007            .unwrap();
2008
2009        let result = fake.connection.next_event().await.unwrap().unwrap();
2010        assert_eq!(
2011            result.payload["message"]["content"][0]["is_error"],
2012            json!(true),
2013            "a denied tool must not run: {}",
2014            result.payload
2015        );
2016
2017        let frames = fake.written_frames();
2018        let message = frames[0]["response"]["response"]["message"]
2019            .as_str()
2020            .expect("deny must carry a message");
2021        assert!(!message.is_empty(), "{frames:?}");
2022        assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2023    }
2024
2025    /// An answer that is not one of the two behaviors the protocol defines is
2026    /// refused by name rather than sent and silently dropped, and a request id
2027    /// nothing is waiting on is refused the same way.
2028    #[cfg(unix)]
2029    #[tokio::test]
2030    async fn claude_permission_answers_outside_the_protocol_are_refused_by_name() {
2031        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2032        let request = fake.connection.next_event().await.unwrap().unwrap();
2033        let request_id = request.payload["request_id"].clone();
2034
2035        let error = fake
2036            .connection
2037            .respond(request_id.clone(), json!({"outcome": "selected"}))
2038            .await
2039            .unwrap_err();
2040        assert!(error.to_string().contains("`allow`"), "{error}");
2041        assert!(error.to_string().contains("`deny`"), "{error}");
2042
2043        let error = fake
2044            .connection
2045            .respond(json!("not-a-live-request"), json!({"behavior": "allow"}))
2046            .await
2047            .unwrap_err();
2048        assert!(error.to_string().contains("not-a-live-request"), "{error}");
2049
2050        // The refusals wrote nothing, so the real request is still answerable.
2051        assert!(fake.written_frames().is_empty());
2052        fake.connection
2053            .respond(request_id, json!({"behavior": "allow"}))
2054            .await
2055            .unwrap();
2056        // Read through the tool result so the child has necessarily consumed
2057        // and captured the answer before its bytes are inspected.
2058        let result = fake.connection.next_event().await.unwrap().unwrap();
2059        assert_eq!(result.payload["message"]["content"][0]["is_error"], false);
2060        assert_eq!(fake.written_frames().len(), 1);
2061    }
2062
2063    /// The CLI blocks its turn forever on an unanswered request (measured: 20 s
2064    /// with no further frame), so the bound lives here: past the timeout the
2065    /// adapter denies on the caller's behalf and the turn moves on.
2066    #[cfg(unix)]
2067    #[tokio::test]
2068    async fn an_unanswered_claude_permission_request_is_denied_on_the_adapter_bound() {
2069        let mut fake = FakeClaude::spawn_with(
2070            FAKE_CLAUDE_ASKS_PERMISSION,
2071            Duration::from_secs(5),
2072            Duration::from_millis(250),
2073        )
2074        .await;
2075
2076        let request = fake.connection.next_event().await.unwrap().unwrap();
2077        assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2078
2079        let result = tokio::time::timeout(Duration::from_secs(5), fake.connection.next_event())
2080            .await
2081            .expect("the adapter must deny on its own bound rather than hang")
2082            .unwrap()
2083            .unwrap();
2084        assert_eq!(
2085            result.payload["message"]["content"][0]["is_error"],
2086            json!(true),
2087            "an unanswered request must deny: {}",
2088            result.payload
2089        );
2090
2091        let frames = fake.written_frames();
2092        assert_eq!(frames.len(), 1, "{frames:?}");
2093        assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2094        assert!(frames[0]["response"]["response"]["message"]
2095            .as_str()
2096            .unwrap()
2097            .contains("timeout"));
2098    }
2099
2100    #[test]
2101    fn capability_reports_distinguish_resume_from_process_attach() {
2102        assert!(
2103            !PiRuntimeBackend::new()
2104                .capabilities()
2105                .attach_existing_process
2106        );
2107        assert!(
2108            !ClaudeCodeRuntimeBackend::new()
2109                .capabilities()
2110                .attach_existing_process
2111        );
2112        assert!(
2113            !OpenCodeRuntimeBackend::new()
2114                .capabilities()
2115                .attach_existing_process
2116        );
2117        assert!(
2118            OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
2119                .capabilities()
2120                .attach_existing_process
2121        );
2122    }
2123
2124    #[test]
2125    fn generated_ids_are_uuid_shaped_and_unique() {
2126        let first = generated_session_id();
2127        let second = generated_session_id();
2128        assert_eq!(first.len(), 36);
2129        assert_ne!(first, second);
2130    }
2131
2132    #[test]
2133    fn opencode_event_session_id_covers_current_event_shapes() {
2134        assert_eq!(
2135            opencode_event_session_id(&json!({
2136                "type": "session.status",
2137                "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
2138            })),
2139            Some("session-direct")
2140        );
2141        assert_eq!(
2142            opencode_event_session_id(&json!({
2143                "type": "message.part.updated",
2144                "properties": {"part": {"sessionID": "session-part", "type": "text"}}
2145            })),
2146            Some("session-part")
2147        );
2148        assert_eq!(
2149            opencode_event_session_id(&json!({
2150                "type": "message.updated",
2151                "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
2152            })),
2153            Some("session-info")
2154        );
2155        assert_eq!(
2156            opencode_event_session_id(&json!({"type": "server.connected"})),
2157            None
2158        );
2159    }
2160
2161    #[tokio::test]
2162    async fn opencode_runtime_skips_events_for_other_sessions() {
2163        let (sender, receiver) = mpsc::unbounded_channel();
2164        sender
2165            .send(json!({
2166                "type": "session.idle",
2167                "properties": {"sessionID": "foreign-session"}
2168            }))
2169            .unwrap();
2170        sender
2171            .send(json!({
2172                "type": "message.part.delta",
2173                "properties": {"sessionID": "local-session", "delta": "hello"}
2174            }))
2175            .unwrap();
2176        let mut connection = OpenCodeRuntimeConnection {
2177            handle: RuntimeHandle {
2178                harness: HarnessId::from(HarnessId::OPENCODE),
2179                runtime_id: "local-session".into(),
2180                endpoint: RuntimeEndpoint::Http {
2181                    base_url: "http://127.0.0.1:1".into(),
2182                    protocol: "opencode-http".into(),
2183                },
2184            },
2185            base_url: "http://127.0.0.1:1".into(),
2186            cwd: "/tmp".into(),
2187            client: reqwest::Client::new(),
2188            receiver,
2189            child: None,
2190        };
2191
2192        let event = connection.next_event().await.unwrap().unwrap();
2193
2194        assert_eq!(event.kind, "message.part.delta");
2195        assert_eq!(event.payload["properties"]["sessionID"], "local-session");
2196    }
2197
2198    #[cfg(unix)]
2199    #[tokio::test]
2200    async fn opencode_shutdown_reaps_a_launcher_process_group() {
2201        let mut command = Command::new("/bin/sh");
2202        command
2203            .args(["-c", "sleep 30 & wait"])
2204            .stdin(Stdio::null())
2205            .stdout(Stdio::null())
2206            .stderr(Stdio::null())
2207            .kill_on_drop(true)
2208            .process_group(0);
2209        let mut child = command.spawn().unwrap();
2210        let pid = child.id().unwrap();
2211
2212        terminate_opencode_server(&mut child).await.unwrap();
2213
2214        assert!(child.try_wait().unwrap().is_some());
2215        let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
2216        assert!(
2217            !group_still_exists,
2218            "OpenCode worker process group survived close"
2219        );
2220    }
2221
2222    #[cfg(unix)]
2223    #[tokio::test]
2224    async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
2225        let mut command = Command::new("/bin/sh");
2226        command
2227            .args(["-c", "sleep 30 & exit 0"])
2228            .stdin(Stdio::null())
2229            .stdout(Stdio::null())
2230            .stderr(Stdio::null())
2231            .kill_on_drop(true)
2232            .process_group(0);
2233        let mut child = command.spawn().unwrap();
2234        let pid = child.id().unwrap();
2235        tokio::time::sleep(Duration::from_millis(200)).await;
2236
2237        terminate_opencode_server(&mut child).await.unwrap();
2238
2239        assert!(child.try_wait().unwrap().is_some());
2240        assert!(
2241            !process_group_exists(pid),
2242            "OpenCode worker process group survived its exited launcher"
2243        );
2244    }
2245
2246    #[tokio::test]
2247    async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
2248        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2249        let address = listener.local_addr().unwrap();
2250        let server = tokio::spawn(async move {
2251            let (_socket, _) = listener.accept().await.unwrap();
2252            tokio::time::sleep(Duration::from_secs(30)).await;
2253        });
2254        let started = tokio::time::Instant::now();
2255
2256        let error = wait_for_health_for(
2257            &reqwest::Client::new(),
2258            &format!("http://{address}"),
2259            Duration::from_millis(200),
2260        )
2261        .await
2262        .unwrap_err();
2263
2264        assert!(error.to_string().contains("health request timed out"));
2265        assert!(started.elapsed() < Duration::from_secs(1));
2266        server.abort();
2267    }
2268
2269    #[cfg(unix)]
2270    #[tokio::test]
2271    async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
2272        let script = r#"
2273            i=0
2274            while IFS= read -r line; do
2275              i=$((i + 1))
2276              case "$i" in
2277                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
2278                2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
2279                3)
2280                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
2281                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2282                  ;;
2283              esac
2284            done
2285        "#;
2286        let backend = AcpRuntimeBackend::new(
2287            HarnessId::from("mock-acp"),
2288            RuntimeLaunch {
2289                program: "/bin/sh".into(),
2290                arguments: vec!["-c".into(), script.into()],
2291                env: BTreeMap::new(),
2292            },
2293        );
2294        let mut connection = backend
2295            .start(RuntimeStartRequest {
2296                cwd: std::env::current_dir().unwrap(),
2297                launch: None,
2298                mcp_servers: Vec::new(),
2299            })
2300            .await
2301            .unwrap();
2302        assert_eq!(connection.handle().runtime_id, "acp_mock");
2303        assert_eq!(
2304            connection
2305                .send_input(RuntimeInput {
2306                    text: "hi".into(),
2307                    image_urls: Vec::new(),
2308                })
2309                .await
2310                .unwrap()
2311                .as_deref(),
2312            Some("3")
2313        );
2314        assert_eq!(
2315            connection.next_event().await.unwrap().unwrap().kind,
2316            "session/update"
2317        );
2318        assert_eq!(
2319            connection.next_event().await.unwrap().unwrap().kind,
2320            "supercode/acp_request_completed"
2321        );
2322        connection.close().await.unwrap();
2323    }
2324
2325    /// ORC-6: the orchestrator mounts its per-session MCP server through the
2326    /// harness's own start door. On ACP that door is `session/new`'s
2327    /// `mcpServers`, which this backend used to hard-code to `[]`.
2328    #[cfg(unix)]
2329    #[tokio::test]
2330    async fn acp_start_forwards_mcp_servers_into_session_new() {
2331        let capture = std::env::temp_dir().join(format!(
2332            "supercode-acp-mcp-{}-{}.json",
2333            std::process::id(),
2334            std::time::SystemTime::now()
2335                .duration_since(std::time::UNIX_EPOCH)
2336                .unwrap()
2337                .as_nanos()
2338        ));
2339        let script = format!(
2340            r#"
2341            i=0
2342            while IFS= read -r line; do
2343              i=$((i + 1))
2344              case "$i" in
2345                1) printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":1,"agentCapabilities":{{}},"authMethods":[]}}}}' ;;
2346                2)
2347                  printf '%s\n' "$line" > {capture}
2348                  printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"sessionId":"acp_mock"}}}}'
2349                  ;;
2350              esac
2351            done
2352        "#,
2353            capture = capture.display()
2354        );
2355        let backend = AcpRuntimeBackend::new(
2356            HarnessId::from("mock-acp"),
2357            RuntimeLaunch {
2358                program: "/bin/sh".into(),
2359                arguments: vec!["-c".into(), script],
2360                env: BTreeMap::new(),
2361            },
2362        );
2363        let mut connection = backend
2364            .start(RuntimeStartRequest {
2365                cwd: std::env::current_dir().unwrap(),
2366                launch: None,
2367                mcp_servers: vec![McpServerLaunch {
2368                    name: "orchestrator".into(),
2369                    command: "/usr/bin/node".into(),
2370                    arguments: vec!["/tmp/server.mjs".into()],
2371                    env: BTreeMap::from([(
2372                        "SUPERCODE_ORCHESTRATOR_PROFILE".into(),
2373                        "coder".into(),
2374                    )]),
2375                }],
2376            })
2377            .await
2378            .unwrap();
2379        connection.close().await.unwrap();
2380
2381        let sent: Value =
2382            serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
2383        let _ = std::fs::remove_file(&capture);
2384        assert_eq!(sent["method"], "session/new");
2385        assert_eq!(
2386            sent["params"]["mcpServers"],
2387            json!([{
2388                "name": "orchestrator",
2389                "command": "/usr/bin/node",
2390                "args": ["/tmp/server.mjs"],
2391                "env": [{"name": "SUPERCODE_ORCHESTRATOR_PROFILE", "value": "coder"}],
2392            }])
2393        );
2394    }
2395
2396    #[cfg(unix)]
2397    #[tokio::test]
2398    async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
2399        let script = r#"
2400            i=0
2401            while IFS= read -r line; do
2402              i=$((i + 1))
2403              if [ "$i" -eq 1 ]; then
2404                printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
2405              elif printf '%s' "$line" | grep -q 'session/new'; then
2406                printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
2407              else
2408                exit 9
2409              fi
2410            done
2411        "#;
2412        let backend = AcpRuntimeBackend::new(
2413            HarnessId::from("mock-acp"),
2414            RuntimeLaunch {
2415                program: "/bin/sh".into(),
2416                arguments: vec!["-c".into(), script.into()],
2417                env: BTreeMap::new(),
2418            },
2419        );
2420        let mut connection = backend
2421            .start(RuntimeStartRequest {
2422                cwd: std::env::current_dir().unwrap(),
2423                launch: None,
2424                mcp_servers: Vec::new(),
2425            })
2426            .await
2427            .unwrap();
2428        assert_eq!(connection.handle().runtime_id, "existing_login");
2429        connection.close().await.unwrap();
2430    }
2431
2432    #[cfg(unix)]
2433    #[tokio::test]
2434    async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
2435        let script = r#"
2436            i=0
2437            while IFS= read -r line; do
2438              i=$((i + 1))
2439              case "$i" in
2440                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
2441                2)
2442                  case "$line" in
2443                    *'"method":"session/load"'*'"sessionId":"existing-session"'*)
2444                      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
2445                      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
2446                      ;;
2447                    *) exit 42 ;;
2448                  esac
2449                  ;;
2450                3)
2451                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
2452                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2453                  ;;
2454              esac
2455            done
2456        "#;
2457        let backend = AcpRuntimeBackend::new(
2458            HarnessId::from("known-acp"),
2459            RuntimeLaunch {
2460                program: "/bin/sh".into(),
2461                arguments: vec!["-c".into(), script.into()],
2462                env: BTreeMap::new(),
2463            },
2464        )
2465        .with_resume_support(true);
2466        assert!(backend.capabilities().resume_session);
2467        let mut connection = backend
2468            .attach(RuntimeAttachRequest {
2469                runtime_id: "existing-session".into(),
2470                cwd: Some(std::env::current_dir().unwrap()),
2471                launch: None,
2472            })
2473            .await
2474            .unwrap();
2475        assert_eq!(connection.handle().runtime_id, "existing-session");
2476        assert_eq!(
2477            connection
2478                .send_input(RuntimeInput {
2479                    text: "continue".into(),
2480                    image_urls: Vec::new(),
2481                })
2482                .await
2483                .unwrap()
2484                .as_deref(),
2485            Some("3")
2486        );
2487        let event = connection.next_event().await.unwrap().unwrap();
2488        assert_eq!(event.kind, "session/update");
2489        assert_eq!(
2490            event
2491                .payload
2492                .pointer("/params/update/content/text")
2493                .and_then(Value::as_str),
2494            Some("fresh output")
2495        );
2496        assert_eq!(
2497            connection.next_event().await.unwrap().unwrap().kind,
2498            "supercode/acp_request_completed"
2499        );
2500        connection.close().await.unwrap();
2501    }
2502}