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