Skip to main content

supercode_harness/runtime/
adapters.rs

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