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