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