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