1use 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#[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 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 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#[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 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 "--permission-prompt-tool".into(),
206 "stdio".into(),
207 ],
208 env: BTreeMap::new(),
209 },
210 permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
211 }
212 }
213
214 pub fn launch(&self) -> &RuntimeLaunch {
219 &self.launch
220 }
221
222 pub fn with_launch(launch: RuntimeLaunch) -> Self {
224 Self {
225 launch,
226 permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
227 }
228 }
229
230 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 let mut prefix = launch.unwrap_or_else(|| self.launch.clone());
252 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
291const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
301
302pub const CLAUDE_PERMISSION_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
315
316const CLAUDE_PERMISSION_BEHAVIORS: [&str; 2] = ["allow", "deny"];
323
324const 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
366async 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 prefix: RuntimeLaunch,
412 cwd: PathBuf,
416 spoke: bool,
421 buffered_events: VecDeque<Value>,
425 next_control_request: u64,
426 control_timeout: Duration,
427 pending_permissions: Vec<PendingPermission>,
431 permission_timeout: Duration,
432 mounted_mcp_servers: Vec<String>,
436}
437
438struct PendingPermission {
440 request_id: String,
442 deadline: tokio::time::Instant,
444}
445
446impl ClaudeRuntimeConnection {
447 async fn process_ended(&self) -> bool {
454 matches!(self.transport.child.lock().await.try_wait(), Ok(Some(_)))
455 }
456
457 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 self.pending_permissions.clear();
484 self.spoke = false;
485 Ok(())
486 }
487
488 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 fn is_control_response(value: &Value) -> bool {
514 value.get("type").and_then(Value::as_str) == Some("control_response")
515 }
516
517 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 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 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 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 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 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 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 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
668fn 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 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 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 async fn interrupt(&mut self) -> Result<()> {
784 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 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 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#[derive(Debug, Clone)]
884pub struct AcpRuntimeBackend {
885 harness: HarnessId,
886 launch: RuntimeLaunch,
887 resume_session: bool,
888}
889
890impl AcpRuntimeBackend {
891 pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
893 Self {
894 harness,
895 launch,
896 resume_session: false,
897 }
898 }
899
900 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 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
1053fn 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 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#[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 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 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 pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
1255 self.launch = launch;
1256 self
1257 }
1258
1259 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 #[cfg(unix)]
1312 command.process_group(0);
1313 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 #[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 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 #[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 #[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
1780fn 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 assert!(fake.written_frames().is_empty());
2285 fake.connection
2286 .respond(request_id, json!({"behavior": "allow"}))
2287 .await
2288 .unwrap();
2289 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 #[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 #[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}