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