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