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<super::GroupLeader>)> {
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        // `kill_on_drop` only targets the launcher, and a startup that blows
1112        // its caller's deadline is DROPPED mid-wait. `GroupLeader` turns that
1113        // drop into a group signal; the explicit teardown below covers a
1114        // startup that fails while this future is still running.
1115        let mut child = super::GroupLeader(command.spawn().map_err(|error| {
1116            Error::Other(format!("could not launch {}: {error}", launch.program))
1117        })?);
1118        let base_url = format!("http://127.0.0.1:{port}");
1119        if let Err(error) = wait_for_health(client, &base_url).await {
1120            let _ = terminate_opencode_server(&mut child).await;
1121            return Err(error);
1122        }
1123        Ok((base_url, Some(child)))
1124    }
1125
1126    async fn open(
1127        &self,
1128        cwd: &Path,
1129        runtime_id: Option<String>,
1130        launch: Option<RuntimeLaunch>,
1131    ) -> Result<Box<dyn RuntimeConnection>> {
1132        let client = self.http_client()?;
1133        let (base_url, child) = self.service(&client, launch).await?;
1134        let cwd_string = cwd.to_string_lossy().to_string();
1135        let runtime_id = match runtime_id {
1136            Some(id) => {
1137                http_ok(
1138                    client
1139                        .get(format!("{base_url}/session/{id}"))
1140                        .query(&[("directory", &cwd_string)])
1141                        .send()
1142                        .await,
1143                )
1144                .await?;
1145                id
1146            }
1147            None => {
1148                let response = http_ok(
1149                    client
1150                        .post(format!("{base_url}/session"))
1151                        .query(&[("directory", &cwd_string)])
1152                        .json(&json!({}))
1153                        .send()
1154                        .await,
1155                )
1156                .await?;
1157                response
1158                    .json::<Value>()
1159                    .await
1160                    .map_err(http_error)?
1161                    .get("id")
1162                    .and_then(Value::as_str)
1163                    .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
1164                    .to_string()
1165            }
1166        };
1167        let receiver = spawn_sse(
1168            client.clone(),
1169            format!("{base_url}/event"),
1170            cwd_string.clone(),
1171        );
1172        Ok(Box::new(OpenCodeRuntimeConnection {
1173            handle: RuntimeHandle {
1174                harness: HarnessId::from(HarnessId::OPENCODE),
1175                runtime_id,
1176                endpoint: RuntimeEndpoint::Http {
1177                    base_url: base_url.clone(),
1178                    protocol: "opencode-http-sse".into(),
1179                },
1180            },
1181            base_url,
1182            cwd: cwd_string,
1183            client,
1184            receiver,
1185            child,
1186        }))
1187    }
1188}
1189
1190#[async_trait]
1191impl RuntimeBackend for OpenCodeRuntimeBackend {
1192    fn harness(&self) -> HarnessId {
1193        HarnessId::from(HarnessId::OPENCODE)
1194    }
1195
1196    fn capabilities(&self) -> RuntimeCapabilities {
1197        RuntimeCapabilities {
1198            start_session: true,
1199            resume_session: true,
1200            attach_existing_process: self.base_url.is_some(),
1201            send_input: true,
1202            stream_events: true,
1203            interrupt: true,
1204            steer: false,
1205            respond_to_requests: true,
1206        }
1207    }
1208
1209    async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1210        self.open(&request.cwd, None, request.launch).await
1211    }
1212
1213    async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1214        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1215        self.open(&cwd, Some(request.runtime_id), request.launch)
1216            .await
1217    }
1218
1219    async fn attach_existing(
1220        &self,
1221        request: RuntimeAttachRequest,
1222    ) -> Result<Box<dyn RuntimeConnection>> {
1223        if self.base_url.is_none() {
1224            return Err(Error::Other(
1225                "OpenCode live attach requires the existing server's `base_url`".into(),
1226            ));
1227        }
1228        let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1229        self.open(&cwd, Some(request.runtime_id), request.launch)
1230            .await
1231    }
1232}
1233
1234struct OpenCodeRuntimeConnection {
1235    handle: RuntimeHandle,
1236    base_url: String,
1237    cwd: String,
1238    client: reqwest::Client,
1239    receiver: mpsc::UnboundedReceiver<Value>,
1240    child: Option<super::GroupLeader>,
1241}
1242
1243#[async_trait]
1244impl RuntimeConnection for OpenCodeRuntimeConnection {
1245    fn handle(&self) -> &RuntimeHandle {
1246        &self.handle
1247    }
1248
1249    async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1250        let mut parts = Vec::new();
1251        if !input.text.is_empty() {
1252            parts.push(json!({"type": "text", "text": input.text}));
1253        }
1254        for url in input.image_urls {
1255            let mime = image_mime_type(&url).ok_or_else(|| {
1256                Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
1257            })?;
1258            parts.push(json!({"type":"file", "mime":mime, "url":url}));
1259        }
1260        http_ok(
1261            self.client
1262                .post(format!(
1263                    "{}/session/{}/prompt_async",
1264                    self.base_url, self.handle.runtime_id
1265                ))
1266                .query(&[("directory", &self.cwd)])
1267                .json(&json!({"parts": parts}))
1268                .send()
1269                .await,
1270        )
1271        .await?;
1272        Ok(None)
1273    }
1274
1275    async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1276        loop {
1277            let Some(payload) = self.receiver.recv().await else {
1278                return Ok(None);
1279            };
1280            if opencode_event_session_id(&payload)
1281                .is_some_and(|session_id| session_id != self.handle.runtime_id)
1282            {
1283                continue;
1284            }
1285            let kind = payload
1286                .get("type")
1287                .and_then(Value::as_str)
1288                .unwrap_or("event")
1289                .to_string();
1290            return Ok(Some(HarnessEvent {
1291                sequence: None,
1292                kind,
1293                payload,
1294            }));
1295        }
1296    }
1297
1298    async fn interrupt(&mut self) -> Result<()> {
1299        http_ok(
1300            self.client
1301                .post(format!(
1302                    "{}/session/{}/abort",
1303                    self.base_url, self.handle.runtime_id
1304                ))
1305                .query(&[("directory", &self.cwd)])
1306                .send()
1307                .await,
1308        )
1309        .await?;
1310        Ok(())
1311    }
1312
1313    async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1314        let permission = request_id.as_str().ok_or_else(|| {
1315            Error::Other("OpenCode permission request id must be a string".into())
1316        })?;
1317        http_ok(
1318            self.client
1319                .post(format!(
1320                    "{}/session/{}/permissions/{permission}",
1321                    self.base_url, self.handle.runtime_id
1322                ))
1323                .query(&[("directory", &self.cwd)])
1324                .json(&response)
1325                .send()
1326                .await,
1327        )
1328        .await?;
1329        Ok(())
1330    }
1331
1332    async fn close(&mut self) -> Result<()> {
1333        if let Some(child) = &mut self.child {
1334            terminate_opencode_server(child).await?;
1335        }
1336        Ok(())
1337    }
1338}
1339
1340fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1341    let rest = url.strip_prefix("data:")?;
1342    let (mime_type, data) = rest.split_once(";base64,")?;
1343    mime_type.starts_with("image/").then_some((mime_type, data))
1344}
1345
1346fn image_mime_type(url: &str) -> Option<&str> {
1347    if let Some((mime_type, _)) = data_image_parts(url) {
1348        return Some(mime_type);
1349    }
1350    let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1351    if path.ends_with(".png") {
1352        Some("image/png")
1353    } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1354        Some("image/jpeg")
1355    } else if path.ends_with(".gif") {
1356        Some("image/gif")
1357    } else if path.ends_with(".webp") {
1358        Some("image/webp")
1359    } else {
1360        None
1361    }
1362}
1363
1364fn claude_image_part(url: &str) -> Result<Value> {
1365    if let Some((media_type, data)) = data_image_parts(url) {
1366        return Ok(json!({
1367            "type":"image",
1368            "source":{"type":"base64", "media_type":media_type, "data":data}
1369        }));
1370    }
1371    if url.starts_with("https://") || url.starts_with("http://") {
1372        return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1373    }
1374    Err(Error::Other(
1375        "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1376    ))
1377}
1378
1379fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1380    let properties = payload.get("properties").unwrap_or(payload);
1381    properties
1382        .get("sessionID")
1383        .and_then(Value::as_str)
1384        .or_else(|| {
1385            properties
1386                .get("part")
1387                .and_then(|part| part.get("sessionID"))
1388                .and_then(Value::as_str)
1389        })
1390        .or_else(|| {
1391            properties
1392                .get("info")
1393                .and_then(|info| info.get("sessionID"))
1394                .and_then(Value::as_str)
1395        })
1396}
1397
1398async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1399    #[cfg(unix)]
1400    let process_group = child.id();
1401    let leader_exited = child.try_wait()?.is_some();
1402    if leader_exited {
1403        #[cfg(unix)]
1404        if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1405            crate::lsp::kill_process_group(pid);
1406            wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1407        }
1408        return Ok(());
1409    }
1410    // `Child::kill().await` waits for process reaping and can block forever
1411    // when a launcher leaves its native worker and inherited handles alive.
1412    // Terminate the isolated group while its leader can still reap workers;
1413    // killing leader and workers simultaneously can leave transient orphan
1414    // zombies and made close observably race process cleanup on Linux.
1415    #[cfg(unix)]
1416    if let Some(pid) = process_group {
1417        unsafe {
1418            libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1419        }
1420        let mut leader_reaped = false;
1421        if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1422            status?;
1423            leader_reaped = true;
1424            if !process_group_exists(pid) {
1425                return Ok(());
1426            }
1427        }
1428        // The leader may exit while a detached worker ignores SIGTERM. Do
1429        // not mistake a reaped launcher for a stopped server tree.
1430        crate::lsp::kill_process_group(pid);
1431        if leader_reaped {
1432            return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1433        }
1434    }
1435    #[cfg(not(unix))]
1436    child.start_kill()?;
1437    tokio::time::timeout(Duration::from_secs(3), child.wait())
1438        .await
1439        .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1440    #[cfg(unix)]
1441    if let Some(pid) = process_group {
1442        wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1443    }
1444    Ok(())
1445}
1446
1447#[cfg(unix)]
1448fn process_group_exists(pid: u32) -> bool {
1449    let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1450    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1451}
1452
1453#[cfg(unix)]
1454async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1455    let deadline = tokio::time::Instant::now() + timeout;
1456    while process_group_exists(pid) {
1457        if tokio::time::Instant::now() >= deadline {
1458            return Err(Error::Other(format!(
1459                "timed out stopping OpenCode process group {pid}"
1460            )));
1461        }
1462        tokio::time::sleep(Duration::from_millis(10)).await;
1463    }
1464    Ok(())
1465}
1466
1467struct RawLineTransport {
1468    stdin: Mutex<ChildStdin>,
1469    child: Mutex<super::GroupLeader>,
1470    receiver: mpsc::UnboundedReceiver<Value>,
1471    endpoint: RuntimeEndpoint,
1472}
1473
1474impl RawLineTransport {
1475    async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1476        let mut command = Command::new(&launch.program);
1477        command
1478            .args(&launch.arguments)
1479            .envs(&launch.env)
1480            .stdin(Stdio::piped())
1481            .stdout(Stdio::piped())
1482            .stderr(Stdio::inherit())
1483            .kill_on_drop(true);
1484        // A raw-protocol launcher spawns its worker the same way a JSON-line
1485        // one does. Give it its own group so close — and a dropped deadline —
1486        // reap the worker instead of orphaning it holding this stdout.
1487        #[cfg(unix)]
1488        command.process_group(0);
1489        if let Some(cwd) = cwd {
1490            command.current_dir(cwd);
1491        }
1492        let mut child = command.spawn().map_err(|error| {
1493            Error::Other(format!("could not launch {}: {error}", launch.program))
1494        })?;
1495        let pid = child.id();
1496        let stdin = child
1497            .stdin
1498            .take()
1499            .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1500        let stdout = child
1501            .stdout
1502            .take()
1503            .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1504        let (sender, receiver) = mpsc::unbounded_channel();
1505        tokio::spawn(async move {
1506            let mut lines = BufReader::new(stdout).lines();
1507            while let Ok(Some(line)) = lines.next_line().await {
1508                let value = serde_json::from_str(&line)
1509                    .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1510                let _ = sender.send(value);
1511            }
1512        });
1513        Ok(Self {
1514            stdin: Mutex::new(stdin),
1515            child: Mutex::new(super::GroupLeader(child)),
1516            receiver,
1517            endpoint: RuntimeEndpoint::LocalProcess {
1518                pid,
1519                command: std::iter::once(launch.program.clone())
1520                    .chain(launch.arguments.iter().cloned())
1521                    .collect(),
1522                protocol: protocol.into(),
1523            },
1524        })
1525    }
1526
1527    async fn write(&self, value: Value) -> Result<()> {
1528        let mut stdin = self.stdin.lock().await;
1529        stdin.write_all(value.to_string().as_bytes()).await?;
1530        stdin.write_all(b"\n").await?;
1531        stdin.flush().await?;
1532        Ok(())
1533    }
1534
1535    async fn close(&self) -> Result<()> {
1536        let mut child = self.child.lock().await;
1537        if child.try_wait()?.is_some() {
1538            return Ok(());
1539        }
1540        // `Child::kill` reaches the launcher only. Signal the whole group so
1541        // a shim's worker cannot outlive the connection that owns it.
1542        #[cfg(unix)]
1543        if let Some(pid) = child.id() {
1544            crate::lsp::kill_process_group(pid);
1545            tokio::time::timeout(Duration::from_secs(3), child.wait())
1546                .await
1547                .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
1548            return Ok(());
1549        }
1550        #[cfg(not(unix))]
1551        child.kill().await?;
1552        Ok(())
1553    }
1554}
1555
1556async fn raw_next_event(
1557    receiver: &mut mpsc::UnboundedReceiver<Value>,
1558) -> Result<Option<HarnessEvent>> {
1559    let Some(payload) = receiver.recv().await else {
1560        return Ok(None);
1561    };
1562    Ok(Some(harness_event(payload)))
1563}
1564
1565fn harness_event(payload: Value) -> HarnessEvent {
1566    let kind = payload
1567        .get("type")
1568        .and_then(Value::as_str)
1569        .unwrap_or("event")
1570        .to_string();
1571    HarnessEvent {
1572        sequence: None,
1573        kind,
1574        payload,
1575    }
1576}
1577
1578fn claude_interrupt_timeout(bound: Duration) -> Error {
1579    Error::Other(format!(
1580        "Claude Code did not acknowledge the interrupt control request within {}s",
1581        bound.as_secs_f32()
1582    ))
1583}
1584
1585pub(crate) fn generated_session_id() -> String {
1586    let mut bytes = [0_u8; 16];
1587    if getrandom::getrandom(&mut bytes).is_err() {
1588        let nanos = SystemTime::now()
1589            .duration_since(UNIX_EPOCH)
1590            .unwrap_or_default()
1591            .as_nanos()
1592            .to_le_bytes();
1593        bytes.copy_from_slice(&nanos);
1594    }
1595    bytes[6] = (bytes[6] & 0x0f) | 0x40;
1596    bytes[8] = (bytes[8] & 0x3f) | 0x80;
1597    format!(
1598        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1599        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1600        bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1601    )
1602}
1603
1604async fn wait_for_health(client: &reqwest::Client, base_url: &str) -> Result<()> {
1605    wait_for_health_for(client, base_url, Duration::from_secs(10)).await
1606}
1607
1608async fn wait_for_health_for(
1609    client: &reqwest::Client,
1610    base_url: &str,
1611    total_timeout: Duration,
1612) -> Result<()> {
1613    let url = format!("{base_url}/global/health");
1614    let mut last = None;
1615    let deadline = tokio::time::Instant::now() + total_timeout;
1616    // Local package-manager shims can take longer than five seconds to start
1617    // under build or indexing load. Ten seconds avoids false unavailability
1618    // without permitting an unbounded launch; inventory handshakes retain
1619    // their separate 30-second bound around the complete startup.
1620    loop {
1621        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1622        if remaining.is_zero() {
1623            break;
1624        }
1625        let request_timeout = remaining.min(Duration::from_millis(500));
1626        match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1627            Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1628            Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1629            Ok(Err(error)) => last = Some(error.to_string()),
1630            Err(_) => last = Some("health request timed out".into()),
1631        }
1632        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1633        if !remaining.is_zero() {
1634            tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1635        }
1636    }
1637    Err(Error::Other(format!(
1638        "OpenCode server at {base_url} did not become healthy: {}",
1639        last.unwrap_or_else(|| "no response".into())
1640    )))
1641}
1642
1643async fn http_ok(
1644    response: std::result::Result<reqwest::Response, reqwest::Error>,
1645) -> Result<reqwest::Response> {
1646    response
1647        .map_err(http_error)?
1648        .error_for_status()
1649        .map_err(http_error)
1650}
1651
1652fn http_error(error: reqwest::Error) -> Error {
1653    Error::Other(format!("runtime HTTP request failed: {error}"))
1654}
1655
1656fn spawn_sse(
1657    client: reqwest::Client,
1658    url: String,
1659    directory: String,
1660) -> mpsc::UnboundedReceiver<Value> {
1661    let (sender, receiver) = mpsc::unbounded_channel();
1662    tokio::spawn(async move {
1663        let response = client
1664            .get(url)
1665            .query(&[("directory", directory)])
1666            .send()
1667            .await;
1668        let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1669            let _ = sender.send(
1670                json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1671            );
1672            return;
1673        };
1674        let mut stream = response.bytes_stream();
1675        let mut buffer = String::new();
1676        while let Some(chunk) = stream.next().await {
1677            let Ok(chunk) = chunk else {
1678                break;
1679            };
1680            buffer.push_str(&String::from_utf8_lossy(&chunk));
1681            while let Some(newline) = buffer.find('\n') {
1682                let line = buffer[..newline].trim_end_matches('\r').to_string();
1683                buffer.drain(..=newline);
1684                if let Some(data) = line.strip_prefix("data:") {
1685                    let data = data.trim();
1686                    if let Ok(value) = serde_json::from_str(data) {
1687                        let _ = sender.send(value);
1688                    }
1689                }
1690            }
1691        }
1692    });
1693    receiver
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698    use super::*;
1699
1700    /// Fake `claude --print --input-format stream-json` child. It appends every
1701    /// stdin frame to `$1` so a test can assert the exact bytes this adapter
1702    /// wrote, and replies with the control envelope the real CLI replies with.
1703    #[cfg(unix)]
1704    const FAKE_CLAUDE_ACKS: &str = r#"
1705cap="$1"
1706while IFS= read -r line; do
1707  printf '%s\n' "$line" >> "$cap"
1708  case "$line" in
1709    *'"subtype":"interrupt"'*)
1710      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1711      printf '{"type":"system","subtype":"mid_flight"}\n'
1712      printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
1713      printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
1714      ;;
1715    *'"type":"user"'*)
1716      printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
1717      ;;
1718  esac
1719done
1720"#;
1721
1722    /// Same, but the control channel never answers — the hang this adapter must
1723    /// convert into a bounded, structured error.
1724    #[cfg(unix)]
1725    const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
1726cap="$1"
1727while IFS= read -r line; do
1728  printf '%s\n' "$line" >> "$cap"
1729done
1730"#;
1731
1732    /// Raises the permission request the real CLI raises, byte-for-byte from
1733    /// `docs/interop/research/orc2-claude-respond-receipt-2026-09-04.json`
1734    /// (claude 2.1.258, `--permission-prompt-tool stdio`), then blocks exactly
1735    /// as the CLI does: the tool result is emitted only once a
1736    /// `control_response` for that `request_id` arrives, and its content
1737    /// reports the `behavior` that was sent.
1738    #[cfg(unix)]
1739    const FAKE_CLAUDE_ASKS_PERMISSION: &str = r#"
1740cap="$1"
1741printf '{"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'
1742while IFS= read -r line; do
1743  printf '%s\n' "$line" >> "$cap"
1744  case "$line" in
1745    *'"request_id":"053f8a2d-3445-4011-a259-4261b31c7326"'*)
1746      case "$line" in
1747        *'"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' ;;
1748        *) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"denied","is_error":true}]}}\n' ;;
1749      esac
1750      ;;
1751  esac
1752done
1753"#;
1754
1755    /// Rejects the interrupt the way the CLI reports a control failure.
1756    #[cfg(unix)]
1757    const FAKE_CLAUDE_REJECTS: &str = r#"
1758cap="$1"
1759while IFS= read -r line; do
1760  printf '%s\n' "$line" >> "$cap"
1761  case "$line" in
1762    *'"subtype":"interrupt"'*)
1763      rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
1764      printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
1765      ;;
1766  esac
1767done
1768"#;
1769
1770    #[cfg(unix)]
1771    struct FakeClaude {
1772        connection: ClaudeRuntimeConnection,
1773        capture: std::path::PathBuf,
1774        _dir: std::path::PathBuf,
1775    }
1776
1777    #[cfg(unix)]
1778    impl FakeClaude {
1779        async fn spawn(script: &str, control_timeout: Duration) -> Self {
1780            Self::spawn_with(script, control_timeout, CLAUDE_PERMISSION_RESPONSE_TIMEOUT).await
1781        }
1782
1783        async fn spawn_with(
1784            script: &str,
1785            control_timeout: Duration,
1786            permission_timeout: Duration,
1787        ) -> Self {
1788            let dir = std::env::temp_dir().join(format!(
1789                "supercode-fake-claude-{}-{}",
1790                std::process::id(),
1791                generated_session_id()
1792            ));
1793            std::fs::create_dir_all(&dir).unwrap();
1794            let capture = dir.join("stdin.jsonl");
1795            let launch = RuntimeLaunch {
1796                program: "/bin/sh".into(),
1797                arguments: vec![
1798                    "-c".into(),
1799                    script.into(),
1800                    "fake-claude".into(),
1801                    capture.display().to_string(),
1802                ],
1803                env: BTreeMap::new(),
1804            };
1805            let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
1806                .await
1807                .unwrap();
1808            let connection = ClaudeRuntimeConnection {
1809                handle: RuntimeHandle {
1810                    harness: HarnessId::from(HarnessId::CLAUDE_CODE),
1811                    runtime_id: "fake-session".into(),
1812                    endpoint: transport.endpoint.clone(),
1813                },
1814                transport,
1815                buffered_events: VecDeque::new(),
1816                next_control_request: 1,
1817                control_timeout,
1818                pending_permissions: Vec::new(),
1819                permission_timeout,
1820            };
1821            Self {
1822                connection,
1823                capture,
1824                _dir: dir,
1825            }
1826        }
1827
1828        fn written_frames(&self) -> Vec<Value> {
1829            std::fs::read_to_string(&self.capture)
1830                .unwrap_or_default()
1831                .lines()
1832                .filter(|line| !line.trim().is_empty())
1833                .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
1834                .collect()
1835        }
1836    }
1837
1838    #[cfg(unix)]
1839    #[tokio::test]
1840    async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
1841        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1842
1843        fake.connection.interrupt().await.unwrap();
1844        fake.connection.interrupt().await.unwrap();
1845
1846        let frames = fake.written_frames();
1847        assert_eq!(
1848            frames.len(),
1849            2,
1850            "each interrupt must write exactly one control frame: {frames:?}"
1851        );
1852        let mut ids = Vec::new();
1853        for frame in &frames {
1854            assert_eq!(frame["type"], "control_request");
1855            assert_eq!(frame["request"]["subtype"], "interrupt");
1856            let id = frame["request_id"].as_str().expect("frame carries an id");
1857            assert!(!id.is_empty());
1858            ids.push(id.to_string());
1859        }
1860        assert_ne!(ids[0], ids[1], "request ids must be unique per call");
1861    }
1862
1863    /// The harness acknowledges an interrupt sent with no turn in flight —
1864    /// measured against claude 2.1.224, which replies `success` with an empty
1865    /// `still_queued` list and keeps taking input. The adapter reports that
1866    /// rather than inventing a turn-state gate, and the session stays usable.
1867    #[cfg(unix)]
1868    #[tokio::test]
1869    async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
1870        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
1871
1872        fake.connection.interrupt().await.unwrap();
1873        fake.connection
1874            .send_input(RuntimeInput {
1875                text: String::new(),
1876                image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
1877            })
1878            .await
1879            .unwrap();
1880
1881        // Events observed while the interrupt was pending are replayed first,
1882        // and the control channel's own frames never reach the consumer. Read
1883        // through the assistant event before inspecting the fake child's
1884        // capture so the child has necessarily consumed the user frame.
1885        let mut kinds = Vec::new();
1886        while kinds.len() < 2 {
1887            let event = fake.connection.next_event().await.unwrap().unwrap();
1888            assert_ne!(event.kind, "control_response");
1889            kinds.push(event.kind);
1890        }
1891        assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
1892
1893        let frames = fake.written_frames();
1894        assert_eq!(frames[0]["type"], "control_request");
1895        assert_eq!(
1896            frames[1]["type"], "user",
1897            "a send issued after an interrupt must reach the harness, in order"
1898        );
1899        assert_eq!(
1900            frames[1]["message"]["content"][0]["source"],
1901            json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
1902            "an image-only turn must remain native without a synthetic text block"
1903        );
1904    }
1905
1906    #[cfg(unix)]
1907    #[tokio::test]
1908    async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
1909        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
1910
1911        let started = std::time::Instant::now();
1912        let error = fake.connection.interrupt().await.unwrap_err();
1913
1914        assert!(
1915            started.elapsed() < Duration::from_secs(5),
1916            "interrupt must return on its own bound, not hang"
1917        );
1918        assert!(
1919            error
1920                .to_string()
1921                .contains("did not acknowledge the interrupt"),
1922            "unexpected error: {error}"
1923        );
1924        assert_eq!(fake.written_frames().len(), 1);
1925    }
1926
1927    #[cfg(unix)]
1928    #[tokio::test]
1929    async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
1930        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
1931
1932        let error = fake.connection.interrupt().await.unwrap_err();
1933
1934        assert!(
1935            error.to_string().contains("no active worker"),
1936            "unexpected error: {error}"
1937        );
1938    }
1939
1940    #[test]
1941    fn claude_code_runtime_advertises_mid_turn_controls() {
1942        let capabilities = ClaudeCodeRuntimeBackend::new().capabilities();
1943        assert!(capabilities.interrupt);
1944        assert!(capabilities.steer);
1945        assert!(capabilities.respond_to_requests);
1946    }
1947
1948    /// ORC-2: the flag that makes the CLI raise its permission prompt on
1949    /// stdout instead of refusing the tool call outright. Measured against
1950    /// claude 2.1.258 — without it the same tool call comes back as
1951    /// `{"type":"system","subtype":"permission_denied"}` and no control
1952    /// request is ever written (receipt step `baseline_without_the_flag`).
1953    #[test]
1954    fn claude_code_launches_as_the_cli_permission_handler() {
1955        let backend = ClaudeCodeRuntimeBackend::new();
1956        let arguments = backend.launch.arguments.join(" ");
1957        assert!(
1958            arguments.contains("--permission-prompt-tool stdio"),
1959            "the default launch must register supercode as the permission handler: {arguments}"
1960        );
1961        assert!(arguments.contains("--input-format stream-json"));
1962        assert!(arguments.contains("--output-format stream-json"));
1963    }
1964
1965    /// ORC-2 dev/01 + dev/03: the whole permission loop on the adapter. The
1966    /// CLI's own `can_use_tool` frame reaches the event consumer, `respond`
1967    /// writes exactly the `control_response` the CLI accepts, and the blocked
1968    /// tool proceeds. Reverting the handler (dropping the `respond`
1969    /// implementation or the pending-request bookkeeping) makes this red.
1970    #[cfg(unix)]
1971    #[tokio::test]
1972    async fn claude_permission_request_surfaces_and_respond_allows_the_blocked_tool() {
1973        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
1974
1975        let request = fake.connection.next_event().await.unwrap().unwrap();
1976        assert_eq!(request.kind, "control_request");
1977        assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
1978        let request_id = request.payload["request_id"].clone();
1979
1980        fake.connection
1981            .respond(request_id.clone(), json!({"behavior": "allow"}))
1982            .await
1983            .unwrap();
1984
1985        let result = fake.connection.next_event().await.unwrap().unwrap();
1986        assert_eq!(result.kind, "user");
1987        assert_eq!(
1988            result.payload["message"]["content"][0]["is_error"],
1989            json!(false),
1990            "the allowed tool must have run: {}",
1991            result.payload
1992        );
1993
1994        let frames = fake.written_frames();
1995        assert_eq!(frames.len(), 1, "one answer per request: {frames:?}");
1996        assert_eq!(
1997            frames[0],
1998            json!({
1999                "type": "control_response",
2000                "response": {
2001                    "subtype": "success",
2002                    "request_id": request_id,
2003                    "response": {"behavior": "allow"},
2004                },
2005            }),
2006            "the answer must be the envelope claude 2.1.258 accepts"
2007        );
2008    }
2009
2010    /// Deny blocks the tool, and the `message` the CLI's validator requires is
2011    /// filled in when the caller omits it — measured: claude 2.1.258 refuses a
2012    /// bare `{"behavior":"deny"}` as a schema-invalid permission result.
2013    #[cfg(unix)]
2014    #[tokio::test]
2015    async fn claude_permission_deny_blocks_the_tool_and_always_carries_a_message() {
2016        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2017
2018        let request = fake.connection.next_event().await.unwrap().unwrap();
2019        fake.connection
2020            .respond(
2021                request.payload["request_id"].clone(),
2022                json!({"behavior": "deny"}),
2023            )
2024            .await
2025            .unwrap();
2026
2027        let result = fake.connection.next_event().await.unwrap().unwrap();
2028        assert_eq!(
2029            result.payload["message"]["content"][0]["is_error"],
2030            json!(true),
2031            "a denied tool must not run: {}",
2032            result.payload
2033        );
2034
2035        let frames = fake.written_frames();
2036        let message = frames[0]["response"]["response"]["message"]
2037            .as_str()
2038            .expect("deny must carry a message");
2039        assert!(!message.is_empty(), "{frames:?}");
2040        assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2041    }
2042
2043    /// An answer that is not one of the two behaviors the protocol defines is
2044    /// refused by name rather than sent and silently dropped, and a request id
2045    /// nothing is waiting on is refused the same way.
2046    #[cfg(unix)]
2047    #[tokio::test]
2048    async fn claude_permission_answers_outside_the_protocol_are_refused_by_name() {
2049        let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2050        let request = fake.connection.next_event().await.unwrap().unwrap();
2051        let request_id = request.payload["request_id"].clone();
2052
2053        let error = fake
2054            .connection
2055            .respond(request_id.clone(), json!({"outcome": "selected"}))
2056            .await
2057            .unwrap_err();
2058        assert!(error.to_string().contains("`allow`"), "{error}");
2059        assert!(error.to_string().contains("`deny`"), "{error}");
2060
2061        let error = fake
2062            .connection
2063            .respond(json!("not-a-live-request"), json!({"behavior": "allow"}))
2064            .await
2065            .unwrap_err();
2066        assert!(error.to_string().contains("not-a-live-request"), "{error}");
2067
2068        // The refusals wrote nothing, so the real request is still answerable.
2069        assert!(fake.written_frames().is_empty());
2070        fake.connection
2071            .respond(request_id, json!({"behavior": "allow"}))
2072            .await
2073            .unwrap();
2074        // Read through the tool result so the child has necessarily consumed
2075        // and captured the answer before its bytes are inspected.
2076        let result = fake.connection.next_event().await.unwrap().unwrap();
2077        assert_eq!(result.payload["message"]["content"][0]["is_error"], false);
2078        assert_eq!(fake.written_frames().len(), 1);
2079    }
2080
2081    /// The CLI blocks its turn forever on an unanswered request (measured: 20 s
2082    /// with no further frame), so the bound lives here: past the timeout the
2083    /// adapter denies on the caller's behalf and the turn moves on.
2084    #[cfg(unix)]
2085    #[tokio::test]
2086    async fn an_unanswered_claude_permission_request_is_denied_on_the_adapter_bound() {
2087        let mut fake = FakeClaude::spawn_with(
2088            FAKE_CLAUDE_ASKS_PERMISSION,
2089            Duration::from_secs(5),
2090            Duration::from_millis(250),
2091        )
2092        .await;
2093
2094        let request = fake.connection.next_event().await.unwrap().unwrap();
2095        assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2096
2097        let result = tokio::time::timeout(Duration::from_secs(5), fake.connection.next_event())
2098            .await
2099            .expect("the adapter must deny on its own bound rather than hang")
2100            .unwrap()
2101            .unwrap();
2102        assert_eq!(
2103            result.payload["message"]["content"][0]["is_error"],
2104            json!(true),
2105            "an unanswered request must deny: {}",
2106            result.payload
2107        );
2108
2109        let frames = fake.written_frames();
2110        assert_eq!(frames.len(), 1, "{frames:?}");
2111        assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2112        assert!(frames[0]["response"]["response"]["message"]
2113            .as_str()
2114            .unwrap()
2115            .contains("timeout"));
2116    }
2117
2118    #[test]
2119    fn capability_reports_distinguish_resume_from_process_attach() {
2120        assert!(
2121            !PiRuntimeBackend::new()
2122                .capabilities()
2123                .attach_existing_process
2124        );
2125        assert!(
2126            !ClaudeCodeRuntimeBackend::new()
2127                .capabilities()
2128                .attach_existing_process
2129        );
2130        assert!(
2131            !OpenCodeRuntimeBackend::new()
2132                .capabilities()
2133                .attach_existing_process
2134        );
2135        assert!(
2136            OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
2137                .capabilities()
2138                .attach_existing_process
2139        );
2140    }
2141
2142    #[test]
2143    fn generated_ids_are_uuid_shaped_and_unique() {
2144        let first = generated_session_id();
2145        let second = generated_session_id();
2146        assert_eq!(first.len(), 36);
2147        assert_ne!(first, second);
2148    }
2149
2150    #[test]
2151    fn opencode_event_session_id_covers_current_event_shapes() {
2152        assert_eq!(
2153            opencode_event_session_id(&json!({
2154                "type": "session.status",
2155                "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
2156            })),
2157            Some("session-direct")
2158        );
2159        assert_eq!(
2160            opencode_event_session_id(&json!({
2161                "type": "message.part.updated",
2162                "properties": {"part": {"sessionID": "session-part", "type": "text"}}
2163            })),
2164            Some("session-part")
2165        );
2166        assert_eq!(
2167            opencode_event_session_id(&json!({
2168                "type": "message.updated",
2169                "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
2170            })),
2171            Some("session-info")
2172        );
2173        assert_eq!(
2174            opencode_event_session_id(&json!({"type": "server.connected"})),
2175            None
2176        );
2177    }
2178
2179    #[tokio::test]
2180    async fn opencode_runtime_skips_events_for_other_sessions() {
2181        let (sender, receiver) = mpsc::unbounded_channel();
2182        sender
2183            .send(json!({
2184                "type": "session.idle",
2185                "properties": {"sessionID": "foreign-session"}
2186            }))
2187            .unwrap();
2188        sender
2189            .send(json!({
2190                "type": "message.part.delta",
2191                "properties": {"sessionID": "local-session", "delta": "hello"}
2192            }))
2193            .unwrap();
2194        let mut connection = OpenCodeRuntimeConnection {
2195            handle: RuntimeHandle {
2196                harness: HarnessId::from(HarnessId::OPENCODE),
2197                runtime_id: "local-session".into(),
2198                endpoint: RuntimeEndpoint::Http {
2199                    base_url: "http://127.0.0.1:1".into(),
2200                    protocol: "opencode-http".into(),
2201                },
2202            },
2203            base_url: "http://127.0.0.1:1".into(),
2204            cwd: "/tmp".into(),
2205            client: reqwest::Client::new(),
2206            receiver,
2207            child: None,
2208        };
2209
2210        let event = connection.next_event().await.unwrap().unwrap();
2211
2212        assert_eq!(event.kind, "message.part.delta");
2213        assert_eq!(event.payload["properties"]["sessionID"], "local-session");
2214    }
2215
2216    #[cfg(unix)]
2217    #[tokio::test]
2218    async fn opencode_shutdown_reaps_a_launcher_process_group() {
2219        let mut command = Command::new("/bin/sh");
2220        command
2221            .args(["-c", "sleep 30 & wait"])
2222            .stdin(Stdio::null())
2223            .stdout(Stdio::null())
2224            .stderr(Stdio::null())
2225            .kill_on_drop(true)
2226            .process_group(0);
2227        let mut child = command.spawn().unwrap();
2228        let pid = child.id().unwrap();
2229
2230        terminate_opencode_server(&mut child).await.unwrap();
2231
2232        assert!(child.try_wait().unwrap().is_some());
2233        let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
2234        assert!(
2235            !group_still_exists,
2236            "OpenCode worker process group survived close"
2237        );
2238    }
2239
2240    #[cfg(unix)]
2241    #[tokio::test]
2242    async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
2243        let mut command = Command::new("/bin/sh");
2244        command
2245            .args(["-c", "sleep 30 & exit 0"])
2246            .stdin(Stdio::null())
2247            .stdout(Stdio::null())
2248            .stderr(Stdio::null())
2249            .kill_on_drop(true)
2250            .process_group(0);
2251        let mut child = command.spawn().unwrap();
2252        let pid = child.id().unwrap();
2253        tokio::time::sleep(Duration::from_millis(200)).await;
2254
2255        terminate_opencode_server(&mut child).await.unwrap();
2256
2257        assert!(child.try_wait().unwrap().is_some());
2258        assert!(
2259            !process_group_exists(pid),
2260            "OpenCode worker process group survived its exited launcher"
2261        );
2262    }
2263
2264    #[tokio::test]
2265    async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
2266        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2267        let address = listener.local_addr().unwrap();
2268        let server = tokio::spawn(async move {
2269            let (_socket, _) = listener.accept().await.unwrap();
2270            tokio::time::sleep(Duration::from_secs(30)).await;
2271        });
2272        let started = tokio::time::Instant::now();
2273
2274        let error = wait_for_health_for(
2275            &reqwest::Client::new(),
2276            &format!("http://{address}"),
2277            Duration::from_millis(200),
2278        )
2279        .await
2280        .unwrap_err();
2281
2282        assert!(error.to_string().contains("health request timed out"));
2283        assert!(started.elapsed() < Duration::from_secs(1));
2284        server.abort();
2285    }
2286
2287    #[cfg(unix)]
2288    #[tokio::test]
2289    async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
2290        let script = r#"
2291            i=0
2292            while IFS= read -r line; do
2293              i=$((i + 1))
2294              case "$i" in
2295                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
2296                2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
2297                3)
2298                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
2299                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2300                  ;;
2301              esac
2302            done
2303        "#;
2304        let backend = AcpRuntimeBackend::new(
2305            HarnessId::from("mock-acp"),
2306            RuntimeLaunch {
2307                program: "/bin/sh".into(),
2308                arguments: vec!["-c".into(), script.into()],
2309                env: BTreeMap::new(),
2310            },
2311        );
2312        let mut connection = backend
2313            .start(RuntimeStartRequest {
2314                cwd: std::env::current_dir().unwrap(),
2315                launch: None,
2316                mcp_servers: Vec::new(),
2317            })
2318            .await
2319            .unwrap();
2320        assert_eq!(connection.handle().runtime_id, "acp_mock");
2321        assert_eq!(
2322            connection
2323                .send_input(RuntimeInput {
2324                    text: "hi".into(),
2325                    image_urls: Vec::new(),
2326                })
2327                .await
2328                .unwrap()
2329                .as_deref(),
2330            Some("3")
2331        );
2332        assert_eq!(
2333            connection.next_event().await.unwrap().unwrap().kind,
2334            "session/update"
2335        );
2336        assert_eq!(
2337            connection.next_event().await.unwrap().unwrap().kind,
2338            "supercode/acp_request_completed"
2339        );
2340        connection.close().await.unwrap();
2341    }
2342
2343    /// ORC-6: the orchestrator mounts its per-session MCP server through the
2344    /// harness's own start door. On ACP that door is `session/new`'s
2345    /// `mcpServers`, which this backend used to hard-code to `[]`.
2346    #[cfg(unix)]
2347    #[tokio::test]
2348    async fn acp_start_forwards_mcp_servers_into_session_new() {
2349        let capture = std::env::temp_dir().join(format!(
2350            "supercode-acp-mcp-{}-{}.json",
2351            std::process::id(),
2352            std::time::SystemTime::now()
2353                .duration_since(std::time::UNIX_EPOCH)
2354                .unwrap()
2355                .as_nanos()
2356        ));
2357        let script = format!(
2358            r#"
2359            i=0
2360            while IFS= read -r line; do
2361              i=$((i + 1))
2362              case "$i" in
2363                1) printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":1,"agentCapabilities":{{}},"authMethods":[]}}}}' ;;
2364                2)
2365                  printf '%s\n' "$line" > {capture}
2366                  printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"sessionId":"acp_mock"}}}}'
2367                  ;;
2368              esac
2369            done
2370        "#,
2371            capture = capture.display()
2372        );
2373        let backend = AcpRuntimeBackend::new(
2374            HarnessId::from("mock-acp"),
2375            RuntimeLaunch {
2376                program: "/bin/sh".into(),
2377                arguments: vec!["-c".into(), script],
2378                env: BTreeMap::new(),
2379            },
2380        );
2381        let mut connection = backend
2382            .start(RuntimeStartRequest {
2383                cwd: std::env::current_dir().unwrap(),
2384                launch: None,
2385                mcp_servers: vec![McpServerLaunch {
2386                    name: "orchestrator".into(),
2387                    command: "/usr/bin/node".into(),
2388                    arguments: vec!["/tmp/server.mjs".into()],
2389                    env: BTreeMap::from([(
2390                        "SUPERCODE_ORCHESTRATOR_PROFILE".into(),
2391                        "coder".into(),
2392                    )]),
2393                }],
2394            })
2395            .await
2396            .unwrap();
2397        connection.close().await.unwrap();
2398
2399        let sent: Value =
2400            serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
2401        let _ = std::fs::remove_file(&capture);
2402        assert_eq!(sent["method"], "session/new");
2403        assert_eq!(
2404            sent["params"]["mcpServers"],
2405            json!([{
2406                "name": "orchestrator",
2407                "command": "/usr/bin/node",
2408                "args": ["/tmp/server.mjs"],
2409                "env": [{"name": "SUPERCODE_ORCHESTRATOR_PROFILE", "value": "coder"}],
2410            }])
2411        );
2412    }
2413
2414    #[cfg(unix)]
2415    #[tokio::test]
2416    async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
2417        let script = r#"
2418            i=0
2419            while IFS= read -r line; do
2420              i=$((i + 1))
2421              if [ "$i" -eq 1 ]; then
2422                printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
2423              elif printf '%s' "$line" | grep -q 'session/new'; then
2424                printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
2425              else
2426                exit 9
2427              fi
2428            done
2429        "#;
2430        let backend = AcpRuntimeBackend::new(
2431            HarnessId::from("mock-acp"),
2432            RuntimeLaunch {
2433                program: "/bin/sh".into(),
2434                arguments: vec!["-c".into(), script.into()],
2435                env: BTreeMap::new(),
2436            },
2437        );
2438        let mut connection = backend
2439            .start(RuntimeStartRequest {
2440                cwd: std::env::current_dir().unwrap(),
2441                launch: None,
2442                mcp_servers: Vec::new(),
2443            })
2444            .await
2445            .unwrap();
2446        assert_eq!(connection.handle().runtime_id, "existing_login");
2447        connection.close().await.unwrap();
2448    }
2449
2450    #[cfg(unix)]
2451    #[tokio::test]
2452    async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
2453        let script = r#"
2454            i=0
2455            while IFS= read -r line; do
2456              i=$((i + 1))
2457              case "$i" in
2458                1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
2459                2)
2460                  case "$line" in
2461                    *'"method":"session/load"'*'"sessionId":"existing-session"'*)
2462                      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
2463                      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
2464                      ;;
2465                    *) exit 42 ;;
2466                  esac
2467                  ;;
2468                3)
2469                  printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
2470                  printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2471                  ;;
2472              esac
2473            done
2474        "#;
2475        let backend = AcpRuntimeBackend::new(
2476            HarnessId::from("known-acp"),
2477            RuntimeLaunch {
2478                program: "/bin/sh".into(),
2479                arguments: vec!["-c".into(), script.into()],
2480                env: BTreeMap::new(),
2481            },
2482        )
2483        .with_resume_support(true);
2484        assert!(backend.capabilities().resume_session);
2485        let mut connection = backend
2486            .attach(RuntimeAttachRequest {
2487                runtime_id: "existing-session".into(),
2488                cwd: Some(std::env::current_dir().unwrap()),
2489                launch: None,
2490            })
2491            .await
2492            .unwrap();
2493        assert_eq!(connection.handle().runtime_id, "existing-session");
2494        assert_eq!(
2495            connection
2496                .send_input(RuntimeInput {
2497                    text: "continue".into(),
2498                    image_urls: Vec::new(),
2499                })
2500                .await
2501                .unwrap()
2502                .as_deref(),
2503            Some("3")
2504        );
2505        let event = connection.next_event().await.unwrap().unwrap();
2506        assert_eq!(event.kind, "session/update");
2507        assert_eq!(
2508            event
2509                .payload
2510                .pointer("/params/update/content/text")
2511                .and_then(Value::as_str),
2512            Some("fresh output")
2513        );
2514        assert_eq!(
2515            connection.next_event().await.unwrap().unwrap().kind,
2516            "supercode/acp_request_completed"
2517        );
2518        connection.close().await.unwrap();
2519    }
2520}